fix(runtime): terminate nonblank tail scan at the first row

This commit is contained in:
m4air
2026-09-16 02:45:18 -07:00
parent 9558152a04
commit 28b4c1b8a5
5 changed files with 523 additions and 1 deletions
@@ -0,0 +1,23 @@
# Terminal wait tail-window termination
`startOfLastNonBlankLines` loops indefinitely if its input begins with a newline and contains fewer nonblank rows than requested. Once the backward cursor reaches zero, JavaScript `lastIndexOf` clamps its negative start position to zero and rediscovers the same first newline. The cursor stops advancing.
The fix ends the scan when the cursor reaches zero and returns the existing short-tail offset, zero. It changes no prompt patterns or readiness rules. The ordinary finite-window selection cases retain their previous offsets.
## Reproduce
```sh
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/terminal-wait-leading-blank/reproduce.mjs > /tmp/orca-terminal-wait-leading-blank.json
```
The script bundles actual source and reverses only the two-line termination change for the baseline. Each case runs in an isolated child with a two-second deadline and 128 MiB heap limit. The parent confirms child termination; no app windows open. Five direct helper/detector cases time out before and return after. A sufficient-row control and actual headless terminal projection controls pass in both variants. Results include source hashes, platform, timing, and an explicit v1.4.198 helper comparison.
The regression suite uses a separate child for inputs that could hang the worker. It also tests exact row offsets with intervening whitespace, trailing blanks and tails shorter than the requested window. Fifty helper/detector tests pass.
## Production and incident limits
The helper is byte-identical in v1.4.198. However, all inspected main/provider/renderer visible-screen projection routes pass through `visibleNonBlankTerminalLines`, and ordinary retained-tail construction removes blank rows too. The real headless producer control confirms this filtering. Calling the public detector directly with a leading newline is therefore insufficient evidence that those production routes trigger the defect.
A clipped 300-character preview can start at a newline. The preview fallback also preserves it when passed empty retained rows; the proof records both facts. It does not establish an actual application lifecycle that combines that fallback with a live detector call. That remains unproven.
This is a defensive termination fix found during the memory audit. The loop itself does not allocate a growing collection. No memory magnitude was measured, and it is not an attribution of #19768's main-process growth or #19831's application-scope OOM.
@@ -0,0 +1,249 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { isDeepStrictEqual } from 'node:util'
import { build } from 'esbuild'
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
}
const root = fileURLToPath(new URL('../../../', import.meta.url))
const sourcePath = 'src/main/runtime/terminal-wait-tail-window.ts'
const absoluteSource = resolve(root, sourcePath)
const current = await readFile(absoluteSource, 'utf8')
const loop = ' while (lineEnd > 0) {'
const end = ' lineEnd = lineStart - 1\n }\n return 0\n}'
if (current.split(loop).length !== 2 || current.split(end).length !== 2) {
throw new Error('Source changed; review the baseline transform.')
}
const baseline = current
.replace(loop, ' for (;;) {')
.replace(end, ' lineEnd = lineStart - 1\n }\n}')
const sha256 = (value) => createHash('sha256').update(value).digest('hex')
const supportingSources = [
'src/main/runtime/terminal-wait-detection.ts',
'src/main/runtime/orca-runtime-terminal-projection.ts',
'src/main/runtime/terminal-tail-read.ts',
'src/main/runtime/terminal-tail-state.ts',
'src/main/runtime/terminal-wait-tail-state.ts',
'src/main/daemon/headless-emulator.ts',
'src/main/runtime/terminal-wait-tail-window.test.ts'
]
const supportingSourceHashes = Object.fromEntries(
await Promise.all(
supportingSources.map(async (path) => [path, sha256(await readFile(resolve(root, path)))])
)
)
const scratch = await mkdtemp(join(tmpdir(), 'orca-terminal-wait-blank-'))
const require = createRequire(import.meta.url)
let runnerId
try {
const runnerPath = join(scratch, 'run-process.cjs')
await build({
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
outfile: runnerPath,
bundle: true,
platform: 'node',
format: 'cjs',
logLevel: 'silent'
})
runnerId = require.resolve(runnerPath)
const { runProcess } = require(runnerId)
const entry = `
import { startOfLastNonBlankLines } from './src/main/runtime/terminal-wait-tail-window';
import { detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './src/main/runtime/terminal-wait-detection';
import { HeadlessEmulator } from './src/main/daemon/headless-emulator';
import { projectTerminalVisibleLines, projectTerminalTailLines } from './src/main/runtime/orca-runtime-terminal-projection';
import { buildPreview } from './src/main/runtime/terminal-tail-state';
import { buildTerminalWaitText } from './src/main/runtime/terminal-wait-tail-state';
const input = JSON.parse(process.argv[2]);
async function main() {
process.stdout.write(JSON.stringify({ phase: 'entered', mode: input.mode }) + '\\n');
let value;
if (input.mode === 'window') value = startOfLastNonBlankLines(input.text, input.count);
if (input.mode === 'blocked') value = detectTerminalWaitBlockedReason(input.text);
if (input.mode === 'ready') value = isKnownReadyPromptPreview(input.text);
if (input.mode === 'producer-controls') {
const emulator = new HeadlessEmulator({ cols: 80, rows: 12, scrollback: 0 });
try {
await emulator.write('\\r\\nordinary output\\r\\n');
const raw = emulator.getVisibleLines();
const visible = projectTerminalVisibleLines(emulator).lines;
const tail = projectTerminalTailLines(emulator, 12).lines;
const longLines = ['prefix', 'x'.repeat(299)];
const preview = buildPreview(longLines, '');
const waitText = buildTerminalWaitText(longLines, '', preview);
value = {
rawRowsStartBlank: raw[0] === '',
visibleRows: visible,
projectedTail: tail,
visibleClassification: detectTerminalWaitBlockedReason(visible.join('\\n')),
ordinaryTailClassification: detectTerminalWaitBlockedReason(buildTerminalWaitText(raw, '', '')),
clippedPreviewStartsNewline: preview.startsWith('\\n'),
retainedTailStartsNewline: waitText.startsWith('\\n'),
retainedTailClassification: detectTerminalWaitBlockedReason(waitText),
emptyTailFallbackStartsNewline: buildTerminalWaitText([], '', preview).startsWith('\\n')
};
} finally { emulator.dispose(); }
}
process.stdout.write(JSON.stringify({ phase: 'returned', value }) + '\\n');
}
main().catch(error => { process.stderr.write(String(error)); process.exitCode = 1; });
`
const cases = [
{ name: 'leading newline only', mode: 'window', text: '\n', count: 12, expected: 0 },
{ name: 'leading newline and text', mode: 'window', text: '\ntext', count: 12, expected: 0 },
{ name: 'blank screen classification', mode: 'blocked', text: '\n\n', expected: null },
{
name: 'leading blank trust dialog',
mode: 'blocked',
text: '\nDo you trust this workspace directory?\n1. Yes\n2. No',
expected: 'agent-trust-workspace'
},
{
name: 'leading blank ready header',
mode: 'ready',
text: '\nOpenAI Codex\nmodel: test\ndirectory: /workspace',
expected: true
},
{
name: 'enough nonblank rows',
mode: 'window',
text: '\nfirst\nsecond',
count: 1,
expected: 7
},
{
name: 'production producer controls',
mode: 'producer-controls',
expected: {
rawRowsStartBlank: true,
visibleRows: ['ordinary output'],
projectedTail: ['ordinary output'],
visibleClassification: null,
ordinaryTailClassification: null,
clippedPreviewStartsNewline: true,
retainedTailStartsNewline: false,
retainedTailClassification: null,
emptyTailFallbackStartsNewline: true
}
}
]
const results = {}
for (const [label, source] of Object.entries({ before: baseline, after: current })) {
const childPath = join(scratch, `${label}.cjs`)
await build({
stdin: { contents: entry, resolveDir: root },
outfile: childPath,
bundle: true,
platform: 'node',
format: 'cjs',
logLevel: 'silent',
plugins: [
{
name: 'terminal-wait-baseline',
setup(builder) {
builder.onLoad({ filter: /terminal-wait-tail-window\.ts$/ }, (args) =>
resolve(args.path) === absoluteSource ? { contents: source, loader: 'ts' } : null
)
}
}
]
})
results[label] = []
for (const input of cases) {
let childTerminated = false
const started = performance.now()
const result = await runProcess({
program: process.execPath,
args: ['--max-old-space-size=128', childPath, JSON.stringify(input)],
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: 2_000,
maxOutputBytes: 8192,
onChildTerminated: () => {
childTerminated = true
}
})
const output = result.stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line))
const expectedTimeout = label === 'before' && cases.indexOf(input) < 5
const returned = output.find((event) => event.phase === 'returned')
if (
!childTerminated ||
result.timedOut !== expectedTimeout ||
!output.some((event) => event.phase === 'entered')
) {
throw new Error(`Unexpected ${label} result for ${input.name}: ${JSON.stringify(result)}`)
}
if (
!expectedTimeout &&
(result.code !== 0 ||
!returned ||
('expected' in input && !isDeepStrictEqual(returned.value, input.expected)))
) {
throw new Error(
`Unexpected ${label} output for ${input.name}: ${result.stdout} ${result.stderr}`
)
}
results[label].push({
name: input.name,
timedOut: result.timedOut,
childTerminated,
code: result.code,
signal: result.signal,
elapsedMs: Math.round(performance.now() - started),
...(returned ? { value: returned.value } : {})
})
}
}
const tag = await runProcess({
program: 'git',
args: ['show', `v1.4.198:${sourcePath}`],
cwd: root,
maxOutputBytes: 16_384
})
const provenance = await runProcess({
program: 'git',
args: ['rev-parse', 'HEAD'],
cwd: root,
maxOutputBytes: 1024
})
process.stdout.write(
`${JSON.stringify(
{
node: process.version,
platform: process.platform,
architecture: process.arch,
revision: provenance.stdout.trim(),
source: sourcePath,
hashes: {
before: sha256(baseline),
after: sha256(current),
reportedVersion: tag.code === 0 ? sha256(tag.stdout) : null
},
supportingSourceHashes,
reportedVersionSourceMatchesBaseline: tag.code === 0 && tag.stdout === baseline,
childTimeoutMs: 2000,
childHeapLimitMiB: 128,
scope:
'Helper termination and producer controls; no incident attribution or retained-byte claim.',
results
},
null,
2
)}\n`
)
} finally {
if (runnerId) {
delete require.cache[runnerId]
}
await rm(scratch, { recursive: true, force: true })
}
@@ -0,0 +1,172 @@
{
"node": "v26.6.0",
"platform": "darwin",
"architecture": "arm64",
"revision": "9558152a04c499c666192dd08906be8ada09e1dc",
"source": "src/main/runtime/terminal-wait-tail-window.ts",
"hashes": {
"before": "e6bc10e924d45bf4bfbd6fabf4404f160107cf7235b11c4e140cd6c78cff9146",
"after": "8df53ea8f92461549d727f251dae7cb4415d4208228ec5f60df9dcf13b863b86",
"reportedVersion": "e6bc10e924d45bf4bfbd6fabf4404f160107cf7235b11c4e140cd6c78cff9146"
},
"supportingSourceHashes": {
"src/main/runtime/terminal-wait-detection.ts": "226d09d2d8f7f1d692fb8ba1ed340818e6bd402b74d7bd98d86b1868a09503f0",
"src/main/runtime/orca-runtime-terminal-projection.ts": "70b815cf26864719b30b64845c0035093c16b9d85cb4bb8d25c4ae5fa63c3026",
"src/main/runtime/terminal-tail-read.ts": "3517bb22b9bf4bdf2f3acee8ceac9ca71221964cfa3f16be1f7b2ad22212b476",
"src/main/runtime/terminal-tail-state.ts": "a6f41a683d5f03023e4f6d20d653ebf069b036f82100c908a775a1890c49b2ef",
"src/main/runtime/terminal-wait-tail-state.ts": "61acc15fb8b9ce7faa0a2df80a7b6bae09dfed103c15b9c6c35a66d6ad585db1",
"src/main/daemon/headless-emulator.ts": "ace96102285c3967cd9544e7696fe73e1ac6df576b476939aa9819f5c298bab6",
"src/main/runtime/terminal-wait-tail-window.test.ts": "0faab1ca7b01e43ab555fd7fe2979975b818c22f25eca4f74dea3eb04f02c66b"
},
"reportedVersionSourceMatchesBaseline": true,
"childTimeoutMs": 2000,
"childHeapLimitMiB": 128,
"scope": "Helper termination and producer controls; no incident attribution or retained-byte claim.",
"results": {
"before": [
{
"name": "leading newline only",
"timedOut": true,
"childTerminated": true,
"code": null,
"signal": "SIGTERM",
"elapsedMs": 2004
},
{
"name": "leading newline and text",
"timedOut": true,
"childTerminated": true,
"code": null,
"signal": "SIGTERM",
"elapsedMs": 2002
},
{
"name": "blank screen classification",
"timedOut": true,
"childTerminated": true,
"code": null,
"signal": "SIGTERM",
"elapsedMs": 2002
},
{
"name": "leading blank trust dialog",
"timedOut": true,
"childTerminated": true,
"code": null,
"signal": "SIGTERM",
"elapsedMs": 2002
},
{
"name": "leading blank ready header",
"timedOut": true,
"childTerminated": true,
"code": null,
"signal": "SIGTERM",
"elapsedMs": 2002
},
{
"name": "enough nonblank rows",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 32,
"value": 7
},
{
"name": "production producer controls",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 36,
"value": {
"rawRowsStartBlank": true,
"visibleRows": ["ordinary output"],
"projectedTail": ["ordinary output"],
"visibleClassification": null,
"ordinaryTailClassification": null,
"clippedPreviewStartsNewline": true,
"retainedTailStartsNewline": false,
"retainedTailClassification": null,
"emptyTailFallbackStartsNewline": true
}
}
],
"after": [
{
"name": "leading newline only",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 34,
"value": 0
},
{
"name": "leading newline and text",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 33,
"value": 0
},
{
"name": "blank screen classification",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 33,
"value": null
},
{
"name": "leading blank trust dialog",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 33,
"value": "agent-trust-workspace"
},
{
"name": "leading blank ready header",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 33,
"value": true
},
{
"name": "enough nonblank rows",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 32,
"value": 7
},
{
"name": "production producer controls",
"timedOut": false,
"childTerminated": true,
"code": 0,
"signal": null,
"elapsedMs": 37,
"value": {
"rawRowsStartBlank": true,
"visibleRows": ["ordinary output"],
"projectedTail": ["ordinary output"],
"visibleClassification": null,
"ordinaryTailClassification": null,
"clippedPreviewStartsNewline": true,
"retainedTailStartsNewline": false,
"retainedTailClassification": null,
"emptyTailFallbackStartsNewline": true
}
}
]
}
}
@@ -0,0 +1,77 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { build } from 'esbuild'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runProcess } from '../../shared/child-process/run-process'
import { startOfLastNonBlankLines } from './terminal-wait-tail-window'
let scratch = ''
let childPath = ''
beforeAll(async () => {
scratch = await mkdtemp(join(tmpdir(), 'orca-tail-window-'))
childPath = join(scratch, 'leading-blank.cjs')
await build({
stdin: {
contents: `
import { startOfLastNonBlankLines } from './src/main/runtime/terminal-wait-tail-window';
import { detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './src/main/runtime/terminal-wait-detection';
const tails = ['', '\\n', '\\n\\n', '\\ntext', '\\ntext\\n', '\\n \\t\\ntext\\n\\n'];
process.stdout.write(JSON.stringify({
offsets: tails.map(value => startOfLastNonBlankLines(value, 12)),
blank: detectTerminalWaitBlockedReason('\\n\\n'),
ordinary: detectTerminalWaitBlockedReason('\\nordinary output'),
blocked: detectTerminalWaitBlockedReason('\\nDo you trust this workspace directory?\\n1. Yes\\n2. No'),
ready: isKnownReadyPromptPreview('\\nOpenAI Codex\\nmodel: test\\ndirectory: /workspace')
}));
`,
resolveDir: process.cwd()
},
outfile: childPath,
bundle: true,
platform: 'node',
format: 'cjs',
logLevel: 'silent'
})
})
afterAll(async () => {
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
describe('terminal wait nonblank tail window', () => {
it('terminates on leading blank rows before classifying the remaining screen', async () => {
// Isolate the synchronous regression so its timeout cannot block the test worker.
const result = await runProcess({
program: process.execPath,
args: ['--max-old-space-size=64', childPath],
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: 2_000,
maxOutputBytes: 4096
})
expect(result.timedOut).toBe(false)
expect(result.code, result.stderr).toBe(0)
expect(JSON.parse(result.stdout)).toEqual({
offsets: [0, 0, 0, 0, 0, 0],
blank: null,
ordinary: null,
blocked: 'agent-trust-workspace',
ready: true
})
})
it.each([
{ value: 'first\nsecond\nthird', count: 2, expected: 'second\nthird' },
{ value: 'first\n\n \t\nsecond\nthird\n', count: 2, expected: 'second\nthird\n' },
{ value: '\nfirst\nsecond', count: 1, expected: 'second' },
{ value: '\nfirst\nsecond', count: 2, expected: 'first\nsecond' },
{ value: 'first\nsecond', count: 3, expected: 'first\nsecond' },
{ value: 'first\nsecond\n\n', count: 1, expected: 'second\n\n' },
{ value: ' \t\r\nsecond', count: 2, expected: ' \t\r\nsecond' }
])('selects the last $count nonblank rows of $value', ({ value, count, expected }) => {
expect(value.slice(startOfLastNonBlankLines(value, count))).toBe(expected)
})
})
@@ -23,7 +23,7 @@ export function startOfLastLines(value: string, count: number): number {
export function startOfLastNonBlankLines(value: string, count: number): number {
let seen = 0
let lineEnd = value.length
for (;;) {
while (lineEnd > 0) {
const lineStart = value.lastIndexOf('\n', lineEnd - 1) + 1
if (hasNonWhitespaceBetween(value, lineStart, lineEnd)) {
seen += 1
@@ -36,6 +36,7 @@ export function startOfLastNonBlankLines(value: string, count: number): number {
}
lineEnd = lineStart - 1
}
return 0
}
function hasNonWhitespaceBetween(value: string, start: number, end: number): boolean {