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