mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix During lock reclamation, reclaiming a suspected stale lock renames the file aside and renames it back if not stale. On POSIX systems, the rename back silently overwrites any new lock file created in the gap. On Windows, it fails to rename back and leaks the stale file. Introduce a cross-platform restoreLockFile helper: - On POSIX: Use os.Link to link the file back (which atomic-fails if the target already exists), then remove the temporary link. - On Windows: Use os.Rename (which safely fails if the target exists). If restoring fails, delete the temporary file immediately to prevent leaks. * fix(lockutil): fix Windows build by using golang.org/x/sys/windows Addresses jatmn's follow-up review: the required Windows smoke check was failing because syscall.MoveFileEx is not exported by the standard library's syscall package on this platform, so internal/lockutil (and everything importing it: cron, daemon, hooks, oauth) failed to compile on Windows. Switched to golang.org/x/sys/windows, which does export MoveFileEx with the same signature, already a direct module dependency. Verified on a real Windows machine that the no-flags call still fails with ERROR_ALREADY_EXISTS when the destination exists, and that this satisfies errors.Is(err, os.ErrExist), which cron/daemon/hooks/oauth's reclaim paths all depend on to decide whether the sidelined lock was genuinely superseded (safe to discard) versus some other, unrelated failure (must be preserved). TestRestoreLockFile already covered both the successful-restore and fail-when-target-exists cases; it now passes on Windows. * fix(lockutil): route lock file deletes through RemoveLockFile with Windows retry loop * fix(lockutil): close restore split-brain and unify RemoveLockFile RestoreLockFile could fail with errors other than os.ErrExist (hardlink- incapable filesystems such as FAT or some FUSE/network mounts, ENOSPC, Windows sharing violations). Callers only clean up on os.ErrExist, so any other failure left the lock path missing with the live holder's lock stranded in the sidelined file; the next O_EXCL create then succeeded and two processes held the lock. RestoreLockFile now falls back to an O_EXCL copy in that case, which preserves the no-overwrite guarantee, and a successful restore with a failed sidelined-file cleanup reports nil instead of a failed restore. RemoveLockFile now has one cross-platform contract: removing an already missing file reports nil on POSIX too, matching Windows, and daemon release() drops its caller-side ErrNotExist filter. Tests: TestRestoreByCopy and TestRemoveLockFile run on all platforms; Windows-tagged tests pin the ERROR_ALREADY_EXISTS mapping to os.ErrExist and exercise the RemoveLockFile retry loop to success and to bounded exhaustion. The package doc comment documents the shared contract. * refactor(swarm): extract reclaimStaleLock to mirror other lock callers The stale-lock reclaim in acquireLock was inline, so its restore path (a holder rotated a fresh lock in the gap between the stale check and the rename) could not be regression-tested the way cron, daemon, hooks, and oauth test theirs. Extract it into reclaimStaleLock with the same shape as the other callers, behavior unchanged, and add the mirrored regression test. * test(hooks,oauth): add stale lock reclaim regression tests Mirror the cron reclaim test in the two packages that lacked coverage for reclaimStaleLock: a genuinely stale lock is reclaimed, a fresh lock (reacquired in the gap) is restored intact rather than stolen, and a missing lock reports no reclaim. daemon already had equivalent tests in lock_test.go. * fix(lock): fail closed and drop sidelined file when restore fails A non-os.ErrExist error from RestoreLockFile means both the no-replace restore and its O_EXCL copy fallback failed, so the lock path may be missing while the sidelined holder is live. Callers previously swallowed that error and their next O_EXCL create would succeed, breaking mutual exclusion. reclaimStaleLock in cron, daemon, hooks, oauth, and swarm now returns an error for that case and the acquire loops fail closed instead of re-acquiring. The sidelined .stale.* file is now removed on every restore failure, not only os.ErrExist: once the restore has failed it has no protocol function (release only consults the lock path), so keeping it only leaked files. Also replace the numeric errno literals in the Windows RemoveLockFile retry loop with windows.ERROR_SHARING_VIOLATION and windows.ERROR_ACCESS_DENIED. * refactor(lockutil): extract shared ReclaimStaleLock helper Consolidate the duplicated reclaimStaleLock functions from cron, hooks, oauth, and swarm into lockutil.ReclaimStaleLock. Each caller now passes an mtime-based isLive predicate to the shared helper, which owns the rename-aside, restore-if-live, cleanup, ErrExist handling, and fail-closed error propagation. Daemon keeps its own reclaimStaleLock because it uses PID-based liveness (different predicate shape and no staleAfter duration). The per-package reclaim regression tests are removed since the shared helper is thoroughly tested in lockutil/reclaim_test.go (dead lock reclaimed, live lock restored, missing lock, fail-closed on restore error, sidelined file dropped when new holder wins). * fix(lockutil): propagate non-contention rename failures from ReclaimStaleLock A rename-aside failure that is not a lost race previously mapped to the benign lost-race result, so acquirers spun until their deadline and reported a timeout instead of the real failure. Keep only a missing source and the Windows sharing-violation/access-denied contention errnos benign (neither Windows errno maps to os.ErrExist, and ERROR_SHARING_VIOLATION does not map to os.ErrPermission either, so the classification compares raw errnos) and return everything else so callers fail closed. Also convert daemon's private reclaimStaleLock to the shared helper so it picks up the same contract, and reword the caller error messages from "restore reclaimed" to "reclaim stale" since the error can now come from the rename aside as well. * fix(lockutil): publish restore-by-copy atomically and honor lost mailbox reclaims restoreByCopy created the destination lock file empty before copying into it, so a crash mid-copy could leave a live holder's lock permanently unrepresented at the lock path. Stage the copy under a private name and publish it with the platform's no-replace primitive instead. swarm's mailbox lock discarded the reclaimed boolean and always retried immediately, so a lost reclaim race (or Windows sharing-violation contention) could hot-spin past the lock timeout instead of falling through to the bounded wait like the other lock callers. * fix(lockutil): shrink the reclaim/restore window with a fast replacing rename Restoring a lock that turned out live went through the no-replace path (plus its copy fallback on some failures) first, which is slow enough that a fourth process's unrelated O_EXCL create could win lockPath while it was absent for the whole call. RestoreLockFile's no-replace contract would then report os.ErrExist and the caller silently dropped the sidelined lock, orphaning the still-running original holder. restoreLiveLock now tries a single replacing rename straight back first, falling back to the no-replace path only if that fails. It shrinks the window from a whole restore call down to roughly one syscall; it does not close it, since the fast path can silently overwrite a competing lock rather than detect it. A fully race-free reclaim would need an OS-level advisory lock checked non-destructively instead of by moving the file, which is a larger change than this.
105 lines
4.2 KiB
Go
105 lines
4.2 KiB
Go
package cron
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/lockutil"
|
|
)
|
|
|
|
// Cross-process lock tuning. The lock is held only for a single metadata
|
|
// read-modify-write (milliseconds), never across a job's exec, so the timeout is
|
|
// generous and the stale threshold sits far above any real hold.
|
|
const (
|
|
cronLockTimeout = 10 * time.Second
|
|
cronLockStaleAfter = 60 * time.Second
|
|
cronLockRetryDelay = 20 * time.Millisecond
|
|
)
|
|
|
|
var cronLockSeq atomic.Uint64
|
|
|
|
// lockJob takes a cross-process exclusive lock for one job by O_EXCL-creating a
|
|
// sibling "<id>.lock" file next to the job directory (so Remove's RemoveAll of
|
|
// the job dir never deletes a live lock). It serializes the read-modify-write of
|
|
// a job's metadata across concurrent schedulers and commands. The lock file is
|
|
// removed on release; a stale lock from a crashed holder (older than
|
|
// cronLockStaleAfter) is reclaimed. Wall-clock time.Now is used deliberately
|
|
// (not the injectable Store.now) so lock timing never depends on a frozen test
|
|
// clock and the stale check compares against real file mtimes.
|
|
func (s *Store) lockJob(id string) (func(), error) {
|
|
if !validID(id) {
|
|
return nil, fmt.Errorf("invalid cron job id %q", id)
|
|
}
|
|
lockPath := s.jobDir(id) + ".lock"
|
|
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), cronLockSeq.Add(1))
|
|
deadline := time.Now().Add(cronLockTimeout)
|
|
for {
|
|
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
if err == nil {
|
|
// A partial write would leave a lock file without our token, so the
|
|
// releaser could never delete it — stranding the lock. Fail closed.
|
|
if _, werr := f.WriteString(token); werr != nil {
|
|
_ = f.Close()
|
|
_ = lockutil.RemoveLockFile(lockPath)
|
|
return nil, fmt.Errorf("cron: write job lock: %w", werr)
|
|
}
|
|
if cerr := f.Close(); cerr != nil {
|
|
_ = lockutil.RemoveLockFile(lockPath)
|
|
return nil, fmt.Errorf("cron: close job lock: %w", cerr)
|
|
}
|
|
var released bool
|
|
return func() {
|
|
if released {
|
|
return
|
|
}
|
|
released = true
|
|
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
|
_ = lockutil.RemoveLockFile(lockPath)
|
|
}
|
|
}, nil
|
|
}
|
|
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
|
// "delete pending" state, so an O_EXCL create races it with
|
|
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
|
// as contention and retry, exactly like ErrExist — otherwise the lock
|
|
// spuriously fails under concurrency on Windows.
|
|
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
|
return nil, fmt.Errorf("cron: acquire job lock: %w", err)
|
|
}
|
|
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
|
|
// Remove lets two racers both "reclaim" and recreate, so both hold the lock;
|
|
// reclaimStaleLock renames the file aside (only one rename of a given file
|
|
// wins) and restores it if it turns out fresh, so a live lock is never deleted
|
|
// out from under its holder.
|
|
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > cronLockStaleAfter {
|
|
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
|
|
info, err := os.Stat(reclaimedPath)
|
|
return err == nil && time.Since(info.ModTime()) <= cronLockStaleAfter
|
|
})
|
|
if rerr != nil {
|
|
// Reclaim hit a hard failure: the rename aside failed outright, or a
|
|
// live holder's lock could not be put back (the lock path may be
|
|
// missing, so re-acquiring would break mutual exclusion). Fail closed
|
|
// instead of spinning to the deadline.
|
|
return nil, fmt.Errorf("cron: reclaim stale job lock: %w", rerr)
|
|
}
|
|
if cleared {
|
|
continue // cleared a genuinely stale lock; retry the O_EXCL create now
|
|
}
|
|
// Lost the reclaim race (or it was actually fresh) — fall through to the
|
|
// bounded wait instead of hot-spinning on a reclaim that never wins (L13).
|
|
}
|
|
if !time.Now().Before(deadline) {
|
|
return nil, fmt.Errorf("cron: timed out acquiring job lock for %q", id)
|
|
}
|
|
time.Sleep(cronLockRetryDelay)
|
|
}
|
|
}
|