@napi-rs/cli 3.10.1 → 3.10.3
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/dist/cli.js +419 -40
- package/dist/index.cjs +419 -40
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +419 -40
- package/package.json +1 -1
- package/src/api/__tests__/__snapshots__/templates.spec.ts.md +1096 -82
- package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
- package/src/api/__tests__/templates.spec.ts +118 -0
- package/src/api/templates/load-wasi-template.ts +370 -35
- package/src/utils/__tests__/misc.spec.ts +339 -0
- package/src/utils/misc.ts +178 -36
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import { existsSync, type BigIntStats } from 'node:fs'
|
|
2
3
|
import {
|
|
4
|
+
appendFile,
|
|
5
|
+
chmod,
|
|
3
6
|
lstat,
|
|
4
7
|
mkdtemp,
|
|
5
8
|
readdir,
|
|
6
9
|
readFile,
|
|
7
10
|
rename,
|
|
8
11
|
rm,
|
|
12
|
+
stat,
|
|
13
|
+
utimes,
|
|
9
14
|
writeFile,
|
|
10
15
|
} from 'node:fs/promises'
|
|
11
16
|
import { tmpdir } from 'node:os'
|
|
@@ -15,6 +20,7 @@ import ava, { type TestFn } from 'ava'
|
|
|
15
20
|
|
|
16
21
|
import {
|
|
17
22
|
retireFailedSnapshotLeftover,
|
|
23
|
+
snapshotFileSystemTransactionInput,
|
|
18
24
|
snapshotLeftoverIsTransactionOwned,
|
|
19
25
|
statIdentitiesMatch,
|
|
20
26
|
updatePackageJson,
|
|
@@ -179,3 +185,336 @@ test('retireFailedSnapshotLeftover restores a successor swapped in during the ra
|
|
|
179
185
|
t.is(await readFile(destination, 'utf8'), 'successor content')
|
|
180
186
|
t.deepEqual(await readdir(t.context.tmpDir), ['leftover.tmp'])
|
|
181
187
|
})
|
|
188
|
+
|
|
189
|
+
// A snapshot re-verifies its source after the copy so the recorded image is
|
|
190
|
+
// provably consistent. The check used to fail the whole transaction on any
|
|
191
|
+
// difference, including a metadata-only re-stamp of an inode the cli had just
|
|
192
|
+
// written itself — the FreeBSD release-build failure in rolldown/rolldown#10268
|
|
193
|
+
// — and its message named none of the eight conditions it stood for.
|
|
194
|
+
//
|
|
195
|
+
// `onAfterCopy` is the seam these tests drive: it runs after each copy pass and
|
|
196
|
+
// before the post-copy stats, so drift is injected deterministically rather than
|
|
197
|
+
// by racing a timer against a large copy.
|
|
198
|
+
//
|
|
199
|
+
// Every mutation below has to be deterministic on the Windows lanes too
|
|
200
|
+
// (`.github/workflows/test-release.yaml` runs `yarn test:cli` on both
|
|
201
|
+
// `windows-latest` and `windows-11-arm`), which rules out two tempting ones:
|
|
202
|
+
//
|
|
203
|
+
// * an identical-mode `chmod` to move `ctime` alone. On Windows that is an
|
|
204
|
+
// attribute no-op and nothing documents it advancing the NTFS ChangeTime
|
|
205
|
+
// that libuv reports as `ctimeNs`.
|
|
206
|
+
// * asserting a full POSIX mode. Node documents that on Windows "only the
|
|
207
|
+
// write permission can be changed, and the distinction among the
|
|
208
|
+
// permissions of group, owner, or others is not implemented", so a mode
|
|
209
|
+
// round-trips as read-only or writable and nothing else.
|
|
210
|
+
//
|
|
211
|
+
// So timestamp drift is forced with `utimes`, whose `mtime` write is defined on
|
|
212
|
+
// every supported platform, and mode drift toggles only the owner write bit and
|
|
213
|
+
// asserts against the mode actually observed afterwards.
|
|
214
|
+
const snapshotSourceBytes = Buffer.from('snapshot source contents')
|
|
215
|
+
const snapshotSourceHash = createHash('sha256')
|
|
216
|
+
.update(snapshotSourceBytes)
|
|
217
|
+
.digest('hex')
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* An in-place rewrite of exactly the same length as {@link snapshotSourceBytes}.
|
|
221
|
+
* Every field the snapshot compares — `dev`, `ino`, `size`, `bytesRead`, `mode`
|
|
222
|
+
* and, once the timestamps are restamped, `mtimeNs` — survives it untouched, so
|
|
223
|
+
* only the content moves. That is the shape a coarse or coalesced filesystem
|
|
224
|
+
* clock hides, and the only thing that can catch it is the hash.
|
|
225
|
+
*/
|
|
226
|
+
function rewrittenSourceBytes(revision: number) {
|
|
227
|
+
const bytes = Buffer.from(snapshotSourceBytes)
|
|
228
|
+
bytes.writeUInt8(0x30 + (revision % 10), bytes.length - 1)
|
|
229
|
+
return bytes
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function sourceHashOf(bytes: Buffer) {
|
|
233
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Two of the mutations below have no Windows equivalent — see the comment on
|
|
237
|
+
// each test for which Windows behavior rules it out.
|
|
238
|
+
const posixTest = process.platform === 'win32' ? test.skip : test
|
|
239
|
+
|
|
240
|
+
async function writeSnapshotSource(tmpDir: string) {
|
|
241
|
+
const source = join(tmpDir, 'source.node')
|
|
242
|
+
await writeFile(source, snapshotSourceBytes)
|
|
243
|
+
return { destination: join(tmpDir, 'snapshot.input'), source }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function sourceMode(source: string) {
|
|
247
|
+
return Number((await stat(source, { bigint: true })).mode & 0o7777n)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Move `mtime` to a fixed whole second, so the drift is the same on a
|
|
252
|
+
* filesystem with nanosecond stamps and on one with a coarser clock.
|
|
253
|
+
*/
|
|
254
|
+
async function restampSource(source: string, second: number) {
|
|
255
|
+
const when = new Date(1_700_000_000_000 + second * 1_000)
|
|
256
|
+
await utimes(source, when, when)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function snapshotInput(
|
|
260
|
+
source: string,
|
|
261
|
+
destination: string,
|
|
262
|
+
onAfterCopy: (attempt: number) => Promise<void>,
|
|
263
|
+
) {
|
|
264
|
+
return snapshotFileSystemTransactionInput(
|
|
265
|
+
source,
|
|
266
|
+
destination,
|
|
267
|
+
undefined,
|
|
268
|
+
undefined,
|
|
269
|
+
0o600,
|
|
270
|
+
true,
|
|
271
|
+
undefined,
|
|
272
|
+
undefined,
|
|
273
|
+
onAfterCopy,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The legitimate FreeBSD case, and the other side of the content-hash rule
|
|
278
|
+
// below: a re-stamp that leaves the bytes alone is accepted on the very next
|
|
279
|
+
// attempt, because that attempt reproduces the hash of the one before it.
|
|
280
|
+
test('snapshot retries a source whose timestamps were re-stamped mid-copy', async (t) => {
|
|
281
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
282
|
+
const mode = await sourceMode(source)
|
|
283
|
+
const attempts: number[] = []
|
|
284
|
+
|
|
285
|
+
const state = await snapshotInput(source, destination, async (attempt) => {
|
|
286
|
+
attempts.push(attempt)
|
|
287
|
+
if (attempt === 1) {
|
|
288
|
+
// Not one byte of content changes: only the inode's timestamps move.
|
|
289
|
+
await restampSource(source, 1)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
t.deepEqual(attempts, [1, 2])
|
|
294
|
+
t.is(state.hash, snapshotSourceHash)
|
|
295
|
+
t.is(state.mode, mode)
|
|
296
|
+
t.deepEqual(await readFile(destination), snapshotSourceBytes)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
posixTest(
|
|
300
|
+
'snapshot retries a source whose ctime alone was re-stamped mid-copy',
|
|
301
|
+
async (t) => {
|
|
302
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
303
|
+
const mode = await sourceMode(source)
|
|
304
|
+
const attempts: number[] = []
|
|
305
|
+
|
|
306
|
+
const state = await snapshotInput(source, destination, async (attempt) => {
|
|
307
|
+
attempts.push(attempt)
|
|
308
|
+
if (attempt === 1) {
|
|
309
|
+
// Re-applying the same mode is the narrowest possible re-stamp: size,
|
|
310
|
+
// mtime and mode all hold still and only ctime moves. This is the
|
|
311
|
+
// FreeBSD symptom in its purest form.
|
|
312
|
+
await chmod(source, mode)
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
t.deepEqual(attempts, [1, 2])
|
|
317
|
+
t.is(state.hash, snapshotSourceHash)
|
|
318
|
+
t.is(state.mode, mode)
|
|
319
|
+
t.deepEqual(await readFile(destination), snapshotSourceBytes)
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
test('snapshot retries mtime drift until the source settles', async (t) => {
|
|
324
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
325
|
+
const attempts: number[] = []
|
|
326
|
+
|
|
327
|
+
const state = await snapshotInput(source, destination, async (attempt) => {
|
|
328
|
+
attempts.push(attempt)
|
|
329
|
+
if (attempt < 3) {
|
|
330
|
+
await restampSource(source, attempt)
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// The third attempt is the last the bound allows, and it is clean.
|
|
335
|
+
t.deepEqual(attempts, [1, 2, 3])
|
|
336
|
+
t.is(state.hash, snapshotSourceHash)
|
|
337
|
+
t.deepEqual(await readFile(destination), snapshotSourceBytes)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('snapshot records the settled mode after a mode re-stamp', async (t) => {
|
|
341
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
342
|
+
// Start read-only and hand back the write bit mid-copy. That is the only
|
|
343
|
+
// mode transition Windows can represent, and it leaves the file writable so
|
|
344
|
+
// the fixture teardown never meets a read-only inode.
|
|
345
|
+
await chmod(source, 0o444)
|
|
346
|
+
const readOnlyMode = await sourceMode(source)
|
|
347
|
+
const attempts: number[] = []
|
|
348
|
+
|
|
349
|
+
const state = await snapshotInput(source, destination, async (attempt) => {
|
|
350
|
+
attempts.push(attempt)
|
|
351
|
+
if (attempt === 1) {
|
|
352
|
+
await chmod(source, 0o644)
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
// Assert against the mode the platform actually reports, never a literal:
|
|
357
|
+
// POSIX settles on 0o644 and Windows on 0o666.
|
|
358
|
+
const writableMode = await sourceMode(source)
|
|
359
|
+
t.not(readOnlyMode, writableMode, 'the chmod must produce real mode drift')
|
|
360
|
+
t.deepEqual(attempts, [1, 2])
|
|
361
|
+
t.is(state.mode, writableMode)
|
|
362
|
+
t.is(state.hash, snapshotSourceHash)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('snapshot still fails on a size change and names the field', async (t) => {
|
|
366
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
367
|
+
const attempts: number[] = []
|
|
368
|
+
|
|
369
|
+
const error = await t.throwsAsync(
|
|
370
|
+
snapshotInput(source, destination, async (attempt) => {
|
|
371
|
+
attempts.push(attempt)
|
|
372
|
+
if (attempt === 1) {
|
|
373
|
+
await appendFile(source, 'appended')
|
|
374
|
+
}
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
// A hard field is fatal on the first observation: no retry is attempted.
|
|
379
|
+
t.deepEqual(attempts, [1])
|
|
380
|
+
t.true(
|
|
381
|
+
error?.message.startsWith(
|
|
382
|
+
`Filesystem transaction source changed while it was snapshotted: ${source} (`,
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
t.true(
|
|
386
|
+
error?.message.includes(
|
|
387
|
+
`size ${snapshotSourceBytes.length} -> ${snapshotSourceBytes.length + 8}`,
|
|
388
|
+
),
|
|
389
|
+
error?.message,
|
|
390
|
+
)
|
|
391
|
+
t.true(
|
|
392
|
+
error?.message.includes(
|
|
393
|
+
`bytesRead ${snapshotSourceBytes.length} != size ${snapshotSourceBytes.length + 8}`,
|
|
394
|
+
),
|
|
395
|
+
error?.message,
|
|
396
|
+
)
|
|
397
|
+
t.false(existsSync(destination))
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
// Swapping a successor onto a path the snapshot still holds open is POSIX-only:
|
|
401
|
+
// Windows refuses to replace a file with a live handle, and the rename itself
|
|
402
|
+
// fails before the assertion is reached with
|
|
403
|
+
// EPERM: operation not permitted, rename '...\\successor.node' -> '...\\source.node'
|
|
404
|
+
// The size-change test above already covers a hard field on the Windows lanes.
|
|
405
|
+
posixTest(
|
|
406
|
+
'snapshot still fails when the path is replaced and names the identity',
|
|
407
|
+
async (t) => {
|
|
408
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
409
|
+
const successor = join(t.context.tmpDir, 'successor.node')
|
|
410
|
+
await writeFile(successor, snapshotSourceBytes)
|
|
411
|
+
const before = await lstat(source, { bigint: true })
|
|
412
|
+
const after = await lstat(successor, { bigint: true })
|
|
413
|
+
|
|
414
|
+
const error = await t.throwsAsync(
|
|
415
|
+
snapshotInput(source, destination, async (attempt) => {
|
|
416
|
+
if (attempt === 1) {
|
|
417
|
+
await rename(successor, source)
|
|
418
|
+
}
|
|
419
|
+
}),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
t.true(
|
|
423
|
+
error?.message.includes(
|
|
424
|
+
`path identity ${before.dev}/${before.ino} -> ${after.dev}/${after.ino}`,
|
|
425
|
+
),
|
|
426
|
+
error?.message,
|
|
427
|
+
)
|
|
428
|
+
t.false(existsSync(destination))
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
test('snapshot gives up after the attempt bound and names the drifted fields', async (t) => {
|
|
433
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
434
|
+
const attempts: number[] = []
|
|
435
|
+
|
|
436
|
+
const error = await t.throwsAsync(
|
|
437
|
+
snapshotInput(source, destination, async (attempt) => {
|
|
438
|
+
attempts.push(attempt)
|
|
439
|
+
// Never settles: every attempt observes a different mtime.
|
|
440
|
+
await restampSource(source, attempt)
|
|
441
|
+
}),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
t.deepEqual(attempts, [1, 2, 3])
|
|
445
|
+
t.true(
|
|
446
|
+
error?.message.startsWith(
|
|
447
|
+
`Filesystem transaction source changed while it was snapshotted: ${source} (`,
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
t.regex(error?.message ?? '', /mtimeNs \d+ -> \d+/)
|
|
451
|
+
t.true(
|
|
452
|
+
error?.message.endsWith('still drifting after 3 snapshot attempts)'),
|
|
453
|
+
error?.message,
|
|
454
|
+
)
|
|
455
|
+
t.false(existsSync(destination))
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
// Codex review of napi-rs/napi-rs#3530: the retry rebases its baseline purely on
|
|
459
|
+
// metadata, so a writer rewriting the file in place at the same length inside
|
|
460
|
+
// one timestamp tick could hand back a mixed copy that every stat field calls
|
|
461
|
+
// settled. Two attempts now have to agree on the hash before one is accepted.
|
|
462
|
+
test('snapshot re-copies until two attempts agree on the source content', async (t) => {
|
|
463
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
464
|
+
const mode = await sourceMode(source)
|
|
465
|
+
const settled = rewrittenSourceBytes(1)
|
|
466
|
+
const attempts: number[] = []
|
|
467
|
+
|
|
468
|
+
const state = await snapshotInput(source, destination, async (attempt) => {
|
|
469
|
+
attempts.push(attempt)
|
|
470
|
+
if (attempt === 1) {
|
|
471
|
+
// Same length, different bytes, and the timestamps pinned to a fixed
|
|
472
|
+
// second so the second attempt sees metadata that looks perfectly
|
|
473
|
+
// settled. Only the content betrays the writer.
|
|
474
|
+
await writeFile(source, settled)
|
|
475
|
+
await restampSource(source, 1)
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// The second attempt copies the settled bytes but cannot know they are
|
|
480
|
+
// settled — its hash is the first one that differs. Only the third attempt,
|
|
481
|
+
// which reproduces it, is accepted.
|
|
482
|
+
t.deepEqual(attempts, [1, 2, 3])
|
|
483
|
+
t.is(state.hash, sourceHashOf(settled))
|
|
484
|
+
t.is(state.mode, mode)
|
|
485
|
+
t.deepEqual(await readFile(destination), settled)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('snapshot gives up when the source content never settles and names the hash', async (t) => {
|
|
489
|
+
const { destination, source } = await writeSnapshotSource(t.context.tmpDir)
|
|
490
|
+
const attempts: number[] = []
|
|
491
|
+
const copied: Buffer[] = [snapshotSourceBytes]
|
|
492
|
+
|
|
493
|
+
const error = await t.throwsAsync(
|
|
494
|
+
snapshotInput(source, destination, async (attempt) => {
|
|
495
|
+
attempts.push(attempt)
|
|
496
|
+
const next = rewrittenSourceBytes(attempt)
|
|
497
|
+
copied.push(next)
|
|
498
|
+
await writeFile(source, next)
|
|
499
|
+
await restampSource(source, attempt)
|
|
500
|
+
}),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
t.deepEqual(attempts, [1, 2, 3])
|
|
504
|
+
t.true(
|
|
505
|
+
error?.message.startsWith(
|
|
506
|
+
`Filesystem transaction source changed while it was snapshotted: ${source} (`,
|
|
507
|
+
),
|
|
508
|
+
)
|
|
509
|
+
t.true(
|
|
510
|
+
error?.message.includes(
|
|
511
|
+
`contentHash ${sourceHashOf(copied[1])} -> ${sourceHashOf(copied[2])}`,
|
|
512
|
+
),
|
|
513
|
+
error?.message,
|
|
514
|
+
)
|
|
515
|
+
t.true(
|
|
516
|
+
error?.message.endsWith('still drifting after 3 snapshot attempts)'),
|
|
517
|
+
error?.message,
|
|
518
|
+
)
|
|
519
|
+
t.false(existsSync(destination))
|
|
520
|
+
})
|
package/src/utils/misc.ts
CHANGED
|
@@ -4353,7 +4353,83 @@ export async function retireFailedSnapshotLeftover(
|
|
|
4353
4353
|
return { outcome: 'kept' }
|
|
4354
4354
|
}
|
|
4355
4355
|
|
|
4356
|
-
|
|
4356
|
+
interface FileSystemTransactionSourceDrift {
|
|
4357
|
+
/**
|
|
4358
|
+
* Differences that make the copy an unfaithful image of the opened file, or
|
|
4359
|
+
* that mean the path no longer names it. Always fatal.
|
|
4360
|
+
*/
|
|
4361
|
+
hard: string[]
|
|
4362
|
+
/**
|
|
4363
|
+
* Differences a re-copy can settle: an inode attribute re-stamp on an
|
|
4364
|
+
* otherwise identical file, or content that has not stopped moving yet.
|
|
4365
|
+
* Retryable.
|
|
4366
|
+
*/
|
|
4367
|
+
soft: string[]
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
/**
|
|
4371
|
+
* Compare the source as it was before the copy against the pinned descriptor
|
|
4372
|
+
* and the path after it, and name every field that moved.
|
|
4373
|
+
*
|
|
4374
|
+
* One message used to stand for eight independent conditions, which made a
|
|
4375
|
+
* failure from a platform the author cannot reach (a FreeBSD CI VM, say)
|
|
4376
|
+
* impossible to diagnose: a truncated read, a chmod, a timestamp re-stamp and a
|
|
4377
|
+
* replaced path all read identically.
|
|
4378
|
+
*/
|
|
4379
|
+
function describeFileSystemTransactionSourceDrift(
|
|
4380
|
+
before: BigIntStats,
|
|
4381
|
+
after: BigIntStats,
|
|
4382
|
+
bytesRead: number,
|
|
4383
|
+
pathStats: BigIntStats | undefined,
|
|
4384
|
+
): FileSystemTransactionSourceDrift {
|
|
4385
|
+
const hard: string[] = []
|
|
4386
|
+
const soft: string[] = []
|
|
4387
|
+
const note = (
|
|
4388
|
+
into: string[],
|
|
4389
|
+
field: string,
|
|
4390
|
+
beforeValue: bigint,
|
|
4391
|
+
afterValue: bigint,
|
|
4392
|
+
) => {
|
|
4393
|
+
if (beforeValue !== afterValue) {
|
|
4394
|
+
into.push(`${field} ${beforeValue} -> ${afterValue}`)
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
note(hard, 'dev', before.dev, after.dev)
|
|
4398
|
+
note(hard, 'ino', before.ino, after.ino)
|
|
4399
|
+
note(hard, 'size', before.size, after.size)
|
|
4400
|
+
if (after.size !== BigInt(bytesRead)) {
|
|
4401
|
+
hard.push(`bytesRead ${bytesRead} != size ${after.size}`)
|
|
4402
|
+
}
|
|
4403
|
+
note(soft, 'mode', before.mode, after.mode)
|
|
4404
|
+
note(soft, 'mtimeNs', before.mtimeNs, after.mtimeNs)
|
|
4405
|
+
note(soft, 'ctimeNs', before.ctimeNs, after.ctimeNs)
|
|
4406
|
+
if (pathStats === undefined) {
|
|
4407
|
+
hard.push('path identity vanished')
|
|
4408
|
+
} else if (!pathStats.isFile()) {
|
|
4409
|
+
hard.push('path identity is no longer a regular file')
|
|
4410
|
+
} else if (!statIdentitiesMatch(before, pathStats)) {
|
|
4411
|
+
hard.push(
|
|
4412
|
+
`path identity ${before.dev}/${before.ino} -> ${pathStats.dev}/${pathStats.ino}`,
|
|
4413
|
+
)
|
|
4414
|
+
}
|
|
4415
|
+
return { hard, soft }
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4418
|
+
/**
|
|
4419
|
+
* How many times a snapshot re-copies a source that moved underneath it before
|
|
4420
|
+
* giving up. See the drift classification comment inside
|
|
4421
|
+
* {@link snapshotFileSystemTransactionInput}.
|
|
4422
|
+
*/
|
|
4423
|
+
const fileSystemTransactionSnapshotAttempts = 3
|
|
4424
|
+
|
|
4425
|
+
/**
|
|
4426
|
+
* Exported for unit tests; not part of the supported `@napi-rs/cli` surface.
|
|
4427
|
+
*
|
|
4428
|
+
* @param onAfterCopy test seam invoked with the 1-based attempt number after
|
|
4429
|
+
* each copy pass and before the post-copy re-verification, so a test can mutate
|
|
4430
|
+
* the source deterministically instead of racing a timer against the copy.
|
|
4431
|
+
*/
|
|
4432
|
+
export async function snapshotFileSystemTransactionInput(
|
|
4357
4433
|
source: string,
|
|
4358
4434
|
destination: string,
|
|
4359
4435
|
mode?: number,
|
|
@@ -4364,6 +4440,7 @@ async function snapshotFileSystemTransactionInput(
|
|
|
4364
4440
|
recordDestinationIdentity?: (
|
|
4365
4441
|
identity: FileSystemTransactionFileIdentity,
|
|
4366
4442
|
) => Promise<void>,
|
|
4443
|
+
onAfterCopy?: (attempt: number) => Promise<void>,
|
|
4367
4444
|
): Promise<FileSystemTransactionJournalFileState> {
|
|
4368
4445
|
// All stats in this flow are bigint so every path-vs-handle continuity check
|
|
4369
4446
|
// below compares exact 64-bit identity, never the lossy Number dev/ino: a
|
|
@@ -4416,7 +4493,6 @@ async function snapshotFileSystemTransactionInput(
|
|
|
4416
4493
|
'changed before it could be snapshotted',
|
|
4417
4494
|
)
|
|
4418
4495
|
}
|
|
4419
|
-
const finalMode = mode ?? Number(sourceStats.mode & 0o7777n)
|
|
4420
4496
|
if (createDestinationParent) {
|
|
4421
4497
|
await mkdir(dirname(destination), { recursive: true })
|
|
4422
4498
|
}
|
|
@@ -4463,50 +4539,116 @@ async function snapshotFileSystemTransactionInput(
|
|
|
4463
4539
|
}
|
|
4464
4540
|
await recordDestinationIdentity(destinationIdentity)
|
|
4465
4541
|
}
|
|
4466
|
-
const hash = createHash('sha256')
|
|
4467
4542
|
const buffer = Buffer.allocUnsafe(64 * 1024)
|
|
4543
|
+
// A snapshot's job is to record a *consistent* copy, so the source is
|
|
4544
|
+
// re-verified after the copy against the stats taken before it. Drift
|
|
4545
|
+
// splits in two:
|
|
4546
|
+
//
|
|
4547
|
+
// hard — dev, ino, size, bytes-read, path identity. The copy is not a
|
|
4548
|
+
// faithful image of the file that was opened, or the path no longer
|
|
4549
|
+
// names it. Never tolerated.
|
|
4550
|
+
// soft — mode, mtimeNs, ctimeNs on an otherwise identical inode. This
|
|
4551
|
+
// is a metadata re-stamp: a kernel, a permission normalization or a
|
|
4552
|
+
// stray `utimes` moves these without touching a byte, and it is a
|
|
4553
|
+
// routine thing to happen to a file this process itself just wrote
|
|
4554
|
+
// into its own staging directory. Failing a release build over it is
|
|
4555
|
+
// wrong — the recorded sha256 plus dev/ino/size already guarantee the
|
|
4556
|
+
// content — so redo the snapshot instead.
|
|
4557
|
+
//
|
|
4558
|
+
// Metadata alone cannot decide that, though, so a retry also has to
|
|
4559
|
+
// reproduce the bytes. Each attempt rebases the baseline on what it just
|
|
4560
|
+
// observed, and a timestamp only moves as far as its filesystem can
|
|
4561
|
+
// express: where the clock is coarse, or where several writes land in one
|
|
4562
|
+
// tick, a writer that rewrites the file in place at the same length moves
|
|
4563
|
+
// no field this function compares. Metadata would read as settled while
|
|
4564
|
+
// the copy mixed two versions of the file, and the hash of that mixture
|
|
4565
|
+
// would be recorded as the authoritative one — nothing downstream ever
|
|
4566
|
+
// reads the source again to notice. So an attempt that follows drift is
|
|
4567
|
+
// accepted only when it hashes to exactly what the attempt before it
|
|
4568
|
+
// hashed to: two passes in a row agreeing on the content is the evidence
|
|
4569
|
+
// the source is settled. A hash that keeps moving is named as
|
|
4570
|
+
// `contentHash` drift and spends the attempt bound like any other.
|
|
4571
|
+
//
|
|
4572
|
+
// The source descriptor is deliberately *not* re-opened between attempts
|
|
4573
|
+
// — it pins the inode validated on the way in, so a retry can never adopt
|
|
4574
|
+
// a successor swapped into the path.
|
|
4575
|
+
let baselineStats = sourceStats
|
|
4576
|
+
let sourceHash = ''
|
|
4577
|
+
let previousHash: string | undefined
|
|
4468
4578
|
let position = 0
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4579
|
+
let drift: FileSystemTransactionSourceDrift | undefined
|
|
4580
|
+
for (
|
|
4581
|
+
let attempt = 1;
|
|
4582
|
+
attempt <= fileSystemTransactionSnapshotAttempts;
|
|
4583
|
+
attempt++
|
|
4584
|
+
) {
|
|
4585
|
+
if (attempt > 1) {
|
|
4586
|
+
// Discard the previous attempt's bytes. The destination inode is
|
|
4587
|
+
// transaction-owned and unpublished, and its identity — already
|
|
4588
|
+
// recorded above — is unaffected by a truncate.
|
|
4589
|
+
await destinationHandle.truncate(0)
|
|
4590
|
+
}
|
|
4591
|
+
const hash = createHash('sha256')
|
|
4592
|
+
position = 0
|
|
4593
|
+
while (true) {
|
|
4594
|
+
const { bytesRead } = await sourceHandle.read(
|
|
4595
|
+
buffer,
|
|
4596
|
+
0,
|
|
4597
|
+
buffer.length,
|
|
4598
|
+
position,
|
|
4599
|
+
)
|
|
4600
|
+
if (bytesRead === 0) {
|
|
4601
|
+
break
|
|
4602
|
+
}
|
|
4603
|
+
hash.update(buffer.subarray(0, bytesRead))
|
|
4604
|
+
let written = 0
|
|
4605
|
+
while (written < bytesRead) {
|
|
4606
|
+
const result = await destinationHandle.write(
|
|
4607
|
+
buffer,
|
|
4608
|
+
written,
|
|
4609
|
+
bytesRead - written,
|
|
4610
|
+
position + written,
|
|
4611
|
+
)
|
|
4612
|
+
written += result.bytesWritten
|
|
4613
|
+
}
|
|
4614
|
+
position += bytesRead
|
|
4615
|
+
}
|
|
4616
|
+
sourceHash = hash.digest('hex')
|
|
4617
|
+
await onAfterCopy?.(attempt)
|
|
4618
|
+
const [finalSourceStats, finalPathStats] = await Promise.all([
|
|
4619
|
+
sourceHandle.stat({ bigint: true }),
|
|
4620
|
+
lstatIfExists(source, { bigint: true }),
|
|
4621
|
+
])
|
|
4622
|
+
drift = describeFileSystemTransactionSourceDrift(
|
|
4623
|
+
baselineStats,
|
|
4624
|
+
finalSourceStats,
|
|
4474
4625
|
position,
|
|
4626
|
+
finalPathStats,
|
|
4475
4627
|
)
|
|
4476
|
-
|
|
4628
|
+
// Rebase on what was just observed either way: on success it is the
|
|
4629
|
+
// settled metadata the journal should record, and on soft drift it is
|
|
4630
|
+
// the baseline the next attempt has to hold still against.
|
|
4631
|
+
baselineStats = finalSourceStats
|
|
4632
|
+
if (previousHash !== undefined && previousHash !== sourceHash) {
|
|
4633
|
+
drift.soft.push(`contentHash ${previousHash} -> ${sourceHash}`)
|
|
4634
|
+
}
|
|
4635
|
+
previousHash = sourceHash
|
|
4636
|
+
if (drift.hard.length > 0 || drift.soft.length === 0) {
|
|
4477
4637
|
break
|
|
4478
4638
|
}
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
bytesRead - written,
|
|
4486
|
-
position + written,
|
|
4639
|
+
}
|
|
4640
|
+
if (drift && (drift.hard.length > 0 || drift.soft.length > 0)) {
|
|
4641
|
+
const reasons = [...drift.hard, ...drift.soft]
|
|
4642
|
+
if (drift.hard.length === 0) {
|
|
4643
|
+
reasons.push(
|
|
4644
|
+
`still drifting after ${fileSystemTransactionSnapshotAttempts} snapshot attempts`,
|
|
4487
4645
|
)
|
|
4488
|
-
written += result.bytesWritten
|
|
4489
4646
|
}
|
|
4490
|
-
position += bytesRead
|
|
4491
|
-
}
|
|
4492
|
-
const [finalSourceStats, finalPathStats] = await Promise.all([
|
|
4493
|
-
sourceHandle.stat({ bigint: true }),
|
|
4494
|
-
lstatIfExists(source, { bigint: true }),
|
|
4495
|
-
])
|
|
4496
|
-
if (
|
|
4497
|
-
!statIdentitiesMatch(sourceStats, finalSourceStats) ||
|
|
4498
|
-
finalSourceStats.size !== sourceStats.size ||
|
|
4499
|
-
finalSourceStats.size !== BigInt(position) ||
|
|
4500
|
-
finalSourceStats.mode !== sourceStats.mode ||
|
|
4501
|
-
finalSourceStats.mtimeNs !== sourceStats.mtimeNs ||
|
|
4502
|
-
finalSourceStats.ctimeNs !== sourceStats.ctimeNs ||
|
|
4503
|
-
finalPathStats?.isFile() !== true ||
|
|
4504
|
-
!statIdentitiesMatch(sourceStats, finalPathStats)
|
|
4505
|
-
) {
|
|
4506
4647
|
throw new Error(
|
|
4507
|
-
`Filesystem transaction source changed while it was snapshotted: ${source}`,
|
|
4648
|
+
`Filesystem transaction source changed while it was snapshotted: ${source} (${reasons.join('; ')})`,
|
|
4508
4649
|
)
|
|
4509
4650
|
}
|
|
4651
|
+
const finalMode = mode ?? Number(baselineStats.mode & 0o7777n)
|
|
4510
4652
|
await applyFileSystemTransactionMode(destinationHandle, destinationMode)
|
|
4511
4653
|
await destinationHandle.sync()
|
|
4512
4654
|
const finalDestinationStats = await destinationHandle.stat({
|
|
@@ -4538,7 +4680,7 @@ async function snapshotFileSystemTransactionInput(
|
|
|
4538
4680
|
const sourceIdentityStats = await sourceHandle.stat({ bigint: true })
|
|
4539
4681
|
return {
|
|
4540
4682
|
dev: String(sourceIdentityStats.dev),
|
|
4541
|
-
hash:
|
|
4683
|
+
hash: sourceHash,
|
|
4542
4684
|
ino: String(sourceIdentityStats.ino),
|
|
4543
4685
|
mode: finalMode,
|
|
4544
4686
|
}
|