Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Version headings describe repository changes. Check [npm](https://www.npmjs.com/package/@agent-axiom/streamsplit) for published versions.

## Unreleased

- Add optional `AbortSignal` cancellation to checking, assertions, and exact fixture replay, with a payload-redacted `CheckAbortedError`.
- Stop pending harness work without claiming to terminate the parser; clean up listeners and handle late emissions/rejections.
- Keep the version-1 replay format and zero-runtime-dependency contract unchanged.

## 0.1.1

Documentation, examples, and release-tooling update. The public API, parser harness implementation, Node >=20 requirement, and zero-runtime-dependency contract are unchanged.
Expand Down
18 changes: 18 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ All offsets and sizes are bytes. `createParser(emit)` must return a new object w
- `ConfigurationError`: invalid options, factory contract, schedules, or replay schema.
- `LimitExceededError`: input/output/execution budget exceeded.
- `ParserTimeoutError`: elapsed per-run deadline exceeded.
- `CheckAbortedError`: the supplied `AbortSignal` was aborted; cancellation never returns a partial pass or failure.
- `InvalidEventError`: unsupported output or normalization failure.

These extend `StreamSplitError`. Keep harness failures separate from parser failures. All public types ship in the package declarations.
Expand Down Expand Up @@ -62,10 +63,27 @@ For deliberately invalid fixtures, opt into `errorPolicy: 'compare'`. Outcomes m
| `maxEvents` | `10000` | Emitted events per run |
| `maxOutputCharacters` | `1048576` | Canonical JSON characters per run, including normalized errors |
| `timeoutMs` | `2000` | Elapsed deadline for each parser run |
| `signal` | none | Optional `AbortSignal` to cancel checking or exact fixture replay |

A schedule's nonnegative integer sizes must sum to the exact byte length. Sizes of zero call `write(new Uint8Array())`. Empty input gets one empty baseline write. Custom and generated schedules are deduplicated, and the baseline is not retested as a candidate.

Coverage reports the seed, bytes, schedules checked, parser runs/calls, number of single cuts actually checked, `allSingleCutsChecked`, and `bytewiseChecked`. A failure's coverage reflects work up to that failure, including reduction calls. Reaching an execution budget during normal checking throws `LimitExceededError`; it never returns a partial pass. Setting all schedule generators off is allowed, but only checks baseline stability unless you supply custom schedules.

`generateSchedules(inputLength, options)` exposes the deterministic schedule generator for inspection. Generated partitions vary small-read and broader-read scales; they are not a uniform sample of all possible partitions. Empty-chunk testing is a documented stress case; set `emptyChunks: false` if your adapter deliberately excludes it.

## Cancellation

Pass an `AbortSignal` to any asynchronous entry point to stop a check, assertion, or replay when its caller cancels:

```ts
const controller = new AbortController();
const pending = checkChunkInvariant({ input, createParser, signal: controller.signal });
// For example, call this from your test runner's cancellation handler:
controller.abort();
await pending; // rejects with CheckAbortedError
```

A pre-aborted signal rejects before the parser factory runs. A live signal can stop pending factory/write/end operations, schedule checking, stability checks, and reduction. Cancellation also wins if a parser or normalization hook aborts the signal before returning or throwing. It is a harness error even under `errorPolicy: 'compare'`; no partial result is returned. The error does not copy the signal's reason or attach it as a cause, because it may contain private data.

Cancellation stops the harness from starting further parser calls. It does not forcibly terminate pending parser work, interrupt blocked synchronous JavaScript, or defeat microtask starvation. Give the same signal to your adapter's own cancellable I/O when needed. Later emissions are ignored, and later promise rejections remain handled. StreamSplit removes its own abort listeners after each lifecycle call, without removing caller listeners. Signals are not saved in replay fixtures; pass a new signal when replaying.

2 changes: 2 additions & 0 deletions docs/SAFETY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

A timeout can reject a pending asynchronous operation and stops the check. It cannot forcibly cancel that operation, interrupt synchronously blocked JavaScript, or defeat microtask starvation. A synchronous operation that eventually returns or throws after the deadline is reported as a timeout. Use worker/process isolation for untrusted or potentially nonterminating parsers.

An optional `AbortSignal` stops checking or replay with `CheckAbortedError`, including while awaiting a parser operation. It has the same isolation limits: pending parser work is not forcibly cancelled, and synchronous work or microtask starvation can delay an external cancellation handler. The abort reason is never included in the error or fixture. See [cancellation](API.md#cancellation) for the complete contract.

The factory, parser methods, and normalization hooks are caller-provided trusted code. StreamSplit is not a sandbox. Bounds constrain the harness; they do not bound arbitrary allocations or work inside the parser. A fixed seed reproduces schedules, not external I/O, random application state, or time-dependent behavior.

There is no telemetry, network access, file access, dynamic module loading, or logging in the runtime library. It runs only the parser and hooks supplied by the caller. **Fixtures contain exact input bytes, and failure objects contain emitted values/errors.** They can hold secrets. Keep sensitive fixtures out of public issue reports and CI logs. The default assertion message omits payload values; inspect failure objects intentionally.
Expand Down
15 changes: 11 additions & 4 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ try {
assert.equal(files.some(file => /^(test|examples|scripts|node_modules|\.github)\//.test(file.path)), false);
writeFileSync(join(directory, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
execFileSync('npm', ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', join(directory, filename)], { cwd: directory, env, stdio: 'pipe' });
const consumer = `import { assertChunkInvariant, checkChunkInvariant, replayChunkInvariant } from '@agent-axiom/streamsplit';
const consumer = `import { assertChunkInvariant, checkChunkInvariant, replayChunkInvariant, CheckAbortedError } from '@agent-axiom/streamsplit';
import assert from 'node:assert/strict';
const input = new TextEncoder().encode('package consumer');
const coverage = await assertChunkInvariant({ input, createParser: emit => {
const parts = []; return { write(chunk) { parts.push(...chunk); }, end() { emit(parts); } };
Expand All @@ -29,13 +30,19 @@ const result = await checkChunkInvariant({ input, createParser: broken });
if (result.ok) throw Error('Broken parser passed');
const replay = await replayChunkInvariant({ fixture: result.failure.fixture, createParser: broken });
if (replay.ok) throw Error('Replay did not reproduce');
console.log('Packed ESM consumer and replay passed.');\n`;
await assert.rejects(checkChunkInvariant({ input, createParser: broken, signal: AbortSignal.abort('private') }), CheckAbortedError);
await assert.rejects(replayChunkInvariant({ fixture: result.failure.fixture, createParser: broken, signal: AbortSignal.abort() }), CheckAbortedError);
console.log('Packed ESM consumer, replay, and cancellation passed.');\n`;
writeFileSync(join(directory, 'consumer.mjs'), consumer);
execFileSync(process.execPath, ['consumer.mjs'], { cwd: directory, stdio: 'inherit' });
writeFileSync(join(directory, 'consumer.mts'), `import { assertChunkInvariant, type ParserFactory } from '@agent-axiom/streamsplit';
writeFileSync(join(directory, 'consumer.mts'), `import { assertChunkInvariant, replayChunkInvariant, CheckAbortedError, StreamSplitError, type ParserFactory, type ReplayOptions } from '@agent-axiom/streamsplit';
const createParser: ParserFactory<number> = emit => ({ write(chunk) { emit(chunk.length); } });
const coverage = await assertChunkInvariant({ input: new Uint8Array(), createParser });
const signal = new AbortController().signal;
const coverage = await assertChunkInvariant({ input: new Uint8Array(), createParser, signal });
coverage.parserRuns satisfies number;
const replay: ReplayOptions<number> = { fixture: { version: 1, inputHex: '', chunkSizes: [0], seed: 0, errorPolicy: 'reject' }, createParser, signal };
await replayChunkInvariant(replay);
new CheckAbortedError() satisfies StreamSplitError;
// @ts-expect-error Text input is not a byte fixture.
await assertChunkInvariant({ input: 'no', createParser });\n`);
execFileSync(process.execPath, [join(root, 'node_modules/typescript/bin/tsc'), '--noEmit', '--strict', '--target', 'ES2022', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', 'consumer.mts'], { cwd: directory, stdio: 'inherit' });
Expand Down
5 changes: 5 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export class StreamSplitError extends Error {
export class ConfigurationError extends StreamSplitError {}
export class LimitExceededError extends StreamSplitError {}
export class ParserTimeoutError extends StreamSplitError {}
export class CheckAbortedError extends StreamSplitError {
constructor() {
super('StreamSplit check was aborted. Pending parser work is not forcibly cancelled.');
}
}
export class InvalidEventError extends StreamSplitError {}
export class NonDeterministicParserError extends StreamSplitError {
constructor(readonly chunkSizes: readonly number[]) {
Expand Down
Loading
Loading