Repository navigation
B6 P2: the momentary lock lives in the git dir (ADR-0042 §10a A1) - #752
Conversation
|
Plan amendment, lookup mechanism (measured before code). pyrite-worker (automated agent). |
d952bdf to
1691e10
Compare
|
Maintainer decision (2026-10-06, via the conductor's retro): the three calls the worker made when its plan went unanswered are accepted.
|
…e (maintainer, 2026-10-07) After the #752 cold read: sha256(realpath) gave one file three stripes on macOS (its path, a hard link, the /System/Volumes/Data firmlink), and lost updates. The key is now the entry's parent directory (st_dev, st_ino) plus its casefolded NFC name, and expect= writes to a file with more than one hard link are refused. Decision 10's stale per-user lock-dir text now points at A1/A2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stripe key was sha256(realpath): on macOS the firmlinked /System/Volumes/Data path and a hard link each got another stripe, so two writers of one file held two locks (the #752 cold read lost 64 of 600 updates). The key is now the parent directory's (st_dev, st_ino) plus the NFC-casefolded name, and expect= writes to a file with more than one hard link raise HardLinked (checked under the lock). Every lock wait is bounded: an unbounded timeout (None, 0, inf, nan) is a ValueError, since a stopped holder keeps its lock. Tests: one parametrised class over seven alias kinds (symlinked dir and file, .. segments, case, NFD, firmlink, GIT_DIR), each a held-lock check and a 4-process chain through atomic_write_text(expect=); the fork test polls waitpid against a deadline; the thread test has one group deadline. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…after the #752 cold read) The lock key is the entry's parent directory (st_dev, st_ino) plus its name folded as NFD(casefold(NFD(name))), Unicode's canonical caseless match: sha256(realpath) gave one file three stripes on macOS (its path, a hard link, the /System/Volumes/Data firmlink), and NFC before the fold gave two keys for one APFS name (U+0390, U+1FB7). The key is rechecked under the lock. expect= writes to a file with more than one hard link are refused, the link count read under the lock. Unbounded waits are rejected. Decision 10's per-user lock-dir text and spike 3's acceptance text now point at A1 and A2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… §10a A1, A2, B6 P2) Where: lock_dir_for(path) is the one answer, <git-dir>/pyrite/locks of the work tree holding the file, beside git's index.lock. It follows git's discovery (gitfile, commondir, HEAD validity; bare and inside-git-dir refused; stop at a filesystem boundary) without running git (22-27 ms a call on macOS) and without reading the environment: with GIT_DIR exported, git rev-parse names another repo for the same file. The tests compare with git rev-parse on every adversary layout. A KB outside git, a read-only git dir and a move across repositories are refused. Lock dirs take the git dir's mode, never wider. Which lock: lock_key is the parent directory's (st_dev, st_ino) plus the name folded by canonical caseless match, so every path to a file (symlink, .., firmlink, case, NFC/NFD, GIT_DIR) takes one lock; it is rechecked under the lock in case the directory was replaced. expect= writes to hard-linked files raise HardLinked. Every wait is bounded; unbounded timeouts are a ValueError. atomic_write_text(expect=) compares under the lock immediately before the rename, and before the truncate on the in-place paths (A5); FileChanged and LockTimeout are typed; stripes are 1024 files, fork-safe. The process tests have a start barrier, a group deadline and a writer deadline, so they fail in bounded time without the change. Nothing calls expect= yet (P3). Refs #730 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3b86eec to
e326cfd
Compare
…-wiki#770) On pyrite-wiki#752 a local verify-red run spun for 2h01m because a retry loop never ended once its guard was removed; nothing bounded a hung test. CI's slowest single test is ~40 s, so 600 s is a ceiling no test needs. A structural test fails if the dependency or the setting is removed or raised above 600. Fixes pyrite-wiki#766 Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
…ectory-swap limit (#774) Maintainer decisions 2026-10-08 after the final #752 cold read: the A2 refinements (fold, recheck, link count under the lock, bounded waits) are accepted under his name; the decision-10 and plan edits are kept, with the msvcrt line made consistent ('Windows is refused'). The in-place path does not fail closed on a directory swapped after the recheck: documented, #774. Text only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Refs #730
B6 P2, rebuilt on ADR-0042 §10a A1 as amended in #748: the momentary lock lives at
<git-dir>/pyrite/locks. Supersedes #733 (closed); its compare, stripes, fork-safety,expectvalidation and process tests carry over.Plan (before code)
Riskiest assumption, tested first
Is "the git dir for a file" a function of the path alone? Not if you ask git naively. In a probe repo
a/kb,git rev-parse --absolute-git-dirprintsa/.git, but withGIT_DIR=b/.gitin the environment it printsb/.git. A process run from a git hook, or a shell withGIT_DIRexported, would lock in a different directory from every other writer of the same file. That is the umask-002 failure #733 died of: one file, two lock dirs, failing open.Decision: the lookup runs
git rev-parsefrom the file's real directory with everyGIT_*variable removed from its environment. The answer then depends on the path . The reference for the tests is that same command run by the test itself, plus the literal path git's documented layout predicts. Cost measured: about 14 ms per call from a shell loop. It runs before the lock is taken, never while holding it. No cache, because a cache keyed by directory would go stale when someone runsgit initin a subdirectory.Goal, as properties
lock_dir_for(path) -> Pathis the only place that maps a file to its lock dir.file_lockcalls it, and nothing takes a lock-dir override (noPYRITE_LOCK_DIR, nolock_dir_path=).<absolute per-worktree git dir>/pyrite/locksfor the repository whose work tree holdsrealpath(path), discovered from the real parent directory. It agrees withgit rev-parse --absolute-git-dir(clean env) on every adversary case.NotInGitRepository, aLockDirError): no repository; a path inside a git dir or a bare repo (--is-inside-work-treefalse); git not installed; a git dir this user cannot write; Windows; two paths (a move) in different git dirs.pyrite/andlocks/are created withmkdirrelative to a git-dir fd, thenfchmodto the git dir's own mode bits (setgid kept, so the group follows a shared repo, independent of umask). A stripe file is readable and writable only by the classes that can write the git dir (0755 -> 0600, 2775 -> 0660). An existingpyrite/orlocks/that grants write to a class the git dir does not is refused. Each is openedO_NOFOLLOW|O_DIRECTORY, and stripesO_NOFOLLOW|O_EXCL, with an fstat regular-file check, relative to the verified directory fd. All of this is carried over from B6 P2: the momentary file lock and the compare (ADR-0042 decision 10) #733.expecttyping;FileChanged/LockTimeout; a bounded wait (LOCK_TIMEOUT10 s);register_at_fork; N=1024 stripes, case- and NFC-folded.flockerror other than EWOULDBLOCK (ENOTSUP/ENOLCK on some network filesystems) raisesLockDirErrornaming the filesystem, rather than spinning until the timeout. The docstring notes the NFS flock caveat.Worktree choice
Per-worktree git dir (
--absolute-git-dir), not--git-common-dir. Each worktree has its own files, and a real path belongs to exactly one work tree, so two writers of one file always discover the same per-worktree dir. It is also where git puts that worktree'sindex.lock.git worktree removedeletes the locks along with the files they guarded.Tests (each adversary is a test; expected values come from git, run in the test)
.git(refused), a submodule (KB in the submodule gets.git/modules/sub; a file in the superproject gets the superproject.git), a nested non-submodule repo, a symlinked KB path, a symlinked.gitdir,GIT_DIR/GIT_WORK_TREE/GIT_CEILING_DIRECTORIESset to mislead (ignored), and no repo (refused, message says "not in a git repository").locks/(0777 under a 0755 git dir) is refused.GIT_DIRpointing at another repo), and they form one chain.Must not change
atomic_write_textwithoutexpecttakes no lock and makes no git call (the test asserts no subprocess). Nothing callsexpect=yet.Out of scope
Wiring (P3); the
index healthwarning; Windows (refused); a per-user fallback for non-git KBs.Open questions (proceeding on the answer shown unless told otherwise)
git rev-parseon aGIT_DIRoverride. I agree with it with the override stripped, because honouring the env is the two-lock-dir failure. Proceeding.Report (pushed 53f4dc1 == origin/feature/b6-p2-git-dir-lock, on dev 7d75837)
Commits (3):
09ffd96dfeat: the momentary lock lives in the git dir (ADR-0042 §10a A1). Addspyrite/utils/file_lock.pyandatomic_write_text(expect=), with tests and a changelog fragment.fb4f1729kb: the component dockb/components/momentary-file-lock.md.1691e106docs: the hallway entryfeedback/2026-10-05-b6-p2-git-dir-lock.md.53f4dc15test: the lock tests fail in bounded time when their guard is removed (the verify-red spin; see below).Closes: none. Refs #730 (P2 of B6; wiring is P3).
Evidence
test (3.12)passed (16m29s),verify-redpassed (8m10s),gatepassed. CI on 53f4dc1 is pending; that commit changes onlytests/test_file_lock.py.verify-red: 3 red · 29 import-only · 0 unexpected pass · 23 n/a. The module is new, so most tests are import-only, and the mutation table below is the behavioural evidence. After 53f4dc1, against dev's code, all 55 tests fail in 54.6 s of pytest time (3 min wall) in a throwaway worktree; before, they spun for 2h+.tests/test_file_lock.py: 55 passed at-n 4(about 10 s awake).test_file_lock,test_config_atomic_save,test_entry_save_atomic,test_file_operations,test_mcp_setup_reads_back,test_doc_write_as_patch): 640 passed, 8 xfailed in 73.7 s at-n 4.--no-verify). The full affected set (382 files) did not finish locally; the cause is under "The stall" below. No production code passesexpect=(grep -rn "atomic_write_text(.*expect" pyrite extensionsis empty). Withoutexpect,atomic_write_texttakes dev's path:guard()yields at once, with no lookup and no lock, andtest_without_expect_nothing_is_locked_or_looked_uppins that. The 382 files reach the change only through an import. PR CI is the authority.git rev-parse --is-inside-work-tree --absolute-git-dir(env cleared). All 13 agree: detached HEAD, sha256 detached HEAD,--separate-git-dir, a worktree added from a worktree, a pruned worktree (both refuse), a Unicode path with spaces, an empty HEAD, a missingobjects/, an inner repo with a garbage HEAD (both fall through to the outer), a.gitsymlink to a gitfile, a CRLF gitfile, a file under.git/worktrees/x, andrefsas a file.ruff checkandruff format --checkclean on the three files.TestWhereTheLockIsandTestPermissions, vialock_dir_forandfile_lock;TestChains(spawned interpreters: 8x8 one key, 8 keys, and 4 writers on 4 paths to one file, one withGIT_DIRset to another repo) andTestKilledHolder(SIGKILL);TestInProcessLock;test_a_forked_child_does_not_inherit_a_locked_process_lock;atomic_write_text:TestCompare.expect=until P3); a second real filesystem (the boundary is simulated by fakingst_dev, and the ENOLCK path by fakingflock); a second OS user (simulated with modes; no second account available).The stall (conductor's question): not a lock, and not the tests
pytest timed the session at 73.7 s.
timemeasured 49m41s of wall clock and 2m14s of user CPU. pytest's clock (perf_counter, mach_absolute_time) does not advance while macOS sleeps;psetime andtimedo. Swap stood at 8.0 of 9.2 GB, with a load average of 11 to 16 on 10 cores. Asampleof a "stuck" worker showed unmarshal (imports) and GC, notflock. No worker had a child waiting on a lock, and the only long-lived children were the pre-existing fakeclaudeprocesses intest_mcp_setup_reads_back.py, which sleep by design. The lock cannot be reached by these tests: the onlyexpect=callers are intests/test_file_lock.py, and each of those tests makes its own repository undertmp_path, so no two xdist workers share a git dir. No design finding. The property that holds: the lock is taken only byexpect=writes, and only for the compare and rename. Nothing holds it across a subprocess, and a child never re-enters a parent's stripe (file_lockis not reentrant; see its docstring). ADR-0042 decision 10 covers this ("never held while work is done").The verify-red spin (conductor's finding): a test bug, now fixed in 53f4dc1
The test file fell back to
FileChanged = getattr(aw, "FileChanged", Exception)so that it would collect without the change. Without the change, every retry loop (_set_key, the increment worker) therefore caught theTypeErrorofexpect=as a conflict and retried forever. The thread test's non-daemon threads then kept pytest alive past its join timeout. This was not the lock: the lock is absent in that mode. Fix: a never-raised stand-in exception; one writer deadline (_WRITER_DEADLINE, 90 s, inside the existing 180 s group deadline and the start barrier) checked on every retry; daemon threads. Property: a lock test whose guard is removed fails in bounded time.Guards: delete the guard alone, and this test fails
33 mutations were applied one at a time, each with the whole file run at
-n 4; all 33 went red.GIT_DIR)test_the_environment_does_not_move_the_lock,test_writers_reaching_one_file_by_different_paths_form_one_chain.gitfile) handling.gitdir must be a repositorytest_an_invalid_dot_git_directory_is_skipped_as_git_skips_it,..._invalid_head_...test_a_repository_with_an_invalid_head_is_skipped_as_git_skips_itcommondirtest_a_linked_worktree_locks_in_its_own_git_dirtest_a_bare_repository_is_refused,test_a_file_inside_the_git_dir_is_refusedtest_discovery_stops_at_a_filesystem_boundarytest_a_symlinked_kb_path,test_a_symlinked_entry_locks_where_its_bytes_livetest_a_read_only_git_dir_is_refused_and_nothing_createdTestPermissionstest_a_lock_dir_wider_than_the_git_dir_is_refusedtest_a_group_shared_git_dir_gives_a_group_lock_dir_whatever_the_umasktest_a_symlinked_lock_dir_is_refused,test_a_symlinked_pyrite_dir_is_refusedtest_a_symlinked_stripe_is_refused_and_its_target_untouchedtest_an_existing_stripe_keeps_its_modeand the chainstest_a_stripe_that_is_not_a_regular_file_is_refusedtest_a_stripe_opens_relative_to_the_verified_directorytest_a_filesystem_without_flock_fails_at_once[5.0]test_a_move_across_two_repositories_is_refused% STRIPES)test_lock_files_are_boundedand otherstest_stripes_fold_case_and_unicode_form, the case-path teststest_a_forked_child_does_not_inherit_a_locked_process_locktest_threads_exclude_each_other_when_the_os_lock_does_notand the chainstest_sigkill_...atomic_writeTestComparetest_a_wait_for_the_lock_is_boundedexpectstr / type validationtest_text_is_not_mistaken_for_a_digestexpecttest_without_expect_nothing_is_locked_or_looked_up,test_a_kb_outside_git_is_refused_for_expect_and_written_withoutCovers: "every caller uses one predicate"
lock_dir_forcallers:file_lockonly (grep -rn lock_dir_for pyrite extensions). Guarded:test_file_lock_asks_lock_dir_for_and_nothing_else, which also asserts there is nolock_dir_path=argument, noLOCK_DIR_ENVand nodefault_lock_dir.file_lockcallers:atomic_write_text'sguard()only. Guarded:TestCompare.atomic_write_text(expect=)callers: none. Out of scope: P3.Learned
GIT_DIRexported,git rev-parsenames another repository for the same file, so "agree with git on a GIT_DIR override" and "one file, one lock dir" cannot both hold. The lookup reads no environment.git rev-parsecosts 22 to 27 ms a call on macOS (/usr/bin/gitis the xcrun shim), and decision 12 puts every write on this path. The lookup is therefore pure, following git'ssetup_git_directory_gently_1: at each level a.gitdir or gitfile comes first, then the directory itself as a git dir; a filesystem boundary stops discovery..gitdirectory and keeps walking up, but fails on an invalid.gitfile. The two cases differ, and both are pinned by tests.index.lockthere.perf_countersession time was 73.7 s against 49 min of wall clock.getattr(module, name, Exception)as a collect-without-the-change fallback turnsexcept Name:intoexcept Exception:. In a retry loop that loops forever under verify-red.Captured in
test_the_environment_does_not_move_the_lock, the module docstring ("How the git dir is found"), and the hallway note.test_an_invalid_dot_git_directory_is_skipped_as_git_skips_it,test_an_invalid_gitfile_is_refused_as_git_refuses_it.test_a_linked_worktree_locks_in_its_own_git_dir, the module docstring, andkb/components/momentary-file-lock.md.test_a_lock_dir_wider_than_the_git_dir_is_refused, the_wider_thandocstring, and the component doc.gotchas.md; the conductor's call._NeverRaiseddocstring and_WRITER_DEADLINEcomment intests/test_file_lock.py. Worth a line in pyrite-dev'stdd.mdnext to the collect-without-the-change pattern; the conductor's call.Unsure (look twice)
GIT_DIR(Learned 1). I chose the path-only answer. If the maintainer wants the environment honoured, two lock dirs per file come back.core.worktreeandcore.barein config are not replicated; a KB reachable only through them is refused.Left
index healthwarning, and Windows support are out of scope.Fix round 1 (pushed 3b86eec == origin/feature/b6-p2-git-dir-lock, on dev 7d75837)
Property: one file has one lock, whatever path reaches it.
Commits:
88dc4f85kb(adr-0042): amends A2 with the maintainer's 2026-10-07 key and the hard-link refusal. Decision 10's stale per-user lock-dir text now points to A1/A2.2bd54df8fix: the key is the parent directory's(st_dev, st_ino)plus the NFC-casefolded name (lock_key).expect=raisesHardLinkedwhennlink > 1, checked under the lock. Every wait is bounded. Includes the alias-class tests.3b86eec1docs: the component doc and changelog name the key and the remaining limits.Alias classes (Covers)
file_lock/atomic_write_text(expect=))TestOneFileOneLock::*[symlinked directory]*[symlinked file]..segments*[dot-dot segments]*[case spelling]*[unicode NFD spelling]/System/Volumes/Data*[firmlink /System/Volumes/Data]GIT_DIRnaming another repo*[GIT_DIR naming another repo]HardLinked): another name for the inode would be another keytest_an_expect_write_to_a_hard_linked_file_is_refused,test_a_hard_link_made_after_the_first_check_is_still_refusedst_dev/st_ino, so the same key; the lock dir is the same physical directoryst_dev(e.g. two NFS mounts of one export)st_devfrom the key would close it, because one lock dir already scopes to one filesystem (discovery stops at a boundary); the maintainer's decision keeps itcore.worktree/GIT_DIR-only work treeNotInGitRepository), as beforeTestWhereTheLockIsEach class has two tests:
test_the_lock_held_by_one_path_is_held_for_the_alias: a holder process holds the lock through the direct path, and a second process taking it through the alias must time out.test_writers_on_a_path_and_its_alias_form_one_chain: 4 processes, 2 per path, 10 writes each throughatomic_write_text(expect=), forming one chain.Bounded failure
file_lock(timeout=None)andatomic_write_text(lock_timeout=None), plus0, negative,infandnan, are aValueError(check_timeout). Capping would silently change what a caller asked for; rejecting says so. Both default toLOCK_TIMEOUT(10 s). The blockingflockpath is gone. Test:test_an_unbounded_wait_is_rejected[...].waitpid(WNOHANG)against a 30 s deadline and kills the child on expiry.TestInProcessLockjoins its threads against one group deadline.Evidence
-n 4 -o faulthandler_timeout=600:tests/test_file_lock.py74 passed, 0 skipped (all 7 alias classes ran on macOS APFS).check_timeout.Guards (added this round)
(st_dev, st_ino)+ folded name*[firmlink /System/Volumes/Data](both)nlink > 1refused under the lockcheck_timeouttest_an_unbounded_wait_is_rejected[None, 0, -1.0, inf, nan]Learned
realpathdoes not unify the/System/Volumes/Datafirmlink with/private/var. The parent directory's(dev, ino)does; I checked it withos.stat..., case and NFD were already unified by realpath and casefold. The bug sat in the one class the round-0 adversary list did not name.Captured in
lock_key's docstring and the module docstring ("One file, one lock"), ADR-0042 §10a A2, andkb/components/momentary-file-lock.md.TestOneFileOneLock, parametrised overALIAS_CLASSES. A new alias class is one line there.HardLinked's docstring,test_a_hard_link_made_after_the_first_check_is_still_refused, and the comment at the link check inatomic_write.py.Unsure
st_dev(see the table): a choice between the maintainer's key and droppingst_dev.Left
Nothing from this round.
Fix round 1 (2026-10-08): one file, one lock, whatever path reaches it
Pushed: 3b86eec (88dc4f8 ADR-0042 A2 amended per the maintainer's 2026-10-07 decision; 2bd54df the fix; 3b86eec docs). CI: run 37709287689.
expect=writes to a file with nlink > 1 are refused, and the link count is checked under the lock. Unbounded waits (None, 0, negative, inf, nan) are rejected.atomic_write_text(expect=)with real processes: symlinked directory, symlinked file,.., case, NFD, the/System/Volumes/Datafirmlink, and GIT_DIR naming another repo. Refused: hard links, and a KB reachable only through core.worktree or GIT_DIR. Out of scope, by reasoning: a bind mount (same key) and a second mount with another st_dev.Learned: only the firmlink broke the old realpath key. No directory-entry key can unify a hard link, so refusal is the answer.
Unsure: dropping st_dev from the key would also cover a second mount of one filesystem.
Fix round 2 (pushed e326cfd == origin/feature/b6-p2-git-dir-lock, on dev f30f4b3)
History rewritten into four commits from dev. No commit contains the passages tracked in pyrite-security#98.
git log -p origin/dev..HEADhas no match for them, and "read-only" appears only for the read-only git dir refusal. Content is identical to the pre-rewrite head (git diffempty). SHAs named in earlier sections of this body are from before the rewrite.88bfd718kb(adr-0042): A2 lock key and bounded waits (maintainer, 2026-10-07)f5904ea6feat: the momentary lock, in the git dir, one lock per file84d168fakb: the component doce326cfd7docs: the hallway entry1. Fold:
NFD(casefold(NFD(name))), notNFC(casefold(NFC))NFC(casefold(NFC))as specified fixes U+0390 but still splits U+1FB7: its title case ᾼ͂ (U+0391 U+0342 U+0345) is the same file to APFS, yet NFC first composes it to U+1FBC, whose casefold puts the iota before the perispomeni. Unicode's own definition, canonical caseless matching (D145), isNFD(casefold(NFD(X))). The coordinator's brute force (p752r1/fold.py, U+0020 to U+2FFFF, 16,310 spelling pairs) finds 0 APFS-same pairs keyed differently with it.greek.py's race now loses 0 of 600.TestFoldThenNormalise: parametrised over every code point whereNFC(NFC(c).casefold()) != NFC(c).casefold(), generated fromunicodedataat test time (26 in Unicode 15.0). Each case asserts that NFC/NFD spellings key equally everywhere, and that every case spelling the filesystem opens as the same file keys equally. Before the fix, 12 of the 26 were red on APFS.ALIAS_CLASSESgains "Greek dialytika-tonos spelling (U+0390 / U+0399 U+0308 U+0301)". It runs as a real-process held-lock test and a 4-process chain throughatomic_write_text(expect=), both red before the fix. The fixture's name now carries ΐ, which also made the case-spelling class red before the fix.2. Bounded waits
check_timeoutrejects withValueError: anything that is not anint/float(None,"5",True), a value that cannot become a float (10**400), and a value that is not finite, is not above zero, or is abovethreading.TIMEOUT_MAX(1e10,2 * TIMEOUT_MAX). It rejects rather than caps. Test:test_an_unbounded_wait_is_rejected[...], 10 cases, the 3 large ones red before.3. Directory replaced: closed, not documented
After acquiring,
file_lockrecomputes every key. If one changed (the parent directory was replaced while the lock was being taken), it releases and retakes the lock on the new keys, within the same deadline. If the directory keeps changing, the result isLockTimeout. I closed it rather than documenting it: the fix is four lines and costs onestatper write, while a documented limit would leave a real fail-open for a cheap case. Tests:TestDirectoryReplaced::test_a_replaced_directory_retries_on_the_new_key(the write lands, and the second stripe is the new key's) and..._replaced_every_time_ends_in_lock_timeout, both red before. Remaining, documented: the entry's directory moved away during a write fails closed, withFileNotFoundErrorand nothing written; the temp file may be left in the moved directory. This is in the module docstring and the component doc.4. Text
<per-user lock dir>/<sha256(realpath)>.lock.Evidence
tests/test_file_lock.py, foreground,-n 4 -o faulthandler_timeout=600, on the rebased tree: 110 passed.Guards (round 2)
NFD(casefold(NFD))TestFoldThenNormalisecases, Greek alias held-lock + chain, case-spelling held-lock + chainTestDirectoryReplacedtestsTIMEOUT_MAXand float conversion incheck_timeouttest_an_unbounded_wait_is_rejected[1e10, 10**400, 2*TIMEOUT_MAX]Learned
NFD(casefold(NFD)), not any NFC ordering: NFC before the fold can compose a character whose casefold reorders the marks (U+1FB7).Captured in
folded = ...inlock_key, the module docstring, ADR-0042 A2, and the component doc._fold_not_nfc/_spellingsandTestFoldThenNormaliseintests/test_file_lock.py.file_lock,TestDirectoryReplaced, and ADR-0042 A2.Unsure
st_dev(round 1) is still a choice for the maintainer.Left
Nothing in this round.
🤖 Generated with Claude Code
Fix round 2 (2026-10-08): the fold, bounded waits, history rewritten
Pushed: e326cfd (4 commits, rebased on dev f30f4b3). The history was rewritten from dev, and the old heads quoted above (1691e10, 53f4dc1, 3b86eec) predate the rewrite. CI: run 37711794894.
NFD(casefold(NFD(name))), Unicode's caseless matching (D145). The brief'sNFC(casefold(NFC))still split U+1FB7 (its title case composes under NFC). The brute-force probe U+0020..U+2FFFF finds 0 mismatches (16,310 spelling pairs). The generated tests cover every code point whose fold is not normalised, plus Greek held-lock and chain tests through real processes.threading.TIMEOUT_MAX, or that cannot be converted, raise ValueError.Learned: the "casefold is not NFC" set is only a lower bound. Only checking each code point's spellings against the filesystem itself found U+1FB7.
Unsure: tested on APFS only; a second mount with another st_dev still gives other keys.