m0serve Realtime from a synchronous Python app

Scheduling stickiness: which worker wins the accept race is CPU placement, not load

A design note from the engineering record, moved out of ROADMAP.md's Known issues on 2026-09-05 and kept as written. The change that followed is accept sharing.

Scheduling stickiness: two forked workers, one shared listener, and eighty accepts in a row to the same worker — reproduced, and it is CPU placement, not load. Seen once (2026-08-29, ubuntu CI runner, PR #168's first run): smoke-reload's two-worker phase re-forked both workers onto the new module — both logged their loop start — and then every one of ten rounds of eight fresh connections was answered by worker 6522; the smoke wants to see both pids and failed. The first recorded failure of that step, and the CI re-run of the same job on the same head passed. The mechanism it looked like was right, the load theory attached to it was not, and the fix direction it named was backwards; all three measured 2026-09-02 in a Linux container (colima, 4 vCPU, the 0.16.0 aarch64 wheel, --workers 2, the smoke's own probe of 10 rounds x 8 sequential connections) with scripts/accept_placement.py.

The mechanism: M0_WORKERS forks after listen, so both workers share ONE listen socket, each registers it EPOLLIN|EPOLLET in its own epoll, and on a connection both wake and the first to reach accept() drains the backlog until EAGAIN while the other gets EAGAIN and parks. Which one is first is the scheduler's, and on a quiet 4-CPU box it is already the same one nine times in ten: 63–77 of 80 to one worker across 15 unpinned runs, the smoke passing each time only because the minority worker surfaced in round 1–3. What makes it ten of ten is where the CLIENT runs. Workers pinned to CPUs 0 and 1 and the probe on CPU 1: 80 of 80 to the worker on CPU 0, no round with both pids, in four runs of five (the fifth 79/1). Probe on CPU 2: 70–76 of 80. The worker that shares the client's CPU loses every time — the accept-queue wakeup runs inside the client's own connect() on its CPU, the worker with an idle CPU of its own is running before the client has blocked, and the co-located worker finds an empty backlog when it finally runs. Nothing pins tasks on a CI runner, but wake-affine placement can hold exactly that shape for the five seconds the probe lasts, and that is the sighting. Load is not the mechanism and tends to CURE it: everything on one CPU alternates 45/35 (one runqueue, CFS's vruntime picks the worker that has run less), and hogs beside either worker move the split toward even, not away from it. Concurrent connections do not fix it either (the burst is drained by whichever worker wakes first; 1–3 rounds of 10 in most placements).

EPOLLEXCLUSIVE is NOT the fix direction: in a pure-Python model of the accept path it sends 80 of 80 to one worker in every placement, quiet or loaded — it removes the very race that was giving the other worker its share. Per-worker SO_REUSEPORT listeners (bound after the fork, the kernel hashing connections across them) balance 40/40 to 46/34 in every placement and are the only shape that does. Not adopted on one CI failure: it changes the accept path of every prefork deployment, and a connection queued at a worker that dies is reset until the respawn rebinds — the shared socket is what makes the supervisor's respawn and --reload invisible to clients. Sequential one-shot connections from a single client are the smoke's shape, not a deployment's; keep-alive connections spread over time, and gunicorn's and nginx's prefork share the property.

The smoke is asserting scheduler fairness (memory: "assert blocking, not fairness"), and loosening it to one pid would hide what it exists to see. The assertion that does not depend on fairness was measured on the same wheel under the reproducing placement: SIGSTOP the worker that answered, and the same probe is answered 80 of 80 by the other worker, promptly (5.5 s for ten rounds, all of it the probe's own sleeps); SIGCONT it, and SIGTERM exits 0 with no crashed or respawned line — the supervisor reaps with WNOHANG alone, so a stopped worker is neither a crash nor a respawn. smoke-reload's two-worker phase now asserts it that way — stop the worker that answered, the other must serve the new body 8 of 8, and both pids must be the ones the supervisor logged as re-forked — and was sabotaged in both layers before it counted: with kill -STOP made a no-op it fails as "SIGSTOP did not take", and with _reload altered to leave the old worker 1 alive while logging it as re-forked (so only the stop layer can see it) it fails naming the old body that worker served. accept_placement.py serve --stop-winner is the same measurement bare. SO_REUSEPORT per worker stays the change to make to the server only if a deployment, not a probe, shows the imbalance mattering.