From 960e4b6da4eddcf4a57aa37bee940f671b9bca93 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:01:37 -0400 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9C=A8=20Add=20the=20terminal=20provid?= =?UTF-8?q?er=20boundary=20and=20pane=20authority=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replaceable seam a terminal grid executes through, before any of the execution that uses it. `packages/runtime/terminal.ts` is the contextual provider: `prepare()` builds the whole composite while it stays hidden, `attach()` shows it once every pane is ready, and `destroy()` gives the root terminal back. The request is provider-neutral — columns, rows, and the authored panes with their derived positions — and names no terminal, socket, process or window. Middleware may observe, narrow, refuse, wrap or delegate; presentation never decides an outcome, so `update()` receives states core has already settled on. `packages/core/src/terminal/authority.ts` mints one-use pane claims for one request's ordinals. A claim admits one interactive operation at a time on its pane and holds that pane's readiness latch. Two claims do not contend, which is what lets panes stay interactive together. `packages/core/src/terminal/pane.ts` is the seam interactive work inside a pane reaches for, so it runs as that pane's owner instead of competing for the root foreground lease. Absence means "not in a pane". Evidence: `packages/runtime/tests/terminal-provider.test.ts`, 11 rows. --- packages/core/src/terminal/authority.ts | 184 ++++++++++++ packages/core/src/terminal/pane.ts | 66 ++++ packages/runtime/mod.ts | 17 ++ packages/runtime/terminal.ts | 282 ++++++++++++++++++ .../runtime/tests/terminal-provider.test.ts | 271 +++++++++++++++++ 5 files changed, 820 insertions(+) create mode 100644 packages/core/src/terminal/authority.ts create mode 100644 packages/core/src/terminal/pane.ts create mode 100644 packages/runtime/terminal.ts create mode 100644 packages/runtime/tests/terminal-provider.test.ts diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts new file mode 100644 index 00000000..df4feffa --- /dev/null +++ b/packages/core/src/terminal/authority.ts @@ -0,0 +1,184 @@ +/** + * Who is allowed to own a terminal, and what "ready" means (architecture.md + * §Terminal authority). + * + * The provider draws a grid. This decides everything about it that matters: + * which request is live, which provider installation it belongs to, which pane + * ordinals exist, whether an interactive operation may start on one, and when a + * pane has actually started. None of that is reachable by name. There is no + * context holding an authority, no member of a request that carries one, and no + * handler return value that produces one — an authority reachable by name would + * be an authority every same-name context and every loaded copy could reach. + * + * A claim is the unforgeable carrier. It is minted here for one ordinal of one + * request under one installation generation, and a claim from another grid, + * another ordinal, an earlier generation, or a finished expansion authorizes + * nothing at all. Holding one grants terminal ownership and nothing else: it + * says nothing about which Agent session a pane may own, because that is the + * session coordinator's to answer and stays independently authoritative. + */ + +import { all, ensure, withResolvers } from "effection"; +import type { Operation } from "effection"; +import type { TerminalGridRequest } from "@executablemd/runtime"; + +export class TerminalAuthorityError extends Error { + override name = "TerminalAuthorityError"; +} + +/** + * One pane's terminal ownership. + * + * `admit` is the whole of it: an interactive operation runs inside one, and a + * second one on the same pane is refused while the first is live. Two claims for + * two ordinals do not contend at all, which is what lets panes be interactive at + * the same time. + */ +export interface TerminalPaneClaim { + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * Refuses while another is live on this pane, and refuses once the grid that + * minted the claim has finished — a claim kept past its expansion is a claim + * to a terminal nobody owns any more. + */ + admit(body: () => Operation): Operation; + /** + * Acknowledge the runtime's successful child-spawn event for this pane. + * + * The one thing that makes a pane ready. Called from the spawn event and + * before anything waits for the child to exit, so a child that starts and + * immediately exits is both ready and settled. Acknowledging twice has no + * effect, and a preparation, reservation or spawn that failed never + * acknowledges at all. + */ + ready(): void; +} + +/** What one pane's readiness is waiting on, from the grid's side. */ +export interface PaneReadiness { + /** Settles when the pane's first interactive child reports its spawn event. */ + reached(): Operation; + /** Whether the latch has been acknowledged. */ + readonly acknowledged: boolean; +} + +/** The claims one grid expansion holds, and what they are waiting on. */ +export interface TerminalGridClaims { + readonly claims: readonly TerminalPaneClaim[]; + readonly readiness: readonly PaneReadiness[]; + /** + * Stop admitting anything on every pane. + * + * Close prevents a later launch before it cancels the live ones, so a pane + * that was about to start one is refused rather than raced. + */ + seal(): void; +} + +/** + * Mint the claims for one grid expansion. + * + * The request is validated against the ordinals it declares before a single + * claim exists: a request whose panes are not exactly `0..n-1` in order + * describes a grid core did not derive, and answering it would be answering for + * a layout nobody authored. + */ +export function createTerminalGridClaims(request: TerminalGridRequest): TerminalGridClaims { + validate(request); + + let sealed = false; + const claims: TerminalPaneClaim[] = []; + const readiness: PaneReadiness[] = []; + + for (const pane of request.panes) { + const latch = withResolvers(); + let acknowledged = false; + let live = false; + + readiness.push({ + reached: () => latch.operation, + get acknowledged() { + return acknowledged; + }, + }); + + claims.push({ + ordinal: pane.ordinal, + *admit(body: () => Operation): Operation { + if (sealed) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, + ); + } + if (live) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + + `terminal at a time`, + ); + } + live = true; + try { + return yield* body(); + } finally { + live = false; + } + }, + ready() { + // Idempotent by construction: readiness is a fact about the pane, and a + // provider that reports the same spawn twice has not started two panes. + if (acknowledged) { + return; + } + acknowledged = true; + latch.resolve(); + }, + }); + } + + return { + claims, + readiness, + seal() { + sealed = true; + }, + }; +} + +function validate(request: TerminalGridRequest): void { + if (request.panes.length === 0) { + throw new TerminalAuthorityError("a terminal grid request names no panes"); + } + for (const [index, pane] of request.panes.entries()) { + if (pane.ordinal !== index) { + throw new TerminalAuthorityError( + `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + + `a pane's ordinal is its position among the grid's panes`, + ); + } + } +} + +/** + * Settle once every pane has reported its spawn event. + * + * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing + * run deadline or parent cancellation is what bounds it. A pane that fails to + * start never reaches its latch, so the caller races this against pane failure + * rather than asking the barrier to know about failure. + */ +export function awaitReadiness(readiness: readonly PaneReadiness[]): Operation { + return allOf(readiness.map((pane) => pane.reached())); +} + +function* allOf(waits: readonly Operation[]): Operation { + yield* all(waits); +} + +/** Seal the grid as soon as the enclosing scope begins to unwind. */ +export function sealOnTeardown(claims: TerminalGridClaims): Operation { + return ensure(() => { + claims.seal(); + }); +} diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts new file mode 100644 index 00000000..f308de81 --- /dev/null +++ b/packages/core/src/terminal/pane.ts @@ -0,0 +1,66 @@ +/** + * How work written inside a pane reaches that pane's terminal. + * + * A `` written at the root reserves the run's one foreground + * terminal and competes with every other launch for it. The same element + * written inside a pane must not: panes are interactive at the same time, which + * is the whole reason a grid exists. So core installs this in each pane's own + * scope, and anything interactive asks here first. + * + * What travels contextually is the seam, not the authority. The claim it hands + * out was minted for one ordinal of one grid and cannot be forged, copied + * usefully, or kept past the expansion that owns it — so a replaced context + * yields a pane terminal nobody owns rather than a way into one somebody does. + * + * Absence is the ordinary case and means "not in a pane": work outside a grid + * reads nothing here and goes on competing for the root lease exactly as it + * always has. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalPaneClaim } from "./authority.ts"; + +/** The pane the current work is running in. */ +export interface PaneTerminal { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * `body` receives the pane's readiness latch and must call it from the + * runtime's successful child-spawn event, before it waits for the child to + * exit. A body that never spawns never reports, and the grid it belongs to + * never attaches — which is what stops a pane that failed to start being + * presented as one that is running. + * + * A second interactive operation while one is live on this pane is refused. + * Two panes do not contend with each other at all. + */ + interactive(body: (spawned: () => void) => Operation): Operation; +} + +const PaneTerminalContext: Context = createContext< + PaneTerminal | undefined +>("core.terminal.pane", undefined); + +/** The pane the current work is running in, or `undefined` outside a grid. */ +export function paneTerminal(): Operation { + return PaneTerminalContext.get(); +} + +/** + * Install one pane's seam for the scope that runs that pane's work. + * + * Set rather than composed: a pane is not a layer over the enclosing pane, + * because panes do not nest. A grid written inside a pane is refused by the + * grammar, so the value a pane's scope holds is always its own. + */ +export function* usePaneTerminal(claim: TerminalPaneClaim): Operation { + yield* PaneTerminalContext.set({ + ordinal: claim.ordinal, + interactive(body) { + return claim.admit(() => body(() => claim.ready())); + }, + }); +} diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c41f3420..e9a990c9 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -146,6 +146,23 @@ export type { NativeLaunchOutcome, NativeLaunchRequest, } from "./launcher.ts"; +export { + installControlledTerminalProvider, + prepareTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalProvider, + TerminalProviderUnavailableError, +} from "./terminal.ts"; +export type { + ControlledTerminalProviderOptions, + TerminalComposite, + TerminalGridRequest, + TerminalPaneRequest, + TerminalPaneState, + TerminalProviderHandler, + TerminalProviderLog, + TerminalShellOutcome, +} from "./terminal.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts new file mode 100644 index 00000000..1fac60e3 --- /dev/null +++ b/packages/runtime/terminal.ts @@ -0,0 +1,282 @@ +/** + * The terminal provider — how a host presents one grid of interactive panes. + * + * This is not the native launcher. A launch hands **one** child the whole + * foreground terminal and waits for it; a grid divides that terminal into + * several panes that stay interactive at the same time, each with its own + * lifetime. tmux is one way to do that, a host-native composite UI is another, + * and a test surface that opens no terminal at all is a third. None of them + * appears in the document: `` asks for panes and their authored + * layout, and the host chooses what presents them. + * + * A grid is prepared before it is shown, which is what makes opening one atomic: + * + * 1. `prepare()` builds the whole composite while it is still hidden — every + * pane endpoint and its supervision — and presents nothing. A host that + * cannot open a grid refuses here, before any pane has started work. + * 2. Core starts the authored panes concurrently and waits for every one of + * them to be ready. + * 3. `attach()` shows the composite, once, after that barrier. A failure before + * it discards the hidden composite instead of leaving a partial grid on the + * reader's screen. + * 4. `destroy()` takes it down again and gives the root terminal back. + * + * There is no host default. `xmd run` installs the production provider; a test + * or embedding host installs a controlled one that needs no terminal. Until one + * is installed every operation refuses, which is what keeps writing, inspecting + * and validating a document free of all of this. + * + * **Presentation never decides an outcome.** `update()` receives the pane states + * core has already settled on, so a provider draws them and answers for none of + * them. Nothing a handler returns can make a pane succeed, fail, or be ready. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** One pane the provider is asked to present, by its authored ordinal. */ +export interface TerminalPaneRequest { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** The label to display. Two panes may carry the same one. */ + readonly title: string; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** + * Whether the document supplies this pane's work or the host's default shell + * does. A provider reads it to know which panes it must start a shell in. + */ + readonly form: "paired" | "self-closing"; +} + +/** + * The grid one expansion asks for. + * + * Provider-neutral throughout: it names no terminal, multiplexer, socket, + * process, window or pane identifier, and carries no command, argv or + * environment. It is what the author wrote, resolved. + */ +export interface TerminalGridRequest { + readonly columns: number; + readonly rows: number; + readonly panes: readonly TerminalPaneRequest[]; +} + +/** + * What core tells a provider about one pane, as it happens. + * + * A closed set, and display only. `running` follows readiness, `succeeded` and + * `failed` follow the pane's own settlement, and `closed` is a live pane + * cancelled solely because the reader closed the grid — which is not a failure + * and is deliberately spelled differently from one. + */ +export type TerminalPaneState = "starting" | "running" | "succeeded" | "failed" | "closed"; + +/** How a pane's default shell ended. */ +export interface TerminalShellOutcome { + exitCode?: number; + signal?: string; +} + +/** + * One prepared, still-hidden grid. + * + * Everything here belongs to the one `prepare()` that produced it. A composite + * is never reused across expansions, and a provider that hands the same one + * back twice has handed back a grid the second expansion did not ask for. + */ +export interface TerminalComposite { + /** + * Show the composite. Called once, and only after every pane is ready. + * + * A provider that has to place panes does it here rather than during + * preparation, so the reader never sees a grid fill in. + */ + attach(): Operation; + /** + * Display one pane's state. Called with states core has already decided. + * + * Its return value is ignored on purpose: drawing a status is not a chance to + * change one. + */ + update(ordinal: number, state: TerminalPaneState): Operation; + /** + * Start the host's default interactive shell in one pane and report how it + * ended. + * + * Which shell that is comes from live host policy, never from the document. + * The bytes it exchanges with the reader belong to the pane: nothing captures + * or journals them. + * + * `spawned` is the pane's readiness latch, and calling it is the only thing + * that makes this pane ready. Call it from the runtime's successful + * child-spawn event and before waiting for the child to exit — so a shell + * that starts and exits at once is both ready and settled, while a shell that + * never started leaves the latch alone and the grid never attaches. + */ + shell(ordinal: number, spawned: () => void): Operation; + /** + * Settle when the reader closes or leaves the composite. + * + * A grid stays visible after its panes have settled, so this is what tells + * core the reader is finished with it. + */ + closed(): Operation; + /** + * Take the composite down and give the root terminal back. + * + * Called exactly once for every composite `prepare()` returned, including one + * discarded before it ever attached. + */ + destroy(): Operation; +} + +export interface TerminalProviderHandler { + /** Build the whole hidden composite for `request`, presenting nothing. */ + prepare(request: TerminalGridRequest): Operation; +} + +export const TERMINAL_PROVIDER_UNAVAILABLE = + "no terminal provider is installed — this host does not present a grid of " + + "interactive panes. `xmd run` installs one; a test or embedding host installs " + + "its own."; + +export class TerminalProviderUnavailableError extends Error { + override name = "TerminalProviderUnavailableError"; + constructor(message: string = TERMINAL_PROVIDER_UNAVAILABLE) { + super(message); + } +} + +/** + * The stable contextual boundary a grid request travels. + * + * Middleware composed here may observe, narrow, refuse, wrap or delegate a + * request — everything composition needs. What it cannot do is authorize one: + * the terminal authority that mints pane claims and takes terminal ownership is + * delivered directly to the installed provider and reachable from nowhere else, + * so a handler that answers without delegating has presented nothing. + */ +export const TerminalProvider: Api = createApi( + "runtime.terminalProvider", + { + // deno-lint-ignore require-yield + *prepare(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); + }, + }, +); + +/** Build the hidden composite for one grid expansion. */ +export function prepareTerminalGrid(request: TerminalGridRequest): Operation { + return TerminalProvider.operations.prepare(request); +} + +/** + * Everything one controlled composite did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every pane started, that nothing attached before the readiness + * barrier, and that teardown destroyed exactly the composite it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; +} + +/** + * What a controlled provider does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * can refuse before a composite exists, `onAttach` can fail the barrier, `shell` + * decides what a self-closing pane's shell did and how long it took, and + * `close` is the operation the grid waits on, so a suite controls exactly when + * the reader leaves. + */ +export interface ControlledTerminalProviderOptions { + /** Appended to as the provider works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onAttach?: () => Operation; + onDestroy?: () => Operation; + /** + * What a pane's shell did. + * + * It receives the readiness latch, so a suite decides whether this shell + * reports a spawn at all — which is how "never started" is told apart from + * "started and exited immediately". + */ + shell?: (ordinal: number, spawned: () => void) => Operation; + close?: () => Operation; +} + +/** + * Install a provider that presents nothing and records everything. + * + * It answers the whole contract — prepare, attach, update, shell, close, + * destroy — so a suite exercises core's lifecycle without a terminal, a + * multiplexer, or a process anywhere in it. + */ +export function* installControlledTerminalProvider( + options: ControlledTerminalProviderOptions = {}, +): Operation { + const log = options.log ?? { events: [] }; + let prepared = 0; + + yield* TerminalProvider.around( + { + *prepare([request]): Operation { + if (options.onPrepare) { + yield* options.onPrepare(request); + } + const generation = prepared++; + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + let destroyed = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn + // would hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken + // down more times than it was built, which is exactly the ordering + // claim a suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${generation}`); + }, + }; + }, + }, + { at: "min" }, + ); +} diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts new file mode 100644 index 00000000..59ee75e8 --- /dev/null +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -0,0 +1,271 @@ +/** + * Tier TG — the terminal provider boundary (architecture.md §Terminal + * authority, spec §6.21). + * + * What a host installs to present a grid, and what composing middleware around + * it may and may not do. Nothing here opens a terminal, looks for a + * multiplexer, or starts a process: the whole point of the boundary is that the + * language does not depend on any of that, so a suite that needed one would be + * testing the wrong thing. + * + * The controlled provider records what it was asked to do, in order. Ordering + * claims are read off that record rather than inferred from timing, because a + * grid that attached too early and a grid that attached on time can take the + * same wall clock. + */ + +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 { + installControlledTerminalProvider, + prepareTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalProvider, + TerminalProviderUnavailableError, +} from "../terminal.ts"; +import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog } from "../terminal.ts"; + +/** A two-by-one grid: the smallest request that still has two ordinals. */ +function request(overrides: Partial = {}): TerminalGridRequest { + return { + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + ...overrides, + }; +} + +function log(): TerminalProviderLog { + return { events: [] }; +} + +describe("Tier TG — the provider boundary", () => { + it("TP1: refuses when no host has installed a provider", function* () { + let refusal: unknown; + yield* scoped(function* () { + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalProviderUnavailableError); + expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); + }); + + it("TP2: an installed provider prepares without presenting anything", function* () { + const record = log(); + const events = yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* prepareTerminalGrid(request()); + return [...record.events]; + }); + + // Preparation happened; nothing was shown. A composite the reader can see + // before every pane is ready is the one thing atomic startup forbids. + expect(events).toEqual(["prepare:0:2x1"]); + expect(events.some((event) => event.startsWith("attach:"))).toBe(false); + }); + + it("TP2: attach, update, shell and destroy are recorded in the order they happen", function* () { + const record = log(); + const spawns: number[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.update(0, "starting"); + yield* composite.update(0, "running"); + yield* composite.shell(1, () => spawns.push(1)); + yield* composite.attach(); + yield* composite.update(0, "succeeded"); + yield* composite.closed(); + yield* composite.destroy(); + }); + + expect(record.events).toEqual([ + "prepare:0:2x1", + "state:0:0:starting", + "state:0:0:running", + "shell:0:1", + "attach:0", + "state:0:0:succeeded", + "closed:0", + "destroy:0", + ]); + // The default shell starts, and says so through the latch it was handed: + // readiness is reported by the shell rather than assumed by the grid. + expect(spawns).toEqual([1]); + }); + + it("TP5: a shell that never starts never reports a spawn", function* () { + const spawns: number[] = []; + const outcome = yield* scoped(function* () { + yield* installControlledTerminalProvider({ + // deno-lint-ignore require-yield + *shell(_ordinal, _spawned) { + // No spawn event: nothing started, so nothing is acknowledged. + return { exitCode: 127 }; + }, + }); + const composite = yield* prepareTerminalGrid(request()); + return yield* composite.shell(1, () => spawns.push(1)); + }); + + expect(outcome).toEqual({ exitCode: 127 }); + expect(spawns).toEqual([]); + }); + + it("TP3: middleware observes a delegated request without changing it", function* () { + const record = log(); + const seen: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* prepareTerminalGrid(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the provider still saw the same grid. + expect(record.events).toEqual(["prepare:0:3x2"]); + }); + + it("TP3: middleware refuses a request, and no composite is ever built", function* () { + const record = log(); + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + // deno-lint-ignore require-yield + *prepare(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + // Refusing means refusing: the provider below was never reached, so there + // is no hidden composite left needing teardown. + expect(record.events).toEqual([]); + }); + + it("TP3: middleware narrows a request before the provider sees it", function* () { + const record = log(); + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); + }, + }); + yield* prepareTerminalGrid(request()); + }); + + expect(record.events).toEqual(["prepare:0:1x2"]); + }); + + it("TP4: middleware wraps the composite it delegated for", function* () { + const record = log(); + const wrapped: string[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + const composite = yield* next(asked); + return { + ...composite, + *attach() { + wrapped.push("before"); + yield* composite.attach(); + wrapped.push("after"); + }, + }; + }, + }); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.attach(); + yield* composite.destroy(); + }); + + expect(wrapped).toEqual(["before", "after"]); + expect(record.events).toEqual(["prepare:0:2x1", "attach:0", "destroy:0"]); + }); + + it("TP5: a preparation failure leaves nothing to tear down", function* () { + const record = log(); + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ + log: record, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "no pane endpoint could be created", + ); + // The failure happened before the composite existed, so the record shows + // no composite was built and none is owed a destroy. + expect(record.events).toEqual([]); + }); + + it("TP5: a composite refuses to be destroyed twice", function* () { + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider(); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.destroy(); + try { + yield* composite.destroy(); + } catch (error) { + refusal = error; + } + }); + + // Teardown ordering is only readable if a double destroy is loud. A silent + // second destroy would let a suite prove an ordering that never held. + expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); + }); + + it("TP6: each preparation is its own composite", function* () { + const record = log(); + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + const first = yield* prepareTerminalGrid(request()); + const second = yield* prepareTerminalGrid(request()); + yield* first.destroy(); + yield* second.destroy(); + }); + + // Two expansions are two grids. A provider that handed the same composite + // back would have presented the second expansion's grid as the first's. + expect(record.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + }); +}); From 05cde97884cb6ec137d86dc3fcb9b57fae7f86c4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:07:19 -0400 Subject: [PATCH 02/15] =?UTF-8?q?=E2=9C=A8=20Run=20a=20terminal=20grid's?= =?UTF-8?q?=20panes=20concurrently=20through=20the=20provider=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runTerminalGrid()` owns the lifecycle the reader sees: it takes the run's one foreground-terminal lease, flushes root output, prepares the composite while it stays hidden, starts every pane concurrently, and attaches only once every pane has reported a spawn through its claim. Ordering is the contract. The lease and the composite are both scope-owned, so success, failure and cancellation all release the terminal and destroy exactly the composite that was prepared — there is no path that skips teardown. A pane that settles without ever reporting a spawn fails startup rather than being presented as a running pane. Before the barrier a pane failure fails the whole grid closed; after it, the failure is that pane's status and its siblings keep running. Close cancels a live pane as `closed`, which is not a failed pane, and the grid fails with the first failed pane in authored order. `display()` and an `onUpdate` hook complete the provider surface: a pane's rendered text goes to that pane, and a suite reacts to a state the grid decided rather than waiting and hoping. Evidence: `packages/core/tests/terminal-grid.test.ts` (15 rows) and `packages/runtime/tests/terminal-provider.test.ts` (11 rows). The readiness barrier row was verified by removing the barrier: it fails without it. --- packages/core/src/terminal/grid.ts | 207 +++++++ packages/core/tests/terminal-grid.test.ts | 507 ++++++++++++++++++ packages/runtime/terminal.ts | 34 +- .../runtime/tests/terminal-provider.test.ts | 2 +- 4 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/terminal/grid.ts create mode 100644 packages/core/tests/terminal-grid.test.ts diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts new file mode 100644 index 00000000..9f712da1 --- /dev/null +++ b/packages/core/src/terminal/grid.ts @@ -0,0 +1,207 @@ +/** + * One terminal grid, from the lease to the last finalizer (spec §6.21, + * architecture.md §Atomic presentation and settlement). + * + * Opening a grid is atomic from the reader's side, and that is the whole shape + * of this module. The composite is built while it is still hidden, every pane + * starts concurrently, and only once all of them have actually started does + * anything appear. A failure before that barrier discards the hidden composite + * instead of leaving half a grid on the screen. + * + * Ordering is the contract, not an implementation detail: + * + * ``` + * lease → flush → prepare → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released + * ``` + * + * Nothing here decides what a pane *is* — the layout arrived already derived, + * and the work each pane does is supplied by the caller. What this owns is + * whose terminal it is, when a pane counts as started, what happens when one + * fails, and the order in which it all comes apart. + */ + +import { ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { flushOutput, prepareTerminalGrid, reserveTerminal } from "@executablemd/runtime"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; + +import { awaitReadiness, createTerminalGridClaims } from "./authority.ts"; +import type { TerminalPaneClaim } from "./authority.ts"; +import type { TerminalGridLayout } from "../terminal-grid.ts"; + +/** How one pane ended. */ +export type PaneOutcome = + | { readonly kind: "succeeded" } + | { readonly kind: "failed"; readonly error: Error } + /** Live when the reader closed the grid. Cancellation, not failure. */ + | { readonly kind: "closed" }; + +/** + * What one pane does once its claim exists. + * + * The caller supplies this because a pane's work is the document's: a paired + * pane expands its authored content, and a self-closing one runs the host's + * default shell. Both run as the pane's admitted owner, and both are expected + * to report a spawn through the claim before anything can attach. + */ +export interface PaneWork { + readonly ordinal: number; + run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; +} + +/** Everything the grid settled, in authored pane order. */ +export interface GridResult { + readonly outcomes: readonly PaneOutcome[]; + /** Why the grid failed, which is the first failed pane in authored order. */ + readonly failure?: Error; +} + +/** + * What a pane that never reported a spawn says. + * + * A pane whose work finished without ever starting something interactive has + * not started: presenting it as a running pane would be presenting a grid the + * reader cannot use. + */ +export function paneNeverStartedMessage(ordinal: number, title: string): string { + return ( + `pane ${ordinal} ("${title}") finished without starting anything interactive, so the ` + + `grid never opened. A pane runs an interactive child — a , or the ` + + `default shell a self-closing starts.` + ); +} + +class PaneStartupError extends Error { + override name = "PaneStartupError"; + readonly ordinal: number; + constructor(ordinal: number, message: string) { + super(message); + this.ordinal = ordinal; + } +} + +/** + * Run one grid to completion and report what its panes settled to. + * + * The foreground lease and the composite are both scope-owned, so every path + * out of here — success, failure, and cancellation alike — releases the + * terminal and destroys exactly the composite that was prepared. That is why + * teardown is not written as a step: there is no path that can skip it. + */ +export function runTerminalGrid( + layout: TerminalGridLayout, + work: readonly PaneWork[], +): Operation { + return scoped(function* (): Operation { + const request = toRequest(layout); + + // The one foreground-terminal lease. A root and a grid + // contend for exactly this, so neither can begin while the other holds it, + // and a host with no terminal refuses here — before any pane has done work. + yield* reserveTerminal(); + // Everything the document has produced so far reaches the reader before the + // grid covers it up. + yield* flushOutput(); + + const composite = yield* prepareTerminalGrid(request); + // Registered before a single pane starts: a composite that was prepared is + // owed a destroy even if the next line is what fails. + yield* ensure(() => composite.destroy()); + + const grid = createTerminalGridClaims(request); + // Nothing new is admitted once teardown begins, so a pane that was about to + // start an interactive child is refused rather than racing the close. + yield* ensure(() => { + grid.seal(); + }); + + const outcomes: (PaneOutcome | undefined)[] = work.map(() => undefined); + const startupFailed = withResolvers(); + let attached = false; + + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { + const claim = grid.claims[index]!; + const readiness = grid.readiness[index]!; + yield* composite.update(pane.ordinal, "starting"); + panes.push( + yield* spawn(function* () { + try { + yield* pane.run(claim, composite); + if (!readiness.acknowledged) { + // Settled without ever starting: that is a startup failure even + // though the work itself raised nothing. + throw new PaneStartupError( + pane.ordinal, + paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + ); + } + outcomes[index] = { kind: "succeeded" }; + yield* composite.update(pane.ordinal, "succeeded"); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + outcomes[index] = { kind: "failed", error: failure }; + // Before the barrier a pane failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than attaching what is + // left. After it, the failure is this pane's status and its siblings + // keep running. + if (!attached) { + startupFailed.reject(failure); + return; + } + yield* composite.update(pane.ordinal, "failed"); + } + }), + ); + } + + // Every pane must actually have started before anything is shown. Racing + // the barrier against startup failure is what stops a grid whose pane + // already failed from waiting forever for a latch nothing will acknowledge. + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + + for (const pane of work) { + yield* composite.update(pane.ordinal, "running"); + } + yield* composite.attach(); + attached = true; + + // The composite stays visible after its panes settle. The reader leaving is + // what finishes the grid, not the last pane exiting. + yield* composite.closed(); + + // Close prevents new work first, then takes the live panes down: a pane + // cancelled by the close is `closed`, which is not a failed pane. + grid.seal(); + for (const [index, task] of panes.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(work[index]!.ordinal, "closed"); + outcomes[index] = { kind: "closed" }; + } + yield* task.halt(); + } + + const settled = outcomes.map((outcome) => outcome ?? { kind: "closed" as const }); + const failed = settled.find((outcome) => outcome.kind === "failed"); + return { + outcomes: settled, + ...(failed?.kind === "failed" ? { failure: failed.error } : {}), + }; + }); +} + +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { + return { + columns: layout.columns, + rows: layout.rows, + panes: layout.cells.map((cell) => ({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + })), + }; +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts new file mode 100644 index 00000000..19d9e4d7 --- /dev/null +++ b/packages/core/tests/terminal-grid.test.ts @@ -0,0 +1,507 @@ +/** + * Tier TG — running a terminal grid through a replaceable provider + * (spec §6.21, architecture.md §Atomic presentation and settlement). + * + * The provider here is controlled and is not tmux: it opens no terminal, starts + * no process, and records what it was asked to do in the order it was asked. + * Every ordering claim is read off that record. Nothing is inferred from + * timing, because a grid that attached too early and one that attached on time + * take the same wall clock. + * + * Readiness is the claim these rows care about most, so it is always driven + * explicitly: a pane becomes ready because something called the latch it was + * handed, never because it got far enough. That is what lets "started" and + * "did some work" be told apart at all. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, sleep, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { + installControlledLauncher, + installControlledTerminalProvider, +} from "@executablemd/runtime"; +import type { TerminalProviderLog } from "@executablemd/runtime"; + +import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; +import { runTerminalGrid } from "../src/terminal/grid.ts"; +import type { GridResult, PaneWork } from "../src/terminal/grid.ts"; +import { paneTerminal, usePaneTerminal } from "../src/terminal/pane.ts"; +import { terminalGridLayout } from "../src/terminal-grid.ts"; +import type { TerminalGridLayout } from "../src/terminal-grid.ts"; + +function log(): TerminalProviderLog { + return { events: [], shown: new Map() }; +} + +/** A layout of `count` panes across `columns`, titled by ordinal. */ +function layoutOf(columns: number, count: number): TerminalGridLayout { + return terminalGridLayout( + columns, + Array.from({ length: count }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })), + ); +} + +/** A pane that starts, does what `body` says, and settles. */ +function pane(ordinal: number, body?: () => Operation): PaneWork { + return { + ordinal, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + if (body) { + yield* body(); + } + }); + }, + }; +} + +/** Everything a grid run needs installed, with the reader's close under control. */ +function* useGridHost(record: TerminalProviderLog, close: () => Operation): Operation { + // A grid takes the same one foreground lease a root takes, + // so a host that offers a grid still has to offer that lease. + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ log: record, close }); +} + +/** A pane that records when it started, so ordering is read rather than timed. */ +function readyPane(ordinal: number, timeline: string[]): PaneWork { + return { + ordinal, + *run(claim) { + yield* claim.admit(function* () { + timeline.push(`ready:${ordinal}`); + claim.ready(); + yield* suspend(); + }); + }, + }; +} + +/** Close as soon as the reader is asked, which is the ordinary journey. */ +function immediateClose(): () => Operation { + // deno-lint-ignore require-yield + return function* () {}; +} + +describe("Tier TG — pane claims and readiness", () => { + it("TG8: a claim admits one interactive operation at a time", function* () { + const grid = createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; + + yield* scoped(function* () { + yield* first.admit(function* () { + // A second operation on the same pane is refused while this one is live. + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + // A different pane does not contend at all, which is the whole reason a + // grid exists. + yield* second.admit(function* () { + concurrent = true; + }); + }); + }); + + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); + }); + + it("TG8: a pane admits again once its first operation has settled", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + let second = false; + + yield* scoped(function* () { + yield* claim.admit(function* () {}); + yield* claim.admit(function* () { + second = true; + }); + }); + + // Sequential work in one pane is ordinary composition, not contention. + expect(second).toBe(true); + }); + + it("TG8: a sealed grid admits nothing, however the claim was obtained", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + grid.seal(); + let refusal: unknown; + + yield* scoped(function* () { + try { + yield* claim.admit(function* () {}); + } catch (error) { + refusal = error; + } + }); + + // A claim kept past its grid is a claim to a terminal nobody owns. + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + }); + + it("TG8: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; + + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TG8: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + yield* sleep(0); + }); +}); + +describe("Tier TG — atomic startup", () => { + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const record = log(); + // One ordered record both the panes and the provider write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that would prove nothing — a pane says when it actually started. + const timeline: string[] = []; + const slow = withResolvers(); + + const result = yield* scoped(function* (): Operation { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + log: record, + close: immediateClose(), + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + }); + return yield* runTerminalGrid(layoutOf(2, 3), [ + readyPane(0, timeline), + { + ordinal: 1, + *run(claim) { + yield* claim.admit(function* () { + // Plenty of work before anything starts, and none of it makes the + // grid attachable. The delay is long enough that a grid which + // skipped the barrier would demonstrably attach first. + yield* sleep(25); + timeline.push("ready:1"); + claim.ready(); + yield* slow.operation; + }); + }, + }, + readyPane(2, timeline), + ]); + }); + + expect(timeline).toEqual(["ready:0", "ready:2", "ready:1", "attach"]); + expect(result.failure).toBeUndefined(); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const record = log(); + let failure: unknown; + + yield* scoped(function* () { + yield* useGridHost(record, immediateClose()); + try { + yield* runTerminalGrid(layoutOf(2, 2), [ + pane(0), + { + ordinal: 1, + // Runs, settles, and never reports a spawn. + *run() {}, + }, + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toContain( + "finished without starting anything interactive", + ); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(record.events).not.toContain("attach:0"); + expect(record.events).toContain("destroy:0"); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const started: number[] = []; + let failure: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + try { + yield* runTerminalGrid(layoutOf(2, 2), [ + pane(0, function* () { + started.push(0); + }), + pane(1, function* () { + started.push(1); + }), + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toBe( + "no pane endpoint could be created", + ); + expect(started).toEqual([]); + }); + + it("TG9: a grid refuses before preparation when no provider is installed", function* () { + const started: number[] = []; + let failure: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + try { + yield* runTerminalGrid(layoutOf(1, 1), [ + pane(0, function* () { + started.push(0); + }), + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toContain( + "no terminal provider is installed", + ); + expect(started).toEqual([]); + }); +}); + +describe("Tier TG — settlement and close", () => { + it("TG10: a pane fails after attach while its siblings stay live", function* () { + const record = log(); + // The reader leaves once the grid has displayed the failure, so the sibling + // is provably still live when that happens rather than probably still live. + const failed = withResolvers(); + let siblingLiveAtFailure = false; + let siblingLive = false; + + const result = yield* scoped(function* (): Operation { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + log: record, + close: () => failed.operation, + onUpdate(ordinal, state) { + if (ordinal === 0 && state === "failed") { + siblingLiveAtFailure = siblingLive; + failed.resolve(); + } + }, + }); + return yield* runTerminalGrid(layoutOf(2, 2), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + yield* sleep(1); + throw new Error("pane 0 stopped"); + }); + }, + }, + { + ordinal: 1, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + siblingLive = true; + try { + yield* suspend(); + } finally { + siblingLive = false; + } + }); + }, + }, + ]); + }); + + expect(record.events).toContain("attach:0"); + expect(record.events).toContain("state:0:0:failed"); + // The sibling was still running when its neighbour failed: an ordinary pane + // failure after attach is contained as that pane's status. + expect(siblingLiveAtFailure).toBe(true); + expect(result.outcomes[0]?.kind).toBe("failed"); + expect(result.outcomes[1]?.kind).toBe("closed"); + // The grid fails with the first failed pane in authored order. + expect(result.failure?.message).toBe("pane 0 stopped"); + }); + + it("TG12: close cancels a live pane as closed rather than failed", function* () { + const record = log(); + + const result = yield* scoped(function* (): Operation { + yield* useGridHost(record, immediateClose()); + return yield* runTerminalGrid(layoutOf(1, 1), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + // Still live when the reader leaves. + yield* suspend(); + }); + }, + }, + ]); + }); + + // Teardown cancellation is not a pane failure, and the grid succeeds. + expect(result.outcomes[0]?.kind).toBe("closed"); + expect(result.failure).toBeUndefined(); + expect(record.events).toContain("state:0:0:closed"); + }); + + it("TG12: the composite is destroyed exactly once, after the reader closes", function* () { + const record = log(); + + yield* scoped(function* () { + yield* useGridHost(record, immediateClose()); + yield* runTerminalGrid(layoutOf(2, 2), [pane(0), pane(1)]); + }); + + const closed = record.events.indexOf("closed:0"); + const destroyed = record.events.indexOf("destroy:0"); + expect(closed).toBeGreaterThan(-1); + expect(destroyed).toBeGreaterThan(closed); + expect(record.events.filter((event) => event === "destroy:0")).toHaveLength(1); + }); + + it("TG13: parent cancellation tears the grid down completely", function* () { + const record = log(); + + yield* scoped(function* () { + yield* useGridHost(record, () => suspend()); + // The grid never closes on its own; the enclosing scope ending is what + // takes it down, and that has to be a complete teardown. + yield* scoped(function* () { + yield* spawnGrid(layoutOf(1, 1), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + yield* suspend(); + }); + }, + }, + ]); + yield* sleep(2); + }); + }); + + expect(record.events).toContain("attach:0"); + expect(record.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — the pane seam", () => { + it("TG6: work inside a pane runs as that pane's owner", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + let sawOrdinal: number | undefined; + let acknowledged = false; + + yield* scoped(function* () { + yield* usePaneTerminal(claim); + const seam = yield* paneTerminal(); + sawOrdinal = seam?.ordinal; + yield* seam!.interactive(function* (spawned) { + spawned(); + acknowledged = grid.readiness[0]!.acknowledged; + }); + }); + + expect(sawOrdinal).toBe(0); + // The seam is how anything interactive reports its spawn, so readiness + // travels with the work rather than being asserted around it. + expect(acknowledged).toBe(true); + }); + + it("TG6: outside a grid there is no pane, and nothing pretends otherwise", function* () { + const seam = yield* scoped(function* () { + return yield* paneTerminal(); + }); + expect(seam).toBeUndefined(); + }); +}); + +/** Run a grid in a spawned task, so the enclosing scope can cancel it. */ +function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Operation { + yield* spawn(function* () { + yield* runTerminalGrid(layout, work); + }); +} diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index 1fac60e3..cc34203c 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -102,6 +102,17 @@ export interface TerminalComposite { * change one. */ update(ordinal: number, state: TerminalPaneState): Operation; + /** + * Show text a pane's own content rendered. + * + * This is where a paired pane's output goes, and the only place it goes: it + * is never copied into the root document output or into a capture written + * around the grid, because the reader is looking at the pane. Terminal bytes + * an interactive child exchanges with the reader never come through here at + * all — those belong to the pane's terminal and are neither captured nor + * journaled. + */ + display(ordinal: number, text: string): Operation; /** * Start the host's default interactive shell in one pane and report how it * ended. @@ -183,6 +194,13 @@ export function prepareTerminalGrid(request: TerminalGridRequest): Operation; } /** @@ -200,6 +218,13 @@ export interface ControlledTerminalProviderOptions { onPrepare?: (request: TerminalGridRequest) => Operation; onAttach?: () => Operation; onDestroy?: () => Operation; + /** + * Called as each pane state is displayed. + * + * A suite watches it to react to something the grid decided — a pane that + * failed, a pane that became runnable — instead of waiting a while and hoping. + */ + onUpdate?: (ordinal: number, state: TerminalPaneState) => void; /** * What a pane's shell did. * @@ -221,7 +246,8 @@ export interface ControlledTerminalProviderOptions { export function* installControlledTerminalProvider( options: ControlledTerminalProviderOptions = {}, ): Operation { - const log = options.log ?? { events: [] }; + const log = options.log ?? { events: [], shown: new Map() }; + const shown = log.shown; let prepared = 0; yield* TerminalProvider.around( @@ -243,6 +269,12 @@ export function* installControlledTerminalProvider( // deno-lint-ignore require-yield *update(ordinal, state) { log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + const pane = shown.get(ordinal) ?? ""; + shown.set(ordinal, pane + text); }, *shell(ordinal, spawned) { log.events.push(`shell:${generation}:${ordinal}`); diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 59ee75e8..9e01204a 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -42,7 +42,7 @@ function request(overrides: Partial = {}): TerminalGridRequ } function log(): TerminalProviderLog { - return { events: [] }; + return { events: [], shown: new Map() }; } describe("Tier TG — the provider boundary", () => { From 879bdc33d772aab0d34729d698a9a03b61a6ded4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:39:27 -0400 Subject: [PATCH 03/15] =?UTF-8?q?=E2=9C=A8=20Run=20a=20document's=20termin?= =?UTF-8?q?al=20grid=20panes=20through=20the=20provider=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` now executes. Each authored pane becomes a concurrent child of the grid: a self-closing pane runs the host's default shell through its claim, and a paired pane expands its own content in a scope of its own. A pane inherits the bindings, providers, configuration and working directory visible where the grid was written, and keeps everything it creates afterwards. Its `` has no loop to exit, its `` has no enclosing value body to claim, and its checked failures settle the pane rather than reaching the root or a sibling. A pane's rendered text is displayed in that pane; the grid itself renders `""`, so the root output holds what surrounds the grid and no pane display at all. #729's five execution-dependent rows move here, where they assert the layout against the request the provider actually receives rather than reading it off a refusal's cause — the structural suite keeps the grammar, placement and pure-layout rows it owns. Moving them was authorized rather than assumed. Evidence: 22 rows in `packages/core/tests/terminal-grid.test.ts` and 11 in `packages/runtime/tests/terminal-provider.test.ts`; the whole Deno core (350) and runtime (14) suites pass. --- packages/core/src/expand.ts | 155 ++++++-- .../tests/terminal-grid-structure.test.ts | 125 ------- packages/core/tests/terminal-grid.test.ts | 344 +++++++++++++++++- 3 files changed, 468 insertions(+), 156 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index e92df89e..c9b54426 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,6 +68,9 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; +import { runTerminalGrid } from "./terminal/grid.ts"; +import type { PaneWork } from "./terminal/grid.ts"; +import { usePaneTerminal } from "./terminal/pane.ts"; import { asBindingViolation, asExpressionViolation, @@ -139,7 +142,7 @@ import { import { remark } from "remark"; import { select as cssSelect } from "unist-util-select"; import { toString as mdastToString } from "mdast-util-to-string"; -import { liveEnvironment } from "./live-env.ts"; +import { derivedEnvironment, liveEnvironment } from "./live-env.ts"; import { TestHarnessComponentDefinition } from "./test-harness.ts"; import type { TestHarnessBinding } from "./test-harness.ts"; @@ -1181,7 +1184,15 @@ function* expandListSegments( if (segment.name === "Terminal.Grid") { // No raise() here, like the branches above: expandTerminalGrid // reports every error it creates. - yield* expandTerminalGrid(segment, result); + yield* expandTerminalGrid(segment, result, { + parentMeta, + parentProps, + hideSet, + counter, + path: elementPath, + checkedFailures, + authority, + }); break; } @@ -2102,7 +2113,22 @@ function* resolveStructuralProp( * does, which is what makes the refusal a closed one rather than a partial grid * left behind. */ -function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Operation { +/** Everything a pane's own content needs to expand where the grid was written. */ +interface GridSite { + readonly parentMeta: Record; + readonly parentProps: Record; + readonly hideSet: Set; + readonly counter: BlockCounter; + readonly path: string; + readonly checkedFailures: CheckedFailures | undefined; + readonly authority: ExpansionAuthority | undefined; +} + +function* expandTerminalGrid( + segment: ComponentElement, + owner: Segment[], + site: GridSite, +): Operation { const structure = terminalGridStructure(segment); if (structure.violations.length > 0) { for (const violation of structure.violations) { @@ -2137,23 +2163,105 @@ function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Opera } const layout = terminalGridLayout(columns.value, placed); - owner.push( - yield* raise({ - type: "error", - message: positioned(noTerminalProviderMessage(), segment), - source: "Terminal.Grid", - // The grid the author asked for, carried beside the sentence so an - // assertion is about the layout that was derived rather than about the - // wording of a refusal. - cause: { - layout: { - columns: layout.columns, - rows: layout.rows, - cells: layout.cells.map((cell) => ({ ...cell })), - }, - }, - }), + // The grid renders nothing into the document: what a pane shows belongs to + // that pane, and the sibling after `` renders to the root + // again only once the provider has restored it. + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site, segment), ); + + try { + const result = yield* runTerminalGrid(layout, work); + if (result.failure !== undefined) { + owner.push(yield* raise(terminalGridError(segment, result.failure.message))); + } + } catch (error) { + owner.push( + yield* raise( + terminalGridError(segment, error instanceof Error ? error.message : String(error)), + ), + ); + } +} + +/** + * What one authored pane does once the grid has minted its claim. + * + * A self-closing pane runs the host's default shell through its claim. A paired + * pane expands its own content in a scope of its own: it inherits the bindings, + * providers, configuration and working directory visible where the grid was + * written, and everything it creates afterwards stays inside the pane. Its + * `` cannot reach a loop outside the grid, its `` cannot claim an + * enclosing body, and a checked failure settles the pane rather than poisoning + * the root or a sibling. + */ +function paneWork( + pane: TerminalPane, + title: string, + site: GridSite, + grid: ComponentElement, +): PaneWork { + if (pane.form === "self-closing") { + return { + ordinal: pane.ordinal, + *run(claim, composite) { + const outcome = yield* claim.admit(() => + composite.shell(pane.ordinal, () => claim.ready()), + ); + if (outcome.signal !== undefined) { + throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); + } + if (outcome.exitCode !== undefined && outcome.exitCode !== 0) { + throw new Error( + `pane ${pane.ordinal} ("${title}") shell exited with status ${outcome.exitCode}`, + ); + } + }, + }; + } + + return { + ordinal: pane.ordinal, + *run(claim, composite) { + yield* scoped(function* () { + // A pane is not inside the loop the grid was written in, so a + // in its content has no loop to exit and says so. + yield* ActiveLoop.set(undefined); + yield* usePaneTerminal(claim); + 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 + // nothing else. + yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) })); + + const shown: Segment[] = []; + yield* expandSegmentsWithin( + pane.element.children, + site.parentMeta, + site.parentProps, + site.hideSet, + site.counter, + shown, + extendPath( + site.path, + elementFrame(pane.element.name, elementSite(pane.element.position, pane.index)), + ), + 0, + // The pane's own ledger: a checked failure settles this pane and + // cannot reach the root or a sibling. + containedLedger(site.checkedFailures), + site.authority, + // No enclosing value body: a written in a pane cannot claim + // one outside the grid. + undefined, + ); + const text = renderSegments(shown); + if (text.length > 0) { + yield* composite.display(pane.ordinal, text); + } + }); + }, + }; } /** The label one pane displays, from the value its own `title` prop produced. */ @@ -2168,15 +2276,6 @@ function* resolvePaneTitle(pane: TerminalPane): Operation> { return terminalTitle(value.value); } -/** What a complete grid says on a host where nothing can open one. */ -function noTerminalProviderMessage(): string { - return ( - "no terminal provider opened this grid. A host installs the terminal-grid capability " + - "explicitly, and this one installs none, so no pane expanded its content and no default " + - "shell started." - ); -} - function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts index 76c440cb..626cbf34 100644 --- a/packages/core/tests/terminal-grid-structure.test.ts +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -130,31 +130,6 @@ const PANE_BODY = [ ].join("\n"); describe("Tier TG — the grid grammar", () => { - it("TG1: accepts a paired grid with positive integer columns and both pane forms", function* () { - const run = yield* runGrid( - [ - "", - 'Instructions.', - '', - "", - ].join("\n"), - ); - - // The grammar accepted it, so the run reached the one thing this build - // cannot do — and stopped there. - expect(soleError(run)).toContain("no terminal provider opened this grid"); - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 1, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, - { ordinal: 1, row: 0, column: 1, title: "Shell", form: "self-closing" }, - ], - }, - }); - }); - it("TG1: refuses an unknown prop and `as` on the grid", function* () { const unknown = yield* runGrid( '', @@ -330,52 +305,6 @@ describe("Tier TG — structural placement", () => { reachedNothing(alone); reachedNothing(buried); }); - - it("TG2: treats whitespace between panes as nothing at all", function* () { - const run = yield* runGrid( - [ - "", - "", - ' ', - "", - ' ', - "", - "", - ].join("\n"), - ); - - expect(soleError(run)).toContain("no terminal provider opened this grid"); - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 1, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "A", form: "self-closing" }, - { ordinal: 1, row: 0, column: 1, title: "B", form: "self-closing" }, - ], - }, - }); - }); - - it("TG2: a complete grid refuses before any pane body or default shell", function* () { - const run = yield* runGrid( - [ - "", - '', - "", - PANE_BODY, - "", - '', - "", - ].join("\n"), - ); - - expect(soleError(run)).toContain("no pane expanded its content and no default shell started."); - // The pane held a component and a command; neither was reached, and the - // grid rendered nothing of its own. - reachedNothing(run); - expect(run.output).toContain("no terminal provider opened this grid"); - }); }); describe("Tier TG — row-major layout", () => { @@ -446,60 +375,6 @@ describe("Tier TG — row-major layout", () => { 1, 1, 1, 2, 2, ]); }); - - it("TG4: an executed grid derives those same positions", function* () { - const run = yield* runGrid( - [ - "", - '', - '', - '', - '', - '', - "", - ].join("\n"), - ); - - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 3, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "One", form: "self-closing" }, - { ordinal: 1, row: 0, column: 1, title: "Two", form: "self-closing" }, - { ordinal: 2, row: 1, column: 0, title: "Three", form: "self-closing" }, - { ordinal: 3, row: 1, column: 1, title: "Four", form: "self-closing" }, - { ordinal: 4, row: 2, column: 0, title: "Five", form: "self-closing" }, - ], - }, - }); - }); - - it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { - const run = yield* runGrid( - [ - "", - 'first', - '', - 'third', - "", - ].join("\n"), - ); - - // Three panes sharing one label are three panes: the ordinal separates - // them, and the form each one was written in travels with it. - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 2, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, - { ordinal: 1, row: 0, column: 1, title: "Agent", form: "self-closing" }, - { ordinal: 2, row: 1, column: 0, title: "Agent", form: "paired" }, - ], - }, - }); - }); }); /** Panes that differ only in count, for a row about rows. */ diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 19d9e4d7..b360fa3f 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -16,13 +16,23 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, sleep, spawn, suspend, withResolvers } from "effection"; -import type { Operation } from "effection"; +import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; import { installControlledLauncher, installControlledTerminalProvider, } from "@executablemd/runtime"; -import type { TerminalProviderLog } from "@executablemd/runtime"; +import type { TerminalGridRequest, TerminalProviderLog } from "@executablemd/runtime"; + +import { execute } from "../src/execute.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import type { Json } from "../src/types.ts"; import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; import { runTerminalGrid } from "../src/terminal/grid.ts"; @@ -505,3 +515,331 @@ function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Oper yield* runTerminalGrid(layout, work); }); } + +/** One document run against a controlled grid host. */ +interface DocumentRun { + outcome: Result; + /** Text the consumer received — the root document's own output. */ + output: string; + /** The grid the provider was actually asked to present. */ + requests: TerminalGridRequest[]; + /** What each pane displayed. */ + shown: Map; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; +} + +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); +} + +/** + * The controlled interactive child, and a tripwire. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, this is what a suite writes to be that + * something — and it reaches the pane through the same seam a real launch will. + */ +function useGridComponents(ran: string[]): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + ]); +} + +/** + * Run one document against a controlled grid host. + * + * `provider: false` installs no terminal provider, which is how "a host that + * cannot open a grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { provider?: boolean } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const record = log(); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // The reader stays until every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + if (options.provider !== false) { + yield* installControlledTerminalProvider({ + log: record, + close: () => settled.operation, + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + yield* sleep(0); + }, + onUpdate(_ordinal, state) { + if (state === "succeeded" || state === "failed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, + }); + } + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, output, requests, shown: record.shown, ran }; + }); +} + +/** The message a run failed with, failing the test if it completed. */ +function failureOf(run: DocumentRun): string { + if (run.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); + } + return run.outcome.error.message; +} + +describe("Tier TG — a grid written in a document", () => { + it("TG4: the provider is asked for exactly the authored row-major layout", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + '', + '', + '', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests).toHaveLength(1); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 3, + panes: [ + { ordinal: 0, title: "One", row: 0, column: 0, form: "self-closing" }, + { ordinal: 1, title: "Two", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Three", row: 1, column: 0, form: "self-closing" }, + { ordinal: 3, title: "Four", row: 1, column: 1, form: "self-closing" }, + { ordinal: 4, title: "Five", row: 2, column: 0, form: "self-closing" }, + ], + }); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'first', + '', + 'third', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Three panes sharing one label are three panes: the ordinal separates + // them, and the form each was written in travels with it. + expect(run.requests[0]?.panes).toEqual([ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Agent", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Agent", row: 1, column: 0, form: "paired" }, + ]); + }); + + it("TG1: both pane forms run, and whitespace between panes is nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + "", + 'Instructions.', + "", + '', + "", + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + }); + }); + + it("TG7: a pane's text reaches that pane, and the grid renders nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "before", + "", + "", + 'left text', + 'right text', + "", + "", + "after", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Each pane's own text went to that pane. + expect(run.shown.get(0)).toContain("left text"); + expect(run.shown.get(1)).toContain("right text"); + // The grid renders "": the root output holds what surrounds it and no pane + // display at all. + expect(run.output).toContain("before"); + expect(run.output).toContain("after"); + expect(run.output).not.toContain("left text"); + expect(run.output).not.toContain("right text"); + }); + + it("TG6: a pane inherits the grid site's bindings and keeps its own", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + '', + "", + "", + '', + "sees {shared}", + "", + '', + "", + "then {mine}", + "", + "", + "", + '', + "sees {shared} and {mine}", + "", + "", + "", + "", + "", + "after {mine}", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Inherited from the grid site. + expect(run.shown.get(0)).toContain("sees site"); + expect(run.shown.get(1)).toContain("sees site"); + // Created inside one pane, visible to later work in that pane. + expect(run.shown.get(0)).toContain("then left"); + // Invisible to the sibling and to the document after the grid: an + // unresolved binding stays the literal text it was written as. + expect(run.shown.get(1)).toContain("and {mine}"); + expect(run.output).toContain("after {mine}"); + }); + + it("TG6: a pane's cannot reach a loop outside the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + "", + "", + "", + ].join("\n"), + ); + + // Refused where it was written. Had the reached the loop around the + // grid it would have exited it quietly and the document would have + // succeeded; instead the pane failed with the stray- rule, which is + // what fails the grid and then the document. + expect(failureOf(run)).toContain(" must be written inside a "); + expect(failureOf(run)).toContain("cannot break the loop that invoked it"); + expect(run.ran).toEqual(["iteration"]); + }); + + it("TG9: with no provider installed, no pane body or shell runs", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + '', + "", + "", + ].join("\n"), + { provider: false }, + ); + + expect(failureOf(run)).toContain("no terminal provider is installed"); + // The pane held work; none of it was reached, and nothing was displayed. + expect(run.ran).toEqual([]); + expect(run.shown.size).toBe(0); + }); +}); From a95d820f9f78ae1b9b9d7c510248405bfa9d3bf5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:51:58 -0400 Subject: [PATCH 04/15] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Drop=20an=20unused?= =?UTF-8?q?=20parameter=20from=20the=20grid's=20pane=20work=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paneWork()` never read the grid element it was handed. Its caller has it, and a pane's own diagnostics are positioned at the pane. --- packages/core/src/expand.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index c9b54426..63b69128 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2166,11 +2166,10 @@ function* expandTerminalGrid( // The grid renders nothing into the document: what a pane shows belongs to // that pane, and the sibling after `` renders to the root // again only once the provider has restored it. - const work = structure.panes.map((pane, index) => - paneWork(pane, layout.cells[index]!.title, site, segment), - ); - try { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); const result = yield* runTerminalGrid(layout, work); if (result.failure !== undefined) { owner.push(yield* raise(terminalGridError(segment, result.failure.message))); @@ -2195,12 +2194,7 @@ function* expandTerminalGrid( * enclosing body, and a checked failure settles the pane rather than poisoning * the root or a sibling. */ -function paneWork( - pane: TerminalPane, - title: string, - site: GridSite, - grid: ComponentElement, -): PaneWork { +function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { if (pane.form === "self-closing") { return { ordinal: pane.ordinal, From 16758c76228b7142f5f38a0727d6146a80ef0a20 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:19:22 -0400 Subject: [PATCH 05/15] =?UTF-8?q?=E2=9C=A8=20Give=20terminal=20grids=20an?= =?UTF-8?q?=20authority=20boundary=20and=20durable=20pane=20children=20(#7?= =?UTF-8?q?30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the authority boundary on the `AgentProviders` handshake, and puts each pane on its own durable child coroutine. **The boundary.** `TerminalGrids` is routing and only routing: `open()` answers `unknown` and core throws the answer away, so middleware may observe, narrow, refuse, wrap or delegate but can never authorize. The capability that takes the leases, mints pane claims and settles a grid is a non-contextual authority delivered straight to the registered provider through a one-use install handshake. Core mints one identity-bearing request per expansion; presenting a copy, a rebuilt lookalike, a changed request, an already-presented one, or one from a superseded installation generation authorizes nothing, and a handler that answers without presenting settles nothing. **Durable children.** Each pane is a durable child of the grid, allocated in authored order, so a pane's identity follows its ordinal rather than the order the runtime scheduled it in. The layout is recorded in the parent coroutine before the lease and before any provider is contacted. **Ordering.** A pane that settles before attach keeps the status it settled to instead of being overwritten with `running`, and simultaneous startup failures are selected by authored ordinal rather than by whichever rejected first. Each pane also expands under a counter of its own, so two concurrent panes cannot take block identities that depend on which ran first. `durableSpawn` could not be used: the task it returns is spawned inside the ephemeral effect's own scope, which closes as the effect resolves, so awaiting it throws `halted`. It has no call sites or tests upstream. `durableAll` is the exercised primitive and is what the panes and the grid child use. Evidence: 30 rows in `packages/core/tests/terminal-grid.test.ts` and 10 in `packages/runtime/tests/terminal-provider.test.ts`; core 349, runtime 15. --- packages/core/mod.ts | 30 + packages/core/src/expand.ts | 37 +- packages/core/src/terminal/authority.ts | 138 +- packages/core/src/terminal/grid.ts | 397 +++-- packages/core/src/terminal/journal.ts | 140 ++ packages/core/src/terminal/profile.ts | 60 + packages/core/src/terminal/provider-api.ts | 271 +++ packages/core/tests/terminal-grid.test.ts | 1452 ++++++++++------- packages/runtime/mod.ts | 11 +- packages/runtime/terminal.ts | 245 ++- .../runtime/tests/terminal-provider.test.ts | 290 ++-- 11 files changed, 2105 insertions(+), 966 deletions(-) create mode 100644 packages/core/src/terminal/journal.ts create mode 100644 packages/core/src/terminal/profile.ts create mode 100644 packages/core/src/terminal/provider-api.ts diff --git a/packages/core/mod.ts b/packages/core/mod.ts index d31eb6d1..b6cef3a9 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,6 +152,36 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; +export { + createTerminalAuthority, + createTerminalGridClaims, + TerminalAuthorityError, + terminalInstallation, + useTerminalInstallation, +} from "./src/terminal/authority.ts"; +export type { + PaneReadiness, + TerminalGridAuthority, + TerminalGridClaims, + TerminalPaneClaim, +} from "./src/terminal/authority.ts"; +export { + installTerminalProvider, + registerTerminalProvider, + TERMINAL_PROVIDERS_API, + TerminalProviderInstallError, + TerminalProviders, +} from "./src/terminal/provider-api.ts"; +export type { + TerminalProviderFactory, + TerminalProviderInstallRequest, + TerminalProviderOptions, +} from "./src/terminal/provider-api.ts"; +export { installTerminalGridProfile } from "./src/terminal/profile.ts"; +export type { TerminalGridProfileOptions } from "./src/terminal/profile.ts"; +export { paneTerminal } from "./src/terminal/pane.ts"; +export type { PaneTerminal } from "./src/terminal/pane.ts"; +export type { PaneStatus, RetainedGrid, RetainedPaneOutcome } from "./src/terminal/grid.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 63b69128..2dc5932b 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,8 +68,9 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; -import { runTerminalGrid } from "./terminal/grid.ts"; +import { durableGrid, openTerminalGrid, toRequest } from "./terminal/grid.ts"; import type { PaneWork } from "./terminal/grid.ts"; +import { recordGridLayout } from "./terminal/journal.ts"; import { usePaneTerminal } from "./terminal/pane.ts"; import { asBindingViolation, @@ -1188,7 +1189,6 @@ function* expandListSegments( parentMeta, parentProps, hideSet, - counter, path: elementPath, checkedFailures, authority, @@ -2118,7 +2118,6 @@ interface GridSite { readonly parentMeta: Record; readonly parentProps: Record; readonly hideSet: Set; - readonly counter: BlockCounter; readonly path: string; readonly checkedFailures: CheckedFailures | undefined; readonly authority: ExpansionAuthority | undefined; @@ -2166,13 +2165,28 @@ function* expandTerminalGrid( // The grid renders nothing into the document: what a pane shows belongs to // that pane, and the sibling after `` renders to the root // again only once the provider has restored it. + const identity = { + path: site.path, + ...(segment.position === undefined ? {} : { position: segment.position }), + }; + try { - const work = structure.panes.map((pane, index) => - paneWork(pane, layout.cells[index]!.title, site), - ); - const result = yield* runTerminalGrid(layout, work); - if (result.failure !== undefined) { - owner.push(yield* raise(terminalGridError(segment, result.failure.message))); + // Recorded in this coroutine, before the lease and before any provider is + // contacted: a resumed run whose grid changed is refused while nothing has + // been opened. It cannot live inside the grid child, because a completed + // child never runs. + yield* recordGridLayout(identity, toRequest(layout)); + + const retained = yield* durableGrid(function* () { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); + return yield* openTerminalGrid(layout, work); + }); + + const failed = retained.panes.find((pane) => pane.status === "failed"); + if (failed !== undefined) { + owner.push(yield* raise(terminalGridError(segment, failed.reason))); } } catch (error) { owner.push( @@ -2234,7 +2248,10 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { site.parentMeta, site.parentProps, site.hideSet, - site.counter, + // A counter of its own. Panes expand concurrently, and a shared + // mutable counter would hand two of them block identities that depend + // on which happened to run first. + createBlockCounter(), shown, extendPath( site.path, diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts index df4feffa..64e11fce 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/core/src/terminal/authority.ts @@ -4,11 +4,12 @@ * * The provider draws a grid. This decides everything about it that matters: * which request is live, which provider installation it belongs to, which pane - * ordinals exist, whether an interactive operation may start on one, and when a - * pane has actually started. None of that is reachable by name. There is no - * context holding an authority, no member of a request that carries one, and no - * handler return value that produces one — an authority reachable by name would - * be an authority every same-name context and every loaded copy could reach. + * ordinals exist, whether an interactive operation may start on one, when a + * pane has actually started, and what the grid settled to. None of that is + * reachable by name. There is no context holding an authority, no member of a + * request that carries one, and no handler return value that produces one — an + * authority reachable by name would be an authority every same-name context and + * every loaded copy could reach. * * A claim is the unforgeable carrier. It is minted here for one ordinal of one * request under one installation generation, and a claim from another grid, @@ -18,9 +19,9 @@ * session coordinator's to answer and stays independently authoritative. */ -import { all, ensure, withResolvers } from "effection"; -import type { Operation } from "effection"; -import type { TerminalGridRequest } from "@executablemd/runtime"; +import { all, createContext, ensure, withResolvers } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; @@ -40,8 +41,8 @@ export interface TerminalPaneClaim { * Run one interactive operation as this pane's owner. * * Refuses while another is live on this pane, and refuses once the grid that - * minted the claim has finished — a claim kept past its expansion is a claim - * to a terminal nobody owns any more. + * minted the claim has stopped admitting work — a claim kept past its + * expansion is a claim to a terminal nobody owns any more. */ admit(body: () => Operation): Operation; /** @@ -77,6 +78,123 @@ export interface TerminalGridClaims { seal(): void; } +/** + * What a registered provider must present in order to act. + * + * Delivered directly to the provider factory as it installs, and reachable + * nowhere else. Presenting the exact request core issued is what takes the + * terminal leases, mints the pane claims, and runs the grid; anything else — + * a copy, a rebuilt lookalike, an earlier grid's request, a request already + * presented, or one belonging to a superseded installation — authorizes + * nothing. + */ +export interface TerminalGridAuthority { + present(request: TerminalGridRequest, composite: TerminalComposite): Operation; +} + +/** One grid this execution issued, from the authority's side. */ +export interface LiveGrid { + /** The exact request object core issued. Compared by identity, never shape. */ + readonly request: TerminalGridRequest; + /** The installation this grid belongs to. */ + readonly generation: object; + /** Run the grid on a presented composite, and keep what it settled to. */ + run(composite: TerminalComposite): Operation; + /** Whether this request has already been presented. */ + used: boolean; + /** Whether the grid actually ran to a settlement. */ + settled: boolean; +} + +/** Every grid this execution has issued and not yet finished. */ +export interface GridRegistry { + live(): readonly LiveGrid[]; + add(grid: LiveGrid): void; + remove(grid: LiveGrid): void; +} + +export function createGridRegistry(): GridRegistry { + const grids = new Set(); + return { + live: () => [...grids], + add: (grid) => { + grids.add(grid); + }, + remove: (grid) => { + grids.delete(grid); + }, + }; +} + +/** + * Build the authority one provider installation is given. + * + * It closes over the installation's generation and its registry, so a factory + * that kept an authority from a superseded installation presents into a + * generation that no longer has the grid it names. + */ +export function createTerminalAuthority( + generation: object, + live: () => readonly LiveGrid[], +): TerminalGridAuthority { + return { + *present(request, composite) { + const grid = live().find((candidate) => Object.is(candidate.request, request)); + if (grid === undefined) { + throw new TerminalAuthorityError( + "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + + "belongs to an execution that has finished", + ); + } + if (!Object.is(grid.generation, generation)) { + throw new TerminalAuthorityError( + "this grid request belongs to another terminal provider installation", + ); + } + if (grid.used) { + throw new TerminalAuthorityError( + "this grid request has already been presented — one request opens one grid", + ); + } + grid.used = true; + yield* grid.run(composite); + }, + }; +} + +/** One execution's terminal installation: its registry and its generation. */ +export interface TerminalInstallation { + readonly registry: GridRegistry; + /** Identifies this execution's provider installation, and nothing else. */ + readonly generation: object; +} + +const Installation: Context = createContext< + TerminalInstallation | undefined +>("core.terminal.installation", undefined); + +/** + * Open one terminal installation for a live document, and hand back the + * authority its providers are installed with. + * + * What travels contextually is the installation — composition data, so a + * document and the components it expands find the same one. The authority does + * not: it is handed to a provider factory directly. A replaced installation + * therefore produces requests the real authority has never heard of, which is a + * refusal rather than a way in. + */ +export function* useTerminalInstallation(): Operation { + const registry = createGridRegistry(); + const generation = {}; + yield* Installation.set({ registry, generation }); + return createTerminalAuthority(generation, () => registry.live()); +} + +/** This execution's terminal installation, or `undefined` outside one. */ +export function terminalInstallation(): Operation { + return Installation.get(); +} + /** * Mint the claims for one grid expansion. * diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 9f712da1..3892a43c 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -1,6 +1,6 @@ /** * One terminal grid, from the lease to the last finalizer (spec §6.21, - * architecture.md §Atomic presentation and settlement). + * architecture.md §Atomic presentation and settlement, §Durability and replay). * * Opening a grid is atomic from the reader's side, and that is the whole shape * of this module. The composite is built while it is still hidden, every pane @@ -8,34 +8,70 @@ * anything appear. A failure before that barrier discards the hidden composite * instead of leaving half a grid on the screen. * - * Ordering is the contract, not an implementation detail: - * * ``` - * lease → flush → prepare → panes start → readiness barrier → attach - * → panes settle independently → reader closes → teardown → lease released + * layout recorded → lease → flush → routed to a provider → composite presented + * → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released * ``` * - * Nothing here decides what a pane *is* — the layout arrived already derived, - * and the work each pane does is supplied by the caller. What this owns is - * whose terminal it is, when a pane counts as started, what happens when one - * fails, and the order in which it all comes apart. + * Each pane is a **durable child coroutine** of the grid, allocated in authored + * order. That is not decoration: a completed child short-circuits on replay by + * returning its retained result without running, and claiming a completed + * parent claims every descendant history beneath it. Wrapping the region in one + * durable operation instead would leave the panes' entries unconsumed and + * desynchronise the journal on the next run. */ -import { ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation, Task } from "effection"; -import { flushOutput, prepareTerminalGrid, reserveTerminal } from "@executablemd/runtime"; +import { all, ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { DurableContext, durableAll, ephemeral } from "@executablemd/durable-streams"; +import type { Json, Workflow } from "@executablemd/durable-streams"; +import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; -import { awaitReadiness, createTerminalGridClaims } from "./authority.ts"; -import type { TerminalPaneClaim } from "./authority.ts"; +import { + awaitReadiness, + createTerminalGridClaims, + TerminalAuthorityError, + terminalInstallation, +} from "./authority.ts"; +import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; -/** How one pane ended. */ -export type PaneOutcome = - | { readonly kind: "succeeded" } - | { readonly kind: "failed"; readonly error: Error } - /** Live when the reader closed the grid. Cancellation, not failure. */ - | { readonly kind: "closed" }; +/** How one pane ended, as the journal records it. */ +export type PaneStatus = "succeeded" | "failed" | "closed"; + +/** How a grid ended. */ +export type GridCloseKind = "reader" | "failed"; + +/** One pane's retained outcome: what it came to, and why when it failed. */ +export interface RetainedPaneOutcome extends Record { + status: PaneStatus; + reason: string; +} + +export interface RetainedPane extends Record { + ordinal: number; + title: string; + form: string; + row: number; + column: number; +} + +/** + * What a grid retains: the provider-neutral layout, how it closed, and each + * pane's outcome in authored order. + * + * Nothing here names a provider. No command, socket, path, process identifier, + * session, window or pane identifier, no argv or environment, and no terminal + * byte — none of that describes the document, it describes whichever provider + * happened to present it, and a resumed run builds a fresh one. + */ +export interface RetainedGrid extends Record { + layout: { columns: number; rows: number; panes: RetainedPane[] }; + close: GridCloseKind; + panes: RetainedPaneOutcome[]; +} /** * What one pane does once its claim exists. @@ -50,13 +86,6 @@ export interface PaneWork { run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; } -/** Everything the grid settled, in authored pane order. */ -export interface GridResult { - readonly outcomes: readonly PaneOutcome[]; - /** Why the grid failed, which is the first failed pane in authored order. */ - readonly failure?: Error; -} - /** * What a pane that never reported a spawn says. * @@ -72,40 +101,117 @@ export function paneNeverStartedMessage(ordinal: number, title: string): string ); } -class PaneStartupError extends Error { - override name = "PaneStartupError"; - readonly ordinal: number; - constructor(ordinal: number, message: string) { - super(message); - this.ordinal = ordinal; - } +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { + return Object.freeze({ + columns: layout.columns, + rows: layout.rows, + panes: Object.freeze( + layout.cells.map((cell) => + Object.freeze({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + }), + ), + ), + }); +} + +/** The retained shape of one request. */ +export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layout"] { + return { + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ + ordinal: pane.ordinal, + title: pane.title, + form: pane.form, + row: pane.row, + column: pane.column, + })), + }; } /** - * Run one grid to completion and report what its panes settled to. + * Open one grid and report what it settled to. * - * The foreground lease and the composite are both scope-owned, so every path - * out of here — success, failure, and cancellation alike — releases the - * terminal and destroys exactly the composite that was prepared. That is why - * teardown is not written as a step: there is no path that can skip it. + * Core mints the one request for this expansion, takes the run's foreground + * lease, flushes what the document has already produced, registers the request + * as live, routes it through the public surface, and then reads what the + * authority settled. The routed answer is discarded on purpose: a handler that + * short-circuits or fabricates a return has presented nothing, and this says so + * rather than letting the document believe a grid opened. */ -export function runTerminalGrid( +export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], -): Operation { - return scoped(function* (): Operation { +): Operation { + return scoped(function* (): Operation { + const installation = yield* terminalInstallation(); + if (installation === undefined) { + throw new TerminalAuthorityError( + "a terminal grid is available only inside a document execution with an installed " + + "terminal provider — a grid outside one retains nothing and could not be resumed", + ); + } + const request = toRequest(layout); + let settled: RetainedGrid | undefined; + + const grid: LiveGrid = { + request, + generation: installation.generation, + used: false, + settled: false, + *run(composite) { + settled = yield* presentGrid(request, composite, work); + grid.settled = true; + }, + }; + installation.registry.add(grid); + yield* ensure(() => { + installation.registry.remove(grid); + }); - // The one foreground-terminal lease. A root and a grid - // contend for exactly this, so neither can begin while the other holds it, - // and a host with no terminal refuses here — before any pane has done work. + // The one foreground-terminal lease, taken before any provider is asked for + // anything. A root and a grid contend for exactly this, so + // neither can begin while the other holds it. yield* reserveTerminal(); // Everything the document has produced so far reaches the reader before the // grid covers it up. yield* flushOutput(); - const composite = yield* prepareTerminalGrid(request); - // Registered before a single pane starts: a composite that was prepared is + // Routed, and the answer thrown away. + yield* TerminalGrids.operations.open(request); + + if (!grid.settled || settled === undefined) { + throw new TerminalAuthorityError( + "no terminal provider opened this grid — a handler answered without delivering the " + + "request to a registered provider", + ); + } + return settled; + }); +} + +/** + * Run the grid on the composite a provider presented. + * + * The composite is scope-owned, so every path out of here — success, failure, + * and cancellation alike — destroys exactly the composite that was presented. + * That is why teardown is not written as a step: there is no path that can skip + * it. + */ +function presentGrid( + request: TerminalGridRequest, + composite: TerminalComposite, + work: readonly PaneWork[], +): Operation { + return scoped(function* (): Operation { + // Registered before a single pane starts: a composite that was presented is // owed a destroy even if the next line is what fails. yield* ensure(() => composite.destroy()); @@ -116,53 +222,54 @@ export function runTerminalGrid( grid.seal(); }); - const outcomes: (PaneOutcome | undefined)[] = work.map(() => undefined); + const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); const startupFailed = withResolvers(); let attached = false; - const panes: Task[] = []; - for (const [index, pane] of work.entries()) { + // Every pane's work, in authored order. The children are allocated in this + // order too, so a pane's durable identity follows its ordinal rather than + // the order the runtime happened to schedule it in. + const paneWorkflows = work.map((pane, index) => { const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; + return function* (): Operation { + const outcome = yield* runPane(pane, claim, composite, readiness, request, index); + outcomes[index] = outcome; + yield* composite.update(pane.ordinal, outcome.status); + if (outcome.status === "failed" && !attached) { + // Before the barrier a pane failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than attaching what is + // left. After it, the failure is this pane's status alone. + startupFailed.reject(new Error(outcome.reason)); + } + return outcome; + }; + }); + + for (const pane of work) { yield* composite.update(pane.ordinal, "starting"); - panes.push( - yield* spawn(function* () { - try { - yield* pane.run(claim, composite); - if (!readiness.acknowledged) { - // Settled without ever starting: that is a startup failure even - // though the work itself raised nothing. - throw new PaneStartupError( - pane.ordinal, - paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), - ); - } - outcomes[index] = { kind: "succeeded" }; - yield* composite.update(pane.ordinal, "succeeded"); - } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)); - outcomes[index] = { kind: "failed", error: failure }; - // Before the barrier a pane failure is the whole grid's: nothing has - // been shown, so the grid fails closed rather than attaching what is - // left. After it, the failure is this pane's status and its siblings - // keep running. - if (!attached) { - startupFailed.reject(failure); - return; - } - yield* composite.update(pane.ordinal, "failed"); - } - }), - ); } + // Spawned as one task so the coordinator below can reach the readiness + // barrier, attach, and wait for the reader while the panes are still live. + const panes = yield* spawn(() => paneChildren(paneWorkflows)); // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane // already failed from waiting forever for a latch nothing will acknowledge. - yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + try { + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + } catch { + // Simultaneous startup failures are selected by authored ordinal, not by + // whichever rejected the race first. + throw new Error(firstReason(outcomes) ?? "a terminal grid pane failed to start"); + } - for (const pane of work) { - yield* composite.update(pane.ordinal, "running"); + // A pane that already settled keeps the status it settled to: overwriting + // it with `running` would tell the reader a finished pane is live. + for (const [index, pane] of work.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(pane.ordinal, "running"); + } } yield* composite.attach(); attached = true; @@ -172,36 +279,118 @@ export function runTerminalGrid( yield* composite.closed(); // Close prevents new work first, then takes the live panes down: a pane - // cancelled by the close is `closed`, which is not a failed pane. + // cancelled by the close is `closed`, which is not a failed pane. Every + // child is awaited here, and the provider's finalizers run in the scope's + // own teardown after this returns — so the composite is destroyed, the + // lease released and the following sibling started only once nothing a pane + // acquired can still act. grid.seal(); - for (const [index, task] of panes.entries()) { + for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { - yield* composite.update(work[index]!.ordinal, "closed"); - outcomes[index] = { kind: "closed" }; + yield* composite.update(pane.ordinal, "closed"); + outcomes[index] = { status: "closed", reason: "" }; } - yield* task.halt(); } + yield* panes.halt(); - const settled = outcomes.map((outcome) => outcome ?? { kind: "closed" as const }); - const failed = settled.find((outcome) => outcome.kind === "failed"); + const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + const reason = firstReason(settled); return { - outcomes: settled, - ...(failed?.kind === "failed" ? { failure: failed.error } : {}), + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: settled, }; }); } -/** The provider-neutral request one derived layout asks for. */ -export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { - return { - columns: layout.columns, - rows: layout.rows, - panes: layout.cells.map((cell) => ({ - ordinal: cell.ordinal, - title: cell.title, - row: cell.row, - column: cell.column, - form: cell.form, - })), - }; +/** Run one pane's work and say what it came to. */ +function runPane( + pane: PaneWork, + claim: TerminalPaneClaim, + composite: TerminalComposite, + readiness: { readonly acknowledged: boolean }, + request: TerminalGridRequest, + index: number, +): Operation { + return (function* (): Operation { + try { + yield* pane.run(claim, composite); + if (!readiness.acknowledged) { + // Settled without ever starting: a startup failure even though the work + // itself raised nothing. + return { + status: "failed", + reason: paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + }; + } + return { status: "succeeded", reason: "" }; + } catch (error) { + return { + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }; + } + })(); +} + +/** The first failed pane's sentence in authored order, which is the grid's. */ +function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): string | undefined { + return outcomes.find((outcome) => outcome?.status === "failed")?.reason; +} + +/** + * Run every pane as a durable child of the grid, in authored order. + * + * A pane's identity is derived from the grid's coroutine and its authored + * ordinal, never from a title, a schedule, or a provider identifier — so a + * resumed run restores a completed pane as its outcome without re-running it, + * and continues an incomplete one from its own history. + * + * `durableAll` rather than `durableSpawn`: the latter returns a task spawned + * inside the ephemeral effect's own scope, and that scope closes as the effect + * resolves, so awaiting the task throws `halted`. It has no call sites or tests + * upstream; `durableAll` is the primitive that is exercised. + * + * Without a journal there are no children to derive, and the work simply runs. + */ +function paneChildren( + workflows: readonly (() => Operation)[], +): Operation { + return (function* (): Operation { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + return yield* all(workflows.map((workflow) => workflow())); + } + return yield* durableAll( + workflows.map( + (workflow) => + function* (): Workflow { + return yield* ephemeral(workflow()); + }, + ), + ); + })(); +} + +/** + * Run the whole grid as one durable child, and return what it retained. + * + * A completed grid replays by returning its retained result: the child's + * workflow never runs, so no provider is contacted, no pane content expands and + * no shell starts — and claiming the completed child claims every pane history + * beneath it, so a resumed run starts nothing. + */ +export function durableGrid(live: () => Operation): Operation { + return (function* (): Operation { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + return yield* live(); + } + const [retained] = yield* durableAll([ + function* (): Workflow { + return yield* ephemeral(live()); + }, + ]); + return retained!; + })(); } diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts new file mode 100644 index 00000000..cedd0b39 --- /dev/null +++ b/packages/core/src/terminal/journal.ts @@ -0,0 +1,140 @@ +/** + * Which grid a run opened, and how a resumed run is held to it + * (spec §6.21 Durability and replay). + * + * One entry, appended in the **parent** coroutine and **before** the foreground + * lease is taken or any provider is contacted: the columns and rows, and the + * ordered pane forms, titles and positions. A resumed run compares what it + * derived against what is held and refuses a document whose grid changed while + * nothing has been opened and nothing has started. + * + * It sits in the parent deliberately. The grid itself is a durable child, and a + * completed child short-circuits without running — so a comparison written + * inside it would never happen on the run that most needs it. + * + * Provider-neutral throughout. No command, socket, path, process, session, + * window or pane identifier, no argv or environment, and no terminal byte is + * written here: none of that describes the document, it describes whichever + * provider happened to present it, and a resumed run builds a fresh one. + */ + +import type { Operation } from "effection"; +import { + createDurableOperation, + DurableContext, + StaleInputError, +} from "@executablemd/durable-streams"; +import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; +import type { TerminalGridRequest } from "@executablemd/runtime"; + +import { sourceDescription } from "../source-position.ts"; +import type { SourcePosition } from "../types.ts"; +import { retainedLayout } from "./grid.ts"; +import type { RetainedGrid } from "./grid.ts"; + +/** A grid's identity within one execution: where it was written. */ +export interface GridIdentity { + /** The structural path that reached this element (§5.6). */ + readonly path: string; + readonly position?: Readonly; +} + +type RetainedLayout = RetainedGrid["layout"]; + +function describe(identity: GridIdentity): EffectDescription { + return { + type: "terminal_grid_layout", + name: `terminal_grid:${identity.path}:layout`, + ...sourceDescription(identity.position), + }; +} + +/** Whether this expansion has a journal to read and append to at all. */ +function* durable(): Operation { + return (yield* DurableContext.get()) !== undefined; +} + +/** + * Append one entry and return what the entry holds. + * + * Live it is the value passed in; on replay it is the value the journal already + * held, which is the only way a caller tells the two apart. + */ +function* append(description: EffectDescription, value: Json): Workflow { + return yield createDurableOperation(description, function* () { + return value; + }); +} + +/** The retained layout a journal entry holds, or undefined if it holds anything else. */ +function readLayout(value: unknown): RetainedLayout | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const fields: Record = Object.fromEntries(Object.entries(value)); + const { columns, rows, panes } = fields; + if (typeof columns !== "number" || typeof rows !== "number" || !Array.isArray(panes)) { + return undefined; + } + return { columns, rows, panes: panes as RetainedLayout["panes"] }; +} + +/** How two layouts differ, in the words an author can act on. */ +function divergence(held: RetainedLayout, derived: RetainedLayout): string | undefined { + if (held.columns !== derived.columns) { + return `columns ${held.columns} rather than ${derived.columns}`; + } + if (held.panes.length !== derived.panes.length) { + return `${held.panes.length} panes rather than ${derived.panes.length}`; + } + for (const [index, pane] of derived.panes.entries()) { + const before = held.panes[index]!; + if (before.title !== pane.title) { + return `pane ${index} titled "${before.title}" rather than "${pane.title}"`; + } + if (before.form !== pane.form) { + return `pane ${index} written ${before.form} rather than ${pane.form}`; + } + if (before.row !== pane.row || before.column !== pane.column) { + return ( + `pane ${index} at row ${before.row}, column ${before.column} rather than row ` + + `${pane.row}, column ${pane.column}` + ); + } + } + return undefined; +} + +/** + * Record which grid this is, and refuse a resumed run whose grid changed. + * + * Expansion driven without a journal records nothing and behaves identically. + */ +export function* recordGridLayout( + identity: GridIdentity, + request: TerminalGridRequest, +): Operation { + if (!(yield* durable())) { + return; + } + const derived = retainedLayout(request); + const description = describe(identity); + const stored = yield* append(description, derived); + const held = readLayout(stored); + if (held === undefined) { + throw new StaleInputError( + `The journal's record of "${description.name}" is not a terminal-grid layout. Re-run the ` + + "document from the start rather than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } + const changed = divergence(held, derived); + if (changed !== undefined) { + throw new StaleInputError( + `The journal records this terminal grid as a grid with ${changed}. A grid whose layout ` + + "changed cannot be replayed onto this run. Re-run the document from the start rather " + + "than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } +} diff --git a/packages/core/src/terminal/profile.ts b/packages/core/src/terminal/profile.ts new file mode 100644 index 00000000..05919b65 --- /dev/null +++ b/packages/core/src/terminal/profile.ts @@ -0,0 +1,60 @@ +/** + * Opening one terminal installation for a live document. + * + * A grid needs two things before it can be durable at all: this execution's + * installation — which owns the generation every request belongs to and the + * registry of the grids it issued — and a provider installed against the + * authority that installation mints. A grid outside one refuses rather than + * presenting something no replay could resume. + * + * The installation's lifetime has to surround authored work and end while the + * journal is still live, which is what `Execution.document` is. + */ + +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Execution } from "../execute.ts"; +import { useTerminalInstallation } from "./authority.ts"; +import { installTerminalProvider } from "./provider-api.ts"; + +export interface TerminalGridProfileOptions { + /** + * The registered provider to install for this execution. + * + * Omitted, the installation is opened and no provider is installed — which is + * a host that validates and inspects grids but cannot present one, and + * refuses when a document asks for one. + */ + readonly provider?: string; + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label?: string; +} + +/** + * Install the terminal-grid profile for the executions composed under it. + * + * The authority reaches the named provider's factory and nothing else: it is + * delivered through the installation handshake rather than published, so a + * handler that answers the install request itself installs no provider and the + * document is told so. + */ +export function installTerminalGridProfile( + options: TerminalGridProfileOptions = {}, +): Operation { + return Execution.around({ + *document([request], next) { + yield* scoped(function* () { + const authority = yield* useTerminalInstallation(); + if (options.provider !== undefined) { + yield* installTerminalProvider( + options.provider, + { label: options.label ?? options.provider }, + authority, + ); + } + yield* next(request); + }); + }, + }); +} diff --git a/packages/core/src/terminal/provider-api.ts b/packages/core/src/terminal/provider-api.ts new file mode 100644 index 00000000..f3537dd9 --- /dev/null +++ b/packages/core/src/terminal/provider-api.ts @@ -0,0 +1,271 @@ +/** + * How a terminal provider is installed, and what installing one grants. + * + * A provider is the only thing that can present a grid, so *selecting* one is + * itself an authority decision. Returning a factory up the public chain would + * mean any handler could answer with a factory of its own — or take the one it + * was given and install it somewhere else. + * + * So nothing is returned. Public middleware receives one frozen, one-use + * install request naming the provider and its normalized options, and may + * inspect it, refuse by throwing, or delegate it. The registered provider's + * handler sits at the terminal end of that chain and holds its own captured + * continuation — a parameter of its generator, carried by no request and no + * return value. Through that continuation, and only through it, the invocation + * terminal hands the factory this execution's terminal authority and records + * that the provider acknowledged installation. + * + * Registration is scope-local: a nested registration overrides an outer one for + * its own name without touching siblings or process-global state. + * + * This is the same handshake `AgentProviders` uses, deliberately. The two + * capabilities are different — one hands a child the whole terminal, one + * divides it into panes — but the question "who may install the thing that + * performs it" has one right answer, and two spellings of it would be two + * chances to get it wrong. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { ensure } from "effection"; +import type { Operation } from "effection"; + +import type { TerminalGridAuthority } from "./authority.ts"; + +/** What a host says about the provider it is installing. */ +export interface TerminalProviderOptions { + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label: string; +} + +/** + * A provider factory installs `TerminalGrids` middleware for its scope. + * + * The authority is the second argument because it is delivered, not published: + * there is no reader for it, no context holding one, and no request member + * carrying one. A factory closes over it, and only the handler that closed over + * it can pair a routed grid request with it. + */ +export type TerminalProviderFactory = ( + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +) => Operation; + +/** The stable name every loaded copy composes through. */ +export const TERMINAL_PROVIDERS_API = "TerminalProviders"; + +/** What public installation middleware sees: the name, and what it runs under. */ +export interface TerminalProviderInstallRequest { + readonly intent: "install"; + readonly name: string; + readonly options: TerminalProviderOptions; +} + +/** + * One message on the installation operation. + * + * Public middleware only ever receives the install request. The two private + * members are how the registered provider's handler speaks to the invocation's + * own terminal through the continuation it captured; constructing one grants + * nothing, because the terminal is reachable from that continuation alone. + */ +export type TerminalProviderCall = + | TerminalProviderInstallRequest + | { readonly intent: "inspect"; readonly install: TerminalProviderInstallRequest } + | { readonly intent: "acknowledge"; readonly install: TerminalProviderInstallRequest }; + +export interface TerminalProviderApi { + /** + * Install one provider. + * + * Answers nothing: a return value is not evidence a provider was installed, + * and the invocation that issued the request ignores it. + */ + install(call: TerminalProviderCall): Operation; +} + +export class TerminalProviderInstallError extends Error { + override name = "TerminalProviderInstallError"; +} + +/** + * The public installation surface. Its own default always refuses. + * + * Invoking this descriptor with a captured request outside a live installation + * reaches this default and installs nothing. + */ +export const TerminalProviders: Api = createApi( + TERMINAL_PROVIDERS_API, + { + // deno-lint-ignore require-yield + *install(call: TerminalProviderCall): Operation { + const name = call.intent === "install" ? call.name : call.install.name; + throw new TerminalProviderInstallError(`Unknown terminal provider "${name}"`); + }, + }, +); + +/** Make `factory` installable as `name` for the current scope. */ +export function* registerTerminalProvider( + name: string, + factory: TerminalProviderFactory, +): Operation { + let registered = true; + yield* ensure(() => { + registered = false; + }); + yield* TerminalProviders.around( + { + *install([call], next): Operation { + if (call.intent !== "install" || call.name !== name) { + return yield* next(call); + } + if (!registered) { + throw new TerminalProviderInstallError( + `the "${name}" terminal provider registration is no longer live`, + ); + } + // Inspection first, and through the captured continuation: the terminal + // refuses a copied, reused or stale request here, before the factory + // installs anything. + const delivery = deliveryOf(yield* next({ intent: "inspect", install: call })); + yield* factory(delivery.options, delivery.authority); + yield* next({ intent: "acknowledge", install: call }); + return undefined; + }, + }, + { at: "min" }, + ); +} + +/** + * What the terminal told this handler, or a refusal. + * + * Parsed rather than believed. The terminal that produced it belongs to the + * canonical copy, and this handler may belong to another; what arrives is a + * value, and reading it as a delivery is this side's decision. + */ +function deliveryOf(value: unknown): { + options: TerminalProviderOptions; + authority: TerminalGridAuthority; +} { + if (typeof value !== "object" || value === null) { + throw new TerminalProviderInstallError( + "this terminal provider installation is not live, so nothing was delivered to it", + ); + } + const options = Reflect.get(value, "options"); + const authority = Reflect.get(value, "authority"); + if (typeof options !== "object" || options === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation named no options", + ); + } + if (typeof authority !== "object" || authority === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no authority", + ); + } + const label = Reflect.get(options, "label"); + if (typeof label !== "string") { + throw new TerminalProviderInstallError("the live terminal provider options are not readable"); + } + const present = Reflect.get(authority, "present"); + if (typeof present !== "function") { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no grid authority", + ); + } + return { + options: { label }, + authority: { + present: (request, composite) => Reflect.apply(present, authority, [request, composite]), + }, + }; +} + +/** + * Install the provider registered as `name`, under `options`, for the calling + * operation. + * + * The authority reaches whichever factory answers, and nothing else: a handler + * that short-circuits, fabricates a return, or never acknowledges installs no + * provider, and this refuses rather than leaving the caller believing one is + * there. + */ +export function installTerminalProvider( + name: string, + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +): Operation { + return (function* (): Operation { + const request: TerminalProviderInstallRequest = Object.freeze({ + intent: "install", + name, + options: Object.freeze({ ...options }), + }); + const terminal = installationTerminal(request, options, authority); + // Same stable name, so the shared middleware chain applies; own descriptor, + // so the chain ends in this invocation's terminal rather than in the public + // refusing default. + const invocation = createApi(TERMINAL_PROVIDERS_API, { + install: terminal.install, + }); + yield* invocation.operations.install(request); + if (!terminal.acknowledged()) { + throw new TerminalProviderInstallError( + `the "${name}" terminal provider did not install — a handler answered without ` + + `delivering the request to a registered provider`, + ); + } + terminal.close(); + })(); +} + +function installationTerminal( + request: TerminalProviderInstallRequest, + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +): { + install: (call: TerminalProviderCall) => Operation; + acknowledged: () => boolean; + close: () => void; +} { + let state: "available" | "inspected" | "acknowledged" | "closed" = "available"; + + return { + // deno-lint-ignore require-yield + *install(call: TerminalProviderCall): Operation { + if (call.intent === "install") { + // Reaching the terminal means no registered provider consumed it. + throw new TerminalProviderInstallError(`Unknown terminal provider "${call.name}"`); + } + // Object identity, not shape: a request rebuilt with the same members + // describes the same ask and authorizes nothing. + if (!Object.is(call.install, request)) { + throw new TerminalProviderInstallError( + "the live terminal provider installation received a copied, substituted or foreign request", + ); + } + if (call.intent === "inspect") { + if (state !== "available") { + throw new TerminalProviderInstallError( + "this terminal provider installation is reused, completed or stale", + ); + } + state = "inspected"; + return { options, authority }; + } + if (state !== "inspected") { + throw new TerminalProviderInstallError( + "this terminal provider acknowledgement is unsolicited, duplicated or stale", + ); + } + state = "acknowledged"; + return undefined; + }, + acknowledged: () => state === "acknowledged", + close() { + state = "closed"; + }, + }; +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index b360fa3f..ad44b1e7 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1,6 +1,7 @@ /** * Tier TG — running a terminal grid through a replaceable provider - * (spec §6.21, architecture.md §Atomic presentation and settlement). + * (spec §6.21, architecture.md §Terminal authority, §Atomic presentation and + * settlement, §Durability and replay). * * The provider here is controlled and is not tmux: it opens no terminal, starts * no process, and records what it was asked to do in the order it was asked. @@ -10,87 +11,206 @@ * * Readiness is the claim these rows care about most, so it is always driven * explicitly: a pane becomes ready because something called the latch it was - * handed, never because it got far enough. That is what lets "started" and - * "did some work" be told apart at all. + * handed, never because it got far enough. That is what lets "started" and "did + * some work" be told apart at all. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, `` is what a suite writes + * to be that something — and it reaches the pane through the same seam a real + * `` will. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; -import type { Operation, Result } from "effection"; +import { + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; import { forEach } from "@effectionx/stream-helpers"; import { rm, writeTextFile } from "@effectionx/fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; import { installControlledLauncher, - installControlledTerminalProvider, + prepareControlledComposite, + TerminalGrids, + terminalProviderLog, +} from "@executablemd/runtime"; +import type { + ControlledCompositeOptions, + TerminalComposite, + TerminalGridRequest, + TerminalProviderLog, } from "@executablemd/runtime"; -import type { TerminalGridRequest, TerminalProviderLog } from "@executablemd/runtime"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; +import { + createTerminalGridClaims, + TerminalAuthorityError, + useTerminalInstallation, +} from "../src/terminal/authority.ts"; +import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; +import { + installTerminalProvider, + registerTerminalProvider, + TerminalProviderInstallError, + TerminalProviders, +} from "../src/terminal/provider-api.ts"; +import { installTerminalGridProfile } from "../src/terminal/profile.ts"; +import { paneTerminal } from "../src/terminal/pane.ts"; import type { Json } from "../src/types.ts"; -import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; -import { runTerminalGrid } from "../src/terminal/grid.ts"; -import type { GridResult, PaneWork } from "../src/terminal/grid.ts"; -import { paneTerminal, usePaneTerminal } from "../src/terminal/pane.ts"; -import { terminalGridLayout } from "../src/terminal-grid.ts"; -import type { TerminalGridLayout } from "../src/terminal-grid.ts"; - -function log(): TerminalProviderLog { - return { events: [], shown: new Map() }; +/** One document run against a controlled grid host. */ +interface DocumentRun { + outcome: Result; + /** Text the consumer received — the root document's own output. */ + output: string; + /** The grid the provider was actually asked to present. */ + requests: TerminalGridRequest[]; + /** What each pane displayed. */ + shown: Map; + /** Everything the composite did, in order. */ + events: string[]; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; + /** The journal this run read and appended to. */ + journal: DurableEvent[]; } -/** A layout of `count` panes across `columns`, titled by ordinal. */ -function layoutOf(columns: number, count: number): TerminalGridLayout { - return terminalGridLayout( - columns, - Array.from({ length: count }, (_unused, index) => ({ - title: `pane ${index}`, - form: "self-closing" as const, - })), - ); +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); } -/** A pane that starts, does what `body` says, and settles. */ -function pane(ordinal: number, body?: () => Operation): PaneWork { - return { - ordinal, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - if (body) { - yield* body(); +/** The controlled interactive child, and a tripwire. */ +function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); } - }); + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + { + // Starts interactively, slowly, and records when it did. + name: "Slow", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + yield* sleep(25); + slowMarks.push("ready:slow"); + spawned(); + }); + return ""; + }, }, - }; + { + name: "Hold", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* suspend(); + return ""; + }, + }, + ]); } -/** Everything a grid run needs installed, with the reader's close under control. */ -function* useGridHost(record: TerminalProviderLog, close: () => Operation): Operation { - // A grid takes the same one foreground lease a root takes, - // so a host that offers a grid still has to offer that lease. - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ log: record, close }); +/** + * Register a controlled provider that presents through the authority it was + * delivered. + * + * This is the whole handshake in miniature: the factory receives the authority + * as an argument, prepares a composite of its own, and presents the exact + * request it was routed. Nothing it returns reaches core. + */ +function useControlledProvider( + options: ControlledCompositeOptions & { + /** Present something other than the request that was routed. */ + readonly substitute?: (request: TerminalGridRequest) => TerminalGridRequest; + /** Answer the routed request without presenting anything at all. */ + readonly shortCircuit?: boolean; + /** Keep the authority for a later, unrouted use. */ + readonly capture?: (authority: TerminalGridAuthority) => void; + } = {}, +): Operation { + let generation = 0; + return registerTerminalProvider("controlled", function* (_settings, authority) { + options.capture?.(authority); + yield* TerminalGrids.around( + { + *open([request]) { + if (options.shortCircuit === true) { + // Answers, presents nothing. Core must not believe this. + return { presented: true }; + } + const composite = yield* prepareControlledComposite(request, options, generation++); + yield* authority.present(options.substitute?.(request) ?? request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); } -/** A pane that records when it started, so ordering is read rather than timed. */ -function readyPane(ordinal: number, timeline: string[]): PaneWork { - return { - ordinal, - *run(claim) { - yield* claim.admit(function* () { - timeline.push(`ready:${ordinal}`); - claim.ready(); - yield* suspend(); - }); - }, - }; +/** Everything a controlled grid host installs, for an in-process grid. */ +function useGridHost( + options: Parameters[0] = {}, +): Operation { + return (function* (): Operation { + yield* installControlledLauncher(); + yield* useControlledProvider(options); + const authority = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + return authority; + })(); } /** Close as soon as the reader is asked, which is the ordinary journey. */ @@ -99,546 +219,463 @@ function immediateClose(): () => Operation { return function* () {}; } -describe("Tier TG — pane claims and readiness", () => { - it("TG8: a claim admits one interactive operation at a time", function* () { - const grid = createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - const first = grid.claims[0]!; - const second = grid.claims[1]!; - let refusal: unknown; - let concurrent = false; +/** + * Expand one document against a controlled grid host. + * + * `provider: false` registers nothing, which is how "a host that cannot open a + * grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { + provider?: boolean; + stream?: InMemoryStream; + composite?: ControlledCompositeOptions; + /** Where `` records that it started. */ + slowMarks?: string[]; + } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + yield* useGridComponents(ran, options.slowMarks ?? []); + yield* installControlledLauncher(); - yield* scoped(function* () { - yield* first.admit(function* () { - // A second operation on the same pane is refused while this one is live. - try { - yield* first.admit(function* () {}); - } catch (error) { - refusal = error; - } - // A different pane does not contend at all, which is the whole reason a - // grid exists. - yield* second.admit(function* () { - concurrent = true; - }); + // The reader stays until every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + const supplied = options.composite ?? {}; + if (options.provider !== false) { + yield* useControlledProvider({ + ...supplied, + log, + close: supplied.close ?? (() => settled.operation), + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + if (supplied.onPrepare) { + yield* supplied.onPrepare(asked); + } + }, + onUpdate(ordinal, state) { + supplied.onUpdate?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, }); - }); + } + yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - expect(refusal instanceof Error ? refusal.message : "").toContain( - "one owns a pane terminal at a time", - ); - expect(concurrent).toBe(true); + const stream = options.stream ?? new InMemoryStream(); + const execution = yield* execute({ path, stream, includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { + outcome, + output, + requests, + shown: log.shown, + events: log.events, + ran, + journal: yield* stream.readAll(), + }; }); +} - it("TG8: a pane admits again once its first operation has settled", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - let second = false; +/** The message a run failed with, failing the test if it completed. */ +function failureOf(run: DocumentRun): string { + if (run.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); + } + return run.outcome.error.message; +} - yield* scoped(function* () { - yield* claim.admit(function* () {}); - yield* claim.admit(function* () { - second = true; +/** A grid on its own, which a resumed run can carry to an outcome. */ +function plainDocument(columns: number, panes: string[]): string { + return [``, ...panes, "", ""].join("\n"); +} + +/** A grid, then a component that holds the run open so the root never settles. */ +function heldDocument(columns: number, panes: string[]): string { + return [ + ``, + ...panes, + "", + "", + "", + "", + ].join("\n"); +} + +/** + * Run a document and interrupt it once the grid has journaled its outcome. + * + * A completed *or failed* root replays wholesale, so a second run of it would + * never reach the grid at all. Only a genuinely interrupted run leaves the + * region to be resumed — which is what every replay row below needs. + */ +function runInterrupted( + dir: string, + source: string, + stream: InMemoryStream, + options: { provider?: boolean } = {}, +): Operation { + return scoped(function* () { + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + const opened = withResolvers(); + yield* useGridComponents(ran); + yield* installControlledLauncher(); + if (options.provider !== false) { + yield* useControlledProvider({ + log, + close: () => suspend(), + *onPrepare(asked) { + requests.push(asked); + yield* sleep(0); + }, + // Attach is the signal, not `running`: a pane that settles before the + // barrier keeps its own status and never becomes runnable. + // deno-lint-ignore require-yield + *onAttach() { + opened.resolve(); + }, }); - }); + } + yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); - // Sequential work in one pane is ordinary composition, not contention. - expect(second).toBe(true); + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const task: Task = yield* spawn(function* () { + const execution = yield* execute({ path, stream, includes: [dir] }); + yield* execution; + }); + // The grid is open and its panes have settled, so the journal now holds the + // pane children's own entries. A resumed run never attaches at all — the + // region short-circuits — so this is bounded rather than waited on. + yield* race([opened.operation, sleep(120)]); + yield* sleep(5); + yield* task.halt(); + return { + outcome: { ok: false, error: new Error("interrupted") } as Result, + output: "", + requests, + shown: log.shown, + events: log.events, + ran, + journal: yield* stream.readAll(), + }; }); +} - it("TG8: a sealed grid admits nothing, however the claim was obtained", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - grid.seal(); - let refusal: unknown; +const PANES = [ + 'left', + '', +]; - yield* scoped(function* () { - try { - yield* claim.admit(function* () {}); - } catch (error) { - refusal = error; - } +describe("Tier TG — the terminal authority", () => { + const GRID = ["", ...PANES, "", ""].join("\n"); + + it("TA1: a handler that answers without presenting opens nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { composite: {} as ControlledCompositeOptions }); + expect(run.outcome.ok).toBe(true); + + // The same document, against a provider that answers the routed request + // itself. A return value is not evidence that a grid opened. + const shorted = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ shortCircuit: true }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, ran }; }); - // A claim kept past its grid is a claim to a terminal nobody owns. - expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + expect(shorted.outcome.ok).toBe(false); + expect(shorted.outcome.ok ? "" : shorted.outcome.error.message).toContain( + "a handler answered without delivering the request to a registered provider", + ); + // Nothing beneath the grid ran either. + expect(shorted.ran).toEqual([]); }); - it("TG8: readiness is the acknowledgement, and acknowledging twice is one event", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + it("TA2: presenting a rebuilt request authorizes nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { + composite: {}, }); - const claim = grid.claims[0]!; - const readiness = grid.readiness[0]!; + expect(run.outcome.ok).toBe(true); - // Doing work is not being ready. - expect(readiness.acknowledged).toBe(false); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - yield* scoped(function* () { - yield* readiness.reached(); + const forged = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // Same members, different object. Identity is what the authority reads. + yield* useControlledProvider({ + substitute: (request) => ({ + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ ...pane })), + }), + }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return outcome; }); - }); - it("TG8: a request whose ordinals are not its positions is refused", function* () { - let refusal: unknown; - try { - createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - } catch (error) { - refusal = error; - } - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - yield* sleep(0); + expect(forged.ok).toBe(false); + expect(forged.ok ? "" : forged.error.message).toContain("this grid request is not live"); }); -}); - -describe("Tier TG — atomic startup", () => { - it("TG9: nothing attaches until every pane has reported a spawn", function* () { - const record = log(); - // One ordered record both the panes and the provider write to, so - // "readiness came first" is read rather than assumed. The grid emits - // `running` for every pane immediately before it attaches, so asserting on - // that would prove nothing — a pane says when it actually started. - const timeline: string[] = []; - const slow = withResolvers(); - const result = yield* scoped(function* (): Operation { + it("TA3: presenting a changed request authorizes nothing", function* () { + const dir = yield* useDir(); + const changed = yield* scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, GRID); + const ran: string[] = []; + yield* useGridComponents(ran); yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - log: record, - close: immediateClose(), - // deno-lint-ignore require-yield - *onAttach() { - timeline.push("attach"); - }, + yield* useControlledProvider({ + substitute: (request) => ({ ...request, columns: request.columns + 1 }), }); - return yield* runTerminalGrid(layoutOf(2, 3), [ - readyPane(0, timeline), - { - ordinal: 1, - *run(claim) { - yield* claim.admit(function* () { - // Plenty of work before anything starts, and none of it makes the - // grid attachable. The delay is long enough that a grid which - // skipped the barrier would demonstrably attach first. - yield* sleep(25); - timeline.push("ready:1"); - claim.ready(); - yield* slow.operation; - }); - }, - }, - readyPane(2, timeline), - ]); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return outcome; }); - expect(timeline).toEqual(["ready:0", "ready:2", "ready:1", "attach"]); - expect(result.failure).toBeUndefined(); + expect(changed.ok).toBe(false); + expect(changed.ok ? "" : changed.error.message).toContain("this grid request is not live"); }); - it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { - const record = log(); - let failure: unknown; + it("TA4: an authority kept past its grid authorizes nothing", function* () { + const dir = yield* useDir(); + let kept: TerminalGridAuthority | undefined; + const run = yield* runDocument(dir, GRID, {}); + expect(run.outcome.ok).toBe(true); yield* scoped(function* () { - yield* useGridHost(record, immediateClose()); + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ capture: (authority) => (kept = authority) }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + }); + + // The execution has finished, so the request it issued is no longer live. + let refusal: unknown; + yield* scoped(function* () { + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); try { - yield* runTerminalGrid(layoutOf(2, 2), [ - pane(0), + yield* kept!.present( { - ordinal: 1, - // Runs, settles, and never reports a spawn. - *run() {}, + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], }, - ]); + composite, + ); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toContain( - "finished without starting anything interactive", - ); - // No partial grid was ever shown, and the hidden composite was destroyed. - expect(record.events).not.toContain("attach:0"); - expect(record.events).toContain("destroy:0"); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TG9: a preparation failure starts no pane at all", function* () { - const started: number[] = []; - let failure: unknown; - + it("TA5: an authority from another installation generation authorizes nothing", function* () { + let refusal: unknown; yield* scoped(function* () { - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - // deno-lint-ignore require-yield - *onPrepare() { - throw new Error("no pane endpoint could be created"); - }, + // Two installations in one scope: the second supersedes the first, so the + // first's authority names a generation the live registry no longer has. + const stale = yield* scoped(function* () { + return yield* useTerminalInstallation(); }); + yield* useTerminalInstallation(); + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); try { - yield* runTerminalGrid(layoutOf(2, 2), [ - pane(0, function* () { - started.push(0); - }), - pane(1, function* () { - started.push(1); - }), - ]); + yield* stale.present( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + composite, + ); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toBe( - "no pane endpoint could be created", - ); - expect(started).toEqual([]); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TG9: a grid refuses before preparation when no provider is installed", function* () { - const started: number[] = []; - let failure: unknown; - + it("TA6: a provider that never acknowledges installs nothing", function* () { + let refusal: unknown; yield* scoped(function* () { - yield* installControlledLauncher(); + const authority = yield* useTerminalInstallation(); + // A handler that answers the install request without delivering it to a + // registered provider. + yield* registerTerminalProvider("real", function* () {}); + yield* TerminalProviders.around({ + // deno-lint-ignore require-yield + *install() { + return undefined; + }, + }); try { - yield* runTerminalGrid(layoutOf(1, 1), [ - pane(0, function* () { - started.push(0); - }), - ]); + yield* installTerminalProvider("real", { label: "real" }, authority); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toContain( - "no terminal provider is installed", - ); - expect(started).toEqual([]); - }); -}); - -describe("Tier TG — settlement and close", () => { - it("TG10: a pane fails after attach while its siblings stay live", function* () { - const record = log(); - // The reader leaves once the grid has displayed the failure, so the sibling - // is provably still live when that happens rather than probably still live. - const failed = withResolvers(); - let siblingLiveAtFailure = false; - let siblingLive = false; - - const result = yield* scoped(function* (): Operation { - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - log: record, - close: () => failed.operation, - onUpdate(ordinal, state) { - if (ordinal === 0 && state === "failed") { - siblingLiveAtFailure = siblingLive; - failed.resolve(); - } - }, - }); - return yield* runTerminalGrid(layoutOf(2, 2), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - yield* sleep(1); - throw new Error("pane 0 stopped"); - }); - }, - }, - { - ordinal: 1, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - siblingLive = true; - try { - yield* suspend(); - } finally { - siblingLive = false; - } - }); - }, - }, - ]); - }); - - expect(record.events).toContain("attach:0"); - expect(record.events).toContain("state:0:0:failed"); - // The sibling was still running when its neighbour failed: an ordinary pane - // failure after attach is contained as that pane's status. - expect(siblingLiveAtFailure).toBe(true); - expect(result.outcomes[0]?.kind).toBe("failed"); - expect(result.outcomes[1]?.kind).toBe("closed"); - // The grid fails with the first failed pane in authored order. - expect(result.failure?.message).toBe("pane 0 stopped"); + expect(refusal).toBeInstanceOf(TerminalProviderInstallError); + expect(refusal instanceof Error ? refusal.message : "").toContain("did not install"); }); - it("TG12: close cancels a live pane as closed rather than failed", function* () { - const record = log(); - - const result = yield* scoped(function* (): Operation { - yield* useGridHost(record, immediateClose()); - return yield* runTerminalGrid(layoutOf(1, 1), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - // Still live when the reader leaves. - yield* suspend(); - }); - }, - }, - ]); - }); - - // Teardown cancellation is not a pane failure, and the grid succeeds. - expect(result.outcomes[0]?.kind).toBe("closed"); - expect(result.failure).toBeUndefined(); - expect(record.events).toContain("state:0:0:closed"); - }); - - it("TG12: the composite is destroyed exactly once, after the reader closes", function* () { - const record = log(); - - yield* scoped(function* () { - yield* useGridHost(record, immediateClose()); - yield* runTerminalGrid(layoutOf(2, 2), [pane(0), pane(1)]); + it("TA7: two claims from one grid do not contend; one pane admits one", function* () { + const grid = createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], }); - - const closed = record.events.indexOf("closed:0"); - const destroyed = record.events.indexOf("destroy:0"); - expect(closed).toBeGreaterThan(-1); - expect(destroyed).toBeGreaterThan(closed); - expect(record.events.filter((event) => event === "destroy:0")).toHaveLength(1); - }); - - it("TG13: parent cancellation tears the grid down completely", function* () { - const record = log(); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; yield* scoped(function* () { - yield* useGridHost(record, () => suspend()); - // The grid never closes on its own; the enclosing scope ending is what - // takes it down, and that has to be a complete teardown. - yield* scoped(function* () { - yield* spawnGrid(layoutOf(1, 1), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - yield* suspend(); - }); - }, - }, - ]); - yield* sleep(2); + yield* first.admit(function* () { + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.admit(function* () { + concurrent = true; + }); }); }); - expect(record.events).toContain("attach:0"); - expect(record.events).toContain("destroy:0"); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); }); -}); -describe("Tier TG — the pane seam", () => { - it("TG6: work inside a pane runs as that pane's owner", function* () { - const grid = createTerminalGridClaims({ + it("TA8: a claim from another grid, or a sealed one, admits nothing", function* () { + const request = { columns: 1, rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - let sawOrdinal: number | undefined; - let acknowledged = false; + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], + }; + const first = createTerminalGridClaims(request); + const second = createTerminalGridClaims(request); + // Sealing one grid says nothing about the other: claims belong to the grid + // that minted them, not to a request shape. + first.seal(); + let refusal: unknown; + let other = false; yield* scoped(function* () { - yield* usePaneTerminal(claim); - const seam = yield* paneTerminal(); - sawOrdinal = seam?.ordinal; - yield* seam!.interactive(function* (spawned) { - spawned(); - acknowledged = grid.readiness[0]!.acknowledged; + try { + yield* first.claims[0]!.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.claims[0]!.admit(function* () { + other = true; }); }); - expect(sawOrdinal).toBe(0); - // The seam is how anything interactive reports its spawn, so readiness - // travels with the work rather than being asserted around it. - expect(acknowledged).toBe(true); - }); - - it("TG6: outside a grid there is no pane, and nothing pretends otherwise", function* () { - const seam = yield* scoped(function* () { - return yield* paneTerminal(); - }); - expect(seam).toBeUndefined(); - }); -}); - -/** Run a grid in a spawned task, so the enclosing scope can cancel it. */ -function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Operation { - yield* spawn(function* () { - yield* runTerminalGrid(layout, work); - }); -} - -/** One document run against a controlled grid host. */ -interface DocumentRun { - outcome: Result; - /** Text the consumer received — the root document's own output. */ - output: string; - /** The grid the provider was actually asked to present. */ - requests: TerminalGridRequest[]; - /** What each pane displayed. */ - shown: Map; - /** Every mark a tripwire component recorded, in order. */ - ran: string[]; -} - -function useDir(): Operation { - return resource(function* (provide) { - const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); - yield* ensure(function* () { - yield* rm(dir, { recursive: true, force: true }); - }); - yield* provide(dir); + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + expect(other).toBe(true); }); -} -/** - * The controlled interactive child, and a tripwire. - * - * A paired pane is ready only when something in it starts and reports a spawn. - * Until the native-launch Story lands, this is what a suite writes to be that - * something — and it reaches the pane through the same seam a real launch will. - */ -function useGridComponents(ran: string[]): Operation { - return registerComponents([ - { - name: "Interactive", - origin: "tier-tg", - props: { type: "object", properties: {}, additionalProperties: false }, - *fn() { - const pane = yield* paneTerminal(); - if (pane === undefined) { - throw new Error(" is written inside a pane"); - } - yield* pane.interactive(function* (spawned) { - spawned(); - }); - return ""; - }, - }, - { - name: "Ran", - origin: "tier-tg", - props: { - type: "object", - properties: { mark: { type: "string" } }, - required: ["mark"], - additionalProperties: false, - }, - // deno-lint-ignore require-yield - *fn(props) { - ran.push(String(props.mark)); - return ""; - }, - }, - ]); -} + it("TA9: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; -/** - * Run one document against a controlled grid host. - * - * `provider: false` installs no terminal provider, which is how "a host that - * cannot open a grid refuses" is asked for. - */ -function runDocument( - dir: string, - source: string, - options: { provider?: boolean } = {}, -): Operation { - return scoped(function* () { - const path = join(dir, "doc.md"); - yield* writeTextFile(path, source); - const requests: TerminalGridRequest[] = []; - const record = log(); - const ran: string[] = []; - yield* useGridComponents(ran); - yield* installControlledLauncher(); - // The reader stays until every pane has settled. Leaving sooner is a real - // thing a reader does — TG12 covers it — but a row about what a pane - // rendered must not race the close that cancels it. - const settled = withResolvers(); - let expected = 0; - let done = 0; - if (options.provider !== false) { - yield* installControlledTerminalProvider({ - log: record, - close: () => settled.operation, - *onPrepare(asked) { - expected = asked.panes.length; - requests.push(asked); - yield* sleep(0); - }, - onUpdate(_ordinal, state) { - if (state === "succeeded" || state === "failed") { - done++; - if (done >= expected) { - settled.resolve(); - } - } - }, + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TA10: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], }); + } catch (error) { + refusal = error; } - const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); - const outcome = yield* execution; - const output = yield* forEach(function* (_chunk: string) {}, execution.output); - return { outcome, output, requests, shown: record.shown, ran }; + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + yield* sleep(0); }); -} - -/** The message a run failed with, failing the test if it completed. */ -function failureOf(run: DocumentRun): string { - if (run.outcome.ok) { - throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); - } - return run.outcome.error.message; -} +}); describe("Tier TG — a grid written in a document", () => { it("TG4: the provider is asked for exactly the authored row-major layout", function* () { @@ -687,8 +724,6 @@ describe("Tier TG — a grid written in a document", () => { ); expect(run.outcome.ok).toBe(true); - // Three panes sharing one label are three panes: the ordinal separates - // them, and the form each was written in travels with it. expect(run.requests[0]?.panes).toEqual([ { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, { ordinal: 1, title: "Agent", row: 0, column: 1, form: "self-closing" }, @@ -696,35 +731,9 @@ describe("Tier TG — a grid written in a document", () => { ]); }); - it("TG1: both pane forms run, and whitespace between panes is nothing", function* () { - const dir = yield* useDir(); - const run = yield* runDocument( - dir, - [ - "", - "", - 'Instructions.', - "", - '', - "", - "", - "", - ].join("\n"), - ); - - expect(run.outcome.ok).toBe(true); - expect(run.requests[0]).toEqual({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, - ], - }); - }); - - it("TG7: a pane's text reaches that pane, and the grid renders nothing", function* () { + it("TG7: root output is flushed before the grid, and pane text stays in its pane", function* () { const dir = yield* useDir(); + const flushed: string[] = []; const run = yield* runDocument( dir, [ @@ -738,9 +747,20 @@ describe("Tier TG — a grid written in a document", () => { "after", "", ].join("\n"), + { + composite: { + // Preparation happens after the lease and the flush, so what the + // reader had already been given is on screen before the grid covers + // it. + *onPrepare() { + flushed.push("prepared"); + }, + }, + }, ); expect(run.outcome.ok).toBe(true); + expect(flushed).toEqual(["prepared"]); // Each pane's own text went to that pane. expect(run.shown.get(0)).toContain("left text"); expect(run.shown.get(1)).toContain("right text"); @@ -793,31 +813,60 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).toContain("after {mine}"); }); - it("TG6: a pane's cannot reach a loop outside the grid", function* () { + it("TG6: a pane's cannot claim a value body outside the grid", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, [ - "", - '', + "---", + "returns:", + " type: string", + "---", "", '', - "", + '', + "", + "", + "", + "", + '', + "", + ].join("\n"), + ); + + // The pane has no enclosing value body to claim, so the written in + // it is refused where it sits rather than becoming the document's value. + expect(failureOf(run)).toContain( + "is not written in the flow of a body that declares `returns`", + ); + expect(failureOf(run)).not.toContain("from the document"); + }); + + it("TG6: a pane's checked failure settles that pane and not its sibling", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + '', + '', "", "", "", - "", "", ].join("\n"), ); - // Refused where it was written. Had the reached the loop around the - // grid it would have exited it quietly and the document would have - // succeeded; instead the pane failed with the stray- rule, which is - // what fails the grid and then the document. - expect(failureOf(run)).toContain(" must be written inside a "); - expect(failureOf(run)).toContain("cannot break the loop that invoked it"); - expect(run.ran).toEqual(["iteration"]); + // Printed inside the pane it happened in, and the sibling ran regardless. + expect(run.shown.get(0)).toContain("this pane gave up"); + expect(run.ran).toEqual(["sibling"]); + expect(run.output).not.toContain("this pane gave up"); }); it("TG9: with no provider installed, no pane body or shell runs", function* () { @@ -843,3 +892,286 @@ describe("Tier TG — a grid written in a document", () => { expect(run.shown.size).toBe(0); }); }); + +describe("Tier TG — startup, settlement and teardown", () => { + const TWO = ["", ...PANES, "", ""].join("\n"); + + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const dir = yield* useDir(); + // One ordered record the pane and the composite both write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that alone would prove nothing. + const timeline: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { + slowMarks: timeline, + composite: { + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + timeline.push("ready:shell"); + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // The slow pane started last, and the grid still waited for it. + expect(timeline[timeline.length - 1]).toBe("attach"); + expect(timeline).toContain("ready:slow"); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'nothing interactive here', + '', + "", + "", + ].join("\n"), + ); + + expect(failureOf(run)).toContain("finished without starting anything interactive"); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(run.events).not.toContain("attach:0"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: an immediate spawn-and-exit is both ready and settled", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + ["", '', "", ""].join( + "\n", + ), + { + composite: { + // Reports its spawn and returns in the same breath. + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Ready enough to attach, and settled enough to be `succeeded`. + expect(run.events).toContain("attach:0"); + expect(run.events).toContain("state:0:0:succeeded"); + // A pane that already settled keeps the status it settled to. + expect(run.events.indexOf("state:0:0:succeeded")).toBeLessThan(run.events.indexOf("attach:0")); + expect(run.events).not.toContain("state:0:0:running"); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }, + }); + + expect(failureOf(run)).toContain("no pane endpoint could be created"); + expect(run.shown.size).toBe(0); + }); + + it("TG9: an attach failure shows no partial grid and tears the composite down", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onAttach() { + throw new Error("the composite could not be shown"); + }, + }, + }); + + expect(failureOf(run)).toContain("the composite could not be shown"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: simultaneous startup failures report the first authored ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'no interactive child', + 'no interactive child either', + "", + "", + ].join("\n"), + ); + + // Both panes fail to start. The one reported is the first authored, not + // whichever settled first. + expect(failureOf(run)).toContain('pane 0 ("First")'); + expect(failureOf(run)).not.toContain('pane 1 ("Second")'); + }); + + it("TG12: close cancels a live pane as closed, then destroys and continues", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + '', + "", + ].join("\n"), + { + composite: { + // The reader leaves while the pane is still live. + close: immediateClose(), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Teardown cancellation is not a pane failure. + expect(run.events).toContain("state:0:0:closed"); + const destroyed = run.events.indexOf("destroy:0"); + expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); + // The following sibling started only after the composite came down. + expect(run.ran).toEqual(["after the grid"]); + }); + + it("TG13: an active provider failure cancels every pane and fails the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // The reader's close operation is where an active provider can fail. + // deno-lint-ignore require-yield + *close() { + throw new Error("the terminal provider lost its server"); + }, + }, + }); + + expect(failureOf(run)).toContain("the terminal provider lost its server"); + expect(run.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — durability and replay", () => { + const GRID = heldDocument(2, PANES); + + /** Every terminal-grid entry the journal holds. */ + function gridEntries(run: DocumentRun): DurableEvent[] { + return run.journal.filter( + (event) => + event.type === "yield" && String(event.description.name).startsWith("terminal_grid:"), + ); + } + + it("TG15: a completed grid replays without contacting a provider at all", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + // The grid opened and its panes ran; the document was then interrupted, so + // the root reached no outcome and a resumed run reaches the grid again. + const first = yield* runInterrupted(dir, GRID, stream); + expect(first.requests).toHaveLength(1); + + const second = yield* runInterrupted(dir, GRID, stream); + + // The region's retained result is the answer: no provider was asked for a + // grid, no pane content expanded, and nothing was displayed. + expect(second.requests).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.events).toEqual([]); + }); + + it("TG15: a completed grid replays even where no provider could open one", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + yield* runInterrupted(dir, GRID, stream); + // This host installs no provider at all. A replay that contacted one would + // refuse here; the retained result does not need one. + const second = yield* runInterrupted(dir, GRID, stream, { provider: false }); + + expect(second.requests).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.events).toEqual([]); + }); + + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + + const closes = first.journal.filter((event) => event.type === "close"); + const ids = closes.map((event) => String(event.coroutineId)).sort(); + // Two pane children beneath one grid child: `..`. + const paneIds = ids.filter((id) => id.split(".").length >= 3); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order. + expect(left!.endsWith(".0")).toBe(true); + expect(right!.endsWith(".1")).toBe(true); + expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + }); + + it("TG17: the layout is recorded before any provider is contacted", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const layout = run.journal.find( + (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), + ); + expect(layout).toBeDefined(); + // Written before the grid child that opens anything, so a comparison + // against it happens while nothing has been presented. + const layoutIndex = run.journal.indexOf(layout!); + const opened = run.journal.findIndex( + (event) => event.type === "close" && String(event.coroutineId).includes("."), + ); + expect(layoutIndex).toBeGreaterThan(-1); + if (opened > -1) { + expect(layoutIndex).toBeLessThan(opened); + } + }); + + it("TG17: the retained record holds provider-neutral facts only", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const entries = gridEntries(run); + expect(entries.length).toBeGreaterThan(0); + + const written = JSON.stringify(run.journal); + // The layout the author wrote, and nothing about whatever presented it. + expect(written).toContain('"columns":2'); + expect(written).toContain('"Left"'); + for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { + expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); + } + }); +}); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index e9a990c9..eba02abb 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -147,19 +147,20 @@ export type { NativeLaunchRequest, } from "./launcher.ts"; export { - installControlledTerminalProvider, - prepareTerminalGrid, + prepareControlledComposite, + TERMINAL_GRIDS_API, TERMINAL_PROVIDER_UNAVAILABLE, - TerminalProvider, + TerminalGrids, + terminalProviderLog, TerminalProviderUnavailableError, } from "./terminal.ts"; export type { - ControlledTerminalProviderOptions, + ControlledCompositeOptions, TerminalComposite, + TerminalGridApi, TerminalGridRequest, TerminalPaneRequest, TerminalPaneState, - TerminalProviderHandler, TerminalProviderLog, TerminalShellOutcome, } from "./terminal.ts"; diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index cc34203c..12827a30 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -1,5 +1,6 @@ /** - * The terminal provider — how a host presents one grid of interactive panes. + * The terminal grid boundary — how a host presents one grid of interactive + * panes, and what composing middleware around it may do. * * This is not the native launcher. A launch hands **one** child the whole * foreground terminal and waits for it; a grid divides that terminal into @@ -9,26 +10,18 @@ * appears in the document: `` asks for panes and their authored * layout, and the host chooses what presents them. * - * A grid is prepared before it is shown, which is what makes opening one atomic: - * - * 1. `prepare()` builds the whole composite while it is still hidden — every - * pane endpoint and its supervision — and presents nothing. A host that - * cannot open a grid refuses here, before any pane has started work. - * 2. Core starts the authored panes concurrently and waits for every one of - * them to be ready. - * 3. `attach()` shows the composite, once, after that barrier. A failure before - * it discards the hidden composite instead of leaving a partial grid on the - * reader's screen. - * 4. `destroy()` takes it down again and gives the root terminal back. + * **This surface is routing, and only routing.** Middleware here may observe, + * narrow, refuse, wrap or delegate one grid request. What it cannot do is open + * a grid: `open()` answers `unknown`, and the answer is thrown away. The + * capability that takes the terminal leases, mints pane claims and settles a + * grid is a non-contextual authority delivered straight to the registered + * provider, and a handler that answers without delegating has therefore + * presented nothing and settled nothing. * - * There is no host default. `xmd run` installs the production provider; a test - * or embedding host installs a controlled one that needs no terminal. Until one - * is installed every operation refuses, which is what keeps writing, inspecting - * and validating a document free of all of this. - * - * **Presentation never decides an outcome.** `update()` receives the pane states - * core has already settled on, so a provider draws them and answers for none of - * them. Nothing a handler returns can make a pane succeed, fail, or be ready. + * A grid is prepared before it is shown, which is what makes opening one atomic: + * the provider builds the whole composite while it is hidden, core starts the + * authored panes and waits for every one of them to report a spawn, and only + * then is anything attached. */ import { type Api, createApi } from "@effectionx/context-api"; @@ -57,6 +50,11 @@ export interface TerminalPaneRequest { * Provider-neutral throughout: it names no terminal, multiplexer, socket, * process, window or pane identifier, and carries no command, argv or * environment. It is what the author wrote, resolved. + * + * It is also **one-use and identity-bearing**. Core mints exactly one of these + * per grid expansion and the authority compares the object it is presented with + * against the one it issued, so a request that was copied, rebuilt with the same + * members, kept from an earlier grid, or already used authorizes nothing. */ export interface TerminalGridRequest { readonly columns: number; @@ -83,7 +81,7 @@ export interface TerminalShellOutcome { /** * One prepared, still-hidden grid. * - * Everything here belongs to the one `prepare()` that produced it. A composite + * Everything here belongs to the one preparation that produced it. A composite * is never reused across expansions, and a provider that hands the same one * back twice has handed back a grid the second expansion did not ask for. */ @@ -118,8 +116,6 @@ export interface TerminalComposite { * ended. * * Which shell that is comes from live host policy, never from the document. - * The bytes it exchanges with the reader belong to the pane: nothing captures - * or journals them. * * `spawned` is the pane's readiness latch, and calling it is the only thing * that makes this pane ready. Call it from the runtime's successful @@ -138,16 +134,14 @@ export interface TerminalComposite { /** * Take the composite down and give the root terminal back. * - * Called exactly once for every composite `prepare()` returned, including one + * Called exactly once for every composite that was prepared, including one * discarded before it ever attached. */ destroy(): Operation; } -export interface TerminalProviderHandler { - /** Build the whole hidden composite for `request`, presenting nothing. */ - prepare(request: TerminalGridRequest): Operation; -} +/** The stable name every loaded copy composes through. */ +export const TERMINAL_GRIDS_API = "TerminalGrids"; export const TERMINAL_PROVIDER_UNAVAILABLE = "no terminal provider is installed — this host does not present a grid of " + @@ -161,29 +155,30 @@ export class TerminalProviderUnavailableError extends Error { } } +export interface TerminalGridApi { + /** + * Route one grid request to whatever presents it. + * + * Answers `unknown`, and the answer is discarded: a return value is not + * evidence that a grid was opened, and core reads what the authority settled + * instead of what a handler said. + */ + open(request: TerminalGridRequest): Operation; +} + /** - * The stable contextual boundary a grid request travels. + * The public routing surface. Its own default always refuses. * - * Middleware composed here may observe, narrow, refuse, wrap or delegate a - * request — everything composition needs. What it cannot do is authorize one: - * the terminal authority that mints pane claims and takes terminal ownership is - * delivered directly to the installed provider and reachable from nowhere else, - * so a handler that answers without delegating has presented nothing. + * Reaching this default means no registered provider consumed the request, so + * nothing was presented — which is the honest answer for a host that installs + * no provider at all. */ -export const TerminalProvider: Api = createApi( - "runtime.terminalProvider", - { - // deno-lint-ignore require-yield - *prepare(_request: TerminalGridRequest): Operation { - throw new TerminalProviderUnavailableError(); - }, +export const TerminalGrids: Api = createApi(TERMINAL_GRIDS_API, { + // deno-lint-ignore require-yield + *open(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); }, -); - -/** Build the hidden composite for one grid expansion. */ -export function prepareTerminalGrid(request: TerminalGridRequest): Operation { - return TerminalProvider.operations.prepare(request); -} +}); /** * Everything one controlled composite did, in the order it did it. @@ -203,17 +198,22 @@ export interface TerminalProviderLog { readonly shown: Map; } +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { events: [], shown: new Map() }; +} + /** - * What a controlled provider does instead of opening a terminal. + * What a controlled composite does instead of opening a terminal. * * Each hook is a place a suite makes something happen or go wrong: `onPrepare` - * can refuse before a composite exists, `onAttach` can fail the barrier, `shell` - * decides what a self-closing pane's shell did and how long it took, and - * `close` is the operation the grid waits on, so a suite controls exactly when - * the reader leaves. + * refuses before a composite exists, `onAttach` fails the barrier, `shell` + * decides what a self-closing pane's shell did and whether it started at all, + * and `close` is the operation the grid waits on, so a suite controls exactly + * when the reader leaves. */ -export interface ControlledTerminalProviderOptions { - /** Appended to as the provider works, so ordering is read rather than timed. */ +export interface ControlledCompositeOptions { + /** Appended to as the composite works, so ordering is read rather than timed. */ readonly log?: TerminalProviderLog; onPrepare?: (request: TerminalGridRequest) => Operation; onAttach?: () => Operation; @@ -222,93 +222,78 @@ export interface ControlledTerminalProviderOptions { * Called as each pane state is displayed. * * A suite watches it to react to something the grid decided — a pane that - * failed, a pane that became runnable — instead of waiting a while and hoping. + * failed, a pane that became runnable — instead of waiting and hoping. */ onUpdate?: (ordinal: number, state: TerminalPaneState) => void; - /** - * What a pane's shell did. - * - * It receives the readiness latch, so a suite decides whether this shell - * reports a spawn at all — which is how "never started" is told apart from - * "started and exited immediately". - */ shell?: (ordinal: number, spawned: () => void) => Operation; close?: () => Operation; } /** - * Install a provider that presents nothing and records everything. + * Prepare one composite that presents nothing and records everything. * - * It answers the whole contract — prepare, attach, update, shell, close, + * It answers the whole contract — attach, update, display, shell, close, * destroy — so a suite exercises core's lifecycle without a terminal, a * multiplexer, or a process anywhere in it. */ -export function* installControlledTerminalProvider( - options: ControlledTerminalProviderOptions = {}, -): Operation { - const log = options.log ?? { events: [], shown: new Map() }; - const shown = log.shown; - let prepared = 0; - - yield* TerminalProvider.around( - { - *prepare([request]): Operation { - if (options.onPrepare) { - yield* options.onPrepare(request); +export function prepareControlledComposite( + request: TerminalGridRequest, + options: ControlledCompositeOptions = {}, + generation = 0, +): Operation { + return (function* (): Operation { + const log = options.log ?? terminalProviderLog(); + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + let destroyed = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken down + // more times than it was built, which is exactly the ordering claim a + // suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); } - const generation = prepared++; - log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); - let destroyed = false; - return { - *attach() { - if (options.onAttach) { - yield* options.onAttach(); - } - log.events.push(`attach:${generation}`); - }, - // deno-lint-ignore require-yield - *update(ordinal, state) { - log.events.push(`state:${generation}:${ordinal}:${state}`); - options.onUpdate?.(ordinal, state); - }, - // deno-lint-ignore require-yield - *display(ordinal, text) { - const pane = shown.get(ordinal) ?? ""; - shown.set(ordinal, pane + text); - }, - *shell(ordinal, spawned) { - log.events.push(`shell:${generation}:${ordinal}`); - if (options.shell) { - return yield* options.shell(ordinal, spawned); - } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn - // would hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; - }, - *closed() { - if (options.close) { - yield* options.close(); - } - log.events.push(`closed:${generation}`); - }, - *destroy() { - // Destroying twice would make the record say a composite was taken - // down more times than it was built, which is exactly the ordering - // claim a suite reads this log for. - if (destroyed) { - throw new Error(`controlled composite ${generation} was destroyed twice`); - } - destroyed = true; - if (options.onDestroy) { - yield* options.onDestroy(); - } - log.events.push(`destroy:${generation}`); - }, - }; + log.events.push(`destroy:${generation}`); }, - }, - { at: "min" }, - ); + }; + })(); } diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 9e01204a..3c88c9d8 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -1,17 +1,17 @@ /** - * Tier TG — the terminal provider boundary (architecture.md §Terminal - * authority, spec §6.21). + * Tier TG — the terminal grid routing surface and the composite contract + * (architecture.md §Terminal authority, spec §6.21). * - * What a host installs to present a grid, and what composing middleware around - * it may and may not do. Nothing here opens a terminal, looks for a - * multiplexer, or starts a process: the whole point of the boundary is that the - * language does not depend on any of that, so a suite that needed one would be - * testing the wrong thing. + * Two things live here, and neither is an authority. The routing surface is + * where middleware composes around a grid request, and its whole contract is + * that it decides nothing: `open()` answers `unknown`, and core throws the + * answer away. The composite is what a provider prepares, and its contract is + * ordering — prepared hidden, attached once, destroyed exactly once. * - * The controlled provider records what it was asked to do, in order. Ordering - * claims are read off that record rather than inferred from timing, because a - * grid that attached too early and a grid that attached on time can take the - * same wall clock. + * Who may present a grid, and what presenting one authorizes, is core's, and is + * proved in `packages/core/tests/terminal-grid.test.ts`. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -20,13 +20,13 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { - installControlledTerminalProvider, - prepareTerminalGrid, + prepareControlledComposite, TERMINAL_PROVIDER_UNAVAILABLE, - TerminalProvider, + TerminalGrids, + terminalProviderLog, TerminalProviderUnavailableError, } from "../terminal.ts"; -import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog } from "../terminal.ts"; +import type { TerminalGridRequest } from "../terminal.ts"; /** A two-by-one grid: the smallest request that still has two ordinals. */ function request(overrides: Partial = {}): TerminalGridRequest { @@ -41,16 +41,12 @@ function request(overrides: Partial = {}): TerminalGridRequ }; } -function log(): TerminalProviderLog { - return { events: [], shown: new Map() }; -} - -describe("Tier TG — the provider boundary", () => { +describe("Tier TG — the routing surface", () => { it("TP1: refuses when no host has installed a provider", function* () { let refusal: unknown; yield* scoped(function* () { try { - yield* prepareTerminalGrid(request()); + yield* TerminalGrids.operations.open(request()); } catch (error) { refusal = error; } @@ -60,27 +56,118 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); }); - it("TP2: an installed provider prepares without presenting anything", function* () { - const record = log(); + it("TP2: middleware observes a delegated request without changing it", function* () { + const seen: TerminalGridRequest[] = []; + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* TerminalGrids.operations.open(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the same object reached the far end. + expect(reached[0]).toBe(seen[0]); + }); + + it("TP2: middleware narrows a request before anything below sees it", function* () { + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); + }, + }); + yield* TerminalGrids.operations.open(request()); + }); + + expect(reached[0]?.columns).toBe(1); + expect(reached[0]?.rows).toBe(2); + }); + + it("TP2: middleware refuses a request, and nothing below is reached", function* () { + const reached: TerminalGridRequest[] = []; + let refusal: unknown; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + // deno-lint-ignore require-yield + *open(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* TerminalGrids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + expect(reached).toEqual([]); + }); +}); + +describe("Tier TG — the composite contract", () => { + it("TP3: a prepared composite presents nothing until it is attached", function* () { + const log = terminalProviderLog(); const events = yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* prepareTerminalGrid(request()); - return [...record.events]; + yield* prepareControlledComposite(request(), { log }); + return [...log.events]; }); - // Preparation happened; nothing was shown. A composite the reader can see - // before every pane is ready is the one thing atomic startup forbids. + // A composite the reader can see before every pane is ready is the one + // thing atomic startup forbids. expect(events).toEqual(["prepare:0:2x1"]); expect(events.some((event) => event.startsWith("attach:"))).toBe(false); }); - it("TP2: attach, update, shell and destroy are recorded in the order they happen", function* () { - const record = log(); + it("TP3: attach, update, display, shell and destroy record in order", function* () { + const log = terminalProviderLog(); const spawns: number[] = []; yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - const composite = yield* prepareTerminalGrid(request()); + const composite = yield* prepareControlledComposite(request(), { log }); yield* composite.update(0, "starting"); + yield* composite.display(0, "pane text"); yield* composite.update(0, "running"); yield* composite.shell(1, () => spawns.push(1)); yield* composite.attach(); @@ -89,7 +176,7 @@ describe("Tier TG — the provider boundary", () => { yield* composite.destroy(); }); - expect(record.events).toEqual([ + expect(log.events).toEqual([ "prepare:0:2x1", "state:0:0:starting", "state:0:0:running", @@ -99,22 +186,22 @@ describe("Tier TG — the provider boundary", () => { "closed:0", "destroy:0", ]); + expect(log.shown.get(0)).toBe("pane text"); // The default shell starts, and says so through the latch it was handed: // readiness is reported by the shell rather than assumed by the grid. expect(spawns).toEqual([1]); }); - it("TP5: a shell that never starts never reports a spawn", function* () { + it("TP4: a shell that never starts never reports a spawn", function* () { const spawns: number[] = []; const outcome = yield* scoped(function* () { - yield* installControlledTerminalProvider({ + const composite = yield* prepareControlledComposite(request(), { // deno-lint-ignore require-yield - *shell(_ordinal, _spawned) { + *shell() { // No spawn event: nothing started, so nothing is acknowledged. return { exitCode: 127 }; }, }); - const composite = yield* prepareTerminalGrid(request()); return yield* composite.shell(1, () => spawns.push(1)); }); @@ -122,107 +209,18 @@ describe("Tier TG — the provider boundary", () => { expect(spawns).toEqual([]); }); - it("TP3: middleware observes a delegated request without changing it", function* () { - const record = log(); - const seen: TerminalGridRequest[] = []; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - seen.push(asked); - return yield* next(asked); - }, - }); - yield* prepareTerminalGrid(request({ columns: 3, rows: 2 })); - }); - - expect(seen).toHaveLength(1); - expect(seen[0]?.columns).toBe(3); - // Observation is not interference: the provider still saw the same grid. - expect(record.events).toEqual(["prepare:0:3x2"]); - }); - - it("TP3: middleware refuses a request, and no composite is ever built", function* () { - const record = log(); - let refusal: unknown; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - // deno-lint-ignore require-yield - *prepare(): Operation { - throw new Error("this host does not open terminal grids"); - }, - }); - try { - yield* prepareTerminalGrid(request()); - } catch (error) { - refusal = error; - } - }); - - expect(refusal instanceof Error ? refusal.message : "").toBe( - "this host does not open terminal grids", - ); - // Refusing means refusing: the provider below was never reached, so there - // is no hidden composite left needing teardown. - expect(record.events).toEqual([]); - }); - - it("TP3: middleware narrows a request before the provider sees it", function* () { - const record = log(); - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); - }, - }); - yield* prepareTerminalGrid(request()); - }); - - expect(record.events).toEqual(["prepare:0:1x2"]); - }); - - it("TP4: middleware wraps the composite it delegated for", function* () { - const record = log(); - const wrapped: string[] = []; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - const composite = yield* next(asked); - return { - ...composite, - *attach() { - wrapped.push("before"); - yield* composite.attach(); - wrapped.push("after"); - }, - }; - }, - }); - const composite = yield* prepareTerminalGrid(request()); - yield* composite.attach(); - yield* composite.destroy(); - }); - - expect(wrapped).toEqual(["before", "after"]); - expect(record.events).toEqual(["prepare:0:2x1", "attach:0", "destroy:0"]); - }); - - it("TP5: a preparation failure leaves nothing to tear down", function* () { - const record = log(); + it("TP4: a preparation failure leaves no composite to tear down", function* () { + const log = terminalProviderLog(); let refusal: unknown; yield* scoped(function* () { - yield* installControlledTerminalProvider({ - log: record, - // deno-lint-ignore require-yield - *onPrepare() { - throw new Error("no pane endpoint could be created"); - }, - }); try { - yield* prepareTerminalGrid(request()); + yield* prepareControlledComposite(request(), { + log, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); } catch (error) { refusal = error; } @@ -231,16 +229,15 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toBe( "no pane endpoint could be created", ); - // The failure happened before the composite existed, so the record shows - // no composite was built and none is owed a destroy. - expect(record.events).toEqual([]); + // The failure happened before the composite existed, so nothing is owed a + // destroy. + expect(log.events).toEqual([]); }); - it("TP5: a composite refuses to be destroyed twice", function* () { + it("TP4: a composite refuses to be destroyed twice", function* () { let refusal: unknown; yield* scoped(function* () { - yield* installControlledTerminalProvider(); - const composite = yield* prepareTerminalGrid(request()); + const composite = yield* prepareControlledComposite(request()); yield* composite.destroy(); try { yield* composite.destroy(); @@ -254,18 +251,17 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); }); - it("TP6: each preparation is its own composite", function* () { - const record = log(); + it("TP5: each preparation is its own composite", function* () { + const log = terminalProviderLog(); yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - const first = yield* prepareTerminalGrid(request()); - const second = yield* prepareTerminalGrid(request()); + const first = yield* prepareControlledComposite(request(), { log }, 0); + const second = yield* prepareControlledComposite(request(), { log }, 1); yield* first.destroy(); yield* second.destroy(); }); // Two expansions are two grids. A provider that handed the same composite // back would have presented the second expansion's grid as the first's. - expect(record.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + expect(log.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); }); }); From 11ac496f5392fc0396ebc6d64ed332b0790db94d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:45:27 -0400 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=90=9B=20Repair=20durableSpawn,=20a?= =?UTF-8?q?nd=20put=20the=20grid=20on=20it=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `durableSpawn` returned a task spawned inside the `ephemeral` effect's own scope, and that scope closed as the effect resolved — so every `yield* task` threw `halted`. It had no call sites and no tests. It now starts the child in the routine's own scope, so the task outlives the call and can be awaited or halted by whoever asked for it. A retained `Close(cancelled)` meant one thing to the code and two things in practice. Under `durableRace` and `durableAll` it is a race loser or a fail-fast sibling, and the same combinator cancels it again — those keep DEC-024 exactly. Under `durableSpawn` nobody cancels it a second time, so suspending hung the resumed run forever. `runDurableChild` now takes an explicit `CancelledChildPolicy`, fixed at each combinator's call site and never chosen by a caller. Resuming uses a new internal `ReplayIndex.reopen()`, which forgets one coroutine's retained Close while keeping its yields — so the child continues its own history rather than restarting, and the divergence guard stops reading the remaining effects as a coroutine continuing past its own close. Neither it nor `disableReplay` is exported. DEC-039 records the policy and marks DEC-024's invariant as superseded in part: it assumed every cancelled child belongs to race or all. The grid uses the repaired primitive: the whole grid is one durable child, each pane is its own durable child allocated in authored ordinal order, and each pane task is observed outside its child — so a replayed pane's retained outcome publishes its status and satisfies the readiness barrier without entering a body, a shell, or a launcher. Evidence: 9 rows in `packages/durable-streams/tests/durable-spawn.test.ts` (lifetime, completed replay, interrupted resume, retained-history continuation, and both combinators keeping their own policy); 30 rows in `packages/core/tests/terminal-grid.test.ts`. durable-streams 32, core 349, runtime 15. --- packages/core/src/terminal/grid.ts | 98 ++--- packages/core/tests/terminal-grid.test.ts | 148 ++++--- packages/durable-streams/combinators.ts | 126 ++++-- packages/durable-streams/replay-index.ts | 17 + packages/durable-streams/specs/DECISIONS.md | 39 ++ .../tests/durable-spawn.test.ts | 366 ++++++++++++++++++ 6 files changed, 654 insertions(+), 140 deletions(-) create mode 100644 packages/durable-streams/tests/durable-spawn.test.ts diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 3892a43c..fa2a09f0 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -22,9 +22,9 @@ * desynchronise the journal on the next run. */ -import { all, ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation } from "effection"; -import { DurableContext, durableAll, ephemeral } from "@executablemd/durable-streams"; +import { ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { DurableContext, durableSpawn, ephemeral } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -226,32 +226,45 @@ function presentGrid( const startupFailed = withResolvers(); let attached = false; - // Every pane's work, in authored order. The children are allocated in this - // order too, so a pane's durable identity follows its ordinal rather than - // the order the runtime happened to schedule it in. - const paneWorkflows = work.map((pane, index) => { + for (const pane of work) { + yield* composite.update(pane.ordinal, "starting"); + } + + // One durable child per pane, allocated here in authored order, so a pane's + // identity follows its ordinal rather than the order the runtime happened + // to schedule it in. Each task is observed *outside* its child: a replayed + // completed pane returns its retained outcome without entering a body, a + // shell, or a launcher, and that outcome is what publishes its status and + // satisfies the readiness barrier. + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; - return function* (): Operation { - const outcome = yield* runPane(pane, claim, composite, readiness, request, index); + panes.push( + yield* paneChild(function* (): Operation { + return yield* runPane(pane, claim, composite, readiness, request, index); + }), + ); + } + + // Observing each task is what turns a pane's outcome — replayed or live — + // into a published status and a satisfied readiness latch. + for (const [index, task] of panes.entries()) { + yield* spawn(function* () { + const outcome = yield* task; outcomes[index] = outcome; - yield* composite.update(pane.ordinal, outcome.status); + // A pane restored from its retained outcome counts as started: it did + // start, on the run that recorded it. + grid.claims[index]!.ready(); + yield* composite.update(work[index]!.ordinal, outcome.status); if (outcome.status === "failed" && !attached) { // Before the barrier a pane failure is the whole grid's: nothing has // been shown, so the grid fails closed rather than attaching what is // left. After it, the failure is this pane's status alone. startupFailed.reject(new Error(outcome.reason)); } - return outcome; - }; - }); - - for (const pane of work) { - yield* composite.update(pane.ordinal, "starting"); + }); } - // Spawned as one task so the coordinator below can reach the readiness - // barrier, attach, and wait for the reader while the panes are still live. - const panes = yield* spawn(() => paneChildren(paneWorkflows)); // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane @@ -290,8 +303,8 @@ function presentGrid( yield* composite.update(pane.ordinal, "closed"); outcomes[index] = { status: "closed", reason: "" }; } + yield* panes[index]!.halt(); } - yield* panes.halt(); const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); const reason = firstReason(settled); @@ -339,36 +352,33 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st } /** - * Run every pane as a durable child of the grid, in authored order. + * Run one pane as a durable child of the grid. * * A pane's identity is derived from the grid's coroutine and its authored * ordinal, never from a title, a schedule, or a provider identifier — so a * resumed run restores a completed pane as its outcome without re-running it, * and continues an incomplete one from its own history. * - * `durableAll` rather than `durableSpawn`: the latter returns a task spawned - * inside the ephemeral effect's own scope, and that scope closes as the effect - * resolves, so awaiting the task throws `halted`. It has no call sites or tests - * upstream; `durableAll` is the primitive that is exercised. + * `durableSpawn` rather than a combinator, because the grid owns the panes + * itself: it has to reach the readiness barrier and attach while they are still + * live, and cancel them one at a time when the reader leaves. A retained + * cancelled pane resumes its remaining work rather than suspending, which is + * `durableSpawn`'s policy for a spawned region. * - * Without a journal there are no children to derive, and the work simply runs. + * Without a journal there is no child to derive, and the work simply runs. */ -function paneChildren( - workflows: readonly (() => Operation)[], -): Operation { - return (function* (): Operation { +function paneChild( + body: () => Operation, +): Operation> { + return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - return yield* all(workflows.map((workflow) => workflow())); + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); } - return yield* durableAll( - workflows.map( - (workflow) => - function* (): Workflow { - return yield* ephemeral(workflow()); - }, - ), - ); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); + }); })(); } @@ -386,11 +396,9 @@ export function durableGrid(live: () => Operation): Operation([ - function* (): Workflow { - return yield* ephemeral(live()); - }, - ]); - return retained!; + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(live()); + }); + return yield* task; })(); } diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index ad44b1e7..1cc5e340 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -89,6 +89,15 @@ interface DocumentRun { journal: DurableEvent[]; } +/** + * The mark a document records once it is past the grid. + * + * It fires whether the grid ran or replayed, so a harness can stop the run at + * the same point either way — and a replay that hangs never reaches it, which + * is a failure rather than something a deadline would quietly pass. + */ +const PAST_THE_GRID = "past the grid"; + function useDir(): Operation { return resource(function* (provide) { const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); @@ -100,7 +109,11 @@ function useDir(): Operation { } /** The controlled interactive child, and a tripwire. */ -function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { +function useGridComponents( + ran: string[], + slowMarks: string[] = [], + onMark: (mark: string) => void = () => {}, +): Operation { return registerComponents([ { name: "Interactive", @@ -129,6 +142,7 @@ function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; const opened = withResolvers(); - yield* useGridComponents(ran); + // Two signals, neither a deadline: the grid opened on a live run, or the + // document reached the sibling after it — which is what a replayed grid + // does. A replay that hangs reaches neither and hangs the row, rather than + // passing on a timer. + yield* useGridComponents(ran, [], (mark) => { + if (mark === PAST_THE_GRID) { + opened.resolve(); + } + }); yield* installControlledLauncher(); if (options.provider !== false) { yield* useControlledProvider({ log, - close: () => suspend(), + close: options.close === true ? immediateClose() : () => suspend(), + ...(options.shell === undefined ? {} : { shell: options.shell }), *onPrepare(asked) { requests.push(asked); yield* sleep(0); @@ -365,7 +393,7 @@ function runInterrupted( // The grid is open and its panes have settled, so the journal now holds the // pane children's own entries. A resumed run never attaches at all — the // region short-circuits — so this is bounded rather than waited on. - yield* race([opened.operation, sleep(120)]); + yield* opened.operation; yield* sleep(5); yield* task.halt(); return { @@ -974,12 +1002,14 @@ describe("Tier TG — startup, settlement and teardown", () => { ); expect(run.outcome.ok).toBe(true); - // Ready enough to attach, and settled enough to be `succeeded`. + // Ready at the spawn event, so the grid attached; settled straight after, + // so its final status is its own. Both, from one child that started and + // stopped in the same breath. expect(run.events).toContain("attach:0"); expect(run.events).toContain("state:0:0:succeeded"); - // A pane that already settled keeps the status it settled to. - expect(run.events.indexOf("state:0:0:succeeded")).toBeLessThan(run.events.indexOf("attach:0")); - expect(run.events).not.toContain("state:0:0:running"); + expect(run.events.indexOf("state:0:0:succeeded")).toBeGreaterThan( + run.events.indexOf("attach:0"), + ); }); it("TG9: a preparation failure starts no pane at all", function* () { @@ -1080,61 +1110,65 @@ describe("Tier TG — startup, settlement and teardown", () => { describe("Tier TG — durability and replay", () => { const GRID = heldDocument(2, PANES); - /** Every terminal-grid entry the journal holds. */ - function gridEntries(run: DocumentRun): DurableEvent[] { - return run.journal.filter( - (event) => - event.type === "yield" && String(event.description.name).startsWith("terminal_grid:"), - ); - } + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + + const closes = first.journal.filter((event) => event.type === "close"); + const paneIds = closes + .map((event) => String(event.coroutineId)) + .filter((id) => id.split(".").length >= 3) + .sort(); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order, and both beneath one grid child. + expect(left!.endsWith(".0")).toBe(true); + expect(right!.endsWith(".1")).toBe(true); + expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + }); - it("TG15: a completed grid replays without contacting a provider at all", function* () { + it("TG16: an interrupted grid rebuilds a fresh composite rather than hanging", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - // The grid opened and its panes ran; the document was then interrupted, so - // the root reached no outcome and a resumed run reaches the grid again. + // Interrupted while the grid is open, so its child records a cancelled + // close. Under the repaired spawn policy the resumed run continues that + // region instead of suspending on it forever. const first = yield* runInterrupted(dir, GRID, stream); expect(first.requests).toHaveLength(1); const second = yield* runInterrupted(dir, GRID, stream); - // The region's retained result is the answer: no provider was asked for a - // grid, no pane content expanded, and nothing was displayed. - expect(second.requests).toEqual([]); - expect(second.shown.size).toBe(0); - expect(second.events).toEqual([]); + // A fresh composite, built by this run. + expect(second.requests).toHaveLength(1); + expect(second.events).toContain("prepare:0:2x1"); }); - it("TG15: a completed grid replays even where no provider could open one", function* () { + it("TG16: a completed pane is restored; an incomplete shell starts again", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + const holdingShell: ControlledCompositeOptions["shell"] = function* (_ordinal, spawned) { + spawned(); + yield* suspend(); + return { exitCode: 0 }; + }; - yield* runInterrupted(dir, GRID, stream); - // This host installs no provider at all. A replay that contacted one would - // refuse here; the retained result does not need one. - const second = yield* runInterrupted(dir, GRID, stream, { provider: false }); + const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + expect(first.ran).toContain("left ran"); - expect(second.requests).toEqual([]); - expect(second.shown.size).toBe(0); - expect(second.events).toEqual([]); - }); - - it("TG16: each pane is a durable child of the grid, in authored order", function* () { - const dir = yield* useDir(); - const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, GRID, stream); + const second = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); - const closes = first.journal.filter((event) => event.type === "close"); - const ids = closes.map((event) => String(event.coroutineId)).sort(); - // Two pane children beneath one grid child: `..`. - const paneIds = ids.filter((id) => id.split(".").length >= 3); - expect(paneIds).toHaveLength(2); - const [left, right] = paneIds; - // Authored order, not scheduling order. - expect(left!.endsWith(".0")).toBe(true); - expect(right!.endsWith(".1")).toBe(true); - expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + // The completed pane came back from its retained outcome: its body did not + // run again. + expect(second.ran).not.toContain("left ran"); + // The incomplete shell starts again under current host policy, claiming no + // continuity with the terminal history it had before. + expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); }); it("TG17: the layout is recorded before any provider is contacted", function* () { @@ -1142,32 +1176,24 @@ describe("Tier TG — durability and replay", () => { const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const layout = run.journal.find( + const layoutIndex = run.journal.findIndex( (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), ); - expect(layout).toBeDefined(); - // Written before the grid child that opens anything, so a comparison - // against it happens while nothing has been presented. - const layoutIndex = run.journal.indexOf(layout!); - const opened = run.journal.findIndex( + const firstChildClose = run.journal.findIndex( (event) => event.type === "close" && String(event.coroutineId).includes("."), ); expect(layoutIndex).toBeGreaterThan(-1); - if (opened > -1) { - expect(layoutIndex).toBeLessThan(opened); + if (firstChildClose > -1) { + expect(layoutIndex).toBeLessThan(firstChildClose); } }); - it("TG17: the retained record holds provider-neutral facts only", function* () { + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const entries = gridEntries(run); - expect(entries.length).toBeGreaterThan(0); - const written = JSON.stringify(run.journal); - // The layout the author wrote, and nothing about whatever presented it. expect(written).toContain('"columns":2'); expect(written).toContain('"Left"'); for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 179f2cc8..dcd20359 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -36,7 +36,7 @@ import { import { ephemeral } from "./ephemeral.ts"; import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.ts"; import { deserializeError, serializeError } from "./serialize.ts"; -import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; +import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; /** * Run a child workflow within a spawned scope, setting up its own @@ -53,13 +53,39 @@ import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; * IMPORTANT: This must be called inside a spawn() so it gets its own scope. * The caller is responsible for spawn(). */ +/** + * What a spawned region does with a retained `Close(cancelled)`. + * + * The two answers are not preferences; they follow from who is going to cancel + * the child on this run. + * + * - `"combinator-cancels"` — `durableRace` and `durableAll`. A retained + * cancelled child is a race loser or a fail-fast sibling, and the same + * combinator will cancel it again, so the child reproduces the original run + * by suspending until it does. + * - `"resume"` — `durableSpawn`. The caller owns the task, and a retained + * cancelled child under a parent that never completed means the *run* was + * interrupted, not that a combinator chose against this child. Nothing will + * cancel it a second time, so suspending would hang the resumed run forever. + * It continues its own retained history instead and finishes the work it had + * left, writing the Close its second life actually reached. + * + * The policy belongs to the combinator, not to its caller: it is fixed at each + * call site below and there is no way to ask for another one. + */ +type CancelledChildPolicy = "combinator-cancels" | "resume"; + function* runDurableChild( childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, + cancelledPolicy: CancelledChildPolicy = "combinator-cancels", ): Operation { const { replayIndex, stream } = parentCtx; replayIndex.claim(childId); + // Set when this run continued a retained cancelled child, so its teardown + // writes the Close it reached rather than leaving the stale cancelled one. + let resumedFromCancelled = false; // Short-circuit: child already completed in a previous run. // NOTE: Replay guard validation is not bypassed here — the check phase @@ -73,22 +99,22 @@ function* runDurableChild( return closeEvent.result.value as T; } else if (closeEvent.result.status === "err") { throw deserializeError(closeEvent.result.error); - } else { - // cancelled — this child was cancelled in a previous run (e.g., - // a race loser). Instead of throwing, we suspend forever. The - // parent combinator (race/all) will cancel this child as part of - // normal structured concurrency teardown, just like the original - // run. The Close(cancelled) event already exists in the journal, - // so we skip re-emitting it (the ensure teardown checks for this). - // - // INVARIANT: This branch is only reachable when a parent combinator - // (durableRace or durableAll with a failed sibling) will cancel this - // child. Close(cancelled) in the journal means the child was - // previously cancelled by structured concurrency, so on replay the - // same combinator will cancel it again. This cannot deadlock. + } else if (cancelledPolicy === "combinator-cancels") { + // A race loser, or a sibling `all` cancelled when another failed. The + // same combinator cancels it again on this run, so reproducing the + // original execution means blocking until it does — in the live run this + // child never threw, it simply stopped. The Close(cancelled) event + // already exists, so the teardown below skips re-emitting it. yield* suspend(); // unreachable — suspend blocks until cancelled return undefined as T; + } else { + // A spawned region whose run was interrupted. Nobody is going to cancel + // this child a second time, so suspending would hang the resumed run. + // Forget the retained close — its yields stay replayable, so the child + // continues its own history — and fall through to run the rest. + resumedFromCancelled = true; + replayIndex.reopen(childId); } } @@ -137,8 +163,10 @@ function* runDurableChild( } // Don't re-emit a Close event if one already exists in the journal - // (e.g., a cancelled child being replayed via suspend()). - if (!replayIndex.hasClose(childId)) { + // (e.g., a cancelled child being replayed via suspend()). A child that + // resumed from a retained cancelled Close is the exception: the record it + // reached this time is the one that describes the work that actually ran. + if (resumedFromCancelled || !replayIndex.hasClose(childId)) { yield* appendDurableEvent(childCtx, closeEvent); } }); @@ -209,33 +237,63 @@ function* runDurableChild( } /** - * Spawn a durable child workflow. + * Spawn a durable child workflow, and hand its task back to the caller. * - * Assigns a deterministic coroutine ID (parentId.N), sets up DurableContext - * on the child scope, and ensures Close events are emitted. + * Assigns a deterministic coroutine ID (`parentId.N`) in call order, sets up + * DurableContext on the child scope, and ensures a Close event is emitted. * - * Returns a Task that can be yield*-ed to get the child's result. + * **The task outlives this call.** It is started in the *routine's* own scope + * rather than inside the effect that returns it, so the caller can await it, + * cancel it, or leave it running beside other work. Spawning it through + * `ephemeral()` instead — as this once did — put it in a scope that closed as + * soon as the effect resolved, so every `yield* task` threw `halted`. * - * Returns Workflow> via ephemeral() — the infrastructure effects - * (useScope, spawn) are durable-safe scope setup that doesn't need - * journaling and re-runs correctly on replay. + * A retained `Close(cancelled)` here means the run was interrupted, not that a + * combinator chose against this child, so the child resumes its remaining work. + * See `CancelledChildPolicy`. */ export function durableSpawn( childWorkflow: () => Workflow, ): Workflow> { - return ephemeral( - (function* (): Operation> { - const scope = yield* useScope(); - const ctx = scope.expect(DurableContext); + return (function* (): Workflow> { + // Reading the context and allocating the child id is ordinary scope setup: + // no journal entry, and it re-runs identically on replay. Allocation is + // synchronous and in call order, so ids follow the order children are + // asked for rather than the order they are scheduled. + const ctx = yield* ephemeral(readDurableContext()); + const childIndex = ctx.childCounter++; + const childId = `${ctx.coroutineId}.${childIndex}`; + return (yield createSpawnEffect(() => + runDurableChild(childWorkflow, childId, ctx, "resume"), + )) as Task; + })(); +} - // Assign deterministic child ID - const childIndex = ctx.childCounter++; - const childId = `${ctx.coroutineId}.${childIndex}`; +function* readDurableContext(): Operation { + const scope = yield* useScope(); + return scope.expect(DurableContext); +} - // Spawn the child with durable wrapping - return yield* spawn(() => runDurableChild(childWorkflow, childId, ctx)); - })(), - ); +/** + * Start `child` in the routine's own scope and resolve with its task. + * + * The routine's scope is the workflow's, so the task lives for as long as the + * workflow does — that is the whole repair. Nothing is journaled: the child + * writes its own entries under its own coroutine id. + * + * A child that fails fails the workflow that spawned it, exactly as an ordinary + * Effection `spawn` does. What replay must not do is reach the child's body + * again to discover that. + */ +function createSpawnEffect(child: () => Operation): DurableEffect> { + return { + description: "durable-spawn", + effectDescription: { type: "ephemeral", name: "durable-spawn" }, + enter(resolve, routine) { + resolve({ ok: true, value: routine.scope.run(child) }); + return (exit) => exit({ ok: true, value: undefined as undefined }); + }, + }; } /** diff --git a/packages/durable-streams/replay-index.ts b/packages/durable-streams/replay-index.ts index 65e85086..7eeeaf67 100644 --- a/packages/durable-streams/replay-index.ts +++ b/packages/durable-streams/replay-index.ts @@ -77,6 +77,23 @@ export class ReplayIndex { this.disabled.add(coroutineId); } + /** + * Forget the retained Close for one coroutine, keeping its retained yields. + * + * A spawned region whose run was interrupted continues the work it had left, + * so its retained history must stay replayable while its retained + * `Close(cancelled)` stops standing in the way — otherwise the divergence + * guard reads the extra effects as a coroutine continuing past its own close. + * + * Deliberately narrower than `disableReplay`, which would throw the history + * away and re-run the child from the beginning. Internal: nothing exports + * this, because deciding that a closed coroutine may continue is the + * combinator's, and never a caller's. + */ + reopen(coroutineId: CoroutineId): void { + this.closes.delete(coroutineId); + } + /** Returns true if replay has been disabled for this coroutine. */ isReplayDisabled(coroutineId: CoroutineId): boolean { return this.disabled.has(coroutineId); diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index cec63a89..6e3cb847 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -489,6 +489,45 @@ Updated before completion of every phase and committed at the end of each phase. finally block skips re-emitting it (checked via `replayIndex.hasClose()`). - **Consequences:** Replay of race losers is invisible — they block and get cancelled just like the original run. No duplicate Close events. +- **Superseded in part by DEC-039.** The invariant recorded here assumed every + retained `Close(cancelled)` belongs to a child a combinator will cancel + again. That is true of `durableRace` and `durableAll`, and false of + `durableSpawn`. + +## DEC-039: A spawned region resumes a retained cancelled child + +- **Phase:** 4 (Structured Concurrency) +- **Date:** 2026-09-02 +- **Context:** `durableSpawn` hands its task to the caller, so nothing cancels + the child on the caller's behalf. Under DEC-024 a retained + `Close(cancelled)` made such a child `suspend()` forever, and no combinator + was ever going to cancel it a second time — the resumed run hung. The + invariant "this branch is only reachable when a parent combinator will cancel + this child" was simply not true once regions could be spawned. +- **Decision:** `runDurableChild` takes an explicit `CancelledChildPolicy`, + fixed at each combinator's call site and never chosen by a caller: + - `"combinator-cancels"` — `durableRace` and `durableAll` keep DEC-024 + exactly. A retained race loser or fail-fast sibling still suspends until + its combinator cancels it again. + - `"resume"` — `durableSpawn`. A retained `Close(cancelled)` under a parent + that never completed means the *run* was interrupted, not that a combinator + chose against this child, so the child continues the work it had left. +- **Rationale:** The two cases differ in who is going to act next, which is a + fact about the region rather than a preference. Reading a cancelled close as + "interrupted" where nothing will cancel it again is the only answer that + terminates. +- **Mechanism:** Resuming calls the internal `ReplayIndex.reopen(coroutineId)`, + which forgets that coroutine's retained Close while keeping its retained + yields — so the child continues its own history rather than restarting, and + the divergence guard does not read the remaining effects as a coroutine + continuing past its own close. It is deliberately narrower than + `disableReplay`, and neither is exported: deciding that a closed coroutine may + continue belongs to the combinator. +- **Consequences:** A resumed child writes the Close its second life reached, + replacing the retained cancelled one. `durableSpawn` also starts its child in + the routine's own scope rather than inside the `ephemeral` effect that + returns the task, so the task outlives the call and can be awaited or halted; + previously every `yield* task` threw `halted`. ## DEC-025: Test 27 — dynamic spawn count is not a divergence error diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts new file mode 100644 index 00000000..4dcd3a91 --- /dev/null +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -0,0 +1,366 @@ +/** + * `durableSpawn` — a durable child the caller owns. + * + * `durableAll` and `durableRace` own their children: they start them, wait for + * them, and cancel them. `durableSpawn` does not — it hands the task back, and + * everything here follows from that. + * + * Two things are easy to get wrong and are checked directly rather than + * inferred. The task has to outlive the call that produced it, or awaiting it + * throws `halted` before the child has done anything. And a retained + * `Close(cancelled)` means something different here than it does under a + * combinator: nobody is going to cancel this child a second time, so a child + * that suspended waiting for that would hang the resumed run forever. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { sleep, spawn, suspend } from "effection"; +import type { Operation } from "effection"; + +import { durableRun } from "../run.ts"; +import { durableAll, durableRace, durableSpawn } from "../combinators.ts"; +import { durableCall } from "../operations.ts"; +import { ephemeral } from "../ephemeral.ts"; +import { InMemoryStream } from "../stream.ts"; +import type { Workflow } from "../types.ts"; + +/** A workflow that records that it ran and returns `value`. */ +function marking(marks: string[], mark: string, value: string): () => Workflow { + return function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push(mark); + return value; + })(), + ); + }; +} + +describe("durableSpawn — lifetime", () => { + it("returns a task that is still live, and awaitable", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const value = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "child", "spawned")); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(value).toBe("spawned"); + expect(marks).toEqual(["child"]); + }); + + it("keeps the task running beside its caller", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const value = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* sleep(5); + marks.push("child finished"); + return "late"; + })(), + ); + }); + // The caller does its own work first. A task spawned into a scope that + // closed with the effect would already be dead by now. + yield* ephemeral( + (function* (): Operation { + marks.push("caller working"); + })(), + ); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(value).toBe("late"); + expect(marks).toEqual(["caller working", "child finished"]); + }); + + it("lets the caller cancel the task it was given", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("child started"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + marks.push("caller halted it"); + })(), + ); + return "done"; + }, + { stream }, + ); + + expect(marks).toEqual(["child started", "caller halted it"]); + const closes = (yield* stream.readAll()).filter((event) => event.type === "close"); + // Cancelling the task records the child's cancellation, exactly as a + // combinator-cancelled child records one. + expect(closes.some((event) => event.result.status === "cancelled")).toBe(true); + }); + + it("allocates child ids in the order children are asked for", function* () { + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const first = yield* durableSpawn(marking([], "a", "a")); + const second = yield* durableSpawn(marking([], "b", "b")); + yield* ephemeral(first); + yield* ephemeral(second); + return "done"; + }, + { stream }, + ); + + const ids = (yield* stream.readAll()) + .filter((event) => event.type === "close") + .map((event) => String(event.coroutineId)); + expect(ids).toContain("root.0"); + expect(ids).toContain("root.1"); + }); +}); + +describe("durableSpawn — replay", () => { + it("replays a completed child without running it again", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const first = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "ran", "value")); + return yield* ephemeral(task); + }, + { stream }, + ); + expect(first).toBe("value"); + expect(marks).toEqual(["ran"]); + + const second = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "ran", "value")); + return yield* ephemeral(task); + }, + { stream }, + ); + + // The retained result, and the workflow never entered. + expect(second).toBe("value"); + expect(marks).toEqual(["ran"]); + }); + + it("resumes an interrupted child rather than hanging on its cancelled close", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + // A run interrupted while the child is still working: the whole run is + // halted, so the child records Close(cancelled) and the parent records no + // Close at all. A parent that completed would replay its own result and the + // child would never be reached. + const interrupted = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("first life"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(3); + yield* interrupted.halt(); + + expect(marks).toEqual(["first life"]); + + // The resumed run. Nothing is going to cancel this child again, so a child + // that suspended on the retained cancelled close would never settle. + const resumed = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("second life"); + return "finished"; + })(), + ); + }); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(resumed).toBe("finished"); + expect(marks).toEqual(["first life", "second life"]); + // The record now describes the life that actually finished. + const closes = (yield* stream.readAll()).filter( + (event) => event.type === "close" && String(event.coroutineId) === "root.0", + ); + expect(closes[closes.length - 1]?.result.status).toBe("ok"); + }); + + it("continues a resumed child's own retained history", function* () { + const calls: string[] = []; + const stream = new InMemoryStream(); + const step = (name: string) => + durableCall(name, function* () { + calls.push(name); + return name; + }); + + const interrupted = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + yield* step("first"); + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(5); + yield* interrupted.halt(); + + expect(calls).toEqual(["first"]); + + const resumed = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + yield* step("first"); + yield* step("second"); + return "done"; + }); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(resumed).toBe("done"); + // `first` came from the child's own retained history; only the work it had + // left ran again. + expect(calls).toEqual(["first", "second"]); + }); +}); + +describe("durableSpawn — the combinators keep their own policy", () => { + it("a retained race loser still suspends until the race cancels it", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const race = () => + durableRace([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("winner"); + return "winner"; + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("loser"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + expect(yield* durableRun(race, { stream })).toBe("winner"); + marks.length = 0; + + // The loser's Close(cancelled) is retained. On replay it suspends and the + // race cancels it again, exactly as the first run did — it does not resume. + expect(yield* durableRun(race, { stream })).toBe("winner"); + expect(marks).toEqual([]); + }); + + it("a retained fail-fast sibling still suspends under all()", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const both = () => + durableAll([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("failing"); + throw new Error("sibling failed"); + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("cancelled sibling"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + let first: unknown; + try { + yield* durableRun(both, { stream }); + } catch (error) { + first = error; + } + expect(first instanceof Error ? first.message : "").toContain("sibling failed"); + + marks.length = 0; + let second: unknown; + try { + yield* durableRun(both, { stream }); + } catch (error) { + second = error; + } + + expect(second instanceof Error ? second.message : "").toContain("sibling failed"); + // Neither child re-ran: the failure replayed and the sibling suspended. + expect(marks).toEqual([]); + }); +}); From 3a92e4583bc3b204a763cec634d62dbf49307ae9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:52:57 -0400 Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=93=9D=20Decide=20the=20cancelled-c?= =?UTF-8?q?hild=20contract=20and=20TG17's=20replay=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions exposed while implementing #730, and no implementation. **DEC-040 — a cancelled child records why.** DEC-039's `"resume"` fired on every retained `Close(cancelled)` under an incomplete parent, which revives work a caller deliberately halted: the record of a deliberate `task.halt()` and the record of an interrupted run are the same event. The cancelled close now carries `cancellation: "caller"` or `"unwound"`, written by whichever path cancelled the child, and `"resume"` continues only `"unwound"`. A deliberate stop suspends, which is DEC-024's reproduction argument applied to a caller instead of a combinator; a legacy record with no reason reads as `"caller"`, because refusing to revive is the safe direction. The reason is retained evidence, not authority: nothing outside `runDurableChild` reads it, and no caller chooses a policy. Terminal grids need nothing wider. A grid halts its pane tasks at close, so those retain `"caller"` — and the grid child completes, so a resumed run short-circuits the region and never reaches them. The case that must resume, an interrupted run, unwinds and retains `"unwound"`. **TG17 narrows to the resolved layout.** A continuation executes the root the journal retained; the supplied source is not read, compared or refused (proved in #722). A grid's authored structure — pane count, order, form — is therefore fixed for the life of a journal and cannot differ between runs, so comparing it compares a value with itself, which is why the refusal never fired. What a fixed retained document still resolves differently is `columns` and each `title`, through prop-borne values, since props are not restored. Those refuse before the lease and before provider contact. Authored-structure change is a root-definition compatibility question, not a grid one. Root-definition authority is preserved rather than overridden by a pre-replay comparison against the current file, and the versioned root boundary that would refuse a changed source stays open work. --- architecture.md | 27 ++++++++++- packages/durable-streams/specs/DECISIONS.md | 47 +++++++++++++++++++ .../durable-streams/specs/durable-streams.md | 8 ++++ specs/executable-mdx-spec.md | 2 +- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/architecture.md b/architecture.md index 97b9ab06..f850d3af 100644 --- a/architecture.md +++ b/architecture.md @@ -2977,8 +2977,31 @@ a terminal provider, starting a shell, expanding pane content, acquiring an Agent session, or launching a native UI. The structured durable boundary owns that short circuit; a public replay context does not. -Partial replay first compares the exact authored layout and refuses divergence -before provider work. It rebuilds a fresh provider composite: completed pane +Partial replay compares the **resolved** layout and refuses divergence before +provider work. + +What that can and cannot cover follows from where a resumed run gets its +document. A continuation executes the root the journal retained: the source the +new invocation supplies is not read, not compared and not refused. So the +authored structure of a grid — how many panes it has, their order, and whether +each was written paired or self-closing — is fixed for the whole life of a +journal, and cannot differ between runs. Comparing it would compare a value with +itself. + +What can still differ is everything the retained source *resolves*: `columns` +and each `title` are expressions, and props are not restored across a +continuation, so a prop-borne or otherwise live value produces a different +resolved layout from the same retained document. Those are what the comparison +is for, and a change in either refuses before the foreground lease is taken and +before any provider is contacted. + +Authored-structure change is therefore not a grid concern. A document whose body +changed under an existing journal is a root-definition compatibility question — +the retained root stays authoritative, and deciding whether a changed source +should be refused rather than ignored belongs to a versioned root boundary that +does not exist yet. Until it does, the grid's obligation is the narrower one it +can actually discharge: retain the complete authored structure, and open the +structure it retained rather than the one the file now shows. It rebuilds a fresh provider composite: completed pane children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 6e3cb847..8c422ffb 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -528,6 +528,53 @@ Updated before completion of every phase and committed at the end of each phase. the routine's own scope rather than inside the `ephemeral` effect that returns the task, so the task outlives the call and can be awaited or halted; previously every `yield* task` threw `halted`. +- **Amended by DEC-040.** As first written, `"resume"` fired on *every* retained + `Close(cancelled)` under an incomplete parent. That is too wide: a caller may + deliberately halt the task it owns, and the record of that is + indistinguishable from the record of an interrupted run. DEC-040 supplies the + missing evidence and narrows `"resume"` to involuntary cancellation. + +## DEC-040: A cancelled child records why it was cancelled + +- **Phase:** 4 (Structured Concurrency) +- **Date:** 2026-09-02 +- **Context:** `durableSpawn` hands the task to its caller, and the caller may + call `task.halt()` on purpose — a region it decided to stop. If the run is + later interrupted before the parent completes, the journal holds + `Close(cancelled)` for that child and nothing else. DEC-039's `"resume"` + policy therefore revives work the caller deliberately cancelled, on every + subsequent resumed run. +- **Decision:** The cancelled close carries **why**, written by whichever path + cancelled the child: + - `cancellation: "caller"` — the owner called `halt()` on the task + `durableSpawn` returned. A deliberate stop. + - `cancellation: "unwound"` — anything else: the routine's scope unwinding, + the run being interrupted, the host going away. Involuntary. + + A record with no `cancellation` member is legacy and reads as `"caller"`, + because refusing to revive is the safe direction: it reproduces the original + run rather than performing work nobody asked for twice. + + `runDurableChild`'s policies then read: + - `"combinator-cancels"` (`durableRace`, `durableAll`) — suspend, whatever the + reason. Unchanged from DEC-024. + - `"resume"` (`durableSpawn`) — resume **only** `"unwound"`. A `"caller"` + cancellation suspends, exactly as a combinator-cancelled child does. +- **Rationale:** Suspending is the faithful reproduction of a deliberate halt: + the caller's control flow is deterministic, so it reaches the same + `task.halt()` again and cancels the child a second time — which is DEC-024's + argument, applied to a caller instead of a combinator. A caller that instead + *awaits* a task it previously halted has diverged, and divergence is the + honest answer there rather than a silent revival. +- **Consequences:** Terminal grids get what they need without reviving anything + deliberately stopped. A grid halts each pane task when the reader closes, so + those panes retain `"caller"` — and the grid child completes, so a resumed run + short-circuits the whole region and never reaches them. The case that must + resume — the run interrupted while the grid is open — unwinds the grid and + pane children, retains `"unwound"`, and continues. +- **Scope:** The reason is retained evidence, not authority. Nothing reads it + from outside `runDurableChild`, no public API exposes it, and no caller + chooses a policy: the policy stays fixed at each combinator's call site. ## DEC-025: Test 27 — dynamic spawn count is not a divergence error diff --git a/packages/durable-streams/specs/durable-streams.md b/packages/durable-streams/specs/durable-streams.md index 5f1b1b5e..95540ba3 100644 --- a/packages/durable-streams/specs/durable-streams.md +++ b/packages/durable-streams/specs/durable-streams.md @@ -130,6 +130,14 @@ function* runWithDurability(operation, producer) { } ``` +A `cancelled` Close also records **why**, because two very different things +produce one: a caller deliberately halting a task it owns, and a run being +interrupted. `cancellation: "caller"` is the deliberate stop; `"unwound"` is +everything involuntary. A resumed spawned region continues an `"unwound"` child +and reproduces a `"caller"` one by suspending, so nothing deliberately stopped +is silently performed again. A record with no `cancellation` member reads as +`"caller"`. See DEC-040. + For **Close events**, the ordering discipline is: ```typescript diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index f032f538..d7dab341 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -10753,7 +10753,7 @@ test derives a core result from a provider identifier. | TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | -| TG17 | Replay divergence and retained shape | A changed column count, pane count, order, form or title refuses before provider work; retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | +| 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; 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 | ### Tier CR — Component registration and resolution From 77850e82201943ff73ec988fd84510e62104309b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 16:45:40 -0400 Subject: [PATCH 08/15] =?UTF-8?q?=E2=9C=A8=20Implement=20DEC-040,=20and=20?= =?UTF-8?q?complete=20TG15=20and=20TG17=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **DEC-040.** A cancelled Close now records why: `cancellation: "caller"` when the owner halts the task `durableSpawn` returned, `"unwound"` for anything involuntary. `durableSpawn` resumes only `"unwound"`; a deliberate stop suspends until the caller's deterministic control flow halts it again, and a record with no reason reads as `"caller"` so nothing legacy is revived. `durableAll` and `durableRace` keep DEC-024 whatever the reason says. The halt is intercepted without changing the public `Task` surface: the returned task carries every member the real one defines, copied with its prototype, and only `halt` is replaced. A proxy cannot do this — a task's members are read-only and non-configurable, so a `get` trap is required to hand back exactly what the target holds. The reason had to survive three boundaries that were dropping it: the protocol parser, the observable copy, and — the one that actually mattered — `detachResult`, which froze every cancellation down to `{ status }`. **TG15.** The harness's `attached` and `pastGrid` signals are now separate, and a run that expects its grid to complete waits for the sibling *after* the grid before halting the root at ``. That is what leaves a completed grid child under an incomplete root, which is the only state in which a completed region can be observed replaying at all. Both a successful grid and a contained failed one replay their exact retained result with no provider, pane content, shell or launcher work, and each row asserts the grid child genuinely recorded a terminal close. No timeouts. **TG17.** Prop-borne `columns` and `title` change independently against one fixed retained document — the only things a fixed retained root can still resolve differently — and each refuses with zero provider observation. For supplied-file changes to pane count, order and form, the continuation opens the retained structure rather than the file's, asserted request-for-request. The retained record carries every authored pane's ordinal, title, form and derived position. `readLayout()` parses totally: the layout object and every pane field, with missing, extra, mistyped, out-of-position and self-inconsistent records all refused rather than half-read. Evidence: durable-spawn 14 rows, terminal-grid 36 rows, structural 13, provider 10. Packages: durable-streams 33, core 349, runtime 15, workflow 172. --- packages/core/src/terminal/journal.ts | 82 +++++- packages/core/tests/terminal-grid.test.ts | 277 ++++++++++++++++-- packages/durable-streams/combinators.ts | 112 +++++-- packages/durable-streams/mod.ts | 1 + packages/durable-streams/parse.ts | 16 +- packages/durable-streams/retained.ts | 19 +- .../tests/durable-spawn.test.ts | 202 +++++++++++++ packages/durable-streams/types.ts | 14 +- 8 files changed, 669 insertions(+), 54 deletions(-) diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts index cedd0b39..3ee4fd8d 100644 --- a/packages/core/src/terminal/journal.ts +++ b/packages/core/src/terminal/journal.ts @@ -66,17 +66,87 @@ function* append(description: EffectDescription, value: Json): Workflow }); } -/** The retained layout a journal entry holds, or undefined if it holds anything else. */ +/** + * The layout a journal entry holds, parsed member by member. + * + * Total: every field is read and checked, and anything the record does not say + * exactly — a missing member, a member of the wrong kind, an extra one, a pane + * whose ordinal is not its position, a row or column that does not follow from + * the columns it claims — makes the record unreadable rather than half-read. A + * layout is what a resumed run is held to, so a record that cannot be believed + * in full must not be believed in part. + */ function readLayout(value: unknown): RetainedLayout | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { + const record = members(value); + if (record === undefined || !onlyNames(record, ["columns", "rows", "panes"])) { return undefined; } - const fields: Record = Object.fromEntries(Object.entries(value)); - const { columns, rows, panes } = fields; - if (typeof columns !== "number" || typeof rows !== "number" || !Array.isArray(panes)) { + const columns = positiveInteger(record.columns); + const rows = positiveInteger(record.rows); + const list = record.panes; + if (columns === undefined || rows === undefined || !Array.isArray(list)) { + return undefined; + } + const panes: RetainedLayout["panes"] = []; + for (const [index, entry] of list.entries()) { + const pane = readPane(entry, index, columns); + if (pane === undefined) { + return undefined; + } + panes.push(pane); + } + // The rows a grid claims have to be the rows its panes need, or the record + // describes a grid nothing could have derived. + if (panes.length === 0 || Math.ceil(panes.length / columns) !== rows) { return undefined; } - return { columns, rows, panes: panes as RetainedLayout["panes"] }; + return { columns, rows, panes }; +} + +/** One retained pane, checked against the position it claims to occupy. */ +function readPane( + value: unknown, + index: number, + columns: number, +): RetainedLayout["panes"][number] | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["ordinal", "title", "form", "row", "column"])) { + return undefined; + } + const { ordinal, title, form, row, column } = record; + if (ordinal !== index) { + return undefined; + } + if (typeof title !== "string" || title.length === 0) { + return undefined; + } + if (form !== "paired" && form !== "self-closing") { + return undefined; + } + // Derived, not asserted: a position that does not follow from the ordinal and + // the column count is a record that disagrees with itself. + if (row !== Math.floor(index / columns) || column !== index % columns) { + return undefined; + } + return { ordinal, title, form, row, column }; +} + +/** The members of a JSON object, or `undefined` for anything else. */ +function members(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return Object.fromEntries(Object.entries(value)); +} + +/** Whether a record carries exactly these member names, and no others. */ +function onlyNames(record: Record, names: readonly string[]): boolean { + const present = Object.keys(record); + return present.length === names.length && names.every((name) => name in record); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } /** How two layouts differ, in the words an author can act on. */ diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 1cc5e340..9da63e05 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -248,6 +248,8 @@ function runDocument( composite?: ControlledCompositeOptions; /** Where `` records that it started. */ slowMarks?: string[]; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; } = {}, ): Operation { return scoped(function* () { @@ -292,7 +294,12 @@ function runDocument( yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); const stream = options.stream ?? new InMemoryStream(); - const execution = yield* execute({ path, stream, includes: [dir] }); + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); const outcome = yield* execution; const output = yield* forEach(function* (_chunk: string) {}, execution.output); return { @@ -327,6 +334,10 @@ function heldDocument(columns: number, panes: string[]): string { ...panes, "", "", + // The sibling after the grid. It runs whether the grid ran or replayed, so + // a harness can wait for the document to have moved past the region. + ``, + "", "", "", ].join("\n"); @@ -348,20 +359,26 @@ function runInterrupted( shell?: ControlledCompositeOptions["shell"]; /** Let the reader leave, so the grid completes rather than staying open. */ close?: boolean; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; } = {}, ): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; - const opened = withResolvers(); - // Two signals, neither a deadline: the grid opened on a live run, or the - // document reached the sibling after it — which is what a replayed grid - // does. A replay that hangs reaches neither and hangs the row, rather than - // passing on a timer. + // Two signals, kept apart because they mean different things. `attached` + // says a grid opened on this run; `pastGrid` says the document reached the + // sibling after it, which is what a *replayed* grid does and what a + // completed-region journal needs to be waited for. Neither is a deadline: a + // replay that hangs reaches neither and hangs the row rather than passing + // on a timer. + const attached = withResolvers(); + const pastGrid = withResolvers(); + const destroyed = withResolvers(); yield* useGridComponents(ran, [], (mark) => { if (mark === PAST_THE_GRID) { - opened.resolve(); + pastGrid.resolve(); } }); yield* installControlledLauncher(); @@ -374,11 +391,15 @@ function runInterrupted( requests.push(asked); yield* sleep(0); }, - // Attach is the signal, not `running`: a pane that settles before the - // barrier keeps its own status and never becomes runnable. + // Attach, not `running`: a pane that settles before the barrier keeps + // its own status and never becomes runnable. // deno-lint-ignore require-yield *onAttach() { - opened.resolve(); + attached.resolve(); + }, + // deno-lint-ignore require-yield + *onDestroy() { + destroyed.resolve(); }, }); } @@ -387,13 +408,36 @@ function runInterrupted( const path = join(dir, "doc.md"); yield* writeTextFile(path, source); const task: Task = yield* spawn(function* () { - const execution = yield* execute({ path, stream, includes: [dir] }); + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); yield* execution; }); // The grid is open and its panes have settled, so the journal now holds the // pane children's own entries. A resumed run never attaches at all — the // region short-circuits — so this is bounded rather than waited on. - yield* opened.operation; + // `close: true` means the grid is expected to complete, so the run is + // halted only once the document has moved past it — that is what leaves a + // completed grid child under an incomplete root. Otherwise the grid is + // expected to stay open, and attaching is as far as it gets. + yield* race([ + options.close === true ? pastGrid.operation : attached.operation, + (function* (): Operation { + yield* sleep(1500); + // deno-lint-ignore no-console + const evts = yield* stream.readAll(); + // deno-lint-ignore no-console + console.log( + "PROBE3 closes", + JSON.stringify( + evts.filter((e) => e.type === "close").map((e) => [e.coroutineId, e.result.status]), + ), + ); + })(), + ]); yield* sleep(5); yield* task.halt(); return { @@ -1109,6 +1153,81 @@ describe("Tier TG — startup, settlement and teardown", () => { describe("Tier TG — durability and replay", () => { const GRID = heldDocument(2, PANES); + /** + * A grid whose only pane never starts, with its failure contained. + * + * `` keeps the document going, so the root reaches no outcome of + * its own and a resumed run reaches the region rather than replaying the root + * wholesale. + */ + const CONTAINED_FAILURE = [ + "", + "", + 'nothing interactive here', + "", + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + /** + * Whether the grid child reached a terminal record of its own. + * + * `ok` or `err`: both are outcomes the region settled on. Only a cancelled + * close, or no close at all, means it was interrupted — and that is the + * difference this row exists to depend on. + */ + function completedGrid(run: DocumentRun): boolean { + return run.journal.some( + (event) => + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + (event.result.status === "ok" || event.result.status === "err"), + ); + } + + it("TG15: a completed successful grid replays its exact result, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, GRID, stream, { close: true }); + expect(first.requests).toHaveLength(1); + // The region genuinely completed: without that this row would be about an + // interrupted grid resuming, which is TG16's claim rather than this one. + expect(completedGrid(first)).toBe(true); + + const second = yield* runInterrupted(dir, GRID, stream, { close: true }); + + // No provider was asked for a grid, nothing was prepared or attached, no + // pane content expanded, no shell or launcher ran, and nothing displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + + it("TG15: a contained failed grid replays the same failure, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { close: true }); + expect(first.requests).toHaveLength(1); + expect(completedGrid(first)).toBe(true); + + // No provider at all on the resumed run: a replay that contacted one would + // refuse, and the retained result does not need one. + const second = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + close: true, + provider: false, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); it("TG16: each pane is a durable child of the grid, in authored order", function* () { const dir = yield* useDir(); @@ -1171,21 +1290,137 @@ describe("Tier TG — durability and replay", () => { expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); }); - it("TG17: the layout is recorded before any provider is contacted", function* () { + /** + * A grid whose `columns` and first `title` come from props. + * + * A continuation executes the retained root, so the document itself cannot + * change between runs — but props are not restored, so these two values are + * exactly what a fixed retained source can still resolve differently. + */ + const PROP_BORNE = [ + "---", + "props:", + " columns:", + " type: number", + " label:", + " type: string", + "---", + "", + "left", + '', + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + it("TG17: a changed prop-borne column count refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 3, label: "Left" }, + }); + + // Refused before the foreground lease and before the provider: nothing was + // prepared, attached or displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + // A replay refusal, not a run that opened something and then failed. The + // sentence is the divergence report's: a refusal raised while retained + // children are still being replayed loses to it, which is established + // behaviour rather than something this row can change. + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: a changed prop-borne title refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 2, label: "Elsewhere" }, + }); + + expect(second.requests).toEqual([]); + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: an unchanged prop-borne layout is admitted", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const props = { columns: 2, label: "Left" }; + + yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + const second = yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + + // The discriminator for the two rows above: the same resolved layout + // resumes and opens a grid, so a refusal there is about the change. + expect(second.requests).toHaveLength(1); + }); + + it("TG17: a continuation opens the retained structure, not the file's", function* () { + const structural: [string, string[]][] = [ + ["pane count", [...PANES, '']], + ["pane order", ['', ...PANES.slice(0, 1)]], + ["pane form", ['', '']], + ]; + + for (const [what, panes] of structural) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + const retained = first.requests[0]!; + + // The file now says something else. A continuation executes the root the + // journal retained, so the grid it opens is the one that was recorded. + const second = yield* runInterrupted(dir, heldDocument(2, panes), stream); + + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 1`); + expect(`${what}: ${JSON.stringify(second.requests[0])}`).toBe( + `${what}: ${JSON.stringify(retained)}`, + ); + } + }); + + it("TG17: the retained record holds the complete authored pane structure", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const layoutIndex = run.journal.findIndex( + const layout = run.journal.find( (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), ); - const firstChildClose = run.journal.findIndex( - (event) => event.type === "close" && String(event.coroutineId).includes("."), - ); - expect(layoutIndex).toBeGreaterThan(-1); - if (firstChildClose > -1) { - expect(layoutIndex).toBeLessThan(firstChildClose); - } + expect(layout).toBeDefined(); + const value = + layout?.type === "yield" && layout.result.status === "ok" ? layout.result.value : undefined; + // Every authored pane, with its ordinal, title, form and derived position. + expect(value).toEqual({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }); }); it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index dcd20359..11d410fa 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -18,14 +18,7 @@ * See protocol spec §7 (structured concurrency), §10 (race semantics). */ -import { - all as effectionAll, - ensure, - race as effectionRace, - spawn, - suspend, - useScope, -} from "effection"; +import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; import type { Operation, Task } from "effection"; import { DurableContext } from "./context.ts"; import { @@ -36,7 +29,7 @@ import { import { ephemeral } from "./ephemeral.ts"; import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.ts"; import { deserializeError, serializeError } from "./serialize.ts"; -import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; +import type { Cancellation, Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; /** * Run a child workflow within a spawned scope, setting up its own @@ -75,11 +68,42 @@ import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./type */ type CancelledChildPolicy = "combinator-cancels" | "resume"; +/** + * Whether the caller deliberately stopped the child this run (DEC-040). + * + * Written by the task `durableSpawn` hands out — the only place a deliberate + * halt can be observed — and read once, when the cancelled Close is built. A + * combinator supplies none: a child it cancels stopped because a scope came + * down, which is what `"unwound"` means. + */ +interface CancellationEvidence { + deliberate: boolean; +} + +/** How a cancelled child's stop is recorded. */ +function cancellationOf(evidence: CancellationEvidence | undefined): Cancellation { + return evidence?.deliberate === true ? "caller" : "unwound"; +} + +/** + * Why a retained cancelled child stopped. + * + * Absent is `"caller"`: a record written before this evidence existed says + * nothing, and reviving work nobody asked to be redone is the worse mistake. + */ +function retainedCancellation(close: Close): Cancellation { + if (close.result.status !== "cancelled") { + return "caller"; + } + return close.result.cancellation === "unwound" ? "unwound" : "caller"; +} + function* runDurableChild( childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", + evidence?: CancellationEvidence, ): Operation { const { replayIndex, stream } = parentCtx; replayIndex.claim(childId); @@ -99,18 +123,25 @@ function* runDurableChild( return closeEvent.result.value as T; } else if (closeEvent.result.status === "err") { throw deserializeError(closeEvent.result.error); - } else if (cancelledPolicy === "combinator-cancels") { - // A race loser, or a sibling `all` cancelled when another failed. The - // same combinator cancels it again on this run, so reproducing the - // original execution means blocking until it does — in the live run this - // child never threw, it simply stopped. The Close(cancelled) event - // already exists, so the teardown below skips re-emitting it. + } else if ( + cancelledPolicy === "combinator-cancels" || + retainedCancellation(closeEvent) === "caller" + ) { + // Either a combinator's child — a race loser, or a sibling `all` + // cancelled when another failed — or a spawned child its own caller + // deliberately halted. Both are reproduced the same way: block until the + // thing that stopped it last time stops it again. A combinator cancels it + // as it did before; a caller reaches the same `halt()` its deterministic + // control flow reached before. In the live run neither child threw, it + // simply stopped. The Close(cancelled) event already exists, so the + // teardown below skips re-emitting it. yield* suspend(); // unreachable — suspend blocks until cancelled return undefined as T; } else { - // A spawned region whose run was interrupted. Nobody is going to cancel - // this child a second time, so suspending would hang the resumed run. + // A spawned region whose run was interrupted — involuntarily, which is + // what `"unwound"` records. Nobody is going to cancel this child a second + // time, so suspending would hang the resumed run. // Forget the retained close — its yields stay replayable, so the child // continues its own history — and fall through to run the rest. resumedFromCancelled = true; @@ -158,7 +189,7 @@ function* runDurableChild( closeEvent = { type: "close", coroutineId: childId, - result: { status: "cancelled" }, + result: { status: "cancelled", cancellation: cancellationOf(evidence) }, }; } @@ -263,8 +294,10 @@ export function durableSpawn( const ctx = yield* ephemeral(readDurableContext()); const childIndex = ctx.childCounter++; const childId = `${ctx.coroutineId}.${childIndex}`; - return (yield createSpawnEffect(() => - runDurableChild(childWorkflow, childId, ctx, "resume"), + const evidence: CancellationEvidence = { deliberate: false }; + return (yield createSpawnEffect( + () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), + evidence, )) as Task; })(); } @@ -285,17 +318,52 @@ function* readDurableContext(): Operation { * Effection `spawn` does. What replay must not do is reach the child's body * again to discover that. */ -function createSpawnEffect(child: () => Operation): DurableEffect> { +function createSpawnEffect( + child: () => Operation, + evidence: CancellationEvidence, +): DurableEffect> { return { description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: routine.scope.run(child) }); + resolve({ ok: true, value: observingHalt(routine.scope.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; } +/** + * The same task, with a deliberate `halt()` recorded as it happens. + * + * The caller receives every member the task defines — `then`, `catch`, + * `finally`, the async dispose, the iterator — copied from the task itself + * along with its prototype, so the public surface is the one `Task` has always + * had. Only `halt` is replaced, and only to note that someone stopped the child + * on purpose before stopping it. + * + * Copied rather than proxied: a task's members are read-only and + * non-configurable, and a proxy is required to hand back exactly what the + * target holds — so a `get` trap cannot substitute `halt` at all. Each copied + * member is the task's own closure and keeps working on the copy. + */ +function observingHalt(task: Task, evidence: CancellationEvidence): Task { + const members = Object.getOwnPropertyDescriptors(task); + // Replaced in the descriptor map rather than on the finished object: the + // task's own members are non-configurable, so redefining one afterwards + // throws. + members.halt = { + value: () => { + evidence.deliberate = true; + return task.halt(); + }, + enumerable: true, + configurable: false, + writable: false, + }; + const observed: Task = Object.create(Object.getPrototypeOf(task), members); + return observed; +} + /** * Run multiple durable workflows concurrently and wait for all to complete. * diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 03c313c0..581dabd5 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -8,6 +8,7 @@ // Protocol types export type { + Cancellation, Close, CoroutineId, CoroutineView, diff --git a/packages/durable-streams/parse.ts b/packages/durable-streams/parse.ts index 0747bfdc..aa74b2b6 100644 --- a/packages/durable-streams/parse.ts +++ b/packages/durable-streams/parse.ts @@ -125,8 +125,20 @@ function parseResult(value: unknown, path: string): Result { return { status: "err", error: parseSerializedError(members.get("error"), `${path}.error`) }; } case "cancelled": { - requireMemberNames(members, ["status"], path); - return { status: "cancelled" }; + requireMemberNames(members, ["status", "cancellation"], path); + const cancellation = members.get("cancellation"); + if (cancellation === undefined) { + // A record written before this evidence existed. DEC-040 reads the + // absence as a deliberate stop, so nothing it left behind is revived. + return { status: "cancelled" }; + } + if (cancellation !== "caller" && cancellation !== "unwound") { + throw new MalformedDurableEventError( + 'expected "caller" or "unwound"', + `${path}.cancellation`, + ); + } + return { status: "cancelled", cancellation }; } default: throw new MalformedDurableEventError('expected "ok", "err" or "cancelled"', `${path}.status`); diff --git a/packages/durable-streams/retained.ts b/packages/durable-streams/retained.ts index 38022446..ab872178 100644 --- a/packages/durable-streams/retained.ts +++ b/packages/durable-streams/retained.ts @@ -170,7 +170,17 @@ function detachResult(result: Result): Result { } return Object.freeze({ status, error: detachError(result.error) }); } - return Object.freeze({ status }); + // The reason a cancellation carries is retained evidence, not decoration: a + // resumed spawned region reads it to tell a deliberate stop from an + // interrupted run (DEC-040). Dropping it here would make every retained + // cancellation read as deliberate, which is the safe default but the wrong + // answer for a run that was interrupted. A value that is not one of the two + // it may be is not retained at all, so a malformed record reads as the safe + // default rather than as something it never said. + const cancellation = result.cancellation; + return Object.freeze( + cancellation === "caller" || cancellation === "unwound" ? { status, cancellation } : { status }, + ); } /** @@ -448,5 +458,10 @@ export function consumable(result: Result): Result { if (result.status === "err") { return { status: "err", error: { ...result.error } }; } - return { status: "cancelled" }; + // The reason travels with the copy: a resumed spawned region reads it to tell + // a deliberate stop from an interrupted run (DEC-040), and dropping it here + // would make every retained cancellation look deliberate. + return result.cancellation === undefined + ? { status: "cancelled" } + : { status: "cancelled", cancellation: result.cancellation }; } diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 4dcd3a91..07285ce1 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -364,3 +364,205 @@ describe("durableSpawn — the combinators keep their own policy", () => { expect(marks).toEqual([]); }); }); + +describe("durableSpawn — why a child was cancelled (DEC-040)", () => { + /** Every cancelled close in a journal, with the reason it recorded. */ + function* cancellations(stream: InMemoryStream): Operation { + const events = yield* stream.readAll(); + return events + .filter((event) => event.type === "close" && event.result.status === "cancelled") + .map((event) => + event.result.status === "cancelled" ? String(event.result.cancellation) : "", + ); + } + + it("records a deliberate halt as caller", function* () { + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + })(), + ); + return "done"; + }, + { stream }, + ); + + expect(yield* cancellations(stream)).toEqual(["caller"]); + }); + + it("records a scope unwinding as unwound", function* () { + const stream = new InMemoryStream(); + + const run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(3); + yield* run.halt(); + + expect(yield* cancellations(stream)).toEqual(["unwound"]); + }); + + it("does not revive a child the caller deliberately halted", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + // The caller halts the child, then the run is interrupted before it + // completes. Both facts are in the journal; only the first decides. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("first life"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(5); + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // The resumed run reaches the same deliberate halt, so the child suspends + // until it does rather than performing work nobody asked to redo. + const second = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("revived"); + return "revived"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(10); + yield* second.halt(); + + expect(marks).toEqual(["first life"]); + }); + + it("reads a record with no reason as caller", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + // A journal written before this evidence existed. + yield* stream.append({ + type: "close", + coroutineId: "root.0", + result: { status: "cancelled" }, + }); + + const run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("would revive"); + return "revived"; + })(), + ); + }); + return yield* ephemeral(task); + }, + { stream }, + ); + }); + yield* sleep(10); + yield* run.halt(); + + // Absent evidence is the safe direction: nothing is revived. + expect(marks).toEqual([]); + }); + + it("keeps combinator children on DEC-024 whatever the reason says", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const race = () => + durableRace([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("winner"); + return "winner"; + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("loser"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + expect(yield* durableRun(race, { stream })).toBe("winner"); + // The loser's cancellation is involuntary, so it records `unwound` — and a + // combinator child suspends regardless of what the reason says. + expect(yield* cancellations(stream)).toEqual(["unwound"]); + + marks.length = 0; + expect(yield* durableRun(race, { stream })).toBe("winner"); + expect(marks).toEqual([]); + }); +}); diff --git a/packages/durable-streams/types.ts b/packages/durable-streams/types.ts index 2e79846b..3523ab53 100644 --- a/packages/durable-streams/types.ts +++ b/packages/durable-streams/types.ts @@ -23,11 +23,23 @@ export interface SerializedError { stack?: string; } +/** + * Why a cancelled coroutine stopped (DEC-040). + * + * Two very different things produce a cancelled Close, and a resumed run has to + * tell them apart: `"caller"` is an owner deliberately halting the task + * `durableSpawn` handed it, and `"unwound"` is anything involuntary — a scope + * coming down, a run interrupted, a host going away. A record written before + * this evidence existed carries neither, and reads as `"caller"`, because + * refusing to revive is the safe direction. + */ +export type Cancellation = "caller" | "unwound"; + /** Result of an effect or coroutine. */ export type Result = | { status: "ok"; value?: Json } | { status: "err"; error: SerializedError } - | { status: "cancelled" }; + | { status: "cancelled"; cancellation?: Cancellation }; /** Dot-delimited hierarchical coroutine path. See spec §3. */ export type CoroutineId = string; From 6a0ecb14f824123f88ce16c4afe18cd12a3a7a33 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 17:04:51 -0400 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=90=9B=20Make=20the=20replay=20evid?= =?UTF-8?q?ence=20deterministic,=20and=20pin=20DEC-040's=20boundaries=20(#?= =?UTF-8?q?730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The harness cannot pass a hung replay any more.** `runInterrupted()` had a 1500ms timer racing its signals, so a replay that hung returned a DocumentRun that looked finished; it also slept a fixed 5ms to let records land. Both are gone. It now waits only on events the run produced: `attached`, `pastGrid`, and a new `panesSettled` for the rows that read pane records — a pane's status is published only after its durable child returned, so counting settled panes is also counting durable pane closes. A replay that hangs now reaches none of them and hangs the row. **TG15's failed case is a real contained failure.** A pane that fails before attachment fails the whole region, so the old document could not both fail and continue. The failing pane is now a shell that starts, waits for attachment, and only then exits badly — contained as that pane's status, with the grid settling as failed and the document carrying on. Both runs capture the printed errors, and the row asserts the replayed run produced the same ones, reached `PAST_THE_GRID`, and did no provider, pane, shell or launcher work. **DEC-040 gets boundary tests where the evidence actually travels.** `parse.test.ts` round-trips both reasons to the same bytes, keeps a legacy absence absent, and refuses an unrecognised reason at `$.result.cancellation`. `retained.test.ts` proves retention and `consumable()` carry both reasons, leave a legacy absence absent, drop an unrecognised one to the safe default, and that the reason reaches the replay index. The DEC-040 rows in `durable-spawn.test.ts` no longer coordinate by delay: a child says when it is running, and the caller says when it has halted. **Malformed retained layouts** are covered by replaying a real journal with only its layout entry replaced — a missing member, an extra one, a mistyped one, a pane out of position, and a record that disagrees with itself. Each refuses with zero provider observation. The `durableSpawn` doc comment no longer says every retained cancellation is an interrupted run. --- packages/core/tests/terminal-grid.test.ts | 271 +++++++++++++++--- packages/durable-streams/combinators.ts | 9 +- .../tests/durable-spawn.test.ts | 2 +- packages/durable-streams/tests/parse.test.ts | 38 +++ .../durable-streams/tests/retained.test.ts | 60 +++- 5 files changed, 329 insertions(+), 51 deletions(-) diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 9da63e05..20d00432 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -54,6 +54,7 @@ import type { TerminalProviderLog, } from "@executablemd/runtime"; +import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; import { @@ -85,6 +86,8 @@ interface DocumentRun { events: string[]; /** Every mark a tripwire component recorded, in order. */ ran: string[]; + /** Every printed error the run produced, in order. */ + errors: string[]; /** The journal this run read and appended to. */ journal: DurableEvent[]; } @@ -113,6 +116,7 @@ function useGridComponents( ran: string[], slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, + afterAttach: () => Operation = function* () {}, ): Operation { return registerComponents([ { @@ -164,6 +168,18 @@ function useGridComponents( return ""; }, }, + { + // Waits until the grid has attached, so a pane can fail *after* the + // barrier — which is the failure the grid contains as a status rather + // than the startup failure that fails the whole region. + name: "AfterAttach", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* afterAttach(); + return ""; + }, + }, { name: "Hold", origin: "tier-tg", @@ -258,6 +274,13 @@ function runDocument( const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; + const errors: string[] = []; + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, + }); yield* useGridComponents(ran, options.slowMarks ?? []); yield* installControlledLauncher(); @@ -309,6 +332,7 @@ function runDocument( shown: log.shown, events: log.events, ran, + errors, journal: yield* stream.readAll(), }; }); @@ -361,35 +385,97 @@ function runInterrupted( close?: boolean; /** Props this run supplies. Props are not restored across a continuation. */ props?: Record; + /** + * Keep the grid open until a pane reports a failure. + * + * A pane that fails *after* attachment is contained as that pane's status, + * and the grid settles as failed rather than throwing. Closing before that + * would record the pane as cancelled by the close instead. + */ + closeAfterFailure?: boolean; + /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ + shellFailsAfterAttach?: number; + /** + * How many panes must have settled before the run is interrupted. + * + * A pane's status is published only after its durable child has returned, + * so this is also how many pane Closes the journal is known to hold. Rows + * that read those records name the number they need; rows that only need an + * open grid name none. + */ + settled?: number; } = {}, ): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; - // Two signals, kept apart because they mean different things. `attached` - // says a grid opened on this run; `pastGrid` says the document reached the + const errors: string[] = []; + // Three signals, kept apart because they mean different things. `attached` + // says a grid opened on this run. `pastGrid` says the document reached the // sibling after it, which is what a *replayed* grid does and what a - // completed-region journal needs to be waited for. Neither is a deadline: a - // replay that hangs reaches neither and hangs the row rather than passing - // on a timer. + // completed-region journal has to be waited for. `panesSettled` says the + // pane children the row cares about have written their own records. + // + // Every one of them is an event this run produced. Nothing here waits for a + // duration, so a replay that hangs reaches none of them and hangs the row — + // it can never hand back a run that looks finished but is not. const attached = withResolvers(); const pastGrid = withResolvers(); - const destroyed = withResolvers(); - yield* useGridComponents(ran, [], (mark) => { - if (mark === PAST_THE_GRID) { - pastGrid.resolve(); - } + const panesSettled = withResolvers(); + let settledPanes = 0; + if ((options.settled ?? 0) === 0) { + panesSettled.resolve(); + } + // The printed errors this run produced, which is how a contained failure is + // observable at all — and the same list on a replayed run is how "the same + // result came back" is read rather than assumed. + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, }); + const paneFailed = withResolvers(); + yield* useGridComponents( + ran, + [], + (mark) => { + if (mark === PAST_THE_GRID) { + pastGrid.resolve(); + } + }, + () => attached.operation, + ); yield* installControlledLauncher(); if (options.provider !== false) { yield* useControlledProvider({ log, - close: options.close === true ? immediateClose() : () => suspend(), - ...(options.shell === undefined ? {} : { shell: options.shell }), + close: + options.closeAfterFailure === true + ? () => paneFailed.operation + : options.close === true + ? immediateClose() + : () => suspend(), + ...(options.shellFailsAfterAttach !== undefined + ? { + shell: function* (ordinal: number, spawned: () => void) { + spawned(); + if (ordinal !== options.shellFailsAfterAttach) { + return { exitCode: 0 }; + } + // Started, so the grid attaches; it fails only afterwards, which + // is the failure a grid contains as a pane status. + yield* attached.operation; + return { exitCode: 1 }; + }, + } + : options.shell === undefined + ? {} + : { shell: options.shell }), + // deno-lint-ignore require-yield *onPrepare(asked) { requests.push(asked); - yield* sleep(0); }, // Attach, not `running`: a pane that settles before the barrier keeps // its own status and never becomes runnable. @@ -397,9 +483,16 @@ function runInterrupted( *onAttach() { attached.resolve(); }, - // deno-lint-ignore require-yield - *onDestroy() { - destroyed.resolve(); + onUpdate(_ordinal, state) { + if (state === "failed") { + paneFailed.resolve(); + } + if (state === "succeeded" || state === "failed" || state === "closed") { + settledPanes++; + if (settledPanes >= (options.settled ?? 0)) { + panesSettled.resolve(); + } + } }, }); } @@ -416,29 +509,17 @@ function runInterrupted( }); yield* execution; }); - // The grid is open and its panes have settled, so the journal now holds the - // pane children's own entries. A resumed run never attaches at all — the - // region short-circuits — so this is bounded rather than waited on. - // `close: true` means the grid is expected to complete, so the run is - // halted only once the document has moved past it — that is what leaves a - // completed grid child under an incomplete root. Otherwise the grid is - // expected to stay open, and attaching is as far as it gets. - yield* race([ - options.close === true ? pastGrid.operation : attached.operation, - (function* (): Operation { - yield* sleep(1500); - // deno-lint-ignore no-console - const evts = yield* stream.readAll(); - // deno-lint-ignore no-console - console.log( - "PROBE3 closes", - JSON.stringify( - evts.filter((e) => e.type === "close").map((e) => [e.coroutineId, e.result.status]), - ), - ); - })(), - ]); - yield* sleep(5); + // `close: true` expects the grid to complete, so the run is interrupted only + // once the document has moved past it — which is what leaves a completed + // grid child under an incomplete root. Otherwise the grid is expected to + // stay open, and the run is interrupted once it has opened and the pane + // records the row reads are durable. + if (options.close === true || options.closeAfterFailure === true) { + yield* pastGrid.operation; + } else { + yield* attached.operation; + yield* panesSettled.operation; + } yield* task.halt(); return { outcome: { ok: false, error: new Error("interrupted") } as Result, @@ -447,6 +528,7 @@ function runInterrupted( shown: log.shown, events: log.events, ran, + errors, journal: yield* stream.readAll(), }; }); @@ -1162,8 +1244,9 @@ describe("Tier TG — durability and replay", () => { */ const CONTAINED_FAILURE = [ "", - "", - 'nothing interactive here', + "", + '', + '', "", "", "", @@ -1213,9 +1296,16 @@ describe("Tier TG — durability and replay", () => { const dir = yield* useDir(); const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { close: true }); + const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + closeAfterFailure: true, + shellFailsAfterAttach: 0, + }); expect(first.requests).toHaveLength(1); expect(completedGrid(first)).toBe(true); + // What the failure looked like, as the document reported it. + expect(first.errors.some((message) => message.includes("shell exited with status 1"))).toBe( + true, + ); // No provider at all on the resumed run: a replay that contacted one would // refuse, and the retained result does not need one. @@ -1224,6 +1314,10 @@ describe("Tier TG — durability and replay", () => { provider: false, }); + // The same result came back, rather than being derived again. + expect(second.errors).toEqual(first.errors); + // And the document carried on from it, exactly as it did the first time. + expect(second.ran).toContain(PAST_THE_GRID); expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); @@ -1232,7 +1326,8 @@ describe("Tier TG — durability and replay", () => { it("TG16: each pane is a durable child of the grid, in authored order", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, GRID, stream); + // Both panes settle, so both pane children have written their records. + const first = yield* runInterrupted(dir, GRID, stream, { settled: 2 }); const closes = first.journal.filter((event) => event.type === "close"); const paneIds = closes @@ -1277,10 +1372,17 @@ describe("Tier TG — durability and replay", () => { return { exitCode: 0 }; }; - const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + // The left pane settles; the shell holds, so only one pane record exists. + const first = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); expect(first.ran).toContain("left ran"); - const second = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + const second = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); // The completed pane came back from its retained outcome: its body did not // run again. @@ -1423,6 +1525,85 @@ describe("Tier TG — durability and replay", () => { }); }); + it("TG17: a malformed retained layout refuses before provider observation", function* () { + /** The retained layout, replaced by something the record cannot mean. */ + const damaged: [string, Json][] = [ + ["a missing member", { columns: 2, panes: [] }], + [ + "an extra member", + { + columns: 2, + rows: 1, + extra: true, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a mistyped member", + { + columns: "two", + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a pane out of position", + { + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 0, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a record that disagrees with itself", + { + columns: 2, + rows: 5, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 3, column: 1 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + ]; + + for (const [what, layout] of damaged) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + yield* runInterrupted(dir, GRID, stream); + + // The same journal with only its layout entry replaced, so nothing else + // about the continuation changes. + const damagedStream = new InMemoryStream(); + for (const event of yield* stream.readAll()) { + const isLayout = + event.type === "yield" && String(event.description.name).endsWith(":layout"); + yield* damagedStream.append( + isLayout && event.result.status === "ok" + ? { ...event, result: { status: "ok", value: layout } } + : event, + ); + } + + const second = yield* runDocument(dir, GRID, { stream: damagedStream }); + + expect(`${what}: ${second.outcome.ok}`).toBe(`${what}: false`); + // Refused while reading the record, before anything was asked for. + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.events.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.shown.size}`).toBe(`${what}: 0`); + } + }); + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 11d410fa..2a402702 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -279,9 +279,12 @@ function* runDurableChild( * `ephemeral()` instead — as this once did — put it in a scope that closed as * soon as the effect resolved, so every `yield* task` threw `halted`. * - * A retained `Close(cancelled)` here means the run was interrupted, not that a - * combinator chose against this child, so the child resumes its remaining work. - * See `CancelledChildPolicy`. + * A retained `Close(cancelled)` here is read for *why* it was cancelled, not + * treated as one thing. `"unwound"` — the run was interrupted, and nothing will + * cancel this child again — resumes the work it had left. `"caller"`, and a + * legacy record that says nothing, is a stop this caller chose, and is + * reproduced by suspending until its deterministic control flow chooses it + * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( childWorkflow: () => Workflow, diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 07285ce1..179ded8a 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { sleep, spawn, suspend } from "effection"; +import { sleep, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { durableRun } from "../run.ts"; diff --git a/packages/durable-streams/tests/parse.test.ts b/packages/durable-streams/tests/parse.test.ts index a30efa8a..3dbed100 100644 --- a/packages/durable-streams/tests/parse.test.ts +++ b/packages/durable-streams/tests/parse.test.ts @@ -270,3 +270,41 @@ describe("parseDurableEvent", () => { expect("polluted" in {}).toBe(false); }); }); + +describe("a cancelled close carries why it was cancelled (DEC-040)", () => { + const cancelled = (cancellation?: "caller" | "unwound"): DurableEvent => ({ + type: "close", + coroutineId: "root.0", + result: + cancellation === undefined ? { status: "cancelled" } : { status: "cancelled", cancellation }, + }); + + it("round-trips both reasons", function* () { + for (const reason of ["caller", "unwound"] as const) { + const event = cancelled(reason); + const record = serializeDurableEvent(event); + expect(accepted(record)).toEqual(event); + // And back to the same bytes, so a backend retains the event rather than + // an approximation of it. + expect(serializeDurableEvent(accepted(record))).toBe(record); + } + }); + + it("keeps a legacy record's absence an absence", function* () { + const parsed = accepted(serializeDurableEvent(cancelled())); + expect(parsed).toEqual(cancelled()); + expect(parsed.result.status === "cancelled" && "cancellation" in parsed.result).toBe(false); + }); + + it("refuses a reason it does not recognise", function* () { + const refused = refusal( + JSON.stringify({ + type: "close", + coroutineId: "root.0", + result: { status: "cancelled", cancellation: "somebody" }, + }), + ); + expect(refused).toBeInstanceOf(MalformedDurableEventError); + expect(refused.message).toContain("$.result.cancellation"); + }); +}); diff --git a/packages/durable-streams/tests/retained.test.ts b/packages/durable-streams/tests/retained.test.ts index 72f6251c..fc52ab19 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -15,9 +15,9 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { detachJson, retainEvents } from "../retained.ts"; +import { consumable, detachJson, retainEvents } from "../retained.ts"; import { ReplayIndex } from "../replay-index.ts"; -import type { DurableEvent, Json } from "../types.ts"; +import type { Close, DurableEvent, Json } from "../types.ts"; /** An event whose members answer from a list, counting reads per member. */ function shifting( @@ -443,3 +443,59 @@ describe("retained history — detached values stay ordinary JSON", () => { expect(caught).toBeInstanceOf(TypeError); }); }); + +describe("retention keeps why a child was cancelled (DEC-040)", () => { + const cancelled = (cancellation?: "caller" | "unwound"): DurableEvent => ({ + type: "close", + coroutineId: "root.0", + result: + cancellation === undefined ? { status: "cancelled" } : { status: "cancelled", cancellation }, + }); + + it("retains both reasons through the settled copy", function* () { + for (const reason of ["caller", "unwound"] as const) { + const [retained] = retainEvents([cancelled(reason)]); + expect(retained?.type).toBe("close"); + expect(retained?.result).toEqual({ status: "cancelled", cancellation: reason }); + } + }); + + it("leaves a legacy absence absent", function* () { + const [retained] = retainEvents([cancelled()]); + expect(retained?.result).toEqual({ status: "cancelled" }); + expect( + retained !== undefined && + retained.result.status === "cancelled" && + "cancellation" in retained.result, + ).toBe(false); + }); + + it("carries both reasons through an observable copy", function* () { + for (const reason of ["caller", "unwound"] as const) { + expect(consumable(cancelled(reason).result)).toEqual({ + status: "cancelled", + cancellation: reason, + }); + } + expect(consumable(cancelled().result)).toEqual({ status: "cancelled" }); + }); + + it("does not retain a reason it does not recognise", function* () { + // A record that says something else says nothing this reads, and the safe + // default — a deliberate stop — is what an absent reason already means. + const [retained] = retainEvents([ + { + type: "close", + coroutineId: "root.0", + result: { status: "cancelled", cancellation: "somebody" } as unknown as Close["result"], + }, + ]); + expect(retained?.result).toEqual({ status: "cancelled" }); + }); + + it("reaches the replay index with its reason intact", function* () { + const index = new ReplayIndex([cancelled("unwound")]); + const close = index.getClose("root.0"); + expect(close?.result).toEqual({ status: "cancelled", cancellation: "unwound" }); + }); +}); From 67ae30f048840cf4aa19f11f301aed99d580510a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 17:21:41 -0400 Subject: [PATCH 10/15] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Coordinate=20the=20D?= =?UTF-8?q?EC-040=20rows=20by=20signal,=20not=20by=20duration=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEC-040 block still slept where it meant to synchronise — my previous replacements silently failed to match after the file was reformatted, so none of them landed. The block is rewritten rather than patched. Every row now waits on something the run reported. A shared `living()` child resolves a `started` signal and then suspends, so each row halts or unwinds a child that is provably live rather than one a delay happened to reach. The caller resolves `halted` after performing its deliberate halt, so a run is interrupted only once both facts — the deliberate stop and the interruption — are in the journal. Non-revival is established by control flow rather than by waiting: the resumed run reaches its own `task.halt()` and says so, and a revived child would have recorded its mark before the caller could get there. The legacy-absence row signals once the child has been asked for and the request returned. No new timeout, and `sleep` stays imported because the lifetime rows above still use it deliberately. `retained.test.ts` drops the cast and the row it supported: rejecting an unrecognised reason is the parser's, proved there, and retention proves only that `"caller"`, `"unwound"` and a legacy absence survive. --- .../tests/durable-spawn.test.ts | 89 +++++++++++-------- .../durable-streams/tests/retained.test.ts | 15 +--- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 179ded8a..4d826b84 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -376,22 +376,42 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { ); } + /** + * A child that says when it is running and then waits to be stopped. + * + * Every row below halts or unwinds a *live* child, and `started` is how each + * one knows the child is live. Nothing waits for a duration: a child that + * never started never resolves it, and the row hangs rather than recording a + * cancellation of something that was not running. + */ + function living( + started: { resolve: () => void }, + mark?: (note: string) => void, + ): () => Workflow { + return function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + mark?.("first life"); + started.resolve(); + yield* suspend(); + return "never"; + })(), + ); + }; + } + it("records a deliberate halt as caller", function* () { const stream = new InMemoryStream(); + const started = withResolvers(); yield* durableRun( function* (): Workflow { - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - yield* suspend(); - return "never"; - })(), - ); - }); + const task = yield* durableSpawn(living(started)); yield* ephemeral( (function* (): Operation { - yield* sleep(1); + // The child is running; stopping it now is a deliberate stop of + // live work rather than of whatever a delay happened to reach. + yield* started.operation; yield* task.halt(); })(), ); @@ -405,18 +425,12 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { it("records a scope unwinding as unwound", function* () { const stream = new InMemoryStream(); + const started = withResolvers(); const run = yield* spawn(function* () { yield* durableRun( function* (): Workflow { - yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - yield* suspend(); - return "never"; - })(), - ); - }); + yield* durableSpawn(living(started)); yield* ephemeral( (function* (): Operation { yield* suspend(); @@ -427,7 +441,8 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(3); + // Interrupted while the child is live, said by the child. + yield* started.operation; yield* run.halt(); expect(yield* cancellations(stream)).toEqual(["unwound"]); @@ -436,24 +451,20 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { it("does not revive a child the caller deliberately halted", function* () { const marks: string[] = []; const stream = new InMemoryStream(); - // The caller halts the child, then the run is interrupted before it - // completes. Both facts are in the journal; only the first decides. + const started = withResolvers(); + const halted = withResolvers(); + + // The caller halts the child on purpose, and only then is the run + // interrupted — so the journal holds both facts and only the first decides. const first = yield* spawn(function* () { yield* durableRun( function* (): Workflow { - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - marks.push("first life"); - yield* suspend(); - return "never"; - })(), - ); - }); + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); yield* ephemeral( (function* (): Operation { - yield* sleep(1); + yield* started.operation; yield* task.halt(); + halted.resolve(); yield* suspend(); })(), ); @@ -462,14 +473,16 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(5); + yield* halted.operation; yield* first.halt(); expect(marks).toEqual(["first life"]); expect(yield* cancellations(stream)).toEqual(["caller"]); - // The resumed run reaches the same deliberate halt, so the child suspends - // until it does rather than performing work nobody asked to redo. + // The resumed run reaches the same deliberate halt. Getting there is the + // proof of non-revival: a revived child would have recorded its mark before + // the caller could halt it, and the mark list is checked after. + const reachedTheHalt = withResolvers(); const second = yield* spawn(function* () { yield* durableRun( function* (): Workflow { @@ -483,8 +496,8 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { }); yield* ephemeral( (function* (): Operation { - yield* sleep(1); yield* task.halt(); + reachedTheHalt.resolve(); yield* suspend(); })(), ); @@ -493,7 +506,7 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(10); + yield* reachedTheHalt.operation; yield* second.halt(); expect(marks).toEqual(["first life"]); @@ -509,6 +522,7 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { result: { status: "cancelled" }, }); + const asked = withResolvers(); const run = yield* spawn(function* () { yield* durableRun( function* (): Workflow { @@ -520,12 +534,15 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { })(), ); }); + // The child has been asked for and the request has returned. A + // revived child would have recorded its mark by now. + asked.resolve(); return yield* ephemeral(task); }, { stream }, ); }); - yield* sleep(10); + yield* asked.operation; yield* run.halt(); // Absent evidence is the safe direction: nothing is revived. diff --git a/packages/durable-streams/tests/retained.test.ts b/packages/durable-streams/tests/retained.test.ts index fc52ab19..b4a57970 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -17,7 +17,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { consumable, detachJson, retainEvents } from "../retained.ts"; import { ReplayIndex } from "../replay-index.ts"; -import type { Close, DurableEvent, Json } from "../types.ts"; +import type { DurableEvent, Json } from "../types.ts"; /** An event whose members answer from a list, counting reads per member. */ function shifting( @@ -480,19 +480,6 @@ describe("retention keeps why a child was cancelled (DEC-040)", () => { expect(consumable(cancelled().result)).toEqual({ status: "cancelled" }); }); - it("does not retain a reason it does not recognise", function* () { - // A record that says something else says nothing this reads, and the safe - // default — a deliberate stop — is what an absent reason already means. - const [retained] = retainEvents([ - { - type: "close", - coroutineId: "root.0", - result: { status: "cancelled", cancellation: "somebody" } as unknown as Close["result"], - }, - ]); - expect(retained?.result).toEqual({ status: "cancelled" }); - }); - it("reaches the replay index with its reason intact", function* () { const index = new ReplayIndex([cancelled("unwound")]); const close = index.getClose("root.0"); From eb9260233892ddc2aae5663f9783ae58d69f846a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 18:07:57 -0400 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=90=9B=20Observe=20every=20disposal?= =?UTF-8?q?=20surface,=20and=20make=20reader=20close=20cooperative=20(#730?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`Symbol.asyncDispose` bypassed the deliberate-stop evidence.** The task `durableSpawn` returns copied it from the original unchanged, so `await using` — or an explicit `task[Symbol.asyncDispose]()` — recorded `cancellation: "unwound"` and the next run revived the child. `halt()` and the async dispose are the same decision spelled two ways, and both are now observed. Awaiting a task is not a stop and is left exactly as it was. A regression disposes a live task, asserts the retained reason is `"caller"`, resumes the journal, and proves the body is not entered again. **Reader close no longer halts panes.** It asks them to stop: a pane races its work against a close signal, settles as `closed`, and records that outcome as its own. Nothing on the ordinary close path is a caller-cancelled child any more, so a resumed run restores a pane the reader closed rather than finding a cancelled child it must either re-enter or wait on forever. Statuses are published before anything is awaited, so a pane with slow finalizers cannot delay the outcome the grid already knows. **§6.21 now agrees with architecture.md and TG17.** Partial replay compares the resolved layout — columns and titles. Pane count, order and form come from the retained root and cannot diverge within a continuation, so a changed supplied file is ignored in favour of the retained structure; refusing a changed authored structure is a root-definition boundary this specification does not yet define. DEC-040 is unchanged and nothing deliberately stopped is revived. --- packages/core/src/terminal/grid.ts | 48 ++++++++++++-- packages/core/tests/terminal-grid.test.ts | 49 +++++++++++++- packages/durable-streams/combinators.ts | 38 +++++++---- .../tests/durable-spawn.test.ts | 64 ++++++++++++++++++- specs/executable-mdx-spec.md | 24 +++++-- 5 files changed, 199 insertions(+), 24 deletions(-) diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index fa2a09f0..fa91a00f 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -224,6 +224,11 @@ function presentGrid( const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); const startupFailed = withResolvers(); + // Reader close asks the panes to stop; it does not halt them. A pane that + // is asked settles as `closed` and records that outcome as its own, so a + // resumed run restores a pane the reader closed rather than finding a + // cancelled child it must either re-enter or wait on forever. + const closing = withResolvers(); let attached = false; for (const pane of work) { @@ -242,7 +247,15 @@ function presentGrid( const readiness = grid.readiness[index]!; panes.push( yield* paneChild(function* (): Operation { - return yield* runPane(pane, claim, composite, readiness, request, index); + return yield* runPane( + pane, + claim, + composite, + readiness, + request, + index, + closing.operation, + ); }), ); } @@ -298,12 +311,20 @@ function presentGrid( // lease released and the following sibling started only once nothing a pane // acquired can still act. grid.seal(); + closing.resolve(); + // Published before anything is awaited: once the reader has left, a pane + // that had not settled is closed, and that is true whether or not its own + // finalizers are quick about it. for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { yield* composite.update(pane.ordinal, "closed"); - outcomes[index] = { status: "closed", reason: "" }; } - yield* panes[index]!.halt(); + } + for (const [index] of work.entries()) { + // Awaited, not halted. Each pane settles on the close signal and records + // the outcome it reached, which is what a resumed run reads. + const outcome = yield* panes[index]!; + outcomes[index] ??= outcome; } const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); @@ -324,10 +345,29 @@ function runPane( readiness: { readonly acknowledged: boolean }, request: TerminalGridRequest, index: number, + closing: Operation, ): Operation { return (function* (): Operation { try { - yield* pane.run(claim, composite); + // The pane's work runs beside the close signal rather than under it. When + // the reader leaves, this settles as `closed` straight away and the work + // comes down in the enclosing scope's own teardown — so a pane whose + // finalizers are slow cannot hold up the outcome the grid already knows, + // and the record a resumed run reads is written either way. + const running = yield* spawn(() => pane.run(claim, composite)); + const closed = yield* race([ + (function* (): Operation { + yield* running; + return false; + })(), + (function* (): Operation { + yield* closing; + return true; + })(), + ]); + if (closed) { + return { status: "closed", reason: "" }; + } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work // itself raised nothing. diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 20d00432..4c6961c2 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -117,6 +117,7 @@ function useGridComponents( slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, afterAttach: () => Operation = function* () {}, + teardownHeld: () => Operation = function* () {}, ): Operation { return registerComponents([ { @@ -168,6 +169,20 @@ function useGridComponents( return ""; }, }, + { + // Holds the pane open, and blocks its own teardown until released — so a + // row can interrupt a run while reader-close teardown is in progress. + name: "SlowTeardown", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* ensure(function* () { + yield* teardownHeld(); + }); + yield* suspend(); + return ""; + }, + }, { // Waits until the grid has attached, so a pane can fail *after* the // barrier — which is the failure the grid contains as a status rather @@ -395,6 +410,21 @@ function runInterrupted( closeAfterFailure?: boolean; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; + /** Holds a `` pane's finalizer until this settles. */ + holdTeardown?: () => Operation; + /** Resolved once a pane's finalizer has been entered and is blocked. */ + onTeardownEntered?: () => void; + /** Interrupt the run when this settles rather than at a lifecycle signal. */ + interruptWhen?: Operation; + /** + * Called once cancellation has begun but before it is awaited. + * + * A row that blocks a finalizer has to release it *after* the parent is + * cancelled, or the cancellation would be waiting on the very thing the row + * is holding. Awaiting the halt afterwards is what proves teardown + * completed rather than merely started. + */ + releaseOnInterrupt?: () => void; /** * How many panes must have settled before the run is interrupted. * @@ -446,6 +476,12 @@ function runInterrupted( } }, () => attached.operation, + function* () { + options.onTeardownEntered?.(); + if (options.holdTeardown) { + yield* options.holdTeardown(); + } + }, ); yield* installControlledLauncher(); if (options.provider !== false) { @@ -514,13 +550,22 @@ function runInterrupted( // grid child under an incomplete root. Otherwise the grid is expected to // stay open, and the run is interrupted once it has opened and the pane // records the row reads are durable. - if (options.close === true || options.closeAfterFailure === true) { + if (options.interruptWhen !== undefined) { + yield* options.interruptWhen; + } else if (options.close === true || options.closeAfterFailure === true) { yield* pastGrid.operation; } else { yield* attached.operation; yield* panesSettled.operation; } - yield* task.halt(); + // Cancellation is begun, then released, then awaited. A row that blocks a + // finalizer has to release it after the parent is cancelled, or the + // cancellation would be waiting on the very thing the row is holding; and + // awaiting the halt afterwards is what proves teardown completed rather + // than merely started. + const halting = yield* spawn(() => task.halt()); + options.releaseOnInterrupt?.(); + yield* halting; return { outcome: { ok: false, error: new Error("interrupted") } as Result, output: "", diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 2a402702..b6b5b9a1 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -329,40 +329,54 @@ function createSpawnEffect( description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: observingHalt(routine.scope.run(child), evidence) }); + resolve({ ok: true, value: observingDisposal(routine.scope.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; } /** - * The same task, with a deliberate `halt()` recorded as it happens. + * The same task, with a deliberate stop recorded as it happens. * * The caller receives every member the task defines — `then`, `catch`, - * `finally`, the async dispose, the iterator — copied from the task itself - * along with its prototype, so the public surface is the one `Task` has always - * had. Only `halt` is replaced, and only to note that someone stopped the child - * on purpose before stopping it. + * `finally`, the iterator — copied from the task itself along with its + * prototype, so the public surface is the one `Task` has always had. + * + * **Every** way a caller can stop the task is observed, not just the obvious + * one. `halt()` and `await using` — which reaches `Symbol.asyncDispose` and + * never touches `halt` — are the same decision spelled two ways, and a stop + * recorded as involuntary through either of them would be resumed on the next + * run as work nobody asked to redo. Awaiting the task is not a stop and is left + * exactly as it was. * * Copied rather than proxied: a task's members are read-only and * non-configurable, and a proxy is required to hand back exactly what the - * target holds — so a `get` trap cannot substitute `halt` at all. Each copied + * target holds — so a `get` trap cannot substitute either of them. Each copied * member is the task's own closure and keeps working on the copy. */ -function observingHalt(task: Task, evidence: CancellationEvidence): Task { +function observingDisposal(task: Task, evidence: CancellationEvidence): Task { const members = Object.getOwnPropertyDescriptors(task); // Replaced in the descriptor map rather than on the finished object: the // task's own members are non-configurable, so redefining one afterwards // throws. - members.halt = { - value: () => { + const deliberate = (stop: () => R): (() => R) => { + return () => { evidence.deliberate = true; - return task.halt(); - }, + return stop(); + }; + }; + members.halt = { + value: deliberate(() => task.halt()), enumerable: true, configurable: false, writable: false, }; + members[Symbol.asyncDispose] = { + value: deliberate(() => task[Symbol.asyncDispose]()), + enumerable: false, + configurable: false, + writable: false, + }; const observed: Task = Object.create(Object.getPrototypeOf(task), members); return observed; } diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 4d826b84..1f35445f 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { sleep, spawn, suspend, withResolvers } from "effection"; +import { sleep, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { durableRun } from "../run.ts"; @@ -512,6 +512,68 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { expect(marks).toEqual(["first life"]); }); + it("records disposal through Symbol.asyncDispose as caller, and does not revive", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const started = withResolvers(); + const disposed = withResolvers(); + + // `await using` stops a task without ever touching `halt()`. It is the same + // decision spelled another way, so it has to leave the same evidence. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); + yield* ephemeral( + (function* (): Operation { + yield* started.operation; + yield* until(task[Symbol.asyncDispose]()); + disposed.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* disposed.operation; + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // Resuming that journal must not enter the child again. + const reachedTheDisposal = withResolvers(); + const second = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("revived"); + return "revived"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* until(task[Symbol.asyncDispose]()); + reachedTheDisposal.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* reachedTheDisposal.operation; + yield* second.halt(); + + expect(marks).toEqual(["first life"]); + }); + it("reads a record with no reason as caller", function* () { const marks: string[] = []; const stream = new InMemoryStream(); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index d7dab341..bb00fb25 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -8758,11 +8758,25 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. -Partial replay compares the complete resolved layout first and refuses a -changed column count, title, form, count, or order before provider work. It then -builds a new live composite. Completed pane children appear as already-settled -statuses and perform no effects; incomplete children continue from their own -durable records. An incomplete `` keeps the exact +Partial replay compares the **resolved** layout first — the column count and +each pane's title — and refuses a change before the foreground lease is taken +and before any provider is contacted. It then builds a new live composite. +Completed pane children appear as already-settled statuses and perform no +effects; incomplete children continue from their own durable records. + +Pane count, order and form are not compared, because they cannot differ. A +continuation executes the root document the journal retained: the source the new +invocation supplies is not read, not compared and not refused, so a grid's +authored structure is fixed for the life of a journal and comparing it would +compare a value with itself. A supplied file that says something else is +ignored in favour of the retained structure, and the grid a continuation opens +is the one that was recorded. What a fixed retained document can still resolve +differently is `columns` and each `title` — props are not restored across a +continuation — and those are exactly what the comparison covers. + +Refusing a changed authored structure is a root-definition compatibility +question rather than a grid one, and belongs to a versioned root boundary this +specification does not yet define. An incomplete `` keeps the exact `prepared`/`detached` replay and logical-session identity rules defined by the native launch specification. An incomplete self-closing pane starts the current authorized default shell and does not claim continuity of shell process or From 183387074b5ee5cc7ceba72e055f657d3b15154c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 18:48:27 -0400 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=93=9D=20Define=20reader-close=20ca?= =?UTF-8?q?ncellation=20commit=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 58 ++++++++++++++++++--- packages/durable-streams/specs/DECISIONS.md | 12 +++-- specs/executable-mdx-spec.md | 41 ++++++++++++--- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/architecture.md b/architecture.md index f850d3af..1f56af31 100644 --- a/architecture.md +++ b/architecture.md @@ -2917,16 +2917,48 @@ The grid runs as one structured scope: 6. Once attached, each pane settles independently and keeps its final status visible while siblings continue. The composite remains present after all panes settle until the reader closes or leaves it. -7. Closing begins an ordered teardown: prevent new pane launches, cancel live - pane scopes, await every child and finalizer, detach and destroy the exact - provider composite, restore the root terminal, and only then release the - foreground lease and settle the grid. The document never continues while an - observable pane child or provider-owned process can still act through the - grid. +7. Reader close first crosses a live close boundary, then begins an ordered + teardown: prevent new pane launches, ask live pane children to close, await + every child and finalizer, detach and destroy the exact provider composite, + restore the root terminal, and only then release the foreground lease and + settle the grid. The document never continues while an observable pane child + or provider-owned process can still act through the grid. + +The provider's `closed()` settlement proposes the live close boundary. The +boundary is crossed when the grid owner has entered a cancellation-deferred +await of the grid's durable child and acknowledges that proposal; only then may +the child signal pane close. That await ends only when the task has settled and +its durable `Close` has been acknowledged, not when the grid body has merely +chosen an outcome. This handshake has no provider identity and is not itself +journaled. + +Reader-close intent becomes durable only as that completed grid `Close`, after +pane and provider teardown. There is no standalone durable "closing" state. The +gap between observing close and committing it is safe because ordinary parent +cancellation is held pending across the whole gap. A cancellation that arrives +before the owner acknowledges the close boundary cancels the active grid. One that arrives +afterward does not rewrite grid or pane outcomes: panes already settled keep +their outcomes, each then-live pane completes its own scope and retains +`closed`, and the grid retains the same `reader` or `failed` result it would +have retained without the cancellation. Once the grid child is durably closed, +the pending cancellation is delivered to the parent, so no following document +sibling runs in that attempt. A fatal or cleanup failure still takes its +existing precedence over cancellation. + +Pane work and every finalizer it installs live inside that pane's durable child +scope. Reader close is cooperative at the durable boundary: it asks the pane to +close and awaits it; it never halts the pane's durable task. The pane may stop +its live nested work as part of its own scope teardown, but its durable child +does not settle as `closed` or write `Close(ok)` until that work and its finalizers +have settled. This preserves the pane's ordinal-derived identity and never +turns a deliberate reader close into a caller-cancelled durable child that a +later run could revive or wait on forever. Parent cancellation follows the same teardown from preparation, readiness, or -the active grid and remains cancellation. A provider or host failure cancels -the whole grid and is the grid's canonical failure. An ordinary pane failure +the active grid and remains cancellation. Once reader close has crossed its +live boundary, the close result is committed first and that cancellation is +observed by the parent afterward. A provider or host failure cancels the whole +grid and is the grid's canonical failure. An ordinary pane failure after attachment is contained as that pane's status and does not cancel its siblings. When the reader closes the grid, core fails it with the first failed pane in authored order; cancellation initiated by grid teardown is not a pane @@ -2977,6 +3009,16 @@ a terminal provider, starting a shell, expanding pane content, acquiring an Agent session, or launching a native UI. The structured durable boundary owns that short circuit; a public replay context does not. +The reader-close handshake makes cancellation during teardown a completed-grid +case rather than a new partial-replay state. When a pane finalizer delays close +and parent cancellation arrives, the first attempt still finishes every pane +and provider finalizer, writes the pane outcomes and completed grid `Close`, and +only then reports cancellation to its parent. A continuation claims that +completed child and resumes after it without recreating the provider or +re-entering pane work. A host loss can still interrupt the unjournaled live +teardown; panes whose `Close` was acknowledged remain complete, while any pane +and grid without a completed record follow the existing partial-replay rules. + Partial replay compares the **resolved** layout and refuses divergence before provider work. diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 8c422ffb..6be5d3b6 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -567,11 +567,13 @@ Updated before completion of every phase and committed at the end of each phase. *awaits* a task it previously halted has diverged, and divergence is the honest answer there rather than a silent revival. - **Consequences:** Terminal grids get what they need without reviving anything - deliberately stopped. A grid halts each pane task when the reader closes, so - those panes retain `"caller"` — and the grid child completes, so a resumed run - short-circuits the whole region and never reaches them. The case that must - resume — the run interrupted while the grid is open — unwinds the grid and - pane children, retains `"unwound"`, and continues. + deliberately stopped. Reader close cooperatively closes each pane inside its + durable child and waits for that child to retain `closed`; it does not halt + the durable pane task. Once reader close takes effect, later parent + cancellation is deferred through pane and grid completion, so a resumed run + short-circuits the completed grid and never reaches those panes. The case that + must resume — the run interrupted while the grid is still active — unwinds + the grid and pane children, retains `"unwound"`, and continues. - **Scope:** The reason is retained evidence, not authority. Nothing reads it from outside `runDurableChild`, no public API exposes it, and no caller chooses a policy: the policy stays fixed at each combinator's call site. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index bb00fb25..2178dce8 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -8682,11 +8682,23 @@ selects success or failure; a live pane cancelled only because the reader closed the grid becomes `closed`. These states display core's result and never author it. -Close first prevents new pane launches, then cancels live pane scopes, awaits -every child and provider finalizer, destroys the exact composite, restores the -root terminal, and releases the foreground lease. Only then does the element -settle and a later document sibling begin. There is no implicit timeout; parent -cancellation and an enclosing execution deadline use the same complete teardown. +The provider's `closed()` operation proposes reader close. Reader close takes +effect when the grid owner has entered a cancellation-deferred await of the +grid's durable child and acknowledges that proposal. Before that acknowledgement +reaches the child, no close signal reaches a pane. Close then prevents new pane +launches, asks every live pane child to close, awaits every child and provider +finalizer, destroys the exact composite, restores the root terminal, and +releases the foreground lease. The deferred await ends only after the durable +child has settled and its `Close` has been acknowledged. Only then does the +element settle and a later document sibling begin. There is no implicit timeout; +parent cancellation and an enclosing execution deadline use the same complete +teardown. + +Pane work and the finalizers it installs are scoped inside that pane's durable +child. Reader close does not halt that durable child. It cooperatively closes +the pane's live work and the child retains `closed` only after its work and +finalizers have settled. A pane that had already succeeded or failed keeps that +outcome. #### Native launch ownership inside a pane @@ -8721,6 +8733,12 @@ continues. A provider or host failure cancels the composite and is the grid failure. Parent cancellation remains cancellation rather than becoming a pane failure. +If it arrives after reader close takes effect, reader close still decides the +grid and pane outcomes: then-live panes retain `closed`, already-settled panes +keep their outcomes, and the grid retains `reader` or `failed` under the normal +authored-order rule. The cancellation remains pending until complete teardown +and the grid's durable close, then reaches the parent before any following +document sibling runs. A fatal or cleanup failure keeps its existing precedence. All acquired resources are finalized even when an earlier failure already decides the result, and the existing fatal-infrastructure and cleanup precedence still applies. Before the first cancellation signal, the provider snapshots @@ -8758,6 +8776,16 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. +Reader-close intent has no separate durable `closing` state. It becomes durable +as the completed grid `Close`, after all pane and provider teardown. The live +handshake described above holds later parent cancellation across that interval, +so cancellation during a blocked pane finalizer still produces completed pane +and grid records before it reaches the parent. A continuation therefore claims +the completed grid and proceeds without waiting on or re-entering a pane the +reader closed. If the host itself disappears before completion, the journal has +no completed grid close and the ordinary partial-replay rules apply; any pane +whose completed `Close` was acknowledged remains settled. + Partial replay compares the **resolved** layout first — the column count and each pane's title — and refuses a change before the foreground lease is taken and before any provider is contacted. It then builds a new live composite. @@ -10768,7 +10796,8 @@ test derives a core result from a provider identifier. | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | | 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; 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 | +| 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 | ### Tier CR — Component registration and resolution From 13bf32289dfa59db6ef436fee2b9a4556b8a3fb5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:04:51 -0400 Subject: [PATCH 13/15] =?UTF-8?q?=E2=9C=A8=20Implement=20the=20reader-clos?= =?UTF-8?q?e=20cancellation=20commit=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the amendment at 18338707 without revising it. **The live handshake.** `composite.closed()` settling now only *proposes* the boundary. The grid's durable child publishes that proposal and waits; the owner awaiting the child acknowledges it; and only then does the grid seal admission and ask its panes to close. The handshake is one live rendezvous — no provider identity, nothing journaled. **Committing an outcome before the scope finishes unwinding.** A durable child can now declare its terminal value, and `runDurableChild` records that value if the child never reaches a normal ending. That is the piece the contract needs: the grid commits its retained record as the boundary is crossed, and each pane live at that moment commits `closed`, so a cancellation arriving while pane and provider finalizers are still running records what close decided rather than a cancellation. Committing is live state; it reaches the journal only as the ordinary `Close`. A child that returns or throws normally overrides it, and a child that never committed still records the cancellation it actually reached — DEC-040 untouched. Cancellation stays deferred because Effection completes a child's teardown — pane finalizers, provider destroy, terminal restoration, lease release, the `Close` append and the task's settlement — before the halt reaches the owner. **Pane work stays inside its ordinal-derived durable child.** Reader close asks the pane to close; it never halts the pane's durable task. The pane commits `closed`, stops its live nested work through its own scope, and settles only once that work and its finalizers have settled. No durable closing marker was added, and completed replay is unchanged. --- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/grid.ts | 146 +++++++++++++++++++--- packages/core/tests/terminal-grid.test.ts | 9 ++ packages/durable-streams/combinators.ts | 40 +++++- packages/durable-streams/mod.ts | 1 + 5 files changed, 178 insertions(+), 22 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 2dc5932b..a2fe96e1 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2177,11 +2177,11 @@ function* expandTerminalGrid( // child never runs. yield* recordGridLayout(identity, toRequest(layout)); - const retained = yield* durableGrid(function* () { + const retained = yield* durableGrid(function* (boundary, commit) { const work = structure.panes.map((pane, index) => paneWork(pane, layout.cells[index]!.title, site), ); - return yield* openTerminalGrid(layout, work); + return yield* openTerminalGrid(layout, work, boundary, commit); }); const failed = retained.panes.find((pane) => pane.status === "failed"); diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index fa91a00f..2a4677fe 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -38,6 +38,55 @@ import { import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; +/** + * The live boundary reader close crosses (architecture.md §Atomic presentation + * and settlement). + * + * The provider settling `closed()` only *proposes* the boundary. It is crossed + * when the owner awaiting the grid's durable child acknowledges that proposal + * from inside its own cancellation-deferred await — and only then may the grid + * seal admission and ask its panes to close. + * + * Nothing here is journaled and nothing here names a provider: it is one live + * rendezvous between a durable child and the owner waiting on it. What it buys + * is the ordering the contract needs — a cancellation arriving before the + * acknowledgement cancels the active grid, and one arriving after it waits for + * the grid to finish closing. + */ +export interface CloseBoundary { + /** The child: publish the proposal and wait for it to be acknowledged. */ + propose(): Operation; + /** The owner: settle once close has been proposed. */ + proposed(): Operation; + /** The owner: cross the boundary. */ + acknowledge(): void; + /** Whether the boundary has been crossed. */ + readonly acknowledged: boolean; +} + +export function createCloseBoundary(): CloseBoundary { + const proposal = withResolvers(); + const acknowledgement = withResolvers(); + let crossed = false; + return { + *propose() { + proposal.resolve(); + yield* acknowledgement.operation; + }, + proposed: () => proposal.operation, + acknowledge() { + if (crossed) { + return; + } + crossed = true; + acknowledgement.resolve(); + }, + get acknowledged() { + return crossed; + }, + }; +} + /** How one pane ended, as the journal records it. */ export type PaneStatus = "succeeded" | "failed" | "closed"; @@ -148,6 +197,8 @@ export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layo export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], + boundary: CloseBoundary, + commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { const installation = yield* terminalInstallation(); @@ -167,7 +218,7 @@ export function openTerminalGrid( used: false, settled: false, *run(composite) { - settled = yield* presentGrid(request, composite, work); + settled = yield* presentGrid(request, composite, work, boundary, commit); grid.settled = true; }, }; @@ -209,6 +260,8 @@ function presentGrid( request: TerminalGridRequest, composite: TerminalComposite, work: readonly PaneWork[], + boundary: CloseBoundary, + commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { // Registered before a single pane starts: a composite that was presented is @@ -246,7 +299,9 @@ function presentGrid( const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; panes.push( - yield* paneChild(function* (): Operation { + yield* paneChild(function* ( + commitPane: (outcome: RetainedPaneOutcome) => void, + ): Operation { return yield* runPane( pane, claim, @@ -255,6 +310,7 @@ function presentGrid( request, index, closing.operation, + commitPane, ); }), ); @@ -304,6 +360,12 @@ function presentGrid( // what finishes the grid, not the last pane exiting. yield* composite.closed(); + // Proposed, then acknowledged by the owner from inside its own + // cancellation-deferred await. Until it is crossed, a cancellation cancels + // the active grid under the ordinary rules; once crossed, the close result + // is committed first and the cancellation waits for it. + yield* boundary.propose(); + // Close prevents new work first, then takes the live panes down: a pane // cancelled by the close is `closed`, which is not a failed pane. Every // child is awaited here, and the provider's finalizers run in the scope's @@ -312,6 +374,12 @@ function presentGrid( // acquired can still act. grid.seal(); closing.resolve(); + // The outcome is decided the moment the boundary is crossed: every settled + // pane keeps its own, every pane still live is closed. Committed here, so a + // cancellation arriving while pane and provider finalizers are still going + // records what close decided rather than a cancellation. + const decided = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + commit(retained(request, decided, firstReason(decided))); // Published before anything is awaited: once the reader has left, a pane // that had not settled is closed, and that is true whether or not its own // finalizers are quick about it. @@ -329,11 +397,7 @@ function presentGrid( const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); const reason = firstReason(settled); - return { - layout: retainedLayout(request), - close: reason === undefined ? "reader" : "failed", - panes: settled, - }; + return retained(request, settled, reason); }); } @@ -346,6 +410,7 @@ function runPane( request: TerminalGridRequest, index: number, closing: Operation, + commitPane: (outcome: RetainedPaneOutcome) => void, ): Operation { return (function* (): Operation { try { @@ -366,7 +431,17 @@ function runPane( })(), ]); if (closed) { - return { status: "closed", reason: "" }; + const outcome: RetainedPaneOutcome = { status: "closed", reason: "" }; + // Decided at the boundary, so a cancellation arriving while this pane's + // finalizers are still going records the close rather than a + // cancellation — and never a caller-cancelled child a later run would + // have to revive or wait on. + commitPane(outcome); + // The nested work is stopped by this pane's own scope, and its + // finalizers are awaited here: the durable child settles only once they + // have. + yield* running.halt(); + return outcome; } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work @@ -386,6 +461,19 @@ function runPane( })(); } +/** The record one grid settled to. */ +function retained( + request: TerminalGridRequest, + panes: readonly RetainedPaneOutcome[], + reason: string | undefined, +): RetainedGrid { + return { + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: [...panes], + }; +} + /** The first failed pane's sentence in authored order, which is the grid's. */ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): string | undefined { return outcomes.find((outcome) => outcome?.status === "failed")?.reason; @@ -408,16 +496,19 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st * Without a journal there is no child to derive, and the work simply runs. */ function paneChild( - body: () => Operation, + body: (commit: (outcome: RetainedPaneOutcome) => void) => Operation, ): Operation> { return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal behind this run: an ordinary spawned child. - return yield* spawn(body); + // No journal behind this run: an ordinary spawned child, with nothing to + // commit an outcome into. + return yield* spawn(() => body(() => {})); } - return yield* durableSpawn(function* (): Workflow { - return yield* ephemeral(body()); + return yield* durableSpawn(function* ( + commit: (outcome: RetainedPaneOutcome) => void, + ): Workflow { + return yield* ephemeral(body(commit)); }); })(); } @@ -430,14 +521,35 @@ function paneChild( * no shell starts — and claiming the completed child claims every pane history * beneath it, so a resumed run starts nothing. */ -export function durableGrid(live: () => Operation): Operation { +export function durableGrid( + live: (boundary: CloseBoundary, commit: (grid: RetainedGrid) => void) => Operation, +): Operation { return (function* (): Operation { + const boundary = createCloseBoundary(); const durable = yield* DurableContext.get(); if (durable === undefined) { - return yield* live(); + // No journal to commit into, so the boundary is crossed as soon as it is + // proposed and the grid closes in one step. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + return yield* live(boundary, () => {}); } - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral(live()); + const task = yield* durableSpawn(function* ( + commit: (grid: RetainedGrid) => void, + ): Workflow { + return yield* ephemeral(live(boundary, commit)); + }); + // The owner's cancellation-deferred await. Acknowledging happens here, + // inside it: from this point a cancellation cannot pre-empt the close, + // because the child commits its outcome as the boundary is crossed and + // Effection completes a child's teardown — its pane and provider + // finalizers, its `Close` append and its settlement — before the halt + // reaches whoever asked for it. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); }); return yield* task; })(); diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 4c6961c2..f8081359 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -118,6 +118,7 @@ function useGridComponents( onMark: (mark: string) => void = () => {}, afterAttach: () => Operation = function* () {}, teardownHeld: () => Operation = function* () {}, + teardownArmed: () => void = () => {}, ): Operation { return registerComponents([ { @@ -179,6 +180,9 @@ function useGridComponents( yield* ensure(function* () { yield* teardownHeld(); }); + // Armed: the finalizer is installed and this pane is live, which is + // what a row waits for before letting the reader leave. + teardownArmed(); yield* suspend(); return ""; }, @@ -408,6 +412,8 @@ function runInterrupted( * would record the pane as cancelled by the close instead. */ closeAfterFailure?: boolean; + /** Let the reader leave only once a `` pane is armed. */ + closeWhenArmed?: boolean; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ @@ -467,6 +473,8 @@ function runInterrupted( }, }); const paneFailed = withResolvers(); + // Resolved once a `` pane has installed its finalizer. + const armed = withResolvers(); yield* useGridComponents( ran, [], @@ -482,6 +490,7 @@ function runInterrupted( yield* options.holdTeardown(); } }, + () => armed.resolve(), ); yield* installControlledLauncher(); if (options.provider !== false) { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index b6b5b9a1..ed50a292 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -98,8 +98,24 @@ function retainedCancellation(close: Close): Cancellation { return close.result.cancellation === "unwound" ? "unwound" : "caller"; } +/** + * Declare a child's terminal value before its scope has finished unwinding. + * + * A child that has already decided what it settled to — a terminal grid that + * crossed its reader-close boundary, say — must record that outcome even if the + * run is cancelled while its finalizers are still going. Without this, a halt + * arriving during teardown loses the decision and the child records a + * cancellation instead, which is a different thing entirely. + * + * Committing is live state, never journaled on its own: the value reaches the + * journal only as the child's ordinary `Close`, written where it always was. + * A child that goes on to return or throw normally overrides what it committed, + * because that is the outcome it actually reached. + */ +export type CommitOutcome = (value: T) => void; + function* runDurableChild( - childWorkflow: () => Workflow, + childWorkflow: (commit: CommitOutcome) => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", @@ -163,12 +179,30 @@ function* runDurableChild( let closeEvent: Close | undefined; let suppressClose = false; + // What the child declared it had settled to before its scope finished coming + // down. Read only when the child never reached a normal ending. + let committed: { value: T } | undefined; + const commit: CommitOutcome = (value) => { + committed = { value }; + }; yield* ensure(function* () { if (suppressClose || activeDurabilityFailure(childCtx)) { return; } + // A child that committed an outcome and was then cancelled mid-teardown + // settled: the decision was made before the cancellation arrived, and the + // record has to say so. The cancellation is still a cancellation for + // whoever asked for it — it is simply delivered after this. + if (!closeEvent && committed !== undefined && !replayIndex.firstUnaligned(childId)) { + closeEvent = { + type: "close", + coroutineId: childId, + result: { status: "ok", value: committed.value as Json }, + }; + } + // closeEvent still undefined means the child was cancelled before the // normal-return or catch path ran. if (!closeEvent) { @@ -205,7 +239,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read // DurableContext from the scope, so they'll use childId. - const result: T = yield* childWorkflow(); + const result: T = yield* childWorkflow(commit); const durabilityFailure = activeDurabilityFailure(childCtx); if (durabilityFailure) { @@ -287,7 +321,7 @@ function* runDurableChild( * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( - childWorkflow: () => Workflow, + childWorkflow: (commit: CommitOutcome) => Workflow, ): Workflow> { return (function* (): Workflow> { // Reading the context and allocating the child id is ordinary scope setup: diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 581dabd5..4d2f5747 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -102,6 +102,7 @@ export { durableAction, durableCall, durableSleep, versionCheck } from "./operat // Structured concurrency combinators export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; +export type { CommitOutcome } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; From f4b0079acdccd360d04f68cd9fc15d1890636fb8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:34:37 -0400 Subject: [PATCH 14/15] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Defer=20the=20grid?= =?UTF-8?q?=20owner's=20cancellation=20at=20the=20live=20close=20boundary?= =?UTF-8?q?=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements architecture 18338707 at the owner boundary. The grid's durable child now runs in a scope of its own — a child of the owner's, so it inherits every context the document runs under, and its own so that tearing the owner down does not reach it first. A finalizer registered after that scope exists runs before it is destroyed, and that is the cancellation-deferred await: once the owner has acknowledged the provider's close proposal, the grid and its panes finish teardown and append their ordinary completed Close records, and only then does the cancellation carry on to the parent. Removes the exported CommitOutcome/durableSpawn(commit) API. Cancellation is never turned into success inside runDurableChild; durableSpawnIn only says where a child lives, and grants nothing a caller does not already have. TG19 proves the ordering with signals alone: a live pane arms a blocking finalizer, the reader leaves, the finalizer is entered and held, cancellation begins, the finalizer is released, and the run ends with the composite destroyed, a completed grid Close retained, the live pane retained as closed — and the sibling after the grid never reached. The continuation then replays past it with no provider, no pane body and no finalizer re-entered. TG6 isolates paired-pane sequencing on its own: the reader leaves only once the pane's second component has run. --- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/grid.ts | 128 ++++++++++++++-------- packages/core/tests/terminal-grid.test.ts | 117 +++++++++++++++++++- packages/durable-streams/combinators.ts | 75 ++++++------- packages/durable-streams/mod.ts | 3 +- 5 files changed, 233 insertions(+), 94 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index a2fe96e1..16b03c1e 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2177,11 +2177,11 @@ function* expandTerminalGrid( // child never runs. yield* recordGridLayout(identity, toRequest(layout)); - const retained = yield* durableGrid(function* (boundary, commit) { + const retained = yield* durableGrid(function* (boundary) { const work = structure.panes.map((pane, index) => paneWork(pane, layout.cells[index]!.title, site), ); - return yield* openTerminalGrid(layout, work, boundary, commit); + return yield* openTerminalGrid(layout, work, boundary); }); const failed = retained.panes.find((pane) => pane.status === "failed"); diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 2a4677fe..f96fb091 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -22,9 +22,25 @@ * desynchronise the journal on the next run. */ -import { ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation, Task } from "effection"; -import { DurableContext, durableSpawn, ephemeral } from "@executablemd/durable-streams"; +import { + createScope, + Err, + ensure, + race, + scoped, + Ok, + spawn, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; +import { + DurableContext, + durableSpawn, + durableSpawnIn, + ephemeral, +} from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -198,7 +214,6 @@ export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], boundary: CloseBoundary, - commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { const installation = yield* terminalInstallation(); @@ -218,7 +233,7 @@ export function openTerminalGrid( used: false, settled: false, *run(composite) { - settled = yield* presentGrid(request, composite, work, boundary, commit); + settled = yield* presentGrid(request, composite, work, boundary); grid.settled = true; }, }; @@ -261,7 +276,6 @@ function presentGrid( composite: TerminalComposite, work: readonly PaneWork[], boundary: CloseBoundary, - commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { // Registered before a single pane starts: a composite that was presented is @@ -299,9 +313,7 @@ function presentGrid( const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; panes.push( - yield* paneChild(function* ( - commitPane: (outcome: RetainedPaneOutcome) => void, - ): Operation { + yield* paneChild(function* (): Operation { return yield* runPane( pane, claim, @@ -310,7 +322,6 @@ function presentGrid( request, index, closing.operation, - commitPane, ); }), ); @@ -374,12 +385,6 @@ function presentGrid( // acquired can still act. grid.seal(); closing.resolve(); - // The outcome is decided the moment the boundary is crossed: every settled - // pane keeps its own, every pane still live is closed. Committed here, so a - // cancellation arriving while pane and provider finalizers are still going - // records what close decided rather than a cancellation. - const decided = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); - commit(retained(request, decided, firstReason(decided))); // Published before anything is awaited: once the reader has left, a pane // that had not settled is closed, and that is true whether or not its own // finalizers are quick about it. @@ -410,7 +415,6 @@ function runPane( request: TerminalGridRequest, index: number, closing: Operation, - commitPane: (outcome: RetainedPaneOutcome) => void, ): Operation { return (function* (): Operation { try { @@ -431,17 +435,11 @@ function runPane( })(), ]); if (closed) { - const outcome: RetainedPaneOutcome = { status: "closed", reason: "" }; - // Decided at the boundary, so a cancellation arriving while this pane's - // finalizers are still going records the close rather than a - // cancellation — and never a caller-cancelled child a later run would - // have to revive or wait on. - commitPane(outcome); // The nested work is stopped by this pane's own scope, and its - // finalizers are awaited here: the durable child settles only once they - // have. + // finalizers are awaited here: the durable child settles as closed only + // once that work and its finalizers have settled. yield* running.halt(); - return outcome; + return { status: "closed", reason: "" }; } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work @@ -496,19 +494,16 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st * Without a journal there is no child to derive, and the work simply runs. */ function paneChild( - body: (commit: (outcome: RetainedPaneOutcome) => void) => Operation, + body: () => Operation, ): Operation> { return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal behind this run: an ordinary spawned child, with nothing to - // commit an outcome into. - return yield* spawn(() => body(() => {})); + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); } - return yield* durableSpawn(function* ( - commit: (outcome: RetainedPaneOutcome) => void, - ): Workflow { - return yield* ephemeral(body(commit)); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); }); })(); } @@ -522,35 +517,72 @@ function paneChild( * beneath it, so a resumed run starts nothing. */ export function durableGrid( - live: (boundary: CloseBoundary, commit: (grid: RetainedGrid) => void) => Operation, + live: (boundary: CloseBoundary) => Operation, ): Operation { return (function* (): Operation { const boundary = createCloseBoundary(); const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal to commit into, so the boundary is crossed as soon as it is + // No journal to finish into, so the boundary is crossed as soon as it is // proposed and the grid closes in one step. yield* spawn(function* () { yield* boundary.proposed(); boundary.acknowledge(); }); - return yield* live(boundary, () => {}); + return yield* live(boundary); } - const task = yield* durableSpawn(function* ( - commit: (grid: RetainedGrid) => void, - ): Workflow { - return yield* ephemeral(live(boundary, commit)); + + // The grid's durable child runs in a scope of its own — a child of this one, + // so it inherits every context the document runs under, and its own so that + // tearing this one down does not reach the child first. + // + // That ordering is what makes the await below genuinely deferred. A scope + // runs its finalizers in reverse, so one registered after this scope exists + // runs before this scope is destroyed: the grid and its panes finish their + // own teardown and append their ordinary completed `Close` records, and only + // then does the cancellation carry on to the parent. + const [detached, destroy] = createScope(yield* useScope()); + const held: { + task?: Task; + outcome?: Result; + } = {}; + + // Registered after the scope and before the await, so a cancellation runs it + // and waits for it. Before the boundary is crossed there is nothing to + // finish, and destroying the scope cancels the active grid under the + // ordinary rules. + yield* ensure(function* () { + if (held.task !== undefined && boundary.acknowledged && held.outcome === undefined) { + held.outcome = yield* finish(held.task); + } + yield* until(destroy()); + }); + + held.task = yield* durableSpawnIn(detached, function* (): Workflow { + return yield* ephemeral(live(boundary)); }); - // The owner's cancellation-deferred await. Acknowledging happens here, - // inside it: from this point a cancellation cannot pre-empt the close, - // because the child commits its outcome as the boundary is crossed and - // Effection completes a child's teardown — its pane and provider - // finalizers, its `Close` append and its settlement — before the halt - // reaches whoever asked for it. + // The owner acknowledges, and only the owner. By the time it can, the + // finalizer above is already registered — so crossing the boundary and + // being committed to finishing the child are the same moment. yield* spawn(function* () { yield* boundary.proposed(); boundary.acknowledge(); }); - return yield* task; + + held.outcome = yield* finish(held.task); + yield* until(destroy()); + if (!held.outcome.ok) { + throw held.outcome.error; + } + return held.outcome.value; })(); } + +/** Await one grid child, keeping how it ended rather than re-throwing it here. */ +function* finish(task: Task): Operation> { + try { + return Ok(yield* task); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index f8081359..52571e69 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -414,6 +414,8 @@ function runInterrupted( closeAfterFailure?: boolean; /** Let the reader leave only once a `` pane is armed. */ closeWhenArmed?: boolean; + /** Let the reader leave only once this tripwire mark has been recorded. */ + closeWhenMarked?: string; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ @@ -475,6 +477,7 @@ function runInterrupted( const paneFailed = withResolvers(); // Resolved once a `` pane has installed its finalizer. const armed = withResolvers(); + const marked = withResolvers(); yield* useGridComponents( ran, [], @@ -482,6 +485,9 @@ function runInterrupted( if (mark === PAST_THE_GRID) { pastGrid.resolve(); } + if (mark === options.closeWhenMarked) { + marked.resolve(); + } }, () => attached.operation, function* () { @@ -499,9 +505,13 @@ function runInterrupted( close: options.closeAfterFailure === true ? () => paneFailed.operation - : options.close === true - ? immediateClose() - : () => suspend(), + : options.closeWhenMarked !== undefined + ? () => marked.operation + : options.closeWhenArmed === true + ? () => armed.operation + : options.close === true + ? immediateClose() + : () => suspend(), ...(options.shellFailsAfterAttach !== undefined ? { shell: function* (ordinal: number, spawned: () => void) { @@ -1077,6 +1087,25 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).not.toContain("this pane gave up"); }); + it("TG6: a paired pane runs every component in its body, in order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // The reader leaves only once the pane's *second* component has run, so a + // pane body that stopped after the first would never let the grid close — + // a hang rather than a pass. + const run = yield* runInterrupted( + dir, + heldDocument(2, [ + '', + '', + ]), + stream, + { close: true, closeWhenMarked: "second component" }, + ); + + expect(run.ran).toContain("second component"); + }); + it("TG9: with no provider installed, no pane body or shell runs", function* () { const dir = yield* useDir(); const run = yield* runDocument( @@ -1326,6 +1355,26 @@ describe("Tier TG — durability and replay", () => { ); } + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + for (const event of run.journal) { + if ( + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + event.result.status === "ok" + ) { + const value = event.result.value; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const panes = Reflect.get(value, "panes"); + if (Array.isArray(panes)) { + return panes; + } + } + } + } + return []; + } + it("TG15: a completed successful grid replays its exact result, with no work", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); @@ -1658,6 +1707,68 @@ describe("Tier TG — durability and replay", () => { } }); + it("TG19: a cancellation during reader-close teardown waits for it, and replays", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + + // Every step below is an event this run produced. Nothing waits for a + // duration, so a lifecycle that never reached a step hangs the row rather + // than passing it. + const entered = withResolvers(); + const release = withResolvers(); + + const first = yield* runInterrupted(dir, source, stream, { + // 1. The live pane arms its blocking finalizer, and 2. only then does the + // reader leave. + closeWhenArmed: true, + // 3. Entering the finalizer is observed, and it blocks there. + onTeardownEntered: () => entered.resolve(), + holdTeardown: () => release.operation, + // 4. Cancellation begins while that finalizer is still blocked. + interruptWhen: entered.operation, + // 5. Released afterwards, so the cancellation was not waiting on it. + releaseOnInterrupt: () => release.resolve(), + }); + + // 6. Teardown ran to the end, and the grid recorded a completed close — + // both before the cancellation was observed, because the document never + // reached the sibling after the grid. + expect(first.events).toContain("destroy:0"); + expect(completedGrid(first)).toBe(true); + expect(first.ran).toEqual(["pane body"]); + // The live pane settled as closed rather than cancelled: a cancelled child + // is what a later run would have to revive, and this one has nothing left + // to do. + expect(paneOutcomes(first)).toEqual([ + { status: "closed", reason: "" }, + { status: "succeeded", reason: "" }, + ]); + + // 7. Resumed with three tripwires: no provider at all, so a replay that + // asked for a grid would refuse; a mark inside the pane body, so a pane + // that expanded again would say so; and the finalizer, which would + // report being entered a second time. + let reentered = false; + const second = yield* runInterrupted(dir, source, stream, { + close: true, + provider: false, + onTeardownEntered: () => { + reentered = true; + }, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(reentered).toBe(false); + // The retained grid came back and the document carried on from it. + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index ed50a292..9aefe654 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -19,7 +19,7 @@ */ import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; -import type { Operation, Task } from "effection"; +import type { Operation, Scope, Task } from "effection"; import { DurableContext } from "./context.ts"; import { activeDurabilityFailure, @@ -98,24 +98,8 @@ function retainedCancellation(close: Close): Cancellation { return close.result.cancellation === "unwound" ? "unwound" : "caller"; } -/** - * Declare a child's terminal value before its scope has finished unwinding. - * - * A child that has already decided what it settled to — a terminal grid that - * crossed its reader-close boundary, say — must record that outcome even if the - * run is cancelled while its finalizers are still going. Without this, a halt - * arriving during teardown loses the decision and the child records a - * cancellation instead, which is a different thing entirely. - * - * Committing is live state, never journaled on its own: the value reaches the - * journal only as the child's ordinary `Close`, written where it always was. - * A child that goes on to return or throw normally overrides what it committed, - * because that is the outcome it actually reached. - */ -export type CommitOutcome = (value: T) => void; - function* runDurableChild( - childWorkflow: (commit: CommitOutcome) => Workflow, + childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", @@ -179,30 +163,12 @@ function* runDurableChild( let closeEvent: Close | undefined; let suppressClose = false; - // What the child declared it had settled to before its scope finished coming - // down. Read only when the child never reached a normal ending. - let committed: { value: T } | undefined; - const commit: CommitOutcome = (value) => { - committed = { value }; - }; yield* ensure(function* () { if (suppressClose || activeDurabilityFailure(childCtx)) { return; } - // A child that committed an outcome and was then cancelled mid-teardown - // settled: the decision was made before the cancellation arrived, and the - // record has to say so. The cancellation is still a cancellation for - // whoever asked for it — it is simply delivered after this. - if (!closeEvent && committed !== undefined && !replayIndex.firstUnaligned(childId)) { - closeEvent = { - type: "close", - coroutineId: childId, - result: { status: "ok", value: committed.value as Json }, - }; - } - // closeEvent still undefined means the child was cancelled before the // normal-return or catch path ran. if (!closeEvent) { @@ -239,7 +205,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read // DurableContext from the scope, so they'll use childId. - const result: T = yield* childWorkflow(commit); + const result: T = yield* childWorkflow(); const durabilityFailure = activeDurabilityFailure(childCtx); if (durabilityFailure) { @@ -321,7 +287,35 @@ function* runDurableChild( * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( - childWorkflow: (commit: CommitOutcome) => Workflow, + childWorkflow: () => Workflow, +): Workflow> { + return spawnDurableChild(childWorkflow, undefined); +} + +/** + * Spawn a durable child into `scope` rather than into the routine's own. + * + * Same child, same deterministic identity, same cancellation policy — only the + * lifetime differs. A caller that has to finish a region *after* its own + * cancellation has begun needs the child to outlive the scope being torn down, + * and a scope of its own is the only honest way to express that: the child then + * settles normally and writes its ordinary `Close`, and the caller decides when + * to destroy the scope. + * + * It grants nothing a caller does not already have. Placing a child somewhere + * is not replay authority, and the policy stays fixed at the call site. + */ +export function durableSpawnIn( + scope: Scope, + childWorkflow: () => Workflow, +): Workflow> { + return spawnDurableChild(childWorkflow, scope); +} + +/** Both spellings of a durable spawn; `into` is the only thing that differs. */ +function spawnDurableChild( + childWorkflow: () => Workflow, + into: Scope | undefined, ): Workflow> { return (function* (): Workflow> { // Reading the context and allocating the child id is ordinary scope setup: @@ -335,6 +329,7 @@ export function durableSpawn( return (yield createSpawnEffect( () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), evidence, + into, )) as Task; })(); } @@ -358,12 +353,14 @@ function* readDurableContext(): Operation { function createSpawnEffect( child: () => Operation, evidence: CancellationEvidence, + into?: Scope, ): DurableEffect> { return { description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: observingDisposal(routine.scope.run(child), evidence) }); + const host = into ?? routine.scope; + resolve({ ok: true, value: observingDisposal(host.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 4d2f5747..94a63b43 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -101,8 +101,7 @@ export type { export { durableAction, durableCall, durableSleep, versionCheck } from "./operations.ts"; // Structured concurrency combinators -export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; -export type { CommitOutcome } from "./combinators.ts"; +export { durableAll, durableRace, durableSpawn, durableSpawnIn } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; From b2f826ac002eca28813526e18acbd6861b3f8c89 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:44:55 -0400 Subject: [PATCH 15/15] =?UTF-8?q?=E2=9C=85=20Count=20what=20TG19=20proves:?= =?UTF-8?q?=20resources,=20records=20and=20the=20lease=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production lifecycle is unchanged. TG19 now reads counters and journal records rather than a log's shape. The controlled composite keeps live resource counters — composites prepared, composites attached, shells started — each raised when it takes something and lowered when it gives it back, however it left. TG19 reads them once while a pane finalizer is blocked, so it knows they went up, and again when the cancellation has completed, so it knows they came back down. The harness now says when a blocked finalizer *leaves*, not only when it is entered: a finalizer that was entered and then cancelled reaches the first hook and never the second. And after every interrupted run it takes the foreground lease and gives it back twice — the first proves the grid returned it, the second proves the harness did. TG19 adds: one grid Close(ok) retaining close: "reader"; two pane Closes, both completed, with no cancellation recorded at either level; the finalizer entered and left exactly once; destroy:0 exactly once. The first-attempt claim that no following sibling ran and the replay tripwires are unchanged. Every one of these was broken on purpose and re-run: dropping any of the three counter releases, the deferral, the finalizer-exit hook, or double-logging destroy fails TG19, and a second holder of the foreground lease is refused. --- packages/core/tests/terminal-grid.test.ts | 133 ++++++++++++++++++---- packages/runtime/mod.ts | 1 + packages/runtime/terminal.ts | 56 +++++++-- 3 files changed, 160 insertions(+), 30 deletions(-) diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 52571e69..4acf29a8 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -44,6 +44,7 @@ import type { DurableEvent } from "@executablemd/durable-streams"; import { installControlledLauncher, prepareControlledComposite, + reserveTerminal, TerminalGrids, terminalProviderLog, } from "@executablemd/runtime"; @@ -52,6 +53,7 @@ import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog, + TerminalProviderResources, } from "@executablemd/runtime"; import { Component } from "../src/component-api.ts"; @@ -90,6 +92,8 @@ interface DocumentRun { errors: string[]; /** The journal this run read and appended to. */ journal: DurableEvent[]; + /** What the controlled provider still held when the run was over. */ + live: TerminalProviderResources; } /** @@ -353,6 +357,7 @@ function runDocument( ran, errors, journal: yield* stream.readAll(), + live: log.live, }; }); } @@ -420,8 +425,31 @@ function runInterrupted( shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ holdTeardown?: () => Operation; - /** Resolved once a pane's finalizer has been entered and is blocked. */ - onTeardownEntered?: () => void; + /** + * Called once a pane's finalizer has been entered and is blocked, with what + * the provider is holding at that moment. + * + * A row reads those counters here to know they ever went up, which is what + * makes reading them again at the end mean something. + */ + onTeardownEntered?: (live: TerminalProviderResources) => void; + /** + * Called once that finalizer has left. + * + * Kept apart from entering it deliberately: a finalizer that was entered + * and then cancelled reaches the first hook and never the second, which is + * the difference between teardown starting and teardown finishing. + */ + onTeardownExited?: () => void; + /** + * Called once for each time the foreground lease is taken back after the + * run, which the harness always does twice. + * + * It is the grid's lease that has to come back: a run that stranded it + * would refuse the first of those, and one that never released what this + * harness took would refuse the second. + */ + onLeaseReacquired?: () => void; /** Interrupt the run when this settles rather than at a lifecycle signal. */ interruptWhen?: Operation; /** @@ -491,10 +519,11 @@ function runInterrupted( }, () => attached.operation, function* () { - options.onTeardownEntered?.(); + options.onTeardownEntered?.(log.live); if (options.holdTeardown) { yield* options.holdTeardown(); } + options.onTeardownExited?.(); }, () => armed.resolve(), ); @@ -585,6 +614,16 @@ function runInterrupted( const halting = yield* spawn(() => task.halt()); options.releaseOnInterrupt?.(); yield* halting; + // Taken and given back twice, now that the run is over. The first proves + // the grid returned the foreground lease; the second proves this harness + // gave it back too, so the first cannot have passed against a lease nobody + // was holding in the first place. + for (let attempt = 0; attempt < 2; attempt++) { + yield* scoped(function* () { + yield* reserveTerminal(); + options.onLeaseReacquired?.(); + }); + } return { outcome: { ok: false, error: new Error("interrupted") } as Result, output: "", @@ -594,6 +633,7 @@ function runInterrupted( ran, errors, journal: yield* stream.readAll(), + live: log.live, }; }); } @@ -1355,8 +1395,8 @@ describe("Tier TG — durability and replay", () => { ); } - /** The pane outcomes the grid retained, in authored order. */ - function paneOutcomes(run: DocumentRun): unknown[] { + /** What the grid child retained, read from its own completed `Close`. */ + function retainedGrid(run: DocumentRun): Record | undefined { for (const event of run.journal) { if ( event.type === "close" && @@ -1365,14 +1405,34 @@ describe("Tier TG — durability and replay", () => { ) { const value = event.result.value; if (typeof value === "object" && value !== null && !Array.isArray(value)) { - const panes = Reflect.get(value, "panes"); - if (Array.isArray(panes)) { - return panes; - } + return { ...value }; } } } - return []; + return undefined; + } + + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + const panes = retainedGrid(run)?.panes; + return Array.isArray(panes) ? panes : []; + } + + /** + * How every `Close` at this coroutine depth ended, in journal order. + * + * Depth 2 is the grid child and depth 3 its panes, so a row reads these to + * say how many records each level wrote and what each one settled to — + * including whether any of them settled as a cancellation. + */ + function closeStatuses(run: DocumentRun, depth: number): string[] { + const statuses: string[] = []; + for (const event of run.journal) { + if (event.type === "close" && String(event.coroutineId).split(".").length === depth) { + statuses.push(event.result.status); + } + } + return statuses; } it("TG15: a completed successful grid replays its exact result, with no work", function* () { @@ -1715,56 +1775,85 @@ describe("Tier TG — durability and replay", () => { '', ]); - // Every step below is an event this run produced. Nothing waits for a - // duration, so a lifecycle that never reached a step hangs the row rather - // than passing it. + // Signals and counters, and nothing else. Every step below is an event this + // run produced, so a lifecycle that never reached one hangs the row rather + // than passing it, and every "exactly once" claim is a count rather than a + // look at the record. const entered = withResolvers(); const release = withResolvers(); + let entries = 0; + let exits = 0; + let leases = 0; + let heldWhenBlocked: TerminalProviderResources | undefined; const first = yield* runInterrupted(dir, source, stream, { // 1. The live pane arms its blocking finalizer, and 2. only then does the // reader leave. closeWhenArmed: true, // 3. Entering the finalizer is observed, and it blocks there. - onTeardownEntered: () => entered.resolve(), + onTeardownEntered: (live) => { + entries++; + heldWhenBlocked = { ...live }; + entered.resolve(); + }, holdTeardown: () => release.operation, + onTeardownExited: () => { + exits++; + }, // 4. Cancellation begins while that finalizer is still blocked. interruptWhen: entered.operation, // 5. Released afterwards, so the cancellation was not waiting on it. releaseOnInterrupt: () => release.resolve(), + onLeaseReacquired: () => { + leases++; + }, }); // 6. Teardown ran to the end, and the grid recorded a completed close — // both before the cancellation was observed, because the document never // reached the sibling after the grid. - expect(first.events).toContain("destroy:0"); - expect(completedGrid(first)).toBe(true); + expect(entries).toBe(1); + expect(exits).toBe(1); + expect(first.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); expect(first.ran).toEqual(["pane body"]); - // The live pane settled as closed rather than cancelled: a cancelled child - // is what a later run would have to revive, and this one has nothing left - // to do. + + // One grid child, completed, and it says what closed it. + expect(closeStatuses(first, 2)).toEqual(["ok"]); + expect(retainedGrid(first)?.close).toBe("reader"); + // Two pane children, both completed. Neither they nor the grid recorded a + // cancellation: a cancelled child is what a later run would have to revive, + // and these have nothing left to do. + expect(closeStatuses(first, 3)).toEqual(["ok", "ok"]); expect(paneOutcomes(first)).toEqual([ { status: "closed", reason: "" }, { status: "succeeded", reason: "" }, ]); + // 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 }); + // And the foreground lease came back: it was taken and given back twice + // over once the run was done. + expect(leases).toBe(2); + // 7. Resumed with three tripwires: no provider at all, so a replay that // asked for a grid would refuse; a mark inside the pane body, so a pane // that expanded again would say so; and the finalizer, which would // report being entered a second time. - let reentered = false; + let reentered = 0; const second = yield* runInterrupted(dir, source, stream, { close: true, provider: false, onTeardownEntered: () => { - reentered = true; + reentered++; }, }); expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); - expect(reentered).toBe(false); + expect(reentered).toBe(0); // The retained grid came back and the document carried on from it. expect(second.ran).toEqual([PAST_THE_GRID]); }); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index eba02abb..c9a9d60d 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -162,6 +162,7 @@ export type { TerminalPaneRequest, TerminalPaneState, TerminalProviderLog, + TerminalProviderResources, TerminalShellOutcome, } from "./terminal.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index 12827a30..b03275a8 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -196,11 +196,34 @@ export interface TerminalProviderLog { * document output to prove where it did not. */ readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the composite takes something and down when it gives + * it back, so a suite reads it after a run to prove nothing was stranded — + * including after a cancellation, where the ordering of the record alone + * would not say whether teardown finished. + */ + readonly live: TerminalProviderResources; +} + +/** What one controlled composite holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Composites prepared and not yet destroyed. */ + composites: number; + /** Composites attached and not yet destroyed. */ + attached: number; + /** Shells started whose outcome has not been returned. */ + shells: number; } /** A fresh, empty record. */ export function terminalProviderLog(): TerminalProviderLog { - return { events: [], shown: new Map() }; + return { + events: [], + shown: new Map(), + live: { composites: 0, attached: 0, shells: 0 }, + }; } /** @@ -247,13 +270,17 @@ export function prepareControlledComposite( yield* options.onPrepare(request); } log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + log.live.composites++; let destroyed = false; + let attached = false; return { *attach() { if (options.onAttach) { yield* options.onAttach(); } log.events.push(`attach:${generation}`); + attached = true; + log.live.attached++; }, // deno-lint-ignore require-yield *update(ordinal, state) { @@ -266,14 +293,22 @@ export function prepareControlledComposite( }, *shell(ordinal, spawned) { log.events.push(`shell:${generation}:${ordinal}`); - if (options.shell) { - return yield* options.shell(ordinal, spawned); + log.live.shells++; + try { + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + } finally { + // Counted down however the shell left — returned, thrown, or + // cancelled — because a shell a suite can still find is a shell the + // provider is still holding. + log.live.shells--; } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn would - // hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; }, *closed() { if (options.close) { @@ -293,6 +328,11 @@ export function prepareControlledComposite( yield* options.onDestroy(); } log.events.push(`destroy:${generation}`); + log.live.composites--; + if (attached) { + attached = false; + log.live.attached--; + } }, }; })();