# G-095 punkcheck concurrent-writer safety: atomic saves and advisory event-scoped locking Status: proposed Scope: src/modules/punkcheck-999999.0a1.0.tm (save/load chokepoint: atomic rename, lockfile protocol, merge-on-flush, non-interactive recovery), src/make.tcl + src/modules/punk/mix/cli-999999.0a1.0.tm (event-scoped lock consumers as verified), src/tests/modules/punkcheck/testsuites/punkcheck/ (new concurrency suite) Goal: Concurrent writers to the same .punkcheck file cannot corrupt it or silently erase each other's records, and readers never observe torn content - every save is write-temp-then-atomic-rename (bounded retry for Windows sharing violations); an advisory cross-platform lockfile (open {WRONLY CREAT EXCL}, contents naming holder pid/host/installer/timestamp, bounded-retry acquisition, age-based stale-break with warning) serializes whole installer events per punkcheck root; deferred-mode flushes merge own records (INSTALLER by -name, FILEINFO by -targets) into freshly-loaded file state instead of wholesale overwrite; and the duplicate-INSTALLER sanity recovery degrades to a clean actionable error instead of an askuser prompt when stdin is non-interactive. Acceptance: a concurrency test drives two child tclsh installer processes against one .punkcheck folder - the file parses cleanly throughout (a polling load_records_from_file reader hits no TDL parse error), both installers' records are present afterwards, and no duplicate INSTALLER records exist; lock behaviour verified - the second writer observably waits, or times out with a message naming the holder from lockfile contents; stale-break verified against a planted orphan lockfile; suite passes on the project's Windows Tcl 8.6 + Tcl 9 test matrix (capability-gated where child-process facilities require it), with a unix run when an environment is available. ## Context 2026-07-19 risk review: every punkcheck writer is an unguarded read-modify-write cycle against a shared file, and the batch/deferred path holds the entire recordset in memory for the whole tree walk (seconds-to-minutes of checksumming) before a truncate-in-place rewrite of a MB-scale file. Failure modes ranked by operational pain: 1. Duplicate INSTALLER/FILEINFO records from interleaved insert cycles - the next run either aborts (records_as_target_dict errors in targetset_init) or demands interactive confirmation to delete the file (installtrack's duplicate-INSTALLER sanity check via punk::lib::askuser) - which blocks or errors unattended `-confirm 0` builds. 2. Torn reads - save truncates in place, so a concurrent reader (another installer's load, or punkcheck::cli status) can parse a half-written file and abort. 3. Silent lost updates - mildest: .punkcheck is an expendable skip-optimization cache, so losses mean redundant recopies next run. The lock's larger value: two processes racing one .punkcheck are also racing `file copy` into the same target tree - serializing the installer event serializes the real hazard, not just the metadata. Current avoidance is convention only: per-target-root file partitioning, strictly sequential make.tcl stages that re-load at stage boundaries, and single-user build habits. Nothing technical prevents overlap (two builds, or user + agent). ## Approach - Builds on G-094's single save chokepoint (achieved 2026-07-21 - see goals/archive/G-094-punkcheck-single-lifecycle.md): every record-lifecycle write now funnels through installtrack method save_working_recordset - lock, atomic rename and merge-on-flush are implemented once there, inherited by every path (eager per-op saves, start_event registration, deferred flush). - Lock scope: the whole installer event (start_event through final flush/end; batch install = the whole depth-0 call), sibling lockfile `.punkcheck.lock` in the punkcheck folder. - Acquisition: `open ... {WRONLY CREAT EXCL}` (atomic create-new on Windows and POSIX, Tcl 8.6 and 9, no extra packages); hold the channel open for the duration (on Windows an open handle also resists deletion); on contention retry with backoff to a timeout, then fail with a message quoting the holder identity from the lockfile. - Stale locks: age-based break with warning as the portable baseline; optional liveness hardening behind capability checks (twapi process_exists on Windows, kill -0 probe on unix). - Optimistic tripwire independent of the lock: capture a file signature (mtime/size) at load, verify at flush; mismatch means a writer bypassed the protocol (e.g. an older vendored punkcheck during the transition window) - reload-and-merge or warn loudly rather than silently overwrite. ## Alternatives considered - SQLite storage - rejected for now (user decision 2026-07-19): the TDL text format's easy human readability/debugability is an overriding factor. Reconsideration requires all three: performance becomes an actual problem; sqlite is determined to provide an overwhelming advantage in that situation; and good debugging/viewing tools are built alongside the migration. (sqlite3 is also only conditionally available across the target interps.) - mkdir-as-mutex - not implementable in Tcl: `file mkdir` succeeds silently on an existing directory, so there is no atomic test-and-set. - TclX flock / OS byte-range locks / twapi-exclusive CreateFile - not cross-platform across the 8.6+9 win/unix envelope; mandatory locking would also punish read-only consumers (punkcheck::cli status). ## Notes - Expendability is tiered, not absolute (2026-07-19 review): in build trees (modules/, lib/) deletion costs only redundant recopies - overwrite modes there are source-driven and targets are clobber-by-design; for synced-targets consumers (the bin/ deploy) deletion wedges existing targets into skip-until-manual-action - fail-safe but stuck, and not self-healing, since skipped records never store target cksums; in derived projects the G-027 committed-baseline direction keeps safe-overwrite classification working after record loss. The invariant to preserve across all tiers: absent records degrade to skip/report-confirm, never to overwrite. Within those consequence bounds, deletion stays the documented last-resort recovery; locking does not make the file precious. - The installtrack constructor's deletion prompt ("It is safe to delete .punkcheck files...") oversells for synced-targets consumers - add the wedge caveat to that message when it is touched under this goal (G-094 landed without touching it). - G-094 (archived) recorded direct evidence for this goal during its verification: one transient targetset_init -> save_working_recordset save failure on the first post-re-vendor build with a second session active in the same checkout (not reproducible; subsequent runs clean) - see the Progress section of goals/archive/G-094-punkcheck-single-lifecycle.md. - G-094 (archived) carried the punkcheck MAX_PATH candidate as a load/save consolidation rider (deep relative source chains exceed Windows path limits, unnormalized - first recorded by G-087) and did NOT address it; this goal's chokepoint work is the next natural carrier - see goals/archive/G-094-punkcheck-single-lifecycle.md and goals/archive/G-087-thin-project-layouts.md. - Advisory protocol is only effective once all writers carry it - re-vendor bootsupport immediately after landing; the signature tripwire covers the transition window. - Readers (punkcheck::cli status/paths) gain torn-read immunity from atomic renames without any reader-side change. - Related: G-028 (Restart Manager locker naming for OS-mandatory locks on deploy targets) - same "name the blocker" diagnostic principle; this goal's timeout message naming the lock holder is the advisory-lock sibling. - Related: G-027 - a derived-project pull writing .punkcheck files while builds run in that project is a future instance of exactly this scenario.