rowan.id.au— Piers Rowan

HomePiROS › PREEMPTION.md

Keeping the box alive: PiROS scheduling & preemption

A build log of one optimisation thread in PiROS — from a purely cooperative scheduler to hardware-timer preemption — written to be read, argued with, and added to. It is deliberately honest about the dead ends, because the dead ends are where the learning is.

What problem is this really about? An operating system runs many things at once on one CPU. If one of those things gets stuck in a loop and never voluntarily gives the CPU back, does the whole machine freeze — console, network, everything? On a server you never want the answer to be "yes." This document is the story of making the answer "no," and of discovering that "no" is harder than it looks.

Glossary

Written for the range from curious hobbyist to systems engineer. Skim it, or jump back when a term bites.


Where we started: cooperative, on purpose

PiROS began life single‑core with a cooperative scheduler, and that was a considered choice, not a shortcut.

Almost everything PiROS does that could block — reading the serial console, accepting a TCP connection, waiting for a disk sector — is polled. The natural shape of polled I/O is: check if the thing is ready; if not, hand the CPU to someone else and try again later. That "hand the CPU to someone else" is exactly a cooperative yield. So the servers (the SSH daemon, background services) yield precisely when they would otherwise be waiting, and the console gets its turn. No timer, no locks held across switches, no re‑entrancy hazards. For an I/O‑bound system it is simple and it is correct.

The alternative we deferred: full preemptive scheduling from day one. We rejected it early because preemption drags in real complexity — chiefly, an interrupt can now land in the middle of kernel code, so any lock a preempted task was holding can deadlock the task that preempts it. The cooperative model sidesteps that entirely (a task only ever switches at a point it chose, where it holds no locks). We wrote the design note to ourselves at the time: "Real fix = timer preemption, but mind the locks."

The flaw we knew we were accepting: one CPU‑bound task that never yields freezes the entire box. Cooperative scheduling has no answer to a runaway. And a "provable‑runtime" OS whose selling point is that it safely runs other people's signed code cannot have "one bad loop takes down the server" as a known failure mode. So preemption went on the list — with eyes open.


The goal, stated plainly

A hung or runaway program must not be able to kill the machine. You should still be able to reach the box — console or SSH — and, ideally, stop the offender.

Everything below is in service of that one sentence.


First move: partial preemption with the legacy PIT

The first working step used the PIT, the simplest timer available, at ~100 Hz. Because PiROS runs the kernel with IF = 0, the timer only ever fires while a ring‑3 program is executing — never inside kernel code — so a timer‑driven context switch can never interrupt a lock holder. That property is what made preemption tractable at all.

But we did not enable it for everything at once. We enabled it only for kernel‑context tasks (l4 == 0): the console, the SSH server, and the short‑lived tools they run inline. Preempting those is safe because resuming them needs no address‑space switch. We explicitly skipped preempting real processes (l4 != 0), because early testing showed that preempting a spawned process left it "never advancing past its entry" — a symptom we didn't yet understand. The guard was a single line: preempt only if this isn't a real process.

Why ship a partial fix? Because it already bought something real and testable: a runaway tool typed at the console could no longer freeze the box. We proved it — set a deliberate infinite‑loop program spinning on the console, and the SSH server still answered a login on the network. The box stayed alive under a runaway. That was genuine progress, and shipping it kept the change small and the failure surface understood.

What we left broken, and admitted to: a runaway process — a spawned service, l4 != 0 — would still freeze the box, because we weren't preempting it. That was the boss fight, and we named it as the remaining work.


The boss fight: why a preempted process "never advanced"

Turning on preemption for real processes reproduced the old symptom immediately, so we stopped guessing and instrumented it. A deliberate runaway was spawned as a genuine process and every single preemption of it was traced.

The findings were sharp and, at first, baffling:

Putting that together: the process returned to ring 3 with a perfectly valid register state, and then a timer interrupt fired before it could execute a single instruction — every single time. That is a textbook livelock: resume → instant re‑preempt → resume → instant re‑preempt, forever, all at the entry point.

We ruled out, with evidence, several tempting explanations:

So the code looked correct. The bug was a runtime timing detail, and printf‑tracing had taken us as far as it could.


The two questions that cracked it

The breakthrough came from two pointed diagnostic questions:

1. What mode is your timer in — periodic, one‑shot, or TSC‑deadline? **2. Are you writing the End‑Of‑Interrupt at the start or the end of your handler?**

The answers, read straight from the code, were: the PIT, periodic, with EOI at the very start of the handler, before the context switch. And that combination is the livelock.

