Skip to content

test(loading): kill the server on an unwinding panic, not on the last line - #717

Merged
TinDang97 merged 2 commits into
mainfrom
fix/test-server-leak-on-panic
Aug 25, 2026
Merged

test(loading): kill the server on an unwinding panic, not on the last line#717
TinDang97 merged 2 commits into
mainfrom
fix/test-server-leak-on-panic

Conversation

@TinDang97

@TinDang97 TinDang97 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Replaces #714, which I opened from the wrong head branch (it showed #712's already-merged diff).

What happened

tests/loading_state_476.rs put its kill(child) on the last line of the test body. A failing
assert! unwinds straight past it, so the server is reparented to init. After one afternoon of
deliberately failing mutation runs of this file:

five orphans, ~170% CPU each — 834% combined — for 7.5 hours, answering nothing on their
ports and spending the time in syscalls (13:42 system vs 0:09 user on one thread). The cost is not
only the cores: every wall-clock-sensitive test that ran afterwards ran on a machine under
invisible load.

Fix

A ServerGuard(Option<Child>) owns the child and kills it in Drop, so an unwinding panic reaps
it. kill_now() consumes the guard where the test needs the server gone rather than merely
doomed (the store must be closed before it is reopened); terminate() is idempotent via
Option::take, so the explicit kill and the drop cannot double-kill.

The regression test, and why it checks the pid

It panics deliberately inside catch_unwind and then asserts on the process, not the port —
a dead server's port frees up either way, so a connect-refused proves nothing about whether
anything is still running.

#[cfg(unix)], deliberately: Check (Windows) runs cargo nextest run, Command::new("ps")
cannot spawn there, and the first version read that spawn failure as "not alive" through
.unwrap_or(false) — it would have passed on Windows without checking anything, which is the
exact shape of guarantee this test exists to refute. The probe now expects, so a ps that
cannot run is an error rather than a green test.

Mutations (each fails as it must)

mutation result
mem::forget(guard) server pid 4554 survived a panicking test
rename the probe binary `ps` must be available to probe process liveness: NotFound

Wider exposure

Measured across the suite: 147 test files spawn a server, 30 contain no impl Drop at all
tracked in #713 (the count there is corrected, with the method; two earlier numbers were wrong).

Summary by CodeRabbit

  • Bug Fixes

    • Improved test-server cleanup when loading-state tests fail or panic.
    • Added regression coverage to ensure server processes are terminated reliably.
    • Prevented orphaned server processes from remaining after interrupted tests.
  • Documentation

    • Added a changelog entry describing the improved cleanup behavior.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e4fc7180-e6d8-462e-8c9f-21e3a814ffe0

📥 Commits

Reviewing files that changed from the base of the PR and between ab60b9a and 6390645.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • tests/loading_state_476.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The loading-state test now owns spawned servers through ServerGuard. Explicit cleanup and drop-based cleanup terminate and reap child processes. A Unix-only regression test verifies cleanup after panic unwinding, and the changelog documents the fix.

Changes

Loading-state server cleanup

Layer / File(s) Summary
ServerGuard lifecycle and test integration
tests/loading_state_476.rs
The test replaces standalone child termination with ServerGuard. The guard cleans up during explicit calls and scope unwinding. Store creation and restarted servers use the guard.
Panic cleanup regression coverage
tests/loading_state_476.rs, CHANGELOG.md
A Unix-only test panics with a guarded server in scope, then checks the child PID with ps. The changelog records the cleanup change.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 63906

The change is localized to test cleanup behavior and documentation, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers: pilotspacex-byte

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the problem, fix, regression test, platform scope, and broader impact. However, it does not follow the repository template because it omits the required Summary, Checklist, Pe… Update the description to include all template sections. Add the required checklist with current results, state the performance impact or write "None", and include any relevant notes. Preserve the existing technical explanation under the Su…
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 1 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary change: ensuring the server is killed during panic unwinding instead of only on the test's final line.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the problem, fix, regression test, platform scope, and broader impact. However, it does not follow the repository template because it omits the required Summary, Checklist, Performance Impact, and Notes headings and content.

Resolution

Update the description to include all template sections. Add the required checklist with current results, state the performance impact or write "None", and include any relevant notes. Preserve the existing technical explanation under the Summary or Notes sections.

Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 1 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/test-server-leak-on-panic

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@TinDang97

Copy link
Copy Markdown
Collaborator Author

Merge-bar record

Same override as #715 and for the same measured reason: the self-hosted runner is offline
(orb list empty, moon-dev-vm offline), so Check (monoio) and Client compat cannot run at
all. All hosted PR checks are green.

Covered instead by a full scripts/ci-local.sh --native run on a branch merging this PR with
#715 and #716 — exit 0, fingerprint valid, monoio 6055/6055 and tokio 5266/5266, including this
file's lst476_* tests.

Remaining gap, stated plainly: no io_uring coverage. macOS ran monoio on kqueue.

… line

`tests/loading_state_476.rs` called `kill(child)` at the end of each test body.
A failing `assert!` unwinds past that line, so the server was reparented to
init and left running.

This is not hypothetical. After a round of deliberately failing mutation runs
of this file, five orphans were left behind: ~170% CPU each, 834% combined,
for 7.5 hours. They answered nothing on their ports and spent the time in
syscalls (13:42 system time vs 0:09 user). The wasted cores are the smaller
half of the cost — every wall-clock-sensitive test run afterwards measured a
machine under a third of a load it could not see.

The child is now owned by a `ServerGuard` that kills and reaps on drop.
`kill_now()` stays for the one place that needs the store CLOSED rather than
merely doomed, before it is reopened.

