Chapter 30 / 51
Volume 4 · Hot Reload Deep Dive

GC & zero leaks: the old generation's burial rite

What happens after a lease count hits zero? A dedicated thread dismantles the old generation in a fixed order — this chapter is about confirming it really is dismantled clean.

Overview

Positioning: volume 4's verification chapter — the mechanisms were covered; here you get the observability (finalize logs, force-free assertions, memory reports) and a real historical case (how the early "leak the old generation entirely" strategy evolved into zero-leak GC).

30.1 Intro

Hot-reload garbage collection has an inherent difficulty: the old generation's fate depends on the completion timing of asynchronous events (when connections close, when callbacks return) — unpredictable across many workers. XServer's history genuinely saw two strategies: early versions chose "leak the old generation entirely" (never free, absolute safety); the current version evolved into "zero references + safety signal + deferred release" — the safety signal being the old driver's connection count reaching zero (all Close callbacks done), after which release proceeds in order past a grace period. The reason for evolving is simple: under the leak strategy, frequent reloads eat all memory.

30.2 The reaper & destruction order

generation reaper (dedicated thread, engine timer scans every 500ms) detects: old generation lease == 0 (safety signal: old driver's connection count at zero) │ + grace period (custom/udp classes: fixed 5s; a 30s timeout backstop) ▼ destruct in order (the order is fixed) 1. ServiceUnit /* your cleanup: close db, flush buffers */ 2. TCC state /* compiled artifacts and symbols */ 3. driver runtime /* protocol-layer state */ 4. server struct /* config tree and snapshots */ log: generation finalized (once per generation) assertion baseline: no force-free anywhere / no timeout force

The order means: your ServiceUnit runs before any host resource is dismantled — host facilities are still available during cleanup; and it runs after the old generation has stopped serving — cleanup affects no request. That position is engineered.

30.3 The measured case

The repository's history records the acceptance experiment for this mechanism (three-cycle swaps between 8080↔8081): the same ports swap generations three rounds in a row, asserting —

  • every round serves flawlessly (zero failed requests)
  • all 6 old generations finalize (log count matches the swap count)
  • zero leaks (memory reports identical before and after), zero crashes, no force-free

Your own service can use the same three-part acceptance: N swaps under load → N finalized in the logs → a flat RSS curve (drill's sampling approach). These assertions are baked into lifecycle_reload_test and reload_matrix (ch. 06's baseline table).

30.4 Timers die with their generation

Hidden in the GC order is a corollary that matters to applications: the old generation's timers are invalidated at the swap, not at GC. Mechanically, a timer binds to the generation that created it (strictly, the thread-local mark of "the old generation currently executing") — after a swap the old generation's timers are cancelled, and the new generation re-arms its timers from its Init.

  • Result: a timer never fires into the wrong generation — the statics a callback touches always belong to its creating generation
  • Cost: periodic tasks must be rebuilt in the new generation's Init (ch. 27's file-watch trap is exactly this)
  • A firing already in flight: a callback that has begun executing is protected by that generation's lease until it completes

30.5 Contract

  • Order: ServiceUnit → TCC → driver → config, immutable.
  • Observability: the finalized log count equals the swap count; any force-free is a defect.
  • Timers: bound to the creating generation; invalidated at swaps; periodic tasks rebuilt in the new Init.

Common mistakes

Long blocking inside ServiceUnit
Symptom
After several swaps the GC queue backs up; finalize logs lag behind the swaps.
Cause
The reaper thread runs each generation's Unit serially — one Unit stuck for 30 seconds queues everything behind it. Cleanup must be quick (close handles, flush buffers); long work goes to a background thread you manage yourself.
Taking delayed finalize as a leak
Symptom
finalized appears minutes after the swap; suspected memory leak.
Cause
Long connections (WS/TAKEOVER) hold the old generation's lease — finalize waits for them to end (ch. 29's lease table). Check connections first, leaks second.

Exercises

BasicCounting finalize

Swap 5 times in a row; tally the finalized log lines and reconcile with the count.

Acceptance: 5:5 aligned, no force-free.
AdvancedThe three-cycle acceptance

Replicate 30.3's experiment: three-cycle swaps under load plus an RSS sampling curve.

Acceptance: zero failed requests; RSS stable within ±5% of pre-swap levels.
ChallengeProving timer invalidation

The old generation arms a 10s periodic task (printing a generation marker); swap at second 3 and watch the old task stop, the new generation rebuild and continue.

Acceptance: the log distinguishes both generations' prints; the old task fires zero times after the swap.

Summary

PointConclusion
Strategy evolutionearly "leak everything" → zero references + safety signal + deferred release
Destruction orderServiceUnit → TCC → driver → config (fixed)
Observabilityreconcile finalized counts; force-free = defect signal
Timersbound to the creating generation, invalidated at swaps, rebuilt in Init
xrt further reading