Files
paseo/docs/terminal-worker.md
T
Sebastian ArenaandMohamed Boudra 41582eb5fd Terminals survive daemon restarts, crashes and updates (#7644)
* M1: terminals survive daemon restarts: stop reasons, detached terminal worker, kill cleanup

restart_server_request.killTerminals / shutdown_server_request.keepTerminals and server_info.features.terminalsSurviveRestart; CLI daemon restart --kill-terminals and daemon stop --keep-terminals (capability-gated). The terminal worker runs detached over an owner-only, secret-authenticated socket and is adopted on daemon start. One kill cleanup contract (the reaper) by birth time, used by the daemon, the supervisor and stopDaemonInstance. Settings offers Restart and close terminals.

* M1: fix review findings: reaper session/table retry, short-command creation order, remote kill-restart cleanup report, stop test port

* M1: fix review findings: reaper-owned cleanup record, session member identity by terminal id, unreadable cleanup records, Settings restart error rendered in the card

* M2: worker protocol versions and draining, worker runs from its own copy, desktop upgrades keep terminals, out-of-date daemon notice, docs/terminal-worker.md

- Daemon classifies live workers by protocol: adopts current, drains one version old, shuts
  down older, leaves a newer one running and answers list/create with
  terminal_worker_incompatible (daemon status prints it). Never more than two workers.
- esbuild worker bundle (build:lib); a built daemon copies bundle, shell integration and
  node-pty into $PASEO_HOME/terminal-worker/<version>-<hash>/ and launches from it; copies no
  record names are pruned.
- Desktop app_update/version_mismatch stop with keep terminals on macOS/Linux; Windows and a
  daemon without the capability close them.
- Out-of-date daemon sidebar notice with Update button, worded by terminalsSurviveRestart.
- PASEO-13: unreadable worker records fail status and kill-restart retry.
- PASEO-14: a session member without a terminal id is unknown, not unrelated.
- Smoke test 46 with A/B installation fixtures and per-fixture CLI shims (PASEO-9 fold).

* M2: track in-flight worker requests without leaving an unhandled rejection

detach({ afterPendingRequests }) tracked each request with request.finally(), which rejects
with the request; a fire-and-forget send that timed out surfaced as an unhandled rejection
(worker-terminal-manager.test.ts). Track settlement only.

* M2: CI round 1 fixes: worker terminal manager in the MCP key test, status cleanup field, shared socket dir

- mcp-server.test.ts (new upstream test) built the worker manager with the old fork API;
  it now starts a real detached worker in a throwaway home.
- daemon-session.test.ts expected status without terminalCleanupUnconfirmed (added in M1).
- paseo-daemon.test.ts: a home under the scratch temp dir is too long for a unix socket, so
  the worker listens in the per-user paseo-<uid> directory, which outlives workers by design;
  the test now requires it to be empty instead of absent.

* M2: run Windows PowerShell by its install path for process identity

server-tests (windows-latest): bootstrap-provider-availability.test.ts starts a daemon with a
PATH that holds only git, and the terminal worker failed to start: 'cannot read its own process
identity: spawn powershell.exe ENOENT'. Process inspection (managed-processes.ts inspect and the
terminal worker's process table) now runs %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe.

* M2: lifecycle e2e plain stop uses the shutdown RPC on Windows

CI round 2, server-integration (windows-latest): 'stop and stop --force never signal processes
a stale or earlier-boot record names' called stopDaemonInstance without a shutdown channel,
which Windows refuses ('has no graceful shutdown channel'). Plain stops in the file now pass
one, as the CLI does.

* M2: native notice outlet, Windows desktop-update wording, worker cap counts unconfirmed cleanups, create recovers after a drain

* M2: phone sidebar mounts the callout slot (iOS notice verified), worker record reads retry transient errors

* M2: native notice preconditions use explicit working directories, one block per terminal

* M1: compact sidebar shows only native callouts on every platform; PTY round trips in the MCP key test get a 5 s window

* ci: pin Playwright browser jobs to ubuntu-24.04

The pinned Playwright (1.58) has no ubuntu-26.04 build, and forks whose
ubuntu-latest already resolves to 26.04 fail at browser install. Job names
are unchanged, so required-check contexts still match.

* ci: use ubuntu-latest for Playwright jobs, same as upstream

Reverts the ubuntu-24.04 pin; upstream main's Playwright shards pass on ubuntu-latest.

* fix(server): keep terminal worker record IO off the daemon event loop

* fix(server): restore terminal creation after worker failure

A disconnected terminal worker remained the current manager indefinitely, leaving dead sessions listed and every later create bound to a closed socket. Confirm process death before retiring the member and use the existing single launch path for replacement. Reconnect live workers and replay their screens while preserving terminal identity.

* test(server): verify orphan cleanup during worker recovery

Recovery reaps orphaned programs before a later daemon stop. The previous fixed-delay assertion raced that cleanup and failed when it completed promptly. Verify orphan removal, same-daemon replacement creation and cleanup on normal or forced stop instead.

* test(server): isolate Windows dependency fixture setup

Build the local package registry in setup so its npm packing processes do not consume the installation journey deadline. Install the platform package used by Windows runtime fixtures directly, avoiding an unused wrapper postinstall probe without a timeout.

* test(server): wait for pending watchers before disposal

Observation admission starts before asynchronous path setup reaches watcher subscription. Disposing at the admission counter races setup and never exercises late watcher cleanup. Wait for the subscriptions to be pending before disposing.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-28 18:04:29 +07:00

9.3 KiB

Terminal worker

Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.

Process model

supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
                                                exits      (re-parented to init)
  • The daemon starts the worker through a launcher (terminal-worker-process.ts --launch). The launcher spawns the worker detached, waits for ready on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses tree-kill, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
  • The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's listTerminals.
  • A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
  • The worker exits on its own when it has no controller and no terminals, on shutdown, on SIGTERM (killing its terminals), and when its record disappears (a deleted $PASEO_HOME: nobody can ever reach it again).
  • Its stderr goes to $PASEO_HOME/terminal-worker.log. No pipe to the daemon survives the daemon.

Which triggers keep terminals and which close them is owned by the stop reason (keepTerminals) that flows from the request through the supervisor to stop(). The kill side always ends in the reaper (terminal-worker-reaper.ts), the only path that signals terminal processes outside a connected daemon.

Record, socket and secret

The worker writes $PASEO_HOME/terminal-worker/<pid>.json (owner-only) with its identity (birth time + command line carrying --paseo-terminal-worker <recordId>), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.

  • The first frame on the socket must be hello with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is 0600, the unix socket 0600 in a 0700 directory.
  • Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so terminal-worker-socket-path.ts tries $PASEO_HOME/terminal-worker/sock/, then $TMPDIR/paseo-<uid>/, then /tmp/paseo-<uid>/, and accepts an existing directory only if it is a real 0700 directory owned by you. It never chmods a shared root.
  • Cleanup signals a process only when its birth time matches the record. Birth times come from ps -o lstart with LC_ALL=C (it follows LC_TIME otherwise) and from Win32_Process.CreationDate. A missing birth time is "unknown", never matched by command text: a shell that ran exec keeps its pid and birth time but not its argv.
  • A kill cleanup that cannot confirm every process gone writes closing-<recordId>.json beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
  • An unreadable record of either kind makes daemon status and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with EPERM, EBUSY or EACCES is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.

Protocol versions

TERMINAL_WORKER_PROTOCOL_VERSION (terminal-worker-protocol.ts) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:

Worker reports Daemon does
its own version adopts it
one version older adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits
two or more older shuts it down; its terminals close (logged)
newer, or a version it cannot read leaves it running, undriven; list_terminals and create_terminal answer rpc_error terminal_worker_incompatible, and paseo daemon status prints why

Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.

The frozen envelope never changes across versions: the frame format (4-byte length + v8.serialize), hello/helloAck, the listTerminals info fields and shutdown. That is what lets any daemon close any worker: a kill stop sends shutdown to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.

Tests stand up other versions with PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE, read by the worker. It is test-only.

Running from its own copy

A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's spawn-helper and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:

  • npm run build bundles the worker with esbuild (packages/server/scripts/build-terminal-worker.mjs) into dist/server/terminal-worker/ with its shell-integration/ beside it. Only node-pty stays external. A package loaded through a module-local createRequire is invisible to esbuild and would be missing at run time; terminal-worker-runtime.test.ts fails if the bundle loads anything but built-ins and node-pty.
  • Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into $PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/ and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
  • A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
  • Dev (.ts source) launches from source and copies nothing.

Each create carries the controlling daemon's environment and Paseo CLI path (baseEnv, paseoCliPath), so a terminal opened after an upgrade gets the new installation's PASEO_CLI while older terminals keep theirs.

Desktop

Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through daemon stop --keep-terminals (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.

On Windows every desktop stop closes terminals. In the desktop app the worker runs as Paseo.exe with ELECTRON_RUN_AS_NODE, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.

Known limits

  • On macOS ps has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that execs keeps its entry on every OS.
  • On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's PASEO_TERMINAL_ID. A job that scrubbed its environment (env -i) is reported as unconfirmed, with its pid, and never signaled.
  • The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
  • Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.