From c0c7f5a3d26ed18374f144fcc72c7ef919db11b8 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Thu, 22 Oct 2026 01:42:07 +0000 Subject: [PATCH 1/3] feat(ghostwright): add scoped terminal backend factories --- experiments/ghostwright/README.md | 44 +++ experiments/ghostwright/docs/architecture.md | 23 ++ experiments/ghostwright/spikes/in-process.ts | 51 +-- experiments/ghostwright/src/backend.ts | 70 ++++ experiments/ghostwright/src/execution.ts | 3 +- experiments/ghostwright/src/index.ts | 2 + experiments/ghostwright/src/pty/client.ts | 4 +- .../ghostwright/src/terminal/backend-scope.ts | 36 ++ .../src/terminal/ghostty-backend.ts | 112 ++++++ .../ghostwright/src/terminal/session.ts | 107 ++++-- experiments/ghostwright/src/terminal/wasm.ts | 24 +- experiments/ghostwright/src/tracing/replay.ts | 93 ++--- experiments/ghostwright/src/tracing/trace.ts | 16 +- experiments/ghostwright/src/types.ts | 3 + experiments/ghostwright/test/backend.test.ts | 346 ++++++++++++++++++ 15 files changed, 813 insertions(+), 121 deletions(-) create mode 100644 experiments/ghostwright/src/backend.ts create mode 100644 experiments/ghostwright/src/terminal/backend-scope.ts create mode 100644 experiments/ghostwright/src/terminal/ghostty-backend.ts create mode 100644 experiments/ghostwright/test/backend.test.ts diff --git a/experiments/ghostwright/README.md b/experiments/ghostwright/README.md index b1c6c2c..2f7c035 100644 --- a/experiments/ghostwright/README.md +++ b/experiments/ghostwright/README.md @@ -219,6 +219,50 @@ const matches = await terminal.history.findText('tool completed', { direction: ' - [C versus Rust PTY-host comparison](HOST-COMPARISON.md) - [Performance report](PERFORMANCE.md) +## Terminal backend + +Ghostty remains the default and sole shipped emulator. A session can supply a +`backend: CreateBackend` factory to substitute or decorate it: + +```ts +import { createGhosttyBackend, launchTerminal, type CreateBackend } from 'ghostwright'; + +const backend: CreateBackend = function* (options) { + const ghostty = yield* createGhosttyBackend(options); + return { + ...ghostty, + write(bytes) { + const update = ghostty.write(bytes); + return update; // detached snapshot and terminal effects + }, + }; +}; + +await using terminal = await launchTerminal({ command: 'my-cli', backend }); +``` + +The factory returns an Effection `Operation` and acquires fresh +state for each session. Use Effection resources to register release with the +factory's scope. The session keeps that scope alive through process/output drain +and awaits its cleanup on close or failed launch. Factories invoked through the +Effection API inherit the caller's context. Async and local component sessions +own their own scopes; pass their configuration explicitly in the factory closure. + +Backend reads, writes, resize and input encoders are synchronous. A write or +resize returns a `BackendUpdate` containing the resulting immutable snapshot and +an ordered list of effects. The session queues PTY responses; the emulator never +performs transport I/O or reenters application output. The existing semantic OSC +splitter, CSS locator adapters, revision retention and assertions remain shared. +History and graphics inspection are part of the contract; unsupported graphics +must be reported explicitly in the snapshot. + +The same factory option works with trace replay and the experimental local +component harness. Traces record `backend.identity` (name and behavior version). +Replay requires a matching identity and an explicit factory for custom backends; +it never loads code named in a recording. A decorator that changes emulator or +input behavior must use a distinct identity. The terminal environment still uses +Ghostwright's deterministic profile; this seam does not add alternate profiles. + ## Compatibility - Node 22+ diff --git a/experiments/ghostwright/docs/architecture.md b/experiments/ghostwright/docs/architecture.md index 29acc12..915efc3 100644 --- a/experiments/ghostwright/docs/architecture.md +++ b/experiments/ghostwright/docs/architecture.md @@ -70,6 +70,29 @@ Ghostty is the sole authority for: JavaScript does not interpret terminal control sequences. It extracts only registered description OSC messages before forwarding ordinary bytes to Ghostty. +## Backend boundary + +`CreateBackend` is `(options: BackendOptions) => Operation`. +The default `createGhosttyBackend` acquires the existing Ghostty WASM engine as +an Effection resource and releases it with its scope, including late acquisition +and initialization failure. Its returned functions close over that instance and +reject use after release. The async session bridge owns a child scope, inheriting +the Effection caller when present, and explicitly closes it after transport drain. +The factory is explicit per session; there is no global backend registry. + +`write` and `resize` return one `BackendUpdate`: a detached screen snapshot and +terminal effects. These operations, input encoding, and reads cannot suspend. +That keeps each ordinary byte segment and its resulting screen atomic relative +to registered OSC boundaries. Effects are values; session-owned queues deliver +PTY responses without blocking parsing. Session clocks and revision numbering +remain above the emulator boundary. + +Live PTY sessions, local appIO sessions and replay consume the same contract. +The backend owns VT semantics; the shared output pipeline owns metadata pairing +and freshness. Replay consumes snapshots but does not deliver terminal effects. +Trace identity checks reject a different emulator/version before processing +recorded output. Existing Ghostty traces retain their artifact hash check. + ## Session resource tree ```text diff --git a/experiments/ghostwright/spikes/in-process.ts b/experiments/ghostwright/spikes/in-process.ts index 1ca478c..463e0b4 100644 --- a/experiments/ghostwright/spikes/in-process.ts +++ b/experiments/ghostwright/spikes/in-process.ts @@ -7,7 +7,8 @@ import { parseKey } from '../src/keys.ts'; import { normalizeViewport } from '../src/profile.ts'; import { createQueries } from '../src/queries.ts'; import { TerminalOutput } from '../src/terminal/output.ts'; -import { GhosttyWasmTerminal } from '../src/terminal/wasm.ts'; +import type { TerminalBackend, BackendUpdate } from '../src/backend.ts'; +import { openBackend, type BackendScope } from '../src/terminal/backend-scope.ts'; import { waitForOperation, type WaitForOptions } from '../src/wait-for.ts'; import type { KeyName, @@ -20,20 +21,23 @@ import type { /** Experimental connection: no command, child process, native host, or global stream replacement. */ export async function createTerminal(options: InProcessOptions = {}): Promise { const viewport = normalizeViewport(options.viewport); - const engine = await GhosttyWasmTerminal.create(viewport); + const owner = await openBackend( + { viewport, graphicsStorageLimitBytes: options.graphics?.storageLimitBytes }, + { create: options.backend }, + ); try { - const terminal = new InProcessTerminal(engine, options); + const terminal = new InProcessTerminal(owner, options); await terminal.installExtensions(); return terminal; } catch (error) { - engine.free(); + await owner[Symbol.asyncDispose](); throw error; } } type InProcessOptions = Pick< TerminalLaunchOptions, - 'viewport' | 'extensions' | 'selector' | 'assertionTimeoutMs' + 'viewport' | 'extensions' | 'selector' | 'assertionTimeoutMs' | 'backend' | 'graphics' >; /** Buffer early input until the app installs its reader, even if it calls resume first. */ @@ -71,7 +75,8 @@ class InProcessTerminal implements AsyncDisposable { readonly appIO: { input: Input; output: Output }; readonly screen; readonly keyboard; - readonly #engine: GhosttyWasmTerminal; + readonly #engine: TerminalBackend; + readonly #backendScope: BackendScope; readonly #extensions: ExtensionLifecycle; readonly #output: TerminalOutput; readonly #controller = new AbortController(); @@ -83,8 +88,9 @@ class InProcessTerminal implements AsyncDisposable { #failure: unknown; #closing?: Promise; - constructor(engine: GhosttyWasmTerminal, options: InProcessOptions) { - this.#engine = engine; + constructor(owner: BackendScope, options: InProcessOptions) { + this.#backendScope = owner; + const engine = (this.#engine = owner.backend); this.#options = options; this.#snapshot = engine.snapshot(); this.#extensions = new ExtensionLifecycle(() => this.#snapshot); @@ -92,8 +98,7 @@ class InProcessTerminal implements AsyncDisposable { options.extensions ?? [], () => this.#snapshot, (bytes) => { - engine.write(bytes); - return this.#observe(); + return this.#observe(engine.write(bytes)); }, ); this.stdout = new Output({ @@ -101,12 +106,6 @@ class InProcessTerminal implements AsyncDisposable { try { this.#ensureOpen(); this.#output.push(chunk); - for (const effect of engine.takeEffects()) { - if (effect.type === 'write-pty') { - // Never reenter app rendering from a Ghostty output callback. - void this.#send(effect.data).catch((error) => this.#fail(error)); - } - } done(); } catch (error) { done(error instanceof Error ? error : new Error(String(error))); @@ -173,8 +172,14 @@ class InProcessTerminal implements AsyncDisposable { } } - #observe(): ScreenSnapshot { - this.#snapshot = Object.freeze({ ...this.#engine.snapshot(), sequence: ++this.#revision }); + #observe(update: BackendUpdate): ScreenSnapshot { + this.#snapshot = Object.freeze({ ...update.snapshot, sequence: ++this.#revision }); + for (const effect of update.effects) { + if (effect.type === 'write-pty') { + // Queue responses without reentering application rendering. + void this.#send(effect.data).catch((error) => this.#fail(error)); + } + } this.#extensions.frame(this.#snapshot); return this.#snapshot; } @@ -229,8 +234,7 @@ class InProcessTerminal implements AsyncDisposable { this.#ensureOpen(); const size = normalizeViewport(viewport); this.#extensions.action({ kind: 'resize', ...size }); - this.#engine.resize(size); - this.#output.observations.screen(this.#observe()); + this.#output.observations.screen(this.#observe(this.#engine.resize(size))); Object.assign(this.stdout, size); this.stdout.emit('resize'); } @@ -262,8 +266,11 @@ class InProcessTerminal implements AsyncDisposable { clearTimeout(deadline); this.stdin.destroy(); this.stdout.destroy(); - this.#engine.free(); - await this.#extensions.close(); + try { + await this.#backendScope[Symbol.asyncDispose](); + } finally { + await this.#extensions.close(); + } } } } diff --git a/experiments/ghostwright/src/backend.ts b/experiments/ghostwright/src/backend.ts new file mode 100644 index 0000000..bb32421 --- /dev/null +++ b/experiments/ghostwright/src/backend.ts @@ -0,0 +1,70 @@ +import type { Operation } from 'effection'; +import type { + KeyName, + KeyPress, + KittyImageSnapshot, + MouseOptions, + Point, + ScreenLine, + ScreenSnapshot, + Viewport, +} from './types.ts'; + +export interface BackendOptions { + readonly viewport: Required; + readonly graphicsStorageLimitBytes?: number; +} + +/** Identifies emulator behavior for trace replay, including its build/version. */ +export interface BackendIdentity { + readonly name: string; + readonly version: string; +} + +export type TerminalEffect = + | { type: 'write-pty'; data: Uint8Array } + | { type: 'bell' } + | { type: 'title' } + | { type: 'working-directory' } + | { type: 'clipboard-write'; data: string }; + +export interface BackendUpdate { + readonly snapshot: ScreenSnapshot; + readonly effects: readonly TerminalEffect[]; +} + +export interface BackendHistoryRow { + readonly line: ScreenLine; + readonly kittyVirtualPlaceholder: boolean; +} + +/** + * One session's emulator. These functions must remain synchronous: output and + * its snapshot form one indivisible observation at a registered OSC boundary. + * Snapshots are detached immutable evidence; effects never perform transport I/O. + * Unsupported graphics are explicit in snapshot.graphics.supported. + */ +export interface TerminalBackend { + readonly identity: BackendIdentity; + write(bytes: Uint8Array): BackendUpdate; + resize(viewport: Required): BackendUpdate; + snapshot(): ScreenSnapshot; + encodeKey(key: KeyName | KeyPress): Uint8Array; + encodeMouse( + action: 'move' | 'down' | 'up', + point: Point, + options?: MouseOptions, + anyButtonPressed?: boolean, + ): Uint8Array; + encodePaste(text: string): Uint8Array; + encodeFocus(state: 'in' | 'out'): Uint8Array; + scrollbackRows(): number; + history(start: number, count: number): readonly BackendHistoryRow[]; + clipboard(): string; + inspectImage(id: number): KittyImageSnapshot | undefined; + copyImageData(id: number): Uint8Array | undefined; + cachedImage(id: number): KittyImageSnapshot | undefined; +} + +/** Acquire fresh state and register its release in the caller's Effection scope. */ +export type CreateBackend = (options: BackendOptions) => Operation; diff --git a/experiments/ghostwright/src/execution.ts b/experiments/ghostwright/src/execution.ts index 41cb620..46081f5 100644 --- a/experiments/ghostwright/src/execution.ts +++ b/experiments/ghostwright/src/execution.ts @@ -383,7 +383,8 @@ export function* captureOperation( /** Cancellation waits for bounded acquisition and closes even a late launch. */ export function useSession(options: TerminalLaunchOptions): Operation { return resource(function* (provide) { - const launching = TerminalSession.launch(options).then( + const owner = yield* useScope(); + const launching = TerminalSession.launch(options, owner).then( (session) => ({ ok: true as const, session }), (cause) => ({ ok: false as const, cause }), ); diff --git a/experiments/ghostwright/src/index.ts b/experiments/ghostwright/src/index.ts index 51a99a2..ff7e6b3 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -1,3 +1,5 @@ +export type * from './backend.ts'; +export { createGhosttyBackend } from './terminal/ghostty-backend.ts'; export * from './types.ts'; export * from './observations.ts'; export * from './inspection.ts'; diff --git a/experiments/ghostwright/src/pty/client.ts b/experiments/ghostwright/src/pty/client.ts index 0fc1d93..79a0cdf 100644 --- a/experiments/ghostwright/src/pty/client.ts +++ b/experiments/ghostwright/src/pty/client.ts @@ -70,7 +70,9 @@ export class SidecarClient { child.stderr.on('data', (b: Buffer) => { this.#stderr = (this.#stderr + b.toString()).slice(-8192); }); - child.on('exit', (code, signal) => { + // Process exit can precede the final stdout ACK. Drain stdio before + // deciding that the host disappeared without completing its requests. + child.on('close', (code, signal) => { if (!this.#closed) this.#fail(new SidecarExitedError(`PTY host exited (${code ?? signal}); ${this.#stderr}`)); }); diff --git a/experiments/ghostwright/src/terminal/backend-scope.ts b/experiments/ghostwright/src/terminal/backend-scope.ts new file mode 100644 index 0000000..6a9b68a --- /dev/null +++ b/experiments/ghostwright/src/terminal/backend-scope.ts @@ -0,0 +1,36 @@ +import { createScope, suspend, type Scope } from 'effection'; +import type { BackendOptions, CreateBackend, TerminalBackend } from '../backend.ts'; +import { createGhosttyBackend } from './ghostty-backend.ts'; + +export interface BackendScope extends AsyncDisposable { + readonly backend: TerminalBackend; +} + +/** Async owners explicitly dispose this scope after draining their output. */ +export async function openBackend( + options: BackendOptions, + ownership: { create?: CreateBackend; parent?: Scope } = {}, +): Promise { + const scope = createScope(ownership.parent); + const create = ownership.create ?? createGhosttyBackend; + let resolve!: (backend: TerminalBackend) => void; + let reject!: (error: unknown) => void; + const ready = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + const lifetime = scope.run(function* () { + resolve(yield* create(options)); + yield* suspend(); + }); + void lifetime.catch(reject); + let closing: Promise | undefined; + const close = (): Promise => + (closing ??= Promise.resolve().then(() => scope[Symbol.asyncDispose]())); + try { + return { backend: await ready, [Symbol.asyncDispose]: close }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/experiments/ghostwright/src/terminal/ghostty-backend.ts b/experiments/ghostwright/src/terminal/ghostty-backend.ts new file mode 100644 index 0000000..0b41bb9 --- /dev/null +++ b/experiments/ghostwright/src/terminal/ghostty-backend.ts @@ -0,0 +1,112 @@ +import { call, resource, type Operation } from 'effection'; +import { readFile } from 'node:fs/promises'; +import type { BackendOptions, BackendUpdate, TerminalBackend } from '../backend.ts'; +import { SessionClosedError } from '../errors.ts'; +import { GhosttyWasmTerminal } from './wasm.ts'; + +/** Pinned Ghostty, with one WASM instance owned by the acquiring scope. */ +// oxlint-disable-next-line bombshell-dev/exported-function-async, bombshell-dev/require-export-jsdoc -- Scoped acquisition returns an Effection Operation; documented above. +export function createGhosttyBackend(options: BackendOptions): Operation { + return resource(function* (provide) { + // Observe late acquisition even if the owning operation is cancelled. + const opening = GhosttyWasmTerminal.create( + options.viewport, + options.graphicsStorageLimitBytes, + ).then( + (engine) => ({ ok: true as const, engine }), + (error) => ({ ok: false as const, error }), + ); + let closed = false; + try { + const result = yield* call(() => opening); + if (!result.ok) throw result.error; + const engine = result.engine; + const lock = JSON.parse( + yield* call(() => + readFile( + new URL( + import.meta.url.includes('/dist/') + ? '../ghostty.lock.json' + : '../../ghostty.lock.json', + import.meta.url, + ), + 'utf8', + ), + ), + ); + const check = (): void => { + if (closed) throw new SessionClosedError('Terminal backend scope is closed'); + }; + const update = (cause: 'pty-output' | 'resize'): BackendUpdate => + Object.freeze({ + snapshot: engine.snapshot(cause), + effects: Object.freeze(engine.takeEffects()), + }); + const backend: TerminalBackend = { + identity: Object.freeze({ + name: 'ghostty', + version: lock.artifacts['artifacts/ghostty-vt.wasm'].sha256, + }), + write(bytes) { + check(); + engine.write(bytes); + return update('pty-output'); + }, + resize(viewport) { + check(); + engine.resize(viewport); + return update('resize'); + }, + snapshot() { + check(); + return engine.snapshot(); + }, + encodeKey(key) { + check(); + return engine.encodeKey(key); + }, + encodeMouse(action, point, mouseOptions, anyButtonPressed) { + check(); + return engine.encodeMouse(action, point, mouseOptions, anyButtonPressed); + }, + encodePaste(text) { + check(); + return engine.encodePaste(text); + }, + encodeFocus(state) { + check(); + return engine.encodeFocus(state); + }, + scrollbackRows() { + check(); + return engine.scrollbackRows(); + }, + history(start, count) { + check(); + return engine.history(start, count); + }, + clipboard() { + check(); + return engine.clipboard(); + }, + inspectImage(id) { + check(); + return engine.inspectImage(id); + }, + copyImageData(id) { + check(); + return engine.copyImageData(id); + }, + cachedImage(id) { + check(); + return engine.cachedImage(id); + }, + }; + yield* provide(Object.freeze(backend)); + } finally { + closed = true; + const result = yield* call(() => opening); + if (result.ok) result.engine.free(); + } + }); +} diff --git a/experiments/ghostwright/src/terminal/session.ts b/experiments/ghostwright/src/terminal/session.ts index 647571f..89f98af 100644 --- a/experiments/ghostwright/src/terminal/session.ts +++ b/experiments/ghostwright/src/terminal/session.ts @@ -1,3 +1,4 @@ +import type { Scope } from 'effection'; // oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { resolve } from 'node:path'; import { @@ -60,7 +61,8 @@ import type { import { ExtensionLifecycle } from './lifecycle.ts'; import { TerminalOutput } from './output.ts'; import type { Observations } from '../observations.ts'; -import { GhosttyWasmTerminal } from './wasm.ts'; +import type { TerminalBackend, TerminalEffect } from '../backend.ts'; +import { openBackend, type BackendScope } from './backend-scope.ts'; import { findText } from './text.ts'; type ControlCommand = | { kind: FrameKind.RESIZE; value: Required } @@ -91,7 +93,9 @@ function observableKey(s: ScreenSnapshot): string { export class TerminalSession implements AsyncTerminal { #host!: SidecarClient; observations!: Observations; - #engine!: GhosttyWasmTerminal; + #engine!: TerminalBackend; + #backendScope!: BackendScope; + readonly #started = performance.now(); #snapshot!: ScreenSnapshot; #status: ProcessStatus = { state: 'starting', ptyEof: false }; #closed = false; @@ -131,7 +135,7 @@ export class TerminalSession implements AsyncTerminal { this.#trace = new SessionTrace({ options, policy: t, directory: dir }); this.#exitPromise = new Promise((r) => (this.#exitResolve = r)); } - static async launch(options: TerminalLaunchOptions): Promise { + static async launch(options: TerminalLaunchOptions, parent?: Scope): Promise { assertSupportedRuntime(); if (!options.command || options.command.includes('\0')) throw new GhostwrightError({ @@ -179,26 +183,32 @@ export class TerminalSession implements AsyncTerminal { ); throw cause; } - self.#engine = await GhosttyWasmTerminal.create( - self.#viewport, - options.graphics?.storageLimitBytes ?? 64 * 1024 * 1024, + self.#backendScope = await openBackend( + { + viewport: self.#viewport, + graphicsStorageLimitBytes: options.graphics?.storageLimitBytes, + }, + { create: options.backend, parent }, ); - self.#snapshot = self.#engine.snapshot(); + self.#engine = self.#backendScope.backend; + self.#trace.backend = self.#engine.identity; self.#extensions = new ExtensionLifecycle(() => self.#snapshot); try { + self.#snapshot = self.#engine.snapshot(); self.#outputStream = new TerminalOutput( options.extensions ?? [], () => self.#snapshot, (bytes) => { - self.#engine.write(bytes); + const update = self.#engine.write(bytes); self.#terminalHistoryGeneration++; - self.#publish('pty-output', self.#sourceFrameSequence); + self.#publish(update.snapshot, 'pty-output', self.#sourceFrameSequence); + self.#effects(update.effects); return self.#snapshot; }, (error) => self.#trace.add('extension-diagnostic', { message: error.message }), ); } catch (error) { - self.#engine.free(); + await self.#backendScope[Symbol.asyncDispose](); throw error; } self.observations = self.#outputStream.observations; @@ -219,9 +229,8 @@ export class TerminalSession implements AsyncTerminal { await self.#extensions.install(options.extensions ?? []); self.#host = await SidecarClient.start(assets.host, options.commandTimeoutMs ?? 5000); } catch (error) { - self.#engine.free(); try { - await self.#extensions.close(); + await self.#release(); } catch (cleanupError) { throw new ExtensionLifecycleError('Terminal launch and extension cleanup failed', [ error, @@ -282,13 +291,12 @@ export class TerminalSession implements AsyncTerminal { self.#host.forceKill(); await self.#outputPump; self.#closed = true; - self.#engine.free(); const failure = new LaunchError( `Unable to launch ${JSON.stringify(options.command)} in ${JSON.stringify(cwd)}`, { cause }, ); try { - await self.#extensions.close(); + await self.#release(); } catch (error) { throw new ExtensionLifecycleError('Terminal launch and extension cleanup failed', [ failure, @@ -301,13 +309,12 @@ export class TerminalSession implements AsyncTerminal { self.#host.forceKill(); await self.#outputPump; self.#closed = true; - self.#engine.free(); const failure = new LaunchError( `Unable to launch ${JSON.stringify(options.command)} in ${JSON.stringify(cwd)}: ${self.#fatalError.message}`, { cause: self.#fatalError }, ); try { - await self.#extensions.close(); + await self.#release(); } catch (error) { throw new ExtensionLifecycleError('Terminal launch and extension cleanup failed', [ failure, @@ -316,11 +323,13 @@ export class TerminalSession implements AsyncTerminal { } throw failure; } + // A short-lived child can exit or reach EOF before spawn's promise resumes. + // Preserve those observations while adding the acknowledged identity. self.#status = { - state: 'running', + ...self.#status, + state: self.#status.state === 'starting' ? 'running' : self.#status.state, pid: spawned.pid, processGroupId: spawned.processGroupId, - ptyEof: false, }; self.#trace.add('spawned', { pid: spawned.pid, processGroupId: spawned.processGroupId }); return self; @@ -338,7 +347,7 @@ export class TerminalSession implements AsyncTerminal { return (this.options.extensions ?? []).some((extension) => extension.id === id); } now(): number { - return this.#engine.now(); + return performance.now() - this.#started; } #notify(): void { for (const f of this.#listeners) f(); @@ -355,7 +364,9 @@ export class TerminalSession implements AsyncTerminal { while (this.#raw.reduce((n, b) => n + b.length, 0) > max) this.#raw.shift(); this.#sourceFrameSequence = sourceFrameSequence; this.#outputStream.push(bytes); - for (const effect of this.#engine.takeEffects()) { + } + #effects(effects: readonly TerminalEffect[]): void { + for (const effect of effects) { this.#trace.add('terminal-effect', { effect: effect.type, ...(effect.type === 'write-pty' ? { bytes: effect.data.length } : {}), @@ -364,6 +375,7 @@ export class TerminalSession implements AsyncTerminal { this.#trace.input(effect.data, 0, false); // Parsing output must not wait for a child that has stopped reading replies. void this.#command(() => this.#host.write(effect.data)).catch((error) => { + if (this.#closePromise) return; this.#fatalError = error; this.#status = { ...this.#status, state: 'failed' }; this.#notify(); @@ -381,20 +393,29 @@ export class TerminalSession implements AsyncTerminal { }), ); this.#queuedCommands++; - const result = this.#commands.then(operation).finally(() => { - this.#queuedCommands--; - }); + const result = this.#commands + .then(() => { + // CLOSE overtakes the queue; never send another command behind it. + this.#ensure('perform queued action'); + return operation(); + }) + .finally(() => { + this.#queuedCommands--; + }); this.#commands = result.catch(() => undefined); return result; } - #publish(cause: 'pty-output' | 'resize' | 'reset', sourceFrameSequence?: number): void { - const decoded = this.#engine.snapshot(cause), - lines = decoded.lines.map((line, index) => + #publish( + decoded: ScreenSnapshot, + cause: 'pty-output' | 'resize' | 'reset', + sourceFrameSequence?: number, + ): void { + const lines = decoded.lines.map((line, index) => JSON.stringify(line) === JSON.stringify(this.#snapshot.lines[index]) ? this.#snapshot.lines[index] : line, ), - next = Object.freeze({ ...decoded, lines: Object.freeze(lines) }), + next = Object.freeze({ ...decoded, timestamp: this.now(), lines: Object.freeze(lines) }), changed = observableKey(next) !== observableKey(this.#snapshot); if (!changed) return; const visual = visualKey(next) !== visualKey(this.#snapshot), @@ -466,9 +487,10 @@ export class TerminalSession implements AsyncTerminal { // arrive before the PTY acknowledgement reaches the caller. this.#viewport = command.value; this.#trace.add('resize', { viewport: command.value }); - this.#engine.resize(command.value); + const update = this.#engine.resize(command.value); this.#terminalHistoryGeneration++; - this.#publish('resize'); + this.#publish(update.snapshot, 'resize'); + this.#effects(update.effects); this.observations.screen(this.#snapshot); return this.#host.resize(command.value); } @@ -477,7 +499,7 @@ export class TerminalSession implements AsyncTerminal { const receipt: Readonly = Object.freeze({ actionSequence: sequence, screenSequenceBefore: before, - acknowledgedAt: this.#engine.now(), + acknowledgedAt: this.now(), bytesWritten: ack.bytesWritten ?? 0, }); this.#lastAction = receipt; @@ -527,7 +549,7 @@ export class TerminalSession implements AsyncTerminal { const receipt = Object.freeze({ actionSequence: sequence, screenSequenceBefore: before, - acknowledgedAt: this.#engine.now(), + acknowledgedAt: this.now(), bytesWritten: total, }); this.#lastAction = receipt; @@ -710,7 +732,7 @@ export class TerminalSession implements AsyncTerminal { return Object.freeze({ actionSequence: ++this.#action, screenSequenceBefore: this.#revision, - acknowledgedAt: this.#engine.now(), + acknowledgedAt: this.now(), bytesWritten: 0, }); const before = this.#revision, @@ -731,7 +753,7 @@ export class TerminalSession implements AsyncTerminal { const receipt = Object.freeze({ actionSequence: sequence, screenSequenceBefore: before, - acknowledgedAt: this.#engine.now(), + acknowledgedAt: this.now(), bytesWritten: 0, }); this.#lastAction = receipt; @@ -740,12 +762,27 @@ export class TerminalSession implements AsyncTerminal { } finally { this.#closed = true; this.#status = { ...this.#status, state: 'closed' }; - this.#engine.free(); this.#trace.add('cleanup'); this.#notify(); - await this.#extensions.close(); + await this.#release(); } } + async #release(): Promise { + const failures: unknown[] = []; + for (const close of [ + () => this.#backendScope[Symbol.asyncDispose](), + () => this.#extensions.close(), + ]) { + try { + await close(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) + throw new ExtensionLifecycleError('Terminal resource cleanup failed', failures); + } + async waitForChange(test: () => boolean, timeout: number): Promise { if (test()) return; await new Promise((resolvePromise, reject) => { diff --git a/experiments/ghostwright/src/terminal/wasm.ts b/experiments/ghostwright/src/terminal/wasm.ts index 9c755fd..c53a5b0 100644 --- a/experiments/ghostwright/src/terminal/wasm.ts +++ b/experiments/ghostwright/src/terminal/wasm.ts @@ -1,3 +1,4 @@ +import type { TerminalEffect, BackendHistoryRow } from '../backend.ts'; import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { FUNCTIONAL_KEYS } from '../keys.ts'; @@ -81,16 +82,6 @@ function callbackModule(parameterCount: number, returnsInt = false): WebAssembly ); } -export type TerminalEffect = - | { type: 'write-pty'; data: Uint8Array } - | { type: 'bell' } - | { type: 'title' } - | { type: 'working-directory' } - | { type: 'clipboard-write'; data: string }; -interface HistoryRow { - line: ScreenLine; - kittyVirtualPlaceholder: boolean; -} const defaultStyle: CellStyle = Object.freeze({ bold: false, italic: false, @@ -172,8 +163,13 @@ export class GhosttyWasmTerminal { // The pinned, hash-checked module supplies this C ABI. WebAssembly's // standard typings do not describe individual exported signatures. self.#e = instance.exports as Exports; - self.#initialize(storageLimitBytes); - return self; + try { + self.#initialize(storageLimitBytes); + return self; + } catch (error) { + self.free(); + throw error; + } } #view(): DataView { return new DataView(this.#e.memory.buffer); @@ -607,7 +603,7 @@ export class GhosttyWasmTerminal { return this.#get(15); } /** Copies a bounded oldest-based scrollback range without moving Ghostty's viewport. */ - history(start: number, count: number): readonly HistoryRow[] { + history(start: number, count: number): readonly BackendHistoryRow[] { const total = this.#get(14), available = this.#get(15), end = Math.min(available, start + count), @@ -623,7 +619,7 @@ export class GhosttyWasmTerminal { style = this.#alloc(styleLayout.size), graphemes = this.#alloc(256), length = this.#alloc(4), - result: HistoryRow[] = []; + result: BackendHistoryRow[] = []; // `total` is deliberately read even though `available` determines the range: // it exercises Ghostty's documented total/scrollback coordinate contract. void total; diff --git a/experiments/ghostwright/src/tracing/replay.ts b/experiments/ghostwright/src/tracing/replay.ts index 1f24740..6accb06 100644 --- a/experiments/ghostwright/src/tracing/replay.ts +++ b/experiments/ghostwright/src/tracing/replay.ts @@ -11,7 +11,8 @@ import type { import type { Observation } from '../observations.ts'; import { TerminalOutput } from '../terminal/output.ts'; import { TRACE_SCHEMA_VERSION } from './trace.ts'; -import { GhosttyWasmTerminal } from '../terminal/wasm.ts'; +import type { CreateBackend } from '../backend.ts'; +import { openBackend } from '../terminal/backend-scope.ts'; export interface ReplayResult { readonly revisions: readonly ScreenRevision[]; @@ -19,6 +20,7 @@ export interface ReplayResult { readonly finalSnapshot: ScreenSnapshot; } export interface ReplayOptions { + readonly backend?: CreateBackend; readonly extensions?: readonly TerminalExtensionDefinition[]; } @@ -39,7 +41,10 @@ export async function replayTrace( 'utf8', ), ); - if (metadata.ghostty?.wasmSha256 !== lock.artifacts['artifacts/ghostty-vt.wasm']?.sha256) + if ( + metadata.ghostty && + metadata.ghostty.wasmSha256 !== lock.artifacts['artifacts/ghostty-vt.wasm']?.sha256 + ) throw new AssetIntegrityError('Trace Ghostty artifact is incompatible'); const viewport = metadata.profile?.viewport as Required | undefined; if (!viewport) throw new AssetIntegrityError('Trace lacks its initial viewport'); @@ -54,15 +59,23 @@ export async function replayTrace( .map((line) => JSON.parse(line)); if (events[0]?.sequence !== 1) throw new AssetIntegrityError('Trace beginning was evicted; replay would be incomplete'); - const engine = await GhosttyWasmTerminal.create(viewport, metadata.graphics?.storageLimitBytes); + await using owner = await openBackend( + { viewport, graphicsStorageLimitBytes: metadata.graphics?.storageLimitBytes }, + { create: options.backend }, + ); + const engine = owner.backend; + const identity = metadata.backend ?? { name: 'ghostty', version: metadata.ghostty?.wasmSha256 }; + if (engine.identity.name !== identity.name || engine.identity.version !== identity.version) + throw new AssetIntegrityError( + 'Trace backend is incompatible; supply the matching backend factory', + ); const revisions: ScreenRevision[] = [], observations: Observation[] = []; let previous = engine.snapshot(), sequence = 0, sourceFrameSequence = 0, timestamp = 0; - const publish = (cause: ScreenRevision['cause']): ScreenSnapshot => { - const next = engine.snapshot(cause); + const publish = (next: ScreenSnapshot, cause: ScreenRevision['cause']): ScreenSnapshot => { const observable = (s: ScreenSnapshot): string => JSON.stringify([ s.lines, @@ -107,44 +120,38 @@ export async function replayTrace( } return previous; }; - try { - const output = new TerminalOutput( - extensions, - () => previous, - (bytes) => { - engine.write(bytes); - return publish('pty-output'); - }, - ); - output.observations.subscribe((observation) => - observations.push(Object.freeze({ ...observation, timestamp })), - ); - for (const event of events) { - timestamp = event.timestamp; - if (event.type === 'output' && event.raw?.direction === 'from-pty') { - const { offset, length } = event.raw; - if ( - !Number.isSafeInteger(offset) || - !Number.isSafeInteger(length) || - offset < 0 || - length < 0 || - offset + length > raw.length - ) - throw new AssetIntegrityError('Trace raw range is incomplete'); - sourceFrameSequence = event.frameSequence; - output.push(raw.slice(offset, offset + length)); - engine.takeEffects(); // Responses are already present in the recorded transport. - } else if (event.type === 'resize' && event.viewport) { - engine.resize(event.viewport); - output.observations.screen(publish('resize')); - } + const output = new TerminalOutput( + extensions, + () => previous, + (bytes) => { + // Replay consumes output; returned PTY effects were handled during capture. + return publish(engine.write(bytes).snapshot, 'pty-output'); + }, + ); + output.observations.subscribe((observation) => + observations.push(Object.freeze({ ...observation, timestamp })), + ); + for (const event of events) { + timestamp = event.timestamp; + if (event.type === 'output' && event.raw?.direction === 'from-pty') { + const { offset, length } = event.raw; + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(length) || + offset < 0 || + length < 0 || + offset + length > raw.length + ) + throw new AssetIntegrityError('Trace raw range is incomplete'); + sourceFrameSequence = event.frameSequence; + output.push(raw.slice(offset, offset + length)); + } else if (event.type === 'resize' && event.viewport) { + output.observations.screen(publish(engine.resize(event.viewport).snapshot, 'resize')); } - return Object.freeze({ - revisions: Object.freeze(revisions), - observations: Object.freeze(observations), - finalSnapshot: previous, - }); - } finally { - engine.free(); } + return Object.freeze({ + revisions: Object.freeze(revisions), + observations: Object.freeze(observations), + finalSnapshot: previous, + }); } diff --git a/experiments/ghostwright/src/tracing/trace.ts b/experiments/ghostwright/src/tracing/trace.ts index 05f46c8..820153a 100644 --- a/experiments/ghostwright/src/tracing/trace.ts +++ b/experiments/ghostwright/src/tracing/trace.ts @@ -1,3 +1,4 @@ +import type { BackendIdentity } from '../backend.ts'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; // oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { resolve } from 'node:path'; @@ -16,6 +17,7 @@ export interface TraceEvent { } /** Records terminal events and optionally persists trace artifacts on failure. */ export class SessionTrace { + backend?: BackendIdentity; #events: TraceEvent[] = []; #seq = 0; readonly started = performance.now(); @@ -106,11 +108,15 @@ export class SessionTrace { runtime: currentRuntime(), platform: { os: process.platform, arch: process.arch }, ghostwrightVersion: '0.1.0', - ghostty: { - repository: 'https://github.com/ghostty-org/ghostty', - commit: 'f8041e849b36efbbb9736b6ecf0ccfcb01d94e69', - wasmSha256: wasmArtifact.sha256, - }, + backend: this.backend, + ghostty: + this.backend?.name === 'ghostty' + ? { + repository: 'https://github.com/ghostty-org/ghostty', + commit: 'f8041e849b36efbbb9736b6ecf0ccfcb01d94e69', + wasmSha256: wasmArtifact.sha256, + } + : undefined, ptyHost: { protocolVersion: 1, target, sha256: hostArtifact.sha256 }, profile: { term: 'xterm-ghostty', diff --git a/experiments/ghostwright/src/types.ts b/experiments/ghostwright/src/types.ts index c63eb2f..a6b9faf 100644 --- a/experiments/ghostwright/src/types.ts +++ b/experiments/ghostwright/src/types.ts @@ -1,3 +1,4 @@ +import type { CreateBackend } from './backend.ts'; import type { Operation } from 'effection'; import type { RegionLocator } from './locators.ts'; @@ -72,6 +73,8 @@ export interface TerminalExtensionDefinition { } export interface TerminalLaunchOptions { + /** Fresh scoped emulator; defaults to createGhosttyBackend. */ + backend?: CreateBackend; command: string; args?: readonly string[]; cwd?: string; diff --git a/experiments/ghostwright/test/backend.test.ts b/experiments/ghostwright/test/backend.test.ts new file mode 100644 index 0000000..fa55a10 --- /dev/null +++ b/experiments/ghostwright/test/backend.test.ts @@ -0,0 +1,346 @@ +import { expect, test } from 'bun:test'; +import { call, createContext, resource, run, suspend, type Operation } from 'effection'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { + GhostwrightError, + createGhosttyBackend, + launchTerminal, + expectTerminal, + replayTrace, + type CreateBackend, + type TerminalBackend, +} from '../src/index.ts'; +import { normalizeViewport } from '../src/profile.ts'; +import { createTerminal } from '../spikes/in-process.ts'; +import { withTerminal as withOperationTerminal } from '../src/effection/index.ts'; +import { TerminalOutput } from '../src/terminal/output.ts'; +import type { Observation } from '../src/observations.ts'; + +const bytes = (text: string): Uint8Array => new TextEncoder().encode(text); +const options = { viewport: normalizeViewport({ columns: 20, rows: 4 }) }; + +test('scoped backends isolate state, return effects, and reject escaped use after release', async () => { + let escaped!: TerminalBackend; + await run(function* () { + const first = (escaped = yield* createGhosttyBackend(options)); + const second = yield* createGhosttyBackend(options); + const initial = first.snapshot(); + const update = first.write(bytes('hello\x1b[?2004h\x1b[6n')); + expect(update.snapshot.lines[0]!.text).toContain('hello'); + expect(initial.lines[0]!.text).not.toContain('hello'); + expect(second.snapshot().lines[0]!.text).not.toContain('hello'); + expect(update.effects).toContainEqual({ type: 'write-pty', data: bytes('\x1b[1;6R') }); + expect(first.write(bytes('!')).effects).toEqual([]); + expect(first.encodePaste('paste')).toEqual(bytes('\x1b[200~paste\x1b[201~')); + expect(second.encodePaste('paste')).toEqual(bytes('paste')); + expect( + first.resize(normalizeViewport({ columns: 30, rows: 5 })).snapshot.viewport.columns, + ).toBe(30); + }); + expect(() => escaped.snapshot()).toThrow('scope is closed'); + expect(() => escaped.write(bytes('late'))).toThrow('scope is closed'); + expect(() => escaped.encodeKey('Enter')).toThrow('scope is closed'); +}); + +test('halting an operation releases its backend', async () => { + const ready = Promise.withResolvers(); + const task = run(function* () { + const backend = yield* createGhosttyBackend(options); + ready.resolve(backend); + yield* suspend(); + }); + const backend = await ready.promise; + await task.halt(); + expect(() => backend.snapshot()).toThrow('scope is closed'); +}); + +test('backend updates preserve semantic boundaries within one input chunk', async () => { + await run(function* () { + const backend = yield* createGhosttyBackend(options); + let current = backend.snapshot(); + const observations: Observation[] = []; + const output = new TerminalOutput( + [ + { + id: 'semantic', + osc: { + number: 7777, + namespace: 'test', + maxBufferedBytes: 1024, + decode: () => ({ protocolFrame: 1, value: { selector: '#save' } }), + }, + }, + ], + () => current, + (data) => (current = backend.write(data).snapshot), + ); + output.observations.subscribe((observation) => observations.push(observation)); + output.push(bytes('before\x1b]7777;test;v=1;payload\x1b\\\r\x1b[2Kafter')); + expect(observations.map((observation) => observation.kind)).toEqual([ + 'screen', + 'extension', + 'screen', + ]); + expect(observations[1]!.screen.lines[0]!.text).toContain('before'); + expect(current.lines[0]!.text).toContain('after'); + expect(output.observations.current('semantic')).toBeUndefined(); + }); +}); + +test('PTY actions use the supplied backend and replay requires its identity', async () => { + const directory = await mkdtemp(`${tmpdir()}/ghostwright-backend-`); + let released = 0; + let writes = 0; + const backend: CreateBackend = (settings) => + resource(function* (provide) { + const ghostty = yield* createGhosttyBackend(settings); + try { + yield* provide({ + ...ghostty, + identity: { name: 'test-keymap', version: '1' }, + write(data) { + writes++; + return ghostty.write(data); + }, + encodeKey(key) { + return ghostty.encodeKey( + typeof key === 'string' + ? key === 'x' + ? 'y' + : key + : { ...key, key: key.key === 'x' ? 'y' : key.key }, + ); + }, + }); + } finally { + released++; + } + }); + try { + const terminal = await launchTerminal({ + command: process.execPath, + args: [ + '-e', + 'process.stdin.setRawMode(true); process.stdin.on("data", b => { process.stdout.write("GOT:" + b); process.exit(0); }); process.stdout.write("READY");', + ], + backend, + trace: { policy: 'on', directory }, + }); + try { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + await terminal.keyboard.press('x'); + await expectTerminal(terminal.getByText('GOT:y')).toBePresent(); + expect((await terminal.process.waitForExit()).exitCode).toBe(0); + } finally { + await terminal.close(); + } + expect(writes).toBeGreaterThan(0); + expect(released).toBe(1); + const trace = `${directory}/${(await readdir(directory))[0]}`; + await expect(replayTrace(trace)).rejects.toThrow('Trace backend is incompatible'); + const replay = await replayTrace(trace, { backend }); + expect(replay.finalSnapshot.lines.map((line) => line.text).join('\n')).toContain('GOT:y'); + expect(released).toBe(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('local component sessions use the factory for output and resize and await cleanup', async () => { + let writes = 0, + resizes = 0, + released = false; + let acquired!: TerminalBackend; + const backend: CreateBackend = (settings) => + resource(function* (provide) { + const ghostty = (acquired = yield* createGhosttyBackend(settings)); + try { + yield* provide({ + ...ghostty, + write(data) { + writes++; + return ghostty.write(data); + }, + resize(viewport) { + resizes++; + return ghostty.resize(viewport); + }, + }); + } finally { + yield* call(() => Promise.resolve()); + released = true; + } + }); + const terminal = await createTerminal({ backend }); + try { + terminal.stdout.write('local'); + expect(terminal.screen.getText()).toContain('local'); + await terminal.resize({ columns: 30, rows: 5 }); + expect(terminal.screen.current().viewport.columns).toBe(30); + } finally { + await terminal[Symbol.asyncDispose](); + } + expect(writes).toBe(1); + expect(resizes).toBe(1); + expect(released).toBe(true); + expect(() => acquired.snapshot()).toThrow('scope is closed'); +}); + +test('failed process launch and failed extension setup release the acquired backend', async () => { + let released = 0; + const backend: CreateBackend = (settings) => + resource(function* (provide) { + const ghostty = yield* createGhosttyBackend(settings); + try { + yield* provide(ghostty); + } finally { + released++; + } + }); + await expect( + launchTerminal({ command: '/definitely/missing', backend, trace: 'off' }), + ).rejects.toMatchObject({ code: 'GW_LAUNCH' }); + expect(released).toBe(1); + await expect( + launchTerminal({ + command: process.execPath, + backend, + trace: 'off', + extensions: [ + { + id: 'broken', + setup() { + throw new GhostwrightError({ code: 'TEST_SETUP', message: 'broken setup' }); + }, + }, + ], + }), + ).rejects.toThrow('broken setup'); + expect(released).toBe(2); +}); + +test('failed backend acquisition releases resources before rejecting launch', async () => { + let acquired!: TerminalBackend; + const backend: CreateBackend = function* (settings): Operation { + acquired = yield* createGhosttyBackend(settings); + throw new GhostwrightError({ code: 'TEST_BACKEND', message: 'backend unavailable' }); + }; + await expect( + launchTerminal({ command: process.execPath, backend, trace: 'off' }), + ).rejects.toThrow('backend unavailable'); + expect(() => acquired.snapshot()).toThrow('scope is closed'); +}); + +test('session backend factories inherit Effection context and outlive process drain', async () => { + const context = createContext('backend-test-context'); + let acquired!: TerminalBackend; + let cleanupScreen = ''; + const backend: CreateBackend = function* (settings) { + expect(yield* context.expect()).toBe('scoped'); + return (acquired = yield* createGhosttyBackend(settings)); + }; + await run(function* () { + yield* context.set('scoped'); + yield* withOperationTerminal( + { + command: process.execPath, + args: [ + '-e', + 'process.stdin.setRawMode(true); process.stdin.resume(); process.on("SIGTERM", () => { process.stdout.write("DRAINED"); process.exit(0); }); process.stdout.write("READY");', + ], + backend, + trace: 'off', + extensions: [ + { + id: 'drain', + setup(observe) { + return () => { + cleanupScreen = observe + .current() + .lines.map((line) => line.text) + .join('\n'); + }; + }, + }, + ], + }, + function* (terminal) { + yield* terminal.screen.findByText('READY'); + yield* terminal.process.signal('SIGTERM'); + yield* terminal.process.waitForExit(); + }, + ); + }); + expect(cleanupScreen).toContain('DRAINED'); + expect(() => acquired.snapshot()).toThrow('scope is closed'); +}); + +test('cancelling during acquisition still closes a late backend and process', async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let acquired!: TerminalBackend; + let released = false; + let bodyEntered = false; + const backend: CreateBackend = (settings) => + resource(function* (provide) { + acquired = yield* createGhosttyBackend(settings); + try { + entered.resolve(); + yield* call(() => release.promise); + yield* provide(acquired); + } finally { + released = true; + } + }); + const task = run(function* () { + yield* withOperationTerminal( + { command: '/bin/sh', args: ['-c', 'sleep 30'], backend, trace: 'off' }, + function* () { + bodyEntered = true; + yield* suspend(); + }, + ); + }); + await entered.promise; + const stopping = task.halt().then(() => {}); + release.resolve(); + await stopping; + expect(bodyEntered).toBe(false); + expect(released).toBe(true); + expect(() => acquired.snapshot()).toThrow('scope is closed'); +}); + +test('a backend cleanup failure still closes extensions and surfaces the failure', async () => { + let extensionClosed = false; + const backend: CreateBackend = (settings) => + resource(function* (provide) { + const ghostty = yield* createGhosttyBackend(settings); + try { + yield* provide(ghostty); + } finally { + // oxlint-disable-next-line no-unsafe-finally -- Exercise a failing resource finalizer. + throw new GhostwrightError({ code: 'TEST_CLEANUP', message: 'backend cleanup failed' }); + } + }); + const terminal = await launchTerminal({ + command: '/bin/sh', + args: ['-c', 'printf READY; sleep 30'], + backend, + trace: 'off', + extensions: [ + { + id: 'cleanup', + setup() { + return () => { + extensionClosed = true; + }; + }, + }, + ], + }); + await expectTerminal(terminal.getByText('READY')).toBePresent(); + await expect(terminal.close()).rejects.toThrow(); + expect(extensionClosed).toBe(true); + expect(() => process.kill(terminal.process.status().pid!, 0)).toThrow(); +}); From ae79578511241965a48b25e2addcd0f6850c5ad9 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Thu, 22 Oct 2026 02:08:27 +0000 Subject: [PATCH 2/3] feat(ghostwright): compose keyboard operations and capture continuations --- experiments/ghostwright/README.md | 78 ++++- experiments/ghostwright/docs/architecture.md | 35 ++ .../ghostwright/src/effection/index.ts | 22 +- experiments/ghostwright/src/execution.ts | 94 +++++- experiments/ghostwright/src/index.ts | 1 + experiments/ghostwright/src/operations.ts | 85 +++++ experiments/ghostwright/src/types.ts | 3 + .../ghostwright/test/operations.test.ts | 302 ++++++++++++++++++ .../ghostwright/type-tests/operations.ts | 40 +++ 9 files changed, 638 insertions(+), 22 deletions(-) create mode 100644 experiments/ghostwright/src/operations.ts create mode 100644 experiments/ghostwright/test/operations.test.ts create mode 100644 experiments/ghostwright/type-tests/operations.ts diff --git a/experiments/ghostwright/README.md b/experiments/ghostwright/README.md index 2f7c035..ab53010 100644 --- a/experiments/ghostwright/README.md +++ b/experiments/ghostwright/README.md @@ -219,6 +219,68 @@ const matches = await terminal.history.findText('tool completed', { direction: ' - [C versus Rust PTY-host comparison](HOST-COMPARISON.md) - [Performance report](PERFORMANCE.md) +## Composable execution + +Keyboard input runs through an operation-based driver and typed middleware. +The first middleware is outermost; capture-local middleware runs inside inherited +middleware. Existing keyboard methods use this path in both async and Effection APIs. + +```ts +import { + launchTerminal, + captureProgram, + regionLocator, + textContains, + type KeyboardMiddleware, + type TerminalProgram, +} from 'ghostwright'; + +const logKeys: KeyboardMiddleware = { + *press(args, next) { + const receipt = yield* next(...args); + console.log('acknowledged', receipt.actionSequence); + return receipt; + }, +}; + +const submit: TerminalProgram = function* ({ keyboard }) { + yield* keyboard.type('hello'); + yield* keyboard.press('Enter'); +}; + +await using terminal = await launchTerminal({ + command: 'my-cli', + keyboardMiddleware: [logKeys], +}); +await terminal.screen.findByText('Ready'); + +const viewport = regionLocator({ column: 0, row: 0, width: 80, height: 24 }); +const recording = await terminal.run( + captureProgram( + { + until: viewport.satisfies(textContains('Complete')), + }, + submit, + ), +); +``` + +The same program runs as `yield* terminal.run(program)` on the Effection terminal. +Programs receive a capability facade with keyboard operations and nested capture. +They return `Operation`; the async runner returns `Promise`. `captureProgram` +returns the recording and waits for both the condition and the continuation. + +Use `keyboardMiddleware` in capture options to add behavior only within that +capture and its children. Middleware may use `try/finally` for scoped cleanup; +cancellation awaits it and cancels pending input without closing the parent +session. Middleware is trusted: calling `next` twice sends two actions. Ghostwright +does not retry keyboard actions automatically. + +`createKeyboardOperations({ driver, middleware })` provides the same typed +composition for an explicit driver. `composeOperation` is the underlying function +combinator. This initial operation core covers keyboard input and capture; other +terminal APIs continue using their existing adapters. + ## Terminal backend Ghostty remains the default and sole shipped emulator. A session can supply a @@ -228,14 +290,14 @@ Ghostty remains the default and sole shipped emulator. A session can supply a import { createGhosttyBackend, launchTerminal, type CreateBackend } from 'ghostwright'; const backend: CreateBackend = function* (options) { - const ghostty = yield* createGhosttyBackend(options); - return { - ...ghostty, - write(bytes) { - const update = ghostty.write(bytes); - return update; // detached snapshot and terminal effects - }, - }; + const ghostty = yield* createGhosttyBackend(options); + return { + ...ghostty, + write(bytes) { + const update = ghostty.write(bytes); + return update; // detached snapshot and terminal effects + }, + }; }; await using terminal = await launchTerminal({ command: 'my-cli', backend }); diff --git a/experiments/ghostwright/docs/architecture.md b/experiments/ghostwright/docs/architecture.md index 915efc3..d410e76 100644 --- a/experiments/ghostwright/docs/architecture.md +++ b/experiments/ghostwright/docs/architecture.md @@ -208,3 +208,38 @@ Explicit overrides of profile-owned environment keys are rejected. Other explici Maintainers use pinned Zig for upstream Ghostty WASM and Cargo plus the target linker for the Rust host. Artifact metadata pins source commit, toolchains, build flags, protocol/binding versions, ABI layouts, and checksums. Independent verification checks files without rebuilding them. + +## Operation composition + +Keyboard execution has an explicit capability boundary in `src/operations.ts`. +`KeyboardOperations` is the port contract. `createKeyboardOperations` accepts a +driver and an ordered list of typed middleware; callers receive the composed +facade. Each middleware receives the original argument tuple and a `next` +continuation with the same result type. The first entry is outermost. + +The session adapter implements this contract using the existing PTY action +machinery. Encoding, command serialization, action receipts, and shutdown guards +remain in the session. Middleware can surround an operation's execution and +cleanup, but the raw driver is not exposed by the resulting execution capability. +Middleware is trusted application code: forwarding twice performs two actions; +there is no implicit retry or exactly-once enforcement. + +`TerminalProgram` is a function from `ExecutionOperations` to `Operation`. +`AsyncExecution.run` executes a program in its owned scope; the Effection +facade's `run` invokes the same program in the caller's current scope. Both use +the same composed keyboard operations. Native keyboard calls no longer travel +through the Promise facade, so middleware can resolve the caller's Effection +context. The driver combines current-operation cancellation with owner +cancellation, including interruption of blocked PTY writes. + +`captureProgram(options, body)` wraps a continuation with the existing capture +kernel. Recording subscribes before the body starts; completion still requires +both the condition and the body, and the deadline bounds both. Capture-local +keyboard middleware is appended inside inherited middleware and passed to nested +captures. It does not modify the parent. Escaped child capabilities reject calls +after the capture closes, and middleware cleanup completes before cancellation +returns. The existing callback capture APIs use the same inheritance rules. + +This first boundary covers keyboard operations and capture programs. Mouse, +process, query, and graphics APIs retain their existing adapters. Driver injection +is explicit; no scope-global port registry or experimental Effection API is needed. diff --git a/experiments/ghostwright/src/effection/index.ts b/experiments/ghostwright/src/effection/index.ts index de48afa..3cdb2d6 100644 --- a/experiments/ghostwright/src/effection/index.ts +++ b/experiments/ghostwright/src/effection/index.ts @@ -1,3 +1,4 @@ +import type { TerminalProgram } from '../operations.ts'; import { call, type Operation } from 'effection'; import type { AssertionOptions, @@ -82,16 +83,23 @@ export class EffectionTerminal implements OperationTerminal { options: CaptureOptions, body: (terminal: EffectionTerminal) => Operation, ): ReturnType { - return captureOperation(this.inner.session, options, (terminal) => - body(new EffectionTerminal(terminal)), + return captureOperation( + this.inner.session, + options, + (terminal) => body(new EffectionTerminal(terminal)), + this.inner.keyboardMiddleware, ); } + /** Invoke a program directly in the caller scope through owned capabilities. */ + run(program: TerminalProgram): Operation { + return this.inner.runOperation(program); + } keyboard = { - press: (k: KeyName | KeyPress) => op(() => this.inner.keyboard.press(k)), - type: (t: string, o?: TraceableInputOptions) => op(() => this.inner.keyboard.type(t, o)), - paste: (t: string, o?: TraceableInputOptions) => op(() => this.inner.keyboard.paste(t, o)), - focus: (s: 'in' | 'out') => op(() => this.inner.keyboard.focus(s)), - write: (d: Uint8Array) => op(() => this.inner.keyboard.write(d)), + press: (k: KeyName | KeyPress) => this.inner.operations.keyboard.press(k), + type: (t: string, o?: TraceableInputOptions) => this.inner.operations.keyboard.type(t, o), + paste: (t: string, o?: TraceableInputOptions) => this.inner.operations.keyboard.paste(t, o), + focus: (s: 'in' | 'out') => this.inner.operations.keyboard.focus(s), + write: (d: Uint8Array) => this.inner.operations.keyboard.write(d), }; mouse = { move: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.move(p, o)), diff --git a/experiments/ghostwright/src/execution.ts b/experiments/ghostwright/src/execution.ts index 46081f5..ffddbcd 100644 --- a/experiments/ghostwright/src/execution.ts +++ b/experiments/ghostwright/src/execution.ts @@ -1,3 +1,9 @@ +import { + createKeyboardOperations, + type KeyboardMiddleware, + type ExecutionOperations, + type TerminalProgram, +} from './operations.ts'; import { action, call, @@ -32,6 +38,8 @@ import { waitForOperation, type WaitForOptions } from './wait-for.ts'; import { locatedMouse, type LocatedMouse } from './mouse.ts'; export interface CaptureOptions { + /** Appended inside inherited middleware for this capture and its children. */ + readonly keyboardMiddleware?: readonly KeyboardMiddleware[]; readonly until: Condition; readonly timeoutMs?: number; readonly maxObservations?: number; @@ -135,13 +143,66 @@ export class AsyncExecution implements AsyncTerminal { readonly history: AsyncTerminal['history']; readonly graphics: AsyncTerminal['graphics']; readonly session: TerminalSession; + readonly operations: ExecutionOperations; + readonly keyboardMiddleware: readonly KeyboardMiddleware[]; private readonly scope: Scope; readonly signal: AbortSignal; - constructor(session: TerminalSession, scope: Scope, signal: AbortSignal) { + constructor( + session: TerminalSession, + scope: Scope, + signal: AbortSignal, + middleware = session.options.keyboardMiddleware ?? [], + ) { this.session = session; this.scope = scope; this.signal = signal; - this.keyboard = this.#bind(session.keyboardFor(signal)); + this.keyboardMiddleware = Object.freeze([...middleware]); + const invoke = function* ( + body: (keyboard: AsyncTerminal['keyboard']) => Promise, + ): Operation { + const current = yield* useAbortSignal(); + return yield* call(() => body(session.keyboardFor(AbortSignal.any([signal, current])))); + }; + const keyboard = createKeyboardOperations({ + driver: { + press: (...args) => invoke((k) => k.press(...args)), + type: (...args) => invoke((k) => k.type(...args)), + paste: (...args) => invoke((k) => k.paste(...args)), + focus: (...args) => invoke((k) => k.focus(...args)), + write: (...args) => invoke((k) => k.write(...args)), + }, + middleware: this.keyboardMiddleware, + }); + this.operations = Object.freeze({ + keyboard: Object.freeze({ + press: (...args: Parameters) => + this.#owned(() => keyboard.press(...args)), + type: (...args: Parameters) => + this.#owned(() => keyboard.type(...args)), + paste: (...args: Parameters) => + this.#owned(() => keyboard.paste(...args)), + focus: (...args: Parameters) => + this.#owned(() => keyboard.focus(...args)), + write: (...args: Parameters) => + this.#owned(() => keyboard.write(...args)), + }), + capture: (options: CaptureOptions, body: TerminalProgram) => + this.#owned(() => + captureOperation( + session, + options, + (child) => body(child.operations), + this.keyboardMiddleware, + ), + ), + }); + this.keyboard = { + press: (...args) => this.run((t) => t.keyboard.press(...args)), + type: (...args) => this.run((t) => t.keyboard.type(...args)), + paste: (...args) => this.run((t) => t.keyboard.paste(...args)), + focus: (...args) => this.run((t) => t.keyboard.focus(...args)), + write: (...args) => this.run((t) => t.keyboard.write(...args)), + }; this.mouse = locatedMouse(this.#bind(session.mouseFor(signal)), (locator, matcher) => this.assert(locator, matcher), ); @@ -172,6 +233,15 @@ export class AsyncExecution implements AsyncTerminal { }), }); } + *#owned(operation: () => Operation): Operation { + this.signal.throwIfAborted(); + return yield* race([scoped(operation), aborted(this.signal)]); + } + /** Run reusable operation work through this executor's owned scope. */ + run = (program: TerminalProgram): Promise => this.#run(() => program(this.operations)); + /** Native callers retain their current scope; owner cancellation still applies. */ + runOperation = (program: TerminalProgram): Operation => + this.#owned(() => program(this.operations)); #bind Promise>>(methods: T): T { const bind = (method: (...args: Args) => Promise) => @@ -235,8 +305,11 @@ export class AsyncExecution implements AsyncTerminal { body: (execution: AsyncExecution) => Promise, ): Promise => this.#run(() => - captureOperation(this.session, options, (child) => - call(() => Promise.resolve().then(() => body(child))), + captureOperation( + this.session, + options, + (child) => call(() => Promise.resolve().then(() => body(child))), + this.keyboardMiddleware, ), ); } @@ -271,8 +344,11 @@ export function* assertRegion( } } -export function* execution(session: TerminalSession): Operation { - return new AsyncExecution(session, yield* useScope(), yield* useAbortSignal()); +export function* execution( + session: TerminalSession, + middleware = session.options.keyboardMiddleware ?? [], +): Operation { + return new AsyncExecution(session, yield* useScope(), yield* useAbortSignal(), middleware); } // oxlint-disable-next-line bombshell-dev/max-params -- shared async/operation capture executor @@ -280,6 +356,7 @@ export function* captureOperation( session: TerminalSession, options: CaptureOptions, body: (execution: AsyncExecution) => Operation, + inherited = session.options.keyboardMiddleware ?? [], ): Operation { const max = options.maxObservations ?? 1000, maxBytes = options.maxBytes ?? 64 * 1024 * 1024; @@ -287,7 +364,10 @@ export function* captureOperation( throw new InvalidOptionsError('Capture limits must be positive safe integers'); return yield* bounded( scoped(function* () { - const child = yield* execution(session); + const child = yield* execution(session, [ + ...inherited, + ...(options.keyboardMiddleware ?? []), + ]); const startedAt = performance.now(), baseline = session.observations.current()!; const state = options.until.create(startedAt), diff --git a/experiments/ghostwright/src/index.ts b/experiments/ghostwright/src/index.ts index ff7e6b3..9a57903 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -1,3 +1,4 @@ +export * from './operations.ts'; export type * from './backend.ts'; export { createGhosttyBackend } from './terminal/ghostty-backend.ts'; export * from './types.ts'; diff --git a/experiments/ghostwright/src/operations.ts b/experiments/ghostwright/src/operations.ts new file mode 100644 index 0000000..76c34c4 --- /dev/null +++ b/experiments/ghostwright/src/operations.ts @@ -0,0 +1,85 @@ +// oxlint-disable bombshell-dev/exported-function-async -- Composition constructs functions; execution returns Effection Operations. +import type { Operation } from 'effection'; +import type { ActionReceipt, KeyName, KeyPress, TraceableInputOptions } from './types.ts'; +import type { Capture, CaptureOptions } from './execution.ts'; + +/** A typed continuation around one operation. First middleware is outermost. */ +export type OperationMiddleware = ( + args: Args, + next: (...args: Args) => Operation, +) => Operation; + +/** Compose without capturing a scope; execution belongs to the caller. */ +export function composeOperation( + driver: (...args: Args) => Operation, + middleware: readonly (OperationMiddleware | undefined)[], +): (...args: Args) => Operation { + return middleware.reduceRight( + (next, around) => (around ? (...args) => around(args, next) : next), + driver, + ); +} + +/** Input capability implemented by a driver and exposed through middleware. */ +export interface KeyboardOperations { + press(key: KeyName | KeyPress): Operation; + type(text: string, options?: TraceableInputOptions): Operation; + paste(text: string, options?: TraceableInputOptions): Operation; + focus(state: 'in' | 'out'): Operation; + write(data: Uint8Array): Operation; +} + +export type KeyboardMiddleware = { + readonly [K in keyof KeyboardOperations]?: KeyboardOperations[K] extends ( + ...args: infer Args + ) => Operation + ? OperationMiddleware + : never; +}; + +/** Build a middleware-preserving capability from an explicitly supplied driver. */ +export function createKeyboardOperations(options: { + readonly driver: KeyboardOperations; + readonly middleware?: readonly KeyboardMiddleware[]; +}): KeyboardOperations { + const { driver, middleware = [] } = options; + return Object.freeze({ + press: composeOperation( + (...args: Parameters) => driver.press(...args), + middleware.map((m) => m.press), + ), + type: composeOperation( + (...args: Parameters) => driver.type(...args), + middleware.map((m) => m.type), + ), + paste: composeOperation( + (...args: Parameters) => driver.paste(...args), + middleware.map((m) => m.paste), + ), + focus: composeOperation( + (...args: Parameters) => driver.focus(...args), + middleware.map((m) => m.focus), + ), + write: composeOperation( + (...args: Parameters) => driver.write(...args), + middleware.map((m) => m.write), + ), + }); +} + +/** Reusable work receives capabilities, never a raw driver or captured Scope. */ +export type TerminalProgram = (terminal: ExecutionOperations) => Operation; + +/** The initial operation core covers keyboard input and scoped capture. */ +export interface ExecutionOperations { + readonly keyboard: KeyboardOperations; + capture(options: CaptureOptions, body: TerminalProgram): Operation; +} + +/** Subscribe before invoking the continuation; retain capture's completion rules. */ +export function captureProgram( + options: CaptureOptions, + body: TerminalProgram, +): TerminalProgram { + return (terminal) => terminal.capture(options, body); +} diff --git a/experiments/ghostwright/src/types.ts b/experiments/ghostwright/src/types.ts index a6b9faf..9a4f5ae 100644 --- a/experiments/ghostwright/src/types.ts +++ b/experiments/ghostwright/src/types.ts @@ -1,3 +1,4 @@ +import type { KeyboardMiddleware } from './operations.ts'; import type { CreateBackend } from './backend.ts'; import type { Operation } from 'effection'; import type { RegionLocator } from './locators.ts'; @@ -73,6 +74,8 @@ export interface TerminalExtensionDefinition { } export interface TerminalLaunchOptions { + /** Keyboard operation middleware, outermost first. */ + readonly keyboardMiddleware?: readonly KeyboardMiddleware[]; /** Fresh scoped emulator; defaults to createGhosttyBackend. */ backend?: CreateBackend; command: string; diff --git a/experiments/ghostwright/test/operations.test.ts b/experiments/ghostwright/test/operations.test.ts new file mode 100644 index 0000000..a66ee76 --- /dev/null +++ b/experiments/ghostwright/test/operations.test.ts @@ -0,0 +1,302 @@ +import { expect, test } from 'bun:test'; +import { call, createContext, run, scoped, suspend } from 'effection'; +import { + captureProgram, + composeOperation, + launchTerminal, + regionLocator, + textContains, + type ExecutionOperations, + type KeyboardMiddleware, + type TerminalProgram, +} from '../src/index.ts'; +import { withTerminal as withOperationTerminal } from '../src/effection/index.ts'; + +const screen = regionLocator({ column: 0, row: 0, width: 80, height: 24 }); +const options = { + command: process.execPath, + args: [ + '-e', + 'process.stdin.setRawMode(true); process.stdin.on("data", b => process.stdout.write(b)); process.stdout.write("READY");', + ], + trace: 'off' as const, +}; + +function around(label: string, events: string[]): KeyboardMiddleware { + return { + *press(args, next) { + events.push(`${label}:before`); + try { + return yield* next(...args); + } finally { + events.push(`${label}:after`); + } + }, + }; +} + +test('operation composition preserves typed results, order, and failure identity', async () => { + const events: string[] = []; + const failure = { reason: 'driver failed' }; + const operation = composeOperation( + function* (value: number) { + events.push('driver'); + if (value < 0) throw failure; + yield* call(() => Promise.resolve()); + return value * 2; + }, + [ + function* ([value], next) { + events.push('outer'); + try { + return (yield* next(value + 1)) + 3; + } finally { + events.push('cleanup'); + } + }, + function* (args, next) { + events.push('inner'); + return yield* next(...args); + }, + ], + ); + expect(await run(() => operation(2))).toBe(9); + expect(events).toEqual(['outer', 'inner', 'driver', 'cleanup']); + await expect(Promise.resolve(run(() => operation(-2)))).rejects.toBe(failure); + expect(events.slice(-4)).toEqual(['outer', 'inner', 'driver', 'cleanup']); +}); + +for (const mode of ['async', 'effection'] as const) { + test(`${mode} keyboard facade invokes middleware once and returns the real receipt`, async () => { + const events: string[] = []; + let sequence = 0; + const middleware: KeyboardMiddleware = { + *press([key], next) { + expect(key).toBe('x'); + events.push('transform'); + const receipt = yield* next('y'); + sequence = receipt.actionSequence; + return receipt; + }, + }; + const configured = { ...options, keyboardMiddleware: [around('outer', events), middleware] }; + if (mode === 'async') { + await using terminal = await launchTerminal(configured); + await terminal.screen.findByText('READY'); + const receipt = await terminal.keyboard.press('x'); + expect(receipt.actionSequence).toBe(sequence); + await terminal.screen.findByText('READYy'); + } else { + await run(() => + withOperationTerminal(configured, function* (terminal) { + yield* terminal.screen.findByText('READY'); + const receipt = yield* terminal.keyboard.press('x'); + expect(receipt.actionSequence).toBe(sequence); + yield* terminal.screen.findByText('READYy'); + }), + ); + } + expect(events).toEqual(['outer:before', 'transform', 'outer:after']); + }); +} + +test('native operation middleware resolves context at invocation, including nested scopes', async () => { + const context = createContext('keyboard-test-context', 'outer'); + const seen: string[] = []; + await run(() => + withOperationTerminal( + { + ...options, + keyboardMiddleware: [ + { + *press(args, next) { + seen.push(yield* context.expect()); + return yield* next(...args); + }, + }, + ], + }, + function* (terminal) { + yield* terminal.screen.findByText('READY'); + yield* scoped(function* () { + yield* context.set('inner'); + yield* terminal.keyboard.press('a'); + }); + yield* terminal.keyboard.press('b'); + }, + ), + ); + expect(seen).toEqual(['inner', 'outer']); +}); + +for (const mode of ['async', 'effection'] as const) { + test(`${mode} capture programs inherit middleware and isolate nested overrides`, async () => { + const events: string[] = []; + let escaped!: ExecutionOperations; + const program = captureProgram( + { + until: screen.satisfies(textContains('READYab')), + keyboardMiddleware: [around('capture', events)], + }, + function* (terminal) { + escaped = terminal; + yield* terminal.capture( + { + until: screen.satisfies(textContains('READYa')), + keyboardMiddleware: [around('nested', events)], + }, + function* (child) { + yield* child.keyboard.press('a'); + }, + ); + yield* terminal.keyboard.press('b'); + }, + ); + const configured = { ...options, keyboardMiddleware: [around('root', events)] }; + if (mode === 'async') { + await using terminal = await launchTerminal(configured); + await terminal.screen.findByText('READY'); + const capture = await terminal.run(program); + expect(capture.observations.at(-1)!.screen.lines[0]!.text).toContain('READYab'); + await expect(Promise.resolve(run(() => escaped.keyboard.press('!')))).rejects.toBeDefined(); + await terminal.keyboard.press('c'); + } else { + await run(() => + withOperationTerminal(configured, function* (terminal) { + yield* terminal.screen.findByText('READY'); + const capture = yield* terminal.run(program); + expect(capture.observations.at(-1)!.screen.lines[0]!.text).toContain('READYab'); + let rejected = false; + try { + yield* escaped.keyboard.press('!'); + } catch { + rejected = true; + } + expect(rejected).toBe(true); + yield* terminal.keyboard.press('c'); + }), + ); + } + expect(events).toEqual([ + 'root:before', + 'capture:before', + 'nested:before', + 'nested:after', + 'capture:after', + 'root:after', + 'root:before', + 'capture:before', + 'capture:after', + 'root:after', + 'root:before', + 'root:after', + ]); + }); +} + +test('capture cancellation unwinds middleware before returning and leaves parent usable', async () => { + let cleaned = false; + let forwarded = false; + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + const program = captureProgram( + { + timeoutMs: 40, + until: screen.satisfies(textContains('NEVER')), + keyboardMiddleware: [ + { + *press(args, next) { + try { + yield* suspend(); + forwarded = true; + return yield* next(...args); + } finally { + yield* call(() => + Promise.resolve().then(() => { + cleaned = true; + }), + ); + } + }, + }, + ], + }, + function* (child) { + yield* child.keyboard.press('x'); + }, + ); + await expect(terminal.run(program)).rejects.toMatchObject({ code: 'GW_CAPTURE_TIMEOUT' }); + expect(cleaned).toBe(true); + expect(forwarded).toBe(false); + await terminal.keyboard.press('z'); + await terminal.screen.findByText('READYz'); +}); + +test('program failures are catchable and owner disposal cancels pending continuations', async () => { + const terminal = await launchTerminal(options); + try { + await terminal.screen.findByText('READY'); + const failure = { reason: 'program failed' }; + const failing: TerminalProgram = function* () { + yield* call(() => Promise.reject(failure)); + throw failure; + }; + await expect(terminal.run(failing)).rejects.toBe(failure); + await terminal.keyboard.press('q'); + const entered = Promise.withResolvers(); + let cleaned = false; + const pending = terminal + .run(function* () { + try { + entered.resolve(); + yield* suspend(); + } finally { + cleaned = true; + } + }) + .catch((error) => error); + await entered.promise; + await terminal.close(); + expect(await pending).toBeDefined(); + expect(cleaned).toBe(true); + } finally { + await terminal.close(); + } +}); + +test('native capture cancellation interrupts a blocked driver write and preserves its parent', async () => { + await run(() => + withOperationTerminal( + { + ...options, + args: [ + '-e', + 'process.stdin.setRawMode(true); process.stdout.write("READY"); setInterval(() => {}, 1000)', + ], + }, + function* (terminal) { + yield* terminal.screen.findByText('READY'); + let failure: unknown; + try { + yield* terminal.run( + captureProgram( + { + timeoutMs: 40, + until: screen.satisfies(textContains('NEVER')), + }, + function* (child) { + yield* child.keyboard.write(new Uint8Array(1024 * 1024)); + }, + ), + ); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ code: 'GW_CAPTURE_TIMEOUT' }); + expect(terminal.process.status().state).toBe('running'); + yield* terminal.process.signal('SIGTERM'); + yield* terminal.process.waitForExit(); + }, + ), + ); +}); diff --git a/experiments/ghostwright/type-tests/operations.ts b/experiments/ghostwright/type-tests/operations.ts new file mode 100644 index 0000000..563669e --- /dev/null +++ b/experiments/ghostwright/type-tests/operations.ts @@ -0,0 +1,40 @@ +import type { Operation } from 'effection'; +import { + captureProgram, + type ActionReceipt, + type AsyncExecution, + type Capture, + type CaptureOptions, + type EffectionTerminal, + type KeyboardMiddleware, + type TerminalProgram, +} from '../src/index.ts'; + +declare const asyncTerminal: AsyncExecution; +declare const nativeTerminal: EffectionTerminal; +declare const options: CaptureOptions; + +const program: TerminalProgram = function* ({ keyboard }) { + // @ts-expect-error Raw input accepts bytes, not strings. + yield* keyboard.write('hello'); + return yield* keyboard.press('Enter'); +}; +const asyncResult: Promise = asyncTerminal.run(program); +const nativeResult: Operation = nativeTerminal.run(program); +const recorded: Promise = asyncTerminal.run(captureProgram(options, program)); +// @ts-expect-error Capturing returns evidence, not the continuation's return value. +const receipt: Promise = asyncTerminal.run(captureProgram(options, program)); +const middleware: KeyboardMiddleware = { + *press(args, next) { + // @ts-expect-error The continuation preserves the key argument type. + yield* next(42); + const result: ActionReceipt = yield* next(...args); + return result; + }, + // @ts-expect-error Middleware must preserve the action receipt result type. + *write(args, next) { + yield* next(...args); + return 'written'; + }, +}; +void [asyncResult, nativeResult, recorded, receipt, middleware]; From edeac257015f710d241932179148c77125a9d8ad Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Thu, 22 Oct 2026 02:30:29 +0000 Subject: [PATCH 3/3] fix(ghostwright): align native execution with caller scope ownership --- experiments/ghostwright/README.md | 8 +- experiments/ghostwright/docs/architecture.md | 42 ++- .../ghostwright/src/assertions/index.ts | 100 ++++--- .../ghostwright/src/effection/index.ts | 146 +++++---- experiments/ghostwright/src/execution.ts | 23 +- experiments/ghostwright/src/index.ts | 2 +- .../ghostwright/src/terminal/session.ts | 138 ++++++--- .../ghostwright/test/scope-ownership.test.ts | 283 ++++++++++++++++++ 8 files changed, 589 insertions(+), 153 deletions(-) create mode 100644 experiments/ghostwright/test/scope-ownership.test.ts diff --git a/experiments/ghostwright/README.md b/experiments/ghostwright/README.md index ab53010..7220d17 100644 --- a/experiments/ghostwright/README.md +++ b/experiments/ghostwright/README.md @@ -271,9 +271,11 @@ They return `Operation`; the async runner returns `Promise`. `captureProgr returns the recording and waits for both the condition and the continuation. Use `keyboardMiddleware` in capture options to add behavior only within that -capture and its children. Middleware may use `try/finally` for scoped cleanup; -cancellation awaits it and cancels pending input without closing the parent -session. Middleware is trusted: calling `next` twice sends two actions. Ghostwright +capture and its children. Register asynchronous cleanup with Effection `ensure`; +cancellation awaits that release and cancels pending input without closing the +parent session. Effection 4.2 can interrupt a `finally` block that is already +yielding when cancellation arrives, so reserve ordinary `finally` for synchronous +cleanup. Middleware is trusted: calling `next` twice sends two actions. Ghostwright does not retry keyboard actions automatically. `createKeyboardOperations({ driver, middleware })` provides the same typed diff --git a/experiments/ghostwright/docs/architecture.md b/experiments/ghostwright/docs/architecture.md index d410e76..0db6afe 100644 --- a/experiments/ghostwright/docs/architecture.md +++ b/experiments/ghostwright/docs/architecture.md @@ -237,9 +237,49 @@ kernel. Recording subscribes before the body starts; completion still requires both the condition and the body, and the deadline bounds both. Capture-local keyboard middleware is appended inside inherited middleware and passed to nested captures. It does not modify the parent. Escaped child capabilities reject calls -after the capture closes, and middleware cleanup completes before cancellation +after the capture closes, and cleanup registered with `ensure` completes before cancellation returns. The existing callback capture APIs use the same inheritance rules. This first boundary covers keyboard operations and capture programs. Mouse, process, query, and graphics APIs retain their existing adapters. Driver injection is explicit; no scope-global port registry or experimental Effection API is needed. + +## Scope ownership and cancellation + +Native `withTerminal` creates an explicit `scoped` boundary. Delegating to a +resource with `yield*` alone shares the caller's scope; it does not delimit the +session lifetime. On success, failure, or halt, the session now drains and closes +before control returns to the outer operation. `useSession` remains a lower-level +resource whose lifetime intentionally belongs to the acquiring scope. + +| Boundary | Context and lifetime | +| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Native keyboard operation / program | Child of the invoking scope; inherits current context and also observes terminal-owner cancellation. | +| Native Promise facade (queries, mouse, process, history, graphics) | Creates an invocation-local executor, so its async tasks belong to the caller's subtree instead of being siblings under the terminal owner. | +| Async terminal methods | Explicitly execute in the terminal/capture owner scope. A foreign Effection caller's context does not cross a Promise call automatically. | +| Capture | Owns observation subscriptions, timers, callback tasks, and child capabilities. Local middleware is an explicit inherited list, not a dynamic Effection API registry. | +| Backend scope | Explicit async bridge with an optional native parent for inherited context. The session keeps it alive through PTY output drain and explicitly awaits its release. | + +`useAbortSignal` is itself a resource. Its abortion follows cleanup registration +order, so cleanup must not assume that every signal is already aborted when it +starts. Native Promise bridges pass an invocation signal to actual cancellable +work. Cancelling only the Effection `call` would abandon the Promise while its +subscriptions, timers, or input remained active. Legacy assertions, locator +clicks, revision collection, and process-exit timeouts release their waits on +abort as well. Synchronous snapshots remain evidence values and do not acquire +new scopes. + +Middleware functions and `composeOperation` do not capture a `Scope`. Explicit +owner binding exists only at the async facade; native invocation preserves the +caller's context. Concurrent callers can shadow context values without affecting +each other, and operation resources release before the action returns. + +For asynchronous middleware release, use `yield* ensure(function* () { ... })`. +Effection 4.2 has characterized limitations around yielding `finally`: a halt +arriving while a finally block is already yielding can skip its remaining work, +and a yielding delegated finally can resume its caller after halt. Ghostwright +does not claim to repair arbitrary user generators. Its framework-owned resource +finalizers enter cleanup when their suspended resource is released; synchronous +capture unsubscribe/stop paths do not yield. The ownership tests gate `ensure` +cleanup and verify that repeated disposal and shutdown during an already-running +release wait for completion. diff --git a/experiments/ghostwright/src/assertions/index.ts b/experiments/ghostwright/src/assertions/index.ts index 264d5eb..cd41317 100644 --- a/experiments/ghostwright/src/assertions/index.ts +++ b/experiments/ghostwright/src/assertions/index.ts @@ -70,23 +70,53 @@ function diagnostic( .join(', ') || 'none' }\n\n ${tens}\n ${ones}\n${rows}`; } -// oxlint-disable-next-line bombshell-dev/max-params -- wait needs all four params for polling logic +// oxlint-disable-next-line bombshell-dev/max-params -- polling, diagnostics, and cancellation boundary async function wait( session: TerminalSession, test: () => boolean, timeout: number, message: () => string, + signal?: AbortSignal, ): Promise { try { - await session.waitForChange(test, timeout); + await session.waitForChange(test, timeout, signal); } catch (cause) { + signal?.throwIfAborted(); if (cause instanceof StrictLocatorError) throw cause; throw new TerminalAssertionError(message(), { cause }); } } +/** Wait for a revision or settlement timer, releasing both on cancellation. */ +function pause( + session: TerminalSession, + options: { milliseconds: number; signal?: AbortSignal }, +): Promise { + const { milliseconds, signal } = options; + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const finish = (): void => { + clearTimeout(timer); + off(); + signal?.removeEventListener('abort', abort); + resolve(); + }; + const off = session.subscribe(finish); + const timer = setTimeout(finish, milliseconds); + const abort = (): void => { + reject(signal!.reason); + finish(); + }; + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + }); +} class LocatorExpectation implements AsyncLocatorExpectation { constructor(readonly locator: Locator) {} + get signal(): AbortSignal | undefined { + return this.locator.signal; + } async toBePresent(options: AssertionOptions = {}): Promise { + this.signal?.throwIfAborted(); const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? @@ -94,6 +124,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { try { return await this.locator.unique(timeout); } catch (cause) { + this.signal?.throwIfAborted(); if (cause instanceof StrictLocatorError) throw cause; throw new TerminalAssertionError( diagnostic( @@ -106,6 +137,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { } } async toBeStable(options: StableAssertionOptions = {}): Promise { + this.signal?.throwIfAborted(); const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? @@ -114,6 +146,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { start = performance.now(); let match = await this.toBePresent({ timeoutMs: timeout }); for (;;) { + this.signal?.throwIfAborted(); const age = this.locator.session.now() - this.locator.session.screen.current().lastVisualChangeAt, remaining = Math.max(0, settle - age); @@ -127,17 +160,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { settle, ), ); - await new Promise((resolve) => { - const off = this.locator.session.subscribe(() => { - off(); - clearTimeout(timer); - resolve(); - }), - timer = setTimeout(() => { - off(); - resolve(); - }, remaining); - }); + await pause(this.locator.session, { milliseconds: remaining, signal: this.signal }); const m = this.locator.matches(); if (m.length > 1) throw new StrictLocatorError( @@ -151,6 +174,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { } } async toBeAbsent(options: StableAssertionOptions = {}): Promise { + this.signal?.throwIfAborted(); const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? @@ -158,22 +182,13 @@ class LocatorExpectation implements AsyncLocatorExpectation { settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, start = performance.now(); for (;;) { + this.signal?.throwIfAborted(); if (this.locator.matches().length === 0) { const snapshot = this.locator.session.screen.current(), age = this.locator.session.now() - snapshot.lastVisualChangeAt, remaining = Math.max(0, settle - age); if (remaining === 0) return; - await new Promise((resolve) => { - const off = this.locator.session.subscribe(() => { - off(); - clearTimeout(timer); - resolve(); - }), - timer = setTimeout(() => { - off(); - resolve(); - }, remaining); - }); + await pause(this.locator.session, { milliseconds: remaining, signal: this.signal }); if ( this.locator.matches().length === 0 && this.locator.session.now() - this.locator.session.screen.current().lastVisualChangeAt >= @@ -201,10 +216,12 @@ class LocatorExpectation implements AsyncLocatorExpectation { timeout, settle, ), + this.signal, ); } } async toHaveStyle(style: StyleQuery, options: AssertionOptions = {}): Promise { + this.signal?.throwIfAborted(); const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? @@ -233,10 +250,12 @@ class LocatorExpectation implements AsyncLocatorExpectation { : 'no match' }`; }, + this.signal, ); return this.locator.matches()[0]; } async toContainCursor(options: AssertionOptions = {}): Promise { + this.signal?.throwIfAborted(); const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? @@ -266,16 +285,21 @@ class LocatorExpectation implements AsyncLocatorExpectation { `${JSON.stringify(this.locator.query)} to contain the cursor`, timeout, ), + this.signal, ); return this.locator.matches()[0]; } } class TerminalExpectation implements AsyncTerminalExpectation { - constructor(readonly session: TerminalSession) {} + constructor( + readonly session: TerminalSession, + readonly signal?: AbortSignal, + ) {} async toSatisfy( predicate: (snapshot: ScreenSnapshot) => boolean, options: StableAssertionOptions = {}, ): Promise { + this.signal?.throwIfAborted(); const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? @@ -284,6 +308,7 @@ class TerminalExpectation implements AsyncTerminalExpectation { started = performance.now(), safe = safePredicate(predicate); for (;;) { + this.signal?.throwIfAborted(); const snapshot = this.session.screen.current(); if (safe.test(snapshot)) { const remaining = Math.max(0, settle - (this.session.now() - snapshot.lastVisualChangeAt)); @@ -292,17 +317,7 @@ class TerminalExpectation implements AsyncTerminalExpectation { throw new TerminalAssertionError( diagnostic(this.session, 'screen predicate to converge', timeout, settle) + safe.note(), ); - await new Promise((resolve) => { - const unsubscribe = this.session.subscribe(() => { - clearTimeout(timer); - unsubscribe(); - resolve(); - }), - timer = setTimeout(() => { - unsubscribe(); - resolve(); - }, remaining); - }); + await pause(this.session, { milliseconds: remaining, signal: this.signal }); continue; } await wait( @@ -311,6 +326,7 @@ class TerminalExpectation implements AsyncTerminalExpectation { Math.max(1, timeout - (performance.now() - started)), () => diagnostic(this.session, 'screen predicate to converge', timeout, settle) + safe.note(), + this.signal, ); } } @@ -318,6 +334,7 @@ class TerminalExpectation implements AsyncTerminalExpectation { predicate: (snapshot: ScreenSnapshot) => boolean, options: TransientAssertionOptions = {}, ): Promise { + this.signal?.throwIfAborted(); const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? @@ -340,6 +357,7 @@ class TerminalExpectation implements AsyncTerminalExpectation { () => diagnostic(this.session, `screen predicate since revision ${baseline}`, timeout) + safe.note(), + this.signal, ); return result as ScreenRevision; } @@ -351,15 +369,17 @@ class TerminalExpectation implements AsyncTerminalExpectation { } } /** Create an async assertion expectation for a locator or terminal session. */ -export function expectTerminal(target: Locator): LocatorExpectation; -export function expectTerminal(target: TerminalSession): TerminalExpectation; +export function expectTerminal(target: Locator, signal?: AbortSignal): LocatorExpectation; +export function expectTerminal(target: TerminalSession, signal?: AbortSignal): TerminalExpectation; export function expectTerminal( target: Locator | TerminalSession, + signal?: AbortSignal, ): LocatorExpectation | TerminalExpectation; export function expectTerminal( target: Locator | TerminalSession, + signal?: AbortSignal, ): LocatorExpectation | TerminalExpectation { return target instanceof Locator - ? new LocatorExpectation(target) - : new TerminalExpectation(target); + ? new LocatorExpectation(signal ? target.withSignal(signal) : target) + : new TerminalExpectation(target, signal); } diff --git a/experiments/ghostwright/src/effection/index.ts b/experiments/ghostwright/src/effection/index.ts index 3cdb2d6..d2163b6 100644 --- a/experiments/ghostwright/src/effection/index.ts +++ b/experiments/ghostwright/src/effection/index.ts @@ -1,5 +1,5 @@ import type { TerminalProgram } from '../operations.ts'; -import { call, type Operation } from 'effection'; +import { call, scoped, useAbortSignal, type Operation } from 'effection'; import type { AssertionOptions, ActionReceipt, @@ -48,46 +48,67 @@ type OperationQueries = { }; import type { RegionLocator } from '../locators.ts'; const expectRegion = createExpect(); -const op = (fn: () => Promise): Operation => call(fn); /** Effection wrapper around an async Locator. */ export class EffectionLocator implements OperationLocator { - constructor(readonly inner: Locator) {} + constructor( + readonly inner: Locator, + readonly owner?: AsyncExecution, + ) {} + /** Legacy locator work is cancelled with both its caller and owning terminal. */ + invoke(body: (locator: Locator) => Promise): Operation { + const locator = this.inner; + const operation = function* (): Operation { + const signal = yield* useAbortSignal(); + return yield* call(() => body(locator.withSignal(signal))); + }; + return this.owner ? this.owner.runOperation(operation) : scoped(operation); + } nth(i: number): EffectionLocator { - return new EffectionLocator(this.inner.nth(i)); + return new EffectionLocator(this.inner.nth(i), this.owner); } region(r: Rect): EffectionLocator { - return new EffectionLocator(this.inner.region(r)); + return new EffectionLocator(this.inner.region(r), this.owner); } matches(): readonly LocatorMatch[] { return this.inner.matches(); } click(o?: MouseOptions): Operation { - return op(() => this.inner.click(o)); + return this.invoke((locator) => locator.click(o)); } } /** Effection wrapper around a TerminalSession. */ export class EffectionTerminal implements OperationTerminal { constructor(readonly inner: AsyncExecution) {} + /** Bridge Promise APIs through a child of the invoking scope, not the session owner. */ + invoke(body: (terminal: AsyncExecution) => Promise): Operation { + const owner = this.inner; + return owner.runOperation(function* () { + const invocation = yield* execution(owner.session, owner.keyboardMiddleware); + return yield* call(() => body(invocation)); + }); + } get signal(): AbortSignal { return this.inner.signal; } assert(locator: RegionLocator, matcher: Matcher): ReturnType { - return assertRegion(this.inner.session, locator, matcher); + return this.inner.runOperation(() => assertRegion(this.inner.session, locator, matcher)); } expect(locator: RegionLocator): ReturnType { return expectRegion.operation(this, locator); } waitFor = (callback: () => T | PromiseLike, options?: WaitForOptions): Operation => - op(() => this.inner.waitFor(callback, options)); + this.invoke((inner) => inner.waitFor(callback, options)); capture( options: CaptureOptions, body: (terminal: EffectionTerminal) => Operation, ): ReturnType { - return captureOperation( - this.inner.session, - options, - (terminal) => body(new EffectionTerminal(terminal)), - this.inner.keyboardMiddleware, + return this.inner.runOperation(() => + captureOperation( + this.inner.session, + options, + (terminal) => body(new EffectionTerminal(terminal)), + this.inner.keyboardMiddleware, + ), ); } /** Invoke a program directly in the caller scope through owned capabilities. */ @@ -102,68 +123,70 @@ export class EffectionTerminal implements OperationTerminal { write: (d: Uint8Array) => this.inner.operations.keyboard.write(d), }; mouse = { - move: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.move(p, o)), - hover: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.hover(p, o)), - down: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.down(p, o)), - up: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.up(p, o)), - click: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.click(p, o)), - doubleClick: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.doubleClick(p, o)), + move: (p: MouseTarget, o?: MouseOptions) => this.invoke((inner) => inner.mouse.move(p, o)), + hover: (p: MouseTarget, o?: MouseOptions) => this.invoke((inner) => inner.mouse.hover(p, o)), + down: (p: MouseTarget, o?: MouseOptions) => this.invoke((inner) => inner.mouse.down(p, o)), + up: (p: MouseTarget, o?: MouseOptions) => this.invoke((inner) => inner.mouse.up(p, o)), + click: (p: MouseTarget, o?: MouseOptions) => this.invoke((inner) => inner.mouse.click(p, o)), + doubleClick: (p: MouseTarget, o?: MouseOptions) => + this.invoke((inner) => inner.mouse.doubleClick(p, o)), // oxlint-disable-next-line bombshell-dev/max-params -- wraps mouse.drag(start, end, options) API drag: (a: MouseTarget, b: Point | DragOffset, o?: MouseOptions) => - op(() => this.inner.mouse.drag(a, b, o)), - wheel: (o: WheelOptions) => op(() => this.inner.mouse.wheel(o)), + this.invoke((inner) => inner.mouse.drag(a, b, o)), + wheel: (o: WheelOptions) => this.invoke((inner) => inner.mouse.wheel(o)), }; process = { status: () => this.inner.process.status(), - signal: (s: string, t?: 'child' | 'process-group') => op(() => this.inner.process.signal(s, t)), - waitForExit: (o?: AssertionOptions) => op(() => this.inner.process.waitForExit(o)), + signal: (s: string, t?: 'child' | 'process-group') => + this.invoke((inner) => inner.process.signal(s, t)), + waitForExit: (o?: AssertionOptions) => this.invoke((inner) => inner.process.waitForExit(o)), }; get screen(): OperationTerminal['screen'] & OperationQueries { const screen = this.inner.screen; return Object.freeze({ ...screen, findBy: (locator: RegionLocator, options?: WaitForOptions) => - op(() => screen.findBy(locator, options)), + this.invoke((inner) => inner.screen.findBy(locator, options)), findAllBy: (locator: RegionLocator, options?: WaitForOptions) => - op(() => screen.findAllBy(locator, options)), + this.invoke((inner) => inner.screen.findAllBy(locator, options)), findByText: (...args: Parameters) => - op(() => screen.findByText(...args)), + this.invoke((inner) => inner.screen.findByText(...args)), findAllByText: (...args: Parameters) => - op(() => screen.findAllByText(...args)), + this.invoke((inner) => inner.screen.findAllByText(...args)), findBySelector: (...args: Parameters) => - op(() => screen.findBySelector(...args)), + this.invoke((inner) => inner.screen.findBySelector(...args)), findAllBySelector: (...args: Parameters) => - op(() => screen.findAllBySelector(...args)), + this.invoke((inner) => inner.screen.findAllBySelector(...args)), }); } revisions = { collect: (options: RevisionCollectionOptions) => - op(() => this.inner.revisions.collect(options)), + this.invoke((inner) => inner.revisions.collect(options)), }; history = { - read: (query?: HistoryQuery) => op(() => this.inner.history.read(query)), + read: (query?: HistoryQuery) => this.invoke((inner) => inner.history.read(query)), findText: (text: string, options?: HistorySearchOptions) => - op(() => this.inner.history.findText(text, options)), + this.invoke((inner) => inner.history.findText(text, options)), }; graphics = { - inspectImage: (id: number) => op(() => this.inner.graphics.inspectImage(id)), - copyImageData: (id: number) => op(() => this.inner.graphics.copyImageData(id)), + inspectImage: (id: number) => this.invoke((inner) => inner.graphics.inspectImage(id)), + copyImageData: (id: number) => this.invoke((inner) => inner.graphics.copyImageData(id)), }; getByText(t: string, o?: TextLocatorOptions): EffectionLocator { - return new EffectionLocator(this.inner.getByText(t, o)); + return new EffectionLocator(this.inner.getByText(t, o), this.inner); } region(r: Rect): OperationRegion { const x = this.inner.region(r); return { - getByText: (t, o) => new EffectionLocator(x.getByText(t, o) as Locator), + getByText: (t, o) => new EffectionLocator(x.getByText(t, o) as Locator, this.inner), snapshot: () => x.snapshot(), }; } resize(v: Viewport): Operation { - return op(() => this.inner.resize(v)); + return this.invoke((inner) => inner.resize(v)); } close(): Operation { - return op(() => this.inner.close()); + return this.invoke((inner) => inner.close()); } } /** Launch a terminal session, run an Effection operation body, and clean up when done. */ @@ -171,16 +194,18 @@ export function* withTerminal( options: TerminalLaunchOptions, body: (terminal: EffectionTerminal) => Operation, ): Operation { - const session = yield* useSession(options); - const terminal = yield* execution(session); - try { - const result: T = yield* body(new EffectionTerminal(terminal)); - yield* call(() => recordSuccess(session)); - return result; - } catch (error) { - yield* call(() => recordFailure(session, error)); - throw error; - } + return yield* scoped(function* () { + const session = yield* useSession(options); + const terminal = yield* execution(session); + try { + const result: T = yield* body(new EffectionTerminal(terminal)); + yield* call(() => recordSuccess(session)); + return result; + } catch (error) { + yield* call(() => recordFailure(session, error)); + throw error; + } + }); } /** Effection locator assertion expectation. */ export interface EffectionLocatorExpectation { @@ -207,24 +232,29 @@ export function expectOperation( target: EffectionLocator | EffectionTerminal, ): EffectionLocatorExpectation | EffectionTerminalExpectation { if (target instanceof EffectionLocator) { - const e = expectAsync(target.inner); return { - toBePresent: (o?: AssertionOptions) => op(() => e.toBePresent(o)), - toBeAbsent: (o?: StableAssertionOptions) => op(() => e.toBeAbsent(o)), - toBeStable: (o?: StableAssertionOptions) => op(() => e.toBeStable(o)), - toHaveStyle: (style: StyleQuery, o?: AssertionOptions) => op(() => e.toHaveStyle(style, o)), - toContainCursor: (o?: AssertionOptions) => op(() => e.toContainCursor(o)), + toBePresent: (o?: AssertionOptions) => + target.invoke((inner) => expectAsync(inner).toBePresent(o)), + toBeAbsent: (o?: StableAssertionOptions) => + target.invoke((inner) => expectAsync(inner).toBeAbsent(o)), + toBeStable: (o?: StableAssertionOptions) => + target.invoke((inner) => expectAsync(inner).toBeStable(o)), + toHaveStyle: (style: StyleQuery, o?: AssertionOptions) => + target.invoke((inner) => expectAsync(inner).toHaveStyle(style, o)), + toContainCursor: (o?: AssertionOptions) => + target.invoke((inner) => expectAsync(inner).toContainCursor(o)), }; } - const e = expectAsync(target.inner.session); + return { toSatisfy: (predicate: (snapshot: ScreenSnapshot) => boolean, o?: StableAssertionOptions) => - op(() => e.toSatisfy(predicate, o)), + target.invoke((inner) => expectAsync(inner.session, inner.signal).toSatisfy(predicate, o)), toHaveShown: ( predicate: (snapshot: ScreenSnapshot) => boolean, o?: TransientAssertionOptions, - ) => op(() => e.toHaveShown(predicate, o)), + ) => + target.invoke((inner) => expectAsync(inner.session, inner.signal).toHaveShown(predicate, o)), toHaveShownText: (t: string, o?: TransientAssertionOptions) => - op(() => e.toHaveShownText(t, o)), + target.invoke((inner) => expectAsync(inner.session, inner.signal).toHaveShownText(t, o)), }; } diff --git a/experiments/ghostwright/src/execution.ts b/experiments/ghostwright/src/execution.ts index ffddbcd..a00fc1d 100644 --- a/experiments/ghostwright/src/execution.ts +++ b/experiments/ghostwright/src/execution.ts @@ -31,7 +31,7 @@ import type { RegionLocator } from './locators.ts'; import type { RegionInspection } from './inspection.ts'; import type { Condition } from './conditions.ts'; import type { Observation } from './observations.ts'; -import { TerminalSession } from './terminal/session.ts'; +import { TerminalSession, type Locator } from './terminal/session.ts'; import type { ActionReceipt, AsyncTerminal, TerminalLaunchOptions } from './types.ts'; import { createQueries, type ScreenQueries } from './queries.ts'; import { waitForOperation, type WaitForOptions } from './wait-for.ts'; @@ -209,9 +209,12 @@ export class AsyncExecution implements AsyncTerminal { this.process = { status: () => session.process.status(), signal: (name, target) => this.#promise(() => session.signalProcess(name, target, signal)), - waitForExit: (...args) => this.#promise(() => session.process.waitForExit(...args)), + waitForExit: (options) => + this.#promise(() => session.process.waitForExit({ ...options, signal })), + }; + this.revisions = { + collect: (options) => this.#promise(() => session.collectRevisions(options, signal)), }; - this.revisions = this.#bind(session.revisions); this.history = this.#bind(session.history); this.graphics = this.#bind(session.graphics); this.screen = Object.freeze({ @@ -291,9 +294,17 @@ export class AsyncExecution implements AsyncTerminal { ); getByText = ( ...args: Parameters - ): ReturnType => this.session.getByText(...args); - region = (...args: Parameters): ReturnType => - this.session.region(...args); + ): ReturnType => + this.session.getByText(...args).withSignal(this.signal); + region = ( + ...args: Parameters + ): ReturnType => { + const region = this.session.region(...args); + return { + ...region, + getByText: (...query) => (region.getByText(...query) as Locator).withSignal(this.signal), + }; + }; resize = (viewport: Parameters[0]): Promise => this.#promise(() => this.session.resize(viewport, this.signal)); close = (): Promise => this.#promise(() => this.session.close()); diff --git a/experiments/ghostwright/src/index.ts b/experiments/ghostwright/src/index.ts index 9a57903..b7928c4 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -45,7 +45,7 @@ export function expectTerminal( | AsyncLocatorExpectation | OperationTerminalExpectation | AsyncTerminalExpectation { - if (target instanceof AsyncExecution) return expectAsync(target.session); + if (target instanceof AsyncExecution) return expectAsync(target.session, target.signal); if (target instanceof EffectionLocator || target instanceof EffectionTerminal) return expectOperation(target); if (target instanceof Locator) return expectAsync(target); diff --git a/experiments/ghostwright/src/terminal/session.ts b/experiments/ghostwright/src/terminal/session.ts index 89f98af..164245a 100644 --- a/experiments/ghostwright/src/terminal/session.ts +++ b/experiments/ghostwright/src/terminal/session.ts @@ -678,11 +678,12 @@ export class TerminalSession implements AsyncTerminal { status: () => ({ ...this.#status }), signal: (signal: string, target: 'child' | 'process-group' = 'process-group') => this.#send({ kind: FrameKind.SIGNAL, value: { signal, target } }), - waitForExit: async (options?: { timeoutMs?: number }) => + waitForExit: async (options?: { timeoutMs?: number; signal?: AbortSignal }) => this.#timeout( this.#exitPromise, options?.timeoutMs ?? this.options.assertionTimeoutMs ?? DEFAULT_ASSERTION_TIMEOUT_MS, () => new ProcessExitedError('Timed out waiting for process exit'), + options?.signal, ), }; revisions = { @@ -783,42 +784,67 @@ export class TerminalSession implements AsyncTerminal { throw new ExtensionLifecycleError('Terminal resource cleanup failed', failures); } - async waitForChange(test: () => boolean, timeout: number): Promise { + async waitForChange(test: () => boolean, timeout: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); if (test()) return; - await new Promise((resolvePromise, reject) => { - const off = this.subscribe(() => { - if (test()) { - clearTimeout(timer); - off(); - resolvePromise(); - } else if (this.#status.state === 'closed' || this.#status.state === 'failed') { - clearTimeout(timer); - off(); - reject(new SessionClosedError('Session closed before condition matched')); - } else if (this.#status.ptyEof) { - clearTimeout(timer); - off(); - reject(new ProcessExitedError('Process exited before condition matched')); - } - }), - timer = setTimeout(() => { - off(); - reject(new TerminalAssertionError('timeout')); - }, timeout); + await new Promise((resolve, reject) => { + const finish = (error?: unknown): void => { + clearTimeout(timer); + off(); + signal?.removeEventListener('abort', abort); + if (error !== undefined) reject(error); + else resolve(); + }; + const check = (): void => { + try { + if (test()) finish(); + else if (this.#status.state === 'closed' || this.#status.state === 'failed') + finish(new SessionClosedError('Session closed before condition matched')); + else if (this.#status.ptyEof) + finish(new ProcessExitedError('Process exited before condition matched')); + } catch (error) { + finish(error); + } + }; + const off = this.subscribe(check); + const timer = setTimeout(() => finish(new TerminalAssertionError('timeout')), timeout); + const abort = (): void => finish(signal!.reason); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + else check(); }); } - // oxlint-disable-next-line bombshell-dev/max-params -- internal method - async #timeout(p: Promise, ms: number, error: () => Error): Promise { - return new Promise((r, j) => { - const t = setTimeout(() => j(error()), ms); + // oxlint-disable-next-line bombshell-dev/max-params -- timeout, error, and cancellation boundary + async #timeout( + p: Promise, + ms: number, + error: () => Error, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + }; + const abort = (): void => { + cleanup(); + reject(signal!.reason); + }; + const timer = setTimeout(() => { + cleanup(); + reject(error()); + }, ms); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); p.then( - (x) => { - clearTimeout(t); - r(x); + (value) => { + cleanup(); + resolve(value); }, - (e) => { - clearTimeout(t); - j(e); + (cause) => { + cleanup(); + reject(cause); }, ); }); @@ -869,7 +895,11 @@ export class TerminalSession implements AsyncTerminal { .slice(0, limit), ); } - async collectRevisions(options: RevisionCollectionOptions): Promise { + async collectRevisions( + options: RevisionCollectionOptions, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); const baseline = this.#baselineSequence(options.since), max = options.maxRevisions ?? 1000, configuredMax = this.options.history?.maxRevisions ?? 1000, @@ -894,6 +924,7 @@ export class TerminalSession implements AsyncTerminal { const finish = (timer: ReturnType, error?: Error): void => { clearTimeout(timer); off(); + signal?.removeEventListener('abort', abort); if (error) reject(error); else resolvePromise(); }; @@ -933,6 +964,9 @@ export class TerminalSession implements AsyncTerminal { ), timeout, ); + const abort = (): void => finish(timer, signal!.reason); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); }); return Object.freeze({ baselineSequence: baseline, @@ -1114,17 +1148,29 @@ export class Locator implements AsyncLocator { readonly options: TextLocatorOptions = {}, readonly index?: number, readonly bounds?: Rect, + readonly signal?: AbortSignal, ) {} + withSignal(signal: AbortSignal): Locator { + return new Locator( + this.session, + this.query, + this.options, + this.index, + this.bounds, + this.signal ? AbortSignal.any([this.signal, signal]) : signal, + ); + } nth(index: number): Locator { if (!Number.isInteger(index) || index < 0) throw new CoordinateRangeError('nth index must be nonnegative'); - return new Locator(this.session, this.query, this.options, index, this.bounds); + return new Locator(this.session, this.query, this.options, index, this.bounds, this.signal); } region(rect: Rect): Locator { this.session.validateRegion(rect); - return new Locator(this.session, this.query, this.options, this.index, rect); + return new Locator(this.session, this.query, this.options, this.index, rect, this.signal); } matches(): readonly LocatorMatch[] { + this.signal?.throwIfAborted(); const out = findText(this.session.screen.current(), this.query, this.options, this.bounds); const chosen = this.index === undefined ? out : out[this.index] ? [out[this.index]] : []; return Object.freeze(chosen); @@ -1139,14 +1185,18 @@ export class Locator implements AsyncLocator { `Locator ${JSON.stringify(this.query)} matched ${m.length} ranges: ${m.map((x) => JSON.stringify(x.range)).join(', ')}`, ); if (!m.length) { - await this.session.waitForChange(() => { - m = get(); - if (m.length > 1) - throw new StrictLocatorError( - `Locator ${JSON.stringify(this.query)} matched multiple ranges`, - ); - return m.length === 1; - }, timeout); + await this.session.waitForChange( + () => { + m = get(); + if (m.length > 1) + throw new StrictLocatorError( + `Locator ${JSON.stringify(this.query)} matched multiple ranges`, + ); + return m.length === 1; + }, + timeout, + this.signal, + ); } return m[0]; } @@ -1156,6 +1206,6 @@ export class Locator implements AsyncLocator { column: Math.floor((m.range.column + m.range.column + m.range.width - 1) / 2), row: Math.floor((m.range.row + m.range.row + m.range.height - 1) / 2), }; - return this.session.mouse.click(p, options); + return this.session.mouseFor(this.signal).click(p, options); } } diff --git a/experiments/ghostwright/test/scope-ownership.test.ts b/experiments/ghostwright/test/scope-ownership.test.ts new file mode 100644 index 0000000..091844b --- /dev/null +++ b/experiments/ghostwright/test/scope-ownership.test.ts @@ -0,0 +1,283 @@ +import { expect, test } from 'bun:test'; +import { + call, + createContext, + ensure, + run, + scoped, + spawn, + suspend, + useAbortSignal, + useScope, + withResolvers, +} from 'effection'; +import { withTerminal, type EffectionTerminal } from '../src/effection/index.ts'; +import { + launchTerminal, + expectTerminal, + GhostwrightError, + type KeyboardMiddleware, +} from '../src/index.ts'; + +const options = { + command: process.execPath, + args: [ + '-e', + 'process.stdin.setRawMode(true); process.stdin.on("data", b => process.stdout.write(b)); process.stdout.write("READY")', + ], + trace: 'off' as const, +}; + +test('native withTerminal releases each session before returning to a still-live caller', async () => { + await run(function* () { + const parent = yield* useAbortSignal(); + let escaped!: EffectionTerminal; + const result = yield* withTerminal(options, function* (terminal) { + escaped = terminal; + yield* terminal.screen.findByText('READY'); + return 42; + }); + expect(result).toBe(42); + expect(parent.aborted).toBe(false); + expect(escaped.signal.aborted).toBe(true); + expect(escaped.process.status().state).toBe('closed'); + expect(() => process.kill(escaped.process.status().pid!, 0)).toThrow(); + }); +}); + +test('halting a native query releases its subscriptions before the parent continues', async () => { + await run(() => + withTerminal(options, function* (terminal) { + yield* terminal.screen.findByText('READY'); + const observations = terminal.inner.session.observations; + const subscribe = observations.subscribe.bind(observations); + let subscriptions = 0; + observations.subscribe = (listener) => { + subscriptions++; + const off = subscribe(listener); + return () => { + subscriptions--; + off(); + }; + }; + const entered = withResolvers(); + const child = yield* spawn(() => + terminal.waitFor(() => { + entered.resolve(); + throw new GhostwrightError({ code: 'TEST_NOT_READY', message: 'not ready' }); + }), + ); + yield* entered.operation; + expect(subscriptions).toBe(1); + yield* child.halt(); + expect(subscriptions).toBe(0); + expect(terminal.signal.aborted).toBe(false); + yield* terminal.keyboard.press('a'); + }), + ); +}); + +test('concurrent native callers inherit their own context and isolate middleware resources', async () => { + const context = createContext('scope-audit-context', 'parent'); + const entered = withResolvers(); + const release = withResolvers(); + const events: string[] = []; + const middleware: KeyboardMiddleware = { + *press(args, next) { + const label = yield* context.expect(); + events.push(`${label}:enter`); + yield* ensure(() => { + events.push(`${label}:cleanup`); + }); + if (label === 'first') { + entered.resolve(); + yield* release.operation; + } + return yield* next(...args); + }, + }; + await run(() => + withTerminal({ ...options, keyboardMiddleware: [middleware] }, function* (terminal) { + yield* terminal.screen.findByText('READY'); + const parent = yield* useScope(); + const first = yield* spawn(function* () { + yield* context.set('first'); + yield* terminal.keyboard.press('a'); + }); + yield* entered.operation; + yield* scoped(function* () { + yield* context.set('second'); + yield* terminal.keyboard.press('b'); + }); + expect(events).toEqual(['first:enter', 'second:enter', 'second:cleanup']); + expect(parent.get(context)).toBe('parent'); + release.resolve(); + yield* first; + expect(events.at(-1)).toBe('first:cleanup'); + }), + ); +}); + +test('async programs deliberately run in their owner context, not an unrelated native caller', async () => { + const context = createContext('scope-audit-async', 'default'); + await using terminal = await launchTerminal({ + ...options, + keyboardMiddleware: [ + { + *press(args, next) { + expect(yield* context.expect()).toBe('default'); + return yield* next(...args); + }, + }, + ], + }); + await terminal.screen.findByText('READY'); + await run(function* () { + yield* context.set('foreign'); + yield* call(() => terminal.keyboard.press('x')); + }); +}); + +test('owner disposal waits for gated ensure cleanup, including repeated disposal', async () => { + const entered = Promise.withResolvers(); + const cleaning = Promise.withResolvers(); + const release = Promise.withResolvers(); + const events: string[] = []; + const terminal = await launchTerminal({ + ...options, + keyboardMiddleware: [ + { + *press(args, next) { + yield* ensure(function* () { + events.push('cleanup:start'); + cleaning.resolve(); + yield* call(() => release.promise); + events.push('cleanup:end'); + }); + entered.resolve(); + yield* suspend(); + return yield* next(...args); + }, + }, + ], + }); + const pending = terminal.keyboard.press('x').catch((error) => error); + await entered.promise; + const first = terminal.close().then(() => { + events.push('closed:first'); + }); + try { + await cleaning.promise; + const second = terminal.close().then(() => { + events.push('closed:second'); + }); + expect(events).toEqual(['cleanup:start']); + release.resolve(); + await Promise.all([first, second, pending]); + expect(events.slice(0, 2)).toEqual(['cleanup:start', 'cleanup:end']); + expect(events.slice(2).sort()).toEqual(['closed:first', 'closed:second']); + } finally { + release.resolve(); + await terminal.close(); + } +}); + +for (const kind of [ + 'revision', + 'locator', + 'stable-locator', + 'stable-screen', + 'locator-click', +] as const) { + test(`halting native ${kind} work removes low-level listeners`, async () => { + await run(() => + withTerminal(options, function* (terminal) { + yield* terminal.screen.findByText('READY'); + const session = terminal.inner.session; + const subscribe = session.subscribe.bind(session); + const entered = withResolvers(); + let subscriptions = 0; + session.subscribe = (listener) => { + subscriptions++; + const off = subscribe(listener); + queueMicrotask(() => entered.resolve()); + return () => { + subscriptions--; + return off(); + }; + }; + const child = yield* spawn(function* () { + if (kind === 'revision') + yield* terminal.revisions.collect({ since: 0, until: () => false }); + else if (kind === 'locator') + yield* expectTerminal(terminal.getByText('NEVER')).toBePresent(); + else if (kind === 'stable-locator') + yield* expectTerminal(terminal.getByText('READY')).toBeStable({ settleMs: 1000 }); + else if (kind === 'stable-screen') + yield* expectTerminal(terminal).toSatisfy(() => true, { settleMs: 1000 }); + else yield* terminal.getByText('NEVER').click(); + }); + yield* entered.operation; + expect(subscriptions).toBe(1); + yield* child.halt(); + expect(subscriptions).toBe(0); + expect(terminal.signal.aborted).toBe(false); + }), + ); + }); +} + +test('native body failure releases resources before the outer scope catches it', async () => { + await run(function* () { + const failure = { reason: 'body' }; + let escaped!: EffectionTerminal; + try { + yield* withTerminal(options, function* (terminal) { + escaped = terminal; + yield* terminal.screen.findByText('READY'); + throw failure; + }); + } catch (error) { + expect(error).toBe(failure); + } + expect(escaped.signal.aborted).toBe(true); + expect(escaped.process.status().state).toBe('closed'); + }); +}); + +test('ensure cleanup already running on successful action completion survives owner shutdown', async () => { + const cleaning = Promise.withResolvers(); + const release = Promise.withResolvers(); + const events: string[] = []; + const terminal = await launchTerminal({ + ...options, + keyboardMiddleware: [ + { + *press(args, next) { + yield* ensure(function* () { + events.push('cleaning'); + cleaning.resolve(); + yield* call(() => release.promise); + events.push('cleaned'); + }); + return yield* next(...args); + }, + }, + ], + }); + await terminal.screen.findByText('READY'); + const pending = terminal.keyboard.press('x').catch((error) => error); + await cleaning.promise; + const closing = terminal.close().then(() => { + events.push('closed'); + }); + try { + expect(events).toEqual(['cleaning']); + release.resolve(); + await Promise.all([pending, closing]); + expect(events).toEqual(['cleaning', 'cleaned', 'closed']); + } finally { + release.resolve(); + await terminal.close(); + } +});