Skip to content

fix(task-graph): harden CacheRef, canonicalize binary format, byte-bounded backpressure#557

Open
sroussey wants to merge 3 commits into
claude/stoic-bell-RLJWTfrom
claude/beautiful-mayer-b0do4n
Open

fix(task-graph): harden CacheRef, canonicalize binary format, byte-bounded backpressure#557
sroussey wants to merge 3 commits into
claude/stoic-bell-RLJWTfrom
claude/beautiful-mayer-b0do4n

Conversation

@sroussey

@sroussey sroussey commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Three follow-up fixes for the binary-streaming framework + result-as-reference work in #545. Targets claude/stoic-bell-RLJWT so the patches merge with the rest of that PR.

Fix 1 (CRITICAL) — Brand CacheRef with a literal kind

Files: packages/task-graph/src/cache/CacheRef.ts, packages/task-graph/src/task/CacheCoordinator.ts, packages/task-graph/src/cache/RunPrivateCacheRepo.ts + test updates.

CacheRef was discriminated by shape alone: any object with {$ref: string} satisfied isCacheRef. The cache-ref resolver walks task outputs and calls getOutputByRef(ref) on every match — so any code path surfacing an attacker-influenced {$ref: "cache://OTHER_RUN/secret"} (tool result, parsed JSON, AI structured output, embedded JSON-Schema reference) could trick resolveJobOutput / resolveOutput into reading bytes from another run or tenant's private cache slot.

Adds a literal kind: "task-graph/CacheRef" brand (a string, not a Symbol — so it survives JSON round-trip across queue rows / IPC), checked alongside $ref in isCacheRef. New makeCacheRef(...) helper constructs branded refs; CacheCoordinator.getBinaryRefSinksByPolicy and RunPrivateCacheRepo.saveOutputStream defensively re-wrap legacy backings.

Fix 2 (HIGH) — Canonical BinaryFormat vocabulary

Files: packages/task-graph/src/task/StreamTypes.ts, StreamProcessor.ts, CacheCoordinator.ts, TaskRegistry.ts + test updates.

materializeBinary previously accepted any string and silently coerced unknown values (including casing typos like "Blob") to the ArrayBuffer branch — a format: "Blob" mistake produced ArrayBuffers where every downstream consumer expected Blobs, with the mismatch only surfacing far from the task definition.

Introduces BinaryFormat = "blob" | "binary" and a single assertBinaryFormat(schema, port) helper used by StreamProcessor, CacheCoordinator.hydrateRefsBelowThreshold, and TaskRegistry.registerTask. Invalid format on an x-stream: "binary" port now throws at registration time, not during a streaming run.

Fix 3 (HIGH) — Byte-bounded backpressure (default 8 MiB)

Files: packages/task-graph/src/task/StreamProcessor.ts (BinaryStreamRouter), StreamTypes.ts, task-graph/StreamPump.ts (pipeBinaryToCache), ITask.ts, TaskRunner.ts + test updates.

BinaryStreamRouter buffered chunks without bound. A fast producer (AI image / audio generator yielding 1 MiB chunks) feeding a slow sink (remote object store, throttled FS) would race ahead and accumulate the whole payload in memory before the sink saw the first chunk.

Adds DEFAULT_BINARY_HIGH_WATER_BYTES = 8 MiB and converts push(chunk) to a Promise that parks the producer once bufferedBytes >= highWaterMarkBytes. end() and fail() release any parked producer so abort mid-park does not leak the Promise. IRunConfig.binaryHighWaterBytes allows per-run override. IExecuteContext.binaryBackpressure?: () => Promise<void> lets tasks emitting via a side channel cooperate; absent runtime supplies a no-op. StreamPump.pipeBinaryToCache gets the same byte-counted queue and exposes a backpressure() callable.

Test plan

  • All 824 task-graph vitest tests pass on the new branch (baseline: 805 on claude/stoic-bell-RLJWT)
  • bun run build:packages clean across all 71 turbo tasks
  • bun scripts/test.ts task vitest — 1117 pass / 24 skipped (no regressions)
  • New tests cover: JSON round-trip preserves the brand; isCacheRef rejects shape-only {$ref: string}; resolveOutput does not walk JSON-Schema refs; resolveJobOutput never calls getOutputByRef for attacker-supplied unbranded shapes; assertBinaryFormat throws for "Blob" / "wat" / accepts "blob" / "binary" / undefined; TaskRegistry.registerTask rejects format: "Blob" on a binary port; symmetric Blob / ArrayBuffer rehydration; 100 × 1 MiB through a 50 ms / chunk sink keeps peak buffer ≤ 4 MiB + 1 chunk; abort-while-parked settles within 100 ms; 100 MiB end-to-end through StreamProcessor.run with backpressure delivers every byte.

Follow-up to #545.


Generated by Claude Code

claude added 3 commits June 9, 2026 08:28
…ly collisions

`CacheRef` was discriminated by shape alone: any object with `{ $ref: string }`
satisfied `isCacheRef`, including JSON-Schema `$ref` pointers embedded in
metadata. The cache-ref resolver walks task outputs and calls
`getOutputByRef(ref)` on every match — so any code path that surfaces an
attacker-influenced `{$ref: "cache://OTHER_RUN/secret"}` shape (e.g. a tool
result, an AI structured-output field, a parsed-JSON document) could trick
`resolveJobOutput` / `resolveOutput` into reading bytes from another run or
tenant's private cache slot.

This patch adds a literal `kind: "task-graph/CacheRef"` brand discriminator
that:
  - survives JSON serialization across queue rows / IPC (Symbol-based brands
    would be erased by `JSON.stringify` and break cross-process resolution);
  - is checked by `isCacheRef` alongside the `$ref` string;
  - is applied uniformly by a new `makeCacheRef(...)` helper that callers
    use to construct refs.

`CacheCoordinator.getBinaryRefSinksByPolicy` and
`RunPrivateCacheRepo.saveOutputStream` now defensively re-wrap the value
returned by legacy backings (`isCacheRef(raw) ? raw : makeCacheRef(raw)`),
so a backing that pre-dates the brand still produces a discriminator-bearing
ref when seen through the framework. In-tree test repositories and callers
are updated to use `makeCacheRef`.

Test coverage:
  - `CacheRef.test.ts` now expects shape-only `{$ref: string}` to be rejected
    and exercises JSON round-trip preserving the brand.
  - `resolveOutput.test.ts` adds a case where a JSON-Schema-shaped
    `{schema: {$ref: "#/\$defs/Foo"}}` is left untouched and the resolver is
    never called (identity preserved).
  - `resolveJobOutput.test.ts` adds the cross-tenant attack case: an
    attacker-supplied `{note: {\$ref: "cache://OTHER_RUN/secret"}}` never
    invokes `getOutputByRef`.
…b"|"binary"

`materializeBinary` previously accepted any string and silently coerced
unknown values (including casing typos like `"Blob"`) to the ArrayBuffer
branch. A task author writing `format: "Blob"` would unknowingly produce an
ArrayBuffer where every downstream consumer expected a Blob — and the
mismatch only surfaced at the consumer (often as a misleading runtime
error during streaming, or worse, silent data corruption when the consumer
duck-typed both shapes).

This patch establishes a canonical `BinaryFormat = "blob" | "binary"` type
and routes every binary-port consumer through a single
`assertBinaryFormat(schema, port)` helper:

  - `undefined` and `"blob"` resolve to `"blob"` (the documented default);
  - `"binary"` resolves to `"binary"`;
  - anything else throws with the allowed vocabulary in the message.

`materializeBinary` now takes the canonical `BinaryFormat` directly and
`StreamProcessor` / `CacheCoordinator.hydrateRefsBelowThreshold` both call
`assertBinaryFormat` before invoking it.

`TaskRegistry.registerTask` runs the same check at registration time over
every output port with `x-stream: "binary"`, so the typo fails near the
task definition rather than during a streaming run. The task is not added
to the registry when the check fails.

Test coverage:
  - `StreamBinaryTypes.test.ts` replaces the now-removed "unknown format =
    binary" behavior with `assertBinaryFormat` cases for `"blob"`,
    `"binary"`, undefined-default, the casing typo `"Blob"`, and an unknown
    value (`"wat"`).
  - `TaskRegistry.test.ts` adds cases asserting registration throws on a
    binary port with `format: "Blob"`, and succeeds on `"blob"` /
    `"binary"`.
  - `Spec2QueueRowAndRehydrate.test.ts` adds symmetric rehydration cases:
    `format: "blob"` rehydrates into a `Blob`, `format: "binary"` into an
    `ArrayBuffer`.
…efault 8 MiB)

The streaming binary router buffered chunks without bound. A fast producer
(e.g. an AI image / audio generator yielding 1 MiB chunks) feeding a slow
sink (remote object store, throttled FS) would let the producer race ahead
and accumulate the entire payload in memory before the sink saw the first
chunk — turning a notionally O(1) streaming path into peak-residency O(N).
The old comment even acknowledged the issue ("backpressure: there is none")
and offloaded the problem onto the sink author.

This patch:

  - Introduces `DEFAULT_BINARY_HIGH_WATER_BYTES = 8 MiB` in `StreamTypes.ts`.
  - `BinaryStreamRouter` now tracks `bufferedBytes` (sum of un-consumed
    chunk sizes). `push(chunk)` returns a Promise that resolves
    immediately while `bufferedBytes < highWaterMarkBytes`, and parks the
    producer until the consumer drains under the mark otherwise. `end()`
    and `fail()` BOTH release any parked producer so an abort mid-park
    does not leak the Promise.
  - `StreamProcessor` `await router.push(...)` on every `binary-delta`
    yield, so the byte-bounded backpressure applies for tasks running
    through the standard streaming path.
  - `IRunConfig.binaryHighWaterBytes` lets callers override per-run.
    Threaded through `TaskRunner` → `StreamProcessor.run` deps.
  - `IExecuteContext.binaryBackpressure?: () => Promise<void>` is a
    cooperative hook for tasks that emit via a side channel and cannot
    use the awaited `push` path; the StreamProcessor and StreamPump
    install router-aware implementations, and an absent runtime supplies
    a no-op (free for tasks that don't call it).
  - `StreamPump.pipeBinaryToCache` (the EventEmitter path used for the
    cache-ingest tee) gets the same byte-counted queue and returns a
    `backpressure()` function alongside `promise` / `detach`.

Test coverage in `StreamingBackpressure.test.ts` adds a "binary
backpressure" describe block:

  - 100 × 1 MiB through a slow (50 ms / chunk) sink with a 4 MiB
    high-water mark: peak buffer stays at or below `mark + 1 chunk`
    and every byte is delivered.
  - End-to-end: 100 MiB through `StreamProcessor.run` with the same
    high-water mark, asserting full delivery without drops.
  - Abort-while-parked: a producer parked at the high-water mark sees
    its `push()` Promise settle within 100 ms of `r.end()`.
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.2% 25243 / 40579
🔵 Statements 62.05% 26110 / 42076
🔵 Functions 63.13% 4763 / 7544
🔵 Branches 51.03% 12430 / 24358
File CoverageNo changed files found.
Generated in workflow #2532 for commit 9588cb1 by the Vitest Coverage Report Action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants