Skip to content
Merged
18 changes: 18 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ members = [
"system/soundserver/virtio-sound",
"system/sshserver",
"system/supervisor",
"system/supervisor/lifecycle",
"system/swap",
"system/symbolize",
"system/terminal",
Expand All @@ -137,6 +138,7 @@ members = [
"system/vdagent/bridge",
"tests/guest/kernelprobe",
"tests/guest/metalprobe",
"tests/guest/pciholder",
"tests/guest/test-runner",
"tests/metal",
]
Expand Down
86 changes: 31 additions & 55 deletions build/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,12 @@ struct ProgramConfig {
provides: Vec<String>,
/// Names in this program's namespace, each a *connector*.
receives: Vec<String>,
/// Device classes the supervisor mints a claim for and endows.
/// Claims the supervisor mints for this row's one process and endows
/// (`toyos_manifest::Program::devices`).
devices: Vec<String>,
/// PCI matchers: one process of this row per function one names
/// (`toyos_manifest::Program::drives`).
drives: Vec<String>,
/// Rights on the `SysCap` duplicate the supervisor endows this program, by the names
/// `toyos_manifest::syscap_rights` takes. A handful of rows in the whole
/// tree declare one.
Expand Down Expand Up @@ -738,6 +742,7 @@ fn render_manifest(config: &SystemConfig) -> Vec<u8> {
provides: cfg.provides.clone(),
receives: cfg.receives.clone(),
devices: cfg.devices.clone(),
drives: cfg.drives.clone(),
syscap: cfg.syscap.clone(),
slots: cfg.slots,
service: cfg.service,
Expand Down Expand Up @@ -3044,6 +3049,7 @@ mod tests {
"images/console.toml",
"tests/images/acpicase.toml",
"tests/images/clipboardcase.toml",
"tests/images/drivescase.toml",
"tests/images/hostedclangcase.toml",
"tests/images/jobcase.toml",
"tests/images/latencycase.toml",
Expand Down Expand Up @@ -3242,52 +3248,14 @@ mod tests {
assert!(provides_disjoint_from_serves(&bad).is_err());
}

/// The supervisor mints one claim per device, so a config naming one twice starts a
/// program with a hole where its claim should be.
fn one_claimant_per_device(cfg: &SystemConfig) -> Result<(), String> {
let mut seen: BTreeMap<&str, &str> = BTreeMap::new();
for (name, prog) in &cfg.programs {
for d in &prog.devices {
if let Some(prev) = seen.insert(d, name) {
return Err(format!(
"device `{d}` is claimed by both `{prev}` and `{name}`; the second \
claim is refused at boot"
));
}
}
}
Ok(())
}

/// Not the capability boundary — `kernel/src/pcidev`'s slot reservation is,
/// and this compares `system.toml` strings.
#[test]
fn every_device_class_has_at_most_one_claimant() {
for cfg in ALL_CONFIGS {
one_claimant_per_device(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}"));
}
let bad: SystemConfig = toml::from_str(
"[programs.a]\ndevices = [\"framebuffer\"]\n\
[programs.b]\ndevices = [\"framebuffer\"]\n",
)
.unwrap();
assert!(one_claimant_per_device(&bad).is_err());
}

/// A device name the ABI does not know renders fine and leaves the supervisor with a
/// `devices` entry it cannot mint — a dead machine for a typo, where this is
/// a red in milliseconds. Same for a `syscap` right.
///
/// The ABI's own parser, not a copy of it: a `pci:<vendor>:<device>` entry
/// names a function and a class name names a class, and this is the same
/// `DeviceRequest::parse` the supervisor and the kernel read the entry with.
/// A `syscap` right the ABI does not know renders fine and leaves the
/// supervisor with a row it cannot endow — a dead machine for a typo,
/// where this is a red in milliseconds. A device entry that names nothing,
/// and two one device answers, are the manifest's own render refusals
/// (`toyos_manifest::render`), which `every_config_renders_its_manifest`
/// holds every config to.
fn names_only_real_capabilities(cfg: &SystemConfig) -> Result<(), String> {
for (name, prog) in &cfg.programs {
for device in &prog.devices {
if toyos_manifest::DeviceRequest::parse(device).is_none() {
return Err(format!("`{name}` names device `{device}`, which is not one"));
}
}
toyos_manifest::syscap_rights(&prog.syscap)
.map_err(|e| format!("`{name}`: {e}"))?;
}
Expand All @@ -3299,23 +3267,29 @@ mod tests {
for cfg in ALL_CONFIGS {
names_only_real_capabilities(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}"));
}
let bad_class: SystemConfig =
toml::from_str("[programs.a]\ndevices = [\"gpu\"]\n").unwrap();
assert!(names_only_real_capabilities(&bad_class).is_err());
// A PCI entry that names no function is the same defect one level down,
// and the one a hand-written config is most likely to make.
let bad_function: SystemConfig =
toml::from_str("[programs.a]\ndevices = [\"pci:1af4\"]\n").unwrap();
assert!(names_only_real_capabilities(&bad_function).is_err());
let bad_right: SystemConfig =
toml::from_str("[programs.a]\nsyscap = [\"root\"]\n").unwrap();
assert!(names_only_real_capabilities(&bad_right).is_err());
}

/// Two rows naming one function are refused where the image is built, by
/// both rows' names: the manifest's refusal reaches the build.
#[test]
fn two_rows_on_one_function_are_a_build_refusal_by_name() {
let config: SystemConfig = toml::from_str(
"[programs.netstack]\ndevices = [\"pci:02:00:00/1af4:1041\"]\n\
[programs.nic]\ndrives = [\"pci:02:00:00\"]\n",
)
.unwrap();
let refused = std::panic::catch_unwind(|| render_manifest(&config)).expect_err("rendered");
let said = refused.downcast_ref::<String>().expect("a formatted panic");
assert!(said.contains("netstack") && said.contains("nic") && said.contains("Overlap"), "{said}");
}

fn claims_no_device(cfg: &SystemConfig) -> Result<(), String> {
for (name, prog) in &cfg.programs {
if !prog.devices.is_empty() {
return Err(format!("program `{name}` claims {:?}", prog.devices));
if !prog.devices.is_empty() || !prog.drives.is_empty() {
return Err(format!("program `{name}` claims {:?} and drives {:?}", prog.devices, prog.drives));
}
}
Ok(())
Expand All @@ -3332,6 +3306,8 @@ mod tests {
let bad: SystemConfig =
toml::from_str("[programs.x]\ndevices = [\"framebuffer\"]\n").unwrap();
assert!(claims_no_device(&bad).is_err());
let driving: SystemConfig = toml::from_str("[programs.x]\ndrives = [\"pci:03:00:00\"]\n").unwrap();
assert!(claims_no_device(&driving).is_err());
}

/// `[boot] start` names program keys, so a typo is a build error rather than
Expand Down
2 changes: 1 addition & 1 deletion images/console.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ syscap = ["roster"]
service = true
restart = true
serves = ["block"]
devices = ["pci:1b36:0010"]
devices = ["pci:01:08:02/1b36:0010"]

# The file servers: DATA, the log and the running slot's volume, one process
# each, serving every program the directories of its role. Started again when
Expand Down
22 changes: 11 additions & 11 deletions images/system.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ assets = ["assets"]
# while the compositor had no way to ask for a program's *declared* authority —
# the launcher gives it one, and starting a service from `[boot] start` is not
# the thing that needed fixing.
start = ["logkeeper", "diskserver", "fileserver", "compositor", "soundserver", "netstack", "filepicker", "acpiserver", "vdagent", "ps2server"]
start = ["logkeeper", "diskserver", "fileserver", "compositor", "soundserver", "netstack", "filepicker", "acpiserver", "ps2server"]

# What every program launched out of `/apps` holds, whatever its own directory
# says. A package is unpacked by `/system/bin/pkg` into a writable directory, so
Expand Down Expand Up @@ -98,25 +98,25 @@ devices = ["isa:0060,0064:1,12"]
[programs.soundserver]
service = true
serves = ["soundserver", "volume"]
devices = ["hda-audio", "pci:1af4:1059"]
devices = ["hda-audio", "pci:04:01:00/1af4:1059"]
syscap = ["rt"]

# netstack holds the NIC's PCI function and drives it: the virtqueues, the register
# window and the interrupt are its own, and the kernel keeps only the claim.
# Named by vendor and device rather than by slot, so one row finds the card
# Named by class, vendor and device rather than by slot, so one row finds the card
# wherever firmware put it.
[programs.netstack]
service = true
serves = ["netstack"]
devices = ["pci:1af4:1041"]
devices = ["pci:02:00:00/1af4:1041"]

# The shared clipboard with QEMU's host: the virtio-serial function its
# vdagent chardev is on, claimed by vendor and device among the functions the
# kernel does not drive (the kernel's console is `1af4:1043` too), and the
# compositor's clipboard port. A machine with no such function, the T14 among
# them, leaves it to say so and exit.
# The shared clipboard with QEMU's host: one process per virtio-serial
# function the kernel does not drive (the kernel's console is `1af4:1043` too),
# holding the function its vdagent chardev is on, and the compositor's
# clipboard port. A machine with no such function, the T14 among them, starts
# none.
[programs.vdagent]
devices = ["pci:1af4:1043"]
drives = ["pci:07:80:00/1af4:1043"]
receives = ["clipboard"]

# Every launch it makes is in a login session, the one `update` and `swap`
Expand Down Expand Up @@ -255,7 +255,7 @@ starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "pai
service = true
restart = true
serves = ["block"]
devices = ["pci:1b36:0010"]
devices = ["pci:01:08:02/1b36:0010"]

# The file servers: DATA, the log and the running slot's volume, one process
# each, serving every program the directories of its role. Started again when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ directory is `issues/every-program-sees-only-the-files-it-was-given.md`'s.

## Stages

1. **init's waiter threads go**. A child's end is readiness on its handle,
and init still parks one thread per service on `SYS_PROCESS_WAIT`
(`close_when_it_ends`, `userland/init/src/main.rs`). *Exit*: init starts no
1. **Done** (#926, stage 1.2 of
`issues/a-device-function-is-driven-from-its-arrival-to-its-end.md`):
**init's waiter threads go**; the supervisor watches each child's end on
its own loop's poller. *Exit*: init starts no
thread to wait for a service's end, and `close_when_it_ends` is gone with
all it does kept: a `restart` row that ends is started again on the ports
it kept; a row without one has its acceptors closed, so a client's next
Expand Down
3 changes: 2 additions & 1 deletion issues/a-claims-own-refusals-are-read-by-nothing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Refusals of `kernel/src/pcidev/mod.rs` that no test reaches:
- `ClaimError::Owned`, `Refusal::NoInterrupt`, `Refusal::CapsTruncated` and
the domain's fault, whose guest readers the guest suite's cut moved to stage J
of `issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md`;
- `ClaimError::Ambiguous`, a config naming a device this machine has two of;
- `ClaimError::Ambiguous`, a partition GUID two partitions carry (a PCI
claim names one function by its arrival since #926);
- `ClaimError::KernelDriven`, a claim on a function one of this kernel's own
drivers bound;
- `ClaimError::Exhausted`, a claim past the vectors, remapping entries or
Expand Down
54 changes: 21 additions & 33 deletions issues/a-device-function-is-driven-from-its-arrival-to-its-end.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ opened: 2026-10-13

# A device function is driven from its arrival to its end

Held by the orchestrator. #910 is stage 1a.
Held by the orchestrator. #910 is stage 1.1 and #926 stage 1.2.

Today the kernel enumerates PCI once (`pcidev::publish`), and the supervisor
mints each claim only when it spawns a `[boot] start` row. A row names a PCI
function by vendor and device, and the kernel refuses the claim `Ambiguous` on
a machine with two. One thread per serving service parks in
`SYS_PROCESS_WAIT`. Nothing reaches userland when a device arrives or leaves.
Today the kernel enumerates PCI once (`pcidev::publish`) and numbers each
function by its arrival. The supervisor mints a `[boot] start` row's claims
when it spawns it, and starts a `drives` row's process on each function it
reads free in the inventory, at boot and after each end of a process it
started. Nothing reaches userland when a device arrives or leaves.

**Ruled** (owner, 2026-10-13):

Expand Down Expand Up @@ -83,30 +83,18 @@ a machine with two. One thread per serving service parks in
its handles owed, of every kind, has run on its last thread
(`object::Releases`), so `CLAIM_RETURN` and the guest tests' claim
retries went.
2. **Instances per function**, one pull request, which also updates the
three tracks folded here:
- `toyos_abi::inventory::Pci` gains an `arrival` number, never reused,
and a claim names its function by it; `DeviceRequest::Pci`,
`toyos_pci::claimed` and `ClaimError::Ambiguous` go.
- `lib/manifest` owns the matcher grammar (`pci:CC:SS:PP`, and
`pci:CC:SS:PP/VVVV:DDDD`). A `drives` row starts one instance per
matching function the kernel does not drive, is never in
`[boot] start`, may not `serves`, and reaches its consumer through
`receives`. A single-instance `devices` entry matching two functions
refuses that row's start, naming both.
- Two matchers that can match one function are a build refusal naming
both rows, which replaces `one_claimant_per_device` and keeps its
refusal of duplicate class, `isa` and `part:` names.
- The supervisor's lifecycle decisions move into
`system/supervisor/lifecycle`, host-tested. A refused start marks its
function refused until the inventory record changes and counts
against the arrival's restart budget.
- The supervisor watches each child's `Process` handle, and the waiter
threads go. vdagent becomes a `drives` row, and its "no such
function" path goes with it.
- Its guest arm, a q35 profile with two `edu` functions after the
claim-slot pull request, holds two instances up until judged, each
reporting its own function. `test-runner` gains `inventory`.
2. **Done** (#926): a claim names a PCI function by its inventory
record's `Arrival`, and `toyos_pci::claimed` and `DeviceRequest::Pci`
went; `ClaimError::Ambiguous` stays only for a partition GUID carried
twice. `lib/manifest` owns the matcher grammar, and two entries one
device can answer are a render refusal naming both rows, which replaced
`one_claimant_per_device`. A `drives` row has one process per function
the kernel does not drive and holds no `serves`, `roles` or `devices`.
The decisions are `toyos-supervisor` (`system/supervisor/lifecycle`),
the refusal law among its host tests; the supervisor watches each
child's `Process` handle and its waiter threads went; vdagent is a
`drives` row. `a_drives_row_holds_one_process_per_function` is the
guest arm, on `Profile::HeadlessTwoEdu`.
2. **Functions arrive and leave.**
- Readers get a device-set object, minted from `DEVICE`.
- The kernel provides native PCIe hot-plug on a port whose host bridge
Expand All @@ -124,9 +112,9 @@ a machine with two. One thread per serving service parks in
## Folded here

- Stage 1 of `issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md`
(the waiter threads) is this track's stage 1.2.
(the waiter threads) was this track's stage 1.2 (#926).
- Stage 2 of `issues/the-supervisor-is-host-tested-and-owns-the-stop.md`
extends the crate stage 1.2 makes. Restart policy and the claims a restart
is owed move with stage 1.2.
extends `toyos-supervisor`, the crate stage 1.2 made, which holds restart
policy and the claims a restart is owed.
- Step 10 of `issues/the-kernel-is-small-interrupts-post-and-threads-wait.md`
is stage 4.
Loading