Tasks and Scheduling Progress

Status: In Progress — phases S-P0 to S-P2 of docs/sched/ are done. Counters and a console view exist, a transition table decides what a slot may become, every task reference carries its tenancy, and the run queue is a tested data structure. S-P3, which repairs the defects, is next. Last review: 2026-08-31

Why this is being rewritten

Everything about a task lived in kernel/arch/x86_64/arch_hw.c — 8312 lines, 267 static functions, with the scheduler, syscalls, program loading, pipes, signals, the network glue, the interrupt controllers and the console all in one space where any function can reach any state.

The defects that produced share one shape: a task's lifetime was managed by several pieces of code that each knew part of the truth. The silent wedge, the reaper writing to a published slot, execve not asking whether siblings shared an address space, a recycled slot keeping a stale cr3. Each was fixed where it was found; none could have been prevented, because nothing said what a slot's states were or who could change them.

What exists now

S-P0 — counters and the view

tasks on the kernel console prints every slot: generation, state, pid, tgid, ppid, cr3, whether it is user or kernel, thread or process, on a CPU or not, and the four provenance fields — cr3_set_by, ready_by, aspace_killed_by and who last changed its state. Three of those existed already, each added after a defect had cost a session.

Alongside it, counters for what the subsystem has done, and four that must be zero and are asserted by the boot gate: an illegal state change, a write to a slot already published as reusable, a stale reference used as if it still named its task, and a task about to run on tables somebody else freed. Each names a defect this kernel has actually produced.

The split is the point. Deciding what to say about a task is portable and lives in kernel/sched/view.c; only reading the machine's table is not, and the architecture answers one function. That is the shape the rest of the rewrite follows.

S-P1 — the transition table

kernel/sched/task.c owns what a slot may become. The transitions that are absent are the design: RUNNING to FREE is the silent wedge, and ZOMBIE to READY would let a reap race a wake and put a task back on a destroyed address space.

All twenty-six assignments to a task's state now go through one function, which asks the table and refuses rather than performs. Storage still lives in the architecture's field; what has moved is the decision.

The table was wrong on its first boot, and the machine said so. SETUP to RUNNING was missing — a core adopts the thread it is already executing, and an idle task is created already on its CPU. The refusal named the slot, both states and the function. With assignments scattered across nine places, that knowledge was written nowhere.

Tenancy is everywhere (decision T1). A bare slot number stays valid forever and names whoever holds the slot now, so a reference kept across a lock release silently becomes a reference to a different task. The generation is bumped when a slot is claimed, never when it is released: bumping on release leaves the freed slot already carrying the number its next occupant will have, so a reference taken beforehand would validate against the new tenant. That is a sabotage case, because it reads as equivalent and is not.

S-P2 — the run queue

hw_pick_next is a wrapper over kernel/sched/runq.c, where "two CPUs never pick the same slot" — a defect this kernel has had — is a unit test rather than a boot somebody hopes goes wrong.

The queue does not decide what may run; it asks through a callback, so the state machine stays the single source of truth about what READY means. A queue with its own opinion would be a second one.

A real defect was fixed in the move. The old scan started from the current task, which is fair while something is running and degenerates when the current task is idle: the search then begins at the same place every time and the high-numbered slots wait behind the low ones. There is a cursor per CPU now.

What this cost

A weak-symbol mistake broke the Windows CI. The view's defaults were weak definitions in one file with the real ones in another, and PE/COFF does not resolve that across objects — the Linux build linked and said nothing. Every weak stub that works in this kernel happens to sit in the same translation unit as its caller, which is why the pattern looked safe when it was copied. The view's data source is registered explicitly now, as the frame layer's lock is, and the trap is recorded in CLAUDE.md.

Next

S-P3, lifetime: exit, reap and exec in one layer, with the two ordering rules made structural rather than remembered, and the address-space ownership question asked in exactly one place. That is also what unblocks the loader rewrite, and through it memory-plan P4 step 3.

