13 KiB
G-095 punkcheck concurrent-writer safety: atomic saves and advisory event-scoped locking
Status: achieved 2026-07-21 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:
- 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 0builds. - 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.
- 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.lockin 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 mkdirsucceeds 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.
Progress
- 2026-07-21 landed in full (punkcheck 0.6.0; bootsupport re-vendored the same day per the
advisory-protocol transition note; no consumer code changes were needed - see verification):
- Atomic saves: save_records_to_file is write-temp-then-atomic-rename (same-directory temp, bounded-backoff retry for Windows sharing violations, temp cleanup on failure plus age-gated sweep of abandoned temps). Complement found during testing: a reader's open can transiently be denied on Windows while a writer's MoveFileEx replace is in flight, so load_records_from_file retries transient read failures (bounded ~2s) while leaving TDL parse errors strict - they now always indicate real corruption.
- Advisory lock: new punkcheck::lock namespace (acquire/release PUNKARGS-documented). Sibling .lock via open {WRONLY CREAT EXCL}; contents name holder pid/host/installer/timestamp; the channel is held open for the duration; in-process re-acquisition is reference-counted (sequential/nested installers over one root in one process - the make.tcl pattern - cannot self-deadlock). start_event acquires; event end and installtrack destroy release; flush and chokepoint saves outside an event take a transient lock. Contention: backoff retry to -timeout (default 15000ms, env PUNKCHECK_LOCK_TIMEOUT) then an error with errorcode {PUNKCHECK LOCK TIMEOUT } quoting the holder. Stale-break: provably-dead holder pid on this host (twapi process_exists / kill -0, capability-gated) breaks immediately; age beyond -staleage (default 300s, env PUNKCHECK_LOCK_STALEAGE) is the portable baseline. Warnings go to -warnchannel.
- Merge-on-flush: flush re-loads the file under the lock and carries over only this installer's own records - the INSTALLER record by -name and touched FILEINFO records by -targets (tracked per instance as events merge finalized records) - preserving other writers' records instead of wholesale overwrite.
- Tripwire: mtime/size signature captured at load, checked at the save chokepoint; mismatch warns loudly (protocol-bypassing writer, e.g. an older vendored punkcheck) and proceeds.
- Non-interactive recovery: the duplicate-INSTALLER sanity check errors cleanly (actionable message naming the duplicate and the recovery path) instead of prompting, when punkcheck::lib::stdin_is_interactive reports non-interactive - strict twapi GetConsoleMode on the real stdin handle on Windows (twapi::get_console_handle is unsuitable: it succeeds for piped children of console shells), -inputmode channel-option probe then test -t 0 on unix, defaulting to non-interactive when no probe exists. The interactive prompt gains the synced-targets deletion-wedge caveat (per this goal's note).
- default_excludefiletail_core adds .punkcheck.* so lock/temp protocol siblings are never treated as installable content (pinned).
- Acceptance verification (2026-07-21):
- concurrency.test (new, 6 tests): two child installer processes over one .punkcheck folder with a polling load_records_from_file reader - zero parse errors, both INSTALLER records present, no duplicates, all 12 targetset records present, no lock/temp leftovers; lock contention - a short-timeout second writer errors naming HOLDER-CHILD from the lockfile then acquires successfully after release; stale-break against planted orphan lockfiles - fresh lockfile naming a provably-dead pid (liveness break, warning names the ghost) and hour-old lockfile from another host (age break at -staleage 5); duplicate-INSTALLER recovery in a pipe-stdin child errors cleanly with no hang; protocol-sibling exclusion.
- Matrix: Windows Tcl 9.0.3 - all four punkcheck suites 84/84 via runtests; Windows Tcl 8.6.17 - 84/84 via a direct tcltest drive (the runtests harness itself cannot boot under native 8.6: its two-stage boot hits missing lpop before compat shims load, and the punk86 kit runtests route fails on kit/dev punk::args-vs-punk::path skew - both pre-existing, outside this goal; the punkcheck module and suites are fully 8.6-clean); unix - linux WSL tclsh 8.6.14, all four suites 84/84 from a native-filesystem staged run (kill -0 liveness and test -t 0 paths exercised for real).
- Consumers verified without edits: punk::mix::cli and five make.tcl installer sites end their events (releasing the lock); make.tcl's vfs/bin kit flows destroy their installers on every path including FAILED branches (destructor releases). punk/mix subtree suites 150 pass / 1 pre-existing constraint-skip.
- Real-build exercise under re-vendored 0.6.0: make.tcl modules and a full make.tcl project complete exit 0 with zero lock timeouts, zero tripwire warnings, unchanged skip/copy decisions, all kit installs skipping 'no change detected', and no .punkcheck.lock or .punkcheck.tmp.* leftovers in the tree.
- Not addressed (explicitly out of this goal's acceptance): the punkcheck MAX_PATH load/save normalization rider inherited from G-087/G-094 - with this goal archived it has no live carrier and was flagged to the user as a candidate goal.