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).
132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
import net from 'node:net';
|
|
import type { Duplex } from 'node:stream';
|
|
|
|
import { getDockerClient } from './docker';
|
|
|
|
// Hand-rolled replacement for docker-modem's hijacked `exec.start`. Bun's
|
|
// node:http client never emits the `'upgrade'` event (it surfaces the 101 as a
|
|
// plain response), so dockerode's `exec.start({ hijack: true })` waits forever
|
|
// under bun — the terminal connects but no bytes ever flow. Docker and podman
|
|
// both answer the exec-start upgrade with `101 UPGRADED` followed by the raw
|
|
// TTY byte stream on the same connection, so we speak that one exchange
|
|
// directly over a net socket and hand the socket to the caller.
|
|
|
|
export type UpgradeParse =
|
|
| { complete: false }
|
|
| { complete: true; statusCode: number; remainder: Buffer };
|
|
|
|
const HEADER_TERMINATOR = Buffer.from('\r\n\r\n');
|
|
|
|
// Parse an accumulating exec-start response buffer. Incomplete until the CRLF
|
|
// CRLF header terminator arrives; after it, `remainder` is the start of the raw
|
|
// stream (or the error body) and must be preserved byte-exact.
|
|
export const parseUpgradeResponse = (data: Buffer): UpgradeParse => {
|
|
const headerEnd = data.indexOf(HEADER_TERMINATOR);
|
|
if (headerEnd === -1) return { complete: false };
|
|
const statusLine =
|
|
data.subarray(0, headerEnd).toString('latin1').split('\r\n', 1)[0] ?? '';
|
|
const match = /^HTTP\/1\.[01] (\d{3})/.exec(statusLine);
|
|
return {
|
|
complete: true,
|
|
statusCode: match?.[1] ? Number.parseInt(match[1], 10) : 0,
|
|
remainder: data.subarray(headerEnd + HEADER_TERMINATOR.length),
|
|
};
|
|
};
|
|
|
|
type ModemTarget = {
|
|
socketPath?: string;
|
|
host?: string;
|
|
port?: number | string;
|
|
protocol?: string;
|
|
};
|
|
|
|
/**
|
|
* Start a created exec and return the raw bidirectional TTY stream. Writes are
|
|
* stdin; reads are the terminal output (Tty:true, so no stdcopy framing). The
|
|
* socket is returned PAUSED: output that arrives while the caller is still
|
|
* wiring handlers stays buffered instead of being dropped (the first tmux
|
|
* screen paint often lands within the same tick as the upgrade). Callers must
|
|
* `stream.resume()` once their `data` handler is attached.
|
|
*/
|
|
export const openExecStream = (
|
|
execId: string,
|
|
{ timeoutMs = 15_000 }: { timeoutMs?: number } = {},
|
|
): Promise<Duplex> =>
|
|
new Promise((resolve, reject) => {
|
|
const modem = getDockerClient().modem as ModemTarget;
|
|
if (modem.protocol && modem.protocol !== 'http' && !modem.socketPath) {
|
|
reject(
|
|
new Error(
|
|
`Exec streaming supports unix sockets and plain tcp docker hosts; got protocol "${modem.protocol}".`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const socket = modem.socketPath
|
|
? net.connect({ path: modem.socketPath })
|
|
: net.connect({
|
|
host: modem.host ?? 'localhost',
|
|
port: Number(modem.port ?? 2375),
|
|
});
|
|
|
|
let buffered = Buffer.alloc(0);
|
|
let settled = false;
|
|
const fail = (error: Error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
reject(error);
|
|
};
|
|
const timer = setTimeout(
|
|
() => fail(new Error(`Exec start timed out after ${timeoutMs}ms.`)),
|
|
timeoutMs,
|
|
);
|
|
|
|
socket.on('error', (error: Error) =>
|
|
fail(new Error(`Exec stream connection failed: ${error.message}`)),
|
|
);
|
|
socket.on('close', () =>
|
|
fail(
|
|
new Error(
|
|
`Exec stream closed before the upgrade completed${buffered.length ? `: ${buffered.toString('utf8').slice(0, 300)}` : '.'}`,
|
|
),
|
|
),
|
|
);
|
|
|
|
socket.on('connect', () => {
|
|
const body = JSON.stringify({ Detach: false, Tty: true });
|
|
socket.write(
|
|
`POST /exec/${encodeURIComponent(execId)}/start HTTP/1.1\r\n` +
|
|
'Host: docker\r\n' +
|
|
'Content-Type: application/json\r\n' +
|
|
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
|
|
'Connection: Upgrade\r\n' +
|
|
'Upgrade: tcp\r\n' +
|
|
'\r\n' +
|
|
body,
|
|
);
|
|
});
|
|
|
|
const onData = (chunk: Buffer) => {
|
|
buffered = Buffer.concat([buffered, chunk]);
|
|
const parsed = parseUpgradeResponse(buffered);
|
|
if (!parsed.complete) return;
|
|
if (parsed.statusCode !== 101 && parsed.statusCode !== 200) {
|
|
fail(
|
|
new Error(
|
|
`Exec start failed: HTTP ${parsed.statusCode || 'unparseable'} ${parsed.remainder.toString('utf8').slice(0, 300)}`.trim(),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
socket.off('data', onData);
|
|
socket.pause();
|
|
if (parsed.remainder.length > 0) socket.unshift(parsed.remainder);
|
|
resolve(socket);
|
|
};
|
|
socket.on('data', onData);
|
|
});
|