cclock

package
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package cclock cooperates with the advisory locks Claude Code takes while mutating its own files.

Claude Code uses the npm proper-lockfile package, whose mutex is mkdir of a DIRECTORY — not a lock file, not flock. Reimplementing it exactly is what makes ccdad's writes and Claude Code's token refreshes mutually exclusive:

  • mkdir(lockPath) succeeds => acquired
  • EEXIST => stat it, and steal (rmdir + retry) only if mtime < now - stale
  • the holder utimes the directory every TouchInterval so waiters do not deem it stale, and verifies its own mtime before every touch so it notices when a waiter stole the lock out from under a stalled toucher

Skipping the touch makes a long hold look abandoned and lets a waiter steal a live lock. Skipping the staleness check makes a crashed holder wedge every future writer forever. Skipping the ownership check before each touch lets a lock's former holder go on believing it is still the exclusive owner after a takeover, because Chtimes on the same path silently succeeds against the new owner's directory too.

Index

Constants

View Source
const DefaultTouchInterval = 3 * time.Second

DefaultTouchInterval is how often a holder advances the lock's mtime. It is deliberately faster than Claude Code's own 5s so a ccdad hold always looks live to a Claude Code waiter.

View Source
const GlobalConfigStale = 10 * time.Second

GlobalConfigStale is the staleness window for the ~/.claude.json lock.

Claude Code takes that one through proper-lockfile WITHOUT passing a stale option (`nv(configPath, {lockfilePath: configPath + ".lock", ...})`), so the window is proper-lockfile's own default: `{stale:1e4}` followed by `Math.max(stale, 2000)`. Ten seconds, and it is the shortest of the four windows ccdad honours -- which is exactly why it must not be re-spelled as one of the others.

View Source
const RefreshStale = 60 * time.Second

RefreshStale is Claude Code's staleness window for the OAuth refresh locks.

It is exported because ccdad has a second caller that takes ONE of these locks on its own — internal/tokens, refreshing the live login's credential — and a caller that re-spells the window is a caller that can drift from it. Never pass a shorter value: a live holder whose toucher stalled gets its lock stolen out from under it, and the holder here can be Claude Code itself.

Variables

View Source
var ErrCompromised = errors.New("lock was taken over by another process while held")

ErrCompromised means the lock was taken over by another process while held. Whatever the lock was protecting must be treated as not written.

View Source
var ErrTimeout = errors.New("lock is held by another process")

ErrTimeout is returned when a lock stayed held past Options.Timeout.

Functions

func GlobalConfigLockDir

func GlobalConfigLockDir() (string, error)

GlobalConfigLockDir is the lock guarding ~/.claude.json.

func LegacyRefreshLockDir

func LegacyRefreshLockDir() (string, error)

LegacyRefreshLockDir is the older sibling-of-the-directory lock Claude Code still takes for compatibility with external tools. Claude Code names it after the REAL path of the credential home, so a symlinked home must resolve first or the two processes lock different files and exclude nothing.

func OAuthRefreshLockDir

func OAuthRefreshLockDir() (string, error)

OAuthRefreshLockDir is Claude Code's primary token-refresh lock.

func StorageWriteLockDir

func StorageWriteLockDir() (string, error)

StorageWriteLockDir guards every read-modify-write of .credentials.json. cswap does not take this lock; ccdad does.

Types

type Held

type Held struct {
	// contains filtered or unexported fields
}

Held is a set of Claude Code locks held together.

func AcquireCredentials

func AcquireCredentials(timeout time.Duration) (*Held, error)

AcquireCredentials takes all three credential locks in Claude Code's own order: the primary refresh lock, then the legacy one, then the storage-write lock around the actual write.

The order is load-bearing. Mirroring Claude Code's sequence means a waiting ccdad and a waiting Claude Code can never hold each other's next lock, so a deadlock between them is impossible. Taking them in any other order reintroduces one.

timeout is per lock, so the worst case is roughly 3x timeout plus up to one backoff interval per lock.

Never make a network call while these are held. Claude Code's refresh reads, round-trips the token endpoint, and saves, all under the two refresh locks; holding them across our own network call would stall Claude Code for the duration of our request.

func AcquireGlobalConfig

func AcquireGlobalConfig(timeout time.Duration) (*Held, error)

AcquireGlobalConfig takes the single lock guarding ~/.claude.json.

It is deliberately NOT folded into AcquireCredentials. The two files have different locks, different stale windows and different writers, and a caller that needs both must take the credential locks first and this one second -- which is the order Claude Code itself uses, because its credential save runs under the refresh locks and only then reaches the config writer. Taking this one first would put a ccdad holding it in front of a Claude Code holding the refresh locks and waiting for it.

The parent directory is created but the config FILE is not. Claude Code's own acquisition passes proper-lockfile realpath:true against the config path, so on a machine where ~/.claude.json does not exist yet its lock attempt fails ENOENT and it falls back to an unlocked write. ccdad does not copy that fallback: our lock directory is a mkdir of a path that needs no existing file, so we can lock a not-yet-created config correctly.

func AcquireGlobalConfigAt added in v0.2.0

func AcquireGlobalConfigAt(path string, timeout time.Duration) (*Held, error)

