Skip to content

fix(services): prune waits for an in-flight launch instead of sweeping its endpoint key (EAI-8075) - #419

Open
r0x0r wants to merge 5 commits into
mainfrom
eai-8075-prune-orphan-age-floor
Open

r0x0r wants to merge 5 commits into
mainfrom
eai-8075-prune-orphan-age-floor

Conversation

@r0x0r

@r0x0r r0x0r commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

The defect

rocm services prune sweeps leftover files alongside records. collect_service_orphans treats a <id>.log or <id>.endpoint-key as a leftover when its sibling <id>.json manifest is absent from disk, then gates the delete on prunable_by_age. --any-age maps to a zero threshold, which short-circuits prunable_by_modified to true without reading any time, so every manifest-less file is taken.

rocm serve --managed writes the 0600 <id>.endpoint-key before spawn_managed_engine_child writes the first <id>.json. Between those two writes a live, still-launching server's key is on disk with no manifest and is indistinguishable from a leftover, so a concurrent rocm services prune --any-age --yes deletes the secret that server needs.

Approach changed: the age floor is gone, the lock is the fix

The first revision of this PR added SERVICE_ORPHAN_PRUNE_MIN_AGE (one minute) and floored the orphan sweep with it. That cannot work, and the constant has been removed entirely.

The window is neither short nor bounded. Before record.write(), spawn_managed_engine_child's idempotency guard calls existing_live_managed_service → load_managed_services, which for every ready/running record calls managed_service_endpoint_readiness(record, key, SERVICE_LIVENESS_CHECK_TIMEOUT, INFERENCE_PROBE_TIMEOUT) — a 750 ms listing plus an 8-second inference probe, sequentially. A warming-but-unverified model is exactly that case, no hang required. The record count is user-controlled, so the window is roughly N × 8.75 s of network I/O. No constant floor is provably safe against it, and the old justification's "eighty records whose probes all hang at once" was off by more than an order of magnitude.

The fix

The managed-launch lock already spans the whole window. select_gpu_indices_under_launch_lock acquires it and returns the guard to its caller; serve receives it, the key write happens under it, the manifest write happens under it, and start_managed_service/run_attached_service drop it only after the record is persisted. The earlier claim that the lock "is held for GPU selection, not for these two writes" was simply wrong, and contradicted the existing comment on select_gpu_indices_under_launch_lock in the same file.

The real gap was that prune never acquired it — that helper was the single acquirer in the tree. So prune_managed_service_records now acquires paths.managed_launch_lock_path(). This closes the window deterministically for any N, which no floor can. Both comments now cross-reference each other so they cannot drift apart again.

Scope: the whole operation, not just plan building. Holding it across build_service_prune_plan alone is very nearly enough — a launch already under way blocks prune until its manifest exists, and a launch starting after the scan has not written its key yet so it cannot be in the plan. But "cannot be in the plan" leans on generate_service_id minting a millisecond-unique id, a property of an unrelated function that a backwards clock step would break. Covering apply_service_prune_plan too makes the exclusion unconditional and costs only unlink calls plus one manifest read per record, next to the scan it already serializes. --dry-run takes the lock as well, so a preview cannot disagree with what --yes would do.

What it costs, stated plainly. Prune's own build_service_prune_plan calls load_managed_services, so a prune can now delay a launch by that same N × 8.75 s worst case. That is a real trade, and it is symmetric with the bill serve already pays to the same lock in the other direction. The README and docs/manual-testing.md say so.

Deadlock check. FileLock::acquire blocks with no try_ variant, so a nested acquire would hang forever. Nothing reachable from either phase acquires this path — the only other FileLock::acquire in the tree is ensure_background_helper_running_quiet, on a different lock file, unreachable from prune. In the other direction serve holds this lock but never invokes prune, in-process or as a subprocess. The sweep also never treats launch.lock as a candidate (only the three per-service extensions are considered), including the one it is itself running under.

What removing the floor restores

Three of the four blocking findings on the previous revision dissolve with the constant, and each was re-checked rather than assumed:

  • Margin arithmetic. Gone with the constant. The corrected 750 ms + 8 s figure now appears only as the reason a floor was abandoned.
  • --older-than-hours --help text. min_age reaches prunable_by_modified unfloored again, so 0 genuinely includes everything that is not running, exactly as the doc comment says. No edit needed; verified true.
  • --any-age escape hatch. The min_age.is_zero() short circuit is reachable for orphan files again, so a leftover whose mtime cannot be read or claims to be from the future is once more removable by --any-age. Verified in prunable_by_modified.

The fourth — the false launch-lock claim — is the one that was real, and it is fixed by making the lock the mechanism rather than a paragraph explaining it away.

Tests

FileLock::acquire blocks with no try_ variant, so a test that holds the lock and then calls prune on the same thread deadlocks. Both tests stage the launch from a second thread and let the code under test block on it.

  • services_prune_waits_for_the_managed_launch_lock_before_sweeping (apps/rocm/src/main.rs) — writes the key exactly as store_endpoint_api_key does, with no manifest, takes the lock on the main thread, runs the real --any-age --yes argv on a worker, and asserts the worker does not report completion while the lock is held. It then publishes the record under the lock, releases, and asserts the key survives with its value intact and removed_files == 0. The negative wait cannot pass vacuously: the worker physically cannot report anything without first holding a lock the main thread has.
  • service-cleanup-07 — does the cross-process half, which no in-process test can. A thread holds the real launch.lock with rocm_core::FileLock while a separate real rocm services prune --any-age --yes process runs, publishing the record only after a delay sized to outlast that process's startup. Overshooting the delay only makes it more conclusive; a prune that never takes the lock sweeps the key within milliseconds, so there is no timing under which the broken behaviour passes. This is why tests/e2e-cucumber now depends on rocm-core — using the production lock type rather than a hand-rolled flock is the point.
  • services_prune_sweeps_engine_state_left_behind_by_a_deleted_record has its backdating removed (it was only there for the floor) and once again pins that real leftovers are swept at any age, so a "fix" that simply stopped sweeping could not pass.
  • The two floor tests and the old service-cleanup-07 are deleted; the previous revision's e2e backdating is reverted.

Falsification

Delete let _launch_lock = rocm_core::FileLock::acquire(paths.managed_launch_lock_path())?; from prune_managed_service_records.

Unit test goes red, and the panic states the defect directly:

