Skip to content

serve capsules by STREAMING — three paths still read a whole capsule into RAM #302

Description

@MichaelTaylor3d

Task

Serve capsules by streaming, never by reading the whole capsule into RAM. User directive, 2026-08-22:

"dig-node should be streaming data out of capsules not reading the whole capsule into ram to serve it.
This is antithetical to what the capsule was supposed to support and achieve."

The capsule format was designed for seeking and ranged verification. Any path that materialises a whole
capsule to answer a request is working against the format, and it converts a ~200-byte request into a
~135 MiB allocation.

Measured — and the good news is most of the serve path ALREADY streams

Do not rewrite what is already right:

  • dig.getContent / dig.getCapsule seek and return one WINDOW at a time (lib.rs:1347). The
    content serve path is already windowed.
  • dig.fetchRange exists with a per-range verification contract (seams::content::range_frame,
    lib.rs:105-106, #1577).
  • Peers range-pull rather than whole-pull (lib.rs:2727).

So the format's streaming affordances exist and are used. The defect is the paths that bypass them.

The paths to fix

  1. lib.rs:1842std::fs::read(&path) of the whole module to extract the data-section blob and decode
    manifests. It is careful about lifetime (it drops the module buffer as soon as the blob exists, and drops
    the blob before rendering) but it still peaks at whole-capsule resident. The data section's offset and
    length are known from the module header — seek to it instead of reading the file to find it.
  2. lib.rs:1311 and lib.rs:2115 — two more std::fs::read(&path) of a module. Determine what each
    needs and whether a seek serves it.
  3. dig.getMetadata renders a WHOLE data section (lib.rs:1347) and is on the anonymous public-read
    allowlist
    . Its own doc concedes a hostile capsule could turn a ~200-byte request into a ~100 MB response
    with 3-4 in-RAM copies, and today it is bounded by refusing oversized sections with METADATA_TOO_LARGE.
    That is a cap, not streaming. The doc argues the section is "a whole JSON object, not a byte stream, so
    it cannot be windowed" — assess whether that is genuinely true or an artefact of parsing eagerly; a
    streaming JSON reader would change the answer. If it really cannot be windowed, say so and leave the cap.

Enumerate every read of a capsule/module file rather than fixing the three named — git grep for
fs::read, read_to_end, and any Vec<u8> sized from a module length. The named ones are what one grep
found, not a complete set.

What "done" looks like

  • No serve path holds a whole capsule resident. State the new peak-resident figure per path and how you
    measured it, not that it "should be lower".
  • Verification is preserved. Content is trusted because it verifies against the merkle root; a streaming
    read must verify incrementally, not defer verification until a whole buffer exists — which would reintroduce
    the allocation it removes. Cite how each streamed path verifies.
  • A test that asserts peak resident memory stays bounded while serving a capsule far larger than the
    bound.
    Prove it red against the current fs::read implementation.

Fix the infra doc, which is miscited and load-bearing

modules/services/rpc.dig.net/infra/variables.tf:75-84 justifies a 2 GiB instance floor with:

"dig-node reads a whole capsule into RAM to serve it (std::fs::read, dig-node-core lib.rs:1004)"

lib.rs:1004 is not a capsule read. It is the staging-directory recursion, which already enforces a byte
budget before reading (max_total_bytes, stat-then-abort). So the sizing rationale cites the wrong line,
and it also overstates the serve path, which windows.

Correct the citation to whichever read actually justifies the floor after this work, or lower the floor if the
answer is that nothing does. An infra sizing comment that cites the wrong line is how a host gets sized for
a defect that was already fixed — or stays undersized for the one that was not.

Supersedes

This replaces the original framing of this ticket, which proposed a memory-derived semaphore with
backpressure
. That bounded the symptom — concurrency × 135 MiB — and was the right shape only if the
135 MiB were irreducible. It is not. Stream first. If a bound is still wanted afterwards as
defence-in-depth, it is a much smaller and less costly one, and it can be a follow-up rather than a
precondition.

Why this matters beyond one host

node-rpc.dig.net is both the public bootstrap peer and the CloudFront origin, so peer-port memory pressure
5xxs the browser read tier (dig_ecosystem#923). But every self-hosted node has this shape — that is why
the fix belongs here and not in Terraform.

Sequencing

Do not fold into dig-node PR#245 (client-side bootstrap seed) or PR#295's successors. Own branch. dig-node
has live lanes on crates/dig-wallet/src/sage/* — this is dig-node-core, so no overlap, but use your own
worktree.

Parent

https://github.com/DIG-Network/dig_ecosystem/issues/923

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind:businessa person can DO something new, or money moves, or a shipped surface stops lying to them

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions