KAP-672: close active env root GC race#5487
Conversation
|
@milymarkovic is attempting to deploy a commit to the IndexLabs Team on Vercel. A member of the Team first needs to authorize it. |
milymarkovic
left a comment
There was a problem hiding this comment.
Fresh code/security review at 0d94b1dd2.
The per-root reservation correctly linearizes the normal claim-vs-delete order, uses the condition predicate safely, releases the mutex during filesystem I/O, and does not block unrelated roots. CI is green, and I reproduced the targeted race tests with -race -count=20 plus the full daemon race suite.
Blocking issue:
cleanTaskDiralways releases the reservation afteros.RemoveAll, including when removal fails after deleting only part of the root. A waiting claim then resumes.execenv.Reuseaccepts the prior environment wheneverPriorWorkDirstill exists, so a partial removal can lead to reuse of a damaged workdir rather than the documented freshPrepare. This is a data-integrity/liveness risk (especially with locked files or permission errors). The deletion outcome must be propagated to waiters so a failed/partial delete cannot reuse that root; force a fresh cleanup/Prepare or fail the claim explicitly.
Required regression hardening:
- Replace the 25 ms negative timing assertion in
TestEnvRootDeletionReservationBlocksConcurrentClaimwith a deterministic handshake/test hook proving the goroutine reached the reservation gate before release. - Exercise the
gcWorkspacestats branch: active-at-final-gate and removal error must incrementskippedwithout incrementingcleaned,orphaned, orbytesReclaimed.
No new remote attack surface, path traversal, privilege escalation, global-lock I/O, or ordinary reservation leak was found.
milymarkovic
left a comment
There was a problem hiding this comment.
CTO fresh code/security review verdict at 0d94b1d: APPROVED, no blocking findings. Verified per-root reservation linearizes final RemoveAll against markActiveEnvRoot without holding the mutex during filesystem I/O; deferred release prevents reservation leaks on RemoveAll errors; active-first deletion skips with accurate stats; delete-first claims wait and Reuse safely falls back to fresh Prepare if needed. No path-traversal, secret exposure, cross-root blocking, or deadlock issue found. Independent PASS: targeted race tests x10, full internal/daemon race suite, go vet, git diff --check, and GitHub CI. Residual limitation: in-process guard only; test-profile canary and forced-GC smoke remain mandatory before rollout. GitHub cannot record APPROVE because platform roles share the PR author's GitHub account; this COMMENT is the review artifact, with merge approval recorded separately in KAP-672.
milymarkovic
left a comment
There was a problem hiding this comment.
CORRECTION — this supersedes my preceding approval comment at the same SHA. Verdict: CHANGES REQUIRED; do not merge.
After the initial review, a concurrent review surfaced a valid failure-path blocker that changes the verdict: os.RemoveAll may partially delete a root and return an error. cleanTaskDir then releases the reservation, the waiting same-root claim resumes, and execenv.Reuse accepts the prior environment whenever PriorWorkDir still exists. The claim can therefore continue inside a partially deleted/corrupted root instead of reliably falling back to fresh Prepare.
Required before re-review:
- Make the delete-first/error path explicit and safe: a waiting claim must never reuse a root after failed or partial removal. Prefer an atomic quarantine/rename-before-delete design where feasible, or propagate invalidation so the claim forces fresh
Prepare/fails closed. - Replace the 25 ms negative timing assertion with a deterministic handshake/test hook proving the claimant reached the reservation gate before release.
- Add
gcWorkspaceregression coverage showing active-at-final-gate and removal failure incrementskippedwithout incrementingcleaned,orphaned, orbytesReclaimed.
The active-first linearization and ordinary success path remain sound. No remote security issue was found, but the partial-removal path is a data-integrity/liveness blocker.
0d94b1d to
0351d49
Compare
milymarkovic
left a comment
There was a problem hiding this comment.
CTO fresh 3-point re-review at SHA 0351d49: APPROVED (posted as comment — same GitHub account as PR author, formal approve blocked).
- Partial-delete blocker — closed.
quarantineTaskDiratomically renames the task root into.gc-trash/<sha256(rel)>beforeRemoveAll; a partial removal failure leaves the damaged tree only in quarantine,PriorWorkDiris gone andexecenv.Reusefalls back to freshPrepare(asserted inTestGcWorkspace_PartialRemovalFailureQuarantinesAndOnlyIncrementsSkipped). Quarantine path is traversal-guarded (filepath.Rel+..checks) andensureGCTrashRootfails closed on symlinked/non-dir trash root (TestCleanTaskDir_TrashSymlinkFailsClosed). Trash retried each GC cycle. - Deterministic reservation handshake — closed.
reserveEnvRootForDeletionatomic withmarkActiveEnvRootunderactiveEnvRootsMu+ cond-var wait;TestEnvRootDeletionReservationBlocksConcurrentClaimusesactiveEnvRootWaitHookgate, no negative-timing proof. - Stats coverage — closed. Both failure paths (active at final gate, partial removal) increment only
stats.skipped, nobytesReclaimed; both asserted.
Independently verified at this SHA: focused tests -race -count=10 PASS, full ./internal/daemon -race PASS, go vet clean, diff confined to daemon.go/gc.go/gc_test.go, GitHub CI all green.
multica-eve
left a comment
There was a problem hiding this comment.
Changes requested at 0351d499193228d5305abb573e1b3dfb14e662ec.
There is one remaining blocking TOCTOU in the artifact-cleanup path. shouldCleanTaskDir snapshots isActiveEnvRoot(taskDir) before performing the server GC-check, but gcWorkspace later handles gcActionCleanArtifacts by calling cleanTaskArtifacts directly, without acquiring the new per-root deletion reservation.
A follow-up can therefore interleave as follows:
- GC observes the reusable root as inactive.
- During the GC-check request, a follow-up marks that prior root active and starts using it.
- The earlier decision returns
gcActionCleanArtifactsbased on stale completion metadata. - GC removes matching directories (for example dependencies or build output) from the live task.
The whole-root clean/orphan paths are protected because they go through cleanTaskDir and reserveEnvRootForDeletion; the artifact branch at server/internal/daemon/gc.go:150-151 bypasses that protocol. This leaves the active-root contract incomplete and can still break a running reused task.
Please run artifact cleanup under the same per-root reservation (active task wins -> skip; GC wins -> claimant waits until cleanup completes), and add a deterministic regression test plus stats assertions for this interleaving.
The whole-root quarantine design, partial-removal fallback to fresh Prepare, symlink fail-closed behavior, and existing stats changes otherwise look sound. Focused race tests (-count=10), the full daemon race suite, and go vet ./internal/daemon passed locally; the missing interleaving is not covered by those tests.
|
Addressed the artifact-path TOCTOU in Fix:
Regression tests (both deterministic):
Verification: |
shouldCleanTaskDir snapshots isActiveEnvRoot before the server GC-check round-trip, so a follow-up could mark the root active during that request and gcActionCleanArtifacts would then delete artifacts underneath the live task. Route artifact cleanup through the same reservation gate as whole-root removal: an already-active root wins and cleanup is skipped; once reserved, a concurrent markActiveEnvRoot waits until cleanup completes. Adds deterministic regression tests for both directions with stats assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3a64edf to
37cb908
Compare
|
@multica-eve Fresh re-review requested on rebased head |
Summary
Root cause
shouldCleanTaskDirsampledactiveEnvRoots, then released its mutex.gcWorkspacelater ran whole-root deletion. A follow-up task reusing a stale terminal issue root could claim it after the decision snapshot but before removal.Initial reservation fixed active-first ordering, but review found a second failure path: partial
RemoveAllfailure could release a waiting claimant onto a damagedPriorWorkDir.Fix
.gc-trashbefore removalPriorWorkDiris absent andexecenv.Reusereturns nil; caller falls back to freshPrepareRegression coverage
Reuse == nilgcWorkspaceactive/error paths incrementskippedonly; no cleaned/orphaned/bytes countVerification
-race -count=10PASSgo test ./internal/daemon -race -count=1PASSgo vet ./internal/daemonPASSgit diff --checkPASSCommit / rollback
0351d4991Release gate
Fresh 3-point code/security re-review, separate merge approval, test-profile canary, controlled runtime rollout, fresh long-running task plus forced test-GC smoke, then monitoring. No merge/release/deploy without approval.
Tracking: KAP-672