Documentation
¶
Overview ¶
Package pidfile is the watcher's single-instance guard (plan 6.6, the `${BRIGADE_STATE_DIR}/watchers/<claude_pid>.json` row of 3.2, corrected by E0-5 items 1 and 3; package tree 7.1).
One pidfile per Claude Code process names the watcher serving it: the watcher's pid, the start token that identifies THAT incarnation of the pid (internal/procutil), the Brigade session it serves, the socket it posts to, and the SHA-256 of the messaging token it was spawned with — never the token itself (D9: the hash is enough for the next SessionStart hook to notice a rotated token and respawn; the token is never written to any file, 3.2).
The file is created O_EXCL so two hooks racing to spawn cannot both win, and it is removed by CONTENT: Remove unlinks only a file that still holds exactly the entry the caller wrote (E0-5 item 3 — the replace flow leaves the superseded watcher running, and its cleanup must not delete the file that by then belongs to its replacement). Liveness is judged by Alive from a procutil.Info, which reads the process STATE: a zombie reads as dead even though kill(pid, 0) still succeeds on it (E0-5 item 1), a foreign pid reads as reused, and a start token that differs byte for byte reads as reused.
The package has no policy about WHEN to spawn, replace or exit — that is the hook's (P3-4) and the watcher's (P3-5). It reads and writes one file and judges one entry.
Index ¶
- Variables
- func Alive(e Entry, info procutil.Info) bool
- func Create(path string, e Entry) error
- func Encode(e Entry) []byte
- func Path(stateDir string, claudePID int) string
- func Remove(path string, e Entry) (bool, error)
- func Replace(path string, old, latest Entry) error
- func TokenSHA256(token string) string
- type Entry
- type LookupFunc
- type Verdict
Constants ¶
This section is empty.
Variables ¶
var ( // ErrMalformed reports a file (or an Entry handed to [Create]) that is // not a pidfile: unparseable, oversized, a non-positive pid, or an // empty start token. ErrMalformed = errors.New("pidfile: malformed entry") // ErrSuperseded is returned by [Replace] when the file no longer holds // the entry the caller expected to retire: another process replaced it // first, and this caller's new entry must not be written over it. ErrSuperseded = errors.New("pidfile: the file holds a different entry") )
Functions ¶
func Alive ¶
Alive judges whether e's watcher is still the process the file describes: the pid exists, it is not a zombie (E0-5 item 1), it is not another user's (EPERM is treated as pid reuse, 6.6), and its current start token equals the stored one byte for byte (the pid-reuse guard). An Info with an empty token — the process could not be read — is never alive. When info carries a PID it must be e's; an Info built by hand with PID 0 skips that cross-check.
func Create ¶
Create writes e to path with O_CREATE|O_EXCL and mode 0600, creating the 0700 parent directory when it is missing. An existing file — a live or stale predecessor — fails with an error for which errors.Is(err, fs.ErrExist) holds; the caller then runs its liveness check (Check) and, when the holder is dead, Replace. Create never deletes anything.
func Encode ¶
Encode renders e as the exact bytes Create writes: one JSON object with every member, followed by a newline. Remove compares against these bytes, so an entry re-encoded from the same field values always matches the file it wrote.
func Path ¶
Path is the pidfile location for a Claude Code pid: `${stateDir}/watchers/<claudePID>.json` (3.2). The pid is an integer the caller has already parsed, so no hostile value can reach the path.
func Remove ¶
Remove unlinks path ONLY when the file still holds exactly Encode(e), and reports whether it did. A missing file and a file with other content both return (false, nil): in either case the file is not this entry's to remove. Compare-then-delete is required, not a courtesy (E0-5 item 3).
func Replace ¶
Replace retires old and writes latest in its place: Remove(path, old), then Create(path, latest). When the file exists but no longer holds old, nothing is written and ErrSuperseded is returned — someone else replaced it first and the caller must re-run its liveness check on the new holder. A file that vanished between the caller's read and this call is not an error: the holder cleaned up, and latest is created. O_EXCL in Create remains the final arbiter of the race.
func TokenSHA256 ¶
TokenSHA256 is the lowercase hex SHA-256 of token: the only form in which the messaging token is ever compared without being stored (D9, 3.2). It is what goes in Entry.TokenSHA256 and what the next hook computes from its own environment to detect a rotated token.
Types ¶
type Entry ¶
type Entry struct {
// PID is the watcher's own pid.
PID int `json:"pid"`
// StartToken is procutil.Info.StartToken for PID at the time the file
// was written; compared byte for byte, never parsed.
StartToken string `json:"start_token"`
// BrigadeSessionID is the session the watcher serves.
BrigadeSessionID string `json:"brigade_session_id"`
// SocketPath is the inbox socket the watcher posts to.
SocketPath string `json:"socket_path"`
// TokenSHA256 is [TokenSHA256] of the messaging token the watcher was
// spawned with, or empty in sink mode where there is no token.
TokenSHA256 string `json:"token_sha256"`
}
Entry is the pidfile's content (3.2). Field order is the wire order; every member is always present so the encoding is one fixed shape and Remove's byte comparison has nothing to be surprised by.
func Read ¶
Read loads the entry at path. A missing file is reported with an error for which errors.Is(err, fs.ErrNotExist) holds. The file must be a regular file (not a symlink, not a FIFO that would block the reader), at most maxBytes, mode 0600 — adapterkit.ReadStrict refuses anything group- or world-accessible with `config` — and a JSON object with a positive pid and a non-empty start token (ErrMalformed otherwise).
type LookupFunc ¶
LookupFunc is procutil.Lookup's shape. Check takes one so the hook's and the watcher's tests can inject process facts instead of starting processes (plan 7.3: every side effect injectable).
type Verdict ¶
type Verdict struct {
// Entry is the file's content when Found.
Entry Entry
// Found is false when there is no pidfile at all.
Found bool
// Alive is [Alive](Entry, lookup(Entry.PID)); false when not Found.
Alive bool
}
Verdict is Check's answer.
func Check ¶
func Check(path string, lookup LookupFunc) (Verdict, error)
Check reads the pidfile at path and judges its holder through lookup. A missing file is (Verdict{}, nil). A file that cannot be read or parsed — the wrong mode, a symlink, garbage — is an error the caller reports rather than a "dead" verdict, because Replace could not retire it by content anyway. A lookup error is returned with Found=true and Alive=false so the caller can see which pid failed.