Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d827c97
feat(relay): bound the total queued frames and the sockets one addres…
chappie-daemon Oct 25, 2026
7f16351
docs(rewrite): record the frame-memory budget and the per-IP cap (ADR…
chappie-daemon Oct 25, 2026
bb71b72
docs(relay): name the two new knobs where RelayConfig's list is, and …
chappie-daemon Oct 25, 2026
74ff50d
docs(rewrite): correct ADR 0008's line citations to the final tree
chappie-daemon Oct 25, 2026
8ae2009
fix(relay): wake the connection loop on a refusal, and encode only fr…
chappie-daemon Oct 25, 2026
c5bc366
docs(rewrite): record the refusal signal in ADR 0008 and retarget its…
chappie-daemon Oct 25, 2026
1479c6c
fix(relay): stop a stored read at the first refused frame, and correc…
chappie-daemon Oct 25, 2026
f688ae4
docs(rewrite): point ADR 0008 at the cap's removal block, not the bla…
chappie-daemon Oct 25, 2026
5b23eeb
fix(relay): hold a refused read until the close retires it, so the wa…
chappie-daemon Oct 25, 2026
ff144dc
docs(rewrite): say that a refused read waits for the close, and what …
chappie-daemon Oct 25, 2026
f67e726
docs(rewrite): record the park's test-power trade the right way round
chappie-daemon Oct 25, 2026
04186d4
docs(rewrite): say that the abort, not the watch, retires a refused read
chappie-daemon Oct 25, 2026
82ade58
feat(config): expose the frame budget and per-IP cap as settings keys
chappie-daemon Oct 25, 2026
717e4cb
docs(rewrite): record the two bounds as operator settings, and what a…
chappie-daemon Oct 25, 2026
47fc464
test(relay): prove from_settings reads the operator's bounds
chappie-daemon Oct 25, 2026
d07f898
fix(relay): cap a setting's bound so an absurd value cannot exhaust u…
chappie-daemon Oct 25, 2026
03180b8
docs(rewrite): say what a bound above the ceiling does, and what the …
chappie-daemon Oct 25, 2026
645a85b
fix(daemon): report a bound setting the relay will replace with its d…
chappie-daemon Oct 25, 2026
99b78a7
docs(rewrite): re-anchor every citation, and name the ceiling where t…
chappie-daemon Oct 25, 2026
593eac8
fix(daemon): tell a zero bound from one above the ceiling, on both re…
chappie-daemon Oct 25, 2026
fdc9b22
feat(config): name the cap's unit in its guided label
chappie-daemon Oct 25, 2026
78ef4cf
docs(rewrite): say what the skip does, and re-anchor the citations it…
chappie-daemon Oct 25, 2026
3257f01
fix(relay): derive the refusal from the range check and word it once
chappie-daemon Oct 25, 2026
0679ea1
docs(rewrite): say what a rejected document leaves to report, and re-…
chappie-daemon Oct 25, 2026
d3416ba
fix(daemon): never describe a substituted document as a bound violation
chappie-daemon Oct 25, 2026
60aa7e9
docs(rewrite): state the two mechanisms a rejected document leaves, a…
chappie-daemon Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/relay-frame-budget-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
---

feat(config): expose the frame budget and the per-IP cap as settings keys

The two bounds #959 added were `RelayConfig` fields only:
"pub frame_memory_budget_bytes: usize," (`crates/nostream-relay/src/relay.rs:66`) and
"pub max_connections_per_ip: usize," (`crates/nostream-relay/src/relay.rs:70`). A running
daemon could not change either number without a rebuild, which is what #989 is about.

Both are now settings the operator writes:

- `network.frameMemoryBudgetBytes` in `resources/default-settings.yaml` — the byte count every
socket's queued outbound payload is counted against.
- `limits.connection.maxConnectionsPerIp` in `resources/default-settings.yaml` — the live
sockets one source address may hold at once.

`RelayConfig::from_settings` reads both leaves, in the one place either is interpreted, and falls
back to the compiled default when a leaf is absent, zero, or above the ceiling that keeps an
operator's number out of the frame accounting:
" value > 0 && value <= MAX_SETTING_BOUND as u64" (`crates/nostream-relay/src/relay.rs:115`). The
daemon builds its config through it: "let mut relay_config = RelayConfig::from_settings(&settings);"
(`crates/nostreamd/src/main.rs:110`), keeping its one environment read. A value it replaces is not
silent: the constructor warns with the setting, the value, the ceiling and the default —
" \"a bound above its ceiling runs with its default\"" (`crates/nostream-relay/src/relay.rs:191`)
— and `nostreamd --check` and `nostream-cli config validate` print the same sentences before a relay
exists, from one place: "pub fn refusal_message(setting: &str, reason: BoundRefusal) -> String {"
(`crates/nostream-relay/src/relay.rs:169`).

The defaults document's values equal `RelayConfig::default()`'s, and a test in
`crates/nostream-relay/tests/config_defaults.rs` holds the two together. No validator rule is
added: the document is the validator's schema, so the two keys alone make an operator's file
valid. See ADR 0008 for what a zero means and why.

The TypeScript tree is untouched: no `src/` or `test/` file changes, so the published package
has no user-visible change.
22 changes: 22 additions & 0 deletions .changeset/relay-frame-memory-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
---

feat(relay): bound the relay's total queued frames and the sockets one address may hold

The outbound queue was bounded per socket and the number of sockets was bounded by nothing:
one address that opened sockets and stopped reading could hold 8 MiB for every socket it kept,
measured as 384 MiB from the shipped attempt limits, and nothing gave a closed socket's queued
bytes back. Two bounds now sit beside that one, both recorded in
`docs/rewrite/decisions/0008-frame-memory-budget.md`:

- `RelayConfig::frame_memory_budget_bytes` (256 MiB) is one process-wide budget every socket's
queued payload is counted against, admitted frame by frame at the sink and refunded when a
socket's last handle drops.
- `RelayConfig::max_connections_per_ip` (64) caps the live sockets one address may hold, as a
gauge beside the attempt limiter rather than folded into it.

Both refuse the way the policy comment on #959 asks: an immediate `1013` close with a fixed
reason and its own counter, never a text frame and never a silent drop. See #959.

The TypeScript tree is untouched: no `src/` or `test/` file changes, so the published package
has no user-visible change.
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| limits.client.subscription.maxFilters | Maximum number of filters per subscription. Defaults to 10. Disabled when set to zero. |
| limits.client.subscription.maxFilterValues | Maximum number of values allowed in each filter, counting its array criteria (`ids`, `authors`, `kinds`, `#<tag>`) together. Defaults to 2500. Disabled when set to zero. |
| limits.client.subscription.maxSubscriptions | Maximum number of subscriptions per connected client. Defaults to 10. Disabled when set to zero. |
| limits.connection.maxConnectionsPerIp | Maximum number of live sockets one source address may hold at once. Defaults to 64. Zero, or a value above 1099511627776, means the default. |
| limits.event.content[].kinds | List of event kinds to apply limit. Use `[min, max]` for ranges. Optional. |
| limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
| limits.event.createdAt.maxNegativeDelta | Maximum number of seconds an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. |
Expand Down Expand Up @@ -199,6 +200,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| 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

| network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
| nip05.domainBlacklist | List of domains blocked from NIP-05 verification. Authors with NIP-05 at these domains will be rejected. |
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/nostream-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ path = "src/main.rs"
clap.workspace = true
anyhow = { workspace = true }
nostream-config.workspace = true
nostream-relay.workspace = true
nostream-core.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
Expand Down
49 changes: 38 additions & 11 deletions crates/nostream-cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use nostream_config::{
SettingsPaths, fit, load, load_defaults, load_document, load_user_settings, merge,
validate_document,
};
use nostream_relay::relay::{refusal_message, refused_bounds};
use serde_yaml::Value;

use crate::error::CliError;
Expand Down Expand Up @@ -57,31 +58,57 @@ pub fn validate(
};

let mut issues = validate_document(&merged, &defaults);
// The typed value the document fits, kept rather than parsed again: it is what the daemon
// would load, and it is what the range report walks.
let mut fitted = None;
if issues.is_empty() {
// The document walk grades JavaScript types; the typed schema is stricter. A document
// the walk approves but the daemon cannot load (a fractional integer, for one) is
// refused here, with the field named, so `config validate` never blesses a config the
// daemon will discard.
if let Err(issue) = fit(&merged, &defaults) {
issues.push(issue);
match fit(&merged, &defaults) {
Ok(settings) => fitted = Some(settings),
Err(issue) => issues.push(issue),
}
}

// The daemon bounds two settings at run time and ignores a value it cannot use. That is not a
// schema fault, so it is not an issue and not fatal — but this door and the daemon's `--check`
// are the two read-only ways an operator looks, so the verdict says it here too, in the words
// `refusal_message` produces for both (ADR 0008). A document the walk refused has no typed
// value to walk — `fit` is what produces one, and it is not reached — so this door is silent
// about the bound on a rejected document, exactly as the daemon's gate is, and for the same
// reason the gate's comment gives.
let refused: Vec<String> = fitted
.as_ref()
.map(|settings| {
refused_bounds(settings)
.into_iter()
.map(|(setting, reason)| refusal_message(setting, reason))
.collect()
})
.unwrap_or_default();

if issues.is_empty() {
let mut verdict = "settings valid: 0 issues".to_owned();
for line in refused {
verdict.push_str("; ");
verdict.push_str(&line);
}
return Ok(CommandOutput {
lines: vec!["settings valid: 0 issues".to_owned()],
lines: vec![verdict],
exit_code: 0,
});
}

// A refused document is reported too, and only when there is a typed value to walk (the gate's
// `settings_gate.rs:41-43` comment says the same, from the daemon's side).
let mut lines: Vec<String> = issues.iter().map(|issue| issue.to_string()).collect();
lines.extend(refused);
lines.push(format!("settings invalid: {} issues", issues.len()));

Ok(CommandOutput {
lines: issues
.iter()
.map(|issue| issue.to_string())
.chain(std::iter::once(format!(
"settings invalid: {} issues",
issues.len()
)))
.collect(),
lines,
exit_code: 2,
})
}
Expand Down
58 changes: 58 additions & 0 deletions crates/nostream-cli/tests/config_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,61 @@ fn the_paths_resolver_honours_the_config_dir() {
);
assert_eq!(paths.config_base_dir, directory.path().to_path_buf());
}

#[test]
fn config_validate_says_which_bound_setting_the_relay_will_not_use() {
// The CLI door and `nostreamd --check` are the two read-only ways onto the same validator;
// a bound the relay replaces must not be silent on one of them. Zero and an over-ceiling
// value are different facts and get different sentences.
let directory = tempfile::tempdir().expect("tempdir");
let path = directory.path().join("settings.yaml");
fs::write(
&path,
"network:\n frameMemoryBudgetBytes: 0\nlimits:\n connection:\n maxConnectionsPerIp: 1099511627777\n",
)
.expect("write");

let output =
config::validate(&workspace_root(), Some(directory.path()), Some(&path)).expect("validate");
assert_eq!(output.exit_code, 0);
let verdict = output.lines.join("\n");
assert!(
verdict.contains(
"network.frameMemoryBudgetBytes: 0 means the default, and the relay runs with it"
),
"verdict: {verdict}"
);
assert!(
verdict.contains(
"limits.connection.maxConnectionsPerIp: above the 1099511627776 a bound accepts; the key's value is ignored"
),
"verdict: {verdict}"
);
}

#[test]
fn config_validate_reports_the_schema_fault_and_not_a_bound_it_cannot_read() {
// The same mixed document through the other door: the bound is not described, because a
// rejected document has no typed value to walk, and the exit code is the schema verdict.
let directory = tempfile::tempdir().expect("tempdir");
let path = directory.path().join("settings.yaml");
fs::write(
&path,
"network:\n frameMemoryBudgetBytes: 1099511627777\nlimits:\n rateLimiter:\n strategy: random\n",
)
.expect("write");

let output =
config::validate(&workspace_root(), Some(directory.path()), Some(&path)).expect("validate");
assert_eq!(output.exit_code, 2);
let text = output.lines.join("\n");
assert!(
text.contains("strategy must be ewma or sliding_window"),
"{text}"
);
assert!(!text.contains("above the"), "no range line: {text}");
assert_eq!(
output.lines.last().map(String::as_str),
Some("settings invalid: 1 issues")
);
}
12 changes: 12 additions & 0 deletions crates/nostream-config/src/guided.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ fn build_categories() -> Vec<GuidedSettingCategory> {
Number,
SafeNonNegativeInteger,
),
validated(
"Frame memory budget (bytes, 0 means the default)",
"network.frameMemoryBudgetBytes",
Number,
SafeNonNegativeInteger,
),
validated(
"Worker count",
"workers.count",
Expand Down Expand Up @@ -263,6 +269,12 @@ fn build_categories() -> Vec<GuidedSettingCategory> {
Number,
SafeNonNegativeInteger,
),
validated(
"Max connections per IP (sockets, 0 means the default)",
"limits.connection.maxConnectionsPerIp",
Number,
SafeNonNegativeInteger,
),
validated(
"Max filters per subscription",
"limits.client.subscription.maxFilters",
Expand Down
4 changes: 4 additions & 0 deletions crates/nostream-config/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ pub struct Network {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_payload_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frame_memory_budget_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_ip_header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trusted_proxies: Option<Vec<String>>,
Expand Down Expand Up @@ -306,6 +308,8 @@ pub struct ConnectionLimits {
pub ip_whitelist: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_blacklist: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_connections_per_ip: Option<u64>,
}

/// `limits.invoice` (`src/@types/settings.ts` `InvoiceLimits`).
Expand Down
1 change: 1 addition & 0 deletions crates/nostream-relay/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tracing.workspace = true

[dev-dependencies]
base64.workspace = true
serde_yaml.workspace = true

[lints]
workspace = true
Loading
Loading