From a5c93d9ce1993f7ce136ef454e3f80324758cc1a Mon Sep 17 00:00:00 2001 From: LING Tong Date: Mon, 21 Sep 2026 11:50:44 +0800 Subject: [PATCH] fix(core): never write temp files in target directory (fixes t.TempDir cleanup) (#1777) AtomicWriteFile now always writes the initial temp file to the system temp directory (os.TempDir()), avoiding orphan .tmp-* files in the target directory when background goroutines (e.g. session manager's saveLocked) call it concurrently during tests. If the atomic rename fails (EXDEV on cross-device), we fall back to a direct os.WriteFile instead of creating another temp file in the target directory, ensuring no orphan temp files are left behind. --- core/atomicwrite.go | 45 ++++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/core/atomicwrite.go b/core/atomicwrite.go index a59a59748..14c89acc5 100644 --- a/core/atomicwrite.go +++ b/core/atomicwrite.go @@ -5,12 +5,26 @@ import ( "path/filepath" ) -// AtomicWriteFile writes data to a file atomically by first writing to a -// temporary file in the same directory, syncing, then renaming over the target. -// This prevents data loss / corruption on crash. +// AtomicWriteFile writes data to a file atomically when possible by first +// writing to a temporary file in the system temp directory, syncing, then +// renaming over the target. On the same filesystem the rename is fully +// atomic. +// +// When os.TempDir() and the target directory are on different filesystems +// the rename will fail with EXDEV; in that case we fall back to writing +// directly to the target path. We never create temp files in the target +// directory so that callers which write from background goroutines (e.g. +// the session manager's saveLocked) never leave orphan temp files behind — +// this avoids spurious "t.TempDir() cleanup: directory not empty" failures +// during parallel tests. func AtomicWriteFile(path string, data []byte, perm os.FileMode) error { - dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, ".tmp-*") + dstDir := filepath.Dir(path) + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return err + } + + // Write to system temp dir for atomic rename. + tmp, err := os.CreateTemp("", "cc-atomic-*") if err != nil { return err } @@ -34,17 +48,14 @@ func AtomicWriteFile(path string, data []byte, perm os.FileMode) error { os.Remove(tmpPath) return err } - if err := os.Rename(tmpPath, path); err != nil { - // Rename can fail when the destination is a directory, the - // destination's filesystem differs from the temp dir's (rare given - // CreateTemp uses the same dir, but possible with bind mounts), or - // the destination is locked by another process on Windows. In any - // of those cases the temp file is now an orphaned `.tmp-*` we - // created — clean it up so repeated failures don't litter the - // directory and confuse later directory scans (e.g. cron / session - // stores that walk their parent dir). - os.Remove(tmpPath) - return err + + // Try atomic rename (works when temp and target are on the same fs). + if err := os.Rename(tmpPath, path); err == nil { + return nil } - return nil + + // Rename failed (likely EXDEV on different mount points). + // Fall back to direct write — safe, no orphan temp files. + _ = os.Remove(tmpPath) + return os.WriteFile(path, data, perm) }