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).
This commit is contained in:
Gabriel Brown
2026-07-12 22:14:37 -04:00
parent c0dd8759af
commit a640b5cb94
6 changed files with 321 additions and 36 deletions
@@ -22,9 +22,39 @@ describe('box registry', () => {
vi.clearAllMocks();
});
test('serializes concurrent acquire into a single docker run', async () => {
test('re-ensures the container on every acquire so an externally removed box self-heals', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const first = await module.acquireUserBox({
username: 'zoe',
workdir: '/w',
containerHome: '/home/zoe',
});
first.release();
// Simulate `docker rm` behind the worker's back: the registry entry still
// exists, but acquire must go back to the runtime rather than trust it.
const second = await module.acquireUserBox({
username: 'zoe',
workdir: '/w',
containerHome: '/home/zoe',
});
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(2);
expect(second.boxName).toBe('spoon-box-zoe');
});
test('serializes concurrent acquires so ensure calls never overlap', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
let inFlight = 0;
vi.mocked(docker.ensureUserContainer).mockImplementation(
async (args: { username: string }) => {
inFlight += 1;
expect(inFlight).toBe(1);
await Promise.resolve();
inFlight -= 1;
return `spoon-box-${args.username}`;
},
);
const [a, b] = await Promise.all([
module.acquireUserBox({
username: 'alice',
@@ -37,7 +67,6 @@ describe('box registry', () => {
containerHome: '/home/alice',
}),
]);
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
expect(a.boxName).toBe('spoon-box-alice');
a.release();
await vi.advanceTimersByTimeAsync(2000);
@@ -83,7 +112,7 @@ describe('box registry', () => {
);
});
test('ensures an adopted box once before returning acquire handles', async () => {
test('acquiring an adopted box ensures it and returns valid handles', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
vi.mocked(docker.listWorkspaceContainerNames).mockResolvedValueOnce([
@@ -102,7 +131,7 @@ describe('box registry', () => {
containerHome: '/home/casey',
});
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
expect(docker.ensureUserContainer).toHaveBeenCalled();
expect(first.boxName).toBe('spoon-box-casey');
expect(second.boxName).toBe('spoon-box-casey');
});