Files
orca/config/scripts/windows-pty-table-stress.cjs
T
Neil a6d4a4fea5 fix(windows): respawn a ConPTY pane that started without keyboard input (#33423)
* fix(windows): respawn a ConPTY pane that started without an input reader

Bundled ConPTY occasionally starts a pane whose console host never reads
its input pipe: the shell prints its banner and prompt, but every keystroke
and the typed launch command are dropped. Such a pane is identifiable from
its first output, because a healthy one always queries DA1 before relaying
anything else. The daemon now waits for that first output, respawns once
when it is missing, and replays the buffered startup output so no byte is
lost. The table stress probe now waits for node-pty's own readiness rather
than typed input.

* fix(windows): bound the ConPTY startup check and never re-run a command

- Respawn only when no command rode in the shell's argv; otherwise keep the
  pane and say on screen that it cannot take input.
- Accept the pane unverified after 5s of silence instead of stalling the spawn.
- Distrust the check after two misses with no pane ever announcing, so a
  changed ConPTY build cannot respawn every pane.
- Kill a discarded pane through its job before closing the pseudoconsole.
- Report a cancel during the wait as an attach cancel, not a spawn failure.
- Assert on real Windows ConPTY that a fresh pane announces its input reader.

* fix(windows): respawn only Orca's own shell argv and close an unready pane now

- Treat user shell args like an argv startup command: never respawn, show
  the notice instead, so a command such as pwsh -Command never runs twice.
- Close a discarded pane's pseudoconsole directly; node-pty defers kill()
  until first output, which a silent pane never produces. Kill the shell by
  pid when no job owns it.
- Reconcile a master node-pty retired during the startup wait.
- Drop the test-only respawn counter; the count rides in the daemon log.

* fix(windows): tear a discarded unready pane down through node-pty's agent

node-pty defers kill() until first output, so closing only the native
pseudoconsole left the input pipe and output worker of a silent discarded
pane alive until daemon exit. The agent's kill closes all three.

* fix(windows): discard startup panes by handle only; account for the mock prelude

A pid-addressed kill could hit a recycled pid when the shell exited during
startup; the job and the agent's native kill both act on handles. Lifecycle
assertions now expect the replayed DA1 query when the suite runs on Windows.

* test(e2e): give the mocked Windows pane its ConPTY prelude
2026-10-24 23:59:37 -07:00

169 lines
5.9 KiB
JavaScript

'use strict'
const assert = require('node:assert/strict')
const { createHash } = require('node:crypto')
const { readFileSync, writeSync } = require('node:fs')
const { dirname, resolve } = require('node:path')
function report(phase, details = {}) {
writeSync(1, `${JSON.stringify({ phase, hostPid: process.pid, ...details })}\n`)
}
async function exerciseTable() {
assert.equal(process.platform, 'win32', 'This probe requires real Windows ConPTY')
const rounds = Number(process.env.ORCA_PTY_TABLE_STRESS_ROUNDS ?? 8)
assert.ok(Number.isInteger(rounds) && rounds > 0 && rounds <= 2000)
const { createStressObserver, loadedStressInputHashes, sanitizeStressText } =
await import('./windows-pty-table-stress-observer.mjs')
const observer = createStressObserver(report)
const pty = require('node-pty')
const nativePath = require.resolve('node-pty/lib/utils')
const loaded = require(nativePath).loadNativeModule('conpty')
const native = loaded.module
const addonPath = resolve(dirname(nativePath), loaded.dir, 'conpty.node')
report('native', {
addonPath: sanitizeStressText(addonPath),
sha256: createHash('sha256').update(readFileSync(addonPath)).digest('hex'),
node: process.version,
rounds,
inputs: loadedStressInputHashes(addonPath, require.resolve)
})
// Unlike production's fallback, this crash probe requires a host that permits nested jobs.
const hostJobAssigned = native.assignCurrentProcessToJob()
report('host-job-precondition', { assigned: hostJobAssigned })
assert.equal(hostJobAssigned, true, 'Probe precondition: host must permit a crash-cleanup job')
const spawned = []
function spawn(round, slot) {
report('spawn', { round, slot })
const proc = pty.spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/q'], {
cwd: process.cwd(),
cols: 80,
rows: 24,
useConptyDll: true
})
const record = { proc, output: '', exited: false, closed: false }
let resolveReady
record.ready = new Promise((resolve) => {
resolveReady = resolve
})
record.exit = new Promise((resolveExit) => {
proc.onExit((event) => {
record.exited = true
resolveReady(false)
resolveExit(event)
})
})
// First output is node-pty's own readiness, after which kill/resize/clear stop deferring.
// Not typed input: ConPTY rarely starts without an input reader, which the daemon respawns.
proc.onData((chunk) => {
record.output = (record.output + chunk).slice(-2048)
resolveReady(true)
})
observer.watch(record, { round, slot })
spawned.push(record)
return record
}
async function waitForReady(records) {
let timer
try {
await Promise.race([
Promise.all(
records.map(async (record) => {
assert.equal(await record.ready, true, `Shell exited before ready: ${record.output}`)
})
),
new Promise((_, reject) => {
timer = setTimeout(() => {
observer.pending('readiness-timeout-state')
const transcripts = records.map(({ proc, output }) => ({
pid: proc.pid,
output: sanitizeStressText(output)
}))
reject(new Error(`PTY readiness timed out: ${JSON.stringify(transcripts)}`))
}, 15_000)
})
])
} finally {
clearTimeout(timer)
}
for (const { proc } of records) {
const members = native.listJobProcessIds(proc._pty, proc.pid)
assert.ok(members?.includes(proc.pid), `Ready shell ${proc.pid} must retain its job`)
}
report('ready', { shellPids: records.map(({ proc }) => proc.pid) })
}
function close(record, round, slot) {
report('kill', { round, slot, shellPid: record.proc.pid })
record.proc.kill()
record.closed = true
observer.checkpoint('kill-returned-state', record)
}
let failure
try {
const survivor = spawn(-1, -1)
await waitForReady([survivor])
const slots = []
for (let round = 0; round < rounds; round += 1) {
for (let slot = 0; slot < 3; slot += 1) {
if (slots[slot]) {
close(slots[slot], round, slot)
}
// The next lookup/insertion overlaps the previous shell's native exit watcher.
slots[slot] = spawn(round, slot)
report('resize-clear-list', { round, slot, shellPid: survivor.proc.pid })
survivor.proc.resize(80 + (round % 2), 24)
survivor.proc.clear()
const members = native.listJobProcessIds(survivor.proc._pty, survivor.proc.pid)
assert.ok(members?.includes(survivor.proc.pid), 'A live survivor must retain its job')
}
// A ready terminal runs kill/resize/clear immediately instead of deferring them.
await waitForReady(slots)
}
assert.equal(survivor.exited, false, survivor.output)
report('overlap-complete', { terminals: spawned.length })
} catch (error) {
failure = { error }
} finally {
for (const record of spawned) {
if (!record.closed) {
close(record, -1, -1)
}
}
let timer
try {
await Promise.race([
Promise.all(spawned.map((record) => record.exit)),
new Promise((_, reject) => {
timer = setTimeout(() => {
observer.pending('exit-drain-timeout-state')
reject(new Error('PTY exit callbacks did not drain'))
}, 15_000)
})
])
} catch (error) {
if (failure) {
report('drain-error', { message: sanitizeStressText(error.stack) })
} else {
failure = { error }
}
} finally {
clearTimeout(timer)
}
}
if (failure) {
throw failure.error
}
observer.pending('complete-state')
report('complete', { terminals: spawned.length })
}
exerciseTable().catch(async (error) => {
const { sanitizeStressText } = await import('./windows-pty-table-stress-observer.mjs')
report('error', { message: sanitizeStressText(error.stack) })
process.exitCode = 1
})