#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;