Files
520359a2c9 MUL-6518 fix(daemon): reclaim orphaned per-task temp dirs in the GC (#7391)
* 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]>
2026-08-21 17:38:44 +08:00

333 lines
14 KiB
Go

package execenv
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
)
// TaskTempDirPrefix is the basename prefix os.MkdirTemp stamps on every
// per-task temp directory the daemon creates under the task temp base. The
// sweep below removes nothing without it, so the base itself — usually a
// shared /tmp holding other programs' files — is never at risk.
const TaskTempDirPrefix = "multica-task-"
// taskTempLockClaimFile is the name the marker is locked under before being
// renamed into place, so .task_lock never exists unlocked while its owner is
// still starting up.
const taskTempLockClaimFile = envRootLockFile + ".claiming"
// LockTaskTempDir takes the temp directory's execution lock and returns the
// held file; the caller owns it for the lifetime of the task run.
//
// This is the same .task_lock an env root uses, for the same reason: the
// kernel releases it when the holding process dies, so it answers "is the
// execution that owns this directory still alive?" across processes without a
// heartbeat, a PID table, or a stale-state cleanup path. That is what lets
// PruneTaskTempDirs reclaim a directory the moment its owner is gone while
// never touching one that is still in use — including a directory owned by a
// different daemon sharing the same temp base, which no in-memory active set
// could ever see, and including a live task in this very process, because a
// lock is held by an open file description rather than by a process.
func LockTaskTempDir(dir string) (*os.File, error) {
// Lock the marker under a name the sweep does not read, then publish it
// with a rename. Creating .task_lock first and locking it second would
// leave a window — however short — in which a concurrent sweep sees a
// marker it can lock, concludes the owner is dead, and deletes the
// directory out from under a task that is about to start using it. Rename
// within one directory is atomic, and the lock rides on the open file
// description rather than the name, so the marker becomes visible and
// held in the same step.
//
// Before that rename the directory carries no published marker, so the
// sweep can only reach it through the legacy branch. What keeps it safe
// there is not its age — legacyTTL takes any duration, down to a
// nanosecond, and a stalled owner can outlast any TTL — but the fact that
// it holds no task content yet. The legacy branch reclaims only
// directories that do; see taskTempDirHoldsContent.
claim := filepath.Join(dir, taskTempLockClaimFile)
lock, err := openLockFile(claim)
if err != nil {
return nil, fmt.Errorf("open task temp dir lock for %s: %w", dir, err)
}
locked, err := lockFileExclusiveNonBlocking(lock)
if err != nil {
lock.Close()
_ = os.Remove(claim)
return nil, fmt.Errorf("lock task temp dir %s: %w", dir, err)
}
if !locked {
// Unreachable in practice: the directory was just created by
// os.MkdirTemp under a name nobody else knows yet.
lock.Close()
_ = os.Remove(claim)
return nil, fmt.Errorf("task temp dir %s is already locked", dir)
}
if err := os.Rename(claim, filepath.Join(dir, envRootLockFile)); err != nil {
releaseLockFile(lock)
_ = os.Remove(claim)
return nil, fmt.Errorf("publish task temp dir lock for %s: %w", dir, err)
}
return lock, nil
}
// ReleaseTaskTempLock drops the temp directory's execution lock and closes the
// file. Safe on nil and safe to call twice.
func ReleaseTaskTempLock(f *os.File) {
releaseLockFile(f)
}
// RemoveTaskTempDir removes dir's contents first, then its lock marker, then
// dir itself — and stops at the first content it cannot remove, leaving the
// marker behind.
//
// That ordering is the whole point, and it is why this is not os.RemoveAll.
// RemoveAll walks the directory and keeps going after a failure, so a payload
// Windows will not let go of still costs the .task_lock sitting next to it:
// the removal fails having deleted exactly the file the NEXT sweep needs to
// recognise this directory as a lock-bearing one. It would come back as a
// directory with no marker — indistinguishable from a pre-lock leftover, and
// so reclaimable only on age, if at all.
//
// Leaving the marker means a failed cleanup costs nothing: the directory is
// still lock-bearing, still unlocked, and the next GC cycle takes the lock and
// retries, however many cycles it takes for the holder to let go.
//
// That holds for every way this can fail, not just the content walk. The
// marker has to be gone before the directory itself can be removed, so when
// that last removal fails the marker is put back — otherwise the one case
// where cleanup gets furthest would be the one case that leaks permanently.
//
// Callers must release their own lock before calling this — the marker is
// deleted here, and on Windows removing a file this process still holds open
// only marks it for deletion, which would then block the directory's removal.
func RemoveTaskTempDir(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
for _, e := range entries {
if e.Name() == envRootLockFile {
continue // last, and only once everything else is gone
}
if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
return err
}
}
lockPath := filepath.Join(dir, envRootLockFile)
if err := os.Remove(lockPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Remove(dir); err != nil {
// The directory outlived its marker: a Windows process holding a handle
// to the directory itself, or content an orphaned child recreated after
// the sweep above walked past it. Put an unlocked marker back, or the
// directory reads as a pre-lock leftover from here on — which, with the
// legacy branch disabled by default, means it never gets reclaimed at
// all. Restoring it keeps the retry available for the next cycle.
if restored, rerr := openLockFile(lockPath); rerr == nil {
restored.Close()
}
return err
}
return nil
}
// taskTempVerdict is what a single directory under the temp base is, as far as
// this sweep can prove.
type taskTempVerdict int
const (
// taskTempInUse: an execution still holds the lock, or something stopped
// this sweep from finding out. Both mean "do not touch" — the sweep fails
// closed, since being wrong here deletes a running task's TMPDIR.
taskTempInUse taskTempVerdict = iota
// taskTempDead: this sweep acquired the lock the owner would still be
// holding, so the kernel has already proven that execution is gone.
taskTempDead
// taskTempLegacy: no lock file at all — left by a daemon predating
// LockTaskTempDir, about which nothing can be proven.
taskTempLegacy
)
// PruneTaskTempDirs reclaims per-task temp directories whose owning execution
// is gone, under the base returned by the daemon's task temp base resolution.
//
// These directories are the agent process's TMPDIR. They live outside
// WorkspacesRoot — the task GC's scan root — so nothing else ever reclaims
// them: their only other exit is the cleanup the daemon defers at the end of
// runTask, which does not run when the daemon is killed and does not finish
// when a file inside is still open (the Windows case in #7364). Whatever that
// misses accumulates on disk forever.
//
// Liveness comes from the lock, not from the clock. A directory is removed
// only once this sweep has itself acquired the .task_lock its owner would
// still be holding, which the kernel grants only after that owner is gone.
//
// The lock is held by the daemon, not by the agent process, so it answers "is
// the owning EXECUTION still alive?" and not "is every child it spawned gone?".
// A daemon killed while agent processes survive it releases the lock on the
// spot, and this sweep may then reclaim a directory such an orphan is still
// writing to. That is the same exposure an env root's .task_lock already
// carries, and from the platform's point of view the task is dead either way;
// closing it properly is a job for process-group teardown, not for the GC.
//
// legacyTTL applies to the one case the lock cannot answer: directories left
// by a daemon predating LockTaskTempDir, which carry no lock file at all. For
// those, age is the only available signal — and age cannot establish liveness,
// which is why legacyTTL defaults to 0 (leave them alone) and reclaiming them
// is an operator's explicit decision. See DefaultGCTaskTempLegacyTTL.
func PruneTaskTempDirs(base string, legacyTTL time.Duration, now time.Time, logger *slog.Logger) (removed int, bytesFreed int64) {
entries, err := os.ReadDir(base)
if err != nil {
return 0, 0 // missing or unreadable base — nothing to prune
}
legacyKept := 0
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), TaskTempDirPrefix) {
continue
}
dir := filepath.Join(base, e.Name())
switch classifyTaskTempDir(dir, logger) {
case taskTempInUse:
continue
case taskTempLegacy:
if legacyTTL <= 0 {
legacyKept++
continue
}
// Age is the only signal here, and age cannot tell a pre-lock
// leftover from a directory this daemon is in the middle of
// publishing: both carry no .task_lock, legacyTTL accepts any
// duration down to a nanosecond, and an owner stalled between
// MkdirTemp and the rename outlasts any TTL at all. Content can
// tell them apart. A directory being published holds nothing but
// its own claim file — the task has not been handed the path yet,
// so it cannot have written anything — while a leftover worth
// reclaiming is by definition one with something in it.
//
// So this branch never touches a directory with no task content.
// The cost is that a pre-lock daemon which crashed before its task
// wrote anything leaves an empty directory nothing reclaims; the
// alternative is deleting the temp dir of a task that is starting.
if !taskTempDirHoldsContent(dir) {
continue
}
newest, _ := dirStat(dir)
if newest.IsZero() || now.Sub(newest) <= legacyTTL {
continue
}
// Everything above is a series of separate observations, not one
// snapshot: the classify that said "legacy" happened before the
// content walk, and the owner may have finished publishing in
// between — leaving a held .task_lock this branch has not looked
// at, on a directory whose task is now writing to it.
//
// Re-reading the classification here closes that, and closes it
// completely rather than narrowing it. Content is only ever there
// because a task was handed this path, which happens only after
// LockTaskTempDir has returned and therefore after the marker is
// published and held. So content observed at the top plus no
// marker seen here means no live task owns this directory — and a
// task cannot appear afterwards either, since MkdirTemp only ever
// hands out a fresh empty directory under a new name.
if taskTempSweepBeforeRecheck != nil {
taskTempSweepBeforeRecheck(dir)
}
if classifyTaskTempDir(dir, logger) != taskTempLegacy {
continue
}
}
_, size := dirStat(dir)
if err := RemoveTaskTempDir(dir); err != nil {
// Not worth escalating: the marker is still there, so the next
// cycle re-acquires the lock and retries. This is the whole reason
// the fix belongs in the GC rather than in a bounded retry on the
// task-completion path — here, failing costs nothing and repeats
// for free.
if logger != nil {
logger.Debug("execenv: prune task temp dir failed", "dir", dir, "error", err)
}
continue
}
removed++
bytesFreed += size
}
if legacyKept > 0 && logger != nil {
// Say it plainly rather than reclaiming them silently: these are the
// directories this daemon cannot prove anything about, and an operator
// who knows no pre-lock daemon is running can opt in.
logger.Info("gc: pre-lock task temp dirs left in place",
"base", base,
"count", legacyKept,
"hint", "set MULTICA_GC_TASK_TEMP_LEGACY_TTL to reclaim them on age",
)
}
return removed, bytesFreed
}
// taskTempSweepBeforeRecheck is a test seam, nil in production. The sweep's
// correctness depends on what can happen BETWEEN its decision and its removal,
// and that interleaving cannot be produced reliably from the outside.
var taskTempSweepBeforeRecheck func(dir string)
// taskTempDirHoldsContent reports whether dir holds anything a task put there,
// as opposed to only the lock files this package maintains. It is what
// separates a pre-lock leftover from a directory mid-publication, without
// consulting the clock.
func taskTempDirHoldsContent(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false // unreadable: not ours to delete
}
for _, e := range entries {
if e.Name() == envRootLockFile || e.Name() == taskTempLockClaimFile {
continue
}
return true
}
return false
}
// classifyTaskTempDir decides what can be proven about one directory.
func classifyTaskTempDir(dir string, logger *slog.Logger) taskTempVerdict {
lockPath := filepath.Join(dir, envRootLockFile)
if _, err := os.Stat(lockPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
// A directory we cannot even look into is not a directory we may
// delete. Another user's temp dir under a shared /tmp lands here.
if logger != nil {
logger.Debug("execenv: task temp dir lock unreadable; leaving it alone", "dir", dir, "error", err)
}
return taskTempInUse
}
return taskTempLegacy
}
lock, err := openLockFile(lockPath)
if err != nil {
return taskTempInUse
}
locked, err := lockFileExclusiveNonBlocking(lock)
if err != nil || !locked {
lock.Close()
return taskTempInUse
}
// Release immediately: RemoveTaskTempDir deletes this very file, and on
// Windows deleting a file this process still has open only marks it for
// deletion, which would then block the directory's own removal. Nothing can
// claim the directory in between — its name is random and its owner is
// dead — and a second sweep racing us just loses the removal to ENOENT.
releaseLockFile(lock)
return taskTempDead
}