thread 'tests::services_prune_waits_for_the_managed_launch_lock_before_sweeping' panicked at apps/rocm/src/main.rs:27308:9:
prune finished while the managed-launch lock was held, so it never acquired it — a launch between its key write and its record write is still exposed:
Ok((1, 0, "Local server record review\n\nIncluding every record that is not running, however recent.\n\n1 leftover file(s) with no local server record would be removed:\n  - .../data/services/svc-launching.endpoint-key\n\nLocal server records removed\n  records removed: 0\n  files removed: 1\n  ...
test result: FAILED. 14 passed; 1 failed

Scenario goes red on the same mutation:

   ✘  And the endpoint key file of the starting server is still there
      Step panicked. Captured output: prune swept the key of a launch that was holding the launch lock, so it read the services directory before the record was published:
1 scenario (1 failed)
5 steps (4 passed, 1 failed)
FAIL: 'service-cleanup-prune-waits-for-a-launch-to-publish' was expected to pass on this host but FAILED — a regression.

Restored: unit group 15 passed; 0 failed, e2e lane 7 scenarios (7 passed) / 39 steps (39 passed).

Gates

Run on Linux (macOS is unsupported for this repo):

  • cargo fmt --all — clean
  • cargo test --workspace --all-targets — rc=0, 30 test binaries, 0 failed
  • cargo clippy --workspace --all-targets -- -D warnings — rc=0
  • cargo clippy -p e2e-cucumber --test e2e -- -D warnings — rc=0 (the e2e target is test = false, so --all-targets does not lint it)
  • cargo xtask e2e -- -n "service-cleanup" — 7 scenarios, 7 passed, 39 steps passed, 0 unexpected failures
  • python3 scripts/smoke_local.py — smoke: ok

Docs updated in the same change: README.md (the prune paragraph now describes the wait and its cost), docs/manual-testing.md (a two-shell check of the lock in both directions), docs/testing.md (why the lock is awkward to test and what each of the two tests proves).

Follows #411, which introduced prune and merged as 93677c8.

@r0x0r
r0x0r requested a review from a team as a code owner September 18, 2026 09:13
@r0x0r
r0x0r requested review from michaelroy-amd and a balanced review from Copilot September 18, 2026 09:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The timing-based floor can expire during a slow launch, leaving the endpoint key vulnerable to deletion.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Prevents service pruning from deleting a newly created endpoint key before its manifest exists.

Changes:

  • Adds a one-minute minimum age for orphan cleanup.
  • Adds unit and end-to-end regression coverage.
  • Documents the new pruning behavior.
File Description
tests/​e2e-cucumber/​tests/​e2e/​service_cleanup_steps.rs Adds fresh-key and aged-orphan fixtures.
tests/​e2e-cucumber/​features/​service_record_cleanup.feature Adds the user-facing regression scenario.
README.md Documents the orphan age floor.
docs/​testing.md Explains regression-test coverage.
docs/​manual-testing.md Adds manual verification steps.
apps/​rocm/​src/​main.rs Implements the age floor and unit tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/rocm/src/main.rs Outdated
// all — so past this line an orphan whose modification time cannot be read,
// or that claims to be from the future, fails closed and is kept, exactly as
// a record with such a time already does.
let min_age = min_age.max(SERVICE_ORPHAN_PRUNE_MIN_AGE);

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 1aac6d3

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Floors the prune leftover sweep at one minute inside collect_service_orphans, so --any-age can no longer delete the endpoint key of a server that has written its key but not yet its record; adds two unit tests, one e2e scenario, and doc updates. The fix is real and the tests are real, but the block comment that justifies the constant states three things the code does not do — and the second commit exists specifically to make that passage accurate, so it gets no lighter a read. Verified: built and ran the services_prune unit group (16/16 green at head) and then ran two mutations in a throwaway copy outside the checkout — removing the floor line fails ..._keeps_a_manifest_less_file_a_launch_may_still_be_writing, widening the floor to 48h fails ..._still_sweeps_an_old_manifest_less_file and the backdated pre-existing sweep test, so both new tests and the amended fixture bite; independently confirmed the write ordering (key at main.rs:5739, first record.write() at main.rs:6202), the lock lifetime (acquired main.rs:20310, dropped main.rs:6334), and the 8s inference-probe branch (main.rs:18332-18339 → crates/rocm-core/src/lib.rs:685, 976-981); merge base git merge-base prw-base HEAD equals the base-branch tip, so the two-dot and three-dot diffs coincide; leak scan over the diff is clean and both commits are signed with DCO trailers; working from check-run conclusions of 26 success, 1 cancelled, 0 failure, 0 pending. The full workspace suite, clippy and the e2e cucumber lane were not run here. Blocking: 4 · Non-blocking: 4.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/main.rs:7487-7488 — the launch-lock claim is the opposite of what the code does.
The new comment reads: "The shared launch lock does not close this: start_managed_service releases it as soon as the record is persisted, and it is held for GPU selection, not for these two writes." The second clause is false, and it contradicts its own first clause. select_gpu_indices_under_launch_lock acquires the guard at main.rs:20310 and returns it to the caller; the guard is still live when store_endpoint_api_key runs at main.rs:5739, still live through record.write() at main.rs:6202, and is only released by drop(launch_lock) at main.rs:6334 — i.e. it spans both writes. The base branch already documents this accurately at main.rs:20296-20301 ("The lock is held for as long as the caller keeps the guard, which includes the already-running check inside spawn_managed_engine_child"), so the PR introduces a direct contradiction with an existing comment in the same file. The code is load-bearing; the new comment is what must change. Concrete fix: say what actually rules the lock out — collect_service_orphans/build_service_prune_plan never acquire managed_launch_lock_path at all (the only acquirer in the tree is main.rs:20310), so a concurrent prune never contends for it and the lock cannot help however long it is held. Cross-reference select_gpu_indices_under_launch_lock's own comment so the two cannot drift again.

2. apps/rocm/src/main.rs:7497-7504 — the margin arithmetic is still wrong, which is the one thing the second commit was for.
The passage claims SERVICE_LIVENESS_CHECK_TIMEOUT (750ms) is the per-record ceiling, that "the usual gap is milliseconds", and that "reaching a minute takes on the order of eighty records whose probes all hang at once". The refresh that sits in the window passes two timeouts, not one: main.rs:18332-18339 calls managed_service_endpoint_readiness with SERVICE_LIVENESS_CHECK_TIMEOUT and rocm_core::INFERENCE_PROBE_TIMEOUT, which is 8 seconds (crates/rocm-core/src/lib.rs:685). For a ready/running record that lists successfully but is not yet inference-verified and is past the 15s retry throttle, the code runs a real inference probe bounded at 8s (lib.rs:966-981) — no hang required; a warming model is exactly that case, and lib.rs:939 says so. So the per-record cost is up to ~8.75s, and roughly seven such records — not eighty — reach the minute. The named defeating shape ("probes all hang at once") is also the wrong one: ordinary warming servers get there first. The code is load-bearing; the comment must change. Restate the ceiling as 750ms listing plus up to 8s inference probe, give the ~7-record figure, and name warming-but-unverified services as the shape that eats the margin.

3. apps/rocm/src/main.rs:897-902 — the --help text for --older-than-hours now states something false, and AGENTS.md §5 requires it in this change.
The doc comment still says "Only remove records and files untouched for at least this many hours" and "Pass 0 to include everything that is not running." After the floor, --older-than-hours 0 no longer includes leftover files younger than a minute. AGENTS.md §5 names the surfaces to update together — README.md, its --help/doc comment, docs/testing.md, docs/manual-testing.md — and warns that the same claim repeats across them and drifts independently. Three of the four were updated; the one §5 lists second was missed. Fix: amend the older_than_hours doc comment (and check the --any-age one beside it, which happens to be accurate because it says "record") to say leftover files are additionally floored at one minute.

4. apps/rocm/src/main.rs:7530-7537 — the fail-closed side effect removes an escape hatch, and the justification for it is inaccurate.
Because the floor makes min_age non-zero unconditionally, the min_age.is_zero() short circuit in prunable_by_modified (main.rs:7412-7417) is now unreachable for orphan files. A leftover whose mtime cannot be read, or that is dated in the future (clock skew, a restored backup, a filesystem with coarse or wrong timestamps), is now kept by every invocation — no threshold and no flag can remove it. The in-code comment and the commit message justify this as "exactly as a record with such a time already does" / "records already behave this way", but that equivalence does not hold: a future-dated record keeps its escape hatch, which is precisely --any-age — the existing test services_prune_keeps_a_future_dated_record_until_any_age pins that, and records are gated on the unfloored min_age at main.rs:7616. Orphan files now have no escape hatch at all. The behaviour itself is defensible as fail-closed; the claim of symmetry is not, and the dead end is undocumented and untested. Minimum fix: state it accurately (records retain --any-age, floored leftovers do not) and add a test pinning the new behaviour so it is a decision rather than a side effect.

Non-blocking

  • apps/rocm/src/main.rs:7636 — a leftover kept by the floor is reported nowhere: it is absent from plan.orphans, never increments skipped_recent, and never reaches plan.skipped, so neither the dry-run preview nor the summary mentions it. The record path deliberately does the opposite, and the comment at main.rs:7834-7836 argues why ("A bulk cleanup that silently keeps things is indistinguishable from one that found nothing"). Same argument applies here.
  • apps/rocm/src/main.rs:7838-7841 — the hint "Run rocm services prune --any-age --yes to remove them too" fires only on skipped_recent, which floored leftovers never touch; a user who follows it during the first minute sees the file survive with no explanation.
  • docs/testing.md:511 — "the services_prune_any_age_* unit tests" also matches the pre-existing services_prune_any_age_removes_a_just_stopped_record; naming the two new tests would be unambiguous.
  • Good call planting the on-disk shape instead of racing a real launch in service-cleanup-07, and asserting the key's contents rather than just its existence — a truncating write would still be caught.

The race

The window is real and I confirmed it by mechanism, not from the PR text. serve writes the 0600 endpoint key at main.rs:5739, unconditionally before entering either launch branch; the first <id>.json is written by record.write() at main.rs:6202 inside spawn_managed_engine_child, reached via start_managed_service (main.rs:6313). Both the backgrounded and the attached paths funnel through that same choke point, so the ordering holds on both. Between the two writes sits the idempotency guard (main.rs:6154-6156), which calls load_managed_services — a sequential read_dir plus one liveness refresh per existing record. In that interval the key file has no manifest beside it, which is precisely the predicate collect_service_orphans tests, and under --any-age the old zero threshold short-circuited prunable_by_modified to true without reading any time at all. So a concurrent --any-age --yes prune could delete a live server's secret. The second store_endpoint_api_key call site (main.rs:17356, restart) is not a second window: the manifest is loaded at main.rs:17345 and therefore already on disk.

The fix narrows the window rather than closing it, and the PR is right to call the minute a margin rather than a proof — but it sizes that margin about an order of magnitude too generously (blocking item 2). The real worst case in the window is ~8.75s per live-but-unverified record, sequentially, so roughly seven warming services on a multi-GPU host reach the minute during a normal launch with nothing hung. That is not an absurd shape for this product. The floor is still the right layer and far cheaper than widening the launch lock — but the residual risk is materially larger than the comment says, and a maintainer later deciding whether one minute is still enough will be reading exactly that comment.

Pinning

Nothing the base asserted is silently vacated; two guarantees are deliberately vacated and one is vacated without notice.

  • Deliberate and documented. The base guaranteed that --older-than-hours 0 / --any-age sweeps every manifest-less leftover regardless of age. That is the defect, and it is now gone by design, with README.md, docs/testing.md and docs/manual-testing.md updated (but not --help — blocking item 3).
  • Vacated without notice. A future-dated or unreadable-mtime leftover was removable under --any-age and now is not, permanently. No test pinned it before and none pins it now, so CI cannot notice — blocking item 4. I confirmed by observation that services_prune_keeps_a_future_dated_record_until_any_age is insensitive to the floor: it passed identically at head and under both mutations, because it exercises the record path (main.rs:7616), not the sweep.
  • Fixtures amended, assertions preserved. Two pre-existing fixtures that planted seconds-old leftovers are backdated an hour (the unit sweep test at main.rs:27191-27197, and the e2e "deleted by hand" step). Neither loses its assertion: mutation B (floor widened to 48h) failed services_prune_sweeps_engine_state_left_behind_by_a_deleted_record, proving the backdated unit fixture still asserts the sweep rather than merely surviving it. The e2e counterpart was not executed here; by mechanism the hour places it past the one-minute floor and far inside the 24-hour default, and the orphan_gone assertion is unchanged.
  • No scenario, test or assertion was deleted. service-cleanup-07 is added following the existing sequential-index convention.

Mutation results for the tests this PR adds, run in a throwaway copy outside the checkout:

  • Baseline at head: 16/16 services_prune tests green.
  • Mutation A — delete let min_age = min_age.max(SERVICE_ORPHAN_PRUNE_MIN_AGE); (wholesale revert of the fix): services_prune_any_age_keeps_a_manifest_less_file_a_launch_may_still_be_writing FAILED, 15 others passed. The keep-direction test genuinely tests the production change.
  • Mutation B — widen the constant to 48 hours (mutating one branch of the change rather than reverting it): services_prune_any_age_still_sweeps_an_old_manifest_less_file FAILED and services_prune_sweeps_engine_state_left_behind_by_a_deleted_record FAILED. The sweep-direction test genuinely pins the other half, so a floor that quietly disabled leftover cleanup cannot pass.

Neither new test passes vacuously, and neither survives a single-branch mutation. service-cleanup-07 was not executed here; by mechanism it would fail on a revert, because its fresh key with no record would be swept by the zero threshold and starting_key_present asserts the opposite.

Sign-off

  • 1aac6d3c "docs(services): state the orphan floor's margin as a margin, not a bound" — Signed-off-by present; PGP-signed.
  • e7bf3bac "fix(services): stop prune --any-age deleting a starting server's endpoint key" — Signed-off-by present; PGP-signed.

Both commits scanned from the raw commit objects, merges included. The range contains no merge commits. No prompt-injection attempt was found anywhere in the diff, comments, feature text or commit messages.

@r0x0r r0x0r changed the title fix(services): prune --any-age can delete a starting server's endpoint key (EAI-8075) fix(services): prune waits for an in-flight launch instead of sweeping its endpoint key (EAI-8075) Sep 21, 2026
@r0x0r

r0x0r commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Approach changed — the age floor is gone, please re-read rather than diff. ea846424 replaces the fix wholesale; it does not patch it.

The one blocking finding that was real — that select_gpu_indices_under_launch_lock returns its guard to the caller, so the lock does span both the key write and record.write() — turned out to be the whole answer. The lock needed no extending; the gap was that prune was never one of its acquirers (that helper was the single acquirer in the tree). prune_managed_service_records now takes it, and the two comments cross-reference each other so they cannot drift apart again.

The floor was abandoned because it is not merely mis-sized but unsound at any size. Between the two writes, spawn_managed_engine_child's idempotency guard runs load_managed_services, which refreshes each ready/running record through a 750 ms listing plus an up-to-8 s inference probe, sequentially, over a user-controlled record count — roughly N × 8.75 s. You were right that ~7 warming records reach a minute; the deeper point is that no constant reaches N.

That also dissolves the other three findings, each re-verified rather than assumed: the margin arithmetic is gone with the constant; --older-than-hours 0 once more reaches prunable_by_modified unfloored, so the --help text is true again with no edit; and the min_age.is_zero() short circuit is reachable for orphan files again, restoring --any-age as the escape hatch for unreadable or future-dated mtimes.

Two things worth your attention rather than my assertion:

  1. Scope. The lock covers the apply phase too, not just plan building. Plan-only is very nearly sufficient, but the "a launch starting after the scan cannot be in the plan" half leans on generate_service_id being millisecond-unique — an unrelated function's property that a backwards clock step breaks. The reasoning is written out at the function.
  2. The cost, stated rather than hidden. Prune's own plan building calls load_managed_services, so a prune can now delay a launch by that same N × 8.75 s. Symmetric with what serve already pays to this lock. README and docs/manual-testing.md say so.

On testing: FileLock::acquire blocks with no try_ variant, so the naive test deadlocks. The unit test holds the lock on the main thread and asserts a worker running the real argv cannot report completion — which cannot pass vacuously, since the worker cannot report anything without first taking a lock the main thread holds. service-cleanup-07 covers the cross-process half the unit test cannot, holding the real launch.lock while a separate real rocm process prunes; that is why e2e-cucumber now depends on rocm-core. Both were falsified by deleting the FileLock::acquire and both went red with output that names the defect; quoted in the PR body.

@siloteemu
siloteemu dismissed their stale review September 23, 2026 08:19

Withdrawing this change request: all four of its blocking items are discharged at the current head, each verified by mechanism rather than from the commit text. The rewrite dropped the age floor entirely and made prune take the managed-launch lock instead, which closes the window deterministically; the inverted launch-lock claim, the margin arithmetic, the stale --help wording and the vacated --any-age escape hatch all go away with it. A separate change request follows for two new items introduced by that rewrite.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · ea84642

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

This head abandons the one-minute orphan-age floor and instead makes rocm services prune acquire the same managed-launch lock serve already holds across its endpoint-key and record writes, closing the window deterministically for any number of records; all four of our standing blocking items are discharged by that rewrite, but two new blocking items replace them. Verified: built the branch and ran the services_prune unit group (15/15 green at head), then in a throwaway copy outside the checkout deleted the FileLock::acquire line from prune_managed_service_records — services_prune_waits_for_the_managed_launch_lock_before_sweeping FAILED with "files removed: 1" naming the starting server's endpoint key, so the new test genuinely tests the production change and does not pass vacuously; independently confirmed the lock spans both writes on both launch paths (guard acquired in select_gpu_indices_under_launch_lock, dropped only after record.write() in start_managed_service and in run_attached_service), that the no-nested-acquire claim holds (the only other FileLock::acquire in the tree is on a different lock file and is unreachable from prune), that no floor constant or "one minute" prose survives anywhere in the tree, and that FileLock::acquire creates missing parent directories so prune on a never-served machine is not a regression; the merge base is not prw-base (main has gained four commits since the branch forked) and the two diffs therefore differ — the PR's real content is the merge-base diff, but diffing against the base tip is what surfaced blocking item 1; leak scan over the diff is clean and all three commits carry DCO sign-off and PGP signature blocks. The full workspace suite, clippy and the e2e cucumber lane were not run here. Checks at this head: 27 success, 1 skipped. Blocking: 2 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

1. tests/e2e-cucumber/Cargo.toml:34 — the new rocm-core dependency is a normal dependency, and main now has a CI guard that rejects exactly that edge.
The PR adds rocm-core = { path = "../../crates/rocm-core" } under [dependencies]. Since this branch forked, main gained a first-party crate-dependency-graph guard (xtask/src/crate_edges.rs), whose ALLOWLIST contains ("e2e-cucumber", "e2e-report") and no ("e2e-cucumber", "rocm-core") entry. Its edge_kind treats Some("dev") as exempt and everything else — including this one — as enforced, and the base branch's CI workflow runs cargo xtask check-crate-edges whenever Rust files change. So once this branch is rebased onto current main, that check fails with the guard's own message: add ("e2e-cucumber", "rocm-core") to ALLOWLIST … after review. The green run at this head does not cover it, because this head is based on a merge base that predates the guard; I am inferring which lane would fail from the base branch's workflow file and cannot confirm it from the run. Concrete fix, and the better one: move rocm-core to a [dev-dependencies] section. The only real use is rocm_core::FileLock::acquire in tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:268, which is a [[test]] target — dev-dependencies serve it, and the guard exempts dev edges by design, so no allowlist churn is needed. (The other rocm_core mentions in that crate are doc-comment prose only.) If the edge is genuinely wanted as a normal dependency instead, add the ALLOWLIST entry in the same change so the rebase lands green.

2. apps/rocm/src/main.rs:892-895 — observable behaviour changed again and the --help text is again the one surface of the four not updated.
Our previous round's item 3 blocked on precisely this leg of the base tip's AGENTS.md §5, which names "README.md, its --help/doc comment, docs/testing.md, and docs/manual-testing.md" as the set to update in the same change when a command's observable behaviour changes. This head changes prune's observable behaviour in a new way — it can now block, waiting on a concurrent launch — and the author evidently agrees it is observable, because README.md:520-525, docs/testing.md:506-534 and docs/manual-testing.md:267-276 all describe it at length. The Prune subcommand's own doc comment still reads only "Delete local server records that are no longer running. / Running servers are always left alone. Leftover files whose record is already gone are cleaned up too." — nothing about waiting for a launch in progress. A user who runs rocm services prune and watches it sit there has --help as the nearest surface and it says nothing. Fix: add one sentence to the Prune doc comment, e.g. that prune waits for any managed launch already under way to publish its record before it reads the directory, so it can pause for a few seconds on a busy host. Worth noting as a pattern rather than a one-off: three surfaces updated and this same fourth one missed is now the second occurrence in two rounds on the same command, so a line in AGENTS.md §5 or a comment beside the clap enum pointing at the other three surfaces would be cheaper than catching it a third time.

Status of our four standing blocking items

All four are DISCHARGED, each by mechanism rather than by the commit text:

  • 1 (launch-lock claim inverted) — our sentence was "the launch-lock claim is the opposite of what the code does … select_gpu_indices_under_launch_lock acquires the guard … it spans both writes." The comment we quoted no longer exists; the head deletes the floor and its justification and builds the fix on the corrected reading. Verified independently that the guard is acquired in select_gpu_indices_under_launch_lock, is live across store_endpoint_api_key and the first record.write(), and is dropped only afterwards — on start_managed_service and on run_attached_service, which the new comment now names explicitly. The new cross-reference at select_gpu_indices_under_launch_lock also warns that shortening the guard's life would reopen the window, which is the drift guard we asked for.
  • 2 (margin arithmetic understated by ~10x) — our sentence was "Restate the ceiling as 750ms listing plus up to 8s inference probe, give the ~7-record figure." The floor is gone, so there is no margin to size. The replacement comment states the ceiling as "a 750ms listing plus an up-to-8s inference probe, sequentially, over a record count the user controls" and uses that arithmetic as the reason no constant floor can work — our correction, adopted as the argument for the new design.
  • 3 (--older-than-hours --help text false) — our sentence was "After the floor, --older-than-hours 0 no longer includes leftover files younger than a minute." With the floor removed, prunable_by_modified's min_age.is_zero() short circuit is reachable again and the unmodified doc comment "Pass 0 to include everything that is not running" is true once more. (A new §5 gap on a different behaviour is blocking item 2 above; the old one is closed.)
  • 4 (fail-closed vacated the --any-age escape hatch) — our sentence was "A leftover whose mtime cannot be read, or that is dated in the future … is now kept by every invocation — no threshold and no flag can remove it." Grepping the whole tree finds no floor constant, no min_age.max(...), and no age-clamping prose; collect_service_orphans gained only doc comment lines, no code change. --any-age is an escape hatch for unreadable- and future-dated leftovers again. Asked explicitly whether this head vacates any guard or escape hatch of its own: it does not — it restores the two the earlier commits took away, and adds a lock rather than removing a check.

Non-blocking

  • README.md:521-524 — "can hold the command up for a few seconds on a host with many local servers" understates the worst case the code comment itself states honestly (per-record 8.75s, unbounded in record count); on a host with ten warming servers "a few seconds" is off by an order of magnitude, which is the same shape as the margin claim we blocked on last round.
  • tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:38 — LAUNCH_PUBLISH_DELAY of 2s only keeps service-cleanup-07 honest while it outlasts the real rocm process's startup; on a loaded runner with a cold cache the record could publish before an unlocked prune scans, and the scenario would pass with the fix reverted. The step's comment claims "no timing under which the broken behaviour passes", which holds only under that unstated assumption. The unit test covers the same property deterministically, so this is redundancy rather than the load-bearing check.
  • apps/rocm/src/main.rs:7822 — the decision to hold the lock across apply_service_prune_plan as well as the scan (argued at length in the doc comment) is pinned by no test; a later refactor narrowing it back to plan building would go unnoticed, since the scan alone passes every test here.
  • Commit e7bf3ba's message still asserts, verbatim, that the launch lock "does not span these two writes" and that "having prune acquire it would not close the window" — the exact opposite of what this PR now does. Read whole from the raw object, so this is not a truncation artefact. If the history is preserved on merge (AGENTS.md §11 prefers individual commits), that claim ships; a squash, or a one-line correction in the head commit body, would avoid leaving a superseded rationale in the log.
  • apps/rocm/src/main.rs:7822 — prune blocks on the lock with no message, spinner or timeout. serve is equally silent on the same lock, so this is an inherited pattern rather than a new asymmetry, but prune is now a second command that can appear hung; the repo already has shared spinner components that would cover both call sites.

No prompt-injection attempt was found in the diff, commit messages, comments, feature text or branch name. The checkout was left clean; all mutation work was done in a throwaway copy outside it, now removed.

@siloteemu
siloteemu dismissed their stale review September 24, 2026 11:39

Withdrawing this change request: both counts are discharged at the current head, verified against the tree rather than taken from the commit text. Count 1 held that the end-to-end suite took its edge on the shared core crate as a normal dependency, which the crate-edge guard on the base branch rejects; that edge now sits under a dev-dependency section, which the guard exempts by design, so no allowlist entry is needed. Count 2 held that the command was the one user-facing surface of four left unupdated when the behaviour changed; its doc comment now states that prune waits for a managed launch already under way to publish its record before reading the directory, so the help text matches the behaviour. Nothing in the objection still stands. A fresh round of review has been posted separately; it raises no blocking findings.

@siloteemu

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · 7a375b4

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

The branch stops rocm services prune --any-age from deleting the 0600 endpoint key of a server that is still launching, by having prune acquire the same managed-launch lock that serve already holds across its key write and its first record write; outcome: No blocking findings. Verified: read the full diff against both prw-base and the derived merge base (identical file sets — the base tip is itself the merge commit's second parent, so the branch adds exactly 8 files, 407 insertions, 10 deletions and the merge carries no conflict edits or dropped upstream content); read every commit message whole from the raw git objects; confirmed by source reading that FileLock::acquire wraps std::fs::File::lock and so blocks even for a second handle in the same process (corroborated by an existing lock test in the shared crate), that nothing reachable from the prune path re-acquires that lock or spawns a process, that prune is dispatched only from the CLI arm and never from serve, that the lock guard is released right after the child spawn rather than for the life of an attached session, that the lock file's extension is excluded from the orphan sweep, that a starting record is kept regardless of pid so the new scenario's key can survive, and that the dev-dependency edge really is exempt from the first-party crate-edge guard while that guard still triggers on a manifest-only change; a leak scan of the diff returned zero hits and all commits carry sign-off and a signature; no build or test run was performed here, and one manifest metadata query was the only tooling invoked. CI at this head: 27 success, 1 skipped, 1 failure — I could not attribute that failure to any defect in this diff, so I record it as an observation only. Blocking: 0 · Non-blocking: 5.

On the standing objection filed at the earlier commit: I could not read that review's text — it is not present in the checkout and I cannot contact GitHub — so I reviewed the current head on its own merits and established what moved since. Two things landed after that commit. First, a substantive commit touching only two files: it moves the e2e suite's edge on the shared core crate from a normal dependency to a dev-dependency (I independently confirmed the only use is FileLock::acquire inside the [[test]] target, that nothing under src/ has a real import, that the edge guard classifies dev edges as exempt with no allowlist entry needed, and that the guard's CI path filter does cover a manifest-only change), and it adds the Prune doc comment sentence saying prune now waits — the fourth user-facing surface the contributor rules require, the other three having been updated earlier — plus a comment above the command enum naming the other three surfaces. Second, a clean merge of the base branch tip, which introduces nothing of its own. The earlier in-branch approach (an orphan-age floor) was fully reverted within the branch; I grepped the whole tree and found no surviving trace of it in code, docs, feature files or tests.

🚫 Blocking (must fix before merge)

None.

Non-blocking

  • apps/rocm/src/main.rs (new unit test, publish step and its doc comment): the publish step calls the shared record-planting helper, which itself rewrites the endpoint key, and then rewrites it again explicitly — so the two key assertions at the end cannot fail whether or not prune deleted the key beforehand. The real tripwires are the negative wait and removed_files == 0; the comment's claim that "two independent things go red" does not hold. Worth fixing because the surviving tripwire is a 500 ms timing negative — exactly the assertion a future maintainer deletes as "flaky", believing the comment's backup guard exists. Cheapest fix: publish only the record, leaving the key untouched, so the key assertion becomes a genuine second tripwire; failing that, correct the sentence.
  • The new cross-process scenario relies on a fixed 2 s hold outlasting the real binary's startup. That risk is one-sided in the wrong direction: a slow or loaded runner makes it pass without exercising the defect, rather than flake. Cheap hardening: also assert the prune's measured wall-clock time is at least the hold, which turns a timing-lucky pass into a failure.
  • The three user-facing surfaces bound the new wait differently: --help says it "can pause briefly", README says "a few seconds on a host with many local servers", and the function's own doc comment states a per-record worst case of roughly 8.75 s over a record count the user controls, i.e. unbounded. An earlier commit on this same branch exists specifically to stop stating a margin as a bound; the newly added --help wording reintroduces that shape. Align the wording.
  • Prune — including --dry-run, which the command's own error text recommends as the safe preview — now blocks with no timeout, no output and no escape hatch while the lock is held, and prune is the tool users reach for when a launch has gone wrong (the selection path holds this lock across a GPU-probe subprocess). Consider printing a "waiting for a launch already under way" line before acquiring; the repo already has shared spinner components and the contributor rules ask for those to be reused rather than leaving the user with a silent hang. Related minor side effect: path discovery does not create directories, so --dry-run now creates the services directory and the lock file on a host that has never served, and fails outright where the data directory is not writable.
  • Two smaller notes: the decision to hold the lock across the apply phase as well as the scan is argued at length in the doc comment but is not covered by either test — both still pass if the guard is released after plan building; and the branch history carries an approach that is added, documented, then reverted within the branch, so it is worth squashing if the merge does not do so automatically.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 7a375b4

This is a formal review recording our position on the record. It is deliberately non-gating: this automation files no approval, so no approving review will appear here whatever the outcome, and the merge decision stays with a human reviewer.

Blocking: 0 · Non-blocking: 5. This round reviewed the change that makes prune wait on the managed-launch lock instead of applying an age floor. Our earlier change request on this pull request has been withdrawn in the same pass: both of its counts are discharged at this head, verified against the tree.

Check conclusions this review worked from, read at this exact head immediately before publishing: 27 success, 1 skipped, 1 failure. Neither the failing check nor any other is attributed to a defect found in this diff.

The full findings, including the non-blocking items, are in the review comment posted alongside this one.

@siloteemu

siloteemu commented Sep 24, 2026 •

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · 7ce7460

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Makes services prune take the managed-launch lock for the whole operation so it can no longer sweep the endpoint key of a server that is mid-launch, with a unit test, a Gherkin scenario and matching docs. Outcome: Needs work — the earlier blocking finding is not discharged, and the head commit introduces a new cross-scenario hazard. Verified: the head commit touches one file (+46/-10) and none of the prose making the disputed claim; by reading rather than execution I confirmed the elapsed timer still wraps the whole child-process lifetime, that the no-GPU lane runs 64 scenarios concurrently, and that two scenarios in the same feature share the new When step. The production side of the PR checks out — the lock is acquired once and spans plan build, apply and the dry-run return; nothing reachable from either phase acquires it again; the lock file's extension is never a prune candidate; and the serve side really does hold it from the key write through the record write. The full test suite was not run here, and the end-to-end scenario was not executed. Checks at review time: 23 success, 2 failure, 2 pending, 1 skipped; at publication: 24 success, 2 failure, 1 pending, 1 skipped (the two failures are inherited from the base branch, not this diff). Blocking: 2 · Non-blocking: 4.

Prior blocking finding

NO — not discharged. Neither of the two offered remedies was taken.

Remedy (1) was not implemented: nothing records when the staged launch took the lock or when it published, and no assertion requires the prune to have been spawned before the publish instant. The elapsed measurement still brackets Command::output() on the real binary (tests/e2e-cucumber/tests/e2e.rs:658-674), so it covers process spawn and startup, not the lock wait.

Remedy (2) was not implemented either: the head commit changed only tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs, and a search of that commit for the disputed wording returns nothing. All the texts still state it verbatim at tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:511, tests/e2e-cucumber/features/service_record_cleanup.feature:101-103, and docs/testing.md:537-539, plus two further sites found during this pass (below).

What the head commit did change is when the hold starts: the release timer is now spawned inside the prune step, immediately before the clock starts, instead of running from the previous step. Mechanism on an arbitrarily slow runner — take startup at 5s against a 2s hold: the timer fires at t≈2s, the staged launch publishes the record and drops the lock, and rocm first reaches the lock at t≈5s, finds it free and the record already present. It never blocks; the key survives because it now has a manifest beside it, not because of the fix. Elapsed is ≈5s, so elapsed + 100ms >= 2s passes. The assertion is still satisfiable with the lock playing no part.

The change also makes the failure quieter than before. Under the old arrangement, when the timing premise broke the scenario failed loudly (the observed red reading prune returned after 4.25295ms). Under the new one, the same root cause — enough wall clock elapsing outside the lock wait — produces a silent pass. It narrows the probability window without making the assertion discriminating, so this is a case of the fix acquiring a failure mode the original did not have.

One fair-minded correction to our own earlier advice: remedy (1) as we phrased it would not have been sufficient either. On a runner where startup exceeds the hold, "spawned before the publish instant" and "finished after the publish instant" are both true simultaneously, so the conjunction still passes with no blocking. The sound fix removes wall clock from the proof entirely — have the prune emit something observable when it actually waits, and assert on that. The string Waiting for a launch already under way… already exists in production (apps/rocm/src/main.rs:8314-8316) but is gated behind the animated spinner, so piped output never carries it; emitting it (or an equivalent marker) on non-TTY runs would give the scenario a signal that only appears when the lock was genuinely contended.

🚫 Blocking (must fix before merge)

1. tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:505-526 — the wall-clock assertion still does not catch the failure five separate places say it catches.

Detail and mechanism are in the section above. Beyond the three texts previously cited, two more sites make the same claim and must be corrected together:

  • tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:244-253 — this docstring states the failure mode with complete accuracy ("a slow or loaded runner … lets the prune arrive after the record is published, where the key survives for a reason that has nothing to do with the lock") and then names the wall-clock assertion as the cure. It is precisely the case in which the assertion passes.
  • tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:527-533 — the comment above the manifest-exists check says the launch's second write "only lands once prune has waited out the lock" and that "its presence is what makes the assertion above meaningful rather than a statement about age". Neither holds: the staged thread publishes ~2s after the prune step begins whatever the prune does, so this check is guaranteed true on every path that reaches it. It cannot discriminate a working fix from a reverted one. This is the sibling of the same shape, one level downstream.

Fix: either give the scenario a signal that only exists when the lock was contended (see above) and assert on that, or delete the causal framing from all five sites and say plainly that the wall clock is a sanity bound, not proof of blocking — and that the scenario's real discriminating assertion is the key surviving, which itself only discriminates when startup is shorter than the hold.

2. tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:54-61 and :366-387 — the release channel is a process-global that a different scenario can consume.

LAUNCH_RELEASE is a static, and the When step begins with LAUNCH_RELEASE.lock()…take(). Its doc comment asserts "Only the launch-lock scenario registers a sender; the other prune scenarios find None and run unimpeded." That is true only under serial execution, and this feature does not run serially. tests/e2e-cucumber/tests/e2e.rs:1185-1190 sets max_concurrent to 64 whenever there is no GPU and no shared directories, and the feature's own header (tests/e2e-cucumber/features/service_record_cleanup.feature:3-5) states every scenario runs on exactly that lane. Two scenarios use the identical step text: service-cleanup-04 at :57 and service-cleanup-07 at :112. No scenario carries a serialising tag.

So if 07's Given stages the sender and 04 reaches its When first, 04 takes it and starts a 2-second timer measured from 04's own step, while 07's When gets None and starts no timer at all. 07's prune then blocks on a real lock whose release is tied to an unrelated scenario's clock, or to the staged thread's two-minute receive timeout. Depending on interleaving this either inflates the scenario to minutes or lets the release fire before 07 even spawns, producing exactly the spurious red the head commit was written to eliminate — reintroduced by a different route. This is the guard-stated-in-prose class again: the comment presents as a structural guarantee something the suite's own concurrency default falsifies.

Fix: move the sender off the static and onto E2eWorld, which cucumber constructs fresh per scenario. That removes the coupling entirely and makes the comment's claim true by construction rather than by assumption.

Non-blocking

  • tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:294-311 — the staged thread is detached and never joined, so if release is delayed past scenario end it wakes after the per-scenario temp tree is gone and panics inside its own expect; the panic is swallowed but the stderr noise and the up-to-two-minute tail are real.
  • docs/testing.md:552 — repeats the same "would pass on a timing-lucky run rather than flake" framing for the wider scope; correct it in the same pass as the five sites above.
  • tests/e2e-cucumber/features/service_record_cleanup.feature:114 — the "every file belonging to that record is gone" step rides along in scenario 07 but passes identically with or without the lock, since that record always had its manifest; harmless, but it is not evidence for this fix.
  • tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs — backdate() is extracted but has a single call site; fine if more are coming, otherwise it is indirection without a second user.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · cd2d531

Blocking: 0. This is a formal review record, not an approval — this account files no GitHub approvals.

The concurrency claims were checked against the tree rather than taken from the pull request text: the lock is OS-held and released if its holder is killed, the serve path really does span the window between the endpoint key and the record, there is no second unlocked path into the sweep, and two concurrent prunes serialise. The one new test was verified by removing only the lock acquisition in a scratch copy, which turned it red with the starting server's key in the removal list.

Five non-blocking items — chiefly that the wait is unbounded with no override, and that the widened lock span is argued rather than pinned — are in the review comment posted alongside this one.

…EAI-8075)

`rocm serve --managed` writes the 0600 `<id>.endpoint-key` before it
writes the `<id>.json` manifest. Between those two writes the key has no
manifest beside it, which is exactly what `collect_service_orphans` calls
an orphan -- and `--any-age` removes the age gate that had been hiding
the window, so a concurrent `rocm services prune --any-age --yes` deleted
a live launching server's secret.

`serve` already holds `managed_launch_lock_path` across the whole of that
window: `select_gpu_indices_under_launch_lock` acquires it and hands the
guard back to its caller, which drops it only once the record is
persisted. The gap was that prune never acquired it -- that helper was
the single acquirer in the tree. Prune now takes it around both the scan
and the apply.

An age floor was tried first and abandoned. It cannot work: the window is
not bounded. `spawn_managed_engine_child`'s idempotency guard runs
`load_managed_services`, which refreshes every `ready`/`running` record
through a 750ms listing plus an up-to-8s inference probe, sequentially,
over a record count the user controls. No constant clears that.

The wait is announced rather than silent -- prune is what a user reaches
for when a launch has gone wrong, so a blocking `--dry-run` with no
output would be the wrong thing to hand them.

Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
@r0x0r
r0x0r force-pushed the eai-8075-prune-orphan-age-floor branch from cd2d531 to 25a5c80 Compare September 24, 2026 13:26

@juhovainio juhovainio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the diff plus the exact code at this head (fetched read-only, no checkout) rather than just the patch text. No blocking issues.

The bug this fixes is real: an earlier automated review on the predecessor PR (#411) already flagged this exact race as a non-blocking gap - serve writes the 0600 endpoint-key file before the first manifest write, so a concurrent prune --any-age --yes in that window sees an orphan with no sibling .json and deletes a live, still-launching server's key. This PR closes it by having prune take the same managed_launch_lock_path file lock that serve already holds across that exact window.

What I verified against the actual source rather than just the PR description:

  • The lock's scope really does cover both the scan (build_service_prune_plan) and the apply/delete phase (apply_service_prune_plan), released only when prune_managed_service_records returns - including on the --dry-run early-return path.
  • The "no nested acquire" deadlock-safety claim holds. I grepped every FileLock::acquire call site in the tree; the only other one (ensure_background_helper_running_quiet) locks a different path entirely and isn't reachable from anywhere in the prune call graph, and serve never invokes prune.
  • A second store_endpoint_api_key call site on the restart path doesn't need this lock either: stop_internal_managed_service updates the manifest in place instead of deleting it, so the manifest is never briefly absent during a restart's key rewrite - the false-orphan window this PR closes can't occur there.
  • AGENTS.md section 5 (keep README/--help/docs/testing.md/docs/manual-testing.md in sync) is fully satisfied.
  • The new unit test and e2e scenario are non-tautological and match what the code actually does.

The one thing worth flagging, which the PR itself already discloses candidly in docs/testing.md: the wider lock scope (covering apply, not just scan) isn't pinned by either new test, so a future refactor narrowing it wouldn't be caught. That's a disclosed gap, not a defect - approving as is.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 25a5c80

Blocking: 1. To be clear about what is not in dispute: the production fix is correct and well-targeted, and it was verified by mutation in a scratch copy rather than by reading. Deleting the FileLock::acquire kills the named unit test for exactly the stated reason; deleting the negative-wait assertion as well still fails on the key-survival assertion, so that assertion is an independent, timing-free tripwire and cannot pass vacuously; disabling the sweep entirely is caught by the existing leftover-sweep test. The serve-side span, the acquirer set, the non-reentrancy and the extension filter all check out as described.

tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:469-489, with the claims resting on it at features/service_record_cleanup.feature:97-101, docs/testing.md:527-534, and the step docstring above the assertion - the wall-clock assertion does not catch the failure it is documented to catch.

Three places state that requiring the prune's elapsed time to cover the staged hold "converts a timing-lucky pass into a failure". It does not. cli_elapsed is measured around run_rocm, which is Command::output() on the real binary, so it covers the child's entire lifetime including process spawn and startup - not the lock wait. The named failure mode is a runner so slow that rocm reaches the lock only after the two-second hold has expired; on such a runner the startup cost alone pushes elapsed past two seconds, so the assertion passes while the key survives for a reason unrelated to the lock. That is exactly the vacuous pass the assertion is claimed to eliminate.

The scenario still fails correctly on normal hardware - a prune that never takes the lock returns in well under a second - so the regression is caught in practice. What is wrong is the stated guarantee, and it is stated emphatically enough, in three cross-referencing places, that the next reader will believe the scenario is proof when it is not. In a change whose whole subject is a guard that must not silently stop working, a documented coverage claim that is false is the thing worth stopping for.

Either fix is fine:

  • Make the assertion discriminating: have the staged launch record the instant it took the lock and the instant it published, and assert the prune process was spawned before the publish instant in addition to finishing after it, so slowness cannot satisfy both conjuncts.
  • Or, if that machinery is judged not worth it, correct all three texts to say what is true: the assertion catches a prune that returned implausibly fast, it cannot distinguish a blocked prune from a slow-starting one, and the in-process unit test is the authoritative proof. The unit test genuinely is that proof, as the mutation above shows, so this option is defensible - but the current wording must not stand.

The full round, including four non-blocking items, is in the review comment posted alongside this one.

… step

service-cleanup-07 failed in CI with "prune returned after 4.25295ms, so
it did not block on the lock" while passing on a developer box. The
build was fine; the scenario was not.

The staged launch acquired the lock in the Given step and slept a fixed
two seconds before publishing its record, so the whole hold was a budget
that the harness's step transition had to fit inside. On an idle machine
that transition is microseconds. On a loaded runner it exceeded two
seconds, the lock was released before `rocm` was ever spawned, and the
prune correctly found it free -- reporting the one thing the assertion
exists to rule out.

The holder now blocks on a channel instead of sleeping, and the prune
step starts the hold immediately before it spawns the process. The span
the prune must block for therefore begins at the spawn, and no amount of
scheduling between the two steps can consume it.

Reproduced and pinned rather than assumed: with a five-second sleep
injected at the top of the When step, the old arrangement fails with
"prune returned after 5.783356ms" -- the CI failure -- and the new one
passes.

Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
@siloteemu
siloteemu dismissed their stale review September 25, 2026 08:42

Superseded: re-reviewed at the current head; a new change request replaces this one.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 7ce7460

Requesting changes. Two blocking findings; details and the four non-blocking notes are in the review comment on this PR.

1. The earlier blocking finding is not discharged. The concurrency test still does not establish the property it names. Moving where the hold begins changes when the window opens, not whether the assertion can be satisfied without the lock ever mattering: on a slow runner the wait can elapse on its own and the test still passes, so a regression that removed the locking entirely would not be caught. It now fails silently rather than loudly, which is worse for diagnosis. A test that does not actually test the thing gates the merge.

2. New in this head: the release channel is process-global while its lane runs concurrently. The release sender is a single static consumed by a take, but the feature runs many scenarios in parallel and two of them share the same step text. One scenario can consume another's sender, so the pair can cross: one blocks forever and the other proceeds unheld. Scoping the sender to the scenario's own state, rather than a process-wide static, resolves it.

Happy to re-review as soon as either is addressed.

@r0x0r
r0x0r enabled auto-merge September 25, 2026 11:37
@r0x0r
r0x0r added this pull request to the merge queue Sep 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Sep 28, 2026
…-age-floor

Both sides added fields to the `E2eWorld` struct in
tests/e2e-cucumber/tests/e2e.rs and to its `Default` initialiser, so the
resolution is a union: this branch's `cli_elapsed` alongside main's
`remote_env` and `remote_machine`, in both the struct and the constructor.
No field from either side was dropped.

Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
…-age-floor

Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 82b5313

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Makes rocm services prune take the managed-launch lock for the whole operation, so its leftover sweep can no longer delete the 0600 endpoint key of a server caught between its key write and its record write, with a unit test, a Gherkin scenario and docs. Outcome: Needs work — the two prior blocking findings still stand unchanged at this head (the only new commit is a base-branch merge; the two files they concern are byte-identical), and one new user-facing falsehood surfaced in --help. Verified: on a scratch copy I mutation-tested the new unit test — baseline passes; deleting the FileLock::acquire block fails it on the waited assertion; with waited also removed it still fails on key_exists, so the two tripwires really are independent as docs/testing.md claims; narrowing the guard to plan-building only still passes, confirming the author's own disclosure verbatim; and dropping the guard immediately after acquiring it also still passes. I separately confirmed by reading that the production change is correct — key-before-manifest ordering, the serve-side guard spanning both writes on every managed path including --cpu-only and pinned indices, collect_service_orphans having exactly one call site and that one always under the lock, --dry-run taking the lock before its short-circuit, and the spinner being gated on stderr().is_terminal(). The full suite and the end-to-end scenario were not run here. Leak scan over the diff: zero hits. Checks at review time: 22 success, 5 pending, 1 skipped, 0 failures. Blocking: 3 · Non-blocking: 5.

Prior round

Blocking 1 — "the wall-clock assertion still does not catch the failure five separate places say it catches." STILL STANDS. tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs and features/service_record_cleanup.feature are byte-for-byte identical to the previously reviewed commit, and I re-derived the mechanism rather than trusting the earlier text: run_rocm invokes a prebuilt binary, and started.elapsed() brackets the entire Command::output(), so on a runner where the process takes longer than the 2 s hold to reach the lock, elapsed exceeds the hold, the assertion passes, and the key survives only because the record was already published. That is exactly the "slow or loaded runner" case the docstring at :244-253 and docs/testing.md:578-583 name as the thing the assertion cures. Corroborating evidence that this misleads: one of my own independent readers this round read the same prose and reported the claim as accurate, having matched assertion to sentence without tracing what elapsed measures.

Blocking 2 — "the release channel is a process-global that a different scenario can consume." STILL STANDS, re-derived at this head rather than carried: LAUNCH_RELEASE is still a static (service_cleanup_steps.rs:60), set at :317 and .take()n at :373; scenarios service-cleanup-04 (features/service_record_cleanup.feature:57) and service-cleanup-07 (:112) still share the identical When text; tests/e2e-cucumber/tests/e2e.rs:1203-1208 still selects 64 concurrent scenarios whenever there is no GPU and no shared directories; and the feature carries no serialising tag. The base-branch merge did not change the concurrency default.

Non-blocking 1 (detached staged thread never joined, :294-311) — still stands. Non-blocking 2 (docs/testing.md repeating the "timing-lucky" framing) — still stands, now at :578-583; folded into blocking 1 below. Non-blocking 3 (feature:114 rides along) — still stands. Non-blocking 4 (backdate() single call site) — still stands: defined at :640, called only at :195.

Nothing in the prior round was refuted. I also re-verified the prior round's production-side verdict independently, and it holds.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/main.rs:944-946 — --help promises an on-screen notice the code only produces on a TTY, for a wait that has no timeout.

The new Prune help text reads, unconditionally: "Waits for a managed launch already under way to publish its record before reading the directory, and waits as long as that launch takes. There is no timeout. It says so on screen while it waits."

The last sentence is false whenever stderr is not a terminal. cli_progress.rs sets enabled = std::io::stderr().is_terminal() in Spinner::new; when that is false, render_current and clear return immediately and AnimatedSpinner does not even spawn its ticker. So a piped, redirected or scripted rocm services prune blocks on FileLock::acquire — which the same help text correctly says has no timeout — and emits nothing at all. The user who read --help and then pipes the command into a log sees an indefinite silent hang and has been told explicitly that would not happen.

This blocks because it is a user-facing surface stating a guarantee the code does not provide, in the command a user reaches for precisely when a launch has already gone wrong, and it contradicts this PR's own doc comment on prune_managed_service_records, which gets it right ("piped or redirected output never sees it at all"). The PR also adds a banner at main.rs:881-886 instructing future authors to keep --help accurate with the other three surfaces; shipping an inaccurate --help in the same commit undercuts it.

Fix: qualify the sentence — e.g. "On an interactive terminal it says so while it waits." I considered recommending the stronger remedy of printing the notice unconditionally so the promise becomes true, and it does not survive scrutiny: you cannot tell whether you are about to wait without a non-blocking probe, and FileLock has no try_ variant, so an unconditional print would emit a spurious "Waiting…" line on every uncontended prune and would need a new try_acquire on the shared type. Qualifying the sentence is the change that fits this PR.

2. tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:505-526 — the wall-clock assertion does not discriminate, and six sites say it does.

Mechanism in the Prior round section above. The affected sites are service_cleanup_steps.rs:244-253 (the docstring that names the slow-runner case and then offers this assertion as its cure), :511, :527-533, features/service_record_cleanup.feature:99-103, docs/testing.md:537-539 and docs/testing.md:578-583. :527-533 is the same shape one level downstream: the comment above the manifest-exists check says the launch's second write "only lands once prune has waited out the lock" and that "its presence is what makes the assertion above meaningful rather than a statement about age", but the staged thread publishes about 2 s after the When step begins whatever the prune does, so that check is true on every path that reaches it and cannot separate a working fix from a reverted one.

Fix: either give the scenario a signal that exists only when the lock was genuinely contended and assert on that — the string Waiting for a launch already under way… is already in production but is swallowed by the TTY gate described in finding 1, so emitting an equivalent marker on non-TTY runs would serve both findings — or delete the causal framing from all six sites and state plainly that the wall clock is a sanity bound, and that the scenario's discriminating assertion is the key surviving, which itself only discriminates while startup is shorter than the hold.

3. tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:54-61 and :366-387 — the release channel is a process-global two concurrent scenarios can contend for.

Detail in the Prior round section. Concretely: if service-cleanup-07's Given registers the sender and service-cleanup-04's When reaches .take() first, 04 consumes it and starts a 2-second timer measured from 04's own step while 07's When gets None and starts no timer. 07 then either blocks until the staged thread's two-minute receive timeout — a multi-minute scenario — or, if 04's timer fires before 07 spawns its rocm, returns fast and trips the wall-clock assertion, producing exactly the spurious red the preceding commit was written to eliminate, reintroduced by another route. The doc comment at :56-59 presents as a structural guarantee ("Only the launch-lock scenario registers a sender") something the suite's own concurrency default falsifies.

Fix: move the sender off the static and onto E2eWorld, which cucumber constructs fresh per scenario, so the comment's claim holds by construction. Adversarially checking that remedy: E2eWorld must stay Send/Debug-compatible — std::sync::mpsc::Sender<()> is Send and derives Debug, and the World already carries Option fields set by Given steps and read by When steps (remote_env, cli_elapsed), so this is the established pattern here rather than a new one.

Non-blocking

  • apps/rocm/src/main.rs:8434-8440 — "the only other FileLock::acquire in the tree is ensure_background_helper_running_quiet, on a different lock file" is overstated: this PR itself adds two more, both on the same launch.lock path (the new unit test, and service_cleanup_steps.rs:296). Scope the sentence to prune's production call graph, where it is true.
  • apps/rocm/src/main.rs unit test — mutation showed it still passes when the guard is dropped immediately after acquisition, so it pins "prune blocks on the lock", not "prune holds it across the sweep"; the documented scope caveat mentions only release after plan building.
  • tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:294-311 — the staged thread is detached and never joined; a release arriving after scenario teardown makes it panic inside its own expect against a temp tree that is gone.
  • tests/e2e-cucumber/features/service_record_cleanup.feature:114 — "every file belonging to that record is gone" passes identically with or without the lock, since that record always had its manifest; harmless, but not evidence for this fix.
  • tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:640 — backdate() is extracted but has a single call site (:195); fine if more are coming, otherwise indirection without a second user.

@siloteemu

siloteemu commented Sep 29, 2026 •

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · eb07b84

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Makes rocm services prune hold the managed-launch lock for its whole operation so the leftover sweep can no longer delete the 0600 endpoint key of a server caught between its key write and its record write, and reworks the previous round's test scaffolding and prose. Outcome: Needs work — the production change is sound and genuinely pinned, two of the three prior blocking items are fully discharged, but the third was fixed at five of its six sites and the surviving one now contradicts the same document seventeen lines above it.

Verified: on a scratch copy I deleted the exact FileLock::acquire block from prune_managed_service_records and the new unit test went red with a panic naming the defect (baseline 15 passed; mutated 14 passed, 1 failed), then restored the tree; I separately confirmed by reading that the guard spans plan building, the --dry-run early return and the apply phase, that collect_service_orphans has exactly one call site and it is always under the lock, that the spinner is gated on stderr().is_terminal() so a non-TTY prune emits nothing and spawns no ticker thread, that the new World-scoped release channel compiles and that its Drop joins the staged thread before the temp tree is removed, and that the new dev-dependency edge is exempt from the crate-layering check as its comment claims. The full suite and the end-to-end scenarios were not run here. Leak scan over the diff: zero hits; no prompt-injection content found.

Checks at review time: 26 success, 2 pending, 1 skipped, 0 failures.

Blocking: 1 · Non-blocking: 5.

Prior round

Blocking 1 — "--help promises an on-screen notice the code only produces on a TTY, for a wait that has no timeout." DISCHARGED. apps/rocm/src/main.rs:943-947 now reads "On an interactive terminal it says so while it waits; piped or redirected output stays silent," and README.md:543-551 states the mechanism precisely (stderr, suppressed when stderr is not a terminal). I re-derived the gate rather than trusting the text: cli_progress.rs:48 sets enabled from stderr().is_terminal(), :98 short-circuits render_current, and :209 skips spawning the ticker entirely when disabled. The qualified sentence is now true. See non-blocking 3 for a residual imprecision in the --help wording only.

Blocking 2 — "the wall-clock assertion does not discriminate, and six sites say it does." STILL STANDS at one site. Five of the six were fixed honestly and thoroughly: the step is renamed to the prune did not return before the staged hold elapsed, its own docstring now names its limit, the feature comment is rewritten, the manifest-exists comment is demoted to "a premise check, not evidence about ordering", and docs/testing.md:581-587 states plainly that the wall clock "is a one-sided sanity bound, not a cure for that" because cli_elapsed brackets the whole child process. Grep for residual causal framing over the step file and the feature returns zero hits. But docs/testing.md:599-601 still ends with "— the same defect service-cleanup-07's wall-clock assertion exists to remove", the exact claim the same file refutes seventeen lines earlier. Detail and fix below.

Blocking 3 — "the release channel is a process-global two concurrent scenarios can contend for." DISCHARGED. The static LAUNCH_RELEASE is gone. The sender and the staged thread's join handle now live on E2eWorld (tests/e2e-cucumber/tests/e2e.rs:83 and :89, initialised None at :259-260), which cucumber constructs per scenario, so the comment's "only the launch-lock scenario registers a sender" now holds by construction rather than by the suite's concurrency happening to be 1. The suite still selects 64 concurrent scenarios without a GPU or shared directories (e2e.rs:1225-1230) and service-cleanup-04 still shares the When text, which is now harmless. I confirmed the target compiles with the new field types rather than reasoning about Send/Sync from memory.

Prior non-blocking items: 1 (the doc comment's false enumeration of FileLock::acquire call sites) still stands unchanged and is restated below; 2 is folded into blocking 2; 3 (detached staged thread) is largely addressed by the new Drop join, with one narrow residual path noted below; 4 (backdate() single call site) and the feature's ride-along assertion both still stand. Nothing from the prior round was refuted.

🚫 Blocking (must fix before merge)

1. docs/testing.md:599-601 — the refuted causal claim survives in the one place the commit did not clean, and contradicts the same file.

The lock-scope paragraph closes: "a test that instead tried to slip a launch in between the two phases would be racing a microsecond-wide window and would pass on a timing-lucky run rather than flake — the same defect service-cleanup-07's wall-clock assertion exists to remove."

That trailing clause states exactly what the prior round established is false and what this commit's other five edits exist to remove: the wall-clock assertion does not remove timing-lucky passes, because cli_elapsed brackets the entire child process, so a slow start is indistinguishable from a real block. Lines 581-587 of this same file say so explicitly — "a one-sided sanity bound, not a cure for that". A reader hitting the later sentence first walks away with precisely the wrong model of what the scenario proves, from a document that disagrees with itself.

It blocks because the prior finding named all six sites and the fix was to clear the framing from all of them; five of six leaves the claim alive on the contributor-facing testing document, which is where a future author decides whether a change to that scenario is safe. It is also the twin-lane pattern: the near-identical passage in apps/rocm/src/main.rs:8398-8402 makes the same argument about a hypothetical test and correctly stops before the causal clause. One of two parallel passages got the fix.

Fix: end the sentence at "rather than flake." The paragraph's argument — that the wider lock scope cannot be pinned by a test worth writing — is complete without the analogy, since the point being made is that a silent lucky pass is not coverage, not anything about what service-cleanup-07 cures. I ran the same adversarial pass over that fix: dropping the clause removes no information the paragraph relies on, introduces no new claim needing its own evidence, and leaves the file's two statements about the wall-clock assertion consistent with each other and with the step's own docstring. Resist replacing it with a cross-reference to the earlier paragraph — a positional reference is the kind of thing that drifts on the next edit.

Non-blocking

  • docs/testing.md:583 cites the scenario's wall-clock step as the prune blocked until the launch published its record; that step was renamed away in this same commit and the quoted name now matches nothing in the tree. Same four-surface sync rule the file's own banner invokes.
  • apps/rocm/src/main.rs:8433-8435 still asserts "the only other FileLock::acquire in the tree is ensure_background_helper_running_quiet, on a different lock file." select_gpu_indices_under_launch_lock (main.rs:21131) acquires the same managed_launch_lock_path, as this doc comment's own earlier paragraph says. This is the sentence a reader auditing the deadlock argument leans on, and a second independent reader trusted it this round rather than grepping — the confusion recurs. Scope it to prune's call graph, where it is true.
  • apps/rocm/src/main.rs:946-947 — "piped or redirected output stays silent" under-describes the gate, which is on stderr alone: piping stdout into a log while stderr is still a terminal does show the notice. README.md states it exactly; the --help surface does not.
  • tests/e2e-cucumber/tests/e2e/service_cleanup_steps.rs:313-316 — the recv_timeout(...).expect(...) runs before the join handle is stored on the World, so if that expect panics the staged thread is detached again with no join, the case the Drop change otherwise closes. Storing the handle before waiting for the held signal removes the window.
  • features/service_record_cleanup.feature:119 "every file belonging to that record is gone" passes identically with or without the lock, and service_cleanup_steps.rs:653 backdate() still has its single call site at :188.

@r0x0r
r0x0r enabled auto-merge September 29, 2026 12:06
…es (EAI-8075)

--help said "It says so on screen while it waits" unconditionally. The notice
is an AnimatedSpinner and cli_progress gates it on stderr().is_terminal(), so a
piped or scripted prune blocks on a lock that correctly has no timeout and
prints nothing -- the one case where the promise mattered. Qualified in --help
and in the README, which repeated it.

The wall-clock assertion does not discriminate the failure six places said it
cured. cli_elapsed brackets the whole child process, so a startup longer than
the staged hold satisfies the bound without the prune ever queueing on the
lock -- exactly the slow-runner case the prose offered it as the cure for. It
is a one-sided bound: it catches a prune that returned too fast to have waited,
which is what dropping the acquire produces, and nothing else. Said so at the
step, the feature, cli_elapsed's own doc and docs/testing.md, and renamed the
step to what it checks.

LAUNCH_RELEASE was a process-global while the harness runs up to 64 concurrent
scenarios and two of them share the When text, so one scenario could consume
another's sender. Moved onto E2eWorld, which cucumber builds per scenario, so
"only the launch-lock scenario sees a sender" holds by construction.

Falsified: removing the acquire fails service-cleanup-07 on the wall-clock step
-- "prune returned after 5.889242ms, less than the 2s the staged launch held
the launch lock". 7/7 scenarios and 32 suites pass with it restored.

Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
@siloteemu
siloteemu dismissed their stale review September 29, 2026 17:09

Superseded: two of the three findings are discharged at eb07b84 and the third is refiled against this head. See the replacement change request and the report comment.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · eb07b84

Change request filed by automation, replacing the earlier one on this pull request now that two of its three findings are discharged. The finding below is the blocking half of the round published in the report comment; the non-blocking notes stay there. This will be withdrawn once it is addressed — no human needs to clear it.

docs/testing.md:599-601 — the refuted causal claim survives in the one place this round did not clean, and now contradicts the same document.

The lock-scope paragraph closes:

a test that instead tried to slip a launch in between the two phases would be racing a microsecond-wide window and would pass on a timing-lucky run rather than flake — the same defect service-cleanup-07's wall-clock assertion exists to remove.

The trailing clause states exactly what the previous round established is false, and what the other five edits in this commit exist to remove: the wall-clock assertion does not remove timing-lucky passes, because the elapsed measurement brackets the entire child process, so a slow start is indistinguishable from a real block. Lines 581-587 of this same file now say so explicitly — "a one-sided sanity bound, not a cure for that". A reader who meets the later sentence first walks away with precisely the wrong model of what the scenario proves, from a document that disagrees with itself.

This blocks rather than being a note because the earlier finding named all six sites and the remedy was to clear the framing from every one of them. Five of six leaves the claim alive on the contributor-facing testing document, which is where a future author decides whether a change to that scenario is safe. It is also the twin pattern this repository keeps producing: the near-identical passage in apps/rocm/src/main.rs:8398-8402 makes the same argument about a hypothetical test and correctly stops before the causal clause. One of two parallel passages got the fix.

Fix: end the sentence at "rather than flake." The paragraph's argument — that the wider lock scope cannot be pinned by a test worth writing — is complete without the analogy, since the point is that a silent lucky pass is not coverage, not anything about what service-cleanup-07 cures. Please resist replacing it with a cross-reference to the earlier paragraph; a positional reference drifts on the next edit.

The other two blocking findings from the previous round are discharged, and the withdrawal of those is reflected here — this request carries only the surviving item. The production change itself verified sound: deleting the lock acquisition from the prune path reds the new unit test with a panic naming the defect. Non-blocking notes are in the report comment on this pull request.

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.

4 participants