Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
130 changes: 69 additions & 61 deletions build/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -673,13 +673,48 @@ fn starts_name_what_a_launch_can_start(config: &SystemConfig) -> Result<(), Stri
Ok(())
}

/// What a row receives reaches a server that runs: a port the supervisor
/// serves, one a row `[boot] start` names serves, since a launch starts no row
/// that serves ([`starts_name_what_a_launch_can_start`]), or one a row
/// provides, never `netstack`; and never a port the row serves itself.
/// `[apps] receives` is narrower: nobody makes a `provides` port for a package.
///
/// **Checked on every manifest rendered**: the supervisor mints `netstack`
/// from its running server's acceptor, under the lock that server's own start
/// holds, and never from a provider, so a row receiving it where none runs is
/// endowed nothing, and the server's own row receiving it waits at boot for
/// ever.
fn receives_reach_a_running_server(config: &SystemConfig) -> Result<(), String> {
let running = |port: &String| {
SUPERVISOR_SERVED.contains(&port.as_str())
|| config.boot.start.iter().any(|key| config.programs.get(key).is_some_and(|row| row.serves.contains(port)))
};
for (name, program) in &config.programs {
if program.provides.iter().any(|port| port == "netstack") {
return Err(format!("`{name}` provides `netstack`, which the supervisor mints from its started server alone"));
}
for port in &program.receives {
if program.serves.contains(port) {
return Err(format!("`{name}` receives `{port}`, which it serves itself"));
}
if !running(port) && !config.programs.values().any(|row| row.provides.contains(port)) {
return Err(format!("`{name}` receives `{port}`, which no row in `[boot] start` serves and no row provides"));
}
}
}
match config.apps.receives.iter().find(|port| !running(port)) {
Some(port) => Err(format!("`[apps] receives` names `{port}`, which no row in `[boot] start` serves")),
None => Ok(()),
}
}

/// The resolved config as the records `/system/bin/supervisor` reads.
///
/// The format, the renderer and the parser are `lib/manifest/`, whose
/// round-trip test is what makes "what the build writes is what the supervisor reads" a
/// fact rather than two hand-matched implementations.
fn render_manifest(config: &SystemConfig) -> Vec<u8> {
for gate in [held_by_their_holders_alone, starts_name_what_a_launch_can_start] {
for gate in [held_by_their_holders_alone, starts_name_what_a_launch_can_start, receives_reach_a_running_server] {
if let Err(why) = gate(config) {
panic!("the image config cannot be rendered as a manifest: {why}");
}
Expand Down Expand Up @@ -3004,36 +3039,41 @@ mod tests {
parse_config(&Path::new(env!("CARGO_MANIFEST_DIR")).join(cfg))
}

/// Every name a program `receives` must be served or provided by some
/// program in the *same* config, or served by the supervisor. The build-time form of
/// "a client cannot name a service the system does not have", and the gate
/// with the sharpest teeth: no guest, no mutated tree.
fn receives_have_providers(cfg: &SystemConfig) -> Result<(), String> {
let mut providers: Vec<&str> = SUPERVISOR_SERVED.to_vec();
for prog in cfg.programs.values() {
providers.extend(prog.serves.iter().map(String::as_str));
providers.extend(prog.provides.iter().map(String::as_str));
}
for (name, prog) in &cfg.programs {
for r in &prog.receives {
if !providers.contains(&r.as_str()) {
return Err(format!(
"program `{name}` receives `{r}`, which no program serves or provides"
));
}
}
}
Ok(())
}

/// A received name reaches the supervisor, a started row's port or a row's
/// own, and each refusal has a config that takes it and nothing else.
/// `every_config_renders_its_manifest` runs it over every committed config.
#[test]
fn every_receives_names_a_provider() {
for cfg in ALL_CONFIGS {
receives_have_providers(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}"));
fn a_received_name_reaches_a_server_that_runs_and_never_its_own() {
let gate = |text: &str| receives_reach_a_running_server(&toml::from_str(text).unwrap());
assert_eq!(
gate("[boot]\nstart = [\"netstack\"]\n[programs.netstack]\nserves = [\"netstack\"]\n\
[programs.host]\nreceives = [\"netstack\", \"power\", \"surface\"]\n\
[programs.terminal]\nprovides = [\"surface\"]\n[apps]\nreceives = [\"netstack\", \"power\"]\n"),
Ok(())
);
for (text, said) in [
("[programs.host]\nreceives = [\"ghost\"]\n", "`host` receives `ghost`, which no row in `[boot] start` serves"),
(
"[programs.netstack]\nserves = [\"netstack\"]\n[programs.host]\nreceives = [\"netstack\"]\n",
"`host` receives `netstack`, which no row in `[boot] start` serves",
),
(
"[boot]\nstart = [\"netstack\"]\n[programs.netstack]\nserves = [\"netstack\"]\nreceives = [\"netstack\"]\n",
"`netstack` receives `netstack`, which it serves itself",
),
(
"[programs.relay]\nprovides = [\"netstack\"]\n[programs.host]\nreceives = [\"netstack\"]\n",
"`relay` provides `netstack`, which the supervisor mints from its started server alone",
),
("[apps]\nreceives = [\"ghost\"]\n", "`[apps] receives` names `ghost`"),
("[programs.netstack]\nserves = [\"netstack\"]\n[apps]\nreceives = [\"netstack\"]\n", "`[apps] receives` names `netstack`"),
("[programs.terminal]\nprovides = [\"surface\"]\n[apps]\nreceives = [\"surface\"]\n", "`[apps] receives` names `surface`"),
] {
match gate(text) {
Err(why) if why.contains(said) => {}
other => panic!("{text:?}: {other:?}, not {said:?}"),
}
}
let bad: SystemConfig =
toml::from_str("[programs.client]\nreceives = [\"ghost\"]\n").unwrap();
assert!(receives_have_providers(&bad).is_err());
}

/// The swap port reaches `swap` and nothing else, and the idle slot
Expand Down Expand Up @@ -3123,38 +3163,6 @@ mod tests {
assert!(toml::from_str::<SystemConfig>("[programs.shell]\nstart = [\"toybox\"]\n").is_err());
}

/// `[apps] receives` is narrower than a program's: a `provides` name is one
/// port per instance and nobody makes one for a package, so naming one here
/// is a namespace the supervisor cannot build.
fn apps_receive_a_served_name(cfg: &SystemConfig) -> Result<(), String> {
let mut served: Vec<&str> = SUPERVISOR_SERVED.to_vec();
for prog in cfg.programs.values() {
served.extend(prog.serves.iter().map(String::as_str));
}
for name in &cfg.apps.receives {
if !served.contains(&name.as_str()) {
return Err(format!("`[apps] receives` names `{name}`, which no program serves"));
}
}
Ok(())
}

#[test]
fn an_installed_app_receives_only_names_the_supervisor_holds() {
for cfg in ALL_CONFIGS {
apps_receive_a_served_name(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}"));
}
let provided: SystemConfig = toml::from_str(
"[apps]\nreceives = [\"surface\"]\n\
[programs.terminal]\nprovides = [\"surface\"]\n",
)
.unwrap();
assert!(apps_receive_a_served_name(&provided).is_err());
let ghost: SystemConfig =
toml::from_str("[apps]\nreceives = [\"ghost\"]\n").unwrap();
assert!(apps_receive_a_served_name(&ghost).is_err());
}

/// A `serves` name is one port machine-wide; a `provides` name is one port
/// per instance. A name declared both ways is a config where the supervisor makes a
/// port nobody accepts from while the real one is made elsewhere.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
status: open
kind: defect
opened: 2026-10-12
---

# netstack replaces one program's cached answers to keep another's

netstack's DNS cache keeps each program's answers in a view of its own, named
by the row and session its `netstack` connector was minted for, and holds at
most `toyos_dns::MAX_VIEWS` views. When a lookup of a program with no view
ends with an answer to keep and every view holds a live answer, the least
recently used view is replaced whole. That view's program finds its next
lookup of a name it held sent to the resolver, and so learns that at least
`MAX_VIEWS` other programs kept answers since its own last lookup: a count of
programs, never a name. Anyone can
multiply the tenants, since every ssh login opens a session, so a program can
also replace another's view on purpose, or keep views of its own and watch the
oldest go to count other programs' first lookups. A view whose answers have all
expired is replaced before a live one, which tells its program nothing.

No bound on one pool whose tenants anyone can multiply avoids this. The lookup
slots (`issues/netstack-lookup-slots-have-no-per-client-share.md`) and the
node's places (`issues/netstacks-places-are-one-number-for-every-client.md`)
share its root: what netstack holds for a program is charged to netstack, not
to the program.

Evidence: `toyos-dns`'s host test
`a_new_view_replaces_the_least_recently_used_live_view_when_none_has_expired`.

Exit condition: a view is charged to the program it serves, so netstack never
replaces one program's view to keep another's, and a test of the node in which
`MAX_VIEWS` + 1 programs each keep an answer finds the first program's next
lookup of its name answered with no query.

Owner: whoever holds `issues/toyos-has-its-own-network-stack.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
status: open
kind: tooling
opened: 2026-10-12
---

# Nothing shows that netstack keys a lookup's cached answers by its program

netstack's DNS cache answers a program only from its own view, which
`system/netstack/src/serve.rs` names by the badge on the lookup's connection,
and the supervisor mints each program its own `netstack` connector, badged
with its row and its session. Host tests hold the cache's rules, and none
reaches either wiring. A change that passed one view for every lookup, minted
one badge for every program, or minted a row's badge without its session, so
that two logins of one row shared answers, would turn no test red: no guest
test resolves a name (`https_get` dials an address), and under QEMU's slirp
the lease's resolver forwards to the host's, which reaches the internet. The
T14 read the row half once, in #883's metal measurement; nothing has read the
session half, since that run's second row ran in the first's session.

Exit condition: a netcase guest test against a resolver the harness answers
(`issues/there-is-no-network-gate.md`'s `-netdev socket`) shows that a lookup
of a name one row holds sends a query when a second row asks it, and when the
same row asks it in a second session.

Owner: `issues/there-is-no-network-gate.md`, whose `-netdev socket` the test
needs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
status: owner
kind: question
opened: 2026-10-12
---

# The lease's resolver's cache tells a program what this machine asked

netstack asks the resolvers the held lease names, and keeps each program's
answers to that program (`toyos-dns`'s views). The resolver upstream keeps one
cache for every client it serves, and netstack cannot partition a cache it
does not hold. A program learns whether anyone asked that resolver for a name
within the name's TTL in two ways:

- it times its own lookup, which comes back sooner when the resolver holds the
name than when the resolver has to ask the name's servers;
- it sends a query with recursion not desired (RD clear, RFC 1035 §4.1.1) from
a datagram socket of its own, which the resolver answers from its cache if
at all.

On a network where this machine is the resolver's only client, that is what
this machine's other programs asked. Only a resolver that iterates from the
root itself, for each view, and asks no shared recursive resolver removes it,
at a round trip per delegation per view.

## The question

Does netstack resolve iteratively for each view, or does ToyOS accept this
channel?

## Exit condition

The owner's answer. If netstack is to close the channel, this file becomes a
`track` whose exit is a test of the node in which one view's lookup of a name
another view's lookup fetched sends no query to the lease's resolver. If the
channel is accepted, this file becomes a `defect` that stays open.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
status: open
kind: finding
opened: 2026-10-12
---

# The step from netstack's accept to a whole request is bimodal

Found by pull request #883's T14 run, which stamped every lookup netstack
served in two boots of one session: base, without the DNS cache, and cache,
with it ([evidence `a370c0a9`](https://github.com/ToyOSOrg/evidence/tree/a370c0a950b49ca54cea7abbe234a4f597dcdfed/dnscache-metal-804ccd7fc/run)).
The stamps bound the step from `n1`, the return of the wait in which
netstack's pass accepted the client's connection, to `n2`, its read of the
request whole. Read per stamp ([`accread-base.txt`](https://github.com/ToyOSOrg/evidence/blob/aad605669378cdf7ee67c82eccc9eb31070943b2/dnscache-r2/accread-base.txt),
[`accread-cache.txt`](https://github.com/ToyOSOrg/evidence/blob/aad605669378cdf7ee67c82eccc9eb31070943b2/dnscache-r2/accread-cache.txt)),
it has two modes in both arms and nothing between them:

- 0.0285–0.2007 ms on 225 of base's 228 stamps, and 0.0394–0.2220 ms on 249
of cache's 253;
- 0.3586–0.4056 ms on the other seven: in base, the download's own lookup in
runs 5 and 6 and `host`'s, and in cache, the download's own lookup in runs
4, 5 and 6 and `host`'s. `host`'s read 0.362 ms in both.

Hits and misses show it alike, in both arms, so it is no work of the cache,
whose own part of a hit, `n2` to the answer, has a median of 5.3 µs. It is not yet
known to be netstack's: the span also holds the client's write of its request
after its connect returns, and netstack's next pass of its loop up to that
read. The step after the answer, to the client's return, read 0.131–0.209 ms
over the twelve downloads' lookups, the same order as the slow mode's excess.

Both arms ran pull request #861's netstack, whose wait returns only on a
change (`system/netstack/src/watches.rs`): each descends from its head
`443fb5d10`. Both predate #871's kernel, which saves user FP/vector state only
at a switch instead of at every system call, and the span holds system calls
on both sides.

**Exit**: a T14 run whose stamps on both sides of the IPC, the client's write
and its return beside netstack's wake and read, name what the slow mode waits
on; then it is removed, or recorded as a defect in the file that owns that
step, and this file goes.

**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`, whose
server's loop and stamps the span crosses.
5 changes: 3 additions & 2 deletions system/netstack/dns/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# A member of the host workspace (root `Cargo.toml`): netstack resolves with it on
# the guest, and its tests run on the host. What lives here is every decision a
# stub resolver makes — the question, which datagram answers it, what the
# answer says, when to ask again — pure, so each is checked without a network.
# answer says, when to ask again, how long an answer is kept and for whom —
# pure, so each is checked without a network.

[package]
name = "toyos-dns"
description = "A stub DNS resolver's decisions (RFC 1035): the question, which datagram answers it, what it says and when to ask again, pure."
description = "A stub DNS resolver's decisions (RFC 1035): the question, which datagram answers it, what it says, when to ask again, and how long an answer is kept, and for whom, pure."
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
Expand Down
Loading