[GH-ISSUE #6057] [Bug] Asset catalog cancellation settles before its child process exits #3702

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

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

Before submitting

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

Area

Evidence, artifacts, or reconstruction

Observed behavior

The production artifact-provider path returned reason: cancelled and successful provider close while its owned helper process was still alive. The helper was a real Node subprocess substituted for /usr/bin/assetutil, with all production execution options preserved. It delayed exit for 500 ms after SIGTERM. Cancellation settled in 0.57 ms, before the exit marker existed; the process was still alive at that point.

A control added only stopSignal: "SIGTERM" at that substitution boundary. It settled after 510.28 ms, with the exit marker present and the child no longer alive. This establishes cancellation/exit ordering in the default asset-catalog path with a controlled subprocess, not the signal behavior of native assetutil.

Reproduction

From the source revision below with locked dependencies installed, save this focused test as tests/process-global/assetChildLifecycle.test.ts and run:

node node_modules/vitest/vitest.mjs run tests/process-global/assetChildLifecycle.test.ts --maxWorkers=1

The cases below were executed in one six-case file; unrelated cases and imports are removed here. All six characterization cases passed. These two cases also passed when replayed independently from this excerpt. These assertions pin the observed baseline behavior, not a software repair.

import { mkdir, mkdtemp, readFile, rm, writeFile, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, it, vi } from 'vitest';
import * as output from '../../src/process/ExecFileOutput.js';
import { ArtifactProvider } from '../../src/artifacts/ArtifactProvider.js';
const exists = async (path:string) => access(path).then(()=>true,()=>false);
it.each([false, true])('asset cancellation waits for controlled child exit, supervised=%s', async (supervised) => {
 const root = await mkdtemp(join(tmpdir(), 'rea-catalog-lifetime-'));
 const ready=join(root,'ready'), exited=join(root,'exited');
 await mkdir(join(root,'Contents','Resources'),{recursive:true});
 await writeFile(join(root,'Contents','Resources','Assets.car'),'inert fixture');
 const script = `const fs=require('node:fs');process.on('SIGTERM',()=>{setTimeout(()=>{fs.writeFileSync(process.argv[2],'exited');process.exit(0)},500)});fs.writeFileSync(process.argv[1],String(process.pid));setInterval(()=>{},1000);`;
 const real = output.execFileOutput;
 let called = 0, suppliedStop: unknown;
 const spy = vi.spyOn(output,'execFileOutput').mockImplementation((command,args,options) => {
  expect(command).toBe('/usr/bin/assetutil'); called++; suppliedStop = options?.stopSignal;
  return real(process.execPath,['-e',script,ready,exited],{...options,...(supervised?{stopSignal:'SIGTERM' as const}:{})});
 });
 const controller=new AbortController();
 const client=new ArtifactProvider({},'darwin').createBundleResourceClient({callerPath:root,canonicalPath:root,infoPlistPath:join(root,'Contents','Info.plist'),layout:'contents',executableSha256:null});
 let pid: number|undefined;
 try {
  const pending=client.execute('inspect_asset_catalog',{}, {signal:controller.signal});
  await expect.poll(()=>exists(ready)).toBe(true); pid=Number(await readFile(ready,'utf8'));
  const start=performance.now();controller.abort('stop');const result=await pending;
  const elapsed=performance.now()-start, childExited=await exists(exited);
  let alive=true;try {process.kill(pid,0)} catch {alive=false}
  const closed=await client.close();
  console.log(JSON.stringify({area:'asset',supervised,called,suppliedStop:suppliedStop??null,elapsed,childExited,alive,resultOk:result.ok,reason:result.ok?null:result.error.reason,closeOk:closed.ok}));
  expect(result).toMatchObject({ok:false,error:{reason:'cancelled'}});
  expect(called).toBe(1);expect(suppliedStop).toBeUndefined();expect(closed.ok).toBe(true);
  expect(childExited).toBe(supervised);if(!supervised)expect(alive).toBe(true);
  await expect.poll(()=>exists(exited)).toBe(true);
 } finally {spy.mockRestore();if(pid!==undefined){try{process.kill(pid,'SIGKILL')}catch{}}await client.close();await rm(root,{recursive:true,force:true});}
});

The test releases its fixtures and any retained resources in finalization.

Expected behavior

An invocation started for one-shot catalog inspection must finish owned process cleanup before reporting cancellation, or retain/report unresolved cleanup. The lifecycle contract distinguishes caller cancellation from native-work completion. The subprocess helper contract explicitly provides exit/stdio settlement and bounded stopping through stopSignal.

Impact and scope

P2: cancellation and provider close can finish while a helper is still running. The demonstrated overrun was bounded by the controlled child’s 500-ms signal handler; no unbounded native assetutil leak, repeated accumulation, file damage, or real-app prevalence was established. The supervised control proves a working stop mode in the existing helper. Ordinary successful inspection and timeout ordering were not tested in this reproduction.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e. Fetched origin/main at 1f92c773989585a7c0f3255b7a9264789394908c; the producing code remains unchanged. That newer revision was source-compared, not executed.

Runtime and platform

macOS 27.0.1 arm64; Node.js v26.12.0; locked Vitest 5.0.3. The repository-pinned Node.js 24.18.0 was not exercised.

Target and artifact context

Synthetic macOS resource bundle with one regular Contents/Resources/Assets.car holding inert bytes. Directory traversal and catalog digest reads were real. A controlled Node child replaced only the native helper executable and arguments; no native catalog validity is claimed.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed characterization results:

production options: stopSignal absent
cancellation: result.ok=false, reason=cancelled, elapsed=0.57 ms
child exit marker=false; process alive=true; provider close.ok=true
control adding stopSignal=SIGTERM: elapsed=510.28 ms
child exit marker=true; process alive=false; provider close.ok=true

Production-provider behavior was executed. CLI/MCP reachability is source-derived; their transports were not separately invoked.

Typed outcome or recovery expected

Keep the typed cancellation outcome, but settle it after the child/stdio close or carry an exact retained process cleanup owner when release cannot be confirmed. Preserving the caller reason is a separate issue, #5998.

Analysis or suspected cause

Source-derived: runAssetUtil supplies signal and a 60,000-ms timeout without stopSignal. It therefore uses Node execFile’s immediate abort-rejection path instead of the helper’s supervised stop path. The provider owns no retained helper handle on this path. The control changed only this missing execution option.

Additional context

Compared #5976 (deadline classification), #5977 (successful utility-output classification), #5992 (directory cleanup replacing a primary error), #5998 (lost caller cancellation reason), and #5698 (damaged catalog diagnostics). None requires observing helper exit before cancellation settles. PR #3285 joins the sibling Interface Builder analysis; it does not supervise the assetutil child. Related PR #6055 preserves the caller cancellation reason for #5998; its diff leaves process stopping and child-exit ordering unchanged. No last known good revision or software fix was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6057 ### 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 Evidence, artifacts, or reconstruction ### Observed behavior The production artifact-provider path returned `reason: cancelled` and successful provider close while its owned helper process was still alive. The helper was a real Node subprocess substituted for `/usr/bin/assetutil`, with all production execution options preserved. It delayed exit for 500 ms after SIGTERM. Cancellation settled in 0.57 ms, before the exit marker existed; the process was still alive at that point. A control added only `stopSignal: "SIGTERM"` at that substitution boundary. It settled after 510.28 ms, with the exit marker present and the child no longer alive. This establishes cancellation/exit ordering in the default asset-catalog path with a controlled subprocess, not the signal behavior of native assetutil. ### Reproduction From the source revision below with locked dependencies installed, save this focused test as `tests/process-global/assetChildLifecycle.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/process-global/assetChildLifecycle.test.ts --maxWorkers=1 ``` The cases below were executed in one six-case file; unrelated cases and imports are removed here. All six characterization cases passed. These two cases also passed when replayed independently from this excerpt. These assertions pin the observed baseline behavior, not a software repair. ```ts import { mkdir, mkdtemp, readFile, rm, writeFile, access } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, it, vi } from 'vitest'; import * as output from '../../src/process/ExecFileOutput.js'; import { ArtifactProvider } from '../../src/artifacts/ArtifactProvider.js'; const exists = async (path:string) => access(path).then(()=>true,()=>false); it.each([false, true])('asset cancellation waits for controlled child exit, supervised=%s', async (supervised) => { const root = await mkdtemp(join(tmpdir(), 'rea-catalog-lifetime-')); const ready=join(root,'ready'), exited=join(root,'exited'); await mkdir(join(root,'Contents','Resources'),{recursive:true}); await writeFile(join(root,'Contents','Resources','Assets.car'),'inert fixture'); const script = `const fs=require('node:fs');process.on('SIGTERM',()=>{setTimeout(()=>{fs.writeFileSync(process.argv[2],'exited');process.exit(0)},500)});fs.writeFileSync(process.argv[1],String(process.pid));setInterval(()=>{},1000);`; const real = output.execFileOutput; let called = 0, suppliedStop: unknown; const spy = vi.spyOn(output,'execFileOutput').mockImplementation((command,args,options) => { expect(command).toBe('/usr/bin/assetutil'); called++; suppliedStop = options?.stopSignal; return real(process.execPath,['-e',script,ready,exited],{...options,...(supervised?{stopSignal:'SIGTERM' as const}:{})}); }); const controller=new AbortController(); const client=new ArtifactProvider({},'darwin').createBundleResourceClient({callerPath:root,canonicalPath:root,infoPlistPath:join(root,'Contents','Info.plist'),layout:'contents',executableSha256:null}); let pid: number|undefined; try { const pending=client.execute('inspect_asset_catalog',{}, {signal:controller.signal}); await expect.poll(()=>exists(ready)).toBe(true); pid=Number(await readFile(ready,'utf8')); const start=performance.now();controller.abort('stop');const result=await pending; const elapsed=performance.now()-start, childExited=await exists(exited); let alive=true;try {process.kill(pid,0)} catch {alive=false} const closed=await client.close(); console.log(JSON.stringify({area:'asset',supervised,called,suppliedStop:suppliedStop??null,elapsed,childExited,alive,resultOk:result.ok,reason:result.ok?null:result.error.reason,closeOk:closed.ok})); expect(result).toMatchObject({ok:false,error:{reason:'cancelled'}}); expect(called).toBe(1);expect(suppliedStop).toBeUndefined();expect(closed.ok).toBe(true); expect(childExited).toBe(supervised);if(!supervised)expect(alive).toBe(true); await expect.poll(()=>exists(exited)).toBe(true); } finally {spy.mockRestore();if(pid!==undefined){try{process.kill(pid,'SIGKILL')}catch{}}await client.close();await rm(root,{recursive:true,force:true});} }); ``` The test releases its fixtures and any retained resources in finalization. ### Expected behavior An invocation started for one-shot catalog inspection must finish owned process cleanup before reporting cancellation, or retain/report unresolved cleanup. [The lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L132) distinguishes caller cancellation from native-work completion. [The subprocess helper contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/process/ExecFileOutput.ts#L11-L21) explicitly provides exit/stdio settlement and bounded stopping through `stopSignal`. ### Impact and scope P2: cancellation and provider close can finish while a helper is still running. The demonstrated overrun was bounded by the controlled child’s 500-ms signal handler; no unbounded native assetutil leak, repeated accumulation, file damage, or real-app prevalence was established. The supervised control proves a working stop mode in the existing helper. Ordinary successful inspection and timeout ordering were not tested in this reproduction. ### REA version `rea-agents` 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. Fetched `origin/main` at `1f92c773989585a7c0f3255b7a9264789394908c`; the producing code remains unchanged. That newer revision was source-compared, not executed. ### Runtime and platform macOS 27.0.1 arm64; Node.js v26.12.0; locked Vitest 5.0.3. The repository-pinned Node.js 24.18.0 was not exercised. ### Target and artifact context Synthetic macOS resource bundle with one regular Contents/Resources/Assets.car holding inert bytes. Directory traversal and catalog digest reads were real. A controlled Node child replaced only the native helper executable and arguments; no native catalog validity is claimed. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed characterization results: ```text production options: stopSignal absent cancellation: result.ok=false, reason=cancelled, elapsed=0.57 ms child exit marker=false; process alive=true; provider close.ok=true control adding stopSignal=SIGTERM: elapsed=510.28 ms child exit marker=true; process alive=false; provider close.ok=true ``` Production-provider behavior was executed. CLI/MCP reachability is source-derived; their transports were not separately invoked. ### Typed outcome or recovery expected Keep the typed cancellation outcome, but settle it after the child/stdio close or carry an exact retained process cleanup owner when release cannot be confirmed. Preserving the caller reason is a separate issue, #5998. ### Analysis or suspected cause Source-derived: [runAssetUtil](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/apple/AppleAssetCatalogAnalysis.ts#L259-L293) supplies `signal` and a 60,000-ms timeout without `stopSignal`. It therefore uses Node execFile’s immediate abort-rejection path instead of the helper’s supervised stop path. The provider owns no retained helper handle on this path. The control changed only this missing execution option. ### Additional context Compared #5976 (deadline classification), #5977 (successful utility-output classification), #5992 (directory cleanup replacing a primary error), #5998 (lost caller cancellation reason), and #5698 (damaged catalog diagnostics). None requires observing helper exit before cancellation settles. PR #3285 joins the sibling Interface Builder analysis; it does not supervise the assetutil child. Related PR [#6055](https://github.com/morluto/rea/pull/6055) preserves the caller cancellation reason for [#5998](https://github.com/morluto/rea/issues/5998); its diff leaves process stopping and child-exit ordering unchanged. No last known good revision or software fix 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#3702
No description provided.