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
10 changes: 9 additions & 1 deletion 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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ members = [
"toyos-symbols",
"toyos-tco",
"toyos-tmpdir",
"toyos-transport",
"toyos-untrusted",
"toyos-update",
"toyos-userbound",
Expand Down
18 changes: 18 additions & 0 deletions issues/design-debt/the-transport-heads-orderings-have-no-oracle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
status: open
kind: tooling
opened: 2026-09-27
---

# The transport head's orderings have no oracle

A consumer stores its head `Release` after it has loaded the entries below it,
and a producer loads the head `Acquire` before it writes over them
(`toyos-transport/src/queue.rs`, `Consumer::release` and `Producer::space`).
No test reds if either is `Relaxed`: what the pair forbids is load buffering —
a consumer's load of an entry reading the producer's later overwrite — which
loom does not model, and every other test runs both ends on one thread. The
tail's edge has its control (`publish-relaxed`); the head's has none.

**Exit condition.** A control that relaxes the head's two orderings, and a
model or a run on a weakly ordered CPU that goes red under it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
status: assigned
kind: defect
opened: 2026-09-27
---

# A read or write answered Lost is never answered

`Client::complete` (`toyos-blockring/src/client.rs`) takes a completion's tag
off the wire before it looks at the status. A user read or write answered
`Status::Lost` is then refused as `Violation::Entry`, and the session ends; but
the tag is no longer on the wire, so `Client::session_ended` does not answer it
`Refused`, and nothing ever answers its ticket. blockd's client
(`userland/blockd/src/session.rs`) keeps that ticket in `pending` for good,
across every reconnect. That breaks the module's own "every request asked for
is answered exactly once". Only a server that breaks the protocol writes that
completion; blockd does not.

Held by the orchestrator.

**Exit condition.** A completion the client refuses leaves its tag answered by
the session's end — refused before the tag is taken off the wire, or answered
where it is refused — and a model or unit test in which a server answers a
write `Lost` goes red without the fix and green with it.
1 change: 1 addition & 0 deletions src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,7 @@ mod tests {
("toyos-sched-sim", "toyos-sched/sim/Cargo.toml"),
("toyos-proclife", "toyos-proclife/Cargo.toml"),
("toyos-blockring", "toyos-blockring/Cargo.toml"),
("toyos-transport", "toyos-transport/Cargo.toml"),
] {
let path = root.join(manifest);
let text = fs::read_to_string(&path)
Expand Down
7 changes: 5 additions & 2 deletions src/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ const SCHED_LOOM: &[&str] = &["-p", "toyos-sched-loom"];
const SCHED_SIM: &[&str] = &["-p", "toyos-sched-sim"];
const PROCLIFE: &[&str] = &["-p", "toyos-proclife"];
const BLOCKRING: &[&str] = &["-p", "toyos-blockring"];
const TRANSPORT: &[&str] = &["-p", "toyos-transport"];

const fn red(
krate: &'static [&'static str],
Expand Down Expand Up @@ -367,8 +368,10 @@ pub(crate) const CONTROLS: &[Control] = &[
red(BLOCKRING, "mutate-no-reissue-after-loss", None, &[
"what_a_flush_calls_durable_is_on_the_medium ... FAILED",
]),
red(BLOCKRING, "mutate-ring-publish-relaxed", Some("loom_ring"), &[
"a_published_request_is_read_whole ... FAILED",
red(TRANSPORT, "publish-relaxed", Some("loom"), &["a_published_entry_is_read_whole ... FAILED"]),
red(TRANSPORT, "no-clamp", Some("loom"), &["a_hostile_producer_yields_entries_or_a_violation ... FAILED"]),
red(TRANSPORT, "end-keeps-inflight", None, &[
"an_end_answers_every_tag_once_and_a_late_completion_nothing ... FAILED",
]),
];

Expand Down
10 changes: 8 additions & 2 deletions tests/common/blockd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,8 +432,12 @@ pub fn blockd_serves_partitions(
Ok(())
}

/// blockd's two failures, each survived by its client.
/// blockd's two failures, each survived by its client, and a client that
/// breaks the protocol, survived by blockd.
///
/// - `hostile-head`: with a write on the device, a client moves its completion
/// ring's head a ring behind blockd's tail; the answer finds no room, blockd
/// ends that session, and serves the next.
/// - `reset`: blockd withholds its second write's answer; the silence ends in
/// a controller reset; the withheld write is answered not done; and the
/// write acknowledged before it, which the reset may have lost, is on the
Expand All @@ -454,6 +458,7 @@ pub fn blockd_survives_its_death(
rust_bins: &[(String, Vec<u8>)],
) -> Result<(), String> {
let (mut qemu, layout, disk, trace, before) = boot(c_bins, rust_bins, "blockd-death", &[])?;
let hostile = role(&mut qemu, "hostile-head", Duration::from_secs(120))?;
let reset = role(&mut qemu, "reset", Duration::from_secs(240))?;
for want in [
"blockd: WITHHELD the device's answer to a write",
Expand All @@ -477,7 +482,8 @@ pub fn blockd_survives_its_death(
}
let tail = partclaim::shut_down(qemu);
partclaim::no_panic("on the way down", &tail)?;
let mut log = Serial::named("blockd_survives_its_death", format!("{}{}", reset.serial, crash.serial));
let mut log =
Serial::named("blockd_survives_its_death", format!("{}{}{}", hostile.serial, reset.serial, crash.serial));
log.push(&tail);
log.must_be_clean()?;

Expand Down
12 changes: 12 additions & 0 deletions tests/toyos-rust-tests/Cargo.lock

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

85 changes: 83 additions & 2 deletions tests/toyos-rust-tests/src/bin/blockd_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
//! was answered;
//! - `bench` — the same bytes through the kernel's driver (a partition claim on
//! the first controller) and through blockd, timed;
//! - `hostile-head` — a client that, with a write on the device, moves its
//! completion ring's head a ring behind blockd's tail: the session is
//! ended, and blockd serves the next one;
//! - `reset` — blockd started withholding its second answer: the silence ends
//! in a controller reset, the withheld write is answered not done, and the
//! write acknowledged before it is on the medium after the next flush;
Expand All @@ -32,9 +35,12 @@
use std::io::{BufRead, BufReader, Write};
use std::os::toyos::process::{ChildExt, CommandExt};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::Ordering;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::time::{Duration, Instant};

use blockd::nvme::{Controller, Owner};
use blockd::region::Region;
use blockd::{Error, Outcome, Session, Unsent};
use toyos::endow::Endowments;
use toyos::poller::{Poller, READABLE};
Expand All @@ -45,8 +51,9 @@ use toyos::syscap::SysCap;
use toyos::AsHandle;
use toyos_abi::part::PartGuid;
use toyos_abi::syscall::{DeviceType, PciId, SyscallError, DEV_PREFIX, SERVE_PREFIX, SYSCAP_LABEL};
use toyos_blockring::layout::{ARENA, CQ_HEAD, CQ_TAIL, DEPTH, SQ_BASE, SQ_TAIL};
use toyos_blockring::wire::{self, Refusal};
use toyos_blockring::{BLOCK_BYTES, MAX_REQUEST_BLOCKS, PORT};
use toyos_blockring::{Op, Request, BLOCK_BYTES, MAX_REQUEST_BLOCKS, PORT};

const SELF: &str = "/system/bin/test_rs_blockd_io";

Expand Down Expand Up @@ -91,6 +98,10 @@ const NARROW: u64 = 128 * 1024 * 1024;
/// a liveness bound, far past what one block takes.
const AIMED: Duration = Duration::from_secs(10);

/// How long `hostile-head` waits for blockd to withhold its write's answer, and
/// then for the reset that ends its session: a liveness bound.
const SILENCE_ENDS: Duration = Duration::from_secs(30);

fn guid(text: &str) -> [u8; 16] {
PartGuid::parse(text).unwrap_or_else(|| panic!("{text} is no GUID")).0
}
Expand Down Expand Up @@ -121,6 +132,8 @@ struct Blockd {
acceptor: Acceptor,
connector: Connector,
child: Option<Child>,
/// Every line the running blockd says.
said: Option<Receiver<String>>,
}

impl Blockd {
Expand All @@ -130,7 +143,7 @@ impl Blockd {

fn with(syscap: SysCap, args: &[&str]) -> Self {
let (acceptor, connector) = port::create().unwrap_or_else(|e| fail(format!("no port: {e:?}")));
let mut blockd = Self { syscap, acceptor, connector, child: None };
let mut blockd = Self { syscap, acceptor, connector, child: None, said: None };
blockd.spawn(args, false);
blockd
}
Expand Down Expand Up @@ -164,6 +177,7 @@ impl Blockd {
command.endow(&format!("{SERVE_PREFIX}{PORT}"), acceptor.0);
let mut child = command.spawn().unwrap_or_else(|e| fail(format!("blockd did not start: {e}")));
let out = child.stdout.take().expect("piped");
let (says, said) = mpsc::channel();
let mut kill = kill_on_withheld.then(|| {
toyos_abi::syscall::dup(toyos_abi::RawHandle(child.as_raw_handle()))
.unwrap_or_else(|e| fail(format!("blockd's handle would not duplicate: {e:?}")))
Expand All @@ -177,9 +191,27 @@ impl Blockd {
println!("blockd_io: blockd killed with the withheld write done on the device");
}
}
let _ = says.send(line);
}
});
self.child = Some(child);
self.said = Some(said);
}

/// Wait, at most `bound`, for the running blockd to say a line holding
/// `needle`.
fn says(&self, needle: &str, bound: Duration) {
let said = self.said.as_ref().expect("spawned");
let asked = Instant::now();
loop {
let left = bound.saturating_sub(asked.elapsed());
match said.recv_timeout(left) {
Ok(line) if line.contains(needle) => return,
Ok(_) => {}
Err(RecvTimeoutError::Timeout) => fail(format!("blockd did not say {needle:?} in {bound:?}")),
Err(RecvTimeoutError::Disconnected) => fail(format!("blockd ended before it said {needle:?}")),
}
}
}

/// End the running blockd, if one is, and wait for it to be gone.
Expand Down Expand Up @@ -497,6 +529,54 @@ fn reset() {
println!("blockd_io: PASS reset");
}

/// A client whose write is on the device moves its completion ring's head a
/// ring's depth behind the tail blockd published, so the answer finds no room:
/// blockd ends that session, and serves the next.
fn hostile_head() {
let blockd = Blockd::start(&["--silence-write", "1"]);
let region = Region::create().unwrap_or_else(|e| fail(format!("a region: {e:?}")));
let conn = blockd.names().open(PORT).unwrap_or_else(|e| fail(format!("the port: {e:?}")));
let shared = region.share().unwrap_or_else(|e| fail(format!("a second handle: {e:?}")));
conn.send_bytes_with_handles(&[shared], wire::MSG_OPEN, &guid(TARGET))
.unwrap_or_else(|e| fail(format!("the open: {e:?}")));
let header = conn.recv_header().unwrap_or_else(|e| fail(format!("the answer: {e:?}")));
let mut payload = [0u8; 64];
conn.recv_bytes(&header, &mut payload).unwrap_or_else(|e| fail(format!("the answer: {e:?}")));
if header.msg_type != wire::MSG_OPENED {
fail(format!("the slot's open was answered {}", header.msg_type));
}
// A write of the slot's block 0 from arena block 0 under tag 1, as the
// words a client puts on the request ring, published and rung.
let words = region.words();
let run = ARENA.run(0, 1).unwrap_or_else(|| fail("arena block 0 is no run".into()));
let write = Request { op: Op::Write { run, lba: 0 }, tag: 1 };
for (at, word) in write.encode().into_iter().enumerate() {
words[SQ_BASE + at].store(word, Ordering::Relaxed);
}
words[SQ_TAIL].store(1, Ordering::Release);
conn.write_nonblock(&[1]).unwrap_or_else(|e| fail(format!("the doorbell: {e:?}")));
blockd.says("WITHHELD", SILENCE_ENDS);
let tail = words[CQ_TAIL].load(Ordering::Acquire);
words[CQ_HEAD].store(tail.wrapping_sub(DEPTH), Ordering::Release);
conn.write_nonblock(&[1]).unwrap_or_else(|e| fail(format!("the doorbell: {e:?}")));
println!("blockd_io: with a write on the device, the client moved its completion head {DEPTH} behind the tail");
blockd.says("closed after", SILENCE_ENDS);
println!("blockd_io: blockd ended the session and runs on");
let mut next = open(blockd.names(), TARGET);
let block = pattern(0x6B, 0);
match next.write(0, &block) {
Ok(Outcome::Done) => {}
other => fail(format!("the next session's write was answered {other:?}")),
}
flushed(&mut next);
match next.read(0, 1) {
Ok((Outcome::Done, Some(data))) if data == block => {}
other => fail(format!("the next session read back {:?}", other.map(|(o, _)| o))),
}
println!("blockd_io: the next session wrote, flushed and read back the slot's block 0");
println!("blockd_io: PASS hostile-head");
}

/// The FAT32 volume's device: a session, with blockd's supervisor beside it.
struct Volume {
blockd: Blockd,
Expand Down Expand Up @@ -964,6 +1044,7 @@ fn main() {
Some("claims") => claims(),
Some("holder") => holder_role(args.get(2).map_or("", String::as_str)),
Some("bench") => bench(),
Some("hostile-head") => hostile_head(),
Some("reset") => reset(),
Some("crash") => crash(),
Some(role @ ("dma-inside" | "dma-outside" | "dma-revoked" | "dma-after")) => dma(role),
Expand Down
23 changes: 9 additions & 14 deletions toyos-blockring/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# A member of the host workspace (root `Cargo.toml`). What lives here is the
# block protocol between a block service and its client — the shared session
# page's layout, the two rings on it, the words a request and a completion are,
# the control frames a session is opened with, and every decision
# either end makes about what a completion means — with nothing that touches a
# device, a handle or a mapping. `userland/blockd` serves it and its client
# glue speaks it; both map the page and hand this crate the words.
# page's layout, where its two `toyos-transport` rings are, the words a request
# and a completion are, the control frames a session is opened with, and every
# decision either end makes about what a completion means — with nothing that
# touches a device, a handle or a mapping. `userland/blockd` serves it and its
# client glue speaks it; both map the page and hand this crate the words.
#
# Its defects are orders — a completion racing a crash, a flush racing a reset,
# a reconnect racing a reissue — so `src/model.rs` enumerates every ordering of
# a scripted client against a server, a device and a crash, and
# `tests/loom_ring.rs` checks the rings' publication under loom.
# a scripted client against a server, a device and a crash; the rings'
# publication is `toyos-transport`'s, and checked under loom there.

[package]
name = "toyos-blockring"
Expand All @@ -32,19 +32,14 @@ mutate-abort-keeps-inflight = []
# re-issues nothing: writes it had acknowledged are gone and a later flush
# still says durable.
mutate-no-reissue-after-loss = []
# The rings publish a tail with `Relaxed`, so the consumer can see the index
# before the entry's words.
mutate-ring-publish-relaxed = []

[dependencies]
# Whose flush answers for which writes the disk lost, and who holds which span:
# the server half's bookkeeping, decided where the kernel's block layer decides
# it today.
toyos-blockhold = { path = "../toyos-blockhold" }

[dev-dependencies]
# Already resolved in this workspace for `kernel-loom` and `toyos-sched/loom`.
loom = "0.7"
# The rings on the session page.
toyos-transport = { path = "../toyos-transport" }

[lints.rust]
warnings = "deny"
Loading
Loading