Files
rea/src/process/ProcessOutputTokenScanner.test.ts
T
morluto 91884c3743 fix(ghidra): own operation-scoped JVM failure evidence
Scan provider output before retention and keep bounded confirmed token facts
through output completion. Exclude identifier prefixes, pre-checkpoint context,
and unconfirmed tails; preserve invalid UTF-8 and directional truncation facts.

Make confirmed OOME observations authoritative for projection. Retained raw
prefixes cannot establish an OOME, and unrelated output cannot supply its
resource kind or recovery advice. Incomplete no-OOME output preserves the
primary error and explains uncertainty. Current checkpoint coverage remains
separate from historical budget loss.

Adapt the retention approach from #4774 with independently reproduced scanner
and projection counterexamples corrected before integration.

Validation: 268 focused tests, npm run check, npm run docs:check, and test
discovery (1639 files exactly once) pass. Controlled Node pipes/socket and fake
Ghidra JVM only; real Ghidra and native Linux/Windows behavior are unverified.

Fixes #4682
Fixes #5220

Co-authored-by: reason-machines[bot] <293529679+reason-machines[bot]@users.noreply.github.com>
Co-authored-by: Saim Shafique <msaimshafique4664@gmail.com>
2026-10-19 17:15:32 +08:00

238 lines
8.7 KiB
TypeScript

import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import {
PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES,
ProcessOutputTokenScanner,
} from "./ProcessOutputTokenScanner.js";
import { ProviderProcessSupervisor } from "./ProviderProcess.js";
const MARKER = "java.lang.OutOfMemoryError";
const OOME_LINE =
"ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError: Java heap space";
const cleanups: (() => void)[] = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
});
const supervised = (maxDiagnosticBytes = 64) => {
const producer = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
kill: () => false,
});
const supervisor = new ProviderProcessSupervisor(
{ process: producer, ownsProcessLifetime: false },
{ maxDiagnosticBytes, outputToken: MARKER },
);
cleanups.push(() => {
supervisor.dispose();
producer.stdout.destroy();
producer.stderr.destroy();
});
return { producer, supervisor };
};
describe("ProviderProcessSupervisor output marker", () => {
it("observes a marker printed after the retention budget is exhausted", () => {
const { producer, supervisor } = supervised();
const checkpoint = supervisor.checkpointOutput();
producer.stdout.write("x".repeat(200));
producer.stderr.write(`${OOME_LINE}\n\tat frame\n`);
const scoped = supervisor.snapshotOutputSince(checkpoint);
expect(scoped.complete).toBe(false);
expect(scoped.snapshot.stderr.text).toBe("");
expect(scoped.markers).toEqual([
{
stream: "stderr",
offset: OOME_LINE.indexOf(MARKER),
line: OOME_LINE,
truncated: false,
},
]);
});
it("detects a marker and its line split across chunks", () => {
const { producer, supervisor } = supervised(0);
const checkpoint = supervisor.checkpointOutput();
producer.stdout.write("progress\n");
const text = `${OOME_LINE}\nnext\n`;
const split = text.indexOf(MARKER) + 7;
producer.stdout.write(text.slice(0, 20));
producer.stdout.write(text.slice(20, split));
producer.stdout.write(text.slice(split, split + 10));
producer.stdout.write(text.slice(split + 10));
expect(supervisor.snapshotOutputSince(checkpoint).markers).toEqual([
{
stream: "stdout",
offset: "progress\n".length + OOME_LINE.indexOf(MARKER),
line: OOME_LINE,
truncated: false,
},
]);
});
it("does not attribute a marker from before the checkpoint", () => {
const { producer, supervisor } = supervised(0);
producer.stderr.write(`${OOME_LINE}\n`);
const checkpoint = supervisor.checkpointOutput();
producer.stderr.write("later unrelated failure\n");
expect(supervisor.snapshotOutputSince(checkpoint).markers).toBeUndefined();
});
it("forgets markers on reset and for checkpoints from another epoch", () => {
const { producer, supervisor } = supervised(0);
const stale = supervisor.checkpointOutput();
producer.stderr.write(`${OOME_LINE}\n`);
supervisor.resetOutput();
expect(supervisor.snapshotOutputSince(stale).markers).toBeUndefined();
const current = supervisor.checkpointOutput();
producer.stderr.write("after reset\n");
expect(supervisor.snapshotOutputSince(current).markers).toBeUndefined();
});
it("does not scan when no marker is configured", () => {
const producer = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
kill: () => false,
});
const supervisor = new ProviderProcessSupervisor({
process: producer,
ownsProcessLifetime: false,
});
cleanups.push(() => supervisor.dispose());
const checkpoint = supervisor.checkpointOutput();
producer.stderr.write(`${OOME_LINE}\n`);
expect(supervisor.snapshotOutputSince(checkpoint).markers).toBeUndefined();
});
});
describe("ProcessOutputTokenScanner", () => {
it("bounds the retained line and omits a prefix it could not keep whole", () => {
const scanner = new ProcessOutputTokenScanner(MARKER);
const prefix = "p".repeat(PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES * 3);
const suffix = `: ${"s".repeat(PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES * 3)}`;
scanner.scan(Buffer.from(prefix.slice(0, 1000)), 0);
scanner.scan(Buffer.from(prefix.slice(1000)), 1000);
scanner.scan(Buffer.from(`${MARKER}${suffix.slice(0, 50)}`), prefix.length);
scanner.scan(
Buffer.from(`${suffix.slice(50)}\n`),
prefix.length + MARKER.length + 50,
);
const observed = scanner.latestWithin(0, Number.MAX_SAFE_INTEGER);
expect(observed).toEqual({
offset: prefix.length,
line: `${MARKER}${suffix.slice(0, PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES)}`,
truncated: true,
trailingContextIncomplete: true,
});
});
it("keeps the latest of several markers", () => {
const scanner = new ProcessOutputTokenScanner(MARKER);
const text = `first ${MARKER}\nsecond ${MARKER}: Metaspace\n`;
scanner.scan(Buffer.from(text), 0);
expect(scanner.latestWithin(0, text.length)).toEqual({
offset: text.lastIndexOf(MARKER),
line: `second ${MARKER}: Metaspace`,
truncated: false,
});
});
it("does not re-record a marker carried in the line tail", () => {
const scanner = new ProcessOutputTokenScanner(MARKER);
scanner.scan(Buffer.from(`detail ${MARKER}`), 0);
scanner.scan(Buffer.from(": Java heap space"), `detail ${MARKER}`.length);
scanner.scan(Buffer.from("\n"), `detail ${MARKER}: Java heap space`.length);
expect(scanner.latestWithin(0, 100)).toEqual({
offset: "detail ".length,
line: `detail ${MARKER}: Java heap space`,
truncated: false,
});
});
});
it("keeps a confirmed token when a later identifier shares its literal prefix across every chunk split", () => {
const valid = `${OOME_LINE}\n`;
const invalid = `${MARKER}Custom: ordinary identifier\n`;
const text = Buffer.from(valid + invalid);
for (let split = 0; split <= text.length; split++) {
const scanner = new ProcessOutputTokenScanner(MARKER);
scanner.scan(text.subarray(0, split), 0);
scanner.scan(text.subarray(split), split);
scanner.finish();
expect(scanner.latestWithin(0, text.length)).toMatchObject({
line: OOME_LINE,
offset: OOME_LINE.indexOf(MARKER),
});
}
});
it("omits line context owned by an earlier operation even when the token starts at the checkpoint", () => {
const { producer, supervisor } = supervised(0);
producer.stderr.write("earlier operation: unable to create native thread; ");
const checkpoint = supervisor.checkpointOutput();
producer.stderr.write(`${MARKER}: Java heap space\n`);
expect(supervisor.snapshotOutputSince(checkpoint).markers).toEqual([
{
stream: "stderr",
offset: "earlier operation: unable to create native thread; ".length,
line: `${MARKER}: Java heap space`,
truncated: true,
},
]);
});
it("confirms a terminal bare token only at actual stream EOF", async () => {
const { producer, supervisor } = supervised(0);
const checkpoint = supervisor.checkpointOutput();
producer.stderr.write(MARKER);
expect(supervisor.snapshotOutputSince(checkpoint).markers).toBeUndefined();
const ended = new Promise<void>((resolve) =>
producer.stderr.once("end", resolve),
);
producer.stderr.end();
await ended;
expect(supervisor.snapshotOutputSince(checkpoint).markers).toMatchObject([
{ line: MARKER },
]);
});
it("does not promote a pending token when the stream closes without EOF", () => {
const { producer, supervisor } = supervised(0);
const checkpoint = supervisor.checkpointOutput();
producer.stderr.write(MARKER);
producer.stderr.destroy();
producer.emit("close", 1, null);
expect(supervisor.snapshotOutputSince(checkpoint).markers).toBeUndefined();
});
it("excludes a token whose prefix belongs to the earlier checkpoint range", () => {
const { producer, supervisor } = supervised(0);
producer.stderr.write(MARKER.slice(0, 10));
const checkpoint = supervisor.checkpointOutput();
producer.stderr.write(`${MARKER.slice(10)}: Java heap space\n`);
expect(supervisor.snapshotOutputSince(checkpoint).markers).toBeUndefined();
});
it("preserves invalid UTF-8 uncertainty in bounded token context", () => {
const scanner = new ProcessOutputTokenScanner(MARKER);
const bytes = Buffer.concat([
Buffer.from(`${MARKER}: `),
Buffer.from([0xff]),
Buffer.from("\n"),
]);
scanner.scan(bytes, 0);
expect(scanner.latestWithin(0, bytes.length)).toMatchObject({
offset: 0,
line: `${MARKER}: \ufffd`,
invalidUtf8: true,
});
});