Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
status: open
kind: defect
opened: 2026-09-27
---

# A disk whose port went away panics the boot at ROOT's hold

`usb_transport_break` (nightly tier) fails its boot with:

PANIC: panicked at src/rootfs.rs:177:19:
boot: the partition ROOT was read from, <guid>, cannot be held: Unusable

`rootfs::hold_source` (#506, 265a0fce) runs after the USB gate. It asks
`gpt::claimable` for ROOT's partition, and that reads every registered disk's
table. The test arms `usb-port-gone`, which ends the gate's last read on its
disk as a port that read disconnected and leaves the disk to its port's
teardown. That disk's table read then fails, `table_refused` answers
`Unusable`, and `hold_source` panics, although ROOT is on the other stick.

Measured at 9583d913 merged with 5e446e5c:
- `cargo test --test toyos-build -- --nightly usb_transport_break` EXIT=1,
red alone as well.
- The same run with `usb-port-gone` removed from the test's `PARAMS`, as a
checked patch that was restored: the boot no longer panics, and the test
fails only on the port-gone assertion it no longer staged.

The nightlies agree:
- Red the same way on main's nightly at e8d7c9c0 (run 36228604597).
- Red at fd62f567 (run 36278449733, guest (7)).
- On the nightly at 3f46a019 (run 36111884575, before #506) it failed for a
different reason.

`cargo run -- --known-red usb_transport_break` answers NO.

**Exit**: a disk that left during boot is not a disk ROOT's hold must read,
or the boot's refusal is what `usb_transport_break` expects. Either way
`usb_transport_break` is green on a nightly.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
status: open
kind: defect
opened: 2026-09-27
---

# `screen_diag_boot` finds no `i8042:` line on the panel

`screen_diag_boot` (nightly tier) fails with `"i8042:" is not on screen five
seconds after the boot finished`. The decoded panel ends at `Boot: complete`,
and no i8042 line is painted in the five seconds after it. The i8042 health
line (`report_health`, `to_screen`) never reaches the panel on the diag image.

- Green on main's nightly at 3f46a019 (run 36111884575).
- Red at e8d7c9c0 (run 36228604597).
- Red at fd62f567 (run 36278449733, guest (9)).
- Red alone at 9583d913 merged with 5e446e5c: `cargo test --test toyos-build
-- --nightly screen_diag_boot` EXIT=1.

The range 3f46a019..e8d7c9c0 holds fourteen landings and is not bisected.
The candidates that touch the idle path, the console or the log are #502
(a288537b), #492 (b0adc600) and #506 (265a0fce).

`cargo run -- --known-red screen_diag_boot` answers NO.

**Exit**: the landing named by a build at it and at its parent, and
`screen_diag_boot` green on a nightly.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
status: open
kind: defect
opened: 2026-09-27
---

# `home_budget_refusal_retried` is red on every nightly

`home_budget_refusal_retried` (nightly tier) is red, and red alone, on the
nightlies of three trees, each time with the same two shapes:
- main at e8d7c9c0 (run 36228604597);
- PR #524's branch at 8c5be843 (run 36273557690);
- main at fd62f567 (run 36278449733, guest (12)).

The two shapes: `no fsync: /home/... durable on attempt line — the retry
never ran`, and `Boot timed out waiting for ===READY===`. In the second, the
console ends at the loader's `Applied 5483 relocations`, with
`fsync-budget-spent` on the boot parameter line. It was green at 3f46a019
(run 36111884575).

It is green alone on a dev host at f231c43e (`cargo test --test toyos-build
-- --nightly home_budget_refusal_retried` EXIT=0).
`cargo run -- --known-red home_budget_refusal_retried` answers NO.

`fsync-budget-spent` is the machine-wide actuator that
issues/boot-media/partition-claim-gives-up-reds-beside-other-guests-and-is-green-alone.md
names as racing whichever fsync the boot reaches first. Not shown: whether
that race is this test's cause.

**Exit**: the cause shown on a red run's log, and the test green on a
nightly.

This file was deleted.

21 changes: 21 additions & 0 deletions issues/kernel/std-and-libc-drop-the-answer-thread-join-gives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
status: open
kind: defect
opened: 2026-09-27
---

# std and libc drop the answer `thread_join` gives

`rust/library/std/src/sys/thread/toyos.rs`'s `Thread::join` and
`userland/libc/src/pthread.rs`'s `pthread_join` call
`toyos_abi::syscall::thread_join` and discard what it returns. A join the
kernel refuses — `NotFound` for a tid it never had or already collected,
`Gone` for a cancelled wait — reads as a thread that finished. std's unix
`join` panics on the same refusal, and `pthread_join` returns 0 for it.

It hid a kernel defect: from #513 until the fix beside this file,
`sys_thread_join` answered `NotFound` to every joiner that parked before its
thread exited, and only `fpu_isolation`, which asserts the answer, saw it.

**Exit**: std's `join` fails loudly on a refused join and `pthread_join`
returns the error, each with a guest test that joins a tid twice.
27 changes: 20 additions & 7 deletions kernel/src/syscall/proc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,24 @@ pub(super) fn sys_thread_join(tid: u64) -> u64 {
// None means never existed or already collected; the predicate below answers both.
let target = process::thread_sched(caller, tid);
let parkable = crate::scheduler::Parkable::at_entry();
loop {
match process::wait_thread_zombie(tid, caller) {
Ok(Some(_)) => return 0,
Ok(None) => {}
Err(()) => return SyscallError::NotFound.to_u64(),
}
// Collecting takes the zombie out of the table, so the first answer is kept: asked
// again, the same join finds no such thread.
let answer = core::cell::Cell::new(None);
let settled = || {
answer.get().is_some()
|| match process::wait_thread_zombie(tid, caller) {
Ok(None) => false,
Ok(Some(_)) => {
answer.set(Some(0));
true
}
Err(()) => {
answer.set(Some(SyscallError::NotFound.to_u64()));
true
}
}
};
while !settled() {
let Some(sched) = target.as_ref() else {
// Nothing to arm on and no zombie: wait_thread_zombie will never answer differently.
return SyscallError::NotFound.to_u64();
Expand All @@ -170,13 +182,14 @@ pub(super) fn sys_thread_join(tid: u64) -> u64 {
tid.raw() as u64,
WaitClass::Other,
Deadline::never(),
|| matches!(process::wait_thread_zombie(tid, caller), Ok(Some(_)) | Err(())),
settled,
)
.is_err()
{
return cancelled();
}
}
answer.get().expect("a settled join holds its answer")
}

pub(super) fn sys_nanosleep(nanos: u64) -> u64 {
Expand Down
6 changes: 4 additions & 2 deletions src/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use sha2::{Digest, Sha256};
use toyos_tmpdir::TempDir;

use crate::toolchain::HOSTED_ARCH;

Expand Down Expand Up @@ -131,7 +132,8 @@ pub fn install(root: &Path) -> Result<String, String> {
)
})?;
let token = std::env::var("GH_TOKEN").expect("checked above");
let tarball = std::env::temp_dir().join(ASSET);
let staging = TempDir::new("toolchain-install");
let tarball = staging.join(ASSET);
let into = root.join("rust/build");
fs::create_dir_all(&into).map_err(|e| format!("{}: {e}", into.display()))?;
// The retry is on the transfer and the unpack together: a truncated body is
Expand Down Expand Up @@ -206,7 +208,7 @@ pub fn ensure_published(root: &Path) -> Result<String, String> {
install nowhere"
));
}
let tmp = std::env::temp_dir();
let tmp = TempDir::new("toolchain-publish");
let manifest = manifest(root, &tag)?;
let notes = notes(root, &tag, &manifest)?;
fs::write(tmp.join("TOOLCHAIN"), &manifest).map_err(|e| e.to_string())?;
Expand Down
2 changes: 0 additions & 2 deletions src/sourcegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -837,8 +837,6 @@ const TEMP_DIR_ALLOWED: &[(&str, usize)] = &[
// A failed golden comparison leaves its dump for the reader to copy over
// the golden file: outliving the test is its purpose.
("toyos-keymap/tests/tables.rs", 1),
// `issues/build/the-release-path-stages-in-tmpdir-outside-the-scratch-guard.md`.
("src/release.rs", 2),
];


Expand Down
36 changes: 23 additions & 13 deletions tests/common/blockd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,12 @@ fn trace_events(trace: &Path) -> Result<Vec<Did>, String> {
/// whose writes to `span` did not end in a flush and after which another
/// lifetime wrote to it; its last write is the one blockd withheld the answer
/// to, and the ones before it since its last flush are the acknowledged ones.
/// The lifetime after it must write exactly those, in that order, before any
/// other write to `span` — which is what reissue means, and what a client
/// that forgot them cannot do by accident. Every Flush in the trace is
/// blockd's: the kernel's driver issues none.
/// The lifetime after it must write exactly those before any other write to
/// `span`, each after every one of them it overlaps that came before it —
/// which is what reissue means, and what a client that forgot them cannot do
/// by accident. Two writes that do not overlap may be in flight together, so
/// the device's order between them is neither lifetime's to keep. Every Flush
/// in the trace is blockd's: the kernel's driver issues none.
fn reissued_after(trace: &Path, span: Span) -> Result<Vec<u64>, String> {
let mut lives: Vec<Vec<Did>> = Vec::new();
for did in trace_events(trace)? {
Expand All @@ -281,9 +283,12 @@ fn reissued_after(trace: &Path, span: Span) -> Result<Vec<u64>, String> {
}
let wrote = |life: &[Did]| life.iter().any(|d| matches!(d, Did::Wrote { .. }));
let volume: Vec<&[Did]> = lives.iter().map(Vec::as_slice).filter(|l| wrote(l)).collect();
let tail = |life: &[Did]| -> Vec<u64> {
let writes = |dids: &[Did]| -> Vec<(u64, u64)> {
dids.iter().filter_map(|d| match d { Did::Wrote { lba, sectors } => Some((*lba, *sectors)), _ => None }).collect()
};
let tail = |life: &[Did]| -> Vec<(u64, u64)> {
let after = life.iter().rposition(|d| *d == Did::Flushed).map_or(0, |at| at + 1);
life[after..].iter().filter_map(|d| match d { Did::Wrote { lba, .. } => Some(*lba), _ => None }).collect()
writes(&life[after..])
};
let died: Vec<usize> = (0..volume.len().saturating_sub(1)).filter(|&i| !tail(volume[i]).is_empty()).collect();
let [died] = died[..] else {
Expand All @@ -295,7 +300,7 @@ fn reissued_after(trace: &Path, span: Span) -> Result<Vec<u64>, String> {
));
};
let mut unflushed = tail(volume[died]);
let withheld = unflushed.pop().expect("a tail is not empty");
let (withheld, _) = unflushed.pop().expect("a tail is not empty");
// An empty prefix equals anything, so a loss with nothing acknowledged
// before it would pass with no write to reissue.
if unflushed.is_empty() {
Expand All @@ -304,12 +309,17 @@ fn reissued_after(trace: &Path, span: Span) -> Result<Vec<u64>, String> {
acknowledged was left to reissue"
));
}
let next: Vec<u64> = volume[died + 1]
.iter()
.filter_map(|d| match d { Did::Wrote { lba, .. } => Some(*lba), _ => None })
.take(unflushed.len())
.collect();
if next != unflushed {
let next: Vec<(u64, u64)> = writes(volume[died + 1]).into_iter().take(unflushed.len()).collect();
let overlaps = |a: (u64, u64), b: (u64, u64)| a.0 < b.0 + b.1 && b.0 < a.0 + a.1;
// Every write that overlaps one of them, in the order it went out.
let around = |w: (u64, u64), ws: &[(u64, u64)]| ws.iter().copied().filter(|&o| overlaps(o, w)).collect::<Vec<_>>();
let (mut want, mut got) = (unflushed.clone(), next.clone());
want.sort_unstable();
got.sort_unstable();
let same = want == got && unflushed.iter().all(|&w| around(w, &unflushed) == around(w, &next));
let lbas = |ws: &[(u64, u64)]| ws.iter().map(|w| w.0).collect::<Vec<_>>();
let (unflushed, next) = (lbas(&unflushed), lbas(&next));
if !same {
return Err(format!(
"blockd died with the writes at sectors {unflushed:?} acknowledged and no flush after them \
(and {withheld} withheld); the blockd after it wrote {next:?} first"
Expand Down
Loading
Loading