mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* 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
169 lines
5.9 KiB
JavaScript
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
|
|
})
|