Skip to content
Open
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
17 changes: 17 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 @@ -143,12 +143,14 @@ members = [
"system/vdagent",
"system/vdagent/bridge",
"tests/guest/hangupholder",
"tests/guest/inputsink",
"tests/guest/kernelprobe",
"tests/guest/metalprobe",
"tests/guest/osbench",
"tests/guest/osbench/lan",
"tests/guest/osbench/rules",
"tests/guest/pciholder",
"tests/guest/starveling",
"tests/guest/test-runner",
"tests/metal",
]
Expand Down
23 changes: 23 additions & 0 deletions build/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ struct SystemConfig {
/// them, because a package directory is writable.
#[serde(default)]
apps: AppsConfig,
/// The supervisor's own row, which no `[programs]` key is.
#[serde(default)]
supervisor: SupervisorConfig,
}

/// **Unknown fields refused**, as a row's are.
#[derive(Deserialize, Default)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
struct SupervisorConfig {
/// Its job's floor, in MiB (`toyos_manifest::Manifest::supervisor_floor`).
memory_floor_mib: u64,
}

#[derive(Deserialize, Default)]
Expand Down Expand Up @@ -199,6 +210,10 @@ struct ProgramConfig {
/// A login authority, for which the supervisor opens login sessions
/// (`toyos_manifest::Program::login`).
login: bool,
/// Its job's floor, in MiB: memory held back for it however short every
/// other job runs (`toyos_manifest::Program::memory_floor`). A control
/// path's alone, and reviewed as the privilege it is.
memory_floor_mib: u64,
}

/// The directories a program's crate sits directly under, by who uses it:
Expand Down Expand Up @@ -836,18 +851,25 @@ fn render_manifest(config: &SystemConfig) -> Vec<u8> {
starts: cfg.starts.clone(),
login: cfg.login,
folder: None,
memory_floor: mib(cfg.memory_floor_mib),
}
})
.collect(),
supervisor_serves: SUPERVISOR_SERVED.iter().map(|s| (*s).to_string()).collect(),
apps: config.apps.receives.clone(),
folder: apps_folder(config).expect("the gate above read it"),
start: config.boot.start.clone(),
supervisor_floor: mib(config.supervisor.memory_floor_mib),
};
toyos_manifest::render(&manifest)
.unwrap_or_else(|e| panic!("the image config cannot be rendered as a manifest: {e:?}"))
}

/// `mib` MiB in bytes; a floor past every machine is a broken config.
fn mib(mib: u64) -> u64 {
mib.checked_mul(1 << 20).unwrap_or_else(|| panic!("a memory floor of {mib} MiB is past every machine"))
}