Here is the mechanism, spelled out:

  1. The timer fires. The handler immediately sends EOI — telling the interrupt controller "I'm done, you may deliver the next one" — and then performs a context switch to another task.
  2. But the PIT is periodic and free‑running. It keeps ticking throughout the switch and throughout the other task's run.
  3. Because the kernel runs with IF = 0, one of those ticks gets latched as pending.
  4. When we finally switch back to the preempted process and it executes iretq to return to ring 3, IF becomes 1 — and the pending tick is delivered that instant, before the process runs one instruction.
  5. Go to 1.

This also explained a note left by an earlier attempt: "tried moving EOI to the end of the handler — didn't help." Of course it didn't. The handler switches stacks in the middle (it resumes a different task), so the EOI that eventually runs belongs to a different task's handler than the interrupt that's pending. A periodic timer plus a stack‑switching interrupt handler cannot keep the interrupt‑to‑EOI pairing straight. You cannot fix this by moving one write. The periodic model is the problem.

Crucially — this was not the CR3 switch, not memory corruption, not the verification system. It was the shape of the clock.


The fix: make the clock a one‑shot

The cure follows directly from the diagnosis. Replace the periodic PIT with the LAPIC timer used as a one‑shot.

A one‑shot timer fires once and then hardware‑disarms itself. It physically cannot tick again — cannot latch a pending interrupt — until we deliberately re‑arm it. So it cannot fire during a context switch. We re‑arm it only when a task is about to run, EOI to the LAPIC, and the resumed task always gets a genuine, full quantum of forward progress. The livelock's fuel — a stray pending tick generated while we were busy switching — simply stops existing.

Two flavours of one‑shot, chosen automatically at boot:

Why the fallback isn't optional. We discovered — by probing CPUID on the running machine — that QEMU's pure‑software CPU (TCG, which is what you get on a Mac with no hardware acceleration) reports tsc-deadline = false but x2apic = true. TCG simply doesn't emulate TSC‑deadline. So the dual path is not belt‑and‑braces: it's the difference between the development/CI environment working at all and not. The cloud box gets TSC‑deadline; the emulator gets the initial‑count one‑shot; both are one‑shots, so the livelock fix holds either way.

A couple of gotchas were paid in blood along the way, and are worth recording:

The result, measured: a runaway spawned as a real process went from zero forward progress (wedged at its entry) to actively running — it emitted 8,694 progress heartbeats where before it emitted none — while the box stayed fully responsive. The console answered ps in well under a second, and a complete SSH login (with its TLS‑grade handshake and crypto) succeeded, over the network, while the runaway burned an entire core. The core goal — a runaway service can't kill the box — was, for processes, met.


The twist: tuning the quantum, and finding a hole

With process preemption working, an obvious follow‑up: tune the quantum down for snappier response, and re‑measure.

The measurements were the first surprise:

QuantumPreemption rateCommand latency under a runaway
100,000~2 Hz~0.32 s
4,000~2 Hz~0.20 s
1,000~2 Hz~0.27 s

Identical. Shrinking the quantum 100× changed nothing. The reason is instructive: under this workload the cadence is bounded not by the timer at all but by the cooperative round‑robin itself — specifically by kernel tasks (the idle SSH server) busy‑waiting before they yield, rather than sleeping the CPU. The timer quantum is simply not the responsiveness knob here; the busy‑wait is. (That's a separate, known piece of debt: idle should hlt the CPU or be interrupt‑driven, not spin.)

That would have been a tidy, mildly disappointing finding — "tuning doesn't help, the box is already sub‑second" — except that chasing why dragged a much more serious problem into the light.

The regression. To test responsiveness we finally ran the most basic thing imaginable — an actual console command that finishes and exits (echo, ls, cat) — and it hung the shell. Worse, it hung on the exact commit we'd already published as "validated."

How had that slipped through? Because none of the earlier tests had ever run an exiting console tool. The runaway used for testing is an infinite loop — it never exits. ps and whoami are kernel builtins, not ring‑3 tools. So the one everyday case — load a small program, run it to completion, return to the prompt — had genuinely never been exercised under the new preemption. That is a real testing lesson, recorded here rather than hidden: your regression suite has to include the boring path.

Tracing it showed the same fingerprint as the original bug — a console tool wedged at its entry (push %rbx), preempted 200+ times, never advancing — but with a critical difference: it happened for kernel‑context tasks (l4 == 0) and not for real processes (l4 != 0, which now resumed cleanly). The immediate‑re‑preempt livelock had a second home we hadn't found, and it lived in the exact category of task — the console — you least want to freeze.


What we tried, and what it cost

Being honest about the dead ends, because they narrow the search for whoever picks this up next:

Two hypotheses were also cleared by inspection, so nobody re‑runs them: the console task's trap stack does not overlap its own execution stack (they are separate dedicated stacks), and there is no page fault involved.


Where it stands now, and the honest trade‑off

We stopped digging on the root cause and shipped a decision that is defensible on its own terms:

Preempt only ring‑3 processes (l4 != 0). Leave kernel‑context tasks (l4 == 0) cooperative.

The reasoning is more than damage control — it's arguably the right design:

The cost, stated without spin:

Runaway typeOld PIT (guarded)Now (LAPIC, process‑only)
Spawned service (l4 != 0)froze the boxsurvivable
Console‑typed tool (l4 == 0)survivablenot preempted → can freeze
Everyday console toolsworkwork ✓

So we traded the console‑runaway case (rare: a human typing a broken loop into the shell) for the service‑runaway case (the real operational concern) — and, along the way, un‑broke the everyday tools that a bad "preempt everything" had silently taken down.

Open threads, kept on the record:

  1. The l4 == 0 immediate‑re‑preempt root cause. A pending LAPIC tick delivered at the ring‑3 iretq before a kernel‑hosted tool executes. Cracking it (with gdb) would restore full coverage — console runaways included — with no trade‑off.
  2. Real responsiveness. The lever isn't the quantum; it's replacing the idle busy‑wait polls with hlt / interrupt‑driven wakeups. That's the change that would make the box feel instant under load.

Lessons worth keeping


Update — the debugger session: the l4 == 0 livelock is fixable, and the wall behind it

We took open thread #1 into a debugger. The short version: the l4 == 0 livelock is fixable, the fix is clean — and it revealed that the real blocker to full preemption is open thread #2, the polling model, not anything about the timer hardware.

The fix for l4 == 0 is a strict LAPIC guard: the preemption timer is armed only while ring‑3 code runs. On every entry into the kernel (a context switch, a syscall) it is disarmed (initial‑count = 0); it is re‑armed only as the last act before iretq drops back to ring 3. That makes the countdown a stopwatch for ring‑3 CPU time alone, so a tick can never latch while we're parked in the kernel with interrupts off and then ambush the next task's iretq. With it in place, console tools run‑and‑exit and a console‑typed runaway is survivable — under full preemption, no trade‑off.

The wall it exposed: with l4 == 0 fixed and every ring‑3 task preemptible, a spawned process (l4 ≠ 0) that runs away made the box unresponsive again — this time by starving the interactive tasks of the core, not by a livelock. The classic two‑level "interactive‑before‑batch" scheduler (prefer tasks that yield voluntarily over ones that burn a whole quantum) kept console tools alive but did not rescue SSH under a process runaway.

So we opened the box in the debugger (QEMU's monitor — there's no gdb on the dev Mac, but info lapic and gva2gpa answer the same questions). Two natural hardware suspects, both ruled out:

SuspectVerdictEvidence at the wedge
LAPIC MMIO not mapped in the process's page tables❌ ruled outgva2gpa of the LAPIC virtual address, in the process's own CR3, resolves to physical 0xFEE00000 — mapped correctly
A missed EOI leaving the timer stuck "in‑service"❌ ruled outinfo lapic: ISR (none), IRR (none) — nothing in‑service or pending

The real picture: at the wedge the CPU is CPL = 0 with interrupts off, spinning in the UART receive poll — kernel code — and the timer reads initial_count = 0, i.e. correctly disarmed, because we are in kernel time. So it is not a hardware or interrupt bug at all. It is the polling model: the console (serial::receive) and the SSH server (accept) never block when idle — there is no serial or NIC interrupt, and no hlt — they busy‑poll‑and‑yield, so they are always "ready." The guard correctly confines the timer to ring‑3 time; with two always‑ready kernel busy‑pollers competing for the core, a ring‑3 process gets squeezed to nothing (a runaway hogs without fairness, or starves with it), and the box burns its wall‑clock in kernel polls.

The conclusion that redirects the work: the LAPIC guard and the two‑level fairness are both correct designs — together they proved that PiROS has no genuine idle. Until an idle console/sshd blocks (a serial‑RX interrupt, a NIC interrupt, or hlt‑with‑timer‑wake) instead of spinning, there is no clean way to give a ring‑3 process a fair slice. Interrupt‑driven / blocking idle is the real next milestone — start with the serial‑RX IRQ, the smallest piece and the one the console needs — after which the guard and the fairness scheduler slot straight in.

What shipped (v1.2.0 / r15): the conservative, verified state — preempt only spawned processes (l4 ≠ 0). A runaway service is contained (the box stays reachable); console tools are unchanged; a console‑typed runaway is the one documented gap. The full‑preemption‑plus‑fairness work is kept as a patch, waiting on blocking idle.

This page will grow as the open threads close.