Documentation
¶
Overview ¶
Package agentslock is the single shared writer for .agentsrc.lock — the resolved-state companion to .agentsrc.json (config-distribution-model §7).
It is schema-agnostic: it owns the whole JSON document and treats top-level sections (config, packages, adapters, …) as opaque values, so the config/ package resolver and the graph-adapter lifecycle share one file without either importing the other's schema (§7.4). A writer stages only its own section and flushes; sibling sections are preserved verbatim. Flush is atomic (temp file + rename, via fsops.WriteFileAtomic). A single Lockfile is safe for concurrent SetSection from parallel resolver goroutines. Flush also takes a portable single-file sidecar lock, rereads the latest on-disk document, and reapplies only this process's staged top-level keys before the atomic write. That keeps sibling sections written by another process from being lost while preserving the §7.4 "parallel resolution, serialized write" contract.
Index ¶
Constants ¶
const LockVersion = 1
LockVersion is the current .agentsrc.lock schema version.
Variables ¶
This section is empty.
Functions ¶
func AcquireFileLock ¶ added in v0.4.2
AcquireFileLock takes the package's advisory, inter-process lock guarding the file at path and returns a release function the caller MUST invoke to free it. It is the reusable form of the primitive that serializes .agentsrc.lock writes (see Flush); other cooperating da processes (e.g. an append-only NDJSON writer) call it to serialize their own writes to a shared file.
The lock is a SINGLE sidecar file at "<path>.lock" whose contents are the holder's identity (pid, acquisition time, random token). Acquisition is one atomic name-creation: the identity is fully written to a unique temp sibling first and then hard-linked to the lock name (os.Link → CreateHardLinkW, both atomic fail-if-exists). The lock name therefore NEVER holds a partial or identity-less object — there is no observable two-step state, which is what eliminated the mid-acquire interleavings of the previous dir+holder design (a contender could judge the dir between the Mkdir and the holder write). On filesystems without hardlinks (FAT/exFAT, some network mounts) the claim degrades to O_CREATE|O_EXCL + write + read-back verify; that path reopens a microseconds-wide create→write gap, guarded by lockNoHolderGrace and the verify, and is documented as the only residual two-step surface.
The lock is ADVISORY — it excludes only other callers of this function that name the same path, not arbitrary writers of the underlying file. The parent directory of path is created if it does not yet exist.
Acquisition blocks up to lockAcquireTimeout, retrying every lockRetryInterval, and returns a timeout error if a live holder never releases. Because the lock has no kernel-backed auto-release, a holder that crashed without releasing (SIGKILL/OOM/power loss) is detected as stale once its recorded age exceeds lockStaleTTL and is reclaimed at most once per call — so a slow but live holder is never torn down out from under itself. A legacy lock DIRECTORY at the same name (left by a pre-single-file da binary) is judged by the old dir+holder staleness rules and reclaimed through the same rename-away path, so upgraded binaries never wedge on old remnants.
The returned release frees the lock name exactly once (identity-verified atomic rename-away, see releaseLock) and reports an error only when the name could not be freed; it is once-guarded (a second call returns the first call's cached error without touching the filesystem). The once-guard plus the identity check guarantee a duplicate or overdue release can never touch a lock this caller no longer owns.
func SetAcquireTimeout ¶ added in v0.5.0
SetAcquireTimeout overrides the acquire budget and returns a restore function. It is the cross-package seam for a concurrent-CONTENTION test that induces many real, slow, LIVE holds: under the race detector the runtime stretches every timing 10-20x, so on a loaded CI runner (the windows-latest leg especially, where -race runs alongside coverage) a single legitimate hold can outlast the 5s production budget even though the polling primitive is correct — no wakeup is ever lost, only latency added. Such a test widens the budget here to stay deterministic; production binaries (never built with -race) keep the 5s bound. The override MUST stay BELOW lockStaleTTL for a test that expects a timeout, else the held lock crosses the stale threshold and is reclaimed instead. Not for parallel tests — it mutates a process-global (callers here run serially).
func Update ¶ added in v0.5.0
Update runs a serialized read-modify-write against the lockfile at path: it acquires the advisory file lock, opens the CURRENT on-disk document UNDER that lock, invokes fn to stage changes (SetSection / SetInputsDigest against the just-read state), then writes atomically and releases — all inside one lock hold. This closes the lost-update window that an unsynchronized Open→read→…→Flush leaves: when two processes both read-modify-write the SAME shared section (the config/packages "units" section), a plain Flush reapplies each process's stale whole-section snapshot and silently drops the other's keys. Update makes the read happen under the same lock as the write, so each writer observes the other's committed keys and preserves them.
fn returns a non-nil error to ABORT with no write (the lock is released and the document is left untouched). fn must not call Flush/Update on the same Lockfile (the lock is not reentrant); it only stages via SetSection / SetInputsDigest and reads via Section.
Types ¶
type Lockfile ¶
type Lockfile struct {
// contains filtered or unexported fields
}
Lockfile is the in-memory view of a .agentsrc.lock document: open it, read or stage sections, then Flush. Safe for concurrent use.
func Open ¶
Open loads the lockfile at path. A missing file yields a fresh document (lock_version only); a present file is parsed, preserving every top-level key — including sections this process does not know about.
func (*Lockfile) Flush ¶
Flush writes the whole document to path atomically, preserving every section. It is callable more than once (e.g. persist config before a slow adapter activation, then flush adapters after). The parent directory must exist.
Flush is NOT a serialized read-modify-write: it acquires the advisory lock only for the write. A caller that must READ a shared section, compute a new value from it, and WRITE it back atomically — with no concurrent writer slipping in between the read and the write (the classic lost-update on a section BOTH processes write, e.g. the config/packages "units" section) — must use Update instead, which holds the lock across the whole cycle.
func (*Lockfile) InputsDigest ¶
InputsDigest returns the top-level inputs_digest and whether it was present. An absent or empty field reports ("", false).
func (*Lockfile) Section ¶
Section decodes the named section into v and reports whether it was present. An absent section returns (false, nil) so callers can treat "no section yet" and "section exists" uniformly.
func (*Lockfile) SetInputsDigest ¶
SetInputsDigest stages the top-level inputs_digest field (§7A.3): the whole-normalized hash of all local config scopes that drives staleness. An empty digest clears the field. Safe for concurrent use.