[GH-ISSUE #5616] [Bug] PR #5587 removes compiler scratch roots before inherited-pipe children exit #3404

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

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

Before submitting

  • I searched 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

Scoped to the unmerged change in PR #5587, head b7c2646dcfd4e4b75d709be3b7d2a5ec70172130, applied to source base 57d64b6ba5ae0ac6f83569461244c8e4d94ccfd6. This is not a claim that current main has the behavior.

An injected fake compiler exits 0 after spawning one finite child with inherited stdout/stderr. The production Darwin ownership-reader compiler path uses a 1-second deadline. With the PR diff, preparation rejects at 6.006 seconds and removes its compiler scratch root; the child observes that root absent at its 9-second completion. Without the PR diff on the same base, preparation settles at 9.474 seconds, after output closure, and the child observes the root still present before cleanup.

Two independent source-boundary comparisons produced the same present/absent results. No real Swift compiler, native target, or packaged CLI was executed. The second native-UI compiler consumer below is source-derived, not separately reproduced.

Reproduction

Use an isolated checkout of the source base above with locked dependencies and pinned Node 24.18.0. Save this exact probe as .cache/codebase-audit/compiler-root-probe.mjs and run node --import tsx .cache/codebase-audit/compiler-root-probe.mjs first without the PR diff, then with gh pr diff 5587 applied. The tested diff SHA-256 was 63b671e19c1108b4671c47d7b3458475276d71cb19799f40646d140cf0fb3743; check the head before comparing. No build is needed.

The probe awaits the finite child's marker before removing its fixture and performs no PID-based signaling.

import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { createDarwinProcessRunTokenReader } from '../../src/process/DarwinProcessRunTokenReader.ts';

const fixture = await mkdtemp(join(tmpdir(), 'rea-compiler-root-replay-'));
const compiler = join(fixture, 'swiftc');
const finished = join(fixture, 'finished.json');
const childSource = `const fs = require('node:fs'); const path = require('node:path'); setTimeout(() => { fs.writeFileSync(${JSON.stringify(finished)}, JSON.stringify({ tempRootExists: fs.existsSync(path.dirname(process.env.TMPDIR)) })); process.exit(0); }, 9000);`;
await writeFile(compiler, `#!${process.execPath}\nconst { spawn } = require('node:child_process');\nspawn(process.execPath, ['-e', ${JSON.stringify(childSource)}], { stdio: 'inherit' });\nprocess.exit(0);\n`, { mode: 0o700 });
await chmod(compiler, 0o700);
const reader = createDarwinProcessRunTokenReader({
  compiler: { kind: 'swiftc', executable: compiler, sdkRoot: '/fake-sdk' },
  compileTimeoutMs: 1000,
  moduleCachePath: '',
});
const started = performance.now();
let failure;
let settledMs;
let observation;
try {
  try { await reader.prepare(); }
  catch (cause) { failure = { name: cause.name, message: cause.message }; }
  settledMs = performance.now() - started;
  for (let attempt = 0; attempt < 120; attempt++) {
    try { observation = JSON.parse(await readFile(finished, 'utf8')); break; }
    catch (cause) { if (cause.code !== 'ENOENT') throw cause; }
    await delay(100);
  }
  if (failure === undefined || observation === undefined) throw new Error('Probe did not exercise the controlled failure and finite descendant');
  console.log(JSON.stringify({ settledMs, completedMs: performance.now() - started, failure, ...observation }));
} finally {
  await reader.close();
  for (let attempt = 0; attempt < 120; attempt++) {
    try { await readFile(finished); break; }
    catch (cause) { if (cause.code !== 'ENOENT') throw cause; }
    await delay(100);
  }
  await rm(fixture, { recursive: true, force: true });
}

Expected behavior

Settling bounded output draining must not establish process-family completion. Retain the compiler scratch root and its cleanup capability until the exact launched compiler family is confirmed finished or stopped; report incomplete cleanup when that cannot be established. Do not broaden authority to unrelated processes.

This follows the existing lifecycle ownership rule.

Impact and scope

Observed: a test-owned live descendant outlasts the scratch root it inherited. Real Swift compiler impact, frequency, and severity are NOT_ENOUGH_INFO; the test establishes ordering only. No data loss or real target behavior was reproduced.

REA version

Source 57d64b6ba5ae0ac6f83569461244c8e4d94ccfd6 plus only PR #5587's two-file diff, head b7c2646dcfd4e4b75d709be3b7d2a5ec70172130. Current main checked during this report was a35623707e6245ef2ab4ffb8bd6bc0edae3f4ac8 and did not contain that diff.

Runtime and platform

Node 24.18.0; macOS arm64.

Target and artifact context

No analysis target. One temporary Node fake compiler and one finite inherited-pipe child. All fixtures are caller-owned; the original source checkout is unchanged.

Interface

Other — production source-boundary probe.

Earliest failure stage

Cleanup, cancellation, or lifecycle.

Relevant output or evidence

Root verifier results:

Source prepare settlement Child completion observation
Pinned baseline 9.474 s tempRootExists: true
Baseline + exact PR diff 6.006 s tempRootExists: false at 9.451 s

Both return DarwinProcessOwnershipInspectionError with compiler timeout. The PR destroys stdout/stderr at the end of its stop grace after signaling only the direct child. DarwinProcessRunTokenReader then removes operationRoot in the compilation catch path. The root-removal owner has no retained process-family cleanup capability.

Source-derived adjacent consumer: NativeUiHelperRuntime also runs its compiler with execFileOutput and removes its root after the operation settles. Its runtime behavior was not separately tested.

Typed outcome or recovery expected

Keep process completion and output-reader completion distinct. Preserve the primary timeout plus exact pending process/root owners until cleanup is confirmed or retryable; no unverified cleanup-success inference from closed readers.

Analysis or suspected cause

The direct compiler has already exited, so signaling it cannot establish descendant completion. PR #5587 bounds the helper promise by closing readers, while temp-owning consumers currently treat that settlement as permission to remove compiler files. This is distinct from #5557's Kuna path, already fixed using owned-command supervision.

The Darwin helper is itself a prerequisite for process-ownership inspection. A naive conversion of its compiler to runOwnedCommand may recurse into compilation of the same helper; a remedy needs a bootstrap-safe ownership rule.

Additional context

Related #4227. Dedupe: closed #1173/#1194 and open #5502 report leftover compiler storage, rather than this PR-only early-removal ordering. Closed #5202 concerns reader close racing admitted read/identity queries, not compiler descendants after output cutoff; #5199 concerns sampler ps shutdown. #2218/#2252 concern persistent cache placement. Open PR #5615 changes evaluation-command preparation/cleanup, not these compiler consumers. No exact active issue or fixing PR surfaced in the fresh searches.

Originally created by @morluto on GitHub (Oct 20, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5616 ### Before submitting - [x] I searched 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 **Scoped to the unmerged change in PR #5587**, head `b7c2646dcfd4e4b75d709be3b7d2a5ec70172130`, applied to source base `57d64b6ba5ae0ac6f83569461244c8e4d94ccfd6`. This is not a claim that current main has the behavior. An injected fake compiler exits 0 after spawning one finite child with inherited stdout/stderr. The production Darwin ownership-reader compiler path uses a 1-second deadline. With the PR diff, preparation rejects at 6.006 seconds and removes its compiler scratch root; the child observes that root absent at its 9-second completion. Without the PR diff on the same base, preparation settles at 9.474 seconds, after output closure, and the child observes the root still present before cleanup. Two independent source-boundary comparisons produced the same present/absent results. No real Swift compiler, native target, or packaged CLI was executed. The second native-UI compiler consumer below is source-derived, not separately reproduced. ### Reproduction Use an isolated checkout of the source base above with locked dependencies and pinned Node 24.18.0. Save this exact probe as `.cache/codebase-audit/compiler-root-probe.mjs` and run `node --import tsx .cache/codebase-audit/compiler-root-probe.mjs` first without the PR diff, then with `gh pr diff 5587` applied. The tested diff SHA-256 was `63b671e19c1108b4671c47d7b3458475276d71cb19799f40646d140cf0fb3743`; check the head before comparing. No build is needed. The probe awaits the finite child's marker before removing its fixture and performs no PID-based signaling. ```js import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { createDarwinProcessRunTokenReader } from '../../src/process/DarwinProcessRunTokenReader.ts'; const fixture = await mkdtemp(join(tmpdir(), 'rea-compiler-root-replay-')); const compiler = join(fixture, 'swiftc'); const finished = join(fixture, 'finished.json'); const childSource = `const fs = require('node:fs'); const path = require('node:path'); setTimeout(() => { fs.writeFileSync(${JSON.stringify(finished)}, JSON.stringify({ tempRootExists: fs.existsSync(path.dirname(process.env.TMPDIR)) })); process.exit(0); }, 9000);`; await writeFile(compiler, `#!${process.execPath}\nconst { spawn } = require('node:child_process');\nspawn(process.execPath, ['-e', ${JSON.stringify(childSource)}], { stdio: 'inherit' });\nprocess.exit(0);\n`, { mode: 0o700 }); await chmod(compiler, 0o700); const reader = createDarwinProcessRunTokenReader({ compiler: { kind: 'swiftc', executable: compiler, sdkRoot: '/fake-sdk' }, compileTimeoutMs: 1000, moduleCachePath: '', }); const started = performance.now(); let failure; let settledMs; let observation; try { try { await reader.prepare(); } catch (cause) { failure = { name: cause.name, message: cause.message }; } settledMs = performance.now() - started; for (let attempt = 0; attempt < 120; attempt++) { try { observation = JSON.parse(await readFile(finished, 'utf8')); break; } catch (cause) { if (cause.code !== 'ENOENT') throw cause; } await delay(100); } if (failure === undefined || observation === undefined) throw new Error('Probe did not exercise the controlled failure and finite descendant'); console.log(JSON.stringify({ settledMs, completedMs: performance.now() - started, failure, ...observation })); } finally { await reader.close(); for (let attempt = 0; attempt < 120; attempt++) { try { await readFile(finished); break; } catch (cause) { if (cause.code !== 'ENOENT') throw cause; } await delay(100); } await rm(fixture, { recursive: true, force: true }); } ``` ### Expected behavior Settling bounded output draining must not establish process-family completion. Retain the compiler scratch root and its cleanup capability until the exact launched compiler family is confirmed finished or stopped; report incomplete cleanup when that cannot be established. Do not broaden authority to unrelated processes. This follows the existing [lifecycle ownership rule](https://github.com/morluto/rea/blob/57d64b6ba5ae0ac6f83569461244c8e4d94ccfd6/docs/tool-design.md#own-the-lifecycle). ### Impact and scope Observed: a test-owned live descendant outlasts the scratch root it inherited. Real Swift compiler impact, frequency, and severity are `NOT_ENOUGH_INFO`; the test establishes ordering only. No data loss or real target behavior was reproduced. ### REA version Source `57d64b6ba5ae0ac6f83569461244c8e4d94ccfd6` plus only PR #5587's two-file diff, head `b7c2646dcfd4e4b75d709be3b7d2a5ec70172130`. Current main checked during this report was `a35623707e6245ef2ab4ffb8bd6bc0edae3f4ac8` and did not contain that diff. ### Runtime and platform Node 24.18.0; macOS arm64. ### Target and artifact context No analysis target. One temporary Node fake compiler and one finite inherited-pipe child. All fixtures are caller-owned; the original source checkout is unchanged. ### Interface Other — production source-boundary probe. ### Earliest failure stage Cleanup, cancellation, or lifecycle. ### Relevant output or evidence Root verifier results: | Source | prepare settlement | Child completion observation | |---|---:|---| | Pinned baseline | 9.474 s | `tempRootExists: true` | | Baseline + exact PR diff | 6.006 s | `tempRootExists: false` at 9.451 s | Both return `DarwinProcessOwnershipInspectionError` with compiler timeout. The PR destroys stdout/stderr at the end of its stop grace after signaling only the direct child. [DarwinProcessRunTokenReader](https://github.com/morluto/rea/blob/57d64b6ba5ae0ac6f83569461244c8e4d94ccfd6/src/process/DarwinProcessRunTokenReader.ts#L474-L523) then removes `operationRoot` in the compilation catch path. The root-removal owner has no retained process-family cleanup capability. Source-derived adjacent consumer: [NativeUiHelperRuntime](https://github.com/morluto/rea/blob/57d64b6ba5ae0ac6f83569461244c8e4d94ccfd6/src/native/NativeUiHelperRuntime.ts#L32-L77) also runs its compiler with `execFileOutput` and removes its root after the operation settles. Its runtime behavior was not separately tested. ### Typed outcome or recovery expected Keep process completion and output-reader completion distinct. Preserve the primary timeout plus exact pending process/root owners until cleanup is confirmed or retryable; no unverified cleanup-success inference from closed readers. ### Analysis or suspected cause The direct compiler has already exited, so signaling it cannot establish descendant completion. PR #5587 bounds the helper promise by closing readers, while temp-owning consumers currently treat that settlement as permission to remove compiler files. This is distinct from #5557's Kuna path, already fixed using owned-command supervision. The Darwin helper is itself a prerequisite for process-ownership inspection. A naive conversion of its compiler to `runOwnedCommand` may recurse into compilation of the same helper; a remedy needs a bootstrap-safe ownership rule. ### Additional context Related #4227. Dedupe: closed #1173/#1194 and open #5502 report leftover compiler storage, rather than this PR-only early-removal ordering. Closed #5202 concerns reader close racing admitted read/identity queries, not compiler descendants after output cutoff; #5199 concerns sampler `ps` shutdown. #2218/#2252 concern persistent cache placement. Open PR #5615 changes evaluation-command preparation/cleanup, not these compiler consumers. No exact active issue or fixing PR surfaced in the fresh searches.
bsakaguchi 2026-10-31 00:40:00 +00:00
  • closed this issue
  • added the
    triage
    bug
    labels
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#3404
No description provided.