[GH-ISSUE #6101] [Bug] ASAR header listing drops the caller's cancellation reason #3733

Closed
opened 2026-10-31 00:43:29 +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/6101

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

Cancellation during ASAR header listing loses the caller's exact abort reason. The production artifact provider returns ArtifactOperationError with reason: "cancelled" and cause === undefined, although the request signal still holds the object passed to AbortController.abort(reason).

This reproduced with real valid ASAR containers containing one member and 400 members. A synchronized scheduling wrapper invoked the real listing implementation, then queued the abort while it awaited its first traversal yield. Non-aborted inventories succeeded; pre-aborted requests preserved the exact caller reason. The cancellation code remained cancelled and cleanup was complete.

Reproduction

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

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

The assertions characterize current incorrect behavior. The exact self-contained test below passed both cases under Node 24.18.0. The same ASAR cases also passed in a combined four-case focused run under Node 24.18.0 and Node 24.22.0.

import { createHash } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, it, vi } from 'vitest';
import { ArtifactProvider } from '../../src/artifacts/ArtifactProvider.js';
import { projectAnalysisError } from '../../src/domain/analysisErrorProjection.js';
import { asarHeaderBytes } from '../fixtures/asarHeader.js';
import { createTestTempDirectory } from '../fixtures/temporaryDirectory.js';

const timing = vi.hoisted(() => ({ controller: undefined as AbortController | undefined, reason: undefined as unknown, listings: 0 }));
vi.mock('../../src/artifacts/AsarListing.js', async original => {
  const actual = await original<typeof import('../../src/artifacts/AsarListing.js')>();
  return { ...actual, readAsarListing: (...args: Parameters<typeof actual.readAsarListing>) => {
    const pending = actual.readAsarListing(...args);
    timing.listings++;
    if (timing.controller) queueMicrotask(() => timing.controller?.abort(timing.reason));
    return pending;
  } };
});

it.each([1, 400])('ASAR inventory loses the caller reason during listing with %i entries', async count => {
  const root = await createTestTempDirectory('rea-listing-reason-');
  const path = join(root, 'app.asar');
  const bytes = asarHeaderBytes(JSON.stringify({ files: Object.fromEntries(Array.from({ length: count }, (_, i) => [`f${i}`, { size: 0, offset: '0' }])) }));
  await writeFile(path, bytes);
  const target = { path, sourcePath: path, sha256: createHash('sha256').update(bytes).digest('hex'), kind: 'archive' as const, format: 'asar' as const };
  const client = new ArtifactProvider(process.env).createClient(target);
  const reason = { kind: 'caller-request-ended', token: count };
  const controller = new AbortController();
  timing.controller = controller; timing.reason = reason; timing.listings = 0;
  try {
    const result = await client.execute('inventory_artifact', {}, { signal: controller.signal });
    expect(timing.listings).toBe(1);
    expect(controller.signal.reason).toBe(reason);
    expect(result.ok).toBe(false);
    if (result.ok) throw new Error('Expected cancelled inventory');
    expect(result.error).toMatchObject({ reason: 'cancelled', cause: undefined, cleanupIncomplete: false });
    console.log('ASAR', JSON.stringify({ count, code: projectAnalysisError(result.error).code, reasonPreserved: result.error.cause === reason, cleanupIncomplete: result.error.cleanupIncomplete }));
    timing.controller = undefined;
    const control = await client.execute('inventory_artifact', {});
    expect(control.ok).toBe(true);
    const pre = new AbortController(); pre.abort(reason);
    const preResult = await client.execute('inventory_artifact', {}, { signal: pre.signal });
    expect(preResult.ok).toBe(false);
    if (!preResult.ok) expect(preResult.error.cause).toBe(reason);
  } finally { timing.controller = undefined; await client.close(); }
});

The module wrapper changes only cancellation timing. It calls the original listing function and returns its actual promise. Filesystem admission, the ASAR snapshot, traversal, cancellation construction, provider error translation and cleanup use production implementations. No native Electron application, CLI process or MCP transport was used.

Expected behavior

When listing observes an aborted signal, retain its exact reason in the producer's cancellation fact so the returned typed error has cause === signal.reason.

The lifecycle contract requires preserving the caller's abort reason as the typed cancellation cause. The testing contract requires synchronized cancellation and explicit reason-identity checks. Neighboring ASAR checkpoints already retain signal.reason.

Impact and scope

P2: a bounded loss of cancellation provenance. A caller relying on the documented typed cause cannot identify its supplied cancellation token from the returned failure. The operation still refuses correctly, returns cancellation classification, and reports no incomplete cleanup in these cases. Keeping the caller's signal/reason separately is a practical workaround.

There is no 128-member minimum: the first traversal iteration yields before inspecting its first node. One-member and 400-member archives both reproduced. Source-derived reachability includes artifact inspection and extraction through the shared ASAR reader. Other cancellation phases, real Electron applications, naturally occurring frequency, other platforms and transport delivery were not measured. No security boundary crossing or data loss was observed.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 arm64; Node.js 24.18.0 and 24.22.0; repository-locked Vitest 5.0.3. Source tests with locked dependencies; no build performed.

Target and artifact context

Authored, valid header-only ASAR containers with one or 400 packed zero-byte members. The fixture includes Chromium pickle alignment. No application execution or target mutation.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Both runtime runs produced these ASAR observation lines:

ASAR {"count":1,"code":"cancelled","reasonPreserved":false,"cleanupIncomplete":false}
ASAR {"count":400,"code":"cancelled","reasonPreserved":false,"cleanupIncomplete":false}

Both calls returned ok: false, reason: "cancelled" and cause: undefined; the signal's reason retained object identity. For each archive, a normal retry succeeded and a pre-aborted request returned the supplied object as its cause. The executed cancellation timing is at the first real listing yield, rather than an arbitrary delay after dispatch.

Typed outcome or recovery expected

Keep the existing cancellation classification and cleanup behavior. Preserve the exact opaque caller reason in the listing failure and returned ArtifactOperationError.cause. An absent reason should reflect the owner's actual cancellation observation, rather than a missing constructor option.

Analysis or suspected cause

readAsarListing throws new ArtifactReaderFailure("cancelled", "ASAR operation cancelled") without options. The constructor consequently records cancellation.reason from an absent cause. The cancellation accessor and artifact error translation faithfully propagate that undefined value. This source explanation agrees with the executed provider result.

Public reachability is source-derived: inspect-artifact forwards its signal into inspect_artifact; artifact inspection invokes inventory with the signal; the ASAR reader calls the affected listing function. The executed test enters at the production inventory_artifact provider boundary.

Additional context

Fetched upstream main d8a9673fc44fde4bd30ff98c241886e6683580ca leaves the listing throw, reader, cancellation representation, accessor and provider translation unchanged. Nearby resource-scope changes add shutdown hooks, root classification adds trailing ZIP support, and shared projection changes captured-output duplication; none supplies the missing cancellation fact. The newer revision was source-compared, not executed.

Related reports were checked by producer and fix. Electron target listing (#5993) loses completed-prefix diagnostics after ASAR descriptor cleanup failure; #5184 concerns swallowed descriptor-close failures in that different ASAR consumer. Neither covers the header-listing cancellation constructor. #4638 concerns OwnedCommand, which this listing path does not call. Closed PR #4711 changes Kuna query waits. Merged PR #5671 covers reverse, pcap and SQLite admission, and merged PR #4274 leaves this listing constructor intact.

Related cancellation-provenance reports cover other producers: JavaScript artifact reading (#5935), derived MCP operations (#5944), Rizin (#5959), call-path tracing (#5960), JavaScript recovery (#5964), managed static entry (#5985), JavaScript application checkpoints (#5987), asset catalogs (#5998), ZIP extraction (#5999), WABT (#6002), DMG commands (#6032), LLDB discovery (#6037), web-script export (#6042), managed snapshot finalization (#6065), and detached PGP verification (#6100). These reports concern separate producers; their local fixes do not supply the missing cancellation fact in ASAR header listing.

No first affected revision or last known good release was established; no fix or restoration was tested.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6101 ### 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 Cancellation during ASAR header listing loses the caller's exact abort reason. The production artifact provider returns `ArtifactOperationError` with `reason: "cancelled"` and `cause === undefined`, although the request signal still holds the object passed to `AbortController.abort(reason)`. This reproduced with real valid ASAR containers containing one member and 400 members. A synchronized scheduling wrapper invoked the real listing implementation, then queued the abort while it awaited its first traversal yield. Non-aborted inventories succeeded; pre-aborted requests preserved the exact caller reason. The cancellation code remained `cancelled` and cleanup was complete. ### Reproduction From this source revision with locked dependencies installed, save the following test as `tests/process-global/asarListingReason.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/process-global/asarListingReason.test.ts --maxWorkers=1 ``` The assertions characterize current incorrect behavior. The exact self-contained test below passed both cases under Node 24.18.0. The same ASAR cases also passed in a combined four-case focused run under Node 24.18.0 and Node 24.22.0. ```ts import { createHash } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { expect, it, vi } from 'vitest'; import { ArtifactProvider } from '../../src/artifacts/ArtifactProvider.js'; import { projectAnalysisError } from '../../src/domain/analysisErrorProjection.js'; import { asarHeaderBytes } from '../fixtures/asarHeader.js'; import { createTestTempDirectory } from '../fixtures/temporaryDirectory.js'; const timing = vi.hoisted(() => ({ controller: undefined as AbortController | undefined, reason: undefined as unknown, listings: 0 })); vi.mock('../../src/artifacts/AsarListing.js', async original => { const actual = await original<typeof import('../../src/artifacts/AsarListing.js')>(); return { ...actual, readAsarListing: (...args: Parameters<typeof actual.readAsarListing>) => { const pending = actual.readAsarListing(...args); timing.listings++; if (timing.controller) queueMicrotask(() => timing.controller?.abort(timing.reason)); return pending; } }; }); it.each([1, 400])('ASAR inventory loses the caller reason during listing with %i entries', async count => { const root = await createTestTempDirectory('rea-listing-reason-'); const path = join(root, 'app.asar'); const bytes = asarHeaderBytes(JSON.stringify({ files: Object.fromEntries(Array.from({ length: count }, (_, i) => [`f${i}`, { size: 0, offset: '0' }])) })); await writeFile(path, bytes); const target = { path, sourcePath: path, sha256: createHash('sha256').update(bytes).digest('hex'), kind: 'archive' as const, format: 'asar' as const }; const client = new ArtifactProvider(process.env).createClient(target); const reason = { kind: 'caller-request-ended', token: count }; const controller = new AbortController(); timing.controller = controller; timing.reason = reason; timing.listings = 0; try { const result = await client.execute('inventory_artifact', {}, { signal: controller.signal }); expect(timing.listings).toBe(1); expect(controller.signal.reason).toBe(reason); expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected cancelled inventory'); expect(result.error).toMatchObject({ reason: 'cancelled', cause: undefined, cleanupIncomplete: false }); console.log('ASAR', JSON.stringify({ count, code: projectAnalysisError(result.error).code, reasonPreserved: result.error.cause === reason, cleanupIncomplete: result.error.cleanupIncomplete })); timing.controller = undefined; const control = await client.execute('inventory_artifact', {}); expect(control.ok).toBe(true); const pre = new AbortController(); pre.abort(reason); const preResult = await client.execute('inventory_artifact', {}, { signal: pre.signal }); expect(preResult.ok).toBe(false); if (!preResult.ok) expect(preResult.error.cause).toBe(reason); } finally { timing.controller = undefined; await client.close(); } }); ``` The module wrapper changes only cancellation timing. It calls the original listing function and returns its actual promise. Filesystem admission, the ASAR snapshot, traversal, cancellation construction, provider error translation and cleanup use production implementations. No native Electron application, CLI process or MCP transport was used. ### Expected behavior When listing observes an aborted signal, retain its exact `reason` in the producer's cancellation fact so the returned typed error has `cause === signal.reason`. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L102-L111) requires preserving the caller's abort reason as the typed cancellation cause. The [testing contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/testing.md#L269-L275) requires synchronized cancellation and explicit reason-identity checks. [Neighboring ASAR checkpoints](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/AsarArtifactReader.ts#L658-L679) already retain `signal.reason`. ### Impact and scope P2: a bounded loss of cancellation provenance. A caller relying on the documented typed cause cannot identify its supplied cancellation token from the returned failure. The operation still refuses correctly, returns cancellation classification, and reports no incomplete cleanup in these cases. Keeping the caller's signal/reason separately is a practical workaround. There is no 128-member minimum: the first traversal iteration yields before inspecting its first node. One-member and 400-member archives both reproduced. Source-derived reachability includes artifact inspection and extraction through the shared ASAR reader. Other cancellation phases, real Electron applications, naturally occurring frequency, other platforms and transport delivery were not measured. No security boundary crossing or data loss was observed. ### REA version rea-agents 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 arm64; Node.js 24.18.0 and 24.22.0; repository-locked Vitest 5.0.3. Source tests with locked dependencies; no build performed. ### Target and artifact context Authored, valid header-only ASAR containers with one or 400 packed zero-byte members. The fixture includes Chromium pickle alignment. No application execution or target mutation. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Both runtime runs produced these ASAR observation lines: ```text ASAR {"count":1,"code":"cancelled","reasonPreserved":false,"cleanupIncomplete":false} ASAR {"count":400,"code":"cancelled","reasonPreserved":false,"cleanupIncomplete":false} ``` Both calls returned `ok: false`, `reason: "cancelled"` and `cause: undefined`; the signal's reason retained object identity. For each archive, a normal retry succeeded and a pre-aborted request returned the supplied object as its cause. The executed cancellation timing is at the first real listing yield, rather than an arbitrary delay after dispatch. ### Typed outcome or recovery expected Keep the existing cancellation classification and cleanup behavior. Preserve the exact opaque caller reason in the listing failure and returned `ArtifactOperationError.cause`. An absent reason should reflect the owner's actual cancellation observation, rather than a missing constructor option. ### Analysis or suspected cause [readAsarListing](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/AsarListing.ts#L41-L57) throws `new ArtifactReaderFailure("cancelled", "ASAR operation cancelled")` without options. [The constructor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactReader.ts#L198-L214) consequently records `cancellation.reason` from an absent cause. [The cancellation accessor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactFileOperation.ts#L155-L162) and [artifact error translation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactOperationFailure.ts#L20-L38) faithfully propagate that undefined value. This source explanation agrees with the executed provider result. Public reachability is source-derived: [inspect-artifact](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/artifactCommands.ts#L227-L267) forwards its signal into `inspect_artifact`; [artifact inspection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactProvider.ts#L422-L486) invokes inventory with the signal; [the ASAR reader](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/AsarArtifactReader.ts#L57-L85) calls the affected listing function. The executed test enters at the production `inventory_artifact` provider boundary. ### Additional context Fetched upstream main `d8a9673fc44fde4bd30ff98c241886e6683580ca` leaves the listing throw, reader, cancellation representation, accessor and provider translation unchanged. Nearby resource-scope changes add shutdown hooks, root classification adds trailing ZIP support, and shared projection changes captured-output duplication; none supplies the missing cancellation fact. The newer revision was source-compared, not executed. Related reports were checked by producer and fix. [Electron target listing (#5993)](https://github.com/morluto/rea/issues/5993) loses completed-prefix diagnostics after ASAR descriptor cleanup failure; [#5184](https://github.com/morluto/rea/issues/5184) concerns swallowed descriptor-close failures in that different ASAR consumer. Neither covers the header-listing cancellation constructor. [#4638](https://github.com/morluto/rea/issues/4638) concerns OwnedCommand, which this listing path does not call. Closed [PR #4711](https://github.com/morluto/rea/pull/4711) changes Kuna query waits. Merged [PR #5671](https://github.com/morluto/rea/pull/5671) covers reverse, pcap and SQLite admission, and merged [PR #4274](https://github.com/morluto/rea/pull/4274) leaves this listing constructor intact. Related cancellation-provenance reports cover other producers: [JavaScript artifact reading (#5935)](https://github.com/morluto/rea/issues/5935), [derived MCP operations (#5944)](https://github.com/morluto/rea/issues/5944), [Rizin (#5959)](https://github.com/morluto/rea/issues/5959), [call-path tracing (#5960)](https://github.com/morluto/rea/issues/5960), [JavaScript recovery (#5964)](https://github.com/morluto/rea/issues/5964), [managed static entry (#5985)](https://github.com/morluto/rea/issues/5985), [JavaScript application checkpoints (#5987)](https://github.com/morluto/rea/issues/5987), [asset catalogs (#5998)](https://github.com/morluto/rea/issues/5998), [ZIP extraction (#5999)](https://github.com/morluto/rea/issues/5999), [WABT (#6002)](https://github.com/morluto/rea/issues/6002), [DMG commands (#6032)](https://github.com/morluto/rea/issues/6032), [LLDB discovery (#6037)](https://github.com/morluto/rea/issues/6037), [web-script export (#6042)](https://github.com/morluto/rea/issues/6042), [managed snapshot finalization (#6065)](https://github.com/morluto/rea/issues/6065), and [detached PGP verification (#6100)](https://github.com/morluto/rea/issues/6100). These reports concern separate producers; their local fixes do not supply the missing cancellation fact in ASAR header listing. No first affected revision or last known good release was established; no fix or restoration was tested.
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#3733
No description provided.