@ttsc/metro 0.28.5 → 0.28.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/lib/core/fingerprint.d.ts +62 -41
- package/lib/core/fingerprint.js +666 -180
- package/lib/core/fingerprint.js.map +1 -1
- package/lib/core/fingerprint.mjs +668 -182
- package/lib/core/fingerprint.mjs.map +1 -1
- package/lib/core/options.d.ts +3 -1
- package/lib/core/options.js +9 -2
- package/lib/core/options.js.map +1 -1
- package/lib/core/options.mjs +9 -2
- package/lib/core/options.mjs.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +2 -2
- package/lib/index.mjs.map +1 -1
- package/lib/transformer.d.ts +11 -12
- package/lib/transformer.js +35 -24
- package/lib/transformer.js.map +1 -1
- package/lib/transformer.mjs +37 -26
- package/lib/transformer.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/fingerprint.ts +906 -223
- package/src/core/options.ts +18 -4
- package/src/index.ts +5 -4
- package/src/transformer.ts +39 -26
package/lib/core/fingerprint.js
CHANGED
|
@@ -21,20 +21,20 @@ var path = require('node:path');
|
|
|
21
21
|
* `projectRoot` plus the resolved tsconfig's directory when it lies outside),
|
|
22
22
|
* hashed with the exact walk universe the `@ttsc/unplugin` transform core
|
|
23
23
|
* validates its own cache against.
|
|
24
|
-
* - **Recorded
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* - **Recorded transform inputs.** The host-owned reference graph
|
|
25
|
+
* (samchon/ttsc#718) reports each transform's derived inputs. Workers retain
|
|
26
|
+
* them under `node_modules/.cache/ttsc-metro`, compare their generation state
|
|
27
|
+
* with the exact main-process key baseline, and batch one durable write per
|
|
28
|
+
* delivered module.
|
|
29
29
|
*
|
|
30
30
|
* Snapshot layout: one main file carrying a random epoch id plus per-worker
|
|
31
31
|
* files with unique names, so concurrent workers never race a shared write.
|
|
32
|
-
* `withTtsc`
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* main
|
|
36
|
-
*
|
|
37
|
-
* afterwards.
|
|
32
|
+
* `withTtsc` compacts worker files into the main file before its workers exist,
|
|
33
|
+
* under a process-shared lock for builds using the same project cache. Readers
|
|
34
|
+
* take the union of every file, reading the worker files strictly before the
|
|
35
|
+
* main file: the compactor renames the merged main into place strictly before
|
|
36
|
+
* deleting a worker file, so a worker file that disappears mid-read is always
|
|
37
|
+
* already merged into the main the reader loads afterwards.
|
|
38
38
|
*
|
|
39
39
|
* Sound degradations, by design:
|
|
40
40
|
*
|
|
@@ -50,9 +50,12 @@ var path = require('node:path');
|
|
|
50
50
|
* until a later run records the volatile declaration gone.
|
|
51
51
|
* - A recorded input that disappears hashes as a stable `missing` marker, so
|
|
52
52
|
* deletion and reappearance both move the key.
|
|
53
|
+
* - A worker state that differs from the static key's run baseline taints the
|
|
54
|
+
* observation; compaction rotates the epoch so even A -> B -> A cannot reuse
|
|
55
|
+
* output stored under the earlier A key.
|
|
53
56
|
*/
|
|
54
57
|
/** Bumped when the snapshot JSON shape changes; mismatches read as corrupt. */
|
|
55
|
-
const SNAPSHOT_VERSION =
|
|
58
|
+
const SNAPSHOT_VERSION = 2;
|
|
56
59
|
/** Snapshot directory segments under the fingerprint base directory. */
|
|
57
60
|
const SNAPSHOT_DIRECTORY = ["node_modules", ".cache", "ttsc-metro"];
|
|
58
61
|
/** Recovery-document prefix in the parent cache directory. */
|
|
@@ -63,6 +66,14 @@ const MAIN_SNAPSHOT = "graph-inputs.json";
|
|
|
63
66
|
const WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-";
|
|
64
67
|
/** Prefix used after a compactor atomically claims an immutable worker file. */
|
|
65
68
|
const CLAIMED_WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-claimed-";
|
|
69
|
+
/** Directory lock serializing the one mutable main-snapshot rewrite. */
|
|
70
|
+
const SNAPSHOT_COMPACTION_LOCK = "snapshot-compaction.lock";
|
|
71
|
+
/** Complete owner record stored inside an atomically published lock. */
|
|
72
|
+
const SNAPSHOT_COMPACTION_OWNER = "owner.json";
|
|
73
|
+
/** Cache-key baseline file prefix, one immutable identity per Metro run. */
|
|
74
|
+
const KEY_BASELINE_PREFIX = "key-baseline-";
|
|
75
|
+
/** Run-token prefix that forces every transformer key to be non-reusable. */
|
|
76
|
+
const NON_REUSABLE_RUN_PREFIX = "nonce:";
|
|
66
77
|
/** Bases whose latest observation is not yet durable in the main snapshot. */
|
|
67
78
|
const unhealthySnapshots = new Set();
|
|
68
79
|
/**
|
|
@@ -83,11 +94,15 @@ function resolveFingerprintBase(projectRoot) {
|
|
|
83
94
|
* already inside the base walk (an explicit out-of-root `project`, or a
|
|
84
95
|
* monorepo-root tsconfig discovered above the app). Matching the transform
|
|
85
96
|
* core's own validation universe keeps the invariant simple: everything the
|
|
86
|
-
* core treats as an input is fingerprinted
|
|
87
|
-
*
|
|
97
|
+
* core treats as an input is fingerprinted by the walk, the recorded snapshot,
|
|
98
|
+
* or both.
|
|
88
99
|
*/
|
|
89
100
|
function fingerprintRoots(base, explicitProject) {
|
|
90
|
-
const
|
|
101
|
+
const explicit = normalizedExplicitProject(explicitProject);
|
|
102
|
+
return projectViewRoots(base, resolveProjectTsconfig(base, explicit), explicit);
|
|
103
|
+
}
|
|
104
|
+
/** Roots covered by one selected project's static walk. */
|
|
105
|
+
function projectViewRoots(base, tsconfig, explicitProject) {
|
|
91
106
|
// Containment, not walk membership. The question here is whether the
|
|
92
107
|
// tsconfig's directory already sits inside the subtree the base walk covers,
|
|
93
108
|
// so that adding it would repeat the same walk. `isProjectWalkPath` answers a
|
|
@@ -95,13 +110,17 @@ function fingerprintRoots(base, explicitProject) {
|
|
|
95
110
|
// stopped hashing files that cannot enter the program it began answering
|
|
96
111
|
// `false` for every `tsconfig.json`, which returned the base twice and hashed
|
|
97
112
|
// the whole project twice on every cache key (samchon/ttsc#1307).
|
|
113
|
+
const resolvedBase = path.resolve(base);
|
|
98
114
|
const directory = path.dirname(path.resolve(tsconfig));
|
|
99
|
-
const relative = path.relative(
|
|
115
|
+
const relative = path.relative(resolvedBase, directory);
|
|
100
116
|
const inside = relative === "" ||
|
|
101
117
|
(relative !== ".." &&
|
|
102
118
|
!relative.startsWith(`..${path.sep}`) &&
|
|
103
119
|
!path.isAbsolute(relative));
|
|
104
|
-
|
|
120
|
+
if (explicitProject === undefined && inside && directory !== resolvedBase) {
|
|
121
|
+
return [directory];
|
|
122
|
+
}
|
|
123
|
+
return inside ? [resolvedBase] : [resolvedBase, directory];
|
|
105
124
|
}
|
|
106
125
|
/**
|
|
107
126
|
* Resolve one transform's project view, once, for every watch input it reports.
|
|
@@ -112,105 +131,204 @@ function fingerprintRoots(base, explicitProject) {
|
|
|
112
131
|
*/
|
|
113
132
|
function resolveProjectView(props) {
|
|
114
133
|
const base = resolveFingerprintBase(props.projectRoot);
|
|
115
|
-
|
|
134
|
+
const explicitProject = normalizedExplicitProject(props.explicitProject);
|
|
135
|
+
const start = explicitProject === undefined && props.filename !== undefined
|
|
136
|
+
? path.dirname(path.resolve(props.filename))
|
|
137
|
+
: base;
|
|
138
|
+
const discovery = explicitProject === undefined
|
|
139
|
+
? api.discoverNearestProjectTsconfig(start, props.projectDiscoveryFilesystem)
|
|
140
|
+
: undefined;
|
|
141
|
+
const tsconfig = discovery === undefined
|
|
142
|
+
? resolveProjectTsconfig(start, explicitProject)
|
|
143
|
+
: (discovery.file ?? path.resolve(process.cwd(), "tsconfig.json"));
|
|
144
|
+
const discoveryInputs = discovery === undefined
|
|
145
|
+
? [captureProjectDiscoveryInput(tsconfig)]
|
|
146
|
+
: discovery.candidates.map((candidate) => captureProjectDiscoveryInput(candidate.file, candidate.fileExists));
|
|
147
|
+
if (!discoveryInputs.some((input) => samePath(input.file, tsconfig))) {
|
|
148
|
+
discoveryInputs.push(captureProjectDiscoveryInput(tsconfig));
|
|
149
|
+
}
|
|
150
|
+
return createProjectView({
|
|
116
151
|
base,
|
|
152
|
+
compilerOptions: props.compilerOptions,
|
|
153
|
+
discoveryInputs,
|
|
154
|
+
explicitProject,
|
|
155
|
+
tsconfig,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** Create one cache view from an already selected project config. */
|
|
159
|
+
function createProjectView(props) {
|
|
160
|
+
const policy = membershipPolicy(props.tsconfig, props.compilerOptions);
|
|
161
|
+
return {
|
|
162
|
+
base: props.base,
|
|
163
|
+
discoveryInputs: props.discoveryInputs ?? [],
|
|
117
164
|
explicitProject: props.explicitProject,
|
|
118
|
-
policy
|
|
165
|
+
policy,
|
|
166
|
+
roots: projectViewRoots(props.base, props.tsconfig, props.explicitProject),
|
|
167
|
+
tsconfig: props.tsconfig,
|
|
168
|
+
walkPolicy: policy,
|
|
119
169
|
};
|
|
120
170
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// close.
|
|
141
|
-
//
|
|
142
|
-
// The overlay belongs in the key rather than the stamp because it is part of
|
|
143
|
-
// the question, not part of any file: keyed by path alone the memo would hand
|
|
144
|
-
// a caller who passed `allowJs` the policy resolved for a caller who did not.
|
|
145
|
-
const key = [tsconfig, stableStringify(compilerOptions ?? {})].join(String.fromCharCode(0));
|
|
146
|
-
const existing = MEMBERSHIP_POLICIES.get(key);
|
|
147
|
-
if (existing !== undefined && existing.stamp === stampOf(existing.sources)) {
|
|
148
|
-
return existing.policy;
|
|
149
|
-
}
|
|
150
|
-
// The caller's compiler-options overlay wins for the compile, so it has to
|
|
151
|
-
// win here too, exactly as it does in the adapter: a project given
|
|
152
|
-
// `allowJs: true` through `withTtsc` has a wider program than its tsconfig
|
|
153
|
-
// alone describes, and a narrower policy here would ask a different question
|
|
154
|
-
// about the same project (samchon/ttsc#1316).
|
|
155
|
-
const policy = api.mergeMembershipPolicyOverlay(api.readProjectMembershipPolicy(tsconfig), compilerOptions ?? {}, path.dirname(path.resolve(tsconfig)));
|
|
156
|
-
// Stamp the whole `extends` chain, not the leaf. Adding `exclude` to a shared
|
|
157
|
-
// `tsconfig.base.json` leaves the leaf's own mtime and size untouched while
|
|
158
|
-
// changing every answer the policy gives, so a leaf-only stamp would keep the
|
|
159
|
-
// worker on the pre-edit policy for its lifetime.
|
|
160
|
-
const sources = policy.sources.length === 0 ? [tsconfig] : [...policy.sources];
|
|
161
|
-
MEMBERSHIP_POLICIES.set(key, {
|
|
162
|
-
policy,
|
|
163
|
-
sources,
|
|
164
|
-
stamp: stampOf(sources),
|
|
165
|
-
});
|
|
166
|
-
return policy;
|
|
171
|
+
/** Attach identity to the exact file predicate that selected a worker project. */
|
|
172
|
+
function captureProjectDiscoveryInput(file, selectedFileExists) {
|
|
173
|
+
const baseline = api.captureWatchInputFileBaseline(file);
|
|
174
|
+
if (baseline === undefined) {
|
|
175
|
+
return { file };
|
|
176
|
+
}
|
|
177
|
+
const fileExists = selectedFileExists ?? baseline.fileExists;
|
|
178
|
+
return {
|
|
179
|
+
evidence: {
|
|
180
|
+
identity: baseline.identity,
|
|
181
|
+
missing: !fileExists,
|
|
182
|
+
state: {
|
|
183
|
+
codec: "predicates",
|
|
184
|
+
observation: { fileExists },
|
|
185
|
+
},
|
|
186
|
+
unavailable: fileExists ? undefined : "not-file",
|
|
187
|
+
},
|
|
188
|
+
file,
|
|
189
|
+
};
|
|
167
190
|
}
|
|
168
|
-
/**
|
|
169
|
-
function
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// whenever any child is added or removed (samchon/ttsc#1316).
|
|
177
|
-
return stats.isDirectory()
|
|
178
|
-
? `${source}:directory`
|
|
179
|
-
: `${source}:${stats.mtimeMs}:${stats.size}`;
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
182
|
-
// Absent now. `readProjectMembershipPolicy` answers for that too, and
|
|
183
|
-
// the policy must be re-asked once the config appears.
|
|
184
|
-
return `${source}:absent`;
|
|
185
|
-
}
|
|
186
|
-
})
|
|
187
|
-
.join("|");
|
|
191
|
+
/** Resolve one project policy from source rather than trusting metadata alone. */
|
|
192
|
+
function membershipPolicy(tsconfig, compilerOptions) {
|
|
193
|
+
// The caller overlay wins here exactly as it does for the compile. Re-reading
|
|
194
|
+
// source is deliberate: a long-lived worker cannot validate config contents
|
|
195
|
+
// from mtime and size, because a same-stamp rewrite is legal on coarse or
|
|
196
|
+
// restored filesystems. `resolveProjectView` runs once per delivered module,
|
|
197
|
+
// and its result is shared by the batched recorder.
|
|
198
|
+
return api.mergeMembershipPolicyOverlay(api.readProjectMembershipPolicy(tsconfig), compilerOptions ?? {}, path.dirname(path.resolve(tsconfig)));
|
|
188
199
|
}
|
|
189
200
|
/**
|
|
190
201
|
* Locate the tsconfig governing the project, mirroring the transform core's
|
|
191
202
|
* discovery: an explicit `project` resolves against the working directory;
|
|
192
203
|
* otherwise ancestor directories starting at `base` are searched for a
|
|
193
|
-
* `tsconfig.json
|
|
204
|
+
* `tsconfig.json` file, falling back to `<cwd>/tsconfig.json` like the shared
|
|
205
|
+
* transform core.
|
|
194
206
|
*/
|
|
195
207
|
function resolveProjectTsconfig(base, explicitProject) {
|
|
196
|
-
if (explicitProject !== undefined
|
|
208
|
+
if (explicitProject !== undefined) {
|
|
197
209
|
return path.isAbsolute(explicitProject)
|
|
198
210
|
? explicitProject
|
|
199
211
|
: path.resolve(process.cwd(), explicitProject);
|
|
200
212
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
const discovered = api.findNearestProjectTsconfig(base);
|
|
214
|
+
if (discovered !== undefined) {
|
|
215
|
+
return discovered;
|
|
216
|
+
}
|
|
217
|
+
return path.resolve(process.cwd(), "tsconfig.json");
|
|
218
|
+
}
|
|
219
|
+
/** Empty project strings carry the same implicit meaning as omission. */
|
|
220
|
+
function normalizedExplicitProject(explicitProject) {
|
|
221
|
+
return explicitProject === undefined || explicitProject.length === 0
|
|
222
|
+
? undefined
|
|
223
|
+
: explicitProject;
|
|
224
|
+
}
|
|
225
|
+
/** Resolve every implicit project whose files can be delivered below base. */
|
|
226
|
+
function fingerprintProjectViews(props) {
|
|
227
|
+
const primary = resolveProjectView(props);
|
|
228
|
+
if (primary.explicitProject !== undefined) {
|
|
229
|
+
return {
|
|
230
|
+
discoveryInputs: [primary.tsconfig],
|
|
231
|
+
projects: [stableFingerprintProjectView(primary, props.compilerOptions)],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const firstMap = api.findProjectTsconfigs(primary.base, props.projectDiscoveryFilesystem);
|
|
235
|
+
if (!firstMap.complete) {
|
|
236
|
+
throw new Error("Unable to enumerate Metro's implicit TypeScript projects.");
|
|
237
|
+
}
|
|
238
|
+
const projects = [
|
|
239
|
+
stableFingerprintProjectView(primary, props.compilerOptions),
|
|
240
|
+
];
|
|
241
|
+
for (const tsconfig of firstMap.files) {
|
|
242
|
+
const resolved = path.resolve(tsconfig);
|
|
243
|
+
if (projects.some((project) => samePath(project.tsconfig, resolved))) {
|
|
244
|
+
continue;
|
|
210
245
|
}
|
|
211
|
-
|
|
246
|
+
projects.push(stableFingerprintProjectView(createProjectView({
|
|
247
|
+
base: primary.base,
|
|
248
|
+
compilerOptions: props.compilerOptions,
|
|
249
|
+
explicitProject: undefined,
|
|
250
|
+
tsconfig: resolved,
|
|
251
|
+
}), props.compilerOptions));
|
|
252
|
+
}
|
|
253
|
+
const secondMap = api.findProjectTsconfigs(primary.base, props.projectDiscoveryFilesystem);
|
|
254
|
+
const selectedAfter = resolveProjectView(props);
|
|
255
|
+
if (!secondMap.complete ||
|
|
256
|
+
!sameProjectMap(firstMap.files, secondMap.files) ||
|
|
257
|
+
!sameProjectMap(firstMap.candidates, secondMap.candidates) ||
|
|
258
|
+
!samePath(primary.tsconfig, selectedAfter.tsconfig) ||
|
|
259
|
+
stableStringify(primary.discoveryInputs) !==
|
|
260
|
+
stableStringify(selectedAfter.discoveryInputs) ||
|
|
261
|
+
projects.some((project) => stableStringify(api.readTsconfigSourceSnapshot(project.tsconfig)) !==
|
|
262
|
+
stableStringify(project.configSources))) {
|
|
263
|
+
throw new Error("Metro's implicit TypeScript project map changed.");
|
|
212
264
|
}
|
|
213
|
-
|
|
265
|
+
const routedRoots = projects.map((project) => project.roots[0]);
|
|
266
|
+
return {
|
|
267
|
+
// `findProjectTsconfigs` covers candidates at and below Metro's base. The
|
|
268
|
+
// primary nearest-config search can also cross above that base to a
|
|
269
|
+
// monorepo config, and every rejected candidate on that ancestor path is
|
|
270
|
+
// just as capable of changing the selected project. Keep both sets in the
|
|
271
|
+
// main-process baseline so a worker does not taint every unchanged run for
|
|
272
|
+
// reporting a candidate the static key itself used.
|
|
273
|
+
discoveryInputs: [
|
|
274
|
+
...new Set([
|
|
275
|
+
...primary.discoveryInputs.map((input) => input.file),
|
|
276
|
+
...firstMap.candidates,
|
|
277
|
+
]),
|
|
278
|
+
].sort(),
|
|
279
|
+
projects: projects.map((project, index) => {
|
|
280
|
+
const root = routedRoots[index];
|
|
281
|
+
const nestedRoots = routedRoots.filter((candidate, candidateIndex) => candidateIndex !== index &&
|
|
282
|
+
!samePath(candidate, root) &&
|
|
283
|
+
pathIsWithin(candidate, root));
|
|
284
|
+
return nestedRoots.length === 0
|
|
285
|
+
? project
|
|
286
|
+
: {
|
|
287
|
+
...project,
|
|
288
|
+
walkPolicy: {
|
|
289
|
+
...project.policy,
|
|
290
|
+
excludedDirectories: [
|
|
291
|
+
...project.policy.excludedDirectories,
|
|
292
|
+
...nestedRoots,
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** Read one implicit project's policy between equal complete config snapshots. */
|
|
300
|
+
function stableFingerprintProjectView(project, compilerOptions) {
|
|
301
|
+
const before = api.readTsconfigSourceSnapshot(project.tsconfig);
|
|
302
|
+
const refreshed = createProjectView({
|
|
303
|
+
base: project.base,
|
|
304
|
+
compilerOptions,
|
|
305
|
+
explicitProject: project.explicitProject,
|
|
306
|
+
tsconfig: project.tsconfig,
|
|
307
|
+
});
|
|
308
|
+
const after = api.readTsconfigSourceSnapshot(project.tsconfig);
|
|
309
|
+
if (before.some((entry) => entry.contents === null) ||
|
|
310
|
+
after.some((entry) => entry.contents === null) ||
|
|
311
|
+
stableStringify(before) !== stableStringify(after)) {
|
|
312
|
+
throw new Error("Unable to read a stable TypeScript project config graph.");
|
|
313
|
+
}
|
|
314
|
+
return { ...refreshed, configSources: after };
|
|
315
|
+
}
|
|
316
|
+
/** Whether two complete lexical config enumerations name the same paths. */
|
|
317
|
+
function sameProjectMap(left, right) {
|
|
318
|
+
return (left.length === right.length &&
|
|
319
|
+
left.every((entry, index) => samePath(entry, right[index])));
|
|
320
|
+
}
|
|
321
|
+
/** Host-platform equality for two resolved path spellings. */
|
|
322
|
+
function samePath(left, right) {
|
|
323
|
+
return path.relative(path.resolve(left), path.resolve(right)) === "";
|
|
324
|
+
}
|
|
325
|
+
/** Whether one resolved path lies at or below another. */
|
|
326
|
+
function pathIsWithin(child, parent) {
|
|
327
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child));
|
|
328
|
+
return (relative === "" ||
|
|
329
|
+
(relative !== ".." &&
|
|
330
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
331
|
+
!path.isAbsolute(relative)));
|
|
214
332
|
}
|
|
215
333
|
/**
|
|
216
334
|
* Compute the fingerprint `getCacheKey` folds into Metro's static transformer
|
|
@@ -220,80 +338,183 @@ function resolveProjectTsconfig(base, explicitProject) {
|
|
|
220
338
|
function computeProjectFingerprint(props) {
|
|
221
339
|
try {
|
|
222
340
|
const base = resolveFingerprintBase(props.projectRoot);
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
// costing one compile the way it does for a bundler (samchon/ttsc#1307).
|
|
228
|
-
//
|
|
229
|
-
// The caller's compiler-options overlay is part of that configuration, and
|
|
230
|
-
// has to reach the walk as well as the recorder. The two are the halves of
|
|
231
|
-
// one cache key and run in different processes, so they agree only by
|
|
232
|
-
// deriving from the same declared options: a walk resolved without the
|
|
233
|
-
// overlay while the recorder resolves with it leaves an overlay-admitted
|
|
234
|
-
// input in neither half, which is the both-sides-disagree hole in its
|
|
235
|
-
// quietest form (samchon/ttsc#1316).
|
|
236
|
-
const project = resolveProjectView({
|
|
237
|
-
compilerOptions: props.compilerOptions,
|
|
238
|
-
explicitProject: props.explicitProject,
|
|
239
|
-
projectRoot: props.projectRoot,
|
|
240
|
-
});
|
|
241
|
-
for (const root of fingerprintRoots(base, props.explicitProject)) {
|
|
242
|
-
hash.update(stableStringify(api.collectProjectInputHashes(root, undefined, undefined, project.policy)));
|
|
243
|
-
}
|
|
244
|
-
const snapshot = readSnapshotState(base);
|
|
245
|
-
if (snapshot === undefined || snapshot.volatile) {
|
|
246
|
-
hash.update(nonce());
|
|
341
|
+
const before = observeProjectFingerprint(props);
|
|
342
|
+
const after = observeProjectFingerprint(props);
|
|
343
|
+
if (stableStringify(before) !== stableStringify(after)) {
|
|
344
|
+
throw new Error("Metro's project fingerprint changed while observed.");
|
|
247
345
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
hash.update(stableStringify(api.collectExternalInputHashes(snapshot.files)));
|
|
346
|
+
if (props.runId !== undefined) {
|
|
347
|
+
writeKeyBaseline(base, props.runId, after.inputs, after.staticInputs);
|
|
251
348
|
}
|
|
349
|
+
const hash = node_crypto.createHash("sha256");
|
|
350
|
+
hash.update(stableStringify(after.fingerprint));
|
|
252
351
|
return hash.digest("hex");
|
|
253
352
|
}
|
|
254
353
|
catch {
|
|
255
354
|
return nonce();
|
|
256
355
|
}
|
|
257
356
|
}
|
|
357
|
+
/** Build the complete value hashed by one static key. */
|
|
358
|
+
function observeProjectFingerprint(props) {
|
|
359
|
+
// Judge the fingerprint's walk by the same configuration the compile does.
|
|
360
|
+
// The caller overlay reaches this walk and the worker through the same
|
|
361
|
+
// serialized options, so neither side can silently describe another program.
|
|
362
|
+
const projectMap = fingerprintProjectViews(props);
|
|
363
|
+
const inputs = {};
|
|
364
|
+
const staticInputs = new Set();
|
|
365
|
+
const configSources = new Map();
|
|
366
|
+
const projectFingerprints = [];
|
|
367
|
+
for (const candidate of projectMap.discoveryInputs) {
|
|
368
|
+
addDiscoveryBaselineInput(inputs, candidate, staticInputs);
|
|
369
|
+
}
|
|
370
|
+
for (const project of projectMap.projects) {
|
|
371
|
+
for (const source of project.configSources) {
|
|
372
|
+
const existing = configSources.get(source.path);
|
|
373
|
+
if (existing !== undefined && existing.contents !== source.contents) {
|
|
374
|
+
throw new Error("A TypeScript config changed during fingerprinting.");
|
|
375
|
+
}
|
|
376
|
+
const baseline = addBaselineInput(inputs, source.path, staticInputs);
|
|
377
|
+
configSources.set(source.path, {
|
|
378
|
+
contents: source.contents,
|
|
379
|
+
identity: baseline.identity,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
for (const root of project.roots) {
|
|
383
|
+
const snapshot = api.collectProjectInputHashSnapshot(root, undefined, undefined, project.walkPolicy);
|
|
384
|
+
if (!snapshot.complete) {
|
|
385
|
+
throw new Error("Unable to read a complete Metro project walk.");
|
|
386
|
+
}
|
|
387
|
+
const fingerprintedInputs = {};
|
|
388
|
+
for (const [key, expected] of Object.entries(snapshot.hashes)) {
|
|
389
|
+
const file = path.resolve(root, key);
|
|
390
|
+
const baseline = addBaselineInput(inputs, file, staticInputs);
|
|
391
|
+
if (baseline.hostHash !== expected) {
|
|
392
|
+
throw new Error("A Metro project input changed while fingerprinted.");
|
|
393
|
+
}
|
|
394
|
+
fingerprintedInputs[key] = {
|
|
395
|
+
hash: expected,
|
|
396
|
+
identity: baseline.identity,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
projectFingerprints.push({
|
|
400
|
+
inputs: fingerprintedInputs,
|
|
401
|
+
root,
|
|
402
|
+
tsconfig: project.tsconfig,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const snapshot = readSnapshotState(resolveFingerprintBase(props.projectRoot));
|
|
407
|
+
if (snapshot === undefined || snapshot.volatile || snapshot.tainted) {
|
|
408
|
+
throw new Error("Metro's recorded transform snapshot is not reusable.");
|
|
409
|
+
}
|
|
410
|
+
const recorded = {};
|
|
411
|
+
for (const file of snapshot.files) {
|
|
412
|
+
const baseline = addBaselineInput(inputs, file);
|
|
413
|
+
recorded[snapshotPathKey(file)] = {
|
|
414
|
+
hash: baseline.hostHash,
|
|
415
|
+
identity: baseline.identity,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
fingerprint: {
|
|
420
|
+
configSources: [...configSources].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
|
|
421
|
+
projects: projectFingerprints,
|
|
422
|
+
snapshot: { id: snapshot.id, inputs: recorded },
|
|
423
|
+
},
|
|
424
|
+
inputs,
|
|
425
|
+
staticInputs: [...staticInputs].sort(),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** Add one lexical path's stable broad state to a key baseline. */
|
|
429
|
+
function addBaselineInput(inputs, file, staticInputs) {
|
|
430
|
+
const key = snapshotPathKey(file);
|
|
431
|
+
const observed = api.captureWatchInputBaseline(file);
|
|
432
|
+
if (observed === undefined) {
|
|
433
|
+
throw new Error("Unable to read a stable Metro input baseline.");
|
|
434
|
+
}
|
|
435
|
+
const existing = inputs[key];
|
|
436
|
+
if (existing !== undefined) {
|
|
437
|
+
if (existing.identity !== observed.identity ||
|
|
438
|
+
existing.fileExists !== observed.fileExists ||
|
|
439
|
+
("hostHash" in existing &&
|
|
440
|
+
stableStringify(existing) !== stableStringify(observed))) {
|
|
441
|
+
throw new Error("A Metro input changed between baseline observations.");
|
|
442
|
+
}
|
|
443
|
+
inputs[key] = { ...existing, ...observed };
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
inputs[key] = observed;
|
|
447
|
+
}
|
|
448
|
+
staticInputs?.add(key);
|
|
449
|
+
return observed;
|
|
450
|
+
}
|
|
451
|
+
/** Add the stable file predicate used by the project-map traversal. */
|
|
452
|
+
function addDiscoveryBaselineInput(inputs, file, staticInputs) {
|
|
453
|
+
const key = snapshotPathKey(file);
|
|
454
|
+
const observed = api.captureWatchInputFileBaseline(file);
|
|
455
|
+
if (observed === undefined) {
|
|
456
|
+
throw new Error("Unable to read a stable Metro project candidate.");
|
|
457
|
+
}
|
|
458
|
+
const existing = inputs[key];
|
|
459
|
+
if (existing !== undefined &&
|
|
460
|
+
(existing.identity !== observed.identity ||
|
|
461
|
+
existing.fileExists !== observed.fileExists)) {
|
|
462
|
+
throw new Error("A Metro project candidate changed while observed.");
|
|
463
|
+
}
|
|
464
|
+
inputs[key] = existing ?? observed;
|
|
465
|
+
staticInputs.add(key);
|
|
466
|
+
}
|
|
258
467
|
/**
|
|
259
468
|
* A value no other run can reproduce. Folding it means this run's cache entries
|
|
260
469
|
* are written but never reused by later runs, and this run reuses nothing from
|
|
261
|
-
* earlier ones — the sound fallback whenever the recorded
|
|
470
|
+
* earlier ones — the sound fallback whenever the recorded transform input set
|
|
262
471
|
* is unknown or unrepresentable.
|
|
263
472
|
*/
|
|
264
473
|
function nonce() {
|
|
265
474
|
return `nonce:${node_crypto.randomBytes(32).toString("hex")}`;
|
|
266
475
|
}
|
|
267
476
|
/**
|
|
268
|
-
* Prepare the snapshot for a new run. Called from `withTtsc`
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
477
|
+
* Prepare the snapshot for a new run. Called from `withTtsc` before any worker
|
|
478
|
+
* exists: creates the main snapshot (fresh epoch id) when missing or corrupt,
|
|
479
|
+
* compacts leftover worker files into it, and sweeps unparseable worker files
|
|
480
|
+
* plus crash-leftover temp files. Concurrent config processes are serialized; a
|
|
481
|
+
* contender takes a non-reusable run token instead of racing the mutable main
|
|
482
|
+
* rewrite. An unparseable worker file's recordings are unrecoverable, so its
|
|
483
|
+
* removal mints a fresh epoch id — every key that might have depended on the
|
|
484
|
+
* lost recordings is soundly orphaned, and later runs stabilize instead of
|
|
485
|
+
* degrading to a nonce forever. A failed rewrite leaves a recovery document
|
|
486
|
+
* outside the snapshot directory and returns a non-reusable run token. The
|
|
487
|
+
* token crosses bundle and process boundaries, so `getCacheKey` degrades to a
|
|
488
|
+
* nonce even when neither snapshot location can persist the failure and an old
|
|
489
|
+
* main later reappears.
|
|
279
490
|
*/
|
|
280
491
|
function prepareSnapshot(projectRoot) {
|
|
281
492
|
const base = resolveFingerprintBase(projectRoot);
|
|
282
|
-
|
|
493
|
+
const runId = node_crypto.randomBytes(16).toString("hex");
|
|
494
|
+
let reusable = true;
|
|
283
495
|
let pending = {
|
|
284
496
|
files: [],
|
|
497
|
+
tainted: false,
|
|
285
498
|
version: SNAPSHOT_VERSION,
|
|
286
499
|
volatile: false,
|
|
287
500
|
};
|
|
501
|
+
let releaseCompactionLock;
|
|
288
502
|
try {
|
|
289
503
|
// A nonexistent base can never be a working Metro setup (Metro verifies
|
|
290
504
|
// the project root exists), so preparing a snapshot there would only
|
|
291
505
|
// materialize directory trees at arbitrary paths.
|
|
292
506
|
if (!fs.existsSync(base)) {
|
|
293
|
-
return;
|
|
507
|
+
return runId;
|
|
294
508
|
}
|
|
295
509
|
const directory = snapshotDirectory(base);
|
|
296
510
|
fs.mkdirSync(directory, { recursive: true });
|
|
511
|
+
releaseCompactionLock = acquireSnapshotCompactionLock(directory);
|
|
512
|
+
if (releaseCompactionLock === undefined) {
|
|
513
|
+
// Another Metro config process is already rewriting the mutable main
|
|
514
|
+
// snapshot. It owns all pending worker documents, while this run takes a
|
|
515
|
+
// private nonce and therefore cannot reuse or publish under a stale key.
|
|
516
|
+
return `${NON_REUSABLE_RUN_PREFIX}${runId}`;
|
|
517
|
+
}
|
|
297
518
|
// Read the worker files strictly before the main file (see the module doc
|
|
298
519
|
// comment): a concurrent compactor deletes a worker file only after the
|
|
299
520
|
// merged main is renamed into place, so whatever this enumeration misses
|
|
@@ -305,9 +526,9 @@ function prepareSnapshot(projectRoot) {
|
|
|
305
526
|
throw new Error("Unable to enumerate Metro snapshot state.");
|
|
306
527
|
}
|
|
307
528
|
const main = readMainDocument(directory);
|
|
308
|
-
hadReadableMain = main !== undefined && typeof main.id === "string";
|
|
309
529
|
const files = new Set(main?.files ?? []);
|
|
310
530
|
const observations = [...recovery.entries, ...workers.entries];
|
|
531
|
+
const tainted = observations.some((entry) => entry.tainted);
|
|
311
532
|
const volatile = observations.length === 0
|
|
312
533
|
? (main?.volatile ?? false)
|
|
313
534
|
: // Worker files carry the previous run's fresh observations, so they
|
|
@@ -324,9 +545,10 @@ function prepareSnapshot(projectRoot) {
|
|
|
324
545
|
recovery.corruptPaths.length !== 0;
|
|
325
546
|
pending = {
|
|
326
547
|
files: [...files].sort(),
|
|
327
|
-
id: !recovering && workers.corruptPaths.length === 0
|
|
548
|
+
id: !recovering && workers.corruptPaths.length === 0 && !tainted
|
|
328
549
|
? (main?.id ?? node_crypto.randomBytes(16).toString("hex"))
|
|
329
550
|
: node_crypto.randomBytes(16).toString("hex"),
|
|
551
|
+
tainted: false,
|
|
330
552
|
version: SNAPSHOT_VERSION,
|
|
331
553
|
volatile,
|
|
332
554
|
};
|
|
@@ -337,6 +559,7 @@ function prepareSnapshot(projectRoot) {
|
|
|
337
559
|
...recovery.paths,
|
|
338
560
|
...recovery.corruptPaths,
|
|
339
561
|
...listTemporaryFiles(directory),
|
|
562
|
+
...listExpiredKeyBaselines(directory),
|
|
340
563
|
]) {
|
|
341
564
|
try {
|
|
342
565
|
fs.rmSync(file, { force: true });
|
|
@@ -353,16 +576,133 @@ function prepareSnapshot(projectRoot) {
|
|
|
353
576
|
unhealthySnapshots.delete(base);
|
|
354
577
|
}
|
|
355
578
|
}
|
|
356
|
-
catch
|
|
579
|
+
catch {
|
|
580
|
+
reusable = false;
|
|
357
581
|
try {
|
|
358
582
|
persistUnhealthySnapshot(base, pending);
|
|
359
583
|
}
|
|
360
|
-
catch
|
|
361
|
-
|
|
362
|
-
|
|
584
|
+
catch {
|
|
585
|
+
// The returned token carries the failure when neither on-disk location
|
|
586
|
+
// can. No consumer may turn that token into a reusable cache key.
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
if (releaseCompactionLock !== undefined) {
|
|
591
|
+
try {
|
|
592
|
+
releaseCompactionLock();
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
reusable = false;
|
|
596
|
+
try {
|
|
597
|
+
persistUnhealthySnapshot(base, pending);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// The returned non-reusable token remains the final safety boundary.
|
|
601
|
+
}
|
|
363
602
|
}
|
|
364
603
|
}
|
|
365
604
|
}
|
|
605
|
+
return reusable ? runId : `${NON_REUSABLE_RUN_PREFIX}${runId}`;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Acquire the process-shared lock for the mutable main snapshot.
|
|
609
|
+
*
|
|
610
|
+
* A complete owner directory is published atomically, and no process waits
|
|
611
|
+
* while holding a reusable run identity. A contending process therefore
|
|
612
|
+
* degrades immediately to a nonce. The owner retires the directory atomically
|
|
613
|
+
* after every success or failure path has persisted its verdict.
|
|
614
|
+
*/
|
|
615
|
+
function acquireSnapshotCompactionLock(directory) {
|
|
616
|
+
const lock = path.join(directory, SNAPSHOT_COMPACTION_LOCK);
|
|
617
|
+
const token = node_crypto.randomBytes(16).toString("hex");
|
|
618
|
+
const candidate = path.join(directory, `.snapshot-compaction-${process.pid.toString(36)}-${token}`);
|
|
619
|
+
let candidateCreated = false;
|
|
620
|
+
try {
|
|
621
|
+
fs.mkdirSync(candidate);
|
|
622
|
+
candidateCreated = true;
|
|
623
|
+
fs.writeFileSync(path.join(candidate, SNAPSHOT_COMPACTION_OWNER), JSON.stringify({ pid: process.pid, token }), "utf8");
|
|
624
|
+
if (fs.existsSync(lock)) {
|
|
625
|
+
reapDeadSnapshotCompactionLock(lock);
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
fs.renameSync(candidate, lock);
|
|
629
|
+
candidateCreated = false;
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
if (fs.existsSync(lock)) {
|
|
633
|
+
reapDeadSnapshotCompactionLock(lock);
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
if (candidateCreated) {
|
|
640
|
+
fs.rmSync(candidate, { force: true, recursive: true });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return () => {
|
|
644
|
+
const owner = readSnapshotCompactionOwner(lock);
|
|
645
|
+
if (owner?.pid !== process.pid || owner.token !== token) {
|
|
646
|
+
throw new Error("Metro snapshot compaction lock ownership changed.");
|
|
647
|
+
}
|
|
648
|
+
const retired = path.join(directory, `.snapshot-compaction-released-${token}`);
|
|
649
|
+
fs.renameSync(lock, retired);
|
|
650
|
+
try {
|
|
651
|
+
fs.rmSync(retired, { force: true, recursive: true });
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
// A retired owner cannot block or be confused with the fixed lock name.
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
/** Read a complete lock owner; malformed state remains a conservative lock. */
|
|
659
|
+
function readSnapshotCompactionOwner(lock) {
|
|
660
|
+
try {
|
|
661
|
+
const value = JSON.parse(fs.readFileSync(path.join(lock, SNAPSHOT_COMPACTION_OWNER), "utf8"));
|
|
662
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
const owner = value;
|
|
666
|
+
return Number.isSafeInteger(owner.pid) &&
|
|
667
|
+
owner.pid > 0 &&
|
|
668
|
+
typeof owner.token === "string" &&
|
|
669
|
+
/^[a-f0-9]{32}$/.test(owner.token)
|
|
670
|
+
? { pid: owner.pid, token: owner.token }
|
|
671
|
+
: undefined;
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Remove a lock whose recorded process is proven dead.
|
|
679
|
+
*
|
|
680
|
+
* The dead owner's random token also names its quarantine. At most one
|
|
681
|
+
* contender can move the fixed lock there. The quarantine remains as a tiny
|
|
682
|
+
* election record, so a delayed contender can never move a newer owner's lock
|
|
683
|
+
* after the first contender has recovered the fixed name.
|
|
684
|
+
*/
|
|
685
|
+
function reapDeadSnapshotCompactionLock(lock) {
|
|
686
|
+
const owner = readSnapshotCompactionOwner(lock);
|
|
687
|
+
if (owner === undefined || processIsAlive(owner.pid))
|
|
688
|
+
return;
|
|
689
|
+
const quarantine = path.join(path.dirname(lock), `.snapshot-compaction-stale-${owner.token}`);
|
|
690
|
+
try {
|
|
691
|
+
fs.renameSync(lock, quarantine);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
// Another contender recovered this owner, or the lock changed after read.
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/** Treat every process-query failure except a definite missing PID as live. */
|
|
698
|
+
function processIsAlive(pid) {
|
|
699
|
+
try {
|
|
700
|
+
process.kill(pid, 0);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
return error.code !== "ESRCH";
|
|
705
|
+
}
|
|
366
706
|
}
|
|
367
707
|
/**
|
|
368
708
|
* Crash-leftover temp files from the atomic writer, swept at compaction. Only
|
|
@@ -390,6 +730,27 @@ function listTemporaryFiles(directory) {
|
|
|
390
730
|
return [];
|
|
391
731
|
}
|
|
392
732
|
}
|
|
733
|
+
/** Old run baselines that cannot belong to an ordinary live Metro session. */
|
|
734
|
+
function listExpiredKeyBaselines(directory) {
|
|
735
|
+
const horizon = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
736
|
+
try {
|
|
737
|
+
return fs
|
|
738
|
+
.readdirSync(directory)
|
|
739
|
+
.filter((name) => name.startsWith(KEY_BASELINE_PREFIX) && name.endsWith(".json"))
|
|
740
|
+
.map((name) => path.join(directory, name))
|
|
741
|
+
.filter((file) => {
|
|
742
|
+
try {
|
|
743
|
+
return fs.statSync(file).mtimeMs < horizon;
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
return [];
|
|
752
|
+
}
|
|
753
|
+
}
|
|
393
754
|
/**
|
|
394
755
|
* Read the unioned snapshot state, or `undefined` when the main snapshot is
|
|
395
756
|
* missing or any snapshot file is corrupt (a torn or foreign write means the
|
|
@@ -417,27 +778,62 @@ function readSnapshotState(base) {
|
|
|
417
778
|
}
|
|
418
779
|
const files = new Set(main.files);
|
|
419
780
|
let volatile = main.volatile;
|
|
781
|
+
let tainted = main.tainted;
|
|
420
782
|
for (const entry of workers.entries) {
|
|
421
783
|
for (const file of entry.files) {
|
|
422
784
|
files.add(file);
|
|
423
785
|
}
|
|
424
786
|
volatile ||= entry.volatile;
|
|
787
|
+
tainted ||= entry.tainted;
|
|
425
788
|
}
|
|
426
|
-
return { files: [...files].sort(), id: main.id, volatile };
|
|
789
|
+
return { files: [...files].sort(), id: main.id, tainted, volatile };
|
|
427
790
|
}
|
|
428
791
|
/**
|
|
429
|
-
* Recorder held by each Metro worker. It persists
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
* files on the next run.
|
|
792
|
+
* Recorder held by each Metro worker. It persists every derived watch input and
|
|
793
|
+
* any volatile declaration, compares compiler-generation evidence with the
|
|
794
|
+
* matching main-process run baseline, and marks any temporal mismatch tainted.
|
|
795
|
+
* A clean transform also writes a document so it can clear a volatile
|
|
796
|
+
* declaration from an earlier run. One cumulative document is flushed per
|
|
797
|
+
* delivered module; the unique name makes worker writes race-free, and
|
|
798
|
+
* `withTtsc` compacts the files on the next run.
|
|
437
799
|
*/
|
|
438
|
-
function createSnapshotRecorder() {
|
|
800
|
+
function createSnapshotRecorder(runId) {
|
|
439
801
|
const suffix = `${process.pid.toString(36)}-${node_crypto.randomBytes(6).toString("hex")}`;
|
|
440
802
|
const states = new Map();
|
|
803
|
+
const baselines = new Map();
|
|
804
|
+
const baselineStaticInputs = new Map();
|
|
805
|
+
function keyBaselineCoverage(input, base) {
|
|
806
|
+
// A direct recorder without the private run handshake cannot prove that
|
|
807
|
+
// any input belongs to the main process's static key. Retain every path
|
|
808
|
+
// without claiming a temporal mismatch. Production always receives a run
|
|
809
|
+
// id from `withTtsc`; an absent or unreadable matching baseline then fails
|
|
810
|
+
// closed.
|
|
811
|
+
if (runId === undefined) {
|
|
812
|
+
return { matches: true, static: false };
|
|
813
|
+
}
|
|
814
|
+
let baseline = baselines.get(base);
|
|
815
|
+
if (baseline === undefined) {
|
|
816
|
+
baseline = readKeyBaseline(base, runId) ?? null;
|
|
817
|
+
baselines.set(base, baseline);
|
|
818
|
+
baselineStaticInputs.set(base, new Set(baseline?.staticInputs ?? []));
|
|
819
|
+
}
|
|
820
|
+
const key = snapshotPathKey(input.file);
|
|
821
|
+
const expected = baseline?.inputs[key];
|
|
822
|
+
try {
|
|
823
|
+
const matches = expected !== undefined &&
|
|
824
|
+
input.evidence !== undefined &&
|
|
825
|
+
api.watchInputEvidenceMatchesBaseline(input.evidence, expected);
|
|
826
|
+
return {
|
|
827
|
+
matches,
|
|
828
|
+
static: matches &&
|
|
829
|
+
baseline !== null &&
|
|
830
|
+
baselineStaticInputs.get(base)?.has(key) === true,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
return { matches: false, static: false };
|
|
835
|
+
}
|
|
836
|
+
}
|
|
441
837
|
function stateFor(project) {
|
|
442
838
|
const base = project.base;
|
|
443
839
|
let state = states.get(base);
|
|
@@ -446,7 +842,7 @@ function createSnapshotRecorder() {
|
|
|
446
842
|
dirty: false,
|
|
447
843
|
files: new Set(),
|
|
448
844
|
observed: false,
|
|
449
|
-
|
|
845
|
+
tainted: false,
|
|
450
846
|
volatile: false,
|
|
451
847
|
};
|
|
452
848
|
states.set(base, state);
|
|
@@ -459,6 +855,7 @@ function createSnapshotRecorder() {
|
|
|
459
855
|
}
|
|
460
856
|
const document = {
|
|
461
857
|
files: [...state.files].sort(),
|
|
858
|
+
tainted: state.tainted,
|
|
462
859
|
version: SNAPSHOT_VERSION,
|
|
463
860
|
volatile: state.volatile,
|
|
464
861
|
};
|
|
@@ -475,35 +872,47 @@ function createSnapshotRecorder() {
|
|
|
475
872
|
persistUnhealthySnapshot(base, document);
|
|
476
873
|
}
|
|
477
874
|
catch (recoveryError) {
|
|
478
|
-
|
|
875
|
+
// A reusable run id proves that the main process authorized a cache
|
|
876
|
+
// key. The explicit nonce token is different: it guarantees that this
|
|
877
|
+
// run's output cannot be reused, so losing its observation is safe.
|
|
878
|
+
if (isReusableSnapshotRunId(runId) ||
|
|
879
|
+
(runId === undefined && hasReadableMainSnapshot(base))) {
|
|
479
880
|
throw new AggregateError([snapshotError, recoveryError], "Unable to persist a Metro snapshot observation or its recovery record.");
|
|
480
881
|
}
|
|
481
882
|
}
|
|
482
883
|
}
|
|
483
884
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
state.dirty = true;
|
|
499
|
-
flush(base, state);
|
|
500
|
-
}
|
|
501
|
-
return;
|
|
885
|
+
function recordMany(props) {
|
|
886
|
+
const base = props.project.base;
|
|
887
|
+
const state = stateFor(props.project);
|
|
888
|
+
const firstObservation = !state.observed;
|
|
889
|
+
state.observed = true;
|
|
890
|
+
for (const input of props.inputs) {
|
|
891
|
+
const file = path.resolve(input.file);
|
|
892
|
+
const coverage = keyBaselineCoverage({ ...input, file }, base);
|
|
893
|
+
if (!coverage.matches) {
|
|
894
|
+
state.tainted = true;
|
|
895
|
+
}
|
|
896
|
+
if (!coverage.static && !state.files.has(file)) {
|
|
897
|
+
state.files.add(file);
|
|
898
|
+
state.dirty = true;
|
|
502
899
|
}
|
|
503
|
-
|
|
900
|
+
}
|
|
901
|
+
// A clean empty delivery must still clear a volatile verdict from the
|
|
902
|
+
// preceding run. Persist once for the whole module, not once per input.
|
|
903
|
+
if (firstObservation || state.tainted) {
|
|
504
904
|
state.dirty = true;
|
|
505
|
-
|
|
905
|
+
}
|
|
906
|
+
flush(base, state);
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
record(props) {
|
|
910
|
+
recordMany({
|
|
911
|
+
inputs: [{ file: props.input }],
|
|
912
|
+
project: props.project,
|
|
913
|
+
});
|
|
506
914
|
},
|
|
915
|
+
recordMany,
|
|
507
916
|
recordVolatile(props) {
|
|
508
917
|
const base = props.project.base;
|
|
509
918
|
const state = stateFor(props.project);
|
|
@@ -517,6 +926,65 @@ function createSnapshotRecorder() {
|
|
|
517
926
|
},
|
|
518
927
|
};
|
|
519
928
|
}
|
|
929
|
+
/** Filesystem-keyed lexical spelling used by main and worker processes. */
|
|
930
|
+
function snapshotPathKey(file) {
|
|
931
|
+
const resolved = path.resolve(file);
|
|
932
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
933
|
+
}
|
|
934
|
+
/** Persist the exact filesystem state one run's static key observed. */
|
|
935
|
+
function writeKeyBaseline(base, runId, inputs, staticInputs) {
|
|
936
|
+
if (!isReusableSnapshotRunId(runId)) {
|
|
937
|
+
throw new Error("Invalid Metro snapshot run identity.");
|
|
938
|
+
}
|
|
939
|
+
const file = path.join(snapshotDirectory(base), `${KEY_BASELINE_PREFIX}${runId}.json`);
|
|
940
|
+
if (fs.existsSync(file)) {
|
|
941
|
+
const existing = readKeyBaseline(base, runId);
|
|
942
|
+
if (existing === undefined ||
|
|
943
|
+
stableStringify(existing.inputs) !== stableStringify(inputs) ||
|
|
944
|
+
stableStringify(existing.staticInputs) !== stableStringify(staticInputs)) {
|
|
945
|
+
throw new Error("A Metro run attempted to replace its key baseline.");
|
|
946
|
+
}
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
writeSnapshotDocument(file, {
|
|
950
|
+
inputs,
|
|
951
|
+
runId,
|
|
952
|
+
staticInputs,
|
|
953
|
+
version: SNAPSHOT_VERSION,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
/** Read only the immutable baseline belonging to this worker's run. */
|
|
957
|
+
function readKeyBaseline(base, runId) {
|
|
958
|
+
if (!isReusableSnapshotRunId(runId)) {
|
|
959
|
+
return undefined;
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
const value = JSON.parse(fs.readFileSync(path.join(snapshotDirectory(base), `${KEY_BASELINE_PREFIX}${runId}.json`), "utf8"));
|
|
963
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
964
|
+
return undefined;
|
|
965
|
+
}
|
|
966
|
+
const document = value;
|
|
967
|
+
if (document.version !== SNAPSHOT_VERSION ||
|
|
968
|
+
document.runId !== runId ||
|
|
969
|
+
typeof document.inputs !== "object" ||
|
|
970
|
+
document.inputs === null ||
|
|
971
|
+
Array.isArray(document.inputs) ||
|
|
972
|
+
Object.values(document.inputs).some((entry) => !api.isWatchInputKeyBaseline(entry)) ||
|
|
973
|
+
!Array.isArray(document.staticInputs) ||
|
|
974
|
+
document.staticInputs.some((entry) => typeof entry !== "string" ||
|
|
975
|
+
!Object.prototype.hasOwnProperty.call(document.inputs, entry))) {
|
|
976
|
+
return undefined;
|
|
977
|
+
}
|
|
978
|
+
return value;
|
|
979
|
+
}
|
|
980
|
+
catch {
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/** Whether a run token is allowed to authorize a reusable static key. */
|
|
985
|
+
function isReusableSnapshotRunId(runId) {
|
|
986
|
+
return runId !== undefined && /^[a-f0-9]{32}$/.test(runId);
|
|
987
|
+
}
|
|
520
988
|
function snapshotDirectory(base) {
|
|
521
989
|
return path.join(base, ...SNAPSHOT_DIRECTORY);
|
|
522
990
|
}
|
|
@@ -656,14 +1124,32 @@ function parseSnapshotDocument(text) {
|
|
|
656
1124
|
return undefined;
|
|
657
1125
|
}
|
|
658
1126
|
const document = value;
|
|
659
|
-
|
|
1127
|
+
const keys = Object.keys(document).sort();
|
|
1128
|
+
const expectedKeys = ["files", "tainted", "version", "volatile"];
|
|
1129
|
+
if (Object.prototype.hasOwnProperty.call(document, "id")) {
|
|
1130
|
+
expectedKeys.push("id");
|
|
1131
|
+
expectedKeys.sort();
|
|
1132
|
+
}
|
|
1133
|
+
if (stableStringify(keys) !== stableStringify(expectedKeys) ||
|
|
1134
|
+
document.version !== SNAPSHOT_VERSION ||
|
|
1135
|
+
!Array.isArray(document.files) ||
|
|
1136
|
+
document.files.some((entry) => typeof entry !== "string" ||
|
|
1137
|
+
!(path.posix.isAbsolute(entry) || path.win32.isAbsolute(entry))) ||
|
|
1138
|
+
new Set(document.files).size !== document.files.length ||
|
|
1139
|
+
stableStringify(document.files) !==
|
|
1140
|
+
stableStringify([...document.files].sort()) ||
|
|
1141
|
+
typeof document.tainted !== "boolean" ||
|
|
1142
|
+
typeof document.volatile !== "boolean" ||
|
|
1143
|
+
(document.id !== undefined &&
|
|
1144
|
+
(typeof document.id !== "string" || !/^[a-f0-9]{32}$/.test(document.id)))) {
|
|
660
1145
|
return undefined;
|
|
661
1146
|
}
|
|
662
1147
|
return {
|
|
663
|
-
files: document.files
|
|
1148
|
+
files: document.files,
|
|
664
1149
|
...(typeof document.id === "string" ? { id: document.id } : {}),
|
|
1150
|
+
tainted: document.tainted,
|
|
665
1151
|
version: SNAPSHOT_VERSION,
|
|
666
|
-
volatile: document.volatile
|
|
1152
|
+
volatile: document.volatile,
|
|
667
1153
|
};
|
|
668
1154
|
}
|
|
669
1155
|
/** Write a snapshot document atomically (unique temp file, then rename). */
|