@napi-rs/cli 3.10.2 → 3.10.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@napi-rs/cli",
3
- "version": "3.10.2",
3
+ "version": "3.10.4",
4
4
  "description": "Cli tools for napi-rs",
5
5
  "author": "LongYinan <lynweklm@gmail.com>",
6
6
  "homepage": "https://napi.rs/",
@@ -831,6 +831,263 @@ test('a fresh WASI declaration narrows the inherited root union', async (t) => {
831
831
  const ROOT_BINDING_TARGET_DECLARATION =
832
832
  "export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'"
833
833
 
834
+ test('an ESM root declaration derives the WASI declaration unless it cannot cross', async (t) => {
835
+ const { tmpDir, projectDir, typeDefDir } = t.context
836
+ await writeFile(
837
+ join(typeDefDir, 'sum.type'),
838
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
839
+ )
840
+ const { dts } = await generateTypeDef({
841
+ typeDefDir,
842
+ cwd: projectDir,
843
+ declareBindingTarget: true,
844
+ })
845
+
846
+ // A `"type": "module"` package's `index.d.ts` is an ESM declaration, and so
847
+ // is an explicit `.d.mts` — but the generated typedef carries nothing a
848
+ // `.d.cts` cannot re-declare, so the WASI declaration derives verbatim.
849
+ // (napi-rs#3531: an unconditional refusal failed every WASI build in a
850
+ // module package, including `--esm` ones.)
851
+ const sourcePath = join(projectDir, 'index.d.ts')
852
+ const destinationPath = join(projectDir, 'pkg.wasi.d.cts')
853
+ for (const esmSourcePath of [sourcePath, join(projectDir, 'index.d.mts')]) {
854
+ t.is(
855
+ prepareWasiBindingTypeDef(
856
+ dts,
857
+ esmSourcePath,
858
+ destinationPath,
859
+ true,
860
+ 'module',
861
+ ),
862
+ dts,
863
+ )
864
+ }
865
+
866
+ // The threadless flavor still gets its node-global rewrites on the way, and
867
+ // what it emits is valid TypeScript beside the ESM source it came from.
868
+ const threadless = prepareWasiBindingTypeDef(
869
+ dts,
870
+ sourcePath,
871
+ destinationPath,
872
+ false,
873
+ 'module',
874
+ )
875
+ const codes = await semanticDiagnosticCodes(join(tmpDir, 'esm-derived'), {
876
+ 'index.d.ts': dts,
877
+ 'pkg.wasi.d.cts': threadless,
878
+ })
879
+ t.deepEqual(codes['pkg.wasi.d.cts'], [])
880
+
881
+ // Only the constructs a CommonJS declaration cannot carry still refuse —
882
+ // each one named in the error, from `.d.ts` and `.d.mts` sources alike.
883
+ const hazards: Array<[what: string, source: string, barrier: string]> = [
884
+ [
885
+ 'an `export default` declaration',
886
+ 'export declare function sum(a: number): number\nexport default sum\n',
887
+ 'an `export default` declaration',
888
+ ],
889
+ [
890
+ 'a default class declaration',
891
+ 'export default class Sum {}\n',
892
+ 'an `export default` declaration',
893
+ ],
894
+ [
895
+ 'a default function declaration',
896
+ 'export default function sum(a: number): number\n',
897
+ 'an `export default` declaration',
898
+ ],
899
+ [
900
+ 'a default interface declaration',
901
+ 'export default interface Sum { a: number }\n',
902
+ 'an `export default` declaration',
903
+ ],
904
+ [
905
+ 'a default namespace declaration',
906
+ 'export default namespace Sum { const a: number }\n',
907
+ 'an `export default` declaration',
908
+ ],
909
+ [
910
+ 'a `default` re-export',
911
+ 'declare const sum: number\nexport { sum as default }\n',
912
+ 'a `default` re-export',
913
+ ],
914
+ [
915
+ 'a `default` re-export from a package',
916
+ "export { sum as default } from 'sums'\n",
917
+ 'a `default` re-export',
918
+ ],
919
+ [
920
+ 'a type-only `default` re-export',
921
+ 'type Sum = number\nexport type { Sum as default }\n',
922
+ 'a `default` re-export',
923
+ ],
924
+ [
925
+ 'a `default` namespace re-export',
926
+ "export * as default from 'sums'\n",
927
+ 'a `default` re-export',
928
+ ],
929
+ [
930
+ 'a relative import specifier',
931
+ "import { sum } from './sum.js'\nexport declare function f(): typeof sum\n",
932
+ "a relative './sum.js' specifier",
933
+ ],
934
+ [
935
+ 'an extensionless relative specifier',
936
+ "import { sum } from './sum'\nexport declare function f(): typeof sum\n",
937
+ "a relative './sum' specifier",
938
+ ],
939
+ [
940
+ 'a relative export specifier',
941
+ "export { sum } from './sum.js'\n",
942
+ "a relative './sum.js' specifier",
943
+ ],
944
+ [
945
+ 'a relative export-star specifier',
946
+ "export * from './sums.js'\n",
947
+ "a relative './sums.js' specifier",
948
+ ],
949
+ [
950
+ 'a relative import type specifier',
951
+ "import type { Sum } from './sum.js'\nexport declare function f(s: Sum): void\n",
952
+ "a relative './sum.js' specifier",
953
+ ],
954
+ [
955
+ 'a relative import() type',
956
+ "export declare function f(): import('./sum.js').Sum\n",
957
+ "a relative './sum.js' specifier",
958
+ ],
959
+ [
960
+ 'a relative import-equals specifier',
961
+ "import sum = require('./sum.js')\nexport declare function f(): typeof sum\n",
962
+ "a relative './sum.js' specifier",
963
+ ],
964
+ [
965
+ 'a relative ambient module specifier',
966
+ "declare module './sum.js' { export const sum: number }\n",
967
+ "a relative './sum.js' specifier",
968
+ ],
969
+ [
970
+ 'a relative types reference',
971
+ "/// <reference types='./sum' />\nexport declare const sum: number\n",
972
+ 'a relative \'/// <reference types="./sum" />\' directive',
973
+ ],
974
+ [
975
+ 'attributes on a runtime statement',
976
+ "import sum from './sum.json' with { type: 'json' }\nexport declare const s: typeof sum\n",
977
+ '`with` attributes on a runtime module statement',
978
+ ],
979
+ ]
980
+ const esmSources: Array<
981
+ [path: string, packageType: 'module' | 'commonjs' | undefined]
982
+ > = [
983
+ [sourcePath, 'module'],
984
+ [join(projectDir, 'index.d.mts'), undefined],
985
+ ]
986
+ for (const [what, source, barrier] of hazards) {
987
+ for (const [esmSourcePath, packageType] of esmSources) {
988
+ const error = t.throws(
989
+ () =>
990
+ prepareWasiBindingTypeDef(
991
+ source,
992
+ esmSourcePath,
993
+ destinationPath,
994
+ true,
995
+ packageType,
996
+ ),
997
+ { instanceOf: Error },
998
+ )
999
+ t.true(
1000
+ error?.message.includes(barrier),
1001
+ `${what} names the offending construct, got: ${error?.message}`,
1002
+ )
1003
+ }
1004
+ }
1005
+
1006
+ // Everything else a module package can write crosses honestly.
1007
+ const honest: Record<string, string> = {
1008
+ 'bare and node: imports':
1009
+ "import { Buffer } from 'buffer'\nimport type { ReadableStream } from 'node:stream/web'\nexport declare function f(b: Buffer, s: ReadableStream): void\n",
1010
+ '.mjs specifiers':
1011
+ "import { sum } from './sum.mjs'\nexport declare function f(): typeof sum\n",
1012
+ '.cjs specifiers':
1013
+ "import { sum } from './sum.cjs'\nexport declare function f(): typeof sum\n",
1014
+ 'an export assignment':
1015
+ 'declare const binding: { sum(a: number): number }\nexport = binding\n',
1016
+ 'a declare global block':
1017
+ 'declare global { namespace Legacy { const v: number } }\nexport {}\n',
1018
+ 'a bare ambient module':
1019
+ "declare module 'legacy' { export const sum: number }\n",
1020
+ 'a .mjs ambient module':
1021
+ "declare module './sum.mjs' { export const sum: number }\n",
1022
+ 'a default inside an ambient module':
1023
+ "declare module 'legacy' { const sum: number\nexport default sum }\n",
1024
+ 'a type-only import with resolution-mode':
1025
+ "import type { Sum } from './sum.js' with { 'resolution-mode': 'import' }\nexport declare function f(s: Sum): void\n",
1026
+ 'a type-only import pinned to require mode':
1027
+ "import type { Sum } from './sum.js' with { 'resolution-mode': 'require' }\nexport declare function f(s: Sum): void\n",
1028
+ 'a type-only export with resolution-mode':
1029
+ "export type { Sum } from './sum.js' with { 'resolution-mode': 'import' }\n",
1030
+ 'a json specifier':
1031
+ "import sum from './sum.json'\nexport declare const s: typeof sum\n",
1032
+ 'a path reference':
1033
+ "/// <reference path='./sum.d.ts' />\nexport declare const sum: number\n",
1034
+ 'a bare import() type':
1035
+ "export declare function f(): import('buffer').Buffer\n",
1036
+ 'an import() type pinned to import mode':
1037
+ "export declare function f(): import('./sum.js', { with: { 'resolution-mode': 'import' } }).Sum\n",
1038
+ 'a namespace export':
1039
+ 'export declare namespace Legacy { const sum: number }\n',
1040
+ }
1041
+ for (const [what, source] of Object.entries(honest)) {
1042
+ t.notThrows(
1043
+ () =>
1044
+ prepareWasiBindingTypeDef(
1045
+ source,
1046
+ sourcePath,
1047
+ destinationPath,
1048
+ true,
1049
+ 'module',
1050
+ ),
1051
+ what,
1052
+ )
1053
+ }
1054
+
1055
+ // Nothing is scanned when the source is already CommonJS — a `.d.cts` (or a
1056
+ // `.d.ts` in a `commonjs` package) reads the same beside its destination, so
1057
+ // every hazard above carries over verbatim.
1058
+ for (const [, source] of hazards) {
1059
+ for (const [cjsSourcePath, packageType] of [
1060
+ [join(projectDir, 'index.d.cts'), 'module'],
1061
+ [sourcePath, 'commonjs'],
1062
+ [sourcePath, undefined],
1063
+ ] as const) {
1064
+ t.notThrows(() =>
1065
+ prepareWasiBindingTypeDef(
1066
+ source,
1067
+ cjsSourcePath,
1068
+ destinationPath,
1069
+ true,
1070
+ packageType,
1071
+ ),
1072
+ )
1073
+ }
1074
+ }
1075
+
1076
+ // A specifier that can cross still rebases onto the destination directory
1077
+ // — including a `declare module` augmentation name, which targets the file
1078
+ // it spells out.
1079
+ const relocated = prepareWasiBindingTypeDef(
1080
+ "/// <reference path='./sum.d.ts' />\ndeclare module './sum.mjs' { export const sum: number }\nexport declare function f(): import('./sum.mjs').Sum\nexport { sum } from './sum.mjs'\n",
1081
+ join(projectDir, 'types', 'index.d.mts'),
1082
+ destinationPath,
1083
+ true,
1084
+ )
1085
+ t.true(relocated.includes("declare module './types/sum.mjs'"))
1086
+ t.true(relocated.includes("import('./types/sum.mjs')"))
1087
+ t.true(relocated.includes("from './types/sum.mjs'"))
1088
+ t.true(relocated.includes("reference path='./types/sum.d.ts'"))
1089
+ })
1090
+
834
1091
  test('a crate without type defs still declares the binding target', async (t) => {
835
1092
  const { projectDir, typeDefDir } = t.context
836
1093
 
package/src/api/build.ts CHANGED
@@ -21,6 +21,7 @@ import type { BuildOptions as RawBuildOptions } from '../def/build.js'
21
21
  import {
22
22
  CLI_VERSION,
23
23
  commitFileSystemTransaction,
24
+ commonJsDeclarationBarrier,
24
25
  copyFileAtomic,
25
26
  type Crate,
26
27
  debugFactory,
@@ -795,6 +796,16 @@ export function bindingTargetDeclarationPredicate(input: {
795
796
  (input.rootLoaderCandidate && (input.hasWasiFallback || exports.length > 0))
796
797
  }
797
798
 
799
+ /**
800
+ * The `.d.cts` content for a WASI flavor loader, derived from the root
801
+ * declaration this build wrote.
802
+ *
803
+ * An ESM source (`*.d.mts`, or `*.d.ts` in a `"type": "module"` package) is
804
+ * re-declared verbatim when nothing in it changes meaning under CommonJS
805
+ * interpretation — which is everything a generated typedef and most
806
+ * `--dts-header`s contain. {@link commonJsDeclarationBarrier} names the
807
+ * constructs that still refuse, and why.
808
+ */
798
809
  export function prepareWasiBindingTypeDef(
799
810
  source: string,
800
811
  sourcePath: string,
@@ -803,12 +814,15 @@ export function prepareWasiBindingTypeDef(
803
814
  packageType?: 'module' | 'commonjs',
804
815
  ) {
805
816
  if (
806
- sourcePath.endsWith('.d.mts') ||
807
- (sourcePath.endsWith('.d.ts') && packageType === 'module')
817
+ sourcePath.endsWith('.mts') ||
818
+ (sourcePath.endsWith('.ts') && packageType === 'module')
808
819
  ) {
809
- throw new Error(
810
- `Cannot emit the CommonJS WASI declaration ${destinationPath} from the ESM declaration ${sourcePath}. Use a .d.cts --dts path for WASI builds in module packages.`,
811
- )
820
+ const barrier = commonJsDeclarationBarrier(source)
821
+ if (barrier !== undefined) {
822
+ throw new Error(
823
+ `Cannot emit the CommonJS WASI declaration ${destinationPath} from the ESM declaration ${sourcePath}: ${barrier} cannot be re-declared in a CommonJS declaration. Use a .d.cts --dts path for WASI builds from ESM sources.`,
824
+ )
825
+ }
812
826
  }
813
827
  const targetSource = hasThreads
814
828
  ? source
@@ -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
+ })