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
8 changes: 8 additions & 0 deletions Cargo.lock

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

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ members = [
"toyos-i219",
"toyos-keymap",
"toyos-ld",
"toyos-logstream",
"toyos-manifest",
"toyos-mixer",
"toyos-pci",
Expand Down Expand Up @@ -110,6 +111,9 @@ toyos-fat32 = { path = "toyos-fat32" }
toyos-fat32-check = { path = "toyos-fat32-check" }
toyos-gpt = { path = "toyos-gpt" }
toyos-keymap = { path = "toyos-keymap" }
# The record stream's boot parameter, so the gate that clears a valued
# parameter by name reads the same constant the kernel and `logd` do.
toyos-logstream = { path = "toyos-logstream" }
toyos-manifest = { path = "toyos-manifest" }
# The watchdog's parameter name, so the gate over `kernel/src/params.rs` reads
# the same constant the kernel and the bootloader do.
Expand Down
24 changes: 24 additions & 0 deletions issues/design-debt/a-boot-with-no-log-volume-streams-nothing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
status: open
kind: defect
opened: 2026-09-08
---

# A boot with no `/log` streams nothing, and that is backwards for the bench

`userland/logd/src/main.rs`'s loop offers the record stream only what it has
already written to the volume, and it reaches that offer through
`let Some(v) = volume.as_mut() else { continue }`. So a boot with no log
partition, or one whose volume `policy::fate` has given up on, streams not one
record — the second sink is a mirror of the first and dies with it.

That is right for the rule it comes from (the file is the sink of record and the
stream may never cost it a line) and wrong for the machine the stream was built
for. On the bench's ThinkPad a dead stick is precisely when the cable is the
only channel left, and it is the case where the stream is worth most.

The exit condition is a decision about what `logd` offers when there is nothing
to write to: the records it read, or nothing. Whichever it is, the ordering rule
the stream rests on — a line reaches the file before it reaches the wire — has
to be restated for a boot where there is no file, because today it is what makes
the answer "nothing" by construction.
23 changes: 23 additions & 0 deletions issues/design-debt/logd-holds-a-netd-connector-on-every-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
status: open
kind: defect
opened: 2026-09-08
---

# `logd` holds a `netd` connector on every boot, and streams on almost none

`system.toml`'s `[programs.logd]` carries `receives = ["netd"]` on every image
this tree builds. The record stream it is for is armed by a boot parameter
(`logstream=`), which no shipping boot carries, so the one process that reads
every record every CPU wrote holds an outbound network connector for the whole
life of a machine that will never open a connection.

`init` builds a program's endowment from its manifest row before it spawns it
and has no way to make a row conditional on the parameter line, so the authority
is static while the feature is not. `sshd` is kept out of `[boot] start`
entirely for a weaker version of the same argument.

The exit condition is one of: `init` learns to grant a connector only when the
boot asked for what it is for; or the manifest gains a way to say "this row is
armed by this parameter"; or the tree decides an unopened connector is not
authority worth withholding and this file is closed by that ruling.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
status: open
kind: defect
opened: 2026-09-08
---

# The boot parameter line's value is read in two crates

`toyos-abi/src/boot.rs` reads the kernel command line twice — `root_uuid` for
`root=` and `actuators` for the token list — and
`toyos-logstream/src/lib.rs`'s `value_in` is a third reading with `root_uuid`'s
body: `cmdline.split(',').find_map(|t| t.strip_prefix(P))`. There is one
question there and it is asked in two crates.

The reading belongs in `toyos_abi::boot` beside its two neighbours, as one
function taking the prefix, with `root_uuid` and `value_in` both calls to it.
What stopped that here is the branch rule: a commit touching `toyos-abi/src` may
not share a branch with anything else, and the record stream is not an ABI
change.

The exit condition is `toyos_abi::boot` growing that one function on an ABI
branch of its own, and `toyos-logstream` losing `value_in` in the branch that
follows it.
10 changes: 10 additions & 0 deletions issues/hardware/the-t14-answers-only-through-a-usb-stick.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,15 @@ Constraints a reader would otherwise pay to re-derive:
hand-over, so a function still holding its last holder's queue addresses can
act on none of them. `release` asks for a reset where the function advertises
one; the I219 does, so on the T14 both hold.
- **The record stream is `logstream=<a.b.c.d>:<port>` on the parameter line**,
copied by the kernel into `/system/bin/init`'s environment and read from there
by `logd` (`toyos-logstream`'s `PARAM` and `ENV`). What is left to build is the
metal half: arming the flashed image with the Mac's address and listening while
the T14 boots. A boot that dies before `logd` runs still needs the stick.
- **A stalled peer's backpressure reaches `logd`'s queue only after megabytes.**
Between them stand a 2 MiB kernel pipe (`kernel/src/pipe.rs`'s `PIPE_SIZE`) and
netd's 64 KiB send buffer, and a `log-storm` at `--smp 8` produces 4,213 lines
/ 674 KiB — measured — which they absorb entirely. The guest arm for that path
widens the storm's records instead of narrowing the peer (`log-storm-wide`).
- The metal loop is `toyos-metal` (`src/metal.rs`), and the T14 is run by the
orchestrator alone.
33 changes: 33 additions & 0 deletions issues/kernel/netd-drops-what-the-clients-pipe-would-not-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
status: open
kind: defect
opened: 2026-09-08
---

# netd drops what arrives off the wire when the client's pipe will not take it

`userland/netd/src/main.rs`'s `bridge_piped` moves what the wire delivered into
the client's rx pipe like this:

```rust
Ok(n) if n > 0 => {
let _ = toyos_abi::syscall::write_nonblock(pipe.as_handle(), &buf[..n]);
}
```

`write_nonblock` answers **how many bytes it took**, and it takes fewer than it
was offered when the pipe is short of room. The return value is discarded, so
those bytes are gone: `recv_slice` has already consumed them from the socket,
the client is never told, and the stream it reads is short in the middle with
nothing anywhere saying so. A client slower than the wire is exactly when it
fires.

The send direction had the same shape and no longer does: it now takes out of
the pipe only what the socket has room for, so nothing is consumed from one side
without landing on the other. The same answer does not fit here yet — it needs
the pipe's remaining room, which no syscall answers today.

Reproduced on the send side by `tests/common/logstream.rs`'s
`log_stream_stalled_peer_wide_storm`: against a peer that had stopped reading,
a record arrived cut in half with the next record's line beginning inside it.
That arm is the reproduction to point this half at once the room is askable.
8 changes: 8 additions & 0 deletions kernel/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 kernel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ toyos-fat32 = { path = "../toyos-fat32" }
toyos-elf = { path = "../toyos-elf" }
toyos-gpt = { path = "../toyos-gpt" }
toyos-hda = { path = "../toyos-hda" }
toyos-logstream = { path = "../toyos-logstream" }
toyos-pci = { path = "../toyos-pci" }
toyos-pcid = { path = "../toyos-pcid" }
toyos-tco = { path = "../toyos-tco" }
Expand Down
3 changes: 3 additions & 0 deletions kernel/src/actuator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ actuators! {
/// Have every CPU emit patterned log records at once from spawned kernel threads.
log_storm = "log-storm";

/// Widen every storm record to nearly a whole record's message, so one boot offers a stalled log stream more than the buffers under it can hold.
log_storm_wide = "log-storm-wide";

/// Remove the IF/TF bracket around shard selection through publication — the negative control on the log's interrupt-atomicity claim.
log_unbracketed_reserve = "log-unbracketed-reserve";

Expand Down
10 changes: 9 additions & 1 deletion kernel/src/loader/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,15 @@ pub fn spawn_init() -> Pid {
}],
label.as_bytes().to_vec(),
);
match spawn(&[INIT_PATH], PendingHandles::Ready(handles, endowments), String::from("/"), Vec::new()) {
// **The only channel a boot parameter has to userland**, and the one thing
// the kernel ever puts in an environment: the command line reaches no
// process. It is information and not authority — reaching the address needs
// a `netd` connector, which one manifest row grants.
let env = match crate::params::log_stream() {
Some(at) => alloc::format!("{}={at}\0", toyos_logstream::ENV).into_bytes(),
None => Vec::new(),
};
match spawn(&[INIT_PATH], PendingHandles::Ready(handles, endowments), String::from("/"), env) {
Ok(object) => object.pid(),
Err(crate::object::Refusal::Error(e)) => panic!("spawn_init: failed to spawn: {e:?}"),
Err(crate::object::Refusal::Handle(e)) => panic!("spawn_init: {e}"),
Expand Down
16 changes: 13 additions & 3 deletions kernel/src/log/storm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ const STORM_RECORDS: u64 = 1024;
// Must exceed one machine word: a single-store payload couldn't reveal a torn write.
const PAYLOAD: usize = 96;

/// What `log-storm-wide` widens the payload to: a record's whole message less
/// the `t=`/`i=`/`k=` fields in front of it.
///
/// A storm of these is what a guest offers a log stream whose peer has stopped
/// reading and cannot outrun with narrow records — the buffers between `logd`
/// and that peer hold megabytes. The reader regenerates a payload from `t=` and
/// `i=`, so the log gate does not run on a boot carrying this.
const WIDE_PAYLOAD: usize = 900;

/// Deterministic checksum of `thread` and `index`, embedded in a record's `k=` field.
pub fn checksum(thread: u64, index: u64) -> u64 {
(thread.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ index.wrapping_mul(0xC2B2_AE3D_27D4_EB4F))
Expand All @@ -25,12 +34,13 @@ pub fn payload_byte(checksum: u64, offset: usize) -> u8 {
/// The reader regenerates this text independently from `t=`/`i=`, so the format here must stay in sync with it.
pub fn emit_patterned(thread: u64, index: u64) {
let checksum = checksum(thread, index);
let mut payload = [0u8; PAYLOAD];
for (offset, byte) in payload.iter_mut().enumerate() {
let width = if crate::actuator::log_storm_wide() { WIDE_PAYLOAD } else { PAYLOAD };
let mut payload = [0u8; WIDE_PAYLOAD];
for (offset, byte) in payload[..width].iter_mut().enumerate() {
*byte = payload_byte(checksum, offset);
}
// Fallback rather than `expect`: a panic here would halt the machine over the producer's own formatting.
let payload = core::str::from_utf8(&payload).unwrap_or("");
let payload = core::str::from_utf8(&payload[..width]).unwrap_or("");
crate::log!("logstorm t={thread} i={index} k={checksum:016x} {payload}");
}

Expand Down
61 changes: 55 additions & 6 deletions kernel/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
//! other kind of token and is test-only, so a kernel built without them refuses
//! every one it is handed; a name here is claimed before that table sees it,
//! and is the only way an image the owner flashes asks for anything.
//!
//! Two kinds live here: a flag, which is a name [`PARAMS`] matches whole, and a
//! parameter carrying a value after it, which [`claims`] matches as a prefix
//! and no table holds.

use core::sync::atomic::{AtomicBool, Ordering};

use crate::sync::Lock;

/// Each parameter beside the flag it sets, so a name cannot be claimed and then handled by nothing.
pub const PARAMS: &[(&str, &AtomicBool)] =
&[(toyos_tco::PARAM, &WATCHDOG_NAMED), ("early-panel", &EARLY_PANEL_NAMED)];
Expand All @@ -13,25 +19,68 @@ static WATCHDOG_NAMED: AtomicBool = AtomicBool::new(false);
static EARLY_PANEL_NAMED: AtomicBool = AtomicBool::new(false);
static PARSED: AtomicBool = AtomicBool::new(false);

/// [`toyos_logstream::PARAM`]'s value, **copied** and not borrowed.
///
/// The parameter line is in memory no reserved region covers, so `mm::init` may
/// hand it out; nothing may hold a borrow of it past [`init`]. There is also no
/// allocator yet, so the copy goes into a fixed buffer rather than a `String`.
static LOG_STREAM: Lock<([u8; toyos_logstream::MAX_VALUE_BYTES], usize)> =
Lock::new(([0; toyos_logstream::MAX_VALUE_BYTES], 0));

pub fn init(cmdline: &str) {
for token in toyos_abi::boot::actuators(cmdline) {
if let Some((_, named)) = PARAMS.iter().find(|(name, _)| *name == token) {
named.store(true, Ordering::Relaxed);
}
}
if let Some(value) = toyos_logstream::value_in(cmdline) {
// A value this buffer cannot hold is refused whole rather than
// truncated: half an address is an address, and it is somebody else's.
if value.len() > toyos_logstream::MAX_VALUE_BYTES {
crate::log!(
"log-stream: {}{value:?} is {} bytes and an address is at most {}",
toyos_logstream::PARAM,
value.len(),
toyos_logstream::MAX_VALUE_BYTES
);
} else {
let mut held = LOG_STREAM.lock();
held.0[..value.len()].copy_from_slice(value.as_bytes());
held.1 = value.len();
}
}
PARSED.store(true, Ordering::Relaxed);
}

/// Whether this kernel handles `token` itself, which is what stops
/// `actuator::init` refusing it as a name it does not know.
///
/// **The one parameter that carries a value is not in [`PARAMS`] and is not
/// read here**: the black-box page's address is read out of the raw buffer in
/// `kernel_main`'s first statements (`crate::blackbox::arm`), because a panic
/// before this function runs still has to be able to seal. All this does is stop
/// the actuator table refusing a word it does not know.
/// **Neither parameter that carries a value is in [`PARAMS`]**, which is a
/// table of flags matched whole; both are prefixes matched here. The black-box
/// page's address is not read by [`init`] either — it comes out of the raw
/// buffer in `kernel_main`'s first statements (`crate::blackbox::arm`), because
/// a panic before [`init`] runs still has to be able to seal. The log stream's
/// address is read by [`init`], because nothing before it needs one.
pub fn claims(token: &str) -> bool {
PARAMS.iter().any(|(name, _)| *name == token) || token.starts_with(toyos_blackbox::PARAM)
PARAMS.iter().any(|(name, _)| *name == token)
|| token.starts_with(toyos_blackbox::PARAM)
|| token.starts_with(toyos_logstream::PARAM)
}

/// Where this boot streams its records, for the one hop the kernel makes with
/// it: into `/system/bin/init`'s environment.
///
/// A `String` and not a borrow, because the buffer behind it is locked and the
/// bytes it copied are gone from the parameter line by now. Called once, after
/// the allocator exists.
pub fn log_stream() -> Option<alloc::string::String> {
let held = LOG_STREAM.lock();
if held.1 == 0 {
return None;
}
// Written from a `&str`, so this cannot fail; a refusal here would still be
// an empty stream rather than a boot that dies over a log's address.
core::str::from_utf8(&held.0[..held.1]).ok().map(alloc::string::ToString::to_string)
}

pub fn watchdog() -> bool {
Expand Down
Loading
Loading