@napi-rs/cli 3.9.1 → 3.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,683 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { existsSync } from 'node:fs'
4
+ import {
5
+ mkdir,
6
+ mkdtemp,
7
+ readdir,
8
+ readFile,
9
+ rm,
10
+ symlink,
11
+ writeFile,
12
+ } from 'node:fs/promises'
13
+ import { tmpdir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+ import { performance } from 'node:perf_hooks'
16
+ import { setTimeout as delay } from 'node:timers/promises'
17
+
18
+ import ava, { type TestFn } from 'ava'
19
+
20
+ import {
21
+ createProcessIncarnationGetter,
22
+ type ProcessExecutionIdentity,
23
+ resolveProcessExecutionIdentityForLocking,
24
+ withFileSystemReconciliation,
25
+ } from '../misc.js'
26
+
27
+ const test = ava as TestFn<{
28
+ tmpDir: string
29
+ }>
30
+
31
+ test.beforeEach(async (t) => {
32
+ t.context = {
33
+ tmpDir: await mkdtemp(join(tmpdir(), 'napi-rs-reconciliation-spec-')),
34
+ }
35
+ })
36
+
37
+ test.afterEach.always(async (t) => {
38
+ if (existsSync(t.context.tmpDir)) {
39
+ await rm(t.context.tmpDir, { recursive: true, force: true })
40
+ }
41
+ })
42
+
43
+ const completeIdentity: ProcessExecutionIdentity = {
44
+ boot: 'test-boot:1',
45
+ bootSession: null,
46
+ machine: 'test-machine:1',
47
+ namespace: 'test-ns',
48
+ }
49
+
50
+ const incompleteIdentity: ProcessExecutionIdentity = {
51
+ boot: null,
52
+ bootSession: null,
53
+ machine: null,
54
+ namespace: null,
55
+ }
56
+
57
+ // Lock acquisition stamps every owner with the current process incarnation,
58
+ // which the default getter probes by spawning a platform command (powershell
59
+ // on Windows) on every call until one succeeds. Unit tests inject a fixture
60
+ // so no acquisition pays a real probe inside its lock-acquisition budget.
61
+ const testIncarnation = 'test-incarnation:1'
62
+ const getProcessIncarnation = async () => testIncarnation
63
+
64
+ async function collectFileNames(root: string): Promise<string[]> {
65
+ const names: string[] = []
66
+ const walk = async (directory: string): Promise<void> => {
67
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
68
+ if (entry.isDirectory()) {
69
+ await walk(join(directory, entry.name))
70
+ } else {
71
+ names.push(entry.name)
72
+ }
73
+ }
74
+ }
75
+ await walk(root)
76
+ return names
77
+ }
78
+
79
+ // Lock file paths relative to root, using '/' separators so dirname() reads
80
+ // naturally ('real/<name>' vs '<name>' at the root). Symlinked directories
81
+ // are not traversed.
82
+ async function collectReconciliationLocks(root: string): Promise<string[]> {
83
+ const lockName = /^\.napi-rs-filesystem-reconciliation\.[0-9a-f]{64}\.swp$/
84
+ const locks: string[] = []
85
+ const walk = async (directory: string, prefix: string): Promise<void> => {
86
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
87
+ if (entry.isDirectory()) {
88
+ await walk(join(directory, entry.name), `${prefix}${entry.name}/`)
89
+ } else if (lockName.test(entry.name)) {
90
+ locks.push(`${prefix}${entry.name}`)
91
+ }
92
+ }
93
+ }
94
+ await walk(root, '')
95
+ return locks.sort()
96
+ }
97
+
98
+ test('resolveProcessExecutionIdentityForLocking returns complete on the first read', async (t) => {
99
+ let reads = 0
100
+ const resolution = await resolveProcessExecutionIdentityForLocking(
101
+ async () => {
102
+ reads += 1
103
+ return completeIdentity
104
+ },
105
+ { expiresAt: performance.now() + 150, timeout: 150 },
106
+ )
107
+
108
+ t.true(resolution.complete)
109
+ t.is(resolution.identity, completeIdentity)
110
+ t.is(reads, 1)
111
+ })
112
+
113
+ test('resolveProcessExecutionIdentityForLocking re-probes until the identity is complete', async (t) => {
114
+ let reads = 0
115
+ const resolution = await resolveProcessExecutionIdentityForLocking(
116
+ async () => {
117
+ reads += 1
118
+ return reads < 3 ? incompleteIdentity : completeIdentity
119
+ },
120
+ // A sub-second budget flakes on loaded CI runners: two 20ms retry delays
121
+ // plus event-loop lag can exceed it before the third read (seen on
122
+ // windows-msvc with ~300ms timer starvation). The nominal runtime stays
123
+ // ~40ms; the budget only bounds how long failures are ridden out.
124
+ { expiresAt: performance.now() + 2_000, timeout: 2_000 },
125
+ )
126
+
127
+ t.true(resolution.complete)
128
+ t.is(resolution.identity, completeIdentity)
129
+ t.is(reads, 3)
130
+ })
131
+
132
+ test('resolveProcessExecutionIdentityForLocking resolves incomplete past the wait budget instead of throwing', async (t) => {
133
+ let reads = 0
134
+ const resolution = await resolveProcessExecutionIdentityForLocking(
135
+ async () => {
136
+ reads += 1
137
+ return incompleteIdentity
138
+ },
139
+ // Reads past the first happen on 20ms retry delays; the budget must
140
+ // tolerate event-loop lag on loaded CI runners so at least one re-probe
141
+ // (reads > 1 below) is guaranteed.
142
+ { expiresAt: performance.now() + 1_000, timeout: 1_000 },
143
+ )
144
+
145
+ t.false(resolution.complete)
146
+ t.deepEqual(resolution.identity, incompleteIdentity)
147
+ t.true(reads > 1)
148
+ })
149
+
150
+ test('createProcessIncarnationGetter caches a successful read forever', async (t) => {
151
+ let reads = 0
152
+ const getIncarnation = createProcessIncarnationGetter(async () => {
153
+ reads += 1
154
+ return 'probe:1'
155
+ }, 1_000)
156
+
157
+ t.is(await getIncarnation(), 'probe:1')
158
+ t.is(await getIncarnation(), 'probe:1')
159
+ t.is(reads, 1)
160
+ })
161
+
162
+ test('createProcessIncarnationGetter shares one in-flight probe', async (t) => {
163
+ let reads = 0
164
+ let resolveProbe!: (value: string | null) => void
165
+ const getIncarnation = createProcessIncarnationGetter(
166
+ () =>
167
+ new Promise<string | null>((resolve) => {
168
+ reads += 1
169
+ resolveProbe = resolve
170
+ }),
171
+ 1_000,
172
+ )
173
+
174
+ const first = getIncarnation()
175
+ const second = getIncarnation()
176
+ resolveProbe('probe:shared')
177
+ t.is(await first, 'probe:shared')
178
+ t.is(await second, 'probe:shared')
179
+ t.is(reads, 1)
180
+ })
181
+
182
+ test('createProcessIncarnationGetter rate-limits failed probes instead of re-spawning per acquisition', async (t) => {
183
+ let now = 10_000
184
+ let reads = 0
185
+ const getIncarnation = createProcessIncarnationGetter(
186
+ async () => {
187
+ reads += 1
188
+ return null
189
+ },
190
+ 1_000,
191
+ () => now,
192
+ )
193
+
194
+ t.is(await getIncarnation(), null)
195
+ t.is(reads, 1)
196
+ // Within the cooldown a failed probe is reused: back-to-back lock
197
+ // acquisitions (anchor guard + symlink guards + object lock) pay one probe.
198
+ now += 999
199
+ t.is(await getIncarnation(), null)
200
+ t.is(reads, 1)
201
+ now += 1
202
+ t.is(await getIncarnation(), null)
203
+ t.is(reads, 2)
204
+ })
205
+
206
+ test('createProcessIncarnationGetter recovers after a failed probe and then stops probing', async (t) => {
207
+ let now = 0
208
+ let reads = 0
209
+ const getIncarnation = createProcessIncarnationGetter(
210
+ async () => {
211
+ reads += 1
212
+ return reads === 1 ? null : 'probe:recovered'
213
+ },
214
+ 1_000,
215
+ () => now,
216
+ )
217
+
218
+ t.is(await getIncarnation(), null)
219
+ now += 1_000
220
+ t.is(await getIncarnation(), 'probe:recovered')
221
+ t.is(await getIncarnation(), 'probe:recovered')
222
+ t.is(reads, 2)
223
+ })
224
+
225
+ test('withFileSystemReconciliation publishes lock owners stamped with the injected process incarnation', async (t) => {
226
+ const anchor = join(t.context.tmpDir, 'pkg')
227
+ await mkdir(anchor)
228
+
229
+ const lockNamePattern =
230
+ /^\.napi-rs-filesystem-reconciliation\.[0-9a-f]{64}\.swp$/
231
+ const incarnations: unknown[] = []
232
+ await withFileSystemReconciliation(
233
+ anchor,
234
+ async () => {
235
+ for (const name of await readdir(t.context.tmpDir)) {
236
+ if (lockNamePattern.test(name)) {
237
+ const owner = JSON.parse(
238
+ await readFile(join(t.context.tmpDir, name), 'utf8'),
239
+ )
240
+ incarnations.push(owner.incarnation)
241
+ }
242
+ }
243
+ },
244
+ {
245
+ getProcessExecutionIdentity: async () => completeIdentity,
246
+ getProcessIncarnation,
247
+ lockAcquisitionTimeout: 10_000,
248
+ },
249
+ )
250
+
251
+ t.true(incarnations.length > 0)
252
+ for (const incarnation of incarnations) {
253
+ t.is(incarnation, testIncarnation)
254
+ }
255
+ })
256
+
257
+ test('#3512: degraded acquisition never publishes an unverifiable owner and still runs', async (t) => {
258
+ const anchor = join(t.context.tmpDir, 'pkg')
259
+ await mkdir(anchor)
260
+
261
+ let operationRan = false
262
+ const result = await withFileSystemReconciliation(
263
+ anchor,
264
+ async () => {
265
+ operationRan = true
266
+ return 'operation-sentinel'
267
+ },
268
+ {
269
+ getProcessExecutionIdentity: async () => incompleteIdentity,
270
+ getProcessIncarnation,
271
+ identityWaitTimeout: 150,
272
+ lockAcquisitionTimeout: 2_000,
273
+ },
274
+ )
275
+
276
+ // Hosts that cannot provide identity (FreeBSD, containers without
277
+ // /etc/machine-id, policy-blocked Windows tools) must keep building.
278
+ t.true(operationRan)
279
+ t.is(result, 'operation-sentinel')
280
+
281
+ // Nothing unverifiable may ever reach disk: no lock, reclaim, retired, or
282
+ // candidate state may be left behind by the degraded acquisition.
283
+ const names = await collectFileNames(t.context.tmpDir)
284
+ t.deepEqual(
285
+ names.filter((name) =>
286
+ /^\.napi-rs-filesystem-reconciliation.*\.swp$/.test(name),
287
+ ),
288
+ [],
289
+ )
290
+ t.deepEqual(
291
+ names.filter((name) => name.includes('.candidate.')),
292
+ [],
293
+ )
294
+ })
295
+
296
+ test('withFileSystemReconciliation retries identity probes until complete, then runs the operation', async (t) => {
297
+ const anchor = join(t.context.tmpDir, 'pkg')
298
+ await mkdir(anchor)
299
+
300
+ let reads = 0
301
+ const result = await withFileSystemReconciliation(
302
+ anchor,
303
+ async () => 'operation-sentinel',
304
+ {
305
+ getProcessExecutionIdentity: async () => {
306
+ reads += 1
307
+ return reads < 3 ? incompleteIdentity : completeIdentity
308
+ },
309
+ getProcessIncarnation,
310
+ lockAcquisitionTimeout: 10_000,
311
+ },
312
+ )
313
+
314
+ t.is(result, 'operation-sentinel')
315
+ // The resolver re-probes twice before the complete read; each acquired lock
316
+ // identity (guard + inode object) resolves separately, so the exact count
317
+ // depends on how many lock identities the anchor resolves to.
318
+ t.true(reads >= 3)
319
+ const names = await collectFileNames(t.context.tmpDir)
320
+ t.deepEqual(
321
+ names.filter((name) => name.endsWith('.swp')),
322
+ [],
323
+ )
324
+ })
325
+
326
+ test('withFileSystemReconciliation serializes concurrent operations on the same anchor', async (t) => {
327
+ const anchor = join(t.context.tmpDir, 'pkg')
328
+ await mkdir(anchor)
329
+
330
+ const markers: string[] = []
331
+ const getProcessExecutionIdentity = async () => completeIdentity
332
+ const [first, second] = await Promise.all([
333
+ withFileSystemReconciliation(
334
+ anchor,
335
+ async () => {
336
+ markers.push('a:start')
337
+ await delay(50)
338
+ markers.push('a:end')
339
+ return 'a'
340
+ },
341
+ {
342
+ getProcessExecutionIdentity,
343
+ getProcessIncarnation,
344
+ lockAcquisitionTimeout: 10_000,
345
+ },
346
+ ),
347
+ withFileSystemReconciliation(
348
+ anchor,
349
+ async () => {
350
+ markers.push('b:start')
351
+ markers.push('b:end')
352
+ return 'b'
353
+ },
354
+ {
355
+ getProcessExecutionIdentity,
356
+ getProcessIncarnation,
357
+ lockAcquisitionTimeout: 10_000,
358
+ },
359
+ ),
360
+ ])
361
+
362
+ t.is(first, 'a')
363
+ t.is(second, 'b')
364
+ t.deepEqual(markers, ['a:start', 'a:end', 'b:start', 'b:end'])
365
+ })
366
+
367
+ test('degraded acquisition respects an existing lock it cannot evaluate', async (t) => {
368
+ const anchor = join(t.context.tmpDir, 'pkg')
369
+ await mkdir(anchor)
370
+
371
+ const lockNamePattern =
372
+ /^\.napi-rs-filesystem-reconciliation\.[0-9a-f]{64}\.swp$/
373
+
374
+ // Phase 1: record the lock owner files a complete acquisition publishes.
375
+ const recorded: Array<{ name: string; content: string }> = []
376
+ await withFileSystemReconciliation(
377
+ anchor,
378
+ async () => {
379
+ for (const name of await readdir(t.context.tmpDir)) {
380
+ if (lockNamePattern.test(name)) {
381
+ recorded.push({
382
+ name,
383
+ content: await readFile(join(t.context.tmpDir, name), 'utf8'),
384
+ })
385
+ }
386
+ }
387
+ },
388
+ {
389
+ getProcessExecutionIdentity: async () => completeIdentity,
390
+ getProcessIncarnation,
391
+ lockAcquisitionTimeout: 10_000,
392
+ },
393
+ )
394
+ t.true(recorded.length > 0)
395
+
396
+ // Recreate the recorded owners as legacy unverifiable owners: identity
397
+ // fields nulled, PID left pointing at this live process.
398
+ const crafted = new Map<string, string>()
399
+ for (const { name, content } of recorded) {
400
+ const owner = JSON.parse(content)
401
+ owner.machine = null
402
+ owner.boot = null
403
+ owner.namespace = null
404
+ const serialized = JSON.stringify(owner)
405
+ crafted.set(name, serialized)
406
+ await writeFile(join(t.context.tmpDir, name), serialized)
407
+ }
408
+
409
+ // Phase 2: degraded acquisition must refuse to bypass the existing locks.
410
+ let operationRan = false
411
+ const error = await t.throwsAsync(
412
+ withFileSystemReconciliation(
413
+ anchor,
414
+ async () => {
415
+ operationRan = true
416
+ },
417
+ {
418
+ getProcessExecutionIdentity: async () => incompleteIdentity,
419
+ getProcessIncarnation,
420
+ identityWaitTimeout: 150,
421
+ lockAcquisitionTimeout: 600,
422
+ },
423
+ ),
424
+ )
425
+
426
+ t.false(operationRan)
427
+ t.true(
428
+ error.message.includes(
429
+ 'did not provide a complete machine, boot-session, and process-namespace identity',
430
+ ),
431
+ )
432
+ t.false(error.message.includes('Timed out after'))
433
+ t.false(error.message.includes('cannot be safely reclaimed'))
434
+
435
+ // The unverifiable owners were never touched, bypassed, or reclaimed.
436
+ for (const [name, serialized] of crafted) {
437
+ t.is(await readFile(join(t.context.tmpDir, name), 'utf8'), serialized)
438
+ }
439
+ })
440
+
441
+ test('degraded acquisition heals when identity probes recover and reclaims a stale lock', async (t) => {
442
+ const anchor = join(t.context.tmpDir, 'pkg')
443
+ await mkdir(anchor)
444
+
445
+ const lockNamePattern =
446
+ /^\.napi-rs-filesystem-reconciliation\.[0-9a-f]{64}\.swp$/
447
+
448
+ // Phase 1 records owners written with the DEFAULT getter: processOwnerState
449
+ // evaluates staleness against the module-level singleton (the real host
450
+ // identity), never an injected fixture, so only a real-identity owner can
451
+ // become provably stale on this host.
452
+ const recorded: Array<{ name: string; content: string }> = []
453
+ await withFileSystemReconciliation(
454
+ anchor,
455
+ async () => {
456
+ for (const name of await readdir(t.context.tmpDir)) {
457
+ if (lockNamePattern.test(name)) {
458
+ recorded.push({
459
+ name,
460
+ content: await readFile(join(t.context.tmpDir, name), 'utf8'),
461
+ })
462
+ }
463
+ }
464
+ },
465
+ { getProcessIncarnation },
466
+ )
467
+ t.true(recorded.length > 0)
468
+
469
+ const deadPid = await new Promise<number>((resolvePid, rejectPid) => {
470
+ const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' })
471
+ child.once('error', rejectPid)
472
+ child.once('exit', () => resolvePid(child.pid as number))
473
+ })
474
+
475
+ // Rewrite each owner with a guaranteed-dead PID: same real host identity,
476
+ // so the dead-PID check in processOwnerState proves staleness before any
477
+ // incarnation comparison.
478
+ for (const { name, content } of recorded) {
479
+ const owner = JSON.parse(content)
480
+ owner.pid = deadPid
481
+ owner.incarnation = null
482
+ await writeFile(join(t.context.tmpDir, name), JSON.stringify(owner))
483
+ }
484
+
485
+ // Phase 2: probes fail past the identity wait budget (forcing the degraded
486
+ // path), then recover; the degraded loop must re-read, heal, and reclaim.
487
+ let reads = 0
488
+ const result = await withFileSystemReconciliation(
489
+ anchor,
490
+ async () => 'healed-sentinel',
491
+ {
492
+ getProcessExecutionIdentity: async () => {
493
+ reads += 1
494
+ return reads <= 12 ? incompleteIdentity : completeIdentity
495
+ },
496
+ getProcessIncarnation,
497
+ identityWaitTimeout: 150,
498
+ lockAcquisitionTimeout: 10_000,
499
+ },
500
+ )
501
+
502
+ t.is(result, 'healed-sentinel')
503
+ t.true(reads > 4)
504
+ const names = await collectFileNames(t.context.tmpDir)
505
+ t.deepEqual(
506
+ names.filter((name) => name.endsWith('.swp')),
507
+ [],
508
+ )
509
+ t.deepEqual(
510
+ names.filter(
511
+ (name) => name.includes('.candidate.') || name.includes('.reclaim.'),
512
+ ),
513
+ [],
514
+ )
515
+ })
516
+
517
+ test('degraded acquisition skips transaction journal recovery; locked acquisition recovers', async (t) => {
518
+ const anchor = join(t.context.tmpDir, 'pkg')
519
+ await mkdir(anchor)
520
+
521
+ // Minimal valid v3 journal. readFileSystemTransactionOwnerAt requires an
522
+ // object with kind 'napi-rs-filesystem-transaction', version 1|2|3 and a
523
+ // UUID token; normalizeFileSystemTransactionJournal requires the same
524
+ // version, phase 'prepared', the same token, and an entries array whose
525
+ // length stays within the maximum — an empty array skips all per-entry
526
+ // validation.
527
+ const journalRoot = join(anchor, '.napi-rs-filesystem-transaction.swp')
528
+ await mkdir(journalRoot)
529
+ const token = randomUUID()
530
+ const ownerJson = JSON.stringify({
531
+ kind: 'napi-rs-filesystem-transaction',
532
+ version: 3,
533
+ token,
534
+ })
535
+ const stateJson = JSON.stringify({
536
+ version: 3,
537
+ phase: 'prepared',
538
+ token,
539
+ entries: [],
540
+ })
541
+ await writeFile(join(journalRoot, 'owner.json'), ownerJson)
542
+ await writeFile(join(journalRoot, 'state.json'), stateJson)
543
+
544
+ // Degraded run: recovery assumes cross-process exclusivity, so it must be
545
+ // skipped entirely — the journal survives byte-identical.
546
+ const result = await withFileSystemReconciliation(
547
+ anchor,
548
+ async () => 'degraded-sentinel',
549
+ {
550
+ getProcessExecutionIdentity: async () => incompleteIdentity,
551
+ getProcessIncarnation,
552
+ identityWaitTimeout: 150,
553
+ lockAcquisitionTimeout: 2_000,
554
+ },
555
+ )
556
+ t.is(result, 'degraded-sentinel')
557
+ t.true(existsSync(journalRoot))
558
+ t.is(await readFile(join(journalRoot, 'owner.json'), 'utf8'), ownerJson)
559
+ t.is(await readFile(join(journalRoot, 'state.json'), 'utf8'), stateJson)
560
+
561
+ // Locked run: the same journal is recovered and removed.
562
+ const locked = await withFileSystemReconciliation(
563
+ anchor,
564
+ async () => 'locked-sentinel',
565
+ {
566
+ getProcessExecutionIdentity: async () => completeIdentity,
567
+ getProcessIncarnation,
568
+ lockAcquisitionTimeout: 10_000,
569
+ },
570
+ )
571
+ t.is(locked, 'locked-sentinel')
572
+ t.false(existsSync(journalRoot))
573
+ })
574
+
575
+ test('#3444: symlink guard whose lock would escape the anchor parent is skipped', async (t) => {
576
+ const realDir = join(t.context.tmpDir, 'real')
577
+ await mkdir(join(realDir, 'pkg'), { recursive: true })
578
+ await symlink('real', join(t.context.tmpDir, 'link'), 'dir')
579
+
580
+ let locksDuringOp: string[] = []
581
+ const result = await withFileSystemReconciliation(
582
+ join(t.context.tmpDir, 'link', 'pkg'),
583
+ async () => {
584
+ locksDuringOp = await collectReconciliationLocks(t.context.tmpDir)
585
+ return 'sentinel'
586
+ },
587
+ {
588
+ getProcessExecutionIdentity: async () => completeIdentity,
589
+ getProcessIncarnation,
590
+ lockAcquisitionTimeout: 10_000,
591
+ },
592
+ )
593
+
594
+ t.is(result, 'sentinel')
595
+ // The guard for tmpDir/link would land its lock in realpath(tmpDir), above
596
+ // the canonical anchor's parent (tmpDir/real), so it is skipped: only the
597
+ // canonical anchor guard and the object lock remain, both inside real/.
598
+ t.is(locksDuringOp.length, 2)
599
+ t.true(locksDuringOp.every((lock) => dirname(lock) === 'real'))
600
+ t.false(locksDuringOp.some((lock) => dirname(lock) === '.'))
601
+ t.deepEqual(await collectReconciliationLocks(t.context.tmpDir), [])
602
+ })
603
+
604
+ test('#3444: symlink guard whose lock stays within the anchor parent is kept', async (t) => {
605
+ const realDir = join(t.context.tmpDir, 'real')
606
+ await mkdir(join(realDir, 'pkg'), { recursive: true })
607
+ await symlink('pkg', join(realDir, 'pkglink'), 'dir')
608
+
609
+ let locksDuringOp: string[] = []
610
+ const result = await withFileSystemReconciliation(
611
+ join(realDir, 'pkglink'),
612
+ async () => {
613
+ locksDuringOp = await collectReconciliationLocks(t.context.tmpDir)
614
+ return 'sentinel'
615
+ },
616
+ {
617
+ getProcessExecutionIdentity: async () => completeIdentity,
618
+ getProcessIncarnation,
619
+ lockAcquisitionTimeout: 10_000,
620
+ },
621
+ )
622
+
623
+ t.is(result, 'sentinel')
624
+ // Anchor guard + object lock + the pkglink spelling guard, whose lock root
625
+ // is tmpDir/real itself (within-or-equal the anchor's parent).
626
+ t.is(locksDuringOp.length, 3)
627
+ t.true(locksDuringOp.every((lock) => dirname(lock) === 'real'))
628
+ t.deepEqual(await collectReconciliationLocks(t.context.tmpDir), [])
629
+ })
630
+
631
+ test('#3444: plain anchor keeps exactly the guard and object locks', async (t) => {
632
+ await mkdir(join(t.context.tmpDir, 'pkg'))
633
+
634
+ let locksDuringOp: string[] = []
635
+ const result = await withFileSystemReconciliation(
636
+ join(t.context.tmpDir, 'pkg'),
637
+ async () => {
638
+ locksDuringOp = await collectReconciliationLocks(t.context.tmpDir)
639
+ return 'sentinel'
640
+ },
641
+ {
642
+ getProcessExecutionIdentity: async () => completeIdentity,
643
+ getProcessIncarnation,
644
+ lockAcquisitionTimeout: 10_000,
645
+ },
646
+ )
647
+
648
+ t.is(result, 'sentinel')
649
+ t.is(locksDuringOp.length, 2)
650
+ t.true(locksDuringOp.every((lock) => dirname(lock) === '.'))
651
+ t.deepEqual(await collectReconciliationLocks(t.context.tmpDir), [])
652
+ })
653
+
654
+ test('#3444: nested symlink guard lands at the fully-canonical walk-time root', async (t) => {
655
+ await mkdir(join(t.context.tmpDir, 'real', 'sub', 'pkg'), {
656
+ recursive: true,
657
+ })
658
+ await symlink('real', join(t.context.tmpDir, 'outer'), 'dir')
659
+ await symlink('pkg', join(t.context.tmpDir, 'real', 'sub', 'pkglink'), 'dir')
660
+
661
+ let locksDuringOp: string[] = []
662
+ const result = await withFileSystemReconciliation(
663
+ join(t.context.tmpDir, 'outer', 'sub', 'pkglink'),
664
+ async () => {
665
+ locksDuringOp = await collectReconciliationLocks(t.context.tmpDir)
666
+ return 'sentinel'
667
+ },
668
+ {
669
+ getProcessExecutionIdentity: async () => completeIdentity,
670
+ getProcessIncarnation,
671
+ lockAcquisitionTimeout: 10_000,
672
+ },
673
+ )
674
+
675
+ t.is(result, 'sentinel')
676
+ // Anchor guard + object lock + the pkglink spelling guard. The guard's
677
+ // dirname (tmpDir/outer/sub) itself goes through a symlink, so its lock
678
+ // must land at the walk-time canonical root realpath(.../real/sub); the
679
+ // tmpDir/outer guard escapes the anchor's parent and is skipped.
680
+ t.is(locksDuringOp.length, 3)
681
+ t.true(locksDuringOp.every((lock) => dirname(lock) === 'real/sub'))
682
+ t.deepEqual(await collectReconciliationLocks(t.context.tmpDir), [])
683
+ })