Skip to content

[Bug] Cancelling a managed source hash leaves its resource scope stuck in failed cleanup #4381

Closed

Description

@coygeek

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

Cancelling hashManagedSource after the first file-stream data event rejects with ProviderAdapterError and cleanupIncomplete: true. The shared ArtifactResourceScope retains a failed owner, and both retryCleanup() and close() reject. The focused production-boundary probe reproduced this in three of three attempts on each tested source revision. Uninterrupted hashing and pre-aborted cancellation controls passed.

Reproduction

Prepare separate source checkouts at the tested main and release revisions below using the pinned Node/npm toolchain and locked dependencies (npm ci). Main supplies the locked tsx v4.23.15 runner; release does not include it. Set REA_TSX to the absolute path of main's node_modules/.bin/tsx and use that runner for either checkout. Save this complete script outside the checkouts as repro.mts, then run it from the chosen checkout root:

repro_scratch=$(mktemp -d /tmp/rea-repro.XXXXXX)
REA_CHECKOUT="$PWD" REPRO_SCRATCH="$repro_scratch" "$REA_TSX" /absolute/path/to/repro.mts
rmdir "$repro_scratch"
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, open, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const root = process.env.REA_CHECKOUT, work = process.env.REPRO_SCRATCH;
assert(root && work, 'Set REA_CHECKOUT and REPRO_SCRATCH');
const load = (p: string) => import(pathToFileURL(join(root, p)).href);
const { hashManagedSource, readManagedSnapshot } = await load('src/dotnet/ManagedSnapshot.ts');
const { ArtifactResourceScope } = await load('src/artifacts/ArtifactResourceScope.ts');
await mkdir(work, { recursive: true });
const path = join(work, 'owned-sparse.bin'), size = 32 * 1024 * 1024;
const file = await open(path, 'wx+'); await file.truncate(size);
const proto = Object.getPrototypeOf(file), original = proto.createReadStream;
await file.close();
const messages = (e: any, seen = new Set()): string => {
  if (!e || typeof e !== 'object' || seen.has(e)) return '';
  seen.add(e);
  return [e.message, messages(e.cause, seen), messages(e.cleanupCause, seen),
    ...(e.errors ?? []).map((x: any) => messages(x, seen))].join('\n');
};
try {
  const expected = createHash('sha256').update(Buffer.alloc(size)).digest('hex');
  assert.equal((await hashManagedSource(path, 'inspect_managed_artifact')).sha256, expected);
  for (const read of [hashManagedSource, readManagedSnapshot]) {
    const ac = new AbortController(); ac.abort();
    await assert.rejects(read(path, 'inspect_managed_artifact', { signal: ac.signal }),
      (e: any) => e.constructor.name === 'AnalysisCancelledError');
  }
  console.log('control: exact hash and pre-aborted cancellation passed');
  for (let trial = 1; trial <= 3; trial++) {
    const ac = new AbortController(), resources = new ArtifactResourceScope();
    let data = false, failure: any;
    proto.createReadStream = function (options: any) {
      const stream = original.call(this, options);
      stream.once('data', () => { data = true; setImmediate(() => ac.abort()); });
      return stream;
    };
    try { await hashManagedSource(path, 'inspect_managed_artifact', { signal: ac.signal, resources }); }
    catch (e) { failure = e; } finally { proto.createReadStream = original; }
    assert(data && failure?.cleanupIncomplete && resources.hasPending);
    assert.match(messages(failure), /File handle was invalidated before this owner confirmed its close/);
    await assert.rejects(resources.retryCleanup()); await assert.rejects(resources.close());
    console.log(`trial ${trial}: ${failure.constructor.name}, cleanupIncomplete=true; retry and close rejected`);
  }
} finally { proto.createReadStream = original; await rm(path, { force: true }); }

