Summary
A served object cannot stream a reply, so any call whose result is large must fit one 16 MiB frame. Inbound payloads have a stream; replies do not. That asymmetry is now load-bearing for a shipped consumer, and it forces callers to guess a size in units they do not control.
Where it bites
Interface::call receives no caller identity and no connection, so the served side has nothing to open a stream back on. Inbound bytes ride a stream opened alongside the call and get flow control and chunking for free; the reply gets MAX_FRAME_LEN (16 MiB, message/codec.rs) and a protocol error if it does not fit:
frame of {n} bytes exceeds the {MAX_FRAME_LEN}-byte cap
The concrete case: the TinyMemory module serves ExportPage, which returns records. The caller asks for a page as a count of records; whether that page fits is a function of bytes it cannot see. In opencompany the operator-facing knob is memory migrate --page-size, default 500, no upper bound — so a company whose chunks came from document ingest can request a page that cannot be framed, and the migration stops on a byte count that names no knob.
We worked around it caller-side (halve the page on a cap refusal, down to one record, then report that a single record cannot cross). That is sound, but it is guessing at bytes through a count, and every consumer that returns a variable-size result will end up writing the same loop.
What would remove it
A reply-stream seam: some way for a served object to answer with a stream rather than a frame — either by handing Interface::call the caller's connection, or by a call_streaming counterpart whose reply is a StreamRef the caller reads.
Then a page is bounded by flow control rather than by the caller's guess, and MAX_FRAME_LEN goes back to being what its comment says it is — a bound on control messages, with anything larger going through crate::stream.
Not urgent, and why it is worth filing anyway
Nothing is broken today: the workaround holds, and 16 MiB is far above a normal page. It is filed because the constraint is invisible from the outside — it looks like a caller bug until you read codec.rs — and because the next module that returns bulk data will hit it and write the same loop. openhuman's own AGENTS.md already records it as a known gap ("A reply-stream seam upstream would remove that half"); this is that note, in the repo that owns the seam.
Related
tinybus/crates/tinybus/src/message/codec.rs — MAX_FRAME_LEN, and the comment saying oversized payloads belong on crate::stream
- opencompany#1550 — the module memory driver and its page-halving workaround
- openhuman
AGENTS.md, "Loadable native modules" — "Payloads in and out are not symmetric"
Summary
A served object cannot stream a reply, so any call whose result is large must fit one 16 MiB frame. Inbound payloads have a stream; replies do not. That asymmetry is now load-bearing for a shipped consumer, and it forces callers to guess a size in units they do not control.
Where it bites
Interface::callreceives no caller identity and no connection, so the served side has nothing to open a stream back on. Inbound bytes ride a stream opened alongside the call and get flow control and chunking for free; the reply getsMAX_FRAME_LEN(16 MiB,message/codec.rs) and aprotocolerror if it does not fit:The concrete case: the TinyMemory module serves
ExportPage, which returns records. The caller asks for a page as a count of records; whether that page fits is a function of bytes it cannot see. In opencompany the operator-facing knob ismemory migrate --page-size, default 500, no upper bound — so a company whose chunks came from document ingest can request a page that cannot be framed, and the migration stops on a byte count that names no knob.We worked around it caller-side (halve the page on a cap refusal, down to one record, then report that a single record cannot cross). That is sound, but it is guessing at bytes through a count, and every consumer that returns a variable-size result will end up writing the same loop.
What would remove it
A reply-stream seam: some way for a served object to answer with a stream rather than a frame — either by handing
Interface::callthe caller's connection, or by acall_streamingcounterpart whose reply is aStreamRefthe caller reads.Then a page is bounded by flow control rather than by the caller's guess, and
MAX_FRAME_LENgoes back to being what its comment says it is — a bound on control messages, with anything larger going throughcrate::stream.Not urgent, and why it is worth filing anyway
Nothing is broken today: the workaround holds, and 16 MiB is far above a normal page. It is filed because the constraint is invisible from the outside — it looks like a caller bug until you read
codec.rs— and because the next module that returns bulk data will hit it and write the same loop. openhuman's ownAGENTS.mdalready records it as a known gap ("A reply-stream seam upstream would remove that half"); this is that note, in the repo that owns the seam.Related
tinybus/crates/tinybus/src/message/codec.rs—MAX_FRAME_LEN, and the comment saying oversized payloads belong oncrate::streamAGENTS.md, "Loadable native modules" — "Payloads in and out are not symmetric"