Skip to content

Decide how the process demux bounds an unread sink #8

Description

@amondnet

The process demux in packages/core/src/sandbox/harness/demux.ts buffers a sink nobody reads without bound. drainUntil serves one stream by reading the shared source, and enqueues the other stream's chunks as a side effect — so a caller that reads one stream and neither reads nor cancel()s the other accumulates the whole of that other stream in memory. A large sandbox log can exhaust the worker.

Raised by cubic on #7 and deliberately not fixed there: a correct fix needs a decision this codebase has not made. The code carries a standing hazard note recording the same thing.

What was tried, and what it measured

A per-sink occupancy cap (8 MiB) was implemented and then withdrawn. Two defects, both measured by instrumenting the demux to record peak per-sink bytes across eight interleavings with 1 MiB chunks, both streams read to completion.

The accounting was inflated. pull does not re-fire per chunk, so a running total keeps charging chunks the consumer has already read. Deriving occupancy from 1 - controller.desiredSize plus a mirrored FIFO of chunk sizes corrected it — the 2:1 interleaving's peak fell from 3.0 MiB to 1.0 MiB.

With the accounting corrected, the overshoot was still real. A consumer lagging one macrotask per read peaks at 9 MiB and gets errored — on a stream it is reading to completion. No sink applies backpressure to the shared reader, so a live-but-slower consumer falls behind without bound, and any fixed occupancy cap must eventually fire on a working stream.

The obvious escape does not work either. Erroring only a sink whose consumer has taken nothing fails unfixably: microtasks starve macrotasks completely — measured at 100,000 microtask turns before a single setTimeout(0) fired — so a consumer on a macrotask cadence takes nothing while the drain runs, and "took nothing" cannot distinguish a slow consumer from an absent one.

Why the naive fixes are wrong

Honoring the other sink's desiredSize reintroduces the hang the current design exists to avoid: one shared reader feeds both sinks, and drainUntil enqueues the other source's events on purpose so a consumer reading a single stream to completion does not deadlock.

Option Cost
Block on the other sink's demand Hangs any single-stream consumer
Drop past a cap with a truncation marker The harness byte streams have no representation for one — the same reason truncated events are already dropped
Error past a cap Withdrawn above: fires on working streams

What closing this actually requires

One of:

  • A contract obligation on callers — every consumer must read both streams or cancel() the one it does not need. That makes pausing the drain legitimate rather than a hang, and backpressure becomes implementable. It is a public-contract change, which is why it is not a review-fix decision.
  • A spill destination that is not the heap — bound memory without dropping data or erroring.

Exposure today

Narrow. run reads both streams concurrently via Promise.all, so it never accumulates. Only a spawn caller that consumes one stream and abandons the other without cancelling is affected.

Notes

  • src/sandbox/harness/** is vendored from chatbot-pf/pleaseworks and relicensed to Apache-2.0, so whichever way this is decided is also a divergence decision.
  • Two lagging-consumer tests in test/sandbox/harness/process.demux.test.ts pin the property the cap broke — any future guard has to keep them green.

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions