diff --git a/src/server/index.ts b/src/server/index.ts index 7d08fc422..00abb374b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -213,22 +213,14 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: Web ws.data.liveCloseFallback = setTimeout(() => { ws.data.liveCloseFallback = undefined; if (ws.data.liveUpstream !== upstream) return; - if (upstream.readyState === WebSocket.CLOSED) { - finalizeLiveSideband(ws, upstream); - return; - } - // A close frame was already sent below. Retry once, but never surrender - // native-main ownership while the authenticated transport remains live. + // A close frame was already sent below. Retry once before releasing the + // drain, then force local cleanup if the peer never completes the handshake. try { upstream.close(1000, "upstream close timeout"); } catch { /* upstream is already unusable */ } - // Some implementations transition synchronously without delivering the - // close event. That is still an observed CLOSED transport and is safe to - // finalize. CONNECTING/CLOSING peers keep the lease so profile switching - // fails at its own bounded drain deadline instead of racing live traffic. - if (upstream.readyState === WebSocket.CLOSED) finalizeLiveSideband(ws, upstream); + finalizeLiveSideband(ws, upstream); }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); } @@ -243,13 +235,12 @@ function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = "" } else { // The sideband holds a native-main admission lease. Do not release it just // because the downstream left: its authenticated upstream remains live - // until the close event arrives or the transport is observed CLOSED. The - // bounded fallback only retries close; it does not release ownership. + // until this close handshake completes (or the bounded fallback runs). armLiveSidebandCloseFallback(ws, upstream); try { upstream.close(code, reason); } catch { - /* the fallback retries close without releasing ownership */ + /* the fallback releases ownership if this socket never reports close */ } } try { diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 23631fd52..617d83583 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -42,7 +42,7 @@ export interface WsData { liveOpened?: boolean; /** Once teardown starts, ignore new client frames until the upstream closes. */ liveClosing?: boolean; - /** Schedules one bounded close retry without surrendering native-main ownership. */ + /** Schedules a bounded close retry before forcing local sideband cleanup. */ liveCloseFallback?: ReturnType; /** Turn/account ownership retained for the complete sideband socket lifetime. */ liveTurnAdmissionLease?: AdmissionLease; diff --git a/tests/native-profile-drain-server.test.ts b/tests/native-profile-drain-server.test.ts index a59256c17..bf3bb84a7 100644 --- a/tests/native-profile-drain-server.test.ts +++ b/tests/native-profile-drain-server.test.ts @@ -343,7 +343,7 @@ describe("native main profile scoped server admission", () => { } }); - test("uncooperative Live sideband keeps main ownership through close fallback and switch timeout", async () => { + test("uncooperative Live sideband releases main ownership after close fallback", async () => { class UncooperativeUpstream extends EventTarget { readyState = WebSocket.CONNECTING; closeCalls = 0; @@ -428,9 +428,9 @@ describe("native main profile scoped server admission", () => { await Bun.sleep(1_100); expect(upstream?.closeCalls).toBe(2); expect(upstream?.readyState).toBe(WebSocket.CLOSING); - expect(getNativeMainProfileRequestCount()).toBe(1); + expect(getNativeMainProfileRequestCount()).toBe(0); - const blocked = await handleNativeProfileAPI( + const switched = await handleNativeProfileAPI( new Request("http://localhost/api/native-main-profiles/switch", { method: "POST", body: JSON.stringify({ target: "target", confirmedStopped: true }), @@ -439,9 +439,9 @@ describe("native main profile scoped server admission", () => { {} as OcxConfig, { manager, drainTimeoutMs: 75 }, ); - expect(blocked?.status).toBe(409); - expect(switches).toBe(0); - expect(getNativeMainProfileRequestCount()).toBe(1); + expect(switched?.status).toBe(200); + expect(switches).toBe(1); + expect(getNativeMainProfileRequestCount()).toBe(0); upstream?.finishClose(); await Bun.sleep(0);