@napi-rs/cli 3.9.0 → 3.10.0

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