Skip to content

fix(conn_slab): decommit slot buffer pages inside the slab mutex - #300

Open
detail-app[bot] wants to merge 1 commit into
masterfrom
detail/bug-fix/fix-conn-slab-decommit-slot-buffer-pages-inside-th-e2a58a
Open

detail-app[bot] wants to merge 1 commit into
masterfrom
detail/bug-fix/fix-conn-slab-decommit-slot-buffer-pages-inside-th-e2a58a

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 16, 2026

Copy link
Copy Markdown

Detail bug report: View on Detail

Fixes ENG-701

Bug

ConnSlab.release in src/core/conn_slab.zig decommitted a slot's buffer pages with madvise after the slab mutex was released. The defer self.mutex.unlock(io) was scoped to an inner labeled blk: block, so it fired at break :blk — not at function return — leaving the madvise running lockless.

The free list is LIFO, so the just-freed slot is the very next one claim returns. The stdio frontend spawns one OS-threaded task per accepted connection (server.zigLifecycle.spawngroup.concurrent), and conn.zig binds the new connection's net.Stream.Reader/Writer to slab.recvBuf/sendBuf immediately on claim. A concurrently accepted connection could therefore start writing those pages while the releasing thread's madvise(MADV_DONTNEED) (Linux) / FREE_REUSABLE (macOS) was discarding them, silently zeroing the live owner's data — surfacing as spurious 400 Bad Request (zeroed HTTP head) or garbled/truncated responses and upstream bodies. There was no lock, fence, or stale-state re-check between unlock and madvise.

Fix

Moved the madvise decommit inside the slab mutex critical section: release now takes a function-scope defer self.mutex.unlock(io) and performs the decommit before the slot becomes claimable. The decommit only ever touches pages of a slot that is still exclusively .free and unreachable by a concurrent claim. release fires once per connection lifetime (not per request), so adding one madvise syscall to the lock hold is negligible on a path dominated by the upstream round trip, and the slab mutex is uncontended on the close path in the common case.

Added a regression test (release decommits page-aligned slot buffers before the slot is claimable) that exercises the real madvise path with page-sized buffers — the existing tests use sub-page buffers so the aligned_end ≤ aligned_base guard skips the decommit entirely — and asserts the slab survives the in-lock decommit (on Linux, confirms MADV_DONTNEED zeroed the re-claimed buffer; on other platforms, confirms the buffer is still usable).

Testing

  • Routine checks (all pass): zig build test --summary all (520 pass, 1 pre-existing skip — incl. the new madvise-path test), zig fmt --check, ziglint, and zig build otlp -Dfrontend=stdio (builds the affected stdio frontend). zig test src/core/conn_slab.zig: all 8 conn_slab/limits tests pass.
  • Threaded slab race reproducer (not committed, throwaway): built a standalone harness modeled on the exact slab pattern — real OS threads via std.Io.Threaded, page-aligned slot buffers, LIFO free-list, and the real ConnSlab.release/claim — running 2,000,000 claim-write-verify cycles (4 thread pairs, 8 threads):
    • With this fix: 0 corruptions.
    • Negative control (release reverted to the buggy post-unlock madvise): 114,136 / 2,000,000 (~5.7%) corruptions — bytes written 0x42 read back as 0x00 from recvBuf, confirming the race is real and this change is what closes it.
  • Mixed-operations mutex stress (not committed, throwaway): 8 threads, 1.6M claim/setState/release/inUse ops against a shared slab under std.Io.Threaded confirmed no deadlock, no debug-assert trip, and consistent accounting (all 64 slots returned) with the wider critical section.
  • ABA/generation guard: covered by the existing exhausted slab returns null, recovers after release unit test, which asserts a re-claimed slot carries a new generation (old handle is dead); the in-lock madvise does not perturb the generation bump.
  • End-to-end stdio HTTP-forwarding churn stress (not run): I started the stdio edge-otlp binary and a high-churn client against it, but a pre-existing panic in src/frontend/stdio/conn.zig's pipe_buffered path (request.iterateHeaders() asserts reader.state == .received_head after the body reader is already created, at conn.zig:161) aborts connection tasks when forwarding certain POSTs — this is in code the fix does not touch and is out of scope. The local-only /_edge/metrics path serves 200, but exercising the slab-backed recvBuf forwarding path end-to-end through the live binary is blocked by that pre-existing bug. The race is instead reproduced and closed via the direct threaded slab harness above.
  • RSS idle-decommit measurement (not run): confirmed the decommit path still fires under the new placement via the unit test's MADV_DONTNEED zero-page assertion, but did not measure idle VmRSS return on a running binary.
  • macOS (MADV.FREE_REUSABLE) variant (not run): this environment is Linux-only; the unit test guards the non-Linux branch by asserting buffer usability rather than zeroing.

Automatic Fixes PRs can be configured here.

@macroscopeapp

macroscopeapp Bot commented Sep 16, 2026

Copy link
Copy Markdown

Approvability

Verdict: Approved at 3162194

Macroscope's review found this PR approvable — This is a focused concurrency bug fix that keeps the existing page-decommit operation under the slab mutex, preventing reuse of a slot while its pages are being discarded. Its only broader effect is a bounded mutex hold around one connection-close syscall, with no API, schema, deployment, or sensitive-area changes.

You can add or adjust custom eligibility rules. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant