test fault-on-AP and affinity; report the faulting core

fault-ap-df pins a #DF to an AP (its own IST must catch it); affinity checks a pinned task never migrates. onException now names the core, so an AP fault is attributed and shown contained. Both teeth-checked.
This commit is contained in:
Daniel Samson
2026-07-08 14:45:28 +01:00
parent 37f72a4df8
commit 26ac97df31
4 changed files with 127 additions and 8 deletions
+16 -3
View File
@@ -194,7 +194,15 @@ next lands.
core online, and enters the run loop. With interrupts on, each core's own timer tick core online, and enters the run loop. With interrupts on, each core's own timer tick
preempts its idle context into whatever the global ready queue offers — so all cores preempts its idle context into whatever the global ready queue offers — so all cores
pull real work in parallel. The `smp` test spawns CPU-bound workers and confirms they pull real work in parallel. The `smp` test spawns CPU-bound workers and confirms they
execute on all four cores at once. execute on all four cores at once, and `fault-ap-df` pins a #DF to an AP and checks
that core catches it on **its own** IST (a broken per-core TSS would triple-fault) —
reported as "core N: …", so a fault is always attributed to the core it happened on,
and is contained to that core (the rest of the system keeps running).
- **Thread affinity** — `spawnOn(entry, priority, cpu)` pins a task to a core (its own
per-core pinned queue, merged with the global queue at selection; see
[scheduling.md](scheduling.md#affinity-pinning-a-task-to-a-core)). The `affinity`
test confirms a pinned task never migrates. This is the mechanism the fault-on-AP
test rides on, and the *explicit-affinity* real-time-predictable model.
- **`single_threaded` off** — the kernel was built `single_threaded = true`, which - **`single_threaded` off** — the kernel was built `single_threaded = true`, which
compiles `std.atomic` down to plain non-atomic ops. Harmless on one core, but it compiles `std.atomic` down to plain non-atomic ops. Harmless on one core, but it
quietly breaks the big kernel lock across cores; it's now `false`. quietly breaks the big kernel lock across cores; it's now `false`.
@@ -214,8 +222,13 @@ next lands.
- **IPIs** — cross-core wake/preempt. Not needed for correctness: an idle core wakes - **IPIs** — cross-core wake/preempt. Not needed for correctness: an idle core wakes
on its own timer tick and pulls ready work then; IPIs only cut that latency from on its own timer tick and pulls ready work then; IPIs only cut that latency from
≤1 ms to near-instant. ≤1 ms to near-instant.
- **Per-core run queues + thread affinity** — the Fiasco.OC direction, if the single - **Per-core run queues** — the Fiasco.OC direction, if the single global queue's lock
global queue's lock contention ever bites (and the more real-time-predictable model). contention ever bites. (Thread *affinity* already exists — see above; this is the
further step of giving each core its own primary run queue for load distribution.)
- **Fault recovery** — today a fault halts (only) the faulting core. Turning that into
"kill the task, keep the core running" is the [resilience](resilience.md) track (it
needs the task's lock/resource state handled), and for taking a core fully offline,
its tasks migrated first.
## Further reading ## Further reading
+9 -5
View File
@@ -320,21 +320,25 @@ fn kib(frames: u64) u64 {
return frames * danos.page_size / (1024); return frames * danos.page_size / (1024);
} }
/// Report a CPU exception and halt. There's no fault recovery yet, so any /// Report a CPU exception and halt **this core**. There's no fault recovery yet, so
/// exception is terminal — but it reports what and where (to every output sink, /// the faulting core is terminal — but the fault is *contained* to it: on an
/// plus a POST code and a persistent breadcrumb) instead of silently resetting. /// application processor only that core stops, and the rest of the system keeps
/// running (full recovery — kill the task, keep the core — is the resilience track,
/// see docs/resilience.md). The report names the core so an AP fault is attributed,
/// and goes to every output sink plus a POST code and a persistent breadcrumb.
fn onException(state: *const arch.CpuState) noreturn { fn onException(state: *const arch.CpuState) noreturn {
log.checkpoint(cp_exception); log.checkpoint(cp_exception);
const core = scheduler.currentCpuIndex();
// A fault is user-facing enough to paint on screen too (via statusPrint), on // A fault is user-facing enough to paint on screen too (via statusPrint), on
// top of the diagnostic log. // top of the diagnostic log.
statusPrint("\nCPU EXCEPTION: {s} (vector {d})\n", .{ arch.vectorName(state.vector), state.vector }); statusPrint("\nCPU EXCEPTION on core {d}: {s} (vector {d})\n", .{ core, arch.vectorName(state.vector), state.vector });
statusPrint(" error code : 0x{x}\n", .{state.error_code}); statusPrint(" error code : 0x{x}\n", .{state.error_code});
statusPrint(" RIP : 0x{x:0>16}\n", .{state.rip}); statusPrint(" RIP : 0x{x:0>16}\n", .{state.rip});
statusPrint(" RSP : 0x{x:0>16}\n", .{state.rsp}); statusPrint(" RSP : 0x{x:0>16}\n", .{state.rsp});
if (state.vector == 14) statusPrint(" CR2 (addr) : 0x{x:0>16}\n", .{arch.readCr2()}); if (state.vector == 14) statusPrint(" CR2 (addr) : 0x{x:0>16}\n", .{arch.readCr2()});
var buf: [128]u8 = undefined; var buf: [128]u8 = undefined;
log.recordPanic(std.fmt.bufPrint(&buf, "CPU exception {s} (vector {d}) at RIP 0x{x}", .{ arch.vectorName(state.vector), state.vector, state.rip }) catch "cpu exception"); log.recordPanic(std.fmt.bufPrint(&buf, "CPU exception {s} (vector {d}) on core {d} at RIP 0x{x}", .{ arch.vectorName(state.vector), state.vector, core, state.rip }) catch "cpu exception");
arch.halt(); arch.halt();
} }
+91
View File
@@ -74,6 +74,8 @@ pub fn run(case: []const u8, boot_info: *const BootInfo) void {
ipcTest(); ipcTest();
} else if (eql(case, "smp")) { } else if (eql(case, "smp")) {
smpTest(); smpTest();
} else if (eql(case, "affinity")) {
affinityTest();
} else if (eql(case, "smp-stress")) { } else if (eql(case, "smp-stress")) {
stressTest(); stressTest();
} else if (eql(case, "smp-retry")) { } else if (eql(case, "smp-retry")) {
@@ -84,6 +86,8 @@ pub fn run(case: []const u8, boot_info: *const BootInfo) void {
faultPageFault(); faultPageFault();
} else if (eql(case, "fault-df")) { } else if (eql(case, "fault-df")) {
faultDoubleFault(); faultDoubleFault();
} else if (eql(case, "fault-ap-df")) {
faultApTest();
} else if (eql(case, "fault-nx")) { } else if (eql(case, "fault-nx")) {
faultNoExecute(); faultNoExecute();
} else if (eql(case, "fault-null")) { } else if (eql(case, "fault-null")) {
@@ -552,6 +556,52 @@ fn smpTest() void {
result(); result();
} }
// --- affinity: a pinned task never migrates -------------------------------
var affinity_cores = [_]bool{false} ** 8;
var affinity_running: bool = true;
fn affinityWorker() void {
const p: *volatile bool = &affinity_running;
while (p.*) {
const c = sched.currentCpuIndex();
if (c < affinity_cores.len) affinity_cores[c] = true;
}
sched.exit();
}
/// A task pinned to a core must run **only** on that core. Pin a busy worker to
/// core 1 and let it run through many preemptions; it must have stamped core 1 and no
/// other. An *unpinned* task scatters across cores (that's what the smp test shows),
/// so a broken pin fails this deterministically — over this many time slices a
/// free-floating task will land on some other core.
fn affinityTest() void {
log("DANOS-TEST-BEGIN: affinity\n", .{});
affinity_cores = .{false} ** 8;
affinity_running = true;
if (!sched.spawnOn(affinityWorker, 4, 1)) {
check("worker pinned to core 1 (run with -smp)", false);
result();
return;
}
var spins: u64 = 0;
while (spins < 3_000_000_000) spins +%= 1; // many time slices across the cores
affinity_running = false;
var settle: u64 = 0;
while (settle < 200_000_000) settle +%= 1; // let the worker see the flag and exit
var others: u32 = 0;
for (affinity_cores, 0..) |seen, c| {
if (seen and c != 1) others += 1;
}
log("DANOS-AFFINITY: pinned worker touched core 1={}, other cores={d}\n", .{ affinity_cores[1], others });
check("pinned task ran on its core (1)", affinity_cores[1]);
check("pinned task never migrated to another core", others == 0);
result();
}
// --- SMP stress: hammer the big kernel lock across cores ------------------ // --- SMP stress: hammer the big kernel lock across cores ------------------
const stress_pairs = 4; // producer/consumer pairs (8 tasks; fits the 16-task pool) const stress_pairs = 4; // producer/consumer pairs (8 tasks; fits the 16-task pool)
@@ -704,3 +754,44 @@ fn faultDoubleFault() void {
); );
bad_sp += 0; bad_sp += 0;
} }
var ap_reached_fault: bool = false;
/// A task that faults with a #DF *on whatever core it's pinned to*. Announces the
/// core, then triggers the same double fault as `faultDoubleFault` — which is only
/// survivable on IST1, so it exercises that core's own TSS.
fn apDoubleFaultTask() void {
log("DANOS-AP: task running on core {d}, triggering #DF\n", .{sched.currentCpuIndex()});
@atomicStore(bool, &ap_reached_fault, true, .release);
arch.disableInterrupts();
var bad_sp: u64 = 0x5000000000;
asm volatile (
\\mov %[sp], %%rsp
\\ud2
:
: [sp] "r" (bad_sp),
: .{ .memory = true }
);
bad_sp += 0;
}
/// Fault on an application processor. Pins a double-faulting task to core 1, so the
/// fault is taken and handled by *that core's own* IDT and TSS/IST — not the BSP's.
/// The harness matches "core N: double fault (vector 8)" with N ≥ 1, which can only
/// appear if the AP caught the #DF on its IST1 (a broken per-core TSS would
/// triple-fault and reset instead). We then show the BSP still runs afterwards, so
/// the fault was *contained* to the AP, not fatal to the system.
fn faultApTest() void {
log("DANOS-TEST-BEGIN: fault-ap-df\n", .{});
if (!sched.spawnOn(apDoubleFaultTask, 6, 1)) {
log("DANOS-AP: could not pin to core 1 (run with -smp) - FAIL\n", .{});
arch.halt();
}
// Wait until the AP is about to fault, then keep running to prove containment.
var spins: u64 = 0;
while (!@atomicLoad(bool, &ap_reached_fault, .acquire) and spins < 5_000_000_000) spins +%= 1;
var settle: u64 = 0;
while (settle < 500_000_000) settle +%= 1; // let the AP take + report the fault
log("DANOS-BSP: core {d} still running after the AP fault (contained)\n", .{sched.currentCpuIndex()});
arch.halt();
}
+11
View File
@@ -119,6 +119,11 @@ CASES = [
"smp": 4, "smp": 4,
"expect": r"DANOS-TEST-RESULT: PASS", "expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL"},
# Affinity: a pinned task must never migrate off its core.
{"name": "affinity",
"smp": 4,
"expect": r"DANOS-TEST-RESULT: PASS",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Stress the big kernel lock across cores; heavier, so a longer timeout. # Stress the big kernel lock across cores; heavier, so a longer timeout.
{"name": "smp-stress", {"name": "smp-stress",
"smp": 4, "smp": 4,
@@ -133,6 +138,12 @@ CASES = [
{"name": "fault-ud", "expect": r"invalid opcode \(vector 6\)"}, {"name": "fault-ud", "expect": r"invalid opcode \(vector 6\)"},
{"name": "fault-pf", "expect": r"page fault \(vector 14\)"}, {"name": "fault-pf", "expect": r"page fault \(vector 14\)"},
{"name": "fault-df", "expect": r"double fault \(vector 8\)"}, {"name": "fault-df", "expect": r"double fault \(vector 8\)"},
# Fault on an application processor: a #DF pinned to core 1 must be caught by that
# core's own IST (N >= 1), proving per-core TSS works; a broken one triple-faults.
{"name": "fault-ap-df",
"smp": 4,
"expect": r"core [1-9]\d*: double fault \(vector 8\)",
"fail": r"could not pin"},
{"name": "fault-nx", {"name": "fault-nx",
"expect": r"page fault \(vector 14\)", "expect": r"page fault \(vector 14\)",
"fail": r"NX not enforced"}, "fail": r"NX not enforced"},