[GH-ISSUE #4640] [Bug] Flutter inspections return successful JSON after SIGINT during cleanup retry #2419

Closed
opened 2026-10-17 21:04:23 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

CLI

Observed behavior

identify-flutter-build and inspect-dart-aot emit ordinary successful inspection Evidence after SIGINT aborts their command signal during the final cleanup retry. The inspection has already completed and the retry removes the snapshot root, but the JSON reports ok:true without a typed cancelled outcome or partial_observation. The dispatcher subprocess exits 130. Both commands reproduced this mismatch in three out of three interrupted invocations on each tested build; each matching noninterrupted retry control succeeded once.

Reproduction

Use a source checkout at the revision listed below, its pinned Node/npm toolchain, npm ci, and npm run build:cached. This controlled reproduction uses the built CLI dispatcher, real parser/services/provider/serializer, and the repository's authored AOT fixture. It injects one transient snapshot-root removal failure and sends real SIGINT during the real recursive removal retry; it does not replace close() or hold cleanup open artificially.

From the checkout root, prepare a scratch directory and save the following script as $work/probe.mjs:

source_root="$PWD"
work=$(mktemp -d)
mkdir -p "$work/main/home" "$work/release/home"
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
import { writeFile, rm } from 'node:fs/promises';
import { Writable } from 'node:stream';
import assert from 'node:assert/strict';
const root=process.cwd(), scratch=process.env.HOME.replace(/\/home$/,'');
const require=createRequire(`${root}/package.json`);
const load=p=>import(pathToFileURL(`${root}/dist/${p}.js`));
const { createCli }=await load('cli');
const { createCliResultOutput }=await load('cli/cliResultOutput');
const { FlutterBuildProvider }=await load('flutter/FlutterBuildProvider');
const { FlutterBuildAnalysisService }=await load('application/flutter/FlutterBuildAnalysisService');
const { aotFixtureImage }=await import(pathToFileURL(`${process.argv[2]}/src/flutter/FlutterBuild.fixture.ts`));
const { ZipWriter, Uint8ArrayReader, Uint8ArrayWriter }=await import(pathToFileURL(require.resolve('@zip.js/zip.js')));
const writer=new ZipWriter(new Uint8ArrayWriter(),{useWebWorkers:false});
await writer.add('lib/arm64-v8a/libapp.so',new Uint8ArrayReader(aotFixtureImage()));
const target=`${scratch}/sample.apk`;
await writeFile(target,await writer.close());
const command=process.argv[3], cancel=process.argv[4]==='true';
{
  let calls=0, signal, inspected;
  const oldExecute=FlutterBuildProvider.prototype.execute;
  const oldService=FlutterBuildAnalysisService.prototype.execute;
  FlutterBuildProvider.prototype.execute=async function(request,options) {
   signal=options.signal;
   this.removeRoot=async path=>{
    calls++;
    if(calls===1) throw Object.assign(new Error('transient snapshot removal refusal'),{code:'EACCES'});
    assert.equal(inspected.ok,true);
    assert.equal(signal.aborted,false);
    if(cancel)process.kill(process.pid,'SIGINT');
    await rm(path,{recursive:true,force:true});
   };
   return oldExecute.call(this,request,options);
  };
  FlutterBuildAnalysisService.prototype.execute=async function(...args){
   inspected=await oldService.apply(this,args);
   assert.equal(inspected.ok,true);
   assert.equal(signal.aborted,false);
   return inspected;
  };
  let output='';process.exitCode=undefined;
  try {
   const sink=new Writable({write(c,e,cb){output+=c.toString();cb();}});
   const args=[command,target,'--json','--full-output'];
   const resultOutput=createCliResultOutput(args,sink);
   await createCli({},resultOutput).serve(args,{env:{},stdout(t){if(!resultOutput.handled)output+=t;},exit(c){throw Error(`unexpected exit ${c}`);}});
   const value=JSON.parse(output);
   const record={command,cancel,removal_attempts:calls,inspection_ok:inspected.ok,signal_aborted:signal.aborted,ok:value.ok,code:value.error?.code??value.data?.code??null,evidence:value.data?.operation??null,exit_code:process.exitCode??0};
   console.log(JSON.stringify(record));
   await writeFile(`${scratch}/${command}-${cancel}.json`,output);
   assert.equal(calls,2); assert.equal(value.ok,true);assert.equal(signal.aborted,cancel);assert.equal(process.exitCode??0,cancel?130:0);
  } finally {FlutterBuildProvider.prototype.execute=oldExecute;FlutterBuildAnalysisService.prototype.execute=oldService;}
 }

Run from the source checkout. Each interrupted dispatcher subprocess should print a successful JSON row and exit 130; the controls exit 0. Run without shell errexit so the deliberate nonzero interrupt statuses do not stop the loop:

for command in identify-flutter-build inspect-dart-aot; do
  for cancel in false true true true; do
    env -i PATH="$PATH" HOME="$work/main/home" TMPDIR="$work/main"       node "$work/probe.mjs" "$source_root" "$command" "$cancel"
    echo "dispatcher subprocess status=$?"
  done
done

To repeat against the published package, install it into scratch, change to the installed package root, and run the same loop with a separate HOME:

npm install --prefix "$work/release-runtime" --omit=dev --no-audit --no-fund rea-agents@6.5.0
cd "$work/release-runtime/node_modules/rea-agents"
for command in identify-flutter-build inspect-dart-aot; do
  for cancel in false true true true; do
    env -i PATH="$PATH" HOME="$work/release/home" TMPDIR="$work/release"       node "$work/probe.mjs" "$source_root" "$command" "$cancel"
    echo "dispatcher subprocess status=$?"
  done
done

The script imports fixture bytes from the source checkout for both builds. It captures the actual service result before the retry and asserts that inspection succeeded with an active signal at that point. The first removal throws EACCES; the retry sends SIGINT immediately before awaiting real rm. The script's exit_code field is process.exitCode; subprocess statuses were also observed by the parent runner. This invokes the built dispatcher directly rather than the launcher executable.

Expected behavior

For a cancellable observation, check cancellation after the last awaited finalization step and preserve completed observations under typed cancellation. SIGINT during a successful cleanup retry should produce cancelled with the completed Evidence retained as a partial observation. The never-interrupted retry must remain successful, and the interrupt status must remain 130. This expectation follows the current lifecycle contract; the release also reproduces the same externally visible mismatch, although its adapter predates the current finalizer implementation.

Impact and scope

Structured-output consumers receive the wrong operation classification after interruption. Completed inspection facts remain available and the retry removes the snapshot root. The transient first removal failure and interrupt timing were controlled; the frequency of naturally occurring filesystem failures is unmeasured. This report covers only the two reproduced Flutter CLI commands. No target execution, target mutation, data loss, or security-boundary crossing was observed.

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Both Flutter CLI commands returned success-shaped JSON after an observed abort during real final cleanup on both tested builds, with three interrupted observations per command/build.
  • Workaround status: available
  • Workaround basis: Each interrupted dispatcher subprocess exited 130 despite its successful JSON, so callers can honor the nonzero process status and retain the emitted inspection facts.

REA version

Current main: 4087048270, package version 6.5.0. Published npm package: rea-agents@6.5.0, verified as the registry's latest release and installed from its published tarball separately from the checkout.

Runtime and platform

Node v24.18.0; npm 11.16.0; macOS 27.0.1, arm64. No external analysis engine was required. Each tested build used a fresh isolated HOME/TMPDIR; dispatcher subprocesses inherited only PATH, HOME, and TMPDIR.

Target and artifact context

A local authored APK with one lib/arm64-v8a/libapp.so entry containing aotFixtureImage() from src/flutter/FlutterBuild.fixture.ts. The provider parses the bytes without executing the target. A transient failure of the first snapshot-root removal makes the existing close path retry that owned root.

Interface

CLI

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

  • Expected source: Current checkout docs/tool-design.md:91-102 requires the final signal check and retention of collected observations; src/cli/finishAnalysisCommand.ts:72-78 implements this when supplied a signal.
  • Failure source: The reproduction above, through src/cli/flutterCommands.ts:74-87 and src/flutter/FlutterBuildProvider.ts:501-547,619-626; the installed release's corresponding built modules also reproduced the behavior.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

Representative rows were identical on both tested builds; each interrupted row occurred three times and its dispatcher subprocess exited 130:

{"command":"identify-flutter-build","cancel":false,"removal_attempts":2,"inspection_ok":true,"signal_aborted":false,"ok":true,"code":null,"evidence":"identify_flutter_build","exit_code":0}
{"command":"identify-flutter-build","cancel":true,"removal_attempts":2,"inspection_ok":true,"signal_aborted":true,"ok":true,"code":null,"evidence":"identify_flutter_build","exit_code":130}
{"command":"inspect-dart-aot","cancel":false,"removal_attempts":2,"inspection_ok":true,"signal_aborted":false,"ok":true,"code":null,"evidence":"inspect_dart_aot","exit_code":0}
{"command":"inspect-dart-aot","cancel":true,"removal_attempts":2,"inspection_ok":true,"signal_aborted":true,"ok":true,"code":null,"evidence":"inspect_dart_aot","exit_code":130}

Source-derived reachability: when the first removal fails, the provider retains the root but can still return successful inspection Evidence. Its subsequent close() retries that root. This leaves real asynchronous filesystem work after successful service completion, allowing SIGINT before the command returns. The production signal wrapper sets the interrupt status independently of the returned result.

Typed outcome or recovery expected

With the same successful inspection, injected first-removal refusal, successful retry, and SIGINT timing, return typed cancelled with the completed Evidence under details.partial_observation, preserving operation, artifact identity, and observations. Keep subprocess status 130 and keep each never-interrupted retry successful. If cleanup remains incomplete, preserve the cleanup failure classification and owned-resource diagnostics. The reproduction asserts the current defect; a restoration check must instead require cancellation for interrupted cases and preservation of their completed Evidence.

Analysis or suspected cause

On current main, the Flutter registration supplies its signal to inspection but omits it from finishAnalysisCommand, whose post-close cancellation guard requires that optional signal. The release uses an inline provider.close() followed by returning the settled service result, without a post-close signal check. These are source-derived explanations for the same observed outcome, rather than measurements of naturally occurring timing frequency.

Additional context

Related reports cover different lifecycle boundaries: #4254 concerns cancellation within WASM provider cleanup before its final guard; #3923 and #3927 concern firmware and Android results returning from a provider after cancellation. #3935 concerns failed cleanup retries discarding completed results. Here the Flutter service has already returned successfully, the later CLI cleanup retry succeeds, and SIGINT during that retry is omitted from the structured result. No other format or live MCP cancellation reproduction is claimed. The first affected release and natural failure frequency are unknown.

Originally created by @coygeek on GitHub (Oct 17, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4640 ### 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 CLI ### Observed behavior `identify-flutter-build` and `inspect-dart-aot` emit ordinary successful inspection Evidence after SIGINT aborts their command signal during the final cleanup retry. The inspection has already completed and the retry removes the snapshot root, but the JSON reports `ok:true` without a typed `cancelled` outcome or `partial_observation`. The dispatcher subprocess exits 130. Both commands reproduced this mismatch in three out of three interrupted invocations on each tested build; each matching noninterrupted retry control succeeded once. ### Reproduction Use a source checkout at the revision listed below, its pinned Node/npm toolchain, `npm ci`, and `npm run build:cached`. This controlled reproduction uses the built CLI dispatcher, real parser/services/provider/serializer, and the repository's authored AOT fixture. It injects one transient snapshot-root removal failure and sends real SIGINT during the real recursive removal retry; it does not replace `close()` or hold cleanup open artificially. From the checkout root, prepare a scratch directory and save the following script as `$work/probe.mjs`: ```sh source_root="$PWD" work=$(mktemp -d) mkdir -p "$work/main/home" "$work/release/home" ``` ```js import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; import { writeFile, rm } from 'node:fs/promises'; import { Writable } from 'node:stream'; import assert from 'node:assert/strict'; const root=process.cwd(), scratch=process.env.HOME.replace(/\/home$/,''); const require=createRequire(`${root}/package.json`); const load=p=>import(pathToFileURL(`${root}/dist/${p}.js`)); const { createCli }=await load('cli'); const { createCliResultOutput }=await load('cli/cliResultOutput'); const { FlutterBuildProvider }=await load('flutter/FlutterBuildProvider'); const { FlutterBuildAnalysisService }=await load('application/flutter/FlutterBuildAnalysisService'); const { aotFixtureImage }=await import(pathToFileURL(`${process.argv[2]}/src/flutter/FlutterBuild.fixture.ts`)); const { ZipWriter, Uint8ArrayReader, Uint8ArrayWriter }=await import(pathToFileURL(require.resolve('@zip.js/zip.js'))); const writer=new ZipWriter(new Uint8ArrayWriter(),{useWebWorkers:false}); await writer.add('lib/arm64-v8a/libapp.so',new Uint8ArrayReader(aotFixtureImage())); const target=`${scratch}/sample.apk`; await writeFile(target,await writer.close()); const command=process.argv[3], cancel=process.argv[4]==='true'; { let calls=0, signal, inspected; const oldExecute=FlutterBuildProvider.prototype.execute; const oldService=FlutterBuildAnalysisService.prototype.execute; FlutterBuildProvider.prototype.execute=async function(request,options) { signal=options.signal; this.removeRoot=async path=>{ calls++; if(calls===1) throw Object.assign(new Error('transient snapshot removal refusal'),{code:'EACCES'}); assert.equal(inspected.ok,true); assert.equal(signal.aborted,false); if(cancel)process.kill(process.pid,'SIGINT'); await rm(path,{recursive:true,force:true}); }; return oldExecute.call(this,request,options); }; FlutterBuildAnalysisService.prototype.execute=async function(...args){ inspected=await oldService.apply(this,args); assert.equal(inspected.ok,true); assert.equal(signal.aborted,false); return inspected; }; let output='';process.exitCode=undefined; try { const sink=new Writable({write(c,e,cb){output+=c.toString();cb();}}); const args=[command,target,'--json','--full-output']; const resultOutput=createCliResultOutput(args,sink); await createCli({},resultOutput).serve(args,{env:{},stdout(t){if(!resultOutput.handled)output+=t;},exit(c){throw Error(`unexpected exit ${c}`);}}); const value=JSON.parse(output); const record={command,cancel,removal_attempts:calls,inspection_ok:inspected.ok,signal_aborted:signal.aborted,ok:value.ok,code:value.error?.code??value.data?.code??null,evidence:value.data?.operation??null,exit_code:process.exitCode??0}; console.log(JSON.stringify(record)); await writeFile(`${scratch}/${command}-${cancel}.json`,output); assert.equal(calls,2); assert.equal(value.ok,true);assert.equal(signal.aborted,cancel);assert.equal(process.exitCode??0,cancel?130:0); } finally {FlutterBuildProvider.prototype.execute=oldExecute;FlutterBuildAnalysisService.prototype.execute=oldService;} } ``` Run from the source checkout. Each interrupted dispatcher subprocess should print a successful JSON row and exit 130; the controls exit 0. Run without shell `errexit` so the deliberate nonzero interrupt statuses do not stop the loop: ```sh for command in identify-flutter-build inspect-dart-aot; do for cancel in false true true true; do env -i PATH="$PATH" HOME="$work/main/home" TMPDIR="$work/main" node "$work/probe.mjs" "$source_root" "$command" "$cancel" echo "dispatcher subprocess status=$?" done done ``` To repeat against the published package, install it into scratch, change to the installed package root, and run the same loop with a separate HOME: ```sh npm install --prefix "$work/release-runtime" --omit=dev --no-audit --no-fund rea-agents@6.5.0 cd "$work/release-runtime/node_modules/rea-agents" for command in identify-flutter-build inspect-dart-aot; do for cancel in false true true true; do env -i PATH="$PATH" HOME="$work/release/home" TMPDIR="$work/release" node "$work/probe.mjs" "$source_root" "$command" "$cancel" echo "dispatcher subprocess status=$?" done done ``` The script imports fixture bytes from the source checkout for both builds. It captures the actual service result before the retry and asserts that inspection succeeded with an active signal at that point. The first removal throws `EACCES`; the retry sends SIGINT immediately before awaiting real `rm`. The script's `exit_code` field is `process.exitCode`; subprocess statuses were also observed by the parent runner. This invokes the built dispatcher directly rather than the launcher executable. ### Expected behavior For a cancellable observation, check cancellation after the last awaited finalization step and preserve completed observations under typed cancellation. SIGINT during a successful cleanup retry should produce `cancelled` with the completed Evidence retained as a partial observation. The never-interrupted retry must remain successful, and the interrupt status must remain 130. This expectation follows the current lifecycle contract; the release also reproduces the same externally visible mismatch, although its adapter predates the current finalizer implementation. ### Impact and scope Structured-output consumers receive the wrong operation classification after interruption. Completed inspection facts remain available and the retry removes the snapshot root. The transient first removal failure and interrupt timing were controlled; the frequency of naturally occurring filesystem failures is unmeasured. This report covers only the two reproduced Flutter CLI commands. No target execution, target mutation, data loss, or security-boundary crossing was observed. - Impact level: P2 - Assessment status: supported - Impact basis: Both Flutter CLI commands returned success-shaped JSON after an observed abort during real final cleanup on both tested builds, with three interrupted observations per command/build. - Workaround status: available - Workaround basis: Each interrupted dispatcher subprocess exited 130 despite its successful JSON, so callers can honor the nonzero process status and retain the emitted inspection facts. ### REA version Current `main`: 408704827057280b240c91963ee5590fd4dcc988, package version 6.5.0. Published npm package: `rea-agents@6.5.0`, verified as the registry's latest release and installed from its published tarball separately from the checkout. ### Runtime and platform Node v24.18.0; npm 11.16.0; macOS 27.0.1, arm64. No external analysis engine was required. Each tested build used a fresh isolated HOME/TMPDIR; dispatcher subprocesses inherited only PATH, HOME, and TMPDIR. ### Target and artifact context A local authored APK with one `lib/arm64-v8a/libapp.so` entry containing `aotFixtureImage()` from `src/flutter/FlutterBuild.fixture.ts`. The provider parses the bytes without executing the target. A transient failure of the first snapshot-root removal makes the existing close path retry that owned root. ### Interface CLI ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence - Expected source: Current checkout `docs/tool-design.md:91-102` requires the final signal check and retention of collected observations; `src/cli/finishAnalysisCommand.ts:72-78` implements this when supplied a signal. - Failure source: The reproduction above, through `src/cli/flutterCommands.ts:74-87` and `src/flutter/FlutterBuildProvider.ts:501-547,619-626`; the installed release's corresponding built modules also reproduced the behavior. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete Representative rows were identical on both tested builds; each interrupted row occurred three times and its dispatcher subprocess exited 130: ```json {"command":"identify-flutter-build","cancel":false,"removal_attempts":2,"inspection_ok":true,"signal_aborted":false,"ok":true,"code":null,"evidence":"identify_flutter_build","exit_code":0} {"command":"identify-flutter-build","cancel":true,"removal_attempts":2,"inspection_ok":true,"signal_aborted":true,"ok":true,"code":null,"evidence":"identify_flutter_build","exit_code":130} {"command":"inspect-dart-aot","cancel":false,"removal_attempts":2,"inspection_ok":true,"signal_aborted":false,"ok":true,"code":null,"evidence":"inspect_dart_aot","exit_code":0} {"command":"inspect-dart-aot","cancel":true,"removal_attempts":2,"inspection_ok":true,"signal_aborted":true,"ok":true,"code":null,"evidence":"inspect_dart_aot","exit_code":130} ``` Source-derived reachability: when the first removal fails, the provider retains the root but can still return successful inspection Evidence. Its subsequent `close()` retries that root. This leaves real asynchronous filesystem work after successful service completion, allowing SIGINT before the command returns. The production signal wrapper sets the interrupt status independently of the returned result. ### Typed outcome or recovery expected With the same successful inspection, injected first-removal refusal, successful retry, and SIGINT timing, return typed `cancelled` with the completed Evidence under `details.partial_observation`, preserving operation, artifact identity, and observations. Keep subprocess status 130 and keep each never-interrupted retry successful. If cleanup remains incomplete, preserve the cleanup failure classification and owned-resource diagnostics. The reproduction asserts the current defect; a restoration check must instead require cancellation for interrupted cases and preservation of their completed Evidence. ### Analysis or suspected cause On current main, the Flutter registration supplies its signal to inspection but omits it from `finishAnalysisCommand`, whose post-close cancellation guard requires that optional signal. The release uses an inline `provider.close()` followed by returning the settled service result, without a post-close signal check. These are source-derived explanations for the same observed outcome, rather than measurements of naturally occurring timing frequency. ### Additional context Related reports cover different lifecycle boundaries: [#4254](https://github.com/morluto/rea/issues/4254) concerns cancellation within WASM provider cleanup before its final guard; [#3923](https://github.com/morluto/rea/issues/3923) and [#3927](https://github.com/morluto/rea/issues/3927) concern firmware and Android results returning from a provider after cancellation. [#3935](https://github.com/morluto/rea/issues/3935) concerns failed cleanup retries discarding completed results. Here the Flutter service has already returned successfully, the later CLI cleanup retry succeeds, and SIGINT during that retry is omitted from the structured result. No other format or live MCP cancellation reproduction is claimed. The first affected release and natural failure frequency are unknown.
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#2419
No description provided.