Scheduling policy — priorities, fairness, affinity, load balancing — is S-P5, after the per-task CPU accounting at S-P4 that nothing here has today. The first draft of the plan listed it as a one-line non-goal, which was wrong: a plan called "Tasks and Scheduling" that contains no scheduling is mis-scoped. The ordering argument stands, the omission did not.


S-P4 — Accounting (done, 2026-09-02)

The machine can now say where its time went. Nothing above this phase was possible without it: a policy decides which of several correct choices to make, and every way of deciding - priorities, weights, whether anybody is starving - is a statement about time already spent. Tuning a policy against numbers this kernel could not produce would have been tuning against a guess.

kernel/sched/account.c is arithmetic and nothing else: it makes no decision, holds no lock, and calls nothing, so it is host-tested exhaustively and a defect in it can never be a defect in scheduling.

Charged by tick, not by timestamp. Every tick goes to exactly one place - one task, or one core's idleness - which makes the check an identity rather than a tolerance:

[TASKS] CPUTIME charged=0x241 idle=0x3b0 seen=0x5f1 balanced=yes dropped=0x0
        cpu0_idle=0x10c cpu1_idle=0xfc cpu2_idle=0xae cpu3_idle=0xfa

577 + 944 = 1521, and the four per-core figures add to the idle total exactly. A tolerance is something you argue about when it drifts; an identity is something the boot gate asserts.

An idle task is a task, and must not read as work. Every core here has an idle task holding a real slot, so charging by slot alone would report a machine doing nothing as fully busy - and every ratio built on that would be wrong in the direction that hides a problem.

Every core charges its own tick, unlike the wall clock, which only core zero owns. The distinction is easy to get backwards: the clock counts wall time and this counts CPU time, and on four cores those differ by a factor of four.

Three defects in the wiring, two of them found by their own counters

  • The tick handler indexed the task table with a value it had not checked. It runs on every core on every interrupt, including before that table means anything.
  • Accounting was sized with g_cpu_online_count, which is 1 when the scheduler starts - the other cores come up afterwards. Three quarters of the ticks were refused.
  • The report read the totals, then walked the per-core counters, with ticks arriving in between. It printed a core with more idle time than the whole machine had. Two moments reported as one, which is the same mistake as a log line assembled from two writes.

The second is the interesting one. A refused tick keeps the identity intact - charged plus idle still equalled seen, because a refused tick was never seen - so the balance check could never have found it. It needed its own counter, and with that counter the sabotage is unambiguous: sizing the layer for one core again turns the boot red with cputime_ticks_dropped=1184.

That is the shape this project keeps meeting: a check that cannot fail is not a check. The balance assertion is real, and it would have watched this bug go past.

And then the balance assertion was wrong in the other direction

It asserted an exact identity, and turned a correct kernel red twice in twelve boots: charged=540 idle=874 seen=1413, one apart.

Two separate faults, and both are mine. The three totals were incremented non-atomically from four cores - excused in a comment saying a lost increment "costs one tick", which was wrong about the consequence: it costs the identity, and the identity is the whole promise. They are atomic now, which at a hundred ticks a second on four cores costs nothing measurable.

The second is the more interesting. No read order makes it exact. A tick increments the total and then one of the parts, so a core caught between those two adds leaves the parts one short; reading the parts first just moves the discrepancy to the other side. The reader is not atomic with respect to four writers and cannot be made so cheaply.

What is true is that at most one tick per core can be mid-update, so the two sides agree to within the number of cores - which is exactly the bound docs/sched/phases.md asked for, and which I tightened into an identity without noticing I was changing the requirement. The bound is a fact about the counters, not a tolerance chosen to make a check pass; anything beyond it is a tick counted twice or lost, which concurrency does not explain.

A check that reports healthy behaviour is one people learn to ignore. This project has said so since the interleaved-line detector, and I wrote one anyway within an hour of quoting it.

What S-P5 will be judged against

max_wait - the longest a task waited between becoming runnable and running - is recorded here because this is the only moment both halves are known. It is the number that says whether anybody is starving, and it is deliberately the worst case rather than the last one.


