feat(kernel): seL4-style IPC, notifications, status ABI

- badges + Mint, Call/Reply/ReplyRecv w/ gen-checked reply slot
- cap transfer (1/msg, copy, needs Delegate, rechecked at delivery)
- notifications: signal/wait/poll/bind, irq -> notification routing
- WaitAny/WaitForInterrupt removed; a0=status, ErrorCode
- libkos: single ecall primitive, compat wrappers + typed new api
- fix: trap return clobbered user t0 on every trap
- fix: wait queues FIFO, delegate keeps badge, fault cap checked
- docs/ipc.md spec, ipc tests, ipc fuzz, kani ntfn + waitqueue
This commit is contained in:
2026-10-26 20:27:38 +01:00
parent d43421e33e
commit 76f10ff1f1
46 changed files with 3339 additions and 1296 deletions

View File

@@ -5,7 +5,7 @@ use platform::Platform;
use types::{
address::PhysicalAddress,
bounded::{CapSlot, Priority},
id::{EndpointId, IrqId, ProcessId, ThreadId},
id::{EndpointId, ProcessId, ThreadId},
permission::PermissionSet,
time::TimeSlice,
};
@@ -41,10 +41,6 @@ impl Kernel<MockPlatform> {
tid
}
pub fn register_irq_for_test(&mut self, irq: IrqId, thread: ThreadId) {
let _ = self.irqs.register(irq, thread);
}
pub fn block_thread_for_test(&mut self, tid: ThreadId, reason: domain::thread::BlockReason) {
let core = self.platform.core_id();
if let Ok(t) = self.threads.get_mut(tid) {
@@ -128,6 +124,10 @@ impl Kernel<MockPlatform> {
}
}
pub fn destroy_process_notifications_for_test(&mut self, owner: ProcessId) {
self.destroy_notifications_owned_by(owner);
}
pub fn thread_state_for_test(&self, tid: ThreadId) -> Option<ThreadState> {
self.threads.get(tid).ok().map(|t| *t.state())
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,350 @@
//! Randomized check of the IPC state machine. Six threads in three
//! processes send, call, receive, reply and signal at random over two
//! endpoints and a notification; one process is periodically killed and
//! re-created. After every step the kernel's queues, block reasons, reply
//! slots and run queue must agree, and no message may be duplicated.
use super::*;
use crate::syscall::{Delivery, DeliveryKind};
use crate::{SyscallOutcome, SyscallResult};
use domain::message::Message;
use domain::resource::Resource;
use domain::syscall::Syscall;
use domain::thread::{BlockReason, ThreadState};
use types::bounded::CapSlot;
use types::id::{EndpointId, NotificationId, ThreadId};
use types::permission::{Permission, PermissionSet};
const STEPS: usize = 4000;
const THREADS_PER_PROC: usize = 2;
const PROCS: usize = 3;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
}
fn all_perms() -> PermissionSet {
PermissionSet::empty()
.with(Permission::Read)
.with(Permission::Write)
.with(Permission::Execute)
.with(Permission::Delegate)
}
struct Proc {
pid: ProcessId,
threads: [ThreadId; THREADS_PER_PROC],
eps: [CapSlot; 2],
ntfn: CapSlot,
}
struct World {
k: Kernel<MockPlatform>,
procs: [Proc; PROCS],
eps: [EndpointId; 2],
ntfn: NotificationId,
next_id: usize,
delivered: [bool; 2 * STEPS + 16],
sent: [bool; 2 * STEPS + 16],
calls_answered: usize,
signals_seen: usize,
ran: usize,
rebirths: usize,
}
impl World {
fn new() -> Self {
let (mut k, p0) = kernel_with_process();
let t0 = k.current_thread().unwrap();
let p1 = k.create_process_internal().unwrap();
let p2 = k.create_process_internal().unwrap();
// Endpoints and the notification belong to init, which never dies.
let eps = [
k.create_endpoint(p0).unwrap(),
k.create_endpoint(p0).unwrap(),
];
let ntfn = k.create_notification(p0).unwrap();
let mut procs = [p0, p1, p2].map(|pid| Proc {
pid,
threads: [t0; THREADS_PER_PROC],
eps: [CapSlot::new(0).unwrap(); 2],
ntfn: CapSlot::new(0).unwrap(),
});
for (i, p) in procs.iter_mut().enumerate() {
Self::equip(&mut k, p, eps, ntfn, i == 0, t0);
}
Self {
k,
procs,
eps,
ntfn,
next_id: 1,
delivered: [false; 2 * STEPS + 16],
sent: [false; 2 * STEPS + 16],
calls_answered: 0,
signals_seen: 0,
ran: 0,
rebirths: 0,
}
}
fn equip(
k: &mut Kernel<MockPlatform>,
p: &mut Proc,
eps: [EndpointId; 2],
ntfn: NotificationId,
is_init: bool,
init_thread: ThreadId,
) {
for j in 0..THREADS_PER_PROC {
p.threads[j] = if is_init && j == 0 {
init_thread
} else {
k.spawn_thread_for_test(p.pid, Priority::DEFAULT, TimeSlice::DEFAULT)
};
}
for (e, ep) in eps.iter().enumerate() {
p.eps[e] = k.grant_cap_for_test(p.pid, Resource::Endpoint(*ep), all_perms());
}
p.ntfn = k.grant_cap_for_test(p.pid, Resource::Notification(ntfn), all_perms());
// The second thread of every process gets the notification bound.
k.switch_to_thread_for_test(p.threads[1]);
let _ = k.handle_syscall(Syscall::BindNotification {
notification: p.ntfn,
});
}
fn fresh_id(&mut self) -> usize {
let id = self.next_id;
self.next_id += 1;
self.sent[id] = true;
id
}
fn record(&mut self, d: &Delivery) {
match d.kind {
DeliveryKind::Signal => self.signals_seen += 1,
_ => {
let id = d.words[0];
assert!(
id < self.sent.len() && self.sent[id],
"delivered id {id} was never sent"
);
assert!(!self.delivered[id], "message {id} delivered twice");
self.delivered[id] = true;
}
}
}
fn record_outcome(&mut self, outcome: &SyscallOutcome) {
if let SyscallOutcome::Complete(SyscallResult::Received(d)) = outcome {
let d = *d;
self.record(&d);
}
}
fn runnable(&self) -> impl Iterator<Item = (usize, ThreadId)> + '_ {
self.procs.iter().enumerate().flat_map(move |(pi, p)| {
p.threads
.into_iter()
.filter(|&t| {
matches!(
self.k.thread_state_for_test(t),
Some(ThreadState::Ready | ThreadState::Running(_))
)
})
.map(move |t| (pi, t))
})
}
/// Kills process `pi` and brings it back with fresh threads and caps.
fn reincarnate(&mut self, pi: usize) {
self.k.destroy_process(self.procs[pi].pid);
self.procs[pi].pid = self.k.create_process_internal().unwrap();
let t0 = self.procs[0].threads[0];
Self::equip(
&mut self.k,
&mut self.procs[pi],
self.eps,
self.ntfn,
false,
t0,
);
self.rebirths += 1;
}
fn step(&mut self, rng: &mut Rng) {
let count = self.runnable().count();
if count == 0 || rng.below(100) == 0 {
// Random clients deadlock; killing someone is what breaks it.
self.reincarnate(1 + rng.below(PROCS - 1));
return;
}
let pick = rng.below(count);
let (pi, tid) = self.runnable().nth(pick).unwrap();
self.ran += 1;
self.k.switch_to_thread_for_test(tid);
// What the adapter would hand the thread when it resumes.
if let Some(SyscallResult::Received(d)) = self.k.take_pending_result(tid) {
self.record(&d);
}
let ep = self.procs[pi].eps[rng.below(2)];
let ntfn = self.procs[pi].ntfn;
let call = match rng.below(9) {
0 => Syscall::Send {
endpoint: ep,
message: Message::from_words([self.fresh_id(), 0, 0, 0]),
},
1 | 2 => Syscall::Call {
endpoint: ep,
message: Message::from_words([self.fresh_id(), 1, 0, 0]),
},
3 | 4 => Syscall::Receive { endpoint: ep },
5 => Syscall::Reply {
message: Message::from_words([self.fresh_id(), 2, 0, 0]),
},
6 => Syscall::ReplyRecv {
endpoint: ep,
message: Message::from_words([self.fresh_id(), 2, 0, 0]),
},
7 => Syscall::Signal { notification: ntfn },
_ => match rng.below(2) {
0 => Syscall::Wait { notification: ntfn },
_ => Syscall::Poll { notification: ntfn },
},
};
let is_reply = matches!(call, Syscall::Reply { .. } | Syscall::ReplyRecv { .. });
let had_reply = self.k.ipc[tid.raw() as usize].reply_to.is_some();
let outcome = self.k.handle_syscall(call);
if is_reply && had_reply {
self.calls_answered += 1;
}
self.record_outcome(&outcome);
}
fn check(&self) {
let k = &self.k;
for p in &self.procs {
for &t in &p.threads {
let Some(state) = k.thread_state_for_test(t) else {
continue;
};
let ipc = &k.ipc[t.raw() as usize];
let queued_on = |e: EndpointId| {
let ep = k.endpoints.get(e).unwrap();
(ep.is_sender(t), ep.is_receiver(t))
};
let in_any_queue = self.eps.iter().any(|&e| {
let (s, r) = queued_on(e);
s || r
}) || k.notifications.get(self.ntfn).unwrap().is_waiter(t);
match state {
ThreadState::Blocked(BlockReason::Sending(e) | BlockReason::Calling(e)) => {
assert_eq!(
queued_on(e),
(true, false),
"{t:?} blocked sending but not queued"
);
assert!(
ipc.outgoing.is_some(),
"{t:?} queued sender without a message"
);
}
ThreadState::Blocked(BlockReason::Receiving(e)) => {
assert_eq!(
queued_on(e),
(false, true),
"{t:?} blocked receiving but not queued"
);
}
ThreadState::Blocked(BlockReason::WaitingNotification(n)) => {
assert!(k.notifications.get(n).unwrap().is_waiter(t));
}
ThreadState::Blocked(BlockReason::AwaitingReply) => {
assert!(!in_any_queue, "{t:?} awaiting reply but still queued");
let me = crate::syscall::ThreadRef {
tid: t,
generation: k.threads.generation(t).unwrap(),
};
let holders = self
.procs
.iter()
.flat_map(|p| p.threads)
.filter(|&s| k.thread_state_for_test(s).is_some())
.filter(|s| k.ipc[s.raw() as usize].reply_to == Some(me))
.count();
assert_eq!(holders, 1, "{t:?} awaits a reply nobody can send");
}
ThreadState::Ready | ThreadState::Running(_) | ThreadState::Dead => {
assert!(!in_any_queue, "{t:?} runnable but queued on an IPC object");
assert!(ipc.outgoing.is_none());
}
}
let blocked = matches!(state, ThreadState::Blocked(_));
assert_eq!(
k.scheduler.is_queued(t),
matches!(state, ThreadState::Ready),
"{t:?} in state {state:?} has wrong run-queue membership"
);
if blocked {
assert_ne!(k.current_thread(), Some(t));
}
// A live reply slot always points at a thread waiting for it.
if let Some(r) = ipc.reply_to
&& k.threads.generation(r.tid) == Some(r.generation)
{
assert_eq!(
k.thread_state_for_test(r.tid),
Some(ThreadState::Blocked(BlockReason::AwaitingReply)),
"{t:?} holds a reply for {:?} which is not waiting",
r.tid
);
}
}
}
for &e in &self.eps {
let ep = k.endpoints.get(e).unwrap();
assert!(!(ep.has_waiting_sender() && ep.has_waiting_receiver()));
}
}
}
#[test]
fn ipc_state_machine_holds_under_random_operations() {
for seed in [3u64, 0xC0FFEE, 0xFEED_FACE, 99, 123_456_789] {
let mut world = World::new();
let mut rng = Rng(seed);
for _ in 0..STEPS {
world.step(&mut rng);
world.check();
}
assert!(
world.calls_answered > 20,
"seed {seed:#x}: too few replies: answered={} signals={} delivered={} ran={}",
world.calls_answered,
world.signals_seen,
world.delivered.iter().filter(|d| **d).count(),
world.ran
);
assert!(
world.rebirths > 5,
"seed {seed:#x}: kill/re-create barely exercised"
);
assert!(world.signals_seen > 20, "seed {seed:#x}: too few signals");
assert!(world.delivered.iter().filter(|d| **d).count() > 200);
}
}

