cli: pin build output/transaction paths to the locked canonical tree (mid-build symlink retarget drift)
Summary
napi build resolves all of its output paths lexically (resolve(), no symlink canonicalization) at Builder construction, and the transaction root is canonicalized only at commit time. If an ancestor symlink of the invocation cwd is retargeted mid-build (capistrano-style atomic deploy swap, versioned-store switching), the running build's staged writes and transaction commit silently follow the symlink into the new target tree — the tree the reconciliation locks no longer cover.
Surfaced during review of #3522 (verified analysis there). Related: #3444, #3521.
Verified mechanics
finalOutputDir,outputDir,reconciliationRootare computed withresolve()— lexical, symlink spelling retained (cli/src/api/build.ts:1131-1138).- Staging dir and all write destinations derive from
finalOutputDir(build.ts:1925-1927, 1957). commitFileSystemTransactioncanonicalizes the transaction root at commit time (cli/src/utils/misc.ts:714-716) — after a mid-operation retarget has taken effect — so the journal and commits land in the new target (misc.ts:752-761).- Topology assertions (
assertReconciliationRequestedPathUnchanged,assertReconciliationAnchorUnchanged) run at lock acquisition and release, never during the operation callback (misc.ts:403-437); the retarget is only detected asESTALEat release — after the drifted writes.
Consequence: a build retargeted mid-flight mutates a tree it does not hold locks for, and can collide with a concurrent process that legitimately locked the new target. Pre-3.8.0 this was entirely unprotected; since 3.8.0 the ancestor-symlink guard serialized only the identical-spelling pairing; #3522 removed even that in exchange for eliminating out-of-project lock litter (#3444).
Proposed direction
Pin the operation's write set to the canonical tree captured at lock acquisition: canonicalize finalOutputDir/destinations/transaction root once, inside the reconciliation capability, so a mid-build retarget makes the running build keep writing to the tree it locked (old target) and fail loudly with ESTALE at release — instead of drifting into a tree locked by someone else.
This closes the overlap for all spelling pairings (not just identical spellings) and subsumes the coordination the removed far-ancestor guards used to provide — without any lock placement outside the anchor's parent.
Scope note: touches path plumbing in build.ts and possibly artifacts.ts/pre-publish.ts/rename.ts; needs a behavior check for users who intentionally build through symlinked cwds (pre-retarget the pinned and lexical paths identify the same inode, so the only behavior change is mid-build retarget handling).
Source: napi-rs/napi-rs