Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/server/src/executor/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,12 @@ export class FakeExecutor implements Executor {
);
});
upstream.on('upgrade', (_req, socket) => {
// The echo pipe's destination is this socket, so a reset reaching
// it would re-emit 'error' with no listener and take the daemon
// down — the real executor's upstream is another process and
// cannot do that, so without this the fake is deadlier than what
// it stands in for.
socket.on('error', () => socket.destroy());
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n',
);
Expand Down
105 changes: 105 additions & 0 deletions packages/server/src/sandbox-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,111 @@ describe('sandbox port proxy', () => {
expect(JSON.parse(other.body)).toMatchObject({ sandboxId });
});

it('survives a client reset while the upgrade is still waking the sandbox', async () => {
const t = await listeningApp();
const sandboxId = await createSandbox(t.port);
const name = findById(t.db, sandboxId)?.name;
if (name === undefined) throw new Error('no sandbox row');

// Hold the sandbox's lock slot so resolveTarget's own locks.run queues
// behind it: the seconds-long window a cold wake really has, made
// deterministic. A reset — not a graceful close — is what raises
// 'error'; with no listener yet attached it throws out of the
// EventEmitter, and the daemon has no uncaughtException handler.
let release!: () => void;
const occupied = new Promise<void>((resolve) => {
release = resolve;
});
const slot = t.locks.run(name, () => occupied);

const crashes: unknown[] = [];
const onUncaught = (err: unknown) => crashes.push(err);
process.on('uncaughtException', onUncaught);
try {
const socket = net.connect(t.port, '127.0.0.1', () => {
socket.write(
[
'GET /ws HTTP/1.1',
`Host: 5173-${sandboxId}.${DOMAIN}`,
'Connection: Upgrade',
'Upgrade: websocket',
'',
'',
].join('\r\n'),
);
});
socket.on('error', () => {
// The client end of the reset we are about to send.
});
// Let the request arrive and park inside handleUpgrade, then reset.
await new Promise((r) => setTimeout(r, 100));
socket.resetAndDestroy();
await new Promise((r) => setTimeout(r, 100));
release();
await slot;
await new Promise((r) => setTimeout(r, 100));
expect(crashes).toEqual([]);
} finally {
process.off('uncaughtException', onUncaught);
release();
}

// Still serving: the reset took its own connection down, nothing else.
const after = await rawGet(t.port, '/healthz', 'localhost');
expect(after.status).toBe(200);
});

it('survives a client reset after the upgrade is established', async () => {
const t = await listeningApp();
const sandboxId = await createSandbox(t.port);

// The other half of the window: once the handshake lands, both the
// proxy's socket and the upstream's are piped. A reset here reaches
// the upstream end too — for the fake that end lives in this process,
// so it must be as unkillable as a real container's would be.
// Staggered across the handshake rather than one clean case: the reset
// has to land while bytes are still moving for the upstream end to see
// it as an error, and a single well-timed close does not reproduce it.
const crashes: unknown[] = [];
const onUncaught = (err: unknown) => crashes.push(err);
process.on('uncaughtException', onUncaught);
try {
await Promise.all(
Array.from(
{ length: 60 },
(_, i) =>
new Promise<void>((resolve) => {
const socket = net.connect(t.port, '127.0.0.1', () => {
socket.write(
[
'GET /ws HTTP/1.1',
`Host: 5173-${sandboxId}.${DOMAIN}`,
'Connection: Upgrade',
'Upgrade: websocket',
'',
'',
].join('\r\n'),
);
setTimeout(() => {
socket.resetAndDestroy();
resolve();
}, i % 10);
});
socket.on('error', () => resolve());
setTimeout(resolve, 3000);
}),
),
);
await new Promise((r) => setTimeout(r, 300));
expect(crashes).toEqual([]);
} finally {
process.off('uncaughtException', onUncaught);
}

const after = await rawGet(t.port, '/healthz', 'localhost');
expect(after.status).toBe(200);
});

it('passes WebSocket upgrades through, both directions', async () => {
const t = await listeningApp();
const sandboxId = await createSandbox(t.port);
Expand Down
12 changes: 8 additions & 4 deletions packages/server/src/sandbox-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ export function createSandboxProxy(deps: SandboxProxyDeps): SandboxProxy {
},

handleUpgrade(req, socket, head) {
// The http server drops its own socket error handler before emitting
// 'upgrade', so until this line the socket is one reset away from
// throwing a listener-less 'error' and taking the daemon with it —
// and resolveTarget below can wait seconds on a cold wake. This is
// what handleRequest gets for free from its ServerResponse.
socket.on('error', () => socket.destroy());
void (async () => {
let resolved: Awaited<ReturnType<typeof resolveTarget>>;
try {
Expand Down Expand Up @@ -256,10 +262,8 @@ export function createSandboxProxy(deps: SandboxProxyDeps): SandboxProxy {
settle();
socket.destroy();
});
socket.on('error', () => {
settle();
upstream.destroy();
});
// No 'error' twin here: the listener above already destroys the
// socket, and a destroyed socket always reaches 'close'.
socket.on('close', () => {
settle();
upstream.destroy();
Expand Down