This executed probe forwards the production file stream unchanged and schedules cancellation after its first real data event. It substitutes no bytes, errors, descriptors, or cleanup operations. It restores the prototype and removes the owned fixture. The script's assertions describe the failing baseline; after restoration, adapt them to require typed cancellation and successful scope cleanup instead.

Expected behavior

Supported cancellation should retain its typed cancellation outcome and complete owner-managed cleanup. The same helper returns AnalysisCancelledError when already aborted. The repository's lifecycle contract requires cancellation through awaited cleanup, exact descriptor ownership, and truthful retry state. Ordinary stream cancellation should not leave the scope permanently unable to confirm release.

Impact and scope

The observed defect is failed cleanup accounting and blocked scope recovery after cancellation at a bounded local hashing boundary. It does not establish an actual descriptor leak. Source inspection shows ManagedStaticClient.execute uses this hash branch after caching a PE snapshot, passing its resource scope and request signal; subsequent work retries retained cleanup before admission. No public CLI/MCP session or live provider was executed, and ordinary-use frequency and other platforms remain unmeasured.

REA version

Tested main source 8ab5024397ddbd3b37ab68a13cabedc2eb7ce7dd; latest published rea-agents 6.5.0 source 3b79af28e80785afce9f5998d8f03289402a1847. Both reproduced. Current main 2c7583599b9ac1c847bd77b1940930fd8d4594c7 has byte-identical relevant hash, provider, regular-file, owner, resource-scope, error-classification and lifecycle-contract files by API source comparison; the runtime script was executed at the tested revisions.

Runtime and platform

Node v24.18.0; npm 11.16.0; Darwin 27.0.0 arm64. Source helpers were imported with the main checkout's locked tsx; no compiled CLI or provider run.

Target and artifact context

Owned synthetic regular file, 32 MiB of zero bytes; no target execution or external provider.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Both source revisions printed:

control: exact hash and pre-aborted cancellation passed
trial 1: ProviderAdapterError, cleanupIncomplete=true; retry and close rejected
trial 2: ProviderAdapterError, cleanupIncomplete=true; retry and close rejected
trial 3: ProviderAdapterError, cleanupIncomplete=true; retry and close rejected

Each attempt also asserted the nested message File handle was invalidated before this owner confirmed its close.

Typed outcome or recovery expected

Repeat the same mid-stream cancellation with real file I/O: require AnalysisCancelledError after confirmed cleanup, no pending owner, and successful scope retry and close. Preserve exact uninterrupted SHA-256, pre-abort cancellation, and truthful retained ownership when a close really fails; skipping ownership checks or cleanup would not restore the contract.

Analysis or suspected cause

Source-derived: the hash stream receives the request signal with autoClose: false. After abort, the close owner observes an invalidated handle and retains a sticky failure. The normalization at ManagedSnapshot.ts:198-211 checks filesystem classification before cancellation; the cleanup wrapper retains that primary failure. The provider branch establishes supported reachability after the first successful snapshot read. The local synthetic bytes exercise hashing, not managed PE parsing.

Additional context

Related closed #4266 and merged #4274 concern retained ownership after an injected close failure in hashStableFile and lifecycle consumers. This report concerns a real stream abort in hashManagedSource that invalidates the handle before its close owner confirms release; it still reproduces on the tested main after that merge. Open #3977 concerns successful DOS verification after cancellation during Ghidra snapshot cleanup, with successful descriptor close, rather than this failed hash-scope recovery. No last-known-good revision was established.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Mid-stream cancellation repeatedly leaves the managed hashing resource scope unable to complete retry or close; the bounded failure is cleanup accounting and recovery, with no measured descriptor leak or broad outage.
  • Workaround status: unverified
  • Workaround basis: A fresh provider owns a fresh scope by construction, but public workflow recovery by replacement or restart was not executed.

Activity

  1. xu91102 commented on Oct 16, 2026

    @xu91102
    Contributor

    Reproduced on current main d72d856f58d4747c5eb4307426e80d20934e017c, Node 24.18.0 / macOS arm64. A real 32 MiB sparse-file hash passed the exact SHA-256 control and both pre-aborted controls. All 3 mid-stream cancellations then produced ProviderAdapterError with cleanupIncomplete: true; the retained scope rejected both retry and close. Only cancellation timing was scheduled at the first actual stream data event; file bytes, errors and cleanup operations were not substituted. This establishes the production hashing/ownership failure, not an actual descriptor leak or a public CLI/MCP run.

    I am preparing a focused fix that leaves descriptor close authority with its existing owner, with cancellation/recovery and genuine close-failure controls. No covering PR was found in the current issue/function-specific searches.

    Update: reproduced again on main 781d5751ad18b25a1fe1ffa8c6035388be8c3283. Proposed two-file fix uses bounded reads through the existing FileHandle owner; it does not relax invalid-handle checks. The real-provider cancellation/recovery and denied-close controls pass (2/2). A deduplicated 37-file owner/consumer/CLI/MCP selection has 518 passing cases across the first run and two isolated whole-file reruns; the original three MCP timeouts are retained with unknown cause. Actual normal CLI and same-client stdio MCP paths, static checks, docs and discovery passed. Public CLI/MCP signal cancellation itself was not run; the cancellation oracle is the real provider/file boundary. Independent Standards/Spec review passed.

    The patch is committed and pushed, but an ordinary fork-to-upstream Draft creation returned CreatePullRequest permission denied. There is no upstream PR or upstream CI for this patch yet; the reason for the creation denial is unknown. Human readiness remains pending.

  2. xu91102 commented on Oct 16, 2026

    @xu91102
    Contributor

    Could a repository owner/admin check why @xu91102 cannot open a pull request here?

    A normal gh pr create --draft from xu91102/rea:codex/managed-hash-cancellation to morluto/rea:main returns GraphQL FORBIDDEN at createPullRequest, with only “does not have the correct permissions to execute CreatePullRequest”. Request ID: 5A1A:21990F:A2BB7E:F25BD9:6AD1F534 (2026-10-16 09:58 UTC).

    Read-only checks show PRs enabled, creation policy ALL, no returned general interaction restriction, a valid fork/branch, and available API quota. There are currently three open PRs from this account: #3263, #3972, #3973. The creation-cap configuration is admin-only and returns HTTP 403 to this account, so I cannot confirm whether a cap or another restriction applies.

    Could you confirm the applicable restriction and your preferred submission sequence for #4381 and #4393? Both focused fixes are already pushed to the fork; no new upstream PR has been created. I have not changed accounts, tried another creation API, or closed existing PRs to work around the refusal.

  3. N0zoM1z0 commented on Oct 16, 2026

    @N0zoM1z0
    Collaborator

    Independently reproduced on Linux / Node 24.18.0 at latest main c884864876c2568d8567235737b32e3f5893751d: uninterrupted SHA-256 and pre-abort controls passed; the original real-stream probe reproduced failed cleanup in 3/3 trials. This establishes failed ownership accounting, not a physical descriptor leak.

    I am integrating @xu91102's published ebeec580c839f2f72144282a44f4e11ea5759843 with original commit authorship preserved, taking independent responsibility for the submission and Linux verification under the current human owner. No upstream covering PR was found. This does not change or resolve the original contributor's reported GitHub permission restriction.

    Initial 482 related tests passed. On an unchanged official Microsoft.Extensions.Primitives 8.0.0 assembly, the compiled real managed provider passed seven cached-hash cancellation/denied-close/final-close recovery scenarios, including same-client reuse and blocked admission before cleanup. Additional tests exposed a related classification defect when the exact caller cancellation reason carries code: EACCES; the boundary correction preserves that cancellation while retaining independent read errors. Final checks and normal public CLI/MCP measurements are running; public managed requests are one-shot, so I do not claim they exercise retained-client hash cancellation.

  4. added a commit that references this issue on Oct 16, 2026
    fe2a398
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions