Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,22 +213,14 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket<WsData>, 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);
}

Expand All @@ -243,13 +235,12 @@ function closeLiveSideband(ws: ServerWebSocket<WsData>, 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 {
Expand Down
2 changes: 1 addition & 1 deletion src/server/ws-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>;
/** Turn/account ownership retained for the complete sideband socket lifetime. */
liveTurnAdmissionLease?: AdmissionLease;
Expand Down
12 changes: 6 additions & 6 deletions tests/native-profile-drain-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }),
Expand All @@ -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);
Expand Down
Loading