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).
208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
vi.mock('../../src/env', () => ({ env: { boxIdleMs: 1000 } }));
|
|
vi.mock('../../src/runtime/docker', () => ({
|
|
ensureUserContainer: vi.fn((args: { username: string }) =>
|
|
Promise.resolve(`spoon-box-${args.username}`),
|
|
),
|
|
stopWorkspaceContainer: vi.fn(() => Promise.resolve()),
|
|
userContainerName: (username: string) => `spoon-box-${username}`,
|
|
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
|
|
}));
|
|
|
|
const load = async () => await import('../../src/user-container');
|
|
|
|
describe('box registry', () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(async () => {
|
|
const module = await load();
|
|
module._resetBoxRegistryForTests();
|
|
vi.useRealTimers();
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
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',
|
|
workdir: '/w',
|
|
containerHome: '/home/alice',
|
|
}),
|
|
module.acquireUserBox({
|
|
username: 'alice',
|
|
workdir: '/w',
|
|
containerHome: '/home/alice',
|
|
}),
|
|
]);
|
|
expect(a.boxName).toBe('spoon-box-alice');
|
|
a.release();
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(docker.stopWorkspaceContainer).not.toHaveBeenCalled();
|
|
b.release();
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(docker.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('double-release of one handle does not reap a box held elsewhere', async () => {
|
|
const module = await load();
|
|
const docker = await import('../../src/runtime/docker');
|
|
const a = await module.acquireUserBox({
|
|
username: 'bob',
|
|
workdir: '/w',
|
|
containerHome: '/home/bob',
|
|
});
|
|
const b = await module.acquireUserBox({
|
|
username: 'bob',
|
|
workdir: '/w',
|
|
containerHome: '/home/bob',
|
|
});
|
|
a.release();
|
|
a.release();
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(docker.stopWorkspaceContainer).not.toHaveBeenCalled();
|
|
b.release();
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(docker.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('reconcileExistingBoxes adopts orphans with zero refs and reaps them', async () => {
|
|
const module = await load();
|
|
const docker = await import('../../src/runtime/docker');
|
|
(
|
|
docker.listWorkspaceContainerNames as unknown as ReturnType<typeof vi.fn>
|
|
).mockResolvedValueOnce(['spoon-box-carol']);
|
|
await module.reconcileExistingBoxes();
|
|
expect(module.runningBoxUsernames().has('carol')).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(docker.stopWorkspaceContainer).toHaveBeenCalledWith(
|
|
'spoon-box-carol',
|
|
);
|
|
});
|
|
|
|
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([
|
|
'spoon-box-casey',
|
|
]);
|
|
await module.reconcileExistingBoxes();
|
|
|
|
const first = await module.acquireUserBox({
|
|
username: 'casey',
|
|
workdir: '/w',
|
|
containerHome: '/home/casey',
|
|
});
|
|
const second = await module.acquireUserBox({
|
|
username: 'casey',
|
|
workdir: '/w',
|
|
containerHome: '/home/casey',
|
|
});
|
|
|
|
expect(docker.ensureUserContainer).toHaveBeenCalled();
|
|
expect(first.boxName).toBe('spoon-box-casey');
|
|
expect(second.boxName).toBe('spoon-box-casey');
|
|
});
|
|
|
|
test('retries ensure after the first acquire fails', async () => {
|
|
const module = await load();
|
|
const docker = await import('../../src/runtime/docker');
|
|
const ensure = vi.mocked(docker.ensureUserContainer);
|
|
ensure
|
|
.mockRejectedValueOnce(new Error('docker failed'))
|
|
.mockResolvedValueOnce('spoon-box-dana');
|
|
|
|
await expect(
|
|
module.acquireUserBox({
|
|
username: 'dana',
|
|
workdir: '/w',
|
|
containerHome: '/home/dana',
|
|
}),
|
|
).rejects.toThrow('docker failed');
|
|
|
|
const handle = await module.acquireUserBox({
|
|
username: 'dana',
|
|
workdir: '/w',
|
|
containerHome: '/home/dana',
|
|
});
|
|
expect(ensure).toHaveBeenCalledTimes(2);
|
|
expect(handle.boxName).toBe('spoon-box-dana');
|
|
});
|
|
|
|
test('finishes an old idle reap before a new acquire can ensure the box', async () => {
|
|
const module = await load();
|
|
const docker = await import('../../src/runtime/docker');
|
|
const ensure = vi.mocked(docker.ensureUserContainer);
|
|
const stop = vi.mocked(docker.stopWorkspaceContainer);
|
|
const events: string[] = [];
|
|
let finishStop!: () => void;
|
|
const stopDone = new Promise<void>((resolve) => {
|
|
finishStop = resolve;
|
|
});
|
|
ensure.mockReset().mockImplementation((args: { username: string }) => {
|
|
events.push('ensure');
|
|
return Promise.resolve(`spoon-box-${args.username}`);
|
|
});
|
|
stop.mockReset().mockImplementationOnce(() => {
|
|
events.push('stop:start');
|
|
return stopDone.then(() => {
|
|
events.push('stop:end');
|
|
});
|
|
});
|
|
|
|
const first = await module.acquireUserBox({
|
|
username: 'erin',
|
|
workdir: '/w',
|
|
containerHome: '/home/erin',
|
|
});
|
|
first.release();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
const secondPromise = module.acquireUserBox({
|
|
username: 'erin',
|
|
workdir: '/w',
|
|
containerHome: '/home/erin',
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(events).toEqual(['ensure', 'stop:start']);
|
|
|
|
finishStop();
|
|
const second = await secondPromise;
|
|
expect(second.boxName).toBe('spoon-box-erin');
|
|
expect(events).toEqual(['ensure', 'stop:start', 'stop:end', 'ensure']);
|
|
});
|
|
});
|