Skip to content

Framing: an optional byte limit, and a way to report exceeding it - #12

Merged
odrobnik merged 3 commits into
mainfrom
claude/framing-byte-limit
Sep 23, 2026
Merged

odrobnik merged 3 commits into
mainfrom
claude/framing-byte-limit

Conversation

@odrobnik

Copy link
Copy Markdown
Contributor

Why

LineFraming.push appends to an unbounded buffer and only emits on a newline, so a peer that sends one huge line — or never sends a newline at all — grows it without limit. There is also nowhere to report that: push returns [Data], with no failure path, so a transport cannot tell "no complete message yet" from "this stream is unusable".

Found while porting acpx's ACPX_MAX_ACP_MESSAGE_BYTES (a 64 MiB default on incoming ACP messages) to SwiftACP, which frames through this package and so had nowhere to put the limit.

What

  • MessageFraming.push is now throws, and FramingError.messageTooLarge carries the limit and how much had accumulated.
  • LineFraming(maxBytes:) and ContentLengthFraming(maxBytes:), both defaulting to 0 — unlimited, today's behaviour — so nothing changes until a caller opts in. Policy stays with the application: what the limit should be, and which environment variable names it, is not this package's business.
  • ContentLengthFraming refuses on the declared length, before a byte of the body is buffered, and also caps headers that never reach a separator. LineFraming cannot know a size in advance, so it checks both a completed line and an unterminated remainder that has already passed the limit — the latter being the case a limit exists for.
  • A failure drops the buffer: there is no boundary left to resynchronise on, so the framing does not keep answering with the same error.

How the error reaches the consumer

It already had somewhere to go — makeInboundStream() is an AsyncThrowingStream:

  • the stdio pump propagates through its existing catch { inbound.finish(throwing: error) };
  • startFramedReaderThread runs on a Thread with no error channel, so its onEOF became onFinish: (any Error?) -> Void. Since finish(throwing:) takes an optional, all three callers collapse to onFinish: { continuation.finish(throwing: $0) } — shorter than the onEOF: { continuation.finish() } they had before.

Compatibility

Source-breaking, narrowly: a conformer adds throws without behaviour change, a caller adds try. In-tree that was two conformers and the call sites above. SSEEventDecoder is unaffected — it is not a MessageFraming.

Verification

82 tests pass, swiftlint --strict clean on 64 files. Seven new tests: the default is unlimited for both framings, an oversized line, an unterminated flood, a message exactly at the limit, rejection by declared Content-Length before the body, and headers that never end.

odrobnik and others added 2 commits September 23, 2026 12:17
`LineFraming.push` appended to an unbounded buffer and only emitted on a newline,
so a peer that sent a huge line — or never sent one at all — grew it without
limit. There was also nowhere to report that: `push` returned `[Data]` with no
failure path, so a transport could not distinguish "no messages yet" from "this
stream is unusable".

- `MessageFraming.push` is now `throws`, and `FramingError.messageTooLarge`
  carries the limit and how much had accumulated.
- `LineFraming(maxBytes:)` and `ContentLengthFraming(maxBytes:)` default to `0`,
  unlimited — existing behaviour is unchanged until a caller opts in. Policy
  (what the limit should be, which environment variable names it) belongs to the
  application, not here.
- `ContentLengthFraming` refuses on the *declared* length, before a byte of the
  body is buffered; it also caps headers that never reach a separator.
  `LineFraming` cannot know a size in advance, so it checks a completed line and
  an unterminated remainder that has already passed the limit.
- A failure drops the buffer: there is no boundary left to resynchronise on, so
  the framing does not keep answering with the same error.

Transports answer by finishing their inbound stream with the error, which is
already an `AsyncThrowingStream` — the stdio pump propagates through its existing
`catch`, and `startFramedReaderThread` gained an `onFailure` callback that its
three callers wire to `finish(throwing:)`.

Source-breaking for out-of-tree conformers and callers: a conformer adds `throws`
without behaviour change, a caller adds `try`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SwiftLint's parameter-count limit pushed back on `onEOF` + `onFailure`, and it
was right to: `AsyncThrowingStream.Continuation.finish(throwing:)` already takes
an optional error, so a single `onFinish: (any Error?) -> Void` covers both
endings and every caller collapses to

    onFinish: { continuation.finish(throwing: $0) }

which is shorter than what was there before this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-23T10:22:01.133808Z 9334ed2 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9334ed2138

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/JSONRPCWire/MessageFraming.swift Outdated
Comment thread Sources/JSONRPCWire/MessageFraming.swift
CI plus two review findings.

**`(any Error)?`.** `any Error?` parses as `any (Error?)` and is rejected by the
toolchains CI builds with, though the one here accepted it. Spelled properly now.

**Completed messages are no longer lost with the failure.** One read can carry a
valid message *and* an oversized one; returning `[Data]` meant the caller
discarded the valid message along with the throw, so a good response could be
lost because of what followed it in the same buffer. `push` now takes an `emit`
closure and hands over each body as it is decoded — delivery and failure both
happen, in that order, by construction rather than by care. The array-returning
form stays as an extension for callers that treat any framing failure as fatal,
with its loss documented.

**A body limit is not a header limit.** `ContentLengthFraming` applied `maxBytes`
to an incomplete header, so `maxBytes: 16` accepted a 2-byte frame in one push
and rejected the same frame split after `Content-Length: 2` — and a transport
read may split anywhere. Headers now have their own generous cap (8 KiB),
independent of the body limit, which still stops a peer that never sends a
separator.

Tests: a good message emitted before an oversized one throws (both framings), a
header split under a small body limit accepted, and the header cap tripped only
by an actually unbounded header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@odrobnik
odrobnik merged commit 44668c9 into main Sep 23, 2026
14 of 16 checks passed
@odrobnik
odrobnik deleted the claude/framing-byte-limit branch September 23, 2026 15:08
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.

1 participant