From a23c63f51f323752f8bd9686297b48f1ff18c9c9 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:45:20 +0200 Subject: [PATCH 01/10] feat: define machine attach v2 protocol --- docs/vrs/requirements.md | 25 +- docs/vrs/spec.md | 90 ++++- src/machine-protocol.ts | 617 +++++++++++++++++++++++++++++++++ tests/machine-protocol.test.ts | 211 +++++++++++ 4 files changed, 921 insertions(+), 22 deletions(-) create mode 100644 src/machine-protocol.ts create mode 100644 tests/machine-protocol.test.ts diff --git a/docs/vrs/requirements.md b/docs/vrs/requirements.md index 73b0988..6597b6a 100644 --- a/docs/vrs/requirements.md +++ b/docs/vrs/requirements.md @@ -71,12 +71,22 @@ implementation contract and validation map live in [spec.md](./spec.md). - **R07 Bounded stream protocol:** Packets are length-delimited, fragmented input is reassembled, oversized input is rejected without unbounded buffering, and reconnect or unsupported capability failure is explicit. -- **R08 Machine attach outcomes:** Machine attach preserves the framed - `GEOMETRY`, `SCREEN`, `DATA`, and `EXIT` stream on a caller-owned inherited - descriptor while stdin/stdout remain the controlling terminal. A clean stream - ends with exactly one `EXIT` when the session process ended or empty `DETACH` - when this client intentionally detached; EOF without either is truncation. - Administrative destruction is truncation unless process `EXIT` was observed. + Machine admission verifies the exact stable id and generation on the same + connection it attaches; rejection causes no attach, resize, or redraw + mutation. Capability support is negotiated behaviorally rather than inferred + from version strings, record shapes, separate preflight observations, or + timeouts. +- **R08 Machine attach stream:** Machine attach is an exclusively framed, + bidirectional stdin/stdout protocol distinct from interactive attach. One + `OPEN` receives either exactly one `ADMISSION_FAILURE` then EOF, or `HELLO`, + an optional atomic `READY` baseline with ordered updates, exactly one typed + bound-stream outcome, then EOF. `READY` contains effective + geometry, the complete revisioned child-input mode snapshot, and screen + bytes. `INPUT` framing preserves its byte payload; the child boundary accepts + valid UTF-8 plus all C0 and escape bytes without replacement or reserved-byte + interpretation and rejects invalid UTF-8 explicitly. Detach is its own frame, + never inferred from input. EOF before a typed outcome is truncation, and + administrative destruction is truncation unless process exit was observed. ### Must expose durable state through one behavioral core @@ -99,4 +109,5 @@ implementation contract and validation map live in [spec.md](./spec.md). protocol/testing APIs, the shipped package entrypoint, local transport, and remote routing preserve the applicable runtime, stream, geometry, registry, and lifecycle contracts. A surface rejects unsupported capabilities instead - of silently weakening them; tests use real PTYs and processes. + of silently weakening them; machine attach never falls back to an older + protocol after v2 admission fails. Tests use real PTYs and processes. diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 491eb6b..570fe18 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -134,22 +134,82 @@ the last writable-attached client leaves the last effective geometry stable. ### Machine attach -`attach --attach-stream-fd-v1 ` requires an inherited writable -descriptor `fd >= 3`. The packaged CLI runs without a wrapper child so the -descriptor, controlling terminal, signals, and process identity reach the -adapter unchanged (R08, R11). +`machine-attach-v2` gives stdin and stdout exclusively to a bounded framed +protocol. It does not allocate a controlling terminal, inherit a side-channel +descriptor, interpret an interactive detach key, or fall back to machine attach +v1 (R08, R11). -The adapter reframes only `GEOMETRY`, `SCREEN`, `DATA`, and terminal outcomes to -the descriptor; terminal interaction stays on stdin/stdout and diagnostics use -stderr. It flushes exactly one clean outcome before EOF: +```text +host adapter daemon + | OPEN(id,generation,size,caps) | | + |------------------------------>| OPEN_V2 + STATUS sentinel | + | |----------------------------->| + | ADMISSION_FAILURE |<-- STATUS (legacy) ----------| + |<------------------------------| | + | |<-- ADMISSION_V2 --------------| + | HELLO -> READY -> updates | | + |<------------------------------| | + | INPUT / RESIZE / DETACH | | + |------------------------------>|----------------------------->| + | typed stream outcome -> EOF | | + |<------------------------------| | +``` -| Outcome | Meaning | -| --- | --- | -| `EXIT(code)` | the session process ended | -| empty `DETACH` | this local client intentionally detached | -| EOF without either | transport loss, reconnect give-up, descriptor failure, or abrupt administrative destruction | +Every machine frame uses the common five-byte header and the 32 MiB bound. +Structured payloads are fatal UTF-8 JSON capped at 64 KiB with exact fields; +unknown tags or fields are protocol errors. `DATA`, `INPUT`, and the screen +suffixes of `READY` and `SNAPSHOT` remain byte payloads. `INPUT` reaches the +node-pty string boundary only after one fatal UTF-8 validation. Valid UTF-8 and +all C0/escape bytes, including `0x1c`, pass unchanged; invalid UTF-8 produces a +typed failure rather than replacement characters (R07, R08). + +The direction-specific frame schemas are: + +| Direction | Frame | Payload and ordering | +| --- | --- | --- | +| host -> adapter | `OPEN` | Protocol 2, exact session id and expected generation, non-zero rows/cols, required capabilities; exactly once and first | +| host -> adapter | `INPUT` | Framed terminal bytes; only after `READY` | +| host -> adapter | `RESIZE` | Non-zero rows/cols; only after `READY` | +| host -> adapter | `DETACH` | Empty explicit intent after `HELLO`, including before `READY`; at most once | +| adapter -> host | `HELLO` | Exact generation, negotiated capabilities, diagnostic daemon build identity | +| adapter -> host | `READY` | Atomic effective geometry, complete input-mode snapshot, and screen baseline; at most once | +| adapter -> host | `DATA` / `GEOMETRY` | Ordered terminal bytes or effective size after `READY` | +| adapter -> host | `INPUT_MODES` | Complete input-mode state at a strictly increasing revision | +| adapter -> host | `SNAPSHOT` | Atomic geometry, complete non-regressing input-mode state, and screen | +| adapter -> host | `ADMISSION_FAILURE` | Typed rejection before `HELLO`; no daemon attach mutation | +| adapter -> host | `EXITED` / `DETACHED` / `STREAM_FAILURE` | Exactly one terminal outcome, then EOF | + +The complete child-input mode snapshot contains application cursor keys, +application keypad, bracketed paste, focus reporting, mouse tracking, mouse +encoding, and the Kitty keyboard flag stack. It is state, not an inference from +screen bytes. A mode change is ordered with `DATA` and sent as a complete +`INPUT_MODES` replacement with an advancing revision. + +The adapter sends daemon packet `OPEN_V2` (reserved type 8) followed by a +`STATUS` request on the same command-role socket. A v2 daemon answers first with +`ADMISSION_V2` (reserved type 9); a legacy daemon ignores type 8 and answers the +status sentinel first. Generation comparison and role transition are one +daemon operation. An accepting v2 daemon consumes and suppresses the following +status sentinel so no legacy status response enters the accepted stream. +Rejection leaves the command role unchanged. Version and +build identity are diagnostic; the three required capabilities are +`framed-utf8-input`, `typed-outcome`, and `input-mode-snapshot` (R07). + +The host lifecycle grammar is (R08): + +```text +( HELLO ( READY (DATA | GEOMETRY | INPUT_MODES | SNAPSHOT)* STREAM_END + | STREAM_END ) +| ADMISSION_FAILURE ) EOF + +STREAM_END = EXITED | DETACHED | STREAM_FAILURE +``` -The last row is a non-zero truncation, not a third clean outcome (R07, R08). +Bare EOF, a second outcome, a pre-`READY` update, a repeated `READY`, or a +backward input-mode revision is a protocol violation. `AdmissionFailure` +includes unsupported daemon/capability, generation mismatch, missing or denied +session, malformed request, and adapter transport failure before `HELLO`. +`StreamFailure` covers baseline, streaming, or shutdown failure after `HELLO`. ## Registry and lifecycle state @@ -215,8 +275,8 @@ input, resize, and multi-client geometry without mocks. | R04 | [server](../../src/server.ts), [connection](../../src/connection.ts) | [integration](../../tests/integration.test.ts), [alternate screen](../../tests/screen-replay-altscreen.test.ts), [scrollback](../../tests/scrollback-fidelity.test.ts) | | R05 | [server](../../src/server.ts) | [integration](../../tests/integration.test.ts) | | R06 | [server](../../src/server.ts), [protocol](../../src/protocol.ts) | [effective geometry](../../tests/effective-geometry.test.ts), [resize](../../tests/resize-tui.test.ts), [status](../../tests/stats-cli.test.ts) | -| R07 | [protocol](../../src/protocol.ts), [connection](../../src/connection.ts), [remote](../../src/remote.ts) | [protocol](../../tests/protocol.test.ts), [connection](../../tests/connection.test.ts), [remote reconnect](../../tests/remote-reconnect.test.ts) | -| R08 | [client](../../src/client.ts), [CLI](../../src/cli.ts), [entrypoint](../../bin/pty) | [attach stream](../../tests/attach-stream.test.ts), [signals](../../tests/wrapper-signal-forwarding.test.ts) | +| R07 | [protocol](../../src/protocol.ts), [machine protocol](../../src/machine-protocol.ts), [connection](../../src/connection.ts), [remote](../../src/remote.ts) | [protocol](../../tests/protocol.test.ts), [machine protocol](../../tests/machine-protocol.test.ts), [connection](../../tests/connection.test.ts), [remote reconnect](../../tests/remote-reconnect.test.ts) | +| R08 | [machine protocol](../../src/machine-protocol.ts), [client](../../src/client.ts), [CLI](../../src/cli.ts), [entrypoint](../../bin/pty) | [machine protocol](../../tests/machine-protocol.test.ts), [attach stream](../../tests/attach-stream.test.ts), [signals](../../tests/wrapper-signal-forwarding.test.ts) | | R09 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [recovery](../../src/recovery.ts), [CLI](../../src/cli.ts) | [root](../../tests/pty-root.test.ts), [display name](../../tests/display-name.test.ts), [status](../../tests/stats-cli.test.ts), [list purity](../../tests/list-purity.test.ts), [recovery](../../tests/recovery.test.ts) | | R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) | | R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) | diff --git a/src/machine-protocol.ts b/src/machine-protocol.ts new file mode 100644 index 0000000..efb1cfb --- /dev/null +++ b/src/machine-protocol.ts @@ -0,0 +1,617 @@ +import { Buffer } from "node:buffer"; + +export const MACHINE_PROTOCOL_VERSION = 2 as const; +export const MAX_MACHINE_FRAME_LENGTH = 32 * 1024 * 1024; + +const HEADER_SIZE = 5; +const MAX_STRUCTURED_PAYLOAD_LENGTH = 64 * 1024; +const MAX_STRING_LENGTH = 4096; +const MAX_CAPABILITIES = 32; +const MAX_KITTY_STACK_DEPTH = 64; + +export const MachineRequestType = { + OPEN: 1, + INPUT: 2, + RESIZE: 3, + DETACH: 4, +} as const; + +export const MachineResponseType = { + HELLO: 1, + READY: 2, + DATA: 3, + GEOMETRY: 4, + SNAPSHOT: 5, + EXITED: 6, + DETACHED: 7, + ADMISSION_FAILURE: 8, + STREAM_FAILURE: 9, + INPUT_MODES: 10, +} as const; + +/** Reserved packet types on the existing daemon socket. */ +export const DaemonExtensionType = { + OPEN_V2: 8, + ADMISSION_V2: 9, +} as const; + +export type MachineCapability = "framed-utf8-input" | "typed-outcome" | "input-mode-snapshot"; + +export interface MachineOpenV2 { + readonly _tag: "Open"; + readonly protocol: typeof MACHINE_PROTOCOL_VERSION; + readonly sessionId: string; + readonly expectedGeneration: string; + readonly rows: number; + readonly cols: number; + readonly requiredCapabilities: readonly MachineCapability[]; +} + +export type MachineRequest = + | MachineOpenV2 + | { readonly _tag: "Input"; readonly bytes: Buffer } + | { readonly _tag: "Resize"; readonly rows: number; readonly cols: number } + | { readonly _tag: "Detach" }; + +export type MouseTrackingMode = "none" | "click" | "button" | "any"; +export type MouseEncodingMode = "x10" | "utf8" | "sgr" | "urxvt"; + +/** Complete child-input state at one ordered terminal-stream revision. */ +export interface MachineInputModeSnapshotV1 { + readonly revision: number; + readonly applicationCursorKeys: boolean; + readonly applicationKeypad: boolean; + readonly bracketedPaste: boolean; + readonly focusReporting: boolean; + readonly mouseTracking: MouseTrackingMode; + readonly mouseEncoding: MouseEncodingMode; + readonly kittyKeyboardFlags: readonly number[]; +} + +export interface MachineBuildInfo { + readonly version: string; + readonly revision?: string; + readonly dirty: boolean; +} + +export interface MachineHelloV2 { + readonly _tag: "Hello"; + readonly protocol: typeof MACHINE_PROTOCOL_VERSION; + readonly generation: string; + readonly capabilities: readonly MachineCapability[]; + readonly build: MachineBuildInfo; +} + +export interface MachineReadyV2 { + readonly _tag: "Ready"; + readonly rows: number; + readonly cols: number; + readonly inputModes: MachineInputModeSnapshotV1; + readonly screen: Buffer; +} + +export type AdmissionFailureReason = + | "unsupported-daemon" + | "generation-mismatch" + | "not-found" + | "permission-denied" + | "unsupported-capability" + | "transport-failure" + | "malformed-request"; + +export type StreamFailurePhase = "baseline" | "stream" | "shutdown"; + +export type MachineOutcome = + | { readonly _tag: "Exited"; readonly code: number; readonly signal: string | null } + | { readonly _tag: "Detached" } + | { + readonly _tag: "AdmissionFailure"; + readonly reason: AdmissionFailureReason; + readonly detail?: string; + } + | { + readonly _tag: "StreamFailure"; + readonly phase: StreamFailurePhase; + readonly reason: string; + readonly diagnostic?: string; + }; + +export type MachineResponse = + | MachineHelloV2 + | MachineReadyV2 + | { readonly _tag: "Data"; readonly bytes: Buffer } + | { readonly _tag: "Geometry"; readonly rows: number; readonly cols: number } + | { + readonly _tag: "Snapshot"; + readonly rows: number; + readonly cols: number; + readonly inputModes: MachineInputModeSnapshotV1; + readonly screen: Buffer; + } + | { readonly _tag: "InputModes"; readonly inputModes: MachineInputModeSnapshotV1 } + | MachineOutcome; + +export type DaemonAdmissionV2 = + | { + readonly _tag: "Accepted"; + readonly protocol: typeof MACHINE_PROTOCOL_VERSION; + readonly generation: string; + readonly capabilities: readonly MachineCapability[]; + readonly build: MachineBuildInfo; + } + | { + readonly _tag: "Rejected"; + readonly reason: AdmissionFailureReason; + readonly detail?: string; + }; + +export interface MachineWireFrame { + readonly type: number; + readonly payload: Buffer; +} + +export class MachineProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "MachineProtocolError"; + } +} + +function encodeFrame(type: number, payload: Buffer): Buffer { + if (payload.length > MAX_MACHINE_FRAME_LENGTH) { + throw new MachineProtocolError( + `Machine frame length ${payload.length} exceeds maximum ${MAX_MACHINE_FRAME_LENGTH}`, + ); + } + const header = Buffer.alloc(HEADER_SIZE); + header.writeUInt8(type, 0); + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +export class MachineFrameReader { + private buffer = Buffer.alloc(0); + + feed(chunk: Buffer): MachineWireFrame[] { + this.buffer = Buffer.concat([this.buffer, chunk]); + const frames: MachineWireFrame[] = []; + while (this.buffer.length >= HEADER_SIZE) { + const type = this.buffer.readUInt8(0); + const length = this.buffer.readUInt32BE(1); + if (length > MAX_MACHINE_FRAME_LENGTH) { + this.buffer = Buffer.alloc(0); + throw new MachineProtocolError(`Machine frame length ${length} exceeds maximum ${MAX_MACHINE_FRAME_LENGTH}`); + } + if (this.buffer.length < HEADER_SIZE + length) break; + frames.push({ + type, + payload: Buffer.from(this.buffer.subarray(HEADER_SIZE, HEADER_SIZE + length)), + }); + this.buffer = this.buffer.subarray(HEADER_SIZE + length); + } + return frames; + } +} + +function structured(value: unknown): Buffer { + const payload = Buffer.from(JSON.stringify(value), "utf8"); + if (payload.length > MAX_STRUCTURED_PAYLOAD_LENGTH) { + throw new MachineProtocolError("Structured machine payload exceeds 64 KiB"); + } + return payload; +} + +function parseStructured(payload: Buffer): unknown { + if (payload.length > MAX_STRUCTURED_PAYLOAD_LENGTH) { + throw new MachineProtocolError("Structured machine payload exceeds 64 KiB"); + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)); + } catch { + throw new MachineProtocolError("Invalid machine protocol JSON"); + } +} + +function exactKeys(object: Record, allowed: readonly string[], name: string): void { + const allowedSet = new Set(allowed); + const unknown = Object.keys(object).find((key) => !allowedSet.has(key)); + if (unknown !== undefined) throw new MachineProtocolError(`${name} has unknown field ${unknown}`); +} + +function record(value: unknown, name: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new MachineProtocolError(`${name} must be an object`); + } + return value as Record; +} + +function string(value: unknown, name: string, allowEmpty = false): string { + if (typeof value !== "string" || (!allowEmpty && value.length === 0) || value.length > MAX_STRING_LENGTH) { + throw new MachineProtocolError(`${name} must be a bounded string`); + } + return value; +} + +function integer(value: unknown, name: string, minimum: number, maximum: number): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + throw new MachineProtocolError(`${name} must be an integer from ${minimum} through ${maximum}`); + } + return value as number; +} + +function boolean(value: unknown, name: string): boolean { + if (typeof value !== "boolean") throw new MachineProtocolError(`${name} must be boolean`); + return value; +} + +const CAPABILITIES = new Set(["framed-utf8-input", "typed-outcome", "input-mode-snapshot"]); + +function capabilities(value: unknown): MachineCapability[] { + if (!Array.isArray(value) || value.length > MAX_CAPABILITIES) { + throw new MachineProtocolError("capabilities must be a bounded array"); + } + const decoded = value.map((item) => { + if (typeof item !== "string" || !CAPABILITIES.has(item as MachineCapability)) { + throw new MachineProtocolError("unknown machine capability"); + } + return item as MachineCapability; + }); + if (new Set(decoded).size !== decoded.length) throw new MachineProtocolError("duplicate machine capability"); + return decoded; +} + +function size(value: unknown): { rows: number; cols: number } { + const object = record(value, "size"); + return { + rows: integer(object.rows, "rows", 1, 0xffff), + cols: integer(object.cols, "cols", 1, 0xffff), + }; +} + +function inputModes(value: unknown): MachineInputModeSnapshotV1 { + const object = record(value, "inputModes"); + exactKeys( + object, + [ + "revision", + "applicationCursorKeys", + "applicationKeypad", + "bracketedPaste", + "focusReporting", + "mouseTracking", + "mouseEncoding", + "kittyKeyboardFlags", + ], + "inputModes", + ); + const tracking = string(object.mouseTracking, "mouseTracking") as MouseTrackingMode; + const encoding = string(object.mouseEncoding, "mouseEncoding") as MouseEncodingMode; + if (!["none", "click", "button", "any"].includes(tracking)) + throw new MachineProtocolError("unknown mouse tracking mode"); + if (!["x10", "utf8", "sgr", "urxvt"].includes(encoding)) + throw new MachineProtocolError("unknown mouse encoding mode"); + if (!Array.isArray(object.kittyKeyboardFlags) || object.kittyKeyboardFlags.length > MAX_KITTY_STACK_DEPTH) { + throw new MachineProtocolError("kittyKeyboardFlags must be a bounded array"); + } + return { + revision: integer(object.revision, "input mode revision", 0, Number.MAX_SAFE_INTEGER), + applicationCursorKeys: boolean(object.applicationCursorKeys, "applicationCursorKeys"), + applicationKeypad: boolean(object.applicationKeypad, "applicationKeypad"), + bracketedPaste: boolean(object.bracketedPaste, "bracketedPaste"), + focusReporting: boolean(object.focusReporting, "focusReporting"), + mouseTracking: tracking, + mouseEncoding: encoding, + kittyKeyboardFlags: object.kittyKeyboardFlags.map((flag) => integer(flag, "kitty keyboard flag", 0, 0xffffffff)), + }; +} + +function buildInfo(value: unknown): MachineBuildInfo { + const object = record(value, "build"); + exactKeys(object, ["version", "revision", "dirty"], "build"); + return { + version: string(object.version, "build.version"), + ...(object.revision === undefined ? {} : { revision: string(object.revision, "build.revision") }), + dirty: boolean(object.dirty, "build.dirty"), + }; +} + +function openV2(value: unknown): MachineOpenV2 { + const object = record(value, "OPEN"); + exactKeys( + object, + ["_tag", "protocol", "sessionId", "expectedGeneration", "rows", "cols", "requiredCapabilities"], + "OPEN", + ); + if (object._tag !== "Open" || object.protocol !== MACHINE_PROTOCOL_VERSION) { + throw new MachineProtocolError("OPEN must request machine protocol 2"); + } + return { + _tag: "Open", + protocol: MACHINE_PROTOCOL_VERSION, + sessionId: string(object.sessionId, "sessionId"), + expectedGeneration: string(object.expectedGeneration, "expectedGeneration"), + ...size(object), + requiredCapabilities: capabilities(object.requiredCapabilities), + }; +} + +function admissionReason(value: unknown): AdmissionFailureReason { + const reason = string(value, "admission failure reason") as AdmissionFailureReason; + if ( + ![ + "unsupported-daemon", + "generation-mismatch", + "not-found", + "permission-denied", + "unsupported-capability", + "transport-failure", + "malformed-request", + ].includes(reason) + ) { + throw new MachineProtocolError("unknown admission failure reason"); + } + return reason; +} + +function optionalDetail(value: unknown, name: string): string | undefined { + return value === undefined ? undefined : string(value, name, true); +} + +function snapshotPayload(metadata: Omit, screen: Buffer): Buffer { + const encodedMetadata = structured({ + rows: metadata.rows, + cols: metadata.cols, + inputModes: metadata.inputModes, + }); + const header = Buffer.alloc(4); + header.writeUInt32BE(encodedMetadata.length, 0); + return Buffer.concat([header, encodedMetadata, screen]); +} + +function decodeSnapshotPayload(payload: Buffer): Omit { + if (payload.length < 4) throw new MachineProtocolError("Snapshot payload is truncated"); + const metadataLength = payload.readUInt32BE(0); + if (metadataLength > MAX_STRUCTURED_PAYLOAD_LENGTH || payload.length < 4 + metadataLength) { + throw new MachineProtocolError("Snapshot metadata is truncated or oversized"); + } + const metadata = record(parseStructured(payload.subarray(4, 4 + metadataLength)), "snapshot metadata"); + exactKeys(metadata, ["rows", "cols", "inputModes"], "snapshot metadata"); + return { + ...size(metadata), + inputModes: inputModes(metadata.inputModes), + screen: Buffer.from(payload.subarray(4 + metadataLength)), + }; +} + +export function encodeMachineRequest(request: MachineRequest): Buffer { + switch (request._tag) { + case "Open": + return encodeFrame(MachineRequestType.OPEN, structured(openV2(request))); + case "Input": + return encodeFrame(MachineRequestType.INPUT, request.bytes); + case "Resize": + return encodeFrame(MachineRequestType.RESIZE, structured(size(request))); + case "Detach": + return encodeFrame(MachineRequestType.DETACH, Buffer.alloc(0)); + } +} + +export function decodeMachineRequest(frame: MachineWireFrame): MachineRequest { + switch (frame.type) { + case MachineRequestType.OPEN: + return openV2(parseStructured(frame.payload)); + case MachineRequestType.INPUT: + return { _tag: "Input", bytes: Buffer.from(frame.payload) }; + case MachineRequestType.RESIZE: { + const value = record(parseStructured(frame.payload), "RESIZE"); + exactKeys(value, ["rows", "cols"], "RESIZE"); + return { _tag: "Resize", ...size(value) }; + } + case MachineRequestType.DETACH: + if (frame.payload.length !== 0) throw new MachineProtocolError("DETACH payload must be empty"); + return { _tag: "Detach" }; + default: + throw new MachineProtocolError(`Unknown machine request type ${frame.type}`); + } +} + +export function encodeMachineResponse(response: MachineResponse): Buffer { + switch (response._tag) { + case "Hello": + return encodeFrame(MachineResponseType.HELLO, structured(response)); + case "Ready": + return encodeFrame(MachineResponseType.READY, snapshotPayload(response, response.screen)); + case "Data": + return encodeFrame(MachineResponseType.DATA, response.bytes); + case "Geometry": + return encodeFrame(MachineResponseType.GEOMETRY, structured(size(response))); + case "Snapshot": + return encodeFrame(MachineResponseType.SNAPSHOT, snapshotPayload(response, response.screen)); + case "InputModes": + return encodeFrame(MachineResponseType.INPUT_MODES, structured({ inputModes: response.inputModes })); + case "Exited": + return encodeFrame(MachineResponseType.EXITED, structured(response)); + case "Detached": + return encodeFrame(MachineResponseType.DETACHED, Buffer.alloc(0)); + case "AdmissionFailure": + return encodeFrame(MachineResponseType.ADMISSION_FAILURE, structured(response)); + case "StreamFailure": + return encodeFrame(MachineResponseType.STREAM_FAILURE, structured(response)); + } +} + +export function decodeMachineResponse(frame: MachineWireFrame): MachineResponse { + const structuredPayload = () => record(parseStructured(frame.payload), "machine response"); + switch (frame.type) { + case MachineResponseType.HELLO: { + const value = structuredPayload(); + exactKeys(value, ["_tag", "protocol", "generation", "capabilities", "build"], "HELLO"); + if (value._tag !== "Hello" || value.protocol !== MACHINE_PROTOCOL_VERSION) + throw new MachineProtocolError("Invalid HELLO"); + return { + _tag: "Hello", + protocol: MACHINE_PROTOCOL_VERSION, + generation: string(value.generation, "generation"), + capabilities: capabilities(value.capabilities), + build: buildInfo(value.build), + }; + } + case MachineResponseType.READY: + return { _tag: "Ready", ...decodeSnapshotPayload(frame.payload) }; + case MachineResponseType.DATA: + return { _tag: "Data", bytes: Buffer.from(frame.payload) }; + case MachineResponseType.GEOMETRY: { + const value = record(parseStructured(frame.payload), "GEOMETRY"); + exactKeys(value, ["rows", "cols"], "GEOMETRY"); + return { _tag: "Geometry", ...size(value) }; + } + case MachineResponseType.SNAPSHOT: + return { _tag: "Snapshot", ...decodeSnapshotPayload(frame.payload) }; + case MachineResponseType.INPUT_MODES: { + const value = structuredPayload(); + exactKeys(value, ["inputModes"], "INPUT_MODES"); + return { _tag: "InputModes", inputModes: inputModes(value.inputModes) }; + } + case MachineResponseType.EXITED: { + const value = structuredPayload(); + exactKeys(value, ["_tag", "code", "signal"], "EXITED"); + if (value._tag !== "Exited") throw new MachineProtocolError("Invalid EXITED"); + return { + _tag: "Exited", + code: integer(value.code, "exit code", -0x80000000, 0x7fffffff), + signal: value.signal === null ? null : string(value.signal, "exit signal"), + }; + } + case MachineResponseType.DETACHED: + if (frame.payload.length !== 0) throw new MachineProtocolError("DETACHED payload must be empty"); + return { _tag: "Detached" }; + case MachineResponseType.ADMISSION_FAILURE: { + const value = structuredPayload(); + exactKeys(value, ["_tag", "reason", "detail"], "ADMISSION_FAILURE"); + if (value._tag !== "AdmissionFailure") throw new MachineProtocolError("Invalid ADMISSION_FAILURE"); + const detail = optionalDetail(value.detail, "admission detail"); + return { + _tag: "AdmissionFailure", + reason: admissionReason(value.reason), + ...(detail === undefined ? {} : { detail }), + }; + } + case MachineResponseType.STREAM_FAILURE: { + const value = structuredPayload(); + exactKeys(value, ["_tag", "phase", "reason", "diagnostic"], "STREAM_FAILURE"); + if (value._tag !== "StreamFailure") throw new MachineProtocolError("Invalid STREAM_FAILURE"); + const phase = string(value.phase, "stream failure phase") as StreamFailurePhase; + if (!["baseline", "stream", "shutdown"].includes(phase)) + throw new MachineProtocolError("Unknown stream failure phase"); + const diagnostic = optionalDetail(value.diagnostic, "stream diagnostic"); + return { + _tag: "StreamFailure", + phase, + reason: string(value.reason, "stream failure reason"), + ...(diagnostic === undefined ? {} : { diagnostic }), + }; + } + default: + throw new MachineProtocolError(`Unknown machine response type ${frame.type}`); + } +} + +export function encodeDaemonOpenV2(open: MachineOpenV2): Buffer { + return encodeFrame(DaemonExtensionType.OPEN_V2, structured(openV2(open))); +} + +export function decodeDaemonOpenV2(frame: MachineWireFrame): MachineOpenV2 { + if (frame.type !== DaemonExtensionType.OPEN_V2) throw new MachineProtocolError("Expected daemon OPEN_V2"); + return openV2(parseStructured(frame.payload)); +} + +export function encodeDaemonAdmissionV2(admission: DaemonAdmissionV2): Buffer { + return encodeFrame(DaemonExtensionType.ADMISSION_V2, structured(admission)); +} + +export function decodeDaemonAdmissionV2(frame: MachineWireFrame): DaemonAdmissionV2 { + if (frame.type !== DaemonExtensionType.ADMISSION_V2) throw new MachineProtocolError("Expected daemon ADMISSION_V2"); + const value = record(parseStructured(frame.payload), "daemon admission"); + if (value._tag === "Accepted") { + exactKeys(value, ["_tag", "protocol", "generation", "capabilities", "build"], "accepted admission"); + if (value.protocol !== MACHINE_PROTOCOL_VERSION) + throw new MachineProtocolError("Daemon accepted an unsupported protocol"); + return { + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: string(value.generation, "generation"), + capabilities: capabilities(value.capabilities), + build: buildInfo(value.build), + }; + } + if (value._tag === "Rejected") { + exactKeys(value, ["_tag", "reason", "detail"], "rejected admission"); + const detail = optionalDetail(value.detail, "admission detail"); + return { _tag: "Rejected", reason: admissionReason(value.reason), ...(detail === undefined ? {} : { detail }) }; + } + throw new MachineProtocolError("Invalid daemon admission response"); +} + +export type MachineAttachState = + | { readonly _tag: "AwaitAdmission" } + | { readonly _tag: "AwaitReady"; readonly hello: MachineHelloV2 } + | { + readonly _tag: "Streaming"; + readonly hello: MachineHelloV2; + readonly ready: MachineReadyV2; + readonly inputModeRevision: number; + } + | { readonly _tag: "AwaitEof"; readonly outcome: MachineOutcome } + | { readonly _tag: "Complete"; readonly outcome: MachineOutcome } + | { readonly _tag: "ProtocolViolation"; readonly reason: string }; + +export type MachineAttachEvent = { readonly _tag: "Frame"; readonly frame: MachineResponse } | { readonly _tag: "Eof" }; + +function violation(reason: string): MachineAttachState { + return { _tag: "ProtocolViolation", reason }; +} + +/** Validates the complete adapter-to-host lifecycle, including terminal EOF. */ +export function reduceMachineAttach(state: MachineAttachState, event: MachineAttachEvent): MachineAttachState { + if (state._tag === "ProtocolViolation" || state._tag === "Complete") { + return violation(`Unexpected ${event._tag} after ${state._tag}`); + } + if (event._tag === "Eof") { + return state._tag === "AwaitEof" + ? { _tag: "Complete", outcome: state.outcome } + : violation(`Truncated machine stream while ${state._tag}`); + } + const frame = event.frame; + switch (state._tag) { + case "AwaitAdmission": + if (frame._tag === "Hello") return { _tag: "AwaitReady", hello: frame }; + if (frame._tag === "AdmissionFailure") return { _tag: "AwaitEof", outcome: frame }; + return violation(`Expected HELLO or ADMISSION_FAILURE, received ${frame._tag}`); + case "AwaitReady": + if (frame._tag === "Ready") { + return { _tag: "Streaming", hello: state.hello, ready: frame, inputModeRevision: frame.inputModes.revision }; + } + if (frame._tag === "Exited" || frame._tag === "Detached" || frame._tag === "StreamFailure") { + return { _tag: "AwaitEof", outcome: frame }; + } + return violation(`Expected READY or pre-ready outcome, received ${frame._tag}`); + case "Streaming": + if (frame._tag === "Data" || frame._tag === "Geometry") return state; + if (frame._tag === "InputModes") { + return frame.inputModes.revision > state.inputModeRevision + ? { ...state, inputModeRevision: frame.inputModes.revision } + : violation("INPUT_MODES revision must advance"); + } + if (frame._tag === "Snapshot") { + return frame.inputModes.revision >= state.inputModeRevision + ? { ...state, inputModeRevision: frame.inputModes.revision } + : violation("SNAPSHOT input-mode revision moved backward"); + } + if (frame._tag === "Exited" || frame._tag === "Detached" || frame._tag === "StreamFailure") { + return { _tag: "AwaitEof", outcome: frame }; + } + return violation(`Unexpected ${frame._tag} after READY`); + case "AwaitEof": + return violation(`Expected EOF after ${state.outcome._tag}, received ${frame._tag}`); + } +} diff --git a/tests/machine-protocol.test.ts b/tests/machine-protocol.test.ts new file mode 100644 index 0000000..b0f9634 --- /dev/null +++ b/tests/machine-protocol.test.ts @@ -0,0 +1,211 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; +import { + MACHINE_PROTOCOL_VERSION, + MachineFrameReader, + MachineProtocolError, + decodeDaemonAdmissionV2, + decodeDaemonOpenV2, + decodeMachineRequest, + decodeMachineResponse, + encodeDaemonAdmissionV2, + encodeDaemonOpenV2, + encodeMachineRequest, + encodeMachineResponse, + reduceMachineAttach, + type DaemonAdmissionV2, + type MachineHelloV2, + type MachineInputModeSnapshotV1, + type MachineOpenV2, +} from "../src/machine-protocol.ts"; + +const modes: MachineInputModeSnapshotV1 = { + revision: 7, + applicationCursorKeys: true, + applicationKeypad: false, + bracketedPaste: true, + focusReporting: false, + mouseTracking: "any", + mouseEncoding: "sgr", + kittyKeyboardFlags: [1, 15], +}; + +const open: MachineOpenV2 = { + _tag: "Open", + protocol: MACHINE_PROTOCOL_VERSION, + sessionId: "session-1", + expectedGeneration: "generation-a", + rows: 24, + cols: 80, + requiredCapabilities: ["framed-utf8-input", "typed-outcome", "input-mode-snapshot"], +}; + +describe("machine attach v2 protocol", () => { + it("round-trips fragmented and coalesced directional frames", () => { + const input = Buffer.from([0x1b, 0x62, 0xff, 0x00, 0x1c]); + const wire = Buffer.concat([ + encodeMachineRequest(open), + encodeMachineRequest({ _tag: "Input", bytes: input }), + encodeMachineRequest({ _tag: "Detach" }), + ]); + const reader = new MachineFrameReader(); + expect(reader.feed(wire.subarray(0, 3))).toEqual([]); + const frames = [...reader.feed(wire.subarray(3, 17)), ...reader.feed(wire.subarray(17))].map(decodeMachineRequest); + + expect(frames).toEqual([open, { _tag: "Input", bytes: input }, { _tag: "Detach" }]); + }); + + it("preserves arbitrary bytes within the framed transport", () => { + const bytes = Buffer.from([0x00, 0xff, 0x1b, 0x62, 0x1c]); + for (const frame of [ + { _tag: "Data" as const, bytes }, + { + _tag: "Ready" as const, + rows: 24, + cols: 80, + inputModes: modes, + screen: bytes, + }, + { + _tag: "Snapshot" as const, + rows: 30, + cols: 100, + inputModes: { ...modes, revision: 8 }, + screen: bytes, + }, + ]) { + const reader = new MachineFrameReader(); + const [wireFrame] = reader.feed(encodeMachineResponse(frame)); + const decoded = decodeMachineResponse(wireFrame); + expect(decoded._tag).toBe(frame._tag); + if (decoded._tag === "Data") expect(decoded.bytes).toEqual(bytes); + else if (decoded._tag === "Ready" || decoded._tag === "Snapshot") expect(decoded.screen).toEqual(bytes); + else throw new Error(`unexpected decoded frame ${decoded._tag}`); + } + }); + + it("round-trips same-connection daemon admission without weakening generation", () => { + const daemonReader = new MachineFrameReader(); + const [openPacket] = daemonReader.feed(encodeDaemonOpenV2(open)); + expect(decodeDaemonOpenV2(openPacket)).toEqual(open); + + const accepted: DaemonAdmissionV2 = { + _tag: "Accepted" as const, + protocol: MACHINE_PROTOCOL_VERSION, + generation: "generation-a", + capabilities: ["framed-utf8-input", "typed-outcome", "input-mode-snapshot"], + build: { version: "0.12.0", revision: "abc123", dirty: false }, + }; + const [admissionPacket] = daemonReader.feed(encodeDaemonAdmissionV2(accepted)); + expect(decodeDaemonAdmissionV2(admissionPacket)).toEqual(accepted); + }); + + it("rejects malformed, unknown and oversized frames", () => { + const unknown = Buffer.alloc(5); + unknown.writeUInt8(255, 0); + expect(() => decodeMachineRequest(new MachineFrameReader().feed(unknown)[0])).toThrow(MachineProtocolError); + + const oversized = Buffer.alloc(5); + oversized.writeUInt8(1, 0); + oversized.writeUInt32BE(32 * 1024 * 1024 + 1, 1); + expect(() => new MachineFrameReader().feed(oversized)).toThrow(MachineProtocolError); + + expect(() => encodeMachineRequest({ ...open, rows: 0 })).toThrow(MachineProtocolError); + + const extraField = Buffer.from(JSON.stringify({ ...open, legacy: true })); + const header = Buffer.alloc(5); + header.writeUInt8(1, 0); + header.writeUInt32BE(extraField.length, 1); + const [frame] = new MachineFrameReader().feed(Buffer.concat([header, extraField])); + expect(() => decodeMachineRequest(frame)).toThrow(/unknown field legacy/); + + const invalidUtf8 = Buffer.from([1, 0, 0, 0, 1, 0xff]); + const [invalidFrame] = new MachineFrameReader().feed(invalidUtf8); + expect(() => decodeMachineRequest(invalidFrame)).toThrow(/Invalid machine protocol JSON/); + }); + + it("accepts only HELLO then READY then updates then one outcome then EOF", () => { + const hello: MachineHelloV2 = { + _tag: "Hello" as const, + protocol: MACHINE_PROTOCOL_VERSION, + generation: "generation-a", + capabilities: ["framed-utf8-input", "typed-outcome", "input-mode-snapshot"], + build: { version: "0.12.0", dirty: false }, + }; + const ready = { _tag: "Ready" as const, rows: 24, cols: 80, inputModes: modes, screen: Buffer.from("$ ") }; + const data = { _tag: "Data" as const, bytes: Buffer.from("ok") }; + const exited = { _tag: "Exited" as const, code: 0, signal: null }; + + let state = reduceMachineAttach({ _tag: "AwaitAdmission" }, { _tag: "Frame", frame: hello }); + state = reduceMachineAttach(state, { _tag: "Frame", frame: ready }); + state = reduceMachineAttach(state, { _tag: "Frame", frame: data }); + state = reduceMachineAttach(state, { _tag: "Frame", frame: exited }); + state = reduceMachineAttach(state, { _tag: "Eof" }); + expect(state).toEqual({ _tag: "Complete", outcome: exited }); + }); + + it("admits a typed rejection only before HELLO and requires EOF afterward", () => { + const rejected = { _tag: "AdmissionFailure" as const, reason: "generation-mismatch" as const, detail: "replaced" }; + let state = reduceMachineAttach({ _tag: "AwaitAdmission" }, { _tag: "Frame", frame: rejected }); + expect(state._tag).toBe("AwaitEof"); + state = reduceMachineAttach(state, { _tag: "Eof" }); + expect(state).toEqual({ _tag: "Complete", outcome: rejected }); + }); + + it("allows every terminal stream outcome after HELLO and before READY", () => { + const hello: MachineHelloV2 = { + _tag: "Hello" as const, + protocol: MACHINE_PROTOCOL_VERSION, + generation: "generation-a", + capabilities: ["typed-outcome" as const], + build: { version: "0.12.0", dirty: false }, + }; + const awaiting = reduceMachineAttach({ _tag: "AwaitAdmission" }, { _tag: "Frame", frame: hello }); + for (const outcome of [ + { _tag: "Exited" as const, code: 1, signal: null }, + { _tag: "Detached" as const }, + { _tag: "StreamFailure" as const, phase: "baseline" as const, reason: "screen unavailable" }, + ]) { + const ended = reduceMachineAttach(awaiting, { _tag: "Frame", frame: outcome }); + expect(reduceMachineAttach(ended, { _tag: "Eof" })).toEqual({ _tag: "Complete", outcome }); + } + }); + + it("orders input mode updates by their explicit revision", () => { + const hello: MachineHelloV2 = { + _tag: "Hello" as const, + protocol: MACHINE_PROTOCOL_VERSION, + generation: "generation-a", + capabilities: ["input-mode-snapshot" as const], + build: { version: "0.12.0", dirty: false }, + }; + const awaitingReady = reduceMachineAttach({ _tag: "AwaitAdmission" }, { _tag: "Frame", frame: hello }); + const streaming = reduceMachineAttach(awaitingReady, { + _tag: "Frame", + frame: { _tag: "Ready", rows: 24, cols: 80, inputModes: modes, screen: Buffer.alloc(0) }, + }); + const advanced = reduceMachineAttach(streaming, { + _tag: "Frame", + frame: { _tag: "InputModes", inputModes: { ...modes, revision: 8 } }, + }); + expect(advanced).toMatchObject({ _tag: "Streaming", inputModeRevision: 8 }); + expect( + reduceMachineAttach(advanced, { + _tag: "Frame", + frame: { _tag: "InputModes", inputModes: { ...modes, revision: 8 } }, + }), + ).toMatchObject({ _tag: "ProtocolViolation" }); + }); + + it("classifies bare EOF and illegal ordering as protocol violations", () => { + expect(reduceMachineAttach({ _tag: "AwaitAdmission" }, { _tag: "Eof" })).toMatchObject({ + _tag: "ProtocolViolation", + }); + expect( + reduceMachineAttach( + { _tag: "AwaitAdmission" }, + { _tag: "Frame", frame: { _tag: "Data", bytes: Buffer.from("early") } }, + ), + ).toMatchObject({ _tag: "ProtocolViolation" }); + }); +}); From b850ae92141432fad33a49c05d7145e7da2f5b6a Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:04:47 +0200 Subject: [PATCH 02/10] feat: add framed machine attach adapter --- completions/pty.bash | 3 +- completions/pty.fish | 1 + completions/pty.zsh | 3 + src/cli.ts | 25 ++ src/client-api.ts | 18 ++ src/completions.ts | 4 + src/machine-attach.ts | 405 +++++++++++++++++++++++++++ tests/help.test.ts | 2 +- tests/machine-attach-v2.test.ts | 472 ++++++++++++++++++++++++++++++++ 9 files changed, 931 insertions(+), 2 deletions(-) create mode 100644 src/machine-attach.ts create mode 100644 tests/machine-attach-v2.test.ts diff --git a/completions/pty.bash b/completions/pty.bash index f2b3809..c480fbf 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -5,7 +5,7 @@ _pty() { COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="run attach a exec peek send events list ls stats restart kill recover rm remove gc tag tag-multi emit rename metadata up down test remote-serve" + commands="run attach a machine-attach-v2 exec peek send events list ls stats restart kill recover rm remove gc tag tag-multi emit rename metadata up down test remote-serve" if [[ ${COMP_CWORD} -eq 1 ]]; then if [[ "${cur}" == -* ]]; then @@ -36,6 +36,7 @@ _pty() { COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi ;; + machine-attach-v2) ;; exec) COMPREPLY=($(compgen -o dirnames -- "${cur}")) ;; diff --git a/completions/pty.fish b/completions/pty.fish index 2060c4d..862f8e6 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -44,6 +44,7 @@ complete -c pty -n __pty_needs_command -l filter-tag -x -d 'TUI: filter to k=v ( complete -c pty -n __pty_needs_command -a run -d 'Create a session and attach' complete -c pty -n __pty_needs_command -a attach -d 'Attach to an existing session' complete -c pty -n __pty_needs_command -a a -d 'Attach to an existing session' +complete -c pty -n __pty_needs_command -a machine-attach-v2 -d 'Headless framed duplex attach for terminal hosts' complete -c pty -n __pty_needs_command -a exec -d 'Replace the current session process' complete -c pty -n __pty_needs_command -a peek -d 'Print current screen (or follow / wait-for-text)' complete -c pty -n __pty_needs_command -a send -d 'Send text or key events' diff --git a/completions/pty.zsh b/completions/pty.zsh index c377d9f..382c4ba 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -17,6 +17,7 @@ _pty() { 'run:Create a session and attach' 'attach:Attach to an existing session' 'a:Alias for attach' + 'machine-attach-v2:Headless framed duplex attach for terminal hosts' 'exec:Replace the current session process' 'peek:Print current screen (or follow / wait-for-text)' 'send:Send text or key events' @@ -78,6 +79,8 @@ _pty() { '--attach-stream-fd-v1[Write framed machine events to an inherited fd]:fd:' \ '1:session:_pty_sessions' ;; + machine-attach-v2) + ;; exec) _arguments \ '1:directory:_directories' diff --git a/src/cli.ts b/src/cli.ts index bf87c9b..3042c61 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -52,6 +52,7 @@ import { import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; import { parseDuration, formatDuration } from "./duration.ts"; +import { machineAttachV2 } from "./machine-attach.ts"; import { serveRemoteControl, runRemoteServeStdio, fetchRemoteList, dialAndRoute, RouteRefusedError, PTY_REMOTE_ALPN, FABRIC_BIN } from "./remote.ts"; import { RECOVERY_PROTOCOL, @@ -151,6 +152,20 @@ Examples: pty attach --no-restart myserver pty attach --remote hetzner myshell`, + "machine-attach-v2": `Usage: pty machine-attach-v2 + +Open one headless, framed, duplex attachment. The first stdin frame identifies +the exact stable session id and expected daemon generation. Responses are +written only to stdout; diagnostics are written only to stderr. + +This protocol does not resolve display-name aliases, restart sessions, reserve +input bytes, or fall back to legacy attach. +Every successfully framed outcome exits zero; adapter/process failures exit +non-zero. The framed outcome, not process status, is the session result. + +Example: + pty machine-attach-v2 < request.frames > response.frames`, + exec: `Usage: pty exec -- [args...] Replace the current session's leaf process with a new command. Run INSIDE a @@ -473,6 +488,7 @@ Attach & interact: pty attach -r Attach, auto-restart if the session is exited pty attach --no-restart Attach only; fail if the session is not running pty attach --remote Attach a session on a fabric peer (over fabric) + pty machine-attach-v2 Headless framed duplex attach for terminal hosts pty exec -- [args...] Replace the current session's process (inside a session) pty send "text" Send raw text (no implicit newline) pty send --seq "text" --seq key:return Send an ordered sequence of chunks / key events @@ -1016,6 +1032,15 @@ async function main(): Promise { break; } + case "machine-attach-v2": { + if (args.length !== 1) { + console.error("Usage: pty machine-attach-v2"); + process.exit(1); + } + await machineAttachV2(); + process.exit(0); + } + case "exec": { // pty exec -- [args...] const dashDash = args.indexOf("--", 1); diff --git a/src/client-api.ts b/src/client-api.ts index b20118a..c609567 100644 --- a/src/client-api.ts +++ b/src/client-api.ts @@ -65,3 +65,21 @@ export { PacketReader, MessageType, type Packet, } from "./protocol.ts"; + +// Headless terminal-host attachment +export { + machineAttachV2, + type MachineAttachV2Options, +} from "./machine-attach.ts"; +export { + MACHINE_PROTOCOL_VERSION, + MachineFrameReader, + decodeMachineRequest, + decodeMachineResponse, + encodeMachineRequest, + encodeMachineResponse, + type MachineOpenV2, + type MachineRequest, + type MachineResponse, + type MachineOutcome, +} from "./machine-protocol.ts"; diff --git a/src/completions.ts b/src/completions.ts index 52f52ca..45f7153 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -96,6 +96,10 @@ const COMMANDS: readonly CommandSpec[] = [ }, ], }, + { + name: "machine-attach-v2", + desc: "Headless framed duplex attach for terminal hosts", + }, { name: "exec", desc: "Replace the current session process", diff --git a/src/machine-attach.ts b/src/machine-attach.ts new file mode 100644 index 0000000..f3e55e1 --- /dev/null +++ b/src/machine-attach.ts @@ -0,0 +1,405 @@ +import * as net from "node:net"; +import type { Readable, Writable } from "node:stream"; +import { + MachineFrameReader, + MachineProtocolError, + DaemonExtensionType, + decodeDaemonAdmissionV2, + decodeMachineRequest, + decodeMachineResponse, + encodeDaemonOpenV2, + encodeMachineRequest, + encodeMachineResponse, + reduceMachineAttach, + type MachineAttachState, + type MachineOutcome, + type MachineOpenV2, + type MachineResponse, + type StreamFailurePhase, +} from "./machine-protocol.ts"; +import { + MessageType, + encodeStatus, +} from "./protocol.ts"; +import { getSocketPath, validateName } from "./sessions.ts"; + +export interface MachineAttachV2Options { + readonly input?: Readable; + readonly output?: Writable; + readonly diagnostics?: Writable; + readonly connect?: (socketPath: string) => net.Socket; + readonly signal?: AbortSignal; +} + +function diagnostic(stream: Writable, message: string): void { + stream.write(`pty machine-attach-v2: ${message}\n`); +} + +function writeWithBackpressure(stream: Writable, bytes: Buffer): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + stream.removeListener("error", onError); + stream.removeListener("close", onClose); + stream.removeListener("finish", onFinish); + }; + const succeed = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onError = (error: Error) => fail(error); + const onClose = () => fail(new Error("stream closed before write completed")); + const onFinish = () => fail(new Error("stream finished before write completed")); + if (stream.destroyed || stream.writableEnded) { + fail(new Error("stream is not writable")); + return; + } + stream.once("error", onError); + stream.once("close", onClose); + stream.once("finish", onFinish); + try { + stream.write(bytes, (error) => error ? fail(error) : succeed()); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); +} + +/** + * Bridge one headless machine-attach-v2 stream to the exact daemon selected by + * its OPEN frame. The bridge never resolves aliases, reads metadata, reserves + * terminal bytes, or falls back to interactive attach. + */ +export function machineAttachV2(options: MachineAttachV2Options = {}): Promise { + const input = options.input ?? process.stdin; + const output = options.output ?? process.stdout; + const diagnostics = options.diagnostics ?? process.stderr; + const connect = options.connect ?? ((socketPath: string) => net.createConnection(socketPath)); + const requestReader = new MachineFrameReader(); + const daemonReader = new MachineFrameReader(); + + let socket: net.Socket | null = null; + let phase: "await-open" | "connecting" | "await-admission" | "await-ready" | "streaming" | "detaching" | "finishing" | "finished" = "await-open"; + let attachState: MachineAttachState = { _tag: "AwaitAdmission" }; + let requestedOpen: MachineOpenV2 | null = null; + let outputWrites = Promise.resolve(); + let daemonWrites = Promise.resolve(); + let resolveCompletion: (outcome: MachineOutcome) => void; + let rejectCompletion: (error: Error) => void; + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + const isClosing = (): boolean => phase === "finishing" || phase === "finished"; + + const stopInput = (): void => { + input.pause(); + input.removeListener("data", handleInputData); + input.removeListener("end", handleInputEnd); + input.removeListener("error", handleInputError); + options.signal?.removeEventListener("abort", handleAbort); + }; + + const failBrokenOutput = (error: unknown): void => { + if (phase === "finished") return; + phase = "finished"; + stopInput(); + output.removeListener("error", handleOutputError); + try { socket?.destroy(); } catch {} + const failure = error instanceof Error ? error : new Error(String(error)); + diagnostic(diagnostics, `stdout failed: ${failure.message}`); + rejectCompletion(failure); + }; + const handleOutputError = (error: Error): void => failBrokenOutput(error); + + const enqueueOutput = (response: MachineResponse): void => { + if (phase === "finished") return; + const next = reduceMachineAttach(attachState, { _tag: "Frame", frame: response }); + if (next._tag === "ProtocolViolation") { + finishFailure("stream", "adapter-lifecycle-violation", next.reason); + return; + } + attachState = next; + outputWrites = outputWrites + .then(() => writeWithBackpressure(output, encodeMachineResponse(response))) + .catch((error) => { failBrokenOutput(error); }); + }; + + const finish = (outcome: MachineOutcome, alreadyEmitted = false): void => { + if (isClosing()) return; + if (!alreadyEmitted) enqueueOutput(outcome); + phase = "finishing"; + stopInput(); + try { socket?.destroy(); } catch {} + outputWrites.then( + () => output.end(() => { + if (phase !== "finished") { + const complete = reduceMachineAttach(attachState, { _tag: "Eof" }); + if (complete._tag !== "Complete") { + const reason = complete._tag === "ProtocolViolation" ? complete.reason : complete._tag; + failBrokenOutput(new Error(`adapter emitted invalid EOF: ${reason}`)); + return; + } + attachState = complete; + phase = "finished"; + output.removeListener("error", handleOutputError); + resolveCompletion(outcome); + } + }), + failBrokenOutput, + ); + }; + + const finishFailure = ( + failurePhase: "admission" | StreamFailurePhase, + reason: string, + detail?: string, + ): void => { + if (isClosing()) return; + if (attachState._tag === "AwaitAdmission") { + finish({ + _tag: "AdmissionFailure", + reason: reason === "not-found" + ? "not-found" + : reason === "permission-denied" + ? "permission-denied" + : reason === "malformed-request" + ? "malformed-request" + : reason === "unsupported-daemon" + ? "unsupported-daemon" + : "transport-failure", + ...(detail === undefined ? {} : { detail }), + }); + return; + } + finish({ + _tag: "StreamFailure", + phase: failurePhase === "admission" ? "baseline" : failurePhase, + reason, + ...(detail === undefined ? {} : { diagnostic: detail }), + }); + }; + + const enqueueDaemon = (bytes: Buffer): void => { + const target = socket; + if (!target || target.destroyed) { + finishFailure("stream", "daemon-connection-closed"); + return; + } + daemonWrites = daemonWrites.then(() => writeWithBackpressure(target, bytes)); + const queueTail = daemonWrites; + input.pause(); + queueTail.then( + () => { if (queueTail === daemonWrites && phase === "streaming") input.resume(); }, + (error) => finishFailure("stream", "daemon-write-failed", error instanceof Error ? error.message : String(error)), + ); + }; + + const handleDaemonFrame = (frame: { type: number; payload: Buffer }): void => { + if (isClosing()) return; + if (phase === "await-admission") { + if (frame.type === MessageType.STATUS && frame.payload.length > 0) { + finish({ + _tag: "AdmissionFailure", + reason: "unsupported-daemon", + detail: "The session daemon does not support machine attach v2", + }); + return; + } + if (frame.type !== DaemonExtensionType.ADMISSION_V2) { + finishFailure("admission", "unexpected-daemon-response", `received frame type ${frame.type}`); + return; + } + let admission; + try { admission = decodeDaemonAdmissionV2(frame); } + catch (error) { + finishFailure("admission", "malformed-daemon-admission", (error as Error).message); + return; + } + if (admission._tag === "Rejected") { + finish({ + _tag: "AdmissionFailure", + reason: admission.reason, + ...(admission.detail === undefined ? {} : { detail: admission.detail }), + }); + return; + } + if (!requestedOpen || admission.generation !== requestedOpen.expectedGeneration) { + finish({ + _tag: "AdmissionFailure", + reason: "generation-mismatch", + detail: "Daemon acceptance did not bind the requested generation", + }); + return; + } + const missingCapability = requestedOpen.requiredCapabilities.find( + (capability) => !admission.capabilities.includes(capability), + ); + if (missingCapability) { + finish({ + _tag: "AdmissionFailure", + reason: "unsupported-capability", + detail: `Daemon acceptance omitted ${missingCapability}`, + }); + return; + } + enqueueOutput({ + _tag: "Hello", + protocol: admission.protocol, + generation: admission.generation, + capabilities: admission.capabilities, + build: admission.build, + }); + phase = "await-ready"; + input.resume(); + return; + } + + let response: MachineResponse; + try { response = decodeMachineResponse(frame); } + catch (error) { + finishFailure(phase === "await-ready" ? "baseline" : "stream", "malformed-daemon-frame", (error as Error).message); + return; + } + enqueueOutput(response); + if (response._tag === "Ready") { + phase = "streaming"; + input.resume(); + } else if (response._tag === "Exited" || response._tag === "Detached" || response._tag === "StreamFailure") { + finish(response, true); + } + }; + + const bindDaemon = (target: net.Socket): void => { + target.on("data", (chunk: Buffer) => { + target.pause(); + try { + for (const frame of daemonReader.feed(chunk)) handleDaemonFrame(frame); + } catch (error) { + finishFailure(phase === "await-ready" ? "baseline" : "stream", "malformed-daemon-stream", (error as Error).message); + } + outputWrites.finally(() => { if (!isClosing() && !target.destroyed) target.resume(); }); + }); + target.on("error", (error: NodeJS.ErrnoException) => { + const reason = error.code === "ENOENT" || error.code === "ECONNREFUSED" + ? "not-found" + : error.code === "EACCES" || error.code === "EPERM" + ? "permission-denied" + : "daemon-transport-failed"; + finishFailure(phase === "await-admission" || phase === "connecting" ? "admission" : "stream", reason, error.message); + }); + target.on("close", () => { + if (!isClosing()) finishFailure(phase === "await-ready" ? "baseline" : "stream", "daemon-stream-ended"); + }); + }; + + const handleInputData = (chunk: Buffer | string): void => { + if (isClosing()) return; + let requests; + try { + requests = requestReader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } catch (error) { + finishFailure("admission", "malformed-request", (error as Error).message); + return; + } + for (const wireFrame of requests) { + let request; + try { request = decodeMachineRequest(wireFrame); } + catch (error) { + finishFailure(phase === "await-open" ? "admission" : "stream", "malformed-request", (error as Error).message); + return; + } + if (phase === "await-open") { + if (request._tag !== "Open") { + finish({ _tag: "AdmissionFailure", reason: "malformed-request", detail: "OPEN must be the first frame" }); + return; + } + try { validateName(request.sessionId); } + catch (error) { + finish({ _tag: "AdmissionFailure", reason: "malformed-request", detail: (error as Error).message }); + return; + } + phase = "connecting"; + requestedOpen = request; + input.pause(); + try { + socket = connect(getSocketPath(request.sessionId)); + bindDaemon(socket); + socket.once("connect", () => { + if (isClosing()) return; + phase = "await-admission"; + enqueueDaemon(encodeDaemonOpenV2(request)); + enqueueDaemon(encodeStatus()); + }); + } catch (error) { + finishFailure("admission", "daemon-transport-failed", (error as Error).message); + } + continue; + } + if (phase === "await-ready" && request._tag === "Detach") { + phase = "detaching"; + input.pause(); + enqueueDaemon(encodeMachineRequest(request)); + continue; + } + if (phase !== "streaming") { + if (attachState._tag === "AwaitAdmission") { + finish({ _tag: "AdmissionFailure", reason: "malformed-request", detail: `${request._tag} arrived before HELLO` }); + } else { + finishFailure("baseline", "request-before-ready", request._tag); + } + return; + } + switch (request._tag) { + case "Open": + finishFailure("stream", "duplicate-open"); + return; + case "Input": + enqueueDaemon(encodeMachineRequest(request)); + break; + case "Resize": + enqueueDaemon(encodeMachineRequest(request)); + break; + case "Detach": + phase = "detaching"; + input.pause(); + enqueueDaemon(encodeMachineRequest(request)); + break; + } + } + }; + + const handleInputEnd = (): void => { + if (!isClosing() && phase !== "detaching") { + finishFailure(phase === "await-open" || phase === "await-admission" ? "admission" : "shutdown", "request-stream-ended"); + } + }; + const handleInputError = (error: Error): void => { + if (!isClosing()) finishFailure(phase === "await-open" ? "admission" : "stream", "request-stream-failed", error.message); + }; + const handleAbort = (): void => { + if (!isClosing()) finishFailure(phase === "await-open" || phase === "await-admission" ? "admission" : "shutdown", "cancelled"); + }; + input.on("data", handleInputData); + input.on("end", handleInputEnd); + input.on("error", handleInputError); + output.on("error", handleOutputError); + if (options.signal) { + if (options.signal.aborted) { + handleAbort(); + } else { + options.signal.addEventListener("abort", handleAbort, { once: true }); + } + } + input.resume(); + + return completion; +} diff --git a/tests/help.test.ts b/tests/help.test.ts index 497c047..b3bb9c3 100644 --- a/tests/help.test.ts +++ b/tests/help.test.ts @@ -11,7 +11,7 @@ const cliSource = fs.readFileSync(path.join(__dirname, "..", "src", "cli.ts"), " // Canonical subcommands that must each ship focused `--help`. const COMMANDS = [ - "run", "attach", "exec", "peek", "send", "events", "list", "stats", + "run", "attach", "machine-attach-v2", "exec", "peek", "send", "events", "list", "stats", "restart", "kill", "recover", "rm", "gc", "tag", "tag-multi", "emit", "rename", "metadata", "up", "down", "test", "remote-serve", ]; diff --git a/tests/machine-attach-v2.test.ts b/tests/machine-attach-v2.test.ts new file mode 100644 index 0000000..87c51f0 --- /dev/null +++ b/tests/machine-attach-v2.test.ts @@ -0,0 +1,472 @@ +import * as net from "node:net"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; +import { PassThrough, Writable } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; +import { machineAttachV2 } from "../src/machine-attach.ts"; +import { + MACHINE_PROTOCOL_VERSION, + DaemonExtensionType, + MachineFrameReader, + decodeDaemonOpenV2, + decodeMachineRequest, + decodeMachineResponse, + encodeDaemonAdmissionV2, + encodeMachineRequest, + encodeMachineResponse, + type MachineInputModeSnapshotV1, + type MachineOpenV2, + type MachineResponse, +} from "../src/machine-protocol.ts"; +import { + MessageType, + encodeStatusResponse, +} from "../src/protocol.ts"; + +const modes: MachineInputModeSnapshotV1 = { + revision: 1, + applicationCursorKeys: false, + applicationKeypad: false, + bracketedPaste: true, + focusReporting: false, + modifyOtherKeys: 0, + mouseTracking: "none", + mouseEncoding: "sgr", + kittyKeyboardFlags: [], +}; + +const open: MachineOpenV2 = { + _tag: "Open", + protocol: MACHINE_PROTOCOL_VERSION, + sessionId: "test-session", + expectedGeneration: "generation-a", + rows: 24, + cols: 80, + requiredCapabilities: ["framed-utf8-input", "typed-outcome", "input-mode-snapshot"], +}; + +const servers: net.Server[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +async function listen(handler: (socket: net.Socket) => void): Promise<(ignored: string) => net.Socket> { + const server = net.createServer(handler); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test address"); + return () => net.createConnection(address.port, "127.0.0.1"); +} + +function harness(connect: (socketPath: string) => net.Socket, signal?: AbortSignal) { + const input = new PassThrough(); + const output = new PassThrough(); + const diagnostics = new PassThrough(); + const responses: MachineResponse[] = []; + const reader = new MachineFrameReader(); + output.on("data", (chunk: Buffer) => { + for (const frame of reader.feed(chunk)) responses.push(decodeMachineResponse(frame)); + }); + const completed = machineAttachV2({ input, output, diagnostics, connect, ...(signal ? { signal } : {}) }); + return { input, output, diagnostics, responses, completed }; +} + +async function waitForResponse( + responses: MachineResponse[], + tag: MachineResponse["_tag"], +): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + const found = responses.find((response) => response._tag === tag); + if (found) return found; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Timed out waiting for ${tag}`); +} + +describe("machine-attach-v2 adapter", () => { + it("classifies an old daemon without sending ATTACH, RESIZE, or DATA", async () => { + const received: number[] = []; + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + received.push(packet.type); + if (packet.type === MessageType.STATUS) socket.write(encodeStatusResponse("{}")); + } + }); + }); + const run = harness(connect); + run.input.write(encodeMachineRequest(open)); + + await expect(run.completed).resolves.toMatchObject({ + _tag: "AdmissionFailure", + reason: "unsupported-daemon", + }); + expect(received).toEqual([8, MessageType.STATUS]); + expect(run.responses).toHaveLength(1); + expect(run.responses[0]).toMatchObject({ _tag: "AdmissionFailure", reason: "unsupported-daemon" }); + }); + + it("proxies valid UTF-8 and control bytes exactly and detaches only on the explicit frame", async () => { + const receivedInput: Buffer[] = []; + const receivedSizes: Array<{ rows: number; cols: number }> = []; + let openSeen = false; + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + if (packet.type === DaemonExtensionType.OPEN_V2) { + expect(decodeDaemonOpenV2(packet)).toEqual(open); + openSeen = true; + } else if (packet.type === MessageType.STATUS) { + expect(openSeen).toBe(true); + socket.write(Buffer.concat([ + encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: open.expectedGeneration, + capabilities: open.requiredCapabilities, + build: { version: "test", dirty: false }, + }), + encodeMachineResponse({ + _tag: "Ready", + rows: open.rows, + cols: open.cols, + inputModes: modes, + screen: Buffer.from("$ "), + }), + ])); + } else { + const request = decodeMachineRequest(packet); + if (request._tag === "Input") receivedInput.push(Buffer.from(request.bytes)); + if (request._tag === "Resize") receivedSizes.push({ rows: request.rows, cols: request.cols }); + if (request._tag === "Detach") socket.write(encodeMachineResponse({ _tag: "Detached" })); + } + } + }); + }); + const run = harness(connect); + run.input.write(encodeMachineRequest(open)); + await waitForResponse(run.responses, "Ready"); + + const bytes = Buffer.from([0x31, 0x32, 0x33, 0x1b, 0x62, 0x1c]); + run.input.write(encodeMachineRequest({ _tag: "Input", bytes })); + run.input.write(encodeMachineRequest({ _tag: "Resize", rows: 30, cols: 100 })); + run.input.write(encodeMachineRequest({ _tag: "Detach" })); + + await expect(run.completed).resolves.toEqual({ _tag: "Detached" }); + expect(openSeen).toBe(true); + expect(Buffer.concat(receivedInput)).toEqual(bytes); + expect(receivedSizes).toEqual([{ rows: 30, cols: 100 }]); + expect(run.responses.map((response) => response._tag)).toEqual(["Hello", "Ready", "Detached"]); + }); + + it("turns a same-connection generation rejection into one typed outcome", async () => { + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + if (packet.type === MessageType.STATUS) { + socket.write(encodeDaemonAdmissionV2({ + _tag: "Rejected", + reason: "generation-mismatch", + detail: "the session was replaced", + })); + } + } + }); + }); + const run = harness(connect); + run.input.write(encodeMachineRequest(open)); + + await expect(run.completed).resolves.toMatchObject({ + _tag: "AdmissionFailure", + reason: "generation-mismatch", + }); + expect(run.responses).toHaveLength(1); + }); + + it.each([ + { + name: "a falsely accepted generation", + generation: "generation-b", + capabilities: open.requiredCapabilities, + reason: "generation-mismatch", + }, + { + name: "an acceptance missing a required capability", + generation: open.expectedGeneration, + capabilities: ["framed-utf8-input", "typed-outcome"] as const, + reason: "unsupported-capability", + }, + ])("rejects $name before HELLO", async ({ generation, capabilities, reason }) => { + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + if (packet.type === MessageType.STATUS) { + socket.write(encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation, + capabilities, + build: { version: "test", dirty: false }, + })); + } + } + }); + }); + const run = harness(connect); + run.input.write(encodeMachineRequest(open)); + + await expect(run.completed).resolves.toMatchObject({ _tag: "AdmissionFailure", reason }); + expect(run.responses).toHaveLength(1); + expect(run.responses[0]._tag).toBe("AdmissionFailure"); + }); + + it("accepts an explicit detach after HELLO while the baseline is pending", async () => { + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + if (packet.type === DaemonExtensionType.OPEN_V2) { + expect(decodeDaemonOpenV2(packet)).toEqual(open); + } else if (packet.type === MessageType.STATUS) { + socket.write(encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: open.expectedGeneration, + capabilities: open.requiredCapabilities, + build: { version: "test", dirty: false }, + })); + } else { + const request = decodeMachineRequest(packet); + if (request._tag === "Detach") socket.write(encodeMachineResponse({ _tag: "Detached" })); + } + } + }); + }); + const run = harness(connect); + run.input.write(encodeMachineRequest(open)); + await waitForResponse(run.responses, "Hello"); + run.input.write(encodeMachineRequest({ _tag: "Detach" })); + + await expect(run.completed).resolves.toEqual({ _tag: "Detached" }); + expect(run.responses.map((response) => response._tag)).toEqual(["Hello", "Detached"]); + }); + + it("rejects a nonempty DETACHED frame after accepted admission", async () => { + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + if (packet.type === MessageType.STATUS) { + const malformedDetached = Buffer.alloc(6); + malformedDetached.writeUInt8(7, 0); + malformedDetached.writeUInt32BE(1, 1); + malformedDetached.writeUInt8(1, 5); + socket.write(Buffer.concat([ + encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: open.expectedGeneration, + capabilities: open.requiredCapabilities, + build: { version: "test", dirty: false }, + }), + malformedDetached, + ])); + } + } + }); + }); + const run = harness(connect); + run.input.write(encodeMachineRequest(open)); + + await expect(run.completed).resolves.toMatchObject({ + _tag: "StreamFailure", + phase: "baseline", + reason: "malformed-daemon-frame", + }); + expect(run.responses.map((response) => response._tag)).toEqual(["Hello", "StreamFailure"]); + }); + + it("treats stdin EOF after explicit DETACH as a half-close", async () => { + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + if (packet.type === DaemonExtensionType.OPEN_V2) { + expect(decodeDaemonOpenV2(packet)).toEqual(open); + } else if (packet.type === MessageType.STATUS) { + socket.write(Buffer.concat([ + encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: open.expectedGeneration, + capabilities: open.requiredCapabilities, + build: { version: "test", dirty: false }, + }), + encodeMachineResponse({ + _tag: "Ready", + rows: open.rows, + cols: open.cols, + inputModes: modes, + screen: Buffer.alloc(0), + }), + ])); + } else { + const request = decodeMachineRequest(packet); + if (request._tag === "Detach") { + setImmediate(() => socket.write(encodeMachineResponse({ _tag: "Detached" }))); + } + } + } + }); + }); + const run = harness(connect); + run.input.write(encodeMachineRequest(open)); + await waitForResponse(run.responses, "Ready"); + run.input.end(encodeMachineRequest({ _tag: "Detach" })); + + await expect(run.completed).resolves.toEqual({ _tag: "Detached" }); + expect(run.responses.at(-1)).toEqual({ _tag: "Detached" }); + }); + + it("rejects coalesced OPEN plus INPUT before daemon admission", async () => { + const receivedInput: Buffer[] = []; + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + if (packet.type !== DaemonExtensionType.OPEN_V2 && packet.type !== MessageType.STATUS) { + const request = decodeMachineRequest(packet); + if (request._tag === "Input") receivedInput.push(request.bytes); + } + } + }); + }); + const run = harness(connect); + run.input.write(Buffer.concat([ + encodeMachineRequest(open), + encodeMachineRequest({ _tag: "Input", bytes: Buffer.from("early") }), + ])); + + await expect(run.completed).resolves.toMatchObject({ + _tag: "AdmissionFailure", + reason: "malformed-request", + }); + expect(receivedInput).toEqual([]); + expect(run.responses).toHaveLength(1); + }); + + it("reports connection refusal as an admission transport failure", async () => { + const probe = net.createServer(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); + const address = probe.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test address"); + const port = address.port; + await new Promise((resolve) => probe.close(() => resolve())); + + const run = harness(() => net.createConnection(port, "127.0.0.1")); + run.input.write(encodeMachineRequest(open)); + await expect(run.completed).resolves.toMatchObject({ + _tag: "AdmissionFailure", + reason: "not-found", + }); + expect(run.responses).toHaveLength(1); + }); + + it("turns explicit cancellation into a terminal framed result", async () => { + const controller = new AbortController(); + const run = harness(() => { throw new Error("must not connect"); }, controller.signal); + controller.abort(); + + await expect(run.completed).resolves.toMatchObject({ + _tag: "AdmissionFailure", + reason: "transport-failure", + }); + expect(run.responses).toHaveLength(1); + }); + + it("rejects promptly when stdout cannot carry the canonical result", async () => { + const input = new PassThrough(); + const diagnostics = new PassThrough(); + let diagnosticText = ""; + diagnostics.on("data", (chunk) => { diagnosticText += chunk.toString(); }); + const output = new Writable({ + write(_chunk, _encoding, callback) { + callback(new Error("closed consumer")); + }, + }); + const completed = machineAttachV2({ + input, + output, + diagnostics, + connect: () => { throw new Error("must not connect"); }, + }); + input.write(encodeMachineRequest({ _tag: "Detach" })); + + await expect(completed).rejects.toThrow("closed consumer"); + expect(diagnosticText).toContain("stdout failed: closed consumer"); + }); + + it("does not wait forever when stdout closes before a pending write completes", async () => { + const input = new PassThrough(); + const diagnostics = new PassThrough(); + const output = new Writable({ write() {} }); + const completed = machineAttachV2({ + input, + output, + diagnostics, + connect: () => { throw new Error("must not connect"); }, + }); + input.write(encodeMachineRequest({ _tag: "Detach" })); + setImmediate(() => output.destroy()); + + await expect(completed).rejects.toThrow("stream closed before write completed"); + }); + + it("uses exit zero when the subprocess delivered a typed admission outcome", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-machine-adapter-")); + const socketPath = path.join(root, `${open.sessionId}.sock`); + const server = net.createServer((socket) => { + const reader = new MachineFrameReader(); + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + if (packet.type === MessageType.STATUS) socket.write(encodeStatusResponse("{}")); + } + }); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + const child = spawn(process.execPath, [path.join(process.cwd(), "dist", "cli.js"), "machine-attach-v2"], { + env: { ...process.env, PTY_ROOT: root }, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk))); + child.stdin.write(encodeMachineRequest(open)); + const [code] = await new Promise<[number | null]>((resolve) => child.once("exit", (value) => resolve([value]))); + + const reader = new MachineFrameReader(); + const responses = reader.feed(Buffer.concat(stdout)).map(decodeMachineResponse); + expect(code).toBe(0); + expect(responses).toHaveLength(1); + expect(responses[0]).toMatchObject({ _tag: "AdmissionFailure", reason: "unsupported-daemon" }); + expect(Buffer.concat(stderr).toString()).toBe(""); + fs.rmSync(root, { recursive: true, force: true }); + }); +}); From fd6100920ddb7ba7f3280be455e3bea1ec9fc3da Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:08:11 +0200 Subject: [PATCH 03/10] feat: admit machine clients atomically --- docs/vrs/spec.md | 10 +- src/machine-protocol.ts | 19 +- src/server.ts | 807 ++++++++++++++++++++++++++------ tests/integration.test.ts | 6 +- tests/machine-protocol.test.ts | 22 + tests/machine-server-v2.test.ts | 310 ++++++++++++ 6 files changed, 1006 insertions(+), 168 deletions(-) create mode 100644 tests/machine-server-v2.test.ts diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 570fe18..18ad273 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -180,10 +180,12 @@ The direction-specific frame schemas are: | adapter -> host | `EXITED` / `DETACHED` / `STREAM_FAILURE` | Exactly one terminal outcome, then EOF | The complete child-input mode snapshot contains application cursor keys, -application keypad, bracketed paste, focus reporting, mouse tracking, mouse -encoding, and the Kitty keyboard flag stack. It is state, not an inference from -screen bytes. A mode change is ordered with `DATA` and sent as a complete -`INPUT_MODES` replacement with an advancing revision. +application keypad, bracketed paste, focus reporting, modifyOtherKeys level, +mouse tracking (including DECSET 9), mouse encoding (including pixel SGR), and +the Kitty keyboard active-flags stack. Kitty set/add/remove and push/pop-n +operations update that one stack. The snapshot is state, not an inference from +screen bytes. A mode change is ordered after the `DATA` that established it and +sent as a complete `INPUT_MODES` replacement with an advancing revision. The adapter sends daemon packet `OPEN_V2` (reserved type 8) followed by a `STATUS` request on the same command-role socket. A v2 daemon answers first with diff --git a/src/machine-protocol.ts b/src/machine-protocol.ts index efb1cfb..ffe8d91 100644 --- a/src/machine-protocol.ts +++ b/src/machine-protocol.ts @@ -53,8 +53,8 @@ export type MachineRequest = | { readonly _tag: "Resize"; readonly rows: number; readonly cols: number } | { readonly _tag: "Detach" }; -export type MouseTrackingMode = "none" | "click" | "button" | "any"; -export type MouseEncodingMode = "x10" | "utf8" | "sgr" | "urxvt"; +export type MouseTrackingMode = "none" | "x10" | "click" | "button" | "any"; +export type MouseEncodingMode = "x10" | "utf8" | "sgr" | "urxvt" | "sgr-pixels"; /** Complete child-input state at one ordered terminal-stream revision. */ export interface MachineInputModeSnapshotV1 { @@ -63,6 +63,7 @@ export interface MachineInputModeSnapshotV1 { readonly applicationKeypad: boolean; readonly bracketedPaste: boolean; readonly focusReporting: boolean; + readonly modifyOtherKeys: 0 | 1 | 2; readonly mouseTracking: MouseTrackingMode; readonly mouseEncoding: MouseEncodingMode; readonly kittyKeyboardFlags: readonly number[]; @@ -278,6 +279,7 @@ function inputModes(value: unknown): MachineInputModeSnapshotV1 { "applicationKeypad", "bracketedPaste", "focusReporting", + "modifyOtherKeys", "mouseTracking", "mouseEncoding", "kittyKeyboardFlags", @@ -286,9 +288,9 @@ function inputModes(value: unknown): MachineInputModeSnapshotV1 { ); const tracking = string(object.mouseTracking, "mouseTracking") as MouseTrackingMode; const encoding = string(object.mouseEncoding, "mouseEncoding") as MouseEncodingMode; - if (!["none", "click", "button", "any"].includes(tracking)) + if (!["none", "x10", "click", "button", "any"].includes(tracking)) throw new MachineProtocolError("unknown mouse tracking mode"); - if (!["x10", "utf8", "sgr", "urxvt"].includes(encoding)) + if (!["x10", "utf8", "sgr", "urxvt", "sgr-pixels"].includes(encoding)) throw new MachineProtocolError("unknown mouse encoding mode"); if (!Array.isArray(object.kittyKeyboardFlags) || object.kittyKeyboardFlags.length > MAX_KITTY_STACK_DEPTH) { throw new MachineProtocolError("kittyKeyboardFlags must be a bounded array"); @@ -299,6 +301,7 @@ function inputModes(value: unknown): MachineInputModeSnapshotV1 { applicationKeypad: boolean(object.applicationKeypad, "applicationKeypad"), bracketedPaste: boolean(object.bracketedPaste, "bracketedPaste"), focusReporting: boolean(object.focusReporting, "focusReporting"), + modifyOtherKeys: integer(object.modifyOtherKeys, "modifyOtherKeys", 0, 2) as 0 | 1 | 2, mouseTracking: tracking, mouseEncoding: encoding, kittyKeyboardFlags: object.kittyKeyboardFlags.map((flag) => integer(flag, "kitty keyboard flag", 0, 0xffffffff)), @@ -554,11 +557,9 @@ export function decodeDaemonAdmissionV2(frame: MachineWireFrame): DaemonAdmissio export type MachineAttachState = | { readonly _tag: "AwaitAdmission" } - | { readonly _tag: "AwaitReady"; readonly hello: MachineHelloV2 } + | { readonly _tag: "AwaitReady" } | { readonly _tag: "Streaming"; - readonly hello: MachineHelloV2; - readonly ready: MachineReadyV2; readonly inputModeRevision: number; } | { readonly _tag: "AwaitEof"; readonly outcome: MachineOutcome } @@ -584,12 +585,12 @@ export function reduceMachineAttach(state: MachineAttachState, event: MachineAtt const frame = event.frame; switch (state._tag) { case "AwaitAdmission": - if (frame._tag === "Hello") return { _tag: "AwaitReady", hello: frame }; + if (frame._tag === "Hello") return { _tag: "AwaitReady" }; if (frame._tag === "AdmissionFailure") return { _tag: "AwaitEof", outcome: frame }; return violation(`Expected HELLO or ADMISSION_FAILURE, received ${frame._tag}`); case "AwaitReady": if (frame._tag === "Ready") { - return { _tag: "Streaming", hello: state.hello, ready: frame, inputModeRevision: frame.inputModes.revision }; + return { _tag: "Streaming", inputModeRevision: frame.inputModes.revision }; } if (frame._tag === "Exited" || frame._tag === "Detached" || frame._tag === "StreamFailure") { return { _tag: "AwaitEof", outcome: frame }; diff --git a/src/server.ts b/src/server.ts index 9449ae3..eea3baf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; +import { isUtf8 } from "node:buffer"; import * as pty from "node-pty"; // @xterm/headless is CJS-only, so keep its default import. The serialize addon // ships native ESM with named exports, so import its runtime namespace. @@ -20,6 +21,20 @@ import { encodeGeometry, decodeSize, } from "./protocol.ts"; +import { + DaemonExtensionType, + MACHINE_PROTOCOL_VERSION, + decodeDaemonOpenV2, + decodeMachineRequest, + encodeDaemonAdmissionV2, + encodeMachineResponse, + type MachineCapability, + type MachineInputModeSnapshotV1, + type MachineResponse, + type MouseEncodingMode, + type MouseTrackingMode, +} from "./machine-protocol.ts"; +import { readPackageVersion } from "./version.ts"; import { getSocketPath, getPidPath, @@ -68,20 +83,73 @@ import type { StatsResult } from "./client.ts"; interface Client { socket: net.Socket; reader: PacketReader; - rows: number; - cols: number; - readonly: boolean; - attachSeq: number; - /** DATA/EXIT must not overtake the SCREEN baseline for ATTACH or PEEK. */ - initialScreenPhase: "live" | "settling" | "cutting"; + role: + | { readonly _tag: "Command" } + | { readonly _tag: "Rejected" } + | { + readonly _tag: "Legacy"; + readonly phase: + | { readonly _tag: "Settling" } + | { + readonly _tag: "Cutting"; + readonly packets: Array<{ readonly isExit: boolean; readonly packet: Buffer }>; + } + | { readonly _tag: "Live" }; + readonly rows: number; + readonly cols: number; + readonly attachSeq: number; + } + | { + readonly _tag: "Readonly"; + readonly phase: + | { readonly _tag: "Settling" } + | { + readonly _tag: "Cutting"; + readonly packets: Array<{ readonly isExit: boolean; readonly packet: Buffer }>; + } + | { readonly _tag: "Live" }; + } + | { + readonly _tag: "MachineAwaitingSentinel"; + readonly rows: number; + readonly cols: number; + } + | { + readonly _tag: "Machine"; + readonly phase: "syncing" | "live" | "ended"; + readonly rows: number; + readonly cols: number; + readonly attachSeq: number; + }; /** Invalidates a delayed SCREEN when the same socket changes attach mode. */ initialScreenGeneration: number; - postCutPackets: Array<{ - type: typeof MessageType.DATA | typeof MessageType.EXIT; - packet: Buffer; - }>; } +interface TerminalInputModeState { + readonly revision: number; + readonly applicationCursorKeys: boolean; + readonly applicationKeypad: boolean; + readonly bracketedPaste: boolean; + readonly focusReporting: boolean; + readonly modifyOtherKeys: 0 | 1 | 2; + readonly mouseTracking9: boolean; + readonly mouseTracking1000: boolean; + readonly mouseTracking1002: boolean; + readonly mouseTracking1003: boolean; + readonly mouseEncoding1005: boolean; + readonly mouseEncoding1006: boolean; + readonly mouseEncoding1015: boolean; + readonly mouseEncoding1016: boolean; + readonly kittyKeyboardFlags: readonly number[]; +} + +const MACHINE_CAPABILITIES = [ + "framed-utf8-input", + "typed-outcome", + "input-mode-snapshot", +] as const satisfies readonly MachineCapability[]; +const MAX_KITTY_KEYBOARD_STACK_DEPTH = 64; + export interface ServerOptions { name: string; generation?: string; @@ -280,9 +348,25 @@ export class PtyServer { private name: string; private options: ServerOptions; private attachCounter = 0; - private sgrMouseMode = false; private cursorHidden = false; - private kittyKeyboardStack: number[] = []; + private inputModeState: TerminalInputModeState = { + revision: 0, + applicationCursorKeys: false, + applicationKeypad: false, + bracketedPaste: false, + focusReporting: false, + modifyOtherKeys: 0, + mouseTracking9: false, + mouseTracking1000: false, + mouseTracking1002: false, + mouseTracking1003: false, + mouseEncoding1005: false, + mouseEncoding1006: false, + mouseEncoding1015: false, + mouseEncoding1016: false, + kittyKeyboardFlags: [], + }; + private lastBroadcastInputModeRevision = 0; // Alt-screen buffer state (DEC private modes ?1049 / ?1047 / ?47). Set when // the child process enters the alternate screen buffer; cleared when it // leaves. Replayed to attaching clients so the SCREEN snapshot lands in the @@ -296,9 +380,6 @@ export class PtyServer { // reports, not whether tracking is active. Clients attaching to a session // that's already mid-stream need all active modes replayed so their own // mouse forwarding logic sees the correct state. - private mouseTracking1000 = false; // button press/release tracking - private mouseTracking1002 = false; // button-motion tracking - private mouseTracking1003 = false; // any-motion tracking private lastResizeTime = 0; private eventWriter: EventWriter; private generation: string; @@ -339,10 +420,17 @@ export class PtyServer { (params) => { for (const p of params) { const v = typeof p === "number" ? p : p[0]; - if (v === 1006) this.sgrMouseMode = true; - if (v === 1000) this.mouseTracking1000 = true; - if (v === 1002) this.mouseTracking1002 = true; - if (v === 1003) this.mouseTracking1003 = true; + if (v === 1) this.updateInputModes({ applicationCursorKeys: true }); + if (v === 9) this.updateInputModes({ mouseTracking9: true }); + if (v === 1000) this.updateInputModes({ mouseTracking1000: true }); + if (v === 1002) this.updateInputModes({ mouseTracking1002: true }); + if (v === 1003) this.updateInputModes({ mouseTracking1003: true }); + if (v === 1004) this.updateInputModes({ focusReporting: true }); + if (v === 1005) this.updateInputModes({ mouseEncoding1005: true }); + if (v === 1006) this.updateInputModes({ mouseEncoding1006: true }); + if (v === 1015) this.updateInputModes({ mouseEncoding1015: true }); + if (v === 1016) this.updateInputModes({ mouseEncoding1016: true }); + if (v === 2004) this.updateInputModes({ bracketedPaste: true }); if (v === 1049 || v === 1047 || v === 47) this.altScreenActive = true; if (v === 25) { if (this.cursorHidden) this.emitEvent(EventType.CURSOR_VISIBLE); @@ -358,10 +446,17 @@ export class PtyServer { (params) => { for (const p of params) { const v = typeof p === "number" ? p : p[0]; - if (v === 1006) this.sgrMouseMode = false; - if (v === 1000) this.mouseTracking1000 = false; - if (v === 1002) this.mouseTracking1002 = false; - if (v === 1003) this.mouseTracking1003 = false; + if (v === 1) this.updateInputModes({ applicationCursorKeys: false }); + if (v === 9) this.updateInputModes({ mouseTracking9: false }); + if (v === 1000) this.updateInputModes({ mouseTracking1000: false }); + if (v === 1002) this.updateInputModes({ mouseTracking1002: false }); + if (v === 1003) this.updateInputModes({ mouseTracking1003: false }); + if (v === 1004) this.updateInputModes({ focusReporting: false }); + if (v === 1005) this.updateInputModes({ mouseEncoding1005: false }); + if (v === 1006) this.updateInputModes({ mouseEncoding1006: false }); + if (v === 1015) this.updateInputModes({ mouseEncoding1015: false }); + if (v === 1016) this.updateInputModes({ mouseEncoding1016: false }); + if (v === 2004) this.updateInputModes({ bracketedPaste: false }); if (v === 1049 || v === 1047 || v === 47) this.altScreenActive = false; if (v === 25) this.cursorHidden = true; } @@ -371,18 +466,98 @@ export class PtyServer { this.terminal.parser.registerCsiHandler( { prefix: ">", final: "u" }, (params) => { - const flags = typeof params[0] === "number" ? params[0] : params[0][0]; - this.kittyKeyboardStack.push(flags); + const first = params[0]; + const flags = first === undefined + ? 0 + : typeof first === "number" + ? first + : first[0]; + if ( + this.inputModeState.kittyKeyboardFlags.length >= + MAX_KITTY_KEYBOARD_STACK_DEPTH + ) { + this.failMachineInputModes("kitty-keyboard-stack-overflow"); + return false; + } + this.updateInputModes({ + kittyKeyboardFlags: [...this.inputModeState.kittyKeyboardFlags, flags], + }); + return false; + } + ); + this.terminal.parser.registerCsiHandler( + { prefix: "=", final: "u" }, + (params) => { + const first = params[0]; + const flags = first === undefined + ? 0 + : typeof first === "number" + ? first + : first[0]; + const second = params[1]; + const mode = second === undefined + ? 1 + : typeof second === "number" + ? second + : second[0]; + const stack = [...this.inputModeState.kittyKeyboardFlags]; + const current = stack.at(-1) ?? 0; + const updated = mode === 2 + ? current | flags + : mode === 3 + ? current & ~flags + : flags; + if (stack.length === 0) stack.push(updated); + else stack[stack.length - 1] = updated; + this.updateInputModes({ kittyKeyboardFlags: stack }); + return false; + } + ); + this.terminal.parser.registerCsiHandler( + { prefix: ">", final: "m" }, + (params) => { + const first = params[0]; + const command = first === undefined + ? 0 + : typeof first === "number" + ? first + : first[0]; + if (command !== 4) return false; + const second = params[1]; + const requested = second === undefined + ? 0 + : typeof second === "number" + ? second + : second[0]; + const level: 0 | 1 | 2 = requested === 1 ? 1 : requested === 2 ? 2 : 0; + this.updateInputModes({ modifyOtherKeys: level }); return false; } ); this.terminal.parser.registerCsiHandler( { prefix: "<", final: "u" }, - () => { - this.kittyKeyboardStack.pop(); + (params) => { + const first = params[0]; + const requested = first === undefined + ? 1 + : typeof first === "number" + ? first + : first[0]; + const count = Math.max(1, Math.min(requested, MAX_KITTY_KEYBOARD_STACK_DEPTH)); + this.updateInputModes({ + kittyKeyboardFlags: this.inputModeState.kittyKeyboardFlags.slice(0, -count), + }); return false; } ); + this.terminal.parser.registerEscHandler({ final: "=" }, () => { + this.updateInputModes({ applicationKeypad: true }); + return false; + }); + this.terminal.parser.registerEscHandler({ final: ">" }, () => { + this.updateInputModes({ applicationKeypad: false }); + return false; + }); // Respond to DA1 (Primary Device Attribute) queries from the child process. // Shells like fish 4.x send ESC[c at startup and block for up to 10s waiting @@ -555,11 +730,26 @@ export class PtyServer { // handlers above and must NOT be forwarded to clients — otherwise the // client's terminal responds and its response appears as garbage input. this.ptyProcess.onData((data: string) => { - this.terminal.write(data); const cleaned = stripTerminalQueries(data); + const inputModeRevision = this.inputModeState.revision; if (cleaned.length > 0) { - this.broadcast(MessageType.DATA, encodeData(cleaned)); + this.broadcastLegacy(MessageType.DATA, encodeData(cleaned)); } + this.terminal.write(data, () => { + if (cleaned.length > 0) { + this.broadcastMachine({ _tag: "Data", bytes: Buffer.from(cleaned) }); + } + if ( + this.inputModeState.revision > inputModeRevision && + this.inputModeState.revision > this.lastBroadcastInputModeRevision + ) { + this.broadcastMachine({ + _tag: "InputModes", + inputModes: this.inputModeSnapshot(), + }); + this.lastBroadcastInputModeRevision = this.inputModeState.revision; + } + }); }); this.ptyProcess.onExit(({ exitCode, signal }) => { @@ -571,7 +761,14 @@ export class PtyServer { // Surface it the way a shell does: 128 + signal (SIGKILL 9 → 137). const code = signal ? 128 + signal : exitCode; this.exitCode = code; - this.broadcast(MessageType.EXIT, encodeExit(code)); + this.broadcastLegacy(MessageType.EXIT, encodeExit(code)); + this.terminal.write("", () => { + this.broadcastMachine({ + _tag: "Exited", + code, + signal: signal ? String(signal) : null, + }); + }); this.emitEvent(EventType.SESSION_EXIT, { exitCode: code, ...(signal ? { signal } : {}), @@ -895,17 +1092,291 @@ export class PtyServer { } } + private inputModeSnapshot(): MachineInputModeSnapshotV1 { + const state = this.inputModeState; + const mouseTracking: MouseTrackingMode = state.mouseTracking1003 + ? "any" + : state.mouseTracking1002 + ? "button" + : state.mouseTracking1000 + ? "click" + : state.mouseTracking9 + ? "x10" + : "none"; + const mouseEncoding: MouseEncodingMode = state.mouseEncoding1016 + ? "sgr-pixels" + : state.mouseEncoding1006 + ? "sgr" + : state.mouseEncoding1015 + ? "urxvt" + : state.mouseEncoding1005 + ? "utf8" + : "x10"; + return { + revision: state.revision, + applicationCursorKeys: state.applicationCursorKeys, + applicationKeypad: state.applicationKeypad, + bracketedPaste: state.bracketedPaste, + focusReporting: state.focusReporting, + modifyOtherKeys: state.modifyOtherKeys, + mouseTracking, + mouseEncoding, + kittyKeyboardFlags: [...state.kittyKeyboardFlags], + }; + } + + private updateInputModes( + patch: Partial> + ): void { + const current = this.inputModeState; + const next = { ...current, ...patch }; + const unchanged = + current.applicationCursorKeys === next.applicationCursorKeys && + current.applicationKeypad === next.applicationKeypad && + current.bracketedPaste === next.bracketedPaste && + current.focusReporting === next.focusReporting && + current.modifyOtherKeys === next.modifyOtherKeys && + current.mouseTracking9 === next.mouseTracking9 && + current.mouseTracking1000 === next.mouseTracking1000 && + current.mouseTracking1002 === next.mouseTracking1002 && + current.mouseTracking1003 === next.mouseTracking1003 && + current.mouseEncoding1005 === next.mouseEncoding1005 && + current.mouseEncoding1006 === next.mouseEncoding1006 && + current.mouseEncoding1015 === next.mouseEncoding1015 && + current.mouseEncoding1016 === next.mouseEncoding1016 && + current.kittyKeyboardFlags.length === next.kittyKeyboardFlags.length && + current.kittyKeyboardFlags.every( + (flags, index) => flags === next.kittyKeyboardFlags[index] + ); + if (unchanged) return; + this.inputModeState = { ...next, revision: current.revision + 1 }; + } + + private failMachineInputModes(reason: string): void { + for (const client of [...this.clients.values()]) { + if (client.role._tag !== "Machine" || client.role.phase === "ended") continue; + this.endMachineStream(client, { + _tag: "StreamFailure", + phase: client.role.phase === "syncing" ? "baseline" : "stream", + reason, + }); + } + } + + private rejectMachineOpen( + client: Client, + reason: "generation-mismatch" | "not-found" | "unsupported-capability" | "malformed-request", + detail?: string + ): void { + client.role = { _tag: "Rejected" }; + client.socket.end( + encodeDaemonAdmissionV2({ + _tag: "Rejected", + reason, + ...(detail === undefined ? {} : { detail }), + }) + ); + } + + private handleMachineOpen( + client: Client, + frame: { readonly type: number; readonly payload: Buffer } + ): void { + if (client.role._tag !== "Command") { + this.rejectMachineOpen(client, "malformed-request", "OPEN_V2 requires a fresh command connection"); + return; + } + let open; + try { + open = decodeDaemonOpenV2(frame); + } catch (error) { + this.rejectMachineOpen( + client, + "malformed-request", + error instanceof Error ? error.message : "invalid OPEN_V2" + ); + return; + } + if (open.sessionId !== this.name) { + this.rejectMachineOpen(client, "not-found", "session id does not match socket"); + return; + } + if (open.expectedGeneration !== this.generation) { + this.rejectMachineOpen(client, "generation-mismatch", "session generation changed"); + return; + } + const unsupported = open.requiredCapabilities.find( + (capability) => !MACHINE_CAPABILITIES.includes(capability) + ); + if (unsupported !== undefined) { + this.rejectMachineOpen( + client, + "unsupported-capability", + `unsupported capability: ${unsupported}` + ); + return; + } + + client.role = { + _tag: "MachineAwaitingSentinel", + rows: open.rows, + cols: open.cols, + }; + } + + private handleMachineRequest( + client: Client, + frame: { readonly type: number; readonly payload: Buffer } + ): void { + let request; + try { + request = decodeMachineRequest(frame); + } catch (error) { + this.endMachineStream(client, { + _tag: "StreamFailure", + phase: + client.role._tag === "Machine" && client.role.phase === "syncing" + ? "baseline" + : "stream", + reason: "malformed-request", + diagnostic: error instanceof Error ? error.message : "invalid request", + }); + return; + } + if ( + client.role._tag === "Machine" && + client.role.phase === "syncing" && + request._tag === "Detach" + ) { + this.endMachineStream(client, { _tag: "Detached" }); + return; + } + if (client.role._tag !== "Machine" || client.role.phase !== "live") { + this.endMachineStream(client, { + _tag: "StreamFailure", + phase: "baseline", + reason: "request-before-ready", + }); + return; + } + switch (request._tag) { + case "Open": + this.endMachineStream(client, { + _tag: "StreamFailure", + phase: "stream", + reason: "duplicate-open", + }); + break; + case "Input": + if (!isUtf8(request.bytes)) { + this.endMachineStream(client, { + _tag: "StreamFailure", + phase: "stream", + reason: "invalid-utf8-input", + }); + break; + } + if (!this.exited) this.ptyProcess.write(request.bytes.toString("utf8")); + break; + case "Resize": + client.role = { + _tag: "Machine", + phase: "live", + rows: request.rows, + cols: request.cols, + attachSeq: ++this.attachCounter, + }; + this.negotiateSize(); + break; + case "Detach": + this.endMachineStream(client, { _tag: "Detached" }); + break; + } + } + + private endMachineStream( + client: Client, + outcome: Extract + ): void { + if (client.role._tag === "Machine" && client.role.phase === "ended") return; + const role = client.role; + const rows = role._tag === "Machine" || role._tag === "MachineAwaitingSentinel" + ? role.rows + : this.terminal.rows; + const cols = role._tag === "Machine" || role._tag === "MachineAwaitingSentinel" + ? role.cols + : this.terminal.cols; + const attachSeq = role._tag === "Machine" ? role.attachSeq : 0; + client.role = { _tag: "Machine", phase: "ended", rows, cols, attachSeq }; + client.initialScreenGeneration++; + client.socket.end(encodeMachineResponse(outcome)); + this.negotiateSize(); + } + + private admitWritableClient( + client: Client, + size: { readonly rows: number; readonly cols: number }, + protocol: "legacy" | "machine-v2", + onCommitted?: () => void + ): void { + const sizeMatched = + size.rows === this.terminal.rows && size.cols === this.terminal.cols; + const attachSeq = ++this.attachCounter; + client.role = protocol === "machine-v2" + ? { _tag: "Machine", phase: "syncing", ...size, attachSeq } + : { _tag: "Legacy", phase: { _tag: "Settling" }, ...size, attachSeq }; + const initialScreenGeneration = ++client.initialScreenGeneration; + const resized = this.negotiateSize(); + if (!resized && protocol === "legacy") { + client.socket.write(encodeGeometry(this.terminal.rows, this.terminal.cols)); + } + try { + mutateMetadataUnderLock(this.name, (meta) => { + meta.lastAttachAt = new Date().toISOString(); + return true; + }, { expectedGeneration: this.generation }); + } catch {} + onCommitted?.(); + + const sendScreen = () => { + this.beginInitialScreenCut( + client, + initialScreenGeneration, + () => protocol === "machine-v2" + ? encodeMachineResponse({ + _tag: "Ready", + rows: this.terminal.rows, + cols: this.terminal.cols, + inputModes: this.inputModeSnapshot(), + screen: Buffer.from(this.serialize.serialize()), + }) + : encodeScreen(this.getModePrefix(true) + this.serialize.serialize()), + () => { + if (!this.exited && !sizeMatched) this.nudgeRedraw(); + } + ); + }; + + if (!this.exited) { + const sinceLast = Date.now() - this.lastResizeTime; + const redrawSettleMs = 80; + if (resized || sinceLast < redrawSettleMs) { + const delay = resized ? redrawSettleMs : redrawSettleMs - sinceLast; + setTimeout(sendScreen, delay); + } else { + sendScreen(); + } + } else { + sendScreen(); + } + } + private handleClient(socket: net.Socket): void { const client: Client = { socket, reader: new PacketReader(), - rows: this.terminal.rows, - cols: this.terminal.cols, - readonly: false, - attachSeq: 0, - initialScreenPhase: "live", + role: { _tag: "Command" }, initialScreenGeneration: 0, - postCutPackets: [], }; this.clients.set(socket, client); @@ -921,78 +1392,53 @@ export class PtyServer { return; } for (const packet of packets) { + if (client.role._tag === "Rejected") continue; + if (client.role._tag === "MachineAwaitingSentinel") { + if ( + Number(packet.type) === MessageType.STATUS && + packet.payload.length === 0 + ) { + const { rows, cols } = client.role; + this.admitWritableClient( + client, + { rows, cols }, + "machine-v2", + () => client.socket.write( + encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: this.generation, + capabilities: MACHINE_CAPABILITIES, + build: { version: readPackageVersion(), dirty: false }, + }) + ) + ); + continue; + } + this.rejectMachineOpen( + client, + "malformed-request", + "STATUS sentinel must immediately follow OPEN_V2" + ); + continue; + } + if (client.role._tag === "Machine") { + this.handleMachineRequest(client, packet); + continue; + } + if (Number(packet.type) === DaemonExtensionType.OPEN_V2) { + this.handleMachineOpen(client, packet); + continue; + } switch (packet.type) { case MessageType.ATTACH: { if (packet.payload.length < 4) break; - const size = decodeSize(packet.payload); - // Read before negotiateSize(): a smaller client shrinks the session - // to its own size, which would then look like it had matched. - const sizeMatched = - size.rows === this.terminal.rows && size.cols === this.terminal.cols; - client.readonly = false; - client.rows = size.rows; - client.cols = size.cols; - client.attachSeq = ++this.attachCounter; - client.initialScreenPhase = "settling"; - client.postCutPackets = []; - const initialScreenGeneration = ++client.initialScreenGeneration; - const resized = this.negotiateSize(); - if (!resized) { - socket.write(encodeGeometry(this.terminal.rows, this.terminal.cols)); - } - // Best-effort: a concurrent metadata command wins this attach - // stamp, but neither writer can overwrite the other's snapshot. - try { - mutateMetadataUnderLock(this.name, (meta) => { - meta.lastAttachAt = new Date().toISOString(); - return true; - }, { expectedGeneration: this.generation }); - } catch {} - - const sendScreen = () => { - this.beginInitialScreenCut( - client, - initialScreenGeneration, - () => this.getModePrefix(true) + this.serialize.serialize(), - () => { - // The serialize addon's output is an approximation — ECH/CUF - // sequences may not perfectly reproduce what the app originally - // drew (e.g., background fills in ratatui). Nudge the child - // with a SIGWINCH so it does a fresh full redraw, whose DATA - // overwrites any serialize artifacts on the client. - // - // Skipped when the client attached at the size the session - // already has: the child is drawn for that geometry, so the - // nudge buys nothing and wakes an otherwise idle process every - // time someone connects. - if (!this.exited && !sizeMatched) this.nudgeRedraw(); - } - ); - }; - - if (!this.exited) { - // If the PTY was just resized (either by this attach or - // recently by another client), wait for the process to - // redraw before serializing. Without this delay, the client - // sees a transient mid-redraw state. - const sinceLast = Date.now() - this.lastResizeTime; - const REDRAW_SETTLE_MS = 80; - if (resized || sinceLast < REDRAW_SETTLE_MS) { - const delay = resized ? REDRAW_SETTLE_MS : REDRAW_SETTLE_MS - sinceLast; - setTimeout(sendScreen, delay); - } else { - sendScreen(); - } - } else { - sendScreen(); - } + this.admitWritableClient(client, decodeSize(packet.payload), "legacy"); break; } case MessageType.PEEK: { - client.readonly = true; - client.initialScreenPhase = "settling"; - client.postCutPackets = []; + client.role = { _tag: "Readonly", phase: { _tag: "Settling" } }; const initialScreenGeneration = ++client.initialScreenGeneration; const resized = this.negotiateSize(); if (!resized) { @@ -1004,28 +1450,36 @@ export class PtyServer { this.beginInitialScreenCut(client, initialScreenGeneration, () => { if (plain) { - return full ? this.getFullPlainScreen() : this.getPlainScreen(); + return encodeScreen( + full ? this.getFullPlainScreen() : this.getPlainScreen() + ); } // scrollback: 0 for viewport only, omit for full scrollback const serializeOpts = full ? undefined : { scrollback: 0 }; - return this.getModePrefix() + this.serialize.serialize(serializeOpts); + return encodeScreen( + this.getModePrefix() + this.serialize.serialize(serializeOpts) + ); }); break; } case MessageType.DATA: { - if (!this.exited && !client.readonly) { + if (!this.exited && client.role._tag !== "Readonly") { this.ptyProcess.write(packet.payload.toString()); } break; } case MessageType.RESIZE: { - if (!client.readonly && client.attachSeq > 0 && packet.payload.length >= 4) { + if (client.role._tag === "Legacy" && packet.payload.length >= 4) { const size = decodeSize(packet.payload); - client.rows = size.rows; - client.cols = size.cols; - client.attachSeq = ++this.attachCounter; + client.role = { + _tag: "Legacy", + phase: client.role.phase, + rows: size.rows, + cols: size.cols, + attachSeq: ++this.attachCounter, + }; this.negotiateSize(); } break; @@ -1064,12 +1518,23 @@ export class PtyServer { // TERMINAL_SANITIZE exits alt-screen on close. Attaching clients want // the alt buffer to persist for the duration of the attach. if (includeAltScreen && this.altScreenActive) prefix += "\x1b[?1049h"; - if (this.mouseTracking1000) prefix += "\x1b[?1000h"; - if (this.mouseTracking1002) prefix += "\x1b[?1002h"; - if (this.mouseTracking1003) prefix += "\x1b[?1003h"; - if (this.sgrMouseMode) prefix += "\x1b[?1006h"; + if (this.inputModeState.applicationCursorKeys) prefix += "\x1b[?1h"; + if (this.inputModeState.applicationKeypad) prefix += "\x1b="; + if (this.inputModeState.modifyOtherKeys > 0) { + prefix += `\x1b[>4;${this.inputModeState.modifyOtherKeys}m`; + } + if (this.inputModeState.mouseTracking9) prefix += "\x1b[?9h"; + if (this.inputModeState.mouseTracking1000) prefix += "\x1b[?1000h"; + if (this.inputModeState.mouseTracking1002) prefix += "\x1b[?1002h"; + if (this.inputModeState.mouseTracking1003) prefix += "\x1b[?1003h"; + if (this.inputModeState.focusReporting) prefix += "\x1b[?1004h"; + if (this.inputModeState.mouseEncoding1005) prefix += "\x1b[?1005h"; + if (this.inputModeState.mouseEncoding1006) prefix += "\x1b[?1006h"; + if (this.inputModeState.mouseEncoding1015) prefix += "\x1b[?1015h"; + if (this.inputModeState.mouseEncoding1016) prefix += "\x1b[?1016h"; + if (this.inputModeState.bracketedPaste) prefix += "\x1b[?2004h"; if (this.cursorHidden) prefix += "\x1b[?25l"; - for (const flags of this.kittyKeyboardStack) { + for (const flags of this.inputModeState.kittyKeyboardFlags) { prefix += `\x1b[>${flags}u`; } return prefix; @@ -1083,22 +1548,25 @@ export class PtyServer { let readOnly = 0; const connections: NonNullable = []; for (const c of this.clients.values()) { - if (c.readonly) { + if (c.role._tag === "Readonly") { readOnly++; connections.push({ role: "readonly", constrains: { rows: false, cols: false }, }); - } else if (c.attachSeq > 0) { + } else if ( + c.role._tag === "Legacy" || + (c.role._tag === "Machine" && c.role.phase !== "ended") + ) { attached++; connections.push({ role: "writable", - rows: c.rows, - cols: c.cols, - lastRequestSequence: c.attachSeq, + rows: c.role.rows, + cols: c.role.cols, + lastRequestSequence: c.role.attachSeq, constrains: { - rows: c.rows === this.terminal.rows, - cols: c.cols === this.terminal.cols, + rows: c.role.rows === this.terminal.rows, + cols: c.role.cols === this.terminal.cols, }, }); } @@ -1139,10 +1607,10 @@ export class PtyServer { connections, }, modes: { - sgrMouse: this.sgrMouseMode, + sgrMouse: this.inputModeState.mouseEncoding1006, cursorHidden: this.cursorHidden, - kittyKeyboard: this.kittyKeyboardStack.length > 0, - kittyKeyboardFlags: [...this.kittyKeyboardStack], + kittyKeyboard: this.inputModeState.kittyKeyboardFlags.length > 0, + kittyKeyboardFlags: [...this.inputModeState.kittyKeyboardFlags], }, uptimeSeconds, createdAt, @@ -1156,9 +1624,13 @@ export class PtyServer { let cols = 0; for (const client of this.clients.values()) { - if (!client.readonly && client.attachSeq > 0) { - rows = rows === 0 ? client.rows : Math.min(rows, client.rows); - cols = cols === 0 ? client.cols : Math.min(cols, client.cols); + const role = client.role; + if ( + role._tag === "Legacy" || + (role._tag === "Machine" && role.phase !== "ended") + ) { + rows = rows === 0 ? role.rows : Math.min(rows, role.rows); + cols = cols === 0 ? role.cols : Math.min(cols, role.cols); } } @@ -1175,10 +1647,13 @@ export class PtyServer { } private broadcastGeometry(rows: number, cols: number): void { - const packet = encodeGeometry(rows, cols); + const legacyPacket = encodeGeometry(rows, cols); + const machinePacket = encodeMachineResponse({ _tag: "Geometry", rows, cols }); for (const client of this.clients.values()) { - if (client.attachSeq > 0 || client.readonly) { - client.socket.write(packet); + if (client.role._tag === "Legacy" || client.role._tag === "Readonly") { + client.socket.write(legacyPacket); + } else if (client.role._tag === "Machine" && client.role.phase === "live") { + client.socket.write(machinePacket); } } } @@ -1207,15 +1682,23 @@ export class PtyServer { private beginInitialScreenCut( client: Client, generation: number, - getScreen: () => string, + getInitialPacket: () => Buffer, onLive?: () => void ): void { if ( client.socket.destroyed || client.initialScreenGeneration !== generation ) return; + if ( + (client.role._tag === "Legacy" || client.role._tag === "Readonly") && + client.role.phase._tag === "Settling" + ) { + client.role = { + ...client.role, + phase: { _tag: "Cutting", packets: [] }, + }; + } - client.initialScreenPhase = "cutting"; /** xterm parses writes asynchronously. This empty write is an ordered * marker: its callback runs after every earlier write and before later * writes, giving SCREEN an exact parser cut. */ @@ -1225,38 +1708,58 @@ export class PtyServer { client.initialScreenGeneration !== generation ) return; - const postCutPackets = client.postCutPackets; - client.postCutPackets = []; - const hasPostCutExit = postCutPackets.some( - (pending) => pending.type === MessageType.EXIT - ); - - client.socket.write(encodeScreen(getScreen())); - client.initialScreenPhase = "live"; - /** node-pty drains PTY data before its public exit event, so a queued - * EXIT is already source-ordered after DATA. A pre-cut EXIT is not in - * this queue and is synthesized only after any final post-cut DATA. */ - for (const pending of postCutPackets) { - client.socket.write(pending.packet); + const role = client.role; + const pending = + (role._tag === "Legacy" || role._tag === "Readonly") && + role.phase._tag === "Cutting" + ? role.phase.packets + : []; + const hasPendingExit = pending.some((item) => item.isExit); + client.socket.write(getInitialPacket()); + if (role._tag === "Legacy") { + client.role = { ...role, phase: { _tag: "Live" } }; + } else if (role._tag === "Readonly") { + client.role = { ...role, phase: { _tag: "Live" } }; + } else if (role._tag === "Machine" && role.phase === "syncing") { + client.role = { ...role, phase: "live" }; } - if (this.exited && !hasPostCutExit) { - client.socket.write(encodeExit(this.exitCode)); + for (const item of pending) client.socket.write(item.packet); + if (this.exited && !hasPendingExit) { + client.socket.write( + client.role._tag === "Machine" + ? encodeMachineResponse({ + _tag: "Exited", + code: this.exitCode, + signal: null, + }) + : encodeExit(this.exitCode) + ); } onLive?.(); }); } - private broadcast( + private broadcastLegacy( type: typeof MessageType.DATA | typeof MessageType.EXIT, packet: Buffer ): void { for (const client of this.clients.values()) { - if (client.initialScreenPhase === "settling") continue; - if (client.initialScreenPhase === "cutting") { - client.postCutPackets.push({ type, packet }); - continue; + const role = client.role; + if (role._tag !== "Legacy" && role._tag !== "Readonly") continue; + if (role.phase._tag === "Live") { + client.socket.write(packet); + } else if (role.phase._tag === "Cutting") { + role.phase.packets.push({ isExit: type === MessageType.EXIT, packet }); + } + } + } + + private broadcastMachine(response: MachineResponse): void { + const packet = encodeMachineResponse(response); + for (const client of this.clients.values()) { + if (client.role._tag === "Machine" && client.role.phase === "live") { + client.socket.write(packet); } - client.socket.write(packet); } } diff --git a/tests/integration.test.ts b/tests/integration.test.ts index cd731a9..69f03e2 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -628,7 +628,7 @@ describe("integration", () => { const internals = server as unknown as { exited: boolean; exitCode: number; - broadcast: ( + broadcastLegacy: ( type: typeof MessageType.DATA | typeof MessageType.EXIT, packet: Buffer ) => void; @@ -646,7 +646,7 @@ describe("integration", () => { internals.exited = true; internals.exitCode = 7; - internals.broadcast(MessageType.EXIT, encodeExit(7)); + internals.broadcastLegacy(MessageType.EXIT, encodeExit(7)); await vi.advanceTimersByTimeAsync(80); expect( heldWrites.pendingWrites.some( @@ -654,7 +654,7 @@ describe("integration", () => { ) ).toBe(true); - internals.broadcast( + internals.broadcastLegacy( MessageType.DATA, encodeData("final-post-cut-data") ); diff --git a/tests/machine-protocol.test.ts b/tests/machine-protocol.test.ts index b0f9634..f1ac1e3 100644 --- a/tests/machine-protocol.test.ts +++ b/tests/machine-protocol.test.ts @@ -25,6 +25,7 @@ const modes: MachineInputModeSnapshotV1 = { applicationKeypad: false, bracketedPaste: true, focusReporting: false, + modifyOtherKeys: 2, mouseTracking: "any", mouseEncoding: "sgr", kittyKeyboardFlags: [1, 15], @@ -122,6 +123,26 @@ describe("machine attach v2 protocol", () => { const invalidUtf8 = Buffer.from([1, 0, 0, 0, 1, 0xff]); const [invalidFrame] = new MachineFrameReader().feed(invalidUtf8); expect(() => decodeMachineRequest(invalidFrame)).toThrow(/Invalid machine protocol JSON/); + + const invalidModes = encodeMachineResponse({ + _tag: "InputModes", + inputModes: { ...modes, modifyOtherKeys: 3 as 2 }, + }); + const [invalidModesFrame] = new MachineFrameReader().feed(invalidModes); + expect(() => decodeMachineResponse(invalidModesFrame)).toThrow(/modifyOtherKeys/); + }); + + it("round-trips the complete key and mouse mode vocabulary", () => { + const inputModes: MachineInputModeSnapshotV1 = { + ...modes, + mouseTracking: "x10", + mouseEncoding: "sgr-pixels", + modifyOtherKeys: 1, + }; + const [frame] = new MachineFrameReader().feed( + encodeMachineResponse({ _tag: "InputModes", inputModes }) + ); + expect(decodeMachineResponse(frame)).toEqual({ _tag: "InputModes", inputModes }); }); it("accepts only HELLO then READY then updates then one outcome then EOF", () => { @@ -184,6 +205,7 @@ describe("machine attach v2 protocol", () => { _tag: "Frame", frame: { _tag: "Ready", rows: 24, cols: 80, inputModes: modes, screen: Buffer.alloc(0) }, }); + expect(streaming).toEqual({ _tag: "Streaming", inputModeRevision: 7 }); const advanced = reduceMachineAttach(streaming, { _tag: "Frame", frame: { _tag: "InputModes", inputModes: { ...modes, revision: 8 } }, diff --git a/tests/machine-server-v2.test.ts b/tests/machine-server-v2.test.ts new file mode 100644 index 0000000..f750096 --- /dev/null +++ b/tests/machine-server-v2.test.ts @@ -0,0 +1,310 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Terminal } from "@xterm/headless"; +import { + MACHINE_PROTOCOL_VERSION, + MachineFrameReader, + decodeDaemonAdmissionV2, + decodeMachineResponse, + encodeDaemonOpenV2, + encodeMachineRequest, + type MachineOpenV2, + type MachineWireFrame, +} from "../src/machine-protocol.ts"; +import { MessageType, PacketReader, encodeStatus } from "../src/protocol.ts"; +import { PtyServer } from "../src/server.ts"; +import { + cleanupAll, + getSocketPath, + readMetadata, +} from "../src/sessions.ts"; + +const testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-machine-v2-")); +const testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-machine-v2-sd-")); +process.env.PTY_SESSION_DIR = testSessionDir; + +const servers: PtyServer[] = []; +const names: string[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) await server.close(); + for (const name of names.splice(0)) cleanupAll(name); +}); + +afterAll(() => { + fs.rmSync(testCwd, { recursive: true, force: true }); + fs.rmSync(testSessionDir, { recursive: true, force: true }); +}); + +function uniqueName(): string { + const name = `machine-v2-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + names.push(name); + return name; +} + +async function startServer( + name: string, + command = "cat", + args: string[] = [], + generation = "generation-a", +): Promise { + const server = new PtyServer({ + name, + generation, + command, + args, + displayCommand: command, + cwd: testCwd, + rows: 24, + cols: 80, + }); + servers.push(server); + await server.ready; + return server; +} + +function connect(name: string): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(getSocketPath(name)); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); +} + +function recordFrames(socket: net.Socket): { + readonly frames: MachineWireFrame[]; + waitFor: (predicate: (frames: readonly MachineWireFrame[]) => boolean) => Promise; +} { + const reader = new MachineFrameReader(); + const frames: MachineWireFrame[] = []; + let wake: (() => void) | undefined; + socket.on("data", (chunk: Buffer) => { + frames.push(...reader.feed(chunk)); + wake?.(); + }); + return { + frames, + async waitFor(predicate) { + const deadline = performance.now() + 5_000; + while (!predicate(frames)) { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Timed out waiting for machine frames")), + Math.max(0, deadline - performance.now()), + ); + wake = () => { + clearTimeout(timer); + resolve(); + }; + }); + wake = undefined; + } + }, + }; +} + +function openRequest(name: string, generation = "generation-a"): MachineOpenV2 { + return { + _tag: "Open", + protocol: MACHINE_PROTOCOL_VERSION, + sessionId: name, + expectedGeneration: generation, + rows: 24, + cols: 80, + requiredCapabilities: [ + "framed-utf8-input", + "typed-outcome", + "input-mode-snapshot", + ], + }; +} + +async function openMachine(name: string): Promise<{ + socket: net.Socket; + frames: ReturnType; +}> { + const socket = await connect(name); + const frames = recordFrames(socket); + socket.write(Buffer.concat([encodeDaemonOpenV2(openRequest(name)), encodeStatus()])); + await frames.waitFor((received) => received.length >= 2); + expect(decodeDaemonAdmissionV2(frames.frames[0])).toMatchObject({ + _tag: "Accepted", + generation: "generation-a", + }); + expect(decodeMachineResponse(frames.frames[1])._tag).toBe("Ready"); + return { socket, frames }; +} + +describe("machine attach v2 daemon admission", () => { + it("rejects a stale generation before role, geometry, or metadata mutation", async () => { + const name = uniqueName(); + await startServer(name); + const before = readMetadata(name)!; + const socket = await connect(name); + const frames = recordFrames(socket); + const closed = new Promise((resolve) => socket.once("close", () => resolve())); + socket.write(Buffer.concat([ + encodeDaemonOpenV2(openRequest(name, "stale-generation")), + encodeStatus(), + ])); + await frames.waitFor((received) => received.length >= 1); + + expect(decodeDaemonAdmissionV2(frames.frames[0])).toMatchObject({ + _tag: "Rejected", + reason: "generation-mismatch", + }); + await closed; + expect(frames.frames).toHaveLength(1); + expect(readMetadata(name)).toEqual(before); + + const statusSocket = await connect(name); + const statusReader = new PacketReader(); + const status = new Promise>((resolve) => { + statusSocket.on("data", (chunk: Buffer) => { + const packet = statusReader.feed(chunk).find((item) => item.type === MessageType.STATUS); + if (packet) resolve(JSON.parse(packet.payload.toString())); + }); + }); + statusSocket.write(encodeStatus()); + await expect(status).resolves.toMatchObject({ + terminal: { rows: 24, cols: 80 }, + clients: { attached: 0, readOnly: 0 }, + }); + socket.destroy(); + statusSocket.destroy(); + }); + + it("commits admission before Accepted and emits one atomic Ready without Geometry", async () => { + const name = uniqueName(); + await startServer(name); + const { socket, frames } = await openMachine(name); + + expect(frames.frames.slice(0, 2).map((frame) => frame.type)).toEqual([9, 2]); + expect(frames.frames.some((frame) => frame.type === 4)).toBe(false); + expect(readMetadata(name)?.lastAttachAt).toBeTypeOf("string"); + socket.destroy(); + }); + + it("treats C0 bytes as input and invalid UTF-8 as a typed stream failure", async () => { + const name = uniqueName(); + await startServer(name, "sh", [ + "-c", + "stty raw -echo -isig; printf 'RAW_READY\\r\\n'; od -An -tx1 -N 3", + ]); + const { socket, frames } = await openMachine(name); + const hasRawReady = () => frames.frames.slice(1).map(decodeMachineResponse).some( + (response) => + (response._tag === "Ready" && response.screen.includes(Buffer.from("RAW_READY"))) || + (response._tag === "Data" && response.bytes.includes(Buffer.from("RAW_READY"))) + ); + if (!hasRawReady()) await frames.waitFor(hasRawReady); + socket.write(encodeMachineRequest({ + _tag: "Input", + bytes: Buffer.from([0x41, 0x1c, 0x42]), + })); + await frames.waitFor((received) => received.some((frame, index) => + index >= 2 && decodeMachineResponse(frame)._tag === "Exited" + )); + const responses = frames.frames.slice(1).map(decodeMachineResponse); + expect( + Buffer.concat( + responses.flatMap((response) => response._tag === "Data" ? [response.bytes] : []) + ).toString() + ).toMatch(/41\s+1c\s+42/); + expect(responses.some((response) => response._tag === "Detached")).toBe(false); + socket.destroy(); + + const second = uniqueName(); + await startServer(second); + const opened = await openMachine(second); + opened.socket.write(encodeMachineRequest({ + _tag: "Input", + bytes: Buffer.from([0xff]), + })); + await opened.frames.waitFor((received) => received.some((frame, index) => + index >= 2 && decodeMachineResponse(frame)._tag === "StreamFailure" + )); + expect(opened.frames.frames.slice(2).map(decodeMachineResponse)).toContainEqual({ + _tag: "StreamFailure", + phase: "stream", + reason: "invalid-utf8-input", + }); + opened.socket.destroy(); + }); + + it("orders mode-establishing Data before one complete mode snapshot", async () => { + const name = uniqueName(); + const modeBytes = [ + "\x1b[?1h", + "\x1b=", + "\x1b[?2004h", + "\x1b[?1004h", + "\x1b[?9h", + "\x1b[?1016h", + "\x1b[>4;2m", + "\x1b[=5;1u", + "\x1b[=2;2u", + "\x1b[=1;3u", + "\x1b[>9u", + "\x1b[>10u", + "\x1b[<2u", + ].join(""); + const script = [ + "process.stdin.once('data', () => process.stdout.write(Buffer.from(process.argv[1], 'base64')))", + "setInterval(() => {}, 1000)", + ].join(";"); + await startServer(name, process.execPath, ["-e", script, Buffer.from(modeBytes).toString("base64")]); + const { socket, frames } = await openMachine(name); + socket.write(encodeMachineRequest({ _tag: "Input", bytes: Buffer.from("go\n") })); + await frames.waitFor((received) => received.some((frame, index) => + index >= 2 && decodeMachineResponse(frame)._tag === "InputModes" + )); + + const responses = frames.frames.slice(2).map(decodeMachineResponse); + const inputModesIndex = responses.findIndex((response) => response._tag === "InputModes"); + const establishingDataIndex = responses.findIndex((response) => + response._tag === "Data" && response.bytes.includes(Buffer.from("\x1b[?1h")) + ); + expect(establishingDataIndex).toBeGreaterThanOrEqual(0); + expect(inputModesIndex).toBeGreaterThan(establishingDataIndex); + expect(responses[inputModesIndex]).toMatchObject({ + _tag: "InputModes", + inputModes: { + applicationCursorKeys: true, + applicationKeypad: true, + bracketedPaste: true, + focusReporting: true, + modifyOtherKeys: 2, + mouseTracking: "x10", + mouseEncoding: "sgr-pixels", + kittyKeyboardFlags: [6], + }, + }); + expect(responses.filter((response) => response._tag === "InputModes")).toHaveLength(1); + socket.destroy(); + }); + + it("allows explicit detach after admission while Ready is still syncing", async () => { + const name = uniqueName(); + const server = await startServer(name); + const terminal = (server as unknown as { terminal: Terminal }).terminal; + const originalWrite = terminal.write.bind(terminal); + terminal.write = () => undefined; + try { + const socket = await connect(name); + const frames = recordFrames(socket); + socket.write(Buffer.concat([encodeDaemonOpenV2(openRequest(name)), encodeStatus()])); + await frames.waitFor((received) => received.length >= 1); + expect(decodeDaemonAdmissionV2(frames.frames[0])._tag).toBe("Accepted"); + socket.write(encodeMachineRequest({ _tag: "Detach" })); + await frames.waitFor((received) => received.length >= 2); + expect(decodeMachineResponse(frames.frames[1])).toEqual({ _tag: "Detached" }); + socket.destroy(); + } finally { + terminal.write = originalWrite; + } + }); +}); From dae2ee61dace36b5b73af856ea0af8391165155d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:15:21 +0200 Subject: [PATCH 04/10] refactor: share bounded frame codec --- src/frame-codec.ts | 100 +++++++++++++++++++++++++++++++++ src/machine-protocol.ts | 47 +++++++--------- src/protocol.ts | 42 ++++---------- tests/machine-protocol.test.ts | 40 ++++++++++++- tests/protocol.test.ts | 40 ++++++++++--- 5 files changed, 201 insertions(+), 68 deletions(-) create mode 100644 src/frame-codec.ts diff --git a/src/frame-codec.ts b/src/frame-codec.ts new file mode 100644 index 0000000..80f19d6 --- /dev/null +++ b/src/frame-codec.ts @@ -0,0 +1,100 @@ +import { Buffer } from "node:buffer"; + +const HEADER_SIZE = 5; + +export interface LengthPrefixedFrame { + readonly type: Type; + readonly payload: Buffer; +} + +export interface FrameLengthLimit { + readonly maximum: number; + readonly error: (declaredLength: number) => Error; +} + +/** Encodes `[type: uint8][length: uint32BE][payload]` with one allocation. */ +export function encodeLengthPrefixedFrame( + type: number, + payload: Buffer, + limit?: FrameLengthLimit, +): Buffer { + if (limit !== undefined && payload.length > limit.maximum) { + throw limit.error(payload.length); + } + const frame = Buffer.allocUnsafe(HEADER_SIZE + payload.length); + frame.writeUInt8(type, 0); + frame.writeUInt32BE(payload.length, 1); + payload.copy(frame, HEADER_SIZE); + return frame; +} + +/** + * Bounded incremental reader for the shared PTY frame envelope. + * + * Each inbound byte is copied at most once into its final header or payload + * allocation. A declared oversize length permanently poisons the reader so a + * caller cannot accidentally resume at an untrusted stream boundary. + */ +export class BoundedFrameReader { + private readonly header = Buffer.allocUnsafe(HEADER_SIZE); + private headerLength = 0; + private type: Type | undefined; + private payload: Buffer | undefined; + private payloadLength = 0; + private poisoned: Error | undefined; + + constructor(private readonly limit: FrameLengthLimit) {} + + feed(chunk: Buffer): Array> { + if (this.poisoned !== undefined) throw this.poisoned; + + const frames: Array> = []; + let offset = 0; + + while (offset < chunk.length) { + if (this.headerLength < HEADER_SIZE) { + const copied = Math.min(HEADER_SIZE - this.headerLength, chunk.length - offset); + chunk.copy(this.header, this.headerLength, offset, offset + copied); + this.headerLength += copied; + offset += copied; + + if (this.headerLength < HEADER_SIZE) break; + + const declaredLength = this.header.readUInt32BE(1); + if (declaredLength > this.limit.maximum) { + this.poisoned = this.limit.error(declaredLength); + throw this.poisoned; + } + this.type = this.header.readUInt8(0) as Type; + this.payload = Buffer.allocUnsafe(declaredLength); + this.payloadLength = 0; + + if (declaredLength === 0) { + frames.push({ type: this.type, payload: this.payload }); + this.resetFrame(); + } + } + + if (this.payload !== undefined) { + const copied = Math.min(this.payload.length - this.payloadLength, chunk.length - offset); + chunk.copy(this.payload, this.payloadLength, offset, offset + copied); + this.payloadLength += copied; + offset += copied; + + if (this.payloadLength === this.payload.length) { + frames.push({ type: this.type as Type, payload: this.payload }); + this.resetFrame(); + } + } + } + + return frames; + } + + private resetFrame(): void { + this.headerLength = 0; + this.type = undefined; + this.payload = undefined; + this.payloadLength = 0; + } +} diff --git a/src/machine-protocol.ts b/src/machine-protocol.ts index ffe8d91..f7ac78d 100644 --- a/src/machine-protocol.ts +++ b/src/machine-protocol.ts @@ -1,9 +1,12 @@ import { Buffer } from "node:buffer"; +import { + BoundedFrameReader, + encodeLengthPrefixedFrame, +} from "./frame-codec.ts"; export const MACHINE_PROTOCOL_VERSION = 2 as const; export const MAX_MACHINE_FRAME_LENGTH = 32 * 1024 * 1024; -const HEADER_SIZE = 5; const MAX_STRUCTURED_PAYLOAD_LENGTH = 64 * 1024; const MAX_STRING_LENGTH = 4096; const MAX_CAPABILITIES = 32; @@ -159,41 +162,29 @@ export class MachineProtocolError extends Error { } function encodeFrame(type: number, payload: Buffer): Buffer { - if (payload.length > MAX_MACHINE_FRAME_LENGTH) { - throw new MachineProtocolError( - `Machine frame length ${payload.length} exceeds maximum ${MAX_MACHINE_FRAME_LENGTH}`, - ); - } - const header = Buffer.alloc(HEADER_SIZE); - header.writeUInt8(type, 0); - header.writeUInt32BE(payload.length, 1); - return Buffer.concat([header, payload]); + return encodeLengthPrefixedFrame(type, payload, { + maximum: MAX_MACHINE_FRAME_LENGTH, + error: machineFrameTooLarge, + }); } export class MachineFrameReader { - private buffer = Buffer.alloc(0); + private readonly reader = new BoundedFrameReader({ + maximum: MAX_MACHINE_FRAME_LENGTH, + error: machineFrameTooLarge, + }); feed(chunk: Buffer): MachineWireFrame[] { - this.buffer = Buffer.concat([this.buffer, chunk]); - const frames: MachineWireFrame[] = []; - while (this.buffer.length >= HEADER_SIZE) { - const type = this.buffer.readUInt8(0); - const length = this.buffer.readUInt32BE(1); - if (length > MAX_MACHINE_FRAME_LENGTH) { - this.buffer = Buffer.alloc(0); - throw new MachineProtocolError(`Machine frame length ${length} exceeds maximum ${MAX_MACHINE_FRAME_LENGTH}`); - } - if (this.buffer.length < HEADER_SIZE + length) break; - frames.push({ - type, - payload: Buffer.from(this.buffer.subarray(HEADER_SIZE, HEADER_SIZE + length)), - }); - this.buffer = this.buffer.subarray(HEADER_SIZE + length); - } - return frames; + return this.reader.feed(chunk); } } +function machineFrameTooLarge(declaredLength: number): MachineProtocolError { + return new MachineProtocolError( + `Machine frame length ${declaredLength} exceeds maximum ${MAX_MACHINE_FRAME_LENGTH}`, + ); +} + function structured(value: unknown): Buffer { const payload = Buffer.from(JSON.stringify(value), "utf8"); if (payload.length > MAX_STRUCTURED_PAYLOAD_LENGTH) { diff --git a/src/protocol.ts b/src/protocol.ts index 2276708..e5b0d54 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,4 +1,8 @@ import { Buffer } from "node:buffer"; +import { + BoundedFrameReader, + encodeLengthPrefixedFrame, +} from "./frame-codec.ts"; export const MessageType = { DATA: 0, // Terminal data (bidirectional) @@ -20,9 +24,6 @@ export interface Packet { payload: Buffer; } -// Packet wire format: [type: uint8][length: uint32BE][payload: N bytes] -const HEADER_SIZE = 5; - // BUG-3: cap legitimate packet size. SCREEN replays carry the serialized // xterm buffer (rows × cols × attrs × scrollback). With the 10k-line default // scrollback plus mode prefixes, 32 MiB is generously above any real payload @@ -43,10 +44,7 @@ export class PacketTooLargeError extends Error { } export function encodePacket(type: MessageType, payload: Buffer): Buffer { - const header = Buffer.alloc(HEADER_SIZE); - header.writeUInt8(type, 0); - header.writeUInt32BE(payload.length, 1); - return Buffer.concat([header, payload]); + return encodeLengthPrefixedFrame(type, payload); } export function encodeData(data: string): Buffer { @@ -128,32 +126,12 @@ export function decodeExit(payload: Buffer): number { * Throws `PacketTooLargeError` if a peer declares a length exceeding * `MAX_PACKET_LENGTH` — handlers should destroy the socket. */ export class PacketReader { - private buffer = Buffer.alloc(0); + private readonly reader = new BoundedFrameReader({ + maximum: MAX_PACKET_LENGTH, + error: (declaredLength) => new PacketTooLargeError(declaredLength), + }); feed(data: Buffer): Packet[] { - this.buffer = Buffer.concat([this.buffer, data]); - const packets: Packet[] = []; - - while (this.buffer.length >= HEADER_SIZE) { - const type = this.buffer.readUInt8(0) as MessageType; - const length = this.buffer.readUInt32BE(1); - - if (length > MAX_PACKET_LENGTH) { - // Poison the buffer so subsequent feed() calls can't continue past - // the bad header (even though the caller should drop the connection). - this.buffer = Buffer.alloc(0); - throw new PacketTooLargeError(length); - } - - if (this.buffer.length < HEADER_SIZE + length) break; - - const payload = Buffer.from( - this.buffer.subarray(HEADER_SIZE, HEADER_SIZE + length) - ); - packets.push({ type, payload }); - this.buffer = this.buffer.subarray(HEADER_SIZE + length); - } - - return packets; + return this.reader.feed(data); } } diff --git a/tests/machine-protocol.test.ts b/tests/machine-protocol.test.ts index f1ac1e3..0c26c1f 100644 --- a/tests/machine-protocol.test.ts +++ b/tests/machine-protocol.test.ts @@ -56,6 +56,30 @@ describe("machine attach v2 protocol", () => { expect(frames).toEqual([open, { _tag: "Input", bytes: input }, { _tag: "Detach" }]); }); + it("round-trips many deterministic fragmentations of a coalesced stream", () => { + const expected = [ + open, + { _tag: "Input" as const, bytes: Buffer.from([0x00, 0xff, 0x1b, 0x62, 0x1c]) }, + { _tag: "Resize" as const, rows: 60, cols: 180 }, + { _tag: "Detach" as const }, + ]; + const wire = Buffer.concat(expected.map(encodeMachineRequest)); + + for (let seed = 1; seed <= 64; seed += 1) { + const reader = new MachineFrameReader(); + const frames = []; + let offset = 0; + let state = seed; + while (offset < wire.length) { + state = (state * 1664525 + 1013904223) >>> 0; + const length = 1 + (state % 23); + frames.push(...reader.feed(wire.subarray(offset, offset + length))); + offset += length; + } + expect(frames.map(decodeMachineRequest)).toEqual(expected); + } + }); + it("preserves arbitrary bytes within the framed transport", () => { const bytes = Buffer.from([0x00, 0xff, 0x1b, 0x62, 0x1c]); for (const frame of [ @@ -109,7 +133,21 @@ describe("machine attach v2 protocol", () => { const oversized = Buffer.alloc(5); oversized.writeUInt8(1, 0); oversized.writeUInt32BE(32 * 1024 * 1024 + 1, 1); - expect(() => new MachineFrameReader().feed(oversized)).toThrow(MachineProtocolError); + const poisonedReader = new MachineFrameReader(); + let initialError: unknown; + try { + poisonedReader.feed(oversized); + } catch (error) { + initialError = error; + } + expect(initialError).toBeInstanceOf(MachineProtocolError); + let subsequentError: unknown; + try { + poisonedReader.feed(encodeMachineRequest({ _tag: "Detach" })); + } catch (error) { + subsequentError = error; + } + expect(subsequentError).toBe(initialError); expect(() => encodeMachineRequest({ ...open, rows: 0 })).toThrow(MachineProtocolError); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 773cd51..65d50a5 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -235,18 +235,44 @@ describe("protocol", () => { expect(() => reader.feed(header)).toThrow(PacketTooLargeError); }); - it("poisons the buffer after oversize throw (subsequent feeds don't buffer unbounded)", () => { + it("remains poisoned after an oversized header", () => { const reader = new PacketReader(); const header = Buffer.alloc(5); header.writeUInt8(MessageType.DATA, 0); header.writeUInt32BE(0xffffffff, 1); - try { reader.feed(header); } catch {} - // Buffer is cleared, so subsequent valid packets parse correctly from - // the new boundary (not treating their bytes as payload of the bad one). - const packets = reader.feed(encodeData("hi")); - expect(packets).toHaveLength(1); - expect(packets[0].payload.toString()).toBe("hi"); + let initialError: unknown; + try { + reader.feed(header); + } catch (error) { + initialError = error; + } + expect(initialError).toBeInstanceOf(PacketTooLargeError); + let subsequentError: unknown; + try { + reader.feed(encodeData("hi")); + } catch (error) { + subsequentError = error; + } + expect(subsequentError).toBe(initialError); + }); + + it("round-trips coalesced packets under every single-byte split", () => { + const wire = Buffer.concat([ + encodeData("alpha"), + encodeDetach(), + encodePacket(99 as MessageType, Buffer.from([0x00, 0xff, 0x1b])), + encodeData("omega"), + ]); + const reader = new PacketReader(); + const packets = [...wire].flatMap((byte) => reader.feed(Buffer.from([byte]))); + + expect(packets).toEqual([ + { type: MessageType.DATA, payload: Buffer.from("alpha") }, + { type: MessageType.DETACH, payload: Buffer.alloc(0) }, + { type: 99, payload: Buffer.from([0x00, 0xff, 0x1b]) }, + { type: MessageType.DATA, payload: Buffer.from("omega") }, + ]); }); it("accepts packets at exactly MAX_PACKET_LENGTH", () => { From 2ea714831412be7bc80780fe12bfde72768080ed Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:24:21 +0200 Subject: [PATCH 05/10] docs: specify atomic terminal output state --- docs/vrs/spec.md | 57 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 18ad273..819a05f 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -158,7 +158,7 @@ host adapter daemon Every machine frame uses the common five-byte header and the 32 MiB bound. Structured payloads are fatal UTF-8 JSON capped at 64 KiB with exact fields; unknown tags or fields are protocol errors. `DATA`, `INPUT`, and the screen -suffixes of `READY` and `SNAPSHOT` remain byte payloads. `INPUT` reaches the +suffix of `READY` retain byte payloads. `INPUT` reaches the node-pty string boundary only after one fatal UTF-8 validation. Valid UTF-8 and all C0/escape bytes, including `0x1c`, pass unchanged; invalid UTF-8 produces a typed failure rather than replacement characters (R07, R08). @@ -172,20 +172,57 @@ The direction-specific frame schemas are: | host -> adapter | `RESIZE` | Non-zero rows/cols; only after `READY` | | host -> adapter | `DETACH` | Empty explicit intent after `HELLO`, including before `READY`; at most once | | adapter -> host | `HELLO` | Exact generation, negotiated capabilities, diagnostic daemon build identity | -| adapter -> host | `READY` | Atomic effective geometry, complete input-mode snapshot, and screen baseline; at most once | -| adapter -> host | `DATA` / `GEOMETRY` | Ordered terminal bytes or effective size after `READY` | -| adapter -> host | `INPUT_MODES` | Complete input-mode state at a strictly increasing revision | -| adapter -> host | `SNAPSHOT` | Atomic geometry, complete non-regressing input-mode state, and screen | +| adapter -> host | `READY` | Atomic output revision, effective geometry, complete input-mode snapshot, and screen baseline; at most once | +| adapter -> host | `DATA` | Atomic output revision, input-mode revision, optional complete changed input-mode snapshot, and terminal bytes after `READY` | +| adapter -> host | `GEOMETRY` | Ordered effective size after `READY` | | adapter -> host | `ADMISSION_FAILURE` | Typed rejection before `HELLO`; no daemon attach mutation | | adapter -> host | `EXITED` / `DETACHED` / `STREAM_FAILURE` | Exactly one terminal outcome, then EOF | +The causal output frames are: + +```ts +type Ready = { + outputRevision: number + rows: number + cols: number + inputModes: InputModeSnapshotV1 + screen: Uint8Array +} + +type Data = { + outputRevision: number + inputModeRevision: number + inputModes?: InputModeSnapshotV1 + bytes: Uint8Array +} +``` + +`READY` is the authoritative baseline for one bound stream; its revision is read +at the baseline cut after earlier parser callbacks commit and need not be +contiguous with an earlier connection. Each subsequent `DATA` has non-empty bytes +and `outputRevision = previous + 1`; stripped query-only or empty output emits no +`DATA` and advances no output revision. `inputModes` is absent exactly when +`inputModeRevision` equals the installed revision. A changed mode revision must +strictly advance (it may jump after multiple transitions), and the same `DATA` +carries the complete replacement snapshot whose revision it names. Bytes, causal +stamps, and the optional snapshot form one envelope retained, delivered, and +reduced atomically. + +Each client's bounded ordered writer retains a whole `DATA` envelope or rejects +it and ends that machine stream as a slow consumer. It never splits causal +metadata from bytes, pauses the PTY, or stalls another client. + The complete child-input mode snapshot contains application cursor keys, application keypad, bracketed paste, focus reporting, modifyOtherKeys level, mouse tracking (including DECSET 9), mouse encoding (including pixel SGR), and the Kitty keyboard active-flags stack. Kitty set/add/remove and push/pop-n operations update that one stack. The snapshot is state, not an inference from -screen bytes. A mode change is ordered after the `DATA` that established it and -sent as a complete `INPUT_MODES` replacement with an advancing revision. +screen bytes. + +Fractal or another input-capable host opens its input gate only after applying +`READY`. For each `DATA`, it applies terminal bytes and any changed mode snapshot +in one reducer transition before encoding further child input. It never encodes +against a revision named by `DATA` but not yet installed. The adapter sends daemon packet `OPEN_V2` (reserved type 8) followed by a `STATUS` request on the same command-role socket. A v2 daemon answers first with @@ -200,15 +237,15 @@ build identity are diagnostic; the three required capabilities are The host lifecycle grammar is (R08): ```text -( HELLO ( READY (DATA | GEOMETRY | INPUT_MODES | SNAPSHOT)* STREAM_END +( HELLO ( READY (DATA | GEOMETRY)* STREAM_END | STREAM_END ) | ADMISSION_FAILURE ) EOF STREAM_END = EXITED | DETACHED | STREAM_FAILURE ``` -Bare EOF, a second outcome, a pre-`READY` update, a repeated `READY`, or a -backward input-mode revision is a protocol violation. `AdmissionFailure` +Bare EOF, a second outcome, a pre-`READY` update, a repeated `READY`, or a causal +revision violation is a protocol error. `AdmissionFailure` includes unsupported daemon/capability, generation mismatch, missing or denied session, malformed request, and adapter transport failure before `HELLO`. `StreamFailure` covers baseline, streaming, or shutdown failure after `HELLO`. From d02409819a3ebc72680cc1e3f8572dd001cc793a Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:38:15 +0200 Subject: [PATCH 06/10] feat: make terminal output state atomic --- docs/vrs/spec.md | 15 ++ src/machine-protocol.ts | 164 +++++++++----- src/server.ts | 335 ++++++++++++++++++++++------ tests/machine-attach-v2-e2e.test.ts | 243 ++++++++++++++++++++ tests/machine-attach-v2.test.ts | 55 ++++- tests/machine-protocol.test.ts | 108 ++++++--- tests/machine-server-v2.test.ts | 253 +++++++++++++++++++-- 7 files changed, 998 insertions(+), 175 deletions(-) create mode 100644 tests/machine-attach-v2-e2e.test.ts diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 819a05f..780a25e 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -181,6 +181,21 @@ The direction-specific frame schemas are: The causal output frames are: ```ts +type InputModeSnapshotV1 = { + schema: "pty.input-mode.v1" + wireEncoder: "xterm-input.v1" + revision: number + applicationCursorKeys: boolean + applicationKeypad: boolean + bracketedPaste: boolean + focusReporting: boolean + modifyOtherKeys: 0 | 1 | 2 + kittyKeyboardFlagsStack: u32[] + mouseTracking: "Off" | "X10Press" | "Click" | "ButtonMotion" | "AnyMotion" + mouseEncoding: "X10" | "Utf8" | "Sgr" | "Urxvt" + mouseCoordinates: "Cell" | "Pixel" +} + type Ready = { outputRevision: number rows: number diff --git a/src/machine-protocol.ts b/src/machine-protocol.ts index f7ac78d..5b91da9 100644 --- a/src/machine-protocol.ts +++ b/src/machine-protocol.ts @@ -24,12 +24,10 @@ export const MachineResponseType = { READY: 2, DATA: 3, GEOMETRY: 4, - SNAPSHOT: 5, - EXITED: 6, - DETACHED: 7, - ADMISSION_FAILURE: 8, - STREAM_FAILURE: 9, - INPUT_MODES: 10, + EXITED: 5, + DETACHED: 6, + ADMISSION_FAILURE: 7, + STREAM_FAILURE: 8, } as const; /** Reserved packet types on the existing daemon socket. */ @@ -56,11 +54,14 @@ export type MachineRequest = | { readonly _tag: "Resize"; readonly rows: number; readonly cols: number } | { readonly _tag: "Detach" }; -export type MouseTrackingMode = "none" | "x10" | "click" | "button" | "any"; -export type MouseEncodingMode = "x10" | "utf8" | "sgr" | "urxvt" | "sgr-pixels"; +export type MouseTrackingMode = "Off" | "X10Press" | "Click" | "ButtonMotion" | "AnyMotion"; +export type MouseEncodingMode = "X10" | "Utf8" | "Sgr" | "Urxvt"; +export type MouseCoordinateMode = "Cell" | "Pixel"; /** Complete child-input state at one ordered terminal-stream revision. */ export interface MachineInputModeSnapshotV1 { + readonly schema: "pty.input-mode.v1"; + readonly wireEncoder: "xterm-input.v1"; readonly revision: number; readonly applicationCursorKeys: boolean; readonly applicationKeypad: boolean; @@ -69,7 +70,8 @@ export interface MachineInputModeSnapshotV1 { readonly modifyOtherKeys: 0 | 1 | 2; readonly mouseTracking: MouseTrackingMode; readonly mouseEncoding: MouseEncodingMode; - readonly kittyKeyboardFlags: readonly number[]; + readonly mouseCoordinates: MouseCoordinateMode; + readonly kittyKeyboardFlagsStack: readonly number[]; } export interface MachineBuildInfo { @@ -88,6 +90,7 @@ export interface MachineHelloV2 { export interface MachineReadyV2 { readonly _tag: "Ready"; + readonly outputRevision: number; readonly rows: number; readonly cols: number; readonly inputModes: MachineInputModeSnapshotV1; @@ -123,16 +126,14 @@ export type MachineOutcome = export type MachineResponse = | MachineHelloV2 | MachineReadyV2 - | { readonly _tag: "Data"; readonly bytes: Buffer } - | { readonly _tag: "Geometry"; readonly rows: number; readonly cols: number } | { - readonly _tag: "Snapshot"; - readonly rows: number; - readonly cols: number; - readonly inputModes: MachineInputModeSnapshotV1; - readonly screen: Buffer; + readonly _tag: "Data"; + readonly outputRevision: number; + readonly inputModeRevision: number; + readonly inputModes?: MachineInputModeSnapshotV1; + readonly bytes: Buffer; } - | { readonly _tag: "InputModes"; readonly inputModes: MachineInputModeSnapshotV1 } + | { readonly _tag: "Geometry"; readonly rows: number; readonly cols: number } | MachineOutcome; export type DaemonAdmissionV2 = @@ -260,12 +261,14 @@ function size(value: unknown): { rows: number; cols: number } { }; } -function inputModes(value: unknown): MachineInputModeSnapshotV1 { +export function canonicalizeMachineInputModeSnapshotV1(value: unknown): MachineInputModeSnapshotV1 { const object = record(value, "inputModes"); exactKeys( object, [ "revision", + "schema", + "wireEncoder", "applicationCursorKeys", "applicationKeypad", "bracketedPaste", @@ -273,20 +276,27 @@ function inputModes(value: unknown): MachineInputModeSnapshotV1 { "modifyOtherKeys", "mouseTracking", "mouseEncoding", - "kittyKeyboardFlags", + "mouseCoordinates", + "kittyKeyboardFlagsStack", ], "inputModes", ); const tracking = string(object.mouseTracking, "mouseTracking") as MouseTrackingMode; const encoding = string(object.mouseEncoding, "mouseEncoding") as MouseEncodingMode; - if (!["none", "x10", "click", "button", "any"].includes(tracking)) + const coordinates = string(object.mouseCoordinates, "mouseCoordinates") as MouseCoordinateMode; + if (object.schema !== "pty.input-mode.v1") throw new MachineProtocolError("unknown input mode schema"); + if (object.wireEncoder !== "xterm-input.v1") throw new MachineProtocolError("unknown input wire encoder"); + if (!["Off", "X10Press", "Click", "ButtonMotion", "AnyMotion"].includes(tracking)) throw new MachineProtocolError("unknown mouse tracking mode"); - if (!["x10", "utf8", "sgr", "urxvt", "sgr-pixels"].includes(encoding)) + if (!["X10", "Utf8", "Sgr", "Urxvt"].includes(encoding)) throw new MachineProtocolError("unknown mouse encoding mode"); - if (!Array.isArray(object.kittyKeyboardFlags) || object.kittyKeyboardFlags.length > MAX_KITTY_STACK_DEPTH) { - throw new MachineProtocolError("kittyKeyboardFlags must be a bounded array"); + if (!["Cell", "Pixel"].includes(coordinates)) throw new MachineProtocolError("unknown mouse coordinate mode"); + if (!Array.isArray(object.kittyKeyboardFlagsStack) || object.kittyKeyboardFlagsStack.length > MAX_KITTY_STACK_DEPTH) { + throw new MachineProtocolError("kittyKeyboardFlagsStack must be a bounded array"); } return { + schema: "pty.input-mode.v1", + wireEncoder: "xterm-input.v1", revision: integer(object.revision, "input mode revision", 0, Number.MAX_SAFE_INTEGER), applicationCursorKeys: boolean(object.applicationCursorKeys, "applicationCursorKeys"), applicationKeypad: boolean(object.applicationKeypad, "applicationKeypad"), @@ -295,7 +305,8 @@ function inputModes(value: unknown): MachineInputModeSnapshotV1 { modifyOtherKeys: integer(object.modifyOtherKeys, "modifyOtherKeys", 0, 2) as 0 | 1 | 2, mouseTracking: tracking, mouseEncoding: encoding, - kittyKeyboardFlags: object.kittyKeyboardFlags.map((flag) => integer(flag, "kitty keyboard flag", 0, 0xffffffff)), + mouseCoordinates: coordinates, + kittyKeyboardFlagsStack: object.kittyKeyboardFlagsStack.map((flag) => integer(flag, "kitty keyboard flag", 0, 0xffffffff)), }; } @@ -353,9 +364,10 @@ function optionalDetail(value: unknown, name: string): string | undefined { function snapshotPayload(metadata: Omit, screen: Buffer): Buffer { const encodedMetadata = structured({ + outputRevision: metadata.outputRevision, rows: metadata.rows, cols: metadata.cols, - inputModes: metadata.inputModes, + inputModes: canonicalizeMachineInputModeSnapshotV1(metadata.inputModes), }); const header = Buffer.alloc(4); header.writeUInt32BE(encodedMetadata.length, 0); @@ -369,14 +381,62 @@ function decodeSnapshotPayload(payload: Buffer): Omit { throw new MachineProtocolError("Snapshot metadata is truncated or oversized"); } const metadata = record(parseStructured(payload.subarray(4, 4 + metadataLength)), "snapshot metadata"); - exactKeys(metadata, ["rows", "cols", "inputModes"], "snapshot metadata"); + exactKeys(metadata, ["outputRevision", "rows", "cols", "inputModes"], "snapshot metadata"); return { + outputRevision: integer(metadata.outputRevision, "outputRevision", 0, Number.MAX_SAFE_INTEGER), ...size(metadata), - inputModes: inputModes(metadata.inputModes), + inputModes: canonicalizeMachineInputModeSnapshotV1(metadata.inputModes), screen: Buffer.from(payload.subarray(4 + metadataLength)), }; } +function dataPayload( + metadata: Omit, "_tag" | "bytes">, + bytes: Buffer, +): Buffer { + if (bytes.length === 0) throw new MachineProtocolError("DATA bytes must be non-empty"); + const canonicalInputModes = metadata.inputModes === undefined + ? undefined + : canonicalizeMachineInputModeSnapshotV1(metadata.inputModes); + if (canonicalInputModes !== undefined && canonicalInputModes.revision !== metadata.inputModeRevision) { + throw new MachineProtocolError("DATA inputModes revision does not match inputModeRevision"); + } + const encodedMetadata = structured({ + outputRevision: integer(metadata.outputRevision, "outputRevision", 0, Number.MAX_SAFE_INTEGER), + inputModeRevision: integer(metadata.inputModeRevision, "inputModeRevision", 0, Number.MAX_SAFE_INTEGER), + ...(canonicalInputModes === undefined ? {} : { inputModes: canonicalInputModes }), + }); + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(encodedMetadata.length, 0); + return Buffer.concat([header, encodedMetadata, bytes]); +} + +function decodeDataPayload( + payload: Buffer, +): Omit, "_tag"> { + if (payload.length < 4) throw new MachineProtocolError("DATA metadata length is truncated"); + const metadataLength = payload.readUInt32BE(0); + if (metadataLength > MAX_STRUCTURED_PAYLOAD_LENGTH || payload.length < 4 + metadataLength) { + throw new MachineProtocolError("DATA metadata is truncated or oversized"); + } + const metadata = record(parseStructured(payload.subarray(4, 4 + metadataLength)), "DATA metadata"); + exactKeys(metadata, ["outputRevision", "inputModeRevision", "inputModes"], "DATA metadata"); + const decodedModes = metadata.inputModes === undefined + ? undefined + : canonicalizeMachineInputModeSnapshotV1(metadata.inputModes); + const inputModeRevision = integer(metadata.inputModeRevision, "inputModeRevision", 0, Number.MAX_SAFE_INTEGER); + if (decodedModes !== undefined && decodedModes.revision !== inputModeRevision) { + throw new MachineProtocolError("DATA inputModes revision does not match inputModeRevision"); + } + if (payload.length === 4 + metadataLength) throw new MachineProtocolError("DATA bytes must be non-empty"); + return { + outputRevision: integer(metadata.outputRevision, "outputRevision", 0, Number.MAX_SAFE_INTEGER), + inputModeRevision, + ...(decodedModes === undefined ? {} : { inputModes: decodedModes }), + bytes: Buffer.from(payload.subarray(4 + metadataLength)), + }; +} + export function encodeMachineRequest(request: MachineRequest): Buffer { switch (request._tag) { case "Open": @@ -416,13 +476,9 @@ export function encodeMachineResponse(response: MachineResponse): Buffer { case "Ready": return encodeFrame(MachineResponseType.READY, snapshotPayload(response, response.screen)); case "Data": - return encodeFrame(MachineResponseType.DATA, response.bytes); + return encodeFrame(MachineResponseType.DATA, dataPayload(response, response.bytes)); case "Geometry": return encodeFrame(MachineResponseType.GEOMETRY, structured(size(response))); - case "Snapshot": - return encodeFrame(MachineResponseType.SNAPSHOT, snapshotPayload(response, response.screen)); - case "InputModes": - return encodeFrame(MachineResponseType.INPUT_MODES, structured({ inputModes: response.inputModes })); case "Exited": return encodeFrame(MachineResponseType.EXITED, structured(response)); case "Detached": @@ -453,19 +509,12 @@ export function decodeMachineResponse(frame: MachineWireFrame): MachineResponse case MachineResponseType.READY: return { _tag: "Ready", ...decodeSnapshotPayload(frame.payload) }; case MachineResponseType.DATA: - return { _tag: "Data", bytes: Buffer.from(frame.payload) }; + return { _tag: "Data", ...decodeDataPayload(frame.payload) }; case MachineResponseType.GEOMETRY: { const value = record(parseStructured(frame.payload), "GEOMETRY"); exactKeys(value, ["rows", "cols"], "GEOMETRY"); return { _tag: "Geometry", ...size(value) }; } - case MachineResponseType.SNAPSHOT: - return { _tag: "Snapshot", ...decodeSnapshotPayload(frame.payload) }; - case MachineResponseType.INPUT_MODES: { - const value = structuredPayload(); - exactKeys(value, ["inputModes"], "INPUT_MODES"); - return { _tag: "InputModes", inputModes: inputModes(value.inputModes) }; - } case MachineResponseType.EXITED: { const value = structuredPayload(); exactKeys(value, ["_tag", "code", "signal"], "EXITED"); @@ -552,6 +601,7 @@ export type MachineAttachState = | { readonly _tag: "Streaming"; readonly inputModeRevision: number; + readonly outputRevision: number; } | { readonly _tag: "AwaitEof"; readonly outcome: MachineOutcome } | { readonly _tag: "Complete"; readonly outcome: MachineOutcome } @@ -581,24 +631,36 @@ export function reduceMachineAttach(state: MachineAttachState, event: MachineAtt return violation(`Expected HELLO or ADMISSION_FAILURE, received ${frame._tag}`); case "AwaitReady": if (frame._tag === "Ready") { - return { _tag: "Streaming", inputModeRevision: frame.inputModes.revision }; + return { + _tag: "Streaming", + inputModeRevision: frame.inputModes.revision, + outputRevision: frame.outputRevision, + }; } if (frame._tag === "Exited" || frame._tag === "Detached" || frame._tag === "StreamFailure") { return { _tag: "AwaitEof", outcome: frame }; } return violation(`Expected READY or pre-ready outcome, received ${frame._tag}`); case "Streaming": - if (frame._tag === "Data" || frame._tag === "Geometry") return state; - if (frame._tag === "InputModes") { - return frame.inputModes.revision > state.inputModeRevision - ? { ...state, inputModeRevision: frame.inputModes.revision } - : violation("INPUT_MODES revision must advance"); - } - if (frame._tag === "Snapshot") { - return frame.inputModes.revision >= state.inputModeRevision - ? { ...state, inputModeRevision: frame.inputModes.revision } - : violation("SNAPSHOT input-mode revision moved backward"); + if (frame._tag === "Data") { + const modeAdvanced = frame.inputModeRevision > state.inputModeRevision; + if (frame.outputRevision !== state.outputRevision + 1 || frame.inputModeRevision < state.inputModeRevision) { + return violation("DATA causal stamp did not advance monotonically"); + } + if (modeAdvanced !== (frame.inputModes !== undefined)) { + return violation("DATA must carry inputModes if and only if its mode revision advances"); + } + if (frame.inputModes !== undefined && frame.inputModes.revision !== frame.inputModeRevision) { + return violation("DATA inputModes revision must equal inputModeRevision"); + } + if (frame.bytes.length === 0) return violation("DATA bytes must be non-empty"); + return { + ...state, + inputModeRevision: frame.inputModeRevision, + outputRevision: frame.outputRevision, + }; } + if (frame._tag === "Geometry") return state; if (frame._tag === "Exited" || frame._tag === "Detached" || frame._tag === "StreamFailure") { return { _tag: "AwaitEof", outcome: frame }; } diff --git a/src/server.ts b/src/server.ts index eea3baf..ae0efed 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,6 +24,7 @@ import { import { DaemonExtensionType, MACHINE_PROTOCOL_VERSION, + canonicalizeMachineInputModeSnapshotV1, decodeDaemonOpenV2, decodeMachineRequest, encodeDaemonAdmissionV2, @@ -31,8 +32,6 @@ import { type MachineCapability, type MachineInputModeSnapshotV1, type MachineResponse, - type MouseEncodingMode, - type MouseTrackingMode, } from "./machine-protocol.ts"; import { readPackageVersion } from "./version.ts"; import { @@ -82,6 +81,7 @@ import type { StatsResult } from "./client.ts"; interface Client { socket: net.Socket; + output: BoundedClientOutput; reader: PacketReader; role: | { readonly _tag: "Command" } @@ -92,7 +92,7 @@ interface Client { | { readonly _tag: "Settling" } | { readonly _tag: "Cutting"; - readonly packets: Array<{ readonly isExit: boolean; readonly packet: Buffer }>; + readonly hasPendingExit: boolean; } | { readonly _tag: "Live" }; readonly rows: number; @@ -105,7 +105,7 @@ interface Client { | { readonly _tag: "Settling" } | { readonly _tag: "Cutting"; - readonly packets: Array<{ readonly isExit: boolean; readonly packet: Buffer }>; + readonly hasPendingExit: boolean; } | { readonly _tag: "Live" }; } @@ -125,6 +125,132 @@ interface Client { initialScreenGeneration: number; } +/** One legal maximum-sized protocol frame plus its five-byte envelope. */ +export const DEFAULT_MAX_CLIENT_OUTPUT_BYTES = 32 * 1024 * 1024 + 5; +/** Reserved independently so queue saturation can still produce a typed outcome. */ +export const CLIENT_TERMINAL_OUTCOME_RESERVE_BYTES = 64 * 1024 + 5; + +/** + * Owns all retained output for one client. Socket backpressure never pauses + * the PTY or another client: later frames wait here, in order, until `drain`. + * The normal byte limit includes Node's socket buffer, the live queue, and + * frames held behind an initial-screen cut; only a final typed outcome may use + * the additional fixed reserve. + */ +export class BoundedClientOutput { + private readonly queued: Buffer[] = []; + private queuedBytes = 0; + private readonly held: Buffer[] = []; + private heldBytes = 0; + private blocked = false; + private ending = false; + private closed = false; + + constructor( + private readonly socket: net.Socket, + private readonly maxBufferedBytes = DEFAULT_MAX_CLIENT_OUTPUT_BYTES, + ) { + if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes <= 0) { + throw new RangeError("maxBufferedBytes must be a positive safe integer"); + } + socket.on("drain", () => this.flush()); + socket.on("close", () => this.discard()); + } + + write(packet: Buffer): boolean { + if (!this.canRetain(packet.length, false)) return false; + this.enqueue(packet); + return true; + } + + hold(packet: Buffer): boolean { + if (!this.canRetain(packet.length, false)) return false; + this.held.push(packet); + this.heldBytes += packet.length; + return true; + } + + releaseHeld(initialPacket: Buffer): boolean { + if (!this.canRetain(initialPacket.length, false)) return false; + const held = this.held.splice(0); + this.heldBytes = 0; + this.enqueue(initialPacket); + for (const packet of held) this.enqueue(packet); + return true; + } + + clearHeld(): void { + this.held.length = 0; + this.heldBytes = 0; + } + + end(packet?: Buffer): boolean { + if (packet !== undefined) { + if (!this.canRetain(packet.length, true)) return false; + this.enqueue(packet); + } + this.ending = true; + this.finishEndIfReady(); + return true; + } + + destroy(): void { + this.discard(); + this.socket.destroy(); + } + + private canRetain(additionalBytes: number, includeOutcomeReserve: boolean): boolean { + const limit = this.maxBufferedBytes + + (includeOutcomeReserve ? CLIENT_TERMINAL_OUTCOME_RESERVE_BYTES : 0); + return !this.closed && !this.ending && !this.socket.destroyed && + this.socket.writableLength + this.queuedBytes + this.heldBytes + additionalBytes <= + limit; + } + + private enqueue(packet: Buffer): void { + if (this.blocked || this.queued.length > 0) { + this.queued.push(packet); + this.queuedBytes += packet.length; + return; + } + try { + this.blocked = !this.socket.write(packet); + } catch { + this.destroy(); + } + } + + private flush(): void { + if (this.closed) return; + this.blocked = false; + while (!this.blocked && this.queued.length > 0) { + const packet = this.queued.shift()!; + this.queuedBytes -= packet.length; + try { + this.blocked = !this.socket.write(packet); + } catch { + this.destroy(); + return; + } + } + this.finishEndIfReady(); + } + + private finishEndIfReady(): void { + if (!this.closed && this.ending && !this.blocked && this.queued.length === 0) { + this.closed = true; + this.socket.end(); + } + } + + private discard(): void { + this.closed = true; + this.queued.length = 0; + this.queuedBytes = 0; + this.clearHeld(); + } +} + interface TerminalInputModeState { readonly revision: number; readonly applicationCursorKeys: boolean; @@ -165,6 +291,8 @@ export interface ServerOptions { * Mutable via `pty rename`; `name` stays the immutable stable id. */ displayName?: string; onExit?: (code: number) => void; + /** Maximum normal output retained per client, excluding the bounded terminal-outcome reserve. */ + maxClientOutputBytes?: number; /** When true, spawn the child with a scrubbed environment containing only * a small allow-list of variables (plus any entries in `extraEnv`). * Intended for contexts where the daemon may have inherited secrets that @@ -366,7 +494,8 @@ export class PtyServer { mouseEncoding1016: false, kittyKeyboardFlags: [], }; - private lastBroadcastInputModeRevision = 0; + private lastCommittedInputModeRevision = 0; + private outputRevision = 0; // Alt-screen buffer state (DEC private modes ?1049 / ?1047 / ?47). Set when // the child process enters the alternate screen buffer; cleared when it // leaves. Replayed to attaching clients so the SCREEN snapshot lands in the @@ -472,6 +601,10 @@ export class PtyServer { : typeof first === "number" ? first : first[0]; + if (!Number.isInteger(flags) || flags < 0 || flags > 0xffffffff) { + this.failMachineInputModes("kitty-keyboard-flags-out-of-range"); + return false; + } if ( this.inputModeState.kittyKeyboardFlags.length >= MAX_KITTY_KEYBOARD_STACK_DEPTH @@ -494,6 +627,10 @@ export class PtyServer { : typeof first === "number" ? first : first[0]; + if (!Number.isInteger(flags) || flags < 0 || flags > 0xffffffff) { + this.failMachineInputModes("kitty-keyboard-flags-out-of-range"); + return false; + } const second = params[1]; const mode = second === undefined ? 1 @@ -503,9 +640,9 @@ export class PtyServer { const stack = [...this.inputModeState.kittyKeyboardFlags]; const current = stack.at(-1) ?? 0; const updated = mode === 2 - ? current | flags + ? (current | flags) >>> 0 : mode === 3 - ? current & ~flags + ? (current & ~flags) >>> 0 : flags; if (stack.length === 0) stack.push(updated); else stack[stack.length - 1] = updated; @@ -729,28 +866,7 @@ export class PtyServer { // Query sequences (OSC 10/11, DA1, etc.) are intercepted by parser // handlers above and must NOT be forwarded to clients — otherwise the // client's terminal responds and its response appears as garbage input. - this.ptyProcess.onData((data: string) => { - const cleaned = stripTerminalQueries(data); - const inputModeRevision = this.inputModeState.revision; - if (cleaned.length > 0) { - this.broadcastLegacy(MessageType.DATA, encodeData(cleaned)); - } - this.terminal.write(data, () => { - if (cleaned.length > 0) { - this.broadcastMachine({ _tag: "Data", bytes: Buffer.from(cleaned) }); - } - if ( - this.inputModeState.revision > inputModeRevision && - this.inputModeState.revision > this.lastBroadcastInputModeRevision - ) { - this.broadcastMachine({ - _tag: "InputModes", - inputModes: this.inputModeSnapshot(), - }); - this.lastBroadcastInputModeRevision = this.inputModeState.revision; - } - }); - }); + this.ptyProcess.onData((data: string) => this.handlePtyOutput(data)); this.ptyProcess.onExit(({ exitCode, signal }) => { this.exited = true; @@ -1094,35 +1210,36 @@ export class PtyServer { private inputModeSnapshot(): MachineInputModeSnapshotV1 { const state = this.inputModeState; - const mouseTracking: MouseTrackingMode = state.mouseTracking1003 - ? "any" + const mouseTracking = state.mouseTracking1003 + ? "AnyMotion" as const : state.mouseTracking1002 - ? "button" + ? "ButtonMotion" as const : state.mouseTracking1000 - ? "click" + ? "Click" as const : state.mouseTracking9 - ? "x10" - : "none"; - const mouseEncoding: MouseEncodingMode = state.mouseEncoding1016 - ? "sgr-pixels" - : state.mouseEncoding1006 - ? "sgr" + ? "X10Press" as const + : "Off" as const; + const mouseEncoding = state.mouseEncoding1016 || state.mouseEncoding1006 + ? "Sgr" as const : state.mouseEncoding1015 - ? "urxvt" + ? "Urxvt" as const : state.mouseEncoding1005 - ? "utf8" - : "x10"; - return { + ? "Utf8" as const + : "X10" as const; + return canonicalizeMachineInputModeSnapshotV1({ + schema: "pty.input-mode.v1", + wireEncoder: "xterm-input.v1", revision: state.revision, applicationCursorKeys: state.applicationCursorKeys, applicationKeypad: state.applicationKeypad, bracketedPaste: state.bracketedPaste, focusReporting: state.focusReporting, modifyOtherKeys: state.modifyOtherKeys, + kittyKeyboardFlagsStack: [...state.kittyKeyboardFlags], mouseTracking, mouseEncoding, - kittyKeyboardFlags: [...state.kittyKeyboardFlags], - }; + mouseCoordinates: state.mouseEncoding1016 ? "Pixel" : "Cell", + }); } private updateInputModes( @@ -1152,6 +1269,35 @@ export class PtyServer { this.inputModeState = { ...next, revision: current.revision + 1 }; } + private handlePtyOutput(data: string): void { + const cleaned = stripTerminalQueries(data); + if (cleaned.length > 0) { + this.broadcastLegacy(MessageType.DATA, encodeData(cleaned)); + } + this.terminal.write(data, () => { + if (cleaned.length === 0) return; + let inputModes: MachineInputModeSnapshotV1 | undefined; + if (this.inputModeState.revision > this.lastCommittedInputModeRevision) { + try { + inputModes = this.inputModeSnapshot(); + } catch { + this.failMachineInputModes("unrepresentable-input-mode"); + return; + } + } + const outputRevision = this.outputRevision + 1; + this.outputRevision = outputRevision; + this.broadcastMachine({ + _tag: "Data", + bytes: Buffer.from(cleaned), + outputRevision, + inputModeRevision: this.inputModeState.revision, + ...(inputModes === undefined ? {} : { inputModes }), + }); + this.lastCommittedInputModeRevision = this.inputModeState.revision; + }); + } + private failMachineInputModes(reason: string): void { for (const client of [...this.clients.values()]) { if (client.role._tag !== "Machine" || client.role.phase === "ended") continue; @@ -1169,13 +1315,13 @@ export class PtyServer { detail?: string ): void { client.role = { _tag: "Rejected" }; - client.socket.end( + if (!client.output.end( encodeDaemonAdmissionV2({ _tag: "Rejected", reason, ...(detail === undefined ? {} : { detail }), }) - ); + )) client.output.destroy(); } private handleMachineOpen( @@ -1309,16 +1455,44 @@ export class PtyServer { const attachSeq = role._tag === "Machine" ? role.attachSeq : 0; client.role = { _tag: "Machine", phase: "ended", rows, cols, attachSeq }; client.initialScreenGeneration++; - client.socket.end(encodeMachineResponse(outcome)); + client.output.clearHeld(); + if (!client.output.end(encodeMachineResponse(outcome))) client.output.destroy(); + this.negotiateSize(); + } + + private handleSlowConsumer(client: Client): void { + const role = client.role; + client.initialScreenGeneration++; + client.output.clearHeld(); + if (role._tag === "Machine" && role.phase !== "ended") { + client.role = { ...role, phase: "ended" }; + const failure = encodeMachineResponse({ + _tag: "StreamFailure", + phase: role.phase === "syncing" ? "baseline" : "stream", + reason: "slow-consumer", + }); + if (!client.output.end(failure)) client.output.destroy(); + this.negotiateSize(); + return; + } + this.clients.delete(client.socket); + client.output.destroy(); this.negotiateSize(); } + private writeClient(client: Client, packet: Buffer): boolean { + if (client.output.write(packet)) return true; + this.handleSlowConsumer(client); + return false; + } + private admitWritableClient( client: Client, size: { readonly rows: number; readonly cols: number }, protocol: "legacy" | "machine-v2", onCommitted?: () => void ): void { + client.output.clearHeld(); const sizeMatched = size.rows === this.terminal.rows && size.cols === this.terminal.cols; const attachSeq = ++this.attachCounter; @@ -1328,7 +1502,7 @@ export class PtyServer { const initialScreenGeneration = ++client.initialScreenGeneration; const resized = this.negotiateSize(); if (!resized && protocol === "legacy") { - client.socket.write(encodeGeometry(this.terminal.rows, this.terminal.cols)); + this.writeClient(client, encodeGeometry(this.terminal.rows, this.terminal.cols)); } try { mutateMetadataUnderLock(this.name, (meta) => { @@ -1347,6 +1521,7 @@ export class PtyServer { _tag: "Ready", rows: this.terminal.rows, cols: this.terminal.cols, + outputRevision: this.outputRevision, inputModes: this.inputModeSnapshot(), screen: Buffer.from(this.serialize.serialize()), }) @@ -1374,6 +1549,10 @@ export class PtyServer { private handleClient(socket: net.Socket): void { const client: Client = { socket, + output: new BoundedClientOutput( + socket, + this.options.maxClientOutputBytes ?? DEFAULT_MAX_CLIENT_OUTPUT_BYTES, + ), reader: new PacketReader(), role: { _tag: "Command" }, initialScreenGeneration: 0, @@ -1403,7 +1582,7 @@ export class PtyServer { client, { rows, cols }, "machine-v2", - () => client.socket.write( + () => this.writeClient(client, encodeDaemonAdmissionV2({ _tag: "Accepted", protocol: MACHINE_PROTOCOL_VERSION, @@ -1438,11 +1617,12 @@ export class PtyServer { } case MessageType.PEEK: { + client.output.clearHeld(); client.role = { _tag: "Readonly", phase: { _tag: "Settling" } }; const initialScreenGeneration = ++client.initialScreenGeneration; const resized = this.negotiateSize(); if (!resized) { - socket.write(encodeGeometry(this.terminal.rows, this.terminal.cols)); + this.writeClient(client, encodeGeometry(this.terminal.rows, this.terminal.cols)); } const flags = packet.payload.length > 0 ? packet.payload.readUInt8(0) : 0; const plain = (flags & 1) !== 0; @@ -1486,13 +1666,13 @@ export class PtyServer { } case MessageType.DETACH: { - socket.end(); + client.output.end(); break; } case MessageType.STATUS: { const stats = this.collectStats(); - socket.write(encodeStatusResponse(JSON.stringify(stats))); + this.writeClient(client, encodeStatusResponse(JSON.stringify(stats))); break; } } @@ -1651,9 +1831,9 @@ export class PtyServer { const machinePacket = encodeMachineResponse({ _tag: "Geometry", rows, cols }); for (const client of this.clients.values()) { if (client.role._tag === "Legacy" || client.role._tag === "Readonly") { - client.socket.write(legacyPacket); + this.writeClient(client, legacyPacket); } else if (client.role._tag === "Machine" && client.role.phase === "live") { - client.socket.write(machinePacket); + this.writeClient(client, machinePacket); } } } @@ -1695,7 +1875,7 @@ export class PtyServer { ) { client.role = { ...client.role, - phase: { _tag: "Cutting", packets: [] }, + phase: { _tag: "Cutting", hasPendingExit: false }, }; } @@ -1709,13 +1889,28 @@ export class PtyServer { ) return; const role = client.role; - const pending = + const hasPendingExit = (role._tag === "Legacy" || role._tag === "Readonly") && - role.phase._tag === "Cutting" - ? role.phase.packets - : []; - const hasPendingExit = pending.some((item) => item.isExit); - client.socket.write(getInitialPacket()); + role.phase._tag === "Cutting" && role.phase.hasPendingExit; + let initialPacket: Buffer; + try { + initialPacket = getInitialPacket(); + } catch { + if (client.role._tag === "Machine") { + this.endMachineStream(client, { + _tag: "StreamFailure", + phase: "baseline", + reason: "unrepresentable-input-mode", + }); + } else { + client.output.destroy(); + } + return; + } + if (!client.output.releaseHeld(initialPacket)) { + this.handleSlowConsumer(client); + return; + } if (role._tag === "Legacy") { client.role = { ...role, phase: { _tag: "Live" } }; } else if (role._tag === "Readonly") { @@ -1723,9 +1918,8 @@ export class PtyServer { } else if (role._tag === "Machine" && role.phase === "syncing") { client.role = { ...role, phase: "live" }; } - for (const item of pending) client.socket.write(item.packet); if (this.exited && !hasPendingExit) { - client.socket.write( + this.writeClient(client, client.role._tag === "Machine" ? encodeMachineResponse({ _tag: "Exited", @@ -1747,9 +1941,16 @@ export class PtyServer { const role = client.role; if (role._tag !== "Legacy" && role._tag !== "Readonly") continue; if (role.phase._tag === "Live") { - client.socket.write(packet); + this.writeClient(client, packet); } else if (role.phase._tag === "Cutting") { - role.phase.packets.push({ isExit: type === MessageType.EXIT, packet }); + if (!client.output.hold(packet)) { + this.handleSlowConsumer(client); + } else if (type === MessageType.EXIT && !role.phase.hasPendingExit) { + client.role = { + ...role, + phase: { _tag: "Cutting", hasPendingExit: true }, + }; + } } } } @@ -1758,7 +1959,7 @@ export class PtyServer { const packet = encodeMachineResponse(response); for (const client of this.clients.values()) { if (client.role._tag === "Machine" && client.role.phase === "live") { - client.socket.write(packet); + this.writeClient(client, packet); } } } diff --git a/tests/machine-attach-v2-e2e.test.ts b/tests/machine-attach-v2-e2e.test.ts new file mode 100644 index 0000000..c83ecc7 --- /dev/null +++ b/tests/machine-attach-v2-e2e.test.ts @@ -0,0 +1,243 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + MACHINE_PROTOCOL_VERSION, + MachineFrameReader, + decodeMachineResponse, + encodeMachineRequest, + type MachineOpenV2, + type MachineResponse, + type MachineWireFrame, +} from "../src/machine-protocol.ts"; +import { PtyServer } from "../src/server.ts"; +import { readMetadata } from "../src/sessions.ts"; + +const servers: PtyServer[] = []; +const roots: string[] = []; +const adapters: ChildProcessWithoutNullStreams[] = []; +const originalRoot = process.env.PTY_ROOT; +const originalLegacyRoot = process.env.PTY_SESSION_DIR; + +afterEach(async () => { + for (const adapter of adapters.splice(0)) { + if (adapter.exitCode === null && adapter.signalCode === null) adapter.kill("SIGKILL"); + } + for (const server of servers.splice(0)) await server.close(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + if (originalRoot === undefined) delete process.env.PTY_ROOT; + else process.env.PTY_ROOT = originalRoot; + if (originalLegacyRoot === undefined) delete process.env.PTY_SESSION_DIR; + else process.env.PTY_SESSION_DIR = originalLegacyRoot; +}); + +function createRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-machine-v2-e2e-")); + roots.push(root); + process.env.PTY_ROOT = root; + delete process.env.PTY_SESSION_DIR; + return root; +} + +async function startServer( + name: string, + generation: string, + command: string, + args: readonly string[], +): Promise { + const server = new PtyServer({ + name, + generation, + command, + args: [...args], + displayCommand: command, + cwd: process.cwd(), + rows: 24, + cols: 80, + }); + servers.push(server); + await server.ready; + return server; +} + +function openRequest(name: string, expectedGeneration: string): MachineOpenV2 { + return { + _tag: "Open", + protocol: MACHINE_PROTOCOL_VERSION, + sessionId: name, + expectedGeneration, + rows: 24, + cols: 80, + requiredCapabilities: [ + "framed-utf8-input", + "typed-outcome", + "input-mode-snapshot", + ], + }; +} + +function spawnAdapter(root: string): ChildProcessWithoutNullStreams { + const child = spawn( + process.execPath, + [path.join(process.cwd(), "dist", "cli.js"), "machine-attach-v2"], + { + env: { ...process.env, PTY_ROOT: root, PTY_SESSION_DIR: undefined }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + adapters.push(child); + return child; +} + +function observeAdapter(child: ChildProcessWithoutNullStreams): { + readonly responses: MachineResponse[]; + readonly stdout: Buffer[]; + readonly stderr: Buffer[]; + waitFor: (predicate: (responses: readonly MachineResponse[]) => boolean) => Promise; + exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; +} { + const reader = new MachineFrameReader(); + const responses: MachineResponse[] = []; + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const waiters = new Set<() => void>(); + child.stdout.on("data", (chunk: Buffer) => { + const bytes = Buffer.from(chunk); + stdout.push(bytes); + responses.push(...reader.feed(bytes).map(decodeMachineResponse)); + for (const wake of waiters) wake(); + }); + child.stderr.on("data", (chunk: Buffer) => stderr.push(Buffer.from(chunk))); + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + return { + responses, + stdout, + stderr, + async waitFor(predicate) { + if (predicate(responses)) return; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + waiters.delete(check); + reject(new Error(`Timed out waiting for adapter response; received ${responses.map((response) => response._tag).join(", ")}`)); + }, 5_000); + const check = () => { + if (!predicate(responses)) return; + clearTimeout(timeout); + waiters.delete(check); + resolve(); + }; + waiters.add(check); + }); + }, + exited, + }; +} + +function decodeCompleteStream(bytes: Buffer): MachineResponse[] { + const frames: MachineWireFrame[] = []; + let offset = 0; + while (offset < bytes.length) { + if (bytes.length - offset < 5) throw new Error("Truncated machine frame header at EOF"); + const length = bytes.readUInt32BE(offset + 1); + const end = offset + 5 + length; + if (end > bytes.length) throw new Error("Truncated machine frame payload at EOF"); + frames.push({ type: bytes.readUInt8(offset), payload: bytes.subarray(offset + 5, end) }); + offset = end; + } + return frames.map(decodeMachineResponse); +} + +function terminalBytes(responses: readonly MachineResponse[]): Buffer { + return Buffer.concat(responses.flatMap((response) => { + if (response._tag === "Ready") return [response.screen]; + if (response._tag === "Data") return [response.bytes]; + return []; + })); +} + +function expectCausalDataStream(responses: readonly MachineResponse[]): void { + const ready = responses.find((response) => response._tag === "Ready"); + if (ready?._tag !== "Ready") throw new Error("Expected READY in accepted machine stream"); + let outputRevision = ready.outputRevision; + let inputModeRevision = ready.inputModes.revision; + for (const response of responses.slice(responses.indexOf(ready) + 1)) { + if (response._tag !== "Data") continue; + expect(response.outputRevision).toBe(outputRevision + 1); + expect(response.inputModeRevision).toBeGreaterThanOrEqual(inputModeRevision); + expect(response.inputModes !== undefined).toBe(response.inputModeRevision > inputModeRevision); + if (response.inputModes !== undefined) { + expect(response.inputModes.revision).toBe(response.inputModeRevision); + } + outputRevision = response.outputRevision; + inputModeRevision = response.inputModeRevision; + } +} + +describe.sequential("machine-attach-v2 process and socket integration", () => { + it("preserves terminal input bytes and reserves detach for the explicit frame", async () => { + const root = createRoot(); + const name = `machine-e2e-${process.pid}-bytes`; + const generation = "generation-bytes"; + const childScript = [ + "process.stdin.setRawMode?.(true)", + "process.stdin.resume()", + "process.stdout.write('RAW_READY\\r\\n')", + "let received = Buffer.alloc(0)", + "process.stdin.on('data', (chunk) => {", + " received = Buffer.concat([received, chunk])", + " if (received.length >= 6) process.stdout.write(`HEX:${received.subarray(0, 6).toString('hex')}\\r\\n`)", + "})", + "setInterval(() => {}, 1000)", + ].join(";"); + await startServer(name, generation, process.execPath, ["-e", childScript]); + + const adapter = spawnAdapter(root); + const observed = observeAdapter(adapter); + adapter.stdin.write(encodeMachineRequest(openRequest(name, generation))); + await observed.waitFor((responses) => responses.some((response) => response._tag === "Ready")); + expect(observed.responses.slice(0, 2).map((response) => response._tag)).toEqual(["Hello", "Ready"]); + + const input = Buffer.from([0x31, 0x32, 0x33, 0x1b, 0x62, 0x1c]); + adapter.stdin.write(encodeMachineRequest({ _tag: "Input", bytes: input })); + await observed.waitFor((responses) => terminalBytes(responses).includes(Buffer.from("HEX:3132331b621c"))); + expect(observed.responses.some((response) => response._tag === "Detached")).toBe(false); + + adapter.stdin.end(encodeMachineRequest({ _tag: "Detach" })); + await observed.waitFor((responses) => responses.at(-1)?._tag === "Detached"); + await expect(observed.exited).resolves.toEqual({ code: 0, signal: null }); + + const complete = decodeCompleteStream(Buffer.concat(observed.stdout)); + expect(complete.slice(0, 2).map((response) => response._tag)).toEqual(["Hello", "Ready"]); + expect(complete.at(-1)).toEqual({ _tag: "Detached" }); + expect(complete.some((response) => response._tag === "Exited" || response._tag.endsWith("Failure"))).toBe(false); + expectCausalDataStream(complete); + expect(terminalBytes(complete).includes(Buffer.from("HEX:3132331b621c"))).toBe(true); + expect(Buffer.concat(observed.stderr).toString()).toBe(""); + }); + + it("rejects a stale generation without mutating the live session", async () => { + const root = createRoot(); + const name = `machine-e2e-${process.pid}-stale`; + const generation = "generation-current"; + await startServer(name, generation, process.execPath, ["-e", "setInterval(() => {}, 1000)"]); + const before = readMetadata(name); + + const adapter = spawnAdapter(root); + const observed = observeAdapter(adapter); + adapter.stdin.write(encodeMachineRequest(openRequest(name, "generation-stale"))); + await expect(observed.exited).resolves.toEqual({ code: 0, signal: null }); + + const complete = decodeCompleteStream(Buffer.concat(observed.stdout)); + expect(complete).toHaveLength(1); + expect(complete[0]).toMatchObject({ + _tag: "AdmissionFailure", + reason: "generation-mismatch", + }); + expect(readMetadata(name)).toEqual(before); + expect(Buffer.concat(observed.stderr).toString()).toBe(""); + }); +}); diff --git a/tests/machine-attach-v2.test.ts b/tests/machine-attach-v2.test.ts index 87c51f0..9877343 100644 --- a/tests/machine-attach-v2.test.ts +++ b/tests/machine-attach-v2.test.ts @@ -26,15 +26,24 @@ import { } from "../src/protocol.ts"; const modes: MachineInputModeSnapshotV1 = { + schema: "pty.input-mode.v1", + wireEncoder: "xterm-input.v1", revision: 1, applicationCursorKeys: false, applicationKeypad: false, bracketedPaste: true, focusReporting: false, modifyOtherKeys: 0, - mouseTracking: "none", - mouseEncoding: "sgr", - kittyKeyboardFlags: [], + mouseTracking: "Off", + mouseEncoding: "Sgr", + mouseCoordinates: "Cell", + kittyKeyboardFlagsStack: [], +}; + +const advancedModes: MachineInputModeSnapshotV1 = { + ...modes, + revision: 2, + bracketedPaste: false, }; const open: MachineOpenV2 = { @@ -137,6 +146,7 @@ describe("machine-attach-v2 adapter", () => { }), encodeMachineResponse({ _tag: "Ready", + outputRevision: 0, rows: open.rows, cols: open.cols, inputModes: modes, @@ -145,8 +155,25 @@ describe("machine-attach-v2 adapter", () => { ])); } else { const request = decodeMachineRequest(packet); - if (request._tag === "Input") receivedInput.push(Buffer.from(request.bytes)); - if (request._tag === "Resize") receivedSizes.push({ rows: request.rows, cols: request.cols }); + if (request._tag === "Input") { + receivedInput.push(Buffer.from(request.bytes)); + socket.write(encodeMachineResponse({ + _tag: "Data", + outputRevision: 1, + inputModeRevision: modes.revision, + bytes: Buffer.from("echo"), + })); + } + if (request._tag === "Resize") { + receivedSizes.push({ rows: request.rows, cols: request.cols }); + socket.write(encodeMachineResponse({ + _tag: "Data", + outputRevision: 2, + inputModeRevision: advancedModes.revision, + inputModes: advancedModes, + bytes: Buffer.from("mode"), + })); + } if (request._tag === "Detach") socket.write(encodeMachineResponse({ _tag: "Detached" })); } } @@ -165,7 +192,22 @@ describe("machine-attach-v2 adapter", () => { expect(openSeen).toBe(true); expect(Buffer.concat(receivedInput)).toEqual(bytes); expect(receivedSizes).toEqual([{ rows: 30, cols: 100 }]); - expect(run.responses.map((response) => response._tag)).toEqual(["Hello", "Ready", "Detached"]); + expect(run.responses.map((response) => response._tag)).toEqual(["Hello", "Ready", "Data", "Data", "Detached"]); + expect(run.responses.slice(2, 4)).toEqual([ + { + _tag: "Data", + outputRevision: 1, + inputModeRevision: modes.revision, + bytes: Buffer.from("echo"), + }, + { + _tag: "Data", + outputRevision: 2, + inputModeRevision: advancedModes.revision, + inputModes: advancedModes, + bytes: Buffer.from("mode"), + }, + ]); }); it("turns a same-connection generation rejection into one typed outcome", async () => { @@ -315,6 +357,7 @@ describe("machine-attach-v2 adapter", () => { }), encodeMachineResponse({ _tag: "Ready", + outputRevision: 0, rows: open.rows, cols: open.cols, inputModes: modes, diff --git a/tests/machine-protocol.test.ts b/tests/machine-protocol.test.ts index 0c26c1f..4a436ca 100644 --- a/tests/machine-protocol.test.ts +++ b/tests/machine-protocol.test.ts @@ -20,15 +20,18 @@ import { } from "../src/machine-protocol.ts"; const modes: MachineInputModeSnapshotV1 = { + schema: "pty.input-mode.v1", + wireEncoder: "xterm-input.v1", revision: 7, applicationCursorKeys: true, applicationKeypad: false, bracketedPaste: true, focusReporting: false, modifyOtherKeys: 2, - mouseTracking: "any", - mouseEncoding: "sgr", - kittyKeyboardFlags: [1, 15], + mouseTracking: "AnyMotion", + mouseEncoding: "Sgr", + mouseCoordinates: "Cell", + kittyKeyboardFlagsStack: [1, 15], }; const open: MachineOpenV2 = { @@ -83,28 +86,22 @@ describe("machine attach v2 protocol", () => { it("preserves arbitrary bytes within the framed transport", () => { const bytes = Buffer.from([0x00, 0xff, 0x1b, 0x62, 0x1c]); for (const frame of [ - { _tag: "Data" as const, bytes }, + { _tag: "Data" as const, outputRevision: 8, inputModeRevision: 7, bytes }, { _tag: "Ready" as const, + outputRevision: 7, rows: 24, cols: 80, inputModes: modes, screen: bytes, }, - { - _tag: "Snapshot" as const, - rows: 30, - cols: 100, - inputModes: { ...modes, revision: 8 }, - screen: bytes, - }, ]) { const reader = new MachineFrameReader(); const [wireFrame] = reader.feed(encodeMachineResponse(frame)); const decoded = decodeMachineResponse(wireFrame); expect(decoded._tag).toBe(frame._tag); if (decoded._tag === "Data") expect(decoded.bytes).toEqual(bytes); - else if (decoded._tag === "Ready" || decoded._tag === "Snapshot") expect(decoded.screen).toEqual(bytes); + else if (decoded._tag === "Ready") expect(decoded.screen).toEqual(bytes); else throw new Error(`unexpected decoded frame ${decoded._tag}`); } }); @@ -162,25 +159,41 @@ describe("machine attach v2 protocol", () => { const [invalidFrame] = new MachineFrameReader().feed(invalidUtf8); expect(() => decodeMachineRequest(invalidFrame)).toThrow(/Invalid machine protocol JSON/); - const invalidModes = encodeMachineResponse({ - _tag: "InputModes", - inputModes: { ...modes, modifyOtherKeys: 3 as 2 }, - }); - const [invalidModesFrame] = new MachineFrameReader().feed(invalidModes); - expect(() => decodeMachineResponse(invalidModesFrame)).toThrow(/modifyOtherKeys/); + expect(() => + encodeMachineResponse({ + _tag: "Data", + outputRevision: 8, + inputModeRevision: 7, + inputModes: { ...modes, modifyOtherKeys: 3 as 2 }, + bytes: Buffer.from("x"), + }), + ).toThrow(/modifyOtherKeys/); }); it("round-trips the complete key and mouse mode vocabulary", () => { const inputModes: MachineInputModeSnapshotV1 = { ...modes, - mouseTracking: "x10", - mouseEncoding: "sgr-pixels", + mouseTracking: "X10Press", + mouseEncoding: "Sgr", + mouseCoordinates: "Pixel", modifyOtherKeys: 1, }; const [frame] = new MachineFrameReader().feed( - encodeMachineResponse({ _tag: "InputModes", inputModes }) + encodeMachineResponse({ + _tag: "Data", + outputRevision: 8, + inputModeRevision: inputModes.revision, + inputModes, + bytes: Buffer.from("mode-change"), + }) ); - expect(decodeMachineResponse(frame)).toEqual({ _tag: "InputModes", inputModes }); + expect(decodeMachineResponse(frame)).toEqual({ + _tag: "Data", + outputRevision: 8, + inputModeRevision: inputModes.revision, + inputModes, + bytes: Buffer.from("mode-change"), + }); }); it("accepts only HELLO then READY then updates then one outcome then EOF", () => { @@ -191,8 +204,20 @@ describe("machine attach v2 protocol", () => { capabilities: ["framed-utf8-input", "typed-outcome", "input-mode-snapshot"], build: { version: "0.12.0", dirty: false }, }; - const ready = { _tag: "Ready" as const, rows: 24, cols: 80, inputModes: modes, screen: Buffer.from("$ ") }; - const data = { _tag: "Data" as const, bytes: Buffer.from("ok") }; + const ready = { + _tag: "Ready" as const, + outputRevision: 41, + rows: 24, + cols: 80, + inputModes: modes, + screen: Buffer.from("$ "), + }; + const data = { + _tag: "Data" as const, + outputRevision: 42, + inputModeRevision: 7, + bytes: Buffer.from("ok"), + }; const exited = { _tag: "Exited" as const, code: 0, signal: null }; let state = reduceMachineAttach({ _tag: "AwaitAdmission" }, { _tag: "Frame", frame: hello }); @@ -230,7 +255,7 @@ describe("machine attach v2 protocol", () => { } }); - it("orders input mode updates by their explicit revision", () => { + it("applies output and changed input mode as one causal DATA transition", () => { const hello: MachineHelloV2 = { _tag: "Hello" as const, protocol: MACHINE_PROTOCOL_VERSION, @@ -241,18 +266,36 @@ describe("machine attach v2 protocol", () => { const awaitingReady = reduceMachineAttach({ _tag: "AwaitAdmission" }, { _tag: "Frame", frame: hello }); const streaming = reduceMachineAttach(awaitingReady, { _tag: "Frame", - frame: { _tag: "Ready", rows: 24, cols: 80, inputModes: modes, screen: Buffer.alloc(0) }, + frame: { + _tag: "Ready", + outputRevision: 41, + rows: 24, + cols: 80, + inputModes: modes, + screen: Buffer.alloc(0), + }, }); - expect(streaming).toEqual({ _tag: "Streaming", inputModeRevision: 7 }); + expect(streaming).toEqual({ _tag: "Streaming", inputModeRevision: 7, outputRevision: 41 }); const advanced = reduceMachineAttach(streaming, { _tag: "Frame", - frame: { _tag: "InputModes", inputModes: { ...modes, revision: 8 } }, + frame: { + _tag: "Data", + outputRevision: 42, + inputModeRevision: 8, + inputModes: { ...modes, revision: 8 }, + bytes: Buffer.from("set mode"), + }, }); - expect(advanced).toMatchObject({ _tag: "Streaming", inputModeRevision: 8 }); + expect(advanced).toMatchObject({ _tag: "Streaming", inputModeRevision: 8, outputRevision: 42 }); expect( reduceMachineAttach(advanced, { _tag: "Frame", - frame: { _tag: "InputModes", inputModes: { ...modes, revision: 8 } }, + frame: { + _tag: "Data", + outputRevision: 43, + inputModeRevision: 9, + bytes: Buffer.from("missing mode"), + }, }), ).toMatchObject({ _tag: "ProtocolViolation" }); }); @@ -264,7 +307,10 @@ describe("machine attach v2 protocol", () => { expect( reduceMachineAttach( { _tag: "AwaitAdmission" }, - { _tag: "Frame", frame: { _tag: "Data", bytes: Buffer.from("early") } }, + { + _tag: "Frame", + frame: { _tag: "Data", outputRevision: 1, inputModeRevision: 0, bytes: Buffer.from("early") }, + }, ), ).toMatchObject({ _tag: "ProtocolViolation" }); }); diff --git a/tests/machine-server-v2.test.ts b/tests/machine-server-v2.test.ts index f750096..5ef81b6 100644 --- a/tests/machine-server-v2.test.ts +++ b/tests/machine-server-v2.test.ts @@ -11,7 +11,9 @@ import { decodeMachineResponse, encodeDaemonOpenV2, encodeMachineRequest, + encodeMachineResponse, type MachineOpenV2, + type MachineResponse, type MachineWireFrame, } from "../src/machine-protocol.ts"; import { MessageType, PacketReader, encodeStatus } from "../src/protocol.ts"; @@ -50,6 +52,7 @@ async function startServer( command = "cat", args: string[] = [], generation = "generation-a", + maxClientOutputBytes?: number, ): Promise { const server = new PtyServer({ name, @@ -60,6 +63,7 @@ async function startServer( cwd: testCwd, rows: 24, cols: 80, + maxClientOutputBytes, }); servers.push(server); await server.ready; @@ -235,7 +239,7 @@ describe("machine attach v2 daemon admission", () => { opened.socket.destroy(); }); - it("orders mode-establishing Data before one complete mode snapshot", async () => { + it("atomically stamps a mode-changing burst and omits unchanged snapshots", async () => { const name = uniqueName(); const modeBytes = [ "\x1b[?1h", @@ -252,41 +256,157 @@ describe("machine attach v2 daemon admission", () => { "\x1b[>10u", "\x1b[<2u", ].join(""); - const script = [ - "process.stdin.once('data', () => process.stdout.write(Buffer.from(process.argv[1], 'base64')))", - "setInterval(() => {}, 1000)", - ].join(";"); - await startServer(name, process.execPath, ["-e", script, Buffer.from(modeBytes).toString("base64")]); + const server = await startServer(name); const { socket, frames } = await openMachine(name); - socket.write(encodeMachineRequest({ _tag: "Input", bytes: Buffer.from("go\n") })); - await frames.waitFor((received) => received.some((frame, index) => - index >= 2 && decodeMachineResponse(frame)._tag === "InputModes" - )); + const handlePtyOutput = (server as unknown as { + handlePtyOutput: (data: string) => void; + }).handlePtyOutput.bind(server); + handlePtyOutput(modeBytes); + handlePtyOutput("PLAIN_OUTPUT"); + await frames.waitFor((received) => received.some((frame, index) => { + if (index < 2) return false; + const response = decodeMachineResponse(frame); + return response._tag === "Data" && response.inputModes !== undefined; + })); + const ready = decodeMachineResponse(frames.frames[1]); + if (ready._tag !== "Ready") throw new Error("expected Ready baseline"); const responses = frames.frames.slice(2).map(decodeMachineResponse); - const inputModesIndex = responses.findIndex((response) => response._tag === "InputModes"); - const establishingDataIndex = responses.findIndex((response) => + const establishingData = responses.find((response) => response._tag === "Data" && response.bytes.includes(Buffer.from("\x1b[?1h")) ); - expect(establishingDataIndex).toBeGreaterThanOrEqual(0); - expect(inputModesIndex).toBeGreaterThan(establishingDataIndex); - expect(responses[inputModesIndex]).toMatchObject({ - _tag: "InputModes", + expect(establishingData).toMatchObject({ + _tag: "Data", inputModes: { + schema: "pty.input-mode.v1", + wireEncoder: "xterm-input.v1", applicationCursorKeys: true, applicationKeypad: true, bracketedPaste: true, focusReporting: true, modifyOtherKeys: 2, - mouseTracking: "x10", - mouseEncoding: "sgr-pixels", - kittyKeyboardFlags: [6], + mouseTracking: "X10Press", + mouseEncoding: "Sgr", + mouseCoordinates: "Pixel", + kittyKeyboardFlagsStack: [6], }, }); - expect(responses.filter((response) => response._tag === "InputModes")).toHaveLength(1); + if (establishingData?._tag !== "Data") throw new Error("expected mode-establishing Data"); + expect(establishingData.outputRevision).toBe(ready.outputRevision + 1); + expect(establishingData.inputModeRevision).toBe(establishingData.inputModes?.revision); + + await frames.waitFor((received) => received.some((frame, index) => { + if (index < 2) return false; + const response = decodeMachineResponse(frame); + return response._tag === "Data" && response.bytes.includes(Buffer.from("PLAIN_OUTPUT")); + })); + const unchangedData = frames.frames.slice(2).map(decodeMachineResponse).find((response) => + response._tag === "Data" && response.bytes.includes(Buffer.from("PLAIN_OUTPUT")) + ); + expect(unchangedData).toMatchObject({ + _tag: "Data", + inputModeRevision: establishingData.inputModeRevision, + }); + if (unchangedData?._tag !== "Data") throw new Error("expected unchanged-mode Data"); + expect(unchangedData.outputRevision).toBe(establishingData.outputRevision + 1); + expect(unchangedData.inputModes).toBeUndefined(); socket.destroy(); }); + it("commits revisions at the parser cut and does not count stripped query output", async () => { + const name = uniqueName(); + const script = [ + "process.stdin.setRawMode(true)", + "process.stdin.on('data', chunk => {", + " if (chunk.includes(0x71)) process.stdout.write('\\x1b[c')", + " if (chunk.includes(0x76)) process.stdout.write('VISIBLE')", + "})", + "setInterval(() => {}, 1000)", + ].join(";"); + const server = await startServer(name, process.execPath, ["-e", script]); + const terminal = (server as unknown as { terminal: Terminal }).terminal; + const ptyProcess = (server as unknown as { ptyProcess: { write: (data: string) => void } }).ptyProcess; + const originalWrite = terminal.write.bind(terminal); + const pending: Array<{ data: string; callback?: () => void }> = []; + terminal.write = ((data: string, callback?: () => void) => { + pending.push({ data, callback }); + }) as typeof terminal.write; + + const socket = await connect(name); + const frames = recordFrames(socket); + try { + socket.write(Buffer.concat([encodeDaemonOpenV2(openRequest(name)), encodeStatus()])); + await frames.waitFor((received) => received.length >= 1); + ptyProcess.write("late"); + await expect.poll(() => pending.some((item) => item.data.length > 0)).toBe(true); + + const cut = pending.find((item) => item.data.length === 0); + const late = pending.find((item) => item.data.length > 0); + expect(cut).toBeDefined(); + expect(late).toBeDefined(); + cut!.callback?.(); + await frames.waitFor((received) => received.length >= 2); + const ready = decodeMachineResponse(frames.frames[1]); + expect(ready).toMatchObject({ _tag: "Ready", outputRevision: 0 }); + + late!.callback?.(); + await frames.waitFor((received) => received.length >= 3); + const lateData = decodeMachineResponse(frames.frames[2]); + expect(lateData).toMatchObject({ _tag: "Data", outputRevision: 1 }); + } finally { + terminal.write = originalWrite; + socket.destroy(); + } + + const queryName = uniqueName(); + const queryObserved = path.join(testCwd, `${queryName}-query-observed`); + const queryScript = [ + "const fs = require('node:fs')", + "process.stdin.setRawMode(true)", + "let awaitingReply = false", + "process.stdout.write('RAW_READY')", + "process.stdin.on('data', chunk => {", + " if (chunk.includes(0x71)) { awaitingReply = true; process.stdout.write('\\x1b[c'); return }", + " if (awaitingReply && chunk.includes(0x1b)) { awaitingReply = false; fs.writeFileSync(process.argv[1], '') }", + " if (chunk.includes(0x76)) process.stdout.write('VISIBLE')", + "})", + "setInterval(() => {}, 1000)", + ].join(";"); + const revisionServer = await startServer( + queryName, + process.execPath, + ["-e", queryScript, queryObserved], + ); + const opened = await openMachine(queryName); + const baseline = decodeMachineResponse(opened.frames.frames[1]); + if (baseline._tag !== "Ready") throw new Error("expected Ready baseline"); + await opened.frames.waitFor((received) => received.some((frame, index) => { + if (index < 1) return false; + const response = decodeMachineResponse(frame); + return (response._tag === "Ready" && response.screen.includes(Buffer.from("RAW_READY"))) || + (response._tag === "Data" && response.bytes.includes(Buffer.from("RAW_READY"))); + })); + const beforeQueryRevision = (revisionServer as unknown as { outputRevision: number }).outputRevision; + opened.socket.write(encodeMachineRequest({ _tag: "Input", bytes: Buffer.from("q") })); + await expect.poll(() => fs.existsSync(queryObserved)).toBe(true); + expect((revisionServer as unknown as { outputRevision: number }).outputRevision) + .toBe(beforeQueryRevision); + opened.socket.write(encodeMachineRequest({ _tag: "Input", bytes: Buffer.from("v") })); + await opened.frames.waitFor((received) => received.some((frame, index) => { + if (index < 2) return false; + const response = decodeMachineResponse(frame); + return response._tag === "Data" && response.bytes.includes(Buffer.from("VISIBLE")); + })); + const visible = opened.frames.frames.slice(2).map(decodeMachineResponse).find((response) => + response._tag === "Data" && response.bytes.includes(Buffer.from("VISIBLE")) + ); + expect(visible).toMatchObject({ + _tag: "Data", + outputRevision: beforeQueryRevision + 1, + }); + opened.socket.destroy(); + }); + it("allows explicit detach after admission while Ready is still syncing", async () => { const name = uniqueName(); const server = await startServer(name); @@ -307,4 +427,97 @@ describe("machine attach v2 daemon admission", () => { terminal.write = originalWrite; } }); + + it("fails only a slow machine consumer without pausing or reordering its peer", async () => { + const name = uniqueName(); + const normalOutputLimit = 1024; + const dataEnvelopeBytes = encodeMachineResponse({ + _tag: "Data", + bytes: Buffer.from([0x61]), + outputRevision: 1, + inputModeRevision: 0, + }).length - 1; + const firstResponse = { + _tag: "Data" as const, + bytes: Buffer.alloc(normalOutputLimit - dataEnvelopeBytes, 0x61), + outputRevision: 1, + inputModeRevision: 0, + }; + expect(encodeMachineResponse(firstResponse)).toHaveLength(normalOutputLimit); + const server = await startServer( + name, + "cat", + [], + "generation-a", + normalOutputLimit, + ); + const slow = await openMachine(name); + const healthy = await openMachine(name); + const clients = (server as unknown as { + clients: Map; + broadcastMachine: (response: { _tag: "Data"; bytes: Buffer }) => void; + }).clients; + const slowServerClient = [...clients.values()].find( + (client) => client.role._tag === "Machine" && client.socket.remotePort === slow.socket.localPort, + ); + expect(slowServerClient).toBeDefined(); + + const socket = slowServerClient!.socket; + const captured: Buffer[] = []; + let simulatedWritableLength = 0; + let acceptWrites = false; + let ended = false; + const originalWrite = socket.write; + const originalEnd = socket.end; + Object.defineProperty(socket, "writableLength", { + configurable: true, + get: () => simulatedWritableLength, + }); + socket.write = ((packet: string | Uint8Array) => { + const copy = Buffer.from(packet); + captured.push(copy); + simulatedWritableLength = acceptWrites ? 0 : simulatedWritableLength + copy.length; + return acceptWrites; + }) as typeof socket.write; + socket.end = (() => { + ended = true; + return socket; + }) as typeof socket.end; + + try { + const broadcast = (server as unknown as { + broadcastMachine: (response: MachineResponse) => void; + }).broadcastMachine.bind(server); + broadcast(firstResponse); + broadcast({ _tag: "Data", bytes: Buffer.from("overflow"), outputRevision: 2, inputModeRevision: 0 }); + + await healthy.frames.waitFor((frames) => + frames.slice(2).filter((frame) => decodeMachineResponse(frame)._tag === "Data").length >= 2 + ); + expect( + healthy.frames.frames.slice(2).map(decodeMachineResponse) + .filter((response) => response._tag === "Data") + .map((response) => response.bytes[0]), + ).toEqual([0x61, 0x6f]); + expect(captured).toHaveLength(1); + + simulatedWritableLength = 0; + acceptWrites = true; + socket.emit("drain"); + expect(captured.map((frame) => decodeMachineResponse({ + type: frame.readUInt8(0), + payload: frame.subarray(5), + }))).toEqual([ + firstResponse, + { _tag: "StreamFailure", phase: "stream", reason: "slow-consumer" }, + ]); + expect(ended).toBe(true); + } finally { + socket.write = originalWrite; + socket.end = originalEnd; + delete (socket as unknown as { writableLength?: number }).writableLength; + slow.socket.destroy(); + healthy.socket.destroy(); + } + }); }); From 213431d9a61eb2c3cc7e96bbf0caf9128b37c8b5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:46:56 +0200 Subject: [PATCH 07/10] refactor: remove legacy machine attach path --- CHANGELOG.md | 23 +- README.md | 2 +- completions/pty.bash | 5 +- completions/pty.fish | 1 - completions/pty.zsh | 1 - docs/client.md | 42 +-- docs/vrs/spec.md | 2 +- src/cli.ts | 43 +-- src/client.ts | 139 +------- src/completions.ts | 5 - tests/attach-stream.test.ts | 619 ------------------------------------ tests/completions.test.ts | 27 -- tests/help.test.ts | 10 + 13 files changed, 65 insertions(+), 854 deletions(-) delete mode 100644 tests/attach-stream.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e48992..77579df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,18 +84,17 @@ notification because pty does not journal a cross-file transaction. fail closed while listing the candidate stable ids. Fabric remote routing uses the same rule on the target host. -### Framed machine attach stream - -- `pty attach --attach-stream-fd-v1 ` keeps stdin/stdout as the - controlling terminal while writing ordered, existing-protocol `GEOMETRY`, - `SCREEN`, and `DATA` packets plus a terminal `EXIT` or `DETACH` outcome to a - dedicated inherited descriptor. Intentional local detach is framed and - flushed even when it occurs before the initial daemon baseline, so consumers - can distinguish it from a truncated stream. - The descriptor remains caller-owned and must be closed by the caller for - consumers to observe EOF. Invalid descriptors, write failures, and daemons - that do not provide the v1 geometry-first contract fail clearly on stderr. -- Ordinary interactive attach rendering and resize behavior are unchanged. +### Headless machine attach + +- `pty machine-attach-v2` is the sole machine-facing attach API. It owns stdin + and stdout as bounded, direction-specific framed streams and admits one exact + stable session id and expected daemon generation on one socket. Successful + admission emits `HELLO`, an optional atomic `READY` baseline with ordered + terminal updates, one typed terminal outcome, and EOF. Admission failures + are typed separately from failures after `HELLO`. +- The adapter never resolves display-name aliases, restarts a session, reserves + terminal input bytes, or falls back to interactive attach. `pty attach` + remains exclusively interactive. ### Stream-ordered effective geometry for embedded clients diff --git a/README.md b/README.md index 4205e36..ed90b02 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ pty list --filter-tag role=web # show only sessions with matching tag pty attach myserver # reconnect to a session pty attach -r myserver # reconnect, auto-restart if exited pty attach --no-restart myserver # attach only; fail if not running -pty attach --attach-stream-fd-v1 3 myserver 3>events.bin # framed machine stream +pty machine-attach-v2 < request.frames > response.frames # framed terminal-host API pty exec -- codex # replace this session's process (inside a session) pty peek myserver # print current screen and exit pty peek --plain myserver # print as plain text (no ANSI) diff --git a/completions/pty.bash b/completions/pty.bash index c480fbf..4aa7e82 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -27,11 +27,8 @@ _pty() { COMPREPLY=($(compgen -W "-d --detach -a --attach -e --ephemeral --id --name --no-display-name --tag --env --unset-env --cwd --isolate-env --force" -- "${cur}")) ;; attach|a) - if [[ "${prev}" == "--attach-stream-fd-v1" ]]; then - return - fi if [[ "${cur}" == -* ]]; then - COMPREPLY=($(compgen -W "-r --auto-restart --no-restart --force --remote --attach-stream-fd-v1" -- "${cur}")) + COMPREPLY=($(compgen -W "-r --auto-restart --no-restart --force --remote" -- "${cur}")) else COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi diff --git a/completions/pty.fish b/completions/pty.fish index 862f8e6..29119de 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -83,7 +83,6 @@ complete -c pty -n '__pty_using_command attach a' -l auto-restart -s r -d 'Auto- complete -c pty -n '__pty_using_command attach a' -l no-restart -d 'Attach only; never prompt or restart an exited session' complete -c pty -n '__pty_using_command attach a' -l force -d 'Attach even from inside another pty' complete -c pty -n '__pty_using_command attach a' -l remote -d 'Attach a session on a fabric peer' -complete -c pty -n '__pty_using_command attach a' -l attach-stream-fd-v1 -x -d 'Write framed machine events to an inherited fd' complete -c pty -n '__pty_using_command attach a' -a '(__pty_sessions)' -d 'Session' complete -c pty -n '__pty_using_command exec' -F complete -c pty -n '__pty_using_command peek' -l follow -s f -d 'Follow output read-only' diff --git a/completions/pty.zsh b/completions/pty.zsh index 382c4ba..2126f66 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -76,7 +76,6 @@ _pty() { '--no-restart[Attach only; never prompt or restart an exited session]' \ '--force[Attach even from inside another pty]' \ '--remote[Attach a session on a fabric peer]' \ - '--attach-stream-fd-v1[Write framed machine events to an inherited fd]:fd:' \ '1:session:_pty_sessions' ;; machine-attach-v2) diff --git a/docs/client.md b/docs/client.md index beacc56..a6406b6 100644 --- a/docs/client.md +++ b/docs/client.md @@ -367,22 +367,24 @@ These functions use `process.stdin`/`process.stdout` directly and may call `proc Interactive attach with bidirectional I/O. Takes over stdin/stdout. Ctrl+\ to detach (double-tap to send through). -Set `attachStreamFdV1` to a writable inherited descriptor (3 or greater) for -machine mode. stdin and stdout remain the controlling terminal for input and -resize events, but terminal output is written only to that descriptor using the -existing protocol framing. Version 1 emits ordered `GEOMETRY`, `SCREEN`, and -`DATA` packets followed by one terminal outcome: `EXIT` when the session process -ends or `DETACH` when the local user intentionally detaches. `DETACH` may be the -first packet when the user detaches before the daemon supplies its initial -baseline. Each initial attach or reconnect otherwise starts with `GEOMETRY`; a -daemon that sends terminal data first is rejected as unsupported. - -The descriptor remains caller-owned. `attach()` flushes its writer but does not -close the descriptor, so a consumer sees EOF only when the caller closes its -copy (or the process exits). A clean EOF follows a framed `EXIT` or `DETACH`; -EOF without either outcome is a truncated stream. Descriptor errors fail the -attach and are reported on stderr; stderr text is never written into the framed -stream. +This API is exclusively interactive. Terminal hosts use `machineAttachV2()` or +the equivalent `pty machine-attach-v2` command instead. + +### `machineAttachV2(options?: MachineAttachV2Options): Promise` + +Bridge one headless, framed stdin/stdout stream to an exact running session. +The first request is an `Open` frame containing the stable session id, expected +daemon generation, initial geometry, and required capabilities. The adapter +uses one daemon socket and never resolves aliases, restarts sessions, reserves +input bytes, or falls back to interactive attach. + +After successful admission, responses are ordered as `Hello`, an optional +atomic `Ready` baseline with zero or more `Data`/`Geometry` updates, one typed +`Exited`, `Detached`, or `StreamFailure` outcome, then EOF. Rejection before +`Hello` produces `AdmissionFailure`. Request and response frames are +direction-specific; use `encodeMachineRequest`/`decodeMachineResponse` and +`MachineFrameReader` from `@compoundingtech/pty/client` rather than the +interactive packet protocol. ### `peek(options: PeekOptions): void` @@ -501,11 +503,9 @@ const MessageType = { }; ``` -`DETACH` always has an empty payload. On the session socket it requests that -the client connection detach. On `--attach-stream-fd-v1`, it is the terminal -outcome for an intentional local detach and is flushed before clean completion. -Clean EOF follows either `DETACH` or `EXIT`; EOF without either outcome is a -truncated stream. +`DETACH` always has an empty payload. On the interactive session socket it +requests that the client connection detach. Machine attach uses its own typed, +direction-specific request and response frames. Packet types are length-delimited. Clients predating `GEOMETRY` ignore the unknown bounded packet and continue with following `SCREEN`/`DATA`, preserving diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 780a25e..b748079 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -330,7 +330,7 @@ input, resize, and multi-client geometry without mocks. | R05 | [server](../../src/server.ts) | [integration](../../tests/integration.test.ts) | | R06 | [server](../../src/server.ts), [protocol](../../src/protocol.ts) | [effective geometry](../../tests/effective-geometry.test.ts), [resize](../../tests/resize-tui.test.ts), [status](../../tests/stats-cli.test.ts) | | R07 | [protocol](../../src/protocol.ts), [machine protocol](../../src/machine-protocol.ts), [connection](../../src/connection.ts), [remote](../../src/remote.ts) | [protocol](../../tests/protocol.test.ts), [machine protocol](../../tests/machine-protocol.test.ts), [connection](../../tests/connection.test.ts), [remote reconnect](../../tests/remote-reconnect.test.ts) | -| R08 | [machine protocol](../../src/machine-protocol.ts), [client](../../src/client.ts), [CLI](../../src/cli.ts), [entrypoint](../../bin/pty) | [machine protocol](../../tests/machine-protocol.test.ts), [attach stream](../../tests/attach-stream.test.ts), [signals](../../tests/wrapper-signal-forwarding.test.ts) | +| R08 | [machine protocol](../../src/machine-protocol.ts), [machine adapter](../../src/machine-attach.ts), [CLI](../../src/cli.ts), [entrypoint](../../bin/pty) | [machine protocol](../../tests/machine-protocol.test.ts), [machine adapter](../../tests/machine-attach-v2.test.ts), [machine process and socket](../../tests/machine-attach-v2-e2e.test.ts), [signals](../../tests/wrapper-signal-forwarding.test.ts) | | R09 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [recovery](../../src/recovery.ts), [CLI](../../src/cli.ts) | [root](../../tests/pty-root.test.ts), [display name](../../tests/display-name.test.ts), [status](../../tests/stats-cli.test.ts), [list purity](../../tests/list-purity.test.ts), [recovery](../../tests/recovery.test.ts) | | R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) | | R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) | diff --git a/src/cli.ts b/src/cli.ts index 3042c61..da40f01 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import * as readline from "node:readline/promises"; import { spawnSync, execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, type StatsResult } from "./client.ts"; +import { attach, peek, send, queryStats, resolveSeqDelayMs, type StatsResult } from "./client.ts"; import { printVersion } from "./version.ts"; import { parseSeqValue } from "./keys.ts"; import { @@ -130,7 +130,7 @@ Examples: pty run -- node server.js pty run -d --name "API" --tag role=web --env PORT=3000 -- node server.js`, - attach: `Usage: pty attach [-r|--no-restart] [--force] [--remote ] [--attach-stream-fd-v1 ] + attach: `Usage: pty attach [-r|--no-restart] [--force] [--remote ] Reconnect to a session (alias: pty a). Detach again with Ctrl+\\. @@ -141,10 +141,6 @@ Flags: --force Attach even from inside another pty session (nested) --remote Attach a session on a fabric peer (over fabric); is the session's name/id ON THE REMOTE - --attach-stream-fd-v1 - Machine mode for a running session. Write ordered framed - GEOMETRY, SCREEN, DATA, and terminal EXIT or DETACH outcome - to inherited fd (>= 3); keep stdin/stdout controlling TTY Examples: pty attach myserver @@ -968,20 +964,12 @@ async function main(): Promise { let force = false; let attachName: string | null = null; let attachRemotePeer: string | null = null; - let attachStreamFdV1: number | undefined; for (let ai = 1; ai < args.length; ai++) { const a = args[ai]; if (a === "--auto-restart" || a === "-r") autoRestart = true; else if (a === "--no-restart") noRestart = true; else if (a === "--force") force = true; else if (a === "--remote" && ai + 1 < args.length) { attachRemotePeer = args[++ai]; } - else if (a === "--attach-stream-fd-v1") { - if (ai + 1 >= args.length) { - console.error("pty attach: --attach-stream-fd-v1 requires a file descriptor"); - process.exit(1); - } - attachStreamFdV1 = Number(args[++ai]); - } else if (!attachName) attachName = a; else { console.error(`pty attach: unexpected argument "${a}"`); @@ -996,18 +984,6 @@ async function main(): Promise { console.error("pty attach: --auto-restart and --no-restart are mutually exclusive"); process.exit(1); } - if (attachStreamFdV1 !== undefined) { - try { - validateAttachStreamFdV1(attachStreamFdV1); - } catch (error) { - console.error(`pty attach: ${(error as Error).message}`); - process.exit(1); - } - if (autoRestart) { - console.error("pty attach: --attach-stream-fd-v1 and --auto-restart are mutually exclusive"); - process.exit(1); - } - } // Nesting guard runs BEFORE name validation / ref resolution. A nested // caller gets the informative nesting message even if they mistyped // the session name — otherwise they'd fix the typo, try again, and @@ -1022,12 +998,12 @@ async function main(): Promise { }); if (attachRemotePeer) { // The name is the session's id ON THE REMOTE — don't resolve locally. - await cmdAttachRemote(attachRemotePeer, attachName, attachStreamFdV1); + await cmdAttachRemote(attachRemotePeer, attachName); } else { const resolvedAttachName = await resolveRef(attachName); const restartPolicy: AttachRestartPolicy = - attachStreamFdV1 !== undefined || noRestart ? "never" : autoRestart ? "always" : "prompt"; - await cmdAttach(resolvedAttachName, restartPolicy, force, attachStreamFdV1); + noRestart ? "never" : autoRestart ? "always" : "prompt"; + await cmdAttach(resolvedAttachName, restartPolicy, force); } break; } @@ -1758,7 +1734,6 @@ async function cmdAttach( name: string, restartPolicy: AttachRestartPolicy = "prompt", _force = false, - attachStreamFdV1?: number, ): Promise { // Nesting guard runs in the dispatcher (before name resolution) so the // user gets the nesting hint even for typo'd refs. cmdAttach itself is @@ -1773,7 +1748,7 @@ async function cmdAttach( } if (session.status === "running") { - doAttach(name, attachStreamFdV1); + doAttach(name); return; } @@ -1837,10 +1812,9 @@ async function handleDeadSession( doAttach(session.name); } -function doAttach(name: string, attachStreamFdV1?: number): void { +function doAttach(name: string): void { attach({ name, - ...(attachStreamFdV1 !== undefined ? { attachStreamFdV1 } : {}), onDetach: () => process.exit(0), onExit: (code) => process.exit(code), }); @@ -2054,7 +2028,7 @@ async function cmdSendRemote( /** `pty attach --remote `: dial the peer's exposed pty control * socket over fabric, route it to the named remote session, and attach over * that tunnel — the resilient shell is a long-lived remote pty you attach to. */ -async function cmdAttachRemote(peer: string, name: string, attachStreamFdV1?: number): Promise { +async function cmdAttachRemote(peer: string, name: string): Promise { let socket; try { socket = await dialAndRoute(peer, name); @@ -2065,7 +2039,6 @@ async function cmdAttachRemote(peer: string, name: string, attachStreamFdV1?: nu attach({ name, socket, - ...(attachStreamFdV1 !== undefined ? { attachStreamFdV1 } : {}), // On a loud fabric close, re-dial + re-route to the same remote session and // re-attach (the daemon replays its screen, so the session resumes). A // recoverable transport stall keeps the socket open, so it's just waited out. diff --git a/src/client.ts b/src/client.ts index 3718c11..7e5081e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,10 +1,8 @@ import * as net from "node:net"; import * as tty from "node:tty"; -import * as fs from "node:fs"; import { MessageType, PacketReader, - encodePacket, encodeAttach, encodeData, encodeDetach, @@ -406,26 +404,6 @@ export interface AttachOptions { * A recoverable stall keeps the socket open (no close event), so reconnect * fires only on a genuine close (fabric's loud give-up), never on a stall. */ reconnect?: () => Promise; - /** Write the ordered v1 machine attach stream to this caller-owned inherited - * descriptor. stdin/stdout remain the controlling terminal for input and - * geometry; terminal output is emitted only as framed protocol packets. */ - attachStreamFdV1?: number; -} - -/** Validate a dedicated inherited descriptor without taking ownership of it. */ -export function validateAttachStreamFdV1(fd: number): void { - if (!Number.isSafeInteger(fd) || fd < 3) { - throw new Error( - `--attach-stream-fd-v1 requires a dedicated inherited file descriptor >= 3 (got ${fd})`, - ); - } - try { - fs.fstatSync(fd); - fs.writeSync(fd, Buffer.alloc(0)); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`--attach-stream-fd-v1 descriptor ${fd} is not writable: ${detail}`); - } } /** Backoff schedule for `attach --remote` reconnect attempts, then a cap. */ @@ -445,8 +423,6 @@ export function attach(options: AttachOptions): void { const stdin = process.stdin; const stdout = process.stdout; const canReconnect = !!options.reconnect; - const attachStreamFd = options.attachStreamFdV1; - if (attachStreamFd !== undefined) validateAttachStreamFdV1(attachStreamFd); // `socket` is mutable: the reconnect loop swaps in a fresh routed socket, and // the (once-wired) input handlers write to whichever socket is current. @@ -464,11 +440,6 @@ export function attach(options: AttachOptions): void { let inputWired = false; let stdinDataHandler: ((data: Buffer) => void) | null = null; let resizeHandler: (() => void) | null = null; - let machineInitialState: "geometry" | "screen" | "ready" = "geometry"; - let streamBackpressured = false; - const attachStream = attachStreamFd === undefined - ? null - : fs.createWriteStream("", { fd: attachStreamFd, autoClose: false }); function enterRawMode(): void { if (stdin.isTTY && !stdin.isRaw) { stdin.setRawMode(true); rawWasSet = true; } @@ -494,44 +465,20 @@ export function attach(options: AttachOptions): void { if (exitHandled) return; exitHandled = true; cleanExit(); - if (attachStream && !attachStream.destroyed && !attachStream.writableEnded) { - attachStream.end(() => completeExit(code)); - } else { - completeExit(code); - } + completeExit(code); } function finishDetach(): void { if (exitHandled) return; exitHandled = true; detaching = true; - const completeDetach = () => { - if (exitCompleted) return; - exitCompleted = true; - options.onDetach?.(); - }; - if (attachStream && !attachStream.destroyed && !attachStream.writableEnded) { - attachStream.write(encodeDetach()); - try { socket.write(encodeDetach()); } catch {} - cleanExit(); - attachStream.end(completeDetach); - } else { - try { socket.write(encodeDetach()); } catch {} - cleanExit(); - stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); - completeDetach(); - } + try { socket.write(encodeDetach()); } catch {} + cleanExit(); + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); + if (exitCompleted) return; + exitCompleted = true; + options.onDetach?.(); } - attachStream?.on("error", (error) => { - console.error(`pty attach: machine stream descriptor ${attachStreamFd} failed: ${error.message}`); - if (exitHandled) { - cleanExit(); - completeExit(1); - } else { - finish(1); - } - }); - // Wire stdin/resize forwarding ONCE. Handlers write to the CURRENT `socket`, // so they keep working after the reconnect loop swaps the socket. function wireInput(): void { @@ -593,52 +540,6 @@ export function attach(options: AttachOptions): void { return; } for (const packet of packets) { - if (attachStream) { - const isStreamEvent = - packet.type === MessageType.GEOMETRY || - packet.type === MessageType.SCREEN || - packet.type === MessageType.DATA || - packet.type === MessageType.EXIT; - if (!isStreamEvent) continue; - if (machineInitialState === "geometry" && packet.type !== MessageType.GEOMETRY) { - console.error( - "pty attach: daemon does not support attach stream v1 (expected GEOMETRY before terminal events)", - ); - finish(1); - return; - } - if ( - machineInitialState === "screen" && - packet.type !== MessageType.GEOMETRY && - packet.type !== MessageType.SCREEN - ) { - console.error( - `pty attach: daemon does not support attach stream v1 (expected SCREEN before ${packet.type === MessageType.DATA ? "DATA" : "EXIT"})`, - ); - finish(1); - return; - } - if (machineInitialState === "geometry" && packet.type === MessageType.GEOMETRY) { - machineInitialState = "screen"; - } else if (machineInitialState === "screen" && packet.type === MessageType.SCREEN) { - machineInitialState = "ready"; - } - if (!attachStream.write(encodePacket(packet.type, packet.payload)) && !streamBackpressured) { - streamBackpressured = true; - socket.pause(); - attachStream.once("drain", () => { - streamBackpressured = false; - if (!socket.destroyed) socket.resume(); - }); - } - if (packet.type === MessageType.EXIT) { - exitCode = decodeExit(packet.payload); - sessionExited = true; - finish(exitCode); - return; - } - continue; - } switch (packet.type) { case MessageType.DATA: stdout.write(packet.payload); @@ -664,11 +565,6 @@ export function attach(options: AttachOptions): void { // No reconnect: preserve the original not-found / exit-code behavior. if (err) { cleanExit(); - if (attachStream && !sessionExited) { - console.error(`pty attach: machine stream truncated before EXIT: ${err.message}`); - finish(1); - return; - } const notReachable = err.code === "ENOENT" || err.code === "ECONNREFUSED" || err.code === "ECONNRESET" || err.code === "EPIPE"; if (notReachable) { @@ -680,19 +576,12 @@ export function attach(options: AttachOptions): void { } finish(1); } else { - if (attachStream && !sessionExited) { - console.error("pty attach: machine stream truncated before EXIT: connection closed"); - finish(1); - } else { - finish(exitCode); - } + finish(exitCode); } } function bindSocket(s: net.Socket, preConnected: boolean): void { reader = new PacketReader(); - machineInitialState = "geometry"; - if (streamBackpressured) s.pause(); s.on("data", handleData); s.on("error", (err: NodeJS.ErrnoException) => handleDisconnect(err)); s.on("close", () => handleDisconnect()); @@ -706,7 +595,7 @@ export function attach(options: AttachOptions): void { async function reconnectLoop(): Promise { if (reconnecting) return; reconnecting = true; - const status = attachStream ? process.stderr : stdout; + const status = stdout; status.write(`\r\n[reconnecting… — Ctrl-\\ or Ctrl-C to stop]\r\n`); let attempt = 0; while (!detaching && !exitHandled) { @@ -721,10 +610,8 @@ export function attach(options: AttachOptions): void { // (Transport failures resolve null, so we keep retrying below.) if (detaching || exitHandled) break; reconnecting = false; - status.write(attachStream - ? `[${options.name} session ended]\n` - : TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name} session ended]\r\n`); - finish(attachStream ? 1 : 0); + status.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name} session ended]\r\n`); + finish(0); return; } if (detaching || exitHandled) { try { fresh?.destroy(); } catch {} break; } @@ -739,9 +626,7 @@ export function attach(options: AttachOptions): void { // ends it (besides the user's Ctrl-\ / Ctrl-C). if (++attempt >= RECONNECT_MAX_ATTEMPTS) { reconnecting = false; - status.write(attachStream - ? `[${options.name}: connection lost — re-run \`pty attach --remote\` to reconnect]\n` - : TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name}: connection lost — re-run \`pty attach --remote\` to reconnect]\r\n`); + status.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name}: connection lost — re-run \`pty attach --remote\` to reconnect]\r\n`); finish(1); return; } diff --git a/src/completions.ts b/src/completions.ts index 45f7153..12251c5 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -89,11 +89,6 @@ const COMMANDS: readonly CommandSpec[] = [ { name: "no-restart", desc: "Attach only; never prompt or restart an exited session" }, { name: "force", desc: "Attach even from inside another pty" }, { name: "remote", desc: "Attach a session on a fabric peer" }, - { - name: "attach-stream-fd-v1", - desc: "Write framed machine events to an inherited fd", - argument: { _tag: "free", name: "fd" }, - }, ], }, { diff --git a/tests/attach-stream.test.ts b/tests/attach-stream.test.ts deleted file mode 100644 index cbffb87..0000000 --- a/tests/attach-stream.test.ts +++ /dev/null @@ -1,619 +0,0 @@ -import { afterAll, describe, expect, it } from "vitest"; -import { Buffer } from "node:buffer"; -import { spawn, spawnSync } from "node:child_process"; -import * as fs from "node:fs"; -import * as net from "node:net"; -import * as nodePty from "node-pty"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { - MessageType, - PacketReader, - decodeSize, - encodeExit, - encodeGeometry, - encodePacket, - encodeScreen, -} from "../src/protocol.ts"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const cliPath = path.join(__dirname, "..", "dist", "cli.js"); -const binPath = path.join(__dirname, "..", "bin", "pty"); -const clientUrl = pathToFileURL(path.join(__dirname, "..", "dist", "client.js")).href; -const nodeBin = process.execPath; -const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-attach-stream-")); - -afterAll(() => { - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); -}); - -function collect(stream: NodeJS.ReadableStream): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); - stream.on("end", () => resolve(Buffer.concat(chunks))); - stream.on("error", reject); - }); -} - -async function listen(): Promise<{ server: net.Server; port: number }> { - const server = net.createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("expected TCP address"); - return { server, port: address.port }; -} - -function attachScript(port: number, checkOwnership = true): string { - return ` - import fs from "node:fs"; - import net from "node:net"; - import { attach } from ${JSON.stringify(clientUrl)}; - const socket = net.createConnection({ host: "127.0.0.1", port: ${port} }); - socket.once("connect", () => attach({ - name: "fixture", - socket, - attachStreamFdV1: 3, - onExit: (code) => ${checkOwnership ? `{ - try { - fs.writeSync(3, Buffer.alloc(0)); - process.exit(code); - } catch (error) { - process.stderr.write(\`caller lost ownership of fd 3: \${error.message}\\n\`); - process.exit(99); - } - }` : "process.exit(code)"}, - onDetach: () => process.exit(0), - })); - `; -} - -async function runAgainstFakeDaemon( - send: (socket: net.Socket) => void, -): Promise<{ status: number | null; stdout: Buffer; stderr: Buffer; stream: Buffer }> { - const { server, port } = await listen(); - const connected = new Promise((resolve) => { - server.once("connection", (socket) => { - socket.once("data", () => send(socket)); - resolve(); - }); - }); - const child = spawn(nodeBin, ["--input-type=module", "-e", attachScript(port)], { - stdio: ["pipe", "pipe", "pipe", "pipe"], - }); - const stdout = collect(child.stdout); - const stderr = collect(child.stderr); - const stream = collect(child.stdio[3] as NodeJS.ReadableStream); - await connected; - const status = await new Promise((resolve) => child.once("exit", resolve)); - server.close(); - return { status, stdout: await stdout, stderr: await stderr, stream: await stream }; -} - -describe("pty attach --attach-stream-fd-v1", () => { - it("documents the versioned inherited-FD contract", () => { - const result = spawnSync(nodeBin, [cliPath, "attach", "--help"], { encoding: "utf8" }); - expect(result.status).toBe(0); - expect(result.stdout).toContain("--attach-stream-fd-v1 "); - expect(result.stdout).toMatch(/GEOMETRY.*SCREEN.*DATA.*EXIT/s); - }); - - it("rejects an invalid descriptor before resolving the session", () => { - const result = spawnSync( - nodeBin, - [cliPath, "attach", "--attach-stream-fd-v1", "999999", "missing"], - { encoding: "utf8" }, - ); - expect(result.status).not.toBe(0); - expect(result.stderr).toMatch(/attach-stream-fd-v1.*999999.*not writable/i); - expect(result.stderr).not.toContain('Session "missing"'); - expect(result.stdout).toBe(""); - }); - - it("rejects a missing descriptor value", () => { - const result = spawnSync(nodeBin, [cliPath, "attach", "--attach-stream-fd-v1"], { - encoding: "utf8", - }); - expect(result.status).not.toBe(0); - expect(result.stderr).toMatch(/attach-stream-fd-v1 requires a file descriptor/); - expect(result.stdout).toBe(""); - }); - - it("streams framed events through fd 3 from the shipped bin launcher", async () => { - const root = fs.mkdtempSync(path.join(testRoot, "launcher-")); - const name = `launcher-${process.pid}`; - const launcherEnv: NodeJS.ProcessEnv = { - ...process.env, - PTY_ROOT: root, - PTY_ROOT_LEGACY_SILENT: "1", - }; - delete launcherEnv.PTY_SESSION; - delete launcherEnv.PTY_SERVER_CONFIG; - const created = spawnSync( - nodeBin, - [cliPath, "run", "-d", "--id", name, "--", "sh", "-c", "printf LAUNCHER_READY; read value"], - { env: launcherEnv, encoding: "utf8" }, - ); - expect(created.status, created.stderr).toBe(0); - const child = spawn( - nodeBin, - [binPath, "attach", "--attach-stream-fd-v1", "3", name], - { - env: launcherEnv, - stdio: ["pipe", "pipe", "pipe", "pipe"], - }, - ); - const stdout = collect(child.stdout); - const stderr = collect(child.stderr); - const streamChunks: Buffer[] = []; - const reader = new PacketReader(); - let sentInput = false; - (child.stdio[3] as NodeJS.ReadableStream).on("data", (chunk) => { - const data = Buffer.from(chunk); - streamChunks.push(data); - for (const packet of reader.feed(data)) { - if (packet.type === MessageType.SCREEN && !sentInput) { - sentInput = true; - child.stdin.write("done\n"); - } - } - }); - const status = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("bin launcher attach timed out")), 10_000); - child.once("exit", (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - - expect(status).toBe(0); - expect(await stdout).toEqual(Buffer.alloc(0)); - expect(await stderr).toEqual(Buffer.alloc(0)); - const packets = new PacketReader().feed(Buffer.concat(streamChunks)); - expect(packets[0].type).toBe(MessageType.GEOMETRY); - expect(packets[1].type).toBe(MessageType.SCREEN); - expect(packets[1].payload.toString()).toContain("LAUNCHER_READY"); - expect(packets.at(-1)?.type).toBe(MessageType.EXIT); - }, 15_000); - - it("frames an intentional local detach before the shipped bin launcher closes fd 3", async () => { - const root = fs.mkdtempSync(path.join(testRoot, "launcher-detach-")); - const name = `launcher-detach-${process.pid}`; - const launcherEnv: NodeJS.ProcessEnv = { - ...process.env, - PTY_ROOT: root, - PTY_ROOT_LEGACY_SILENT: "1", - }; - delete launcherEnv.PTY_SESSION; - delete launcherEnv.PTY_SERVER_CONFIG; - const created = spawnSync( - nodeBin, - [cliPath, "run", "-d", "--id", name, "--", "sh", "-c", "printf DETACH_READY; sleep 300"], - { env: launcherEnv, encoding: "utf8" }, - ); - expect(created.status, created.stderr).toBe(0); - - const child = spawn( - nodeBin, - [binPath, "attach", "--attach-stream-fd-v1", "3", name], - { env: launcherEnv, stdio: ["pipe", "pipe", "pipe", "pipe"] }, - ); - const stdout = collect(child.stdout); - const stderr = collect(child.stderr); - const streamChunks: Buffer[] = []; - const reader = new PacketReader(); - let requestedDetach = false; - (child.stdio[3] as NodeJS.ReadableStream).on("data", (chunk) => { - const data = Buffer.from(chunk); - streamChunks.push(data); - for (const packet of reader.feed(data)) { - if (packet.type === MessageType.SCREEN && !requestedDetach) { - requestedDetach = true; - child.stdin.write(Buffer.from([0x1c])); - } - } - }); - - const status = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("bin launcher detach timed out")), 10_000); - child.once("exit", (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - spawnSync(nodeBin, [cliPath, "kill", name], { env: launcherEnv, encoding: "utf8" }); - - expect(status).toBe(0); - expect(await stdout).toEqual(Buffer.alloc(0)); - expect(await stderr).toEqual(Buffer.alloc(0)); - const packets = new PacketReader().feed(Buffer.concat(streamChunks)); - expect(packets[0].type).toBe(MessageType.GEOMETRY); - expect(packets[1].type).toBe(MessageType.SCREEN); - expect(packets[1].payload.toString()).toContain("DETACH_READY"); - expect(packets.at(-1)?.type).toBe(MessageType.DETACH); - expect(packets.some((packet) => packet.type === MessageType.EXIT)).toBe(false); - }, 15_000); - - it("frames an intentional local detach before the initial daemon baseline", async () => { - const { server, port } = await listen(); - const attached = new Promise((resolve) => { - server.once("connection", (socket) => socket.once("data", () => resolve())); - }); - const child = spawn(nodeBin, ["--input-type=module", "-e", attachScript(port)], { - stdio: ["pipe", "pipe", "pipe", "pipe"], - }); - const stdout = collect(child.stdout); - const stderr = collect(child.stderr); - const stream = collect(child.stdio[3] as NodeJS.ReadableStream); - await attached; - child.stdin.write(Buffer.from([0x1c])); - const status = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("pre-baseline detach timed out")), 5_000); - child.once("exit", (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - server.close(); - - expect(status).toBe(0); - expect(await stdout).toEqual(Buffer.alloc(0)); - expect(await stderr).toEqual(Buffer.alloc(0)); - expect(new PacketReader().feed(await stream).map((packet) => packet.type)).toEqual([ - MessageType.DETACH, - ]); - }); - - it("does not frame DETACH when EXIT wins the pending detach-key window", async () => { - const { server, port } = await listen(); - let daemonSocket: net.Socket | undefined; - server.once("connection", (socket) => { - daemonSocket = socket; - socket.once("data", () => socket.write(Buffer.concat([ - encodeGeometry(24, 80), - encodeScreen("exit wins"), - ]))); - }); - const script = ` - import net from "node:net"; - import { attach } from ${JSON.stringify(clientUrl)}; - const socket = net.createConnection({ host: "127.0.0.1", port: ${port} }); - socket.once("connect", () => attach({ - name: "fixture", - socket, - attachStreamFdV1: 3, - onExit: (code) => setTimeout(() => process.exit(code), 600), - onDetach: () => process.exit(42), - })); - `; - const child = spawn(nodeBin, ["--input-type=module", "-e", script], { - stdio: ["pipe", "pipe", "pipe", "pipe"], - }); - const stdout = collect(child.stdout); - const stderr = collect(child.stderr); - const chunks: Buffer[] = []; - const reader = new PacketReader(); - (child.stdio[3] as NodeJS.ReadableStream).on("data", (chunk) => { - const data = Buffer.from(chunk); - chunks.push(data); - if (reader.feed(data).some((packet) => packet.type === MessageType.SCREEN)) { - child.stdin.write(Buffer.from([0x1c])); - setTimeout(() => daemonSocket?.end(encodeExit(0)), 50); - } - }); - const status = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("exit/detach race timed out")), 5_000); - child.once("exit", (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - server.close(); - - expect(status).toBe(0); - expect(await stdout).toEqual(Buffer.alloc(0)); - expect(await stderr).toEqual(Buffer.alloc(0)); - expect(new PacketReader().feed(Buffer.concat(chunks)).map((packet) => packet.type)).toEqual([ - MessageType.GEOMETRY, - MessageType.SCREEN, - MessageType.EXIT, - ]); - }); - - it("reframes fragmented and coalesced daemon packets in order without stdout output", async () => { - const geometry = encodeGeometry(31, 97); - const screen = encodeScreen("\x1b[31mred\x1b[0m"); - const data = encodePacket(MessageType.DATA, Buffer.from("live")); - const exit = encodeExit(0); - - const result = await runAgainstFakeDaemon((socket) => { - socket.write(geometry.subarray(0, 2)); - socket.write(Buffer.concat([geometry.subarray(2), screen, data, exit])); - }); - - expect(result.status).toBe(0); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(result.stderr).toEqual(Buffer.alloc(0)); - const packets = new PacketReader().feed(result.stream); - expect(packets.map((packet) => packet.type)).toEqual([ - MessageType.GEOMETRY, - MessageType.SCREEN, - MessageType.DATA, - MessageType.EXIT, - ]); - expect(decodeSize(packets[0].payload)).toEqual({ rows: 31, cols: 97 }); - expect(packets[1].payload.toString()).toBe("\x1b[31mred\x1b[0m"); - expect(packets[2].payload.toString()).toBe("live"); - }); - - it("keeps stdout as the controlling TTY for the advertised attach geometry", async () => { - const { server, port } = await listen(); - let attachedSize: { rows: number; cols: number } | undefined; - server.once("connection", (socket) => { - const reader = new PacketReader(); - socket.on("data", (chunk) => { - for (const packet of reader.feed(Buffer.from(chunk))) { - if (packet.type !== MessageType.ATTACH) continue; - attachedSize = decodeSize(packet.payload); - socket.end(Buffer.concat([ - encodeGeometry(attachedSize.rows, attachedSize.cols), - encodeScreen("screen stays off stdout"), - encodeExit(0), - ])); - } - }); - }); - const scriptPath = path.join(testRoot, "controlling-tty.mjs"); - const streamPath = path.join(testRoot, "controlling-tty.stream"); - fs.writeFileSync(scriptPath, ` - import net from "node:net"; - import { attach } from ${JSON.stringify(clientUrl)}; - const socket = net.createConnection({ host: "127.0.0.1", port: ${port} }); - socket.once("connect", () => attach({ - name: "fixture", - socket, - attachStreamFdV1: 3, - onExit: (code) => process.exit(code), - })); - `); - const terminal = nodePty.spawn( - "/bin/sh", - ["-c", `exec ${nodeBin} ${scriptPath} 3>${streamPath}`], - { name: "xterm-256color", cols: 91, rows: 27, env: process.env as Record }, - ); - let terminalOutput = ""; - terminal.onData((data) => { terminalOutput += data; }); - const exit = await new Promise<{ exitCode: number }>((resolve) => terminal.onExit(resolve)); - server.close(); - - expect(exit.exitCode).toBe(0); - expect(terminalOutput).toBe(""); - expect(attachedSize).toEqual({ rows: 27, cols: 91 }); - const packets = new PacketReader().feed(fs.readFileSync(streamPath)); - expect(packets.map((packet) => packet.type)).toEqual([ - MessageType.GEOMETRY, - MessageType.SCREEN, - MessageType.EXIT, - ]); - }); - - it("fails clearly when the daemon does not support the v1 geometry contract", async () => { - const result = await runAgainstFakeDaemon((socket) => { - socket.write(encodeScreen("legacy screen")); - }); - - expect(result.status).not.toBe(0); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(result.stream).toEqual(Buffer.alloc(0)); - expect(result.stderr.toString()).toMatch(/daemon does not support attach stream v1/i); - }); - - for (const premature of [ - encodePacket(MessageType.DATA, Buffer.from("too early")), - encodeExit(0), - ]) { - const type = new PacketReader().feed(premature)[0].type === MessageType.DATA ? "DATA" : "EXIT"; - it(`rejects a partial daemon that sends ${type} before the initial SCREEN`, async () => { - const result = await runAgainstFakeDaemon((socket) => { - socket.write(Buffer.concat([encodeGeometry(24, 80), premature])); - }); - - expect(result.status).toBe(1); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(result.stderr.toString()).toMatch(new RegExp(`expected SCREEN before ${type}`, "i")); - expect(new PacketReader().feed(result.stream).map((packet) => packet.type)).toEqual([ - MessageType.GEOMETRY, - ]); - }); - } - - it("fails when the connection closes without a framed EXIT event", async () => { - const result = await runAgainstFakeDaemon((socket) => { - socket.end(Buffer.concat([encodeGeometry(24, 80), encodeScreen("truncated")])); - }); - - expect(result.status).toBe(1); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(result.stderr.toString()).toMatch(/machine stream truncated before EXIT: connection closed/i); - expect(new PacketReader().feed(result.stream).map((packet) => packet.type)).toEqual([ - MessageType.GEOMETRY, - MessageType.SCREEN, - ]); - }); - - it("diagnoses a transport reset as a truncated stream, not a missing session", async () => { - const result = await runAgainstFakeDaemon((socket) => { - socket.write(Buffer.concat([encodeGeometry(24, 80), encodeScreen("partial")]), () => { - socket.resetAndDestroy(); - }); - }); - - expect(result.status).toBe(1); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(result.stderr.toString()).toMatch(/machine stream truncated before EXIT/i); - expect(result.stderr.toString()).not.toMatch(/session .* not found/i); - }); - - it("fails instead of hanging when the inherited stream breaks", async () => { - const { server, port } = await listen(); - let streamReader: { destroy(): void } | undefined; - server.once("connection", (socket) => { - socket.on("error", () => {}); - socket.once("data", () => { - streamReader?.destroy(); - setTimeout(() => { - socket.write(encodeGeometry(24, 80)); - socket.write(encodeScreen("baseline")); - socket.write(encodePacket(MessageType.DATA, Buffer.alloc(1024 * 1024, 65))); - }, 10); - }); - }); - const child = spawn(nodeBin, ["--input-type=module", "-e", attachScript(port, false)], { - stdio: ["pipe", "pipe", "pipe", "pipe"], - }); - const stdout = collect(child.stdout); - const stderr = collect(child.stderr); - streamReader = child.stdio[3] as { destroy(): void }; - const status = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("attach hung after machine stream failure")), 5_000); - child.once("exit", (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - server.close(); - - expect(status).toBe(1); - expect(await stdout).toEqual(Buffer.alloc(0)); - expect((await stderr).toString()).toMatch(/machine stream descriptor 3 failed.*EPIPE/i); - }); - - it("keeps one framed stream across reconnects and requires fresh geometry", async () => { - const { server, port } = await listen(); - let connection = 0; - server.on("connection", (socket) => { - const current = ++connection; - socket.once("data", () => { - if (current === 1) { - socket.end(Buffer.concat([ - encodeGeometry(20, 70), - encodeScreen("first"), - encodePacket(MessageType.DATA, Buffer.from("before reconnect")), - ])); - } else { - socket.end(Buffer.concat([ - encodeGeometry(21, 71), - encodeScreen("second"), - encodePacket(MessageType.DATA, Buffer.from("after reconnect")), - encodeExit(0), - ])); - } - }); - }); - const script = ` - import fs from "node:fs"; - import net from "node:net"; - import { attach } from ${JSON.stringify(clientUrl)}; - const dial = () => new Promise((resolve, reject) => { - const socket = net.createConnection({ host: "127.0.0.1", port: ${port} }); - socket.once("connect", () => resolve(socket)); - socket.once("error", reject); - }); - const socket = await dial(); - attach({ - name: "fixture", - socket, - attachStreamFdV1: 3, - reconnect: dial, - onExit: (code) => { - try { fs.writeSync(3, Buffer.alloc(0)); process.exit(code); } - catch { process.exit(99); } - }, - }); - `; - const child = spawn(nodeBin, ["--input-type=module", "-e", script], { - stdio: ["pipe", "pipe", "pipe", "pipe"], - }); - const stdout = collect(child.stdout); - const stderr = collect(child.stderr); - const stream = collect(child.stdio[3] as NodeJS.ReadableStream); - const status = await new Promise((resolve) => child.once("exit", resolve)); - server.close(); - - expect(status).toBe(0); - expect(await stdout).toEqual(Buffer.alloc(0)); - expect((await stderr).toString()).toMatch(/reconnecting/); - const packets = new PacketReader().feed(await stream); - expect(packets.map((packet) => packet.type)).toEqual([ - MessageType.GEOMETRY, - MessageType.SCREEN, - MessageType.DATA, - MessageType.GEOMETRY, - MessageType.SCREEN, - MessageType.DATA, - MessageType.EXIT, - ]); - expect(decodeSize(packets[3].payload)).toEqual({ rows: 21, cols: 71 }); - }); - - it("requires a fresh SCREEN after GEOMETRY on reconnect", async () => { - const { server, port } = await listen(); - let connection = 0; - server.on("connection", (socket) => { - const current = ++connection; - socket.once("data", () => { - if (current === 1) { - socket.end(Buffer.concat([ - encodeGeometry(20, 70), - encodeScreen("first"), - encodePacket(MessageType.DATA, Buffer.from("before reconnect")), - ])); - } else { - socket.write(Buffer.concat([ - encodeGeometry(21, 71), - encodePacket(MessageType.DATA, Buffer.from("too early")), - ])); - } - }); - }); - const script = ` - import net from "node:net"; - import { attach } from ${JSON.stringify(clientUrl)}; - const dial = () => new Promise((resolve, reject) => { - const socket = net.createConnection({ host: "127.0.0.1", port: ${port} }); - socket.once("connect", () => resolve(socket)); - socket.once("error", reject); - }); - const socket = await dial(); - attach({ - name: "fixture", - socket, - attachStreamFdV1: 3, - reconnect: dial, - onExit: (code) => process.exit(code), - }); - `; - const child = spawn(nodeBin, ["--input-type=module", "-e", script], { - stdio: ["pipe", "pipe", "pipe", "pipe"], - }); - const stdout = collect(child.stdout); - const stderr = collect(child.stderr); - const stream = collect(child.stdio[3] as NodeJS.ReadableStream); - const status = await new Promise((resolve) => child.once("exit", resolve)); - server.close(); - - expect(status).toBe(1); - expect(await stdout).toEqual(Buffer.alloc(0)); - expect((await stderr).toString()).toMatch(/expected SCREEN before DATA/i); - expect(new PacketReader().feed(await stream).map((packet) => packet.type)).toEqual([ - MessageType.GEOMETRY, - MessageType.SCREEN, - MessageType.DATA, - MessageType.GEOMETRY, - ]); - }); -}); diff --git a/tests/completions.test.ts b/tests/completions.test.ts index d73c0db..bf5a338 100644 --- a/tests/completions.test.ts +++ b/tests/completions.test.ts @@ -11,7 +11,6 @@ import { describe, it, expect } from "vitest"; import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; @@ -80,32 +79,6 @@ describe("pty completions ", () => { } }); - it("models --attach-stream-fd-v1 as consuming a required free-form value", () => { - expect(gen("fish")).toContain("-l attach-stream-fd-v1 -x "); - expect(gen("bash")).toContain('"${prev}" == "--attach-stream-fd-v1"'); - expect(gen("zsh")).toMatch(/--attach-stream-fd-v1\[[^\]]+\]:fd:/); - - const bash = which("bash"); - if (!bash) return; - const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-completion-fd-")); - try { - fs.writeFileSync(path.join(root, "target.json"), "{}"); - const script = `${gen("bash")} -COMP_WORDS=(pty attach --attach-stream-fd-v1 3 "") -COMP_CWORD=4 -_pty -printf '%s\n' "\${COMPREPLY[@]}"`; - const result = spawnSync(bash, ["-c", script], { - encoding: "utf8", - env: { ...process.env, PTY_ROOT: root }, - }); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout.trim()).toBe("target"); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - it("prints fish, bash, zsh to stdout", () => { for (const shell of ["fish", "bash", "zsh"] as const) { const out = gen(shell); diff --git a/tests/help.test.ts b/tests/help.test.ts index b3bb9c3..16e7448 100644 --- a/tests/help.test.ts +++ b/tests/help.test.ts @@ -61,6 +61,16 @@ describe("pty --help — no drift", () => { expect(r.stdout).toMatch(/inherited environment variable \(repeatable\)/); }); + it("keeps interactive and machine attach as separate commands", () => { + const interactive = help("attach"); + const machine = help("machine-attach-v2"); + expect(interactive.status).toBe(0); + expect(interactive.stdout).not.toContain("attach-stream-fd-v1"); + expect(machine.status).toBe(0); + expect(machine.stdout).toMatch(/headless, framed, duplex attachment/i); + expect(machine.stdout).toMatch(/does not[\s\S]*fall back to legacy attach/i); + }); + it("every dispatch `case` is either a documented command or a known non-command", () => { // Extract every `case "X":` label from the dispatcher. const cases = [...cliSource.matchAll(/case\s+"([^"]+)":/g)].map((m) => m[1]); From 3cbf54d30a2d0b7e4a8167f0a33ab40af7928a34 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:32:47 +0200 Subject: [PATCH 08/10] feat: publish daemon generation in inventory --- CHANGELOG.md | 9 +++++++++ docs/client.md | 9 +++++++++ docs/vrs/spec.md | 6 ++++++ src/cli.ts | 1 + src/client.ts | 1 + src/remote.ts | 2 ++ src/server.ts | 1 + tests/fixtures/parity/shapes.json | 2 ++ tests/list-filters.test.ts | 1 + tests/protocol.test.ts | 3 ++- tests/remote-fabric.test.ts | 3 ++- tests/tags.test.ts | 9 ++++++++- 12 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77579df..f383d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Machine admission inventory + +- `pty list --json`, remote inventory, and live status now expose the opaque + daemon `generation` alongside the stable session `name`. Current sessions + return a string; flattened historical generation-less inventory returns + `null`. Machine clients can discover the exact identity pair required by + `machine-attach-v2` without parsing registry files or substituting + `createdAt` as an unsafe heuristic. + ### Storage format - Supporting live daemons now advertise a `recovery` capability in session diff --git a/docs/client.md b/docs/client.md index a6406b6..4bfc8e9 100644 --- a/docs/client.md +++ b/docs/client.md @@ -16,6 +16,13 @@ import { PacketReader, MessageType } from "@compoundingtech/pty/protocol"; List all retained sessions without mutating the registry. Cleanup is owned by explicit lifecycle operations such as `gc()` and `cleanupAll()`. +`SessionInfo.metadata?.generation` is the current daemon's opaque admission +token. `pty list --json` projects it as `generation: string | null` alongside +the stable `name`, and remote inventory preserves the same pair. Machine +clients pass a non-null observed token as `OPEN.expectedGeneration`; `null` +identifies historical metadata that cannot be admitted through machine attach +v2. `createdAt` is presentation metadata and is never an admission token. + ### `getSession(ref: string): Promise` Resolve a stable session id or display name. An exact stable id always wins. A @@ -148,6 +155,7 @@ interface SessionInfo { } interface SessionMetadata { + generation?: string; // opaque daemon-generation token command: string; args: string[]; displayCommand: string; @@ -306,6 +314,7 @@ each writable client's requested size and which min-wins axes it constrains. ```typescript interface StatsResult { name: string; + generation: string; // opaque daemon-generation token terminal: { cols: number; rows: number; cursorX: number; cursorY: number; diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index b748079..c9c3e9e 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -273,6 +273,12 @@ fields (R09). Inventory is observational: it derives running/exited/vanished state and enriches it with live status when available, but does not restart, reap, or attach. +Every flattened local or remote inventory row carries the stable id and the +current opaque daemon generation as `generation: string | null`; live status +carries the same daemon generation as a string. Machine clients use this exact +pair for same-connection admission. Historical generation-less records remain +visible as `null` but are not eligible for machine attach v2; `createdAt` is +never interpreted as a generation. Status reports client roles, requested/effective geometry, process resources, and terminal modes. diff --git a/src/cli.ts b/src/cli.ts index da40f01..f4fa36a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2254,6 +2254,7 @@ async function cmdList(opts: ListOptions = {}): Promise { ? s.metadata.displayCommand : null, cwd: s.metadata?.cwd ?? null, + generation: s.metadata?.generation ?? null, createdAt: s.metadata?.createdAt ?? null, exitCode: s.metadata?.exitCode ?? null, exitedAt: s.metadata?.exitedAt ?? null, diff --git a/src/client.ts b/src/client.ts index 7e5081e..bb8efe7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -292,6 +292,7 @@ export interface ProcessResources { export interface StatsResult { name: string; + generation: string; terminal: { cols: number; rows: number; diff --git a/src/remote.ts b/src/remote.ts index 3d41853..a87f06e 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -20,6 +20,7 @@ export const FABRIC_BIN = process.env.PTY_FABRIC_BIN ?? "fabric"; * results render through the existing path unchanged. */ export interface RemoteSessionRow { name: string; + generation: string | null; status: string; command?: string; cwd?: string; @@ -62,6 +63,7 @@ function toRow(s: SessionInfo): RemoteSessionRow { const m = s.metadata; return { name: s.name, + generation: m?.generation ?? null, status: s.status, ...(m?.displayCommand ? { command: m.displayCommand } : {}), ...(m?.cwd ? { cwd: m.cwd } : {}), diff --git a/src/server.ts b/src/server.ts index ae0efed..0516cf9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1762,6 +1762,7 @@ export class PtyServer { return { name: this.name, + generation: this.generation, terminal: { cols: this.terminal.cols, rows: this.terminal.rows, diff --git a/tests/fixtures/parity/shapes.json b/tests/fixtures/parity/shapes.json index 45a913d..85a1117 100644 --- a/tests/fixtures/parity/shapes.json +++ b/tests/fixtures/parity/shapes.json @@ -15,6 +15,7 @@ "run": ["run", "-d", "--id", "runsess", "--no-display-name", "--", "cat"], "expect": { "name": { "exact": "runsess" }, + "generation": { "type": "string" }, "status": { "exact": "running" }, "pid": { "type": "number", "note": "daemon pid; != stats.process.pid (child)" }, "command": { "exact": "cat" }, @@ -30,6 +31,7 @@ "run": ["run", "-d", "--id", "exsess", "--name", "my-label", "--", "sh", "-c", "exit 3"], "expect": { "name": { "exact": "exsess" }, + "generation": { "type": "string" }, "status": { "exact": "exited" }, "pid": { "exact": null }, "command": { "exact": "sh -c exit 3" }, diff --git a/tests/list-filters.test.ts b/tests/list-filters.test.ts index 4570f7e..5797037 100644 --- a/tests/list-filters.test.ts +++ b/tests/list-filters.test.ts @@ -127,6 +127,7 @@ describe("vanished status", () => { const found = sessions.find((s: any) => s.name === name); expect(found).toBeDefined(); expect(found.status).toBe("vanished"); + expect(found.generation).toBeNull(); expect(found.exitCode).toBeNull(); expect(found.exitedAt).toBeNull(); }, 10000); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 65d50a5..5b23ce7 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -338,9 +338,10 @@ describe("protocol", () => { expect(received).toBe("after-unknown"); }); - it("accepts an old-daemon STATUS response without connection details", () => { + it("accepts a STATUS response without connection details", () => { const response = { name: "legacy", + generation: "legacy-generation", terminal: { cols: 80, rows: 24, diff --git a/tests/remote-fabric.test.ts b/tests/remote-fabric.test.ts index 8edbdc4..be89071 100644 --- a/tests/remote-fabric.test.ts +++ b/tests/remote-fabric.test.ts @@ -39,7 +39,7 @@ function waitForFile(p: string, timeoutMs: number): boolean { /** Speak the control protocol directly (no fabric): one line {"op":"list"}, * read until the server half-closes, parse the JSON response. */ -function rawList(sockPath: string): Promise<{ sessions: { name: string }[] }> { +function rawList(sockPath: string): Promise<{ sessions: { name: string; generation: string | null }[] }> { return new Promise((resolve, reject) => { const c = net.createConnection(sockPath); let buf = ""; @@ -95,6 +95,7 @@ describe("pty ls --remote over fabric", () => { expect(names).toContain("demo"); const demo = out.remote[0].sessions.find((s: { name: string }) => s.name === "demo"); expect(demo.status).toBe("running"); + expect(demo.generation).toEqual(expect.any(String)); expect(demo.command).toBe("sleep 300"); expect(demo.displayName).toBe("Demo Session"); }, 20000); diff --git a/tests/tags.test.ts b/tests/tags.test.ts index 3e422d5..9984797 100644 --- a/tests/tags.test.ts +++ b/tests/tags.test.ts @@ -115,7 +115,7 @@ describe("session tags", () => { expect(meta.tags).toEqual({ owner: "forge", env: "dev" }); }, 15000); - it("tags appear in pty list --json", async () => { + it("list and stats publish the exact daemon generation", async () => { const dir = makeSessionDir(); const name = uniqueName(); await startDaemon(dir, name, "cat", [], { owner: "myapp" }); @@ -123,8 +123,15 @@ describe("session tags", () => { const output = runCli(dir, "list", "--json"); const sessions = JSON.parse(output); const session = sessions.find((s: any) => s.name === name); + const metadata = JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf-8")); + const stats = JSON.parse(runCli(dir, "stats", "--json", name)); expect(session).toBeDefined(); expect(session.tags).toEqual({ owner: "myapp" }); + expect(session.generation).toBe(metadata.generation); + expect(stats.generation).toBe(metadata.generation); + expect(stats.generation).toBe(session.generation); + expect(session.generation).not.toBe(session.createdAt); + expect(stats.generation).not.toBe(stats.createdAt); }, 15000); it("pty list --filter-tag filters JSON output to matching sessions", async () => { From 85ce11182b98448bfb3f2c9a0dd5479ef1c1e80d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:31:33 +0200 Subject: [PATCH 09/10] refactor: unify interactive attach on machine v2 --- docs/vrs/spec.md | 23 +- src/cli.ts | 15 +- src/client.ts | 648 +++++++++++++++++++++++--------- src/machine-attach.ts | 9 +- src/machine-protocol.ts | 13 +- src/remote.ts | 117 ++++-- src/server.ts | 16 +- src/testing/session.ts | 12 +- tests/attach-no-restart.test.ts | 101 +++++ tests/machine-attach-v2.test.ts | 266 +++++++++++++ tests/machine-server-v2.test.ts | 30 +- tests/remote-reconnect.test.ts | 24 ++ 12 files changed, 1038 insertions(+), 236 deletions(-) diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index c9c3e9e..e2d8ce7 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -121,6 +121,13 @@ needs reconstructable terminal state must first send `ATTACH` or `PEEK`. Public stats omit command sockets and expose the writable-attached role with the compatibility string `"writable"`. +The unadmitted `ATTACH` writable path is compatibility-only. New interactive or +embedded consumers must use machine attach v2; no new consumer may depend on +the legacy writable role. The compatibility path is removed once +`SessionConnection`, the TUI builder transport, and the server-backed testing +session have migrated to v2 and their legacy protocol parity fixtures have been +replaced. + For writable-attached request set `W`, shared geometry is (R06): ```text @@ -139,6 +146,16 @@ protocol. It does not allocate a controlling terminal, inherit a side-channel descriptor, interpret an interactive detach key, or fall back to machine attach v1 (R08, R11). +Human `pty attach` is an interactive policy adapter over that same behavioral +core, not a second writable daemon protocol. It reads the exact local generation +from metadata or the remote generation from route admission, requests the +optional `host-terminal-replay` capability, and frames complete UTF-8 input, +resize, and detach requests through `machine-attach-v2`. The local policy alone +reserves Ctrl-\\: it recognizes both C0 and Kitty encodings across stream chunks, +uses a bounded ambiguity deadline only for an incomplete Kitty prefix, and +otherwise preserves input bytes exactly. A second Ctrl-\\ within the documented +double-tap window emits one C0 byte to the child. + ```text host adapter daemon | OPEN(id,generation,size,caps) | | @@ -248,6 +265,10 @@ status sentinel so no legacy status response enters the accepted stream. Rejection leaves the command role unchanged. Version and build identity are diagnostic; the three required capabilities are `framed-utf8-input`, `typed-outcome`, and `input-mode-snapshot` (R07). +`host-terminal-replay` is an optional presentation capability: when requested, +the daemon prefixes `READY.screen` with its current host-terminal mode state. +Machine hosts that reconstruct presentation from the typed input-mode snapshot +do not request it. The host lifecycle grammar is (R08): @@ -339,7 +360,7 @@ input, resize, and multi-client geometry without mocks. | R08 | [machine protocol](../../src/machine-protocol.ts), [machine adapter](../../src/machine-attach.ts), [CLI](../../src/cli.ts), [entrypoint](../../bin/pty) | [machine protocol](../../tests/machine-protocol.test.ts), [machine adapter](../../tests/machine-attach-v2.test.ts), [machine process and socket](../../tests/machine-attach-v2-e2e.test.ts), [signals](../../tests/wrapper-signal-forwarding.test.ts) | | R09 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [recovery](../../src/recovery.ts), [CLI](../../src/cli.ts) | [root](../../tests/pty-root.test.ts), [display name](../../tests/display-name.test.ts), [status](../../tests/stats-cli.test.ts), [list purity](../../tests/list-purity.test.ts), [recovery](../../tests/recovery.test.ts) | | R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) | -| R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) | +| R11 | [CLI](../../src/cli.ts), [client](../../src/client.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [interactive attach](../../tests/attach-no-restart.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) | `node scripts/verify-docs.ts --vrs-only` validates this two-document shape, sequential requirement IDs, links, and complete requirement references. diff --git a/src/cli.ts b/src/cli.ts index f4fa36a..a0900b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1983,7 +1983,7 @@ async function cmdPeekRemote( ): Promise { let socket; try { - socket = await dialAndRoute(peer, name); + ({ socket } = await dialAndRoute(peer, name)); } catch (e) { console.error(`pty peek --remote ${peer}: ${(e as Error).message}`); process.exit(1); @@ -2011,7 +2011,7 @@ async function cmdSendRemote( ): Promise { let socket; try { - socket = await dialAndRoute(peer, name); + ({ socket } = await dialAndRoute(peer, name)); } catch (e) { console.error(`pty send --remote ${peer}: ${(e as Error).message}`); process.exit(1); @@ -2029,25 +2029,26 @@ async function cmdSendRemote( * socket over fabric, route it to the named remote session, and attach over * that tunnel — the resilient shell is a long-lived remote pty you attach to. */ async function cmdAttachRemote(peer: string, name: string): Promise { - let socket; + let routed; try { - socket = await dialAndRoute(peer, name); + routed = await dialAndRoute(peer, name); } catch (e) { console.error(`pty attach --remote ${peer}: ${(e as Error).message}`); process.exit(1); } attach({ name, - socket, + socket: routed.socket, + expectedGeneration: routed.generation, // On a loud fabric close, re-dial + re-route to the same remote session and // re-attach (the daemon replays its screen, so the session resumes). A // recoverable transport stall keeps the socket open, so it's just waited out. // Contract: null = transport failure (host unreachable) → attach keeps // retrying; throw = the host is reachable but the session is gone → attach // gives up cleanly. - reconnect: async () => { + reconnect: async (signal) => { try { - return await dialAndRoute(peer, name); + return (await dialAndRoute(peer, name, 10000, signal)).socket; } catch (e) { if (e instanceof RouteRefusedError) throw e; // reachable-but-gone → clean give-up return null; // transport failure → keep retrying (unlimited by default) diff --git a/src/client.ts b/src/client.ts index bb8efe7..3c43252 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,31 +1,224 @@ import * as net from "node:net"; import * as tty from "node:tty"; +import { PassThrough, type Readable, type Writable } from "node:stream"; import { MessageType, PacketReader, - encodeAttach, encodeData, - encodeDetach, encodePeek, - encodeResize, encodeStatus, decodeExit, } from "./protocol.ts"; -import { getSocketPath } from "./sessions.ts"; +import { getSocketPath, readMetadata } from "./sessions.ts"; import { stripAnsi } from "./tui/colors.ts"; import { BRACKETED_PASTE_START, BRACKETED_PASTE_END } from "./paste.ts"; +import { machineAttachV2 } from "./machine-attach.ts"; +import { + MACHINE_PROTOCOL_VERSION, + MachineFrameReader, + decodeMachineResponse, + encodeMachineRequest, + type MachineInputModeSnapshotV1, + type MachineResponse, +} from "./machine-protocol.ts"; const DETACH_KEY = 0x1c; // Ctrl+\ (legacy encoding) const DETACH_KEY_KITTY = "\x1b[92;5u"; // Ctrl+\ (Kitty keyboard protocol) +const DETACH_KEY_KITTY_BYTES = Buffer.from(DETACH_KEY_KITTY); + +interface InteractiveInputPolicyOptions { + readonly onInput: (bytes: Buffer) => void; + readonly onDetach: () => void; + readonly onError?: (error: Error) => void; + readonly ambiguityMs?: number; + readonly doubleTapMs?: number | null; +} + +/** Stateful terminal-only policy layered above the framed machine transport. + * It reserves local detach without interpreting or re-encoding other bytes. */ +export class InteractiveInputPolicy { + private readonly onInput: (bytes: Buffer) => void; + private readonly onDetach: () => void; + private readonly onError: (error: Error) => void; + private readonly ambiguityMs: number; + private readonly doubleTapMs: number | null; + private kittyEnabled = false; + private kittyPrefix = Buffer.alloc(0); + private utf8Prefix = Buffer.alloc(0); + private kittyTimer: NodeJS.Timeout | null = null; + private detachTimer: NodeJS.Timeout | null = null; + private stopped = false; + + constructor(options: InteractiveInputPolicyOptions) { + this.onInput = options.onInput; + this.onDetach = options.onDetach; + this.onError = options.onError ?? (() => {}); + this.ambiguityMs = options.ambiguityMs ?? 25; + this.doubleTapMs = options.doubleTapMs ?? 300; + } + + setInputModes(modes: MachineInputModeSnapshotV1): void { + const enabled = modes.kittyKeyboardFlagsStack.length > 0; + if (this.kittyEnabled && !enabled) this.flushKittyPrefix(); + this.kittyEnabled = enabled; + } + + feed(chunk: Buffer): void { + if (this.stopped || chunk.length === 0) return; + if (this.kittyTimer) { + clearTimeout(this.kittyTimer); + this.kittyTimer = null; + } + const bytes = this.kittyPrefix.length === 0 + ? chunk + : Buffer.concat([this.kittyPrefix, chunk]); + this.kittyPrefix = Buffer.alloc(0); + const forward: Buffer[] = []; + let literalStart = 0; + const flushLiteral = (end: number) => { + if (end > literalStart) forward.push(bytes.subarray(literalStart, end)); + }; + const emitForward = () => { + if (forward.length === 0) return; + this.emitUtf8(Buffer.concat(forward)); + forward.length = 0; + }; + for (let index = 0; index < bytes.length;) { + if (bytes[index] === DETACH_KEY) { + flushLiteral(index); + emitForward(); + this.handleDetachKey(); + index++; + literalStart = index; + continue; + } + if (this.kittyEnabled && bytes[index] === DETACH_KEY_KITTY_BYTES[0]) { + const remaining = bytes.subarray(index); + const compared = Math.min(remaining.length, DETACH_KEY_KITTY_BYTES.length); + if (remaining.subarray(0, compared).equals(DETACH_KEY_KITTY_BYTES.subarray(0, compared))) { + flushLiteral(index); + if (remaining.length < DETACH_KEY_KITTY_BYTES.length) { + this.kittyPrefix = Buffer.from(remaining); + literalStart = bytes.length; + this.kittyTimer = setTimeout(() => this.flushKittyPrefix(), this.ambiguityMs); + break; + } + emitForward(); + this.handleDetachKey(); + index += DETACH_KEY_KITTY_BYTES.length; + literalStart = index; + continue; + } + } + index++; + } + flushLiteral(bytes.length); + emitForward(); + } -/** Replace Kitty keyboard protocol encoding of Ctrl+\ with the legacy byte - * so the rest of the detach logic can work with a single representation. */ -function normalizeDetachKey(data: Buffer): Buffer { - const str = data.toString(); - if (!str.includes(DETACH_KEY_KITTY)) return data; - return Buffer.from( - str.replaceAll(DETACH_KEY_KITTY, String.fromCharCode(DETACH_KEY)) - ); + end(): void { + if (this.stopped) return; + this.flushKittyPrefix(); + if (this.utf8Prefix.length > 0) { + this.fail(new Error("stdin ended with an incomplete UTF-8 sequence")); + } + } + + discardPendingInput(): void { + if (this.kittyTimer) clearTimeout(this.kittyTimer); + this.kittyTimer = null; + this.kittyPrefix = Buffer.alloc(0); + this.utf8Prefix = Buffer.alloc(0); + } + + dispose(): void { + this.stopped = true; + if (this.kittyTimer) clearTimeout(this.kittyTimer); + if (this.detachTimer) clearTimeout(this.detachTimer); + this.kittyTimer = null; + this.detachTimer = null; + this.kittyPrefix = Buffer.alloc(0); + this.utf8Prefix = Buffer.alloc(0); + } + + private handleDetachKey(): void { + if (this.doubleTapMs === null) { + this.stopped = true; + this.onDetach(); + return; + } + if (this.detachTimer) { + clearTimeout(this.detachTimer); + this.detachTimer = null; + this.emitUtf8(Buffer.from([DETACH_KEY])); + return; + } + this.detachTimer = setTimeout(() => { + this.detachTimer = null; + this.stopped = true; + this.onDetach(); + }, this.doubleTapMs); + } + + private flushKittyPrefix(): void { + if (this.kittyTimer) clearTimeout(this.kittyTimer); + this.kittyTimer = null; + if (this.kittyPrefix.length === 0) return; + const bytes = this.kittyPrefix; + this.kittyPrefix = Buffer.alloc(0); + this.emitUtf8(bytes); + } + + private emitUtf8(chunk: Buffer): void { + if (this.stopped || chunk.length === 0) return; + const bytes = this.utf8Prefix.length === 0 + ? chunk + : Buffer.concat([this.utf8Prefix, chunk]); + let complete: number; + try { + complete = completeUtf8Prefix(bytes); + } catch (error) { + this.fail(error instanceof Error ? error : new Error(String(error))); + return; + } + if (complete > 0) this.onInput(Buffer.from(bytes.subarray(0, complete))); + this.utf8Prefix = Buffer.from(bytes.subarray(complete)); + } + + private fail(error: Error): void { + this.dispose(); + this.onError(error); + } +} + +function completeUtf8Prefix(bytes: Buffer): number { + let index = 0; + const continuation = (offset: number, min = 0x80, max = 0xbf): boolean => + offset < bytes.length && bytes[offset] >= min && bytes[offset] <= max; + while (index < bytes.length) { + const first = bytes[index]; + if (first <= 0x7f) { index++; continue; } + let width: number; + let secondMin = 0x80; + let secondMax = 0xbf; + if (first >= 0xc2 && first <= 0xdf) width = 2; + else if (first >= 0xe0 && first <= 0xef) { + width = 3; + if (first === 0xe0) secondMin = 0xa0; + if (first === 0xed) secondMax = 0x9f; + } else if (first >= 0xf0 && first <= 0xf4) { + width = 4; + if (first === 0xf0) secondMin = 0x90; + if (first === 0xf4) secondMax = 0x8f; + } else throw new Error("stdin contained invalid UTF-8"); + if (index + width > bytes.length) return index; + if (!continuation(index + 1, secondMin, secondMax)) throw new Error("stdin contained invalid UTF-8"); + for (let offset = 2; offset < width; offset++) { + if (!continuation(index + offset)) throw new Error("stdin contained invalid UTF-8"); + } + index += width; + } + return index; } // Reset terminal modes that programs may have enabled. This prevents @@ -76,6 +269,8 @@ export function peek(options: PeekOptions): void { const socket = options.socket ?? net.createConnection(getSocketPath(options.name)); const stdout = process.stdout; const follow = options.follow ?? false; + let detachPolicy: InteractiveInputPolicy | null = null; + let followInput: ((raw: Buffer) => void) | null = null; const onReady = () => { socket.write(encodePeek(options.plain, options.full)); @@ -85,19 +280,32 @@ export function peek(options: PeekOptions): void { const stdin = process.stdin; if (stdin.isTTY) stdin.setRawMode(true); - stdin.on("data", (raw: Buffer) => { - const data = normalizeDetachKey(raw); - for (let i = 0; i < data.length; i++) { - if (data[i] === DETACH_KEY) { - if (stdin.isTTY) stdin.setRawMode(false); - socket.destroy(); - stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); - options.onDetach?.(); - return; - } - } - // All other input is silently ignored (read-only) + detachPolicy = new InteractiveInputPolicy({ + onInput: () => {}, + onDetach: () => { + if (stdin.isTTY) stdin.setRawMode(false); + socket.destroy(); + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); + options.onDetach?.(); + }, + doubleTapMs: null, }); + detachPolicy.setInputModes({ + schema: "pty.input-mode.v1", + wireEncoder: "xterm-input.v1", + revision: 0, + applicationCursorKeys: false, + applicationKeypad: false, + bracketedPaste: false, + focusReporting: false, + modifyOtherKeys: 0, + mouseTracking: "Off", + mouseEncoding: "X10", + mouseCoordinates: "Cell", + kittyKeyboardFlagsStack: [0], + }); + followInput = (raw: Buffer) => detachPolicy?.feed(Buffer.from(raw)); + stdin.on("data", followInput); stdin.resume(); } }; @@ -175,6 +383,8 @@ export function peek(options: PeekOptions): void { }); socket.on("close", () => { + detachPolicy?.dispose(); + if (followInput) process.stdin.removeListener("data", followInput); if (process.stdin.isTTY && process.stdin.isRaw) { process.stdin.setRawMode(false); } @@ -389,8 +599,15 @@ export function queryStats(name: string, timeoutMs = 2000): Promise export interface AttachOptions { name: string; + /** Exact daemon incarnation. Local attaches read it from metadata; routed + * attaches receive it from the route admission response. */ + expectedGeneration?: string | null; onExit?: (code: number) => void; onDetach?: () => void; + /** Injectable terminal streams keep the interactive adapter usable as a + * library component. Production callers use the process terminal. */ + input?: Readable & Partial>; + output?: Writable & Partial>; /** Attach over this ALREADY-CONNECTED socket instead of dialing the local * `.sock`. Used by `attach --remote`: a fabric-dialed, control-server- * routed socket. When set, `name` is only used for display. */ @@ -404,7 +621,7 @@ export interface AttachOptions { * - REJECT → clean give-up: the host is reachable but the session is gone. * A recoverable stall keeps the socket open (no close event), so reconnect * fires only on a genuine close (fabric's loud give-up), never on a stall. */ - reconnect?: () => Promise; + reconnect?: (signal: AbortSignal) => Promise; } /** Backoff schedule for `attach --remote` reconnect attempts, then a cap. */ @@ -412,7 +629,7 @@ const RECONNECT_BACKOFF_MS = [100, 250, 500, 1000, 2000, 5000, 10000]; const RECONNECT_BACKOFF_CAP_MS = 15000; /** Consecutive transport-failure attempts before giving up. Default: UNLIMITED * while the terminal is open — the faithful roaming behavior (close the laptop, - * travel, reopen, and it comes back; the user stops it with Ctrl-\ / Ctrl-C). + * travel, reopen, and it comes back; the user stops it with Ctrl-\). * A reachable-but-gone session gives up cleanly regardless (a rejected reconnect, * see AttachOptions.reconnect). Env-overridable for a finite bound in scripts. */ const RECONNECT_MAX_ATTEMPTS = (() => { @@ -421,218 +638,295 @@ const RECONNECT_MAX_ATTEMPTS = (() => { })(); export function attach(options: AttachOptions): void { - const stdin = process.stdin; - const stdout = process.stdout; + const stdin = options.input ?? process.stdin; + const stdout = options.output ?? process.stdout; const canReconnect = !!options.reconnect; + const expectedGeneration = options.socket + ? options.expectedGeneration + : options.expectedGeneration ?? readMetadata(options.name)?.generation; + if (!expectedGeneration) { + try { options.socket?.destroy(); } catch {} + console.error(`Session "${options.name}" has no daemon generation; restart it before attaching.`); + if (options.onExit) options.onExit(1); else process.exit(1); + return; + } + const generation = expectedGeneration; + + type Size = { readonly rows: number; readonly cols: number }; + type AttachPhase = + | { readonly _tag: "Opening"; readonly controller: AbortController; readonly requests: PassThrough; readonly sentSize: Size } + | { readonly _tag: "Ready"; readonly controller: AbortController; readonly requests: PassThrough; readonly sentSize: Size } + | { readonly _tag: "Reconnecting"; readonly controller: AbortController } + | { readonly _tag: "Detaching"; readonly controller: AbortController | null; readonly requests: PassThrough | null } + | { readonly _tag: "Ended" }; + + const terminalSize = (): Size => ({ + rows: (stdout as tty.WriteStream).rows ?? 24, + cols: (stdout as tty.WriteStream).columns ?? 80, + }); + const sameSize = (left: Size, right: Size): boolean => + left.rows === right.rows && left.cols === right.cols; - // `socket` is mutable: the reconnect loop swaps in a fresh routed socket, and - // the (once-wired) input handlers write to whichever socket is current. - let socket: net.Socket = options.socket ?? net.createConnection(getSocketPath(options.name)); - const firstPreConnected = !!options.socket; - let reader = new PacketReader(); - - let detaching = false; + let phase: AttachPhase = { _tag: "Ended" }; + let desiredSize = terminalSize(); + let adapterSequence = 0; let rawWasSet = false; - let exitCode = 0; - let exitHandled = false; - let exitCompleted = false; - let sessionExited = false; // saw an EXIT packet — the session really ended; don't reconnect - let reconnecting = false; - let inputWired = false; let stdinDataHandler: ((data: Buffer) => void) | null = null; + let stdinEndHandler: (() => void) | null = null; let resizeHandler: (() => void) | null = null; + const phaseController = (value: AttachPhase): AbortController | null => + value._tag === "Opening" || value._tag === "Ready" || value._tag === "Reconnecting" || value._tag === "Detaching" + ? value.controller + : null; + function transition(next: AttachPhase): void { + const outgoing = phaseController(phase); + const incoming = phaseController(next); + phase = next; + if (outgoing && outgoing !== incoming) outgoing.abort(); + } + function enterRawMode(): void { - if (stdin.isTTY && !stdin.isRaw) { stdin.setRawMode(true); rawWasSet = true; } + if (stdin.isTTY && !stdin.isRaw && stdin.setRawMode) { stdin.setRawMode(true); rawWasSet = true; } } function exitRawMode(): void { - if (rawWasSet && stdin.isTTY) stdin.setRawMode(false); + if (rawWasSet && stdin.isTTY && stdin.setRawMode) stdin.setRawMode(false); } function teardownInput(): void { if (stdinDataHandler) { stdin.removeListener("data", stdinDataHandler); stdinDataHandler = null; } - if (resizeHandler && stdout instanceof tty.WriteStream) { stdout.removeListener("resize", resizeHandler); resizeHandler = null; } + if (stdinEndHandler) { stdin.removeListener("end", stdinEndHandler); stdinEndHandler = null; } + if (resizeHandler) { stdout.removeListener("resize", resizeHandler); resizeHandler = null; } } - function cleanExit(): void { + function cleanExit(requests: PassThrough | null): void { teardownInput(); + inputPolicy.dispose(); exitRawMode(); - try { socket.destroy(); } catch {} + try { requests?.destroy(); } catch {} } function completeExit(code: number): void { - if (exitCompleted) return; - exitCompleted = true; if (options.onExit) options.onExit(code); else process.exit(code); } function finish(code: number): void { - if (exitHandled) return; - exitHandled = true; - cleanExit(); + if (phase._tag === "Ended") return; + const requests = phase._tag === "Opening" || phase._tag === "Ready" || phase._tag === "Detaching" + ? phase.requests + : null; + transition({ _tag: "Ended" }); + cleanExit(requests); completeExit(code); } - function finishDetach(): void { - if (exitHandled) return; - exitHandled = true; - detaching = true; - try { socket.write(encodeDetach()); } catch {} - cleanExit(); + function completeDetach(): void { + if (phase._tag === "Ended") return; + const requests = phase._tag === "Opening" || phase._tag === "Ready" || phase._tag === "Detaching" + ? phase.requests + : null; + transition({ _tag: "Ended" }); + cleanExit(requests); stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); - if (exitCompleted) return; - exitCompleted = true; options.onDetach?.(); } - // Wire stdin/resize forwarding ONCE. Handlers write to the CURRENT `socket`, - // so they keep working after the reconnect loop swaps the socket. - function wireInput(): void { - if (inputWired) return; - inputWired = true; - let lastDetachKeyTime = 0; - const DOUBLE_TAP_MS = 300; - stdinDataHandler = (raw: Buffer) => { - const data = normalizeDetachKey(raw); - // Fast path: no detach key in this chunk - if (data.indexOf(DETACH_KEY) === -1) { try { socket.write(encodeData(data.toString())); } catch {} return; } - // Slow path: detach key found — process byte by byte - const forward: number[] = []; - for (let i = 0; i < data.length; i++) { - if (data[i] === DETACH_KEY) { - const now = Date.now(); - if (now - lastDetachKeyTime < DOUBLE_TAP_MS) { - // Double-tap: send Ctrl+\ to the process, reset timer - lastDetachKeyTime = 0; - forward.push(DETACH_KEY); - } else { - // First tap: schedule detach (will fire if no second tap) - lastDetachKeyTime = now; - setTimeout(() => { - if (lastDetachKeyTime === now) { - finishDetach(); - } - }, DOUBLE_TAP_MS); - } - } else { - forward.push(data[i]); - } + const writeRequest = (request: Parameters[0]): void => { + if (phase._tag !== "Opening" && phase._tag !== "Ready" && phase._tag !== "Detaching") return; + if (!phase.requests || phase.requests.destroyed) return; + try { phase.requests.write(encodeMachineRequest(request)); } catch {} + }; + const inputPolicy = new InteractiveInputPolicy({ + onInput: (bytes) => { + if (phase._tag === "Ready") writeRequest({ _tag: "Input", bytes }); + }, + onDetach: () => { + if (phase._tag === "Ended" || phase._tag === "Detaching") return; + if (phase._tag === "Reconnecting" || phase._tag === "Opening") { + const current = phase; + transition({ + _tag: "Detaching", + controller: current.controller, + requests: current._tag === "Opening" ? current.requests : null, + }); + completeDetach(); + return; } - if (forward.length > 0) { try { socket.write(encodeData(Buffer.from(forward).toString())); } catch {} } - }; - stdin.on("data", stdinDataHandler); - // Explicitly resume stdin — see the note on readline leaving flowing=false. - stdin.resume(); - if (stdout instanceof tty.WriteStream) { - resizeHandler = () => { try { socket.write(encodeResize(stdout.rows, stdout.columns)); } catch {} }; - stdout.on("resize", resizeHandler); - } - } + transition({ _tag: "Detaching", controller: phase.controller, requests: phase.requests }); + stdin.pause(); + writeRequest({ _tag: "Detach" }); + }, + onError: (error) => { + console.error(`pty attach: ${error.message}`); + finish(1); + }, + }); - function onReady(): void { - enterRawMode(); - const rows = (stdout as tty.WriteStream).rows ?? 24; - const cols = (stdout as tty.WriteStream).columns ?? 80; - try { socket.write(encodeAttach(rows, cols)); } catch {} - wireInput(); + function wireInput(): void { + if (stdinDataHandler) return; + stdinDataHandler = (raw: Buffer) => inputPolicy.feed(Buffer.from(raw)); + stdinEndHandler = () => inputPolicy.end(); + stdin.on("data", stdinDataHandler); + stdin.on("end", stdinEndHandler); + resizeHandler = () => { + desiredSize = terminalSize(); + if (phase._tag !== "Ready" || sameSize(phase.sentSize, desiredSize)) return; + const { controller, requests } = phase; + transition({ _tag: "Ready", controller, requests, sentSize: desiredSize }); + writeRequest({ _tag: "Resize", ...desiredSize }); + }; + stdout.on("resize", resizeHandler); } - function handleData(data: Buffer): void { - let packets; - try { packets = reader.feed(data); } - catch (err: any) { - console.error(`pty client: dropping connection — ${err.message}`); - try { socket.destroy(); } catch {} - return; - } - for (const packet of packets) { - switch (packet.type) { - case MessageType.DATA: - stdout.write(packet.payload); - break; - case MessageType.SCREEN: - // Clear screen and write the replayed buffer (also how a reconnect resumes). - stdout.write("\x1b[2J\x1b[H"); - stdout.write(packet.payload); - break; - case MessageType.EXIT: - exitCode = decodeExit(packet.payload); - sessionExited = true; // real exit — never reconnect past it - stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name} exited with code ${exitCode}]\r\n`); - finish(exitCode); - return; + function handleResponse(response: MachineResponse): void { + switch (response._tag) { + case "Hello": + break; + case "Ready": { + if (phase._tag !== "Opening") return; + const { requests, sentSize } = phase; + transition({ _tag: "Ready", controller: phase.controller, requests, sentSize }); + inputPolicy.setInputModes(response.inputModes); + stdout.write("\x1b[2J\x1b[H"); + stdout.write(response.screen); + if (!sameSize(sentSize, desiredSize)) { + transition({ _tag: "Ready", controller: phase.controller, requests, sentSize: desiredSize }); + writeRequest({ _tag: "Resize", ...desiredSize }); + } + stdin.resume(); + break; } + case "Data": + if (phase._tag !== "Ready") return; + if (response.inputModes) inputPolicy.setInputModes(response.inputModes); + stdout.write(response.bytes); + break; + case "Geometry": + break; + case "Exited": + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name} exited with code ${response.code}]\r\n`); + finish(response.code); + break; + case "Detached": + completeDetach(); + break; + case "AdmissionFailure": + console.error(`pty attach: ${response.reason}${response.detail ? `: ${response.detail}` : ""}`); + finish(1); + break; + case "StreamFailure": + if (phase._tag === "Detaching") { + completeDetach(); + } else if (canReconnect && (phase._tag === "Opening" || phase._tag === "Ready")) { + beginReconnect(); + } else if (phase._tag !== "Ended") { + console.error(`pty attach: ${response.reason}${response.diagnostic ? `: ${response.diagnostic}` : ""}`); + finish(1); + } + break; } } - function handleDisconnect(err?: NodeJS.ErrnoException): void { - if (exitHandled || detaching || reconnecting) return; - if (canReconnect && !sessionExited) { void reconnectLoop(); return; } - // No reconnect: preserve the original not-found / exit-code behavior. - if (err) { - cleanExit(); - const notReachable = err.code === "ENOENT" || err.code === "ECONNREFUSED" - || err.code === "ECONNRESET" || err.code === "EPIPE"; - if (notReachable) { - console.error(options.socket - ? `Remote session "${options.name}" not found or not running.` - : `Session "${options.name}" not found or not running.`); - } else { - console.error(`Connection error: ${err.message}`); + function startAdapter(socket?: net.Socket): void { + const sequence = ++adapterSequence; + const controller = new AbortController(); + inputPolicy.discardPendingInput(); + const requests = new PassThrough(); + const responses = new PassThrough(); + const responseReader = new MachineFrameReader(); + desiredSize = terminalSize(); + transition({ _tag: "Opening", controller, requests, sentSize: desiredSize }); + stdin.resume(); + responses.on("data", (chunk: Buffer) => { + if (sequence !== adapterSequence || phase._tag === "Ended") return; + for (const frame of responseReader.feed(Buffer.from(chunk))) { + handleResponse(decodeMachineResponse(frame)); } + }); + responses.once("end", () => { + if (sequence !== adapterSequence || phase._tag === "Ended") return; + if (phase._tag === "Detaching") completeDetach(); + }); + void machineAttachV2({ + input: requests, + output: responses, + ...(socket ? { connect: () => socket, preconnected: true } : {}), + signal: controller.signal, + }).catch((error) => { + if (sequence !== adapterSequence || controller.signal.aborted || phase._tag === "Ended") return; + console.error(`pty attach: ${error instanceof Error ? error.message : String(error)}`); finish(1); - } else { - finish(exitCode); - } - } - - function bindSocket(s: net.Socket, preConnected: boolean): void { - reader = new PacketReader(); - s.on("data", handleData); - s.on("error", (err: NodeJS.ErrnoException) => handleDisconnect(err)); - s.on("close", () => handleDisconnect()); - if (preConnected) process.nextTick(onReady); - else s.on("connect", onReady); + }); + requests.write(encodeMachineRequest({ + _tag: "Open", + protocol: MACHINE_PROTOCOL_VERSION, + sessionId: options.name, + expectedGeneration: generation, + rows: desiredSize.rows, + cols: desiredSize.cols, + requiredCapabilities: [ + "framed-utf8-input", + "typed-outcome", + "input-mode-snapshot", + "host-terminal-replay", + ], + })); } // Re-establish the routed socket on a loud disconnect and re-attach; the // daemon replays screen+modes so the session resumes. Only reached when // `options.reconnect` is set (attach --remote) and the session didn't EXIT. - async function reconnectLoop(): Promise { - if (reconnecting) return; - reconnecting = true; - const status = stdout; - status.write(`\r\n[reconnecting… — Ctrl-\\ or Ctrl-C to stop]\r\n`); + function beginReconnect(): void { + if (phase._tag !== "Opening" && phase._tag !== "Ready") return; + adapterSequence++; + const controller = new AbortController(); + transition({ _tag: "Reconnecting", controller }); + inputPolicy.discardPendingInput(); + stdout.write(`\r\n[reconnecting… — Ctrl-\\ to stop]\r\n`); + stdin.resume(); + void reconnectLoop(controller); + } + + async function reconnectLoop(controller: AbortController): Promise { let attempt = 0; - while (!detaching && !exitHandled) { + while (phase._tag === "Reconnecting" && !controller.signal.aborted) { const delay = RECONNECT_BACKOFF_MS[attempt] ?? RECONNECT_BACKOFF_CAP_MS; - await new Promise((r) => setTimeout(r, delay)); - if (detaching || exitHandled) break; + await new Promise((resolve) => { + const timer = setTimeout(done, delay); + function done(): void { + clearTimeout(timer); + controller.signal.removeEventListener("abort", done); + resolve(); + } + controller.signal.addEventListener("abort", done, { once: true }); + }); + if (phase._tag !== "Reconnecting" || controller.signal.aborted) return; let fresh: net.Socket | null = null; try { - fresh = await options.reconnect!(); + fresh = await options.reconnect!(controller.signal); } catch { // Reject = reachable host that says the session is gone → clean give-up. // (Transport failures resolve null, so we keep retrying below.) - if (detaching || exitHandled) break; - reconnecting = false; - status.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name} session ended]\r\n`); + if (phase._tag !== "Reconnecting" || controller.signal.aborted) return; + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name} session ended]\r\n`); finish(0); return; } - if (detaching || exitHandled) { try { fresh?.destroy(); } catch {} break; } + if (phase._tag !== "Reconnecting" || controller.signal.aborted) { + try { fresh?.destroy(); } catch {} + return; + } if (fresh) { - socket = fresh; // input handlers now target the fresh socket - reconnecting = false; - bindSocket(fresh, true); // pre-connected; onReady → encodeAttach → daemon SCREEN replay + startAdapter(fresh); return; } // Transport failure (host unreachable) — retry, unlimited by default so a // laptop closed for hours still reconnects on reopen. Only a finite env cap - // ends it (besides the user's Ctrl-\ / Ctrl-C). + // ends it (besides the user's Ctrl-\). if (++attempt >= RECONNECT_MAX_ATTEMPTS) { - reconnecting = false; - status.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name}: connection lost — re-run \`pty attach --remote\` to reconnect]\r\n`); + stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name}: connection lost — re-run \`pty attach --remote\` to reconnect]\r\n`); finish(1); return; } } } - bindSocket(socket, firstPreConnected); + enterRawMode(); + wireInput(); + startAdapter(options.socket); } diff --git a/src/machine-attach.ts b/src/machine-attach.ts index f3e55e1..d8e1b28 100644 --- a/src/machine-attach.ts +++ b/src/machine-attach.ts @@ -28,6 +28,9 @@ export interface MachineAttachV2Options { readonly output?: Writable; readonly diagnostics?: Writable; readonly connect?: (socketPath: string) => net.Socket; + /** The injected socket is already connected (for example, a routed fabric + * tunnel). The default local connector still waits for `connect`. */ + readonly preconnected?: boolean; readonly signal?: AbortSignal; } @@ -333,12 +336,14 @@ export function machineAttachV2(options: MachineAttachV2Options = {}): Promise { + const connected = () => { if (isClosing()) return; phase = "await-admission"; enqueueDaemon(encodeDaemonOpenV2(request)); enqueueDaemon(encodeStatus()); - }); + }; + if (options.preconnected) process.nextTick(connected); + else socket.once("connect", connected); } catch (error) { finishFailure("admission", "daemon-transport-failed", (error as Error).message); } diff --git a/src/machine-protocol.ts b/src/machine-protocol.ts index 5b91da9..2269e3b 100644 --- a/src/machine-protocol.ts +++ b/src/machine-protocol.ts @@ -36,7 +36,11 @@ export const DaemonExtensionType = { ADMISSION_V2: 9, } as const; -export type MachineCapability = "framed-utf8-input" | "typed-outcome" | "input-mode-snapshot"; +export type MachineCapability = + | "framed-utf8-input" + | "typed-outcome" + | "input-mode-snapshot" + | "host-terminal-replay"; export interface MachineOpenV2 { readonly _tag: "Open"; @@ -237,7 +241,12 @@ function boolean(value: unknown, name: string): boolean { return value; } -const CAPABILITIES = new Set(["framed-utf8-input", "typed-outcome", "input-mode-snapshot"]); +const CAPABILITIES = new Set([ + "framed-utf8-input", + "typed-outcome", + "input-mode-snapshot", + "host-terminal-replay", +]); function capabilities(value: unknown): MachineCapability[] { if (!Array.isArray(value) || value.length > MAX_CAPABILITIES) { diff --git a/src/remote.ts b/src/remote.ts index a87f06e..6a0d38c 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1,6 +1,6 @@ import * as net from "node:net"; import * as fs from "node:fs"; -import { execFileSync } from "node:child_process"; +import { execFile } from "node:child_process"; import type { Readable, Writable } from "node:stream"; import { listSessions, getSession, getSocketPath, type SessionInfo } from "./sessions.ts"; @@ -45,7 +45,10 @@ export interface RemoteListResponse { /** Route handshake ack line the server sends once it has connected to the target * session, right before it starts splicing. `dialAndRoute` waits for this so a * routed command reliably knows the route succeeded (or, on `{error}`, failed).*/ -const ROUTE_OK = JSON.stringify({ ok: true }); +export interface RoutedSession { + readonly socket: net.Socket; + readonly generation: string | null; +} /** The dial reached the peer's control server but it REFUSED the route — the * host is reachable and reports the session is gone/absent. Distinct from a @@ -148,7 +151,10 @@ export function handleRemoteConnection(input: Readable, output: Writable, done: target.on("connect", () => { // ACK the route BEFORE splicing so the client knows it succeeded (the // per-session protocol has no ack). Then wire the bidirectional splice. - output.write(ROUTE_OK + "\n"); + output.write(JSON.stringify({ + ok: true, + generation: session.metadata?.generation ?? null, + }) + "\n"); if (residual.length > 0) target.write(residual); input.pipe(target); target.pipe(output, { end: false }); // `finish` owns the final teardown @@ -201,67 +207,108 @@ export function runRemoteServeStdio(): void { * remote session. The resolved socket is a transparent pipe to that session's * daemon socket, ready for the ordinary per-session protocol (attach/peek/send * client code runs over it unchanged). */ -export function dialAndRoute(peer: string, name: string, timeoutMs = 10000): Promise { +export function dialAndRoute( + peer: string, + name: string, + timeoutMs = 10000, + signal?: AbortSignal, +): Promise { return new Promise((resolve, reject) => { - let dialSock: string; - try { - dialSock = execFileSync(FABRIC_BIN, ["dial", peer, PTY_REMOTE_ALPN], { - encoding: "utf-8", - timeout: timeoutMs, - }).trim(); - } catch (e) { - reject(e instanceof Error ? e : new Error(String(e))); - return; - } - if (!dialSock) { - reject(new Error(`fabric dial ${peer} returned no socket`)); - return; - } - const sock = net.createConnection(dialSock); + let sock: net.Socket | null = null; + let settled = false; let acked = false; let buf: Buffer = Buffer.alloc(0); - const timer = setTimeout(() => { - if (!acked) { sock.destroy(); reject(new Error("route handshake timed out")); } - }, timeoutMs); + let timer: NodeJS.Timeout | null = null; + const cleanup = () => { + if (timer) clearTimeout(timer); + timer = null; + signal?.removeEventListener("abort", onAbort); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + try { sock?.destroy(); } catch {} + reject(error instanceof Error ? error : new Error(String(error))); + }; + const succeed = (routed: RoutedSession) => { + if (settled) { + try { routed.socket.destroy(); } catch {} + return; + } + settled = true; + cleanup(); + resolve(routed); + }; + const onAbort = () => fail(signal?.reason ?? new Error("remote dial cancelled")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + + execFile(FABRIC_BIN, ["dial", peer, PTY_REMOTE_ALPN], { + encoding: "utf-8", + timeout: timeoutMs, + signal, + }, (error, stdout) => { + if (settled) return; + if (error) { + fail(error); + return; + } + const dialSock = stdout.trim(); + if (!dialSock) { + fail(new Error(`fabric dial ${peer} returned no socket`)); + return; + } + connect(dialSock); + }); + + const connect = (dialSock: string) => { + if (settled) return; + sock = net.createConnection(dialSock); + timer = setTimeout(() => { + if (!acked) fail(new Error("route handshake timed out")); + }, timeoutMs); const onData = (chunk: Buffer) => { buf = Buffer.concat([buf, chunk]); const nl = buf.indexOf(0x0a); if (nl === -1) return; // wait for the full ack line acked = true; - clearTimeout(timer); - sock.removeListener("data", onData); + if (timer) clearTimeout(timer); + timer = null; + sock!.removeListener("data", onData); const line = buf.subarray(0, nl).toString("utf-8"); const rest = buf.subarray(nl + 1); // bytes after the ack (normally none) - let resp: { ok?: boolean; error?: string }; + let resp: { ok?: boolean; error?: string; generation?: string | null }; try { resp = JSON.parse(line); } catch { - sock.destroy(); - reject(new Error(`bad route response: ${line.slice(0, 80)}`)); + fail(new Error(`bad route response: ${line.slice(0, 80)}`)); return; } if (resp.error || !resp.ok) { // Reached the host; it refused the route (session gone). Distinct from a // transport failure so reconnect can give up cleanly vs retry forever. - sock.destroy(); - reject(new RouteRefusedError(resp.error ?? "route refused")); + fail(new RouteRefusedError(resp.error ?? "route refused")); return; } // Hand back anything that arrived after the ack line so the caller's // per-session protocol sees it (defensive — the server splices only after // acking, so in practice `rest` is empty). - if (rest.length > 0) sock.unshift(rest); - resolve(sock); + if (rest.length > 0) sock!.unshift(rest); + succeed({ socket: sock!, generation: resp.generation ?? null }); }; sock.once("connect", () => { - sock.write(JSON.stringify({ op: "route", name }) + "\n"); + sock!.write(JSON.stringify({ op: "route", name }) + "\n"); }); sock.on("data", onData); - sock.on("error", (e) => { clearTimeout(timer); if (!acked) reject(e); }); + sock.on("error", (e) => { if (!acked) fail(e); }); sock.on("close", () => { - clearTimeout(timer); - if (!acked) reject(new Error(`remote session "${name}" not reachable`)); + if (!acked) fail(new Error(`remote session "${name}" not reachable`)); }); + }; }); } diff --git a/src/server.ts b/src/server.ts index 0516cf9..199daee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -113,6 +113,7 @@ interface Client { readonly _tag: "MachineAwaitingSentinel"; readonly rows: number; readonly cols: number; + readonly hostTerminalReplay: boolean; } | { readonly _tag: "Machine"; @@ -273,6 +274,7 @@ const MACHINE_CAPABILITIES = [ "framed-utf8-input", "typed-outcome", "input-mode-snapshot", + "host-terminal-replay", ] as const satisfies readonly MachineCapability[]; const MAX_KITTY_KEYBOARD_STACK_DEPTH = 64; @@ -1367,6 +1369,7 @@ export class PtyServer { _tag: "MachineAwaitingSentinel", rows: open.rows, cols: open.cols, + hostTerminalReplay: open.requiredCapabilities.includes("host-terminal-replay"), }; } @@ -1490,7 +1493,8 @@ export class PtyServer { client: Client, size: { readonly rows: number; readonly cols: number }, protocol: "legacy" | "machine-v2", - onCommitted?: () => void + onCommitted?: () => void, + hostTerminalReplay = false, ): void { client.output.clearHeld(); const sizeMatched = @@ -1523,7 +1527,10 @@ export class PtyServer { cols: this.terminal.cols, outputRevision: this.outputRevision, inputModes: this.inputModeSnapshot(), - screen: Buffer.from(this.serialize.serialize()), + screen: Buffer.from( + (hostTerminalReplay ? this.getModePrefix(true) : "") + + this.serialize.serialize() + ), }) : encodeScreen(this.getModePrefix(true) + this.serialize.serialize()), () => { @@ -1577,7 +1584,7 @@ export class PtyServer { Number(packet.type) === MessageType.STATUS && packet.payload.length === 0 ) { - const { rows, cols } = client.role; + const { rows, cols, hostTerminalReplay } = client.role; this.admitWritableClient( client, { rows, cols }, @@ -1590,7 +1597,8 @@ export class PtyServer { capabilities: MACHINE_CAPABILITIES, build: { version: readPackageVersion(), dirty: false }, }) - ) + ), + hostTerminalReplay, ); continue; } diff --git a/src/testing/session.ts b/src/testing/session.ts index a7f8536..d4c0f57 100644 --- a/src/testing/session.ts +++ b/src/testing/session.ts @@ -19,7 +19,7 @@ import { captureScreenshot } from "./screenshot.ts"; import type { Screenshot, SpawnOptions, ServerOptions } from "./types.ts"; type Backend = - | { kind: "spawn"; ptyProcess: pty.IPty } + | { kind: "spawn"; ptyProcess: pty.IPty; exitCode: number | null } | { kind: "server"; server: PtyServer; ownsServer: boolean; socket: net.Socket; reader: PacketReader; screenCallbacks: Array<() => void>; exitCode: number | null; name: string }; let nameCounter = 0; @@ -128,7 +128,8 @@ export class Session { terminal.write(data); }); - const backend: Backend = { kind: "spawn", ptyProcess: proc }; + const backend: Backend = { kind: "spawn", ptyProcess: proc, exitCode: null }; + proc.onExit(({ exitCode }) => { backend.exitCode = exitCode; }); return new Session(terminal, serialize, backend, rows, cols); } @@ -235,12 +236,9 @@ export class Session { return this._cols; } - /** Whether the process has exited. Server-mode only; always false for spawn-mode. */ + /** Whether the spawned process or server-backed session has exited. */ get hasExited(): boolean { - if (this.backend.kind === "server") { - return this.backend.exitCode !== null; - } - return false; + return this.backend.exitCode !== null; } /** The PtyServer instance (server-mode only). */ diff --git a/tests/attach-no-restart.test.ts b/tests/attach-no-restart.test.ts index 06437a4..8667f31 100644 --- a/tests/attach-no-restart.test.ts +++ b/tests/attach-no-restart.test.ts @@ -254,6 +254,91 @@ describe("pty attach --no-restart", () => { expect(readEventCount(root, name, "session_start")).toBe(1); }, 20_000); + it("forwards exact fragmented terminal bytes through v2 and keeps detach local", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "bytes-")); + const name = "byte-safe-target"; + const childScript = [ + "process.stdin.setRawMode?.(true)", + "process.stdin.resume()", + "process.stdout.write('\\x1b[>1uRAW_READY\\r\\n')", + "let received = Buffer.alloc(0)", + "process.stdin.on('data', chunk => {", + " received = Buffer.concat([received, chunk])", + " if (received.length >= 7) process.stdout.write(`HEX:${received.subarray(0, 7).toString('hex')}\\r\\n`)", + "})", + "setInterval(() => {}, 1000)", + ].join(";"); + const created = runCli(root, [ + "run", "-d", "--id", name, "--tag", "keep=true", + "--", nodeBin, "-e", childScript, + ]); + expect(created.status, created.stderr).toBe(0); + const daemonPid = Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf8").trim()); + daemonPids.add(daemonPid); + + const attached = nodePty.spawn(nodeBin, [cliPath, "attach", "--no-restart", name], { + name: "xterm-256color", + cols: 100, + rows: 30, + env: env(root), + }); + let output = ""; + attached.onData((data) => { output += data; }); + const exited = new Promise<{ exitCode: number; signal?: number }>((resolve) => attached.onExit(resolve)); + await waitForTerminalText(() => output, "RAW_READY"); + + attached.write("😀"); + attached.write("\x1bb"); + for (const chunk of ["\x1b[9", "2;", "5u", "\x1b[92;5u"]) attached.write(chunk); + await waitForTerminalText(() => output, "HEX:f09f98801b621c"); + + attached.write("\x1c"); + const result = await Promise.race([ + exited, + new Promise((_, reject) => setTimeout(() => reject(new Error(`attach did not detach\n${output}`)), 8_000)), + ]); + expect(result.exitCode).toBe(0); + expect(output).toContain("[detached]"); + }, 20_000); + + it("reconciles a resize that lands after OPEN but before READY", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "opening-resize-")); + const name = "opening-resize-target"; + const created = runCli(root, [ + "run", "-d", "--id", name, "--tag", "keep=true", + "--", "sh", "-c", "printf 'RESIZE_READY\\r\\n'; while IFS= read -r line; do sh -c \"$line\"; done", + ]); + expect(created.status, created.stderr).toBe(0); + const daemonPid = Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf8").trim()); + daemonPids.add(daemonPid); + + const attached = nodePty.spawn(nodeBin, [cliPath, "attach", "--no-restart", name], { + name: "xterm-256color", + cols: 60, + rows: 20, + env: env(root), + }); + let output = ""; + attached.onData((data) => { output += data; }); + try { + await waitForCondition(() => { + try { + return typeof JSON.parse(fs.readFileSync(path.join(root, `${name}.json`), "utf8")).lastAttachAt === "string"; + } catch { + return false; + } + }); + attached.resize(100, 40); + await waitForTerminalText(() => output, "RESIZE_READY"); + attached.write("stty size\r"); + await waitForTerminalText(() => output, "40 100"); + attached.write("\x1c"); + await waitForTerminalText(() => output, "[detached]"); + } finally { + try { attached.kill(); } catch {} + } + }, 20_000); + it("preserves the default prompt-and-restart behavior", async () => { const root = fs.mkdtempSync(path.join(testRoot, "legacy-")); const marker = path.join(root, "invocations"); @@ -285,3 +370,19 @@ describe("pty attach --no-restart", () => { expect(readEventCount(root, name, "session_start")).toBe(1); }); }); + +async function waitForTerminalText(read: () => string, text: string): Promise { + const started = Date.now(); + while (!read().includes(text)) { + if (Date.now() - started > 8_000) throw new Error(`terminal did not contain ${text}\n${read()}`); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +async function waitForCondition(predicate: () => boolean): Promise { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > 8_000) throw new Error("condition did not become true"); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} diff --git a/tests/machine-attach-v2.test.ts b/tests/machine-attach-v2.test.ts index 9877343..e4cc0a4 100644 --- a/tests/machine-attach-v2.test.ts +++ b/tests/machine-attach-v2.test.ts @@ -6,6 +6,7 @@ import { spawn } from "node:child_process"; import { PassThrough, Writable } from "node:stream"; import { afterEach, describe, expect, it } from "vitest"; import { machineAttachV2 } from "../src/machine-attach.ts"; +import { attach, InteractiveInputPolicy } from "../src/client.ts"; import { MACHINE_PROTOCOL_VERSION, DaemonExtensionType, @@ -99,6 +100,14 @@ async function waitForResponse( throw new Error(`Timed out waiting for ${tag}`); } +async function waitFor(predicate: () => boolean, description: string): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for ${description}`); +} + describe("machine-attach-v2 adapter", () => { it("classifies an old daemon without sending ATTACH, RESIZE, or DATA", async () => { const received: number[] = []; @@ -513,3 +522,260 @@ describe("machine-attach-v2 adapter", () => { fs.rmSync(root, { recursive: true, force: true }); }); }); + +describe("interactive attach input policy", () => { + it("preserves UTF-8 and control bytes across arbitrary stdin fragmentation", () => { + const expected = Buffer.from("A😀\x1b[1;3D終"); + const emitted: Buffer[] = []; + const policy = new InteractiveInputPolicy({ + onInput: (bytes) => emitted.push(bytes), + onDetach: () => { throw new Error("unexpected detach"); }, + }); + for (const byte of expected) policy.feed(Buffer.from([byte])); + policy.end(); + expect(Buffer.concat(emitted)).toEqual(expected); + }); + + it("recognizes Kitty Ctrl-\\ across every chunk boundary and forwards a double tap exactly", () => { + const kitty = Buffer.from("\x1b[92;5u"); + for (let split = 1; split < kitty.length; split++) { + const emitted: Buffer[] = []; + const policy = new InteractiveInputPolicy({ + onInput: (bytes) => emitted.push(bytes), + onDetach: () => { throw new Error("unexpected detach"); }, + ambiguityMs: 10_000, + }); + policy.setInputModes({ ...modes, kittyKeyboardFlagsStack: [1] }); + policy.feed(kitty.subarray(0, split)); + policy.feed(kitty.subarray(split)); + policy.feed(kitty); + expect(Buffer.concat(emitted)).toEqual(Buffer.from([0x1c])); + policy.dispose(); + } + }); + + it("preserves causal byte order when ordinary input and a double detach chord are coalesced", () => { + const emitted: Buffer[] = []; + const policy = new InteractiveInputPolicy({ + onInput: (bytes) => emitted.push(bytes), + onDetach: () => { throw new Error("unexpected detach"); }, + }); + policy.setInputModes({ ...modes, kittyKeyboardFlagsStack: [1] }); + policy.feed(Buffer.from("😀\x1bb\x1b[92;5u\x1b[92;5u")); + expect(Buffer.concat(emitted)).toEqual(Buffer.from("😀\x1bb\x1c")); + policy.dispose(); + }); + + it("does not reserve Kitty-looking bytes when Kitty keyboard mode is inactive", () => { + const bytes = Buffer.from("before\x1b[92;5uafter"); + const emitted: Buffer[] = []; + const policy = new InteractiveInputPolicy({ + onInput: (value) => emitted.push(value), + onDetach: () => { throw new Error("unexpected detach"); }, + }); + policy.feed(bytes); + policy.end(); + expect(Buffer.concat(emitted)).toEqual(bytes); + }); + + it("releases an ambiguous Kitty prefix byte-for-byte when its deadline expires", async () => { + const emitted: Buffer[] = []; + const policy = new InteractiveInputPolicy({ + onInput: (value) => emitted.push(value), + onDetach: () => { throw new Error("unexpected detach"); }, + ambiguityMs: 1, + }); + policy.setInputModes({ ...modes, kittyKeyboardFlagsStack: [1] }); + policy.feed(Buffer.from("\x1b[")); + await new Promise((resolve) => setTimeout(resolve, 10)); + policy.feed(Buffer.from("1;3D")); + policy.end(); + expect(Buffer.concat(emitted)).toEqual(Buffer.from("\x1b[1;3D")); + }); + + it("discards only incomplete ordinary input at an uncertain transport boundary", () => { + const emitted: Buffer[] = []; + const policy = new InteractiveInputPolicy({ + onInput: (value) => emitted.push(value), + onDetach: () => { throw new Error("unexpected detach"); }, + }); + policy.feed(Buffer.from("😀").subarray(0, 2)); + policy.discardPendingInput(); + policy.feed(Buffer.from("A")); + policy.end(); + expect(Buffer.concat(emitted)).toEqual(Buffer.from("A")); + }); +}); + +describe("interactive attach lifecycle", () => { + it("cancels the admitted socket when local detach wins during Opening", async () => { + let serverSocketClosed = false; + const connect = await listen((socket) => { + socket.once("close", () => { serverSocketClosed = true; }); + const reader = new MachineFrameReader(); + let opened: MachineOpenV2 | null = null; + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.from(chunk))) { + if (packet.type === DaemonExtensionType.OPEN_V2) { + opened = decodeDaemonOpenV2(packet); + } else if (packet.type === MessageType.STATUS) { + if (!opened) throw new Error("STATUS arrived before OPEN_V2"); + socket.write(encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: opened.expectedGeneration, + capabilities: opened.requiredCapabilities, + build: { version: "test", dirty: false }, + })); + } + } + }); + }); + const input = new PassThrough(); + const output = new PassThrough(); + let detached = false; + const exitCodes: number[] = []; + attach({ + name: "test-session", + expectedGeneration: "generation-a", + socket: connect("ignored"), + input, + output, + onDetach: () => { detached = true; }, + onExit: (code) => { exitCodes.push(code); }, + }); + input.write(Buffer.from([0x1c])); + await waitFor(() => detached, "Opening detach callback"); + await waitFor(() => serverSocketClosed, "admitted socket close"); + expect(exitCodes).toEqual([]); + input.destroy(); + output.destroy(); + }); + + it("completes detach locally when transport fails after the detach request", async () => { + const connect = await listen((socket) => { + const reader = new MachineFrameReader(); + let opened: MachineOpenV2 | null = null; + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.from(chunk))) { + if (packet.type === DaemonExtensionType.OPEN_V2) { + opened = decodeDaemonOpenV2(packet); + continue; + } + if (packet.type === MessageType.STATUS) { + if (!opened) throw new Error("STATUS arrived before OPEN_V2"); + socket.write(Buffer.concat([ + encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: opened.expectedGeneration, + capabilities: opened.requiredCapabilities, + build: { version: "test", dirty: false }, + }), + encodeMachineResponse({ + _tag: "Ready", + outputRevision: 0, + rows: opened.rows, + cols: opened.cols, + inputModes: modes, + screen: Buffer.from("READY"), + }), + ])); + continue; + } + if (decodeMachineRequest(packet)._tag === "Detach") socket.destroy(); + } + }); + }); + const input = new PassThrough(); + const output = new PassThrough(); + let rendered = ""; + output.on("data", (chunk) => { rendered += chunk.toString(); }); + let detached = false; + const exitCodes: number[] = []; + attach({ + name: "test-session", + expectedGeneration: "generation-a", + socket: connect("ignored"), + input, + output, + onDetach: () => { detached = true; }, + onExit: (code) => { exitCodes.push(code); }, + }); + await waitFor(() => rendered.includes("READY"), "interactive READY"); + input.write(Buffer.from([0x1c])); + await waitFor(() => detached, "local detach after transport failure"); + expect(rendered).toContain("[detached]"); + expect(exitCodes).toEqual([]); + input.destroy(); + output.destroy(); + }); + + it("aborts an in-flight reconnect before invoking a library detach callback", async () => { + let drop: (() => void) | null = null; + const connect = await listen((socket) => { + drop = () => socket.destroy(); + const reader = new MachineFrameReader(); + let opened: MachineOpenV2 | null = null; + socket.on("data", (chunk) => { + for (const packet of reader.feed(Buffer.from(chunk))) { + if (packet.type === DaemonExtensionType.OPEN_V2) { + opened = decodeDaemonOpenV2(packet); + } else if (packet.type === MessageType.STATUS) { + if (!opened) throw new Error("STATUS arrived before OPEN_V2"); + socket.write(Buffer.concat([ + encodeDaemonAdmissionV2({ + _tag: "Accepted", + protocol: MACHINE_PROTOCOL_VERSION, + generation: opened.expectedGeneration, + capabilities: opened.requiredCapabilities, + build: { version: "test", dirty: false }, + }), + encodeMachineResponse({ + _tag: "Ready", + outputRevision: 0, + rows: opened.rows, + cols: opened.cols, + inputModes: modes, + screen: Buffer.from("READY"), + }), + ])); + } + } + }); + }); + const input = new PassThrough(); + const output = new PassThrough(); + let rendered = ""; + output.on("data", (chunk) => { rendered += chunk.toString(); }); + let reconnectSignal: AbortSignal | null = null; + let reconnectAborted = false; + let detached = false; + attach({ + name: "test-session", + expectedGeneration: "generation-a", + socket: connect("ignored"), + input, + output, + reconnect: (signal) => new Promise((resolve) => { + reconnectSignal = signal; + signal.addEventListener("abort", () => { + reconnectAborted = true; + resolve(null); + }, { once: true }); + }), + onDetach: () => { detached = true; }, + onExit: (code) => { throw new Error(`unexpected exit ${code}`); }, + }); + await waitFor(() => rendered.includes("READY"), "interactive READY"); + drop!(); + await waitFor(() => reconnectSignal !== null, "in-flight reconnect"); + input.write(Buffer.from([0x1c])); + await waitFor(() => detached, "library detach callback"); + expect(reconnectSignal!.aborted).toBe(true); + expect(reconnectAborted).toBe(true); + expect(rendered).toContain("[detached]"); + input.destroy(); + output.destroy(); + }); +}); diff --git a/tests/machine-server-v2.test.ts b/tests/machine-server-v2.test.ts index 5ef81b6..4b1535e 100644 --- a/tests/machine-server-v2.test.ts +++ b/tests/machine-server-v2.test.ts @@ -110,7 +110,11 @@ function recordFrames(socket: net.Socket): { }; } -function openRequest(name: string, generation = "generation-a"): MachineOpenV2 { +function openRequest( + name: string, + generation = "generation-a", + hostTerminalReplay = false, +): MachineOpenV2 { return { _tag: "Open", protocol: MACHINE_PROTOCOL_VERSION, @@ -122,6 +126,7 @@ function openRequest(name: string, generation = "generation-a"): MachineOpenV2 { "framed-utf8-input", "typed-outcome", "input-mode-snapshot", + ...(hostTerminalReplay ? ["host-terminal-replay" as const] : []), ], }; } @@ -192,6 +197,29 @@ describe("machine attach v2 daemon admission", () => { socket.destroy(); }); + it("prefixes the Ready screen only when a human host requests terminal replay", async () => { + const name = uniqueName(); + const server = await startServer(name); + const handlePtyOutput = (server as unknown as { + handlePtyOutput: (data: string) => void; + }).handlePtyOutput.bind(server); + handlePtyOutput("\x1b[?2004h\x1b[>1uHOST_READY"); + + const socket = await connect(name); + const frames = recordFrames(socket); + socket.write(Buffer.concat([ + encodeDaemonOpenV2(openRequest(name, "generation-a", true)), + encodeStatus(), + ])); + await frames.waitFor((received) => received.length >= 2); + const ready = decodeMachineResponse(frames.frames[1]); + if (ready._tag !== "Ready") throw new Error("expected Ready baseline"); + expect(ready.screen.subarray(0, "\x1b[?2004h\x1b[>1u".length).toString()).toBe( + "\x1b[?2004h\x1b[>1u", + ); + socket.destroy(); + }); + it("treats C0 bytes as input and invalid UTF-8 as a typed stream failure", async () => { const name = uniqueName(); await startServer(name, "sh", [ diff --git a/tests/remote-reconnect.test.ts b/tests/remote-reconnect.test.ts index 34c2721..813a50b 100644 --- a/tests/remote-reconnect.test.ts +++ b/tests/remote-reconnect.test.ts @@ -185,6 +185,25 @@ describe("attach --remote reconnect harness", () => { } }, 40000); + it("completes local detach while the transport is between incarnations", async () => { + const session = Session.spawn(nodeBin, [cliPath, "attach", "--remote", "testpeer", "rshell"], { + rows: 24, cols: 80, + env: { PTY_ROOT: cliRoot, PTY_ROOT_LEGACY_SILENT: "1", PTY_FABRIC_BIN: fakeFabric }, + }); + try { + await session.waitForText("RECONNECT_READY", 8000); + proxy.block(); + proxy.drop(); + await session.waitForText("reconnecting", 8000); + session.sendKeys("\x1c"); + await session.waitForText("[detached]", 3000); + await session.waitFor(() => session.hasExited, 3000, "attach process to exit after detach"); + } finally { + proxy.unblock(); + await session.close(); + } + }, 25000); + it("survives a LONG transport outage (unlimited retry) and reconnects when the peer returns", async () => { const session = Session.spawn(nodeBin, [cliPath, "attach", "--remote", "testpeer", "rshell"], { rows: 24, cols: 80, @@ -199,6 +218,8 @@ describe("attach --remote reconnect harness", () => { proxy.block(); proxy.drop(); await session.waitForText("reconnecting", 8000); // shows the reconnecting indicator + const suppressedSentinel = `MUST_NOT_REACH_CHILD_${rand()}`; + session.sendKeys(`${suppressedSentinel}\r`); await new Promise((r) => setTimeout(r, 6000)); // outage spanning several failed retries // Peer reachable again → the next retry succeeds → session resumes. @@ -206,6 +227,9 @@ describe("attach --remote reconnect harness", () => { await new Promise((r) => setTimeout(r, 6000)); // let a retry land + re-attach session.sendKeys("AFTER_OUTAGE\r"); await session.waitForText("AFTER_OUTAGE", 12000); // reconnected + resumed after the outage + // The child is `cat`, so any delivered sentinel would be visible after + // replay. Input typed between incarnations is intentionally discarded. + expect(session.screenshot().text).not.toContain(suppressedSentinel); } finally { proxy.unblock(); await session.close(); From ea7bc1063bfb16309de5e2717c548c70605839e4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:07:45 +0200 Subject: [PATCH 10/10] fix: recognize modifyOtherKeys detach input --- docs/vrs/spec.md | 5 ++- src/client.ts | 79 ++++++++++++++++++--------------- tests/machine-attach-v2.test.ts | 58 ++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 37 deletions(-) diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index e2d8ce7..b5a92ee 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -151,8 +151,9 @@ core, not a second writable daemon protocol. It reads the exact local generation from metadata or the remote generation from route admission, requests the optional `host-terminal-replay` capability, and frames complete UTF-8 input, resize, and detach requests through `machine-attach-v2`. The local policy alone -reserves Ctrl-\\: it recognizes both C0 and Kitty encodings across stream chunks, -uses a bounded ambiguity deadline only for an incomplete Kitty prefix, and +reserves Ctrl-\\: it recognizes C0, Kitty, and xterm modifyOtherKeys level 2 +encodings across stream chunks according to the installed input-mode snapshot, +uses a bounded ambiguity deadline only for an incomplete active encoding, and otherwise preserves input bytes exactly. A second Ctrl-\\ within the documented double-tap window emits one C0 byte to the child. diff --git a/src/client.ts b/src/client.ts index 3c43252..4adc869 100644 --- a/src/client.ts +++ b/src/client.ts @@ -23,8 +23,8 @@ import { } from "./machine-protocol.ts"; const DETACH_KEY = 0x1c; // Ctrl+\ (legacy encoding) -const DETACH_KEY_KITTY = "\x1b[92;5u"; // Ctrl+\ (Kitty keyboard protocol) -const DETACH_KEY_KITTY_BYTES = Buffer.from(DETACH_KEY_KITTY); +const DETACH_KEY_KITTY = Buffer.from("\x1b[92;5u"); +const DETACH_KEY_MODIFY_OTHER_KEYS = Buffer.from("\x1b[27;5;92~"); interface InteractiveInputPolicyOptions { readonly onInput: (bytes: Buffer) => void; @@ -42,10 +42,10 @@ export class InteractiveInputPolicy { private readonly onError: (error: Error) => void; private readonly ambiguityMs: number; private readonly doubleTapMs: number | null; - private kittyEnabled = false; - private kittyPrefix = Buffer.alloc(0); + private encodedDetachKeys: readonly Buffer[] = []; + private encodedPrefix = Buffer.alloc(0); private utf8Prefix = Buffer.alloc(0); - private kittyTimer: NodeJS.Timeout | null = null; + private encodedPrefixTimer: NodeJS.Timeout | null = null; private detachTimer: NodeJS.Timeout | null = null; private stopped = false; @@ -54,25 +54,30 @@ export class InteractiveInputPolicy { this.onDetach = options.onDetach; this.onError = options.onError ?? (() => {}); this.ambiguityMs = options.ambiguityMs ?? 25; - this.doubleTapMs = options.doubleTapMs ?? 300; + this.doubleTapMs = options.doubleTapMs === undefined ? 300 : options.doubleTapMs; } setInputModes(modes: MachineInputModeSnapshotV1): void { - const enabled = modes.kittyKeyboardFlagsStack.length > 0; - if (this.kittyEnabled && !enabled) this.flushKittyPrefix(); - this.kittyEnabled = enabled; + const next = [ + ...(modes.kittyKeyboardFlagsStack.length > 0 ? [DETACH_KEY_KITTY] : []), + ...(modes.modifyOtherKeys === 2 ? [DETACH_KEY_MODIFY_OTHER_KEYS] : []), + ]; + const unchanged = next.length === this.encodedDetachKeys.length && + next.every((key, index) => key.equals(this.encodedDetachKeys[index])); + if (!unchanged) this.flushEncodedPrefix(); + this.encodedDetachKeys = next; } feed(chunk: Buffer): void { if (this.stopped || chunk.length === 0) return; - if (this.kittyTimer) { - clearTimeout(this.kittyTimer); - this.kittyTimer = null; + if (this.encodedPrefixTimer) { + clearTimeout(this.encodedPrefixTimer); + this.encodedPrefixTimer = null; } - const bytes = this.kittyPrefix.length === 0 + const bytes = this.encodedPrefix.length === 0 ? chunk - : Buffer.concat([this.kittyPrefix, chunk]); - this.kittyPrefix = Buffer.alloc(0); + : Buffer.concat([this.encodedPrefix, chunk]); + this.encodedPrefix = Buffer.alloc(0); const forward: Buffer[] = []; let literalStart = 0; const flushLiteral = (end: number) => { @@ -92,20 +97,24 @@ export class InteractiveInputPolicy { literalStart = index; continue; } - if (this.kittyEnabled && bytes[index] === DETACH_KEY_KITTY_BYTES[0]) { + if (this.encodedDetachKeys.length > 0 && bytes[index] === 0x1b) { const remaining = bytes.subarray(index); - const compared = Math.min(remaining.length, DETACH_KEY_KITTY_BYTES.length); - if (remaining.subarray(0, compared).equals(DETACH_KEY_KITTY_BYTES.subarray(0, compared))) { + const candidates = this.encodedDetachKeys.filter((key) => { + const compared = Math.min(remaining.length, key.length); + return remaining.subarray(0, compared).equals(key.subarray(0, compared)); + }); + if (candidates.length > 0) { flushLiteral(index); - if (remaining.length < DETACH_KEY_KITTY_BYTES.length) { - this.kittyPrefix = Buffer.from(remaining); + const complete = candidates.find((key) => remaining.length >= key.length); + if (!complete) { + this.encodedPrefix = Buffer.from(remaining); literalStart = bytes.length; - this.kittyTimer = setTimeout(() => this.flushKittyPrefix(), this.ambiguityMs); + this.encodedPrefixTimer = setTimeout(() => this.flushEncodedPrefix(), this.ambiguityMs); break; } emitForward(); this.handleDetachKey(); - index += DETACH_KEY_KITTY_BYTES.length; + index += complete.length; literalStart = index; continue; } @@ -118,26 +127,26 @@ export class InteractiveInputPolicy { end(): void { if (this.stopped) return; - this.flushKittyPrefix(); + this.flushEncodedPrefix(); if (this.utf8Prefix.length > 0) { this.fail(new Error("stdin ended with an incomplete UTF-8 sequence")); } } discardPendingInput(): void { - if (this.kittyTimer) clearTimeout(this.kittyTimer); - this.kittyTimer = null; - this.kittyPrefix = Buffer.alloc(0); + if (this.encodedPrefixTimer) clearTimeout(this.encodedPrefixTimer); + this.encodedPrefixTimer = null; + this.encodedPrefix = Buffer.alloc(0); this.utf8Prefix = Buffer.alloc(0); } dispose(): void { this.stopped = true; - if (this.kittyTimer) clearTimeout(this.kittyTimer); + if (this.encodedPrefixTimer) clearTimeout(this.encodedPrefixTimer); if (this.detachTimer) clearTimeout(this.detachTimer); - this.kittyTimer = null; + this.encodedPrefixTimer = null; this.detachTimer = null; - this.kittyPrefix = Buffer.alloc(0); + this.encodedPrefix = Buffer.alloc(0); this.utf8Prefix = Buffer.alloc(0); } @@ -160,12 +169,12 @@ export class InteractiveInputPolicy { }, this.doubleTapMs); } - private flushKittyPrefix(): void { - if (this.kittyTimer) clearTimeout(this.kittyTimer); - this.kittyTimer = null; - if (this.kittyPrefix.length === 0) return; - const bytes = this.kittyPrefix; - this.kittyPrefix = Buffer.alloc(0); + private flushEncodedPrefix(): void { + if (this.encodedPrefixTimer) clearTimeout(this.encodedPrefixTimer); + this.encodedPrefixTimer = null; + if (this.encodedPrefix.length === 0) return; + const bytes = this.encodedPrefix; + this.encodedPrefix = Buffer.alloc(0); this.emitUtf8(bytes); } diff --git a/tests/machine-attach-v2.test.ts b/tests/machine-attach-v2.test.ts index e4cc0a4..ce084e9 100644 --- a/tests/machine-attach-v2.test.ts +++ b/tests/machine-attach-v2.test.ts @@ -554,6 +554,64 @@ describe("interactive attach input policy", () => { } }); + it("recognizes modifyOtherKeys=2 Ctrl-\\ across every chunk partition", () => { + const encoded = Buffer.from("\x1b[27;5;92~"); + const partitionCount = 1 << (encoded.length - 1); + for (let cuts = 0; cuts < partitionCount; cuts++) { + const emitted: Buffer[] = []; + let detachCount = 0; + const policy = new InteractiveInputPolicy({ + onInput: (bytes) => emitted.push(bytes), + onDetach: () => { detachCount++; }, + ambiguityMs: 10_000, + doubleTapMs: null, + }); + policy.setInputModes({ ...modes, modifyOtherKeys: 2 }); + let start = 0; + for (let offset = 1; offset < encoded.length; offset++) { + if ((cuts & (1 << (offset - 1))) === 0) continue; + policy.feed(encoded.subarray(start, offset)); + start = offset; + } + policy.feed(encoded.subarray(start)); + expect(detachCount, `partition mask ${cuts}`).toBe(1); + expect(Buffer.concat(emitted), `partition mask ${cuts}`).toEqual(Buffer.alloc(0)); + policy.dispose(); + } + }); + + it.each([0, 1] as const)( + "preserves modifyOtherKeys Ctrl-\\ bytes exactly at inactive level %i", + (modifyOtherKeys) => { + const bytes = Buffer.from("before\x1b[27;5;92~after"); + const emitted: Buffer[] = []; + const policy = new InteractiveInputPolicy({ + onInput: (value) => emitted.push(value), + onDetach: () => { throw new Error("unexpected detach"); }, + }); + policy.setInputModes({ ...modes, modifyOtherKeys }); + for (const byte of bytes) policy.feed(Buffer.from([byte])); + policy.end(); + expect(Buffer.concat(emitted)).toEqual(bytes); + }, + ); + + it("releases a partial modifyOtherKeys chord when the installed mode disables it", () => { + const encoded = Buffer.from("\x1b[27;5;92~"); + const emitted: Buffer[] = []; + const policy = new InteractiveInputPolicy({ + onInput: (value) => emitted.push(value), + onDetach: () => { throw new Error("unexpected detach"); }, + ambiguityMs: 10_000, + }); + policy.setInputModes({ ...modes, modifyOtherKeys: 2 }); + policy.feed(encoded.subarray(0, 5)); + policy.setInputModes({ ...modes, modifyOtherKeys: 1 }); + policy.feed(encoded.subarray(5)); + policy.end(); + expect(Buffer.concat(emitted)).toEqual(encoded); + }); + it("preserves causal byte order when ordinary input and a double detach chord are coalesced", () => { const emitted: Buffer[] = []; const policy = new InteractiveInputPolicy({