AcquireGlobalConfigAt is AcquireGlobalConfig against a named config file.

Nothing about the lock changes with the path, and that is worth stating because it looks like it should: the lock's NAME is `${configPath}.lock`, so locking a `ccdad run --full-profile` profile's own config locks exactly the file the Claude Code inside that profile would lock, with the same stale window. The profile is not a file only one process touches -- Claude Code rewrites its config constantly, on every startup counter and every project entry -- so the lock means there what it means anywhere.

func (*Held) Compromised

func (h *Held) Compromised() <-chan struct{}

Compromised is closed the moment any one of the held locks is taken over by another process. A caller holding these across a credentials write should select on it and treat the write as NOT durable if it fires: a compromised lock means Claude Code may believe it now owns exclusive access to that lock and could be concurrently reading or writing the same credentials file.

A nil *Held reports no compromise: it returns a channel that never closes, the same behaviour a nil receiver gives a caller who skipped the error check on a failed AcquireCredentials.

func (*Held) Release

func (h *Held) Release() error

Release gives the locks back in reverse acquisition order, releasing all three even if one of them fails. It is idempotent: every call, including concurrent ones, observes the exact outcome of the first.

If any lock was compromised -- taken over by another process while held -- the returned error satisfies errors.Is(err, ErrCompromised), regardless of what the other locks' Release calls returned and regardless of iteration order. A compromise means Claude Code may have concurrently touched the credentials file; that is the failure a caller must act on, so it must never be masked by a more mundane error from another lock in the set.

func (*Held) Scope

func (h *Held) Scope() string

Scope is the already-resolved path this Held's locks actually cover: the credential home for AcquireCredentials, the global config FILE for AcquireGlobalConfig. It is the value resolved at the moment of acquisition, never a fresh re-resolution.

A caller about to write under this Held must derive the target from THIS value rather than calling ccpath again: the environment variables ccpath reads are exactly the ones ccdad itself mutates for per-account credential scoping, so a change between resolutions would lock one path and write another.

type Lock

type Lock struct {
	// contains filtered or unexported fields
}

Lock is a held directory lock.

func Acquire

func Acquire(lockDir string, opts Options) (*Lock, error)

Acquire takes the lock at lockDir, waiting up to opts.Timeout.

func (*Lock) Compromised

func (l *Lock) Compromised() <-chan struct{}

Compromised is closed when this lock discovers it no longer owns its directory. A caller holding the lock across more than a brief critical section should select on it and abandon whatever it was protecting.

func (*Lock) Owned added in v0.7.0

func (l *Lock) Owned() bool

Owned reports, synchronously and right now, whether this holder still owns its lock directory: a stat of the directory compared against the mtime this holder last confirmed. It is the same check Release performs, made available BEFORE a write instead of after one.

Compromised() alone is not enough for a caller whose critical section is longer than an instant. That channel is closed by the touch goroutine on its own ticker, so a takeover can be up to one TouchInterval old before it is visible there -- and a holder that was suspended and has just resumed can reach its write before that goroutine is next scheduled at all, no matter how short the interval is. A caller that must not overwrite a newer holder's work calls this immediately before writing.

It narrows that window; it cannot close it. Nothing here makes the check and the caller's write one atomic operation, so a takeover landing between the two still overwrites. What is left is the few microseconds between a stat and the write that follows it, rather than seconds of a ticker that has not run.

A negative answer also marks the lock lost, so a caller separately watching Compromised() sees the same event rather than silence, and Release goes on to leave the directory alone: it belongs to the new owner now. A nil Lock owns nothing.

func (*Lock) Release

func (l *Lock) Release() error

Release stops touching and removes the lock directory. It is idempotent: every call, including a concurrent one, observes the exact outcome of the first -- sync.Once guarantees that, but only because the outcome itself is stored on the Lock rather than in a local variable a second caller would never see.

If the lock was compromised -- taken over by another process while held -- Release does not remove the directory (it belongs to the new owner now) and returns ErrCompromised instead. This is checked twice. First, via l.lost, which the touch goroutine sets on its own ticker -- so a takeover can be up to one TouchInterval old before that goroutine notices it. Second, synchronously, right here: a final stat of the directory compared against the mtime this holder last confirmed. The second check is what catches a takeover in the window between touch's last tick and this call, which the first check alone cannot see -- and it is why Release must never remove the directory on the strength of l.lost being merely unclosed: unclosed only means "not detected yet", not "did not happen".

type Options

type Options struct {
	// Stale is how old a lock's mtime must be before it may be taken over.
	// Use the value Claude Code uses for that lock; never a shorter one, or a
	// live holder whose toucher stalled (suspend, blocked event loop) gets its
	// lock stolen out from under it.
	Stale time.Duration
	// Timeout bounds how long Acquire waits. Zero means a single attempt.
	Timeout time.Duration
	// TouchInterval is how often the mtime is advanced while held.
	// Zero means DefaultTouchInterval. Must be at most half of Stale, or a
	// holder's own lock could go stale by its own definition between two
	// touches; Acquire rejects a contradictory pair rather than clamping it.
	TouchInterval time.Duration
}

Options configures one acquisition.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL