Skip to content

Advisory lock key must be identity-backed, not realpath text: an NTFS 8.3 alias and its long form take different locks #2356

Open

Description

@easonLiangWorldedtech

Spun out of the #1917 / #1911 safeWriteJson seam work. Owned by the lane that owns the commit-interception matcher (#1911), because that lane already solved the same problem for the interruption hook.

The condition

@resolveLockKey@ / @canonicalDirKey@ in @src/services/file-safety/safeWriteText.ts@ build the advisory lock key from the resolved publish target and canonicalise its directory by text. On NTFS a file has two spellings ??the long name and its 8.3 alias ??and measurement on this box (pinned Node v22.23.4) says:

  • only @fs.realpathSync.native@ expands an 8.3 alias; plain @realpath@ does not,
  • only @fs.stat@'s @dev@ + @ino@ pair unifies the alias, a junction and a case difference (measured: @673102249:562949965654877@ for both spellings of one directory),
  • @path.resolve@ preserves whichever spelling the caller used.

So two writers that reach the same file through different spellings compute two different lock keys and do not exclude each other. This is not hypothetical on the fleet: the windows-latest runner reports the same directory as @runner~1@ where the checkout path spells it long, and @#1911@'s commit matcher already had to abandon spelling comparison for exactly that reason.

What to change

Decide the key by identity the way @#1911@'s replacement-commit matcher does ??equal @dev@+@ino@ means one lock ??while keeping the tolerances @resolveLockKey@ already has (a dangling link must still yield a key, the link walk stays bounded, and a key must be computable while a peer is mid-commit). @canonicalDirKey@ should fold the directory identity into the key, and the basename spelling should not be able to split two writers of the same inode.

Constraints

Related: #2350, #1911, #1917, #1405.

Activity

  1. added a commit that references this issue on Nov 8, 2026
  2. easonLiangWorldedtech commented on Nov 8, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Correction to the body, found the hard way on a required leg: the async call behaves differently from the synchronous one, and the writer uses the async call.

    Measured on the pinned runtime (Node v22.23.4, .tool-versions / engines) against a directory I made for the purpose:

    call input output
    @fs.realpath@ (async) @C:\work\ZOO-CO1\TMP-AL1@ @C:\work\Zoo-Code-fork.tmp-alias-repro@ ??alias expanded
    @fs.realpathSync@ (plain) same @C:\work\ZOO-CO1\TMP-AL1@ ??alias left alone
    @fs.realpathSync.native@ same long name ??expanded
    @fs.realpathSync.native@ on the long name @C:\work\Zoo-Code-fork.tmp-alias-repro@ unchanged
    @fs.statSync@ of both spellings - @673102249:1125899919173473@ both ??one inode

    So the body's claim that only @realpathSync.native@ expands an 8.3 alias is right about the synchronous family and wrong about @fs.realpath@, which is what @resolvePublishTarget@ calls. That is not a footnote: it is the reason #1917's @platform-unit-test (windows-latest)@ leg went red twice.

    Three sites in this series have now hit the same wrong comparison, all with the same shape:

    1. @TaskHistoryStore@ backup cleanup ??the injected @eacces@ was pinned to @target === backup@ text equality; on the runner the store's realpath-derived sibling path is not string-equal to the spelling the test created the file under, so the fault never fired and @delete@ resolved instead of reporting the blocked verdict (leg @128598795578@, 2 failures).
    2. @safeWriteJson.interruption.test.ts@ ??the child identifies the commit rename by @path.resolve(newPath) === targetPath@; on a runner whose temp directory is reported as @...\RUNNER~1...@ the writer publishes under the expanded long spelling while the parent still holds the alias, so the kill point is missed and 12 cases printed @WRITE_COMPLETED@ instead of @EXIT_AT_REPLACEMENT_RENAME@ (leg @128600634309@).
    3. feat(file-safety): canonical advisory lock key (U2, #1375) #1911 replacement-commit interception hook ??already fixed by deciding on @dev@+@ino@ instead of text plus realpath.

    Reproduced rather than inferred: pointing @temp@ at the 8.3 alias makes that suite run 12 failed and pointing it at the long spelling makes it run 12 passed, on the same commit and the same runtime.

    Two consequences this issue has to settle, in addition to the lock-key question it was opened for:

    • Publish-target spelling. @resolvePublishTarget@ currently returns the @fs.realpath@ result, so on Windows the file can be published under a different spelling than the one the caller passed. Every consumer that compares paths as text inherits that difference. Either the writer preserves the caller's spelling whenever canonicalisation changed only spelling and not identity (which needs @Lstat@ to keep genuine symlinks publishing at their referent, and a per-component identity check to tell an alias from a link), or the repo states that the canonical spelling is the file's name after a write and every consumer compares identity. Three test sites already had to learn this; a fourth will.
    • @canonicalDirKey@'s stated basis. Its doc comment says @fs.realpath@ canonicalises a Windows 8.3 short name. For the async call that is true, which is why the lock key unifies two spellings of an existing path. It does not unify a not-yet-existing basename spelled two ways, because the missing components are re-joined as given after the nearest existing ancestor is canonicalised. The identity rule this issue proposes is what closes that gap.

    Related: #2350 (naming and recovery contract), #1911 (the matcher that already decided by identity), #1917 (the two red legs and the reproduction).

  3. added a commit that references this issue on Nov 8, 2026
  4. easonLiangWorldedtech commented on Nov 8, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Contract clause for the identity half of this issue (extends #2350; does not restate it)

    Canonical spelling is the name after a write. Every consumer compares identity (dev+ino), never text.

    This is contract text for the identity clause only. The naming and recovery contract - what a leftover means, which prefixes a recovery pass may claim, when a name may be reused - lives in #2350 and is not restated or changed here.

    What is measured, not reasoned

    On the pinned runtime (node 22.23.4) against a directory created for the purpose:

    • async fs.realpath expands an NTFS 8.3 alias to the long name;
    • path.resolve and synchronous fs.realpathSync preserve whatever spelling they are handed;
    • only fs.realpathSync.native expands within the synchronous family;
    • both spellings of that directory stat to one identity (dev:ino identical), so identity is the comparison both sides agree on no matter which spelling each was handed.

    resolvePublishTarget and canonicalDirKey both use async fs.realpath, so the publish target and the lock key are already canonical spellings. Consequence: any text comparison against a publish target is wrong on a runner whose temp path is reported as an 8.3 alias. Measured, not hypothetical: with TEMP and TMP pointed at the alias, twelve tests in src/utils/__tests__/safeWriteJson.interruption.test.ts stopped injecting their fault and reported expected 'WRITE_COMPLETED' to contain 'EXIT_AT_REPLACEMENT_RENAME' while the write completed; pointed at the long spelling the same tree at the same runtime passed. Same commit, same runtime, only the spelling of TEMP differed.

    The clause

    1. A consumer of a lock key, a publish target, or a staged-entry identity compares dev+ino obtained from a stat (bigint where the size matters), and treats a side it cannot stat as a rejection rather than a mismatch to ignore.
    2. Whole-path text equality is not a consumer of any of these. Two spellings that name one directory are one key, and a writer must not be able to take two locks on one file by spelling it two ways.
    3. The canonical spelling is the name that exists after the write, which is what an identity comparison yields; nothing in this clause requires either spelling to be preferred in messages, on disk, or in a lock-key derivation.

    The open half, stated exactly

    canonicalDirKey unifies spellings of a path that already exists. It decides nothing about a basename that does not exist yet - realpath has no answer for a tail that is not there, and the .catch(() => dirPath) fallback keeps an uncanonicalised spelling whenever realpath fails at all (missing ancestor, EACCES, EIO). The row-11 fix on this issue - create the lock-key directory before acquiring the lock - depends on that being unresolved, not on it being resolved: it makes the directory exist so the identity comparison has something to stat. Whether an uncreated basename has one canonical spelling is still open here and is not decided by this clause.

    A contract consequence to record (production-unreachable today)

    A writer that pins an existing directory using an alias spelling and then re-validates by comparing resolved text can raise a spurious AncestorReplacedError for a directory that did not change: the pin's input comes from a realpath pass that expands the alias, while the authorized text it is compared against may still be the alias. Today this is unreachable in production because the pin inputs come from _resolveScopeRoot, which itself uses async fs.realpath, so the spellings it feeds the comparison are canonical; it is reachable in tests that stub realpath to hand each side its own spelling, and on any runner whose temp path is reported as an 8.3 alias. Under clause 1 the comparison would have accepted the directory, which is the point: the error would be a contract violation, not a legitimate outcome.

    What this comment does not decide

    It does not commit to how or whether the Step 2b ancestor re-validation in src/services/file-safety/safeWriteText.ts (the path.resolve(resolvedDir) !== path.resolve(expected.dir) pairing at :701-710 on the #1912 branch) changes, and it does not edit that check. It is exact today only because its consumers record canonical paths, which is the assumption clause 1 replaces.

    Evidence: PR #1912 thread comment 6716570312 (both-spelling reproduction, os.tmpdir() probe, inode pairs); commits 22a4ba72b and 085fbf85e4ad on easonLiangWorldedtech/Zoo-Code branch fws/u3-observation-completeness, measured on node 22.23.4.

  5. easonLiangWorldedtech commented on Nov 8, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Measured state of the safe-write lock key across every copy reachable from this box, plus a runnable reproduction. No code landed, no PR touched, nothing resolved or closed: 2350 owns which primitive main keeps and 2356 is the contract that decision has to satisfy.

    The half that is already in place, and the half that is not. In every copy below, the identity seam (resolveLockKey) and statSync are already present in the same file as canonicalDirKey. What is unfixed in all of them is the other half: canonicalDirKey canonicalizes through the async fs.realpath and swallows any failure into the uncanonicalized directory (.catch(() => dirPath)), so a not-yet-existing ancestor or an EACCES leaves the key agreeing by spelling while the lock seam agrees by dev:ino. Two callers that spell one directory differently then hold two different keys for one file.

    Per copy, measured by blob rather than by prose (git cat-file -t reports blob for the four copies, which is why a commit-shaped probe answers is a blob, not a commit):

    copy blob lines measured canonicalDirKey silent .catch(() => dirPath) resolveLockKey+statSync
    #1912 head 085fbf85e4ad (contained by vps2f-1912-u3, origin/fws/u3-observation-completeness) 3cd2405e2ed1 987 yes yes yes
    #1913 head 8fa7d858c87e (contained by no ref on this box) 7f6c9d6d5216 674 yes yes yes
    1fb5fcc47310 (the interruption-test fix) e523ccd2a777 977 yes yes yes
    #1917's copy 0ed4a1970725 636 (record: 636) yes yes yes
    #1408's copy 1de2e5769df1 515 (record said 446) no no yes (statSync only)
    #1405's copy 7a59e9aa1939 446 (record said 407) no no yes (statSync only)
    vps2-f's copy 3846158a7ec1 859 (record: 859) yes yes yes

    Two of the shared record's line counts do not reproduce, and the mismatch has a shape: the figure recorded for 1de2e5769df1 equals the figure measured for 7a59e9aa1939, which reads as one row of a table being transcribed onto the wrong lane rather than a wrong blob. Flagged as the likely cause, not asserted.

    main's own line, measured at 5319fe91fb0caf4aa4ec66aac68fa03744ebbc23: there is no src/services/file-safety directory at all. The write-side family is src/utils/fileLock.ts (101 lines - mentions realpath, and has no resolveLockKey, no resolvePublishTarget, no statSync, no ino), src/utils/safeWriteJson.ts (286 lines, no realpath), src/utils/jsonFileRecovery.ts (211 lines, one .tmp occurrence), plus their four specs under src/utils/__tests__/. So the identity-backed key 2356 asks for is missing on main's line in exactly the file 2356 names, and it can be written there without creating a fifth copy of safeWriteText.ts.

    There is no short-name self-check anywhere. Negative search over scripts/ and .github/, and git grep over a current head, for dir /x, 8dot3name and GetShortPathName: zero hits.

    Reproduction (un-elevated, pinned runtime v22.23.4): .tmp-cr-audit/short-name-probe.mjs. What it prints here:

    • stat(long) dev:ino = 673102249:17451448568457636 and stat(short) dev:ino = 673102249:17451448568457636 - spellings unify by stat identity = true;
    • handing the async and both sync realpaths the long spelling returns the long spelling unchanged (all three). Handing them the alias spelling instead: fs.realpath(SHORT) -> C:\work\ZOO-CO~1\TMP-AL~2\LONG-D~1, fs.realpathSync(SHORT) -> C:\work\ZOO-CO~1\TMP-AL~2\LONG-D~1, fs.realpathSync.native(SHORT) -> C:\work\Zoo-Code-fork\.tmp-alias-vps2f\long-directory-name-under-alias. On this box, at this runtime, only fs.realpathSync.native expands the alias; async fs.realpath preserves whatever spelling it is handed. That is narrower than the shared record's claim that async fs.realpath expands it, so the record is named here rather than quietly adopted: whichever primitive survives has to be keyed on something that does not depend on which of these four spellings the platform happened to return. Two practical notes for anyone re-running this: a probe that feeds only the long spelling sees no expansion at all and can be mistaken for a contradiction of the record, and fsutil 8dot3name query C: fails un-elevated (Error 5), so the self-check has to be dir /x / %~sI.
    • dir /x line for a fresh 47-char directory: "2026/11/09 上午 02:12 A-REAL~1 a-really-long-directory-name-for-short-name-testing" - a freshly created 47-character directory gets a short name, so 8.3 generation is on for this volume;
    • fsutil 8dot3name query C: exit=1 out="Failed to open the volume. ~ Error 5: Access is denied. ~ " - fsutil 8dot3name cannot be queried un-elevated (Error 5), so a self-check has to use dir /x / %~sI rather than fsutil;
    • canonicalDirKey shape, missing ancestor: fs.realpath -> rejected:ENOENT - so with the .catch(() => dirPath) fallback the key becomes the raw caller spelling whenever realpath rejects.

    Consequence for whichever primitive survives 2350: the key must be derived from the same identity the guard already compares (dev+ino via statSync), the realpath failure must stop being swallowed into the caller's spelling, and a self-check that actually runs un-elevated is dir /x, not fsutil.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions