diff --git a/cloud/apps/relay/src/host-session-registry.test.ts b/cloud/apps/relay/src/host-session-registry.test.ts index 69697cce60e..fcf73695b40 100644 --- a/cloud/apps/relay/src/host-session-registry.test.ts +++ b/cloud/apps/relay/src/host-session-registry.test.ts @@ -535,6 +535,8 @@ describe('host session cleanup races', () => { const socket = new FakeSocket() const activation = activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledOnce() socket.close() blocked.resolve('control:production-gce-c3:1') await activation @@ -560,6 +562,8 @@ describe('host session cleanup races', () => { const rebindSocket = new FakeSocket() const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledTimes(2) rebindSocket.close() blocked.resolve('control:production-gce-c3:1') await rebinding @@ -574,6 +578,49 @@ describe('host session cleanup races', () => { ) }) + it('skips a closed queued control so its live retry avoids abandoned database work', async () => { + const stalled = deferred() + const activateControl = vi + .fn() + .mockReturnValueOnce(stalled.promise) + .mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 4_000)) + return 'control:production-gce-c3:1' + }) + const { registry, activate, releaseActivity } = createRegistry(activateControl) + const firstSocket = new FakeSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const first = activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledOnce() + const abandonedSocket = new FakeSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const abandoned = activate(abandonedSocket as unknown as WebSocket, identity, null, 1, false, 1) + const liveSocket = new FakeSocket() + const startedAt = Date.now() + let liveCompletedAt: number | undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const live = activate(liveSocket as unknown as WebSocket, identity, null, 1, false, 1) + .then(() => { liveCompletedAt = Date.now() }) + firstSocket.close() + abandonedSocket.close() + stalled.resolve('control:production-gce-c3:1') + await vi.advanceTimersByTimeAsync(8_000) + await Promise.all([first, abandoned, live]) + + console.log(JSON.stringify({ + scenario: 'closed queued control before a live retry', + activationCalls: activateControl.mock.calls.length, + activityReleases: releaseActivity.mock.calls.length, + liveReadyMs: liveCompletedAt === undefined ? null : liveCompletedAt - startedAt + })) + expect(activateControl).toHaveBeenCalledTimes(2) + expect(releaseActivity).toHaveBeenCalledOnce() + expect(liveCompletedAt! - startedAt).toBe(4_000) + expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })?.socket) + .toBe(liveSocket) + }) + it('rejects client lookup when the indexed control socket is not open', async () => { const reservation = { userId: identity.sub, diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 380ccfbc9c6..4e3372ce692 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -1103,7 +1103,7 @@ export class HostSessionRegistry { .catch(() => undefined) .then(async () => { clearTimeout(queueWaitTimer) - if (queueWaitExpired) return + if (queueWaitExpired || socket.readyState !== socket.OPEN) return if ((this.sessions.get(key) ?? null) !== existing) { socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') return diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a236145dd94..e2223171ed1 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -20275,6 +20275,80 @@ "Default config paths are shared with GNOME on a disposable hosted CI runner; nested mode refuses non-GitHub-Actions execution." ], "demotionRule": "Keep experimental on unexplained failures; retain exact bytes and participation checks without retries, skips, or longer deadlines." + }, + { + "id": "relay.control-activation-ownership", + "title": "Closed queued controls leave persistence for the live retry", + "maturity": "experimental", + "protection": "partial", + "owner": "relay", + "layer": "registry-unit-and-local-websocket", + "surfaces": ["relay host control activation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["relay"], + "coverageNotes": "Actual registry queue with controlled store latency plus local WebSocket proof and connection-capacity suites on macOS. No PostgreSQL or live remote host run. PTY, daemon, SSH process state, WSL execution and folder/git workspace behavior are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/cloud/apps/relay/src/host-session-registry.ts" + ], + "invariant": "A closed queued transport starts no durable activation; an already-started activation retains cleanup, and a live successor becomes the indexed control without concurrent same-host activation.", + "oracle": "Queue a closed control before a live retry behind stalled persistence. With controlled 4s store calls, require two total activations, one release for the already-started closed control, and live readiness at 4s. Existing tests retain cleanup when closure happens during I/O.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/host-session-registry.test.ts ../../../cloud/apps/relay/src/host-session-client-accept.test.ts ../../../cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts ../../../cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts --silent=false" + ], + "testFiles": [ + "cloud/apps/relay/src/host-session-registry.test.ts", + "cloud/apps/relay/src/host-session-client-accept.test.ts", + "cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts", + "cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts" + ], + "assertionRefs": [ + { + "file": "cloud/apps/relay/src/host-session-registry.test.ts", + "assertions": [ + "skips a closed queued control so its live retry avoids abandoned database work", + "does not publish a control that closes during activation", + "does not rebind a control that closes during activation", + "fails a control waiting behind a stalled activation without breaking serialization" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/host-session-registry.test.ts ../../../cloud/apps/relay/src/host-session-client-accept.test.ts ../../../cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts ../../../cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts --silent=false", + "result": "passed", + "durationSeconds": 0.407, + "summary": "88 tests across four files passed; relay typecheck passed separately." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused unit and local socket suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline regression failed: three activation calls, two releases, live ready at 8s. Guarded registry passed with two calls, one release, live ready at 4s under controlled store latency." + }, + "performanceBudget": { + "required": true, + "evidence": "Deletes abandoned persistence before it starts. No new timer, polling, queue, provider call or wire change; existing FIFO and post-I/O cleanup remain." + }, + "knownGaps": [ + "No real PostgreSQL contention benchmark or live remote host/physical mobile run.", + "Windows/Linux execution and CI soak are not recorded; queue cleanup after a never-settling predecessor remains unchanged." + ], + "promotionCriteria": [ + "Retain red/green count and latency assertions; collect cross-platform and CI soak evidence." + ], + "demotionRule": "Keep experimental until soak evidence; investigate lifecycle failures without retries or weakening count, identity or cleanup assertions." } ] }