fix(terminal): preserve startup query reply order

Answer cooked-mode terminal queries in the originating turn when the native PTY can prove echo is quiet, and hold later query-only replies behind deferred replies on upstream SSH relays. Keep renderer replies atomic and bound both renderer and host-side queues with local, daemon, relay, native, and Docker SSH fish regression coverage.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Brennan Benson
2026-08-14 14:26:02 -07:00
co-authored by Neil
parent 9bb8836bb6
commit fc7f372ccb
35 changed files with 1679 additions and 150 deletions
+5
View File
@@ -182,6 +182,7 @@ jobs:
# and its fish lane is the only end-to-end guard for #9993, so a skip would
# report green with nothing exercised. Turns those skips into failures.
ORCA_REQUIRE_FISH: '1'
ORCA_REQUIRE_NODE_PTY_ECHO_STATE: '1'
steps:
- name: Checkout
@@ -247,6 +248,8 @@ jobs:
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
src/shared/pty-cooked-querier-reply-delivery.node-pty.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
@@ -283,6 +286,8 @@ jobs:
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-cooked-querier-reply-delivery.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
+77 -4
View File
@@ -93,7 +93,7 @@ index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441
//# sourceMappingURL=conpty_console_list_agent.js.map
\ No newline at end of file
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..68763e40312f4d60e2a7a432bf45b7bac33aa607 100644
--- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
@@ -111,6 +111,20 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
@@ -166,6 +170,13 @@ var UnixTerminal = /** @class */ (function (_super) {
enumerable: false,
configurable: true
});
+ /**
+ * Orca: slave ECHO bit, read synchronously off the master fd we already own.
+ * Guarded so an unpatched or prebuilt binding yields undefined, not a throw.
+ */
+ UnixTerminal.prototype.readEchoState = function () {
+ return typeof pty.echoState === 'function' ? pty.echoState(this._fd) : undefined;
+ };
/**
* openpty
*/
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
@@ -130,7 +144,7 @@ index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..555d8835ad6539c7091445207b5009b66954a992 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -210,7 +224,39 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
@@ -575,6 +607,31 @@ Napi::Value PtyGetProc(const Napi::CallbackInfo& info) {
return name_;
}
+/**
+ * Slave line discipline ECHO bit -> 1 set, 0 clear, -1 unreadable.
+ *
+ * Orca: POSIX redirects a master's mode ioctls to the slave, so this reads the
+ * slave's ECHO with no fork and no extra fd. A kernel that did not redirect would
+ * answer from the master's own termios, whose ECHO defaults SET — so the degraded
+ * answer is "echoing", never a false "quiet".
+ */
+Napi::Value PtyEchoState(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 1 || !info[0].IsNumber()) {
+ return Napi::Number::New(env, -1);
+ }
+
+ int fd = info[0].As<Napi::Number>().Int32Value();
+ struct termios tio;
+ if (tcgetattr(fd, &tio) == -1) {
+ return Napi::Number::New(env, -1);
+ }
+
+ return Napi::Number::New(env, (tio.c_lflag & ECHO) ? 1 : 0);
+}
+
/**
* Nonblocking FD
*/
@@ -684,15 +741,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
@@ -286,7 +332,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -706,80 +821,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
@@ -429,3 +475,30 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
}
}
#endif
@@ -793,6 +946,7 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("open", Napi::Function::New(env, PtyOpen));
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("process", Napi::Function::New(env, PtyGetProc));
+ exports.Set("echoState", Napi::Function::New(env, PtyEchoState));
return exports;
}
diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts
index 98733dc0cd752b554bd94e45904ca341ad141bba..2711baeae9a61f17d2b5dfbe01cead23212460a9 100644
--- a/src/unixTerminal.ts
+++ b/src/unixTerminal.ts
@@ -174,6 +174,14 @@ export class UnixTerminal extends Terminal {
get fd(): number { return this._fd; }
get ptsName(): string { return this._pty; }
+ /**
+ * Orca: slave ECHO bit, read synchronously off the master fd we already own.
+ * Guarded so an unpatched or prebuilt binding yields undefined, not a throw.
+ */
+ public readEchoState(): number | undefined {
+ return typeof (pty as any).echoState === 'function' ? (pty as any).echoState(this._fd) : undefined;
+ }
+
/**
* openpty
*/
+55 -13
View File
@@ -9888,7 +9888,7 @@
},
{
"id": "terminal-input.cooked-reply-queue",
"title": "Cooked-echo-safe terminal reply backlog stays bounded without evicting ordinary input",
"title": "Terminal query replies stay atomic and bounded without evicting ordinary input",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
@@ -9897,6 +9897,7 @@
"desktop terminal input",
"OSC 10/11 replies",
"mode-2031 replies",
"DA1 and CPR replies",
"local PTY",
"daemon and SSH PTYs"
],
@@ -9904,15 +9905,18 @@
"providers": ["local", "daemon", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves a real Linux SSH process receives xterm's OSC reply through the same queue. No live daemon, WSL, physical Linux/Windows client, or mixed-version run is registered.",
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/13137",
"https://github.com/stablyai/orca/issues/7329"
"https://github.com/stablyai/orca/issues/7329",
"https://github.com/stablyai/orca/issues/13892"
],
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending cooked-echo-safe replies and 4096 UTF-16 code units. Overflow removes the oldest pending query-reply entries, never ordinary input or reply-shaped bytes from the ordinary path, so later user input has a finite reply backlog; every retained reply reaches the established provider path as one atomic write. Drain failures settle without an unhandled rejection and cannot clear a newer queue generation.",
"oracle": "Synchronously enqueue 10,000 cooked-echo-safe query replies before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Repeat behind 10,000 ordinary inputs and through the real IPC transport, requiring ordinary and reply-shaped ordinary-path bytes to survive while only the newest 64 explicit replies remain. Exercise the text ceiling, real xterm OSC 10/11 generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, allowing the host to keep later DA1/CPR replies behind an earlier deferred OSC/private DSR reply. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and drain failures cannot clear a newer queue generation.",
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-startup-reply-delivery.test.ts",
"pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
@@ -9920,8 +9924,12 @@
"testFiles": [
"src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/shared/terminal-query-reply.test.ts",
"src/shared/pty-startup-ingress-live-query-reply.test.ts",
"src/shared/pty-startup-reply-delivery.test.ts",
"tests/e2e/terminal-osc-color-queries.spec.ts",
"tests/e2e/terminal-typing-latency.spec.ts",
"tests/e2e/pty-input-write-queue-ssh.spec.ts"
@@ -9935,7 +9943,7 @@
"reply-shaped bytes from the ordinary input path never enter the shed-able reply budget",
"reply count and text overflow remove only the oldest pending replies",
"real xterm OSC 10/11 query handlers remain subject to the same retention ceiling",
"retained cooked-echo-safe replies stay one provider write each",
"retained OSC, DA1, and CPR replies stay one provider write each and both OSC and DA1 floods remain bounded",
"provider-write and yield failures settle without unhandled rejection, repeated same-generation admission, or stale-generation clearing",
"clear releases saturated reply accounting and fences in-flight validation before later input"
]
@@ -9947,10 +9955,29 @@
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation"
]
},
{
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts",
"assertions": [
"a validation barrier runs after earlier deferred input and before later input"
]
},
{
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts",
"assertions": [
"pending ordinary input and asynchronously validated paste stay ahead of but separate from an immediate query reply"
]
},
{
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"assertions": [
"viewport-claim buffering replays ordinary input, a query reply, and later ordinary input as three ordered stream frames",
"a 10,000-reply viewport-claim flood retains and replays only the newest 64 query replies"
]
},
{
"file": "src/shared/terminal-query-reply.test.ts",
"assertions": [
"only complete cooked-echo-risk replies are classified and consecutive replies extract losslessly"
"only complete query replies are classified and consecutive mixed reply kinds extract losslessly"
]
},
{
@@ -9959,6 +9986,12 @@
"repeated live query replies are delivered independently and their cooked echoes stay hidden"
]
},
{
"file": "src/shared/pty-startup-reply-delivery.test.ts",
"assertions": [
"a later DA1 stays behind a deferred OSC reply, ordering-only replies are bounded, and a proven-quiet slave takes the same-turn fast path"
]
},
{
"file": "tests/e2e/terminal-osc-color-queries.spec.ts",
"assertions": [
@@ -9974,7 +10007,8 @@
{
"file": "tests/e2e/pty-input-write-queue-ssh.spec.ts",
"assertions": [
"a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue"
"a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue",
"an upstream-node-pty Linux relay has no echoState export and keeps OSC/DA1 reply bytes out of the next fish child stdin"
]
}
],
@@ -10014,6 +10048,15 @@
"result": "passed",
"durationSeconds": 36.8,
"summary": "A real Linux process behind Docker OpenSSH emitted an OSC 10 query and observed xterm's synthetic RGB reply after it traversed the live renderer queue, Electron IPC, SSH provider, and relay PTY."
},
{
"date": "2026-08-14",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 36.9,
"summary": "Run with SKIP_BUILD=1 against the existing e2e bundle. Both Docker OpenSSH Electron tests passed: the live OSC query returned its RGB reply, and an upstream-node-pty Linux ARM64 relay without echoState kept OSC/DA1 bytes out of the next fish 4.8.1 child's stdin."
}
],
"runtimeBudget": {
@@ -10022,7 +10065,7 @@
},
"flakeHistory": {
"status": "unknown",
"evidence": "Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction into its dedicated spec and then passed the registered dedicated command once. CI and the normal soak window are not yet available."
"evidence": "Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction and then passed both registered full-spec runs. The upstream-node-pty fish handoff oracle passed three local runs, including the final post-rebase run. CI and the normal soak window are not yet available."
},
"redGreenEvidence": {
"status": "partial",
@@ -10030,7 +10073,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "Pending cooked replies are capped at 64 and 4096 UTF-16 code units. Explicit source routing and separate sequence-merged FIFOs keep ordinary input out of the fixed-size reply store; oldest-reply eviction uses an amortized O(1) head advance without scanning ordinary input, consumed prefixes compact at a fixed threshold, and drain coalescing reads the stored classification without repeated reply-regex scans. A synchronous flood forwards only the immediate first and newest 64 replies before the trailing key. Ten complete gate runs, including two 10,000-entry flood shapes each, finished in 29.57 seconds with zero failures. The change adds no polling, timers, listeners, subprocesses, or network retries."
"evidence": "Pending query replies are capped at 64 and 4096 UTF-16 code units. Explicit source routing and separate sequence-merged FIFOs keep ordinary input out of the fixed-size reply store; oldest-reply eviction uses an amortized O(1) head advance without scanning ordinary input, consumed prefixes compact at a fixed threshold, and drain coalescing reads the stored classification without repeated reply-regex scans. Separate synchronous OSC and DA1 floods forward only the immediate first and newest 64 replies before the trailing key, and the remote-runtime viewport-claim buffer likewise retains only the newest 64 query segments while coalescing adjacent ordinary input. The sync ECHO probe removes an `stty` subprocess from the common quiet-query path and the fallback adds no polling beyond the existing bounded ECHO probe."
},
"promotionCriteria": [
"Run the gate on Windows and Linux CI.",
@@ -10039,10 +10082,9 @@
"Accumulate the normal soak window with zero unexplained flakes."
],
"knownGaps": [
"No live daemon, WSL, physical Linux/Windows client, mixed-version, or network-fault run is registered.",
"The remote-runtime transport has a separate immediate-input queue and is unaffected by this gate.",
"No live daemon, paired-runtime, WSL, physical Linux/Windows client, mixed-version, or network-fault run is registered.",
"Ordinary-input queue growth predates this change and is outside this reply-only gate.",
"Reply overload intentionally sheds the oldest synthetic replies; live application behavior under a pathological query flood is not measured."
"Reply overload intentionally sheds the oldest synthetic replies; the modified-F3/CPR byte collision shares that budget, and live application behavior under a pathological query flood is not measured."
],
"demotionRule": "Keep experimental or revert if ordinary input is evicted, pending replies exceed either ceiling, retained replies are coalesced, a trailing key sits behind more than the bounded reply window, or the gate develops unexplained flakes."
},
@@ -16,7 +16,9 @@ const shellContractFiles = [
]
const patchedNodePtyContractFiles = [
'src/main/daemon/node-pty-fd-leak.test.ts',
'src/main/pty/omp-shell-wrapper.node-pty.test.ts'
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
'src/shared/fish-query-reply-child-stdin.node-pty.test.ts',
'src/shared/pty-cooked-querier-reply-delivery.node-pty.test.ts'
]
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
const testFilePatterns = [
@@ -76,6 +78,7 @@ describe('PR workflow parallelism', () => {
expect(shellStep).toBeDefined()
expect(shellInstall).toBeDefined()
expect(workflow.jobs.shell_contracts.env.ORCA_REQUIRE_NODE_PTY_ECHO_STATE).toBe('1')
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
// Why the whole workflow, not just the general shards: any other lane installing
// these shells would silently start running the real-shell tests twice.
+3 -3
View File
@@ -21,7 +21,7 @@ patchedDependencies:
hash: 8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
hash: 501c226bd90a60a3b61a1e87018cb41b231a5f54557894daa7726489f4e90d04
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -63,7 +63,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8)
version: 1.1.0(patch_hash=501c226bd90a60a3b61a1e87018cb41b231a5f54557894daa7726489f4e90d04)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12022,7 +12022,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8):
node-pty@1.1.0(patch_hash=501c226bd90a60a3b61a1e87018cb41b231a5f54557894daa7726489f4e90d04):
dependencies:
node-addon-api: 7.1.1
+20
View File
@@ -3258,6 +3258,26 @@ describe('createPtySubprocess', () => {
killSpy.mockRestore()
})
})
// First link of the daemon's #13892 chain: Session can only forward a probe this
// handle carries. See session-same-turn-query-reply-wiring.test.ts for the second.
describe('sync ECHO probe on the handle', () => {
itOnPosixHost('exposes the patched pty’s echoState as a usable probe', () => {
spawnMock.mockReturnValue({ ...mockPtyProcess(), readEchoState: () => 0 })
const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
expect(handle.echoSyncProbe?.()).toBe('quiet')
})
itOnPosixHost('omits the probe when node-pty lacks Orca’s patch', () => {
spawnMock.mockReturnValue(mockPtyProcess())
const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
expect(handle.echoSyncProbe).toBeUndefined()
})
})
})
describe('checkPtySpawnHealth (retry on transient failure)', () => {
+6 -1
View File
@@ -78,7 +78,10 @@ import {
expandWindowsPathEnvironmentVariables
} from '../../shared/windows-environment-expansion'
import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups'
import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo'
import {
createPtySlaveEchoSyncProbe,
readPtySlavePath
} from '../../shared/pty-slave-line-discipline-echo'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -1075,12 +1078,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
})
const slavePath = readPtySlavePath(proc)
const echoSyncProbe = createPtySlaveEchoSyncProbe(proc)
return {
pid: proc.pid,
shellPath,
shellCwd: spawnCwd,
shellPathEnv: env.PATH,
...(slavePath ? { slavePath } : {}),
...(echoSyncProbe ? { echoSyncProbe } : {}),
...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}),
getForegroundProcess: () => {
// Why: node-pty's `.process` reports the live foreground name but reads a recycled pid on a reaped pty, so bail when dead.
@@ -0,0 +1,122 @@
/**
* Wiring guard for #13892's only real-world delivery path.
*
* The fix is a sync ECHO probe that each PTY host must hand to `PtyStartupIngress`.
* Deleting that spread here — or in local-pty-provider or the relay's pty-handler —
* left the whole suite green, so the fix could be refactored away silently. Sibling
* guards live in `local-pty-provider.test.ts`, `pty-handler.test.ts` and
* `pty-subprocess.test.ts` (which builds the probe this host is handed).
*
* Asserted as behavior, not source text: with the probe wired and the kernel quiet the
* reply reaches the subprocess INSIDE the caller's turn. Without it the reply is queued
* behind a timer, which is exactly the deferral that strands it in the next child's stdin.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Session, type SubprocessHandle } from './session'
import type { PtySlaveEchoSyncProbe } from '../../shared/pty-slave-line-discipline-echo'
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
const OSC11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x07'
function createSubprocess(echoSyncProbe?: PtySlaveEchoSyncProbe): {
handle: SubprocessHandle
written: string[]
} {
const written: string[] = []
return {
written,
handle: {
pid: 4242,
getForegroundProcess: () => null,
...(echoSyncProbe ? { echoSyncProbe } : {}),
write: (data: string) => void written.push(data),
resize: () => {},
kill: () => {},
forceKill: () => {},
signal: () => {},
onData: () => {},
onExit: () => {},
dispose: () => {}
}
}
}
describe('Session hands its subprocess ECHO probe to the startup ingress (#13892)', () => {
let session: Session | null = null
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
session?.dispose()
session = null
vi.useRealTimers()
})
function start(echoSyncProbe?: PtySlaveEchoSyncProbe): { written: string[] } {
const subprocess = createSubprocess(echoSyncProbe)
session = new Session({
sessionId: 'echo-sync-probe-wiring',
cols: 80,
rows: 24,
subprocess: subprocess.handle,
shellReadySupported: false,
ownerBackend: 'posix-pty'
})
return subprocess
}
it('writes a live color reply in the caller’s own turn when the probe says quiet', () => {
const echoSyncProbe = vi.fn<PtySlaveEchoSyncProbe>(() => 'quiet')
const { written } = start(echoSyncProbe)
session?.write(OSC11_REPLY)
// Both matter: the probe must be CONSULTED (the spread exists) and its verdict must
// reach the wire now (the reply is not queued). Deleting the spread fails both.
expect(echoSyncProbe).toHaveBeenCalled()
expect(written).toEqual([OSC11_REPLY])
})
it('still defers when the probe says the slave would echo', () => {
const { written } = start(() => 'echoing')
session?.write(OSC11_REPLY)
expect(written).toEqual([])
})
it('defers when the host has no probe, so an unpatched node-pty keeps today’s behavior', () => {
const { written } = start()
session?.write(OSC11_REPLY)
expect(written).toEqual([])
})
it('does not let DA1 overtake a held OSC reply on an unpatched host', () => {
const da1 = '\x1b[?1;2c'
const { written } = start()
session?.write(OSC11_REPLY)
session?.write(da1)
expect(written).toEqual([])
vi.runOnlyPendingTimers()
expect(written).toEqual([OSC11_REPLY, da1])
})
it('holds a coalesced DA1+CPR payload behind a deferred OSC reply', () => {
const coalesced = '\x1b[?1;2c\x1b[1;1R'
const { written } = start()
session?.write(OSC11_REPLY)
session?.write(coalesced)
expect(written).toEqual([])
vi.runOnlyPendingTimers()
expect(written).toEqual([OSC11_REPLY, '\x1b[?1;2c', '\x1b[1;1R'])
})
})
+11 -7
View File
@@ -27,7 +27,6 @@ import {
type PtyIngressEmission,
type PtyStartupIngressIntent
} from '../../shared/pty-startup-ingress'
import { extractOnlyCookedEchoSafeQueryReplies } from '../../shared/terminal-query-reply'
import type {
PendingOutputRecord,
SessionState,
@@ -36,7 +35,10 @@ import type {
TerminalSnapshot
} from './types'
import type { PtyOwnerBackend } from '../../shared/pty-owner-backend'
import { createPtySlaveEchoProbe } from '../../shared/pty-slave-line-discipline-echo'
import {
createPtySlaveEchoProbe,
type PtySlaveEchoSyncProbe
} from '../../shared/pty-slave-line-discipline-echo'
const SHELL_READY_TIMEOUT_MS = 15_000
// Why: Codex skips marker-gated command delivery; this only bounds older daemon/local paths that still report shell-ready for Codex.
@@ -69,6 +71,9 @@ export type SubprocessHandle = {
/** Slave device path, so startup replies can read the line discipline's ECHO bit before
* writing. Absent on handles with no POSIX slave to read (ConPTY, tests). */
slavePath?: string
/** Fork-free read of the same ECHO bit, so a reply that needs no wait is not deferred
* into the next turn (#13892). Absent when node-pty lacks Orca's patch. */
echoSyncProbe?: PtySlaveEchoSyncProbe
write(data: string): void
resize(cols: number, rows: number): void
/** Stop reading the PTY fd (node-pty pause()) so a flooding child blocks on write. Optional:
@@ -201,7 +206,8 @@ export class Session {
...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}),
write: (data) => this.subprocess.write(data),
onEmission: (emission) => this.emitSubprocessOutput(emission),
...(echoProbe ? { echoProbe } : {})
...(echoProbe ? { echoProbe } : {}),
...(this.subprocess.echoSyncProbe ? { echoSyncProbe: this.subprocess.echoSyncProbe } : {})
})
if (this._shellState === 'pending') {
this.shellPromptReadinessProbe = createShellPromptReadinessProbe({
@@ -268,10 +274,8 @@ export class Session {
}
// Daemon POSIX PTYs need the local provider's cooked-echo containment (#13137).
if (
extractOnlyCookedEchoSafeQueryReplies(data) &&
this.startupIngress.answerLiveQueryReply(data)
) {
// DA1/CPR stay immediate unless an echo-safe reply is already held (#13892).
if (this.startupIngress.answerLiveQueryReply(data)) {
return
}
@@ -2291,4 +2291,46 @@ describe('LocalPtyProvider', () => {
await expect(shutdown).resolves.toBeUndefined()
})
})
// Wiring guard for #13892; see session-same-turn-query-reply-wiring.test.ts for why
// this is asserted per host rather than once on PtyStartupIngress.
describe('same-turn query reply wiring', () => {
const OSC11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x07'
it('hands the pty’s sync ECHO probe to the startup ingress', async () => {
const write = vi.fn()
const readEchoState = vi.fn(() => 0)
spawnMock.mockReturnValueOnce({ ...mockProc, write, readEchoState })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
provider.write(id, OSC11_REPLY)
// Both matter: the probe must be CONSULTED, and a `quiet` verdict must reach the
// pty in this turn rather than behind the deferral timer.
expect(readEchoState).toHaveBeenCalled()
expect(write).toHaveBeenCalledWith(OSC11_REPLY)
})
it('still defers the reply when the pty reports the slave would echo', async () => {
const write = vi.fn()
spawnMock.mockReturnValueOnce({ ...mockProc, write, readEchoState: () => 1 })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
provider.write(id, OSC11_REPLY)
expect(write).not.toHaveBeenCalled()
})
it('does not let DA1 overtake a held OSC reply', async () => {
const da1 = '\x1b[?1;2c'
const write = vi.fn()
spawnMock.mockReturnValueOnce({ ...mockProc, write, readEchoState: () => 1 })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
provider.write(id, OSC11_REPLY)
provider.write(id, da1)
expect(write).not.toHaveBeenCalled()
})
})
})
+8 -8
View File
@@ -68,12 +68,12 @@ import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress'
import { extractOnlyCookedEchoSafeQueryReplies } from '../../shared/terminal-query-reply'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group'
import { readPtsName } from '../pty/node-pty-pts-name'
import {
createPtySlaveEchoProbe,
createPtySlaveEchoSyncProbe,
readPtySlavePath
} from '../../shared/pty-slave-line-discipline-echo'
import {
@@ -970,6 +970,7 @@ export class LocalPtyProvider implements IPtyProvider {
}
}
const startupEchoProbe = createPtySlaveEchoProbe(readPtySlavePath(proc))
const startupEchoSyncProbe = createPtySlaveEchoSyncProbe(proc)
const startupIngress = new PtyStartupIngress({
...(args.startupIngress ? { intent: args.startupIngress } : {}),
ownerBackend: resolvePtyOwnerBackend({
@@ -979,7 +980,8 @@ export class LocalPtyProvider implements IPtyProvider {
}),
write: (data) => proc.write(data),
onEmission: emitIngressData,
...(startupEchoProbe ? { echoProbe: startupEchoProbe } : {})
...(startupEchoProbe ? { echoProbe: startupEchoProbe } : {}),
...(startupEchoSyncProbe ? { echoSyncProbe: startupEchoSyncProbe } : {})
})
startupIngressByPty.set(id, startupIngress)
@@ -1151,12 +1153,10 @@ export class LocalPtyProvider implements IPtyProvider {
return ptyProcesses.has(id)
}
write(id: string, data: string): void {
// Cooked PTYs echo private DSR/OSC replies; CPR/DA remain immediate (#13137, #7329).
if (extractOnlyCookedEchoSafeQueryReplies(data)) {
const ingress = startupIngressByPty.get(id)
if (ingress?.answerLiveQueryReply(data)) {
return
}
// Cooked PTYs echo private DSR/OSC replies; CPR/DA remain immediate unless
// an echo-safe reply is already held, so they cannot overtake it (#13137, #7329, #13892).
if (startupIngressByPty.get(id)?.answerLiveQueryReply(data)) {
return
}
ptyProcesses.get(id)?.write(data)
}
+42
View File
@@ -3927,6 +3927,48 @@ describe('PtyHandler', () => {
expect(handler.activePtyCount).toBe(1)
expect(handler.pendingPtyCreationCount).toBe(0)
})
// Wiring guard for #13892; see session-same-turn-query-reply-wiring.test.ts for why
// this is asserted per host rather than once on PtyStartupIngress.
describe('same-turn query reply wiring', () => {
const OSC11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x07'
it('hands the pty’s sync ECHO probe to the startup ingress', async () => {
const write = vi.fn()
const readEchoState = vi.fn(() => 0)
mockPtySpawn.mockReturnValueOnce({ ...mockPtyInstance, write, readEchoState })
const { id } = await spawnPty()
dispatcher.callNotification('pty.data', { id, data: OSC11_REPLY })
// Both matter: the probe must be CONSULTED, and a `quiet` verdict must reach the
// pty in this turn rather than behind the deferral timer.
expect(readEchoState).toHaveBeenCalled()
expect(write).toHaveBeenCalledWith(OSC11_REPLY)
})
it('still defers the reply when the pty reports the slave would echo', async () => {
const write = vi.fn()
mockPtySpawn.mockReturnValueOnce({ ...mockPtyInstance, write, readEchoState: () => 1 })
const { id } = await spawnPty()
dispatcher.callNotification('pty.data', { id, data: OSC11_REPLY })
expect(write).not.toHaveBeenCalled()
})
it('does not let DA1 overtake a held OSC reply', async () => {
const da1 = '\x1b[?1;2c'
const write = vi.fn()
mockPtySpawn.mockReturnValueOnce({ ...mockPtyInstance, write, readEchoState: () => 1 })
const { id } = await spawnPty()
dispatcher.callNotification('pty.data', { id, data: OSC11_REPLY })
dispatcher.callNotification('pty.data', { id, data: da1 })
expect(write).not.toHaveBeenCalled()
})
})
})
describe('attachIdentityMismatches', () => {
+10 -7
View File
@@ -54,7 +54,6 @@ import {
parsePtyStartupIngressIntent,
type PtyIngressEmission
} from '../shared/pty-startup-ingress'
import { extractOnlyCookedEchoSafeQueryReplies } from '../shared/terminal-query-reply'
import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend'
import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer'
import { expandWindowsPathEnvironmentVariables } from '../shared/windows-environment-expansion'
@@ -78,7 +77,11 @@ import {
isAgentSessionSurfaceBinding,
type AgentSessionOwnerBinding
} from '../shared/agent-session-host-authority'
import { createPtySlaveEchoProbe, readPtySlavePath } from '../shared/pty-slave-line-discipline-echo'
import {
createPtySlaveEchoProbe,
createPtySlaveEchoSyncProbe,
readPtySlavePath
} from '../shared/pty-slave-line-discipline-echo'
// Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap
// there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never
@@ -731,12 +734,14 @@ export class PtyHandler {
)
}
const echoProbe = createPtySlaveEchoProbe(readPtySlavePath(managed.pty))
const echoSyncProbe = createPtySlaveEchoSyncProbe(managed.pty)
managed.startupIngress ??= new PtyStartupIngress({
...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}),
ownerBackend: managed.ownerBackend,
write: (data) => managed.pty.write(data),
onEmission: emitIngressData,
...(echoProbe ? { echoProbe } : {})
...(echoProbe ? { echoProbe } : {}),
...(echoSyncProbe ? { echoSyncProbe } : {})
})
const startup = managed.startupCommand
if (startup?.waitForShellReady) {
@@ -1773,10 +1778,8 @@ export class PtyHandler {
this.lastInputAtByPty.set(id, performance.now())
this.interactiveOutputCharsByPty.set(id, 0)
// Relay PTYs need the local provider's cooked-echo containment (#13137).
if (
extractOnlyCookedEchoSafeQueryReplies(data) &&
managed.startupIngress?.answerLiveQueryReply(data)
) {
// DA1/CPR stay immediate unless an echo-safe reply is already held (#13892).
if (managed.startupIngress?.answerLiveQueryReply(data)) {
return
}
managed.pty.write(data)
@@ -192,6 +192,18 @@ describe('pty input write queue', () => {
])
})
it('keeps all query replies atomic for host-side ordering (#13892)', async () => {
const { writes, queue } = createRecordingQueue()
const replies = ['\x1b[?1;2c', '\x1b[1;1R']
for (const reply of replies) {
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
}
await queue.waitForDrain()
expect(writes.map((write) => write.data)).toEqual(replies)
})
it('does not coalesce a color-scheme reply with a following keystroke', async () => {
const { writes, queue } = createRecordingQueue()
const reply = mode2031SequenceFor('dark')
@@ -236,6 +248,28 @@ describe('pty input write queue', () => {
expect(writes.at(-1)?.data).toBe('k')
})
it('applies the same bound to DA1 replies kept atomic for ordering', async () => {
const { writes, pendingYields, queue } = createParkedQueue()
const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};2c`)
for (const reply of replies) {
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
}
expect(queue.enqueue('pty-1', 'k')).toBe(true)
await Promise.resolve()
for (let turn = 0; turn < PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES; turn += 1) {
await releaseNextWrite(writes, pendingYields)
}
await queue.waitForDrain()
expect(writes.map((write) => write.data)).toEqual([
replies[0],
...replies.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES),
'k'
])
})
it('drops the oldest reply-only payload when the reply text budget fills', async () => {
const reply = (slot: 10 | 11, marker: string): string =>
`\x1b]${slot};${marker.repeat(1_400)}\x1b\\`
@@ -490,7 +524,7 @@ describe('pty input write queue', () => {
// Same intercept shape as LocalPtyProvider.write / Session.write / relay writeData.
const hostWrite = (_id: string, data: string): void => {
if (extractOnlyCookedEchoSafeQueryReplies(data) && ingress.answerLiveQueryReply(data)) {
if (ingress.answerLiveQueryReply(data)) {
return
}
masterWrites.push(`RAW:${data}`)
@@ -3,7 +3,6 @@ import {
isTerminalInputTooLargeWithDeferredMeasurement,
iterateTerminalInputChunks
} from '../../../../shared/terminal-input'
import { needsCookedEchoSafeQueryReply } from '../../../../shared/terminal-query-reply'
// Why: 4096 UTF-16 code units encode to at most ~12KB UTF-8, safely under the
// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on
@@ -262,7 +261,8 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
if (failedGeneration === generation) {
return false
}
const replyOnly = queryReply && needsCookedEchoSafeQueryReply(data)
// Every query reply stays atomic so host-side ordering can classify it (#13892).
const replyOnly = queryReply
if (replyOnly && !admitReply(data)) {
return false
}
@@ -119,6 +119,36 @@ describe('createRemoteRuntimePtyTextBatcher', () => {
}
})
it('runs a validation barrier before input queued after it', async () => {
vi.useFakeTimers()
try {
const events: string[] = []
const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
const batcher = createRemoteRuntimePtyTextBatcher(1_000, (value) => events.push(value), {
maxPendingBytes: text.length + 10
})
expect(batcher.push(text)).toBe(true)
batcher.enqueueAfterValidation(() => {
const pending = batcher.takePending()
if (pending) {
events.push(pending)
}
events.push('reply')
})
expect(batcher.push('tail')).toBe(true)
const drained = batcher.drain()
await vi.advanceTimersByTimeAsync(0)
await drained
batcher.flush()
expect(events).toEqual([text, 'reply', 'tail'])
} finally {
vi.useRealTimers()
}
})
it('drops asynchronously oversized input without flushing clipboard content', async () => {
vi.useFakeTimers()
try {
@@ -9,6 +9,7 @@ import {
export type RemoteRuntimePtyBatcher = {
push: (data: string) => boolean
hasPendingValidation: () => boolean
enqueueAfterValidation: (action: () => void) => void
drain: () => Promise<void>
takePending: () => string
flush: () => void
@@ -127,6 +128,24 @@ export function createRemoteRuntimePtyTextBatcher(
}
}
const enqueueAfterValidation = (action: () => void): void => {
const queuedVersion = validationVersion
const previousTail = validationTail ?? Promise.resolve()
const guardedTail = previousTail.then(() => {
if (validationVersion === queuedVersion) {
action()
}
})
const nextTail = guardedTail
.catch(() => {})
.finally(() => {
if (validationTail === nextTail) {
validationTail = null
}
})
validationTail = nextTail
}
return {
push(data: string): boolean {
if (!data) {
@@ -150,6 +169,7 @@ export function createRemoteRuntimePtyTextBatcher(
// `pending`. `takePending()` cannot see it, so callers that must preserve
// byte order (sendInputImmediate) check this before bypassing the queue.
hasPendingValidation: (): boolean => validationTail !== null,
enqueueAfterValidation,
drain,
takePending,
flush,
@@ -78,11 +78,13 @@ describe('remote transport sendInputImmediate (#7329)', () => {
expect(transport.sendInputImmediate('\x1b]11;rgb:2828/2c2c/3434\x1b\\')).toBe(true)
await Promise.resolve()
// The immediate send flushed pending typed input ahead of the reply
// (preserving byte order) in a single send, without advancing timers.
// The immediate send preserves byte order without hiding the reply inside
// ordinary input, so the host can order it behind an earlier held reply.
const sends = terminalSendCalls() as { params: { text: string } }[]
expect(sends).toHaveLength(1)
expect(sends[0]?.params.text).toBe('yes\x1b]11;rgb:2828/2c2c/3434\x1b\\')
expect(sends.map((send) => send.params.text)).toEqual([
'yes',
'\x1b]11;rgb:2828/2c2c/3434\x1b\\'
])
} finally {
vi.useRealTimers()
}
@@ -140,16 +142,22 @@ describe('remote transport sendInputImmediate (#7329)', () => {
expect(transport.sendInput(paste)).toBe(true)
// Immediately (validation still pending) a TUI emits a CPR reply.
expect(transport.sendInputImmediate('\x1b[3;1R')).toBe(true)
expect(transport.sendInput('z')).toBe(true)
// Wait until the reply is actually flushed instead of sleeping a fixed
// interval: the reply legitimately trails the paste's async validation plus
// one debounce tick, and a fixed sleep raced that chain under CI load.
const combined = (): string =>
(terminalSendCalls() as { params: { text: string } }[]).map((s) => s.params.text).join('')
await expect.poll(combined, { timeout: 5000, interval: 5 }).toContain('\x1b[3;1R')
await expect.poll(combined, { timeout: 5000, interval: 5 }).toContain('\x1b[3;1Rz')
// The paste bytes must come before the reply — no reordering.
expect(combined().indexOf('p')).toBeLessThan(combined().indexOf('\x1b[3;1R'))
expect(combined()).toContain(`${paste}\x1b[3;1R`)
expect(combined()).toContain(`${paste}\x1b[3;1Rz`)
const sends = terminalSendCalls() as { params: { text: string } }[]
const texts = sends.map((send) => send.params.text)
expect(texts.pop()).toBe('z')
expect(texts.pop()).toBe('\x1b[3;1R')
expect(texts.join('')).toBe(paste)
})
})
@@ -5537,7 +5537,7 @@ describe('createRemoteRuntimePtyTransport', () => {
transport.destroy?.()
})
it('replays a claim before input typed during the subscribe round-trip', async () => {
it('replays a claim before separately framed input and query replies', async () => {
vi.useFakeTimers()
try {
runtimeSubscribe.mockImplementation(
@@ -5562,6 +5562,8 @@ describe('createRemoteRuntimePtyTransport', () => {
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
expect(transport.claimViewport?.(101, 33)).toBe(true)
expect(transport.sendInput('x')).toBe(true)
expect(transport.sendInputImmediate('\x1b[?1;2c')).toBe(true)
expect(transport.sendInput('z')).toBe(true)
await vi.advanceTimersByTimeAsync(8)
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.send' })
@@ -5576,9 +5578,16 @@ describe('createRemoteRuntimePtyTransport', () => {
TerminalStreamOpcode.Subscribe,
TerminalStreamOpcode.ClaimViewport,
TerminalStreamOpcode.Resize,
TerminalStreamOpcode.Input,
TerminalStreamOpcode.Input,
TerminalStreamOpcode.Input
])
})
const input = subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Input)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
expect(input).toEqual(['x', '\x1b[?1;2c', 'z'])
transport.destroy?.()
} finally {
vi.useRealTimers()
@@ -5617,6 +5626,45 @@ describe('createRemoteRuntimePtyTransport', () => {
}
})
it('bounds query replies buffered during a viewport claim', async () => {
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
subscriptionCallbacks = callbacks
return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
transport.attach({
existingPtyId: 'remote:terminal-1',
cols: 80,
rows: 24,
callbacks: {}
})
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
expect(transport.claimViewport?.(101, 33)).toBe(true)
for (let index = 0; index < 10_000; index += 1) {
expect(transport.sendInputImmediate(`\x1b[?${index};2c`)).toBe(true)
}
emitMultiplexReady()
await vi.waitFor(() => {
const input = subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Input)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
expect(input).toHaveLength(64)
expect(input.at(0)).toBe('\x1b[?9936;2c')
expect(input.at(-1)).toBe('\x1b[?9999;2c')
})
transport.destroy?.()
})
it('sends coalesced terminal input as binary frames once the stream is established', async () => {
vi.useFakeTimers()
try {
@@ -86,6 +86,7 @@ import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-rev
const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8
const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
const REMOTE_RUNTIME_MAX_PENDING_QUERY_REPLIES = 64
const HOST_SESSION_ATTACH_POLL_MS = 150
const HOST_SESSION_REPLACEMENT_POLL_MAX_MS = 1_000
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
@@ -266,7 +267,8 @@ export function createRemoteRuntimePtyTransport(
})
let lastRecoveryStateKey = ''
let pendingViewportClaim = false
let pendingClaimInput = ''
let pendingClaimInput: { text: string; queryReply: boolean }[] = []
let pendingClaimQueryReplyCount = 0
let terminalCreateRetryWait: {
timer: ReturnType<typeof setTimeout>
resolve: (continueRetrying: boolean) => void
@@ -346,12 +348,37 @@ export function createRemoteRuntimePtyTransport(
const viewportClaimReadyWaiters = new Set<(ready: boolean) => void>()
const clearPendingViewportClaim = (): void => {
pendingViewportClaim = false
pendingClaimInput = ''
pendingClaimInput = []
pendingClaimQueryReplyCount = 0
for (const resolve of viewportClaimReadyWaiters) {
resolve(false)
}
viewportClaimReadyWaiters.clear()
}
const queuePendingClaimInput = (text: string, queryReply: boolean): void => {
if (queryReply && pendingClaimQueryReplyCount >= REMOTE_RUNTIME_MAX_PENDING_QUERY_REPLIES) {
const oldestReply = pendingClaimInput.findIndex((segment) => segment.queryReply)
if (oldestReply !== -1) {
pendingClaimInput.splice(oldestReply, 1)
pendingClaimQueryReplyCount -= 1
const left = pendingClaimInput[oldestReply - 1]
const right = pendingClaimInput[oldestReply]
if (left && right && !left.queryReply && !right.queryReply) {
left.text += right.text
pendingClaimInput.splice(oldestReply, 1)
}
}
}
const tail = pendingClaimInput.at(-1)
if (!queryReply && tail && !tail.queryReply) {
tail.text += text
return
}
pendingClaimInput.push({ text, queryReply })
if (queryReply) {
pendingClaimQueryReplyCount += 1
}
}
// Why: tab/leaf ids are shared by paired viewers; the instance suffix keeps one viewer's refresh off peer records.
const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}`
const terminalCreateMutationId = createBrowserUuid()
@@ -1257,20 +1284,20 @@ export function createRemoteRuntimePtyTransport(
}
}
const inputBatcher = createRemoteRuntimePtyTextBatcher(REMOTE_TERMINAL_INPUT_FLUSH_MS, (text) => {
const sendUnacknowledgedInput = (text: string, queryReply = false): boolean => {
const targetHandle = handle
const targetLifecycleEpoch = lifecycleEpoch
if (!connected || !targetHandle || recoveryBlocksIo()) {
return
return false
}
const stream = getCurrentMultiplexedStream(targetHandle)
if (stream?.sendInput(text)) {
return
return true
}
if (pendingViewportClaim) {
// Why: a claim during subscribe/reconnect has no stream record yet; hold its input so the stream emits claim+input in one order.
pendingClaimInput += text
return
queuePendingClaimInput(text, queryReply)
return true
}
void callRuntime<{ send: RuntimeTerminalSend }>('terminal.send', {
terminal: targetHandle,
@@ -1298,7 +1325,13 @@ export function createRemoteRuntimePtyTransport(
handleRemoteTerminalError(error)
}
})
})
return true
}
const inputBatcher = createRemoteRuntimePtyTextBatcher(
REMOTE_TERMINAL_INPUT_FLUSH_MS,
sendUnacknowledgedInput
)
function sendViewportUpdate(cols: number, rows: number, claim = false): void {
const targetHandle = handle
@@ -1924,9 +1957,10 @@ export function createRemoteRuntimePtyTransport(
nextStream.claimViewport(desiredViewport.cols, desiredViewport.rows)
pendingViewportClaim = false
const queuedInput = pendingClaimInput
pendingClaimInput = ''
if (queuedInput) {
nextStream.sendInput(queuedInput)
pendingClaimInput = []
pendingClaimQueryReplyCount = 0
for (const segment of queuedInput) {
nextStream.sendInput(segment.text)
}
for (const resolve of viewportClaimReadyWaiters) {
resolve(true)
@@ -2345,39 +2379,37 @@ export function createRemoteRuntimePtyTransport(
// Why: query replies (CPR/DSR/DA/OSC) are read in raw mode with a short timeout; the 8ms debounce would miss it and echo the reply onto the prompt (#7329).
sendInputImmediate(data: string): boolean {
const targetHandle = handle
const targetLifecycleEpoch = lifecycleEpoch
if (!connected || !targetHandle || recoveryBlocksIo()) {
return false
}
if (!data) {
return true
}
// Why: earlier input may still be in async byte-length validation (in validationTail, not takePending); route the reply through the ordered queue so it can't jump ahead and reorder bytes.
// Why: wait behind async validation, but keep the reply as its own host-classifiable write.
if (inputBatcher.hasPendingValidation()) {
const accepted = inputBatcher.push(data)
inputBatcher.flush()
return accepted
inputBatcher.enqueueAfterValidation(() => {
if (
!connected ||
lifecycleEpoch !== targetLifecycleEpoch ||
handle !== targetHandle ||
recoveryBlocksIo()
) {
return
}
const pending = inputBatcher.takePending()
if (pending) {
sendUnacknowledgedInput(pending)
}
sendUnacknowledgedInput(data, true)
})
return true
}
const pending = inputBatcher.takePending()
const text = `${pending}${data}`
const stream = getCurrentMultiplexedStream(targetHandle)
if (stream?.sendInput(text)) {
return true
if (pending && !sendUnacknowledgedInput(pending)) {
return false
}
if (pendingViewportClaim) {
pendingClaimInput += text
return true
}
void callRuntime('terminal.send', {
terminal: targetHandle,
text,
client: { id: clientId, type: 'desktop' },
...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {})
}).catch((error) => {
if (handle === targetHandle) {
handleRemoteTerminalError(error)
}
})
return true
return sendUnacknowledgedInput(data, true)
},
sendInputAccepted: sendInputAcceptedToRuntime,
@@ -0,0 +1,258 @@
/**
* Real-fish regression for #13892: a terminal query reply Orca held back is overtaken
* by the DA1 answer written later in the same turn, so fish's read sentinel hands the
* tty to the child while the OSC 11 reply is still queued — and the CHILD READS IT.
*
* What is real here: node-pty running fish, `PtyStartupIngress`, `PtyStartupReplyDelivery`
* and both echo probes, plus the host's own write gate
* (`answerLiveQueryReply` — copied from local-pty-provider.write, which
* is what decides whether a reply is deferred at all). Only the renderer is modelled: it
* answers queries strictly in the order they appear in the stream, so any inversion the
* child sees was produced by the delivery split and nothing else.
*
* The assertion is about a CHILD PROCESS'S STDIN, not the screen: a rendered-output check
* passes while the bytes are still being eaten by the next `npx` / `brew` confirm prompt.
* The child reads a full LINE because a leaked reply carries no newline, so a
* once('data') child would report it alone whenever it happened to land in its own read.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { fishRequirementViolation, resolveFishBinary } from './fish-binary-requirement'
import {
NODE_PTY_SOURCE_BUILD_HINT,
nodePtyEchoStateRequirementViolation,
resolveNodePtyEchoStateSupport
} from './node-pty-echo-state-requirement'
import { PtyStartupIngress } from './pty-startup-ingress'
import {
createPtySlaveEchoProbe,
createPtySlaveEchoSyncProbe,
readPtySlavePath
} from './pty-slave-line-discipline-echo'
// Why fish 4: the DA1-sentinel handoff this measures lives in the 4.0 Rust tty_handoff.
const FISH = resolveFishBinary(4)
const itWithFish = FISH.available ? it : it.skip
const PROMPT_MARK = 'ORCA13892> '
/* oxlint-disable no-control-regex -- terminal query grammars are control sequences */
/** Anchored at an ESC, first match wins; reply values match xterm.js's. */
const QUERY_GRAMMARS = [
{
re: /^\x1b\]1[012];\?(\x07|\x1b\\)/,
reply: (m: RegExpExecArray) => `\x1b]11;rgb:1e1e/1e1e/1e1e${m[1]}`
},
{ re: /^\x1b\[\?6n/, reply: () => '\x1b[?1;1;1R' },
{ re: /^\x1b\[6n/, reply: () => '\x1b[1;1R' },
{ re: /^\x1b\[\?996n/, reply: () => '\x1b[?997;1n' },
{ re: /^\x1b\[>0?c/, reply: () => '\x1b[>0;276;0c' },
{ re: /^\x1b\[0?c/, reply: () => '\x1b[?1;2c' },
{ re: /^\x1b\[>0?q/, reply: () => '\x1bP>|Orca\x1b\\' },
{ re: /^\x1b\[\?u/, reply: () => '\x1b[?0u' }
] as const
/** Still accumulating: no CSI final byte and no OSC/DCS terminator yet. */
const PARTIAL_QUERY_RE =
/^(?:\x1b|\x1b\[[?>=]?[0-9;]*|\x1b\][0-9]*(?:;[^\x07\x1b]*)?\x1b?|\x1bP[^\x1b]*\x1b?)$/
/* oxlint-enable no-control-regex */
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) {
return true
}
await sleep(10)
}
return false
}
describe('a held query reply never reaches the next child process (#13892)', () => {
let configHome: string | null = null
// Always runs, so the CI lane cannot report green with the regression below skipped.
it('has the fish this suite needs when CI requires one', () => {
expect(fishRequirementViolation(FISH)).toBeNull()
})
// Same contract for the other half of the setup: a prebuilt node-pty has no echoState,
// so the fix under test would be off and the regression below would run vacuously.
it('has the source-built node-pty this suite needs when CI requires one', async () => {
const lookup = resolveNodePtyEchoStateSupport(await import('node-pty'))
expect(nodePtyEchoStateRequirementViolation(lookup)).toBeNull()
})
afterEach(() => {
if (configHome) {
rmSync(configHome, { recursive: true, force: true })
configHome = null
}
})
itWithFish(
'answers OSC 11 in the query turn so the reply cannot land in the child’s stdin',
async ({ skip }) => {
const nodePty = await import('node-pty')
// Skip rather than red a developer whose node_modules predates the patch; CI
// sets ORCA_REQUIRE_NODE_PTY_ECHO_STATE=1 so the same state fails there.
const echoStateSupport = resolveNodePtyEchoStateSupport(nodePty)
if (!echoStateSupport.available) {
skip(echoStateSupport.reason)
}
configHome = mkdtempSync(path.join(tmpdir(), 'orca-fish-13892-'))
mkdirSync(path.join(configHome, 'fish'), { recursive: true })
writeFileSync(
path.join(configHome, 'fish/config.fish'),
[
'set -g fish_greeting ""',
`function fish_prompt; printf '${PROMPT_MARK}'; end`,
'function fish_right_prompt; end',
''
].join('\n')
)
const childScript = path.join(configHome, 'read-stdin.mjs')
writeFileSync(
childScript,
"let buffered = ''\n" +
"process.stdin.on('data', (d) => {\n" +
" buffered += d.toString('utf8')\n" +
" if (!buffered.includes('\\n')) return\n" +
" process.stdout.write('CHILD-READ:' + JSON.stringify(buffered) + '\\n')\n" +
' process.exit(0)\n' +
'})\n'
)
const term = nodePty.spawn(FISH.path as string, ['-l', '-i'], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: configHome,
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: configHome,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
LANG: 'en_US.UTF-8',
XDG_CONFIG_HOME: configHome,
XDG_DATA_HOME: path.join(configHome, 'data'),
ORCA_NODE_BIN: process.execPath,
ORCA_CHILD_SCRIPT: childScript
}
})
const echoProbe = createPtySlaveEchoProbe(readPtySlavePath(term))
const echoSyncProbe = createPtySlaveEchoSyncProbe(term)
// Vacuity guard: a DEFINED probe proves nothing — the JS patch alone yields one that
// answers 'unknown' forever. Only a definite verdict off this live pty proves the
// native echoState is really being read, and so that there is a fast path to regress.
expect(
echoSyncProbe?.(),
`the sync probe gave no definite verdict, so #13892's same-turn reply is off. ${NODE_PTY_SOURCE_BUILD_HINT}`
).toMatch(/^(quiet|echoing)$/)
let rendered = ''
const ingress = new PtyStartupIngress({
ownerBackend: 'posix-pty',
write: (data) => term.write(data),
onEmission: (emission) => {
rendered += emission.data
answerQueriesInOrder(emission.data)
},
...(echoProbe ? { echoProbe } : {}),
...(echoSyncProbe ? { echoSyncProbe } : {})
})
// The host gate: cooked-echo-risk replies defer; DA1/CPR stay immediate
// unless one of those is already held.
const hostWrite = (data: string): void => {
if (ingress.answerLiveQueryReply(data)) {
return
}
term.write(data)
}
let tail = ''
let oscQueryCount = 0
function answerQueriesInOrder(chunk: string): void {
let buffer = tail + chunk
tail = ''
let index = 0
while (index < buffer.length) {
const at = buffer.indexOf('\x1b', index)
if (at === -1) {
return
}
const rest = buffer.slice(at)
const grammar = QUERY_GRAMMARS.map((candidate) => ({
candidate,
match: candidate.re.exec(rest)
})).find((entry) => entry.match)
if (grammar?.match) {
if (grammar.candidate === QUERY_GRAMMARS[0]) {
oscQueryCount += 1
}
hostWrite(grammar.candidate.reply(grammar.match))
index = at + grammar.match[0].length
continue
}
if (PARTIAL_QUERY_RE.test(rest)) {
tail = rest
return
}
index = at + 1
}
}
let exited = false
term.onExit(() => {
exited = true
})
term.onData((data) => ingress.accept(data))
try {
expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000)).toBe(true)
await sleep(500)
const oscQueriesBeforeHandoff = oscQueryCount
// Type-ahead is the deterministic shape: queue the child's command while an
// external command still owns the tty, so fish repaints its prompt (re-querying
// OSC 11) and hands the tty over in the same breath.
term.write('sleep 0.4\r')
await sleep(150)
term.write('"$ORCA_NODE_BIN" "$ORCA_CHILD_SCRIPT"\r')
await sleep(1_500)
expect(oscQueryCount).toBeGreaterThan(oscQueriesBeforeHandoff)
const renderedBeforeChildInput = rendered.length
term.write('hello\r')
expect(
await waitUntil(
() => rendered.slice(renderedBeforeChildInput).includes('CHILD-READ:'),
10_000
)
).toBe(true)
const childRead =
rendered.slice(renderedBeforeChildInput).match(/CHILD-READ:[^\r\n]*/)?.[0] ?? ''
// The merge-blocking assertion: the child's first LINE is what the user typed,
// with no escape byte in front of it. Pre-fix this reads
// `\u001b]11;rgb:1e1e/1e1e/1e1e\u001b\\hello\n`.
expect(childRead).toBe('CHILD-READ:"hello\\n"')
} finally {
term.write('exit\r')
await waitUntil(() => exited, 3_000)
try {
term.kill()
} catch {
// already gone
}
}
},
45_000
)
})
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
nodePtyEchoStateRequirementViolation,
REQUIRE_NODE_PTY_ECHO_STATE_ENV_VAR,
resolveNodePtyEchoStateSupport
} from './node-pty-echo-state-requirement'
// The exports an upstream node-pty prebuild carries — the shape the guard exists to catch.
const PREBUILD_EXPORTS = { fork: () => {}, open: () => {}, resize: () => {}, process: () => {} }
const SOURCE_BUILD_EXPORTS = { ...PREBUILD_EXPORTS, echoState: () => 0 }
describe('node-pty echoState requirement', () => {
it('accepts a binding that actually exports echoState', () => {
const lookup = resolveNodePtyEchoStateSupport({ native: SOURCE_BUILD_EXPORTS }, 'darwin')
expect(lookup.available).toBe(true)
expect(lookup.nativeExports).toContain('echoState')
})
it('rejects an upstream prebuild whose JS patch would still hand back a probe', () => {
const lookup = resolveNodePtyEchoStateSupport({ native: PREBUILD_EXPORTS }, 'darwin')
expect(lookup.available).toBe(false)
expect(lookup.available ? '' : lookup.reason).toContain('fork, open, resize, process')
// The remedy must name the flag, because plain `pnpm rebuild node-pty` exits 0 on macOS.
expect(lookup.available ? '' : lookup.reason).toContain('npm_config_build_from_source=true')
})
it('rejects a module with no native binding at all', () => {
expect(resolveNodePtyEchoStateSupport({}, 'linux').available).toBe(false)
expect(resolveNodePtyEchoStateSupport(undefined, 'linux').available).toBe(false)
})
it('fails only when CI demanded the source build', () => {
const prebuild = resolveNodePtyEchoStateSupport({ native: PREBUILD_EXPORTS }, 'linux')
const sourceBuild = resolveNodePtyEchoStateSupport({ native: SOURCE_BUILD_EXPORTS }, 'linux')
expect(nodePtyEchoStateRequirementViolation(prebuild, {})).toBeNull()
expect(
nodePtyEchoStateRequirementViolation(prebuild, { [REQUIRE_NODE_PTY_ECHO_STATE_ENV_VAR]: '1' })
).toContain('no echoState')
expect(
nodePtyEchoStateRequirementViolation(sourceBuild, {
[REQUIRE_NODE_PTY_ECHO_STATE_ENV_VAR]: '1'
})
).toBeNull()
})
})
@@ -0,0 +1,67 @@
/**
* Decides whether the loaded node-pty really carries Orca's `echoState` binding, and
* how strict the suites that depend on it are about its absence.
*
* Why this is not "is the sync probe defined": the JS half of the patch ships in the
* pnpm patch, so `createPtySlaveEchoSyncProbe` hands back a probe as soon as
* node_modules is patched — even when the loaded NATIVE module is the upstream
* prebuild, whose exports are ['fork', 'open', 'resize', 'process']. `readEchoState()`
* then answers undefined, the probe maps it to 'unknown', #13892's same-turn fast path
* never fires, and a suite that only checked the probe exists would pass with the fix
* switched off.
*/
/** Env var CI sets to make a prebuilt or stale node-pty fail instead of skip. */
export const REQUIRE_NODE_PTY_ECHO_STATE_ENV_VAR = 'ORCA_REQUIRE_NODE_PTY_ECHO_STATE'
/** Spelled out because `pnpm rebuild node-pty` finds the shipped darwin prebuild and exits 0. */
export const NODE_PTY_SOURCE_BUILD_HINT =
'Build node-pty from source with `npm_config_build_from_source=true pnpm rebuild node-pty` or `node config/scripts/rebuild-native-deps.mjs`; plain `pnpm rebuild node-pty` is not enough on macOS, where it finds the shipped darwin prebuild and exits 0.'
export type NodePtyEchoStateLookup =
| { available: true; nativeExports: readonly string[] }
| { available: false; nativeExports: readonly string[]; reason: string }
/** Pass the already-imported `node-pty` module; it exposes the raw binding as `native`. */
export function resolveNodePtyEchoStateSupport(
nodePtyModule: unknown,
platform: NodeJS.Platform = process.platform
): NodePtyEchoStateLookup {
const native = (nodePtyModule as { native?: unknown } | null | undefined)?.native
if (typeof native !== 'object' || native === null) {
return {
available: false,
nativeExports: [],
reason:
platform === 'win32'
? 'ConPTY has no slave line discipline to read, so node-pty exposes no native binding'
: `node-pty exposed no native binding to read echoState from. ${NODE_PTY_SOURCE_BUILD_HINT}`
}
}
const nativeExports = Object.keys(native as Record<string, unknown>)
if (typeof (native as Record<string, unknown>).echoState !== 'function') {
return {
available: false,
nativeExports,
reason: `the loaded node-pty native binding exports [${nativeExports.join(', ')}] and has no echoState, so it is an upstream prebuild rather than a build of Orca's patch — every sync probe would answer 'unknown' and #13892's same-turn reply would be off. ${NODE_PTY_SOURCE_BUILD_HINT}`
}
}
return { available: true, nativeExports }
}
/**
* The message to fail with when CI demanded a source-built node-pty and did not get
* one, else null.
*
* Assert this in a test that always runs, so the requirement cannot vanish with the
* suite it guards.
*/
export function nodePtyEchoStateRequirementViolation(
lookup: NodePtyEchoStateLookup,
env: NodeJS.ProcessEnv = process.env
): string | null {
if (lookup.available || env[REQUIRE_NODE_PTY_ECHO_STATE_ENV_VAR] !== '1') {
return null
}
return `${REQUIRE_NODE_PTY_ECHO_STATE_ENV_VAR}=1 but the patched-node-pty tests would skip: ${lookup.reason}`
}
@@ -0,0 +1,158 @@
/**
* The population the ECHO hold exists for: a program that queries while its tty is
* still COOKED and only arms raw mode later (#12112).
*
* This is the guard that kills "drop the reply when the hold expires" — the querier is
* blocked on the answer, so a dropped reply is a hung pane, not a cosmetic glitch. It is
* also what proves #13892's same-turn fast path cannot fire here: the sync probe reads
* `echoing` for as long as the querier stays cooked, so delivery behaves exactly as it
* did before the fast path existed, and the answer still arrives.
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
NODE_PTY_SOURCE_BUILD_HINT,
nodePtyEchoStateRequirementViolation,
resolveNodePtyEchoStateSupport
} from './node-pty-echo-state-requirement'
import { PtyStartupIngress } from './pty-startup-ingress'
import {
createPtySlaveEchoProbe,
createPtySlaveEchoSyncProbe,
readPtySlavePath,
type PtySlaveLineDisciplineEcho
} from './pty-slave-line-discipline-echo'
import { extractOnlyCookedEchoSafeQueryReplies } from './terminal-query-reply'
const OSC11_QUERY = '\x1b]11;?\x07'
const OSC11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x07'
const itOnPosix = process.platform === 'win32' ? it.skip : it
/** Queries OSC 11 while cooked, arms raw only after `rawDelayMs`, then reports what it read. */
const QUERIER_SOURCE = `
const rawDelayMs = Number(process.argv[2])
let received = ''
process.stdout.write(${JSON.stringify(OSC11_QUERY)})
setTimeout(() => {
process.stdin.setRawMode(true)
process.stdin.on('data', (chunk) => {
received += chunk.toString('utf8')
if (!received.includes('\\u0007') && !received.includes('\\u001b\\\\')) return
process.stdout.write('QUERIER-GOT:' + JSON.stringify(received) + '\\n')
process.exit(0)
})
}, rawDelayMs)
`
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) {
return true
}
await sleep(10)
}
return false
}
describe('a cooked querier still gets its answer (#12112)', () => {
let workDir: string | null = null
// Always runs: a prebuilt node-pty has no echoState, so the fast path this suite
// proves cannot fire would be off anyway and every case below would be vacuous.
it('has the source-built node-pty this suite needs when CI requires one', async () => {
const lookup = resolveNodePtyEchoStateSupport(await import('node-pty'))
expect(nodePtyEchoStateRequirementViolation(lookup)).toBeNull()
})
afterEach(() => {
if (workDir) {
rmSync(workDir, { recursive: true, force: true })
workDir = null
}
})
// 250ms outruns the 200ms probe budget, so the deadline flush answers instead of the
// probe; both must still deliver. A raw-delay under the budget is the probe's own path.
for (const rawDelayMs of [50, 250]) {
itOnPosix(
`delivers the reply to a querier that arms raw mode ${rawDelayMs}ms late`,
async ({ skip }) => {
const nodePty = await import('node-pty')
// Skip rather than red a developer whose node_modules predates the patch; CI
// sets ORCA_REQUIRE_NODE_PTY_ECHO_STATE=1 so the same state fails there.
const echoStateSupport = resolveNodePtyEchoStateSupport(nodePty)
if (!echoStateSupport.available) {
skip(echoStateSupport.reason)
}
workDir = mkdtempSync(path.join(tmpdir(), 'orca-cooked-querier-'))
const querierScript = path.join(workDir, 'cooked-querier.mjs')
writeFileSync(querierScript, QUERIER_SOURCE)
const term = nodePty.spawn(process.execPath, [querierScript, String(rawDelayMs)], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workDir,
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', TERM: 'xterm-256color' }
})
const echoProbe = createPtySlaveEchoProbe(readPtySlavePath(term))
const echoSyncProbe = createPtySlaveEchoSyncProbe(term)
// Vacuity guard: a DEFINED probe proves nothing — the JS patch alone yields one
// that answers 'unknown' forever. The `verdictsAtQuery` assertion below is the
// real check; this one names the cause when the native binding is a prebuild.
expect(
echoSyncProbe?.(),
`the sync probe gave no definite verdict, so #13892's same-turn reply is off. ${NODE_PTY_SOURCE_BUILD_HINT}`
).toMatch(/^(quiet|echoing)$/)
let rendered = ''
const verdictsAtQuery: PtySlaveLineDisciplineEcho[] = []
const ingress = new PtyStartupIngress({
ownerBackend: 'posix-pty',
write: (data) => term.write(data),
onEmission: (emission) => {
rendered += emission.data
if (!emission.data.includes(OSC11_QUERY)) {
return
}
verdictsAtQuery.push(echoSyncProbe?.() ?? 'unknown')
// The host gate, verbatim from local-pty-provider.write().
if (extractOnlyCookedEchoSafeQueryReplies(OSC11_REPLY)) {
ingress.answerLiveQueryReply(OSC11_REPLY)
}
},
...(echoProbe ? { echoProbe } : {}),
...(echoSyncProbe ? { echoSyncProbe } : {})
})
let exited = false
term.onExit(() => {
exited = true
})
term.onData((data) => ingress.accept(data))
try {
expect(await waitUntil(() => rendered.includes('QUERIER-GOT:'), 15_000)).toBe(true)
// The querier was cooked when it asked, so the fast path was never eligible.
expect(verdictsAtQuery).toEqual(['echoing'])
expect(rendered).toContain('QUERIER-GOT:')
expect(/QUERIER-GOT:"([^"]*)"/.exec(rendered)?.[1]).toContain('rgb:1e1e/1e1e/1e1e')
} finally {
await waitUntil(() => exited, 3_000)
try {
term.kill()
} catch {
// already gone
}
}
},
30_000
)
}
})
@@ -5,6 +5,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import {
createPtySlaveEchoProbe,
createPtySlaveEchoSyncProbe,
createPtySlaveLineEditorProbe,
readPtySlavePath
} from './pty-slave-line-discipline-echo'
@@ -155,3 +156,51 @@ describe('createPtySlaveLineEditorProbe', () => {
await expect(probe?.()).resolves.toBe('unknown')
})
})
describe('createPtySlaveEchoSyncProbe', () => {
it('maps the native ECHO bit to the same verdicts as the stty probe', () => {
expect(createPtySlaveEchoSyncProbe({ readEchoState: () => 1 }, 'darwin')?.()).toBe('echoing')
expect(createPtySlaveEchoSyncProbe({ readEchoState: () => 0 }, 'linux')?.()).toBe('quiet')
})
it('has no probe to offer where there is no line discipline or no patched node-pty', () => {
// ConPTY: the fast path must never engage on Windows.
expect(createPtySlaveEchoSyncProbe({ readEchoState: () => 0 }, 'win32')).toBeUndefined()
// An unpatched or prebuilt binding — an older relay host is exactly this shape.
expect(createPtySlaveEchoSyncProbe({}, 'darwin')).toBeUndefined()
expect(createPtySlaveEchoSyncProbe({ readEchoState: 'nope' }, 'darwin')).toBeUndefined()
expect(createPtySlaveEchoSyncProbe(undefined, 'darwin')).toBeUndefined()
expect(createPtySlaveEchoSyncProbe(null, 'darwin')).toBeUndefined()
})
it('never reads a failure as proof of quiet', () => {
// -1 is the addon's own "fd unreadable" answer; a throw is a reaped pty.
expect(createPtySlaveEchoSyncProbe({ readEchoState: () => -1 }, 'darwin')?.()).toBe('unknown')
expect(createPtySlaveEchoSyncProbe({ readEchoState: () => undefined }, 'darwin')?.()).toBe(
'unknown'
)
expect(
createPtySlaveEchoSyncProbe(
{
readEchoState: () => {
throw new Error('EBADF')
}
},
'darwin'
)?.()
).toBe('unknown')
})
it('reads the bit through the terminal, so a live toggle is observed', () => {
const term = {
echo: 1,
readEchoState(): number {
return this.echo
}
}
const probe = createPtySlaveEchoSyncProbe(term, 'darwin')
expect(probe?.()).toBe('echoing')
term.echo = 0
expect(probe?.()).toBe('quiet')
})
})
@@ -14,6 +14,9 @@ export type PtySlaveLineEditorState = 'line-editor' | 'other' | 'unknown'
export type PtySlaveLineEditorProbe = () => Promise<PtySlaveLineEditorState>
/** Same verdict, read without a fork — cheap enough to consult inside a query's own turn. */
export type PtySlaveEchoSyncProbe = () => PtySlaveLineDisciplineEcho
const STTY_TIMEOUT_MS = 2_000
// `stty -a` prints the lflags as a space-separated list where a disabled flag is
// prefixed with `-`, so `echo` and `-echo` are the two tokens that matter.
@@ -88,6 +91,41 @@ export function readPtySlavePath(pty: unknown): string | undefined {
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
}
/**
* Fork-free ECHO read off the master fd Orca already owns (#13892).
*
* POSIX redirects a master's mode ioctls to the slave, so node-pty's patched
* `readEchoState` answers for the slave without a subprocess — which is what makes it
* usable inside the query's own event-loop turn, where the async probe is not. A host
* whose node-pty is unpatched (older relay, upstream prebuild) has no such method and
* gets no probe, so it keeps today's deferred behavior with no wire change.
*/
export function createPtySlaveEchoSyncProbe(
pty: unknown,
platform: NodeJS.Platform = process.platform
): PtySlaveEchoSyncProbe | undefined {
if (platform === 'win32') {
return undefined
}
const readEchoState = (pty as { readEchoState?: unknown } | null | undefined)?.readEchoState
if (typeof readEchoState !== 'function') {
return undefined
}
return () => {
let state: unknown
try {
state = (readEchoState as (this: unknown) => unknown).call(pty)
} catch {
return 'unknown'
}
if (state === 1) {
return 'echoing'
}
// -1 (unreadable fd) and anything unexpected must not read as proof of quiet.
return state === 0 ? 'quiet' : 'unknown'
}
}
/**
* Probe for whether the slave would echo a write to the master right now.
*
+8 -1
View File
@@ -1,6 +1,6 @@
import type { PtyOwnerBackend } from './pty-owner-backend'
import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent'
import type { PtySlaveEchoProbe } from './pty-slave-line-discipline-echo'
import type { PtySlaveEchoProbe, PtySlaveEchoSyncProbe } from './pty-slave-line-discipline-echo'
export type PtyIngressEmission = {
data: string
@@ -20,6 +20,13 @@ export type PtyStartupIngressOptions = {
* on backends with no line discipline to read (ConPTY, wsl.exe).
*/
echoProbe?: PtySlaveEchoProbe
/**
* Same verdict without a fork, so it can be consulted inside the query's own turn.
* A `quiet` answer here writes the reply immediately rather than deferring it, which
* is what keeps a later reply in the same turn from overtaking it (#13892). Absent
* when the host's node-pty lacks Orca's patch, which degrades to `echoProbe` alone.
*/
echoSyncProbe?: PtySlaveEchoSyncProbe
}
export type PtyIngressSourceSpan = {
@@ -197,6 +197,23 @@ describe('PtyStartupIngress live query replies (#13137)', () => {
ingress.drainAndClose()
})
it('splits a mixed OSC and DA1 payload without letting DA1 overtake', () => {
vi.useFakeTimers()
const writes: string[] = []
const ingress = new PtyStartupIngress({
ownerBackend: 'posix-pty',
write: (data) => writes.push(data),
onEmission: () => {}
})
const da1 = '\x1b[?1;2c'
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY + da1)).toBe(true)
expect(writes).toEqual([])
vi.advanceTimersByTime(0)
expect(writes).toEqual([OSC_COLOR_REPLY, da1])
ingress.drainAndClose()
})
it('preserves identical replies after a quiet CSI write', async () => {
vi.useFakeTimers()
const writes: string[] = []
+10 -10
View File
@@ -6,7 +6,7 @@ import {
import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent'
import type { PtyOwnerBackend } from './pty-owner-backend'
import { PtyStartupReplyDelivery } from './pty-startup-reply-delivery'
import { answerEachCookedEchoSafeQueryReply } from './terminal-query-reply'
import { deliverTerminalQueryReplyPayload } from './terminal-query-reply-delivery'
import {
combinePtyIngressSourceSpans,
slicePtyIngressSourceSpan,
@@ -55,7 +55,12 @@ export class PtyStartupIngress {
constructor(options: PtyStartupIngressOptions) {
this.intent = options.intent
this.ownerBackend = options.ownerBackend ?? 'posix-pty'
this.delivery = new PtyStartupReplyDelivery(this.ownerBackend, options.write, options.echoProbe)
this.delivery = new PtyStartupReplyDelivery(
this.ownerBackend,
options.write,
options.echoProbe,
options.echoSyncProbe
)
this.onEmission = options.onEmission
this.queryOpen = options.intent !== undefined
if (options.intent) {
@@ -93,10 +98,10 @@ export class PtyStartupIngress {
return this.rawHighWater
}
// Live color replies reuse startup's cooked-echo containment (#13137).
// Query replies stay ordered when an earlier cooked-echo-risk reply is held (#13137, #13892).
answerLiveQueryReply(reply: string): boolean {
return !this.closed && reply.length > 0
? answerEachCookedEchoSafeQueryReply(reply, (part) => this.delivery.answer(part))
? deliverTerminalQueryReplyPayload(reply, this.delivery)
: false
}
@@ -368,12 +373,7 @@ export class PtyStartupIngress {
}
private emit(span: PtyIngressSourceSpan, transformed: boolean, data = span.data): void {
this.onEmission({
data,
rawStartSeq: span.rawStartSeq,
rawEndSeq: span.rawEndSeq,
transformed
})
this.onEmission({ data, rawStartSeq: span.rawStartSeq, rawEndSeq: span.rawEndSeq, transformed })
}
private clearDeadline(): void {
@@ -0,0 +1,154 @@
/**
* The same-turn fast path (#13892) and the ordering property that bounds it.
*
* A held reply is overtaken by anything written later in the same turn — fish's DA1
* sentinel is exactly that — and the held bytes then land in the next child's stdin.
* So a reply the kernel cannot echo must be written inside the query's own turn.
*/
import { describe, expect, it, vi } from 'vitest'
import { PtyStartupReplyDelivery } from './pty-startup-reply-delivery'
import type { PtySlaveLineDisciplineEcho } from './pty-slave-line-discipline-echo'
const OSC11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\'
const OSC10_REPLY = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\'
function createDelivery(syncState: PtySlaveLineDisciplineEcho | null): {
delivery: PtyStartupReplyDelivery
written: string[]
asyncProbe: ReturnType<typeof vi.fn>
} {
const written: string[] = []
// Present but never resolved, so anything reaching it is visibly a deferral.
const asyncProbe = vi.fn(() => new Promise<PtySlaveLineDisciplineEcho>(() => {}))
const delivery = new PtyStartupReplyDelivery(
'posix-pty',
(data) => written.push(data),
asyncProbe,
syncState === null ? undefined : () => syncState
)
return { delivery, written, asyncProbe }
}
describe('PtyStartupReplyDelivery same-turn fast path', () => {
it('writes inside the query turn when the kernel is provably quiet', () => {
const { delivery, written, asyncProbe } = createDelivery('quiet')
expect(delivery.answer(OSC11_REPLY)).toBe(true)
expect(written).toEqual([OSC11_REPLY])
expect(asyncProbe).not.toHaveBeenCalled()
})
it('still projects the software echo shapes a quiet kernel says nothing about', () => {
const { delivery, written } = createDelivery('quiet')
delivery.answer(OSC11_REPLY)
// readline rewrites OSC as BEL and echoes it at a raw ECHO-off prompt (#12112), so
// only the kernel's caret projection may retire on a `quiet` verdict.
expect(delivery.hasExpectedEcho).toBe(true)
expect(delivery.matchEcho(`\x07${written[0]?.slice(2, -2) ?? ''}`).kind).toBe('complete')
expect(delivery.matchEcho(OSC11_REPLY.replaceAll('\x1b', '^[')).kind).toBe('none')
})
it('defers exactly as before when the probe cannot prove quiet', () => {
for (const state of ['echoing', 'unknown', null] as const) {
const { delivery, written } = createDelivery(state)
expect(delivery.answer(OSC11_REPLY)).toBe(true)
expect(written).toEqual([])
}
})
it('queues behind a held reply rather than overtaking it', () => {
const written: string[] = []
// The tty goes raw between the two queries, which is precisely when a fast path
// without the empty-queue guard would deliver reply #2 ahead of reply #1.
const verdicts: PtySlaveLineDisciplineEcho[] = ['echoing', 'quiet']
const delivery = new PtyStartupReplyDelivery(
'posix-pty',
(data) => written.push(data),
undefined,
() => verdicts.shift() ?? 'quiet'
)
delivery.answer(OSC11_REPLY)
expect(written).toEqual([])
delivery.answer(OSC10_REPLY)
expect(written).toEqual([])
// Both land together, in query order, when the held queue flushes.
delivery.reset()
expect(written).toEqual([OSC11_REPLY, OSC10_REPLY])
delivery.close()
})
it('leaves ConPTY untouched: no probe is consulted and the write is immediate', () => {
const written: string[] = []
const syncProbe = vi.fn((): PtySlaveLineDisciplineEcho => 'quiet')
const delivery = new PtyStartupReplyDelivery(
'windows-conpty',
(data) => written.push(data),
undefined,
syncProbe
)
expect(delivery.answer(OSC11_REPLY)).toBe(true)
expect(written).toEqual([OSC11_REPLY])
expect(syncProbe).not.toHaveBeenCalled()
})
it('holds a later DA1 behind a deferred OSC so it cannot overtake it', () => {
const da1 = '\x1b[?1;2c'
const { delivery, written } = createDelivery('echoing')
delivery.answer(OSC11_REPLY)
expect(delivery.answerInOrder(da1)).toBe(true)
expect(written).toEqual([])
delivery.reset()
expect(written).toEqual([OSC11_REPLY, da1])
expect(delivery.matchEcho(da1.replaceAll('\x1b', '^[')).kind).toBe('complete')
delivery.close()
})
it('writes DA1 immediately when nothing is deferred', () => {
const { delivery, written } = createDelivery('quiet')
expect(delivery.answerInOrder('\x1b[?1;2c')).toBe(true)
expect(written).toEqual(['\x1b[?1;2c'])
})
it('bounds ordering-only replies without changing byte order', () => {
const { delivery, written } = createDelivery('echoing')
const da1Replies = Array.from({ length: 64 }, (_, index) => `\x1b[?${index};2c`)
delivery.answer(OSC11_REPLY)
for (const reply of da1Replies) {
expect(delivery.answerInOrder(reply)).toBe(true)
}
expect(written).toEqual([OSC11_REPLY, ...da1Replies])
expect(delivery.matchEcho(da1Replies.at(-1)?.replaceAll('\x1b', '^[') ?? '').kind).toBe(
'complete'
)
delivery.reset()
expect(written).toEqual([OSC11_REPLY, ...da1Replies])
})
it('reports failure for the reply that failed, without deferring it first', () => {
const onFailed = vi.fn()
const delivery = new PtyStartupReplyDelivery(
'posix-pty',
() => {
throw new Error('pty gone')
},
undefined,
() => 'quiet'
)
expect(delivery.answer(OSC11_REPLY, onFailed)).toBe(false)
// The deferred path answers `true` and calls back later; a same-turn write knows now.
expect(onFailed).toHaveBeenCalledTimes(1)
expect(delivery.hasExpectedEcho).toBe(false)
})
})
+44 -8
View File
@@ -1,5 +1,5 @@
import type { PtyOwnerBackend } from './pty-owner-backend'
import type { PtySlaveEchoProbe } from './pty-slave-line-discipline-echo'
import type { PtySlaveEchoProbe, PtySlaveEchoSyncProbe } from './pty-slave-line-discipline-echo'
// Why this module exists: a startup color reply is written to the PTY master, so
// whatever line discipline sits between Orca and the querying program can echo it
@@ -7,7 +7,9 @@ import type { PtySlaveEchoProbe } from './pty-slave-line-discipline-echo'
// bytes stripped; a POSIX tty echoes it while the querying program is still cooked.
// A program that queries before clearing ECHO loses that race if Orca answers
// inside the query's own turn, so on POSIX the write waits until the slave's ECHO
// bit is observably clear, and recognized echo shapes cover what remains.
// bit is observably clear, and recognized echo shapes cover what remains. When a
// fork-free probe can prove ECHO is already clear, the wait is skipped outright —
// see `answer()`, and #13892 for why deferring a reply that needs no wait is unsafe.
//
// Deliberately NO re-send on a matched echo: ECHO copies bytes to the master
// without consuming them from the slave's input queue, so a program that arms raw
@@ -74,7 +76,7 @@ const MAX_TRACKED_REPLIES = 64
const ECHO_PROBE_MAX_STARTS_PER_SECOND = 10
type ExpectedEcho = { projections: readonly string[]; remainingBytes: number }
type PendingWrite = { reply: string; onFailed: (() => void) | undefined }
type PendingWrite = { reply: string; onFailed: (() => void) | undefined; projectEcho: boolean }
type ActiveEchoProbe = { timer: ReturnType<typeof setTimeout> | null }
/** Only a POSIX tty both echoes the reply and still delivers a deferred write. */
@@ -181,7 +183,8 @@ export class PtyStartupReplyDelivery {
constructor(
private readonly ownerBackend: PtyOwnerBackend,
private readonly writeProvider: (data: string) => void,
private readonly echoProbe?: PtySlaveEchoProbe
private readonly echoProbe?: PtySlaveEchoProbe,
private readonly echoSyncProbe?: PtySlaveEchoSyncProbe
) {}
get hasExpectedEcho(): boolean {
@@ -204,6 +207,15 @@ export class PtyStartupReplyDelivery {
// Why: ConPTY answers the query itself unless Orca beats it in this turn.
return this.writeReply(reply)
}
// Why answer in this turn when the kernel is already quiet: ANY deferral, however
// short, lets a reply written later in the same turn overtake this one — fish's DA1
// read sentinel does exactly that, and the held OSC reply then lands in the NEXT
// child's stdin (#13892). Measured: the leak reproduces at a 5ms defer, so only a
// same-turn write closes it. Requiring an empty queue is what preserves order: a
// reply arriving behind a held one still queues instead of jumping it.
if (this.pendingWrites.length === 0 && this.echoSyncProbe?.() === 'quiet') {
return this.writeReply(reply, onFailed, true)
}
if (this.pendingWrites.length >= MAX_TRACKED_REPLIES) {
this.flushPendingWrites()
}
@@ -212,11 +224,28 @@ export class PtyStartupReplyDelivery {
if (this.pendingWrites.length === 0) {
this.echoPollDeadline = Date.now() + ECHO_POLL_BUDGET_MS
}
this.pendingWrites.push({ reply, onFailed })
this.pendingWrites.push({ reply, onFailed, projectEcho: true })
this.armWriteTimer()
return true
}
/** Preserve query order when an earlier echo-risk reply is deferred (#13892). */
answerInOrder(reply: string): boolean {
if (this.closed) {
return false
}
const followsEchoRiskReply = this.pendingWrites.length > 0
if (this.pendingWrites.length >= MAX_TRACKED_REPLIES) {
this.flushPendingWrites()
}
if (this.pendingWrites.length === 0) {
return this.writeReply(reply, undefined, false, followsEchoRiskReply)
}
// A reply forced behind an echo-risk write may flush while ECHO is still set.
this.pendingWrites.push({ reply, onFailed: undefined, projectEcho: true })
return true
}
/** Recognizes any written reply's echo anywhere in the span, earliest match first. */
matchEcho(data: string): PtyStartupReplyEchoMatch {
let best: PtyStartupReplyEchoMatch = { kind: 'none' }
@@ -332,7 +361,7 @@ export class PtyStartupReplyDelivery {
this.clearWriteTimer()
this.clearActiveEchoProbe()
for (const pending of this.pendingWrites.splice(0)) {
this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible)
this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible, pending.projectEcho)
}
}
@@ -371,11 +400,18 @@ export class PtyStartupReplyDelivery {
return true
}
private writeReply(reply: string, onFailed?: () => void, kernelEchoImpossible = false): boolean {
private writeReply(
reply: string,
onFailed?: () => void,
kernelEchoImpossible = false,
projectEcho = true
): boolean {
if (this.closed) {
return false
}
const projections = replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible)
const projections = projectEcho
? replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible)
: []
// Why: register before write because node-pty can synchronously re-enter onData.
const expected: ExpectedEcho | null =
projections.length > 0 ? { projections, remainingBytes: ECHO_SEARCH_BUDGET_BYTES } : null
@@ -0,0 +1,23 @@
import type { PtyStartupReplyDelivery } from './pty-startup-reply-delivery'
import {
extractOnlyTerminalQueryReplies,
needsCookedEchoSafeQueryReply
} from './terminal-query-reply'
export function deliverTerminalQueryReplyPayload(
data: string,
delivery: Pick<PtyStartupReplyDelivery, 'answer' | 'answerInOrder'>
): boolean {
const replies = extractOnlyTerminalQueryReplies(data)
if (!replies) {
return false
}
let accepted = false
for (const reply of replies) {
const handled = needsCookedEchoSafeQueryReply(reply)
? delivery.answer(reply)
: delivery.answerInOrder(reply)
accepted = handled || accepted
}
return accepted
}
+10
View File
@@ -2,6 +2,7 @@ import { Terminal } from '@xterm/headless'
import { describe, expect, it } from 'vitest'
import {
extractOnlyCookedEchoSafeQueryReplies,
extractOnlyTerminalQueryReplies,
isTerminalQueryReply,
needsCookedEchoSafeQueryReply
} from './terminal-query-reply'
@@ -87,6 +88,15 @@ describe('isTerminalQueryReply', () => {
'\x1b[?997;1n'
])
expect(extractOnlyCookedEchoSafeQueryReplies('\x1b[?997;1ny')).toBe(null)
expect(extractOnlyTerminalQueryReplies('\x1b[?1;2c\x1b[1;1R')).toEqual([
'\x1b[?1;2c',
'\x1b[1;1R'
])
expect(extractOnlyTerminalQueryReplies('\x1b]11;rgb:2828/2c2c/3434\x1b\\\x1b[?1;2c')).toEqual([
'\x1b]11;rgb:2828/2c2c/3434\x1b\\',
'\x1b[?1;2c'
])
expect(extractOnlyTerminalQueryReplies('\x1b[?1;2chello')).toBe(null)
})
it('does NOT match ordinary typed input or navigation sequences', () => {
+55 -41
View File
@@ -23,32 +23,57 @@ const ESC = String.fromCharCode(0x1b)
/* oxlint-disable no-control-regex -- grammars match terminal ESC/BEL sequences by definition */
// Known accepted collision: xterm.js encodes MODIFIED F3 (Shift/Ctrl/Alt+F3) as
// `CSI 1 ; <mod> R`, which is indistinguishable from a CPR report (a classic
// VT ambiguity). Such a keystroke is sent immediately instead of debounced —
// byte order is still preserved (the immediate path flushes pending input
// first), it just skips input-intent/activity bookkeeping. Harmless, so we
// keep the reply grammar complete rather than special-casing it.
const CPR_OR_DSR_RE = new RegExp('^\\u001b\\[\\??[0-9;]*[Rn]$')
const DEVICE_ATTRIBUTES_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$')
// VT ambiguity). Such a keystroke is sent immediately and shares the bounded
// reply budget; byte order is preserved, but a pathological reply flood can shed it.
const CPR_OR_DSR_PREFIX_RE = new RegExp('^\\u001b\\[\\??[0-9;]*[Rn]')
const DEVICE_ATTRIBUTES_PREFIX_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c')
// 4/6 = pixel-size reports, 8 = text-area size in characters (answer to CSI 18t).
const WINDOW_SIZE_REPORT_RE = new RegExp('^\\u001b\\[[468];[0-9]+;[0-9]+t$')
const WINDOW_SIZE_REPORT_PREFIX_RE = new RegExp('^\\u001b\\[[468];[0-9]+;[0-9]+t')
// `?` optional: private-mode reports carry it (DECRPM), ANSI-mode reports don't.
const DECRPM_RE = new RegExp('^\\u001b\\[\\??[0-9;]*\\$y$')
const DECRPM_PREFIX_RE = new RegExp('^\\u001b\\[\\??[0-9;]*\\$y')
// Kitty keyboard protocol flags report: CSI ? flags u. The `?` distinguishes it
// from kitty-protocol *keystrokes* (CSI code;mods u), which must stay batched.
const KITTY_FLAGS_RE = new RegExp('^\\u001b\\[\\?[0-9]+u$')
const KITTY_FLAGS_PREFIX_RE = new RegExp('^\\u001b\\[\\?[0-9]+u')
// OSC color/title responses: ESC ] Ps ; body ST (ST = BEL or ESC backslash).
const OSC_RESPONSE_RE = new RegExp('^\\u001b\\][0-9]+;[^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)$')
const OSC_RESPONSE_PREFIX_RE = new RegExp(
'^\\u001b\\][0-9]+;[^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)'
)
// DCS-framed reports xterm emits: DECRQSS "ESC P 1 $ r Pt ST" / "ESC P 0 $ r ST"
// (vim queries cursor style this way) and XTVERSION "ESC P > | text ST".
const DCS_RESPONSE_RE = new RegExp('^\\u001bP(?:[01]\\$r[^\\u001b]*|>\\|[^\\u001b]*)\\u001b\\\\$')
const DCS_RESPONSE_PREFIX_RE = new RegExp(
'^\\u001bP(?:[01]\\$r[^\\u001b]*|>\\|[^\\u001b]*)\\u001b\\\\'
)
// Private-mode DSR (CSI ? … n) — e.g. color-scheme `?997;1n` — often lands cooked.
// Prefix form peels consecutive replies out of one coalesced payload.
const COOKED_ECHO_RISK_PRIVATE_DSR_PREFIX_RE = new RegExp('^\\u001b\\[\\?[0-9;]*n')
const COOKED_ECHO_RISK_OSC_PREFIX_RE = new RegExp(
'^\\u001b\\][0-9]+;[^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)'
)
const QUERY_REPLY_PREFIX_RES = [
CPR_OR_DSR_PREFIX_RE,
DEVICE_ATTRIBUTES_PREFIX_RE,
WINDOW_SIZE_REPORT_PREFIX_RE,
DECRPM_PREFIX_RE,
KITTY_FLAGS_PREFIX_RE,
OSC_RESPONSE_PREFIX_RE,
DCS_RESPONSE_PREFIX_RE
] as const
/* oxlint-enable no-control-regex */
function terminalQueryReplyEnd(data: string, start: number): number {
if (start >= data.length || data[start] !== ESC) {
return -1
}
const slice = data.slice(start)
for (const re of QUERY_REPLY_PREFIX_RES) {
const match = re.exec(slice)
if (match?.[0]) {
return start + match[0].length
}
}
return -1
}
/**
* True when `data` (from xterm.onData) is a synthetic reply the emulator
* generated in response to a query — not something the user typed. These are
@@ -60,18 +85,7 @@ const COOKED_ECHO_RISK_OSC_PREFIX_RE = new RegExp(
* as replies — with the single documented modified-F3/CPR collision above.
*/
export function isTerminalQueryReply(data: string): boolean {
if (data.length < 3 || data[0] !== ESC) {
return false
}
return (
CPR_OR_DSR_RE.test(data) ||
DEVICE_ATTRIBUTES_RE.test(data) ||
WINDOW_SIZE_REPORT_RE.test(data) ||
DECRPM_RE.test(data) ||
KITTY_FLAGS_RE.test(data) ||
OSC_RESPONSE_RE.test(data) ||
DCS_RESPONSE_RE.test(data)
)
return data.length >= 3 && terminalQueryReplyEnd(data, 0) === data.length
}
/** End index (exclusive) of one cooked-echo-risk reply at `start`, else -1. */
@@ -114,6 +128,24 @@ export function extractOnlyCookedEchoSafeQueryReplies(data: string): string[] |
return replies.length > 0 ? replies : null
}
/** If `data` is entirely one or more consecutive query replies, return each reply. */
export function extractOnlyTerminalQueryReplies(data: string): string[] | null {
if (data.length < 3 || data[0] !== ESC) {
return null
}
const replies: string[] = []
let offset = 0
while (offset < data.length) {
const end = terminalQueryReplyEnd(data, offset)
if (end === -1) {
return null
}
replies.push(data.slice(offset, end))
offset = end
}
return replies.length > 0 ? replies : null
}
/**
* Query replies that must use the ECHO-safe write path on POSIX PTYs so cooked
* prompts do not paint reply bytes (e.g. `997;1n` on `npx` confirm, #13137).
@@ -126,21 +158,3 @@ export function needsCookedEchoSafeQueryReply(data: string): boolean {
const replies = extractOnlyCookedEchoSafeQueryReplies(data)
return replies !== null && replies.length === 1
}
/** True if at least one extracted cooked-echo-risk reply is accepted by `answer`. */
export function answerEachCookedEchoSafeQueryReply(
data: string,
answer: (reply: string) => boolean
): boolean {
const replies = extractOnlyCookedEchoSafeQueryReplies(data)
if (!replies) {
return false
}
let accepted = false
for (const part of replies) {
if (answer(part)) {
accepted = true
}
}
return accepted
}
+122 -2
View File
@@ -1,12 +1,18 @@
import { test } from './helpers/orca-app'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { expect, test } from './helpers/orca-app'
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
import {
cleanupDockerSshRelayTarget,
execDockerSshRelayTargetCommand,
shellQuote as dockerShellQuote,
startDockerSshRelayTarget,
type DockerSshRelayTarget
type DockerSshRelayTarget,
writeDockerSshRelayTargetFile
} from './helpers/docker-ssh-relay-target'
import {
execInTerminal,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
@@ -14,6 +20,17 @@ import {
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
const FISH_VERSION = '4.8.1'
const FISH_ASSETS = {
aarch64: {
asset: `fish-${FISH_VERSION}-linux-aarch64.tar.xz`,
sha256: 'a03c8a445570a2a37e114cb13cebe41842cf17c4dd67a6530a57f742db04eee4'
},
x86_64: {
asset: `fish-${FISH_VERSION}-linux-x86_64.tar.xz`,
sha256: '39cab35242ab77bfdbce73b473000c3b045aaf2fe0951b042199bb7fdba3df78'
}
} as const
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
@@ -37,6 +54,25 @@ function remoteOscQueryScript(runId: string): string {
].join(';')
}
function installRemoteFish(target: DockerSshRelayTarget): void {
const arch = execDockerSshRelayTargetCommand(target, 'uname -m') as keyof typeof FISH_ASSETS
const release = FISH_ASSETS[arch]
if (!release) {
throw new Error(`No pinned fish binary for Docker architecture ${arch}`)
}
const url = `https://github.com/fish-shell/fish-shell/releases/download/${FISH_VERSION}/${release.asset}`
execDockerSshRelayTargetCommand(
target,
[
`node -e ${dockerShellQuote("fetch(process.argv[1]).then(r => { if (!r.ok) throw new Error(String(r.status)); return r.arrayBuffer() }).then(b => require('node:fs').writeFileSync('/tmp/fish.tar.xz', Buffer.from(b)))")} ${dockerShellQuote(url)}`,
`echo ${dockerShellQuote(`${release.sha256} /tmp/fish.tar.xz`)} | sha256sum -c -`,
'tar -xJf /tmp/fish.tar.xz -C /usr/local/bin',
'chmod 0755 /usr/local/bin/fish',
'/usr/local/bin/fish --version'
].join(' && ')
)
}
test.describe('PTY input write queue over SSH', () => {
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH E2E.')
test.skip(process.platform === 'win32', 'Docker SSH E2E uses POSIX ssh tooling.')
@@ -63,4 +99,88 @@ test.describe('PTY input write queue over SSH', () => {
cleanupDockerSshRelayTarget(target)
}
})
test('keeps fish query replies out of the next child stdin on an upstream relay pty', async ({
orcaPage
}, testInfo) => {
test.slow()
let target: DockerSshRelayTarget | null = null
try {
target = startDockerSshRelayTarget(testInfo)
installRemoteFish(target)
const runId = String(Date.now())
const home = `/tmp/orca-fish-${runId}`
const prompt = `ORCA_SSH_FISH_${runId}> `
const childReady = `CHILD_READY_${runId}`
const childRead = `CHILD_READ_${runId}`
execDockerSshRelayTargetCommand(
target,
`mkdir -p ${dockerShellQuote(`${home}/.config/fish`)} ${dockerShellQuote(`${home}/.local/share`)}`
)
writeDockerSshRelayTargetFile(
target,
`${home}/.config/fish/config.fish`,
[
'set -g fish_greeting ""',
`function fish_prompt; printf ${dockerShellQuote(prompt)}; end`,
'function fish_right_prompt; end',
''
].join('\n')
)
const childScript = `${home}/read-stdin.mjs`
writeDockerSshRelayTargetFile(
target,
childScript,
[
`process.stdout.write(${JSON.stringify(`${childReady}\\n`)})`,
"let buffered = ''",
"process.stdin.on('data', chunk => {",
" buffered += chunk.toString('utf8')",
" if (!buffered.includes('\\n')) return",
` process.stdout.write(${JSON.stringify(`${childRead}:`)} + JSON.stringify(buffered) + '\\n')`,
' process.exit(0)',
'})',
''
].join('\n')
)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await connectDockerSshRelayTarget(orcaPage, target)
const relayExports = execDockerSshRelayTargetCommand(
target,
'module=$(find /root/.orca-remote -type d -path \'*/node_modules/node-pty\' | head -n 1); node -e "const p=require(process.argv[1]); console.log(Object.keys(p.native || {}).join(\',\'))" "$module"'
)
testInfo.annotations.push({ type: 'relay-node-pty-exports', description: relayExports })
expect(relayExports).not.toContain('echoState')
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
await execInTerminal(
orcaPage,
ptyId,
`env HOME=${shellQuote(home)} XDG_CONFIG_HOME=${shellQuote(`${home}/.config`)} XDG_DATA_HOME=${shellQuote(`${home}/.local/share`)} TERM=xterm-256color /usr/local/bin/fish -l -i`
)
await waitForTerminalOutput(orcaPage, prompt, 30_000, 80_000)
const blocker = `node -e ${shellQuote(`console.log('BLOCKER_STARTED_${runId}'); setTimeout(() => process.exit(0), 5000)`)}`
await execInTerminal(orcaPage, ptyId, blocker)
await waitForTerminalOutput(orcaPage, `BLOCKER_STARTED_${runId}`, 30_000, 80_000)
await execInTerminal(orcaPage, ptyId, `node ${shellQuote(childScript)}`)
await waitForTerminalOutput(orcaPage, childReady, 30_000, 80_000)
await sendToTerminal(orcaPage, ptyId, 'hello\r')
await waitForTerminalOutput(orcaPage, `${childRead}:"hello\\n"`, 30_000, 80_000)
const screenshotDir = path.join(process.cwd(), 'validation-screenshots', 'sta-3948')
const screenshotPath = path.join(screenshotDir, 'linux-ssh-fish-child-stdin-pass.png')
mkdirSync(screenshotDir, { recursive: true })
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('linux-ssh-fish-child-stdin-pass', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
cleanupDockerSshRelayTarget(target)
}
})
})