Skip to content

feat(config): expose the frame-memory budget and per-IP cap as settings (#989) - #995

Merged
cameri merged 27 commits into
rewrite/mainfrom
factory/017-frame-budget-settings
Oct 30, 2026
Merged

cameri merged 27 commits into
rewrite/mainfrom
factory/017-frame-budget-settings

Conversation

@chappie-daemon

@chappie-daemon chappie-daemon commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

frame_memory_budget_bytes and max_connections_per_ip are RelayConfig fields with the policy's numbers compiled in. A daemon builds its config at one site, so an operator cannot retune either bound without a rebuild — a policy constant wearing a config field's name. This exposes both as settings keys, in the shape the repository's other limits already use.

One check is red and cannot be fixed by this branch: Greptile Review. It fails because this PR's base — factory/016-frame-memory-budget — is unresolvable to that app, which then reviews the whole tree and reports six findings in files this 29-path diff does not touch (zero paths under website/ or src/; they last changed in #806, #807, #799). It clears when the retarget to rewrite/main makes the base resolvable. PR 997, stacked on this branch, is CLEAN with Greptile Review green — so this is one unresolvable base name, not a property of the stack.

Closes #989. Stacked on #990 (the branch that adds the two bounds); see the base note at the end.

What it does

key default means
network.frameMemoryBudgetBytes 268435456 (256 MiB) the process-wide budget for queued outbound frame text
limits.connection.maxConnectionsPerIp 64 live sockets one address may hold

RelayConfig::from_settings(&Settings) is the one place either leaf is interpreted: present and non-zero wins, absent or zero takes the compiled default. nostreamd holds it in a let — inline construction is use of moved value: settings — and keeps only its drain_timeout environment read, so no Default::default() path survives that would ignore an operator's number.

Two bounds, one ceiling, one question. An operator-supplied number feeds FrameBudget::admit, whose guard uses saturating_sub while its accounting uses an unchecked add: a value near usize::MAX would panic in debug, wrap in release, and refill the budget. MAX_SETTING_BOUND (1 TiB) keeps that state unreachable from settings, and bound_refusal asks in_range first so an accepted value can never be reported as refused. A boundary test asserts ceiling, one over, and zero through both functions by value — and it fails if the derivation is bypassed.

A zero is documented, not policed. ADR 0008 deferred this question here; the record now answers it after the last code commit: 0 means the default, following transport.rs's read-time filter rather than the capture's "disabled at zero", because a bound whose zero silently disabled it would be a bound an operator can lose with a typo.

Both read-only doors say the same thing

nostreamd --check and nostream-cli config validate now print the same verdicts from one producer (refusal_message), because a refusal that reads as a network fault is indistinguishable from one. Two sentences, not one: 0 means the default, and the relay runs with it is not the same statement as above the … a bound accepts; the key's value is ignored, and reporting the first as the second was a round of review in this job.

A document the loader rejected is a third case: load replaces it whole, so the walk has nothing of the operator's to describe — the schema fault is the whole report. The gate is guarded on used_defaults to say so rather than imply it.

Also here

  • CONFIGURATION.md rows and the shipped document's comments name the unit, the default, the zero meaning and the ceiling — the ceiling being the one thing an operator cannot discover from --check.
  • The cap's guided label gained its unit ((sockets, 0 means the default)), which the ADR claimed for both labels and only one had.
  • ADR 0005's "such knobs are RelayConfig, not settings" is reversed in place, with its reason answered and what still stands kept; ADR 0008's deferral is closed and its observability gap re-stated.
  • scripts/citation-meaning.py is committed: it prints each citing sentence beside its cited line. The oracle proves a citation resolves; this proves it says what the sentence claims, and the difference cost this job three rounds.

Verification

All five repository commands green at head (57 targets, 935 passed); both audits reproduce (citation-oracle exit 0 on the two ADRs and the changeset; citation-meaning 82 citations, 0 bad). Protected tests: the shipped document's numbers equal RelayConfig::default() (fails when either number is changed), and the constructor's three pass-through/zero/absent cases (fail when from_settings returns Self::default()).

Cost

Five rounds against a $1.50 estimate. Rounds 1, 3 and 4 bought correctness — an escape above usize::MAX, a documented zero reported as a violation, a re-typed comparison no test could see. Rounds 3 and 5 also bought prose that should not have needed a round: the reason is in this PR's shape, not in its scope.

Base

This branch is cut from 04186d4, the head of #990, because #989's subject exists only there. It must merge after #990, and GitHub retargets a pull request only when its base branch is deleted — so whoever merges #990 must also move this one:

gh api -X PATCH repos/cameri/nostream/pulls/<n> -f base=rewrite/main

then rebase if #990's merge added anything.

…s may hold (#959)

The outbound queue was bounded per socket and their sum was bounded by nothing: one
address could hold 8 MiB for every socket it kept, and a closed socket's queued bytes
were never given back. Every socket now counts its queued payload against one
process-wide FrameBudget (256 MiB, RelayConfig::frame_memory_budget_bytes), refunded
when the socket's last sink handle drops, and one address may hold at most
max_connections_per_ip live sockets (64) as a gauge beside the attempt limiter. Both
refuse with an immediate 1013 close, a fixed reason and their own counter.

See docs/rewrite/decisions/0008-frame-memory-budget.md and #959.
… 0008)

Both numbers are deviations from the capture, with their arithmetic, the rejected
accept-time shape, the refund on the last handle's Drop, and what an operator can see
today (#959).
…the budget in the sink doc

The RelayConfig doc enumerates the port-only policies, so it has to name all four;
the sink's module doc is where a reader learns what the sum is bounded by.
…ames it can queue

Three static-review findings on the frame-memory budget, in one commit because they meet
in FrameSink::send:

- A refusal recorded a flag but woke nothing. A shared-budget refusal can arrive while
  this socket's own queue is empty, the socket idle and its read parked, so the 1013
  waited for the next liveness tick instead of being immediate. The first refusal now
  signals a tokio::Notify the account owns, and the connection loop selects on
  FrameSink::refused(); notify_one stores a permit, so a refusal recorded between the
  loop's check and its select is not lost.
- The queue-full check ran after to_text(), so a latching socket kept serialising
  events nobody would read. The early return is back before the encoding, with the
  in-loop check kept for racing senders.
- The racing-sender test's sampler stopped only at the expected total, so the
  accounting bug it exists to catch could spin forever inside thread::scope. It now
  stops when the last sender finishes, and fails with numbers.

The new wire test holds the budget from a socket that never reads and loses a frame on
a socket whose own queue is empty: it times out without the signal and closes in 1.3 s
with it.
…t the record

Review round 1: a performance finding and two documentation defects, plus four notes.

- A budget refusal did not stop the encode. The read loop in fetch_and_send encoded one
  frame per event and discarded each refused one; the window is the connection loop's
  write plus close_frame (30 s each) and is the normal case, since the budget binds when
  queues are full and loops are parked in writes. The read now ends at the top of its
  loop when the sink has a refusal, before pulling or encoding the next record; the sink
  still does not latch a budget refusal, so AC6's re-admission holds. New test in
  subscribe.rs: with a 64-byte budget every event frame is refused and the EOSE would
  fit, so an empty queue proves the read stopped (without the check it holds the 12-byte
  EOSE).
- Two ADR citations were stale by six lines and one named the wrong arm; every citation
  in the file was re-read against the final tree.
- The ADR presented the cap's refusal line as operator-visible; it is a debug! line and
  the daemon ships the info filter, so the record now says so, names the switch, and
  calls the gap a gap.

Notes: the two new refusals are separated by the reason string, not by 1013; the shared
budget's blast radius (one address below its cap can refuse every other client,
including a newcomer's AUTH) is recorded; the cap's key is an address, so a [::] bind
keys per /128; and RelayConfig's doc no longer claims the policies never move from their
defaults.
…ke-up is what closes

Static round 2 showed the parked-read test passed with the refusal signal deleted: the
read returned at the guard, its handler completed, and handlers.join_next() woke the loop.
The two changes had cancelled each other's discriminating power, and the close's
promptness came from a handler finishing rather than from the signal.

A refused read now waits for the close that retires it (the cancellation watch, then
end_read) instead of unwinding: the connection loop's abort_all/close stays the single
owner of the teardown, and the close is prompt because the refusal woke the loop. The
read's own test is restructured for the park — pending after the refusal, one record
taken, no EOSE queued, retired by the connection's close — and the two tests now fail on
their own mechanism: deleting the refused() arm makes the wire test time out at 2 s, and
deleting the read's guard makes the unit test fail with the park message.
…that holds

Round 2 (code, security, architecture) found one ADR paragraph that 5b23eeb had made
false, the code comment copying it, and an ownership claim one clause too strong.

- The read-stop paragraph said a refused read 'ends there'. It stops producing there and
  then waits for the close that retires it, so it now says that, names what a refused
  client therefore still holds, and no longer contradicts its own account of the two 30 s
  write windows.
- The encode citation moved from the park comment to the send (subscribe.rs:325-330), and
  the guard is cited where it is (:284-288).
- 'its read parked on the store' in the wake-up paragraph was the pre-1479c6c state; the
  relay.rs comment copied it. Both now say stopped and waiting for the close.
- Section 7 records that the park holds the store stream to the close: latent today
  (unavailable::stores), but sqlx's &Pool executor holds a pooled connection per stream
  and DB_MAX_POOL_SIZE is 3, so whoever wires the store leaves the loop before parking.
- The ownership comment claimed the loop's abort_all/close was the sole owner of teardown
  while end_read is reachable from six places. It now says what makes that safe: end_read
  removes the entry only while the generation still matches (connection.rs:290-297).

Every line reference in the ADR was re-read from the final tree: 53 of them, all
resolving.
Returning from the loop before parking does not put the close's promptness onto the
refusal signal - it removes the signal's necessity, because a returned handler wakes the
loop on completion. Say that, and that the wire test stops discriminating in that world.
Round 3 (code, architecture) measured the same clause independently: every connection-loop
exit is a break, handlers.abort_all() runs before connection.close(), and a JoinSet-aborted
task is dropped without being polled again. So on the refusal path the parked read dies
inside the cancellation watch, its end_read never runs, and the store stream goes with the
abort; the watch is the path a client CLOSE or a re-REQ takes, and the one the unit test
exercises.

Also: the refused frame is the only one encoded per read (and a queue-latched socket skips
that too), the wire-test pointer no longer says 'below' with nothing below it, and section
7 gives the deployed pool size (prod compose 64, no replica, so held connections stall
writes as well) beside the code default of 3. The two code comments that described the
watch as the retirement now match.
@changeset-bot

changeset-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ba937ef

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The changes affect how the relay sets resource bounds across its socket traffic.

The PR still needs the existing 32-bit build failure fixed before merging.

Findings

  1. P1 32-bit builds fail ▶
  2. P2 Missing citation paths pass ▶
  3. P2 Settings appear to work ▶
  4. P2 Overflow explanation is incorrect ▶
  5. P2 Invalid citation ranges pass ▶

Summary

Exposes the outbound frame budget and per-address socket cap as Rust settings.

  • Operators can set the relay’s frame budget and per-address socket cap.
  • The citation audit shows each sentence beside the line it cites.

One new, non-blocking issue lets the citation checker accept a missing path. The documented fallback and guided-editor choices are intentional or deferred by chappie-daemon.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  YAML[Settings document] --> Settings[Typed settings]
  Settings --> Config[RelayConfig::from_settings]
  Config --> Budget[FrameBudget]
  Config --> Cap[ConnectionCap]
  Settings --> Report[Shared refusal messages]
  Report --> Check[nostreamd --check]
  Report --> CLI[config validate]
Loading

Reviews (2) · Last reviewed commit: "Merge branch 'rewrite/main' into factory..." · Reviewed by Greptile

/// above any deployment each way, and far below `usize::MAX`, so a stray digit cannot reach
/// [`FrameBudget::admit`]'s arithmetic. A value above it is treated like a zero: the default
/// stands (ADR 0008).
pub const MAX_SETTING_BOUND: usize = 1024 * 1024 * 1024 * 1024;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 32-bit builds fail

If built for a 32-bit target such as ARMv7 or i686, MAX_SETTING_BOUND overflows usize while the compiler evaluates it. The relay and both command-line programs cannot build, even with default settings.

Store the ceiling as u64 and check that accepted values fit usize before converting them. Update the ceiling tests to avoid the same overflowing usize expression.

Comment thread CONFIGURATION.md
| mirroring.static[].limits.event | Event limit overrides for this mirror. See configurations under limits.event. |
| mirroring.static[].secret | Secret to pass to relays. Nostream relays only. Optional. |
| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. |
| network.frameMemoryBudgetBytes | The queued outbound payload bytes every socket of this relay shares. Unit: bytes. Defaults to 268435456 (256 MiB). Zero, or a value above 1099511627776 (1 TiB), means the default. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Settings appear to work

The published TypeScript CLI reads the shared defaults, so it accepts and saves both new keys. But the TypeScript relay never reads either one. Operators can receive a successful update while neither limit takes effect.

Mark both settings as Rust-only here and in the shared defaults comments.

Knowledge Base Used: Configuration, CLI, and deployment

Comment on lines +409 to +416
**Why a ceiling, and not just `> 0`.** `usize::MAX` *is* a `usize`, so a bare positivity check
passes it through, and the budget then stops bounding anything: the fit guard subtracts with
` if bytes > self.limit_bytes.saturating_sub(accounted) {`
(`crates/nostream-relay/src/sink.rs:107`), which can never be true against a limit of
`usize::MAX`, while the accounting that follows adds unchecked —
" accounted + bytes," (`crates/nostream-relay/src/sink.rs:113`) — so it panics in a
debug build and wraps in a release one, and a wrapped counter refills the budget it was meant to
exhaust. The ceiling is what keeps an operator's number out of that arithmetic:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Overflow explanation is incorrect

For a limit of usize::MAX, FrameBudget::admit still rejects a frame larger than usize::MAX - accounted. An accepted frame therefore cannot make accounted + bytes overflow.

Correct this explanation and the matching from_settings comment. They currently describe a nonexistent counter wrap as the reason for the ceiling, giving future maintainers the wrong safety model.

bad += 1
continue
body = open(real, errors="replace").read().split("\n")
if not (1 <= first <= len(body)) or end > len(body):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Invalid citation ranges pass

The check accepts reversed ranges such as file.rs:10-1 and ranges ending at zero. These references print EXISTS and return success; an end of zero even reads the last line through Python's negative indexing.

Require 1 <= first <= end <= len(body) so the checker rejects invalid ranges.

Suggested change
if not (1 <= first <= len(body)) or end > len(body):
if not (1 <= first <= end <= len(body)):

@coveralls

coveralls commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 40998394024

Coverage increased (+0.006%) to 73.979%

Details

  • Coverage increased (+0.006%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 10291
Covered Lines: 7992
Line Coverage: 77.66%
Relevant Branches: 5362
Covered Branches: 3588
Branch Coverage: 66.92%
Branches in Coverage %: Yes
Coverage Strength: 867.18 hits per line

💛 - Coveralls

Base automatically changed from factory/016-frame-memory-budget to rewrite/main October 29, 2026 21:18
Brings in #987, #988 and #990, which landed after this branch was cut.

Three conflicts, all resolved toward this branch's side because this
branch is the one that changes what they describe:

- `relay.rs` doc comments: main still calls the two bounds constants that
  belong in `Settings`, which is the state #990 left. This branch makes
  them operator settings read by `RelayConfig::from_settings`, so its
  wording is the one that matches the code.
- `0008-frame-memory-budget.md`: main carries #990's version, where the
  settings key is future work (#989) and a zero is unvalidated. This
  branch *is* that work, so its side carries the resolution, the
  `What 0 means` section and the citation re-anchors.
- `0005-connection-concurrency.md`: identical prose, wrapped differently.

Every citation in both ADRs was re-verified against the merged tree with
the factory's `citation-meaning` tool: 68 in 0008 and 8 in 0005, all
resolving, exit 0. The anchors differ from main's by design — this
branch adds code, so line numbers move.

Verified: `cargo clippy --all-targets -p nostream-relay -- -D warnings`
clean; `cargo test -p nostream-relay` 242 tests passing across 10 targets.
return candidate
if "/" not in candidate:
return None
candidate = candidate.split("/", 1)[1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing citation paths pass

resolve drops directories from a missing path until another file exists. For example, missing/src/utils/settings.ts:1 resolves to src/utils/settings.ts and prints EXISTS. A broken reference therefore passes the check and shows text from a different path.

Require the cited path to exist, or allow only a named, documented prefix.

@cameri
cameri merged commit 4a7e74c into rewrite/main Oct 30, 2026
17 of 18 checks passed
@cameri
cameri deleted the factory/017-frame-budget-settings branch October 30, 2026 12:39
chappie-daemon added a commit that referenced this pull request Oct 30, 2026
rewrite/main squash-merged #995 (4a7e74c), which retargeted this PR onto rewrite/main and broke the stack. The branch already carries #995's commits and every change in the squash, so each of the 8 conflicting files resolves toward the branch; documented reasons and the ADR anchors come from this branch.
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.

3 participants