Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ Semantic `snapshot`, screen-hash, and render-backed `wait` paths prefer `libghos

On a session that has printed nothing yet, `snapshot` returns an empty screen of the session's size and `screenshot` returns a blank image. `record export` gives a header-only asciicast, a blank WebM or a blank SVG of the session's size, with `durationMs: 0`. An animated SVG of such a session reports `durationMs: 1000`, the hold of its one blank frame. All of them report `capturedAtSeq: -1`, and the default files are named `screenshot--1-<profile>.png`, `recording--1-<format>.<ext>` and `recording--1-svg-<profile>[-animated].svg`.

A live `screenshot`, and a live `snapshot --renderer ghostty-web`, first replay the session's event log into a renderer of the host. With `--renderer libghostty-vt`, the screenshot replays the log a second time, in a `ghostty-web` renderer that it boots for the screenshot. The more events the log holds, the longer that takes. The host reports progress when a renderer has booted and after each slice of a replay, and a slice aims at half a second. The command waits for as long as the replay moves forward, also when the replay belongs to a command that started earlier. It fails with `HOST_TIMEOUT` (exit 5) after 5 s in which the host has reported no progress, for example when the renderer hangs.

The screenshot hides the cursor unless you pass `--show-cursor`. `--hide-cursor` is the default, and the result reports `cursorVisible` for the choice.

```bash
Expand Down
16 changes: 9 additions & 7 deletions src/host/README.md

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions src/host/hostMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,10 @@ export async function runHost(
appendMouseEvent: (payload) => eventLog.append('input_mouse', payload),
});

const replayRendererThroughSeq = async (targetSeq: number): Promise<void> => {
const replayRendererThroughSeq = async (
targetSeq: number,
keepAlive: () => void,
): Promise<void> => {
invariant(
Number.isInteger(targetSeq) && targetSeq >= 0,
'targetSeq must be a non-negative integer',
Expand Down Expand Up @@ -421,6 +424,7 @@ export async function runHost(
'renderer snapshot must include the run-complete event sequence',
);
},
keepAlive,
);
};

Expand Down Expand Up @@ -638,7 +642,7 @@ export async function runHost(
rendererBootInFlight,
};
},
snapshot: async (params: unknown) => {
snapshot: async (params: unknown, context) => {
const {
format: requestedFormat,
includeScrollback: requestedIncludeScrollback,
Expand Down Expand Up @@ -672,6 +676,7 @@ export async function runHost(
}),
rendererBackend: backend.rendererBackend,
}),
context.keepAlive,
);
return await captureSnapshotResult({
sessionDir: sessDir,
Expand All @@ -681,7 +686,7 @@ export async function runHost(
expectedSessionId: sessionId,
});
},
screenshot: async (params: unknown) => {
screenshot: async (params: unknown, context) => {
const {
profile: requestedProfileName,
rendererName: requestedRendererName,
Expand Down Expand Up @@ -713,14 +718,18 @@ export async function runHost(
rendererName,
profile,
() => loadReplayInput(),
async (backend) =>
async (backend, _replayInput, reportProgress) =>
await captureScreenshotResult({
backend,
sessionDir: sessDir,
profileName: profile.name,
expectedSessionId: sessionId,
...(showCursor === undefined ? {} : { showCursor }),
// Not `context.keepAlive`: a command that waits behind this
// screenshot in the renderer queue must hear of the progress too.
onReplayProgress: reportProgress,
}),
context.keepAlive,
);
},
type: async (params: unknown) => {
Expand Down Expand Up @@ -822,7 +831,7 @@ export async function runHost(

if (waitResult.kind === 'completed') {
try {
await replayRendererThroughSeq(waitResult.seq);
await replayRendererThroughSeq(waitResult.seq, context.keepAlive);
} catch {
// The run already completed and was committed to the event log. Do not
// turn a best-effort renderer catch-up failure into a command retry
Expand Down
50 changes: 47 additions & 3 deletions src/host/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import type { RendererBackend } from '../renderer/backend.js';
import type { RendererName } from '../renderer/names.js';
import { hashProfile } from '../renderer/profiles.js';
import { replayInSlices } from '../renderer/replaySlices.js';
import type { RenderProfileConfig, ReplayInput } from '../renderer/types.js';
import { invariant } from '../util/assert.js';

Expand Down Expand Up @@ -45,6 +46,11 @@ export class HostRendererManager {
private bootPromise: Promise<RendererBackend> | null = null;
private lifecyclePromise: Promise<void> = Promise.resolve();
private screenshotsDirectoryCreated = false;
// Where the last replay left the current backend. A mouse request can move
// the backend further, so this is where it was at least.
private appliedSeq = -1;
// One entry for each `withBackend` call that waits or runs.
private readonly progressListeners = new Set<{ notify: () => void }>();

constructor(options: HostRendererManagerOptions) {
assertNonEmptyString(options.sessionId, 'sessionId');
Expand Down Expand Up @@ -72,14 +78,29 @@ export class HostRendererManager {
);
}

/**
* Run `operation` on the current backend, after the replay to the input.
*
* `onProgress` runs each time a replay moves forward, the replay of this
* call or of an earlier call that this one waits for. A request passes its
* `keepAlive`, so that it does not time out while the work it needs goes on.
*
* `operation` gets `reportProgress` as its third argument. It tells every
* call that waits, this one included, that work moves forward, for an
* operation that is slow by itself (a libghostty-vt screenshot replays the
* log in a second renderer). A call that waits behind the operation hears
* of it only this way.
*/
async withBackend<T>(
rendererName: RendererName,
profile: RenderProfileConfig,
loadReplayInput: () => ReplayInput | null,
operation: (
backend: RendererBackend,
replayInput: ReplayInput | null,
reportProgress: () => void,
) => T | Promise<T>,
onProgress?: () => void,
): Promise<T> {
assertNonEmptyString(rendererName, 'rendererName');
assertNonEmptyString(profile.name, 'profile name');
Expand All @@ -91,7 +112,11 @@ export class HostRendererManager {
// Load inside the lock: work queued earlier, such as a mouse request that
// catches up to the whole log, can leave the backend past any input
// captured before the wait, and a stateful backend cannot rewind.
return this.runExclusive(async () => {
const listener = onProgress === undefined ? null : { notify: onProgress };
if (listener !== null) {
this.progressListeners.add(listener);
}
const queued = this.runExclusive(async () => {
const replayInput = loadReplayInput();
if (replayInput !== null) {
invariant(
Expand All @@ -111,17 +136,35 @@ export class HostRendererManager {
}

const backend = await this.ensureBackend(rendererName, profile);
// A booted backend is progress too: a replay starts only after the boot.
this.reportProgress();

if (replayInput !== null) {
await backend.replayTo(replayInput);
await replayInSlices(backend, replayInput, this.appliedSeq, (state) => {
this.appliedSeq = state.lastSeq;
this.reportProgress();
});
this.cachedInitialCols = replayInput.initialCols;
this.cachedInitialRows = replayInput.initialRows;
}

return await operation(backend, replayInput);
return await operation(backend, replayInput, () => {
this.reportProgress();
});
});
return queued.finally(() => {
if (listener !== null) {
this.progressListeners.delete(listener);
}
});
}

private reportProgress(): void {
for (const listener of this.progressListeners) {
listener.notify();
}
}

screenshotPath(profileName: string): string {
assertNonEmptyString(profileName, 'profileName');
invariant(
Expand Down Expand Up @@ -248,6 +291,7 @@ export class HostRendererManager {
this.currentProfileName = null;
this.cachedInitialCols = null;
this.cachedInitialRows = null;
this.appliedSeq = -1;
this.bootPromise = null;

if (backend === null) {
Expand Down
13 changes: 12 additions & 1 deletion src/host/rpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ResourceScope } from '../util/resourceScope.js';
import { isOwnLegacySocket, socketRootExists } from './socketRoot.js';

const DEFAULT_TIMEOUT_MS = 5_000;
const LEADING_SPACES = /^ +/;
const HOST_UNREACHABLE_SOCKET_CODES = new Set([
'ECONNREFUSED',
'ENOENT',
Expand Down Expand Up @@ -228,11 +229,21 @@ async function sendRpcAt(
rejectWithTransportError(error);
});

socket.on('data', (chunk: string) => {
socket.on('data', (received: string) => {
if (responseHandled) {
return;
}

// A host may write spaces ahead of the response as keep-alives. The
// response cap counts the response line alone, as the host does, so a
// response at the cap does not fail after keep-alives. Only a space is
// a keep-alive: a newline ends the line.
const chunk =
buffer.length === 0 ? received.replace(LEADING_SPACES, '') : received;
if (chunk.length === 0) {
return;
}

if (buffer.length + chunk.length > MAX_RPC_RESPONSE_CHARS) {
// Only an older host, without the server's response cap, gets here.
// A retry gets the same size, so this is not RPC_ERROR (retryable).
Expand Down
17 changes: 17 additions & 0 deletions src/host/rpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,19 @@ const UNKNOWN_REQUEST_ID = 'unknown';
*
* `signal` aborts when the client socket closes, indicating the caller is no
* longer waiting for a response.
*
* `keepAlive` tells the client that the handler is still making progress. It
* writes one space ahead of the response line. JSON allows whitespace before
* a value, so a client that does not know about it still parses the response,
* and every client restarts its inactivity timer on the byte (`sendRpc` waits
* for that long without a byte before it gives up). Call it only when work
* moved forward, never on a timer: a handler that hangs must go silent so
* that its client fails with HOST_TIMEOUT. It does nothing once the client is
* gone or the response is on its way.
*/
export interface MethodContext {
readonly signal: AbortSignal;
readonly keepAlive: () => void;
}

export type MethodHandler = (
Expand Down Expand Up @@ -491,9 +501,16 @@ export class RpcServer {
socket.off('close', abortRequest);
});

const keepAlive = (): void => {
if (socket.writable) {
socket.write(' ');
}
Comment thread
ThomasK33 marked this conversation as resolved.
Resolved
};

try {
const result = await handler(paramsResult.data, {
signal: requestAbortController.signal,
keepAlive,
});
if (requestAbortController.signal.aborted) {
return;
Expand Down
2 changes: 1 addition & 1 deletion src/protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
- `InputRunEventPayloadSchema` requires `marker` when `noWait` is false and rejects it when `noWait` is true. [schemas.ts:InputRunEventPayloadSchema]
- `WaitForRenderParamsSchema` requires at least one of `text`, `regex`, `screenStableMs`, `cursorRow` or `cursorCol`. `text` and `regex` are mutually exclusive, and scope `cursor-line` needs `text` or `regex`. [schemas.ts:WaitForRenderParamsSchema]
- `RecordDiffResultSchema` checks that `identical` matches the equality of the two screen hashes. An identical result must have an empty diff and equal side row counts. A side with `capturedAtSeq` -1 must hash to a blank screen. For a non-identical result, the rows rebuilt from the diff must hash to each side's `screenHash`. [schemas.ts:RecordDiffResultSchema]
- `RpcRequestSchema` requires non-empty `id` and `method`. The `id` takes at most `MAX_RPC_REQUEST_ID_CHARS` (128) characters, so the id that the server echoes in a response stays small. `MAX_RPC_RESPONSE_CHARS` (67,108,864) is the largest response line, newline included, that `sendRpc` reads and `RpcServer` sends. It does not restrict `method` to `RpcMethodSchema`. [messages.ts:RpcRequestSchema]
- `RpcRequestSchema` requires non-empty `id` and `method`. The `id` takes at most `MAX_RPC_REQUEST_ID_CHARS` (128) characters, so the id that the server echoes in a response stays small. `MAX_RPC_RESPONSE_CHARS` (67,108,864) is the largest response line, newline included, that `sendRpc` reads and `RpcServer` sends. The spaces that a host writes ahead of the line as keep-alives are not part of it. It does not restrict `method` to `RpcMethodSchema`. [messages.ts:RpcRequestSchema]
- `RpcErrorSchema` requires non-empty `code` and `message`, and allows an optional `details` record. A CLI from before `details` was added rejects an error response that has it, so that CLI reports `RPC_ERROR` (`retryable: true`) for such an error from a newer host, and retrying does not help. A newer CLI reads an older host's error responses. [messages.ts:RpcErrorSchema]
- `RpcMethodSchemas` uses `satisfies Record<RpcMethod, ...>`, so TypeScript requires one entry per method. [messages.ts:RpcMethodSchemas]
- `RunParamsSchema` defaults `noWait` to `false`. [messages.ts:RunParamsSchema]
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,23 @@ This directory defines the renderer abstraction that turns replayed session even

- [./ghosttyWeb/README.md](./ghosttyWeb/README.md): `GhosttyWebBackend`, the reference renderer. It runs `ghostty-web` in headless Chromium through Playwright and supports snapshots, PNG screenshots and WebM video.
- [./libghosttyVt/README.md](./libghosttyVt/README.md): `LibghosttyVtBackend`, a native semantic renderer from `@coder/libghostty-vt-node`. Its screenshots go through a `ghostty-web` fallback.
- `RendererBackend`: `boot`, `replayTo`, `snapshot`, `screenshot`, `getVisibleText`, `dispose`, `rendererBackend` and `isBooted` [backend.ts:RendererBackend]. Option types `SnapshotOptions`, `ScreenshotOptions` and `VideoRecordingOptions` [backend.ts:VideoRecordingOptions].
- `RendererBackend`: `boot`, `replayTo`, `snapshot`, `screenshot`, `getVisibleText`, `dispose`, `rendererBackend` and `isBooted` [backend.ts:RendererBackend]. Option types `SnapshotOptions`, `ScreenshotOptions` and `VideoRecordingOptions` [backend.ts:VideoRecordingOptions]. `ScreenshotOptions.onReplayProgress` is called when a backend that replays the log inside `screenshot()` has booted its second renderer or finished a slice of that replay [backend.ts:ScreenshotOptions].
- `MouseEncodingBackend` adds `encodeMouse(input)`, which encodes one `MouseEncodingInput` with the mouse modes the child set and returns the bytes, empty when tracking suppresses the event. `supportsMouseEncoding(backend)` narrows a `RendererBackend` to it. Only `LibghosttyVtBackend` implements it [backend.ts:MouseEncodingBackend] [backend.ts:supportsMouseEncoding].
- `VideoCapableRendererBackend` adds `replayWithTiming(input, timing)`, `finalizeVideo(outputPath)` and `recordedMissingGlyphs()`, which resolves to the `RecordedGlyphFields` of the replay and never rejects [backend.ts:VideoCapableRendererBackend]. `ReplayTimingOptions` is the union of the exported interfaces `AcceleratedTimingOptions`, `RecordedTimingOptions` and `MaxSpeedTimingOptions` (modes `accelerated`, `recorded` and `max-speed`) [backend.ts:ReplayTimingOptions].
- Schemas and inferred types: `ReplayEventSchema` (11 event types, discriminated on `type`), `ReplayInputSchema`, `ReplayStateSchema`, `SemanticSnapshotSchema`, `TextSnapshotSchema`, `ScreenshotResultSchema`, `RenderProfileBundledFontSchema` and `RenderProfileConfigSchema` [types.ts:ReplayEventSchema]. `types.ts` re-exports `VisibleLineSchema` from `src/protocol/schemas.ts`.
- `RendererNameSchema` (`'ghostty-web'`, `'libghostty-vt'`), `resolveRendererName`, and the defaults `DEFAULT_RENDERER_NAME`, `DEFAULT_SEMANTIC_RENDERER_NAME` and `DEFAULT_VISUAL_RENDERER_NAME` [names.ts:RendererNameSchema].
- `createRendererBackend(rendererName, sessionId, profile, options?)` returns a backend instance. `RendererRegistryOptions` can override the `libghostty-vt` loader [registry.ts:createRendererBackend].
- `BUILTIN_PROFILE_NAMES` (`reference-dark`, `reference-light`), `REFERENCE_PROFILE_FONT_STACK`, `getBuiltinProfile`, `resolveProfile` and `hashProfile` [profiles.ts:resolveProfile].
- `canonicalVisibleLines`, `canonicalVisibleText` and `computeScreenHash` derive the canonical visible text and its SHA-256 Screen Hash [canonicalScreen.ts:computeScreenHash].
- `replayInSlices(backend, input, appliedSeq, onReplayed)` replays `input` to a backend in slices when many events are left. A slice holds at most a number of events that follows the time of the last slice (`FIRST_SLICE_EVENTS` first, `TARGET_SLICE_MS` each) at most `MAX_SLICE_BYTES` of output, except one event that is larger, and at most `MAX_SLICE_FLUSHES` events that follow output. It calls `onReplayed` with the state after each `replayTo`, and the last call gets the whole input [replaySlices.ts:replayInSlices] [replaySlices.ts:sliceEnd]. `HostRendererManager` and the libghostty-vt screenshot use it.
- `iterateInRangeReplayEvents(input, lastAppliedSeq)` yields the events of a `ReplayInput` in `(lastAppliedSeq, input.targetSeq]` [replayEvents.ts:iterateInRangeReplayEvents].
- `resolvePlaywrightBrowsersPath`, `resolveDefaultPlaywrightBrowsersPath` and `ensurePlaywrightBrowsersPath` locate the Playwright browser cache [browserPath.ts:ensurePlaywrightBrowsersPath]. Types: `PlaywrightBrowsersPathSource` (`env` or `captured-home`), `PlaywrightBrowsersPathResolution` and `ResolvePlaywrightBrowsersPathOptions` [browserPath.ts:PlaywrightBrowsersPathResolution].
- Bundled font constants and `getBundledFontAssetByIdentity`: `BUNDLED_FONT_ASSETS`, `BUNDLED_FONT_FAMILY`, `BUNDLED_SYMBOLS_FONT_FAMILY`, `BUNDLED_FONT_ROUTE`, `BUNDLED_PRIMARY_FONT_ASSET` and others [bundledFont.ts:getBundledFontAssetByIdentity].
- `readFontFile(bytes)` returns the PostScript name (name ID 6) and the code points that a TrueType, OpenType or WOFF2 font maps to a glyph (cmap formats 4 and 12). `profileFaces(profile)` returns the bundled faces of each family that the profile's font stack names, in stack order, or `null` when the stack does not start with a bundled family. It asserts that the PostScript names are distinct [fontCoverage.ts:profileFaces].
- `discoverCapabilities(mode, deps?)` returns one `CapabilityEntry` per capability name. `mode` is `'quick'` or `'full'` [capabilities.ts:discoverCapabilities]. The exported types are `DiscoveryMode` and `CapabilityDiscoveryDependencies`, which lets callers replace the probes [capabilities.ts:CapabilityDiscoveryDependencies]. Callers are `src/cli/commands/version.ts` and `src/cli/commands/doctor.ts`.
- Capability schemas: `CapabilityNameSchema` (`snapshot`, `wait`, `screenshot`, `record-export-asciicast`, `record-export-webm`, `dashboard`, `mouse-input`), `CapabilityStatusSchema` (`available`, `unavailable`, `degraded`, `unknown`), `CapabilityEntrySchema`, `RendererRuntimeModeSchema`, `RendererRuntimeStatusSchema` and `RendererRuntimeSummarySchema` [capabilities.ts:CapabilityNameSchema].
- `probeLibghosttyVt`, `assertDashboardRendererAvailable`, `buildDashboardCapability`, `LIBGHOSTTY_VT_PACKAGE` and `DASHBOARD_RENDERER_UNAVAILABLE_MESSAGE` [readiness.ts:probeLibghosttyVt]. Types: `LibghosttyVtProbe` and `LibghosttyVtLoader` [readiness.ts:LibghosttyVtProbe]. The probe sets `mouseInputAvailable` from the package's `supportsMouseInput` export [readiness.ts:probeLibghosttyVt].
- `index.ts` is a barrel for the profile, schema, name and registry exports, the `RendererBackend` and `SnapshotOptions` types, and `GhosttyWebBackend`. It does not re-export `browserPath`, `bundledFont`, `canonicalScreen`, `replayEvents`, `capabilities` or `readiness` [index.ts:createRendererBackend].
- `index.ts` is a barrel for the profile, schema, name and registry exports, the `RendererBackend` and `SnapshotOptions` types, and `GhosttyWebBackend`. It does not re-export `browserPath`, `bundledFont`, `canonicalScreen`, `replayEvents`, `replaySlices`, `capabilities` or `readiness` [index.ts:createRendererBackend].

## Invariants

Expand Down
Loading
Loading