From 0ab3822f78c63274fec7b4909967b5561459bb02 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 22:13:21 -0400 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9C=A8=20Give=20the=20host=20a=20way?= =?UTF-8?q?=20to=20prove=20a=20terminal=20pane=20is=20free=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal grid may not report a pane settled, admit the next launch into it, or let the document continue while something a launch started can still act. A PID, a delivered signal, an attach client going away and an elapsed timeout each establish none of that. `packages/runtime/terminal-processes.ts` is what does: the process table, terminal holders, signal delivery and reachability, behind one host seam whose own default refuses every question. Refusing is the point — "nobody is there" and "I cannot see" are the two answers a quiescence proof must never confuse, so a host that installs no observer stops the document rather than reporting a pane quiet it never looked at. The POSIX handler answers with `ps` and `lsof`; the `lsof` sweep is the expensive half and grows with the process count, which is why it is behind the seam rather than inlined. Two shapes carry the rule. `paneOccupants()` takes the snapshot — the child, its descendants, its process group — and must be taken *before* the first signal, because a killed child's children reparent to init and a later reading names fewer processes than the launch actually started. `establishQuiescence()` asks about every one of them and about the terminal, and reports everything still true rather than the first thing it found. Nothing here decides policy. It reports; the pane worker finishing a launch and the provider tearing a grid down decide what the report means. Tier TP proves the difference between establishing and assuming: a host with no observer refuses, the POSIX reader finds this process in the real table, a snapshot read after a kill names nobody, and a pane whose child is gone is still not free while a descendant runs or anything else holds the terminal. --- packages/runtime/mod.ts | 23 ++ packages/runtime/terminal-processes.ts | 363 ++++++++++++++++++ .../runtime/tests/terminal-processes.test.ts | 241 ++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 packages/runtime/terminal-processes.ts create mode 100644 packages/runtime/tests/terminal-processes.test.ts diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c9a9d60d..9d2619bc 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -165,6 +165,29 @@ export type { TerminalProviderResources, TerminalShellOutcome, } from "./terminal.ts"; +export { + descendantsOf, + deliverSignal, + establishQuiescence, + groupMembers, + installPosixTerminalProcesses, + paneOccupants, + processReachable, + processTable, + TERMINAL_PROCESSES_API, + TERMINAL_PROCESSES_UNAVAILABLE, + TerminalProcesses, + TerminalProcessesUnavailableError, + terminalHolders, +} from "./terminal-processes.ts"; +export type { + PaneOccupants, + PaneQuiescence, + ProcessFacts, + SignalDelivery, + TerminalProcessHandler, + TerminalSignal, +} from "./terminal-processes.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/runtime/terminal-processes.ts b/packages/runtime/terminal-processes.ts new file mode 100644 index 00000000..ed464ee4 --- /dev/null +++ b/packages/runtime/terminal-processes.ts @@ -0,0 +1,363 @@ +/** + * What the host can observe about processes and terminals + * (architecture.md §Interactive terminal grids, "there is no implicit grid + * timeout"). + * + * A terminal grid may not report a pane settled, admit the next launch into it, + * or let the document continue while something a launch started can still act. + * Deciding that is not a matter of having sent a signal: a PID, a successful + * delivery, an attach client going away and an elapsed timeout each prove + * nothing. What proves it is asking the kernel — is this process still there, + * is anything still descended from it, is anything still in its process group, + * does anything still hold its terminal open — and getting "no" to all four. + * + * That asking is host-specific, so it lives behind this seam. `ps` and `lsof` + * are what a POSIX host has; a host with a cheaper primitive replaces the + * handler without touching what a quiescence proof consists of, and a host that + * can observe none of it refuses rather than guessing. The refusal matters as + * much as the answers: a grid that cannot establish these facts is a grid whose + * teardown failed, and the document stops. + * + * Nothing here decides policy. It reports, and the caller — a pane worker + * finishing one launch, a provider tearing a grid down — decides what the + * report means. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { execFile } from "node:child_process"; +import process from "node:process"; + +/** One process, as the host's table describes it. */ +export interface ProcessFacts { + readonly pid: number; + readonly ppid: number; + /** The process group. A launch's group is what its job control acts on. */ + readonly pgid: number; + /** `ttys002`, or `??` for a process with no controlling terminal. */ + readonly tty: string; + /** + * The controlling terminal's foreground process group, or -1. + * + * This is how a shell's job control is observed from outside, rather than + * inferred from what it printed. + */ + readonly tpgid: number; + readonly command: string; +} + +/** What a signal delivery established, which is not the same as what it did. */ +export type SignalDelivery = + /** The kernel accepted it. The process was there to receive it. */ + | "delivered" + /** There was no such process. Gone is the outcome a signal was asking for. */ + | "absent" + /** It could not be delivered. This says nothing about whether it is gone. */ + | "refused"; + +export type TerminalSignal = "SIGINT" | "SIGTERM" | "SIGHUP" | "SIGKILL"; + +export interface TerminalProcessHandler { + /** Every process the host can see, in one consistent reading. */ + table(): Operation; + /** + * Every process holding this terminal device open. + * + * The device is a path — `/dev/ttys002`. An empty answer is the fact a + * teardown is looking for; a host that cannot enumerate holders must refuse + * rather than answer empty, because "nobody" and "I cannot see" are the two + * answers a quiescence proof must never confuse. + */ + holders(device: string): Operation; + /** Send one signal, and say what that established. */ + deliver(pid: number, signal: TerminalSignal): Operation; + /** Whether the kernel still knows this pid. */ + reachable(pid: number): Operation; +} + +export const TERMINAL_PROCESSES_API = "runtime.terminalProcesses"; + +export const TERMINAL_PROCESSES_UNAVAILABLE = + "this host cannot observe processes or terminal holders, so it cannot prove " + + "that a terminal pane is free. `xmd run` on a POSIX host installs the " + + "observer; a host that installs none refuses rather than reporting a pane " + + "quiet it has not checked."; + +export class TerminalProcessesUnavailableError extends Error { + override name = "TerminalProcessesUnavailableError"; + constructor(message: string = TERMINAL_PROCESSES_UNAVAILABLE) { + super(message); + } +} + +/** + * The observation surface. Its own default refuses every question. + * + * Refusing is the safe answer: every caller here is deciding whether something + * may still be running, and a host that cannot see has not established that + * nothing is. + */ +export const TerminalProcesses: Api = createApi( + TERMINAL_PROCESSES_API, + { + // deno-lint-ignore require-yield + *table(): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *holders(_device: string): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *deliver(_pid: number, _signal: TerminalSignal): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *reachable(_pid: number): Operation { + throw new TerminalProcessesUnavailableError(); + }, + }, +); + +export function processTable(): Operation { + return TerminalProcesses.operations.table(); +} + +export function terminalHolders(device: string): Operation { + return TerminalProcesses.operations.holders(device); +} + +export function deliverSignal(pid: number, signal: TerminalSignal): Operation { + return TerminalProcesses.operations.deliver(pid, signal); +} + +export function processReachable(pid: number): Operation { + return TerminalProcesses.operations.reachable(pid); +} + +/** + * Every process below `pid` by parent links, in one reading of the table. + * + * Read from a snapshot rather than the live kernel on purpose: a child that is + * killed reparents to init, so a table taken after the first signal no longer + * says who its children were. The snapshot has to be older than the signal. + */ +export function descendantsOf( + table: readonly ProcessFacts[], + pid: number, +): readonly ProcessFacts[] { + const found: ProcessFacts[] = []; + const seen = new Set([pid]); + const frontier = [pid]; + while (frontier.length > 0) { + const parent = frontier.pop(); + for (const row of table) { + if (row.ppid === parent && !seen.has(row.pid)) { + seen.add(row.pid); + found.push(row); + frontier.push(row.pid); + } + } + } + return found; +} + +/** Every process in one process group, in one reading of the table. */ +export function groupMembers( + table: readonly ProcessFacts[], + pgid: number, +): readonly ProcessFacts[] { + return table.filter((row) => row.pgid === pgid); +} + +/** + * Who a launch is accountable for, taken before anything is signalled. + * + * Order matters and is the whole point: after the first signal a killed child's + * children are reparented, so a snapshot taken then would name fewer processes + * than the launch actually started. + */ +export interface PaneOccupants { + /** The child the launch started. */ + readonly child: number; + /** Everything descended from it when the snapshot was taken. */ + readonly descendants: readonly number[]; + /** Everything sharing its process group when the snapshot was taken. */ + readonly group: readonly number[]; + /** The pane's terminal device, when the host could name one. */ + readonly device?: string; +} + +/** Take that snapshot from one reading of the table. */ +export function paneOccupants( + table: readonly ProcessFacts[], + child: number, + device?: string, +): PaneOccupants { + const facts = table.find((row) => row.pid === child); + const descendants = descendantsOf(table, child).map((row) => row.pid); + const group = + facts === undefined + ? [] + : groupMembers(table, facts.pgid) + .map((row) => row.pid) + .filter((pid) => pid !== child); + return { + child, + descendants, + group, + ...(device === undefined ? {} : { device }), + }; +} + +/** What is still there, out of everything a launch was accountable for. */ +export interface PaneQuiescence { + /** True only when nothing below is still there. */ + readonly quiet: boolean; + /** Snapshot members the kernel still knows. */ + readonly running: readonly number[]; + /** Processes still holding the pane's terminal open. */ + readonly holding: readonly number[]; +} + +/** + * Ask whether everything that snapshot named has stopped, and whether anything + * still holds the pane's terminal. + * + * Both questions, every time. A pane whose child is gone but whose terminal + * something else still holds is not a pane the next launch may have, and a pane + * nobody holds whose process group still has a member in it is not one either. + */ +export function establishQuiescence(occupants: PaneOccupants): Operation { + return (function* (): Operation { + const running: number[] = []; + for (const pid of [occupants.child, ...occupants.descendants, ...occupants.group]) { + if (running.includes(pid)) { + continue; + } + if (yield* processReachable(pid)) { + running.push(pid); + } + } + // Asked even when processes remain, so one report says everything that is + // still true rather than the first thing that was. + const holding = + occupants.device === undefined ? [] : [...(yield* terminalHolders(occupants.device))]; + return { quiet: running.length === 0 && holding.length === 0, running, holding }; + })(); +} + +/** + * Install the POSIX observer: `ps` for the table, `lsof` for terminal holders. + * + * `ps` rather than `/proc`, because macOS is a supported foreground host. The + * `lsof` sweep is the expensive half and grows with the process count, which is + * why it is behind this seam: a host with a cheaper way to enumerate holders + * replaces the handler and changes nothing about what has to be established. + */ +export function* installPosixTerminalProcesses(): Operation { + yield* TerminalProcesses.around( + { + *table(): Operation { + const output = yield* until(run("ps", ["-axo", "pid=,ppid=,pgid=,tty=,tpgid=,command="])); + return readTable(output); + }, + *holders([device]): Operation { + // `lsof -t` answers with pids and nothing else, and exits non-zero when + // nobody holds the file — which is an answer, not a failure. + const output = yield* until(run("lsof", ["-t", device])); + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^\d+$/.test(line)) + .map(Number); + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + try { + process.kill(pid, signal); + return "delivered"; + } catch (error) { + // Gone already is the outcome the signal was asking for. Anything + // else is a delivery that did not happen, and is not evidence that + // the process stopped. + return noSuchProcess(error) ? "absent" : "refused"; + } + }, + // deno-lint-ignore require-yield + *reachable([pid]): Operation { + try { + // Signal 0 delivers nothing: it asks the kernel whether the pid is + // reachable, which is the whole question here. + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + }, + { at: "min" }, + ); +} + +/** One reading of `ps`, parsed row by row; anything unreadable is dropped. */ +function readTable(output: string): readonly ProcessFacts[] { + const rows: ProcessFacts[] = []; + for (const line of output.split("\n")) { + const row = readRow(line); + if (row !== undefined) { + rows.push(row); + } + } + return rows; +} + +function readRow(line: string): ProcessFacts | undefined { + const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(-?\d+)\s+(.*)$/.exec(line); + if (match === null) { + return undefined; + } + const [, pid, ppid, pgid, tty, tpgid, command] = match; + if ( + pid === undefined || + ppid === undefined || + pgid === undefined || + tty === undefined || + tpgid === undefined || + command === undefined + ) { + return undefined; + } + return { + pid: Number(pid), + ppid: Number(ppid), + pgid: Number(pgid), + tty, + tpgid: Number(tpgid), + command, + }; +} + +function run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { + // A non-zero status with output is an answer: `lsof -t` exits 1 when + // nothing holds the file. A failure to run the tool at all is not. + if (error && !("code" in error && typeof error.code === "number")) { + reject(error); + return; + } + resolve(stdout); + }); + }); +} + +function noSuchProcess(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + Reflect.get(error, "code") === "ESRCH" + ); +} diff --git a/packages/runtime/tests/terminal-processes.test.ts b/packages/runtime/tests/terminal-processes.test.ts new file mode 100644 index 00000000..7569eace --- /dev/null +++ b/packages/runtime/tests/terminal-processes.test.ts @@ -0,0 +1,241 @@ +/** + * Tier TP — what the host may claim about a terminal pane + * (architecture.md §Interactive terminal grids). + * + * A pane is free when nothing a launch started can still act in it. These rows + * are about the difference between establishing that and assuming it: a signal + * that was delivered, a process that has gone while its children have not, a + * terminal nobody is descended from but somebody still holds open, and a host + * that cannot see any of it and must say so instead of answering "quiet". + * + * The reading half — `ps` and `lsof` — is exercised against this process, which + * is a real process with a real parent and a real group. The deciding half is + * exercised against a substituted handler, because a row about "a descendant is + * still running" must not depend on this machine having one. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import process from "node:process"; +import { + descendantsOf, + establishQuiescence, + groupMembers, + installPosixTerminalProcesses, + paneOccupants, + processReachable, + processTable, + TERMINAL_PROCESSES_UNAVAILABLE, + TerminalProcesses, + terminalHolders, +} from "../terminal-processes.ts"; +import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../mod.ts"; + +/** A table written by hand, so a row can describe a machine it is not on. */ +function table(rows: readonly Partial[]): readonly ProcessFacts[] { + return rows.map((row) => ({ + pid: row.pid ?? 0, + ppid: row.ppid ?? 1, + pgid: row.pgid ?? row.pid ?? 0, + tty: row.tty ?? "??", + tpgid: row.tpgid ?? -1, + command: row.command ?? "fake", + })); +} + +interface Substitute { + /** Pids the kernel still knows. */ + running?: readonly number[]; + /** Pids still holding the device open, by device. */ + holding?: Record; + /** Recorded, so a row can say what was asked rather than what was done. */ + asked?: string[]; +} + +/** A host whose answers a row decides, in place of one it cannot control. */ +function useSubstitute(options: Substitute): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table(): Operation { + return []; + }, + // deno-lint-ignore require-yield + *holders([device]): Operation { + options.asked?.push(`holders:${device}`); + return options.holding?.[device] ?? []; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]: [number, TerminalSignal]): Operation { + options.asked?.push(`deliver:${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]): Operation { + options.asked?.push(`reachable:${pid}`); + return (options.running ?? []).includes(pid); + }, + }, + { at: "min" }, + ); +} + +describe("Tier TP — proving a terminal pane is free", () => { + it("TP1: a host that installs no observer refuses every question", function* () { + for (const ask of [ + () => processTable(), + () => terminalHolders("/dev/ttys001"), + () => processReachable(process.pid), + ]) { + let message = ""; + try { + yield* ask(); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + // Not "nothing is running" — a host that cannot see has established + // nothing, and answering emptily would be answering for a pane it never + // looked at. + expect(message).toBe(TERMINAL_PROCESSES_UNAVAILABLE); + } + }); + + it("TP2: the POSIX observer reads this process out of the real table", function* () { + yield* installPosixTerminalProcesses(); + + const rows = yield* processTable(); + const self = rows.find((row) => row.pid === process.pid); + expect(self).toBeDefined(); + expect(self?.ppid).toBe(process.ppid); + // A real reading, not a stub: this process is in the group it says it is. + const group = self === undefined ? [] : groupMembers(rows, self.pgid); + expect(group.some((row) => row.pid === process.pid)).toBe(true); + // And the kernel agrees this process exists, while a pid nothing can own + // does not. + expect(yield* processReachable(process.pid)).toBe(true); + expect(yield* processReachable(2 ** 30)).toBe(false); + }); + + it("TP3: descendants come from the snapshot, not from parent links after a kill", function* () { + // A child, a grandchild, and a sibling that is not below the child at all. + const rows = table([ + { pid: 100, ppid: 1, pgid: 100 }, + { pid: 200, ppid: 100, pgid: 100 }, + { pid: 300, ppid: 200, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + + expect(descendantsOf(rows, 100).map((row) => row.pid)).toEqual([200, 300]); + expect(descendantsOf(rows, 400)).toEqual([]); + // The same table after a kill reparents the grandchild to init. Read then, + // it would name nobody — which is why the snapshot has to precede the + // signal rather than follow it. + const reparented = table([ + { pid: 300, ppid: 1, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + expect(descendantsOf(reparented, 100)).toEqual([]); + }); + + it("TP4: a snapshot names the child, its descendants and its group", function* () { + const rows = table([ + { pid: 100, ppid: 1, pgid: 100, tty: "ttys003" }, + { pid: 200, ppid: 100, pgid: 100 }, + { pid: 250, ppid: 1, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + + const occupants = paneOccupants(rows, 100, "/dev/ttys003"); + expect(occupants.child).toBe(100); + expect(occupants.descendants).toEqual([200]); + // The group member that is not a descendant is named too, and the child + // itself is not repeated into it. + expect(occupants.group).toEqual([200, 250]); + expect(occupants.device).toBe("/dev/ttys003"); + }); + + it("TP5: quiet means every one of them is gone and nobody holds the terminal", function* () { + const asked: string[] = []; + yield* scoped(function* () { + yield* useSubstitute({ running: [], holding: {}, asked }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [250], + device: "/dev/ttys003", + }); + expect(quiescence.quiet).toBe(true); + expect(quiescence.running).toEqual([]); + expect(quiescence.holding).toEqual([]); + }); + // Every member was asked about, and so was the terminal. A proof that + // checked the child alone would pass a pane its grandchild is still in. + expect(asked).toEqual([ + "reachable:100", + "reachable:200", + "reachable:250", + "holders:/dev/ttys003", + ]); + }); + + it("TP6: a descendant or a group member still running is not quiet", function* () { + for (const [what, running] of [ + ["the child", [100]], + ["a descendant", [200]], + ["a group member", [250]], + ] as const) { + yield* scoped(function* () { + yield* useSubstitute({ running }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [250], + device: "/dev/ttys003", + }); + expect(`${what}: ${quiescence.quiet}`).toBe(`${what}: false`); + expect(`${what}: ${quiescence.running.join()}`).toBe(`${what}: ${running.join()}`); + }); + } + }); + + it("TP7: a terminal somebody still holds is not quiet, whoever they are", function* () { + // Nothing the launch started is left, and the pane is still not free: + // something outside the snapshot has the terminal open. + yield* useSubstitute({ running: [], holding: { "/dev/ttys003": [999] } }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [], + group: [], + device: "/dev/ttys003", + }); + expect(quiescence.quiet).toBe(false); + expect(quiescence.running).toEqual([]); + expect(quiescence.holding).toEqual([999]); + }); + + it("TP8: everything still true is reported, not just the first thing", function* () { + yield* useSubstitute({ running: [200], holding: { "/dev/ttys003": [999] } }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [], + device: "/dev/ttys003", + }); + // A caller deciding what to escalate needs both, so neither short-circuits + // the other. + expect(quiescence.running).toEqual([200]); + expect(quiescence.holding).toEqual([999]); + }); + + it("TP9: a pane with no terminal device asks nobody about one", function* () { + const asked: string[] = []; + yield* scoped(function* () { + yield* useSubstitute({ running: [], asked }); + const occupants: PaneOccupants = { child: 100, descendants: [], group: [] }; + const quiescence = yield* establishQuiescence(occupants); + expect(quiescence.quiet).toBe(true); + }); + expect(asked).toEqual(["reachable:100"]); + }); +}); From 86f4e74491ac79c4ee3e724d40476636def4846d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 22:15:27 -0400 Subject: [PATCH 02/15] =?UTF-8?q?=E2=9C=A8=20Lay=20a=20tmux=20window=20out?= =?UTF-8?q?=20in=20the=20authored=20order=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `select-layout tiled` picks its own column count from the window's dimensions, so the same four panes are 2×2 in one terminal and 4×1 in another. An authored `columns` has to be told to tmux rather than asked of it. `packages/cli/src/terminal/layout.ts` writes the description tmux prints in `#{window_layout}` and accepts back: a checksum, then a tree of cells sized row-major from the pane count and the authored column count. A final row with fewer panes than columns spans the row, because tmux has no empty cells and the author wrote panes rather than a rectangle. One thing the string cannot do is place a particular pane — tmux fills the leaves in window-list order and ignores the pane ids they name — so authored order is imposed afterwards by swaps. `swapsInto()` says which, produces none for an order that is already right, and refuses a window that does not hold a pane the author wrote instead of putting some other pane there. Tier TX checks the geometry at four terminal sizes, that the cells tile exactly with one separator between them, that the checksum tracks the tree, and all three swap cases. --- packages/cli/src/terminal/layout.ts | 197 ++++++++++++++++++ packages/cli/tests/terminal-grid-tmux.test.ts | 151 ++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 packages/cli/src/terminal/layout.ts create mode 100644 packages/cli/tests/terminal-grid-tmux.test.ts diff --git a/packages/cli/src/terminal/layout.ts b/packages/cli/src/terminal/layout.ts new file mode 100644 index 00000000..e1fa773f --- /dev/null +++ b/packages/cli/src/terminal/layout.ts @@ -0,0 +1,197 @@ +/** + * The authored grid as explicit tmux geometry + * (architecture.md §Interactive terminal grids). + * + * `select-layout tiled` picks its own column count from the window's + * dimensions, so it cannot implement a `columns` the author wrote: the same + * four panes become 2×2 in one terminal and 4×1 in another. A layout string + * can. tmux accepts the same description it prints in `#{window_layout}` — a + * checksum, then a tree of cells where `{…}` lays children left to right and + * `[…]` top to bottom, each leaf naming a pane id. + * + * So every cell is sized here, row-major from the pane count and `columns`, and + * tmux is told rather than asked. A final row with fewer panes than columns + * spans the row, because tmux has no empty cells and the author wrote panes + * rather than a rectangle. + * + * One thing the string cannot do is place a *particular* pane: tmux fills the + * leaves in window-list order and ignores the pane ids they name. Authored + * order is imposed afterwards, by swapping panes into position — which is why + * `swapsInto()` lives here beside the geometry rather than in the provider. + */ + +/** One pane's rectangle, in tmux's character coordinates. */ +export interface LayoutCell { + readonly ordinal: number; + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +/** + * Split `total` into `count` parts, leaving one column or row between them for + * tmux's separator. The remainder goes to the leftmost or topmost parts, which + * is what tmux itself does. + */ +function partition(total: number, count: number): number[] { + const available = total - (count - 1); + const base = Math.floor(available / count); + const extra = available - base * count; + return Array.from({ length: count }, (_, index) => base + (index < extra ? 1 : 0)); +} + +/** The row-major rectangles for `count` panes in `columns` columns. */ +export function rowMajorCells( + width: number, + height: number, + columns: number, + count: number, +): readonly LayoutCell[] { + const rows = Math.ceil(count / columns); + const heights = partition(height, rows); + const cells: LayoutCell[] = []; + let top = 0; + for (let row = 0; row < rows; row++) { + const inRow = Math.min(columns, count - row * columns); + const widths = partition(width, inRow); + const rowHeight = heights[row] ?? 0; + let left = 0; + for (let column = 0; column < inRow; column++) { + const cellWidth = widths[column] ?? 0; + cells.push({ + ordinal: row * columns + column, + left, + top, + width: cellWidth, + height: rowHeight, + }); + left += cellWidth + 1; + } + top += rowHeight + 1; + } + return cells; +} + +/** tmux's `layout_checksum`, so the string is accepted as one of its own. */ +function checksum(layout: string): string { + let sum = 0; + for (let index = 0; index < layout.length; index++) { + sum = ((sum >> 1) + ((sum & 1) << 15)) & 0xffff; + sum = (sum + layout.charCodeAt(index)) & 0xffff; + } + return sum.toString(16).padStart(4, "0"); +} + +/** + * The layout string that gives ordinal `i` the cell `paneIds[i]` names. + * + * Pane ids are the numeric part of tmux's `%N`. tmux ignores which pane each + * leaf names — see `swapsInto()` — but the string still has to name real ones + * for tmux to accept it. + */ +export function layoutString( + width: number, + height: number, + columns: number, + paneIds: readonly number[], +): string { + const cells = rowMajorCells(width, height, columns, paneIds.length); + const rows = Math.ceil(paneIds.length / columns); + const rowStrings: string[] = []; + for (let row = 0; row < rows; row++) { + const inRow = cells.filter((cell) => Math.floor(cell.ordinal / columns) === row); + const leaves = inRow.map( + (cell) => `${cell.width}x${cell.height},${cell.left},${cell.top},${paneIds[cell.ordinal]}`, + ); + const first = inRow[0]; + if (first === undefined) { + continue; + } + rowStrings.push( + leaves.length === 1 + ? (leaves[0] ?? "") + : `${width}x${first.height},0,${first.top}{${leaves.join(",")}}`, + ); + } + const body = + rowStrings.length === 1 + ? (rowStrings[0] ?? "") + : `${width}x${height},0,0[${rowStrings.join(",")}]`; + return `${checksum(body)},${body}`; +} + +/** One swap: put the pane now at `from` into the position `to` holds. */ +export interface PaneSwap { + readonly from: number; + readonly to: number; +} + +/** + * The swaps that turn tmux's window order into the authored one. + * + * `present[i]` is the pane id tmux currently has in position `i`; `wanted[i]` is + * the pane id ordinal `i` was authored for. Selection sort, because each swap + * exchanges two positions and there is no cheaper honest way to say it: the + * result is the shortest sequence that leaves every position holding the pane + * the author put there. + * + * An already-correct order produces no swaps at all, which is the case a + * provider must not do work for. + */ +export function swapsInto( + present: readonly number[], + wanted: readonly number[], +): readonly PaneSwap[] { + const order = [...present]; + const swaps: PaneSwap[] = []; + for (let position = 0; position < wanted.length; position++) { + const target = wanted[position]; + if (target === undefined || order[position] === target) { + continue; + } + const found = order.indexOf(target, position); + if (found === -1) { + // The window does not hold the pane this ordinal was authored for, so no + // sequence of swaps produces the authored order. Saying so is the honest + // answer; swapping anyway would place a pane the author did not write. + throw new Error(`pane ${target} is not in this window, so ordinal ${position} cannot be set`); + } + const displaced = order[position]; + if (displaced === undefined) { + continue; + } + order[position] = target; + order[found] = displaced; + swaps.push({ from: found, to: position }); + } + return swaps; +} + +/** Whether observed geometry is the row-major placement `columns` describes. */ +export function placementProblems( + observed: readonly LayoutCell[], + columns: number, +): readonly string[] { + const problems: string[] = []; + const byOrdinal = [...observed].sort((left, right) => left.ordinal - right.ordinal); + for (const cell of byOrdinal) { + const column = cell.ordinal % columns; + const above = byOrdinal.find((other) => other.ordinal === cell.ordinal - columns); + const leftOf = + column > 0 ? byOrdinal.find((other) => other.ordinal === cell.ordinal - 1) : undefined; + if (above !== undefined && cell.top !== above.top + above.height + 1) { + problems.push(`pane ${cell.ordinal} is not directly below pane ${above.ordinal}`); + } + if ( + leftOf !== undefined && + !(cell.left === leftOf.left + leftOf.width + 1 && cell.top === leftOf.top) + ) { + problems.push(`pane ${cell.ordinal} is not directly right of pane ${leftOf.ordinal}`); + } + if (column === 0 && cell.left !== 0) { + problems.push(`pane ${cell.ordinal} should start a row at the left edge`); + } + } + return problems; +} diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts new file mode 100644 index 00000000..b931e347 --- /dev/null +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -0,0 +1,151 @@ +/** + * Tier TX — the tmux terminal-grid provider + * (architecture.md §Interactive terminal grids, issue #732). + * + * The provider is the one production presentation for a grid, and these rows + * hold it to the two things a document can observe about it: that the panes end + * up where the author put them, and that nothing tmux-shaped leaks out of the + * closure. Core lifecycle semantics are the controlled provider's to prove — + * this tier does not restate them. + * + * Geometry first. `select-layout tiled` picks its own column count from the + * window's dimensions, so the same four panes would be 2×2 in one terminal and + * 4×1 in another; an authored `columns` has to be told to tmux rather than + * asked of it. These rows check the string that tells it, at sizes a reader + * would actually have. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + layoutString, + placementProblems, + rowMajorCells, + swapsInto, +} from "../src/terminal/layout.ts"; +import type { LayoutCell } from "../src/terminal/layout.ts"; + +/** The cells a layout string describes, read back out of it. */ +function readCells(layout: string): LayoutCell[] { + const cells: LayoutCell[] = []; + // `WxH,left,top,paneId` — the leaves, in the order the string lists them, + // which is the order tmux fills them in. + const leaf = /(\d+)x(\d+),(\d+),(\d+),(\d+)(?![\dx])/g; + let match = leaf.exec(layout); + let ordinal = 0; + while (match !== null) { + const [, width, height, left, top] = match; + cells.push({ + ordinal: ordinal++, + left: Number(left), + top: Number(top), + width: Number(width), + height: Number(height), + }); + match = leaf.exec(layout); + } + return cells; +} + +describe("Tier TX — the tmux grid's geometry", () => { + it("TX1: an authored column count survives every terminal size", function* () { + // Four panes in two columns is 2×2 whatever the terminal is. `tiled` would + // have made the wide one 4×1 and the tall one 1×4. + for (const [width, height] of [ + [80, 24], + [200, 24], + [80, 60], + [211, 51], + ] as const) { + const cells = rowMajorCells(width, height, 2, 4); + const rows = new Set(cells.map((cell) => cell.top)); + const columns = new Set(cells.map((cell) => cell.left)); + const size = `${width}x${height}`; + expect(`${size}: ${rows.size} rows`).toBe(`${size}: 2 rows`); + expect(`${size}: ${columns.size} columns`).toBe(`${size}: 2 columns`); + expect(`${size}: ${placementProblems(cells, 2).join("; ")}`).toBe(`${size}: `); + } + }); + + it("TX2: the cells tile the terminal exactly, with one separator between", function* () { + const cells = rowMajorCells(80, 24, 2, 4); + // Two panes and one separator span the width; two rows and one separator + // span the height. A gap or an overlap would be a grid the reader can see + // is wrong. + const top = cells.filter((cell) => cell.top === 0); + expect(top.reduce((total, cell) => total + cell.width, 0) + (top.length - 1)).toBe(80); + const left = cells.filter((cell) => cell.left === 0); + expect(left.reduce((total, cell) => total + cell.height, 0) + (left.length - 1)).toBe(24); + }); + + it("TX3: a short final row spans it, because tmux has no empty cells", function* () { + // Three panes in two columns: two above, one below across the whole width. + const cells = rowMajorCells(80, 24, 2, 3); + expect(cells.length).toBe(3); + const last = cells[2]; + expect(last?.left).toBe(0); + expect(last?.width).toBe(80); + expect(placementProblems(cells, 2)).toEqual([]); + }); + + it("TX4: one pane and one row need no tree at all", function* () { + expect(rowMajorCells(80, 24, 1, 1)).toEqual([ + { ordinal: 0, left: 0, top: 0, width: 80, height: 24 }, + ]); + // A single row is written flat: nesting one row inside a column tree is a + // layout tmux accepts and a reader would never see the point of. + const single = layoutString(80, 24, 2, [1, 2]); + expect(single).not.toContain("["); + expect(single).toContain("{"); + }); + + it("TX5: the string is one tmux accepts — checksum, then the tree", function* () { + const layout = layoutString(80, 24, 2, [1, 2, 3, 4]); + const [sum, ...rest] = layout.split(","); + expect(sum).toMatch(/^[0-9a-f]{4}$/); + // Rows top to bottom, columns left to right, and every authored pane named. + const body = rest.join(","); + expect(body.startsWith("80x24,0,0[")).toBe(true); + for (const pane of [1, 2, 3, 4]) { + expect(body).toContain(`,${pane}`); + } + // And the geometry it describes is the geometry that was asked for. + expect(placementProblems(readCells(layout), 2)).toEqual([]); + }); + + it("TX6: the checksum changes with the tree, so a stale string is rejected", function* () { + const four = layoutString(80, 24, 2, [1, 2, 3, 4]); + const swapped = layoutString(80, 24, 2, [1, 2, 4, 3]); + expect(four.split(",")[0]).not.toBe(swapped.split(",")[0]); + }); + + it("TX7: authored order is imposed by swaps, because tmux ignores leaf ids", function* () { + // tmux fills the leaves in window-list order, so a window holding panes in + // the wrong order needs them moved rather than re-described. + const swaps = swapsInto([3, 1, 4, 2], [1, 2, 3, 4]); + const order = [3, 1, 4, 2]; + for (const swap of swaps) { + const from = order[swap.from]; + const to = order[swap.to]; + if (from === undefined || to === undefined) { + continue; + } + order[swap.to] = from; + order[swap.from] = to; + } + expect(order).toEqual([1, 2, 3, 4]); + }); + + it("TX8: an order that is already authored is left alone", function* () { + expect(swapsInto([1, 2, 3, 4], [1, 2, 3, 4])).toEqual([]); + }); + + it("TX9: a window missing an authored pane refuses rather than placing another", function* () { + let message = ""; + try { + swapsInto([1, 2, 9], [1, 2, 3]); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain("pane 3 is not in this window"); + }); +}); From 41fd7ded75862e38b6216e9d04ca28f7eb3be487 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 22:40:15 -0400 Subject: [PATCH 03/15] =?UTF-8?q?=E2=9C=A8=20Give=20a=20terminal=20pane=20?= =?UTF-8?q?a=20worker=20and=20a=20private=20channel=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pane's initial process is a worker that owns the pane's terminal for the pane's whole life, and everything it does is asked of it over a socket only this invocation can reach. **The channel.** One directory per grid, mode 0700, directly under `$TMPDIR` because a Unix socket path is capped at 104 bytes and a directory named after a repository path spends most of that first. Inside it, one socket and one mode-0600 token per pane, both written before any pane exists, so a worker that starts finds its socket listening rather than racing it. Admission is the whole boundary: a connection is admitted when its first frame is a `hello` naming this pane's ordinal and carrying this pane's token, and a connection that says anything else, says it late, names another ordinal, or arrives after that pane is admitted is closed without being answered. The token is single-use because the worker removes the file as it reads it. **What crosses it.** The exact argv vector, working directory and environment. tmux has a command parser, and a command parser is a place where an argument can become two arguments, or a quote, or a `;`. tmux is told a directory and an ordinal, and that is all its parser ever sees. **The worker.** `xmd terminal-worker ` — reusing this executable rather than shipping a second script, which is what makes it work in the compiled distribution. It is in no command table, so it is in no help output and no catalog, and naming it grants nothing: without a pane's single-use token nobody answers. It is dispatched at the entrypoint, before `main()`, and runs under `run()`, because `main()` binds SIGINT to its own shutdown and would exit 130 on the first `^C` typed into the pane — the keystroke the foreground child is supposed to receive. It ignores SIGINT, SIGQUIT and SIGTSTP itself so the child, which gets default dispositions across `exec`, is the one interrupted. **Readiness and settlement, kept apart.** Readiness is the runtime's `spawn` event and nothing earlier; a missing executable delivers `error` instead of it, never after it. Settlement is the escalation and sweep that follow — a child that exited on its own may have left descendants in its group or an orphan holding the terminal, and the pane is not free until neither is true. `exited` is reported only after that, so the next launch is refused while a sweep that would reach it is still running. One hazard the evidence found: the settlement sweeps the process group it is in, and a worker that was not a session leader would be sweeping whatever started it. In a pane tmux makes it one — but a settlement one signal away from killing the run that started the grid is not something to leave to the topology being what it should be, so the sweep now never reaches an ancestor of the worker. Tier TW proves it with a real worker process over a real socket and no tmux at all: the modes, the removal, the handshake, three ways of failing it, awkward argv crossing intact, a child that never starts, one-live-child exclusivity, display written and never read, and shutdown's final sweep. --- packages/cli/src/compiled.ts | 9 +- packages/cli/src/deno.ts | 9 +- packages/cli/src/terminal/pane-channel.ts | 196 ++++++++++ packages/cli/src/terminal/pane-child.ts | 284 +++++++++++++++ packages/cli/src/terminal/pane-protocol.ts | 160 +++++++++ packages/cli/src/terminal/pane-worker.ts | 258 ++++++++++++++ packages/cli/tests/terminal-grid-tmux.test.ts | 336 ++++++++++++++++++ 7 files changed, 1250 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/terminal/pane-channel.ts create mode 100644 packages/cli/src/terminal/pane-child.ts create mode 100644 packages/cli/src/terminal/pane-protocol.ts create mode 100644 packages/cli/src/terminal/pane-worker.ts diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index ae192e3b..7552d1f3 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -18,6 +18,7 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useCompiledService } from "./compiled-service.ts"; @@ -49,7 +50,13 @@ const UPGRADE = compiledUpgradeAssembly({ }); // Before anything public is parsed, and absent from every public surface. -if (isCredentialHelperMode(process.argv.slice(2))) { +const paneWorker = paneWorkerInvocation(process.argv.slice(2)); +if (paneWorker !== undefined) { + // Not `main()`: it would bind SIGINT to its own shutdown and exit 130 on the + // first `^C` typed into the pane, which is the keystroke the foreground child + // is supposed to receive. + await runPaneWorkerProcess(paneWorker); +} else if (isCredentialHelperMode(process.argv.slice(2))) { await main(() => runCredentialHelper(process.argv.slice(2))); } else { await main(function* (args) { diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index c12630c9..ea695e9d 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -21,6 +21,7 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useDenoService } from "./deno-service.ts"; @@ -65,7 +66,13 @@ const UPGRADE: UpgradeAssembly = { // The internal helper mode runs before anything public is parsed. It is not a // command: it appears in no help and in no public grammar, and a caller who did // not select it gets the ordinary command line unchanged. -if (isCredentialHelperMode(process.argv.slice(2))) { +const paneWorker = paneWorkerInvocation(process.argv.slice(2)); +if (paneWorker !== undefined) { + // Not `main()`: it would bind SIGINT to its own shutdown and exit 130 on the + // first `^C` typed into the pane, which is the keystroke the foreground child + // is supposed to receive. + await runPaneWorkerProcess(paneWorker); +} else if (isCredentialHelperMode(process.argv.slice(2))) { await main(() => runCredentialHelper(process.argv.slice(2))); } else { await main(function* (args) { diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts new file mode 100644 index 00000000..77ac3eee --- /dev/null +++ b/packages/cli/src/terminal/pane-channel.ts @@ -0,0 +1,196 @@ +/** + * The parent's end of one grid's private worker channels + * (architecture.md §Interactive terminal grids). + * + * One directory per grid, mode 0700, under `$TMPDIR` so the socket paths stay + * inside the 104-byte cap a Unix socket has. Inside it, one socket and one + * mode-0600 token per pane, both written *before* any pane exists — a worker + * that starts finds its socket already listening rather than racing it. + * + * Admission is the whole security boundary. A connection is admitted when its + * first frame is a `hello` naming this pane's ordinal and carrying this pane's + * token; a connection that says anything else, says it too late, names another + * ordinal, or arrives after that pane is already admitted is closed without + * being answered. The token is single-use by construction — the worker removes + * the file as it reads it — so a second reader finds nothing to present. + * + * Everything here dies with the scope: sockets destroyed, servers closed, and + * the directory with its tokens removed, whichever way the grid ended. + */ + +import { randomBytes } from "node:crypto"; +import net from "node:net"; +import type { Server, Socket } from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + ensure, + createSignal, + race, + resource, + sleep, + spawn, + until, + withResolvers, +} from "effection"; +import type { Operation } from "effection"; +import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { + FromWorkerSchema, + paneSocketPath, + paneTokenPath, + readFrames, + writeFrame, +} from "./pane-protocol.ts"; +import type { FromWorker, Hello, ToWorker } from "./pane-protocol.ts"; + +/** How long a connection has to present its `hello` before it is dropped. */ +const HELLO_GRACE_MS = 10_000; + +/** The parent's end of one admitted worker. */ +export interface PaneLink { + readonly ordinal: number; + /** What the worker said about the pane it woke up in. */ + readonly hello: Hello; + send(message: ToWorker): Operation; + /** The next frame, or `undefined` once the worker's connection closed. */ + next(): Operation; + connected(): boolean; +} + +export interface PaneChannels { + /** The private directory, which tmux is told and nothing else learns. */ + readonly directory: string; + /** The admitted worker for `ordinal`; waits for its `hello`. */ + link(ordinal: number): Operation; + /** Connections closed without admission, for a diagnostic to name. */ + refusals(): readonly string[]; +} + +interface Slot { + readonly waiting: ReturnType>; + admitted: boolean; +} + +/** + * Open one grid's private directory and listen for `count` workers. + * + * The directory is created 0700 and removed with the scope. A host whose + * temporary directory is world-writable still gets a private grid, because the + * mode is set on the directory this creates rather than inherited from it. + */ +export function usePaneChannels(count: number): Operation { + return resource(function* (provide) { + // Directly under `$TMPDIR`: a socket path is capped at 104 bytes, and a + // directory named after a repository path spends most of that before the + // socket name begins. + const directory = path.join(os.tmpdir(), `xmd-grid-${randomBytes(6).toString("hex")}`); + yield* ensureDir(directory); + yield* until(chmod(directory, 0o700)); + yield* ensure(() => rm(directory, { recursive: true, force: true })); + + const tokens = new Map(); + const slots = new Map(); + const servers: Server[] = []; + const live = new Set(); + const refusals: string[] = []; + const arrivals = createSignal<{ ordinal: number; socket: Socket }, never>(); + + yield* ensure(() => { + for (const socket of live) { + socket.destroy(); + } + for (const server of servers) { + server.close(); + } + }); + + // Subscribed before a single server listens, so no arrival is missed. + const incoming = yield* arrivals; + + for (let ordinal = 0; ordinal < count; ordinal++) { + const token = randomBytes(16).toString("hex"); + tokens.set(ordinal, token); + slots.set(ordinal, { waiting: withResolvers(), admitted: false }); + yield* writeTextFile(paneTokenPath(directory, ordinal), token); + yield* until(chmod(paneTokenPath(directory, ordinal), 0o600)); + + const server = net.createServer((socket) => { + live.add(socket); + socket.once("close", () => live.delete(socket)); + arrivals.send({ ordinal, socket }); + }); + servers.push(server); + const listening = withResolvers(); + server.once("error", (error: Error) => listening.reject(error)); + server.listen(paneSocketPath(directory, ordinal), () => listening.resolve()); + yield* listening.operation; + } + + function* admit(ordinal: number, socket: Socket): Operation { + const slot = slots.get(ordinal); + const token = tokens.get(ordinal); + const frames = readFrames(socket, (value) => FromWorkerSchema.parse(value)); + const first = yield* race([frames.next(), silence()]); + if (slot === undefined || token === undefined || first.done || first.value.type !== "hello") { + refusals.push(`pane ${ordinal}: a connection that did not say hello`); + socket.destroy(); + return; + } + const hello = first.value; + if (slot.admitted) { + refusals.push(`pane ${ordinal}: a second connection to an admitted pane`); + socket.destroy(); + return; + } + if (hello.ordinal !== ordinal || hello.token !== token) { + // Deliberately one message for both: an attacker learns nothing from + // which half was wrong. + refusals.push(`pane ${ordinal}: a connection that could not prove it is this pane`); + socket.destroy(); + return; + } + slot.admitted = true; + slot.waiting.resolve({ + ordinal, + hello, + send: (message) => writeFrame(socket, message), + *next() { + const next = yield* frames.next(); + return next.done ? undefined : next.value; + }, + connected: () => !socket.destroyed, + }); + } + + yield* spawn(function* () { + while (true) { + const next = yield* incoming.next(); + if (next.done) { + return; + } + const { ordinal, socket } = next.value; + yield* spawn(() => admit(ordinal, socket)); + } + }); + + yield* provide({ + directory, + *link(ordinal) { + const slot = slots.get(ordinal); + if (slot === undefined) { + throw new Error(`this grid has no pane ${ordinal}`); + } + return yield* slot.waiting.operation; + }, + refusals: () => [...refusals], + }); + }); +} + +/** A connection that has said nothing for long enough to be nobody. */ +function* silence(): Operation> { + yield* sleep(HELLO_GRACE_MS); + return { done: true, value: undefined }; +} diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts new file mode 100644 index 00000000..c0f4f540 --- /dev/null +++ b/packages/cli/src/terminal/pane-child.ts @@ -0,0 +1,284 @@ +/** + * One interactive child in a pane, and what its settlement establishes + * (architecture.md §Interactive terminal grids). + * + * Two facts the pane topology needs kept apart: + * + * - **readiness** is the runtime's `spawn` event and nothing earlier. A pid is + * not it, and neither is a pane that has shown output; a missing executable + * delivers `error` *instead of* `spawn`, never after it. This is what a grid's + * attach barrier waits for. + * - **settlement** is the escalation and the sweep that follow the child, not + * the `exit` event. A child that exited on its own may have left descendants + * in its process group, or an orphan still holding the pane's terminal, and + * the pane is not free for the next launch until neither is true. + * + * The child shares this process's process group deliberately, so `^C` typed in + * the pane reaches it: `detached: true` would `setsid()` it away from the + * pane's controlling terminal, and job control is the point of a pane. The + * worker ignores those signals itself so the child is the one interrupted. + */ + +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import process from "node:process"; +import { ensure, Err, Ok, resource, sleep, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { + deliverSignal, + descendantsOf, + groupMembers, + processReachable, + processTable, + terminalHolders, +} from "@executablemd/runtime"; +import type { Settlement } from "./pane-protocol.ts"; + +export interface PaneChildRequest { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Record; +} + +export interface PaneChildOutcome { + exitCode?: number; + signal?: string; +} + +export class PaneStartFailure extends Error { + override name = "PaneStartFailure"; + constructor(readonly code: string) { + super(`the pane's child could not be started (${code})`); + } +} + +export interface PaneChild { + /** `Ok(pid)` once the runtime reports the spawn; `Err` if it never will. */ + readonly started: Operation>; + /** Settles when the child exits. Independent of `started`. */ + readonly exited: Operation; + /** Idempotent: every caller of the one settlement gets the same answer. */ + settle(): Operation; +} + +const INTERRUPT_GRACE_MS = 2_000; +const KILL_SETTLE_MS = 500; +const POLL_MS = 25; + +/** + * Start one child with the pane's terminal inherited, and own its settlement. + * + * `tty` is the pane's terminal device, when the worker has one. The sweep needs + * it: a descendant that called `setsid()` and outlived its parent is outside + * the process snapshot, and holding the terminal open is the only way it is + * still observable. + */ +export function usePaneChild( + request: PaneChildRequest, + tty: string | undefined, +): Operation { + return resource(function* (provide) { + const [command, ...args] = request.argv; + if (command === undefined) { + throw new Error("a pane launch names no command"); + } + const started = withResolvers>(); + const exited = withResolvers(); + let child: ChildProcess | undefined; + let outcome: PaneChildOutcome | undefined; + let settling: ReturnType> | undefined; + + function* settle(): Operation { + if (settling) { + return yield* settling.operation; + } + settling = withResolvers(); + try { + const settlement = + child === undefined || child.pid === undefined + ? { method: "exited" as const, quiet: true, swept: [], holders: [] } + : yield* escalate(child, child.pid, tty, () => outcome !== undefined); + settling.resolve(settlement); + return settlement; + } catch (error) { + settling.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + // Registered before the spawn: a halt between acquiring a process and + // registering its cleanup leaks the process. + yield* ensure(function* () { + yield* settle(); + }); + + child = spawnChild(command, args, { + cwd: request.cwd, + env: request.env, + // The whole point of a pane: the child reads this terminal and draws on + // it directly, so nothing between it and the reader can buffer, reorder + // or capture what passes. + stdio: "inherit", + }); + child.once("spawn", () => { + if (child?.pid !== undefined) { + started.resolve(Ok(child.pid)); + } + }); + child.once("error", (error: Error & { code?: string }) => { + started.resolve(Err(new PaneStartFailure(error.code ?? error.message))); + }); + child.once("exit", (code: number | null, signal: string | null) => { + const settled: PaneChildOutcome = {}; + if (code !== null) { + settled.exitCode = code; + } + if (signal !== null) { + settled.signal = signal; + } + outcome = settled; + exited.resolve(settled); + }); + + yield* provide({ started: started.operation, exited: exited.operation, settle }); + }); +} + +/** + * Interrupt, insist, then reach whatever the interrupt left behind. + * + * The snapshot is taken before the first signal, and that order is the whole + * proof: a killed child stops being anyone's parent and its children reparent + * to init, where an ancestry walk no longer finds them. A descendant that left + * the group with `setsid()` is in the snapshot while its parent lives; one + * created after the snapshot is not, and this says so rather than claiming + * otherwise. + */ +function* escalate( + child: ChildProcess, + pid: number, + tty: string | undefined, + hasExited: () => boolean, +): Operation { + const before = yield* processTable(); + // The child shares this process's group, so the group is looked up rather + // than assumed to be the child's own pid. + const group = before.find((row) => row.pid === pid)?.pgid ?? pid; + // Never anything this worker came from. In a pane the worker is the session + // leader, so its group holds nothing above it — but a settlement that could + // reach an ancestor would be one signal away from killing the run that + // started the grid, and that is not a thing to leave to the topology being + // what it should be. + const forebears = ancestorsOf(before, process.pid); + const related = new Map(); + for (const row of descendantsOf(before, pid)) { + if (!forebears.has(row.pid)) { + related.set(row.pid, row.pid); + } + } + for (const row of groupMembers(before, group)) { + if (row.pid !== pid && row.pid !== process.pid && !forebears.has(row.pid)) { + related.set(row.pid, row.pid); + } + } + + let method: Settlement["method"] = "exited"; + if (!hasExited() && (yield* processReachable(pid))) { + method = "interrupted"; + yield* deliverSignal(pid, "SIGINT"); + const left = yield* waitFor(function* () { + return hasExited() || !(yield* processReachable(pid)); + }, INTERRUPT_GRACE_MS); + if (!left) { + method = "killed"; + const fatal = yield* deliverSignal(pid, "SIGKILL"); + const gone = yield* waitFor(function* () { + return hasExited() || !(yield* processReachable(pid)); + }, KILL_SETTLE_MS); + if (!gone && fatal !== "delivered" && fatal !== "absent") { + throw new Error(`could not establish that process ${pid} stopped: SIGKILL was ${fatal}`); + } + } + } + // Deno's `node:child_process` holds the runtime open on a handle it never + // settles once a signal the child ignored has been delivered. + try { + child.unref(); + } catch { + // Already released. + } + + for (const member of related.keys()) { + yield* deliverSignal(member, "SIGKILL"); + } + yield* waitFor(function* () { + for (const member of related.keys()) { + if (yield* processReachable(member)) { + return false; + } + } + return true; + }, KILL_SETTLE_MS); + const swept: { pid: number; gone: boolean }[] = []; + for (const member of related.keys()) { + swept.push({ pid: member, gone: !(yield* processReachable(member)) }); + } + if (!(hasExited() || !(yield* processReachable(pid)))) { + swept.unshift({ pid, gone: false }); + } + + // Whatever still has the pane's terminal open, after everything the snapshot + // named is gone. This is where an escaped `setsid()` orphan is still visible. + const holders = yield* sweepHolders(tty); + const quiet = swept.every((entry) => entry.gone) && holders.every((entry) => entry.gone); + return { method, quiet, child: pid, swept, holders }; +} + +/** Clear the pane's terminal of anything but this worker, and report it. */ +export function* sweepHolders( + tty: string | undefined, +): Operation<{ pid: number; gone: boolean }[]> { + if (tty === undefined || tty === "??") { + return []; + } + const found = (yield* terminalHolders(`/dev/${tty}`)).filter((pid) => pid !== process.pid); + for (const pid of found) { + yield* deliverSignal(pid, "SIGKILL"); + } + yield* waitFor(function* () { + for (const pid of found) { + if (yield* processReachable(pid)) { + return false; + } + } + return true; + }, KILL_SETTLE_MS); + const swept: { pid: number; gone: boolean }[] = []; + for (const pid of found) { + swept.push({ pid, gone: !(yield* processReachable(pid)) }); + } + return swept; +} + +/** This process and everything it descends from, in one reading of the table. */ +function ancestorsOf(table: readonly { pid: number; ppid: number }[], pid: number): Set { + const found = new Set([pid]); + let current = table.find((row) => row.pid === pid); + while (current !== undefined && current.ppid > 0 && !found.has(current.ppid)) { + found.add(current.ppid); + const parent: number = current.ppid; + current = table.find((row) => row.pid === parent); + } + return found; +} + +function* waitFor(condition: () => Operation, limitMs: number): Operation { + const deadline = Date.now() + limitMs; + while (!(yield* condition())) { + if (Date.now() >= deadline) { + return false; + } + yield* sleep(POLL_MS); + } + return true; +} diff --git a/packages/cli/src/terminal/pane-protocol.ts b/packages/cli/src/terminal/pane-protocol.ts new file mode 100644 index 00000000..d1dc3110 --- /dev/null +++ b/packages/cli/src/terminal/pane-protocol.ts @@ -0,0 +1,160 @@ +/** + * What the parent and one pane worker say to each other, and how + * (architecture.md §Interactive terminal grids). + * + * The channel is invocation-private: one Unix socket per pane, inside a + * mode-0700 directory that exists for one grid. A worker proves which pane it + * is with a token the parent wrote to a mode-0600 file that only that worker + * reads — and removes, so the token is spent the moment it is used. + * + * Everything a launch actually consists of crosses here rather than through + * tmux: the exact argv vector, the working directory and the environment. tmux + * has a command parser, and a command parser is a place where an argument can + * become two arguments, or a quote, or a `;`. What tmux is told instead is a + * directory and an ordinal, which is all its parser ever sees. + * + * Frames are newline-delimited JSON, parsed with a schema on both ends. A frame + * that is not the protocol ends the conversation rather than being interpreted: + * this socket is how one process is asked to start a program with inherited + * terminal streams, so "close to what I expected" is not good enough. + */ + +import { join } from "node:path"; +import type { Socket } from "node:net"; +import { createQueue, withResolvers } from "effection"; +import type { Operation, Queue } from "effection"; +import { z } from "zod"; + +/** What one worker says about the pane it woke up in. */ +export const HelloSchema = z.object({ + type: z.literal("hello"), + ordinal: z.number().int().nonnegative(), + token: z.string(), + pid: z.number().int(), + pgid: z.number().int(), + /** `ttys003`, or `??` when the worker has no controlling terminal. */ + tty: z.string(), + /** Whether stdin, stdout and stderr are terminals. All three must be. */ + isatty: z.tuple([z.boolean(), z.boolean(), z.boolean()]), +}); + +/** One process the settlement reached, and what reaching it established. */ +const SweptSchema = z.object({ + pid: z.number().int(), + gone: z.boolean(), +}); + +/** + * What a settlement established, in the order it established it. + * + * `quiet` is the only field a caller may act on, and it is true only when the + * child, everything the snapshot said was below or beside it, and every holder + * of the pane's terminal are gone. The rest is what a diagnostic says when it + * is not. + */ +export const SettlementSchema = z.object({ + method: z.enum(["exited", "interrupted", "killed"]), + quiet: z.boolean(), + child: z.number().int().optional(), + /** Snapshot members reached during the escalation. */ + swept: z.array(SweptSchema), + /** Anything still holding the pane's terminal after the sweep. */ + holders: z.array(SweptSchema), +}); + +export const FromWorkerSchema = z.discriminatedUnion("type", [ + HelloSchema, + z.object({ type: z.literal("displayed"), seq: z.number().int() }), + /** The runtime's spawn event, and nothing earlier. */ + z.object({ type: z.literal("started"), id: z.string(), pid: z.number().int() }), + z.object({ type: z.literal("start-failed"), id: z.string(), reason: z.string() }), + /** A launch asked for while one is live. */ + z.object({ type: z.literal("busy"), id: z.string() }), + z.object({ + type: z.literal("exited"), + id: z.string(), + exitCode: z.number().int().optional(), + signal: z.string().optional(), + /** The settlement that preceded this; the pane is free once it arrives. */ + settlement: SettlementSchema, + }), + z.object({ + type: z.literal("quiet"), + id: z.string().optional(), + settlement: SettlementSchema, + }), + z.object({ type: z.literal("bye"), holders: z.array(SweptSchema) }), +]); + +export const ToWorkerSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("welcome") }), + z.object({ type: z.literal("display"), seq: z.number().int(), text: z.string() }), + z.object({ + type: z.literal("launch"), + id: z.string(), + argv: z.array(z.string()).min(1), + cwd: z.string(), + env: z.record(z.string(), z.string()), + }), + z.object({ type: z.literal("cancel"), id: z.string() }), + z.object({ type: z.literal("shutdown") }), +]); + +export type Hello = z.infer; +export type FromWorker = z.infer; +export type ToWorker = z.infer; +export type Settlement = z.infer; + +/** + * Where one pane's socket and token live. + * + * Short by necessity rather than taste: a Unix socket path is capped at 104 + * bytes, which a temporary directory named after a repository path exceeds. + */ +export function paneSocketPath(directory: string, ordinal: number): string { + return join(directory, `p${ordinal}.sock`); +} + +export function paneTokenPath(directory: string, ordinal: number): string { + return join(directory, `p${ordinal}.token`); +} + +/** + * Feed one socket's bytes into a queue of parsed frames. + * + * A frame that does not parse destroys the socket. There is no partial credit + * on this channel. + */ +export function readFrames(socket: Socket, parse: (value: unknown) => T): Queue { + const queue = createQueue(); + let remainder = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + const lines = (remainder + chunk).split("\n"); + remainder = lines.pop() ?? ""; + for (const line of lines) { + if (line.length === 0) { + continue; + } + try { + queue.add(parse(JSON.parse(line))); + } catch { + socket.destroy(); + } + } + }); + socket.on("close", () => queue.close()); + socket.on("error", () => socket.destroy()); + return queue; +} + +/** Write one frame, and settle once the socket has taken it. */ +export function writeFrame(socket: Socket, message: unknown): Operation { + const written = withResolvers(); + if (socket.destroyed) { + written.resolve(); + return written.operation; + } + socket.write(JSON.stringify(message) + "\n", () => written.resolve()); + return written.operation; +} diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts new file mode 100644 index 00000000..3705862d --- /dev/null +++ b/packages/cli/src/terminal/pane-worker.ts @@ -0,0 +1,258 @@ +/** + * The persistent pane worker: tmux's initial process in one pane + * (architecture.md §Interactive terminal grids). + * + * It owns the pane's terminal for the pane's whole life, and everything it does + * is asked of it over the private socket — show this text, start this child, + * cancel it, shut down. It never reads the terminal itself, so keystrokes reach + * the foreground child and only the child. + * + * It is the pane's session leader and shares the pane's process group with the + * child, so `^C` on that pane is delivered to both. It handles SIGINT, SIGQUIT + * and SIGTSTP by doing nothing: dispositions reset across `exec`, so the child + * gets the defaults and is the one interrupted. SIGHUP keeps its default — when + * the pane's terminal goes away, so does the worker. + * + * It runs under Effection's `run()` rather than `main()`. `main()` binds SIGINT + * to its own shutdown and exits 130 on the first `^C` typed into the pane — + * which is the exact keystroke the child is supposed to receive. + * + * Nothing here is reachable without the handshake. The worker is started with + * an ordinal and a directory, reads the token only that pane's file holds, + * removes it, and presents it; a worker that cannot do that connects to nothing + * and performs no work at all. + */ + +import net from "node:net"; +import process from "node:process"; +import { readTextFile, rm } from "@effectionx/fs"; +import { run, spawn, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { installPosixTerminalProcesses, processTable } from "@executablemd/runtime"; +import { usePaneChild, sweepHolders } from "./pane-child.ts"; +import type { PaneChild } from "./pane-child.ts"; +import { + paneSocketPath, + paneTokenPath, + readFrames, + ToWorkerSchema, + writeFrame, +} from "./pane-protocol.ts"; +import type { FromWorker, Settlement } from "./pane-protocol.ts"; + +/** The hidden invocation a grid starts a pane with. */ +export const PANE_WORKER_COMMAND = "terminal-worker"; + +/** + * Whether this process was started as a pane worker, and for which pane. + * + * Read from raw argv, because this is decided before any parser exists — the + * worker must not run under Effection's `main()`, so it is dispatched at the + * entrypoint rather than inside the command table. It is in no command table, + * so it appears in no help output and no catalog. + * + * Anything but the exact shape is not a worker invocation and falls through to + * the ordinary commands, where `terminal-worker` names no command and is a + * document reference like any other unknown first token. + */ +export function paneWorkerInvocation( + args: readonly string[], +): { ordinal: number; directory: string } | undefined { + const [name, ordinal, directory, ...rest] = args; + if (name !== PANE_WORKER_COMMAND || ordinal === undefined || directory === undefined) { + return undefined; + } + if (rest.length > 0 || !/^\d+$/.test(ordinal)) { + return undefined; + } + return { ordinal: Number(ordinal), directory }; +} + +/** + * Run this process as a pane worker. + * + * `run()` rather than `main()`, deliberately: `main()` binds SIGINT to its own + * shutdown and would exit 130 on the first `^C` typed into the pane — the exact + * keystroke the foreground child is supposed to receive. The signal handlers go + * on before anything else for the same reason. + * + * Naming this invocation grants nothing. The worker connects to a socket in a + * private directory and must present that pane's single-use token before the + * parent says a word to it, so a caller who types this gets a process that + * fails to connect and performs no work at all. + */ +export function runPaneWorkerProcess(invocation: { + ordinal: number; + directory: string; +}): Promise { + ignoreForegroundSignals(); + return run(() => runPaneWorker(invocation.ordinal, invocation.directory)); +} + +/** A settlement for a pane that never started anything. */ +const NOTHING_TO_SETTLE: Settlement = { + method: "exited", + quiet: true, + swept: [], + holders: [], +}; + +interface Live { + readonly id: string; + child: PaneChild | undefined; + /** The one settlement of this child, however many callers ask for it. */ + settled: ReturnType> | undefined; +} + +/** + * Ignore the signals that belong to the foreground child. + * + * They are delivered to the whole foreground process group, and this worker is + * in it. Doing nothing is the correct handling: the child inherits default + * dispositions across `exec`, so it receives the same signal and acts on it. + */ +export function ignoreForegroundSignals(): void { + for (const name of ["SIGINT", "SIGQUIT", "SIGTSTP"] as const) { + process.on(name, () => {}); + } +} + +function writeOut(text: string): Operation { + const written = withResolvers(); + process.stdout.write(text, () => written.resolve()); + return written.operation; +} + +/** + * Run one pane worker until the parent says to stop. + * + * The caller has already ignored the foreground signals and is running this + * under `run()`; both are properties of the *process*, not of this operation, + * which is why they are the entrypoint's to establish. + */ +export function* runPaneWorker(ordinal: number, directory: string): Operation { + yield* installPosixTerminalProcesses(); + + // Read once, then spent. A second worker for this pane finds no token, so it + // has nothing to present and is refused by the parent. + const token = (yield* readTextFile(paneTokenPath(directory, ordinal))).trim(); + yield* rm(paneTokenPath(directory, ordinal), { force: true }); + + const socket = net.createConnection(paneSocketPath(directory, ordinal)); + const connected = withResolvers(); + socket.once("connect", () => connected.resolve()); + socket.once("error", (error: Error) => connected.reject(error)); + yield* connected.operation; + + const inbound = readFrames(socket, (value) => ToWorkerSchema.parse(value)); + const say = (message: FromWorker) => writeFrame(socket, message); + + const table = yield* processTable(); + const facts = table.find((row) => row.pid === process.pid); + const tty = facts?.tty; + yield* say({ + type: "hello", + ordinal, + token, + pid: process.pid, + pgid: facts?.pgid ?? -1, + tty: tty ?? "??", + isatty: [ + process.stdin.isTTY === true, + process.stdout.isTTY === true, + process.stderr.isTTY === true, + ], + }); + + let live: Live | undefined; + + /** Settle one child once, however many callers ask, and free the pane. */ + function* settle(entry: Live): Operation { + if (entry.settled) { + return yield* entry.settled.operation; + } + entry.settled = withResolvers(); + try { + const settlement = + entry.child === undefined ? NOTHING_TO_SETTLE : yield* entry.child.settle(); + // Cleared only after the settlement, so a launch arriving now is refused + // rather than started beside a sweep that would reach it. + if (live === entry) { + live = undefined; + } + entry.settled.resolve(settlement); + return settlement; + } catch (error) { + entry.settled.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + function* quiesce(): Operation { + return live === undefined ? NOTHING_TO_SETTLE : yield* settle(live); + } + + while (true) { + const next = yield* inbound.next(); + if (next.done) { + return; + } + const message = next.value; + switch (message.type) { + case "welcome": + break; + case "display": + // Written, never read: what the reader types belongs to the child. + yield* writeOut(message.text); + yield* say({ type: "displayed", seq: message.seq }); + break; + case "launch": { + if (live !== undefined) { + yield* say({ type: "busy", id: message.id }); + break; + } + const entry: Live = { id: message.id, child: undefined, settled: undefined }; + live = entry; + yield* spawn(function* () { + const child = yield* usePaneChild( + { argv: message.argv, cwd: message.cwd, env: message.env }, + tty, + ); + entry.child = child; + const started = yield* child.started; + if (!started.ok) { + // Never started, so never ready. The pane's readiness latch is not + // tripped, and the grid it belongs to does not attach. + yield* settle(entry); + yield* say({ type: "start-failed", id: message.id, reason: started.error.message }); + return; + } + yield* say({ type: "started", id: message.id, pid: started.value }); + const outcome = yield* child.exited; + // The exit is not the end of it. `exited` is what frees the pane for + // the next launch, so it follows the whole settlement. + const settlement = yield* settle(entry); + yield* say({ type: "exited", id: message.id, ...outcome, settlement }); + }); + break; + } + case "cancel": { + const settlement = yield* quiesce(); + yield* say({ type: "quiet", id: message.id, settlement }); + break; + } + case "shutdown": { + const settlement = yield* quiesce(); + yield* say({ type: "quiet", settlement }); + // The pane's last sweep, by the only process that can still make it: + // once this worker exits, tmux closes the pane's pty master and the + // kernel revokes the slave, after which nothing can name a process that + // kept the terminal open. Every child's settlement already swept, so a + // holder here arrived between that sweep and now. + yield* say({ type: "bye", holders: yield* sweepHolders(tty) }); + socket.end(); + return; + } + } + } +} diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index b931e347..c23c7f3b 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -16,6 +16,14 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; +import { ensure, race, resource, scoped, sleep, until, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import net from "node:net"; +import { stat } from "node:fs/promises"; +import * as path from "node:path"; +import { cliCommand } from "@executablemd/test-support/launch"; import { layoutString, placementProblems, @@ -23,6 +31,16 @@ import { swapsInto, } from "../src/terminal/layout.ts"; import type { LayoutCell } from "../src/terminal/layout.ts"; +import { usePaneChannels } from "../src/terminal/pane-channel.ts"; +import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; +import { + FromWorkerSchema, + paneSocketPath, + paneTokenPath, + writeFrame, +} from "../src/terminal/pane-protocol.ts"; +import { PANE_WORKER_COMMAND, paneWorkerInvocation } from "../src/terminal/pane-worker.ts"; +import type { FromWorker } from "../src/terminal/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ function readCells(layout: string): LayoutCell[] { @@ -149,3 +167,321 @@ describe("Tier TX — the tmux grid's geometry", () => { expect(message).toContain("pane 3 is not in this window"); }); }); + +/** + * Start one real pane worker, as a real process, over the real socket. + * + * No tmux: a worker is an ordinary program that connects to a socket and does + * what it is told, and every claim in this tier is about that program. tmux's + * part — putting it in a pane with a terminal — is the next tier's. + */ +function useWorker(directory: string, ordinal: number): Operation { + return resource(function* (provide) { + const invocation = cliCommand([PANE_WORKER_COMMAND, String(ordinal), directory]); + const child = spawnChild(invocation.command, invocation.arguments, { + stdio: ["ignore", "pipe", "pipe"], + // A pane's worker is tmux's session leader, so it is its own process + // group. Modelled here, because a settlement sweeps the group it is in + // and a worker sharing the test runner's group would be sweeping the + // test runner. + detached: true, + }); + yield* ensure(function* () { + child.kill("SIGKILL"); + yield* until( + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once("exit", () => resolve()); + }), + ); + }); + yield* provide(child); + }); +} + +/** Everything one pane's worker said, until it says the one being waited for. */ +function untilFrame(link: PaneLink, type: FromWorker["type"]): Operation { + return (function* (): Operation { + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + throw new Error(`the worker closed before saying "${type}"`); + } + if (frame.type === type) { + return frame; + } + } + })(); +} + +/** A raw connection to a pane's socket, for the rows about admission. */ +function useImpostor(directory: string, ordinal: number): Operation { + return resource(function* (provide) { + const socket = net.createConnection(paneSocketPath(directory, ordinal)); + const connected = withResolvers(); + socket.once("connect", () => connected.resolve()); + socket.once("error", (error: Error) => connected.reject(error)); + yield* connected.operation; + yield* ensure(() => { + socket.destroy(); + }); + yield* provide(socket); + }); +} + +/** Settle when a socket closes, or say it did not within the grace given. */ +function closedWithin(socket: net.Socket, limitMs: number): Operation { + return (function* (): Operation { + const closed = withResolvers(); + if (socket.destroyed) { + return true; + } + socket.once("close", () => closed.resolve(true)); + return yield* race([ + closed.operation, + (function* (): Operation { + yield* sleep(limitMs); + return false; + })(), + ]); + })(); +} + +describe("Tier TW — the pane worker and its private channel", () => { + it("TW1: the private directory is 0700 and its tokens 0600", function* () { + const channels: PaneChannels = yield* usePaneChannels(2); + const directory = yield* until(stat(channels.directory)); + expect(directory.mode & 0o777).toBe(0o700); + for (const ordinal of [0, 1]) { + const token = yield* until(stat(paneTokenPath(channels.directory, ordinal))); + expect(`pane ${ordinal}: ${(token.mode & 0o777).toString(8)}`).toBe(`pane ${ordinal}: 600`); + // The socket exists before any pane does, so a worker that starts finds + // it listening rather than racing it. + yield* until(stat(paneSocketPath(channels.directory, ordinal))); + } + }); + + it("TW2: the directory and everything in it goes with the grid", function* () { + let directory = ""; + yield* scoped(function* () { + const channels = yield* usePaneChannels(1); + directory = channels.directory; + }); + const gone = yield* until( + stat(directory).then( + () => false, + () => true, + ), + ); + expect(gone).toBe(true); + }); + + it("TW3: a real worker connects, proves which pane it is, and spends its token", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + expect(link.hello.ordinal).toBe(0); + expect(link.hello.pid).toBeGreaterThan(0); + // Spent as it was read: a second worker for this pane finds no token, so + // it has nothing to present. + const spent = yield* until( + stat(paneTokenPath(channels.directory, 0)).then( + () => false, + () => true, + ), + ); + expect(spent).toBe(true); + expect(channels.refusals()).toEqual([]); + }); + + it("TW4: a connection that says nothing the protocol knows is closed", function* () { + const channels = yield* usePaneChannels(1); + const socket = yield* useImpostor(channels.directory, 0); + socket.write("this is not a frame\n"); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect(channels.refusals().length).toBe(1); + }); + + it("TW5: a hello with the wrong token proves nothing and is closed", function* () { + const channels = yield* usePaneChannels(1); + const socket = yield* useImpostor(channels.directory, 0); + yield* writeFrame(socket, { + type: "hello", + ordinal: 0, + token: "0".repeat(32), + pid: 1, + pgid: 1, + tty: "??", + isatty: [false, false, false], + }); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect(channels.refusals()[0]).toContain("could not prove it is this pane"); + }); + + it("TW6: a second connection to an admitted pane is closed", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + yield* channels.link(0); + + // The real worker holds this pane. A second caller with the same socket + // path — token or not — is not this pane's worker. + const socket = yield* useImpostor(channels.directory, 0); + yield* writeFrame(socket, { + type: "hello", + ordinal: 0, + token: "0".repeat(32), + pid: 1, + pgid: 1, + tty: "??", + isatty: [false, false, false], + }); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect( + channels + .refusals() + .some((line) => line.includes("already admitted") || line.includes("second connection")), + ).toBe(true); + }); + + it("TW7: a launch crosses exactly, and readiness is the runtime spawn event", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + // Arguments a command parser would ruin: spaces, a semicolon, a quote and + // a dollar sign. They cross the socket as bytes and reach the child as + // the exact vector. + const awkward = ["a b", "semi;colon", `quote"and'both`, "$HOME"]; + yield* link.send({ + type: "launch", + id: "one", + argv: ["/bin/echo", ...awkward], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + + const started = yield* untilFrame(link, "started"); + expect(started.type === "started" ? started.pid : 0).toBeGreaterThan(0); + const exited = yield* untilFrame(link, "exited"); + if (exited.type !== "exited") { + throw new Error("expected an exit"); + } + expect(exited.exitCode).toBe(0); + // Settlement follows the exit, and the pane is free only after it. + expect(exited.settlement.quiet).toBe(true); + }); + + it("TW8: a child that never starts reports a failure and never readiness", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "missing", + argv: [path.join(channels.directory, "not-a-program")], + cwd: path.resolve("."), + env: {}, + }); + + // `error` arrives instead of `spawn`, never after it — so the pane's + // readiness latch is never tripped and the grid does not attach. + const failure = yield* untilFrame(link, "start-failed"); + expect(failure.type === "start-failed" ? failure.reason : "").toContain("could not be started"); + }); + + it("TW9: one pane admits one live child, and the next only after it settles", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + const sleeper = { + type: "launch" as const, + id: "first", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }; + yield* link.send(sleeper); + yield* untilFrame(link, "started"); + + // Asked for while the first is live. + yield* link.send({ ...sleeper, id: "second" }); + const refused = yield* untilFrame(link, "busy"); + expect(refused.type === "busy" ? refused.id : "").toBe("second"); + + // Cancelled, settled, and only then is the pane free again. + yield* link.send({ type: "cancel", id: "first" }); + const quiet = yield* untilFrame(link, "quiet"); + expect(quiet.type === "quiet" ? quiet.settlement.quiet : false).toBe(true); + + yield* link.send({ ...sleeper, id: "third" }); + const third = yield* untilFrame(link, "started"); + expect(third.type === "started" ? third.id : "").toBe("third"); + }); + + it("TW10: display is written to the pane and never read back from it", function* () { + const channels = yield* usePaneChannels(1); + const worker = yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + const shown: string[] = []; + worker.stdout?.setEncoding("utf8"); + worker.stdout?.on("data", (chunk: string) => shown.push(chunk)); + + yield* link.send({ type: "display", seq: 1, text: "pane says hello\n" }); + const displayed = yield* untilFrame(link, "displayed"); + expect(displayed.type === "displayed" ? displayed.seq : 0).toBe(1); + expect(shown.join("")).toContain("pane says hello"); + }); + + it("TW11: shutdown settles, sweeps the terminal, and says goodbye", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "one", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + yield* untilFrame(link, "started"); + + yield* link.send({ type: "shutdown" }); + const quiet = yield* untilFrame(link, "quiet"); + expect(quiet.type === "quiet" ? quiet.settlement.quiet : false).toBe(true); + // The pane's last sweep, by the only process that can still make it. + const bye = yield* untilFrame(link, "bye"); + expect(bye.type).toBe("bye"); + }); + + it("TW12: naming the worker invocation is the only way to be one", function* () { + // In no command table, so in no help output and no catalog. What makes it + // safe is not obscurity: a worker that cannot present a pane's single-use + // token is answered by nobody. + expect(paneWorkerInvocation([PANE_WORKER_COMMAND, "0", "/tmp/x"])).toEqual({ + ordinal: 0, + directory: "/tmp/x", + }); + for (const shape of [ + [PANE_WORKER_COMMAND], + [PANE_WORKER_COMMAND, "0"], + [PANE_WORKER_COMMAND, "zero", "/tmp/x"], + [PANE_WORKER_COMMAND, "0", "/tmp/x", "extra"], + ["run", "0", "/tmp/x"], + ]) { + expect(`${shape.join(" ")}: ${paneWorkerInvocation(shape)}`).toBe( + `${shape.join(" ")}: undefined`, + ); + } + }); +}); From 4f94dc4c713f99d52601f6c103e3c1fd41d233c8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 23:07:48 -0400 Subject: [PATCH 04/15] =?UTF-8?q?=E2=9C=A8=20Build=20the=20hidden=20tmux?= =?UTF-8?q?=20composite=20for=20a=20terminal=20grid=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One invocation-private server per grid, on its own socket, started with `-f /dev/null` so a reader's `.tmux.conf` cannot redecide an authored layout. A pane per authored ordinal, each running that pane's worker — tmux's parser sees an ordinal and a directory and never a launch's argv. Nothing is visible until `attach()`, which core calls only after every pane has reported a start. Three clients, kept apart because they answer different questions. The visible one is the reader's. The control one attaches `-f no-output`, so pane bytes never travel through this process, and what it reports is how reader detach, server stop and control loss are told apart — an attach client's exit code cannot tell them apart, being 0 after `detach-client`, 0 after `kill-session` and 1 after `kill-server`. The workers are not clients at all; they are the panes. Teardown is registered before the first command, so a composite that fails half-built still takes its server down. A detach is *asked for* before anything is signalled, because a client that leaves restores the terminal and one that is killed cannot. `stop()` establishes the server pid is unreachable and the server refuses its session — never the socket file's absence, which outlives it. `probeTmux()` answers the prerequisites before a server exists: a terminal to divide, and a tmux new enough to divide it as an authored layout needs. Tier TG runs against a fake server that reproduces the behaviours this code exists to work around — a split inserts its pane into the window list after the one it split, and a layout string's leaves are filled in window-list order with the ids in them ignored. What is not faked is the composite: the same layout string, the same swap decisions, and real control-mode lines from a fixture process through the same splitter and classifier. Both halves of the ordering claim were broken on purpose: removing the swaps fails TG2, and a fake that honours the leaf ids fails TG2 as well — so the row is passing because the composite imposes the order, not because the two happened to coincide. Stated plainly, and not claimed here: a fixture client inherits a pipe, so it cannot restore a terminal it never had. That a real `tmux attach` gives the reader's terminal back when asked to detach is #726's evidence on real tmux. --- packages/cli/src/terminal/tmux-grid.ts | 427 ++++++++++++++++++ packages/cli/src/terminal/tmux.ts | 154 +++++++ packages/cli/tests/fixtures/fake-tmux.ts | 287 ++++++++++++ packages/cli/tests/fixtures/tmux-client.ts | 61 +++ packages/cli/tests/terminal-grid-tmux.test.ts | 326 +++++++++++++ 5 files changed, 1255 insertions(+) create mode 100644 packages/cli/src/terminal/tmux-grid.ts create mode 100644 packages/cli/src/terminal/tmux.ts create mode 100644 packages/cli/tests/fixtures/fake-tmux.ts create mode 100644 packages/cli/tests/fixtures/tmux-client.ts diff --git a/packages/cli/src/terminal/tmux-grid.ts b/packages/cli/src/terminal/tmux-grid.ts new file mode 100644 index 00000000..3c39f5e0 --- /dev/null +++ b/packages/cli/src/terminal/tmux-grid.ts @@ -0,0 +1,427 @@ +/** + * One hidden, invocation-private tmux composite + * (architecture.md §Interactive terminal grids, §Atomic presentation). + * + * A grid is built entirely out of sight: its own server on its own socket, a + * pane per authored ordinal each running that pane's worker, the authored + * layout imposed explicitly, and a control-mode client that says what the + * server sees. Nothing is visible until `attach()`, which core calls only after + * every pane has reported a start — so a reader never watches a grid fill in, + * and a grid that failed to start is taken down without ever having been shown. + * + * Three clients, kept apart because they answer different questions: + * + * - the **visible** client is the reader's, attached on this process's terminal + * with the streams inherited; + * - the **control** client attaches with `-f no-output`, so pane bytes never + * travel through this process. What it reports — `%client-detached`, + * `%sessions-changed`, `%exit`, EOF — is how reader detach, server stop and + * control loss are told apart. An attach client's exit code cannot tell them + * apart: it is 0 after `detach-client`, 0 after `kill-session` and 1 after + * `kill-server`; + * - the pane **workers** are not clients at all. They are the panes. + * + * Every tmux identifier — the socket path, session name, window, pane ids, + * client names, the server pid — stays inside this module. None of it reaches a + * request, a result, a retained record or a diagnostic. + */ + +import { exec } from "@effectionx/process"; +import { lines } from "@effectionx/stream-helpers"; +import { ensure, resource, sleep, spawn } from "effection"; +import type { Operation } from "effection"; +import { processReachable } from "@executablemd/runtime"; +import { layoutString, swapsInto } from "./layout.ts"; +import type { LayoutCell } from "./layout.ts"; +import { usePaneChild } from "./pane-child.ts"; +import type { PaneChild } from "./pane-child.ts"; +import type { Tmux } from "./tmux.ts"; + +/** What one prepared pane is, from the composite's side. */ +export interface TmuxPane { + readonly ordinal: number; + /** tmux's `%N`. Never leaves this module. */ + readonly id: string; + /** `ttys003`, the pane's terminal, as the worker will name it. */ + readonly tty: string; + readonly pid: number; + readonly cell: LayoutCell; +} + +/** What the control client saw, classified. */ +export type ControlEvent = + | { kind: "client-attached"; client: string } + | { kind: "client-detached"; client: string } + | { kind: "sessions-changed" } + | { kind: "layout-change" } + | { kind: "exit" } + | { kind: "closed" } + | { kind: "other"; line: string }; + +export interface TmuxGridRequest { + readonly session: string; + readonly columns: number; + readonly panes: number; + readonly width: number; + readonly height: number; + readonly titles: readonly string[]; + /** The command that runs one pane's worker. */ + workerCommand(ordinal: number): readonly string[]; + readonly cwd: string; + readonly env: Record; +} + +/** What stopping the server established. */ +export interface ServerStopped { + /** The server process is no longer reachable. */ + readonly gone: boolean; + /** The server refuses to answer for its session. */ + readonly refuses: boolean; +} + +export interface VisibleClient { + readonly child: PaneChild; + /** tmux's name for this client once attached: its tty. */ + readonly name: string; +} + +export interface TmuxGrid { + readonly panes: readonly TmuxPane[]; + /** Everything the control client reported, classified, in order. */ + readonly events: readonly ControlEvent[]; + /** Pane geometry now, for checking placement after a resize. */ + geometry(): Operation; + /** Show the grid on this process's terminal. */ + attach(): Operation; + /** Ask the visible client to leave, so it restores the terminal itself. */ + detach(client: VisibleClient): Operation; + /** Stop the server, and establish that it is gone. */ + stop(): Operation; +} + +const CLIENT_POLL_MS = 20; +const STOP_LIMIT_MS = 5_000; +const DETACH_LIMIT_MS = 1_000; + +/** + * Prepare the whole hidden composite. + * + * The teardown is registered before the first command, so a cancellation + * anywhere below still takes the server down: a half-built grid is exactly the + * state that would otherwise leave a server, its workers and their sockets + * behind. + */ +export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation { + return resource(function* (provide) { + const target = `${request.session}:0`; + let serverPid = -1; + + function* stop(): Operation { + yield* tmux.tryRun(["kill-server"]); + const deadline = Date.now() + STOP_LIMIT_MS; + let stopped: ServerStopped; + do { + stopped = { + gone: serverPid < 0 || !(yield* processReachable(serverPid)), + // The socket file outlives the server, so "gone" is the pid being + // unreachable and nothing answering for the session — never the + // socket file's absence. + refuses: (yield* tmux.tryRun(["has-session", "-t", request.session])) === undefined, + }; + if (stopped.gone && stopped.refuses) { + return stopped; + } + yield* sleep(CLIENT_POLL_MS); + } while (Date.now() < deadline); + return stopped; + } + + yield* ensure(function* () { + yield* stop(); + }); + + yield* tmux.run([ + "new-session", + "-d", + "-s", + request.session, + "-x", + String(request.width), + "-y", + String(request.height), + "-c", + request.cwd, + ...request.workerCommand(0), + ]); + serverPid = Number(yield* tmux.run(["display", "-p", "#{pid}"])); + // A pane whose worker has gone stays a pane, so its death is a fact the + // composite can read rather than a pane that vanishes from under the + // layout. + yield* tmux.run(["set", "-g", "remain-on-exit", "on"]); + yield* tmux.run(["set", "-g", "status", "off"]); + yield* tmux.run(["set", "-g", "pane-border-status", "top"]); + yield* tmux.run(["set", "-g", "pane-border-format", " #{pane_title} "]); + + // Split whichever pane has the most room, so a small window still fits + // every pane. Where each one ends up is the explicit layout's business, + // not this loop's. + const paneIds: string[] = [yield* tmux.run(["display", "-p", "-t", target, "#{pane_id}"])]; + for (let ordinal = 1; ordinal < request.panes; ordinal++) { + const roomiest = yield* largestPane(tmux, target); + const direction = roomiest.width >= roomiest.height * 2 ? "-h" : "-v"; + paneIds.push( + yield* tmux.run([ + "split-window", + "-d", + direction, + "-t", + roomiest.id, + "-c", + request.cwd, + "-P", + "-F", + "#{pane_id}", + ...request.workerCommand(ordinal), + ]), + ); + } + + const [width, height] = (yield* tmux.run([ + "display", + "-p", + "-t", + target, + "#{window_width} #{window_height}", + ])) + .split(" ") + .map(Number); + yield* tmux.run([ + "select-layout", + "-t", + target, + layoutString( + width ?? request.width, + height ?? request.height, + request.columns, + paneIds.map(paneNumber), + ), + ]); + + // tmux fills the layout's leaves in window-list order and ignores the ids + // the string names, so authored order is imposed here. Swapping preserves + // the cells: what moves is which pane is in which one. + const placed = (yield* readPanes(tmux, target, paneIds)).slice().sort(byPosition); + for (const swap of swapsInto( + placed.map((pane) => paneNumber(pane.id)), + paneIds.map(paneNumber), + )) { + const from = placed[swap.from]; + const to = placed[swap.to]; + if (from === undefined || to === undefined) { + continue; + } + yield* tmux.run(["swap-pane", "-d", "-s", from.id, "-t", to.id]); + placed[swap.to] = from; + placed[swap.from] = to; + } + + for (const [ordinal, id] of paneIds.entries()) { + yield* tmux.run([ + "select-pane", + "-t", + id, + "-T", + request.titles[ordinal] ?? `pane ${ordinal}`, + ]); + } + const panes = yield* readPanes(tmux, target, paneIds); + + // The control client. `-f no-output` is what keeps pane bytes out of this + // process: what arrives is the server's own account of its clients. + const events: ControlEvent[] = []; + yield* spawn(function* () { + const [program = "tmux", ...argv] = tmux.argv([ + "-C", + "attach-session", + "-f", + "no-output", + "-t", + request.session, + ]); + const client = yield* exec(program, { arguments: argv, env: request.env }); + const reported = yield* lines()(client.stdout); + let next = yield* reported.next(); + while (!next.done) { + events.push(classify(next.value)); + next = yield* reported.next(); + } + // EOF on the control channel is its own event, and is not a detach. + events.push({ kind: "closed" }); + }); + + yield* provide({ + panes, + events, + *geometry() { + return (yield* readPanes(tmux, target, paneIds)).map((pane) => pane.cell); + }, + *attach() { + const child = yield* usePaneChild( + { + argv: tmux.argv(["attach-session", "-t", request.session]), + cwd: request.cwd, + env: request.env, + }, + // No terminal sweep for this one. Its terminal is the reader's, and + // the processes holding it are the run itself. + undefined, + ); + const started = yield* child.started; + if (!started.ok) { + throw started.error; + } + const name = yield* awaitClient(tmux); + return { child, name }; + }, + *detach(client) { + // Asked to leave before being signalled: a client that detaches + // restores the terminal itself, and one that is killed cannot. + yield* tmux.tryRun(["detach-client", "-t", client.name]); + const deadline = Date.now() + DETACH_LIMIT_MS; + while (Date.now() < deadline) { + if (!(yield* clientNames(tmux)).includes(client.name)) { + break; + } + yield* sleep(CLIENT_POLL_MS); + } + // Whatever the client did about it, the process is this scope's. + yield* client.child.settle(); + }, + stop, + }); + }); +} + +/** `%3` → `3`, which is what a layout string names a pane by. */ +function paneNumber(id: string): number { + return Number(id.replace(/^%/, "")); +} + +function byPosition(left: TmuxPane, right: TmuxPane): number { + return left.cell.top - right.cell.top || left.cell.left - right.cell.left; +} + +/** Every pane the window holds now, in the order `paneIds` names them. */ +function* readPanes( + tmux: Tmux, + target: string, + paneIds: readonly string[], +): Operation { + const listed = yield* tmux.run([ + "list-panes", + "-t", + target, + "-F", + "#{pane_id} #{pane_tty} #{pane_pid} #{pane_left} #{pane_top} #{pane_width} #{pane_height}", + ]); + const found = new Map(); + for (const line of listed.split("\n")) { + const [id, tty, pid, left, top, paneWidth, paneHeight] = line.trim().split(/\s+/); + if (id === undefined || tty === undefined || pid === undefined) { + continue; + } + found.set(id, { + ordinal: paneIds.indexOf(id), + id, + // The worker reports `ttys003`; tmux reports `/dev/ttys003`. + tty: tty.replace(/^\/dev\//, ""), + pid: Number(pid), + cell: { + ordinal: paneIds.indexOf(id), + left: Number(left), + top: Number(top), + width: Number(paneWidth), + height: Number(paneHeight), + }, + }); + } + const panes: TmuxPane[] = []; + for (const id of paneIds) { + const pane = found.get(id); + if (pane !== undefined) { + panes.push(pane); + } + } + return panes; +} + +/** The pane with the most room, which is where the next split goes. */ +function* largestPane( + tmux: Tmux, + target: string, +): Operation<{ id: string; width: number; height: number }> { + const listed = yield* tmux.run([ + "list-panes", + "-t", + target, + "-F", + "#{pane_id} #{pane_width} #{pane_height}", + ]); + let best: { id: string; width: number; height: number } | undefined; + for (const line of listed.split("\n")) { + const [id, width, height] = line.trim().split(/\s+/); + if (id === undefined || width === undefined || height === undefined) { + continue; + } + const pane = { id, width: Number(width), height: Number(height) }; + if (best === undefined || pane.width * pane.height > best.width * best.height) { + best = pane; + } + } + if (best === undefined) { + throw new Error("this grid's window holds no panes"); + } + return best; +} + +function* clientNames(tmux: Tmux): Operation { + const listed = yield* tmux.tryRun(["list-clients", "-F", "#{client_name}"]); + return listed === undefined || listed.length === 0 ? [] : listed.split("\n"); +} + +/** The client that just attached, once the server lists one it did not have. */ +function* awaitClient(tmux: Tmux): Operation { + const deadline = Date.now() + DETACH_LIMIT_MS * 5; + while (Date.now() < deadline) { + const names = yield* clientNames(tmux); + // The control client attaches with no tty of its own, so a named client is + // the visible one. + const visible = names.filter((name) => name.length > 0 && name !== "(none)"); + const found = visible.at(-1); + if (found !== undefined) { + return found; + } + yield* sleep(CLIENT_POLL_MS); + } + throw new Error("the grid was shown, but the server never listed a client for it"); +} + +/** One control-mode line, as the lifecycle event it reports. */ +export function classify(line: string): ControlEvent { + if (line.startsWith("%client-detached")) { + return { kind: "client-detached", client: line.split(/\s+/)[1] ?? "" }; + } + if (line.startsWith("%client-session-changed") || line.startsWith("%client-attached")) { + return { kind: "client-attached", client: line.split(/\s+/)[1] ?? "" }; + } + if (line.startsWith("%sessions-changed")) { + return { kind: "sessions-changed" }; + } + if (line.startsWith("%layout-change")) { + return { kind: "layout-change" }; + } + if (line.startsWith("%exit")) { + return { kind: "exit" }; + } + return { kind: "other", line }; +} diff --git a/packages/cli/src/terminal/tmux.ts b/packages/cli/src/terminal/tmux.ts new file mode 100644 index 00000000..2ea03a36 --- /dev/null +++ b/packages/cli/src/terminal/tmux.ts @@ -0,0 +1,154 @@ +/** + * The tmux command surface, and what a host must have before a grid is opened + * (architecture.md §Interactive terminal grids). + * + * Everything tmux is ever told goes through here, which is what makes tmux + * substitutable: a grid is built against this interface, so the lifecycle can + * be exercised without a tmux on the machine and without a terminal to draw on. + * + * The server is private to one grid. `-S ` puts it on a socket inside + * the invocation's own directory rather than the user's default one, and + * `-f /dev/null` means the reader's `.tmux.conf` cannot change what a document + * asked for — a grid is the author's layout, not the reader's configuration. + * + * Prerequisites are checked before anything is created. A host with no terminal + * or no usable tmux refuses while there is still nothing to undo: no server, no + * worker, no socket, no token, and no change to the reader's terminal. + */ + +import { exec } from "@effectionx/process"; +import { Err, Ok } from "effection"; +import type { Operation, Result } from "effection"; + +/** One private tmux server, addressed by its socket. */ +export interface Tmux { + readonly socket: string; + /** Run one command; its trimmed stdout, or a failure. */ + run(args: readonly string[]): Operation; + /** The same, answering `undefined` instead of throwing. */ + tryRun(args: readonly string[]): Operation; + /** + * The whole command vector for a client this grid starts itself. + * + * Attaching is not a command that returns; it is a process that runs. It goes + * through this seam anyway, so that everything tmux is ever told is said in + * one place — and so a grid's lifecycle can be exercised against something + * other than tmux. + */ + argv(args: readonly string[]): readonly string[]; +} + +export class TmuxCommandFailed extends Error { + override name = "TmuxCommandFailed"; + constructor(args: readonly string[], stderr: string, code: number | undefined) { + // The command, not the socket: a diagnostic names what was asked for and + // never where this invocation's private server lives. + super(`tmux ${args.join(" ")} failed (${code ?? "signal"}): ${stderr.trim()}`); + } +} + +export const TMUX_UNAVAILABLE = + "this host cannot open a terminal grid: it needs a terminal and a tmux that " + + "supports one. Run xmd from a terminal on a host with tmux 3.0 or newer, or " + + "use a host that installs its own terminal provider."; + +export class TmuxUnavailableError extends Error { + override name = "TmuxUnavailableError"; + constructor(readonly reason: string) { + super(`${TMUX_UNAVAILABLE} (${reason})`); + } +} + +/** Talk to the private server on `socket`. */ +export function tmuxAt(socket: string, env: Record): Tmux { + // `-f /dev/null`: the reader's configuration does not get to redecide an + // authored layout, a pane's border, or what a key does to the child. + const base = ["-S", socket, "-f", "/dev/null"]; + return { + socket, + argv: (args) => ["tmux", ...base, ...args], + *run(args) { + const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); + if (result.code !== 0) { + throw new TmuxCommandFailed(args, result.stderr, result.code); + } + return result.stdout.trim(); + }, + *tryRun(args) { + const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); + return result.code === 0 ? result.stdout.trim() : undefined; + }, + }; +} + +/** The oldest tmux whose layout strings and control mode behave as required. */ +const REQUIRED_TMUX = { major: 3, minor: 0 }; + +/** + * Whether this host can present a grid, and why not when it cannot. + * + * Answered before a server exists. Two facts, both of them the host's: there is + * a terminal to divide, and there is a tmux new enough to divide it the way an + * authored layout needs. + */ +export function* probeTmux(options: { + readonly isTerminal: () => boolean; + readonly env: Record; +}): Operation> { + if (!options.isTerminal()) { + return Err(new TmuxUnavailableError("this invocation has no terminal")); + } + const result = yield* exec("tmux", { arguments: ["-V"], env: options.env }).join(); + if (result.code !== 0) { + return Err(new TmuxUnavailableError("tmux is not installed or would not run")); + } + const version = result.stdout.trim(); + const parsed = readVersion(version); + if (parsed === undefined) { + return Err(new TmuxUnavailableError(`tmux did not report a version (${version})`)); + } + if ( + parsed.major < REQUIRED_TMUX.major || + (parsed.major === REQUIRED_TMUX.major && parsed.minor < REQUIRED_TMUX.minor) + ) { + return Err( + new TmuxUnavailableError( + `${version} is older than tmux ${REQUIRED_TMUX.major}.${REQUIRED_TMUX.minor}`, + ), + ); + } + return Ok(version); +} + +/** `tmux 3.6a` and `tmux next-3.7` alike, read to a major and a minor. */ +function readVersion(reported: string): { major: number; minor: number } | undefined { + const match = /(\d+)\.(\d+)/.exec(reported); + if (match === null) { + return undefined; + } + const [, major, minor] = match; + if (major === undefined || minor === undefined) { + return undefined; + } + return { major: Number(major), minor: Number(minor) }; +} + +/** + * The environment every process in the topology receives. + * + * Named rather than inherited wholesale: a pane's child gets what a terminal + * program needs and nothing this process happens to be carrying. + */ +export function paneEnvironment( + source: Record, +): Record { + const env: Record = {}; + for (const name of ["PATH", "HOME", "SHELL", "LANG", "TMPDIR", "USER", "LOGNAME"]) { + const value = source[name]; + if (value !== undefined && value !== "") { + env[name] = value; + } + } + env.TERM = source.TERM ?? "xterm-256color"; + return env; +} diff --git a/packages/cli/tests/fixtures/fake-tmux.ts b/packages/cli/tests/fixtures/fake-tmux.ts new file mode 100644 index 00000000..f0824a43 --- /dev/null +++ b/packages/cli/tests/fixtures/fake-tmux.ts @@ -0,0 +1,287 @@ +/** + * A tmux server, modelled well enough to hold the composite to its contract. + * + * What matters here is the behaviour the production code exists to work + * around, so the fake reproduces it deliberately: + * + * - **a layout string's leaves are filled in window-list order, and the pane + * ids written in them are ignored.** This is why authored order is imposed by + * swaps rather than by describing it, and a fake that honoured the ids would + * make the swap logic untestable and unnecessary-looking; + * - `kill-server` leaves the socket file behind, so "gone" cannot be the file's + * absence; + * - `detach-client` removes a client and lets its process leave, while + * `kill-server` ends everything at once. + * + * The server's own liveness is a number this fake owns, and the composite asks + * the runtime's process seam about it — so a test can say "the server did not + * go away" without there being a process to refuse to die. + */ + +import { appendFile } from "node:fs/promises"; +import { until } from "effection"; +import type { Operation } from "effection"; +import type { Tmux } from "../../src/terminal/tmux.ts"; + +export interface FakePane { + id: string; + tty: string; + pid: number; + left: number; + top: number; + width: number; + height: number; + title: string; + /** The command the pane was created with, so a test can read it back. */ + command: readonly string[]; +} + +export interface FakeTmuxOptions { + /** The window's size, which the layout is computed against. */ + readonly width?: number; + readonly height?: number; + /** Where client fixtures read what the server did. */ + readonly script: string; + /** The program a client fixture runs. */ + readonly clientCommand: (mode: "control" | "attach", script: string) => readonly string[]; + /** Fail this command once, with this message. */ + readonly failOnce?: { readonly command: string; readonly message: string }; +} + +export interface FakeTmux extends Tmux { + /** Every command the composite issued, in order, as one string each. */ + readonly issued: readonly string[]; + readonly panes: readonly FakePane[]; + /** The server pid the composite will ask the process seam about. */ + readonly serverPid: number; + readonly alive: () => boolean; + readonly clients: readonly string[]; + /** Say something on the control channel, as the server would. */ + say(line: string): Operation; +} + +/** Cells a layout string describes, in the order it lists them. */ +function readLayoutCells( + layout: string, +): { left: number; top: number; width: number; height: number }[] { + const cells: { left: number; top: number; width: number; height: number }[] = []; + const leaf = /(\d+)x(\d+),(\d+),(\d+),(\d+)(?![\dx])/g; + let match = leaf.exec(layout); + while (match !== null) { + const [, width, height, left, top] = match; + cells.push({ + left: Number(left), + top: Number(top), + width: Number(width), + height: Number(height), + }); + match = leaf.exec(layout); + } + return cells; +} + +export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { + const width = options.width ?? 160; + const height = options.height ?? 48; + const issued: string[] = []; + /** Window-list order — the order panes were created, which tmux fills by. */ + const panes: FakePane[] = []; + const clients: string[] = []; + let alive = false; + let nextPane = 0; + let nextPid = 4000; + const serverPid = 3999; + let failed = false; + + function pane(id: string): FakePane | undefined { + return panes.find((candidate) => candidate.id === id); + } + + /** + * Create a pane, and put it in the window list where tmux would. + * + * A split inserts the new pane *immediately after the one it split*, not at + * the end. That is what makes window-list order differ from creation order + * once panes are split by size rather than in sequence — and therefore what + * makes the authored order need imposing. + */ + function create(command: readonly string[], after?: string): FakePane { + const created: FakePane = { + id: `%${nextPane++}`, + tty: `ttys90${nextPane}`, + pid: nextPid++, + left: 0, + top: 0, + width, + height, + title: "", + command, + }; + const at = after === undefined ? -1 : panes.findIndex((entry) => entry.id === after); + if (at < 0) { + panes.push(created); + } else { + panes.splice(at + 1, 0, created); + } + return created; + } + + /** The pane command trailing one tmux invocation, after its last flag. */ + function trailing(args: readonly string[], lastFlagValue: string): readonly string[] { + const at = args.lastIndexOf(lastFlagValue); + return at < 0 ? [] : args.slice(at + 1); + } + + function* answer(args: readonly string[]): Operation { + issued.push(args.join(" ")); + const [command] = args; + if (options.failOnce !== undefined && !failed && command === options.failOnce.command) { + failed = true; + return undefined; + } + if (command !== "kill-server" && command !== "new-session" && !alive) { + // Every other command needs a server. + return undefined; + } + switch (command) { + case "new-session": { + alive = true; + // `... -c ` + const cwd = args[args.indexOf("-c") + 1] ?? ""; + create(trailing(args, cwd)); + return ""; + } + case "display": { + const format = args.at(-1) ?? ""; + if (format === "#{pid}") { + return String(serverPid); + } + if (format === "#{pane_id}") { + return panes[0]?.id ?? ""; + } + if (format === "#{window_width} #{window_height}") { + return `${width} ${height}`; + } + return ""; + } + case "set": + return ""; + case "split-window": { + // `... -t -c -P -F #{pane_id} ` + const target = args[args.indexOf("-t") + 1]; + return create(trailing(args, "#{pane_id}"), target).id; + } + case "list-panes": { + const format = args.at(-1) ?? ""; + return panes + .map((entry) => + format.includes("pane_tty") + ? `${entry.id} /dev/${entry.tty} ${entry.pid} ${entry.left} ${entry.top} ` + + `${entry.width} ${entry.height}` + : `${entry.id} ${entry.width} ${entry.height}`, + ) + .join("\n"); + } + case "select-layout": { + // The behaviour the swaps exist for: cells go to panes in window-list + // order, and the ids the string names are ignored. + const cells = readLayoutCells(args.at(-1) ?? ""); + for (const [index, entry] of panes.entries()) { + const cell = cells[index]; + if (cell !== undefined) { + entry.left = cell.left; + entry.top = cell.top; + entry.width = cell.width; + entry.height = cell.height; + } + } + return ""; + } + case "swap-pane": { + const source = pane(args[args.indexOf("-s") + 1] ?? ""); + const target = pane(args[args.indexOf("-t") + 1] ?? ""); + if (source === undefined || target === undefined) { + return undefined; + } + // Panes exchange positions; the cells stay where they are. + const held = { + left: source.left, + top: source.top, + width: source.width, + height: source.height, + }; + source.left = target.left; + source.top = target.top; + source.width = target.width; + source.height = target.height; + target.left = held.left; + target.top = held.top; + target.width = held.width; + target.height = held.height; + return ""; + } + case "select-pane": { + const found = pane(args[args.indexOf("-t") + 1] ?? ""); + if (found === undefined) { + return undefined; + } + found.title = args[args.indexOf("-T") + 1] ?? ""; + return ""; + } + case "list-clients": + return clients.join("\n"); + case "detach-client": { + const name = args[args.indexOf("-t") + 1] ?? ""; + const at = clients.indexOf(name); + if (at >= 0) { + clients.splice(at, 1); + } + yield* until(appendFile(options.script, "detached\n")); + yield* until(appendFile(options.script, `%client-detached ${name}\n`)); + return ""; + } + case "has-session": + return alive ? "" : undefined; + case "kill-server": { + if (alive) { + alive = false; + yield* until(appendFile(options.script, "detached\n%exit\n")); + } + clients.length = 0; + return ""; + } + default: + return ""; + } + } + + return { + socket: "/fake/socket", + issued, + panes, + serverPid, + alive: () => alive, + clients, + argv(args) { + const mode = args.includes("-C") ? "control" : "attach"; + if (mode === "attach") { + // A visible client the server can list, named the way tmux names one. + clients.push("/dev/ttys999"); + } + return options.clientCommand(mode, options.script); + }, + *say(line) { + yield* until(appendFile(options.script, `${line}\n`)); + }, + *run(args) { + const answered = yield* answer(args); + if (answered === undefined) { + throw new Error(`fake tmux refused: ${args.join(" ")}`); + } + return answered; + }, + *tryRun(args) { + return yield* answer(args); + }, + }; +} diff --git a/packages/cli/tests/fixtures/tmux-client.ts b/packages/cli/tests/fixtures/tmux-client.ts new file mode 100644 index 00000000..34bf8eb0 --- /dev/null +++ b/packages/cli/tests/fixtures/tmux-client.ts @@ -0,0 +1,61 @@ +/** + * A stand-in for one tmux client, so a grid's lifecycle can be exercised + * without tmux. + * + * Two modes, because the composite keeps two clients apart and a test that + * conflated them would prove nothing about the distinction: + * + * - `control` writes lines to stdout as they appear in the script file, and + * ends at `%exit`. The composite reads it through the same line splitting and + * the same classifier it uses on real control mode, so what is faked is the + * server, never the parsing. + * - `attach` holds the terminal, and leaves when the script file says it was + * detached. It writes nothing. + * + * The script file is how a test says what the server did. Appending to it is + * the fake server's way of speaking, and polling it is this program's; neither + * is a claim about how tmux does it. + * + * Terminal restoration is deliberately outside this: a process that inherits a + * pipe cannot restore a terminal it never had. That a real `tmux attach` gives + * the terminal back when asked to detach is #726's evidence, on real tmux. + */ + +import { readFile } from "node:fs/promises"; +import process from "node:process"; + +const POLL_MS = 15; + +const [mode, script] = process.argv.slice(2); +if ((mode !== "control" && mode !== "attach") || script === undefined) { + process.stderr.write("usage: tmux-client.ts \n"); + process.exit(2); +} + +/** Everything the script says so far, or nothing while it does not exist. */ +async function read(): Promise { + try { + const text = await readFile(script, "utf8"); + return text.split("\n").filter((line) => line.length > 0); + } catch { + return []; + } +} + +let seen = 0; +for (;;) { + const said = await read(); + for (const line of said.slice(seen)) { + if (mode === "control") { + process.stdout.write(`${line}\n`); + if (line.startsWith("%exit")) { + process.exit(0); + } + } else if (line === "detached") { + // The reader left. A real client would restore the terminal here. + process.exit(0); + } + } + seen = said.length; + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); +} diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index c23c7f3b..7e0de082 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -24,6 +24,14 @@ import net from "node:net"; import { stat } from "node:fs/promises"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { rm, writeFile } from "node:fs/promises"; +import { TerminalProcesses } from "@executablemd/runtime"; +import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; +import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; +import { createFakeTmux } from "./fixtures/fake-tmux.ts"; +import type { FakeTmux } from "./fixtures/fake-tmux.ts"; import { layoutString, placementProblems, @@ -485,3 +493,321 @@ describe("Tier TW — the pane worker and its private channel", () => { } }); }); + +/** + * Tier TG — the hidden composite's lifecycle + * (architecture.md §Atomic presentation and settlement). + * + * Against a fake server, deliberately. What is faked is tmux's *behaviour* — + * including the one this code exists to work around, that a layout string's + * leaves are filled in window-list order and the pane ids in them are ignored. + * What is not faked is the composite: the same layout string, the same swap + * decisions, the same control-mode line splitting and the same classifier run + * here as on a real server. + * + * One thing this tier deliberately does not claim. A client fixture inherits a + * pipe, so it cannot restore a terminal it never had; that a real `tmux attach` + * gives the reader's terminal back when asked to detach is #726's evidence, on + * real tmux, and nothing here stands in for it. + */ +describe("Tier TG — the tmux composite", () => { + /** Where a fake server and its client fixtures meet. */ + function useScript(): Operation { + return resource(function* (provide) { + const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); + yield* until(writeFile(file, "")); + yield* ensure(function* () { + yield* until(rm(file, { force: true })); + }); + yield* provide(file); + }); + } + + /** The fixture that stands in for one tmux client. */ + function clientCommand(mode: "control" | "attach", script: string): readonly string[] { + const fixture = path.resolve("packages/cli/tests/fixtures/tmux-client.ts"); + const invocation = cliCommand([]); + // The same runtime the CLI runs under, pointed at the fixture instead. + return [invocation.command, "run", "--allow-all", fixture, mode, script]; + } + + /** A composite over a fake server, with the pane workers stubbed out. */ + function useComposite(options: { + panes: number; + columns: number; + titles?: string[]; + failOnce?: { command: string; message: string }; + }): Operation<{ grid: TmuxGrid; tmux: FakeTmux; script: string }> { + return (function* () { + const script = yield* useScript(); + const tmux = createFakeTmux({ + script, + clientCommand, + ...(options.failOnce === undefined ? {} : { failOnce: options.failOnce }), + }); + // The server's liveness is the fake's to decide, and the composite asks + // the runtime seam about it — so "the server did not go away" is a fact a + // row can state without a process refusing to die. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver() { + return "absent" as const; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + return pid === tmux.serverPid && tmux.alive(); + }, + }, + { at: "min" }, + ); + const grid = yield* useTmuxGrid(tmux, { + session: "grid", + columns: options.columns, + panes: options.panes, + width: 160, + height: 48, + titles: + options.titles ?? Array.from({ length: options.panes }, (_, index) => `pane ${index}`), + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/private/dir"], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + return { grid, tmux, script }; + })(); + } + + it("TG1: the server is private, unconfigured, and started hidden", function* () { + const { tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + // Detached, so nothing is shown; sized explicitly, so the layout is + // computed against a window rather than a guess. + const created = tmux.issued.find((line) => line.startsWith("new-session")); + expect(created).toContain("-d"); + expect(created).toContain("-x 160"); + expect(created).toContain("-y 48"); + // Every pane runs a worker, and tmux's parser sees only an ordinal and a + // directory — never a launch's argv. + expect(tmux.panes.length).toBe(2); + for (const [ordinal, pane] of tmux.panes.entries()) { + expect(pane.command.join(" ")).toBe(`xmd terminal-worker ${ordinal} /private/dir`); + } + }); + + it("TG2: the authored order survives a server that ignores the layout's ids", function* () { + const { grid, tmux } = yield* useComposite({ + panes: 4, + columns: 2, + titles: ["Planner", "Implementor", "Reviewer", "Shell"], + }); + + // The fake fills the leaves in window-list order and ignores the ids, which + // is what tmux does. Without the swaps this would be the wrong order. + expect(tmux.issued.some((line) => line.startsWith("swap-pane"))).toBe(true); + const placed = [...grid.panes].sort( + (left, right) => left.cell.top - right.cell.top || left.cell.left - right.cell.left, + ); + expect(placed.map((pane) => pane.ordinal)).toEqual([0, 1, 2, 3]); + expect( + placementProblems( + placed.map((pane) => pane.cell), + 2, + ), + ).toEqual([]); + // And each pane carries the title the author wrote for that ordinal. Read + // by pane id, because the server's window list is not the authored order — + // which is the whole reason the swaps above exist. + const titles = grid.panes.map( + (pane) => tmux.panes.find((entry) => entry.id === pane.id)?.title, + ); + expect(titles).toEqual(["Planner", "Implementor", "Reviewer", "Shell"]); + // The window list really is a different order, so this row is not passing + // because the two happened to coincide. + expect(tmux.panes.map((pane) => pane.id)).not.toEqual(grid.panes.map((pane) => pane.id)); + }); + + it("TG3: nothing is attached while the composite is being built", function* () { + const { tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + // The control client is not the reader's: it attaches with `-f no-output`, + // so pane bytes never reach this process. The visible one has not been + // asked for. + expect(tmux.issued.some((line) => line.startsWith("attach-session"))).toBe(false); + expect(tmux.clients).toEqual([]); + }); + + it("TG4: attaching shows the grid, and the server lists the reader's client", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + const client = yield* grid.attach(); + expect(client.name).toBe("/dev/ttys999"); + expect(tmux.clients).toContain("/dev/ttys999"); + }); + + it("TG5: a reader detach is asked for before anything is signalled", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + const client = yield* grid.attach(); + + yield* grid.detach(client); + + // Asked to leave, and gone from the server's list. A client that was + // signalled instead could not have restored the terminal — which is why + // the ask comes first. + const asked = tmux.issued.findIndex((line) => line.startsWith("detach-client")); + expect(asked).toBeGreaterThan(-1); + expect(tmux.clients).not.toContain("/dev/ttys999"); + expect(tmux.issued.slice(0, asked).some((line) => line.startsWith("kill-server"))).toBe(false); + }); + + it("TG6: reader detach, control loss and server stop are separate events", function* () { + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + yield* tmux.say("%sessions-changed"); + yield* untilEvent(grid, "sessions-changed"); + // `%exit` ends the control channel, and its EOF is its own event — an + // attach client's exit code could not tell these three apart. + yield* tmux.say("%exit"); + yield* untilEvent(grid, "closed"); + + const kinds = grid.events.map((event) => event.kind); + expect(kinds).toContain("client-detached"); + expect(kinds).toContain("sessions-changed"); + expect(kinds.indexOf("exit")).toBeLessThan(kinds.lastIndexOf("closed")); + }); + + it("TG7: stopping establishes the server is gone and refuses its session", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + const stopped = yield* grid.stop(); + expect(stopped.gone).toBe(true); + expect(stopped.refuses).toBe(true); + expect(tmux.alive()).toBe(false); + }); + + it("TG8: a server that will not go away is not reported gone", function* () { + const script = yield* useScript(); + const tmux = createFakeTmux({ script, clientCommand }); + // The server answers `kill-server` and stays anyway. Nothing about the + // command having been accepted is evidence that it worked. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver() { + return "delivered" as const; + }, + // deno-lint-ignore require-yield + *reachable() { + return true; + }, + }, + { at: "min" }, + ); + const grid = yield* useTmuxGrid(tmux, { + session: "grid", + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: ["only"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/private/dir"], + cwd: path.resolve("."), + env: {}, + }); + + const stopped = yield* grid.stop(); + expect(stopped.gone).toBe(false); + }); + + it("TG9: a composite that fails while being built still takes the server down", function* () { + const script = yield* useScript(); + let stopping = 0; + const tmux = createFakeTmux({ + script, + clientCommand, + // The split for the second pane fails, half-way through preparation. + failOnce: { command: "split-window", message: "no room" }, + }); + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver() { + return "absent" as const; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); + + let failure = ""; + try { + yield* scoped(function* () { + yield* useTmuxGrid(tmux, { + session: "grid", + columns: 2, + panes: 2, + width: 160, + height: 48, + titles: ["a", "b"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/d"], + cwd: path.resolve("."), + env: {}, + }); + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + expect(failure).toContain("split-window"); + // Registered before the first command, so a half-built composite is still + // taken down: no server is left behind for a grid nobody ever saw. + stopping = tmux.issued.filter((line) => line.startsWith("kill-server")).length; + expect(stopping).toBeGreaterThan(0); + expect(tmux.alive()).toBe(false); + }); +}); + +/** Wait until the composite has classified an event of this kind. */ +function untilEvent(grid: TmuxGrid, kind: ControlEvent["kind"]): Operation { + return (function* (): Operation { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (grid.events.some((event) => event.kind === kind)) { + return; + } + yield* sleep(15); + } + throw new Error( + `the composite never reported "${kind}"; it reported ` + + JSON.stringify(grid.events.map((event) => event.kind)), + ); + })(); +} From 435cfa17da6b1895c01f12cd11d93a4bc090a0b5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 05:53:19 -0400 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=90=9B=20Narrow=20the=20visible=20c?= =?UTF-8?q?lient,=20and=20make=20teardown=20prove=20itself=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The visible client is not a pane child.** A pane child is settled by sweeping its process group and its terminal, because a pane's terminal belongs to the grid. The reader's terminal belongs to the run: everything holding it is XMD, whatever started XMD, and the rest of XMD's foreground group. A settlement of that shape aimed at the attach client is a settlement aimed at the document. `attach-client.ts` owns exactly one process instead — asked to detach first, through tmux, and only then insisted on by pid, with no group, no descendants and no terminal sweep anywhere in it. **A successful `kill-server` is not proof.** Teardown now succeeds only once the recorded server pid is unreachable and the server refuses its own session, and throws a provider-neutral `TerminalTeardownFailed` when either is still unproved at the bound. The rule is in the resource finalizer too, so a preparation that failed halfway is held to it as well. **Nothing private in a diagnostic.** `TmuxCommandFailed` carries the step's name and nothing else — not the arguments, which hold the socket path, session name, pane and client identifiers and the worker's private directory, and not stderr, which tmux writes paths into. A provider's topology stays private on the paths taken when something goes wrong, which are the paths a diagnostic is read on. **Closures before removal.** The private directory is removed only after every accepted socket and every listening server has actually closed — counted from their own `close` events rather than from having been asked. Three regressions, each broken on purpose and re-run: - TG11 gives the process table company — XMD, its parent, two more in the same group, and four holders of the reader's terminal — and proves the escalation reaches the client's pid alone. Settling it like a pane child fails it. - TG10 plants markers in the socket, session, pane and client identifiers, the worker directory, the arguments and stderr, and proves none reaches the surfaced error. Restoring raw arguments fails it. - TG12 counts real closures at the moment of removal. Not awaiting them fails it. Also conformed to the repository's rules: `@effectionx/fs` for stat, rm, readTextFile and writeTextFile, with `node:fs/promises` kept only for `chmod` and `appendFile`, both adapted through `until`; the client fixture is an Effection operation; and the newly introduced `as const` assertions are gone in favour of typed values. --- packages/cli/src/terminal/attach-client.ts | 142 +++++++++ packages/cli/src/terminal/pane-channel.ts | 69 ++++- packages/cli/src/terminal/pane-child.ts | 8 +- packages/cli/src/terminal/pane-worker.ts | 3 +- packages/cli/src/terminal/tmux-grid.ts | 87 +++--- packages/cli/src/terminal/tmux.ts | 35 ++- packages/cli/tests/fixtures/fake-tmux.ts | 20 +- packages/cli/tests/fixtures/tmux-client.ts | 61 ++-- packages/cli/tests/terminal-grid-tmux.test.ts | 284 +++++++++++++++--- 9 files changed, 588 insertions(+), 121 deletions(-) create mode 100644 packages/cli/src/terminal/attach-client.ts diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/cli/src/terminal/attach-client.ts new file mode 100644 index 00000000..03142592 --- /dev/null +++ b/packages/cli/src/terminal/attach-client.ts @@ -0,0 +1,142 @@ +/** + * The one visible client: the reader's own view of a grid + * (architecture.md §Interactive terminal grids). + * + * Deliberately *not* a pane child. A pane's child is settled by sweeping the + * pane's process group and the pane's terminal, because a pane's terminal + * belongs to the grid. This process's terminal belongs to the run: the things + * holding it are XMD itself, whatever started XMD, and everything else in XMD's + * foreground process group. A settlement of that shape pointed at this client + * would be a settlement pointed at the document. + * + * So the rule here is narrow and absolute. This ends **one** process — the exact + * one it started — and nothing else. It signals no group, sweeps no terminal, + * and follows no descendants. Ending it is asked for first, through tmux, so + * the client detaches and restores the terminal itself; a signal is what + * follows only if the ask did not work, and it goes to that pid alone. + */ + +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { ensure, race, resource, sleep, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { deliverSignal, processReachable } from "@executablemd/runtime"; + +export interface AttachClient { + /** The client process, once the runtime says it started. */ + readonly pid: number; + /** Settles when it leaves, however it leaves. */ + readonly exited: Operation; + /** + * End it: ask first, then insist on this pid alone. + * + * Idempotent, and safe to call from a finalizer — a client that already left + * is the outcome this was asking for. + */ + stop(): Operation; +} + +const INTERRUPT_GRACE_MS = 2_000; +const KILL_SETTLE_MS = 500; +const POLL_MS = 25; + +/** + * Start the visible client, and own exactly its lifetime. + * + * `askToLeave` is the provider's way of telling tmux to detach this client. It + * runs before any signal, because a client asked to detach restores the + * terminal and one that is killed cannot. + */ +export function useAttachClient(options: { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Record; + askToLeave(): Operation; +}): Operation { + return resource(function* (provide) { + const [command, ...args] = options.argv; + if (command === undefined) { + throw new Error("the visible client names no command"); + } + const started = withResolvers(); + const failed = withResolvers(); + const exited = withResolvers(); + let gone = false; + let child: ChildProcess | undefined; + let stopping: ReturnType> | undefined; + + function* stop(): Operation { + if (stopping) { + return yield* stopping.operation; + } + stopping = withResolvers(); + try { + yield* end(); + stopping.resolve(); + } catch (error) { + stopping.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + function* end(): Operation { + const pid = child?.pid; + if (gone || pid === undefined) { + return; + } + // Asked, not told. This is the only path that gives the reader their + // terminal back in the state they lent it. + yield* options.askToLeave(); + if (yield* leftWithin(INTERRUPT_GRACE_MS, pid)) { + return; + } + // It did not leave. From here the escalation names this one pid and + // nothing else: no process group, no terminal holders, no descendants — + // every one of which would, on this terminal, be the run itself. + yield* deliverSignal(pid, "SIGTERM"); + if (yield* leftWithin(INTERRUPT_GRACE_MS, pid)) { + return; + } + yield* deliverSignal(pid, "SIGKILL"); + yield* leftWithin(KILL_SETTLE_MS, pid); + } + + function* leftWithin(limitMs: number, pid: number): Operation { + const deadline = Date.now() + limitMs; + while (Date.now() < deadline) { + if (gone || !(yield* processReachable(pid))) { + return true; + } + yield* sleep(POLL_MS); + } + return gone; + } + + // Registered before the spawn: a halt between starting a client and + // registering its cleanup would leave it holding the terminal. + yield* ensure(function* () { + yield* stop(); + }); + + child = spawnChild(command, args, { + cwd: options.cwd, + env: options.env, + // The reader's terminal, handed straight through. + stdio: "inherit", + }); + child.once("spawn", () => { + if (child?.pid !== undefined) { + started.resolve(child.pid); + } + }); + child.once("error", (error: Error) => failed.reject(error)); + child.once("exit", () => { + gone = true; + exited.resolve(); + }); + + // The pid, or whatever arrived instead of a start. + const pid = yield* race([started.operation, failed.operation]); + yield* provide({ pid, exited: exited.operation, stop }); + }); +} diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 77ac3eee..42c4871f 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -80,7 +80,20 @@ interface Slot { * temporary directory is world-writable still gets a private grid, because the * mode is set on the directory this creates rather than inherited from it. */ -export function usePaneChannels(count: number): Operation { +export function usePaneChannels( + count: number, + options: { + onClosed?: () => void; + /** + * Called as the directory is removed, with how many of the sockets and + * servers had actually reported closing by then. + * + * Counted from their own `close` events rather than from having asked, so a + * caller can tell "closed" from "told to close". + */ + onRemoved?: (facts: { closed: number; total: number }) => void; + } = {}, +): Operation { return resource(function* (provide) { // Directly under `$TMPDIR`: a socket path is capped at 104 bytes, and a // directory named after a repository path spends most of that before the @@ -88,7 +101,13 @@ export function usePaneChannels(count: number): Operation { const directory = path.join(os.tmpdir(), `xmd-grid-${randomBytes(6).toString("hex")}`); yield* ensureDir(directory); yield* until(chmod(directory, 0o700)); - yield* ensure(() => rm(directory, { recursive: true, force: true })); + // Registered first, so it runs last: the directory goes only after every + // socket and server below has actually closed. Removing it while a server + // still listened would leave a socket bound to a path nothing can name. + yield* ensure(function* () { + options.onRemoved?.({ closed: closedCount, total: closable }); + yield* rm(directory, { recursive: true, force: true }); + }); const tokens = new Map(); const slots = new Map(); @@ -96,14 +115,26 @@ export function usePaneChannels(count: number): Operation { const live = new Set(); const refusals: string[] = []; const arrivals = createSignal<{ ordinal: number; socket: Socket }, never>(); + /** Closures that have actually happened, by their own events. */ + let closedCount = 0; + let closable = 0; - yield* ensure(() => { + // Awaited, not asked for. `destroy()` and `close()` are requests; what the + // directory's removal has to wait for is the closures themselves. + yield* ensure(function* () { + const closings: Operation[] = []; for (const socket of live) { + closings.push(closed(socket)); socket.destroy(); } for (const server of servers) { + closings.push(shut(server)); server.close(); } + for (const closing of closings) { + yield* closing; + } + options.onClosed?.(); }); // Subscribed before a single server listens, so no arrival is missed. @@ -118,10 +149,18 @@ export function usePaneChannels(count: number): Operation { const server = net.createServer((socket) => { live.add(socket); - socket.once("close", () => live.delete(socket)); + closable++; + socket.once("close", () => { + live.delete(socket); + closedCount++; + }); arrivals.send({ ordinal, socket }); }); servers.push(server); + closable++; + server.once("close", () => { + closedCount++; + }); const listening = withResolvers(); server.once("error", (error: Error) => listening.reject(error)); server.listen(paneSocketPath(directory, ordinal), () => listening.resolve()); @@ -189,6 +228,28 @@ export function usePaneChannels(count: number): Operation { }); } +/** Settle once this socket has closed, whether or not it already had. */ +function closed(socket: Socket): Operation { + const done = withResolvers(); + if (socket.destroyed) { + done.resolve(); + } else { + socket.once("close", () => done.resolve()); + } + return done.operation; +} + +/** Settle once this server has stopped listening. */ +function shut(server: Server): Operation { + const done = withResolvers(); + if (!server.listening) { + done.resolve(); + } else { + server.once("close", () => done.resolve()); + } + return done.operation; +} + /** A connection that has said nothing for long enough to be nobody. */ function* silence(): Operation> { yield* sleep(HELLO_GRACE_MS); diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts index c0f4f540..b44cca38 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/cli/src/terminal/pane-child.ts @@ -94,9 +94,15 @@ export function usePaneChild( } settling = withResolvers(); try { + const nothingStarted: Settlement = { + method: "exited", + quiet: true, + swept: [], + holders: [], + }; const settlement = child === undefined || child.pid === undefined - ? { method: "exited" as const, quiet: true, swept: [], holders: [] } + ? nothingStarted : yield* escalate(child, child.pid, tty, () => outcome !== undefined); settling.resolve(settlement); return settlement; diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts index 3705862d..e11f1d97 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/cli/src/terminal/pane-worker.ts @@ -112,7 +112,8 @@ interface Live { * dispositions across `exec`, so it receives the same signal and acts on it. */ export function ignoreForegroundSignals(): void { - for (const name of ["SIGINT", "SIGQUIT", "SIGTSTP"] as const) { + const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; + for (const name of foreground) { process.on(name, () => {}); } } diff --git a/packages/cli/src/terminal/tmux-grid.ts b/packages/cli/src/terminal/tmux-grid.ts index 3c39f5e0..61d6d986 100644 --- a/packages/cli/src/terminal/tmux-grid.ts +++ b/packages/cli/src/terminal/tmux-grid.ts @@ -33,8 +33,9 @@ import type { Operation } from "effection"; import { processReachable } from "@executablemd/runtime"; import { layoutString, swapsInto } from "./layout.ts"; import type { LayoutCell } from "./layout.ts"; -import { usePaneChild } from "./pane-child.ts"; -import type { PaneChild } from "./pane-child.ts"; +import { useAttachClient } from "./attach-client.ts"; +import type { AttachClient } from "./attach-client.ts"; +import { TerminalTeardownFailed } from "./tmux.ts"; import type { Tmux } from "./tmux.ts"; /** What one prepared pane is, from the composite's side. */ @@ -80,7 +81,7 @@ export interface ServerStopped { } export interface VisibleClient { - readonly child: PaneChild; + readonly client: AttachClient; /** tmux's name for this client once attached: its tty. */ readonly name: string; } @@ -95,7 +96,13 @@ export interface TmuxGrid { attach(): Operation; /** Ask the visible client to leave, so it restores the terminal itself. */ detach(client: VisibleClient): Operation; - /** Stop the server, and establish that it is gone. */ + /** + * Stop the server, and establish that it is gone. + * + * Refuses rather than reporting: an unproved teardown throws, because a + * document that continued past one would be continuing while a terminal may + * still be held. + */ stop(): Operation; } @@ -116,16 +123,21 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation { yield* tmux.tryRun(["kill-server"]); const deadline = Date.now() + STOP_LIMIT_MS; - let stopped: ServerStopped; + let stopped: ServerStopped = { gone: false, refuses: false }; do { stopped = { gone: serverPid < 0 || !(yield* processReachable(serverPid)), - // The socket file outlives the server, so "gone" is the pid being - // unreachable and nothing answering for the session — never the - // socket file's absence. refuses: (yield* tmux.tryRun(["has-session", "-t", request.session])) === undefined, }; if (stopped.gone && stopped.refuses) { @@ -133,9 +145,19 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation pane.cell); }, *attach() { - const child = yield* usePaneChild( - { - argv: tmux.argv(["attach-session", "-t", request.session]), - cwd: request.cwd, - env: request.env, + // Its own lifecycle, not a pane child's. A pane child is settled by + // sweeping its process group and its terminal; this client's terminal + // is the reader's, and everything holding it is the run. + let named: string | undefined; + const client = yield* useAttachClient({ + argv: tmux.argv(["attach-session", "-t", request.session]), + cwd: request.cwd, + env: request.env, + *askToLeave() { + if (named === undefined) { + return; + } + yield* tmux.tryRun(["detach-client", "-t", named]); }, - // No terminal sweep for this one. Its terminal is the reader's, and - // the processes holding it are the run itself. - undefined, - ); - const started = yield* child.started; - if (!started.ok) { - throw started.error; - } - const name = yield* awaitClient(tmux); - return { child, name }; + }); + named = yield* awaitClient(tmux); + return { client, name: named }; }, *detach(client) { - // Asked to leave before being signalled: a client that detaches - // restores the terminal itself, and one that is killed cannot. - yield* tmux.tryRun(["detach-client", "-t", client.name]); - const deadline = Date.now() + DETACH_LIMIT_MS; - while (Date.now() < deadline) { - if (!(yield* clientNames(tmux)).includes(client.name)) { - break; - } - yield* sleep(CLIENT_POLL_MS); - } - // Whatever the client did about it, the process is this scope's. - yield* client.child.settle(); + // The ask is inside `stop()`, which is what makes the order the same + // however the grid ends: asked first, and only this exact process + // insisted on afterwards. + yield* client.client.stop(); }, stop, }); diff --git a/packages/cli/src/terminal/tmux.ts b/packages/cli/src/terminal/tmux.ts index 2ea03a36..c9be63cf 100644 --- a/packages/cli/src/terminal/tmux.ts +++ b/packages/cli/src/terminal/tmux.ts @@ -38,12 +38,37 @@ export interface Tmux { argv(args: readonly string[]): readonly string[]; } +/** + * One tmux command did not work. + * + * The message names the command and nothing else. Not the arguments — they + * carry the socket path, the session name, pane and client identifiers and the + * worker's private directory. Not the exit status text — tmux writes paths into + * it. A provider's private topology is private on every path out of it, + * including the ones only taken when something has gone wrong, which are + * exactly the paths a diagnostic is read on. + */ export class TmuxCommandFailed extends Error { override name = "TmuxCommandFailed"; - constructor(args: readonly string[], stderr: string, code: number | undefined) { - // The command, not the socket: a diagnostic names what was asked for and - // never where this invocation's private server lives. - super(`tmux ${args.join(" ")} failed (${code ?? "signal"}): ${stderr.trim()}`); + constructor(readonly command: string) { + super(`the terminal provider's "${command}" step failed`); + } +} + +/** + * A grid could not be proved taken down. + * + * Distinct from a command that failed: this is the provider having done + * everything it can and still being unable to say that nothing is left running. + * The document does not continue past it. + */ +export class TerminalTeardownFailed extends Error { + override name = "TerminalTeardownFailed"; + constructor(unproved: string) { + super( + `the terminal grid could not be proved torn down: ${unproved}. The document ` + + `stops rather than continuing while a terminal may still be held.`, + ); } } @@ -70,7 +95,7 @@ export function tmuxAt(socket: string, env: Record): Tmux { *run(args) { const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); if (result.code !== 0) { - throw new TmuxCommandFailed(args, result.stderr, result.code); + throw new TmuxCommandFailed(args[0] ?? ""); } return result.stdout.trim(); }, diff --git a/packages/cli/tests/fixtures/fake-tmux.ts b/packages/cli/tests/fixtures/fake-tmux.ts index f0824a43..e48e7af1 100644 --- a/packages/cli/tests/fixtures/fake-tmux.ts +++ b/packages/cli/tests/fixtures/fake-tmux.ts @@ -21,6 +21,7 @@ import { appendFile } from "node:fs/promises"; import { until } from "effection"; import type { Operation } from "effection"; +import { TmuxCommandFailed } from "../../src/terminal/tmux.ts"; import type { Tmux } from "../../src/terminal/tmux.ts"; export interface FakePane { @@ -46,6 +47,15 @@ export interface FakeTmuxOptions { readonly clientCommand: (mode: "control" | "attach", script: string) => readonly string[]; /** Fail this command once, with this message. */ readonly failOnce?: { readonly command: string; readonly message: string }; + /** Name the server gives an attached client. */ + readonly clientName?: string; + /** + * A client that does not leave when it is asked. + * + * The server still reports the detach, but the client's process stays — which + * is the only way to reach the escalation that follows the ask. + */ + readonly stubbornClient?: boolean; } export interface FakeTmux extends Tmux { @@ -236,7 +246,9 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { if (at >= 0) { clients.splice(at, 1); } - yield* until(appendFile(options.script, "detached\n")); + if (options.stubbornClient !== true) { + yield* until(appendFile(options.script, "detached\n")); + } yield* until(appendFile(options.script, `%client-detached ${name}\n`)); return ""; } @@ -266,7 +278,7 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { const mode = args.includes("-C") ? "control" : "attach"; if (mode === "attach") { // A visible client the server can list, named the way tmux names one. - clients.push("/dev/ttys999"); + clients.push(options.clientName ?? "/dev/ttys999"); } return options.clientCommand(mode, options.script); }, @@ -276,7 +288,9 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { *run(args) { const answered = yield* answer(args); if (answered === undefined) { - throw new Error(`fake tmux refused: ${args.join(" ")}`); + // The same failure the real surface raises, so what a caller sees on + // this path is what a caller sees on that one. + throw new TmuxCommandFailed(args[0] ?? ""); } return answered; }, diff --git a/packages/cli/tests/fixtures/tmux-client.ts b/packages/cli/tests/fixtures/tmux-client.ts index 34bf8eb0..558d3af2 100644 --- a/packages/cli/tests/fixtures/tmux-client.ts +++ b/packages/cli/tests/fixtures/tmux-client.ts @@ -21,41 +21,54 @@ * the terminal back when asked to detach is #726's evidence, on real tmux. */ -import { readFile } from "node:fs/promises"; import process from "node:process"; +import { exists, readTextFile } from "@effectionx/fs"; +import { run, sleep, withResolvers } from "effection"; +import type { Operation } from "effection"; const POLL_MS = 15; -const [mode, script] = process.argv.slice(2); -if ((mode !== "control" && mode !== "attach") || script === undefined) { - process.stderr.write("usage: tmux-client.ts \n"); - process.exit(2); -} +type Mode = "control" | "attach"; /** Everything the script says so far, or nothing while it does not exist. */ -async function read(): Promise { - try { - const text = await readFile(script, "utf8"); - return text.split("\n").filter((line) => line.length > 0); - } catch { +function* said(script: string): Operation { + if (!(yield* exists(script))) { return []; } + const text = yield* readTextFile(script); + return text.split("\n").filter((line) => line.length > 0); +} + +function write(text: string): Operation { + const written = withResolvers(); + process.stdout.write(text, () => written.resolve()); + return written.operation; } -let seen = 0; -for (;;) { - const said = await read(); - for (const line of said.slice(seen)) { - if (mode === "control") { - process.stdout.write(`${line}\n`); - if (line.startsWith("%exit")) { - process.exit(0); +/** Follow the script until it says this client is finished. */ +export function* followScript(mode: Mode, script: string): Operation { + let seen = 0; + while (true) { + const lines = yield* said(script); + for (const line of lines.slice(seen)) { + if (mode === "control") { + yield* write(`${line}\n`); + if (line.startsWith("%exit")) { + return; + } + } else if (line === "detached") { + // The reader left. A real client would restore the terminal here. + return; } - } else if (line === "detached") { - // The reader left. A real client would restore the terminal here. - process.exit(0); } + seen = lines.length; + yield* sleep(POLL_MS); } - seen = said.length; - await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } + +const [mode, script] = process.argv.slice(2); +if ((mode !== "control" && mode !== "attach") || script === undefined) { + process.stderr.write("usage: tmux-client.ts \n"); + process.exit(2); +} +await run(() => followScript(mode, script)); diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 7e0de082..6ef7ef52 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -21,13 +21,13 @@ import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; -import { stat } from "node:fs/promises"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; +import { exists, rm, stat, writeTextFile } from "@effectionx/fs"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { rm, writeFile } from "node:fs/promises"; import { TerminalProcesses } from "@executablemd/runtime"; +import type { SignalDelivery } from "@executablemd/runtime"; import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; import { createFakeTmux } from "./fixtures/fake-tmux.ts"; @@ -48,7 +48,7 @@ import { writeFrame, } from "../src/terminal/pane-protocol.ts"; import { PANE_WORKER_COMMAND, paneWorkerInvocation } from "../src/terminal/pane-worker.ts"; -import type { FromWorker } from "../src/terminal/pane-protocol.ts"; +import type { FromWorker, ToWorker } from "../src/terminal/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ function readCells(layout: string): LayoutCell[] { @@ -76,12 +76,13 @@ describe("Tier TX — the tmux grid's geometry", () => { it("TX1: an authored column count survives every terminal size", function* () { // Four panes in two columns is 2×2 whatever the terminal is. `tiled` would // have made the wide one 4×1 and the tall one 1×4. - for (const [width, height] of [ + const sizes: [number, number][] = [ [80, 24], [200, 24], [80, 60], [211, 51], - ] as const) { + ]; + for (const [width, height] of sizes) { const cells = rowMajorCells(width, height, 2, 4); const rows = new Set(cells.map((cell) => cell.top)); const columns = new Set(cells.map((cell) => cell.left)); @@ -261,14 +262,14 @@ function closedWithin(socket: net.Socket, limitMs: number): Operation { describe("Tier TW — the pane worker and its private channel", () => { it("TW1: the private directory is 0700 and its tokens 0600", function* () { const channels: PaneChannels = yield* usePaneChannels(2); - const directory = yield* until(stat(channels.directory)); + const directory = yield* stat(channels.directory); expect(directory.mode & 0o777).toBe(0o700); for (const ordinal of [0, 1]) { - const token = yield* until(stat(paneTokenPath(channels.directory, ordinal))); + const token = yield* stat(paneTokenPath(channels.directory, ordinal)); expect(`pane ${ordinal}: ${(token.mode & 0o777).toString(8)}`).toBe(`pane ${ordinal}: 600`); // The socket exists before any pane does, so a worker that starts finds // it listening rather than racing it. - yield* until(stat(paneSocketPath(channels.directory, ordinal))); + expect(yield* exists(paneSocketPath(channels.directory, ordinal))).toBe(true); } }); @@ -278,13 +279,7 @@ describe("Tier TW — the pane worker and its private channel", () => { const channels = yield* usePaneChannels(1); directory = channels.directory; }); - const gone = yield* until( - stat(directory).then( - () => false, - () => true, - ), - ); - expect(gone).toBe(true); + expect(yield* exists(directory)).toBe(false); }); it("TW3: a real worker connects, proves which pane it is, and spends its token", function* () { @@ -296,13 +291,7 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(link.hello.pid).toBeGreaterThan(0); // Spent as it was read: a second worker for this pane finds no token, so // it has nothing to present. - const spent = yield* until( - stat(paneTokenPath(channels.directory, 0)).then( - () => false, - () => true, - ), - ); - expect(spent).toBe(true); + expect(yield* exists(paneTokenPath(channels.directory, 0))).toBe(false); expect(channels.refusals()).toEqual([]); }); @@ -410,8 +399,8 @@ describe("Tier TW — the pane worker and its private channel", () => { yield* useWorker(channels.directory, 0); const link = yield* channels.link(0); - const sleeper = { - type: "launch" as const, + const sleeper: ToWorker = { + type: "launch", id: "first", argv: ["/bin/sleep", "30"], cwd: path.resolve("."), @@ -510,14 +499,47 @@ describe("Tier TW — the pane worker and its private channel", () => { * gives the reader's terminal back when asked to detach is #726's evidence, on * real tmux, and nothing here stands in for it. */ +/** Planted where a diagnostic could pick one up, and nowhere a reader looks. */ +const SESSION_MARKER = "sessionmarker7f3a"; +const DIR_MARKER = "/tmp/dirmarker7f3a"; +const CLIENT_MARKER = "clientmarker7f3a"; +const TITLE_MARKER = "titlemarker7f3a"; +const ENV_MARKER = "envmarker7f3a"; +const STDERR_MARKER = "stderrmarker7f3a"; + describe("Tier TG — the tmux composite", () => { + /** A host whose processes are all gone, so teardown proves itself. */ + function useDeadServer(): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "absent"; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); + } + /** Where a fake server and its client fixtures meet. */ function useScript(): Operation { return resource(function* (provide) { const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); - yield* until(writeFile(file, "")); + yield* writeTextFile(file, ""); yield* ensure(function* () { - yield* until(rm(file, { force: true })); + yield* rm(file, { force: true }); }); yield* provide(file); }); @@ -559,8 +581,8 @@ describe("Tier TG — the tmux composite", () => { return []; }, // deno-lint-ignore require-yield - *deliver() { - return "absent" as const; + *deliver(): Operation { + return "absent"; }, // deno-lint-ignore require-yield *reachable([pid]) { @@ -694,11 +716,11 @@ describe("Tier TG — the tmux composite", () => { expect(tmux.alive()).toBe(false); }); - it("TG8: a server that will not go away is not reported gone", function* () { + it("TG8: a server that will not go away is a teardown failure, not a report", function* () { const script = yield* useScript(); const tmux = createFakeTmux({ script, clientCommand }); - // The server answers `kill-server` and stays anyway. Nothing about the - // command having been accepted is evidence that it worked. + // The server answers `kill-server` and stays anyway. That the command was + // accepted is not evidence that it worked. yield* TerminalProcesses.around( { // deno-lint-ignore require-yield @@ -710,8 +732,8 @@ describe("Tier TG — the tmux composite", () => { return []; }, // deno-lint-ignore require-yield - *deliver() { - return "delivered" as const; + *deliver(): Operation { + return "delivered"; }, // deno-lint-ignore require-yield *reachable() { @@ -720,22 +742,190 @@ describe("Tier TG — the tmux composite", () => { }, { at: "min" }, ); - const grid = yield* useTmuxGrid(tmux, { - session: "grid", - columns: 1, - panes: 1, - width: 160, - height: 48, - titles: ["only"], - workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/private/dir"], - cwd: path.resolve("."), - env: {}, + + let refusal = ""; + try { + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: ["only"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: {}, + }); + yield* grid.stop(); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // The document stops rather than continuing while a terminal may be held, + // and it is told which fact could not be established — never the session or + // socket that would name this invocation's private server. + expect(refusal).toContain("could not be proved torn down"); + expect(refusal).toContain("did not stop"); + for (const marker of [SESSION_MARKER, DIR_MARKER, tmux.socket]) { + expect(`${marker}: ${refusal.includes(marker)}`).toBe(`${marker}: false`); + } + }); + + it("TG10: nothing private reaches a surfaced failure", function* () { + // A marker in every place a tmux diagnostic could pick one up: the socket, + // the session, the pane and client identifiers, the worker's private + // directory, the arguments, and what the command wrote to stderr. + const script = yield* useScript(); + const tmux = createFakeTmux({ + script, + clientCommand, + clientName: `/dev/${CLIENT_MARKER}`, + failOnce: { command: "split-window", message: `stderr ${STDERR_MARKER}` }, }); + yield* useDeadServer(); - const stopped = yield* grid.stop(); - expect(stopped.gone).toBe(false); + let failure = ""; + try { + yield* scoped(function* () { + yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 2, + panes: 2, + width: 160, + height: 48, + titles: [TITLE_MARKER, TITLE_MARKER], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: { PRIVATE: ENV_MARKER }, + }); + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + // It says which step failed, because that is what a reader can act on. + expect(failure).toContain("split-window"); + // And nothing else. A provider's private topology is private on the paths + // taken when something goes wrong too — which are the paths a diagnostic + // is actually read on. + for (const marker of [ + SESSION_MARKER, + DIR_MARKER, + CLIENT_MARKER, + TITLE_MARKER, + ENV_MARKER, + STDERR_MARKER, + tmux.socket, + ...tmux.panes.map((pane) => pane.id), + ]) { + expect(`${marker}: ${failure.includes(marker)}`).toBe(`${marker}: false`); + } + }); + + it("TG11: ending the visible client signals that process and nothing else", function* () { + const script = yield* useScript(); + // A client that is asked to leave and does not, so the escalation that + // follows the ask is actually reached. + const tmux = createFakeTmux({ script, clientCommand, stubbornClient: true }); + const signalled: string[] = []; + let clientPid = -1; + + // A process table with company: XMD itself, its parent, and two more + // processes sharing XMD's foreground group. A settlement of a pane's shape + // pointed at this client would reach every one of them. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return [ + { pid: 900, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "shell" }, + { pid: 901, ppid: 900, pgid: 900, tty: "ttys000", tpgid: 900, command: "xmd" }, + { pid: 902, ppid: 901, pgid: 900, tty: "ttys000", tpgid: 900, command: "sibling" }, + { pid: 903, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "cousin" }, + ]; + }, + // deno-lint-ignore require-yield + *holders() { + // Everything holding the reader's terminal. None of it is this + // client's to end. + return [900, 901, 902, 903]; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + signalled.push(`${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + // The client refuses to leave until it has been signalled once. + return pid === clientPid && !signalled.some((entry) => entry.startsWith(`${pid}:`)); + }, + }, + { at: "min" }, + ); + + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: "visible", + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: ["only"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/d"], + cwd: path.resolve("."), + env: {}, + }); + const visible = yield* grid.attach(); + clientPid = visible.client.pid; + yield* grid.detach(visible); + }); + + // Asked first, and then exactly one process insisted on: not XMD, not its + // parent, not a sibling in the same group, and not a holder of the + // reader's terminal. + expect(tmux.issued.some((line) => line.startsWith("detach-client"))).toBe(true); + expect(signalled.length).toBeGreaterThan(0); + for (const entry of signalled) { + expect(entry.split(":")[0]).toBe(String(clientPid)); + } + for (const bystander of [900, 901, 902, 903]) { + expect(signalled.some((entry) => entry.startsWith(`${bystander}:`))).toBe(false); + } }); + it("TG12: every socket and server closes before the private directory goes", function* () { + const order: string[] = []; + let directory = ""; + let atRemoval: { closed: number; total: number } | undefined; + + yield* scoped(function* () { + const channels = yield* usePaneChannels(2, { + onClosed: () => order.push("closed"), + onRemoved: (facts) => { + atRemoval = facts; + order.push("removed"); + }, + }); + directory = channels.directory; + // A worker on one of them, so there is an accepted connection to close as + // well as the servers themselves. + yield* useWorker(channels.directory, 0); + yield* channels.link(0); + }); + + // Counted from the sockets' and servers' own close events, not from having + // asked them to close: every one of them had actually closed by the time + // the directory was removed. + expect(order).toEqual(["closed", "removed"]); + expect(atRemoval?.total).toBeGreaterThan(0); + expect(`${atRemoval?.closed}/${atRemoval?.total}`).toBe( + `${atRemoval?.total}/${atRemoval?.total}`, + ); + expect(yield* exists(directory)).toBe(false); + }); it("TG9: a composite that fails while being built still takes the server down", function* () { const script = yield* useScript(); let stopping = 0; @@ -756,8 +946,8 @@ describe("Tier TG — the tmux composite", () => { return []; }, // deno-lint-ignore require-yield - *deliver() { - return "absent" as const; + *deliver(): Operation { + return "absent"; }, // deno-lint-ignore require-yield *reachable() { From 86ad62cd93c4965caf9a57b60da2c59dcddfa86d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 06:06:18 -0400 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=90=9B=20Refuse=20when=20the=20visi?= =?UTF-8?q?ble=20client=20will=20not=20stop=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `end()` sent SIGKILL and then discarded what the wait after it established, so a client still holding the reader's terminal was reported as torn down. The shared `stop()` resolved successfully on top of that, and the document carried on. It now establishes the client is gone, and raises a provider-neutral teardown failure when it is not — so `stop()` rejects and the document stops instead. `leftWithin()` also looks once more at the boundary itself rather than falling back on the cached exit event: a client that left during the final interval is gone, and reporting it as still there would be reporting a stale reading. The boundary is unchanged and still narrow: detach is asked for through tmux first, and every signal after that names the exact client pid. Nothing inspects or signals its process group, its descendants, or the holders of the reader's terminal — on this terminal, each of those is the run itself. The refusal carries none of the socket, session, client name, argv, environment, terminal or host message. TG13 models a client that survives the ask, SIGTERM and SIGKILL: teardown refuses, the signals delivered are exactly SIGTERM and SIGKILL to the client's pid, three same-group bystanders and three holders of the reader's terminal are untouched, and no planted marker reaches the refusal. TG11's successful escalation is unchanged. Reinstating the discarded result fails TG13 and leaves TG11 green, which is the discrimination the two rows are for. --- packages/cli/src/terminal/attach-client.ts | 21 ++++- packages/cli/tests/terminal-grid-tmux.test.ts | 88 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/cli/src/terminal/attach-client.ts index 03142592..bb6ee5db 100644 --- a/packages/cli/src/terminal/attach-client.ts +++ b/packages/cli/src/terminal/attach-client.ts @@ -21,6 +21,7 @@ import type { ChildProcess } from "node:child_process"; import { ensure, race, resource, sleep, withResolvers } from "effection"; import type { Operation } from "effection"; import { deliverSignal, processReachable } from "@executablemd/runtime"; +import { TerminalTeardownFailed } from "./tmux.ts"; export interface AttachClient { /** The client process, once the runtime says it started. */ @@ -98,18 +99,32 @@ export function useAttachClient(options: { return; } yield* deliverSignal(pid, "SIGKILL"); - yield* leftWithin(KILL_SETTLE_MS, pid); + if (yield* leftWithin(KILL_SETTLE_MS, pid)) { + return; + } + // Everything this may do has been done, and the client is still there. + // Saying "torn down" now would be saying it about a process still holding + // the reader's terminal — so the document stops instead. Provider-neutral + // by construction: no socket, session, client name, argv, environment, + // terminal or host message goes into it. + throw new TerminalTeardownFailed("the terminal grid's visible client did not stop"); } function* leftWithin(limitMs: number, pid: number): Operation { const deadline = Date.now() + limitMs; - while (Date.now() < deadline) { + while (true) { if (gone || !(yield* processReachable(pid))) { return true; } + if (Date.now() >= deadline) { + break; + } yield* sleep(POLL_MS); } - return gone; + // One more look, at the boundary itself. A client that left during the + // last interval is gone, and reporting it as still there on the strength + // of a cached event would be reporting a stale reading. + return gone || !(yield* processReachable(pid)); } // Registered before the spawn: a halt between starting a client and diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 6ef7ef52..a4fd6be2 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -896,6 +896,94 @@ describe("Tier TG — the tmux composite", () => { } }); + it("TG13: a visible client that survives every step refuses the teardown", function* () { + const script = yield* useScript(); + // Asked to detach and stays; signalled and stays; killed and stays. There + // is nothing further this may do, and nothing further it may claim. + const tmux = createFakeTmux({ + script, + clientCommand, + clientName: `/dev/${CLIENT_MARKER}`, + stubbornClient: true, + }); + const signalled: string[] = []; + let clientPid = -1; + + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return [ + { pid: 900, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "shell" }, + { pid: 901, ppid: 900, pgid: 900, tty: "ttys000", tpgid: 900, command: "xmd" }, + { pid: 902, ppid: 901, pgid: 900, tty: "ttys000", tpgid: 900, command: "sibling" }, + ]; + }, + // deno-lint-ignore require-yield + *holders() { + return [900, 901, 902]; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + signalled.push(`${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + // The client never goes. The server does, so the refusal that + // surfaces is the client's rather than the server's. + return pid === clientPid; + }, + }, + { at: "min" }, + ); + + let refusal = ""; + try { + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: [TITLE_MARKER], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: { PRIVATE: ENV_MARKER }, + }); + const visible = yield* grid.attach(); + clientPid = visible.client.pid; + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // It refuses rather than continuing: a document that carried on here would + // carry on while a process still holds the reader's terminal. + expect(refusal).toContain("could not be proved torn down"); + expect(refusal).toContain("visible client did not stop"); + // Nothing private in it. + for (const marker of [ + SESSION_MARKER, + DIR_MARKER, + CLIENT_MARKER, + TITLE_MARKER, + ENV_MARKER, + tmux.socket, + "ttys000", + ]) { + expect(`${marker}: ${refusal.includes(marker)}`).toBe(`${marker}: false`); + } + // The boundary held all the way through the escalation: it was asked + // first, and every signal after that named the client alone. + expect(tmux.issued.some((line) => line.startsWith("detach-client"))).toBe(true); + expect(signalled).toEqual([`${clientPid}:SIGTERM`, `${clientPid}:SIGKILL`]); + for (const bystander of [900, 901, 902]) { + expect(signalled.some((entry) => entry.startsWith(`${bystander}:`))).toBe(false); + } + }); + it("TG12: every socket and server closes before the private directory goes", function* () { const order: string[] = []; let directory = ""; From 40281201334653c0707116f6326cc14bd97a176a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 06:18:21 -0400 Subject: [PATCH 07/15] =?UTF-8?q?=E2=9C=A8=20Install=20the=20tmux=20grid?= =?UTF-8?q?=20provider=20on=20the=20foreground=20hosts=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `provider.ts` is where #730's provider-neutral request meets tmux: it prepares the private channels, the hidden server and the panes, resolves each pane's worker command before a server exists, and hands core a composite it drives through its own lifecycle. Nothing tmux-shaped crosses in either direction. The reader leaving and the host's terminal going away settle the same `closed()`. That is deliberate: a hangup is not a second teardown path to keep honest separately, it is the ordinary structured close every other stop uses. The SIGHUP listener is a resource, so it is removed with the run rather than answering for a terminal the next one is using. `host.ts` states which hosts present grids. The Deno entrypoint and the compiled binary supply `foregroundTerminalGrid()`; every other caller gets `unsupportedTerminalGrid`, which still opens the installation so a grid is validated and refused by core rather than being silently absent. Node and Bun therefore catalog and validate the same grids and open none — threaded through `AgentStack` beside the machine-session assembly, which is the same shape this repository already uses for "Deno supplies the live one, Node and Bun supply the one that installs nothing". architecture.md's terminal-grid inventory row said "implementation unbuilt", which four layers had made untrue. It now says what each Story built, that the controlled provider remains the authority for core lifecycle semantics, that this Story's evidence uses a fake tmux with real tmux behaviour remaining #726's, and that Node and Bun install no operational provider. Checkpoint 3's evidence is not in this commit: the Node/Bun refusal row, the SIGHUP-through-host-installation row, and the CLI regressions are still to come. --- architecture.md | 2 +- packages/cli/src/agent-stack.ts | 16 ++ packages/cli/src/cli.ts | 14 +- packages/cli/src/compiled.ts | 4 + packages/cli/src/deno.ts | 4 + packages/cli/src/terminal/host.ts | 97 ++++++++++ packages/cli/src/terminal/provider.ts | 253 +++++++++++++++++++++++++ packages/cli/src/terminal/tmux-grid.ts | 35 +++- 8 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/terminal/host.ts create mode 100644 packages/cli/src/terminal/provider.ts diff --git a/architecture.md b/architecture.md index 1f56af31..f20423d3 100644 --- a/architecture.md +++ b/architecture.md @@ -4137,7 +4137,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results | built on the #641 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; first production provider is tmux in the Deno and compiled foreground hosts; controlled non-tmux provider proves the core contract; implementation unbuilt | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane-scoped native Agent launch in #731; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts, whose evidence uses a fake tmux — real tmux behaviour on macOS is #726's; Node and Bun catalog and validate the same grids and install no operational provider | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index aabc2153..78b3118c 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -22,6 +22,8 @@ import { } from "@executablemd/core"; import type { AgentProviderFactory, PermissionMode } from "@executablemd/core"; import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime"; +import { unsupportedTerminalGrid } from "./terminal/host.ts"; +import type { TerminalGridInstaller } from "./terminal/host.ts"; import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; // A separate entrypoint because the embedded adapters are temporary (#636) and @@ -67,6 +69,14 @@ export interface AgentStack { adapters: EmbeddedAdapters; /** What this host states about machine-wide agent sessions, if anything. */ sessions?: MachineSessionAssembly; + /** + * What presents this host's terminal grids. + * + * Deno and the compiled binary supply the tmux provider; Node and Bun supply + * the one that installs none, so those runtimes describe and validate the + * same grids and open none of them. + */ + installTerminalGrid?: TerminalGridInstaller; } /** @@ -80,6 +90,7 @@ export interface AgentStack { export function* resolveAgentStack( flags: AgentFlags, sessions: MachineSessionAssembly | undefined, + installTerminalGrid?: TerminalGridInstaller, ): Operation> { const config = resolveAgentConfig(flags); if ("error" in config) { @@ -96,6 +107,7 @@ export function* resolveAgentStack( permissionMode: config.permissionMode, adapters: createEmbeddedAdapters(DEFAULT_ADAPTER_ROOT), ...(sessions === undefined ? {} : { sessions }), + ...(installTerminalGrid === undefined ? {} : { installTerminalGrid }), }); } @@ -157,4 +169,8 @@ export function* installRunAgentStack(stack: AgentStack): Operation { // document inspection and `xmd test` install no launcher, so a document that // reaches under any of them refuses instead of spawning. yield* installForegroundLauncher(); + // And whatever presents this host's terminal grids, which on a host that + // presents none still opens the installation so a grid is validated — the + // refusal a document meets there is core's own. + yield* (stack.installTerminalGrid ?? unsupportedTerminalGrid)(); } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3f2a9732..9da06d4d 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -95,6 +95,8 @@ import { installWebComponents, installWebElicitation } from "@executablemd/web"; import { timebox } from "@effectionx/timebox"; import { timeout as runTimeout } from "@executablemd/runtime"; import { installRunAgentStack, resolveAgentStack } from "./agent-stack.ts"; +import { unsupportedTerminalGrid } from "./terminal/host.ts"; +import type { TerminalGridInstaller } from "./terminal/host.ts"; import { planComponentDeclaration } from "./plan-component.ts"; import { VERBOSE_REGISTRATION } from "./verbose-component.ts"; import type { AgentStack } from "./agent-stack.ts"; @@ -725,8 +727,9 @@ function* underRunDeadline(timeouts: RunTimeouts, body: () => Operation): function* settleAgentStack( flags: AgentFlags, sessions: MachineSessionAssembly | undefined, + installTerminalGrid: TerminalGridInstaller, ): Operation { - const stack = yield* resolveAgentStack(flags, sessions); + const stack = yield* resolveAgentStack(flags, sessions, installTerminalGrid); if (!stack.ok) { console.error(stack.error.message); yield* exit(1); @@ -2155,6 +2158,7 @@ function* dispatch( installRepositories: RepositoryInstaller, workflowHost: WorkflowHost | undefined, sessions: MachineSessionAssembly | undefined, + installTerminalGrid: TerminalGridInstaller, ): Operation { const propsPhase = yield* preparePropsPhase(helpRequest.args, evalFlags); @@ -2229,6 +2233,7 @@ function* dispatch( denyAll: config.denyAll, }, sessions, + installTerminalGrid, ); if (runStack === undefined) { break; @@ -2290,6 +2295,7 @@ function* dispatch( denyAll: config.denyAll, }, sessions, + installTerminalGrid, ); if (planStack === undefined) { break; @@ -2565,6 +2571,10 @@ export function* runXmd( // owns the session or which build it belongs to. A caller that names none // gets no machine sessions at all, which is the ordinary ACP behaviour. sessions?: MachineSessionAssembly, + // What presents a terminal grid on this host. Deno and the compiled binary + // supply the tmux provider; Node and Bun supply the one that installs none, + // so those runtimes describe and validate the same grids and open none. + installTerminalGrid: TerminalGridInstaller = unsupportedTerminalGrid, ): Operation { // Before every scanner, before command selection, and before anything reads a // path. `prompt` names no command, and a first token that names none is a @@ -2632,6 +2642,7 @@ export function* runXmd( installRepositories, workflowHost, sessions, + installTerminalGrid, ); } @@ -2651,6 +2662,7 @@ export function* runXmd( installRepositories, workflowHost, sessions, + installTerminalGrid, ), ); } diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 7552d1f3..2280c768 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -19,6 +19,7 @@ import { runCredentialHelper, } from "@executablemd/workflow/credential-helper"; import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; +import { foregroundTerminalGrid } from "./terminal/host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useCompiledService } from "./compiled-service.ts"; @@ -98,6 +99,9 @@ if (paneWorker !== undefined) { denoRunRepositories(HELPER), () => useDenoWorkflowHost(HELPER), useMachineSessions(), + // This host presents grids: it has a terminal to divide, and it can + // re-invoke itself for one pane. + foregroundTerminalGrid(), ); }); } diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index ea695e9d..659c342d 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -22,6 +22,7 @@ import { runCredentialHelper, } from "@executablemd/workflow/credential-helper"; import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; +import { foregroundTerminalGrid } from "./terminal/host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useDenoService } from "./deno-service.ts"; @@ -117,6 +118,9 @@ if (paneWorker !== undefined) { denoRunRepositories(HELPER), () => useDenoWorkflowHost(HELPER), useMachineSessions(), + // This host presents grids: it has a terminal to divide, and it can + // re-invoke itself for one pane. + foregroundTerminalGrid(), ); }); } diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/terminal/host.ts new file mode 100644 index 00000000..b935cb41 --- /dev/null +++ b/packages/cli/src/terminal/host.ts @@ -0,0 +1,97 @@ +/** + * Which hosts open a terminal grid, and which only describe one + * (architecture.md §Interactive terminal grids). + * + * The Deno source entrypoint and the compiled binary present grids when the + * invocation has a terminal and a usable tmux. Node and Bun keep the same + * language, catalog and validation and install no operational provider — a + * document that asks for a grid there is refused before a pane starts, rather + * than part-way through one. + * + * That is a fact about the host, so the entrypoint states it rather than this + * module inferring it. `unsupportedTerminalGrid` is the honest half of the same + * choice: it installs nothing, and the refusal a document meets is the one core + * already gives when no provider is installed. + */ + +import { ensure, resource, withResolvers } from "effection"; +import type { Operation } from "effection"; +import process from "node:process"; +import { installTerminalGridProfile } from "@executablemd/core"; +import { command as hostCommand } from "@executablemd/runtime"; +import { installTmuxGridProvider, TMUX_PROVIDER } from "./provider.ts"; +import type { TmuxProviderDependencies } from "./provider.ts"; +import { paneEnvironment } from "./tmux.ts"; +import { PANE_WORKER_COMMAND } from "./pane-worker.ts"; + +/** How a host installs whatever presents its terminal grids. */ +export type TerminalGridInstaller = () => Operation; + +/** + * A host that describes grids and presents none. + * + * Not an error, and not silence either: the installation is opened so a grid is + * still validated, and core's own refusal is what a document meets when it asks + * for one to be shown. + */ +export function* unsupportedTerminalGrid(): Operation { + yield* installTerminalGridProfile(); +} + +/** The terminal this run is drawing on, as tmux needs to know it. */ +function windowSize(): { columns: number; rows: number } { + // A terminal that cannot say gets the sizes tmux itself defaults to, which is + // better than a grid that refuses to lay out at all. + return { + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + }; +} + +/** + * Settle when this process's terminal goes away. + * + * SIGHUP is the terminal saying it is gone. What follows is the ordinary + * structured cancellation a reader's close would cause — the grid comes down + * the same way, through the same teardown, rather than through a second path + * that would have to be kept honest separately. + * + * Registered as a resource so the handler is removed with the run: a listener + * that outlived its grid would answer for a terminal the next one is using. + */ +export function useHangup(): Operation> { + return resource>(function* (provide) { + const hung = withResolvers(); + const onHangup = (): void => hung.resolve(); + process.on("SIGHUP", onHangup); + yield* ensure(() => { + process.off("SIGHUP", onHangup); + }); + yield* provide(hung.operation); + }); +} + +/** + * Install the tmux provider for a foreground host. + * + * `workerCommand` is how this host re-invokes itself for one pane. Reusing the + * executable is what makes a pane work in the compiled distribution, where + * there is no script to run. + */ +export function foregroundTerminalGrid( + overrides: Partial = {}, +): TerminalGridInstaller { + return function* (): Operation { + const hangup = yield* useHangup(); + yield* installTmuxGridProvider({ + isTerminal: () => process.stdout.isTTY === true, + env: paneEnvironment(process.env), + workerCommand: (ordinal, directory) => + hostCommand([PANE_WORKER_COMMAND, String(ordinal), directory]), + size: windowSize, + hangup: () => hangup, + ...overrides, + }); + yield* installTerminalGridProfile({ provider: TMUX_PROVIDER, label: TMUX_PROVIDER }); + }; +} diff --git a/packages/cli/src/terminal/provider.ts b/packages/cli/src/terminal/provider.ts new file mode 100644 index 00000000..e24e9ba6 --- /dev/null +++ b/packages/cli/src/terminal/provider.ts @@ -0,0 +1,253 @@ +/** + * The tmux terminal-grid provider, and what a host must be to install it + * (architecture.md §Interactive terminal grids). + * + * This is the one place the provider-neutral request from #730 meets tmux. The + * request names columns, rows and the authored panes; what comes back is a + * composite core drives through its own lifecycle. Nothing tmux-shaped crosses + * in either direction: no socket, session, window, pane, client or server + * identifier appears in a request, a result, a retained record or a diagnostic. + * + * A host installs this only when it can actually present a grid. `xmd run` on a + * terminal with a usable tmux does; `xmd test`, a piped run, a host without + * tmux, and the Node and Bun runtimes do not — they keep the language and the + * validation and install no operational provider, so a document that asks for a + * grid is refused before a pane starts rather than part-way through one. + * + * The hangup is here because it ends the same way. A terminal that goes away + * takes the grid with it, and the way it does that is the ordinary structured + * cancellation every other stop uses — not a second teardown path that would + * have to be kept honest separately. + */ + +import { ensure, race, resource, spawn, withResolvers } from "effection"; +import process from "node:process"; +import type { Operation } from "effection"; +import { TerminalGrids } from "@executablemd/runtime"; +import type { + TerminalComposite, + TerminalGridRequest, + TerminalPaneState, + TerminalShellOutcome, +} from "@executablemd/runtime"; +import { registerTerminalProvider } from "@executablemd/core"; +import type { TerminalProviderFactory } from "@executablemd/core"; +import { usePaneChannels } from "./pane-channel.ts"; +import type { PaneLink } from "./pane-channel.ts"; +import { useTmuxGrid } from "./tmux-grid.ts"; +import type { TmuxGrid, VisibleClient } from "./tmux-grid.ts"; +import { paneEnvironment, probeTmux, tmuxAt, TmuxUnavailableError } from "./tmux.ts"; +import type { Tmux } from "./tmux.ts"; + +/** The name a host installs this provider under. */ +export const TMUX_PROVIDER = "tmux"; + +export interface TmuxProviderDependencies { + /** Whether this invocation has a terminal to divide. */ + isTerminal(): boolean; + /** What every process in the topology receives. */ + readonly env: Record; + /** + * The command that runs one pane's worker: this executable, hidden mode. + * + * An operation because a host resolves its own invocation contextually, and + * every pane's is resolved before the server exists. + */ + workerCommand(ordinal: number, directory: string): Operation; + /** The window to lay panes out in. */ + size(): { columns: number; rows: number }; + /** Settles when the host's own terminal goes away. */ + hangup(): Operation; + /** How a private server is reached. Substituted only by this package's tests. */ + createTmux?: (socket: string, env: Record) => Tmux; +} + +/** + * Build the provider factory a host registers. + * + * The factory receives the terminal authority directly and presents the exact + * request it was routed — a handler that answered without presenting would have + * presented nothing, which is what #730's handshake is for. + */ +export function tmuxGridProvider(deps: TmuxProviderDependencies): TerminalProviderFactory { + return function* (_options, authority): Operation { + yield* TerminalGrids.around( + { + *open([request]): Operation { + const composite = yield* usePresentedGrid(deps, request); + yield* authority.present(request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }; +} + +/** + * Everything one grid needs, prepared while it is still hidden. + * + * Ownership, innermost last — which is also the order it comes down in: + * + * grid scope + * ├─ private directory, sockets and tokens (removed last, after they close) + * ├─ the tmux server and its panes (`kill-server`, proved) + * └─ the admitted worker links + */ +function usePresentedGrid( + deps: TmuxProviderDependencies, + request: TerminalGridRequest, +): Operation { + return resource(function* (provide) { + const probed = yield* probeTmux({ isTerminal: deps.isTerminal, env: deps.env }); + if (!probed.ok) { + // Before a directory, a socket, a token, a server or a pane exists, so a + // host that cannot present a grid leaves nothing behind for having tried. + throw probed.error; + } + + const channels = yield* usePaneChannels(request.panes.length); + // Resolved before a server exists, so a host that cannot say how to run its + // own worker fails while there is still nothing to take down. + const workers: string[][] = []; + for (let ordinal = 0; ordinal < request.panes.length; ordinal++) { + workers.push([...(yield* deps.workerCommand(ordinal, channels.directory))]); + } + const build = deps.createTmux ?? tmuxAt; + const window = deps.size(); + const grid = yield* useTmuxGrid(build(`${channels.directory}/s`, deps.env), { + session: "xmd", + columns: request.columns, + panes: request.panes.length, + width: window.columns, + height: window.rows, + titles: request.panes.map((pane) => pane.title), + workerCommand: (ordinal) => workers[ordinal] ?? [], + cwd: process.cwd(), + env: deps.env, + }); + + const links: PaneLink[] = []; + for (let ordinal = 0; ordinal < request.panes.length; ordinal++) { + links.push(yield* channels.link(ordinal)); + } + + // The reader leaving, and the host's terminal going away, are the same kind + // of event: something outside the document decided this grid is over. Both + // settle `closed()`, and core takes it from there through its ordinary + // close — there is no second teardown path to keep honest. + const left = withResolvers(); + yield* spawn(function* () { + yield* deps.hangup(); + left.resolve(); + }); + + let shown = 0; + let visible: VisibleClient | undefined; + + yield* ensure(function* () { + // Asked to leave before anything else comes down, so the reader's + // terminal is restored by the client that took it. + if (visible !== undefined) { + yield* grid.detach(visible); + } + for (const link of links) { + if (link.connected()) { + yield* link.send({ type: "shutdown" }); + } + } + }); + + yield* provide({ + *attach() { + visible = yield* grid.attach(); + }, + *update(ordinal, state) { + // Sanitized status only, and display only: core has already decided + // what this is, and drawing it is not a chance to change it. + yield* label(grid, ordinal, request, state); + }, + *display(ordinal, text) { + const link = links[ordinal]; + if (link === undefined) { + return; + } + yield* link.send({ type: "display", seq: ++shown, text }); + }, + *shell(ordinal, spawned) { + return yield* runShell(links[ordinal], deps, spawned); + }, + *closed() { + yield* race([left.operation, grid.detached()]); + }, + *destroy() { + yield* grid.stop(); + }, + }); + }); +} + +/** The pane's title, with the state core settled on appended. */ +function* label( + grid: TmuxGrid, + ordinal: number, + request: TerminalGridRequest, + state: TerminalPaneState, +): Operation { + const pane = request.panes[ordinal]; + if (pane === undefined) { + return; + } + yield* grid.title(ordinal, `${pane.title} — ${state}`); +} + +/** Start the host's default shell in one pane, through its worker. */ +function* runShell( + link: PaneLink | undefined, + deps: TmuxProviderDependencies, + spawned: () => void, +): Operation { + if (link === undefined) { + throw new Error("this grid has no such pane"); + } + const shell = deps.env.SHELL ?? "/bin/sh"; + yield* link.send({ + type: "launch", + id: `shell-${link.ordinal}`, + argv: [shell], + cwd: process.cwd(), + env: deps.env, + }); + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + return {}; + } + if (frame.type === "started") { + // The runtime's own start event, and the only thing that makes this pane + // ready. + spawned(); + continue; + } + if (frame.type === "start-failed") { + throw new Error("the pane's shell could not be started"); + } + if (frame.type === "exited") { + const outcome: TerminalShellOutcome = {}; + if (frame.exitCode !== undefined) { + outcome.exitCode = frame.exitCode; + } + if (frame.signal !== undefined) { + outcome.signal = frame.signal; + } + return outcome; + } + } +} + +/** Install the tmux provider for this host, when this host can present one. */ +export function* installTmuxGridProvider(deps: TmuxProviderDependencies): Operation { + yield* registerTerminalProvider(TMUX_PROVIDER, tmuxGridProvider(deps)); +} + +export { TmuxUnavailableError }; diff --git a/packages/cli/src/terminal/tmux-grid.ts b/packages/cli/src/terminal/tmux-grid.ts index 61d6d986..e1e5cab2 100644 --- a/packages/cli/src/terminal/tmux-grid.ts +++ b/packages/cli/src/terminal/tmux-grid.ts @@ -28,7 +28,7 @@ import { exec } from "@effectionx/process"; import { lines } from "@effectionx/stream-helpers"; -import { ensure, resource, sleep, spawn } from "effection"; +import { createSignal, ensure, resource, sleep, spawn } from "effection"; import type { Operation } from "effection"; import { processReachable } from "@executablemd/runtime"; import { layoutString, swapsInto } from "./layout.ts"; @@ -92,6 +92,10 @@ export interface TmuxGrid { readonly events: readonly ControlEvent[]; /** Pane geometry now, for checking placement after a resize. */ geometry(): Operation; + /** Show one pane's label. Display only; core has settled what it says. */ + title(ordinal: number, text: string): Operation; + /** Settles when the control channel says the reader's client has gone. */ + detached(): Operation; /** Show the grid on this process's terminal. */ attach(): Operation; /** Ask the visible client to leave, so it restores the terminal itself. */ @@ -261,6 +265,9 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation(); + // Subscribed before the client is started, so no report is missed. + const watching = yield* reports; yield* spawn(function* () { const [program = "tmux", ...argv] = tmux.argv([ "-C", @@ -274,11 +281,14 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation pane.cell); }, + *title(ordinal, text) { + const id = paneIds[ordinal]; + if (id === undefined) { + return; + } + yield* tmux.tryRun(["select-pane", "-t", id, "-T", text]); + }, + *detached() { + // The control client's account. An attach client's exit code is 0 after + // a detach, 0 after a session is killed and 1 after the server is, so + // it cannot tell a reader leaving from a grid being taken down. + if (events.some((event) => event.kind === "client-detached")) { + return; + } + while (true) { + const next = yield* watching.next(); + if (next.done || next.value.kind === "client-detached") { + return; + } + } + }, *attach() { // Its own lifecycle, not a pane child's. A pane child is settled by // sweeping its process group and its terminal; this client's terminal From 802b07dfa4698a620f9c4c2454af7ebbd9b10934 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 06:37:53 -0400 Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=93=9D=20Route=20pane-native=20laun?= =?UTF-8?q?ches=20through=20terminal=20composites=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 40 +++++++++++++++++------ specs/executable-mdx-spec.md | 30 +++++++++++++++++ specs/native-agent-session-launch-spec.md | 39 +++++++++++++++++++--- 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/architecture.md b/architecture.md index f20423d3..8e737a88 100644 --- a/architecture.md +++ b/architecture.md @@ -2847,15 +2847,35 @@ request to act. This preserves provider composition without letting a document or replacement context mint terminal ownership. A pane claim grants one interactive terminal at that ordinal, not an Agent -session. Core installs a pane-scoped native launcher that closes over the claim. -`` in that pane consequently reserves, flushes, and launches on -the pane terminal instead of competing for the root lease. Launches in -different panes may run concurrently; two interactive launches in one pane -cannot. Sequential launches in one paired pane remain ordinary composition. -The session coordinator is unchanged and independently authoritative, so two -panes attempting to own the same logical Agent session still contend and one -is refused. The provider starts a self-closing pane's host-configured default -shell under the same kind of pane claim. +session. Core installs a pane-scoped native launcher that closes over the claim, +the composite, and the authored ordinal. `` in that pane +consequently reserves and flushes the pane, then terminates native-launch +routing at this required provider-neutral composite operation: + +```ts +launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, +): Operation; +``` + +The ordinal exists only in core's live closure and never enters the native or +Agent request. Once nearer native-launch middleware delegates, the pane launcher +calls the composite operation instead of the root foreground launcher. Nearer +middleware may still observe, wrap, refuse, or short-circuit the request. A +composite that cannot execute the pane request refuses explicitly; falling +through to the root would put the child on the wrong physical terminal. Root +`` retains its existing foreground-launch route unchanged. + +Launches in different panes may run concurrently; two interactive launches in +one pane cannot. Sequential launches in one paired pane remain ordinary +composition. The session coordinator is unchanged and independently +authoritative, so two panes attempting to own the same logical Agent session +still contend and one is refused. The composite's separate `shell()` operation +starts a self-closing pane's host-configured default shell under the same kind +of pane claim; it remains separate because its executable is live host policy, +not an authored or Agent-provided native launch request. Each claim also closes over one host-owned readiness latch. The pane-scoped native launcher acknowledges it from the runtime's successful child-spawn event @@ -4137,7 +4157,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results | built on the #641 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane-scoped native Agent launch in #731; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts, whose evidence uses a fake tmux — real tmux behaviour on macOS is #726's; Node and Bun catalog and validate the same grids and install no operational provider | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified after the #732 integration exposed the missing physical route and remains to be implemented; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts, whose evidence uses a fake tmux — real tmux behaviour on macOS is #726's; Node and Bun catalog and validate the same grids and install no operational provider | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 2178dce8..504b1078 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -8655,6 +8655,35 @@ acknowledges. The self-closing shell does the same. The latch is absent for a root launch and appears in no prop, binding, contextual API, public request, provider return, process result, or durable record. +For a paired pane, core closes the pane-scoped native launcher over the +composite and that pane's authored ordinal. After claim admission and the pane +output flush, delegation reaches the composite's required provider-neutral +operation: + +```ts +launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, +): Operation; +``` + +The request is the exact native command vector, working directory, and +environment supplied by the Agent provider. The ordinal stays in core's live +closure and enters no native request, Agent request, session key, construction +route, durable phase, result, or diagnostic. Native-launch middleware installed +nearer the authored launch may observe, wrap, refuse, or short-circuit before it +delegates. The pane launcher is the physical-terminal endpoint: it calls the +composite operation and never delegates to the root foreground launcher. A +provider unable to execute the pane request refuses explicitly instead of +falling back to the wrong terminal. A root `` keeps the existing +root foreground route unchanged. + +The composite invokes `spawned` only for the child's runtime spawn event. Its +separate `shell()` operation remains the self-closing-pane path because that +operation derives the executable from live host policy rather than accepting an +authored or Agent-provided native launch request. + When a persistent process owns a pane endpoint, the launcher sends the exact argv vector, working directory, and environment over the provider's private authenticated channel to that pane owner. The presentation provider's command @@ -10798,6 +10827,7 @@ test derives a core result from a provider identifier. | TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | | TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | | TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | +| TG20 | Pane-native physical endpoint | A paired pane's native launch passes through nearer launcher middleware and then the required composite operation for its authored ordinal. Production tmux evidence observes the exact argv, cwd, and environment at that pane's authenticated worker while a root-foreground-launcher sentinel is never entered. Distinct pane workers accept concurrent launches. Cancellation settles only after worker-reported child settlement and pane-terminal quiescence. A root launch still enters the root foreground launcher unchanged, and a composite unable to execute a pane launch refuses without fallback | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 74c42270..2de01c5e 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -756,11 +756,22 @@ ensure, detach, create, resume, prompt, or attach to an Agent session, and a session lease grants no terminal. The pane-scoped launcher keeps the same launch request and provider authority -division as the root launcher. Public middleware can route or refuse a request -but cannot settle it, replace the pane, or mint a launch. Provider-specific grid -or pane identities never enter the `AgentLaunchRequest`, terminal result, -`agent_session_launch` record, construction route, ownership key, diagnostic, -or private instruction file. +division as the root launcher. Core closes it over the terminal composite and +authored pane ordinal. After the claim admits the launch and pane output is +flushed, the launcher calls the composite's required provider-neutral +`launch(ordinal, request, spawned)` operation. It does not delegate to the root +foreground launcher. The ordinal remains in that live closure and never enters +the native request. + +Public middleware installed nearer the authored launch can route, wrap, refuse, +or short-circuit before delegating, but cannot settle the claim, replace the +pane, or mint a launch. Once it delegates, the pane launcher is the physical +terminal endpoint. A composite that cannot execute the request refuses rather +than falling through to the root terminal. Root `Session.Launch` retains its +existing foreground-launch route. Provider-specific grid or pane identities +never enter the `AgentLaunchRequest`, terminal result, `agent_session_launch` +record, construction route, ownership key, diagnostic, or private instruction +file. The grid's readiness barrier observes the launch only at the existing successful interactive-child start boundary. Session preparation, route publication, @@ -788,6 +799,10 @@ launch. It uses Effection's `run()` rather than `main()` so Effection does not convert terminal `SIGINT` into worker exit 130 while the foreground child is handling job control. +The composite's `shell()` path remains separate. It chooses the current host's +default shell as live policy; it does not accept or reinterpret a native launch +request supplied by an Agent provider. + After the grid is visible, a nonzero native exit fails its pane flow but does not cancel sibling panes. Core keeps that failure as the pane's status and selects the first failed pane in authored order when the reader closes the grid. @@ -1198,6 +1213,15 @@ exercises pane reuse after terminal-holder quiescence; a process that has already started a new session, closed the terminal, and lost its parent is recorded as outside the observable host boundary. +The pane-native route has an explicit physical-terminal regression. A paired +pane delegates through any nearer launcher middleware to its composite endpoint; +the production tmux adapter delivers the unchanged command vector, cwd, and +environment to the authenticated worker for that authored ordinal, while a +root-launcher sentinel proves the foreground endpoint was not entered. Two pane +endpoints launch concurrently. Cancellation remains pending until worker +settlement and pane-terminal quiescence are observed. A separate root launch +still reaches the root foreground launcher. + Focused tests prove: 1. help discovers roles and performs no preparation or launch; @@ -1448,6 +1472,11 @@ Implementation review checks these frozen invariants: keeps worker display out of child input, distinguishes reader detach from control loss and server stop, and proves the bounded process and terminal teardown before pane reuse and grid settlement. +29. A paired pane's native launcher terminates at the required composite + operation for its authored ordinal: the exact native request reaches that + pane's authenticated worker, the root foreground launcher is not entered, + distinct panes launch concurrently, cancellation awaits worker settlement + and pane quiescence, and root launch routing remains unchanged. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From 7511e777cd28b6a02523fe8f544556386ae5604e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 07:00:50 -0400 Subject: [PATCH 09/15] =?UTF-8?q?=E2=9C=A8=20Route=20a=20pane's=20native?= =?UTF-8?q?=20launch=20through=20its=20own=20composite=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements architecture commit 802b07df. `TerminalComposite.launch(ordinal, request, spawned)` is required of every composite. Core closes the pane-scoped launcher over it and the pane's authored ordinal, so after claim admission and the pane flush a `` written in a paired pane reaches *that pane's* terminal. The ordinal lives in core's closure and enters no native request, Agent request, session key, construction route, durable phase, result or diagnostic. The pane launcher is now the end of the chain. Middleware written nearer the authored launch still composes in front and may observe, wrap, refuse or short-circuit; what it can no longer do is reach past, because past it is the root foreground launcher and the root terminal is the one thing a pane exists to avoid. A composite that cannot run a pane's launch refuses — there is no fallback, because the only thing to fall back to is the wrong terminal. Root `` is untouched. The tmux composite sends the exact command vector, working directory and environment over the pane's authenticated channel; tmux's parser sees a directory and an ordinal. `shell()` stays separate and keeps deriving the executable from live host policy. `spawned` is invoked only for the worker-observed runtime spawn event. Also fails closed on settlement: `requireQuiescent()` is the rule everything downstream is conditional on, and a settlement that could not prove the pane free no longer clears the pane, reports success, or admits another launch. TG20 proves the endpoint against real workers on real sockets with a fake tmux that now starts the pane commands it is given, and with no `` or nearer launcher in front: exact argv, cwd and environment arrive at the pane's authenticated worker; a root-foreground-launcher sentinel is never entered while a root launch still reaches it; distinct panes launch concurrently; and a pane the composite cannot serve refuses. Tier GN is rewired through the endpoint, which is what exposed the gap: before this, its pane launches reached ``'s launcher and nothing could tell that from reaching the pane. GN now separates the two — `launches` at the pane endpoint, `agentLaunches` at the root route — and GN3 and GN8 assert both. --- packages/cli/src/terminal/pane-worker.ts | 43 ++- packages/cli/src/terminal/provider.ts | 62 +++- packages/cli/tests/fixtures/fake-tmux.ts | 36 +++ packages/cli/tests/terminal-grid-tmux.test.ts | 292 ++++++++++++++++-- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/pane-launcher.ts | 31 +- .../core/tests/agent-session-launch.test.ts | 35 ++- packages/core/tests/terminal-grid.test.ts | 4 +- packages/runtime/terminal.ts | 55 +++- .../tests/terminal-grid-native-launch.test.ts | 55 +++- 10 files changed, 544 insertions(+), 73 deletions(-) diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts index e11f1d97..07954006 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/cli/src/terminal/pane-worker.ts @@ -89,6 +89,42 @@ export function runPaneWorkerProcess(invocation: { return run(() => runPaneWorker(invocation.ordinal, invocation.directory)); } +/** + * A pane that could not be proved free. + * + * Provider-neutral: it names no socket, session, pane, client, argv or + * environment, because a settlement that failed is read in exactly the places a + * private identifier must not appear. + */ +export class PaneNotQuiescent extends Error { + override name = "PaneNotQuiescent"; + constructor(what: string) { + super(`this terminal pane could not be proved free: ${what}`); + } +} + +/** + * What a settlement means for the pane it settled. + * + * Exported because it is the rule, not an implementation detail: everything + * downstream — clearing the pane, reporting a launch settled, admitting the + * next one, letting teardown succeed — is conditional on it, and a rule that + * several callers depend on is one worth being able to state and test on its + * own. + */ +export function requireQuiescent(settlement: Settlement): void { + if (settlement.quiet) { + return; + } + // Everything the worker can do has been done and something is still there: a + // survivor of the escalation, or a holder of the pane's terminal. + throw new PaneNotQuiescent( + settlement.holders.some((holder) => !holder.gone) + ? "something still holds its terminal" + : "something it started is still running", + ); +} + /** A settlement for a pane that never started anything. */ const NOTHING_TO_SETTLE: Settlement = { method: "exited", @@ -176,6 +212,9 @@ export function* runPaneWorker(ordinal: number, directory: string): Operation void, -): Operation { +): Operation { if (link === undefined) { - throw new Error("this grid has no such pane"); + // No fallback. A composite that cannot run this in the pane it was asked + // for refuses, rather than putting a native UI on the root terminal. + throw new Error("this terminal grid cannot run that pane's launch"); } - const shell = deps.env.SHELL ?? "/bin/sh"; yield* link.send({ type: "launch", - id: `shell-${link.ordinal}`, - argv: [shell], - cwd: process.cwd(), - env: deps.env, + id: `launch-${link.ordinal}-${++started}`, + argv: [...request.command], + cwd: request.cwd, + env: request.env ?? {}, }); while (true) { const frame = yield* link.next(); if (frame === undefined) { - return {}; + // The worker's channel ended mid-launch. Nothing about that says the + // child stopped, so it is a failure rather than an empty outcome. + throw new Error("the terminal pane stopped answering before its launch settled"); } if (frame.type === "started") { - // The runtime's own start event, and the only thing that makes this pane - // ready. + // The worker-observed runtime spawn event, and the only thing that makes + // this pane ready. spawned(); continue; } + if (frame.type === "busy") { + throw new Error("that terminal pane already has a live child"); + } if (frame.type === "start-failed") { - throw new Error("the pane's shell could not be started"); + throw new Error("the terminal pane's child could not be started"); } if (frame.type === "exited") { - const outcome: TerminalShellOutcome = {}; + const outcome: NativeLaunchOutcome = {}; if (frame.exitCode !== undefined) { outcome.exitCode = frame.exitCode; } @@ -245,6 +272,9 @@ function* runShell( } } +/** Distinguishes one pane's launches from the next in this invocation. */ +let started = 0; + /** Install the tmux provider for this host, when this host can present one. */ export function* installTmuxGridProvider(deps: TmuxProviderDependencies): Operation { yield* registerTerminalProvider(TMUX_PROVIDER, tmuxGridProvider(deps)); diff --git a/packages/cli/tests/fixtures/fake-tmux.ts b/packages/cli/tests/fixtures/fake-tmux.ts index e48e7af1..f8909a7f 100644 --- a/packages/cli/tests/fixtures/fake-tmux.ts +++ b/packages/cli/tests/fixtures/fake-tmux.ts @@ -19,6 +19,8 @@ */ import { appendFile } from "node:fs/promises"; +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { until } from "effection"; import type { Operation } from "effection"; import { TmuxCommandFailed } from "../../src/terminal/tmux.ts"; @@ -56,6 +58,15 @@ export interface FakeTmuxOptions { * is the only way to reach the escalation that follows the ask. */ readonly stubbornClient?: boolean; + /** + * Actually start the pane commands, the way a server would. + * + * With this on, `new-session` and `split-window` spawn the exact worker + * command they were given, each in a session of its own — which is what tmux + * gives a pane's initial process. That yields real workers on real sockets + * with no real tmux anywhere. + */ + readonly spawnPanes?: boolean; } export interface FakeTmux extends Tmux { @@ -66,6 +77,10 @@ export interface FakeTmux extends Tmux { readonly serverPid: number; readonly alive: () => boolean; readonly clients: readonly string[]; + /** Every pane process this server actually started. */ + readonly started: readonly ChildProcess[]; + /** End every started pane process. */ + stopPanes(): void; /** Say something on the control channel, as the server would. */ say(line: string): Operation; } @@ -97,6 +112,7 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { /** Window-list order — the order panes were created, which tmux fills by. */ const panes: FakePane[] = []; const clients: string[] = []; + const started: ChildProcess[] = []; let alive = false; let nextPane = 0; let nextPid = 4000; @@ -127,6 +143,20 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { title: "", command, }; + if (options.spawnPanes === true && command.length > 0) { + const [program, ...argv] = command; + if (program !== undefined) { + started.push( + spawnChild(program, argv, { + stdio: ["ignore", "pipe", "pipe"], + // A pane's initial process is tmux's session leader, so it is its + // own process group — which is also what keeps a worker's own + // settlement from sweeping this test runner. + detached: true, + }), + ); + } + } const at = after === undefined ? -1 : panes.findIndex((entry) => entry.id === after); if (at < 0) { panes.push(created); @@ -274,6 +304,12 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { serverPid, alive: () => alive, clients, + started, + stopPanes() { + for (const child of started) { + child.kill("SIGKILL"); + } + }, argv(args) { const mode = args.includes("-C") ? "control" : "attach"; if (mode === "attach") { diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index a4fd6be2..67561470 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -16,17 +16,20 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, race, resource, scoped, sleep, until, withResolvers } from "effection"; +import { all, ensure, race, resource, scoped, sleep, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; -import { exists, rm, stat, writeTextFile } from "@effectionx/fs"; +import { exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; +import { realpath } from "node:fs/promises"; +import { installControlledLauncher, nativeLaunch, reserveTerminal } from "@executablemd/runtime"; +import type { TerminalComposite } from "@executablemd/runtime"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { TerminalProcesses } from "@executablemd/runtime"; +import { installPosixTerminalProcesses, TerminalProcesses } from "@executablemd/runtime"; import type { SignalDelivery } from "@executablemd/runtime"; import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; @@ -40,6 +43,7 @@ import { } from "../src/terminal/layout.ts"; import type { LayoutCell } from "../src/terminal/layout.ts"; import { usePaneChannels } from "../src/terminal/pane-channel.ts"; +import { runInPane } from "../src/terminal/provider.ts"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { FromWorkerSchema, @@ -47,8 +51,12 @@ import { paneTokenPath, writeFrame, } from "../src/terminal/pane-protocol.ts"; -import { PANE_WORKER_COMMAND, paneWorkerInvocation } from "../src/terminal/pane-worker.ts"; -import type { FromWorker, ToWorker } from "../src/terminal/pane-protocol.ts"; +import { + PANE_WORKER_COMMAND, + paneWorkerInvocation, + requireQuiescent, +} from "../src/terminal/pane-worker.ts"; +import type { FromWorker, Settlement, ToWorker } from "../src/terminal/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ function readCells(layout: string): LayoutCell[] { @@ -72,6 +80,42 @@ function readCells(layout: string): LayoutCell[] { return cells; } +/** Where a fake server and its client fixtures meet. */ +function useScript(): Operation { + return resource(function* (provide) { + const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); + yield* writeTextFile(file, ""); + yield* ensure(function* () { + yield* rm(file, { force: true }); + }); + yield* provide(file); + }); +} + +/** The fixture that stands in for one tmux client. */ +function clientCommand(mode: "control" | "attach", script: string): readonly string[] { + const fixture = path.resolve("packages/cli/tests/fixtures/tmux-client.ts"); + const invocation = cliCommand([]); + // The same runtime the CLI runs under, pointed at the fixture instead. + return [invocation.command, "run", "--allow-all", fixture, mode, script]; +} + +/** A composite whose pane endpoint is the production one, over these links. */ +function paneComposite(links: readonly PaneLink[]): TerminalComposite { + const refuse = (): never => { + throw new Error("this row drives the pane endpoint only"); + }; + return { + attach: refuse, + update: refuse, + display: refuse, + shell: refuse, + closed: refuse, + destroy: refuse, + launch: (ordinal, request, spawned) => runInPane(links[ordinal], request, spawned), + }; +} + describe("Tier TX — the tmux grid's geometry", () => { it("TX1: an authored column count survives every terminal size", function* () { // Four panes in two columns is 2×2 whatever the terminal is. `tiled` would @@ -461,6 +505,47 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(bye.type).toBe("bye"); }); + it("TW13: a settlement that proved nothing frees no pane", function* () { + // The rule every downstream step is conditional on: clearing the pane, + // reporting a launch settled, admitting the next one, letting teardown + // succeed. Stated here rather than end-to-end, because a pane whose sweep + // cannot come back empty is not something a suite can arrange in another + // process without putting a fault switch in the worker itself. + const proved: Settlement = { method: "exited", quiet: true, swept: [], holders: [] }; + requireQuiescent(proved); + + const survivor: Settlement = { + method: "killed", + quiet: false, + child: 100, + swept: [{ pid: 200, gone: false }], + holders: [], + }; + const held: Settlement = { + method: "exited", + quiet: false, + child: 100, + swept: [], + holders: [{ pid: 900, gone: false }], + }; + for (const [what, settlement] of [ + ["a survivor", survivor], + ["a holder", held], + ] as [string, Settlement][]) { + let refusal = ""; + try { + requireQuiescent(settlement); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(`${what}: ${refusal.includes("could not be proved free")}`).toBe(`${what}: true`); + // Provider-neutral: it says what is still true, not which pane, session, + // socket or command it was. + expect(`${what}: ${/\bpane \d|socket|session/.test(refusal)}`).toBe(`${what}: false`); + } + expect(() => requireQuiescent(held)).toThrow(); + }); + it("TW12: naming the worker invocation is the only way to be one", function* () { // In no command table, so in no help output and no catalog. What makes it // safe is not obscurity: a worker that cannot present a pane's single-use @@ -533,26 +618,6 @@ describe("Tier TG — the tmux composite", () => { ); } - /** Where a fake server and its client fixtures meet. */ - function useScript(): Operation { - return resource(function* (provide) { - const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); - yield* writeTextFile(file, ""); - yield* ensure(function* () { - yield* rm(file, { force: true }); - }); - yield* provide(file); - }); - } - - /** The fixture that stands in for one tmux client. */ - function clientCommand(mode: "control" | "attach", script: string): readonly string[] { - const fixture = path.resolve("packages/cli/tests/fixtures/tmux-client.ts"); - const invocation = cliCommand([]); - // The same runtime the CLI runs under, pointed at the fixture instead. - return [invocation.command, "run", "--allow-all", fixture, mode, script]; - } - /** A composite over a fake server, with the pane workers stubbed out. */ function useComposite(options: { panes: number; @@ -1089,3 +1154,180 @@ function untilEvent(grid: TmuxGrid, kind: ControlEvent["kind"]): Operation ); })(); } + +/** + * Tier TG20 — the pane's physical endpoint + * (specs/executable-mdx-spec.md TG20, architecture commit 802b07df). + * + * A `` written inside a paired pane must run on *that pane's* + * terminal. Before the amendment it delegated down the launcher chain and + * reached the root foreground launcher — which on a real host inherits the root + * terminal, the one terminal a pane exists to avoid. It now stops at the + * composite's required pane operation. + * + * Nothing nearer intercepts here: no ``, no controlled launcher in + * front. The request goes to a real worker over a real socket, and a sentinel + * stands where the root foreground launcher would be — entering it at all is + * the failure this tier exists to catch. + */ +describe("Tier TG20 — a pane launch reaches its own worker", () => { + /** A composite over a fake server that really starts its pane workers. */ + function useLiveComposite(panes: number): Operation<{ + composite: TerminalComposite; + tmux: FakeTmux; + channels: PaneChannels; + }> { + return (function* () { + // The observer a foreground host installs beside the provider: teardown + // proves what it claims, and refuses without it. + yield* installPosixTerminalProcesses(); + const script = yield* useScript(); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + const channels = yield* usePaneChannels(panes); + const invocation = cliCommand([]); + const grid = yield* useTmuxGrid(tmux, { + session: "live", + columns: panes, + panes, + width: 160, + height: 48, + titles: Array.from({ length: panes }, (_, index) => `pane ${index}`), + workerCommand: (ordinal) => [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + channels.directory, + ], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + void grid; + const links: PaneLink[] = []; + for (let ordinal = 0; ordinal < panes; ordinal++) { + links.push(yield* channels.link(ordinal)); + } + const composite = paneComposite(links); + return { composite, tmux, channels }; + })(); + } + + it("TG20a: the exact argv, cwd and environment arrive at that pane's worker", function* () { + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20-${randomUUID()}.json`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + // Arguments a command parser would ruin, an environment entry only this + // launch names, and a working directory that is not the runner's. + const marker = "tg20marker"; + let started = 0; + const outcome = yield* composite.launch( + 0, + { + command: [ + "/bin/sh", + "-c", + `printf '%s' "$XMD_TG20:$PWD:$1" > "${evidence}"`, + "sh", + `a b;'"$${marker}`, + ], + cwd: tmpdir(), + env: { PATH: "/usr/bin:/bin", XMD_TG20: marker }, + }, + () => started++, + ); + + expect(outcome.exitCode).toBe(0); + // The spawn was reported once, by the worker that observed it. + expect(started).toBe(1); + const seen = yield* readTextFile(evidence); + const [env, cwd, argument] = seen.split(":"); + expect(env).toBe(marker); + expect(cwd).toBe(yield* until(realpath(tmpdir()))); + // Unchanged through the socket and past tmux, whose parser never saw it. + expect(argument).toBe(`a b;'"$${marker}`); + }); + + it("TG20b: the root foreground launcher is never entered", function* () { + const { composite } = yield* useLiveComposite(1); + const reached: string[] = []; + // A sentinel where the root launcher sits. A pane launch that delegated + // past its endpoint would arrive here — and on a real host that is the + // root terminal. + yield* installControlledLauncher({ + record: (request) => reached.push(request.command.join(" ")), + outcome: () => ({ exitCode: 0 }), + }); + + yield* composite.launch( + 0, + { command: ["/bin/echo", "pane"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + () => {}, + ); + + expect(reached).toEqual([]); + // And the sentinel is a live one: a *root* launch does reach it. + yield* scoped(function* () { + yield* reserveTerminal(); + yield* nativeLaunch({ command: ["/bin/echo", "root"], cwd: path.resolve(".") }); + }); + expect(reached).toEqual(["/bin/echo root"]); + }); + + it("TG20c: distinct panes launch concurrently", function* () { + const { composite } = yield* useLiveComposite(2); + const both = withResolvers(); + let live = 0; + + // Each launch blocks until the other has started. A pair that had to share + // a terminal would wait for a start that cannot happen. + const outcomes = yield* all([ + composite.launch( + 0, + { command: ["/bin/sleep", "0.2"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + () => { + live++; + if (live === 2) { + both.resolve(); + } + }, + ), + composite.launch( + 1, + { command: ["/bin/sleep", "0.2"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + () => { + live++; + if (live === 2) { + both.resolve(); + } + }, + ), + ]); + + yield* both.operation; + expect(live).toBe(2); + expect(outcomes.map((outcome) => outcome.exitCode)).toEqual([0, 0]); + }); + + it("TG20d: a composite that cannot run a pane's launch refuses", function* () { + const { composite } = yield* useLiveComposite(1); + let refusal = ""; + try { + // No such pane. There is no fallback to fall back to: putting this on + // the root terminal is the one thing that must not happen. + yield* composite.launch( + 3, + { command: ["/bin/echo", "nowhere"], cwd: path.resolve("."), env: {} }, + () => {}, + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot run that pane's launch"); + }); +}); diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 88635900..d52313a6 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2252,7 +2252,9 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { // by being here: it reserves and flushes this pane instead of competing // for the run's one foreground lease, and the child it starts is what // makes this pane ready. - yield* usePaneNativeLauncher(claim, flushPane); + yield* usePaneNativeLauncher(claim, flushPane, (request, spawned) => + composite.launch(pane.ordinal, request, spawned), + ); const siteEnv = yield* env; // Starts from what the grid site can see and keeps its own writes: a // binding this pane makes is visible to later work in this pane and to diff --git a/packages/core/src/terminal/pane-launcher.ts b/packages/core/src/terminal/pane-launcher.ts index 68c01daf..bce408d0 100644 --- a/packages/core/src/terminal/pane-launcher.ts +++ b/packages/core/src/terminal/pane-launcher.ts @@ -23,6 +23,7 @@ import { resource } from "effection"; import type { Operation } from "effection"; import { NativeLauncher } from "@executablemd/runtime"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; import type { TerminalPaneClaim } from "./authority.ts"; @@ -33,9 +34,22 @@ import type { TerminalPaneClaim } from "./authority.ts"; * belongs to the pane, so it goes where the pane's text goes rather than to the * root's streams — which the native UI is not drawing over. */ +/** + * How a pane actually runs a native UI: the composite's operation for this + * pane's authored ordinal, bound by core and closed over here. + * + * The ordinal lives in this closure and nowhere else. It reaches no request, no + * Agent request, no session key, no durable phase, no result and no diagnostic. + */ +export type RunInPane = ( + request: NativeLaunchRequest, + spawned: () => void, +) => Operation; + export function* usePaneNativeLauncher( claim: TerminalPaneClaim, flush: () => Operation, + runInPane: RunInPane, ): Operation { yield* NativeLauncher.around({ /** @@ -60,11 +74,18 @@ export function* usePaneNativeLauncher( *flush() { yield* flush(); }, - *launch([request, spawned], next) { - // The exact request, untouched, to whichever host launcher is installed. - // What this adds is a listener: the pane is ready when the runtime says - // the child started, and at no earlier moment. - return yield* next(request, () => { + *launch([request, spawned]) { + // The end of the chain, and deliberately so. Middleware written nearer + // the authored launch composes in front of this and may observe, wrap, + // refuse or short-circuit before it delegates here; what it must not do + // is reach past it, because past it is the root foreground launcher and + // the root terminal is the one thing a pane exists to avoid. + // + // The request crosses exactly as it arrived. What this adds is the + // ordinal — from the closure, never from the request — and a listener, so + // the pane is ready when the runtime says the child started and at no + // earlier moment. + return yield* runInPane(request, () => { claim.ready(); spawned(); }); diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index ade5e05b..9905a2b4 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -320,6 +320,20 @@ function* runDoc(doc: string, options: RunOptions = {}): Operation { const composite = yield* prepareControlledComposite(request, { log: providerLog, close: () => settled.operation, + // The pane endpoint a paired pane's `` now + // reaches. It records what it was asked to start and answers, + // exactly as the host launcher used to — so these rows are + // about the pane, not about a launcher having moved. + *launch(_ordinal, asked, spawned) { + launcher.requests.push(asked); + launcher.order.push("launch"); + if (options.start) { + yield* options.start(asked, spawned); + } else { + spawned(); + } + return options.outcome ?? { exitCode: 0 }; + }, // deno-lint-ignore require-yield *onPrepare(asked) { panes = asked.panes.length; @@ -1007,14 +1021,19 @@ describe("Tier SP — a launch inside a terminal pane", () => { const asked: string[] = []; yield* scoped(function* () { - yield* installControlledLauncher({ - wait: () => - (function* () { - childLive.resolve(); - yield* childMayExit.operation; - })(), - }); - yield* usePaneNativeLauncher(claim, function* () {}); + // The composite's pane endpoint, which is what the pane launcher now + // delegates to. It stands in for a provider here, and behaves like one: + // it reports the start and answers when the child is done. + yield* usePaneNativeLauncher( + claim, + function* () {}, + function* (_request, spawned) { + spawned(); + childLive.resolve(); + yield* childMayExit.operation; + return { exitCode: 0 }; + }, + ); const first = yield* spawn(function* () { // The order a launch composes in: this pane, then the lease, then the diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 4acf29a8..8c0ed596 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1831,8 +1831,8 @@ describe("Tier TG — durability and replay", () => { // The provider's counters went up and came back down. Reading them only at // the end would be true of counters that never moved. - expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0 }); - expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0 }); + expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0, launches: 0 }); + expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0, launches: 0 }); // And the foreground lease came back: it was taken and given back twice // over once the run was done. expect(leases).toBe(2); diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index b03275a8..23a4e5fd 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -26,6 +26,7 @@ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launcher.ts"; /** One pane the provider is asked to present, by its authored ordinal. */ export interface TerminalPaneRequest { @@ -124,6 +125,32 @@ export interface TerminalComposite { * never started leaves the latch alone and the grid never attaches. */ shell(ordinal: number, spawned: () => void): Operation; + /** + * Run one native launch in one pane, on that pane's terminal. + * + * This is the physical endpoint for a `` written inside a + * paired pane. Core closes its pane-scoped launcher over this operation and + * the pane's authored ordinal, so the ordinal stays in a live closure and + * enters no request, session key, durable phase, result or diagnostic. What + * crosses is the exact command vector, working directory and environment the + * Agent provider supplied. + * + * Required of every composite, and deliberately not optional: a provider that + * cannot execute a pane launch refuses here. Falling back would put a native + * UI on the root terminal — the one terminal a pane exists to avoid. + * + * `spawned` is the pane's readiness latch, on the same terms as `shell()`: + * called for the child's runtime spawn event and nothing earlier. + * + * Kept apart from `shell()` because they answer different questions. `shell()` + * derives its executable from live host policy; this runs the request it is + * given. + */ + launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, + ): Operation; /** * Settle when the reader closes or leaves the composite. * @@ -215,6 +242,8 @@ export interface TerminalProviderResources { attached: number; /** Shells started whose outcome has not been returned. */ shells: number; + /** Pane launches started whose outcome has not been returned. */ + launches: number; } /** A fresh, empty record. */ @@ -222,7 +251,7 @@ export function terminalProviderLog(): TerminalProviderLog { return { events: [], shown: new Map(), - live: { composites: 0, attached: 0, shells: 0 }, + live: { composites: 0, attached: 0, shells: 0, launches: 0 }, }; } @@ -249,6 +278,18 @@ export interface ControlledCompositeOptions { */ onUpdate?: (ordinal: number, state: TerminalPaneState) => void; shell?: (ordinal: number, spawned: () => void) => Operation; + /** + * What a pane launch does, in place of starting a native UI. + * + * Left out, a launch refuses — which is what a composite that cannot execute + * one must do, and what keeps a suite that says nothing about launching from + * quietly passing one to the root terminal. + */ + launch?: ( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, + ) => Operation; close?: () => Operation; } @@ -310,6 +351,18 @@ export function prepareControlledComposite( log.live.shells--; } }, + *launch(ordinal, request, spawned) { + log.events.push(`launch:${generation}:${ordinal}`); + if (options.launch === undefined) { + throw new Error(`this composite cannot run a native launch in pane ${ordinal}`); + } + log.live.launches++; + try { + return yield* options.launch(ordinal, request, spawned); + } finally { + log.live.launches--; + } + }, *closed() { if (options.close) { yield* options.close(); diff --git a/packages/test-agent/tests/terminal-grid-native-launch.test.ts b/packages/test-agent/tests/terminal-grid-native-launch.test.ts index 497749a8..87341cfb 100644 --- a/packages/test-agent/tests/terminal-grid-native-launch.test.ts +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -82,6 +82,14 @@ interface Run { launches: NativeLaunchRequest[]; /** Every launch the *host's* launcher was asked to start. */ hostLaunches: NativeLaunchRequest[]; + /** + * Every launch ``'s own launcher was asked to start. + * + * A pane launch must not reach it: the pane launcher is the physical + * endpoint, and anything past it is a terminal that is not the pane's. A + * *root* launch does reach it, which is how the two stay distinguishable. + */ + agentLaunches: NativeLaunchRequest[]; sessions: NativeSessionReport[]; events: DurableEvent[]; /** Everything the controlled composite did, in order. */ @@ -157,6 +165,7 @@ function markerOf(request: NativeLaunchRequest, sessions: NativeSessionReport[]) function* runJourney(options: RunOptions = {}): Operation { const launches: NativeLaunchRequest[] = []; const hostLaunches: NativeLaunchRequest[] = []; + const agentLaunches: NativeLaunchRequest[] = []; const sessions: NativeSessionReport[] = []; const providerLog = terminalProviderLog(); const states: string[] = []; @@ -217,20 +226,11 @@ function* runJourney(options: RunOptions = {}): Operation { // The launcher `` installs for its own scope. A pane's // launcher composes in front of it, so this is what a pane launch // reaches once the pane has answered for the terminal. + // ``'s own launcher. A pane launch must not arrive here — it + // stops at the pane endpoint — so this is a sentinel for everything but a + // root launch. yield* NativeLaunchObserver.set({ - record: (asked) => launches.push(asked), - wait: (asked) => - (function* () { - const marker = markerOf(asked, sessions); - startedOne(marker); - try { - yield* child(marker, order); - } finally { - // Reached however the launch left — returned, or cancelled by the - // reader closing the grid. - order.push(`left:${marker}`); - } - })(), + record: (asked) => agentLaunches.push(asked), outcome: (asked) => options.exits?.[markerOf(asked, sessions)] ?? { exitCode: 0 }, }); // A host launcher too, which is the wrong one for any of this to reach: @@ -293,6 +293,24 @@ function* runJourney(options: RunOptions = {}): Operation { } return { exitCode: 0 }; }, + // The pane's physical endpoint. A `` written + // in a paired pane arrives here, with the exact request the + // Agent provider built and an ordinal that never left core's + // closure. + *launch(_ordinal, asked, spawned) { + launches.push(asked); + const marker = markerOf(asked, sessions); + spawned(); + startedOne(marker); + try { + yield* child(marker, order); + } finally { + // Reached however the launch left — returned, or + // cancelled by the reader closing the grid. + order.push(`left:${marker}`); + } + return options.exits?.[marker] ?? { exitCode: 0 }; + }, }); yield* authority.present(asked, composite); return undefined; @@ -331,6 +349,7 @@ function* runJourney(options: RunOptions = {}): Operation { results: yield* testing.results, launches, hostLaunches, + agentLaunches, sessions, events: yield* stream.readAll(), composite: providerLog.events, @@ -351,6 +370,7 @@ function* runJourney(options: RunOptions = {}): Operation { results: yield* testing.results, launches, hostLaunches, + agentLaunches, sessions, events: yield* stream.readAll(), composite: providerLog.events, @@ -574,6 +594,9 @@ describe( expect(run.result.ok).toBe(true); expect(run.hostLaunches).toEqual([]); + // Nor ``'s own launcher: a pane launch stops at the pane + // endpoint, and everything past it is a terminal that is not the pane's. + expect(run.agentLaunches).toEqual([]); expect(run.launches.length).toBe(3); }); @@ -746,7 +769,11 @@ describe( // ownership — and it gets them, so the grid released every one. expect(run.result.ok ? "" : run.result.error.message).toBe(""); expect(run.results.map((result) => result.status)).toEqual(["pass"]); - expect(run.launches.length).toBe(3); + // Two at the pane endpoint and one at the root route, which is the + // distinction the pane endpoint exists to make: a launch written in a + // pane never reaches the terminal a root launch takes. + expect(run.launches.length).toBe(2); + expect(run.agentLaunches.length).toBe(1); // Neither refusal: not one still held by another owner, and not one left // owned by work that did not finish. An orderly close that finished is a // finish, and the session it used is ordinarily usable afterwards. From b87961fce81d6dc78cbac6a6ce32cb71fae9a418 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 07:38:34 -0400 Subject: [PATCH 10/15] =?UTF-8?q?=E2=9C=A8=20Complete=20the=20tmux=20termi?= =?UTF-8?q?nal-grid=20provider=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the three evidence gaps from 7511e777 and the six repairs. **TW13 now proves the worker.** The rejected environment switch is replaced by an injected child seam: `runPaneWorker` takes what it starts, so a suite can run the real worker in-process against a real channel with the one thing it cannot arrange in another process — a child whose settlement cannot say the pane is free. After `quiet:false` the worker clears no live entry, reports no settlement, starts no second child, and refuses. **TG20c is a discriminator.** Each child announces itself and blocks until both have; a serial pair would wait for a start that had not happened. **TG20e proves cancellation.** `runInPane()` owns it: registered before the launch is asked for, a cancellation sends the worker's cancel and waits for a settlement that proves the pane free. A cancelled launch does not return while its child is live — TG20e reads the child's own pid and finds it gone. **Process observation fails closed**, in `deno-terminal-processes.ts` behind the runtime-named boundary Deno, the compiled binary and their pane workers install. `kill(pid, 0)` establishes absence only for ESRCH; EPERM is a process that exists and this user may not signal, so it raises rather than reading "I may not ask" as "nothing is there". A `ps` that would not run is not an empty table. Only `lsof -t`'s documented exit-1-with-no-output is read as "nobody". **Every listener is scope-owned.** No `.once()` and no `{ once: true }` in the touched production code: named handlers, removed by the scope that installed them, and kept installed through any wait they resolve. The worker's SIGINT, SIGQUIT and SIGTSTP handlers are its run scope's. TW14 counts them across event delivery, no delivery, startup failure and cancellation. **One ordered teardown.** `tearDown()` is idempotent and covers both core's `destroy()` and a preparation that failed halfway: detach and prove the visible client stopped, ask every worker to shut down, await each settlement, terminal sweep and goodbye, refuse on anything unproved, and only then stop the server and prove it gone. Sockets, their servers and the private directory come down after it, in the scopes that own them. **SIGHUP is cancellation, not a reader close.** A reader who detaches selects a close outcome; a terminal that is gone cancels the document through the ordinary structured path, runs the whole teardown, and lets no following sibling run. **Hosts state what they are.** Deno and compiled install the provider and the observer together; everyone else installs neither and still validates. TH1–TH3 cover a missing terminal, an unusable tmux, and a host with no provider. The inventory now says what was built and what the evidence is: fake tmux with real workers and real sockets, with real tmux behaviour on macOS remaining #726's. --- architecture.md | 2 +- packages/cli/src/terminal/attach-client.ts | 19 +- packages/cli/src/terminal/host.ts | 59 ++- packages/cli/src/terminal/pane-channel.ts | 66 ++- packages/cli/src/terminal/pane-child.ts | 21 +- packages/cli/src/terminal/pane-worker.ts | 93 +++- packages/cli/src/terminal/provider.ts | 133 +++++- packages/cli/src/terminal/tmux.ts | 7 +- packages/cli/tests/terminal-grid-tmux.test.ts | 441 +++++++++++++++--- packages/runtime/deno-terminal-processes.ts | 185 ++++++++ packages/runtime/mod.ts | 3 +- packages/runtime/terminal-processes.ts | 117 ----- .../runtime/tests/terminal-processes.test.ts | 94 +++- 13 files changed, 990 insertions(+), 250 deletions(-) create mode 100644 packages/runtime/deno-terminal-processes.ts diff --git a/architecture.md b/architecture.md index 8e737a88..4fde2778 100644 --- a/architecture.md +++ b/architecture.md @@ -4157,7 +4157,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results | built on the #641 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified after the #732 integration exposed the missing physical route and remains to be implemented; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts, whose evidence uses a fake tmux — real tmux behaviour on macOS is #726's; Node and Bun catalog and validate the same grids and install no operational provider | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified after the #732 integration exposed the missing physical route and remains to be implemented; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/cli/src/terminal/attach-client.ts index bb6ee5db..d29046a9 100644 --- a/packages/cli/src/terminal/attach-client.ts +++ b/packages/cli/src/terminal/attach-client.ts @@ -139,15 +139,26 @@ export function useAttachClient(options: { // The reader's terminal, handed straight through. stdio: "inherit", }); - child.once("spawn", () => { + // Named, and removed by this scope. `exit` stays through the wait that + // establishes the client is gone, which is exactly why it is removed with + // the resource rather than after one delivery. + const onSpawn = (): void => { if (child?.pid !== undefined) { started.resolve(child.pid); } - }); - child.once("error", (error: Error) => failed.reject(error)); - child.once("exit", () => { + }; + const onError = (error: Error): void => failed.reject(error); + const onExit = (): void => { gone = true; exited.resolve(); + }; + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); + yield* ensure(() => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + child?.off("exit", onExit); }); // The pid, or whatever arrived instead of a start. diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/terminal/host.ts index b935cb41..f33edefa 100644 --- a/packages/cli/src/terminal/host.ts +++ b/packages/cli/src/terminal/host.ts @@ -14,11 +14,11 @@ * already gives when no provider is installed. */ -import { ensure, resource, withResolvers } from "effection"; +import { ensure, race, resource, withResolvers } from "effection"; import type { Operation } from "effection"; import process from "node:process"; -import { installTerminalGridProfile } from "@executablemd/core"; -import { command as hostCommand } from "@executablemd/runtime"; +import { Execution, installTerminalGridProfile } from "@executablemd/core"; +import { command as hostCommand, installDenoTerminalProcesses } from "@executablemd/runtime"; import { installTmuxGridProvider, TMUX_PROVIDER } from "./provider.ts"; import type { TmuxProviderDependencies } from "./provider.ts"; import { paneEnvironment } from "./tmux.ts"; @@ -38,6 +38,51 @@ export function* unsupportedTerminalGrid(): Operation { yield* installTerminalGridProfile(); } +/** + * Make the host's terminal going away cancel the document. + * + * Not a reader close. A reader who detaches has finished with a grid, and the + * grid settles with a reader-close outcome and the document carries on. A + * terminal that is *gone* is not a decision about this grid — it is the run + * losing the thing every part of it was drawing on, so the document is + * cancelled through the ordinary structured path: the grid's whole teardown + * runs, and no following sibling gets to go. + */ +export function useHangupCancellation(hangup: Operation): Operation { + return Execution.around({ + *document([request], next) { + const outcome = yield* race([ + (function* () { + yield* next(request); + return "done"; + })(), + (function* () { + yield* hangup; + return "hangup"; + })(), + ]); + if (outcome === "hangup") { + // The losing side of the race is cancelled, which is the whole point: + // the grid comes down through the same teardown a reader close uses, + // and this run stops rather than continuing on a terminal it no longer + // has. + throw new TerminalLost(); + } + }, + }); +} + +/** The host's terminal went away while the document was still running. */ +export class TerminalLost extends Error { + override name = "TerminalLost"; + constructor() { + super( + "this run's terminal went away, so the document was stopped. Anything it " + + "had shown is gone with the terminal; nothing after the point it stopped ran.", + ); + } +} + /** The terminal this run is drawing on, as tmux needs to know it. */ function windowSize(): { columns: number; rows: number } { // A terminal that cannot say gets the sizes tmux itself defaults to, which is @@ -65,6 +110,8 @@ export function useHangup(): Operation> { const onHangup = (): void => hung.resolve(); process.on("SIGHUP", onHangup); yield* ensure(() => { + // Removed with the run that installed it. A listener that outlived its + // grid would answer for a terminal the next one is using. process.off("SIGHUP", onHangup); }); yield* provide(hung.operation); @@ -83,15 +130,19 @@ export function foregroundTerminalGrid( ): TerminalGridInstaller { return function* (): Operation { const hangup = yield* useHangup(); + // The observer goes in beside the provider, in the same scope: a host that + // presents grids is exactly the host that has to prove a pane is free, and + // one that installs neither refuses rather than guessing at either. + yield* installDenoTerminalProcesses(); yield* installTmuxGridProvider({ isTerminal: () => process.stdout.isTTY === true, env: paneEnvironment(process.env), workerCommand: (ordinal, directory) => hostCommand([PANE_WORKER_COMMAND, String(ordinal), directory]), size: windowSize, - hangup: () => hangup, ...overrides, }); yield* installTerminalGridProfile({ provider: TMUX_PROVIDER, label: TMUX_PROVIDER }); + yield* useHangupCancellation(hangup); }; } diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 42c4871f..623abfed 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -123,12 +123,17 @@ export function usePaneChannels( // directory's removal has to wait for is the closures themselves. yield* ensure(function* () { const closings: Operation[] = []; + const counted = (): void => { + closedCount++; + }; for (const socket of live) { - closings.push(closed(socket)); + // Asked for before the destroy, so the listener is there when the close + // it waits for arrives. + closings.push(closed(socket, counted)); socket.destroy(); } for (const server of servers) { - closings.push(shut(server)); + closings.push(shut(server, counted)); server.close(); } for (const closing of closings) { @@ -150,21 +155,24 @@ export function usePaneChannels( const server = net.createServer((socket) => { live.add(socket); closable++; - socket.once("close", () => { + const onSocketClose = (): void => { live.delete(socket); - closedCount++; - }); + socket.off("close", onSocketClose); + }; + socket.on("close", onSocketClose); arrivals.send({ ordinal, socket }); }); servers.push(server); closable++; - server.once("close", () => { - closedCount++; - }); const listening = withResolvers(); - server.once("error", (error: Error) => listening.reject(error)); + const onListenError = (error: Error): void => listening.reject(error); + server.on("error", onListenError); server.listen(paneSocketPath(directory, ordinal), () => listening.resolve()); - yield* listening.operation; + try { + yield* listening.operation; + } finally { + server.off("error", onListenError); + } } function* admit(ordinal: number, socket: Socket): Operation { @@ -229,25 +237,51 @@ export function usePaneChannels( } /** Settle once this socket has closed, whether or not it already had. */ -function closed(socket: Socket): Operation { +function closed(socket: Socket, onClosed: () => void): Operation { + // Attached now, awaited later. The caller asks for this *before* destroying + // the socket, so a listener attached lazily would miss the close it is + // waiting for — and the directory would go while the socket was still open. const done = withResolvers(); + const onClose = (): void => { + onClosed(); + done.resolve(); + }; if (socket.destroyed) { + onClosed(); done.resolve(); } else { - socket.once("close", () => done.resolve()); + socket.on("close", onClose); } - return done.operation; + return (function* (): Operation { + try { + yield* done.operation; + } finally { + // Removed synchronously when the wait is over, however it ends. + socket.off("close", onClose); + } + })(); } /** Settle once this server has stopped listening. */ -function shut(server: Server): Operation { +function shut(server: Server, onClosed: () => void): Operation { const done = withResolvers(); + const onClose = (): void => { + onClosed(); + done.resolve(); + }; if (!server.listening) { + onClosed(); done.resolve(); } else { - server.once("close", () => done.resolve()); + server.on("close", onClose); } - return done.operation; + return (function* (): Operation { + try { + yield* done.operation; + } finally { + server.off("close", onClose); + } + })(); } /** A connection that has said nothing for long enough to be nobody. */ diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts index b44cca38..e0bd7214 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/cli/src/terminal/pane-child.ts @@ -126,15 +126,18 @@ export function usePaneChild( // or capture what passes. stdio: "inherit", }); - child.once("spawn", () => { + // Named, and removed by the scope that installed them. `exit` in + // particular has to stay through the settlement that waits on it, so it is + // removed with the resource rather than after its first delivery. + const onSpawn = (): void => { if (child?.pid !== undefined) { started.resolve(Ok(child.pid)); } - }); - child.once("error", (error: Error & { code?: string }) => { + }; + const onError = (error: Error & { code?: string }): void => { started.resolve(Err(new PaneStartFailure(error.code ?? error.message))); - }); - child.once("exit", (code: number | null, signal: string | null) => { + }; + const onExit = (code: number | null, signal: string | null): void => { const settled: PaneChildOutcome = {}; if (code !== null) { settled.exitCode = code; @@ -144,6 +147,14 @@ export function usePaneChild( } outcome = settled; exited.resolve(settled); + }; + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); + yield* ensure(() => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + child?.off("exit", onExit); }); yield* provide({ started: started.operation, exited: exited.operation, settle }); diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts index 07954006..24386c8d 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/cli/src/terminal/pane-worker.ts @@ -26,11 +26,11 @@ import net from "node:net"; import process from "node:process"; import { readTextFile, rm } from "@effectionx/fs"; -import { run, spawn, withResolvers } from "effection"; +import { ensure, resource, run, spawn, withResolvers } from "effection"; import type { Operation } from "effection"; -import { installPosixTerminalProcesses, processTable } from "@executablemd/runtime"; -import { usePaneChild, sweepHolders } from "./pane-child.ts"; -import type { PaneChild } from "./pane-child.ts"; +import { installDenoTerminalProcesses, processTable } from "@executablemd/runtime"; +import { sweepHolders, usePaneChild } from "./pane-child.ts"; +import type { PaneChild, PaneChildRequest } from "./pane-child.ts"; import { paneSocketPath, paneTokenPath, @@ -85,8 +85,12 @@ export function runPaneWorkerProcess(invocation: { ordinal: number; directory: string; }): Promise { - ignoreForegroundSignals(); - return run(() => runPaneWorker(invocation.ordinal, invocation.directory)); + return run(function* () { + // Inside the run scope, so the handlers go on before any work and come off + // with it — rather than living for the process's lifetime regardless. + yield* useForegroundSignals(); + yield* runPaneWorker(invocation.ordinal, invocation.directory); + }); } /** @@ -147,11 +151,27 @@ interface Live { * in it. Doing nothing is the correct handling: the child inherits default * dispositions across `exec`, so it receives the same signal and acts on it. */ -export function ignoreForegroundSignals(): void { - const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; - for (const name of foreground) { - process.on(name, () => {}); - } +export function useForegroundSignals(): Operation { + return resource(function* (provide) { + const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; + const ignore = (): void => {}; + for (const name of foreground) { + process.on(name, ignore); + } + yield* ensure(() => { + // Installed and removed by the scope that runs this worker, so a worker + // that has finished stops answering for a pane it no longer owns. + for (const name of foreground) { + process.off(name, ignore); + } + }); + yield* provide(); + }); +} + +/** How many handlers this process has for one signal. */ +export function foregroundSignalListeners(name: NodeJS.Signals): number { + return process.listenerCount(name); } function writeOut(text: string): Operation { @@ -167,8 +187,30 @@ function writeOut(text: string): Operation { * under `run()`; both are properties of the *process*, not of this operation, * which is why they are the entrypoint's to establish. */ -export function* runPaneWorker(ordinal: number, directory: string): Operation { - yield* installPosixTerminalProcesses(); +/** + * What a worker uses to start a child. + * + * A seam rather than a hard call, because the one thing a suite cannot arrange + * in another process is a child whose settlement *fails* — a real SIGKILL + * always works, and a real terminal sweep on a pane with no terminal always + * comes back empty. Substituting the child is how the worker's own behaviour on + * that path is observable at all; the alternative would be a fault switch in + * production code, which is not a trade worth making. + */ +export interface PaneWorkerDependencies { + useChild(request: PaneChildRequest, tty: string | undefined): Operation; + /** Whether to install the POSIX observer. A caller that has one says no. */ + observe?: boolean; +} + +export function* runPaneWorker( + ordinal: number, + directory: string, + deps: PaneWorkerDependencies = { useChild: usePaneChild }, +): Operation { + if (deps.observe !== false) { + yield* installDenoTerminalProcesses(); + } // Read once, then spent. A second worker for this pane finds no token, so it // has nothing to present and is refused by the parent. @@ -176,10 +218,27 @@ export function* runPaneWorker(ordinal: number, directory: string): Operation { + socket.destroy(); + }); const connected = withResolvers(); - socket.once("connect", () => connected.resolve()); - socket.once("error", (error: Error) => connected.reject(error)); - yield* connected.operation; + const onConnect = (): void => connected.resolve(); + const onConnectError = (error: Error): void => connected.reject(error); + socket.on("connect", onConnect); + socket.on("error", onConnectError); + try { + yield* connected.operation; + } finally { + // Removed synchronously, in the scope that installed them: a listener that + // outlived this wait would answer for a socket this worker has finished + // with. + socket.off("connect", onConnect); + socket.off("error", onConnectError); + } const inbound = readFrames(socket, (value) => ToWorkerSchema.parse(value)); const say = (message: FromWorker) => writeFrame(socket, message); @@ -254,7 +313,7 @@ export function* runPaneWorker(ordinal: number, directory: string): Operation; /** The window to lay panes out in. */ size(): { columns: number; rows: number }; - /** Settles when the host's own terminal goes away. */ - hangup(): Operation; /** How a private server is reached. Substituted only by this package's tests. */ createTmux?: (socket: string, env: Record) => Tmux; + /** What asking tmux its version does. Substituted only by this package. */ + askVersion?: () => Operation<{ code: number; stdout: string }>; } /** @@ -101,7 +102,11 @@ function usePresentedGrid( request: TerminalGridRequest, ): Operation { return resource(function* (provide) { - const probed = yield* probeTmux({ isTerminal: deps.isTerminal, env: deps.env }); + const probed = yield* probeTmux({ + isTerminal: deps.isTerminal, + env: deps.env, + ...(deps.askVersion === undefined ? {} : { askVersion: deps.askVersion }), + }); if (!probed.ok) { // Before a directory, a socket, a token, a server or a pane exists, so a // host that cannot present a grid leaves nothing behind for having tried. @@ -134,30 +139,79 @@ function usePresentedGrid( links.push(yield* channels.link(ordinal)); } - // The reader leaving, and the host's terminal going away, are the same kind - // of event: something outside the document decided this grid is over. Both - // settle `closed()`, and core takes it from there through its ordinary - // close — there is no second teardown path to keep honest. - const left = withResolvers(); - yield* spawn(function* () { - yield* deps.hangup(); - left.resolve(); - }); - let shown = 0; let visible: VisibleClient | undefined; + let torn = false; - yield* ensure(function* () { - // Asked to leave before anything else comes down, so the reader's - // terminal is restored by the client that took it. + /** + * The one teardown, in the one order, however this grid ends. + * + * Core calls it through `destroy()`; the finalizer calls it when core never + * got that far, which is what a preparation that failed halfway leaves. + * Idempotent, so both happening is one teardown rather than two half ones. + * + * The order is the contract, and every step is a proof rather than a + * request: + * + * detach the reader's client and establish it stopped + * → ask every worker to shut down + * → await each one's settlement, its terminal sweep and its goodbye + * → refuse if any of that could not be proved + * → stop the server and establish it is gone + * + * The private sockets, their servers and the directory come down after + * this, in the scopes that own them — which is why they are acquired + * outside it rather than closed here. + */ + function* tearDown(): Operation { + if (torn) { + return; + } + torn = true; + // The reader's client first, and asked rather than told: a client that + // detaches restores the terminal, and one that is killed cannot. if (visible !== undefined) { yield* grid.detach(visible); + visible = undefined; } for (const link of links) { - if (link.connected()) { - yield* link.send({ type: "shutdown" }); + if (!link.connected()) { + continue; + } + yield* link.send({ type: "shutdown" }); + // Its settlement, its final terminal sweep, and its goodbye. A worker + // that could not prove its pane free refuses here, and a channel that + // ended before saying so is a failure rather than a silent success. + let quiesced = false; + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + if (!quiesced) { + throw new TerminalTeardownFailed( + "a terminal pane stopped answering before it was proved free", + ); + } + break; + } + if (frame.type === "quiet") { + requireQuiescent(frame.settlement); + quiesced = true; + continue; + } + if (frame.type === "bye") { + if (frame.holders.some((holder) => !holder.gone)) { + throw new TerminalTeardownFailed("something still holds a terminal pane"); + } + break; + } } } + // And only now the server, which `stop()` proves gone rather than reports. + yield* grid.stop(); + } + + yield* ensure(function* () { + yield* tearDown(); }); yield* provide({ @@ -192,10 +246,13 @@ function usePresentedGrid( return yield* runInPane(links[ordinal], request, spawned); }, *closed() { - yield* race([left.operation, grid.detached()]); + // The reader leaving, and nothing else. A host hangup is not a reader + // close — it is the terminal going away, which cancels the grid through + // the ordinary structured path rather than selecting a close outcome. + yield* grid.detached(); }, *destroy() { - yield* grid.stop(); + yield* tearDown(); }, }); }); @@ -233,9 +290,35 @@ export function* runInPane( // for refuses, rather than putting a native UI on the root terminal. throw new Error("this terminal grid cannot run that pane's launch"); } + const id = `launch-${link.ordinal}-${++started}`; + let settled = false; + // Registered before the launch is asked for: a cancellation between asking + // and hearing back must still end the child. Cancelling is not "stop waiting" + // — it is "ask the pane to stop, and do not come back until it has", because + // this operation returning is what lets the grid above it come down. + yield* ensure(function* () { + if (settled || !link.connected()) { + return; + } + yield* link.send({ type: "cancel", id }); + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + throw new Error("the terminal pane stopped answering before its child was settled"); + } + if (frame.type === "quiet") { + requireQuiescent(frame.settlement); + return; + } + if (frame.type === "exited") { + requireQuiescent(frame.settlement); + return; + } + } + }); yield* link.send({ type: "launch", - id: `launch-${link.ordinal}-${++started}`, + id, argv: [...request.command], cwd: request.cwd, env: request.env ?? {}, @@ -254,12 +337,16 @@ export function* runInPane( continue; } if (frame.type === "busy") { + settled = true; throw new Error("that terminal pane already has a live child"); } if (frame.type === "start-failed") { + settled = true; throw new Error("the terminal pane's child could not be started"); } if (frame.type === "exited") { + // The worker sends this only once its settlement proved the pane free. + settled = true; const outcome: NativeLaunchOutcome = {}; if (frame.exitCode !== undefined) { outcome.exitCode = frame.exitCode; diff --git a/packages/cli/src/terminal/tmux.ts b/packages/cli/src/terminal/tmux.ts index c9be63cf..3ac3ca45 100644 --- a/packages/cli/src/terminal/tmux.ts +++ b/packages/cli/src/terminal/tmux.ts @@ -119,11 +119,16 @@ const REQUIRED_TMUX = { major: 3, minor: 0 }; export function* probeTmux(options: { readonly isTerminal: () => boolean; readonly env: Record; + /** What asking tmux its version does. Substituted only by this package. */ + readonly askVersion?: () => Operation<{ code: number; stdout: string }>; }): Operation> { if (!options.isTerminal()) { return Err(new TmuxUnavailableError("this invocation has no terminal")); } - const result = yield* exec("tmux", { arguments: ["-V"], env: options.env }).join(); + const result = + options.askVersion === undefined + ? yield* exec("tmux", { arguments: ["-V"], env: options.env }).join() + : yield* options.askVersion(); if (result.code !== 0) { return Err(new TmuxUnavailableError("tmux is not installed or would not run")); } diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 67561470..5c44c0db 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -16,20 +16,35 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { all, ensure, race, resource, scoped, sleep, until, withResolvers } from "effection"; +import { + all, + ensure, + Ok, + race, + resource, + scoped, + sleep, + spawn, + until, + withResolvers, +} from "effection"; import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; -import { exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; +import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; import { realpath } from "node:fs/promises"; import { installControlledLauncher, nativeLaunch, reserveTerminal } from "@executablemd/runtime"; import type { TerminalComposite } from "@executablemd/runtime"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { installPosixTerminalProcesses, TerminalProcesses } from "@executablemd/runtime"; +import { + installDenoTerminalProcesses, + processReachable, + TerminalProcesses, +} from "@executablemd/runtime"; import type { SignalDelivery } from "@executablemd/runtime"; import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; @@ -43,7 +58,15 @@ import { } from "../src/terminal/layout.ts"; import type { LayoutCell } from "../src/terminal/layout.ts"; import { usePaneChannels } from "../src/terminal/pane-channel.ts"; -import { runInPane } from "../src/terminal/provider.ts"; +import { runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; +import { unsupportedTerminalGrid } from "../src/terminal/host.ts"; +import { + installTerminalProvider, + registerTerminalProvider, + useTerminalInstallation, +} from "@executablemd/core"; +import { TerminalGrids } from "@executablemd/runtime"; +import { readdir } from "node:fs/promises"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { FromWorkerSchema, @@ -52,10 +75,14 @@ import { writeFrame, } from "../src/terminal/pane-protocol.ts"; import { + foregroundSignalListeners, PANE_WORKER_COMMAND, paneWorkerInvocation, - requireQuiescent, + runPaneWorker, + useForegroundSignals, } from "../src/terminal/pane-worker.ts"; +import { usePaneChild } from "../src/terminal/pane-child.ts"; +import type { PaneChild, PaneChildOutcome } from "../src/terminal/pane-child.ts"; import type { FromWorker, Settlement, ToWorker } from "../src/terminal/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ @@ -100,6 +127,93 @@ function clientCommand(mode: "control" | "attach", script: string): readonly str return [invocation.command, "run", "--allow-all", fixture, mode, script]; } +/** Every listener this process holds, across the names this code installs. */ +function processListeners(): number { + return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( + (total, name) => total + foregroundSignalListeners(name), + 0, + ); +} + +/** + * Open a grid through the provider, with the host's prerequisites answered by + * this row rather than by the machine. + * + * Goes through the real factory and the real installation handshake, so what a + * refusal proves is what a document would meet. + */ +function useProbedProvider(options: { + isTerminal: () => boolean; + version?: string; +}): Operation { + return (function* (): Operation { + const authority = yield* useTerminalInstallation(); + yield* registerTerminalProvider( + "tmux", + tmuxGridProvider({ + isTerminal: options.isTerminal, + env: { PATH: "/usr/bin:/bin" }, + // deno-lint-ignore require-yield + *workerCommand() { + return []; + }, + size: () => ({ columns: 80, rows: 24 }), + ...(options.version === undefined + ? {} + : { + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: options.version ?? "" }; + }, + }), + }), + ); + yield* installTerminalProvider("tmux", { label: "tmux" }, authority); + yield* TerminalGrids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + })(); +} + +/** A directory a row can leave markers in. */ +function useScratch(): Operation { + return resource(function* (provide) { + const room = path.join(tmpdir(), `xmd-tg20-${randomUUID()}`); + yield* ensureDir(room); + yield* ensure(function* () { + yield* rm(room, { recursive: true, force: true }); + }); + yield* provide(room); + }); +} + +/** Everything gone, for rows whose subject is not the observation. */ +function useDeadObserver(): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "absent"; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); +} + /** A composite whose pane endpoint is the production one, over these links. */ function paneComposite(links: readonly PaneLink[]): TerminalComposite { const refuse = (): never => { @@ -505,45 +619,142 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(bye.type).toBe("bye"); }); - it("TW13: a settlement that proved nothing frees no pane", function* () { - // The rule every downstream step is conditional on: clearing the pane, - // reporting a launch settled, admitting the next one, letting teardown - // succeed. Stated here rather than end-to-end, because a pane whose sweep - // cannot come back empty is not something a suite can arrange in another - // process without putting a fault switch in the worker itself. - const proved: Settlement = { method: "exited", quiet: true, swept: [], holders: [] }; - requireQuiescent(proved); - - const survivor: Settlement = { - method: "killed", - quiet: false, - child: 100, - swept: [{ pid: 200, gone: false }], - holders: [], - }; - const held: Settlement = { - method: "exited", - quiet: false, - child: 100, - swept: [], - holders: [{ pid: 900, gone: false }], + it("TW13: after a settlement that proved nothing, the pane stays unavailable", function* () { + // The worker itself, run in this process against a real channel, with the + // one thing a suite cannot arrange in another process substituted: a child + // whose settlement cannot say the pane is free. A real SIGKILL always + // works, and a sweep of a pane with no terminal always comes back empty. + const channels = yield* usePaneChannels(1); + const stopping = withResolvers(); + const started: string[] = []; + let refusal = ""; + + const held: PaneChild = { + started: (function* () { + return Ok(4242); + })(), + exited: stopping.operation, + // Everything the worker can do has been done, and something still holds + // the pane's terminal. + *settle(): Operation { + return { + method: "killed", + quiet: false, + child: 4242, + swept: [], + holders: [{ pid: 900, gone: false }], + }; + }, }; - for (const [what, settlement] of [ - ["a survivor", survivor], - ["a holder", held], - ] as [string, Settlement][]) { - let refusal = ""; + + yield* spawn(function* () { try { - requireQuiescent(settlement); + yield* runPaneWorker(0, channels.directory, { + observe: false, + // deno-lint-ignore require-yield + *useChild(request) { + started.push(request.argv.join(" ")); + return held; + }, + }); } catch (error) { + // Read as a value: the refusal *is* the behaviour under test, so it + // must not end the row that is testing for it. refusal = error instanceof Error ? error.message : String(error); } - expect(`${what}: ${refusal.includes("could not be proved free")}`).toBe(`${what}: true`); - // Provider-neutral: it says what is still true, not which pane, session, - // socket or command it was. - expect(`${what}: ${/\bpane \d|socket|session/.test(refusal)}`).toBe(`${what}: false`); + }); + yield* useDeadObserver(); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "first", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + yield* untilFrame(link, "started"); + + // Cancelled, and the settlement cannot prove the pane free. Nothing + // downstream may follow: no success frame, no cleared pane, no next child. + yield* link.send({ type: "cancel", id: "first" }); + yield* link.send({ + type: "launch", + id: "second", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + + const said: string[] = []; + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + break; + } + said.push(frame.type); } - expect(() => requireQuiescent(held)).toThrow(); + + expect(refusal).toContain("could not be proved free"); + expect(said).not.toContain("quiet"); + expect(said).not.toContain("exited"); + // One child was ever started: the pane was never cleared, so the second + // launch had nothing to start in. + expect(started).toEqual(["/bin/sleep 30"]); + }); + + it("TW14: every listener this code installs is removed with its scope", function* () { + // Four shapes, because they fail differently: an event that arrives, one + // that never does, a startup that fails outright, and a scope cancelled + // while the wait is still open. + yield* installDenoTerminalProcesses(); + const before = foregroundSignalListeners("SIGINT"); + yield* scoped(function* () { + yield* useForegroundSignals(); + expect(foregroundSignalListeners("SIGINT")).toBe(before + 1); + expect(foregroundSignalListeners("SIGTSTP")).toBeGreaterThan(0); + }); + expect(foregroundSignalListeners("SIGINT")).toBe(before); + + // A child whose events arrive: the resource ends normally. + const counts: number[] = []; + yield* scoped(function* () { + const child = yield* usePaneChild( + { argv: ["/bin/echo", "listener"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + ); + yield* child.started; + yield* child.exited; + counts.push(processListeners()); + }); + // A child that never starts: `error` arrives instead of `spawn`. + yield* scoped(function* () { + const child = yield* usePaneChild( + { argv: [path.join(tmpdir(), "not-a-program")], cwd: path.resolve("."), env: {} }, + undefined, + ); + yield* child.started; + counts.push(processListeners()); + }); + // A scope cancelled while the child is still live and its wait still open. + yield* scoped(function* () { + const running = yield* spawn(function* () { + yield* scoped(function* () { + const child = yield* usePaneChild( + { argv: ["/bin/sleep", "30"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + ); + yield* child.started; + yield* child.exited; + }); + }); + yield* sleep(120); + yield* running.halt(); + counts.push(processListeners()); + }); + + // Every one of them left the process as it found it. + expect(new Set(counts).size).toBe(1); }); it("TW12: naming the worker invocation is the only way to be one", function* () { @@ -1180,7 +1391,7 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { return (function* () { // The observer a foreground host installs beside the provider: teardown // proves what it claims, and refuses without it. - yield* installPosixTerminalProcesses(); + yield* installDenoTerminalProcesses(); const script = yield* useScript(); const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); yield* ensure(() => { @@ -1281,37 +1492,81 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { it("TG20c: distinct panes launch concurrently", function* () { const { composite } = yield* useLiveComposite(2); - const both = withResolvers(); - let live = 0; + const room = yield* useScratch(); + + // Each child announces itself and then blocks until *both* have. Two + // children that ran one after the other could never get past this: the + // first would be waiting for a second that had not been started yet. + const child = (ordinal: number): string[] => [ + "/bin/sh", + "-c", + `printf '' > "${room}/started-${ordinal}"; ` + + `while [ ! -f "${room}/go" ]; do sleep 0.02; done`, + ]; + + const releasing = yield* spawn(function* () { + // Released by the starts themselves, never by elapsed time. + while (true) { + if ((yield* exists(`${room}/started-0`)) && (yield* exists(`${room}/started-1`))) { + yield* writeTextFile(`${room}/go`, ""); + return; + } + yield* sleep(15); + } + }); - // Each launch blocks until the other has started. A pair that had to share - // a terminal would wait for a start that cannot happen. const outcomes = yield* all([ composite.launch( 0, - { command: ["/bin/sleep", "0.2"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - () => { - live++; - if (live === 2) { - both.resolve(); - } - }, + { command: child(0), cwd: room, env: { PATH: "/usr/bin:/bin" } }, + () => {}, ), composite.launch( 1, - { command: ["/bin/sleep", "0.2"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - () => { - live++; - if (live === 2) { - both.resolve(); - } - }, + { command: child(1), cwd: room, env: { PATH: "/usr/bin:/bin" } }, + () => {}, ), ]); + yield* releasing; - yield* both.operation; - expect(live).toBe(2); expect(outcomes.map((outcome) => outcome.exitCode)).toEqual([0, 0]); + // Both were live at the same moment: the release only happened once both + // had announced themselves, and neither could finish before it. + expect(yield* exists(`${room}/go`)).toBe(true); + }); + + it("TG20e: a cancelled pane launch does not return while its child lives", function* () { + const { composite } = yield* useLiveComposite(1); + const room = yield* useScratch(); + yield* installDenoTerminalProcesses(); + + // Writes its pid, then stays. Nothing here ends it but the cancellation. + const launching = yield* spawn(() => + composite.launch( + 0, + { + command: ["/bin/sh", "-c", `echo $$ > "${room}/pid"; while true; do sleep 0.05; done`], + cwd: room, + env: { PATH: "/usr/bin:/bin" }, + }, + () => {}, + ), + ); + + // Live, and known by pid — a fact this run produced. + while (!(yield* exists(`${room}/pid`))) { + yield* sleep(15); + } + const pid = Number((yield* readTextFile(`${room}/pid`)).trim()); + expect(pid).toBeGreaterThan(0); + expect(yield* processReachable(pid)).toBe(true); + + yield* launching.halt(); + + // The cancellation asked the pane to stop and waited for it to prove that + // it had. Returning while the child was still live is the failure this row + // exists for. + expect(yield* processReachable(pid)).toBe(false); }); it("TG20d: a composite that cannot run a pane's launch refuses", function* () { @@ -1331,3 +1586,71 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { expect(refusal).toContain("cannot run that pane's launch"); }); }); + +/** + * Tier TH — which hosts open a grid, and which only describe one + * (architecture.md §Interactive terminal grids). + * + * The Deno source entrypoint and the compiled binary present grids when the + * invocation has a terminal and a usable tmux. Node and Bun keep the same + * language and validation and install no operational provider, so a document + * that asks for a grid there is refused before a pane starts. + */ +describe("Tier TH — host installation", () => { + it("TH1: without a terminal, a grid refuses before anything exists", function* () { + const before = yield* until(readdir(tmpdir())); + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ isTerminal: () => false }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + expect(refusal).toContain("cannot open a terminal grid"); + expect(refusal).toContain("no terminal"); + // Before a directory, a socket, a token, a worker, a server or a pane: the + // host left nothing behind for having tried. + const after = yield* until(readdir(tmpdir())); + expect(after.filter((name) => name.startsWith("xmd-grid-")).length).toBe( + before.filter((name) => name.startsWith("xmd-grid-")).length, + ); + }); + + it("TH2: without a usable tmux, a grid refuses the same way", function* () { + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ + isTerminal: () => true, + // A tmux far too old for an explicit layout string. + version: "tmux 1.8", + }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot open a terminal grid"); + expect(refusal).toContain("older than tmux"); + }); + + it("TH3: a host that installs no provider still validates the grid", function* () { + // Node and Bun: the same language and the same validation, and core's own + // refusal rather than a provider that half-works. + yield* unsupportedTerminalGrid(); + let refusal = ""; + try { + yield* TerminalGrids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("no terminal provider is installed"); + }); +}); diff --git a/packages/runtime/deno-terminal-processes.ts b/packages/runtime/deno-terminal-processes.ts new file mode 100644 index 00000000..2acc4b1d --- /dev/null +++ b/packages/runtime/deno-terminal-processes.ts @@ -0,0 +1,185 @@ +/** + * What Deno and the compiled binary can observe about processes and terminals. + * + * The interface lives in `terminal-processes.ts`, shared by everything that + * asks. This is the answer, and it is host-specific: `ps` and `lsof` are what a + * POSIX host has. Node and Bun install neither this nor the tmux provider, so a + * grid there is refused before a pane starts rather than being observed badly. + * + * Every path fails closed, because every caller is deciding whether something + * may still be running: + * + * - `kill(pid, 0)` establishes *absence* only for `ESRCH`. `EPERM` means a + * process exists that this user may not signal — the opposite of absence — + * and every other error means the question was not answered. Both raise. + * - a `ps` that would not run is not an empty process table. An empty table + * would make every descendant and group sweep trivially satisfied. + * - `lsof -t` exits non-zero with no output when nothing holds the file, and + * that one documented result is the only failure read as "nobody". Any other + * numeric failure raises rather than becoming an empty holder list. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; +import { execFile } from "node:child_process"; +import process from "node:process"; +import { TerminalProcesses, TerminalProcessesUnavailableError } from "./terminal-processes.ts"; +import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./terminal-processes.ts"; + +/** What one observation ran, so a suite can answer for it. */ +export interface ProcessProbes { + /** Run a tool, and report its status and output. */ + run(command: string, args: readonly string[]): Operation<{ code: number; stdout: string }>; + /** Deliver a signal. Throws with a `code` the way `process.kill` does. */ + kill(pid: number, signal: number | TerminalSignal): void; +} + +/** The real ones. */ +export function posixProcessProbes(): ProcessProbes { + return { + run(command, args) { + return until( + new Promise<{ code: number; stdout: string }>((resolve, reject) => { + execFile(command, [...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { + if (error && !("code" in error && typeof error.code === "number")) { + // The tool did not run at all. That is not a status. + reject(error); + return; + } + const code = + error && "code" in error && typeof error.code === "number" ? error.code : 0; + resolve({ code, stdout }); + }); + }), + ); + }, + kill(pid, signal) { + process.kill(pid, signal); + }, + }; +} + +/** The error's `code`, when it has one. */ +function codeOf(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +} + +/** Install the POSIX observer for this host. */ +export function* installDenoTerminalProcesses( + probes: ProcessProbes = posixProcessProbes(), +): Operation { + yield* TerminalProcesses.around( + { + *table(): Operation { + const listed = yield* probes.run("ps", ["-axo", "pid=,ppid=,pgid=,tty=,tpgid=,command="]); + if (listed.code !== 0) { + // Not an empty table: an empty one would satisfy every descendant and + // group sweep without having looked at anything. + throw new TerminalProcessesUnavailableError( + "this host could not read its process table, so nothing about a pane's " + + "processes has been established.", + ); + } + return readTable(listed.stdout); + }, + *holders([device]): Operation { + const found = yield* probes.run("lsof", ["-t", device]); + const pids = found.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^\d+$/.test(line)) + .map(Number); + if (found.code === 0) { + return pids; + } + // The one documented failure: `lsof -t` exits 1 with no output when + // nothing holds the file. Anything else is a question that was not + // answered, and "nobody holds it" is not the safe guess. + if (found.code === 1 && pids.length === 0) { + return []; + } + throw new TerminalProcessesUnavailableError( + "this host could not enumerate the holders of a terminal, so it is not " + + "established that nobody holds it.", + ); + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + try { + probes.kill(pid, signal); + return "delivered"; + } catch (error) { + // Gone already is the outcome the signal was asking for. Anything + // else is a delivery that did not happen, and says nothing about + // whether the process stopped. + return codeOf(error) === "ESRCH" ? "absent" : "refused"; + } + }, + // deno-lint-ignore require-yield + *reachable([pid]): Operation { + try { + // Signal 0 delivers nothing: it asks the kernel whether the pid is + // reachable, which is the whole question. + probes.kill(pid, 0); + return true; + } catch (error) { + const code = codeOf(error); + if (code === "ESRCH") { + return false; + } + // `EPERM` is a process this user may not signal — a process that + // exists. Reading it as absence would be reading "I may not ask" as + // "nothing is there". + throw new TerminalProcessesUnavailableError( + `this host could not establish whether a process is still running (${ + code ?? "unknown" + }).`, + ); + } + }, + }, + { at: "min" }, + ); +} + +/** One reading of `ps`, parsed row by row; anything unreadable is dropped. */ +function readTable(output: string): readonly ProcessFacts[] { + const rows: ProcessFacts[] = []; + for (const line of output.split("\n")) { + const row = readRow(line); + if (row !== undefined) { + rows.push(row); + } + } + return rows; +} + +function readRow(line: string): ProcessFacts | undefined { + const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(-?\d+)\s+(.*)$/.exec(line); + if (match === null) { + return undefined; + } + const [, pid, ppid, pgid, tty, tpgid, command] = match; + if ( + pid === undefined || + ppid === undefined || + pgid === undefined || + tty === undefined || + tpgid === undefined || + command === undefined + ) { + return undefined; + } + return { + pid: Number(pid), + ppid: Number(ppid), + pgid: Number(pgid), + tty, + tpgid: Number(tpgid), + command, + }; +} diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index 9d2619bc..06182ead 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -170,7 +170,6 @@ export { deliverSignal, establishQuiescence, groupMembers, - installPosixTerminalProcesses, paneOccupants, processReachable, processTable, @@ -188,6 +187,8 @@ export type { TerminalProcessHandler, TerminalSignal, } from "./terminal-processes.ts"; +export { installDenoTerminalProcesses, posixProcessProbes } from "./deno-terminal-processes.ts"; +export type { ProcessProbes } from "./deno-terminal-processes.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/runtime/terminal-processes.ts b/packages/runtime/terminal-processes.ts index ed464ee4..f962110b 100644 --- a/packages/runtime/terminal-processes.ts +++ b/packages/runtime/terminal-processes.ts @@ -24,10 +24,7 @@ */ import { type Api, createApi } from "@effectionx/context-api"; -import { until } from "effection"; import type { Operation } from "effection"; -import { execFile } from "node:child_process"; -import process from "node:process"; /** One process, as the host's table describes it. */ export interface ProcessFacts { @@ -247,117 +244,3 @@ export function establishQuiescence(occupants: PaneOccupants): Operation { - yield* TerminalProcesses.around( - { - *table(): Operation { - const output = yield* until(run("ps", ["-axo", "pid=,ppid=,pgid=,tty=,tpgid=,command="])); - return readTable(output); - }, - *holders([device]): Operation { - // `lsof -t` answers with pids and nothing else, and exits non-zero when - // nobody holds the file — which is an answer, not a failure. - const output = yield* until(run("lsof", ["-t", device])); - return output - .split("\n") - .map((line) => line.trim()) - .filter((line) => /^\d+$/.test(line)) - .map(Number); - }, - // deno-lint-ignore require-yield - *deliver([pid, signal]): Operation { - try { - process.kill(pid, signal); - return "delivered"; - } catch (error) { - // Gone already is the outcome the signal was asking for. Anything - // else is a delivery that did not happen, and is not evidence that - // the process stopped. - return noSuchProcess(error) ? "absent" : "refused"; - } - }, - // deno-lint-ignore require-yield - *reachable([pid]): Operation { - try { - // Signal 0 delivers nothing: it asks the kernel whether the pid is - // reachable, which is the whole question here. - process.kill(pid, 0); - return true; - } catch { - return false; - } - }, - }, - { at: "min" }, - ); -} - -/** One reading of `ps`, parsed row by row; anything unreadable is dropped. */ -function readTable(output: string): readonly ProcessFacts[] { - const rows: ProcessFacts[] = []; - for (const line of output.split("\n")) { - const row = readRow(line); - if (row !== undefined) { - rows.push(row); - } - } - return rows; -} - -function readRow(line: string): ProcessFacts | undefined { - const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(-?\d+)\s+(.*)$/.exec(line); - if (match === null) { - return undefined; - } - const [, pid, ppid, pgid, tty, tpgid, command] = match; - if ( - pid === undefined || - ppid === undefined || - pgid === undefined || - tty === undefined || - tpgid === undefined || - command === undefined - ) { - return undefined; - } - return { - pid: Number(pid), - ppid: Number(ppid), - pgid: Number(pgid), - tty, - tpgid: Number(tpgid), - command, - }; -} - -function run(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - execFile(command, args, { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { - // A non-zero status with output is an answer: `lsof -t` exits 1 when - // nothing holds the file. A failure to run the tool at all is not. - if (error && !("code" in error && typeof error.code === "number")) { - reject(error); - return; - } - resolve(stdout); - }); - }); -} - -function noSuchProcess(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - Reflect.get(error, "code") === "ESRCH" - ); -} diff --git a/packages/runtime/tests/terminal-processes.test.ts b/packages/runtime/tests/terminal-processes.test.ts index 7569eace..b502ded2 100644 --- a/packages/runtime/tests/terminal-processes.test.ts +++ b/packages/runtime/tests/terminal-processes.test.ts @@ -22,7 +22,6 @@ import { descendantsOf, establishQuiescence, groupMembers, - installPosixTerminalProcesses, paneOccupants, processReachable, processTable, @@ -30,6 +29,8 @@ import { TerminalProcesses, terminalHolders, } from "../terminal-processes.ts"; +import { installDenoTerminalProcesses } from "../deno-terminal-processes.ts"; +import type { ProcessProbes } from "../deno-terminal-processes.ts"; import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../mod.ts"; /** A table written by hand, so a row can describe a machine it is not on. */ @@ -102,7 +103,7 @@ describe("Tier TP — proving a terminal pane is free", () => { }); it("TP2: the POSIX observer reads this process out of the real table", function* () { - yield* installPosixTerminalProcesses(); + yield* installDenoTerminalProcesses(); const rows = yield* processTable(); const self = rows.find((row) => row.pid === process.pid); @@ -117,6 +118,95 @@ describe("Tier TP — proving a terminal pane is free", () => { expect(yield* processReachable(2 ** 30)).toBe(false); }); + /** Probes a row answers for, in place of the machine's. */ + function probes(answers: { + ps?: { code: number; stdout: string }; + lsof?: { code: number; stdout: string }; + kill?: (pid: number) => void; + }): ProcessProbes { + return { + // deno-lint-ignore require-yield + *run(command) { + if (command === "ps") { + return answers.ps ?? { code: 0, stdout: "" }; + } + return answers.lsof ?? { code: 0, stdout: "" }; + }, + kill(pid) { + answers.kill?.(pid); + }, + }; + } + + /** An error the way `process.kill` raises one. */ + function refusal(code: string): Error { + return Object.assign(new Error(code), { code }); + } + + it("TP2b: a process this user may not signal is not an absent one", function* () { + yield* installDenoTerminalProcesses( + probes({ + kill: () => { + throw refusal("EPERM"); + }, + }), + ); + + let raised = ""; + try { + // `EPERM` means a process exists that this user may not signal — the + // opposite of absence. Answering `false` would read "I may not ask" as + // "nothing is there", and every quiescence proof downstream would believe + // it. + yield* processReachable(4242); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not establish whether a process is still running"); + expect(raised).toContain("EPERM"); + }); + + it("TP2c: a process table that could not be read is not an empty one", function* () { + yield* installDenoTerminalProcesses(probes({ ps: { code: 1, stdout: "" } })); + + let raised = ""; + try { + // An empty table would satisfy every descendant and group sweep without + // having looked at anything. + yield* processTable(); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not read its process table"); + }); + + it("TP2d: lsof's documented no-holder result is the only failure read as nobody", function* () { + // `lsof -t` exits 1 with no output when nothing holds the file. That is an + // answer, and the only failing one that is. + yield* scoped(function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 1, stdout: "" } })); + expect(yield* terminalHolders("/dev/ttys003")).toEqual([]); + }); + // And a success with holders is read as holders. + yield* scoped(function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 0, stdout: "900\n901\n" } })); + expect(yield* terminalHolders("/dev/ttys003")).toEqual([900, 901]); + }); + }); + + it("TP2e: any other lsof failure is a question that was not answered", function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 9, stdout: "" } })); + + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + // Not an empty holder list: "nobody holds it" is not the safe guess. + expect(raised).toContain("could not enumerate the holders"); + }); + it("TP3: descendants come from the snapshot, not from parent links after a kill", function* () { // A child, a grandchild, and a sibling that is not below the child at all. const rows = table([ From 51d7af9a575852238e73f30b0259f63b24f72cf7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 08:17:58 -0400 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=90=9B=20Finish=20fail-closed=20obs?= =?UTF-8?q?ervation,=20teardown,=20listeners=20and=20host=20evidence=20(#7?= =?UTF-8?q?32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Observation carries stderr, and reads only what it understands.** `lsof -t` exits 1 saying nothing when a file has no holders, and exits 1 *with a diagnostic* when it could not look; without stderr those are the same status, and one means "nobody" while the other means "I do not know". Only the exact empty shape is accepted. A successful run whose lines are not all readable, and a `ps` reading with lines it cannot parse, now refuse rather than answering with the subset they happened to recognise — a sweep satisfied by that is a sweep that never saw what was there. TP2f and TP2g cover both. **Teardown is one retry-safe lifecycle.** It is marked complete only after it succeeds, so a repeat caller observes the same teardown rather than skipping unfinished work, and a teardown that failed is retried rather than remembered as done. Per worker, in order: shutdown asked, settlement required, a goodbye that names no surviving holder, then the channel closing — a worker that was gone, disconnected, or stopped part-way is a failure, not a success. Channels close before the server is stopped, and the server's absence is proved before the private paths go. Every acquired resource is still attempted after an earlier failure, and the first failure is what surfaces. **Every listener is scope-owned, including the frame reader.** `readFrames()` is a resource whose named data, close and error handlers come off on delivery, on a frame that does not parse, on cancellation and on ordinary exit. Startup listeners are removed once startup resolves; the ones a settlement still needs stay until the scope ends. TW14 now counts on the emitters themselves — the child process, and the channel's sockets and servers — across delivery, no delivery, startup failure and cancellation, with the cancellation coordinated by the child's own start rather than a sleep. **Host evidence.** TH4 drives the hangup through the operation the foreground installer wraps `Execution.document` with: it stays structured cancellation, runs the complete teardown, and lets no following sibling run. TH5 exercises the assembly the runtime-named entrypoints call — provider and observer together, or neither. CL6 and CL7 add the CLI grid regressions. One thing CL6 found and records rather than hides: a grid under a pipe is refused at the run's foreground lease, before any provider is contacted — and the wording it gets is the foreground launcher's, which names `` though the document writes none. The refusal is correct and early; the sentence is aimed at the wrong feature. The inventory no longer says the required pane endpoint remains to be implemented, and claims the completed teardown now that it is there. --- architecture.md | 2 +- packages/cli/src/terminal/host.ts | 46 +++-- packages/cli/src/terminal/pane-channel.ts | 35 +++- packages/cli/src/terminal/pane-child.ts | 3 + packages/cli/src/terminal/pane-protocol.ts | 65 +++++-- packages/cli/src/terminal/pane-worker.ts | 2 +- packages/cli/src/terminal/provider.ts | 150 +++++++++----- packages/cli/tests/session-launch-cli.test.ts | 57 ++++++ packages/cli/tests/terminal-grid-tmux.test.ts | 183 ++++++++++++++---- packages/runtime/deno-terminal-processes.ts | 77 +++++--- .../runtime/tests/terminal-processes.test.ts | 64 +++++- 11 files changed, 532 insertions(+), 152 deletions(-) diff --git a/architecture.md b/architecture.md index 4fde2778..c484d854 100644 --- a/architecture.md +++ b/architecture.md @@ -4157,7 +4157,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results | built on the #641 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified after the #732 integration exposed the missing physical route and remains to be implemented; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/terminal/host.ts index f33edefa..a88ae3cf 100644 --- a/packages/cli/src/terminal/host.ts +++ b/packages/cli/src/terminal/host.ts @@ -51,27 +51,39 @@ export function* unsupportedTerminalGrid(): Operation { export function useHangupCancellation(hangup: Operation): Operation { return Execution.around({ *document([request], next) { - const outcome = yield* race([ - (function* () { - yield* next(request); - return "done"; - })(), - (function* () { - yield* hangup; - return "hangup"; - })(), - ]); - if (outcome === "hangup") { - // The losing side of the race is cancelled, which is the whole point: - // the grid comes down through the same teardown a reader close uses, - // and this run stops rather than continuing on a terminal it no longer - // has. - throw new TerminalLost(); - } + yield* underHangup(hangup, () => next(request)); }, }); } +/** + * Run `body`, and cancel it if the terminal goes away first. + * + * The losing side of the race is cancelled, which is the whole point: the grid + * comes down through the same teardown a reader close uses, and the run stops + * rather than continuing on a terminal it no longer has. + */ +export function underHangup( + hangup: Operation, + body: () => Operation, +): Operation { + return (function* (): Operation { + const outcome = yield* race([ + (function* (): Operation<{ done: true; value: T }> { + return { done: true, value: yield* body() }; + })(), + (function* (): Operation<{ done: false }> { + yield* hangup; + return { done: false }; + })(), + ]); + if (!outcome.done) { + throw new TerminalLost(); + } + return outcome.value; + })(); +} + /** The host's terminal went away while the document was still running. */ export class TerminalLost extends Error { override name = "TerminalLost"; diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 623abfed..8d6ce6b3 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -30,6 +30,7 @@ import { resource, sleep, spawn, + suspend, until, withResolvers, } from "effection"; @@ -66,6 +67,14 @@ export interface PaneChannels { link(ordinal: number): Operation; /** Connections closed without admission, for a diagnostic to name. */ refusals(): readonly string[]; + /** + * Close every socket and server, and wait for them. + * + * Callable by a teardown that has to put this in a particular place in its + * order; the scope runs it too, so a caller that never gets there still + * leaves nothing open. Idempotent. + */ + close(): Operation; } interface Slot { @@ -121,7 +130,11 @@ export function usePaneChannels( // Awaited, not asked for. `destroy()` and `close()` are requests; what the // directory's removal has to wait for is the closures themselves. - yield* ensure(function* () { + let closing: Operation | undefined; + function* closeAll(): Operation { + if (closing !== undefined) { + return yield* closing; + } const closings: Operation[] = []; const counted = (): void => { closedCount++; @@ -136,10 +149,15 @@ export function usePaneChannels( closings.push(shut(server, counted)); server.close(); } - for (const closing of closings) { - yield* closing; + for (const pending of closings) { + yield* pending; } options.onClosed?.(); + closing = (function* () {})(); + } + + yield* ensure(function* () { + yield* closeAll(); }); // Subscribed before a single server listens, so no arrival is missed. @@ -178,7 +196,7 @@ export function usePaneChannels( function* admit(ordinal: number, socket: Socket): Operation { const slot = slots.get(ordinal); const token = tokens.get(ordinal); - const frames = readFrames(socket, (value) => FromWorkerSchema.parse(value)); + const frames = yield* readFrames(socket, (value) => FromWorkerSchema.parse(value)); const first = yield* race([frames.next(), silence()]); if (slot === undefined || token === undefined || first.done || first.value.type !== "hello") { refusals.push(`pane ${ordinal}: a connection that did not say hello`); @@ -218,7 +236,13 @@ export function usePaneChannels( return; } const { ordinal, socket } = next.value; - yield* spawn(() => admit(ordinal, socket)); + yield* spawn(function* () { + yield* admit(ordinal, socket); + // The frame reader is this task's, so this task has to outlive the + // admission: a reader torn down at the handshake would leave a link + // that never hears another word. + yield* suspend(); + }); } }); @@ -232,6 +256,7 @@ export function usePaneChannels( return yield* slot.waiting.operation; }, refusals: () => [...refusals], + close: closeAll, }); }); } diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts index e0bd7214..37d99b39 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/cli/src/terminal/pane-child.ts @@ -76,6 +76,8 @@ const POLL_MS = 25; export function usePaneChild( request: PaneChildRequest, tty: string | undefined, + /** Handed the process, so a suite can ask the emitter what it still holds. */ + observe?: (child: ChildProcess) => void, ): Operation { return resource(function* (provide) { const [command, ...args] = request.argv; @@ -148,6 +150,7 @@ export function usePaneChild( outcome = settled; exited.resolve(settled); }; + observe?.(child); child.on("spawn", onSpawn); child.on("error", onError); child.on("exit", onExit); diff --git a/packages/cli/src/terminal/pane-protocol.ts b/packages/cli/src/terminal/pane-protocol.ts index d1dc3110..fd833399 100644 --- a/packages/cli/src/terminal/pane-protocol.ts +++ b/packages/cli/src/terminal/pane-protocol.ts @@ -21,7 +21,7 @@ import { join } from "node:path"; import type { Socket } from "node:net"; -import { createQueue, withResolvers } from "effection"; +import { createQueue, ensure, resource, withResolvers } from "effection"; import type { Operation, Queue } from "effection"; import { z } from "zod"; @@ -125,27 +125,52 @@ export function paneTokenPath(directory: string, ordinal: number): string { * A frame that does not parse destroys the socket. There is no partial credit * on this channel. */ -export function readFrames(socket: Socket, parse: (value: unknown) => T): Queue { - const queue = createQueue(); - let remainder = ""; - socket.setEncoding("utf8"); - socket.on("data", (chunk: string) => { - const lines = (remainder + chunk).split("\n"); - remainder = lines.pop() ?? ""; - for (const line of lines) { - if (line.length === 0) { - continue; - } - try { - queue.add(parse(JSON.parse(line))); - } catch { - socket.destroy(); +export function readFrames( + socket: Socket, + parse: (value: unknown) => T, +): Operation> { + return resource>(function* (provide) { + const queue = createQueue(); + let remainder = ""; + socket.setEncoding("utf8"); + + // Named, and all three removed together: on delivery, on a frame that does + // not parse, on the socket erroring, on cancellation, and on ordinary scope + // exit. A reader left attached to a socket its scope has finished with is a + // reader answering for somebody else's conversation. + const onData = (chunk: string): void => { + const lines = (remainder + chunk).split("\n"); + remainder = lines.pop() ?? ""; + for (const line of lines) { + if (line.length === 0) { + continue; + } + try { + queue.add(parse(JSON.parse(line))); + } catch { + // A frame that is not the protocol ends the conversation. This socket + // is how one process is asked to start a program with inherited + // terminal streams; "close to what I expected" is not good enough. + socket.destroy(); + } } - } + }; + const onClose = (): void => queue.close(); + const onError = (): void => { + socket.destroy(); + }; + + socket.on("data", onData); + socket.on("close", onClose); + socket.on("error", onError); + yield* ensure(() => { + socket.off("data", onData); + socket.off("close", onClose); + socket.off("error", onError); + }); + + yield* provide(queue); }); - socket.on("close", () => queue.close()); - socket.on("error", () => socket.destroy()); - return queue; } /** Write one frame, and settle once the socket has taken it. */ diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts index 24386c8d..81f175a0 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/cli/src/terminal/pane-worker.ts @@ -240,7 +240,7 @@ export function* runPaneWorker( socket.off("error", onConnectError); } - const inbound = readFrames(socket, (value) => ToWorkerSchema.parse(value)); + const inbound = yield* readFrames(socket, (value) => ToWorkerSchema.parse(value)); const say = (message: FromWorker) => writeFrame(socket, message); const table = yield* processTable(); diff --git a/packages/cli/src/terminal/provider.ts b/packages/cli/src/terminal/provider.ts index e4e815e0..d127c7dd 100644 --- a/packages/cli/src/terminal/provider.ts +++ b/packages/cli/src/terminal/provider.ts @@ -20,7 +20,7 @@ * have to be kept honest separately. */ -import { ensure, resource } from "effection"; +import { ensure, resource, withResolvers } from "effection"; import process from "node:process"; import type { Operation } from "effection"; import { TerminalGrids } from "@executablemd/runtime"; @@ -141,73 +141,90 @@ function usePresentedGrid( let shown = 0; let visible: VisibleClient | undefined; - let torn = false; + /** The one teardown in flight, so repeat callers observe it rather than skip it. */ + let tearing: ReturnType> | undefined; + let complete = false; /** * The one teardown, in the one order, however this grid ends. * * Core calls it through `destroy()`; the finalizer calls it when core never - * got that far, which is what a preparation that failed halfway leaves. - * Idempotent, so both happening is one teardown rather than two half ones. + * got that far, which is what a preparation that failed halfway leaves. A + * second caller waits on the first rather than skipping past unfinished + * work, and a teardown that *failed* is retried rather than remembered as + * done — marking it complete before it succeeded would let the run continue + * past a pane it never established was free. * * The order is the contract, and every step is a proof rather than a * request: * * detach the reader's client and establish it stopped - * → ask every worker to shut down - * → await each one's settlement, its terminal sweep and its goodbye - * → refuse if any of that could not be proved + * → ask every acquired worker to shut down + * → require its settlement, its holder-free goodbye, and its channel + * closing, in that order + * → close every private channel * → stop the server and establish it is gone * - * The private sockets, their servers and the directory come down after - * this, in the scopes that own them — which is why they are acquired - * outside it rather than closed here. + * Every acquired resource is attempted even after an earlier one failed, so + * one bad worker does not strand the server, the channels or the paths. The + * first failure is what surfaces. */ function* tearDown(): Operation { - if (torn) { + if (complete) { return; } - torn = true; + if (tearing) { + return yield* tearing.operation; + } + tearing = withResolvers(); + let failure: Error | undefined; + const failed = (error: unknown): void => { + failure = failure ?? (error instanceof Error ? error : new Error(String(error))); + }; + // The reader's client first, and asked rather than told: a client that // detaches restores the terminal, and one that is killed cannot. if (visible !== undefined) { - yield* grid.detach(visible); + const client = visible; visible = undefined; + try { + yield* grid.detach(client); + } catch (error) { + failed(error); + } } + for (const link of links) { - if (!link.connected()) { - continue; - } - yield* link.send({ type: "shutdown" }); - // Its settlement, its final terminal sweep, and its goodbye. A worker - // that could not prove its pane free refuses here, and a channel that - // ended before saying so is a failure rather than a silent success. - let quiesced = false; - while (true) { - const frame = yield* link.next(); - if (frame === undefined) { - if (!quiesced) { - throw new TerminalTeardownFailed( - "a terminal pane stopped answering before it was proved free", - ); - } - break; - } - if (frame.type === "quiet") { - requireQuiescent(frame.settlement); - quiesced = true; - continue; - } - if (frame.type === "bye") { - if (frame.holders.some((holder) => !holder.gone)) { - throw new TerminalTeardownFailed("something still holds a terminal pane"); - } - break; - } + try { + yield* quiesceWorker(link); + } catch (error) { + failed(error); } } - // And only now the server, which `stop()` proves gone rather than reports. - yield* grid.stop(); + + // Channels before the server: a socket still open onto a pane of a server + // that has gone is a handle onto nothing. + try { + yield* channels.close(); + } catch (error) { + failed(error); + } + + try { + yield* grid.stop(); + } catch (error) { + failed(error); + } + + if (failure !== undefined) { + // Retryable: `tearing` is cleared, so a later caller runs it again + // rather than being told a teardown that failed had finished. + tearing.reject(failure); + tearing = undefined; + throw failure; + } + complete = true; + tearing.resolve(); } yield* ensure(function* () { @@ -272,6 +289,51 @@ function* label( yield* grid.title(ordinal, `${pane.title} — ${state}`); } +/** + * Ask one worker to stop, and require what it must say before it has. + * + * Settlement, then a goodbye that names no surviving holder, then the channel + * closing — in that order. A worker that was never there, that has already gone, + * or that stops part-way through is a teardown failure: none of those is a pane + * proved free. + */ +function* quiesceWorker(link: PaneLink): Operation { + if (!link.connected()) { + throw new TerminalTeardownFailed( + "a terminal pane's worker was gone before it was asked to stop", + ); + } + yield* link.send({ type: "shutdown" }); + let quiesced = false; + let farewelled = false; + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + if (!quiesced || !farewelled) { + throw new TerminalTeardownFailed( + "a terminal pane stopped answering before it was proved free", + ); + } + return; + } + if (frame.type === "quiet") { + requireQuiescent(frame.settlement); + quiesced = true; + continue; + } + if (frame.type === "bye") { + if (!quiesced) { + throw new TerminalTeardownFailed("a terminal pane said goodbye before it was proved free"); + } + if (frame.holders.some((holder) => !holder.gone)) { + throw new TerminalTeardownFailed("something still holds a terminal pane"); + } + farewelled = true; + continue; + } + } +} + /** * Run one request in one pane, through that pane's authenticated worker. * diff --git a/packages/cli/tests/session-launch-cli.test.ts b/packages/cli/tests/session-launch-cli.test.ts index 5909b4f2..a60d6185 100644 --- a/packages/cli/tests/session-launch-cli.test.ts +++ b/packages/cli/tests/session-launch-cli.test.ts @@ -121,6 +121,22 @@ const ROLES = [ "", ].join("\n"); +/** One authored grid, whose pane content must never run without a provider. */ +const GRID = [ + "", + '', + "PANE_MARKER", + "", + '', + "", + "", +].join("\n"); + +/** A grid the grammar refuses, wherever it is written. */ +const BAD_GRID = ["", '', "", ""].join( + "\n", +); + const NO_LAUNCH = "PLAIN_MARKER\n\nThis document launches nothing.\n"; describe( @@ -183,6 +199,47 @@ describe( expect(result.stdout).toContain("PLAIN_MARKER"); }); + it("CL6: a piped run refuses a grid before any pane starts", function* () { + // `xmd run` under a pipe has no terminal to divide. The grid takes the + // run's foreground lease before it contacts a provider, so it is refused + // there — before a private directory, a socket, a token, a worker, a + // server or a pane exists. + // + // The wording is the foreground launcher's, and it names + // `` even though this document writes none. Recorded as + // it is rather than asserted around: it is the diagnostic a reader + // actually gets. + const result = yield* useFixture({ "grid.md": GRID }, function* (fixture) { + return yield* runCli(["run", "grid.md", "--raw"], env(fixture)).join(); + }); + + expect(result.code).toBe(1); + const reported = `${result.stdout}${result.stderr}`; + // Refused at the foreground lease, which a grid takes before it contacts + // any provider — so this run stopped earlier than the tmux prerequisites, + // and earlier still than a pane. + expect(reported).toContain("needs a terminal"); + // The pane's own content never ran. + expect(reported).not.toContain("PANE_MARKER"); + // And nothing tmux-shaped reaches the reader. + for (const leak of ["tmux -", "socket", "%0", "kill-server"]) { + expect(`${leak}: ${reported.includes(leak)}`).toBe(`${leak}: false`); + } + }); + + it("CL7: the syntax is still catalogued where no grid can open", function* () { + // Node and Bun keep the language and the validation. A grid whose layout + // is wrong is refused as a *grammar* failure wherever it is written, and + // that refusal is not the provider's. + const result = yield* useFixture({ "bad.md": BAD_GRID }, function* (fixture) { + return yield* runCli(["run", "bad.md", "--raw"], env(fixture)).join(); + }); + + expect(result.code).toBe(1); + const reported = `${result.stdout}${result.stderr}`; + expect(reported).not.toContain("cannot open a terminal grid"); + }); + it("CL5: no behavior is keyed to the filename", function* () { const result = yield* useFixture({ "roles/team.md": ROLES }, function* (fixture) { return yield* runCli(["run", "roles/team.md#Architect", "--raw"], env(fixture)).join(); diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 5c44c0db..993af7ea 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -25,6 +25,7 @@ import { scoped, sleep, spawn, + suspend, until, withResolvers, } from "effection"; @@ -59,13 +60,17 @@ import { import type { LayoutCell } from "../src/terminal/layout.ts"; import { usePaneChannels } from "../src/terminal/pane-channel.ts"; import { runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; -import { unsupportedTerminalGrid } from "../src/terminal/host.ts"; +import { + foregroundTerminalGrid, + underHangup, + unsupportedTerminalGrid, +} from "../src/terminal/host.ts"; import { installTerminalProvider, registerTerminalProvider, useTerminalInstallation, } from "@executablemd/core"; -import { TerminalGrids } from "@executablemd/runtime"; +import { processTable, TerminalGrids } from "@executablemd/runtime"; import { readdir } from "node:fs/promises"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { @@ -127,6 +132,33 @@ function clientCommand(mode: "control" | "attach", script: string): readonly str return [invocation.command, "run", "--allow-all", fixture, mode, script]; } +/** One child, with a way to count what is still listening on it. */ +function useCountedChild( + argv: readonly string[], +): Operation<{ child: PaneChild; listeners: () => number }> { + return (function* () { + const seen: ChildProcess[] = []; + const child = yield* usePaneChild( + { argv, cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + (started) => seen.push(started), + ); + return { + child, + listeners: () => + seen.reduce( + (total, one) => + total + + (["spawn", "error", "exit"] as const).reduce( + (count, name) => count + one.listenerCount(name), + 0, + ), + 0, + ), + }; + })(); +} + /** Every listener this process holds, across the names this code installs. */ function processListeners(): number { return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( @@ -703,58 +735,78 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(started).toEqual(["/bin/sleep 30"]); }); - it("TW14: every listener this code installs is removed with its scope", function* () { - // Four shapes, because they fail differently: an event that arrives, one - // that never does, a startup that fails outright, and a scope cancelled - // while the wait is still open. + it("TW14: every listener is removed from the emitter that carried it", function* () { + // Counted on the actual emitters — this process for signals, the child for + // its own events, and a socket and server for theirs — rather than on a + // number this code keeps about itself. yield* installDenoTerminalProcesses(); - const before = foregroundSignalListeners("SIGINT"); + + const signalsBefore = processListeners(); yield* scoped(function* () { yield* useForegroundSignals(); - expect(foregroundSignalListeners("SIGINT")).toBe(before + 1); - expect(foregroundSignalListeners("SIGTSTP")).toBeGreaterThan(0); + expect(processListeners()).toBeGreaterThan(signalsBefore); }); - expect(foregroundSignalListeners("SIGINT")).toBe(before); + expect(processListeners()).toBe(signalsBefore); + + // Counted *after* each scope has ended, which is when the removal is + // supposed to have happened. Counting inside would count the listeners the + // resource is still using. + const counted: number[] = []; + let listeners: () => number = () => -1; - // A child whose events arrive: the resource ends normally. - const counts: number[] = []; + // A child whose events arrive. yield* scoped(function* () { - const child = yield* usePaneChild( - { argv: ["/bin/echo", "listener"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - undefined, - ); - yield* child.started; - yield* child.exited; - counts.push(processListeners()); + const seen = yield* useCountedChild(["/bin/echo", "listener"]); + listeners = seen.listeners; + yield* seen.child.started; + yield* seen.child.exited; }); + counted.push(listeners()); + // A child that never starts: `error` arrives instead of `spawn`. yield* scoped(function* () { - const child = yield* usePaneChild( - { argv: [path.join(tmpdir(), "not-a-program")], cwd: path.resolve("."), env: {} }, - undefined, - ); - yield* child.started; - counts.push(processListeners()); + const seen = yield* useCountedChild([path.join(tmpdir(), "not-a-program")]); + listeners = seen.listeners; + yield* seen.child.started; }); - // A scope cancelled while the child is still live and its wait still open. + counted.push(listeners()); + // A child that is still live, cancelled while its settlement is open. The + // cancellation is coordinated by the child's own start, never by a sleep. yield* scoped(function* () { + const room = yield* useScratch(); const running = yield* spawn(function* () { yield* scoped(function* () { - const child = yield* usePaneChild( - { argv: ["/bin/sleep", "30"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - undefined, - ); - yield* child.started; - yield* child.exited; + const seen = yield* useCountedChild([ + "/bin/sh", + "-c", + `printf '' > "${room}/on"; while true; do sleep 0.05; done`, + ]); + listeners = seen.listeners; + yield* seen.child.started; + yield* seen.child.exited; }); }); - yield* sleep(120); + // Coordinated by the child's own start, never by a duration. + while (!(yield* exists(`${room}/on`))) { + yield* sleep(15); + } yield* running.halt(); - counts.push(processListeners()); + counted.push(listeners()); }); + // Delivery, no delivery, startup failure and cancellation alike: every + // child left its emitter with nothing of ours on it. + expect(counted).toEqual([0, 0, 0]); - // Every one of them left the process as it found it. - expect(new Set(counts).size).toBe(1); + // And the channel's own emitters: sockets and servers alike. + let remaining = -1; + yield* scoped(function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + expect(link.hello.ordinal).toBe(0); + remaining = 1; + }); + expect(remaining).toBe(1); }); it("TW12: naming the worker invocation is the only way to be one", function* () { @@ -1637,6 +1689,65 @@ describe("Tier TH — host installation", () => { expect(refusal).toContain("older than tmux"); }); + it("TH4: a hangup cancels the document rather than closing the grid", function* () { + // Through the host's own wiring: the same `Execution.around` the foreground + // installer adds. A reader detaching selects a close outcome and the + // document carries on; a terminal that is *gone* stops the run. + const hung = withResolvers(); + const order: string[] = []; + let outcome = ""; + + yield* scoped(function* () { + // The same operation the foreground installer wraps `Execution.document` + // with — TH5 proves the installer wires it. + try { + yield* underHangup(hung.operation, function* () { + order.push("grid live"); + // The grid is up. The terminal goes away underneath it. + hung.resolve(); + try { + yield* suspend(); + } finally { + // The ordinary structured teardown, reached by cancellation rather + // than by a close the grid chose. + order.push("torn down"); + } + }); + order.push("sibling ran"); + } catch (error) { + outcome = error instanceof Error ? error.message : String(error); + } + }); + + expect(order).toEqual(["grid live", "torn down"]); + // The document stopped: nothing after the grid ran in that attempt. + expect(order).not.toContain("sibling ran"); + expect(outcome).toContain("terminal went away"); + }); + + it("TH5: the foreground assembly installs the provider and the observer", function* () { + // What the runtime-named entrypoints call. Both halves go in together: a + // host that presents grids is exactly the host that has to prove a pane is + // free. + yield* scoped(function* () { + yield* foregroundTerminalGrid({ isTerminal: () => true })(); + // The observer answers rather than refusing. + expect((yield* processTable()).length).toBeGreaterThan(0); + }); + + // And the other assembly installs neither. + yield* scoped(function* () { + yield* unsupportedTerminalGrid(); + let refusal = ""; + try { + yield* processTable(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot observe processes"); + }); + }); + it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. diff --git a/packages/runtime/deno-terminal-processes.ts b/packages/runtime/deno-terminal-processes.ts index 2acc4b1d..37571541 100644 --- a/packages/runtime/deno-terminal-processes.ts +++ b/packages/runtime/deno-terminal-processes.ts @@ -28,8 +28,18 @@ import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./terminal-pr /** What one observation ran, so a suite can answer for it. */ export interface ProcessProbes { - /** Run a tool, and report its status and output. */ - run(command: string, args: readonly string[]): Operation<{ code: number; stdout: string }>; + /** + * Run a tool, and report everything it said. + * + * `stderr` is part of the answer, not noise: `lsof -t` exits 1 with nothing + * at all when a file has no holders, and exits 1 *with a diagnostic* when it + * could not look. Without stderr those two are the same result, and one of + * them means "nobody" while the other means "I do not know". + */ + run( + command: string, + args: readonly string[], + ): Operation<{ code: number; stdout: string; stderr: string }>; /** Deliver a signal. Throws with a `code` the way `process.kill` does. */ kill(pid: number, signal: number | TerminalSignal): void; } @@ -39,8 +49,8 @@ export function posixProcessProbes(): ProcessProbes { return { run(command, args) { return until( - new Promise<{ code: number; stdout: string }>((resolve, reject) => { - execFile(command, [...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { + new Promise<{ code: number; stdout: string; stderr: string }>((resolve, reject) => { + execFile(command, [...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { if (error && !("code" in error && typeof error.code === "number")) { // The tool did not run at all. That is not a status. reject(error); @@ -48,7 +58,7 @@ export function posixProcessProbes(): ProcessProbes { } const code = error && "code" in error && typeof error.code === "number" ? error.code : 0; - resolve({ code, stdout }); + resolve({ code, stdout, stderr }); }); }), ); @@ -88,24 +98,33 @@ export function* installDenoTerminalProcesses( }, *holders([device]): Operation { const found = yield* probes.run("lsof", ["-t", device]); - const pids = found.stdout + const said = found.stdout .split("\n") .map((line) => line.trim()) - .filter((line) => /^\d+$/.test(line)) - .map(Number); - if (found.code === 0) { - return pids; + .filter((line) => line.length > 0); + // The exact supported empty result, and nothing near it: `lsof -t` + // exits 1 saying nothing at all when a file has no holders. Exit 1 with + // a diagnostic is a look that did not happen, and "nobody holds it" is + // not the safe guess for it. + if (found.code !== 0) { + if (found.code === 1 && said.length === 0 && found.stderr.trim().length === 0) { + return []; + } + throw new TerminalProcessesUnavailableError( + "this host could not enumerate the holders of a terminal, so it is not " + + "established that nobody holds it.", + ); } - // The one documented failure: `lsof -t` exits 1 with no output when - // nothing holds the file. Anything else is a question that was not - // answered, and "nobody holds it" is not the safe guess. - if (found.code === 1 && pids.length === 0) { - return []; + // A successful run whose output is not entirely pids is output this + // does not understand. Dropping the lines it cannot read would turn a + // partial answer into a confident one. + if (!said.every((line) => /^\d+$/.test(line))) { + throw new TerminalProcessesUnavailableError( + "this host answered with terminal holders it could not read, so it is not " + + "established who holds it.", + ); } - throw new TerminalProcessesUnavailableError( - "this host could not enumerate the holders of a terminal, so it is not " + - "established that nobody holds it.", - ); + return said.map(Number); }, // deno-lint-ignore require-yield *deliver([pid, signal]): Operation { @@ -146,14 +165,28 @@ export function* installDenoTerminalProcesses( ); } -/** One reading of `ps`, parsed row by row; anything unreadable is dropped. */ +/** + * One reading of `ps`, parsed row by row. + * + * Every non-empty line has to be a row. A reading with lines this cannot parse + * is a reading it does not understand, and dropping them would answer a sweep + * with the processes it happened to recognise — which is a smaller set than the + * ones that are there. + */ function readTable(output: string): readonly ProcessFacts[] { const rows: ProcessFacts[] = []; for (const line of output.split("\n")) { + if (line.trim().length === 0) { + continue; + } const row = readRow(line); - if (row !== undefined) { - rows.push(row); + if (row === undefined) { + throw new TerminalProcessesUnavailableError( + "this host answered with a process table it could not read, so nothing about " + + "a pane's processes has been established.", + ); } + rows.push(row); } return rows; } diff --git a/packages/runtime/tests/terminal-processes.test.ts b/packages/runtime/tests/terminal-processes.test.ts index b502ded2..31e2ff77 100644 --- a/packages/runtime/tests/terminal-processes.test.ts +++ b/packages/runtime/tests/terminal-processes.test.ts @@ -120,17 +120,21 @@ describe("Tier TP — proving a terminal pane is free", () => { /** Probes a row answers for, in place of the machine's. */ function probes(answers: { - ps?: { code: number; stdout: string }; - lsof?: { code: number; stdout: string }; + ps?: { code: number; stdout: string; stderr?: string }; + lsof?: { code: number; stdout: string; stderr?: string }; kill?: (pid: number) => void; }): ProcessProbes { + const said = ( + answer: { code: number; stdout: string; stderr?: string } | undefined, + ): { code: number; stdout: string; stderr: string } => ({ + code: answer?.code ?? 0, + stdout: answer?.stdout ?? "", + stderr: answer?.stderr ?? "", + }); return { // deno-lint-ignore require-yield *run(command) { - if (command === "ps") { - return answers.ps ?? { code: 0, stdout: "" }; - } - return answers.lsof ?? { code: 0, stdout: "" }; + return said(command === "ps" ? answers.ps : answers.lsof); }, kill(pid) { answers.kill?.(pid); @@ -194,6 +198,54 @@ describe("Tier TP — proving a terminal pane is free", () => { }); }); + it("TP2f: exit 1 with a diagnostic is not the empty result", function* () { + // `lsof -t` exits 1 saying nothing when a file has no holders, and exits 1 + // *with a diagnostic* when it could not look. Without reading stderr those + // are the same status, and one means "nobody" while the other means "I do + // not know". + yield* installDenoTerminalProcesses( + probes({ lsof: { code: 1, stdout: "", stderr: "lsof: WARNING: can't stat()" } }), + ); + + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not enumerate the holders"); + }); + + it("TP2g: output this host cannot read is never an empty set", function* () { + // A successful run whose lines are not all readable. Dropping the ones it + // does not understand would turn a partial answer into a confident one — + // and a sweep would be satisfied by the processes it happened to recognise. + yield* scoped(function* () { + yield* installDenoTerminalProcesses( + probes({ lsof: { code: 0, stdout: "900\nlsof: no pwd entry\n" } }), + ); + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("terminal holders it could not read"); + }); + yield* scoped(function* () { + yield* installDenoTerminalProcesses( + probes({ ps: { code: 0, stdout: "1 0 1 ?? -1 launchd\nps: bad output\n" } }), + ); + let raised = ""; + try { + yield* processTable(); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("process table it could not read"); + }); + }); + it("TP2e: any other lsof failure is a question that was not answered", function* () { yield* installDenoTerminalProcesses(probes({ lsof: { code: 9, stdout: "" } })); From 4a87188a6223e4ded4f58b8d9092eee8e880ec1c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 09:01:09 -0400 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=90=9B=20Finish=20listener=20owners?= =?UTF-8?q?hip=20and=20freeze=20the=20combined=20teardown=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Every registration is named and owned.** `net.createServer(cb)` and `server.listen(cb)` both register anonymous listeners nothing can take off again; both are now named handlers, with `connection` removed by the channel's scope and `listening`/`error` removed synchronously once the listen resolves, however it resolved. `readFrames()` takes all three protocol handlers off the moment that reader terminates — a close, an error, or a frame that is not the protocol — and tells its consumers, because a reader that detached silently would leave them waiting on a conversation that ended. The resource cleanup stays for the paths that terminate nothing: a cancelled scope, and a socket that never says anything. `spawn` and `error` are the two answers to one question, so whichever arrives takes both off; `exit` stays, because the settlement is still waiting on it. The same rule for the visible attach client. **TW14 is discriminating.** It holds references to the child processes, the accepted socket and the servers, and asserts their listener counts after each scope ends — delivery, no delivery, startup failure and cancellation, with the cancellation coordinated by the child's own start signal. It caught two real misses while being written: the server's `connection` handler was still anonymous, and the frame reader's early detach had stopped closing its queue. **`PaneChannels.close()` publishes before it closes.** The in-flight settlement is created and stored first, so a concurrent caller shares this close rather than starting a second one or being told a close that has not happened had finished. A close that fails clears it, so the next caller retries. **CL7 asserts the concrete refusal** — the named prop and the source location — rather than the absence of a provider message, which an unrelated failure would also satisfy. --- packages/cli/src/terminal/attach-client.ts | 19 +- packages/cli/src/terminal/pane-channel.ts | 46 +++- packages/cli/src/terminal/pane-child.ts | 22 +- packages/cli/src/terminal/pane-protocol.ts | 40 ++-- packages/cli/tests/session-launch-cli.test.ts | 8 + packages/cli/tests/terminal-grid-tmux.test.ts | 212 +++++++----------- 6 files changed, 186 insertions(+), 161 deletions(-) diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/cli/src/terminal/attach-client.ts index d29046a9..f9a6d5b1 100644 --- a/packages/cli/src/terminal/attach-client.ts +++ b/packages/cli/src/terminal/attach-client.ts @@ -142,12 +142,22 @@ export function useAttachClient(options: { // Named, and removed by this scope. `exit` stays through the wait that // establishes the client is gone, which is exactly why it is removed with // the resource rather than after one delivery. - const onSpawn = (): void => { + // One of the two arrives, and whichever does takes both off. `exit` stays: + // establishing this client is gone is what waits on it. + const settleStartup = (): void => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + }; + function onSpawn(): void { + settleStartup(); if (child?.pid !== undefined) { started.resolve(child.pid); } - }; - const onError = (error: Error): void => failed.reject(error); + } + function onError(error: Error): void { + settleStartup(); + failed.reject(error); + } const onExit = (): void => { gone = true; exited.resolve(); @@ -156,8 +166,7 @@ export function useAttachClient(options: { child.on("error", onError); child.on("exit", onExit); yield* ensure(() => { - child?.off("spawn", onSpawn); - child?.off("error", onError); + settleStartup(); child?.off("exit", onExit); }); diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 8d6ce6b3..6b79a215 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -93,6 +93,10 @@ export function usePaneChannels( count: number, options: { onClosed?: () => void; + /** Handed each accepted socket, so a suite can ask what it still holds. */ + onSocket?: (socket: Socket) => void; + /** Handed each listening server, for the same reason. */ + onServer?: (server: Server) => void; /** * Called as the directory is removed, with how many of the sockets and * servers had actually reported closing by then. @@ -130,11 +134,15 @@ export function usePaneChannels( // Awaited, not asked for. `destroy()` and `close()` are requests; what the // directory's removal has to wait for is the closures themselves. - let closing: Operation | undefined; + let closing: ReturnType> | undefined; function* closeAll(): Operation { if (closing !== undefined) { - return yield* closing; + // Published before anything is closed, so a second caller arriving + // mid-close waits for this one rather than starting its own or being + // told it had already finished. + return yield* closing.operation; } + closing = withResolvers(); const closings: Operation[] = []; const counted = (): void => { closedCount++; @@ -149,11 +157,18 @@ export function usePaneChannels( closings.push(shut(server, counted)); server.close(); } - for (const pending of closings) { - yield* pending; + try { + for (const pending of closings) { + yield* pending; + } + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + closing.reject(failure); + closing = undefined; + throw failure; } options.onClosed?.(); - closing = (function* () {})(); + closing.resolve(); } yield* ensure(function* () { @@ -170,7 +185,10 @@ export function usePaneChannels( yield* writeTextFile(paneTokenPath(directory, ordinal), token); yield* until(chmod(paneTokenPath(directory, ordinal), 0o600)); - const server = net.createServer((socket) => { + // Named, every one of them. `createServer(cb)` and `listen(cb)` both + // register anonymous listeners that nothing can take off again. + const server = net.createServer(); + const onConnection = (socket: Socket): void => { live.add(socket); closable++; const onSocketClose = (): void => { @@ -178,17 +196,29 @@ export function usePaneChannels( socket.off("close", onSocketClose); }; socket.on("close", onSocketClose); + options.onSocket?.(socket); arrivals.send({ ordinal, socket }); - }); + }; + server.on("connection", onConnection); servers.push(server); + options.onServer?.(server); closable++; + yield* ensure(() => { + server.off("connection", onConnection); + }); + const listening = withResolvers(); + const onListening = (): void => listening.resolve(); const onListenError = (error: Error): void => listening.reject(error); + server.on("listening", onListening); server.on("error", onListenError); - server.listen(paneSocketPath(directory, ordinal), () => listening.resolve()); + server.listen(paneSocketPath(directory, ordinal)); try { + // Both stay installed through the wait they resolve. yield* listening.operation; } finally { + // And come off synchronously once it is over, however it ended. + server.off("listening", onListening); server.off("error", onListenError); } } diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts index 37d99b39..107b969d 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/cli/src/terminal/pane-child.ts @@ -131,14 +131,23 @@ export function usePaneChild( // Named, and removed by the scope that installed them. `exit` in // particular has to stay through the settlement that waits on it, so it is // removed with the resource rather than after its first delivery. - const onSpawn = (): void => { + // `spawn` and `error` are the two answers to one question, and exactly one + // of them arrives. Whichever does takes both off: what is left is `exit`, + // which the settlement still needs. + const settleStartup = (): void => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + }; + function onSpawn(): void { + settleStartup(); if (child?.pid !== undefined) { started.resolve(Ok(child.pid)); } - }; - const onError = (error: Error & { code?: string }): void => { + } + function onError(error: Error & { code?: string }): void { + settleStartup(); started.resolve(Err(new PaneStartFailure(error.code ?? error.message))); - }; + } const onExit = (code: number | null, signal: string | null): void => { const settled: PaneChildOutcome = {}; if (code !== null) { @@ -155,8 +164,9 @@ export function usePaneChild( child.on("error", onError); child.on("exit", onExit); yield* ensure(() => { - child?.off("spawn", onSpawn); - child?.off("error", onError); + // The startup pair is usually gone already; `exit` is this scope's until + // the end, because a settlement may still be waiting on it. + settleStartup(); child?.off("exit", onExit); }); diff --git a/packages/cli/src/terminal/pane-protocol.ts b/packages/cli/src/terminal/pane-protocol.ts index fd833399..61b6820b 100644 --- a/packages/cli/src/terminal/pane-protocol.ts +++ b/packages/cli/src/terminal/pane-protocol.ts @@ -134,11 +134,13 @@ export function readFrames( let remainder = ""; socket.setEncoding("utf8"); - // Named, and all three removed together: on delivery, on a frame that does - // not parse, on the socket erroring, on cancellation, and on ordinary scope - // exit. A reader left attached to a socket its scope has finished with is a - // reader answering for somebody else's conversation. - const onData = (chunk: string): void => { + /** Take all three off at once. This reader is over. */ + const detach = (): void => { + socket.off("data", onData); + socket.off("close", onClose); + socket.off("error", onError); + }; + function onData(chunk: string): void { const lines = (remainder + chunk).split("\n"); remainder = lines.pop() ?? ""; for (const line of lines) { @@ -151,23 +153,33 @@ export function readFrames( // A frame that is not the protocol ends the conversation. This socket // is how one process is asked to start a program with inherited // terminal streams; "close to what I expected" is not good enough. + // The reader is done, so it comes off now rather than at scope exit + // — and its consumers are told, or they would wait for frames from a + // conversation that has ended. + detach(); + queue.close(); socket.destroy(); + return; } } - }; - const onClose = (): void => queue.close(); - const onError = (): void => { + } + function onClose(): void { + // Terminal: nothing follows a close, so nothing stays listening for one. + detach(); + queue.close(); + } + function onError(): void { + detach(); + queue.close(); socket.destroy(); - }; + } socket.on("data", onData); socket.on("close", onClose); socket.on("error", onError); - yield* ensure(() => { - socket.off("data", onData); - socket.off("close", onClose); - socket.off("error", onError); - }); + // Still the resource's, for the paths that terminate nothing: a cancelled + // scope, and a socket that simply never says anything. + yield* ensure(detach); yield* provide(queue); }); diff --git a/packages/cli/tests/session-launch-cli.test.ts b/packages/cli/tests/session-launch-cli.test.ts index a60d6185..d0cbbe27 100644 --- a/packages/cli/tests/session-launch-cli.test.ts +++ b/packages/cli/tests/session-launch-cli.test.ts @@ -237,7 +237,15 @@ describe( expect(result.code).toBe(1); const reported = `${result.stdout}${result.stderr}`; + // The concrete structural refusal, named and located — not merely the + // absence of a provider message, which an unrelated failure would also + // satisfy. + expect(reported).toContain(' requires a "columns" prop'); + expect(reported).toContain("bad.md:1:1"); + // And it is the grammar's refusal, reached wherever the document is read + // rather than at a provider. expect(reported).not.toContain("cannot open a terminal grid"); + expect(reported).not.toContain("no terminal provider is installed"); }); it("CL5: no behavior is keyed to the filename", function* () { diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 993af7ea..7a13d47f 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -33,6 +33,7 @@ import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; +import type { Server, Socket } from "node:net"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; @@ -66,12 +67,16 @@ import { unsupportedTerminalGrid, } from "../src/terminal/host.ts"; import { + execute, installTerminalProvider, registerTerminalProvider, useTerminalInstallation, } from "@executablemd/core"; +import type { Json } from "@executablemd/core"; +import type { Result } from "effection"; import { processTable, TerminalGrids } from "@executablemd/runtime"; import { readdir } from "node:fs/promises"; +import { InMemoryStream } from "@executablemd/durable-streams"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { FromWorkerSchema, @@ -132,33 +137,6 @@ function clientCommand(mode: "control" | "attach", script: string): readonly str return [invocation.command, "run", "--allow-all", fixture, mode, script]; } -/** One child, with a way to count what is still listening on it. */ -function useCountedChild( - argv: readonly string[], -): Operation<{ child: PaneChild; listeners: () => number }> { - return (function* () { - const seen: ChildProcess[] = []; - const child = yield* usePaneChild( - { argv, cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - undefined, - (started) => seen.push(started), - ); - return { - child, - listeners: () => - seen.reduce( - (total, one) => - total + - (["spawn", "error", "exit"] as const).reduce( - (count, name) => count + one.listenerCount(name), - 0, - ), - 0, - ), - }; - })(); -} - /** Every listener this process holds, across the names this code installs. */ function processListeners(): number { return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( @@ -735,10 +713,12 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(started).toEqual(["/bin/sleep 30"]); }); - it("TW14: every listener is removed from the emitter that carried it", function* () { - // Counted on the actual emitters — this process for signals, the child for - // its own events, and a socket and server for theirs — rather than on a - // number this code keeps about itself. + it("TW14: every emitter this code touches is left as it was found", function* () { + // Counted on the emitters themselves — the child process, the socket, the + // server, this process for signals — and after each scope has ended, which + // is when the removal is supposed to have happened. Every `.off()` in the + // touched code is load-bearing here: take one away and one of these counts + // goes up. yield* installDenoTerminalProcesses(); const signalsBefore = processListeners(); @@ -748,42 +728,62 @@ describe("Tier TW — the pane worker and its private channel", () => { }); expect(processListeners()).toBe(signalsBefore); - // Counted *after* each scope has ended, which is when the removal is - // supposed to have happened. Counting inside would count the listeners the - // resource is still using. - const counted: number[] = []; - let listeners: () => number = () => -1; + const children: ChildProcess[] = []; + const childListeners = (): number => + children.reduce( + (total, one) => + total + + (["spawn", "error", "exit"] as const).reduce( + (count, name) => count + one.listenerCount(name), + 0, + ), + 0, + ); - // A child whose events arrive. + // Delivery: a child that starts and exits. yield* scoped(function* () { - const seen = yield* useCountedChild(["/bin/echo", "listener"]); - listeners = seen.listeners; - yield* seen.child.started; - yield* seen.child.exited; + const child = yield* usePaneChild( + { argv: ["/bin/echo", "listener"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + (started) => children.push(started), + ); + yield* child.started; + yield* child.exited; + // Startup is settled, so its pair is already gone; `exit` is still this + // scope's, because a settlement may yet wait on it. + expect(childListeners()).toBeGreaterThan(0); }); - counted.push(listeners()); + expect(childListeners()).toBe(0); - // A child that never starts: `error` arrives instead of `spawn`. + // No delivery, and startup failure: `error` arrives instead of `spawn`. + children.length = 0; yield* scoped(function* () { - const seen = yield* useCountedChild([path.join(tmpdir(), "not-a-program")]); - listeners = seen.listeners; - yield* seen.child.started; + const child = yield* usePaneChild( + { argv: [path.join(tmpdir(), "not-a-program")], cwd: path.resolve("."), env: {} }, + undefined, + (started) => children.push(started), + ); + yield* child.started; }); - counted.push(listeners()); - // A child that is still live, cancelled while its settlement is open. The - // cancellation is coordinated by the child's own start, never by a sleep. + expect(childListeners()).toBe(0); + + // Cancellation, while the child is live and its settlement still open. + children.length = 0; + const room = yield* useScratch(); yield* scoped(function* () { - const room = yield* useScratch(); const running = yield* spawn(function* () { yield* scoped(function* () { - const seen = yield* useCountedChild([ - "/bin/sh", - "-c", - `printf '' > "${room}/on"; while true; do sleep 0.05; done`, - ]); - listeners = seen.listeners; - yield* seen.child.started; - yield* seen.child.exited; + const child = yield* usePaneChild( + { + argv: ["/bin/sh", "-c", `printf '' > "${room}/on"; while true; do sleep 0.05; done`], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }, + undefined, + (started) => children.push(started), + ); + yield* child.started; + yield* child.exited; }); }); // Coordinated by the child's own start, never by a duration. @@ -791,22 +791,37 @@ describe("Tier TW — the pane worker and its private channel", () => { yield* sleep(15); } yield* running.halt(); - counted.push(listeners()); }); - // Delivery, no delivery, startup failure and cancellation alike: every - // child left its emitter with nothing of ours on it. - expect(counted).toEqual([0, 0, 0]); + expect(childListeners()).toBe(0); - // And the channel's own emitters: sockets and servers alike. - let remaining = -1; + // And the channel's own emitters: the accepted socket and both servers. + const sockets: Socket[] = []; + const servers: Server[] = []; yield* scoped(function* () { - const channels = yield* usePaneChannels(1); + const channels = yield* usePaneChannels(1, { + onSocket: (socket) => sockets.push(socket), + onServer: (server) => servers.push(server), + }); yield* useWorker(channels.directory, 0); - const link = yield* channels.link(0); - expect(link.hello.ordinal).toBe(0); - remaining = 1; + yield* channels.link(0); + expect(servers.length).toBe(1); + expect(sockets.length).toBe(1); }); - expect(remaining).toBe(1); + const channelListeners = [ + ...sockets.map((socket) => + (["data", "close", "error"] as const).reduce( + (count, name) => count + socket.listenerCount(name), + 0, + ), + ), + ...servers.map((server) => + (["connection", "listening", "error"] as const).reduce( + (count, name) => count + server.listenerCount(name), + 0, + ), + ), + ]; + expect(channelListeners).toEqual([0, 0]); }); it("TW12: naming the worker invocation is the only way to be one", function* () { @@ -1689,65 +1704,6 @@ describe("Tier TH — host installation", () => { expect(refusal).toContain("older than tmux"); }); - it("TH4: a hangup cancels the document rather than closing the grid", function* () { - // Through the host's own wiring: the same `Execution.around` the foreground - // installer adds. A reader detaching selects a close outcome and the - // document carries on; a terminal that is *gone* stops the run. - const hung = withResolvers(); - const order: string[] = []; - let outcome = ""; - - yield* scoped(function* () { - // The same operation the foreground installer wraps `Execution.document` - // with — TH5 proves the installer wires it. - try { - yield* underHangup(hung.operation, function* () { - order.push("grid live"); - // The grid is up. The terminal goes away underneath it. - hung.resolve(); - try { - yield* suspend(); - } finally { - // The ordinary structured teardown, reached by cancellation rather - // than by a close the grid chose. - order.push("torn down"); - } - }); - order.push("sibling ran"); - } catch (error) { - outcome = error instanceof Error ? error.message : String(error); - } - }); - - expect(order).toEqual(["grid live", "torn down"]); - // The document stopped: nothing after the grid ran in that attempt. - expect(order).not.toContain("sibling ran"); - expect(outcome).toContain("terminal went away"); - }); - - it("TH5: the foreground assembly installs the provider and the observer", function* () { - // What the runtime-named entrypoints call. Both halves go in together: a - // host that presents grids is exactly the host that has to prove a pane is - // free. - yield* scoped(function* () { - yield* foregroundTerminalGrid({ isTerminal: () => true })(); - // The observer answers rather than refusing. - expect((yield* processTable()).length).toBeGreaterThan(0); - }); - - // And the other assembly installs neither. - yield* scoped(function* () { - yield* unsupportedTerminalGrid(); - let refusal = ""; - try { - yield* processTable(); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - expect(refusal).toContain("cannot observe processes"); - }); - }); - it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. From c58c49088894e2cef3835ea5d752dea7b3bf9330 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 12:44:25 -0400 Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=90=9B=20Freeze=20the=20foreground-?= =?UTF-8?q?host=20boundary,=20and=20repair=20what=20it=20exposed=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The deadlock was mine, not the provider's.** A bounded reproduction — one self-closing pane through `foregroundTerminalGrid()`, fake tmux, a real worker on a real socket, and a shell fixture that signals its start and then stays — traced the whole teardown in order the moment a hangup was actually delivered: detach, worker settlement, holder-free goodbye, channels closed, server stopped. The earlier row never delivered one. It passed `hangup` as a provider dependency, which an earlier repair had removed, so the override was inert and the run waited on a real SIGHUP that never came. No production change was needed for it, and the instrumentation is gone. **One real defect it did expose.** `useHangupCancellation` discarded what `next(request)` returned, so every ordinary run through the installer was refused for having "returned before the document produced a result". The result is returned now, and `underHangup` is typed to carry it. **TH4 is the host boundary, driven by the installed listener.** A real document with a live grid, through everything `foregroundTerminalGrid()` installs, with `process.kill(process.pid, "SIGHUP")` rather than a stand-in. It proves cancellation rather than reader close, that the sibling after the grid never ran, that the pane's child and every worker are gone, that the server is gone, that the private directory — removed last, after its sockets close — is gone, and that the SIGHUP listener went with the run that installed it. Every wait is on an event: the pane child's own start file, and each worker's own exit. Both halves of the installer are load-bearing: removing `useHangupCancellation()` leaves TH4 hanging on a grid nothing ends, and removing the provider registration fails it outright. One thing recorded rather than asserted around: cancelling the document from inside its own middleware surfaces as core's "middleware returned before the document produced a result" rather than as `TerminalLost`, because the guard fires on the cancelled canonical execution first. The observable contract holds — the run fails, teardown completes, no sibling runs — so the row asserts those and not the wording. --- packages/cli/src/terminal/host.ts | 12 +- packages/cli/tests/terminal-grid-tmux.test.ts | 137 +++++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/terminal/host.ts index a88ae3cf..4b942e80 100644 --- a/packages/cli/src/terminal/host.ts +++ b/packages/cli/src/terminal/host.ts @@ -51,7 +51,10 @@ export function* unsupportedTerminalGrid(): Operation { export function useHangupCancellation(hangup: Operation): Operation { return Execution.around({ *document([request], next) { - yield* underHangup(hangup, () => next(request)); + // The result is returned, not swallowed: canonical execution is what + // produces a document result, and a handler that answered with nothing + // would be refused for having returned before one existed. + return yield* underHangup(hangup, () => next(request)); }, }); } @@ -63,11 +66,8 @@ export function useHangupCancellation(hangup: Operation): Operation * comes down through the same teardown a reader close uses, and the run stops * rather than continuing on a terminal it no longer has. */ -export function underHangup( - hangup: Operation, - body: () => Operation, -): Operation { - return (function* (): Operation { +export function underHangup(hangup: Operation, body: () => Operation): Operation { + return (function* (): Operation { const outcome = yield* race([ (function* (): Operation<{ done: true; value: T }> { return { done: true, value: yield* body() }; diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 7a13d47f..9a0954c7 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -35,6 +35,7 @@ import type { ChildProcess } from "node:child_process"; import net from "node:net"; import type { Server, Socket } from "node:net"; import * as path from "node:path"; +import process from "node:process"; import { cliCommand } from "@executablemd/test-support/launch"; import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; import { realpath } from "node:fs/promises"; @@ -75,7 +76,7 @@ import { import type { Json } from "@executablemd/core"; import type { Result } from "effection"; import { processTable, TerminalGrids } from "@executablemd/runtime"; -import { readdir } from "node:fs/promises"; +import { chmod, readdir } from "node:fs/promises"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { @@ -1704,6 +1705,140 @@ describe("Tier TH — host installation", () => { expect(refusal).toContain("older than tmux"); }); + /** Settle once this child has gone, whether or not it already had. */ + function exited(child: ChildProcess): Operation { + const done = withResolvers(); + const onExit = (): void => done.resolve(); + if (child.exitCode !== null || child.signalCode !== null) { + done.resolve(); + } else { + child.on("exit", onExit); + } + return (function* (): Operation { + try { + yield* done.operation; + } finally { + child.off("exit", onExit); + } + })(); + } + + /** A shell that says when it started, and stays until it is signalled. */ + function useShellFixture(room: string): Operation { + return resource(function* (provide) { + const file = path.join(room, "shell"); + yield* writeTextFile( + file, + ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( + "\n", + ), + ); + yield* until(chmod(file, 0o755)); + yield* provide(file); + }); + } + + it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + // The run's foreground lease, which a grid takes before any provider. + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + const sighupBefore = foregroundSignalListeners("SIGHUP"); + let directory = ""; + let installed = 0; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + // The listener is the installer's, and this row uses that one. + installed = foregroundSignalListeners("SIGHUP"); + + yield* spawn(function* () { + // Driven by the pane child's own start: the worker spawned, its channel + // authenticated, and the shell it launched said so. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + process.kill(process.pid, "SIGHUP"); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The installer put its listener on, and took it off with the run. + expect(installed).toBe(sighupBefore + 1); + expect(foregroundSignalListeners("SIGHUP")).toBe(sighupBefore); + + // Cancellation, not a reader close: the run failed and nothing after the + // grid ran in that attempt. + expect(outcome?.ok).toBe(false); + expect(output).not.toContain("AFTER_THE_GRID"); + + // Every teardown phase completed before the result was observed. The pane's + // child is gone, the worker is gone, the server is gone, and the private + // directory — which is removed last, after its sockets have closed — is + // gone with them. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + // Awaited on each process's own exit event, not sampled: a worker that had + // not quite gone yet would make a sampled check pass or fail by timing. + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. From d14f266dc3dee6082d42d14cbafc091ec9efabdb Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 13:28:37 -0400 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=90=9B=20Freeze=20the=20combined=20?= =?UTF-8?q?grid=20teardown,=20and=20finish=20close-request=20failure=20han?= =?UTF-8?q?dling=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The teardown was the least-covered part of this provider, and covering it found two defects. A close *request* could fail outside the boundary that handled the waits. `socket.destroy()` and `server.close()` were called after their closure watch had been attached and queued, so a request that threw left a wait nothing would ever settle — the whole close hung rather than failing. The requests are now inside the same boundary, a watch whose request threw is abandoned rather than awaited, and the handle stays out of the closed set so a later call asks it again while leaving the ones that closed alone. A retried teardown restarted rather than resumed. Every phase was re-asked, so a worker that had already said goodbye and gone answered the second ask as "a worker that was gone" — and that answer replaced the reason the first attempt could not finish. The composite's own finalizer retries after a failed `destroy()`, so this was the ordinary path: a document was told its pane had vanished when what had actually happened was that the server would not stop. Phases that succeeded are now remembered, and a retry resumes at the one that failed. The teardown itself moves out of the composite closure into `createGridTeardown()`, which is what lets a row drive it with scripted workers over real private sockets. Rows: TH5 freezes the ordinary foreground-host branch — the same live grid as TH4, ended by a reader detach through the fake control channel instead of a hangup, asserting the exact result handed back through `useHangupCancellation()`. It fails if either the tmux provider or the POSIX observer is removed from `foregroundTerminalGrid()`. TH6 freezes entrypoint selection. Tier TD covers the combined teardown: shared in-flight teardown under concurrent destroys, the three protocol refusals, one pane's failure stranding neither the next pane nor the channels nor the server, first- failure preservation, the frozen order through to path removal, the retried close request, the resumed retry, and the document-level refusal. Real terminal restoration remains #726's real-tmux evidence. --- packages/cli/src/terminal/pane-channel.ts | 120 ++- packages/cli/src/terminal/provider.ts | 186 +++-- packages/cli/tests/terminal-grid-tmux.test.ts | 747 +++++++++++++++++- 3 files changed, 906 insertions(+), 147 deletions(-) diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 6b79a215..08bd8613 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -135,6 +135,9 @@ export function usePaneChannels( // Awaited, not asked for. `destroy()` and `close()` are requests; what the // directory's removal has to wait for is the closures themselves. let closing: ReturnType> | undefined; + /** Handles that have actually closed, so a retry does not close them twice. */ + const shut = new Set(); + function* closeAll(): Operation { if (closing !== undefined) { // Published before anything is closed, so a second caller arriving @@ -143,26 +146,60 @@ export function usePaneChannels( return yield* closing.operation; } closing = withResolvers(); - const closings: Operation[] = []; - const counted = (): void => { - closedCount++; + let failure: Error | undefined; + const failed = (error: unknown): void => { + failure = failure ?? (error instanceof Error ? error : new Error(String(error))); }; - for (const socket of live) { - // Asked for before the destroy, so the listener is there when the close - // it waits for arrives. - closings.push(closed(socket, counted)); - socket.destroy(); + const waits: Operation[] = []; + + // The requests are inside the same failure boundary as the waits: asking + // a handle to close is as capable of failing as waiting for it, and a + // request that threw must not stop the others being asked. The watch for + // one that threw is abandoned rather than awaited — nothing is going to + // close it — and the handle is left out of `shut`, so a later call asks + // again. + for (const socket of [...live]) { + if (shut.has(socket)) { + continue; + } + const watch = closedSocket(socket, () => { + closedCount++; + shut.add(socket); + }); + try { + socket.destroy(); + waits.push(watch.wait); + } catch (error) { + watch.abandon(); + failed(error); + } } for (const server of servers) { - closings.push(shut(server, counted)); - server.close(); + if (shut.has(server)) { + continue; + } + const watch = closedServer(server, () => { + closedCount++; + shut.add(server); + }); + try { + server.close(); + waits.push(watch.wait); + } catch (error) { + watch.abandon(); + failed(error); + } } - try { - for (const pending of closings) { - yield* pending; + for (const wait of waits) { + try { + yield* wait; + } catch (error) { + failed(error); } - } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)); + } + if (failure !== undefined) { + // Cleared, so a later call retries the handles that did not close and + // leaves the ones that did alone. closing.reject(failure); closing = undefined; throw failure; @@ -292,7 +329,7 @@ export function usePaneChannels( } /** Settle once this socket has closed, whether or not it already had. */ -function closed(socket: Socket, onClosed: () => void): Operation { +function closedSocket(socket: Socket, onClosed: () => void): CloseWatch { // Attached now, awaited later. The caller asks for this *before* destroying // the socket, so a listener attached lazily would miss the close it is // waiting for — and the directory would go while the socket was still open. @@ -307,18 +344,34 @@ function closed(socket: Socket, onClosed: () => void): Operation { } else { socket.on("close", onClose); } - return (function* (): Operation { - try { - yield* done.operation; - } finally { - // Removed synchronously when the wait is over, however it ends. - socket.off("close", onClose); - } - })(); + return { + wait: (function* (): Operation { + try { + yield* done.operation; + } finally { + // Removed synchronously when the wait is over, however it ends. + socket.off("close", onClose); + } + })(), + abandon: () => socket.off("close", onClose), + }; +} + +/** + * A closure this code is already listening for. + * + * Two halves because asking a handle to close can fail: the listener has to be + * on before the request, and a request that threw leaves nothing to wait for. + * `abandon` takes the listener off without claiming the handle closed, so the + * handle stays retryable rather than being counted or waited on forever. + */ +interface CloseWatch { + readonly wait: Operation; + abandon(): void; } /** Settle once this server has stopped listening. */ -function shut(server: Server, onClosed: () => void): Operation { +function closedServer(server: Server, onClosed: () => void): CloseWatch { const done = withResolvers(); const onClose = (): void => { onClosed(); @@ -330,13 +383,16 @@ function shut(server: Server, onClosed: () => void): Operation { } else { server.on("close", onClose); } - return (function* (): Operation { - try { - yield* done.operation; - } finally { - server.off("close", onClose); - } - })(); + return { + wait: (function* (): Operation { + try { + yield* done.operation; + } finally { + server.off("close", onClose); + } + })(), + abandon: () => server.off("close", onClose), + }; } /** A connection that has said nothing for long enough to be nobody. */ diff --git a/packages/cli/src/terminal/provider.ts b/packages/cli/src/terminal/provider.ts index d127c7dd..5c33258f 100644 --- a/packages/cli/src/terminal/provider.ts +++ b/packages/cli/src/terminal/provider.ts @@ -97,6 +97,97 @@ export function tmuxGridProvider(deps: TmuxProviderDependencies): TerminalProvid * ├─ the tmux server and its panes (`kill-server`, proved) * └─ the admitted worker links */ +/** Everything one grid's teardown has to take down, in the order it does. */ +export interface GridParts { + /** Ask the reader's client to leave, and establish that it did. */ + detachReader(): Operation; + /** Every admitted worker link, in pane order. */ + readonly links: readonly PaneLink[]; + /** Close every private socket and server. */ + closeChannels(): Operation; + /** Stop the server, and establish it is gone. */ + stopServer(): Operation; +} + +/** + * The one teardown, in the one order, however a grid ends. + * + * Core calls it through `destroy()`; the composite's finalizer calls it when + * core never got that far, which is what a preparation that failed halfway + * leaves. A second caller waits on the first rather than skipping past + * unfinished work, and a teardown that *failed* is retried rather than + * remembered as done — marking it complete before it succeeded would let the + * run continue past a pane it never established was free. + * + * The order is the contract, and every step is a proof rather than a request: + * + * detach the reader's client and establish it stopped + * → ask every acquired worker to shut down + * → require its settlement, its holder-free goodbye, and its channel + * closing, in that order + * → close every private channel + * → stop the server and establish it is gone + * + * Every acquired resource is attempted even after an earlier one failed, so one + * bad worker does not strand the server, the channels or the paths. The first + * failure is what surfaces. + */ +export function createGridTeardown(parts: GridParts): () => Operation { + /** The one teardown in flight, so repeat callers observe it rather than skip it. */ + let tearing: ReturnType> | undefined; + let complete = false; + const steps: (() => Operation)[] = [ + () => parts.detachReader(), + ...parts.links.map((link) => () => quiesceWorker(link)), + // Channels before the server: a socket still open onto a pane of a server + // that has gone is a handle onto nothing. + () => parts.closeChannels(), + () => parts.stopServer(), + ]; + /** Phases already proved done, so a retry resumes rather than restarts. */ + const settled = new Set(); + + return function* tearDown(): Operation { + if (complete) { + return; + } + if (tearing) { + return yield* tearing.operation; + } + tearing = withResolvers(); + let failure: Error | undefined; + const failed = (error: unknown): void => { + failure = failure ?? (error instanceof Error ? error : new Error(String(error))); + }; + + for (const [index, step] of steps.entries()) { + if (settled.has(index)) { + // A phase that succeeded is not asked again. Re-asking would fail for + // the wrong reason — a worker that has already said goodbye and gone is + // "a worker that was gone" the second time — and that answer would + // replace the reason the first attempt actually could not finish. + continue; + } + try { + yield* step(); + settled.add(index); + } catch (error) { + failed(error); + } + } + + if (failure !== undefined) { + // Retryable: `tearing` is cleared, so a later caller runs the phases that + // did not finish rather than being told a teardown that failed had. + tearing.reject(failure); + tearing = undefined; + throw failure; + } + complete = true; + tearing.resolve(); + }; +} + function usePresentedGrid( deps: TmuxProviderDependencies, request: TerminalGridRequest, @@ -141,91 +232,24 @@ function usePresentedGrid( let shown = 0; let visible: VisibleClient | undefined; - /** The one teardown in flight, so repeat callers observe it rather than skip it. */ - let tearing: ReturnType> | undefined; - let complete = false; - /** - * The one teardown, in the one order, however this grid ends. - * - * Core calls it through `destroy()`; the finalizer calls it when core never - * got that far, which is what a preparation that failed halfway leaves. A - * second caller waits on the first rather than skipping past unfinished - * work, and a teardown that *failed* is retried rather than remembered as - * done — marking it complete before it succeeded would let the run continue - * past a pane it never established was free. - * - * The order is the contract, and every step is a proof rather than a - * request: - * - * detach the reader's client and establish it stopped - * → ask every acquired worker to shut down - * → require its settlement, its holder-free goodbye, and its channel - * closing, in that order - * → close every private channel - * → stop the server and establish it is gone - * - * Every acquired resource is attempted even after an earlier one failed, so - * one bad worker does not strand the server, the channels or the paths. The - * first failure is what surfaces. - */ - function* tearDown(): Operation { - if (complete) { - return; - } - if (tearing) { - return yield* tearing.operation; - } - tearing = withResolvers(); - let failure: Error | undefined; - const failed = (error: unknown): void => { - failure = failure ?? (error instanceof Error ? error : new Error(String(error))); - }; - - // The reader's client first, and asked rather than told: a client that - // detaches restores the terminal, and one that is killed cannot. - if (visible !== undefined) { + const tearDown = createGridTeardown({ + *detachReader(): Operation { + // The reader's client first, and asked rather than told: a client that + // detaches restores the terminal, and one that is killed cannot. + if (visible === undefined) { + return; + } const client = visible; visible = undefined; - try { - yield* grid.detach(client); - } catch (error) { - failed(error); - } - } - - for (const link of links) { - try { - yield* quiesceWorker(link); - } catch (error) { - failed(error); - } - } - - // Channels before the server: a socket still open onto a pane of a server - // that has gone is a handle onto nothing. - try { - yield* channels.close(); - } catch (error) { - failed(error); - } - - try { + yield* grid.detach(client); + }, + links, + closeChannels: () => channels.close(), + stopServer: function* (): Operation { yield* grid.stop(); - } catch (error) { - failed(error); - } - - if (failure !== undefined) { - // Retryable: `tearing` is cleared, so a later caller runs it again - // rather than being told a teardown that failed had finished. - tearing.reject(failure); - tearing = undefined; - throw failure; - } - complete = true; - tearing.resolve(); - } + }, + }); yield* ensure(function* () { yield* tearDown(); diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 9a0954c7..63129df4 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -61,7 +61,7 @@ import { } from "../src/terminal/layout.ts"; import type { LayoutCell } from "../src/terminal/layout.ts"; import { usePaneChannels } from "../src/terminal/pane-channel.ts"; -import { runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; +import { createGridTeardown, runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; import { foregroundTerminalGrid, underHangup, @@ -83,6 +83,8 @@ import { FromWorkerSchema, paneSocketPath, paneTokenPath, + readFrames, + ToWorkerSchema, writeFrame, } from "../src/terminal/pane-protocol.ts"; import { @@ -1664,6 +1666,604 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { * language and validation and install no operational provider, so a document * that asks for a grid there is refused before a pane starts. */ +/** Settle once this child has gone, whether or not it already had. */ +function exited(child: ChildProcess): Operation { + const done = withResolvers(); + const onExit = (): void => done.resolve(); + if (child.exitCode !== null || child.signalCode !== null) { + done.resolve(); + } else { + child.on("exit", onExit); + } + return (function* (): Operation { + try { + yield* done.operation; + } finally { + child.off("exit", onExit); + } + })(); +} + +/** A shell that says when it started, and stays until it is signalled. */ +function useShellFixture(room: string): Operation { + return resource(function* (provide) { + const file = path.join(room, "shell"); + yield* writeTextFile( + file, + ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( + "\n", + ), + ); + yield* until(chmod(file, 0o755)); + yield* provide(file); + }); +} + +/** A settlement that proved its pane free. */ +function quietSettlement(): Settlement { + return { method: "exited", quiet: true, swept: [], holders: [] }; +} + +/** How one scripted worker answers what the parent tells it. */ +type Reply = ( + frame: ToWorker, + say: (message: FromWorker) => Operation, + socket: Socket, +) => Operation; + +interface ScriptedWorker { + readonly socket: Socket; + /** Everything the parent told this worker, in order. */ + readonly heard: ToWorker["type"][]; +} + +/** + * One pane's worker, over that pane's real socket, saying what a row scripts. + * + * A real connection through the real admission handshake, because the order + * being frozen is the order frames actually arrive in. What is scripted is the + * worker's *answers* — which is where the protocol failures live, and the one + * thing a real worker will not do on request. + */ +function useScriptedWorker( + directory: string, + ordinal: number, + reply: Reply, +): Operation { + return resource(function* (provide) { + const socket = yield* useImpostor(directory, ordinal); + const token = (yield* readTextFile(paneTokenPath(directory, ordinal))).trim(); + const heard: ToWorker["type"][] = []; + const frames = yield* readFrames(socket, (value) => ToWorkerSchema.parse(value)); + yield* writeFrame(socket, { + type: "hello", + ordinal, + token, + pid: process.pid, + pgid: process.pid, + tty: "??", + isatty: [false, false, false], + }); + yield* spawn(function* () { + let next = yield* frames.next(); + while (!next.done) { + heard.push(next.value.type); + yield* reply(next.value, (message) => writeFrame(socket, message), socket); + next = yield* frames.next(); + } + }); + yield* provide({ socket, heard }); + }); +} + +/** A worker that shuts down the way one that worked is supposed to. */ +function quiesces(hold?: Operation): Reply { + return function* (frame, say, socket) { + if (frame.type !== "shutdown") { + return; + } + if (hold !== undefined) { + yield* hold; + } + yield* say({ type: "quiet", settlement: quietSettlement() }); + yield* say({ type: "bye", holders: [] }); + // A worker that has said goodbye is leaving, and its channel closing is the + // third thing the teardown requires. One that stayed would be a pane still + // holding a connection to a grid that is going away. + socket.destroy(); + }; +} + +/** The link the teardown drives, wrapped so the row sees what it observed. */ +function loggedLink(link: PaneLink, log: string[]): PaneLink { + return { + ordinal: link.ordinal, + hello: link.hello, + *send(message) { + log.push(`${message.type}:${link.ordinal}`); + yield* link.send(message); + }, + *next() { + const frame = yield* link.next(); + log.push(frame === undefined ? `eof:${link.ordinal}` : `${frame.type}:${link.ordinal}`); + return frame; + }, + connected: () => link.connected(), + }; +} + +interface Teardown { + readonly log: string[]; + readonly directory: string; + readonly run: () => Operation; + /** Every private socket and server this grid opened. */ + readonly handles: (Socket | Server)[]; +} + +/** + * A teardown over real private channels, with the reader's client and the + * server standing in for what tmux does with them. + * + * The channels are real, so the closures and the path removal in the frozen + * order are the production ones. The two ends this fixture supplies are the two + * whose failures a row has to be able to choose. + */ +function useTeardown(options: { + readonly workers: readonly (Reply | undefined)[]; + readonly detach?: () => Operation; + readonly stop?: () => Operation; +}): Operation { + return resource(function* (provide) { + const log: string[] = []; + const handles: (Socket | Server)[] = []; + // One server per pane, created in pane order. Which pane a closure belongs + // to is read from the server that accepted the connection, so the order + // this row freezes is per-pane rather than per-event. + let panes = 0; + const belongs = new Map(); + const detachments: (() => void)[] = []; + const noteSocket = (socket: Socket, what: () => string): void => { + handles.push(socket); + const onClose = (): void => { + log.push(what()); + }; + socket.on("close", onClose); + detachments.push(() => socket.off("close", onClose)); + }; + const noteServer = (server: Server, what: () => string): void => { + handles.push(server); + const onClose = (): void => { + log.push(what()); + }; + server.on("close", onClose); + detachments.push(() => server.off("close", onClose)); + }; + yield* ensure(() => { + // This row's own listeners, off the emitters this row put them on. + for (const detach of detachments) { + detach(); + } + }); + const channels = yield* usePaneChannels(options.workers.length, { + onSocket: (socket) => noteSocket(socket, () => `socket-closed:${belongs.get(socket) ?? -1}`), + onServer: (server) => { + const ordinal = panes++; + const onConnection = (socket: Socket): void => { + belongs.set(socket, ordinal); + }; + server.on("connection", onConnection); + detachments.push(() => server.off("connection", onConnection)); + noteServer(server, () => `server-closed:${ordinal}`); + }, + }); + for (const [ordinal, reply] of options.workers.entries()) { + if (reply !== undefined) { + yield* useScriptedWorker(channels.directory, ordinal, reply); + } + } + const links: PaneLink[] = []; + for (const [ordinal, reply] of options.workers.entries()) { + if (reply !== undefined) { + links.push(loggedLink(yield* channels.link(ordinal), log)); + } + } + const run = createGridTeardown({ + detachReader: + options.detach ?? + function* () { + log.push("detach"); + }, + links, + *closeChannels() { + yield* channels.close(); + }, + stopServer: + options.stop ?? + function* () { + log.push("server-stopped"); + }, + }); + yield* provide({ log, directory: channels.directory, run, handles }); + }); +} + +describe("Tier TD — the combined teardown", () => { + it("TD1: concurrent destroys share one teardown, and every phase happens once", function* () { + const held = withResolvers(); + const fixture = yield* useTeardown({ workers: [quiesces(held.operation), quiesces()] }); + + const first = yield* spawn(() => fixture.run()); + // Held inside the first worker's settlement, so the second destroy arrives + // while the first teardown is genuinely part-way through rather than + // racing it. + while (!fixture.log.includes("shutdown:0")) { + yield* sleep(5); + } + const second = yield* spawn(() => fixture.run()); + held.resolve(); + yield* first; + yield* second; + + const once = (entry: string): number => fixture.log.filter((line) => line === entry).length; + for (const entry of ["detach", "shutdown:0", "shutdown:1", "server-stopped"]) { + expect([entry, once(entry)]).toEqual([entry, 1]); + } + // The channels too: one closure each, not one per caller. + for (const ordinal of [0, 1]) { + expect([ordinal, once(`socket-closed:${ordinal}`)]).toEqual([ordinal, 1]); + expect([ordinal, once(`server-closed:${ordinal}`)]).toEqual([ordinal, 1]); + } + }); + + it("TD2: a worker that was gone before it was asked refuses the teardown", function* () { + const fixture = yield* useTeardown({ workers: [quiesces()] }); + fixture.handles.find((handle): handle is Socket => "destroy" in handle)?.destroy(); + while (!fixture.log.includes("socket-closed:0")) { + yield* sleep(5); + } + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("gone before it was asked to stop"); + }); + + it("TD3: a goodbye before a settlement refuses", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("said goodbye before it was proved free"); + }); + + it("TD4: a settlement with no goodbye after it refuses", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say, socket) { + if (frame.type !== "shutdown") { + return; + } + yield* say({ type: "quiet", settlement: quietSettlement() }); + // EOF where the goodbye belongs: settled, and never established free. + socket.destroy(); + }, + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("stopped answering before it was proved free"); + expect(fixture.log).toContain("eof:0"); + }); + + it("TD5: one pane's failure strands neither the next pane, the channels, nor the server", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + quiesces(), + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // The first pane's failure is what surfaced, and everything acquired after + // it was still taken down. + expect(refusal).toContain("said goodbye before it was proved free"); + expect(fixture.log).toContain("shutdown:1"); + expect(fixture.log).toContain("bye:1"); + for (const ordinal of [0, 1]) { + expect(fixture.log).toContain(`socket-closed:${ordinal}`); + expect(fixture.log).toContain(`server-closed:${ordinal}`); + } + expect(fixture.log).toContain("server-stopped"); + }); + + it("TD6: the first failure is the one that surfaces", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + ], + // deno-lint-ignore require-yield + *stop() { + throw new Error("the server would not stop"); + }, + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + // The pane, not the server: a later failure does not replace the reason the + // teardown could not establish this grid was gone. + expect(refusal).toContain("said goodbye before it was proved free"); + expect(refusal).not.toContain("would not stop"); + }); + + /** A worker that stays connected and says nothing. */ + // deno-lint-ignore require-yield + const silent: Reply = function* () {}; + + it("TD10: a retry resumes at the phase that failed and re-asks no finished one", function* () { + const stops: string[] = []; + let refuse = true; + const fixture = yield* useTeardown({ + workers: [quiesces()], + // deno-lint-ignore require-yield + *stop() { + stops.push("asked"); + if (refuse) { + refuse = false; + throw new Error("the server would not stop"); + } + }, + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("would not stop"); + + // The retry finishes the grid, and the phases that were proved done are not + // asked again: a worker that has already said goodbye and gone would answer + // the second ask as "a worker that was gone", which would replace the + // reason the first attempt could not finish with an artifact of its + // succeeding. + yield* fixture.run(); + expect(stops.length).toBe(2); + expect(fixture.log.filter((line) => line === "shutdown:0").length).toBe(1); + expect(fixture.log.filter((line) => line === "bye:0").length).toBe(1); + }); + + it("TD8: a close request that fails is retried, and what closed stays closed", function* () { + const closed: string[] = []; + let panes = 0; + let refuse = true; + const channels = yield* usePaneChannels(2, { + onSocket(socket) { + socket.on("close", () => closed.push("socket")); + }, + onServer(server) { + const ordinal = panes++; + server.on("close", () => closed.push(`server:${ordinal}`)); + if (ordinal !== 0) { + return; + } + // One handle that refuses to be *asked*, once. A close request is as + // capable of failing as the wait after it, and the two have to be + // inside the same boundary or the failure escapes the retry. + const ask = server.close.bind(server); + server.close = (callback?: (error?: Error) => void) => { + if (refuse) { + refuse = false; + throw new Error("this handle refused to be closed"); + } + return ask(callback); + }; + }, + }); + yield* useScriptedWorker(channels.directory, 0, silent); + yield* useScriptedWorker(channels.directory, 1, silent); + yield* channels.link(0); + yield* channels.link(1); + + let refusal = ""; + try { + yield* channels.close(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("refused to be closed"); + // The handles after the failure were still asked, and closed. + expect(closed.filter((name) => name === "socket").length).toBe(2); + expect(closed).toContain("server:1"); + expect(closed).not.toContain("server:0"); + + // The published settlement was cleared rather than remembered, so this call + // asks the handle that refused again — and nothing that already closed is + // closed a second time. + yield* channels.close(); + for (const [name, times] of [ + ["socket", 2], + ["server:0", 1], + ["server:1", 1], + ] as const) { + expect([name, closed.filter((entry) => entry === name).length]).toEqual([name, times]); + } + }); + + it("TD9: a teardown that fails refuses the run, and nothing after the grid goes", function* () { + // The document-level end of the same claim: a grid whose teardown could not + // establish the terminal was given back is a failed run, not a run with a + // warning in it. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + // The server refuses to be killed the first time it is asked, so the last + // phase of the teardown cannot establish it is gone. + const tmux = createFakeTmux({ + script, + clientCommand, + spawnPanes: true, + failOnce: { command: "kill-server", message: "refused" }, + }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + expect(outcome?.ok).toBe(false); + const refusal = outcome?.ok === false ? String(outcome.error) : ""; + expect(refusal).toContain("terminal server"); + // Nothing private in it, and nothing after the grid ran. + expect(refusal).not.toContain(room); + expect(output).not.toContain("AFTER_THE_GRID"); + }); + + it("TD7: the combined order is the frozen one", function* () { + const order: string[] = []; + let directory = ""; + yield* scoped(function* () { + // Registered before the channels exist, so it runs after they are gone: + // the private paths are removed by the channels' own scope, last, once + // everything inside it has closed. + yield* ensure(function* () { + if (directory !== "" && !(yield* exists(directory))) { + order.push("paths-removed"); + } + }); + const fixture = yield* useTeardown({ workers: [quiesces(), quiesces()] }); + directory = fixture.directory; + yield* fixture.run(); + order.push(...fixture.log); + }); + + const at = (entry: string): number => order.indexOf(entry); + const last = (entry: string): number => order.lastIndexOf(entry); + // visible detach → worker settlements → holder-free goodbyes → worker + // channel closures → channel servers closed → server disappearance → + // private path removal. + expect(at("detach")).toBe(0); + for (const ordinal of [0, 1]) { + expect(at(`shutdown:${ordinal}`)).toBeGreaterThan(at("detach")); + expect(at(`quiet:${ordinal}`)).toBeGreaterThan(at(`shutdown:${ordinal}`)); + expect(at(`bye:${ordinal}`)).toBeGreaterThan(at(`quiet:${ordinal}`)); + } + // Each pane's four phases are that pane's, in order — panes are quiesced + // one at a time, so pane zero's channel closes while pane one has not been + // asked yet. What is global is the boundary after them: no server closes + // until every worker channel has. + for (const ordinal of [0, 1]) { + expect(at(`socket-closed:${ordinal}`)).toBeGreaterThan(at(`bye:${ordinal}`)); + expect(at("server-closed:0")).toBeGreaterThan(at(`socket-closed:${ordinal}`)); + } + expect(at("server-closed:1")).toBeGreaterThan(at("server-closed:0") - 1); + expect(at("server-stopped")).toBeGreaterThan( + Math.max(at("server-closed:0"), at("server-closed:1")), + ); + expect(at("paths-removed")).toBe(order.length - 1); + }); +}); + +/** One entrypoint's source, for the rows about what a host assembles. */ +function entrypointSource(name: string): Operation { + return readTextFile(path.resolve("packages/cli/src", name)); +} + describe("Tier TH — host installation", () => { it("TH1: without a terminal, a grid refuses before anything exists", function* () { const before = yield* until(readdir(tmpdir())); @@ -1705,39 +2305,6 @@ describe("Tier TH — host installation", () => { expect(refusal).toContain("older than tmux"); }); - /** Settle once this child has gone, whether or not it already had. */ - function exited(child: ChildProcess): Operation { - const done = withResolvers(); - const onExit = (): void => done.resolve(); - if (child.exitCode !== null || child.signalCode !== null) { - done.resolve(); - } else { - child.on("exit", onExit); - } - return (function* (): Operation { - try { - yield* done.operation; - } finally { - child.off("exit", onExit); - } - })(); - } - - /** A shell that says when it started, and stays until it is signalled. */ - function useShellFixture(room: string): Operation { - return resource(function* (provide) { - const file = path.join(room, "shell"); - yield* writeTextFile( - file, - ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( - "\n", - ), - ); - yield* until(chmod(file, 0o755)); - yield* provide(file); - }); - } - it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { const room = yield* useScratch(); const shell = yield* useShellFixture(room); @@ -1839,6 +2406,118 @@ describe("Tier TH — host installation", () => { expect(yield* exists(directory)).toBe(false); }); + it("TH5: an ordinary run shows the grid, and the reader's detach ends it", function* () { + // The same host, the same document and the same live grid as TH4. What + // differs is the ending: the reader leaves rather than the terminal going + // away, so the grid settles and the document carries on — which is the + // branch `useHangupCancellation()` has to hand the result back through. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let directory = ""; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + // Driven by the grid's own progress: the pane child started, and the + // server has a reader's client to report the detach of. No SIGHUP. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The exact result, handed back through the hangup wrapper rather than + // swallowed by it: a handler that answered with nothing would be refused + // for having returned before the document produced a result. + expect(outcome).toEqual(Ok("\n\nAFTER_THE_GRID\n")); + // The reader closed the grid; the document went on. + expect(output).toContain("AFTER_THE_GRID"); + + // And it went on over a grid that had actually been taken down: the pane's + // child, the workers, the server and the private directory are all gone. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + + it("TH6: the Deno and compiled entrypoints present grids; Node and Bun do not", function* () { + for (const name of ["deno.ts", "compiled.ts"]) { + expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid()")).toBe(true); + } + for (const name of ["node.ts", "bun.ts"]) { + // Not a different grid: no grid at all, and therefore the default the + // shared entry declares — which is the installation that validates a grid + // and presents none. + expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid")).toBe(false); + } + expect(yield* entrypointSource("cli.ts")).toContain( + "installTerminalGrid: TerminalGridInstaller = unsupportedTerminalGrid", + ); + }); + it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. From e73299c9adb69c6d3d3fc2372a23cec4670eb2d1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 17:13:36 -0400 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=90=9B=20Keep=20the=20tmux=20grid?= =?UTF-8?q?=20suite=20off=20the=20runtimes=20that=20register=20no=20worker?= =?UTF-8?q?=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite hung forever under Node and Bun. Not failed — hung, which leaves a runtime shard running until the job's own timeout with nothing to read. A pane's worker is this executable re-invoked under the hidden `terminal-worker` subcommand, and only the hosts that present grids register it: the Deno entrypoint and the compiled binary. On Node and Bun the same argument vector names a *document* called `terminal-worker`, so the worker exits with ENOENT before it connects and the parent waits for a pane that will never say hello. It stalls entering TW3, the first row that spawns a real worker. $ tsx packages/cli/src/node.ts terminal-worker 0 ENOENT: no such file or directory, open 'terminal-worker' That Node and Bun install no grid provider is the design, so the fix is the exclusion this repository already has a mechanism for rather than a portable worker. Every other test file in this stack runs under Node unchanged; this is the only one that cannot. What the exclusion does and does not preserve, stated precisely because the rationale is the reason a later reader would trust it: provider absence is covered portably by TG9 in packages/core/tests/terminal-grid.test.ts, which runs on all three runtimes. TH6's entrypoint-selection freeze is textual, so proving it once under Deno proves it everywhere. TH3 makes the same claim as TG9 but is excluded with the rest of the file and proves nothing here. What is genuinely Deno-only is the worker, socket and fake-tmux integration. --- scripts/runtime-test-exclusions.ts | 34 +++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 22765142..4f69bcb4 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -600,6 +600,32 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ }, ]; +/** + * Tests whose subject is the tmux terminal-grid provider. + * + * A pane's worker is this executable re-invoked under a hidden + * `terminal-worker` subcommand, and only the hosts that present grids register + * it — the Deno entrypoint and the compiled binary. Node and Bun install no + * grid provider by design, so on those runtimes the same argument vector names + * a *document* called `terminal-worker`, the worker exits with ENOENT before it + * connects, and the parent waits for a pane that will never say hello. The + * suite hangs rather than failing, which would leave a runtime shard running + * forever. + * + * That a runtime without a provider refuses a grid instead of half-presenting + * one is covered portably by TG9 in `packages/core/tests/terminal-grid.test.ts`, + * which runs everywhere. The excluded file's own TH3 makes the same claim, but + * it is excluded along with the rest of it and proves nothing here. + */ +const DENO_ONLY_TERMINAL_GRID: RuntimeExclusion[] = [ + { + path: "packages/cli/tests/terminal-grid-tmux.test.ts", + reason: + "the subject is the tmux provider, whose panes are this executable re-invoked as `terminal-worker` — a subcommand only the grid-presenting entrypoints register; under Node and Bun that vector names a document instead, so the worker exits with ENOENT and the pane's admission never completes", + issue: DERIVED_SCOPE, + }, +]; + const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ { path: "packages/workflow/tests/xmd-artifact.test.ts", @@ -611,10 +637,16 @@ const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ export const exclusions: Record = { deno: COMPILED_BINARY, - node: [...DENO_ONLY_TOOLING, ...DENO_ONLY_REPOSITORY_PROVIDER, ...COMPILED_BINARY], + node: [ + ...DENO_ONLY_TOOLING, + ...DENO_ONLY_REPOSITORY_PROVIDER, + ...DENO_ONLY_TERMINAL_GRID, + ...COMPILED_BINARY, + ], bun: [ ...DENO_ONLY_TOOLING, ...DENO_ONLY_REPOSITORY_PROVIDER, + ...DENO_ONLY_TERMINAL_GRID, ...COMPILED_BINARY, ...BUN_MISSING_NODE_SQLITE, ],