# ADR 0262: One reader bounds a body, and the chunk loop is shared

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0262-one-reader-bounds-a-body-and-the-chunk-loop-is-shared.md

---

# ADR 0262 — One reader bounds a body, and the chunk loop is shared

**Invariants:** I8, I10, I12.

## Context

Reading a stream no further than `maxBytes`, stopping on an abort and cancelling the
source is a small loop with sharp edges: a read that never settles after an abort, a
lock released under a pending read, a cancellation that is awaited on a transport that
never settles it, an abort that must win over the empty-body answer. The framework had
that loop five times (request JSON text, the unix client, connection responses, the
capped download and the sandbox gateway), with different answers on those edges, and
an application had to write a sixth.

## Decision

`readBoundedBody(source, { maxBytes, signal?, limitError, abortError? })` in `stitchkit`
and `stitchkit/http` returns the bytes. It never reads past `maxBytes` plus one chunk,
cancels the source when the limit is crossed and when the signal aborts, answers a
`null` body with an empty result, and lets the caller choose both errors. A body of
exactly `maxBytes` is read.

Inside the core there is one chunk loop, `chunksOf`, which the line readers and the
bounded reader both run on. It races a read with the abort, requests a cancellation
without awaiting it after an abort, and releases the lock only when no abort left a read
pending. The in-core readers of this job — the request text reader, the unix client
body, connection responses, the capped download and the sandbox gateway — run on it and
map its outcome to their own errors. Readers with a different job keep their own code:
an SSE frame reader (a frame limit, a per-chunk accounting hook), the file and
publication writers (a limit checked before a chunk is accepted, feeding a sink) and the
multipart file parts (a text field of a multipart body does use the bounded reader).

A body read that no limit bounded is a defect of the same kind, so the readers that serve
a boundary use the reader with a default: a raw route's `parseBody` reads 1 MiB (a stricter
default than the unbounded read it replaces, so a marked break, with `{ maxBytes }` to raise
it), the authorization server's `/token` and `/register` read 64 KiB (`maxRequestBodyBytes`),
and the audit row reads a request clone up to `payloadMaxBytes`, by default the handler's
`maxJsonBodyBytes`. Each limit is a positive safe integer, refused otherwise, and each cut that
is only observed rather than answered is recorded (`payloadTruncated`).

## Consequences

An application that accepts a body from a socket uses the framework's reader instead of
its own copy, and a fix to the edges lands once. The edges are covered by one test file
and by the installed-package lane on Bun and Node.
