[GH-ISSUE #5999] [Bug] ZIP member extraction drops the caller cancellation reason #3667

Closed
opened 2026-10-31 00:42:52 +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/5999

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 a real ZIP member stream after it opens loses the supplied abort reason. The ZIP reader emits ArtifactReaderFailure with reason: "cancelled" and cancellation.reason === undefined. The real artifact provider's extract_artifact result is ArtifactOperationError with an undefined direct cause; its public projection remains code: "cancelled", category: "cancelled".

A pre-aborted reader.open control preserves the exact caller object. Extraction rolled back its output. These observations use real ZIP bytes and production reader/provider/output code with a controlled abort interleaving, not CLI or MCP transport cancellation.

Reproduction

Add the following focused test under src/zipCancellationReason.test.ts and run node_modules/.bin/vitest run src/zipCancellationReason.test.ts --maxWorkers=1 --no-file-parallelism. The tests were executed together with a WABT cancellation control. Both ZIP exact-cause assertions failed; a diagnostic run asserting the observed missing causes passed. The iterator seam schedules the abort after the first chunk consumed by extraction; archive bytes, error production, translation and rollback remain real.

import { randomBytes } from 'node:crypto';
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from '@zip.js/zip.js';
import { expect, it, vi } from 'vitest';
import { ZipArtifactReader } from './artifacts/ZipArtifactReader.js';
import { ArtifactReaderFailure } from './artifacts/ArtifactReader.js';
import { translateArtifactFailure } from './artifacts/ArtifactOperationFailure.js';
import { ArtifactProvider } from './artifacts/ArtifactProvider.js';
import { SafeOutputTree } from './artifacts/SafeOutputTree.js';
import { parseBinaryTarget } from './application/BinaryTargetResolver.js';
import { projectAnalysisError } from './domain/analysisErrorProjection.js';

async function fixture() {
 const root = await mkdtemp(join(tmpdir(), 'rea-cancel-reason-'));
 const path = join(root, 'sample.zip');
 const bytes = randomBytes(2 * 1024 * 1024);
 const writer = new ZipWriter(new Uint8ArrayWriter());
 await writer.add('member.bin', new Uint8ArrayReader(bytes), { level: 0 });
 await writeFile(path, await writer.close());
 return { root, path, bytes };
}

it('ZIP stream drops reason after open while admission control retains it', async () => {
 const { root, path } = await fixture(); const reader = new ZipArtifactReader(path, 'zip');
 const controller = new AbortController(); const reason = { requested: 'stop ZIP' };
 try {
  const entries = []; for await (const entry of reader.entries()) entries.push(entry);
  const entry = entries[0]!; const stream = await reader.open(entry, controller.signal);
  const consumption = (async () => { for await (const _chunk of stream) controller.abort(reason); })();
  const failure = await consumption.catch(cause => cause);
  expect(failure).toBeInstanceOf(ArtifactReaderFailure);
  expect(failure.reason).toBe('cancelled');
  const translated = translateArtifactFailure('extract_artifact', failure);
  console.log('zip-stream', { tag: translated._tag, reason: failure.reason, producerReason: failure.cancellation?.reason, exactReason: translated.cause === reason, code: projectAnalysisError(translated).code });
  expect(failure.cancellation?.reason).toBeUndefined();
  
  const admission = await Promise.resolve().then(() => reader.open(entry, controller.signal)).catch(cause => cause);
  expect(admission.cancellation.reason).toBe(reason);
  expect(translated.cause).toBe(reason);
 } finally { await reader.close(); await rm(root, { recursive: true, force: true }); }
});

it('ZIP public provider extraction observes stream failure without caller reason', async () => {
 const { root, path } = await fixture(); const output = join(root, 'output');
 const controller = new AbortController(); const reason = { requested: 'stop extraction' };
 const target = await parseBinaryTarget(path); if (!target.ok) throw target.error;
 const client = new ArtifactProvider(process.env).createClient(target.value);
 const original = SafeOutputTree.prototype.write;
 const spy = vi.spyOn(SafeOutputTree.prototype, 'write').mockImplementation(function (...args) {
  const source = args[1]; const originalIterator = source[Symbol.asyncIterator].bind(source);
  source[Symbol.asyncIterator] = () => {
   const iterator = originalIterator(); let first = true;
   const next = iterator.next.bind(iterator);
   iterator.next = async (...input) => { const result = await next(...input); if (first && !result.done) { first = false; setImmediate(() => controller.abort(reason)); } return result; };
   return iterator;
  };
  return original.apply(this, args);
 });
 try {
  const result = await client.execute('extract_artifact', { output_root: output }, { signal: controller.signal });
  expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected cancellation');
  console.log('zip-provider', { tag: result.error._tag, exactReason: result.error.cause === reason, causeAbsent: result.error.cause === undefined, projection: projectAnalysisError(result.error) });
  expect(controller.signal.aborted).toBe(true);
  expect(result.error.cause).toBe(reason);
  await expect(access(output)).rejects.toMatchObject({ code: 'ENOENT' });
 } finally { spy.mockRestore(); await client.close(); await rm(root, { recursive: true, force: true }); }
});

Expected behavior

Preserve the exact supplied abort reason as the direct cause of the typed cancelled artifact error. The lifecycle contract requires caller-reason provenance through wrapping; the shared artifact cancellation accessor already consumes the producer-owned reason.

Impact and scope

P2: bounded cancellation-provenance loss during ZIP member consumption. Cancellation classification and extraction rollback still work, but code consuming the typed error cannot recover the original reason from this failure. A caller retaining its own signal/reason has a practical workaround. No data corruption, cleanup failure, real-client policy incident or platform prevalence was established. Public JSON does not promise serialization of arbitrary opaque causes; this concerns the typed in-process boundary.

REA version

Source revision 52415975942a4b9c53a817915b313ccc404f280e; package metadata reports 6.7.0.

Runtime and platform

macOS 27.0.1 arm64, Node v26.12.0; repository-pinned Vitest 5.0.3.

Target and artifact context

Generated ZIP containing one 2 MiB stored member of random bytes; no target program executed.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Executed diagnostics:

ZIP reader: reason=cancelled, cancellation.reason=undefined
Translated error: ArtifactOperationError, cause=undefined, code=cancelled
Pre-aborted reader.open control: cancellation.reason === caller reason
Provider extraction: ArtifactOperationError, cause=undefined
Provider projection: code=cancelled, category=cancelled
Output rollback: output tree absent
Expected direct-cause assertions: 2 failed (received undefined)

The ZIP abort listener constructs a cancellation without error options. ArtifactReaderFailure derives the cancellation reason from those options. Translation reads that now-empty reason. Public reachability is source-derived from provider extraction, member materialization, CLI registration and MCP registration.

Typed outcome or recovery expected

Keep the existing ArtifactOperationError cancellation classification, operation and cleanup state, with error.cause === signal.reason. No public schema change or arbitrary-cause serialization is needed.

Analysis or suspected cause

Source-derived: extractStream.onAbort forwards the reason to its private controller, but constructs the caller-facing ArtifactReaderFailure without { cause: signal.reason } or an explicit cancellation record. Downstream translation already selects the record correctly; it cannot reconstruct a value the stream producer omitted.

Additional context

Related reports cover the same cancellation contract at different producers: #5935 (JavaScript artifact reader), #5944 (derived MCP operations), #5959 (Rizin), #5960 (call-path traversal), #5964 (JavaScript recovery), #5985 (managed entry), and #5987 (static JavaScript checkpoints). Those producer fixes do not change the boundary above.

PR #5672 handles source errors during output setup and preserves existing causes; it does not change this ZIP constructor. #4846 concerns JADX reader-close ownership, and PR #962 ASAR streaming. Neither covers this loss.

Fetched upstream main c538a843cb49f7156f86beb3b21501e03255af9a and source-compared the relevant reader/provider/extraction/translation files: they are unchanged from the tested revision. The newer revision was not executed. NOT_ENOUGH_INFO: introducing commit, last known good release, naturally occurring frequency, or an equivalent ASAR runtime comparison.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5999 ### 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 Cancelling a real ZIP member stream after it opens loses the supplied abort reason. The ZIP reader emits `ArtifactReaderFailure` with `reason: "cancelled"` and `cancellation.reason === undefined`. The real artifact provider's `extract_artifact` result is `ArtifactOperationError` with an undefined direct cause; its public projection remains `code: "cancelled"`, `category: "cancelled"`. A pre-aborted `reader.open` control preserves the exact caller object. Extraction rolled back its output. These observations use real ZIP bytes and production reader/provider/output code with a controlled abort interleaving, not CLI or MCP transport cancellation. ### Reproduction Add the following focused test under `src/zipCancellationReason.test.ts` and run `node_modules/.bin/vitest run src/zipCancellationReason.test.ts --maxWorkers=1 --no-file-parallelism`. The tests were executed together with a WABT cancellation control. Both ZIP exact-cause assertions failed; a diagnostic run asserting the observed missing causes passed. The iterator seam schedules the abort after the first chunk consumed by extraction; archive bytes, error production, translation and rollback remain real. ```ts import { randomBytes } from 'node:crypto'; import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from '@zip.js/zip.js'; import { expect, it, vi } from 'vitest'; import { ZipArtifactReader } from './artifacts/ZipArtifactReader.js'; import { ArtifactReaderFailure } from './artifacts/ArtifactReader.js'; import { translateArtifactFailure } from './artifacts/ArtifactOperationFailure.js'; import { ArtifactProvider } from './artifacts/ArtifactProvider.js'; import { SafeOutputTree } from './artifacts/SafeOutputTree.js'; import { parseBinaryTarget } from './application/BinaryTargetResolver.js'; import { projectAnalysisError } from './domain/analysisErrorProjection.js'; async function fixture() { const root = await mkdtemp(join(tmpdir(), 'rea-cancel-reason-')); const path = join(root, 'sample.zip'); const bytes = randomBytes(2 * 1024 * 1024); const writer = new ZipWriter(new Uint8ArrayWriter()); await writer.add('member.bin', new Uint8ArrayReader(bytes), { level: 0 }); await writeFile(path, await writer.close()); return { root, path, bytes }; } it('ZIP stream drops reason after open while admission control retains it', async () => { const { root, path } = await fixture(); const reader = new ZipArtifactReader(path, 'zip'); const controller = new AbortController(); const reason = { requested: 'stop ZIP' }; try { const entries = []; for await (const entry of reader.entries()) entries.push(entry); const entry = entries[0]!; const stream = await reader.open(entry, controller.signal); const consumption = (async () => { for await (const _chunk of stream) controller.abort(reason); })(); const failure = await consumption.catch(cause => cause); expect(failure).toBeInstanceOf(ArtifactReaderFailure); expect(failure.reason).toBe('cancelled'); const translated = translateArtifactFailure('extract_artifact', failure); console.log('zip-stream', { tag: translated._tag, reason: failure.reason, producerReason: failure.cancellation?.reason, exactReason: translated.cause === reason, code: projectAnalysisError(translated).code }); expect(failure.cancellation?.reason).toBeUndefined(); const admission = await Promise.resolve().then(() => reader.open(entry, controller.signal)).catch(cause => cause); expect(admission.cancellation.reason).toBe(reason); expect(translated.cause).toBe(reason); } finally { await reader.close(); await rm(root, { recursive: true, force: true }); } }); it('ZIP public provider extraction observes stream failure without caller reason', async () => { const { root, path } = await fixture(); const output = join(root, 'output'); const controller = new AbortController(); const reason = { requested: 'stop extraction' }; const target = await parseBinaryTarget(path); if (!target.ok) throw target.error; const client = new ArtifactProvider(process.env).createClient(target.value); const original = SafeOutputTree.prototype.write; const spy = vi.spyOn(SafeOutputTree.prototype, 'write').mockImplementation(function (...args) { const source = args[1]; const originalIterator = source[Symbol.asyncIterator].bind(source); source[Symbol.asyncIterator] = () => { const iterator = originalIterator(); let first = true; const next = iterator.next.bind(iterator); iterator.next = async (...input) => { const result = await next(...input); if (first && !result.done) { first = false; setImmediate(() => controller.abort(reason)); } return result; }; return iterator; }; return original.apply(this, args); }); try { const result = await client.execute('extract_artifact', { output_root: output }, { signal: controller.signal }); expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected cancellation'); console.log('zip-provider', { tag: result.error._tag, exactReason: result.error.cause === reason, causeAbsent: result.error.cause === undefined, projection: projectAnalysisError(result.error) }); expect(controller.signal.aborted).toBe(true); expect(result.error.cause).toBe(reason); await expect(access(output)).rejects.toMatchObject({ code: 'ENOENT' }); } finally { spy.mockRestore(); await client.close(); await rm(root, { recursive: true, force: true }); } }); ``` ### Expected behavior Preserve the exact supplied abort reason as the direct cause of the typed cancelled artifact error. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) requires caller-reason provenance through wrapping; the [shared artifact cancellation accessor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactFileOperation.ts#L155-L172) already consumes the producer-owned reason. ### Impact and scope P2: bounded cancellation-provenance loss during ZIP member consumption. Cancellation classification and extraction rollback still work, but code consuming the typed error cannot recover the original reason from this failure. A caller retaining its own signal/reason has a practical workaround. No data corruption, cleanup failure, real-client policy incident or platform prevalence was established. Public JSON does not promise serialization of arbitrary opaque causes; this concerns the typed in-process boundary. ### REA version Source revision `52415975942a4b9c53a817915b313ccc404f280e`; package metadata reports 6.7.0. ### Runtime and platform macOS 27.0.1 arm64, Node v26.12.0; repository-pinned Vitest 5.0.3. ### Target and artifact context Generated ZIP containing one 2 MiB stored member of random bytes; no target program executed. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Executed diagnostics: ```text ZIP reader: reason=cancelled, cancellation.reason=undefined Translated error: ArtifactOperationError, cause=undefined, code=cancelled Pre-aborted reader.open control: cancellation.reason === caller reason Provider extraction: ArtifactOperationError, cause=undefined Provider projection: code=cancelled, category=cancelled Output rollback: output tree absent Expected direct-cause assertions: 2 failed (received undefined) ``` The [ZIP abort listener](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ZipArtifactReader.ts#L366-L406) constructs a cancellation without error options. [ArtifactReaderFailure](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactReader.ts#L188-L220) derives the cancellation reason from those options. [Translation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactOperationFailure.ts#L16-L38) reads that now-empty reason. Public reachability is source-derived from [provider extraction](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactProvider.ts#L288-L300), [member materialization](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/extraction/ArtifactExtraction.ts#L330-L340), [CLI registration](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/artifactCommands.ts#L201-L224) and [MCP registration](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerArtifactTools.ts#L30-L49). ### Typed outcome or recovery expected Keep the existing `ArtifactOperationError` cancellation classification, operation and cleanup state, with `error.cause === signal.reason`. No public schema change or arbitrary-cause serialization is needed. ### Analysis or suspected cause Source-derived: `extractStream.onAbort` forwards the reason to its private controller, but constructs the caller-facing `ArtifactReaderFailure` without `{ cause: signal.reason }` or an explicit cancellation record. Downstream translation already selects the record correctly; it cannot reconstruct a value the stream producer omitted. ### Additional context Related reports cover the same cancellation contract at different producers: [#5935](https://github.com/morluto/rea/issues/5935) (JavaScript artifact reader), [#5944](https://github.com/morluto/rea/issues/5944) (derived MCP operations), [#5959](https://github.com/morluto/rea/issues/5959) (Rizin), [#5960](https://github.com/morluto/rea/issues/5960) (call-path traversal), [#5964](https://github.com/morluto/rea/issues/5964) (JavaScript recovery), [#5985](https://github.com/morluto/rea/issues/5985) (managed entry), and [#5987](https://github.com/morluto/rea/issues/5987) (static JavaScript checkpoints). Those producer fixes do not change the boundary above. [PR #5672](https://github.com/morluto/rea/pull/5672) handles source errors during output setup and preserves existing causes; it does not change this ZIP constructor. [#4846](https://github.com/morluto/rea/issues/4846) concerns JADX reader-close ownership, and [PR #962](https://github.com/morluto/rea/pull/962) ASAR streaming. Neither covers this loss. Fetched upstream main `c538a843cb49f7156f86beb3b21501e03255af9a` and source-compared the relevant reader/provider/extraction/translation files: they are unchanged from the tested revision. The newer revision was not executed. NOT_ENOUGH_INFO: introducing commit, last known good release, naturally occurring frequency, or an equivalent ASAR runtime comparison.
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#3667
No description provided.