/// Which build `root`'s tree makes for `arch` against the sysroot whose key
/// for it is `toolchain`, as `/system/etc/os-release` records it, read with
/// `git`. Untracked files are dirty whatever `status.showUntrackedFiles`
Expand Down Expand Up @@ -3183,6 +3205,7 @@ mod tests {
"tests/images/jobcase.toml",
"tests/images/latencycase.toml",
"tests/images/launchcase.toml",
"tests/images/floorcase.toml",
"tests/images/libccase.toml",
"tests/images/sessioncase.toml",
"tests/images/metalcase.toml",
Expand Down
5 changes: 5 additions & 0 deletions images/console.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

assets = ["assets"]

# The controls' floors, as `images/system.toml` gives them.
[supervisor]
memory-floor-mib = 96

[boot]
start = ["logkeeper", "diskserver", "fileserver", "console", "acpiserver", "ps2server"]

Expand Down Expand Up @@ -64,6 +68,7 @@ starts = ["toybox", "shell"]
restart = true
receives = ["input"]
devices = ["isa:0060,0064:1,12"]
memory-floor-mib = 24


# `power` because `bin/shutdown` below is this binary, and on the machine this
Expand Down
11 changes: 11 additions & 0 deletions images/system.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
# day delete it.
assets = ["assets"]

# **Floors are the machine's protected capacity**: memory held back for a
# control path's job however short every other job runs. Only a job that does
# no ordinary client work gets one: the supervisor and ps2server. A floor
# covers what the job holds as it runs, its 8 MiB main stack's commitment
# among it, and the headroom it needs to keep answering. usbd has none, since
# it drives disks too, so a USB keyboard is not protected
# (`issues/no-physical-memory-fairness.md`).
[supervisor]
memory-floor-mib = 96

# What the supervisor starts at boot. Program keys, not paths, and the order means
# nothing: every port exists before any server runs.
[boot]
Expand Down Expand Up @@ -103,6 +113,7 @@ login = true
restart = true
receives = ["input"]
devices = ["isa:0060,0064:1,12"]
memory-floor-mib = 24

[programs.soundserver]
service = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
status: open
kind: tooling
opened: 2026-10-24
---

# A command typed through QMP arrives truncated under host load

`session_lifecycle` types `detach /system/bin/hangupholder` into the
desktop's terminal, one QMP key press and release per character
(`tests/common/sessionlifecycle.rs`). Under heavy parallel host load the
guest can receive the command with keys missing, and the row then stalls
waiting for the holder's spawn.

Sightings:
- #1057, at `4bfe7e489`, in the 22-row QEMU filter with every image building
cold (2471 s of build work across the workers). The shell received
`detach /pholder`, and the supervisor's launcher said "no row declares
/pholder". The row passed alone three times, and the same filter passed on
its next run. `sessioncase.toml` declares no memory floors, and memory was
not short. Logs: https://github.com/ToyOSOrg/evidence/tree/81614cc6bcad9dcd7bbcd47d8f09462d1da7c957/r8
(`rows.log.gz`).
- #1060, at `04ec3f6f4`, with the host at 5.82×: `session_lifecycle` stalled
once and was not reproduced in nine later runs. The cause was not found, and
dropped keystrokes are a suspect.

Where the keys drop is not established. The candidates are QEMU's QMP key
injection and the i8042 model's queue, which the harness fills one keystroke
at a time with no wait for the guest to drain, or the guest's path from
ps2server through the compositor to the terminal.

Owner: the test harness. Exit: find where the keys drop, QMP or the guest's
input path, and fix it there. A guest that loses input is an input defect,
and pacing the harness to hide it is not an exit.
30 changes: 0 additions & 30 deletions issues/a-service-refused-its-restart-for-memory-stays-down.md

This file was deleted.

10 changes: 5 additions & 5 deletions issues/four-shares-take-a-file-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ so a `make -j16` whose jobs each stream to a log of their own holds all 16,
and separate stdout and stderr files per job need 32. Inferred, not
measured: the share's 32 served connections, spent by every process of the
session at one per directory, are probably the first bound such a build
reaches. Raising `STREAM_SHARE` doubles the uncharged rings a share can pin,
2 MiB each (`issues/nothing-charges-kernel-memory-to-a-process.md`), so it
waits for one of two exits: a measured parallel build on ToyOS shows which
bound it reaches and by how much, or kernel memory is charged and a share's
streams are bounded by bytes rather than by count.
reaches. Raising `STREAM_SHARE` doubles the rings a share can pin, 2 MiB
each, counted to the job whose write first needs each (`kernel/src/pipe.rs`),
so it waits for one of two exits: a measured parallel build on ToyOS shows
which bound it reaches and by how much, or a share's streams are bounded by
the bytes they cost its client's job rather than by count.

Owner: the file server.

Expand Down
139 changes: 94 additions & 45 deletions issues/no-physical-memory-fairness.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,102 @@ opened: 2026-07-30

# No physical memory fairness

Any process can allocate physical memory until the system runs out. No
per-process limits, no memory pressure signals, no OOM killer. A single
misbehaving process starves everything.

Running out is a refusal at the call, not a halted machine: a commitment is
promised its frames when it is made, and every kernel object a process causes
is charged before it is allocated (`issues/nothing-charges-kernel-memory-to-a-process.md`),
a thread's kernel stack among them. But the refusal falls on whichever process
asks next, its maker or any other.

**System controls must stay usable under exhaustion, and today they do not**
(owner, item 71: "Preserving usable system controls under exhaustion remains
required; allocation errors alone do not establish that protection"). While
`kernel_objects_refused` holds every frame a process may, every other process
is refused too, system services included. In #1045 round 1's `dirs-uncharged`
control, the file server's data instance died of a refused allocation, and the
supervisor's one refused restart was final
(`issues/a-service-refused-its-restart-for-memory-stays-down.md`).

**Exit:** per-job accounting, landing after the machine-level charges (item 71),
with a floor reserved for the control-path system jobs, the supervisor and the
input servers (the memory design's protected capacity), which no other job's
allocation can take. The supervisor retries a refused restart once memory
returns. Attribution does not impose fixed per-app limits (item 71). The guest
test: with one ordinary job holding every frame it may, the supervisor still
answers a stop and a restart, the input path still delivers, and a starved
service is serving again once the memory is given back.
Any ordinary job can take memory until the machine runs out, and every other
ordinary job is refused at its next call meanwhile. Nothing signals pressure,
and nothing a person can reach stops the job that took it.

**What holds now.** Everything a process causes is counted to its job's
account (`kernel::frames::Ledger`, read through `account_info`), and the
control paths' jobs keep a floor no other job's allocation can take
(`memory-floor-mib` in `images/system.toml`: the supervisor and ps2server).
A floor is a protected total allowance: the first `floor` of the job's use
is guaranteed, cgroup v2 `memory.min`'s contract, not an emergency
allowance on top of it (owner, 2026-10-24). Attribution imposes no per-job
limit. A service whose
restart is refused for memory keeps its ports and starts again once memory
comes back, which its job's readiness says. `memory_floor_holds_the_controls`
(QEMU) and the `memory_floor` metal row hold, with one ordinary job holding
every frame: ps2server's floor carrying a key to the owner of input, a
system holder killing the greedy job, and a starved service serving again.
**No test establishes the supervisor's floor**: with only it removed the
test passes. Without it, a service that dies under exhaustion, such as
fileserver refused its writeback, can be refused its restart for the
supervisor's own memory, which closes it (below).

**What is still missing, and is this entry's exit:**

- **The stop UI.** Under exhaustion nothing a person holds can find and stop
the greedy job, and no exhaustion recovery is claimed for visible controls
or for input: the guest test stands a system holder in for them. The
compositor has no floor (owner, 2026-10-24). Owner: the compositor and
memory tracks. Next: a bounded emergency path in the existing compositor,
in which ordinary rendering cannot spend protected capacity, and a review
of the whole path from input to the visible controls to the stop, USB
input included, before any recovery is claimed.
- **What the supervisor holds grows with the sessions and programs it
runs**, and on its floor. A login authority connects its own control
connection and an owner its own link, and a launched program's image is
not the supervisor's (its server's object, or a copy its own job holds),
but each live program still holds a few KiB of the supervisor's
(its handle, notice and the connectors minted for it), bounded only by the
process table. Owner: the supervisor track. Exit: what a launch makes for
the child counted to the child's job, or a bound on live launches the
floor is sized for.
- **A restart refused for the supervisor's own memory closes the service
for good.** The supervisor waits for memory only where the service's own
job recorded the refusal (`start_again`); where the supervisor's job was
refused, such as mapping a boot image for the child, the service is closed
instead. Owner: the supervisor track. Exit: a start refused for
the supervisor's own memory waits on the supervisor's own job's readiness,
as one refused for the service's waits on the service's.
- **Connect-time capacity.** Both rings of a connection are its connecting
job's, allocated at each one's first write, so a server's reply spends
nothing of its own; but a client's sends are not yet capacity its receiver
chose and was charged for at accept.
- **Floor deficits are the machine's, not each floor job's.** A floor's frames
and whole superframes are held again only as they come back free and whole
(`kernel/pure/frames.rs`), and what is short is pooled: one floor job's
pinned or scattered give-back can refuse another floor job inside its own
floor. Owner: the memory track. Exit: a law where one floor job's deficit
never refuses another's use inside its floor.
- **A USB keyboard is not protected.** usbd drives disks as well as keyboards,
so it is a mixed service and has no floor; the i8042's ps2server has one.
Owner: the USB track. Exit: USB input protected as the review of the input
path above decides; a driver split only to assign a floor is not
presumed.
- **A `drives` process refused its start for memory is not retried on the
memory event**, only at the next inventory read; a service is. Owner: the
supervisor track. Exit: a `drives` start refused for memory waits on its
job's readiness, as a service's restart does.
- **Kill draws on the kernel's kept superframes.** A kill and the teardown it
starts allocate transient heap, which the kept superframes cover rather
than an allocation-free path. Owner: the memory track. Exit: kill and
teardown allocate nothing.
- **`account_info` reports no resident pages, retained objects or live
processes**, which the memory design lists. Owner: the memory track. Exit:
the three in `AccountInfo`, read without the process lock.

**Exit:** a person can see which job holds the memory and stop it while every
frame is held, through a control drawn and served from capacity committed
before the exhaustion; and a connection's receive capacity is charged to its
receiver at accept. The guest test is `memory_floor`'s, its system holder
replaced by the control.

**The class statement, which is what predicts the next instance.** *A client's
request is an allocation request, and every one of them needs an owner who can
say no.* Three instances were filed under it — the compositor's windows, netd's
piped connections, and `SYS_CONNECT` pinning 4 MiB into an unbounded pending
queue — and all three now have a bound *and* a caller that hears the refusal,
which is the pair the class asks for: `toyos_desktop::max_windows` and netstack's
`places_for` each divide an eighth of physical memory by what one unit
costs and refuse past it, and a pipe allocates its ring page on first use rather
than at `create`. **What a bound alone still does not answer is whose window to
refuse.** The memory is charged to nobody, so a cap is the only thing between one
client and the machine — which is this entry, and is why both of those functions'
doc comments name a kernel memory limit as what deletes them. The kernel's
directory numbering and `mkdir`'s directories had such caps too, and charging
each to the machine deleted them.
say no.* Three instances were filed under it — the compositor's windows,
netstack's piped connections, and `SYS_CONNECT` pinning 4 MiB into an
unbounded pending queue — and all three have a bound *and* a caller that hears
the refusal: `toyos_desktop::max_windows` and netstack's `places_for` each
divide an eighth of physical memory by what one unit costs and refuse past it,
and a pipe allocates its ring page on first use rather than at `create`. Their
memory is counted to whichever job made it: a client's own connection is
its job's, but a pipe a server makes and hands out (fileserver's stream
pipes, netstack's bells) is the server's, which the server bounds. With no
per-job limit a cap is still the only thing between one client and the
machine.

**Promoted to `defect` 2026-08-25** (finding-lifecycle ruling: isolation
findings are security-adjacent and are promoted, never folded). One process
starving every other of physical memory is a denial of service across the
isolation boundary that needs no privilege and nothing crafted — it is real
today, not a possibility. Owed by whoever builds per-process physical
accounting; nothing in the tree attributes memory to a process or refuses one
for its share now.
findings are security-adjacent and are promoted, never folded). One job
starving every other of memory is a denial of service across the isolation
boundary that needs no privilege and nothing crafted.
Loading