Files
frostbyte/src/schedTest.c
T
2026-09-11 14:58:32 +02:00

945 lines
37 KiB
C

#include "ktest.h"
#include "sched.h"
#include "ports.h"
#include "elf.h"
#include "paging.h"
#include "pmm.h"
#include "pipe.h"
#include "signal.h"
#include "pit.h"
#include "klib.h"
/*
* ktestRunAll() runs before schedStart() (see ktest.h), on a single thread
* with no live scheduler - there is no second execution context to actually
* context-switch into, and no interrupts to wake a genuinely halted CPU.
* The first block of tests below therefore exercises schedBlockCurrent/
* schedWakeChannel as the pure state-machine they are (mark-blocked /
* match-and-wake), using the always-present current thread (idle, id 0 at
* this point in boot) rather than creating new threads.
*
* The second block tests threadCreate/threadReap/threadJoin's bookkeeping
* (slot allocation, exhaustion, reuse, bounds-checking) directly: a thread
* created here is never actually scheduled to run (the scheduler is not yet
* live), so as long as every thread a test creates is reaped again before
* the test returns, the real thread table is left exactly as it found it -
* no throwaway slot is ever left behind to eat into kmain.c's later budget.
* The one path that genuinely cannot be tested here is threadJoin() ever
* actually parking (schedBlockCurrent + halt()) on a thread that is still
* alive: that requires a second thread for the scheduler to switch to,
* which does not exist before schedStart(). That path is instead proven
* live by the join demo in kmain.c (needle "join-reap-ok" in ci-test.sh).
*/
KTEST(schedBlockCurrentMarksBlockedWithChannel)
{
ThreadState saved = currentThread()->state;
int channel;
schedBlockCurrent(&channel);
KT_EQ(t, currentThread()->state, threadBlocked);
KT_EQP(t, currentThread()->waitChannel, &channel);
schedWakeChannel(&channel);
currentThread()->state = saved;
}
KTEST(schedWakeChannelIgnoresMismatchedChannel)
{
ThreadState saved = currentThread()->state;
int channelA;
int channelB;
schedBlockCurrent(&channelA);
KT_EQ(t, currentThread()->state, threadBlocked);
schedWakeChannel(&channelB);
KT_EQ(t, currentThread()->state, threadBlocked);
KT_EQP(t, currentThread()->waitChannel, &channelA);
schedWakeChannel(&channelA);
KT_EQ(t, currentThread()->state, threadRunnable);
currentThread()->state = saved;
}
KTEST(schedWakeChannelClearsWaitChannelOnWake)
{
ThreadState saved = currentThread()->state;
int channel;
schedBlockCurrent(&channel);
schedWakeChannel(&channel);
KT_EQ(t, currentThread()->state, threadRunnable);
KT_NULL(t, currentThread()->waitChannel);
currentThread()->state = saved;
}
KTEST(schedPollChannelIsStableAndUsableLikeAnyOtherChannel)
{
ThreadState saved = currentThread()->state;
/* Same address every call -- every not-yet-satisfied sysPoll (poll.h)
across every thread blocks on this ONE shared channel, so callers
must never see it move. */
KT_EQP(t, schedPollChannel(), schedPollChannel());
KT_NOTNULL(t, schedPollChannel());
/* And it works exactly like any other channel schedBlockCurrent/
schedWakeChannel already accept -- the schedule()-driven pollDeadline
expiry scan (sched.c) that forces a wake even with nobody calling
schedWakeChannel(schedPollChannel()) needs a live scheduler
(schedulerActive) to run at all, so it is proven live instead, by
user/polltest.c's real timeout phase (tools/ci-test.sh's
"polltest: poll-timeout-ok" needle) -- the same split schedTest.c's
own file comment already documents for sleepUntil/alarmAt. */
schedBlockCurrent(schedPollChannel());
KT_EQ(t, currentThread()->state, threadBlocked);
KT_EQP(t, currentThread()->waitChannel, schedPollChannel());
schedWakeChannel(schedPollChannel());
KT_EQ(t, currentThread()->state, threadRunnable);
currentThread()->state = saved;
}
KTEST(schedWakeChannelWithNoWaiterIsHarmlessNoop)
{
ThreadState saved = currentThread()->state;
int channel;
/* Nobody is blocked on this address: waking it must not touch any
thread's state, including the caller's own. */
schedWakeChannel(&channel);
KT_EQ(t, currentThread()->state, saved);
currentThread()->state = saved;
}
KTEST(schedBlockCurrentIsIdempotentUnderRepeatedCalls)
{
ThreadState saved = currentThread()->state;
int channelA;
int channelB;
schedBlockCurrent(&channelA);
schedBlockCurrent(&channelB);
KT_EQ(t, currentThread()->state, threadBlocked);
KT_EQP(t, currentThread()->waitChannel, &channelB);
/* Waking the stale first channel must not affect a thread that has since
re-blocked on a different one. */
schedWakeChannel(&channelA);
KT_EQ(t, currentThread()->state, threadBlocked);
schedWakeChannel(&channelB);
KT_EQ(t, currentThread()->state, threadRunnable);
currentThread()->state = saved;
}
/* Never actually invoked (the scheduler is not live yet) -- just a valid
function pointer for threadCreate's entry parameter. If it were ever
scheduled for real it would be a harmless, correctly-behaved kernel
thread body (park forever), same convention as workerA/workerB. */
static void noopThreadEntry(void)
{
for (;;)
halt();
}
KTEST(threadCreateExhaustsThenThreadDiscardFreesSlotsBackForReuse)
{
/* Sized off threadCapacity() itself, not a hardcoded number that would
silently stop meaning "the whole table" the next time maxThreads
(sched.c) grows -- exactly the staleness this test's own array size
once had (fixed at 64, which only ever happened to equal maxThreads
by coincidence of when it was written; the very next maxThreads bump
after this one otherwise made the while loop below stop at the array
bound before threadCreate ever actually failed, so the "must refuse
to grow past capacity" check two lines down silently stopped testing
anything and leaked one never-discarded thread for the rest of
boot). idle already occupies one slot, so successful creates always
stay strictly below `capacity`, and the loop below is guaranteed to
hit its threadInvalidId break at or before `count == capacity - 1` --
this array can never actually overflow. */
uint32 capacity = threadCapacity();
uint32 created[capacity];
uint32 count = 0;
/* Every thread created here stays Runnable/never-run for its entire
life in this test (schedule() never runs before schedStart()), so
threadDiscard -- not threadReap, which requires threadDead -- is the
correct way to tear them back down. */
while (count < capacity) {
uint32 id = threadCreate("ktestDummy", noopThreadEntry);
if (id == threadInvalidId)
break;
created[count++] = id;
}
/* The table must have refused to grow past its fixed capacity instead
of silently writing past threads[] -- this is the bounds-check the
original allocator did not have. */
KT_EQU(t, threadCreate("shouldFail", noopThreadEntry), threadInvalidId);
for (uint32 i = 0; i < count; i++)
KT_TRUE(t, threadDiscard(created[i]));
/* Every discarded slot must be usable again: the very next create
should succeed (it would fail immediately if discard had left the
table full). */
uint32 reused = threadCreate("afterDiscard", noopThreadEntry);
KT_NE(t, reused, threadInvalidId);
KT_TRUE(t, threadDiscard(reused));
}
KTEST(threadCreateSkipsAFreshlyFreedLowSlotInFavorOfMovingForward)
{
/* allocSlot (sched.c) used to always hand out the LOWEST currently-free
slot -- this test used to assert exactly that (KT_EQ(reused, b)
after discarding the middle one of three). That was a real,
reproducible bug: this fala's own gate caught killtest.c's and
nicetest.c's "a pid I just userWait()-reaped must reject a further
kill/nice" checks occasionally failing for real, because some
completely unrelated thread elsewhere in the system called
threadCreate in the tiny window between the reap and the recheck
and got handed that exact just-freed low index straight back. The
fix -- allocSlot's own new round-robin cursor -- means the very
next create after freeing a LOW slot, with plenty of headroom still
available, deliberately does NOT reuse it: it keeps moving forward
and grows the high-water mark instead, exactly like a real OS's own
pid allocator avoids immediately handing back a pid that just
exited. See allocSlot's own doc comment for the full story and
why this is a practical mitigation via headroom, not a mathematical
impossibility. */
uint32 a = threadCreate("rrA", noopThreadEntry);
uint32 b = threadCreate("rrB", noopThreadEntry);
uint32 c = threadCreate("rrC", noopThreadEntry);
KT_LT(t, a, b);
KT_LT(t, b, c);
KT_TRUE(t, threadDiscard(b));
uint32 afterFree = threadCreate("rrAfterFree", noopThreadEntry);
KT_NE(t, afterFree, b);
KT_GT(t, afterFree, c);
KT_TRUE(t, threadDiscard(a));
KT_TRUE(t, threadDiscard(afterFree));
KT_TRUE(t, threadDiscard(c));
/* b itself is still sitting there Unused, unreachable until the
cursor eventually wraps back around to it -- the very next test
below proves it is not leaked forever, just deferred. */
KT_EQU(t, threadPgid(b), threadInvalidId);
}
KTEST(threadCreateReusesTheOnlyFreedSlotOnceTheTableIsFull)
{
/* Same "must reuse freed slots, not just grow the high-water mark
forever" property the old lowest-index-specific test above used to
cover, restated so it holds regardless of WHERE allocSlot's cursor
happens to be: fill the ENTIRE table (sized off threadCapacity()
itself, same staleness-proofing reasoning as
threadCreateExhaustsThenThreadDiscardFreesSlotsBackForReuse above),
free exactly one slot picked from the middle, then prove the very
next create can ONLY land on that one slot -- there is nowhere else
left to land, so the round-robin scan is forced all the way around
to it regardless of its own starting position. */
uint32 capacity = threadCapacity();
uint32 created[capacity];
uint32 count = 0;
while (count < capacity) {
uint32 id = threadCreate("rrFill", noopThreadEntry);
if (id == threadInvalidId)
break;
created[count++] = id;
}
KT_EQU(t, threadCreate("rrShouldFail", noopThreadEntry), threadInvalidId);
uint32 freedId = created[count / 2];
KT_TRUE(t, threadDiscard(freedId));
uint32 reused = threadCreate("rrReusesOnlyFree", noopThreadEntry);
KT_EQ(t, reused, freedId);
for (uint32 i = 0; i < count; i++) {
if (created[i] != freedId)
KT_TRUE(t, threadDiscard(created[i]));
}
KT_TRUE(t, threadDiscard(reused));
}
KTEST(threadDiscardRejectsIdleOutOfRangeAndDoubleDiscard)
{
/* id 0 is the permanent idle thread: everRan is true for it from
schedInit onward, so discard must refuse it. */
KT_FALSE(t, threadDiscard(0));
/* Past the fixed capacity entirely. */
KT_FALSE(t, threadDiscard(threadCapacity()));
KT_FALSE(t, threadDiscard(threadCapacity() + 1000));
uint32 id = threadCreate("discardMe", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
KT_TRUE(t, threadDiscard(id));
/* Already threadUnused: a second discard on the same id must fail
rather than double-free the (already-freed) stack. */
KT_FALSE(t, threadDiscard(id));
}
KTEST(threadReapRejectsIdleOutOfRangeAndNeverCreatedSlots)
{
/* id 0 is the permanent idle thread: it is always Running/Runnable,
never Dead, so reap must refuse it. */
KT_FALSE(t, threadReap(0));
/* Past the fixed capacity entirely. */
KT_FALSE(t, threadReap(threadCapacity()));
KT_FALSE(t, threadReap(threadCapacity() + 1000));
/* A slot nobody has ever created a thread in yet is threadUnused, not
threadDead. A freshly created, never-run thread (Runnable) is not
threadDead either -- reap must refuse it too, distinctly from
threadDiscard above. */
uint32 neverUsed = threadCount();
if (neverUsed < threadCapacity())
KT_FALSE(t, threadReap(neverUsed));
uint32 runnable = threadCreate("notDeadYet", noopThreadEntry);
KT_NE(t, runnable, threadInvalidId);
KT_FALSE(t, threadReap(runnable));
KT_TRUE(t, threadDiscard(runnable));
}
KTEST(threadJoinRejectsInvalidIdsWithoutBlocking)
{
/* All of these must return immediately (no schedBlockCurrent/halt())
since there is no live scheduler to ever wake this thread back up --
a wrong implementation here would hang the whole test battery, not
just fail an assertion. */
KT_EQ(t, threadJoin(0), -1);
KT_EQ(t, threadJoin(threadCapacity()), -1);
uint32 neverUsed = threadCount();
if (neverUsed < threadCapacity())
KT_EQ(t, threadJoin(neverUsed), -1);
}
KTEST(threadJoinOnDiscardedSlotFailsCleanly)
{
uint32 id = threadCreate("transient", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
KT_TRUE(t, threadDiscard(id));
/* The slot is threadUnused again -- joining it must behave exactly like
joining an id that was never created, not crash or block. */
KT_EQ(t, threadJoin(id), -1);
}
/*
* threadCreateUser/threadReap/threadDiscard user-memory reclamation
* (Thread.userSlotBase, sched.h). Every real spawn/exec used to leak its
* whole elfSlotSize slot's worth of physical frames on death, since
* frostbyte's single shared page table is never touched by thread teardown
* -- these tests drive that path directly with a synthetic "process" (its
* own fake slot, far above any real USER_BASE any binary is linked at, so
* it can never collide with a real program's mapping) rather than relying
* only on the live boot demo (kmain.c's proctest/spawntarget/exectarget
* chain, needles "user-mem-reclaimed-ok"/"exec-old-slot-freed-ok" in
* ci-test.sh) to prove it.
*/
KTEST(threadCreateUserSetsUserSlotBaseFromEntry)
{
uint64 entry = 0x320000000ull + 0x2000;
uint32 id = threadCreateUser("probe", entry, elfSlotStackTop(entry));
KT_NE(t, id, threadInvalidId);
KT_EQU(t, threadUserSlotBase(id), elfSlotBase(entry));
KT_EQU(t, threadUserSlotBase(id), 0x320000000ull);
KT_TRUE(t, threadDiscard(id));
}
KTEST(threadReapOnDeadUserThreadReclaimsItsMappedPages)
{
uint64 fakeSlotBase = 0x330000000ull;
uint64 entry = fakeSlotBase + 0x2000;
uint64 stackTop = elfSlotStackTop(entry);
uint64 stackSize = 4 * frameSize;
uint64 before = pmmFreeFrames();
/* Mirror what a real loadImageByPath/spawnUser call maps for any user
thread: a sliver of "image" plus an eagerly-mapped stack, both well
inside the 256 MiB slot -- most of the slot stays unmapped, exactly
like a real small program. */
/* At least the image page plus the stack pages -- entry and fakeSlotBase
are virgin address space, so mapping also pays for whatever PDPT/PD/PT
page-table pages do not exist yet (paging.c's nextTable create path),
which this test does not try to predict the exact count of. The reap/
discard side below is the precise assertion: every frame spent here,
data or structural, must come all the way back. */
KT_TRUE(t, pagingMapUserRange(entry, frameSize));
KT_TRUE(t, pagingMapUserRange(stackTop - stackSize, stackSize));
KT_LE(t, pmmFreeFrames(), before - 1 - (stackSize / frameSize));
uint32 id = threadCreateUser("fakeProc", entry, stackTop);
KT_NE(t, id, threadInvalidId);
KT_EQU(t, threadUserSlotBase(id), fakeSlotBase);
/* No live scheduler to run threadExit through before schedStart() --
poke the state directly, the same workaround
schedBlockCurrentMarksBlockedWithChannel uses above for the same
reason. */
threadAt(id)->state = threadDead;
KT_TRUE(t, threadReap(id));
KT_EQU(t, pmmFreeFrames(), before);
KT_EQ(t, pagingResolve(entry), 0);
KT_EQ(t, pagingResolve(stackTop - 1), 0);
}
KTEST(threadDiscardOnNeverRunUserThreadReclaimsItsMappedPages)
{
uint64 fakeSlotBase = 0x340000000ull;
uint64 entry = fakeSlotBase + 0x2000;
uint64 stackTop = elfSlotStackTop(entry);
uint64 stackSize = 4 * frameSize;
uint64 before = pmmFreeFrames();
/* At least the image page plus the stack pages -- entry and fakeSlotBase
are virgin address space, so mapping also pays for whatever PDPT/PD/PT
page-table pages do not exist yet (paging.c's nextTable create path),
which this test does not try to predict the exact count of. The reap/
discard side below is the precise assertion: every frame spent here,
data or structural, must come all the way back. */
KT_TRUE(t, pagingMapUserRange(entry, frameSize));
KT_TRUE(t, pagingMapUserRange(stackTop - stackSize, stackSize));
KT_LE(t, pmmFreeFrames(), before - 1 - (stackSize / frameSize));
uint32 id = threadCreateUser("fakeProc2", entry, stackTop);
KT_NE(t, id, threadInvalidId);
/* Runnable, never scheduled: the threadDiscard path, not threadReap. */
KT_TRUE(t, threadDiscard(id));
KT_EQU(t, pmmFreeFrames(), before);
KT_EQ(t, pagingResolve(entry), 0);
KT_EQ(t, pagingResolve(stackTop - 1), 0);
}
KTEST(threadCreateKernelThreadHasNoUserSlotAndReapLeavesPmmUntouched)
{
uint64 before = pmmFreeFrames();
uint32 id = threadCreate("kernelOnly", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
KT_EQU(t, threadUserSlotBase(id), 0);
threadAt(id)->state = threadDead;
KT_TRUE(t, threadReap(id));
KT_EQU(t, pmmFreeFrames(), before);
}
/*
* threadKill (sched.c) -- the only way to recover a thread genuinely stuck
* forever, now that sysRead/sysWrite can really block on a shared pipe
* (pipe.h/vfs.h). The live proof that it tears down a thread ACTUALLY
* parked mid-syscall is killtest.c spawning killvictim.c (needles
* "killtest: killed victim"/"killtest: kill-ok" in tools/ci-test.sh); what
* is deterministic here is threadKill's own state-machine bookkeeping.
*/
KTEST(threadKillRejectsIdleOutOfRangeAndNeverCreatedSlots)
{
KT_FALSE(t, threadKill(0));
KT_FALSE(t, threadKill(threadCapacity()));
KT_FALSE(t, threadKill(threadCapacity() + 1000));
uint32 neverUsed = threadCount();
if (neverUsed < threadCapacity())
KT_FALSE(t, threadKill(neverUsed));
}
KTEST(threadKillMarksRunnableThreadDeadWithKilledExitCode)
{
uint32 id = threadCreate("killMe", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
KT_TRUE(t, threadKill(id));
KT_EQ(t, threadAt(id)->state, threadDead);
KT_EQU(t, threadAt(id)->exitCode, threadKilledExitCode);
/* A killed thread reaps exactly like one that called threadExit on its
own -- no separate "killed" bookkeeping survives past exitCode. */
KT_TRUE(t, threadReap(id));
}
KTEST(threadKillOnAlreadyDeadOrUnusedSlotFails)
{
uint32 id = threadCreate("killTwice", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
KT_TRUE(t, threadKill(id));
/* Already Dead: a second kill must not succeed (nor reset exitCode). */
KT_FALSE(t, threadKill(id));
KT_EQU(t, threadAt(id)->exitCode, threadKilledExitCode);
KT_TRUE(t, threadReap(id));
/* threadUnused again after reap: still rejected. */
KT_FALSE(t, threadKill(id));
}
KTEST(threadKillWakesAThreadParkedInJoin)
{
ThreadState saved = currentThread()->state;
uint32 victim = threadCreate("victim", noopThreadEntry);
KT_NE(t, victim, threadInvalidId);
/* Mirrors exactly what threadJoin (sched.c) itself does while waiting
for `victim`: block the current thread (the idle thread, id 0, at
this point in boot) on the victim slot's own address -- the same
channel threadExit/threadKill both wake. */
schedBlockCurrent(threadAt(victim));
KT_EQ(t, currentThread()->state, threadBlocked);
KT_TRUE(t, threadKill(victim));
KT_EQ(t, currentThread()->state, threadRunnable);
currentThread()->state = saved;
KT_TRUE(t, threadReap(victim));
}
/*
* Thread.parentId (sched.h) and the real, asynchronous SIGCHLD it drives
* from threadExit/threadKill (signal.h/signal.c's sigRaise) -- the live
* ring-3 proof that a genuinely blocked parent actually wakes for its own
* child's death is user/sigchldtest.c spawning user/sigchldchild.c (needle
* "sigchldtest: sigchld-delivered-to-parent-ok" in tools/ci-test.sh); what
* is deterministic here is the plumbing itself: threadCreate/
* threadCreateUser attributing parentId to whoever is truly running at
* creation time, and threadKill (parameterized by an explicit id, unlike
* threadExit which only ever acts on the CURRENT thread and so cannot be
* driven this way with ktestRunAll's single, never-context-switched
* thread -- see this file's own header comment) actually raising sigChld
* against exactly that id when the id it names dies.
*/
KTEST(threadCreateAttributesParentIdToWhicheverThreadIsActuallyCurrent)
{
uint32 id = threadCreate("childOfCurrent", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
KT_EQU(t, threadAt(id)->parentId, currentThreadId());
KT_TRUE(t, threadDiscard(id));
}
KTEST(threadKillRaisesSigChldAgainstParentId)
{
uint32 parent = threadCreate("chldParent", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 child = threadCreate("chldVictim", noopThreadEntry);
KT_NE(t, child, threadInvalidId);
/* ktestRunAll runs single-threaded (see file header) -- threadCreate
always attributes parentId to THIS KTEST runner itself, so the
"child was spawned by parent" relationship this test actually wants
is set up directly here, the same white-box technique
threadStopExcludesTargetFromUnrelatedWaitChannelWake below already
uses to reach a state the real call path would only reach through
an actual live sysSpawn. */
threadAt(child)->parentId = parent;
KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), 0ull);
KT_TRUE(t, threadKill(child));
KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), (1ull << sigChld));
KT_TRUE(t, threadReap(child));
KT_TRUE(t, threadDiscard(parent));
}
KTEST(threadKillEarlyWakesAParentGenuinelyBlockedWaitingForSigChld)
{
int channel;
uint32 parent = threadCreate("chldBlockedParent", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 child = threadCreate("chldVictim2", noopThreadEntry);
KT_NE(t, child, threadInvalidId);
threadAt(child)->parentId = parent;
/* sigChld's real-world default disposition is Ignore (sigDefaultIsIgnore,
signal.c), which sigWouldDeliver (also signal.c) treats exactly like
an installed sigHandlerIgnore -- correctly declining to early-wake a
thread for a signal that would just be silently dismissed on
delivery anyway, real Unix's own "SIGCHLD does nothing without a
handler" behavior. A parent that actually wants this notification
(like user/sigchldtest.c) always installs a real handler first, so
this test must too, or threadWakeForSignal below would (correctly)
never fire and this would be testing the wrong thing. */
sigSetHandler(threadAt(parent), sigChld, 0x40005000ull, 0, 0);
/* Fabricate exactly what schedBlockCurrent would have left behind had
parent been the one calling it (see
threadStopExcludesTargetFromUnrelatedWaitChannelWake above for the
same convention) -- e.g. a real sigchldtest.c genuinely blocked in
userRead on a pipe with no writer (see user/sigchldtest.c) while
its own sigChld handler is installed. threadWakeForSignal's own
state-machine bookkeeping is already proven generically by
threadWakeForSignalMovesSleepingOrBlockedThreadToRunnable above (for
any signal); this proves specifically that threadKill's own new
sigRaise(parentId, sigChld) call actually reaches that same
early-wake path for THIS signal, not just the pending-bit half. */
threadAt(parent)->state = threadBlocked;
threadAt(parent)->waitChannel = &channel;
KT_TRUE(t, threadKill(child));
KT_EQ(t, threadAt(parent)->state, threadRunnable);
KT_TRUE(t, threadReap(child));
/* Runnable and never actually scheduled (ktestRunAll runs
single-threaded, see file header) -- threadDiscard, not threadReap,
is the correct teardown here, same as every other fabricated-but-
never-run thread in this file. */
KT_TRUE(t, threadDiscard(parent));
}
KTEST(threadKillAgainstAnOrphanWithADeadParentIdRaisesNothing)
{
uint32 deadParent = threadCreate("deadParent", noopThreadEntry);
KT_NE(t, deadParent, threadInvalidId);
KT_TRUE(t, threadKill(deadParent));
KT_EQ(t, threadAt(deadParent)->state, threadDead);
uint32 orphan = threadCreate("orphan", noopThreadEntry);
KT_NE(t, orphan, threadInvalidId);
threadAt(orphan)->parentId = deadParent;
/* sigRaise's own targetId-state guard (signal.c) is what makes this
safe, not anything special here -- an orphan whose spawning parent
is already gone simply has nowhere for sigChld to land, the same
way a real kernel would reparent it to init instead of crashing or
misdelivering into whatever unrelated thread later reuses
deadParent's slot; this kernel does not implement reparenting (one
shared address space, no true fork(), see doSpawn's own file
comment in syscall.c), so the signal is just silently dropped. */
KT_TRUE(t, threadKill(orphan));
KT_EQ(t, threadAt(orphan)->state, threadDead);
KT_EQU(t, threadAt(deadParent)->sigPending & (1ull << sigChld), 0ull);
KT_TRUE(t, threadReap(deadParent));
KT_TRUE(t, threadReap(orphan));
}
/*
* threadJoinAnyChild (sched.c) -- waitpid(-1)-style "wait for ANY child"
* behind sysWait's waitAnyPid case (syscall.c) / userWaitAny (ulib.c). Live
* proof that it genuinely blocks and is genuinely woken by an arbitrary
* child's death (not just a specifically-named one) is user/waitanytest.c
* (needle "waitanytest: any-child-reap-set-ok" in tools/ci-test.sh); what
* is deterministic here is its own non-blocking scan logic, the same shape
* as the parentId/sigChld block above.
*/
KTEST(threadJoinAnyChildReturnsNoChildrenForAThreadWithNoneAtAll)
{
uint32 parent = threadCreate("anyNoKids", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 outId = 0;
int64 outCode = 0;
KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanNoChildren);
KT_TRUE(t, threadDiscard(parent));
}
KTEST(threadJoinAnyChildReturnsNoneDeadWhileAllChildrenAreStillAlive)
{
uint32 parent = threadCreate("anyLiveParent", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 child = threadCreate("anyLiveChild", noopThreadEntry);
KT_NE(t, child, threadInvalidId);
threadAt(child)->parentId = parent;
uint32 outId = 0;
int64 outCode = 0;
KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanNoneDead);
/* Not reaped -- still exactly as threadCreate left it. */
KT_EQ(t, threadAt(child)->state, threadRunnable);
KT_TRUE(t, threadDiscard(child));
KT_TRUE(t, threadDiscard(parent));
}
KTEST(threadJoinAnyChildPicksTheLowestIndexedDeadChildAndCanBeCalledAgainForTheRest)
{
uint32 parent = threadCreate("anyTwoKidsParent", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 childLow = threadCreate("anyChildLow", noopThreadEntry);
KT_NE(t, childLow, threadInvalidId);
uint32 childHigh = threadCreate("anyChildHigh", noopThreadEntry);
KT_NE(t, childHigh, threadInvalidId);
/* allocSlot (sched.c) always hands out the lowest currently-free slot
and nothing else has been created in between, so childHigh's own id
is guaranteed higher than childLow's -- the "lowest index wins"
assertion below actually depends on this. */
KT_LT(t, childLow, childHigh);
threadAt(childLow)->parentId = parent;
threadAt(childHigh)->parentId = parent;
/* Kill the HIGHER-indexed one first, on purpose: proves the scan below
picks the lowest matching slot, not whichever died most recently. */
KT_TRUE(t, threadKill(childHigh));
KT_TRUE(t, threadKill(childLow));
uint32 outId = 0xFFFFFFFFu;
int64 outCode = -1;
KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanFoundDead);
KT_EQU(t, outId, childLow);
KT_EQ(t, outCode, threadKilledExitCode);
/* childLow is fully reaped already -- its slot is threadUnused now. */
KT_EQ(t, threadAt(childLow)->state, threadUnused);
/* childHigh is untouched by that call: still Dead, NOT yet reaped. */
KT_EQ(t, threadAt(childHigh)->state, threadDead);
outId = 0xFFFFFFFFu;
outCode = -1;
KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanFoundDead);
KT_EQU(t, outId, childHigh);
KT_EQ(t, outCode, threadKilledExitCode);
KT_EQ(t, threadJoinAnyChild(parent, &outId, &outCode), waitScanNoChildren);
KT_TRUE(t, threadDiscard(parent));
}
KTEST(threadKillWakesAParentGenuinelyBlockedInWaitAnyOnAnyChildDeath)
{
uint32 parent = threadCreate("anyBlockedParent", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 child = threadCreate("anyBlockedChild", noopThreadEntry);
KT_NE(t, child, threadInvalidId);
threadAt(child)->parentId = parent;
/* Fabricate exactly what sysWait's waitAnyPid path leaves behind via
schedBlockCurrent(currentThread()) when parent itself is the one
calling it (see threadStopExcludesTargetFromUnrelatedWaitChannelWake
below for the same white-box convention) -- channel is the PARENT's
own slot address, not the child's, unlike
threadKillWakesAThreadParkedInJoin's channel above. */
threadAt(parent)->state = threadBlocked;
threadAt(parent)->waitChannel = threadAt(parent);
KT_TRUE(t, threadKill(child));
KT_EQ(t, threadAt(parent)->state, threadRunnable);
KT_TRUE(t, threadReap(child));
KT_TRUE(t, threadDiscard(parent));
}
/*
* threadStop/threadContinue (sched.c) -- job control (signal.h's SIGSTOP/
* SIGCONT/SIGTSTP) on top of the new threadStopped state. The live proof
* that SIGSTOP genuinely stops a thread parked mid-syscall and SIGCONT
* genuinely resumes it through the exact restart-syscall path sysRead
* already uses is user/jobtest.c spawning user/jobtarget.c (needle
* "jobtest: job-stop-continue-ok" in tools/ci-test.sh); what is
* deterministic here is threadStop/threadContinue's own state-machine
* bookkeeping, mirroring threadKillRejects.../threadKillMarks... above.
*/
KTEST(threadStopRejectsIdleOutOfRangeAndNeverCreatedSlots)
{
KT_FALSE(t, threadStop(0));
KT_FALSE(t, threadStop(threadCapacity()));
KT_FALSE(t, threadStop(threadCapacity() + 1000));
uint32 neverUsed = threadCount();
if (neverUsed < threadCapacity())
KT_FALSE(t, threadStop(neverUsed));
}
KTEST(threadStopMovesRunnableThreadToStoppedAndIsIdempotent)
{
uint32 id = threadCreate("stopMe", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
KT_EQ(t, threadAt(id)->state, threadRunnable);
KT_TRUE(t, threadStop(id));
KT_EQ(t, threadAt(id)->state, threadStopped);
/* Idempotent: stopping an already-Stopped thread still succeeds,
matching real kill(pid, SIGSTOP) sent a second time. */
KT_TRUE(t, threadStop(id));
KT_EQ(t, threadAt(id)->state, threadStopped);
KT_TRUE(t, threadContinue(id));
KT_TRUE(t, threadDiscard(id));
}
KTEST(threadStopAcceptsSleepingBlockedAndRunningStates)
{
uint32 id = threadCreate("stopFromEveryState", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
/* Fabricated states, the same way threadReapClosesOpenWritePipeFdSoReaderSeesEof
below pokes threadDead directly: this thread is never actually
scheduled in this pre-schedStart() harness, so its `state` field
stands in for "genuinely Sleeping/Blocked/Running" -- exactly the
states a real SIGSTOP target could be caught in. */
threadAt(id)->state = threadSleeping;
KT_TRUE(t, threadStop(id));
KT_EQ(t, threadAt(id)->state, threadStopped);
KT_TRUE(t, threadContinue(id));
threadAt(id)->state = threadBlocked;
KT_TRUE(t, threadStop(id));
KT_EQ(t, threadAt(id)->state, threadStopped);
KT_TRUE(t, threadContinue(id));
/* Running -- the self-stop case (sysSigSend targeting the caller's own
pid, syscall.c): threadStop does not special-case "am I the one
calling this" the way threadKill implicitly does (Running is simply
absent from threadKill's accepted set); a thread can stop itself. */
threadAt(id)->state = threadRunning;
KT_TRUE(t, threadStop(id));
KT_EQ(t, threadAt(id)->state, threadStopped);
KT_TRUE(t, threadContinue(id));
KT_TRUE(t, threadDiscard(id));
}
KTEST(threadStopOnDeadOrUnusedSlotFails)
{
uint32 id = threadCreate("stopDead", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
threadAt(id)->state = threadDead;
KT_FALSE(t, threadStop(id));
KT_EQ(t, threadAt(id)->state, threadDead);
KT_TRUE(t, threadReap(id));
/* threadUnused again after reap: still rejected. */
KT_FALSE(t, threadStop(id));
}
KTEST(threadContinueRejectsOutOfRangeAndNonStoppedThreads)
{
KT_FALSE(t, threadContinue(threadCapacity()));
KT_FALSE(t, threadContinue(threadCapacity() + 1000));
uint32 id = threadCreate("notStopped", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
/* Runnable, never stopped: SIGCONT against a process that was never
stopped is a harmless no-op at this level (see signal.c's
sigRaise, which still delivers the signal itself regardless). */
KT_FALSE(t, threadContinue(id));
KT_EQ(t, threadAt(id)->state, threadRunnable);
KT_TRUE(t, threadDiscard(id));
}
KTEST(threadStopExcludesTargetFromUnrelatedWaitChannelWake)
{
int channel;
uint32 id = threadCreate("stoppedWhileWaiting", noopThreadEntry);
KT_NE(t, id, threadInvalidId);
/* Fabricate exactly what schedBlockCurrent would have left behind had
this thread been the one calling it (see
threadDiscardClosesOpenReadPipeFdSoWriterSeesBrokenPipe below for
the same fabricate-then-assert convention). */
threadAt(id)->state = threadBlocked;
threadAt(id)->waitChannel = &channel;
KT_TRUE(t, threadStop(id));
KT_EQ(t, threadAt(id)->state, threadStopped);
/* The defining difference from threadBlocked: schedWakeChannel must
NOT match a Stopped thread even though its waitChannel field still
names this exact channel -- only threadContinue (an explicit
SIGCONT) may move it back to threadRunnable, never "the resource it
was originally waiting on became available". */
schedWakeChannel(&channel);
KT_EQ(t, threadAt(id)->state, threadStopped);
KT_TRUE(t, threadContinue(id));
KT_EQ(t, threadAt(id)->state, threadRunnable);
KT_TRUE(t, threadDiscard(id));
}
/*
* threadStop/threadContinue's own new SIGCHLD-on-stop/SIGCHLD-on-continue
* (sched.c, mirroring threadExit/threadKill's parentId/sigChld block
* above): real job control notifies the parent asynchronously the instant
* a child stops or resumes, not only when it eventually dies. The live
* ring-3 proof that this reaches a genuinely blocked-mid-syscall target
* and a real installed handler is user/stopchldtest.c spawning
* user/stopchldtarget.c (needle "stopchldtest: job-control-sigchld-ok" in
* tools/ci-test.sh); what is deterministic here is the same plumbing shape
* threadKillRaisesSigChldAgainstParentId/
* threadKillEarlyWakesAParentGenuinelyBlockedWaitingForSigChld above
* already establish for the death case.
*/
KTEST(threadStopRaisesSigChldAgainstParentIdOnTheActualStopTransition)
{
uint32 parent = threadCreate("stopChldParent", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 child = threadCreate("stopChldVictim", noopThreadEntry);
KT_NE(t, child, threadInvalidId);
threadAt(child)->parentId = parent;
KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), 0ull);
KT_TRUE(t, threadStop(child));
KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), (1ull << sigChld));
KT_TRUE(t, threadContinue(child));
KT_TRUE(t, threadDiscard(child));
KT_TRUE(t, threadDiscard(parent));
}
KTEST(threadStopDoesNotReRaiseSigChldAgainstAnAlreadyStoppedTarget)
{
uint32 parent = threadCreate("stopChldParent2", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 child = threadCreate("stopChldVictim2", noopThreadEntry);
KT_NE(t, child, threadInvalidId);
threadAt(child)->parentId = parent;
KT_TRUE(t, threadStop(child));
KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), (1ull << sigChld));
/* Simulate the parent's own handler already having consumed that one
notification (sigConsumePending, signal.c, clears the bit on
delivery) -- exactly the "next natural ring-3 return" a real
sigchldtest.c-style handler would have already run through before a
second, redundant sigStop against the same still-Stopped target
could possibly arrive. */
threadAt(parent)->sigPending &= ~(1ull << sigChld);
KT_TRUE(t, threadStop(child));
KT_EQ(t, threadAt(child)->state, threadStopped);
KT_EQU(t, threadAt(parent)->sigPending & (1ull << sigChld), 0ull);
KT_TRUE(t, threadContinue(child));
KT_TRUE(t, threadDiscard(child));
KT_TRUE(t, threadDiscard(parent));
}
KTEST(threadStopEarlyWakesAParentGenuinelyBlockedWaitingForSigChldOnStop)
{
int channel;
uint32 parent = threadCreate("stopChldBlockedParent", noopThreadEntry);
KT_NE(t, parent, threadInvalidId);
uint32 child = threadCreate("stopChldVictim3", noopThreadEntry);
KT_NE(t, child, threadInvalidId);
threadAt(child)->parentId = parent;