The regression test panics on purpose inside `catch_unwind` and then asserts
on the PID, not the port: a dead server frees its port either way, so a
connect-refused would prove nothing about whether anything is still running.
Verified load-bearing — swapping the guard for `mem::forget` fails it with
"server pid N survived a panicking test".

Refs: #476
author: Tin Dang
`Check (Windows)` runs `cargo nextest run`, so this suite runs there too,
and `Command::new("ps")` cannot spawn on Windows. The liveness probe read
that spawn failure as "not alive" via `.unwrap_or(false)` — the assertion
would have passed on Windows without checking anything, which is the exact
shape of guarantee this test exists to refute.

Two changes:
  * `#[cfg(unix)]` on the test — the guarantee is only worth what its
    platform-specific primitive is worth, so it declares the platform
    instead of quietly degrading on the others.
  * the probe now `expect`s, so a `ps` that cannot RUN is an error rather
    than a green test.

Both mutations fail as they must:
  * probe binary renamed -> panics with "`ps` must be available to probe
    process liveness: NotFound" instead of passing
  * `mem::forget(guard)` -> "server pid 4554 survived a panicking test"

author: Tin Dang
@TinDang97
TinDang97 force-pushed the fix/test-server-leak-on-panic branch from ce07829 to 6390645 Compare August 25, 2026 14:55
@TinDang97

Copy link
Copy Markdown
Collaborator Author

The earlier Memory steady-state gate red was infrastructure, not this change: sccache could not resolve the Actions cache blob host (dns error: failed to lookup address information: Try again) and the job died at rustc probe time, before compiling anything. Re-ran the job unchanged — green. A test-file + CHANGELOG diff cannot affect that gate.

Note also: moon-dev has since been rebuilt and its runner is back online, so Client compat has already passed on the real oracle for the #715 dispatch and the monoio leg is running. The io_uring gap called out above is closing on its own.

@TinDang97
TinDang97 merged commit 36d8417 into main Aug 25, 2026
18 of 19 checks passed
TinDang97 added a commit that referenced this pull request Aug 25, 2026
… from the last line of a passing test (#720)

* test(harness): own the server through Drop in the 14 spawn_listening suites

moon#713. A test that fails must not leave its server running. The kill
sits on the last line of the test body, and a failing assert unwinds
straight past it — measured on tests/loading_state_476.rs (fixed in #717):
five orphans, ~170% CPU each, 834% combined, for 7.5 hours, answering
nothing on their ports and spending the time in syscalls.

That file was not special. 26 integration suites spawn a real moon and
none of them owned the child through a Drop.

Adds `common::ServerGuard` and `common::spawn_listening_guarded`, so the
guarantee is written once and tested once rather than re-argued per file,
and converts the 14 suites that already go through `common::spawn_listening`.

The guard's shape is dictated by what these suites actually do:
  * `as_mut()` — crash and shutdown suites need the real Child for
    try_wait, stdio and custom readiness loops.
  * `kill_now()` is idempotent — crash-recovery suites SIGKILL on purpose
    and then restart, so "already reaped" is normal, and a second reap
    must not aim a kill at a pid the OS may have recycled.
  * `take()` — a suite that genuinely needs the Child back takes the duty
    to reap with it; the guard keeps only the pid, for assertions.

Restarted servers are guarded too, not just the first spawn: a second
server orphans exactly as well as the first.

tests/server_guard_contract.rs pins all three behaviours against a real
moon process, asserting on the PID rather than the port — a dead server's
port frees up either way, so connection-refused proves nothing about
whether anything is still running. Unix-only, deliberately: Windows runs
this suite and `Command::new("ps")` cannot spawn there, so a probe that
failed to RUN would report "not alive" and pass vacuously.

Verification:
- 14 converted suites + the contract: 31 passed, 0 failed; plus the 12
  #[ignore]d crash/restart tests run explicitly, 12 passed, 0 failed.
- Mutations, each failing its test: mem::forget(guard) and a no-op Drop
  both leave the server alive ("survived a panicking test"); a take() that
  does not transfer ownership trips sg3.
- The two leak mutations left real orphans on the machine, which is the
  bug demonstrating itself.

author: Tin Dang

* test(harness): guard the remaining 12 direct-spawn suites, delete dead sigkill copies

Batch 1 converted the 14 suites that spawn through `common::spawn_listening`.
These 12 build their own `Command::spawn()` chain, so they get `ServerGuard::new`
wrapped around the spawn expression directly:

  aof_fsync_err_subscribe_ordering, aof_multidb_kill9,
  aof_toplevel_multishard_refusal, cold_shadow_overwrite_resurrection,
  cold_tier_observability, crash_matrix_per_shard_bgrewriteaof,
  crash_recovery_cold_del_resurrection, crash_recovery_disk_offload_no_aof,
  crash_recovery_orphan_sweep_readiness, instance_lock, jepsen_lite,
  wal_group_commit

That completes the set: 26 of 26 suites that spawn a real moon now reap it from
`Drop`, on the unwind path as well as the success path.

Seven file-local `fn sigkill(child: &mut Child)` copies are now dead —
`ServerGuard::kill_now` supersedes them — and are deleted along with the
`use std::process::Child` imports they were the last user of.
`aof_fsync_err_subscribe_ordering` was already reaping correctly via
`catch_unwind`/`resume_unwind`; it is converted anyway so the pattern is
uniform, and its hand-rolled kill+wait pair collapses to `kill_now()`.

Verified against a freshly built binary pinned via MOON_BIN, all 27 suites
(26 converted + the contract suite): 44 passed / 0 failed in the normal run,
34 passed / 0 failed under `-- --ignored` (the crash and restart tests, which
is where an orphan would actually be produced). `pgrep -f target/release/moon`
is 0 before and 0 after both runs. `cargo check --all-targets` is clean, no
warnings.

Refs #713

author: Tin Dang
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.

1 participant