[GH-ISSUE #6443] [Bug] capture-process leaves sampled descendant PIDs numeric in normalized terminal text #3857

Closed
opened 2026-10-31 00:44:41 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 23, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6443

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Process capture or runtime observation

Observed behavior

With normalization.pids: true, capture-process replaces the root PID with <pid> but retains a sampled child's numeric PID in frames[].data, rendered lines, and serialized_state. Two captures of the same Node fixture therefore report terminal: changed solely because the child's PID changes, even though its numeric process-sample identity is normalized to 3 in both captures.

The behavior occurred in four of four normalized CLI captures. A further control with pids: false correctly preserved both raw PIDs.

Reproduction

From a fresh checkout at the reported baseline, install the locked dependencies and build:

git checkout 9eb829aca34be36ff112826337996f6139f82eff
npm ci && npm run build:cached

The following fixture and commands reproduce the observed case through the public CLI. A 10000 ms time bucket removes timestamp variation from this short fixture. Native process-capture prerequisites must be available.

WORK=$(mktemp -d)
mkdir -p "$WORK/home" "$WORK/run"
cat > "$WORK/run/parent.js" <<'JS'
const { spawn } = require('node:child_process');
const child = spawn(process.execPath,
  ['-e', 'setTimeout(() => {}, 300)'], { stdio: 'inherit' });
console.log('root pid', process.pid);
console.log('child pid', child.pid);
setTimeout(() => {}, 700);
JS
WORK="$WORK" node <<'JS'
const fs = require('node:fs');
const work = process.env.WORK;
fs.writeFileSync(`${work}/scenario.json`, JSON.stringify({
  executable: 'node',
  arguments: ['parent.js'],
  working_directory: `${work}/run`,
  timeout_ms: 5000,
  settle_ms: 300,
  normalization: { pids: true, time_bucket_ms: 10000 }
}));
JS
for run in 1 2; do
  HOME="$WORK/home" node scripts/rea.mjs capture-process \
    "$WORK/scenario.json" --format json > "$WORK/capture-$run.json"
done
HOME="$WORK/home" node scripts/rea.mjs compare-process-captures \
  "$WORK/capture-1.json" "$WORK/capture-2.json" --format json \
  > "$WORK/comparison.json"
WORK="$WORK" node <<'JS'
const fs = require('node:fs');
const work = process.env.WORK;
const read = name => JSON.parse(fs.readFileSync(`${work}/${name}.json`)).normalized_result;
for (const name of ['capture-1', 'capture-2']) {
  const c = read(name);
  console.log(name, JSON.stringify({
    frames: c.frames,
    rendered_lines: c.rendered_frames.map(f => f.lines.filter(Boolean)),
    samples: c.process_samples,
    exit: c.exit
  }));
}
console.log('terminal:', read('comparison').terminal);
JS

Two runs with defaults and two with the larger time bucket were executed using the already built baseline. The dependency-install/build bootstrap above was not rerun during this reproduction. Frame chunk boundaries can vary by run; inspect the retained child number independently of frame count.

Expected behavior

For an observed child PID, the selected PID normalization should produce a stable terminal token as it does for the root. Preserve the original text and the behavior of pids: false.

The terminal contract defines data, rendered lines, and terminal serialization as normalized representations. The scenario schema defaults PID normalization to true. The existing sample policy includes observed descendant identifiers, both as normalized numeric identities and as <pid> tokens in command text. The docs do not explicitly describe descendant-specific terminal handling; this expectation follows from those combined contracts and the normalized comparison acceptance criterion in #61.

The terminal comparison should be unchanged when the observed PID is its only difference. Other dimensions may still differ or remain unknown.

Impact and scope

P2: ephemeral descendant identifiers create consequential false terminal differences for otherwise identical captured output. The evidence remains inspectable. The reproduced scope is the native CLI capture/comparison path on macOS; MCP and other platforms were not exercised. Process sampling was incomplete overall, but the child was positively sampled in each normalized capture.

No general fixture-preserving workaround was demonstrated. Custom patterns accept literal strings rather than regular expressions, so they cannot directly describe an unknown child PID across runs. Disabling PID normalization preserves the run-specific numbers.

REA version

6.7.0 (source 9eb829aca3)

Runtime and platform

Node.js v26.12.1; macOS 27.0.1; arm64; native node-pty backend.

Target and artifact context

Local Node parent that prints its own PID and a child PID, with both processes exiting normally. Returned process-capture Evidence and process-comparison Evidence; no external service or analysis provider.

Interface

CLI

Earliest failure stage

Capture or observation

Relevant output or evidence

The two larger-time-bucket captures returned these frame texts, all at at_ms: 0:

capture 1: root pid <pid>\r\n
           child pid 49158\r\n
capture 2: root pid <pid>\r\n
           child pid 50003\r\n

Both captures had two raw frames and two rendered frames. The final visible lines retained those respective child numbers. In both, the child process sample used pid: 3, parent_pid: 1, and the target exited with code 0 and reason exited.

The supported CLI comparison returned:

{"status":"changed","terminal":"changed","interaction":"unchanged","exit":"changed","filesystem":"unknown","process":"unknown"}

A read-only comparison of the actual frames (excluding original raw_data) and complete rendered_frames established that replacing only the respective child numbers makes those terminal projections identical. This isolates the PID defect from frame timestamps, ordering, and terminal serialization. The separate changed exit dimension is outside this report.

Typed outcome or recovery expected

Normalize observed descendant PID occurrences consistently in terminal comparison text while retaining original PTY bytes. Continue normalizing the root PID, respecting output budgets, preserving ordinary numbers, and preserving both identifiers when PID normalization is disabled. This report does not require discovery of unsampled short-lived descendants.

Analysis or suspected cause

Source-derived: the production harness passes only terminal.pid to the shared bounded terminal normalizer. Its PID stage builds a matcher from that single PID. The separate sample projection includes all sampled identifiers, but those identifiers never reach the terminal normalizer.

Additional context

Related closed #171 concerns child PID occurrences in sampled command text; that output already uses descendant-aware normalization at this baseline. This report concerns terminal observations. #703 concerns disabled normalization of numeric sample fields; the disabled control passed here. #4003 concerns endpoint-port normalization, a distinct matcher and trigger.

Last known good version: NOT_ENOUGH_INFO; no regression boundary was established.

Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6443 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Process capture or runtime observation ### Observed behavior With `normalization.pids: true`, `capture-process` replaces the root PID with `<pid>` but retains a sampled child's numeric PID in `frames[].data`, rendered `lines`, and `serialized_state`. Two captures of the same Node fixture therefore report `terminal: changed` solely because the child's PID changes, even though its numeric process-sample identity is normalized to `3` in both captures. The behavior occurred in four of four normalized CLI captures. A further control with `pids: false` correctly preserved both raw PIDs. ### Reproduction From a fresh checkout at the reported baseline, install the locked dependencies and build: ```sh git checkout 9eb829aca34be36ff112826337996f6139f82eff npm ci && npm run build:cached ``` The following fixture and commands reproduce the observed case through the public CLI. A 10000 ms time bucket removes timestamp variation from this short fixture. Native process-capture prerequisites must be available. ```sh WORK=$(mktemp -d) mkdir -p "$WORK/home" "$WORK/run" cat > "$WORK/run/parent.js" <<'JS' const { spawn } = require('node:child_process'); const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 300)'], { stdio: 'inherit' }); console.log('root pid', process.pid); console.log('child pid', child.pid); setTimeout(() => {}, 700); JS WORK="$WORK" node <<'JS' const fs = require('node:fs'); const work = process.env.WORK; fs.writeFileSync(`${work}/scenario.json`, JSON.stringify({ executable: 'node', arguments: ['parent.js'], working_directory: `${work}/run`, timeout_ms: 5000, settle_ms: 300, normalization: { pids: true, time_bucket_ms: 10000 } })); JS for run in 1 2; do HOME="$WORK/home" node scripts/rea.mjs capture-process \ "$WORK/scenario.json" --format json > "$WORK/capture-$run.json" done HOME="$WORK/home" node scripts/rea.mjs compare-process-captures \ "$WORK/capture-1.json" "$WORK/capture-2.json" --format json \ > "$WORK/comparison.json" WORK="$WORK" node <<'JS' const fs = require('node:fs'); const work = process.env.WORK; const read = name => JSON.parse(fs.readFileSync(`${work}/${name}.json`)).normalized_result; for (const name of ['capture-1', 'capture-2']) { const c = read(name); console.log(name, JSON.stringify({ frames: c.frames, rendered_lines: c.rendered_frames.map(f => f.lines.filter(Boolean)), samples: c.process_samples, exit: c.exit })); } console.log('terminal:', read('comparison').terminal); JS ``` Two runs with defaults and two with the larger time bucket were executed using the already built baseline. The dependency-install/build bootstrap above was not rerun during this reproduction. Frame chunk boundaries can vary by run; inspect the retained child number independently of frame count. ### Expected behavior For an observed child PID, the selected PID normalization should produce a stable terminal token as it does for the root. Preserve the original text and the behavior of `pids: false`. [The terminal contract](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/docs/process-capture.md#L264) defines `data`, rendered `lines`, and terminal serialization as normalized representations. [The scenario schema](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/src/domain/process/processScenario.ts#L220) defaults PID normalization to true. [The existing sample policy](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/src/process/capture/ProcessNormalization.ts#L382) includes observed descendant identifiers, both as normalized numeric identities and as `<pid>` tokens in command text. The docs do not explicitly describe descendant-specific terminal handling; this expectation follows from those combined contracts and the normalized comparison acceptance criterion in [#61](https://github.com/morluto/rea/issues/61). The terminal comparison should be unchanged when the observed PID is its only difference. Other dimensions may still differ or remain unknown. ### Impact and scope P2: ephemeral descendant identifiers create consequential false terminal differences for otherwise identical captured output. The evidence remains inspectable. The reproduced scope is the native CLI capture/comparison path on macOS; MCP and other platforms were not exercised. Process sampling was incomplete overall, but the child was positively sampled in each normalized capture. No general fixture-preserving workaround was demonstrated. Custom patterns accept literal strings rather than regular expressions, so they cannot directly describe an unknown child PID across runs. Disabling PID normalization preserves the run-specific numbers. ### REA version 6.7.0 (source 9eb829aca34be36ff112826337996f6139f82eff) ### Runtime and platform Node.js v26.12.1; macOS 27.0.1; arm64; native node-pty backend. ### Target and artifact context Local Node parent that prints its own PID and a child PID, with both processes exiting normally. Returned process-capture Evidence and process-comparison Evidence; no external service or analysis provider. ### Interface CLI ### Earliest failure stage Capture or observation ### Relevant output or evidence The two larger-time-bucket captures returned these frame texts, all at `at_ms: 0`: ```text capture 1: root pid <pid>\r\n child pid 49158\r\n capture 2: root pid <pid>\r\n child pid 50003\r\n ``` Both captures had two raw frames and two rendered frames. The final visible lines retained those respective child numbers. In both, the child process sample used `pid: 3`, `parent_pid: 1`, and the target exited with code 0 and reason `exited`. The supported CLI comparison returned: ```json {"status":"changed","terminal":"changed","interaction":"unchanged","exit":"changed","filesystem":"unknown","process":"unknown"} ``` A read-only comparison of the actual `frames` (excluding original `raw_data`) and complete `rendered_frames` established that replacing only the respective child numbers makes those terminal projections identical. This isolates the PID defect from frame timestamps, ordering, and terminal serialization. The separate changed exit dimension is outside this report. ### Typed outcome or recovery expected Normalize observed descendant PID occurrences consistently in terminal comparison text while retaining original PTY bytes. Continue normalizing the root PID, respecting output budgets, preserving ordinary numbers, and preserving both identifiers when PID normalization is disabled. This report does not require discovery of unsampled short-lived descendants. ### Analysis or suspected cause Source-derived: [the production harness](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/src/process/capture/ProcessHarness.ts#L291) passes only `terminal.pid` to the shared bounded terminal normalizer. [Its PID stage](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/src/process/capture/ProcessNormalization.ts#L238) builds a matcher from that single PID. The separate sample projection includes all sampled identifiers, but those identifiers never reach the terminal normalizer. ### Additional context Related closed [#171](https://github.com/morluto/rea/issues/171) concerns child PID occurrences in sampled command text; that output already uses descendant-aware normalization at this baseline. This report concerns terminal observations. [#703](https://github.com/morluto/rea/pull/703) concerns disabled normalization of numeric sample fields; the disabled control passed here. [#4003](https://github.com/morluto/rea/issues/4003) concerns endpoint-port normalization, a distinct matcher and trigger. Last known good version: NOT_ENOUGH_INFO; no regression boundary was established.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3857
No description provided.