Files
Gabriel Brown a640b5cb94 fix(terminal): bun-safe exec attach, box self-heal, surfaced errors
The workspace terminal died everywhere: bun's node:http client never
emits the client 'upgrade' event (it surfaces the 101 as a plain
response), so docker-modem's exec.start({hijack:true}) waited forever —
a connected WebSocket with no bytes flowing (prod), or an instant close
when the socket was unreachable (dev). The pre-Phase-1 bridge avoided
dockerode attach for exactly this reason; the real-TTY-resize rewrite
reintroduced it.

- runtime/exec-stream.ts: speak the exec-start HTTP upgrade over a raw
  net socket (docker and podman both answer 101 UPGRADED). Works under
  bun and node; honors the modem's socketPath/host:port. The stream is
  returned paused so the first screen paint can't race the caller's
  data handler. Exec create/resize stay on dockerode (plain POSTs).
- user-container.ts: acquireUserBox re-ensures the container on every
  acquire instead of trusting a cached "initialized" flag, so a box
  removed behind the worker's back (manual rm, another instance's
  reaper) is recreated instead of bricking terminals and agent turns
  until restart.
- terminal.ts: bridge failures are logged and close the WebSocket with
  a clamped human-readable reason instead of a silent catch;
  xterm-session.tsx displays that reason instead of a mute
  "Session ended". Also roomier terminal line spacing (1.2 -> 1.35).
2026-07-12 22:14:37 -04:00

73 lines
2.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
// exec-stream imports the docker runtime, whose env module requires these at
// import time — set them before the (hoist-free) dynamic import.
process.env.SPOON_WORKER_TOKEN ??= 'test-worker-token';
process.env.GITHUB_APP_ID ??= '123';
process.env.GITHUB_APP_PRIVATE_KEY ??= 'test-key';
const { parseUpgradeResponse } = await import('../../src/runtime/exec-stream');
describe('parseUpgradeResponse', () => {
it('reports incomplete until the header terminator arrives', () => {
expect(parseUpgradeResponse(Buffer.from('')).complete).toBe(false);
expect(
parseUpgradeResponse(Buffer.from('HTTP/1.1 101 UPGRADED\r\n')).complete,
).toBe(false);
expect(
parseUpgradeResponse(
Buffer.from('HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\n'),
).complete,
).toBe(false);
});
it('parses a 101 upgrade and preserves stream bytes after the headers', () => {
const payload = Buffer.concat([
Buffer.from(
'HTTP/1.1 101 UPGRADED\r\n' +
'Content-Type: application/vnd.docker.raw-stream\r\n' +
'Connection: Upgrade\r\n' +
'Upgrade: tcp\r\n' +
'\r\n',
),
Buffer.from([0x1b, 0x5b, 0x48, 0x00, 0xff]),
]);
const parsed = parseUpgradeResponse(payload);
expect(parsed).toMatchObject({ complete: true, statusCode: 101 });
if (!parsed.complete) throw new Error('unreachable');
expect([...parsed.remainder]).toEqual([0x1b, 0x5b, 0x48, 0x00, 0xff]);
});
it('parses a 200 stream response (docker without upgrade) as success-shaped', () => {
const parsed = parseUpgradeResponse(
Buffer.from('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhi'),
);
expect(parsed).toMatchObject({ complete: true, statusCode: 200 });
if (!parsed.complete) throw new Error('unreachable');
expect(parsed.remainder.toString('utf8')).toBe('hi');
});
it('parses error statuses with their body so callers can report them', () => {
const parsed = parseUpgradeResponse(
Buffer.from(
'HTTP/1.1 404 Not Found\r\nContent-Length: 26\r\n\r\n{"message":"no such exec"}',
),
);
expect(parsed).toMatchObject({ complete: true, statusCode: 404 });
if (!parsed.complete) throw new Error('unreachable');
expect(parsed.remainder.toString('utf8')).toBe(
'{"message":"no such exec"}',
);
});
it('returns statusCode 0 for an unparseable status line', () => {
const parsed = parseUpgradeResponse(Buffer.from('garbage\r\n\r\n'));
expect(parsed).toMatchObject({ complete: true, statusCode: 0 });
});
it('does not treat a bare LF-LF as a header terminator', () => {
expect(
parseUpgradeResponse(Buffer.from('HTTP/1.1 101 UPGRADED\n\n')).complete,
).toBe(false);
});
});