Tasks and Scheduling: Architecture

Three layers, the same shape as the memory manager's: the portable ones can be host-tested, and the architecture layer is what is left when everything that is not machine-specific has been taken out of it.

  S2  lifetime      exit, reap, exec, fork/clone; who owns an address space
  S1  run queue     which task runs next, per CPU
  S0  task table    slots, states, tenancy; the state machine

Today all three are one file with the interrupt controllers and the console.

S0 — The task table

Slots, their states, and the one function that changes them.

typedef enum {
    VIBEOS_TASK_FREE = 0,   /* nobody's; may be allocated                  */
    VIBEOS_TASK_SETUP,      /* allocated, being filled in; not schedulable */
    VIBEOS_TASK_READY,      /* schedulable                                 */
    VIBEOS_TASK_RUNNING,    /* on a CPU                                    */
    VIBEOS_TASK_BLOCKED,    /* waiting on something                        */
    VIBEOS_TASK_ZOMBIE,     /* dead, not yet reaped                        */
    VIBEOS_TASK_STATE_COUNT
} vibeos_task_state_t;

int  vibeos_task_transition(uint32_t slot, vibeos_task_state_t to,
                            const char *why);

The transition table is the design. A two-dimensional array of legal moves, checked on every change, with the name of the caller recorded. Today a state is a field several functions assign to, and the illegal transitions are the ones that produced the wedge: RUNNING to FREE without passing through ZOMBIE, or ZOMBIE to FREE while the slot was still being written.

Tenancy. A slot carries a generation number that increases every time it is allocated. A reference to a task is the pair (slot, generation), so a stale reference is detectable instead of silently naming whoever holds the slot now. hw_task_t already has alloc_seq for this - it was added after a bug and is checked in two places out of the dozen that need it.

Publish last. The rule the reaper and exit each learned separately: every field a slot owns is released and cleared before the slot becomes VIBEOS_TASK_FREE. Making it the last statement of one function is the whole mechanism.

S1 — The run queue

Which task runs next on this CPU. A data structure, and therefore portable:

void vibeos_runq_init(vibeos_runq_t *q, uint32_t cpus);
void vibeos_runq_add(vibeos_runq_t *q, uint32_t cpu, uint32_t slot);
void vibeos_runq_remove(vibeos_runq_t *q, uint32_t slot);
int  vibeos_runq_pick(vibeos_runq_t *q, uint32_t cpu);   /* slot, or -1 */

Round-robin over ready tasks, with a per-CPU idle task as the answer when there are none - which is what the current hw_pick_next does, extracted so it can be tested without a machine. The value of moving it is not the algorithm; it is that "two CPUs picked the same task", a defect this project has already had, is a unit test rather than a boot.

Nothing under the lock. The scheduler lock protects the queue and the state field, and nothing else may happen while it is held: no allocation, no page table walk, no disk. hw_spin_lock masks interrupts, so anything slow under it is slow with the timer off - the same rule the FAT read learned the hard way.

S2 — Lifetime

The transitions that mean something: fork, clone, exec, exit, reap. This is where the address-space question lives.

int vibeos_task_exit(uint32_t slot, int status);
int vibeos_task_reap(uint32_t parent, uint32_t child, int *out_status);

One question about address spaces, asked in one place. execve and hw_task_exit both need to know "is anybody else running on these tables?" and for months only one of them asked. hw_aspace_shared_by_other now exists and both use it; the layer boundary is what stops a third caller appearing that does not.

Ordering, which is the whole subsystem. Two rules, both learned from failures documented in CLAUDE.md:

  • Tear down before announcing. A dying task's address space is destroyed before the slot is published as ZOMBIE, because a parent may reap the instant it sees one.
  • Take what you need first, publish last. Anything the kernel reads out of a task must be read before the slot becomes reusable.

These are stated here so a future change can be checked against them rather than against somebody's memory of why the machine used to stop without a word.

What stays in the architecture layer

Context switching, CR3 loading, the per-CPU area, the TSS and the kernel stacks, the guard in hw_task_load_cpu_state. Those are machine-specific and belong where they are - the point of the split is that they stop being mixed with the question of what a task is.