fix(pty): answer a terminal colour query in its own turn

Root-cause follow-up to #15559, which stopped a CPR overtaking a deferred
colour reply but left the deferral itself in place.

Orca answers terminal queries by writing to the PTY master, which a line
discipline in ECHO copies straight back out as junk on a cooked prompt
(#12112). The guard was to withhold the write until an `stty` subprocess
proved ECHO clear — and forking is what forced the decision to be async.
Any deferral, however short, lets a reply written later in the same turn
overtake this one, so the async probe was the bug's root cause.

Read the bit synchronously instead. Linux and the BSDs redirect a
master's mode ioctls to the slave, so a `tcgetattr` on the master fd
node-pty already owns answers for the slave with no fork: measured 0.26us
against 2403us for the subprocess. With a verdict available inline, a
querying program that already cleared ECHO — every raw-mode prober,
including the colour probe behind the `gh auth login` report — is
answered in its own turn and can never be reordered.

The deferral stays for the genuinely cooked case, and the ordering
guarantee stays underneath it: hosts whose node-pty predates this patch
get no sync probe and fall back to the deferred path, which mixed
client/host versions make a live production path.

Reply routing is all-or-nothing: a payload needing neither containment
nor ordering stays on the host's own path, so a CPR answered during shell
startup cannot pass the daemon's post-ready flush gate and splice into
the buffered startup command.

Native side is fail-safe: a kernel that did not redirect would answer
from the master's own termios, whose ECHO defaults set, so the degraded
verdict is "echoing" — never a false "quiet". The JS half ships in the
pnpm patch while the binding needs a source build, so
ORCA_REQUIRE_NODE_PTY_ECHO_STATE=1 makes CI fail rather than silently
skip when it is handed an upstream prebuild.

Co-authored-by: Brennan <brennanb2025@users.noreply.github.com>
This commit is contained in:
Neil
2026-08-19 23:34:30 -07:00
co-authored by Brennan
parent 9d06b3ba93
commit bc319abfbe
42 changed files with 2020 additions and 350 deletions
-1
View File
@@ -112,7 +112,6 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
## Release Channels
The public Homebrew cask tracks stable desktop releases:
-1
View File
@@ -311,7 +311,6 @@ jobs:
including back to Stable, works in-app from there."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish adhoc macOS artifacts
uses: nick-fields/retry@v4
with:
-1
View File
@@ -308,7 +308,6 @@ jobs:
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
- name: Publish daily macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
-1
View File
@@ -290,7 +290,6 @@ jobs:
including back to Stable, works in-app from there."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish hourly macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
+5
View File
@@ -317,6 +317,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
@@ -414,6 +415,8 @@ jobs:
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/startup-shell-portability.live-shell.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:
@@ -458,6 +461,8 @@ jobs:
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.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 }}
+1 -2
View File
@@ -34,8 +34,7 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY:
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
+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: Linux and the BSDs redirect a master's mode ioctls to the slave (POSIX does
+ * not specify it), 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
@@ -11057,7 +11057,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",
@@ -11066,6 +11066,7 @@
"desktop terminal input",
"OSC 10/11 replies",
"mode-2031 replies",
"DA1 and CPR replies",
"local PTY",
"daemon and SSH PTYs"
],
@@ -11073,15 +11074,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-input-write.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-input-coalescing.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"
@@ -11089,8 +11093,12 @@
"testFiles": [
"src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport-input-write.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-input-coalescing.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"
@@ -11104,7 +11112,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"
]
@@ -11116,10 +11124,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-input-coalescing.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"
]
},
{
@@ -11128,6 +11155,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": [
@@ -11143,7 +11176,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"
]
}
],
@@ -11183,6 +11217,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": {
@@ -11191,7 +11234,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",
@@ -11199,7 +11242,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.",
@@ -11208,10 +11251,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."
},
@@ -20,7 +20,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 = [
@@ -84,6 +86,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
@@ -24,7 +24,7 @@ patchedDependencies:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
path: config/patches/lint-staged@16.4.0.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
hash: 277aaf3aeb13536f2b59c1a893de30f4dc338b36c6ebc23e4a081a85a2e5b071
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -66,7 +66,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=277aaf3aeb13536f2b59c1a893de30f4dc338b36c6ebc23e4a081a85a2e5b071)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12025,7 +12025,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8):
node-pty@1.1.0(patch_hash=277aaf3aeb13536f2b59c1a893de30f4dc338b36c6ebc23e4a081a85a2e5b071):
dependencies:
node-addon-api: 7.1.1
+20
View File
@@ -639,4 +639,24 @@ describe('checkPtySpawnHealth (retry on transient failure)', () => {
expect(spawnMock).toHaveBeenCalledTimes(2)
warn.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', async () => {
spawnMock.mockReturnValue({ ...mockPtyProcess(), readEchoState: () => 0 })
const handle = await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
expect(handle.echoSyncProbe?.()).toBe('quiet')
})
itOnPosixHost('omits the probe when node-pty lacks Orca’s patch', async () => {
spawnMock.mockReturnValue(mockPtyProcess())
const handle = await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
expect(handle.echoSyncProbe).toBeUndefined()
})
})
})
+6 -1
View File
@@ -83,7 +83,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',
@@ -1144,12 +1147,14 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise<S
})
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,141 @@
/**
* 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-io-events.test.ts`, `pty-handler-output-drain-differential.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 } from './session'
import type { SubprocessHandle } from './session-subprocess-handle'
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'])
})
// A query reply must not jump the post-ready flush gate: the startup command is parked
// there, and a CPR written ahead of it is read by the shell's line editor as an unbound
// key plus literal text, so the shell runs `4;1Recho ...` and the agent never launches.
it('keeps a CPR reply behind the buffered startup command', async () => {
const { handle, written } = createSubprocess(() => 'quiet')
const session = new Session({
id: 'sess-gate',
subprocess: handle,
shellReadySupported: true
} as never)
session.write('claude\n')
session.write('\x1b[24;1R')
// Still parked: neither byte has reached the PTY while the gate holds.
expect(written.join('')).not.toContain('24;1R')
session.dispose?.()
})
})
@@ -1,3 +1,4 @@
import type { PtySlaveEchoSyncProbe } from '../../shared/pty-slave-line-discipline-echo'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
export type SubprocessHandle = {
@@ -17,6 +18,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:
+5 -3
View File
@@ -12,7 +12,7 @@ import type { SessionOptions } from './session-options'
import type { TuiAgent } from '../../shared/tui-agent'
import { randomUUID } from 'node:crypto'
import { PtyStartupIngress } from '../../shared/pty-startup-ingress'
import { takeLiveQueryReply } from '../../shared/terminal-query-reply'
import type {
SessionState,
ShellReadyState,
@@ -80,7 +80,8 @@ export class Session {
...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}),
write: (data) => this.subprocess.write(data),
onEmission: (emission) => this.output.emit(emission),
...(echoProbe ? { echoProbe } : {})
...(echoProbe ? { echoProbe } : {}),
...(this.subprocess.echoSyncProbe ? { echoSyncProbe: this.subprocess.echoSyncProbe } : {})
})
this.shellReady.startPromptReadinessProbe()
this.subprocess.onData((data) => this.handleSubprocessData(data))
@@ -132,7 +133,8 @@ export class Session {
}
// Daemon POSIX PTYs need the local provider's cooked-echo containment (#13137).
if (takeLiveQueryReply(this.startupIngress, data)) {
// DA1/CPR stay immediate unless an echo-risk reply is already held (#13892, #15559).
if (this.startupIngress.answerLiveQueryReply(data)) {
return
}
@@ -350,4 +350,45 @@ describe('LocalPtyProvider', () => {
expect(dataHandler).not.toHaveBeenCalled()
})
})
// 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()
})
})
})
+6 -4
View File
@@ -72,12 +72,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 { takeLiveQueryReply } 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 {
@@ -1002,6 +1002,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({
@@ -1011,7 +1012,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)
@@ -1193,8 +1195,8 @@ export class LocalPtyProvider implements IPtyProvider {
}
write(id: string, data: string): boolean {
// Cooked PTYs echo private DSR/OSC replies; CPR/DA stay immediate unless one of
// those is still deferred, which they must not overtake (#13137, #7329).
if (takeLiveQueryReply(startupIngressByPty.get(id), data)) {
// those is still held, which they must not overtake (#13137, #7329, #15559).
if (startupIngressByPty.get(id)?.answerLiveQueryReply(data)) {
return true
}
const proc = ptyProcesses.get(id)
@@ -0,0 +1,103 @@
/**
* Wiring guard for the relay link of #13892 / #15559.
*
* The fix is a sync ECHO probe each PTY host must hand to `PtyStartupIngress`. Deleting
* that spread left the whole suite green, so it could be refactored away silently.
* Sibling guards live in `session-same-turn-query-reply-wiring.test.ts`,
* `local-pty-provider-io-events.test.ts` and `pty-subprocess.test.ts`.
*
* Asserted as behaviour, not source text: with the probe wired and the kernel quiet the
* reply reaches the PTY inside the caller's turn. Without it the reply is queued behind
* a timer — the deferral that strands it in the next child's stdin.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
mockCreateShellPromptReadinessProbe: vi.fn(),
mockPtyInstance: {
pid: process.pid,
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
clear: vi.fn(),
pause: vi.fn(),
resume: vi.fn()
}
}))
vi.mock('node-pty', () => ({ spawn: mockPtySpawn }))
vi.mock('../main/pty/posix-pty-process-groups', () => ({
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
}))
vi.mock('../main/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe
}))
import type { PtyHandler } from './pty-handler'
import {
beginPtyHandlerTest,
createPtyRequestHelpers,
endPtyHandlerTest
} from './pty-handler-test-harness'
import type { MockDispatcher } from './pty-handler-test-harness'
const OSC11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x07'
const DA1_REPLY = '\x1b[?1;2c'
describe('relay same-turn query reply wiring', () => {
let dispatcher: MockDispatcher
let handler: PtyHandler
let originalPlatform: PropertyDescriptor | undefined
const { spawnPty } = createPtyRequestHelpers(() => dispatcher)
beforeEach(() => {
;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({
mockPtySpawn,
mockPtyInstance,
mockCreateShellPromptReadinessProbe
}))
})
afterEach(async () => {
await endPtyHandlerTest(handler, originalPlatform)
})
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 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_REPLY })
expect(write).not.toHaveBeenCalled()
})
})
+10 -4
View File
@@ -58,7 +58,6 @@ import {
parsePtyStartupIngressIntent,
type PtyIngressEmission
} from '../shared/pty-startup-ingress'
import { takeLiveQueryReply } 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'
@@ -82,7 +81,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'
import {
deleteRelayFishHistory,
deleteRelayHistory,
@@ -797,12 +800,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) {
@@ -1881,7 +1886,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 (takeLiveQueryReply(managed.startupIngress, data)) {
// DA1/CPR stay immediate unless an echo-risk reply is already held (#13892, #15559).
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)
})
})
@@ -119,7 +119,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(
@@ -144,6 +144,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' })
@@ -158,9 +160,18 @@ describe('createRemoteRuntimePtyTransport', () => {
TerminalStreamOpcode.Subscribe,
TerminalStreamOpcode.ClaimViewport,
TerminalStreamOpcode.Resize,
TerminalStreamOpcode.Input,
TerminalStreamOpcode.Input,
TerminalStreamOpcode.Input
])
})
// Query replies must stay their own frames: concatenating them with keystrokes
// produces a mixed payload the host classifier cannot recognise as a reply.
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()
@@ -623,4 +634,42 @@ describe('createRemoteRuntimePtyTransport', () => {
vi.useRealTimers()
}
})
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?.()
})
})
@@ -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,189 @@
/**
* 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
)
}
})
// Without this the fix can go silently inert: on a kernel that does not redirect a
// master's mode ioctls to the slave, the probe could only ever answer 'echoing', every
// other assertion in this file would still pass, and the same-turn path would never
// fire. This is the one assertion that fails loudly on such a kernel.
describe('the native probe can actually observe a quiet slave', () => {
itOnPosix('reads echoing while cooked and quiet once the child clears ECHO', async () => {
const nodePty = await import('node-pty')
const support = resolveNodePtyEchoStateSupport(nodePty)
if (!support.available) {
expect(nodePtyEchoStateRequirementViolation(support)).toBeNull()
console.warn(`[skip] ${support.reason} ${NODE_PTY_SOURCE_BUILD_HINT}`)
return
}
const pty = nodePty.spawn('/bin/sh', ['-c', 'stty -echo; sleep 2'], {
name: 'xterm-256color',
cols: 80,
rows: 24
})
try {
const probe = createPtySlaveEchoSyncProbe(pty)
expect(probe).toBeDefined()
// A freshly spawned pty is cooked, so the slave's ECHO must read as set.
expect(probe?.()).toBe<PtySlaveLineDisciplineEcho>('echoing')
expect(await waitUntil(() => probe?.() === 'quiet', 4_000)).toBe(true)
} finally {
pty.kill()
}
})
})
@@ -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,43 @@ 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).
*
* Linux and the BSDs (so macOS) redirect a master's mode ioctls to the slave — POSIX
* does not specify pty master termios, so this is per-kernel behaviour, covered by the
* node-pty lane on both platforms. 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 = {
@@ -181,13 +181,13 @@ describe('PtyStartupIngress live query replies (#13137)', () => {
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(writes).toEqual([])
expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true)
expect(ingress.answerLiveQueryReply(CPR_REPLY)).toBe(true)
await vi.advanceTimersByTimeAsync(20)
expect(writes).toEqual([OSC_COLOR_REPLY, CPR_REPLY])
ingress.drainAndClose()
})
it('leaves a CPR reply on the caller immediate path when nothing is deferred', async () => {
it('declines a CPR when nothing is deferred, leaving it on the host path', async () => {
vi.useFakeTimers()
const writes: string[] = []
const ingress = new PtyStartupIngress({
@@ -197,35 +197,47 @@ describe('PtyStartupIngress live query replies (#13137)', () => {
onEmission: () => {}
})
expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false)
// Declining is what keeps the host's own queues in play — notably the daemon's
// post-ready flush gate, which a CPR written past would splice into the buffered
// startup command.
expect(ingress.answerLiveQueryReply(CPR_REPLY)).toBe(false)
expect(writes).toEqual([])
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(writes).toEqual([OSC_COLOR_REPLY])
expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false)
ingress.drainAndClose()
})
it('does not project an ordered CPR reply as an echo to swallow', async () => {
it('projects a queued ordered reply, but never a declined one', async () => {
vi.useFakeTimers()
const emissions: PtyIngressEmission[] = []
const ingress = new PtyStartupIngress({
const unqueued: PtyIngressEmission[] = []
const solo = new PtyStartupIngress({
ownerBackend: 'posix-pty',
// Never settles, so the budget-expiry flush runs with kernelEchoImpossible
// false — the only verdict under which the caret shape IS armed, so this is
// what discriminates the uncontained branch from the contained one.
echoProbe: () => new Promise(() => {}),
write: () => {},
onEmission: (emission) => emissions.push(emission)
onEmission: (emission) => unqueued.push(emission)
})
// Nothing deferred: the delivery declines it, so it can never be projected.
expect(solo.answerLiveQueryReply(CPR_REPLY)).toBe(false)
solo.accept(POSIX_CSI_COOKED_ECHO(CPR_REPLY))
expect(visible(unqueued)).toBe(POSIX_CSI_COOKED_ECHO(CPR_REPLY))
solo.drainAndClose()
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
const queued: PtyIngressEmission[] = []
const held = new PtyStartupIngress({
ownerBackend: 'posix-pty',
echoProbe: () => new Promise(() => {}),
write: () => {},
onEmission: (emission) => queued.push(emission)
})
expect(held.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true)
expect(held.answerLiveQueryReply(CPR_REPLY)).toBe(true)
// Flushes at budget expiry with ECHO still unproven, so its echo must be swallowed.
await vi.advanceTimersByTimeAsync(200)
// The CPR rode the queue for order only, so its caret shape is ordinary output.
ingress.accept(POSIX_CSI_COOKED_ECHO(CPR_REPLY))
expect(visible(emissions)).toBe(POSIX_CSI_COOKED_ECHO(CPR_REPLY))
ingress.drainAndClose()
held.accept(POSIX_CSI_COOKED_ECHO(CPR_REPLY))
expect(visible(queued)).toBe('')
held.drainAndClose()
})
// An ordered reply must not restart the ECHO probe: attemptPendingWrites nulls
@@ -252,7 +264,7 @@ describe('PtyStartupIngress live query replies (#13137)', () => {
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
await vi.advanceTimersByTimeAsync(0)
for (let i = 0; i < orderedCount; i += 1) {
ingress.writeQueryReplyInOrder(CPR_REPLY)
ingress.answerLiveQueryReply(CPR_REPLY)
}
await vi.advanceTimersByTimeAsync(200)
expect(writes[0]).toBe(OSC_COLOR_REPLY)
@@ -277,7 +289,7 @@ describe('PtyStartupIngress live query replies (#13137)', () => {
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
await vi.advanceTimersByTimeAsync(0)
for (let i = 0; i < 8; i += 1) {
ingress.writeQueryReplyInOrder(CPR_REPLY)
ingress.answerLiveQueryReply(CPR_REPLY)
}
// A burst of ordered replies must not exhaust the probe-start budget and force an
// early unprobed flush, which would paint the reply onto a still-cooked prompt.
@@ -300,7 +312,7 @@ describe('PtyStartupIngress live query replies (#13137)', () => {
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true)
expect(ingress.answerLiveQueryReply(CPR_REPLY)).toBe(true)
// Daemon dispose drains before the kill, so the child can still be blocked on this.
ingress.drainAndClose()
expect(writes).toEqual([CPR_REPLY])
@@ -316,7 +328,8 @@ describe('PtyStartupIngress live query replies (#13137)', () => {
onEmission: () => {}
})
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false)
// Nothing ever defers here, so a CPR is always the host's to write.
expect(ingress.answerLiveQueryReply(CPR_REPLY)).toBe(false)
expect(writes).toEqual([OSC_COLOR_REPLY])
ingress.drainAndClose()
}
@@ -357,6 +370,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[] = []
@@ -411,4 +441,29 @@ describe('PtyStartupIngress live query replies (#13137)', () => {
expect(visible(emissions)).not.toContain('rgb:64')
ingress.drainAndClose()
})
// The guard protects the answer() path specifically: a SECOND echo-risk reply arriving
// while a probe is in flight re-enters armWriteTimer, where writeTimer is already null.
// (answerInOrder never arms, so it cannot reach this.)
it('does not fork a second probe when a reply arrives mid-probe', async () => {
vi.useFakeTimers()
let calls = 0
const ingress = new PtyStartupIngress({
ownerBackend: 'posix-pty',
echoProbe: () => {
calls += 1
return new Promise(() => {})
},
write: () => {},
onEmission: () => {}
})
expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(calls).toBe(1)
// A live theme flip answers a second colour query while the first probe is pending.
expect(ingress.answerLiveQueryReply('\x1b]10;rgb:ff/ff/ff\x07')).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(calls).toBe(1)
ingress.drainAndClose()
})
})
+10 -15
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,18 +98,13 @@ 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
}
/** Order-only: holds an uncontained reply behind a deferred one, else leaves it. */
writeQueryReplyInOrder(reply: string): boolean {
return !this.closed && this.delivery.writeBehindDeferredReplies(reply)
}
drainAndClose(): number {
this.enqueue({ kind: 'teardown' })
return this.rawHighWater
@@ -372,12 +372,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)
})
})
+97 -153
View File
@@ -1,46 +1,12 @@
import type { PtyOwnerBackend } from './pty-owner-backend'
import type { PtySlaveEchoProbe } 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
// straight back out as ordinary output (#12112). ConPTY echoes it with the ESC
// 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.
//
// 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
// mode with TCSANOW/TCSADRAIN (libuv's setRawMode, hence Node-based agents) still
// receives the reply, and re-writing would duplicate it in stdin. A TCSAFLUSH
// switcher does discard it; that case is left to the query timeout, because a
// duplicate reply corrupts a parser that is already mid-read.
//
// Why not PostReadyFlushGate's settle-and-fallback shape, which solves this same
// "don't write while ECHO is on" race for shell startup input: that gate defers
// bytes nothing is waiting on, so it can wait for the stream to go observably
// quiet. A color reply is different — the querying program is blocked on it and
// times out — so the wait here is bounded by a budget and always ends in a write.
//
// There are TWO echo sources and they are independent, which is the thing to hold onto
// when reading the rest of this file:
//
// 1. The kernel line discipline, when ECHO is set. Readable state — the probe asks
// the slave directly, and waiting for it to clear removes this echo outright.
// 2. The foreground line editor, in software. readline echoes a master write as if
// it were typed *while the tty is raw with ECHO off*, so the probe's verdict says
// nothing about it. Verified on a live pty: at a bash prompt the probe reports
// `quiet` and readline still emits `BEL 10;rgb:2e2e/3434/3434`.
//
// So `quiet` is proof about (1) only. It gates the withholding and retires the caret
// projection, and must never be read as "no suppression needed" — that reintroduces
// #12112 at a shell prompt, which is the foreground for most of an agent pane's
// startup window. The projections below stay armed for (2) on every path.
export type PtyStartupReplyEchoMatch =
| { kind: 'complete'; offset: number; length: number }
| { kind: 'partial'; offset: number }
| { kind: 'none' }
export type { PtyStartupReplyEchoMatch } from './pty-startup-reply-echo-shapes'
import {
isBetterEchoMatch,
locateEcho,
replyEchoProjections,
type PtyStartupReplyEchoMatch
} from './pty-startup-reply-echo-shapes'
import type { PtySlaveEchoProbe, PtySlaveEchoSyncProbe } from './pty-slave-line-discipline-echo'
// Why bytes and not reads: the echo is a fixed ~30 bytes, but nothing bounds how the
// tty chunks them — an SSH relay or a slow drain delivers a few bytes at a time, and a
@@ -74,98 +40,57 @@ 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; contained: boolean }
type PendingWrite = {
reply: string
onFailed: (() => void) | undefined
/** Arm echo projections when this entry is written. */
projectEcho: boolean
/** Rode the queue only to keep order; the caller was already told it was sent. */
ordered: boolean
}
type ActiveEchoProbe = { timer: ReturnType<typeof setTimeout> | null }
/** Only a POSIX tty both echoes the reply and still delivers a deferred write. */
function defersWrite(ownerBackend: PtyOwnerBackend): boolean {
return ownerBackend === 'posix-pty'
}
function replyEchoProjections(
reply: string,
ownerBackend: PtyOwnerBackend,
kernelEchoImpossible: boolean
): readonly string[] {
if (ownerBackend === 'windows-conpty') {
// Why: ConPTY's projection is the documented, deterministic ESC-stripped form.
return [reply.replaceAll('\x1b', '')]
}
if (!defersWrite(ownerBackend)) {
// wsl.exe is ConPTY-hosted but its echo shape is unverified; suppress nothing.
return []
}
// What makes both shapes below safe to match on is that neither starts with ESC, so
// no query can share a prefix with them. The verbatim echo of a `stty -echoctl` tty
// is deliberately NOT projected for exactly that reason: it is byte-identical to the
// reply, so a bare trailing ESC — how any read can end — is a strict prefix of it.
// That read would be held as an echo candidate, and an expired hold releases its
// bytes raw, past the query parser, so a query torn at its own ESC is never answered.
const projections: string[] = []
// A quiet probe rules out only the kernel's ECHOCTL caret form.
if (!kernelEchoImpossible) {
projections.push(reply.replaceAll('\x1b', '^['))
}
// Readline rewrites OSC, while adding a CSI identity would hold query fragments.
if (reply.includes('\x1b]')) {
projections.push(reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', ''))
}
return projections
}
/** Earliest offset whose suffix of `data` is a strict prefix of `projection`, else -1. */
function suffixPrefixOffset(projection: string, data: string): number {
for (
let offset = Math.max(0, data.length - projection.length + 1);
offset < data.length;
offset += 1
) {
if (projection.startsWith(data.slice(offset))) {
return offset
}
}
return -1
}
// Why search the whole span: the tty coalesces its echo with whatever the shell and the
// program wrote around it, so anchoring at offset 0 recognizes almost no real echo.
function locateEcho(projections: readonly string[], data: string): PtyStartupReplyEchoMatch {
let complete: { offset: number; length: number } | null = null
let partialOffset = -1
for (const projection of projections) {
const at = data.indexOf(projection)
if (at !== -1) {
if (!complete || at < complete.offset) {
complete = { offset: at, length: projection.length }
}
continue
}
const suffix = suffixPrefixOffset(projection, data)
if (suffix !== -1 && (partialOffset === -1 || suffix < partialOffset)) {
partialOffset = suffix
}
}
if (complete) {
return { kind: 'complete', ...complete }
}
return partialOffset === -1 ? { kind: 'none' } : { kind: 'partial', offset: partialOffset }
}
function isBetterEchoMatch(
candidate: PtyStartupReplyEchoMatch,
best: PtyStartupReplyEchoMatch
): boolean {
if (candidate.kind === 'none') {
return false
}
if (best.kind === 'none') {
return true
}
if (candidate.kind !== best.kind) {
return candidate.kind === 'complete'
}
return candidate.offset < best.offset
}
// 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
// straight back out as ordinary output (#12112). ConPTY echoes it with the ESC
// 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. 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
// mode with TCSANOW/TCSADRAIN (libuv's setRawMode, hence Node-based agents) still
// receives the reply, and re-writing would duplicate it in stdin. A TCSAFLUSH
// switcher does discard it; that case is left to the query timeout, because a
// duplicate reply corrupts a parser that is already mid-read.
//
// Why not PostReadyFlushGate's settle-and-fallback shape, which solves this same
// "don't write while ECHO is on" race for shell startup input: that gate defers
// bytes nothing is waiting on, so it can wait for the stream to go observably
// quiet. A color reply is different — the querying program is blocked on it and
// times out — so the wait here is bounded by a budget and always ends in a write.
//
// There are TWO echo sources and they are independent, which is the thing to hold onto
// when reading the rest of this file:
//
// 1. The kernel line discipline, when ECHO is set. Readable state — the probe asks
// the slave directly, and waiting for it to clear removes this echo outright.
// 2. The foreground line editor, in software. readline echoes a master write as if
// it were typed *while the tty is raw with ECHO off*, so the probe's verdict says
// nothing about it. Verified on a live pty: at a bash prompt the probe reports
// `quiet` and readline still emits `BEL 10;rgb:2e2e/3434/3434`.
//
// So `quiet` is proof about (1) only. It gates the withholding and retires the caret
// projection, and must never be read as "no suppression needed" — that reintroduces
// #12112 at a shell prompt, which is the foreground for most of an agent pane's
// startup window. The projections below stay armed for (2) on every path.
/** Owns when a startup color reply is written and how its own echo is recognized. */
export class PtyStartupReplyDelivery {
@@ -181,9 +106,15 @@ 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
) {}
/** True while a reply is queued but unwritten, so later writes must not overtake it. */
get hasDeferredWrites(): boolean {
return this.pendingWrites.length > 0
}
get hasExpectedEcho(): boolean {
return this.expectedEchoes.length > 0
}
@@ -204,6 +135,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 — a 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,32 +152,40 @@ export class PtyStartupReplyDelivery {
if (this.pendingWrites.length === 0) {
this.echoPollDeadline = Date.now() + ECHO_POLL_BUDGET_MS
}
this.pendingWrites.push({ reply, onFailed, contained: true })
this.pendingWrites.push({ reply, onFailed, projectEcho: true, ordered: false })
this.armWriteTimer()
return true
}
/**
* Queues a reply that needs no echo containment behind ones that do, so it cannot
* overtake them. termenv writes `OSC 11 ;? ST` then `CSI 6n` and stops reading at
* the CPR, treating it as proof the terminal answered: a CPR that jumps a still-
* deferred color reply strands that reply in the tty for whatever runs next to read
* (`gh auth login` -> "unexpected escape sequence from terminal").
* Writes a reply that needs no echo containment, keeping it behind any that do.
*
* False means nothing is deferred and the caller owns the write, which keeps the
* latency-critical replies immediate on every path that is not mid-deferral.
* A colour probe writes `OSC 11 ;? ST` then `CSI 6n` and stops reading at the CPR,
* treating it as proof the terminal answered: a CPR that jumps a still-deferred
* colour reply strands that reply in the tty for whatever runs next to read
* (`gh auth login` -> "unexpected escape sequence from terminal", #15559).
*
* Deliberately does NOT arm the write timer — the echo-risk entry that made the queue
* non-empty already owns the continuation, and re-arming would fork a second probe.
*/
writeBehindDeferredReplies(reply: string): boolean {
if (this.closed || this.pendingWrites.length === 0) {
answerInOrder(reply: string): boolean {
if (this.closed) {
return false
}
// Captured BEFORE the overflow flush: a reply that had to wait is written into a
// window where ECHO may still be set, so it needs projections even once the flush
// has emptied the queue out from under it.
const followsEchoRiskReply = this.pendingWrites.length > 0
if (this.pendingWrites.length >= MAX_TRACKED_REPLIES) {
// Flushing keeps order without growing the queue; the caller writes after it.
// Flushing keeps order without growing the queue; this reply is written after it.
this.flushPendingWrites()
return false
}
this.pendingWrites.push({ reply, onFailed: undefined, contained: false })
this.armWriteTimer()
if (this.pendingWrites.length === 0) {
return this.writeReply(reply, undefined, false, followsEchoRiskReply)
}
// Why projected, unlike an unqueued write: a reply forced behind an echo-risk write
// can flush while ECHO is still set, and would then be echoed onto a cooked prompt.
this.pendingWrites.push({ reply, onFailed: undefined, projectEcho: true, ordered: true })
return true
}
@@ -289,8 +237,8 @@ export class PtyStartupReplyDelivery {
}
/**
* Teardown. A contained reply has nowhere left to go — its reader is gone. An
* uncontained one is an ordinary write this queue only borrowed for ordering, and the
* Teardown. An echo-risk reply has nowhere left to go — its reader is gone. An
* ordered one is an ordinary write this queue only borrowed for sequencing, and the
* caller was already told it was sent, so it is handed to the pty best-effort: the
* child can still be alive here (daemon dispose drains before the kill).
*/
@@ -299,7 +247,7 @@ export class PtyStartupReplyDelivery {
this.clearWriteTimer()
this.clearActiveEchoProbe()
for (const pending of this.pendingWrites.splice(0)) {
if (!pending.contained) {
if (pending.ordered) {
try {
this.writeProvider(pending.reply)
} catch {
@@ -371,7 +319,7 @@ export class PtyStartupReplyDelivery {
this.clearWriteTimer()
this.clearActiveEchoProbe()
for (const pending of this.pendingWrites.splice(0)) {
this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible, pending.contained)
this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible, pending.projectEcho)
}
}
@@ -414,16 +362,12 @@ export class PtyStartupReplyDelivery {
reply: string,
onFailed?: () => void,
kernelEchoImpossible = false,
contained = true
projectEcho = true
): boolean {
if (this.closed) {
return false
}
// Not a safety rule: the caret form of a short CSI reply (`^[[6;1R`) is far likelier
// to occur verbatim in ordinary output than a long OSC colour string, so arming it
// is not worth the false swallow. These replies were unprojected before they rode
// this queue, and stay that way.
const projections = contained
const projections = projectEcho
? replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible)
: []
// Why: register before write because node-pty can synchronously re-enter onData.
@@ -0,0 +1,95 @@
import type { PtyOwnerBackend } from './pty-owner-backend'
// The shapes a reply written to the PTY master can come back as, and how one is located
// inside a coalesced read. Split from the delivery scheduler (#12112, #13137): what an
// echo LOOKS like is independent of when the write happens.
export type PtyStartupReplyEchoMatch =
| { kind: 'complete'; offset: number; length: number }
| { kind: 'partial'; offset: number }
| { kind: 'none' }
export function replyEchoProjections(
reply: string,
ownerBackend: PtyOwnerBackend,
kernelEchoImpossible: boolean
): readonly string[] {
if (ownerBackend === 'windows-conpty') {
// Why: ConPTY's projection is the documented, deterministic ESC-stripped form.
return [reply.replaceAll('\x1b', '')]
}
if (ownerBackend !== 'posix-pty') {
// wsl.exe is ConPTY-hosted but its echo shape is unverified; suppress nothing.
return []
}
// What makes both shapes below safe to match on is that neither starts with ESC, so
// no query can share a prefix with them. The verbatim echo of a `stty -echoctl` tty
// is deliberately NOT projected for exactly that reason: it is byte-identical to the
// reply, so a bare trailing ESC — how any read can end — is a strict prefix of it.
// That read would be held as an echo candidate, and an expired hold releases its
// bytes raw, past the query parser, so a query torn at its own ESC is never answered.
const projections: string[] = []
// A quiet probe rules out only the kernel's ECHOCTL caret form.
if (!kernelEchoImpossible) {
projections.push(reply.replaceAll('\x1b', '^['))
}
// Readline rewrites OSC, while adding a CSI identity would hold query fragments.
if (reply.includes('\x1b]')) {
projections.push(reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', ''))
}
return projections
}
/** Earliest offset whose suffix of `data` is a strict prefix of `projection`, else -1. */
function suffixPrefixOffset(projection: string, data: string): number {
for (
let offset = Math.max(0, data.length - projection.length + 1);
offset < data.length;
offset += 1
) {
if (projection.startsWith(data.slice(offset))) {
return offset
}
}
return -1
}
// Why search the whole span: the tty coalesces its echo with whatever the shell and the
// program wrote around it, so anchoring at offset 0 recognizes almost no real echo.
export function locateEcho(projections: readonly string[], data: string): PtyStartupReplyEchoMatch {
let complete: { offset: number; length: number } | null = null
let partialOffset = -1
for (const projection of projections) {
const at = data.indexOf(projection)
if (at !== -1) {
if (!complete || at < complete.offset) {
complete = { offset: at, length: projection.length }
}
continue
}
const suffix = suffixPrefixOffset(projection, data)
if (suffix !== -1 && (partialOffset === -1 || suffix < partialOffset)) {
partialOffset = suffix
}
}
if (complete) {
return { kind: 'complete', ...complete }
}
return partialOffset === -1 ? { kind: 'none' } : { kind: 'partial', offset: partialOffset }
}
export function isBetterEchoMatch(
candidate: PtyStartupReplyEchoMatch,
best: PtyStartupReplyEchoMatch
): boolean {
if (candidate.kind === 'none') {
return false
}
if (best.kind === 'none') {
return true
}
if (candidate.kind !== best.kind) {
return candidate.kind === 'complete'
}
return candidate.offset < best.offset
}
@@ -0,0 +1,37 @@
import type { PtyStartupReplyDelivery } from './pty-startup-reply-delivery'
import {
extractOnlyTerminalQueryReplies,
needsCookedEchoSafeQueryReply
} from './terminal-query-reply'
/**
* True when `delivery` has taken ownership of the WHOLE payload.
*
* All-or-nothing on purpose. Taking only part of it would drop the rest — the caller
* has already been told the write was handled — and taking a payload that needs no
* ordering at all would skip the caller's own queues. The daemon's post-ready flush
* gate is the one that matters: a CPR written past it lands ahead of the buffered
* startup command and the shell executes the spliced remainder instead of the command.
*/
export function deliverTerminalQueryReplyPayload(
data: string,
delivery: Pick<PtyStartupReplyDelivery, 'answer' | 'answerInOrder' | 'hasDeferredWrites'>
): boolean {
const replies = extractOnlyTerminalQueryReplies(data)
if (!replies) {
return false
}
// Nothing here needs echo containment and nothing is deferred, so there is nothing to
// order behind: leave it on the caller's path.
if (!delivery.hasDeferredWrites && !replies.some(needsCookedEchoSafeQueryReply)) {
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
}
+16 -7
View File
@@ -2,9 +2,9 @@ import { Terminal } from '@xterm/headless'
import { describe, expect, it, vi } from 'vitest'
import {
extractOnlyCookedEchoSafeQueryReplies,
extractOnlyTerminalQueryReplies,
isTerminalQueryReply,
needsCookedEchoSafeQueryReply,
takeLiveQueryReply
needsCookedEchoSafeQueryReply
} from './terminal-query-reply'
import { PtyStartupIngress } from './pty-startup-ingress'
@@ -89,6 +89,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', () => {
@@ -134,13 +143,13 @@ describe('isTerminalQueryReply', () => {
// probe, so a CPR taken straight to the PTY overtakes it and leaves `ESC ]` in the
// tty for the next program — bubbletea then dies with
// "unexpected escape sequence from terminal: ['\x1b' ']']".
describe('takeLiveQueryReply ordering (termenv OSC-then-CPR)', () => {
describe('query reply ordering (termenv OSC-then-CPR)', () => {
const OSC_11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\'
const CPR_REPLY = '\x1b[1;1R'
function hostWrites(ingress: PtyStartupIngress, pty: string[]) {
return (data: string): void => {
if (!takeLiveQueryReply(ingress, data)) {
if (!ingress.answerLiveQueryReply(data)) {
pty.push(data)
}
}
@@ -193,7 +202,7 @@ describe('takeLiveQueryReply ordering (termenv OSC-then-CPR)', () => {
})
for (const keystroke of ['y', 'gh auth login\r', '\x1b[A', '\x1b', '\x03']) {
expect(takeLiveQueryReply(ingress, keystroke)).toBe(false)
expect(ingress.answerLiveQueryReply(keystroke)).toBe(false)
}
ingress.drainAndClose()
})
@@ -204,7 +213,7 @@ describe('takeLiveQueryReply ordering (termenv OSC-then-CPR)', () => {
// takeLiveQueryReply match whole strings, so a coalesced payload is not a reply, and
// ordinary input never rides the queue at all. Pinned so the boundary is explicit —
// if a change makes these take the ordered path, that is an improvement, not a break.
describe('takeLiveQueryReply: writes that still bypass the ordered queue', () => {
describe('writes that still bypass the ordered queue', () => {
const BYPASSING = [
{ what: 'reply coalesced with a keystroke', data: '\x1b[6;1Ry' },
{ what: 'keystroke coalesced with a reply', data: 'y\x1b[6;1R' },
@@ -220,7 +229,7 @@ describe('takeLiveQueryReply: writes that still bypass the ordered queue', () =>
})
// Deferral is open, so an ordered write WOULD be queued here.
expect(ingress.answerLiveQueryReply('\x1b]11;rgb:00/00/00\x07')).toBe(true)
expect(takeLiveQueryReply(ingress, data)).toBe(false)
expect(ingress.answerLiveQueryReply(data)).toBe(false)
ingress.drainAndClose()
})
})
+59 -68
View File
@@ -23,34 +23,61 @@ 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 skips input-intent/activity bookkeeping, and
// takeLiveQueryReply will hold it behind a deferred reply like any CPR — so
// within that window (bounded by the host's echo budget) a keystroke typed
// after it can reach the pty first. Accepted: it needs the chord and a
// following keypress inside a live colour-query deferral, and we keep the reply
// grammar complete rather than special-casing an unresolvable ambiguity.
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, so a pathological reply flood can shed it. It is also held behind a deferred
// reply like any CPR, so within that window (bounded by the host's echo budget) a
// keystroke typed after it can reach the pty first. Accepted: it needs the chord and a
// following keypress inside a live colour-query deferral, and we keep the reply grammar
// complete rather than special-casing an unresolvable ambiguity.
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
@@ -62,18 +89,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. */
@@ -116,6 +132,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).
@@ -128,46 +162,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
}
/** Host-side ingress surface for replies the PTY owner may take ownership of. */
export type LiveQueryReplyIngress = {
answerLiveQueryReply: (reply: string) => boolean
writeQueryReplyInOrder: (reply: string) => boolean
}
/**
* True when `ingress` owns this write. A reply a cooked prompt would paint is deferred
* behind an ECHO probe (#13137); a latency-critical reply (CPR/DA) is taken only while
* one of those is still deferred, because termenv stops reading at the CPR it sends
* after its color query — an early CPR strands the color reply in the tty for whatever
* runs next (`gh auth login` -> "unexpected escape sequence from terminal").
*/
export function takeLiveQueryReply(
ingress: LiveQueryReplyIngress | undefined,
data: string
): boolean {
if (!ingress) {
return false
}
return extractOnlyCookedEchoSafeQueryReplies(data)
? ingress.answerLiveQueryReply(data)
: isTerminalQueryReply(data) && ingress.writeQueryReplyInOrder(data)
}
+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)
}
})
})