mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* refactor(daemon): give the lock and dir-stat helpers neutral names openEnvRootLockFile, releaseEnvRootLock and codexStoreStat are all generic: they take a path, and nothing in them knows about env roots or Codex stores. The next commit reuses the same execution lock for per-task temp directories, where the old names would read as a bug at the call site. No behaviour change. Co-authored-by: multica-agent <[email protected]> * fix(daemon): reclaim orphaned per-task temp dirs in the GC Every task gets a private temp dir (multica-task-*) under the system temp base, exported to the agent as TMPDIR/TMP/TEMP. Its only exit was the RemoveAll deferred at the end of runTask, which does not run when the daemon is killed and does not succeed while a file inside is still open — the Windows case reported in #7364, where one machine had accumulated 174 leftover directories. The GC never covered them: it scans WorkspacesRoot, and the temp base is a different root, so multica-task- had exactly one create site and zero reclaim sites. Sweep the temp base as a fourth out-of-root pruner. Liveness is decided by the directory's .task_lock — the same OS advisory lock an env root uses — not by age: the sweep removes a directory only once it has itself acquired the lock the owner would still be holding, which the kernel grants only after that owner is gone. Using the lock rather than a marker or a TTL is what makes this correct rather than probabilistic. It is held by an open file description, so it answers for a live task in this very process as well as one owned by a different daemon sharing /tmp, which no in-memory active set could see. And it needs no upper bound on task runtime — there is none to assume, since DefaultAgentTimeout is 0. Directories left by daemons predating the lock carry no lock file, so age is the only signal available for them; they are reclaimed after MULTICA_GC_TASK_TEMP_LEGACY_TTL (72h). That branch exists to clear the existing leftovers and can be dropped in a release or two. The end-of-run RemoveAll stays exactly as it was, and nothing was added to the task-completion path: the run releases the lock before removing, so a removal that fails there costs nothing and the next GC cycle finishes the job. Co-authored-by: multica-agent <[email protected]> * fix(daemon): keep the temp dir marker when cleanup cannot finish Two fixes from review. 1. A failed cleanup destroyed the marker the next sweep needs. os.RemoveAll keeps walking after a failure, so removing a directory whose payload is still open deleted the .task_lock sitting next to it and then returned the error. The directory came back as one with no marker — indistinguishable from a pre-lock leftover, so it was no longer reclaimable on liveness at all. That is exactly #7364's failure path, and it made the claim this change is built on ("a failed removal costs nothing, the next cycle retries") false. RemoveTaskTempDir removes contents first and stops at the first one it cannot remove, leaving the marker behind; the lock file goes last, once everything else is gone. Both the task-completion path and the GC use it. A cleanup that fails now leaves a directory that is still lock-bearing and still unlocked, so the next cycle takes the lock and retries — for as many cycles as the holder needs. 2. The legacy TTL could delete a live task's TMPDIR. Pre-lock directories were reclaimed on age after 72h by default, but age cannot establish liveness: DefaultAgentTimeout is 0, so a task may legitimately run far longer than any TTL; a daemon on another profile can still be running the old binary during an upgrade; and every daemon on the machine shares one temp base. The default is now 0 — leave them alone — and reclaiming them is an operator's explicit opt-in. Each cycle logs how many were left in place so the choice stays visible rather than silent. Classification also fails closed: only os.ErrNotExist means "no lock file". Any other stat error (another user's dir under a shared /tmp) now reads as in-use instead of legacy. Regressions for both: a Windows test that makes a real sharing violation with CreateFile minus FILE_SHARE_DELETE, asserting the marker survives the failed cleanup and the directory goes once the handle closes (wired into the Windows CI job, which runs targeted filters rather than whole packages); and unix equivalents driving the same paths through an unlinkable payload, plus fail-closed coverage for an unreadable directory. Co-authored-by: multica-agent <[email protected]> * fix(daemon): publish the temp dir marker atomically, restore it on rmdir failure Two more windows in the marker's lifecycle, from review. 1. A directory could be swept between marker creation and lock acquisition. LockTaskTempDir created .task_lock and locked it as two steps, so there was an instant where the marker existed unlocked. A sweep landing there reads exactly what a dead owner leaves behind: it takes the lock, concludes the owner is gone, and deletes the directory — after which the starting task's own lock call succeeds and it runs with a TMPDIR that no longer exists. The marker is now locked under .task_lock.claiming and renamed into place once held. Rename within one directory is atomic and the lock rides on the open file description, not the name, so the marker becomes visible and held in the same step. Before that rename the directory carries no marker, which classifies as legacy — and legacy only reclaims what is OLDER than legacyTTL. A directory created microseconds ago is younger than any positive TTL, and a TTL of 0 disables the branch outright, so no configuration can sweep the pre-publish state either. 2. A failed rmdir left a directory with no marker. The marker has to be deleted before the directory can be removed, so a failure at that last step — a Windows process holding a handle to the directory, or content an orphaned child recreated — left a directory nothing could classify again. With the legacy branch disabled by default that is a permanent leak, and it was the one path where cleanup got furthest. The marker is now put back when that removal fails, so every failure mode leaves the retry available. Regressions: the publication order and the mid-claim on-disk state (spared under a disabled TTL and under positive ones), a sweeper running flat out against 200 publish/use/cleanup cycles, and the rmdir-failure branch — which fails without the restore, verified by removing it. Co-authored-by: multica-agent <[email protected]> * fix(daemon): decide the legacy sweep on content, not on age alone The publication window was still open. The claim-then-rename ordering stopped .task_lock from ever existing unlocked, but a directory mid-claim carries no .task_lock at all, so the sweep still reaches it through the legacy branch — and the argument that protected it there was wrong. That argument was "a directory created microseconds ago is younger than any positive TTL". MULTICA_GC_TASK_TEMP_LEGACY_TTL takes any duration, down to a nanosecond, and an owner stalled between MkdirTemp and the rename outlasts a large TTL just as easily. Reproduced at 1ns, 1ms, 1h and 72h alike: the sweep deleted a directory whose claim was held. Age cannot separate a pre-lock leftover from a directory being published, so stop asking it to. Content can: a directory being published holds nothing but its own lock files, because the task has not been handed the path yet and cannot have written anything, while a leftover worth reclaiming is by definition one with something in it. The legacy branch now reclaims only directories that hold task content, which covers both halves of the window — the empty directory before the claim exists, and the claim-only directory after — at every TTL value, with no timing assumption left in the argument. The cost is that a pre-lock daemon which crashed before its task wrote anything leaves an empty directory nothing reclaims. That is a few bytes against deleting the temp dir of a task that is starting. Tests: the mid-claim cases now run at 1ns/1ms/1h/72h with now pushed PAST the TTL, so they fail if anything reintroduces a timing argument; the pre-claim (empty directory) half is covered the same way; and the invariant itself is pinned directly, so relaxing it breaks a test rather than a race. The existing legacy TTL test now models a real leftover by giving it content. Verified all of these fail with the content rule removed. Co-authored-by: multica-agent <[email protected]> * fix(daemon): re-check the lock immediately before removing a legacy dir The legacy branch's decision is several separate observations — classify, then walk for content, then walk for mtime — and a directory can finish publishing in the middle of them. Classified as legacy while its marker was still unpublished, then published and written to by its task, and the removal went ahead without ever looking at the marker that had appeared: the sweep deletes a live task's TMPDIR. What was accidentally holding this together was that the task's own write refreshes the mtime — a timing crutch, in the branch whose whole point is not to rely on one. Re-read the classification immediately before removing, and skip unless it is still legacy. That closes the window rather than narrowing it, because of what content means: a task only writes into the directory after it has been handed the path, which happens only after LockTaskTempDir returns and therefore only after the marker is published and held. So content observed at the top of the branch, plus no marker seen at the bottom, proves no live task owns this directory — and none can arrive later either, since MkdirTemp only ever hands out a fresh empty directory under a new name. The regression drives that exact interleaving through a barrier: the sweep commits to removing the directory, the owner then publishes and its task writes, and the sweep must notice. `now` is far past the TTL so age cannot mask it. Verified it fails without the re-check. Also from review: drop a stale test comment that still claimed a positive TTL can only be configured far above a directory's age, and record in the config and operator docs that a directory holding no task content is never reclaimed on age — including a shell left by a CURRENT daemon that died between creating the directory and publishing its lock, not just an old daemon's empty leftover. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
388 lines
15 KiB
Go
388 lines
15 KiB
Go
package execenv
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// makeTaskTempDir creates a temp dir the way ensureTaskTempDir does, optionally
|
|
// without the execution lock so a pre-lock daemon's leftovers can be modelled.
|
|
func makeTaskTempDir(t *testing.T, base, suffix string, withLock bool) string {
|
|
t.Helper()
|
|
dir := filepath.Join(base, TaskTempDirPrefix+suffix)
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatalf("create task temp dir: %v", err)
|
|
}
|
|
if withLock {
|
|
lock, err := LockTaskTempDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("LockTaskTempDir(): %v", err)
|
|
}
|
|
ReleaseTaskTempLock(lock)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// TestPruneTaskTempDirsHoldsOffLiveDirThenReclaimsIt is the core contract: a
|
|
// directory whose owner still holds the lock is never removed, and the same
|
|
// sweep reclaims it as soon as that lock is gone.
|
|
//
|
|
// The lock here is held by THIS process, which is the case an in-memory active
|
|
// set would be needed for and the case the lock covers for free: flock and
|
|
// LockFileEx are held by an open file description, not by a process, so the
|
|
// sweep's own probe loses to it exactly as another daemon's would.
|
|
func TestPruneTaskTempDirsHoldsOffLiveDirThenReclaimsIt(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := filepath.Join(base, TaskTempDirPrefix+"live")
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatalf("create task temp dir: %v", err)
|
|
}
|
|
lock, err := LockTaskTempDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("LockTaskTempDir(): %v", err)
|
|
}
|
|
|
|
// legacyTTL 0 so nothing here can be reclaimed on age: the lock is the only
|
|
// thing under test. `now` is far in the future for the same reason.
|
|
future := time.Now().Add(365 * 24 * time.Hour)
|
|
if removed, _ := PruneTaskTempDirs(base, 0, future, testLogger()); removed != 0 {
|
|
t.Fatalf("prune removed %d dirs while the lock was held, want 0", removed)
|
|
}
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Fatalf("live task temp dir was removed: %v", err)
|
|
}
|
|
|
|
ReleaseTaskTempLock(lock)
|
|
|
|
removed, _ := PruneTaskTempDirs(base, 0, future, testLogger())
|
|
if removed != 1 {
|
|
t.Fatalf("prune removed %d dirs after the lock was released, want 1", removed)
|
|
}
|
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
|
t.Fatalf("task temp dir still present after prune: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPruneTaskTempDirsReclaimsUnlockedDirImmediately pins that age plays no
|
|
// part once liveness is answerable: a directory released seconds ago is gone on
|
|
// the next cycle, no TTL wait. This is what makes the end-of-task RemoveAll
|
|
// failing (the Windows sharing violation in #7364) cost nothing.
|
|
func TestPruneTaskTempDirsReclaimsUnlockedDirImmediately(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := makeTaskTempDir(t, base, "released", true)
|
|
payload := filepath.Join(dir, "node-compile-cache")
|
|
if err := os.WriteFile(payload, make([]byte, 4096), 0o600); err != nil {
|
|
t.Fatalf("write payload: %v", err)
|
|
}
|
|
|
|
removed, bytesFreed := PruneTaskTempDirs(base, 0, time.Now(), testLogger())
|
|
if removed != 1 {
|
|
t.Fatalf("prune removed %d dirs, want 1", removed)
|
|
}
|
|
if bytesFreed < 4096 {
|
|
t.Fatalf("prune reported %d bytes freed, want at least 4096", bytesFreed)
|
|
}
|
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
|
t.Fatalf("task temp dir still present after prune: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPruneTaskTempDirsLegacyDirsFallBackToTTL covers directories left by a
|
|
// daemon predating the lock: nothing recorded liveness for them, so age is the
|
|
// only signal available. They carry task content — that is both what makes them
|
|
// worth reclaiming and what distinguishes them from a directory mid-publication
|
|
// (see TestPruneTaskTempDirsLegacyBranchNeedsContent).
|
|
func TestPruneTaskTempDirsLegacyDirsFallBackToTTL(t *testing.T) {
|
|
const legacyTTL = 72 * time.Hour
|
|
|
|
legacyDir := func(t *testing.T, base string) string {
|
|
t.Helper()
|
|
dir := makeTaskTempDir(t, base, "legacy", false)
|
|
if err := os.WriteFile(filepath.Join(dir, "payload"), make([]byte, 1024), 0o600); err != nil {
|
|
t.Fatalf("write payload: %v", err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
t.Run("young legacy dir is kept", func(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := legacyDir(t, base)
|
|
if removed, _ := PruneTaskTempDirs(base, legacyTTL, time.Now(), testLogger()); removed != 0 {
|
|
t.Fatalf("prune removed %d young legacy dirs, want 0", removed)
|
|
}
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Fatalf("young legacy dir was removed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("legacy dir past the TTL is reclaimed", func(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := legacyDir(t, base)
|
|
if removed, _ := PruneTaskTempDirs(base, legacyTTL, time.Now().Add(legacyTTL+time.Hour), testLogger()); removed != 1 {
|
|
t.Fatalf("prune removed %d expired legacy dirs, want 1", removed)
|
|
}
|
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
|
t.Fatalf("expired legacy dir still present: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("zero TTL disables the legacy branch", func(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := legacyDir(t, base)
|
|
if removed, _ := PruneTaskTempDirs(base, 0, time.Now().Add(10*365*24*time.Hour), testLogger()); removed != 0 {
|
|
t.Fatalf("prune removed %d legacy dirs with the TTL disabled, want 0", removed)
|
|
}
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Fatalf("legacy dir was removed with the TTL disabled: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPruneTaskTempDirsOnlyTouchesOwnDirs guards the blast radius: the temp base
|
|
// is usually a shared /tmp, so anything without our prefix — and the base
|
|
// itself — has to survive a sweep that is otherwise willing to delete on age.
|
|
func TestPruneTaskTempDirsOnlyTouchesOwnDirs(t *testing.T) {
|
|
base := t.TempDir()
|
|
foreignDir := filepath.Join(base, "someone-elses-dir")
|
|
if err := os.Mkdir(foreignDir, 0o700); err != nil {
|
|
t.Fatalf("create foreign dir: %v", err)
|
|
}
|
|
foreignFile := filepath.Join(base, "unrelated.sock")
|
|
if err := os.WriteFile(foreignFile, []byte("x"), 0o600); err != nil {
|
|
t.Fatalf("write foreign file: %v", err)
|
|
}
|
|
// A FILE carrying our prefix is not one of our directories either.
|
|
prefixedFile := filepath.Join(base, TaskTempDirPrefix+"not-a-dir")
|
|
if err := os.WriteFile(prefixedFile, []byte("x"), 0o600); err != nil {
|
|
t.Fatalf("write prefixed file: %v", err)
|
|
}
|
|
|
|
removed, _ := PruneTaskTempDirs(base, time.Hour, time.Now().Add(10*365*24*time.Hour), testLogger())
|
|
if removed != 0 {
|
|
t.Fatalf("prune removed %d entries, want 0", removed)
|
|
}
|
|
for _, path := range []string{base, foreignDir, foreignFile, prefixedFile} {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("prune touched %s: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPruneTaskTempDirsMissingBaseIsNotAnError: the base may not exist yet on a
|
|
// daemon that has never run a task, and a GC cycle must not care.
|
|
func TestPruneTaskTempDirsMissingBaseIsNotAnError(t *testing.T) {
|
|
removed, bytesFreed := PruneTaskTempDirs(filepath.Join(t.TempDir(), "nope"), time.Hour, time.Now(), testLogger())
|
|
if removed != 0 || bytesFreed != 0 {
|
|
t.Fatalf("prune over a missing base = (%d, %d), want (0, 0)", removed, bytesFreed)
|
|
}
|
|
}
|
|
|
|
// TestLockTaskTempDirPublishesAnAlreadyHeldMarker pins the publication order:
|
|
// when LockTaskTempDir returns, .task_lock exists AND is held, with no claim
|
|
// file left behind. If the marker were created first and locked second, a sweep
|
|
// landing in between would lock it, read the owner as dead, and delete the
|
|
// directory a task is about to start using.
|
|
func TestLockTaskTempDirPublishesAnAlreadyHeldMarker(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := filepath.Join(base, TaskTempDirPrefix+"publishing")
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatalf("create task temp dir: %v", err)
|
|
}
|
|
lock, err := LockTaskTempDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("LockTaskTempDir(): %v", err)
|
|
}
|
|
t.Cleanup(func() { ReleaseTaskTempLock(lock) })
|
|
|
|
if _, err := os.Stat(filepath.Join(dir, envRootLockFile)); err != nil {
|
|
t.Fatalf("marker not published: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, taskTempLockClaimFile)); !os.IsNotExist(err) {
|
|
t.Fatalf("claim file left behind, stat err = %v", err)
|
|
}
|
|
if got := classifyTaskTempDir(dir, testLogger()); got != taskTempInUse {
|
|
t.Fatalf("classify = %v, want taskTempInUse", got)
|
|
}
|
|
}
|
|
|
|
// TestPruneTaskTempDirsSpareDirMidClaim covers the window itself: a directory
|
|
// whose owner has taken the claim but not yet published it must survive a sweep
|
|
// under every legacy setting, including ones whose TTL the directory has
|
|
// already outlived. Age must play no part in what protects it.
|
|
func TestPruneTaskTempDirsSpareDirMidClaim(t *testing.T) {
|
|
for _, legacyTTL := range []time.Duration{0, time.Nanosecond, time.Millisecond, time.Hour, 72 * time.Hour} {
|
|
t.Run(legacyTTL.String(), func(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := filepath.Join(base, TaskTempDirPrefix+"claiming")
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatalf("create task temp dir: %v", err)
|
|
}
|
|
// Exactly the on-disk state between claim and publish.
|
|
claim, err := openLockFile(filepath.Join(dir, taskTempLockClaimFile))
|
|
if err != nil {
|
|
t.Fatalf("open claim: %v", err)
|
|
}
|
|
if ok, err := lockFileExclusiveNonBlocking(claim); err != nil || !ok {
|
|
t.Fatalf("lock claim: ok=%v err=%v", ok, err)
|
|
}
|
|
t.Cleanup(func() { releaseLockFile(claim) })
|
|
|
|
// now is pushed past the TTL on purpose: what protects this state
|
|
// must not be that it is younger than the TTL, because legacyTTL
|
|
// accepts any duration and an owner can stall between MkdirTemp
|
|
// and the rename.
|
|
now := time.Now().Add(legacyTTL + time.Second)
|
|
if removed, _ := PruneTaskTempDirs(base, legacyTTL, now, testLogger()); removed != 0 {
|
|
t.Fatalf("prune removed %d dirs mid-claim, want 0", removed)
|
|
}
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Fatalf("prune removed a directory whose owner was mid-claim: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLockTaskTempDirRacesPruneCleanly is the concurrency regression: a sweep
|
|
// running flat out against tasks publishing their temp dirs must never hand a
|
|
// task a directory that has been deleted underneath it.
|
|
func TestLockTaskTempDirRacesPruneCleanly(t *testing.T) {
|
|
base := t.TempDir()
|
|
stop := make(chan struct{})
|
|
swept := make(chan struct{})
|
|
go func() {
|
|
defer close(swept)
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
PruneTaskTempDirs(base, 0, time.Now(), testLogger())
|
|
}
|
|
}
|
|
}()
|
|
|
|
for i := 0; i < 200; i++ {
|
|
dir, err := os.MkdirTemp(base, TaskTempDirPrefix)
|
|
if err != nil {
|
|
t.Fatalf("MkdirTemp(): %v", err)
|
|
}
|
|
lock, err := LockTaskTempDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("LockTaskTempDir() on iteration %d: %v", i, err)
|
|
}
|
|
// What the task does next: write into the TMPDIR it was handed.
|
|
if err := os.WriteFile(filepath.Join(dir, "payload"), []byte("x"), 0o600); err != nil {
|
|
t.Fatalf("task temp dir vanished under its owner on iteration %d: %v", i, err)
|
|
}
|
|
ReleaseTaskTempLock(lock)
|
|
_ = RemoveTaskTempDir(dir)
|
|
}
|
|
|
|
close(stop)
|
|
<-swept
|
|
}
|
|
|
|
// TestPruneTaskTempDirsSparesDirBeforeItsClaimExists covers the other half of
|
|
// the publication window — the instant between os.MkdirTemp and the claim file
|
|
// appearing, when the directory is simply empty. Nothing can be locked yet, so
|
|
// the only thing that can protect it is that it holds no task content.
|
|
func TestPruneTaskTempDirsSparesDirBeforeItsClaimExists(t *testing.T) {
|
|
for _, legacyTTL := range []time.Duration{time.Nanosecond, time.Millisecond, 72 * time.Hour} {
|
|
t.Run(legacyTTL.String(), func(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir, err := os.MkdirTemp(base, TaskTempDirPrefix)
|
|
if err != nil {
|
|
t.Fatalf("MkdirTemp(): %v", err)
|
|
}
|
|
now := time.Now().Add(legacyTTL + time.Second)
|
|
if removed, _ := PruneTaskTempDirs(base, legacyTTL, now, testLogger()); removed != 0 {
|
|
t.Fatalf("prune removed %d dirs before their claim existed, want 0", removed)
|
|
}
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Fatalf("prune removed a directory whose owner had not claimed it yet: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPruneTaskTempDirsLegacyBranchNeedsContent states the invariant the two
|
|
// tests above depend on, so that relaxing it fails here rather than in a race:
|
|
// the age branch reclaims a leftover holding task content, and nothing else.
|
|
func TestPruneTaskTempDirsLegacyBranchNeedsContent(t *testing.T) {
|
|
base := t.TempDir()
|
|
empty := makeTaskTempDir(t, base, "empty-legacy", false)
|
|
withContent := makeTaskTempDir(t, base, "used-legacy", false)
|
|
if err := os.WriteFile(filepath.Join(withContent, "payload"), make([]byte, 1024), 0o600); err != nil {
|
|
t.Fatalf("write payload: %v", err)
|
|
}
|
|
|
|
removed, _ := PruneTaskTempDirs(base, time.Hour, time.Now().Add(48*time.Hour), testLogger())
|
|
if removed != 1 {
|
|
t.Fatalf("prune removed %d dirs, want 1 (only the one holding content)", removed)
|
|
}
|
|
if _, err := os.Stat(withContent); !os.IsNotExist(err) {
|
|
t.Fatalf("expired legacy dir with content survived: %v", err)
|
|
}
|
|
if _, err := os.Stat(empty); err != nil {
|
|
t.Fatalf("empty legacy dir was reclaimed on age: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPruneTaskTempDirsRechecksLockBeforeRemoving is the TOCTOU regression: the
|
|
// sweep's legacy decision is several separate observations, and a directory can
|
|
// finish publishing in the middle of them. The barrier below reproduces exactly
|
|
// that interleaving — sweep classifies the directory as legacy, then the owner
|
|
// publishes its marker and the task writes — and the sweep must notice before
|
|
// it removes anything.
|
|
//
|
|
// Age deliberately cannot save this one: `now` is far past the TTL, and the
|
|
// write inside the barrier would normally refresh the directory's mtime, which
|
|
// is precisely the implicit timing crutch this pins against.
|
|
func TestPruneTaskTempDirsRechecksLockBeforeRemoving(t *testing.T) {
|
|
base := t.TempDir()
|
|
dir := makeTaskTempDir(t, base, "publishing-mid-sweep", false)
|
|
// Content at the top of the sweep, so the legacy branch's content and age
|
|
// gates both pass and it commits to removing this directory.
|
|
if err := os.WriteFile(filepath.Join(dir, "payload"), make([]byte, 1024), 0o600); err != nil {
|
|
t.Fatalf("write payload: %v", err)
|
|
}
|
|
|
|
var lock *os.File
|
|
barrierRuns := 0
|
|
taskTempSweepBeforeRecheck = func(swept string) {
|
|
if swept != dir {
|
|
return
|
|
}
|
|
barrierRuns++
|
|
// The owner wins the race: it publishes, and its task starts writing.
|
|
var err error
|
|
if lock, err = LockTaskTempDir(dir); err != nil {
|
|
t.Errorf("LockTaskTempDir() at the barrier: %v", err)
|
|
return
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "task-output"), []byte("live"), 0o600); err != nil {
|
|
t.Errorf("task write at the barrier: %v", err)
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
taskTempSweepBeforeRecheck = nil
|
|
ReleaseTaskTempLock(lock)
|
|
})
|
|
|
|
removed, _ := PruneTaskTempDirs(base, time.Hour, time.Now().Add(72*time.Hour), testLogger())
|
|
if barrierRuns != 1 {
|
|
t.Fatalf("barrier ran %d times, want 1 — the sweep never reached the removal decision", barrierRuns)
|
|
}
|
|
if removed != 0 {
|
|
t.Fatalf("sweep removed %d dirs after the owner published mid-decision, want 0", removed)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "task-output")); err != nil {
|
|
t.Fatalf("sweep destroyed a live task's temp dir: %v", err)
|
|
}
|
|
if got := classifyTaskTempDir(dir, testLogger()); got != taskTempInUse {
|
|
t.Fatalf("classify = %v, want taskTempInUse", got)
|
|
}
|
|
}
|