S-P5 — Policy (the layer, done 2026-09-02)

The phase that makes it a scheduler rather than a rotation. It is last on purpose: a policy decides which of several correct choices to make, so it can only sit on a lifetime layer that is correct, and it can only be evaluated with the accounting from S-P4. Built earlier it would have been tuned against a defect.

kernel/sched/sched_policy.c is a pure function of state the caller supplies - which slots are runnable, which core is asking. No task table, no lock, no clock. Every rule is therefore settled by a host test rather than by watching a boot and forming an impression, which is the only way a policy can be argued about at all.

What it decides

  • The quantum is a number somebody chose. Preemption happened whenever the timer fired, so the slice was "the timer period" - nobody's decision. It is now per class, in ticks, with the reason written next to each.
  • Classes are absolute. A runnable kernel task runs before any normal one, however long the normal one has waited. That is what makes a class different from a large weight. Idle is a class rather than a flag, so an idle task competes by the same rule instead of needing a special case in the picker.
  • Weights come from nice, as a table rather than a formula - the property that matters, roughly 25% per step over a range of about a thousand, is readable in a table and is not readable in an expression.
  • A new task is admitted level with its class, not at zero. Admitting at zero hands every newly created task the whole machine until it catches up: not a subtle unfairness but a fork bomb that needs no malice.
  • Renicing does not reset history. Otherwise a task can have the machine for free by renicing itself.
  • Affinity is checked in the picker, so a pinned task cannot appear elsewhere even when it is the only thing runnable.

The tests measure rather than inspect. They run the picker for tens of thousands of ticks and count who got what: nice 0 against nice 5 must land between 2.7:1 and 3.5:1, which is the weight ratio doing its job rather than a number read off the table. And every rule was confirmed by breaking it - class ordering, weighting, affinity and level admission each produce their own named failure when removed.

The first run found a real starvation bug

Virtual time advanced by (ticks * WEIGHT_BASE) / weight in whole units. For any task more favoured than nice 0 the weight exceeds the base, so that division truncates to zero: a nice -5 task accumulated no virtual time at all, always held the smallest, and ran forever. Not a rounding error - total starvation of everything else.

Virtual time is fixed point now, ten bits of fraction, which puts the smallest step (nice -20) at 11 instead of 0 and leaves four orders of magnitude before a 64-bit overflow.

A mistake worth recording: the header collided with an existing one

The first version of this layer was written to include/vibeos/policy.h, which already existed - it is the security policy, capabilities and MAC. The file was overwritten. It was restored from git intact and the scheduling one renamed to sched_policy.h.

The tell was there and was read past: the editor reported the file as updated rather than created. Check whether a name is taken before taking it, and read what a tool says it did rather than what it was asked to do.

Still open in S-P5

The layer exists, is tested and is not yet wired into hw_pick_next. Steps 4's work stealing and step 5's priority inversion are untouched - the latter is decision T7 and is not mine to settle.


Fork-bomb protection (2026-09-02)

The failure this closes is not that fork can fail. It already could, and returned an error as it should. It is what running out of slots meant: the task table is first-come, so a program forking in a loop took every slot, and then init could not start a service and the shell could not run a command. The machine stayed alive and became unadministrable, which is worse than one that refuses.

kernel/sched/forkguard.c answers one question - may this task create another - from counts the caller supplies, so the rules are settled by host tests instead of by writing a fork bomb and hoping it reproduces.

Two rules, and neither is interesting alone:

  • A reserve. Four slots, one per core, that only privileged tasks may take. Without the ceiling below, a bomb still fills every unreserved slot and every ordinary program stops working.
  • A ceiling. Eight live children per task. Without the reserve above, enough cooperating processes reach the same place.

Both numbers are chosen rather than discovered, and the reasoning is written where they are set: four is the smallest reserve that leaves every core able to start something, and eight is comfortably more than anything in this boot forks

  • the shell's deepest pipeline is three - so the limit binds on a bomb and on nothing else. A healthy boot refuses zero forks.

