@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.
@@ -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
- async function snapshotFileSystemTransactionInput(
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
- while (true) {
4470
- const { bytesRead } = await sourceHandle.read(
4471
- buffer,
4472
- 0,
4473
- buffer.length,
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
- if (bytesRead === 0) {
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
- hash.update(buffer.subarray(0, bytesRead))
4480
- let written = 0
4481
- while (written < bytesRead) {
4482
- const result = await destinationHandle.write(
4483
- buffer,
4484
- written,
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: hash.digest('hex'),
4683
+ hash: sourceHash,
4542
4684
  ino: String(sourceIdentityStats.ino),
4543
4685
  mode: finalMode,
4544
4686
  }