merge: lane/core

This commit is contained in:
2026-09-12 11:05:07 +04:00
9 changed files with 936 additions and 92 deletions
@@ -0,0 +1,102 @@
# 17. SegmentBudget over-admission, and a wait-list to wake denied tasks
Status: accepted
## Context
`core/docs/m7-baseline.md`'s first pass measured ~70 MB RSS for the M1/M7 DoD's "20 active
downloads, `max_active_segments=32`" scenario, against a 60 MB target `docs/adr/0012`
estimated would hold with ~4550 MB of margin. `tools/bench heap-profile` (added to chase
this down — no massif/heaptrack in this environment) found the actual cause directly:
`Engine::segment_budget().budget().active` read 5686 against a `total` of 32. The engine
was not over budget by a measurement artifact or an ADR 0012 arithmetic gap; it was
genuinely running more concurrent segments — and their 1 MiB ring buffers — than
`max_active_segments` allows.
`SegmentBudget::confirm_slot(id)` checked only `t.held < t.target` — a per-task check.
`reallocate_locked()`'s two-pass fairness allocation does correctly bound
`sum(target) <= max_active_` at the moment it computes a plan, but that bound says nothing
about `sum(held)`: a task can be legitimately holding more than its own just-lowered
target for a while, because yield is deliberately deferred to a segment boundary, never
mid-segment (ADR 0011 A1). When that happens, another task's target can correctly rise to
claim the capacity the first task is *about* to give back, and both `confirm_slot()` calls
can succeed against their own, individually-correct targets while the sum of what's
*physically held* exceeds `max_active_`. Reproduced deterministically in
`core/tests/segment/budget_test.cpp` (`budget_active_never_exceeds_max_active_segments_
under_concurrent_load`, `budget_wait_list_wakes_a_task_whose_target_never_changed`)
independent of any real network I/O.
## Decision
**`confirm_slot()` also checks `active_ < max_active_`, unconditionally**, as a backstop
that doesn't depend on any task's target bookkeeping being perfectly in sync with what
every other task currently holds. This is the fix for the invariant itself.
That backstop creates a liveness question the original design never had to answer: a task
denied *only* by this new check has a target that's already correct — it doesn't change
again on its own, so `reallocate_locked()`'s plain "fire a callback when a task's target
changes" mechanism never revisits it. Nothing was watching for "this specific task is owed
a slot"; **`Task` gained a `waiting_for_slot` flag**, set by `confirm_slot()` on exactly
this denial and cleared on the task's next successful confirm however that retry was
triggered. `release_slot()` and `deregister_task()` — the only two places that can free
real capacity — now call `wake_one_waiter_locked()`, which hands the newly-freed slot to
the highest-priority `waiting_for_slot` task (other than the one that just released) via a
direct retry hint, if `reallocate_locked()`'s own plan didn't already produce one.
On the `download_task.cpp` side, a woken task's `apply_slot_target()``fill_slots_locked()`
also needed a fix: a segment that had backed off mid-retry (`SegState::stalled`, its
`release_slot()` already called, a `retry_worker()` timer already scheduled and possibly
already denied once) has no live worker and never surfaces through
`Segmenter::assign_slot()` — it stays *assigned*, just not running, and assign_slot() only
ever hands out unassigned or fresh ranges. `fill_slots_locked()` now restarts any stalled
segment with no live worker directly (bounded by `slot_target`, same as its `assign_slot()`
loop) before looking for new work; a segment it doesn't get to keeps its own scheduled
`retry_worker()` timer as a second chance, so this is additive, not a replacement for that
path.
### What this isn't
Production's real `on_target` callback (`register_task()`'s lambda, `download_task.cpp`)
never runs synchronously with whatever budget call triggered it — it posts through
`host.schedule()` (`engine.cpp`'s single timer thread), so a task's own `mu` can never be
re-entered on the same call stack, and two tasks' callbacks can never race each other into
an AB-BA lock order either (only one ever runs at a time, on one thread). An earlier
version of this fix tried to defend against a same-thread reentrancy hazard that,
diagnosed correctly, doesn't exist in production at all — it exists only if a *test double*
calls back into the budget synchronously from inside `on_target`, which none of production
does. `core/tests/segment/budget_test.cpp`'s `FakeTask` does exactly that (by design — it's
simple and every other test in the file drives the budget from one thread at a time, where
that's harmless); the one test that drives it from *multiple* concurrent threads
(`budget_concurrent_confirm_release_stays_consistent`) uses a separate `AsyncFakeTask` that
posts through a small `TestTimer`, mirroring `host.schedule()` / the engine's timer thread
for real, rather than adding synchronization machinery to `SegmentBudget` itself to paper
over a test double being unlike production. `wake_one_waiter_locked()`'s `exclude`
parameter is the one piece of that defense that's independently justified either way — a
task doesn't need to be told to retry a slot it just gave back itself — and is the only
piece that stayed.
## Consequences
- The M7 RSS number now clears the DoD line: `heap-profile` reports 45.41 MiB for the
20-task/8-segment/32-cap scenario (`core/docs/m7-baseline.md`), and
`budget.active` never exceeds `budget.total` regardless of contention.
- `docs/adr/0016`'s TSan-only load-test straggler (a *different* subsystem —
`rate::RateLimiter`'s byte-pacing, not `SegmentBudget`'s segment-admission) is now
suspected to have been this same root cause rather than the `CURLOPT_LOW_SPEED_TIME`
guess offered there, since the symptom (a task that simply never resumes) matches
exactly. Not reverified at the DoD's full shape under `--preset tsan` in this change —
`tools/bench`'s sanitizer-preset `load` registration still runs at reduced concurrency.
Worth a follow-up run before closing that ADR's postscript.
- `docs/adr/0016`'s actual subject — `rate::RateLimiter::TokenBucket`'s own peek/commit
race, unrelated to `SegmentBudget` — is untouched by this change and remains open.
- `wake_one_waiter_locked()` wakes exactly one task per actual release, by priority order.
Under sustained heavy oversubscription (this bench's 20 tasks × 8 segments = 160 wanted
against 32 available) a low-priority task can still wait a long time for its fair share
— that's the two-pass allocator's own fairness policy working as designed, not a
liveness bug: it will get there, just not fast, and every fresh `reallocate_locked()`
call (any task's `set_want`, registration, or departure) reconsiders everyone from
scratch. No test in this change measures *how* long; if that ever needs a stronger
guarantee (bounded wait time, not just eventual service), it's a fairness-policy change,
not a wiring fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>