View File

@@ -6,10 +6,10 @@ use crate::{SyscallOutcome, SyscallResult};
use domain::message::Message;
use domain::resource::Resource;
use domain::syscall::Syscall;
use domain::thread::{BlockReason, ThreadState};
use domain::thread::ThreadState;
use types::address::DataRef;
use types::bounded::CapSlot;
use types::id::{IrqId, ThreadId};
use types::id::ThreadId;
use types::permission::{Permission, PermissionSet};
fn create_process(k: &mut Kernel<MockPlatform>) -> CapSlot {
@@ -177,57 +177,6 @@ fn endpoint_slots_are_reclaimed_when_processes_die() {
}
}
#[test]
fn interrupt_routing_does_not_survive_the_owning_thread() {
let (mut k, pid_a) = kernel_with_process();
let tid_a = k.current_thread().unwrap();
let cap_b = create_process(&mut k);
let pid_b = ProcessId::from_raw(1);
let tid_b = k.spawn_thread_for_test(pid_b, Priority::DEFAULT, TimeSlice::DEFAULT);
let irq = IrqId::from_raw(10);
k.register_irq_for_test(irq, tid_b);
k.switch_to_thread_for_test(tid_a);
k.handle_syscall(Syscall::Kill { process: cap_b });
// A new thread reuses B's thread id and happens to be blocked.
let reused = k.spawn_thread_for_test(pid_a, Priority::DEFAULT, TimeSlice::DEFAULT);
assert_eq!(reused, tid_b);
k.block_thread_for_test(reused, BlockReason::Receiving(CapSlot::new(1).unwrap()));
assert_eq!(
k.handle_interrupt(irq),
None,
"IRQ delivered to an unrelated thread"
);
}
#[test]
fn interrupt_numbers_beyond_the_table_are_ignored() {
let (mut k, _) = kernel_with_process();
// The PLIC can report sources up to 1023.
assert_eq!(k.handle_interrupt(IrqId::from_raw(1000)), None);
}
#[test]
fn exiting_a_thread_releases_its_interrupt_route() {
let (mut k, pid_a) = kernel_with_process();
let tid_a = k.current_thread().unwrap();
let irq = IrqId::from_raw(10);
k.register_irq_for_test(irq, tid_a);
k.spawn_thread_for_test(pid_a, Priority::DEFAULT, TimeSlice::DEFAULT);
assert!(matches!(
k.handle_syscall(Syscall::ExitThread),
SyscallOutcome::TerminateCaller
));
let reused = k.spawn_thread_for_test(pid_a, Priority::DEFAULT, TimeSlice::DEFAULT);
assert_eq!(reused, tid_a);
k.block_thread_for_test(reused, BlockReason::Receiving(CapSlot::new(1).unwrap()));
assert_eq!(k.handle_interrupt(irq), None);
}
#[test]
fn drop_cap_frees_the_slot() {
let (mut k, pid_a) = kernel_with_process();
@@ -253,3 +202,43 @@ fn killing_yourself_terminates_the_caller() {
assert!(matches!(outcome, SyscallOutcome::TerminateCaller));
assert!(!k.process_exists(pid_a));
}
#[test]
fn exiting_a_thread_unbinds_its_notification() {
let (mut k, pid_a) = kernel_with_process();
let tid_a = k.current_thread().unwrap();
let n = k.create_notification(pid_a).unwrap();
let rw = PermissionSet::empty()
.with(Permission::Read)
.with(Permission::Write);
let ncap = k.grant_cap_for_test(pid_a, Resource::Notification(n), rw);
assert!(!is_error(&k.handle_syscall(Syscall::BindNotification {
notification: ncap
})));
let other = k.spawn_thread_for_test(pid_a, Priority::DEFAULT, TimeSlice::DEFAULT);
assert!(matches!(
k.handle_syscall(Syscall::ExitThread),
SyscallOutcome::TerminateCaller
));
// A new thread reuses the id and blocks in an unrelated receive.
let reused = k.spawn_thread_for_test(pid_a, Priority::DEFAULT, TimeSlice::DEFAULT);
assert_eq!(reused, tid_a);
let ep = k.create_endpoint_for_test();
let recv = k.grant_cap_for_test(pid_a, Resource::Endpoint(ep), ipc_perms());
k.switch_to_thread_for_test(reused);
assert!(matches!(
k.handle_syscall(Syscall::Receive { endpoint: recv }),
SyscallOutcome::BlockCaller(_)
));
k.switch_to_thread_for_test(other);
k.handle_syscall(Syscall::Signal { notification: ncap });
assert!(
matches!(
k.thread_state_for_test(reused),
Some(ThreadState::Blocked(_))
),
"signal reached a thread that never bound the notification"
);
}

View File

@@ -5,6 +5,7 @@ mod capability;
mod exit;
mod invariants;
mod ipc;
mod ipc_invariants;
mod isolation;
mod lifecycle;
mod lifetimes;

View File

@@ -1,6 +1,6 @@
use super::*;
use domain::scheduler::SchedulerDecision;
use types::id::{CoreId, IrqId, ThreadId};
use types::id::CoreId;
use types::time::TimeSlice;
#[test]
@@ -30,26 +30,3 @@ fn tick_preempts_when_time_expired_and_other_thread_ready() {
let d2 = k.handle_tick(CoreId::from_raw(0));
assert!(matches!(d2, SchedulerDecision::Preempt(_)));
}
#[test]
fn interrupt_unknown_irq_returns_none() {
let (mut k, _) = kernel_with_process();
assert!(k.handle_interrupt(IrqId::from_raw(99)).is_none());
}
#[test]
fn interrupt_unblocks_registered_thread() {
let (mut k, _) = kernel_with_process();
let tid = ThreadId::from_raw(0);
let irq = IrqId::from_raw(5);
// Register and block the thread on this IRQ
k.register_irq_for_test(irq, tid);
k.block_thread_for_test(
tid,
domain::thread::BlockReason::WaitingForInterrupt(types::bounded::CapSlot::new(0).unwrap()),
);
let result = k.handle_interrupt(irq);
assert_eq!(result, Some(tid));
}