@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/dist/cli.js +215 -19
- package/dist/index.cjs +219 -23
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +215 -19
- package/package.json +1 -1
- package/src/api/__tests__/build.spec.ts +257 -0
- package/src/api/build.ts +19 -5
- package/src/utils/__tests__/misc.spec.ts +339 -0
- package/src/utils/misc.ts +178 -36
- package/src/utils/typegen.ts +221 -2
package/package.json
CHANGED
|
@@ -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('.
|
|
807
|
-
(sourcePath.endsWith('.
|
|
817
|
+
sourcePath.endsWith('.mts') ||
|
|
818
|
+
(sourcePath.endsWith('.ts') && packageType === 'module')
|
|
808
819
|
) {
|
|
809
|
-
|
|
810
|
-
|
|
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
|
+
})
|