feat(preemption): stop the slice clock while the poller blocks - #28
Merged
Conversation
An idle preemptive runtime was said to wake ~100 times a second, and there was no way to observe it: poll() returns once for a whole idle second no matter how many times a signal cut its stream_select() short and sent it round the EINTR retry. Timing cannot tell those apart either — the process sleeps out the same wall clock either way. So the poller counts every blocking wait it comes back from, the retries included, the way SharedArena::wakeups() counts pokes off the wake pipe. A count that scales with elapsed time rather than with the things being waited on is the signature of something waking the process for nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
The ~10 ms ITIMER_REAL was armed once around run() and free-running until the finally, so SIGALRM interrupted stream_select() about a hundred times a second even with every coroutine parked. Correctness held — the poller retries with the remaining timeout — but an idle preemptive server paid a hundred wakeups a second to preempt nobody: measured 101 poller wakeups and 12.5 ms of CPU per idle second against a cooperative run's 0.6 ms. A slice measures the CPU a coroutine holds, and inside the blocking poll no coroutine holds any, so the clock has nothing to measure there. Scheduler brackets that one call with Preemptor::pauseSlicing()/resumeSlicing(): 1 wakeup and 1.3 ms per idle second, and every forked worker gets it too, since a child idles in the same Scheduler::loop() on its own preemptor. Two conditions make it safe, and both are structural rather than careful. Idle means the poller is about to block, never "the run queue looks empty" — the pause opens after the queue has drained and the timers have fired and closes before anything is dequeued, so a coroutine about to run is still sliceable. And the re-arm is a finally over the whole poll(), so no way out of it — a readiness, a timeout, the internal EINTR retry, a throw from a broken descriptor — can leave the process cooperative behind a preemptor that still reports itself armed. That failure would report nothing. Pausing is not disarming: the hook stays installed, SIGALRM stays ours, a requested-but-untaken preemption stays pending, isArmed() keeps saying yes. disarm() and the shutdown drain lift the pause before draining, because the drain resumes coroutines that may never yield and only a live clock guarantees those resumes return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
Five files, each pinning one half of what the pause has to be true of, and every one of them verified to fail against the free-running clock first: - the wakeup count over an idle preemptive second (1, was 101); - the same count taken inside a forked worker, where only the child can report it — its poller and its timer are not the parent's (1, was 101); - idle CPU compared against a cooperative run of the same program, which is the comparison the cost is actually about (0.7 ms over, was 11.9 ms); - a call-free loop starting after an ordinary idle stretch is still preempted — the plain way out of the poll; - and the same after a signal cut the poll short, asserted on getitimer(2) rather than on the preemptor's own bookkeeping. SIGUSR1 from a helper process, not pcntl_alarm(): on Linux alarm() and setitimer(ITIMER_REAL) are the same timer, so an alarm would overwrite the clock under test. The EINTR one discriminates: with the re-arm moved off the finally onto the readiness path, it reports interval 0, slicing left paused and no preemption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
The rule an agent could otherwise undo by tidying: the pause belongs to the blocking poll and the re-arm belongs in a finally, because "idle" means the poller is about to block rather than the run queue looking empty, and a re-arm skipped on any path out of poll() leaves the process cooperative with nothing anywhere reporting it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
lisachenko
marked this pull request as ready for review
August 16, 2026 09:24
lisachenko
pushed a commit
that referenced
this pull request
Aug 16, 2026
Unifies this branch's shared-record recycling with #29's local-view claim counting, which landed while this was in flight and solved the other half of the same lifecycle. #27 and #28 merged through with two expectation updates. ## One counter, two lifecycles #29 counted claims to decide when to drop a slot's LOCAL VIEW; this branch counted handles to decide when to give the SHARED RECORD back. Two mechanisms measuring the same thing. They are now one - `ResultSlot::$claims`, #29's name kept - and `forgetIfSettled()` is where the single decision fans out: claims == 0 && complete && no waiters -> the local view is dropped, ALWAYS, whatever this process's role -> the shared record goes back only if $owned && $sharedSettled The distinction is the point. The view is per-process bookkeeping and its only cost is memory; the record is family-wide state and giving it back is a claim that nobody will read it through this ticket again. An adopter drops its view and releases nothing. A slot whose worker died drops its view and keeps its record out of circulation, because the record is still pending in shared memory where a zombie may yet write to it. `JoinHandle` now gives its one claim back at the FIRST of two moments - awaited, or collected - guarded by `relinquish()` so the pair can never count as two. Awaiting releases at once, which is stronger than #29's release-at-collection: a batch that spawns N and awaits them in a loop is down to one live slot by the end of the loop rather than N until the handles array dies. The destructor still carries the case it was added for, a handle nobody awaits. ## The decision #29 could not have had to make A settled, never-awaited handle now recycles its shared record and DISCARDS the answer. The alternative - keep it until teardown - is the exhaustion bug itself for fire-and-forget code, which is a real pattern. Nothing is silently misled by the choice, because a sibling holding the id gets a generation refusal rather than the next task's result. A handle dropped while its task is still PENDING keeps the slot until it settles, so overrunning the table with in-flight work is still the same typed refusal it always was. Verified: 300 paced fire-and-forget spawns run on a one-record table, and 50 unpaced spawns on a four-slot table are refused cleanly. ## A real bug the merge produced, caught by main's suite #29 made `settle()` end with `forgetIfSettled()`; this branch made `adopt()` settle eagerly so a stale ticket is refused at attach time. Together, `adopt()` of an already-settled slot forgot the view before the caller's claim was counted, and `await()` then failed with OutOfBounds - `testAPanicWhoseDetailCannotBeAttached` caught it. `adopt()` now claims BEFORE it settles, with the ordering written down. ## Conflicts - `src/Parallel/SlotTable.php` - seven regions, resolved as above. - `AGENTS.md` - both sides appended slot bullets and both edited the soak table. Kept both, rewritten around the two-lifetimes framing, and #27's preemption-soak row and self-test paragraph merged with this branch's `--leak-slots`. - `JoinHandle`/`ResultSlot`/`README` auto-merged and were then reconciled by hand; the README's slot-budget table had claimed a never-awaited handle holds its slot for the rest of the run, which is no longer true. `tools/soak-arena-watermark.php` did not conflict - main never touched it - and its RSS verdict now passes for the two reasons #24 fixed plus this branch's own pruning. ## Expectation updates - `testACompletedResultSlotIsNotRetainedByItsWaiter.phpt`: "views after await, handle still held" is 0 rather than 1, because awaiting now releases immediately. Its subject is untouched, and the destructor path it also covered moves to a new test rather than being dropped. - `testAWorkerKilledInsideACriticalSectionReportsTheRecoveredLock.phpt`: a lost slot is named `#0/gen1`, since an arena slot id carries its generation now. - New `testAnUnawaitedHandleGivesItsSlotBackWhenItIsCollected.phpt` pins the destructor path on a ONE-slot table, where a second spawn can only be allocated if the dropped handle's record really came back. Suite: 108 tests OK on PHP 8.4, against substrate main merged into this branch's substrate counterpart. phpstan level max clean, cs:check clean. All four soaks PASS (arena-watermark, memory-flatness, no-leftover-children, preemption), and both arena-watermark detectors still FAIL on demand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #17, via Option 1 — disarm the slice timer while idle.
new Runtime(preemptive: true)armed a free-running ~10 msITIMER_REALaroundrun(), so SIGALRM cutstream_select()short about a hundred times a second even with every coroutine parked. Correctness held (the poller retries with the remaining timeout) but an idle preemptive server paid for it: 101 poller wakeups and 12.5 ms of CPU per idle second, against a cooperative run's 0.6 ms.A slice measures the CPU a coroutine holds, and inside the blocking poll no coroutine holds any.
Scheduler::pollIdle()now brackets that one call withPreemptor::pauseSlicing()/resumeSlicing(): 1 wakeup and 1.3 ms per idle second. Forked workers get it too — a child idles in the sameScheduler::loop()on its own post-fork preemptor, and its count goes 101 → 1 as well, with the two fork seams untouched.Two conditions make it safe, both structural rather than careful:
finallyover the wholepoll(), so no way out — a readiness, a timeout, the EINTR retry the poller does internally, a throw from a broken descriptor — can leave the process cooperative behind a preemptor that still reports itself armed.Pausing is not disarming: the hook stays installed, SIGALRM stays ours, a requested-but-untaken preemption stays pending,
isArmed()keeps saying yes.disarm()and the shutdown drain lift the pause before draining, because the drain resumes coroutines that may never yield and only a live clock guarantees those resumes return.StreamPoller::wakeups()is added as the diagnostic the first criterion needs — every blocking wait the poller comes back from, EINTR retries included, in the shape ofSharedArena::wakeups(). Timing cannot substitute:poll()returns once for a whole idle second however many times a signal woke it.Acceptance criteria from #17
testAnIdlePreemptiveRuntimeDoesNotWakeThePollerEverySlice.phpt(101 → 1), plustestAnIdleWorkerInAPreemptivePoolStopsItsOwnSliceTimerToo.phptmeasured inside a forked worker.testACallFreeLoopIsStillPreemptedAfterTheRuntimeHasBeenIdle.phpt, because no existing test went idle before burning.testAnIdlePreemptiveRunCostsNoMoreCpuThanACooperativeOne.phpt, the wall-clock-vs-getrusage()pattern.testTheSliceTimerIsArmedAgainAfterASignalCutAPollShort.phpt: SIGUSR1 from a helper process (notpcntl_alarm(), which shares the ITIMER_REAL slot with the clock under test) cuts a watched poll short; asserts the retry completed,getitimer(2)reports the 10 ms interval afterwards, slicing is not left paused, and a burner is still preempted after.Every new test was run against the unmodified code first and shown to fail.
Verification
PHP 8.4.19,
-d ffi.enable=1 -d opcache.jit=off: full suiteOK (103 tests, 103 assertions), PHPStan level max clean,cs:checkclean. PHP 8.5 is CI-only (the local vendor tree resolves the 8.4 z-engine line).Noted during runs, unrelated to this change:
testAnAlreadyCompleteSlotIsAwaitedWithoutParking.phptfailed once in four full-suite runs on a cooperative-path timing race that predates this branch (12/12 green in isolation with and without the change) — worth its own issue.🤖 Generated with Claude Code
https://claude.ai/code/session_01U5QRWPfmkZME7hDjU6i5EA
Generated by Claude Code