The refusal says which rule it was, because both return EAGAIN and they are completely different investigations: one is a program misbehaving, the other is a machine that is full.

[SCHED] fork refused reason=too-many-children pid=0x19 children=0x6 slots_in_use=0x11

Confirmed by tightening the child limit to two, which makes an ordinary boot trip it six times with the reason, the pid and the counts. Live children are counted by walking the table rather than kept in a field: a counter has to be right on every exit path including the ones that fail half way, and the table is twenty-four entries long. Zombies count, because a zombie still holds a slot.


S-P6 — Acceptance (2026-09-03)

Fork storm — done. user/prog/svc_bomb.c forks until the kernel refuses, reports how far it got and which rule stopped it, reaps every child and exits. Its children exit immediately rather than sleeping, because a storm of zombies is the harder case: a zombie still holds a slot, so the parent must reap them all or the table stays full for everybody else.

What the gate asserts is not that the storm ran but that it was stopped and cleaned up after - SVC_BOMB_DONE prints only once every child is reaped - and SVC_BOMB_UNBOUNDED must never appear, which is the guard silently not working while every other line still looks healthy. The real assertion is everything the boot does afterwards: the shell still runs commands and the kernel CLI still answers, on a machine a program has just tried to fill.

It is last in the manifest so that "afterwards" means something.

The state machine is asserted — done, and was already. illegal_transition is on the MUSTBEZERO line and the gate fails on it; that is how blocked -> blocked by=hw_sys_waitpid was found.

Soak — partly done, and stated rather than claimed. The gate asserts vmas_live returns to zero, frames_leaked and frames_double_put are zero, and the CPU-time identity balances. It does not assert that the task counters return to their starting values, because "starting" is not a thing this boot has - init and the services are still running when the counters are read.

Thread churn during exec — not done. TFORK.ELF forks with a thread running and is gated, which is half of it; threads created and joined while the process execs is the other half and does not exist. Left open rather than declared, because it is the combination that produced the execve defect and a criterion nobody exercises is worse than one nobody wrote.

A defect this phase found in one of my own checks

clang-warnings counted every line containing "warning:", which is mostly the linker discarding a build-id note - nineteen on a clean build, none on an incremental one. The number moved between 0, 1, 2, 3 and 19 for reasons that had nothing to do with the code.

CLAUDE.md says to treat a moving warning count as the build telling you something. That only works if it is telling the truth, and this one was not: it is filtered now, and reads 0.

Status: closed

S-P4 (the machine can say where its time went), S-P5 (classes, nice-weighted fairness, affinity), the fork-bomb guard and S-P6 (an enforced quantum, threads reaped where they die) are done and gated. 16 clean boots out of 16 across the Release and Debug configurations, which is the best this project has measured.

The one thing the plan raised and did not do is T7: no lock may be held across a scheduling point. It was attempted with a per-core lock-depth counter, and the attempt inserted a field into hw_cpu_t that shifted the offsets isr.S reads at %gs:8 and %gs:16 - a #GP at rip=0x39 on the next boot. It was reverted whole, and hw_sched_point() carries a comment saying why it is not implemented rather than a stub that looks like it is.

Status history

Moved verbatim from the status cell of docs/implementation_progress.md on 2026-09-29, when that table was compacted to one or two sentences per area. The table says where things stand; this is how they got there.

Tasks and Scheduling (as of 2026-09-29)

S-P0 to S-P6 done, with two gaps named rather than papered over (docs/sched/). A transition table that refuses illegal state changes, tenancy on every task reference, a tested run queue, lifetime, accounting charged to exactly one place per tick, a policy of classes and nice-weighted fairness and affinity driving the picker - measured over tens of thousands of ticks, every rule confirmed by breaking it - a fork-bomb guard on both fork and clone, and a fork-storm service the gate asserts is stopped and cleaned up after. Still open: the quantum is defined and not enforced, so preemption is still whatever the timer does; and thread churn during exec is not exercised