@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.
- package/README.md +17 -4
- package/dist/cli.js +10359 -8729
- package/dist/index.cjs +10381 -8751
- package/dist/index.d.cts +83 -16
- package/dist/index.d.ts +83 -16
- package/dist/index.js +10359 -8729
- package/docs/wasi.md +318 -4
- package/package.json +5 -6
- package/src/api/__tests__/__snapshots__/templates.spec.ts.md +4003 -53
- package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
- package/src/api/__tests__/build-regressions.spec.ts +210 -3
- package/src/api/__tests__/build.spec.ts +2333 -3
- package/src/api/__tests__/create-npm-dirs.spec.ts +105 -3
- package/src/api/__tests__/pre-publish.spec.ts +153 -1
- package/src/api/__tests__/templates.spec.ts +1309 -1
- package/src/api/build.ts +756 -30
- package/src/api/create-npm-dirs.ts +23 -10
- package/src/api/new.ts +13 -18
- package/src/api/pre-publish.ts +34 -11
- package/src/api/rename.ts +10 -21
- package/src/api/templates/binding-target.ts +176 -0
- package/src/api/templates/index.ts +1 -0
- package/src/api/templates/js-binding.ts +54 -10
- package/src/api/templates/load-wasi-template.ts +661 -58
- package/src/api/templates/wasi-worker-template.ts +36 -31
- package/src/commands/build.ts +1 -1
- package/src/utils/__tests__/__snapshots__/typegen.spec.ts.md +18 -28
- package/src/utils/__tests__/__snapshots__/typegen.spec.ts.snap +0 -0
- package/src/utils/__tests__/misc.spec.ts +4 -0
- package/src/utils/__tests__/reconciliation.spec.ts +676 -0
- package/src/utils/__tests__/serialize.spec.ts +55 -0
- package/src/utils/__tests__/target.spec.ts +221 -0
- package/src/utils/__tests__/typegen.spec.ts +115 -0
- package/src/utils/config.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/misc.ts +351 -79
- package/src/utils/serialize.ts +47 -0
- package/src/utils/target.ts +150 -1
- 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
|
+
})
|