Skip to content

feat(preemption): stop the slice clock while the poller blocks - #28

Merged
lisachenko merged 4 commits into
mainfrom
claude/issue-17-idle-preemption-timer
Aug 16, 2026
Merged

feat(preemption): stop the slice clock while the poller blocks#28
lisachenko merged 4 commits into
mainfrom
claude/issue-17-idle-preemption-timer

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

Closes #17, via Option 1 — disarm the slice timer while idle.

new Runtime(preemptive: true) armed a free-running ~10 ms ITIMER_REAL around run(), so SIGALRM cut stream_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 with Preemptor::pauseSlicing() / resumeSlicing(): 1 wakeup and 1.3 ms per idle second. Forked workers get it too — a child idles in the same Scheduler::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:

  • 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.
  • The re-arm is a finally over the whole poll(), 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 of SharedArena::wakeups(). Timing cannot substitute: poll() returns once for a whole idle second however many times a signal woke it.

Acceptance criteria from #17

  • Idle wakeups counted, not felttestAnIdlePreemptiveRuntimeDoesNotWakeThePollerEverySlice.phpt (101 → 1), plus testAnIdleWorkerInAPreemptivePoolStopsItsOwnSliceTimerToo.phpt measured inside a forked worker.
  • A call-free loop still cannot starve a peer — the existing preemption suite unchanged, plus testACallFreeLoopIsStillPreemptedAfterTheRuntimeHasBeenIdle.phpt, because no existing test went idle before burning.
  • Idle CPU comparable to cooperativetestAnIdlePreemptiveRunCostsNoMoreCpuThanACooperativeOne.phpt, the wall-clock-vs-getrusage() pattern.
  • Re-arm survives EINTRtestTheSliceTimerIsArmedAgainAfterASignalCutAPollShort.phpt: SIGUSR1 from a helper process (not pcntl_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 suite OK (103 tests, 103 assertions), PHPStan level max clean, cs:check clean. 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.phpt failed 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

lisachenko and others added 4 commits August 16, 2026 09:14
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
lisachenko marked this pull request as ready for review August 16, 2026 09:24
@lisachenko
lisachenko merged commit dd5e803 into main Aug 16, 2026
4 checks passed
@lisachenko
lisachenko deleted the claude/issue-17-idle-preemption-timer branch 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An idle preemptive runtime wakes 100 times a second

1 participant