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
@@ -189,7 +189,7 @@ export const XtermSession = ({
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
lineHeight: 1.35,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
@@ -257,8 +257,13 @@ export const XtermSession = ({
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
ws.onclose = (event: CloseEvent) => {
if (isAborted()) return;
setStatus('closed');
// The worker attaches a human-readable reason when the bridge fails
// (e.g. container runtime unreachable) — show it instead of a mute
// "Session ended".
if (event.reason) setErrorText(event.reason);
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
@@ -323,7 +328,9 @@ export const XtermSession = ({
{status === 'connected'
? `Connected · ${label}`
: status === 'idle'
? (waitingLabel ? 'Waiting…' : 'Idle')
? waitingLabel
? 'Waiting…'
: 'Idle'
: status === 'connecting'
? 'Connecting…'
: status === 'closed'