ttsc 0.18.4 → 0.19.0
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 +5 -6
- package/cmd/graphbench/main.go +1 -1
- package/cmd/platform/main.go +1 -48
- package/cmd/ttsc/build_branches_test.go +5 -1
- package/cmd/ttsc/main.go +0 -50
- package/cmd/ttsc/main_alias_branches_test.go +6 -12
- package/cmd/ttscgraph/dump.go +13 -3
- package/cmd/ttscserver/main.go +5 -3
- package/driver/lsp.go +13 -1
- package/driver/plugins.go +35 -0
- package/driver/program.go +7 -8
- package/driver/rewrite.go +381 -21
- package/go.mod +1 -0
- package/internal/graph/build.go +297 -3
- package/internal/graph/build_records_a_node_per_top_level_declaration_test.go +16 -2
- package/internal/graph/diagnostics.go +5 -4
- package/internal/graph/dump.go +60 -4
- package/internal/graph/dump_is_a_function_of_the_source_test.go +71 -0
- package/internal/graph/dump_serializes_the_full_graph_test.go +16 -4
- package/internal/graph/edges.go +122 -11
- package/internal/graph/exports.go +74 -17
- package/internal/graph/graph.go +33 -1
- package/internal/graph/node_identity_is_position_invariant_test.go +4 -2
- package/internal/graph/resolve.go +24 -0
- package/internal/graphsymbols/provider.go +50 -14
- package/internal/lspserver/lsp_native_plugin_source.go +77 -23
- package/internal/lspserver/lsp_plugin.go +19 -2
- package/internal/lspserver/lsp_proxy.go +244 -52
- package/internal/lspserver/lsp_server.go +35 -27
- package/internal/lspserver/lsp_symbols.go +25 -16
- package/lib/TtscCompiler.d.ts +10 -0
- package/lib/TtscCompiler.js +24 -6
- package/lib/TtscCompiler.js.map +1 -1
- package/lib/TtscService.js +9 -7
- package/lib/TtscService.js.map +1 -1
- package/lib/compiler/internal/compileProjectInMemory.js +1 -0
- package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
- package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +3 -0
- package/lib/compiler/internal/project/createNativeProjectContextArgs.js +14 -0
- package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -0
- package/lib/compiler/internal/project/readProjectConfig.js +53 -4
- package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
- package/lib/compiler/internal/project/resolveProjectConfig.d.ts +9 -2
- package/lib/compiler/internal/project/resolveProjectConfig.js +45 -16
- package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
- package/lib/compiler/internal/residentTransformProcess.d.ts +16 -5
- package/lib/compiler/internal/residentTransformProcess.js +68 -20
- package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
- package/lib/compiler/internal/runBuild.d.ts +11 -0
- package/lib/compiler/internal/runBuild.js +44 -8
- package/lib/compiler/internal/runBuild.js.map +1 -1
- package/lib/compiler/internal/runSingleFileEmit.js +7 -4
- package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
- package/lib/compiler/internal/sharedHostHelpers.d.ts +16 -0
- package/lib/compiler/internal/sharedHostHelpers.js +23 -0
- package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
- package/lib/compiler/internal/startResidentTransform.js +18 -1
- package/lib/compiler/internal/startResidentTransform.js.map +1 -1
- package/lib/compiler/internal/transformProjectInMemory.js +34 -8
- package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
- package/lib/flags/parser.d.ts +17 -0
- package/lib/flags/parser.js +11 -1
- package/lib/flags/parser.js.map +1 -1
- package/lib/flags/schema.js +7 -0
- package/lib/flags/schema.js.map +1 -1
- package/lib/launcher/internal/prepareExecution.js +21 -2
- package/lib/launcher/internal/prepareExecution.js.map +1 -1
- package/lib/launcher/internal/runTtsc.js +13 -53
- package/lib/launcher/internal/runTtsc.js.map +1 -1
- package/lib/launcher/internal/runTtscserver.js +11 -0
- package/lib/launcher/internal/runTtscserver.js.map +1 -1
- package/lib/launcher/internal/runTtsx.js +27 -11
- package/lib/launcher/internal/runTtsx.js.map +1 -1
- package/lib/launcher/internal/runtimeHooks.d.ts +81 -0
- package/lib/launcher/internal/runtimeHooks.js +632 -43
- package/lib/launcher/internal/runtimeHooks.js.map +1 -1
- package/lib/launcher/internal/servedSourceMap.d.ts +25 -0
- package/lib/launcher/internal/servedSourceMap.js +164 -0
- package/lib/launcher/internal/servedSourceMap.js.map +1 -0
- package/lib/plugin/internal/buildSourcePlugin.d.ts +146 -2
- package/lib/plugin/internal/buildSourcePlugin.js +651 -113
- package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
- package/lib/plugin/internal/loadProjectPlugins.d.ts +7 -0
- package/lib/plugin/internal/loadProjectPlugins.js +33 -10
- package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
- package/lib/structures/ITtscCompilerContext.d.ts +16 -2
- package/lib/structures/ITtscPluginCapabilities.d.ts +8 -0
- package/lib/structures/ITtscPluginFactoryContext.d.ts +14 -0
- package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +3 -0
- package/lib/structures/internal/ITtscProjectIdentity.d.ts +17 -0
- package/lib/structures/internal/ITtscProjectIdentity.js +3 -0
- package/lib/structures/internal/ITtscProjectIdentity.js.map +1 -0
- package/lib/structures/internal/TtscCommonOptions.d.ts +8 -0
- package/package.json +11 -8
- package/shim/ast/lint.go +10 -0
- package/shim/ast/shim.go +44 -0
- package/shim/checker/enums_gen.go +19 -0
- package/shim/checker/shim.go +130 -0
- package/shim/diagnosticwriter/lint.go +30 -3
- package/shim/scanner/regexp.go +27 -0
- package/src/TtscCompiler.ts +25 -6
- package/src/TtscService.ts +15 -7
- package/src/compiler/internal/compileProjectInMemory.ts +1 -0
- package/src/compiler/internal/project/createNativeProjectContextArgs.ts +15 -0
- package/src/compiler/internal/project/readProjectConfig.ts +61 -5
- package/src/compiler/internal/project/resolveProjectConfig.ts +51 -21
- package/src/compiler/internal/residentTransformProcess.ts +95 -21
- package/src/compiler/internal/runBuild.ts +76 -9
- package/src/compiler/internal/runSingleFileEmit.ts +7 -4
- package/src/compiler/internal/sharedHostHelpers.ts +24 -0
- package/src/compiler/internal/startResidentTransform.ts +21 -1
- package/src/compiler/internal/transformProjectInMemory.ts +49 -6
- package/src/flags/parser.ts +28 -1
- package/src/flags/schema.ts +8 -0
- package/src/launcher/internal/prepareExecution.ts +22 -2
- package/src/launcher/internal/runTtsc.ts +13 -61
- package/src/launcher/internal/runTtscserver.ts +14 -0
- package/src/launcher/internal/runTtsx.ts +28 -13
- package/src/launcher/internal/runtimeHooks.ts +771 -53
- package/src/launcher/internal/servedSourceMap.ts +183 -0
- package/src/plugin/internal/buildSourcePlugin.ts +859 -122
- package/src/plugin/internal/loadProjectPlugins.ts +36 -6
- package/src/structures/ITtscCompilerContext.ts +17 -2
- package/src/structures/ITtscPluginCapabilities.ts +9 -0
- package/src/structures/ITtscPluginFactoryContext.ts +15 -0
- package/src/structures/internal/ITtscParsedProjectConfig.ts +3 -0
- package/src/structures/internal/ITtscProjectIdentity.ts +17 -0
- package/src/structures/internal/TtscCommonOptions.ts +8 -0
- package/tools/shim_audit/baseline.json +16 -7
- package/tools/shim_audit/main.go +777 -71
- package/tools/shim_audit/main_test.go +28 -1
- package/tools/shim_audit/producer_exemptions_require_rationale_test.go +42 -0
- package/tools/shim_audit/producer_surface_includes_exposed_methods_test.go +154 -0
- package/tools/shim_audit/producer_surface_rejects_unproducible_reference_test.go +142 -0
- package/utility/host.go +46 -20
- package/utility/serve.go +1 -1
- package/shim/checker/signature_api_completeness.go +0 -58
|
@@ -10,6 +10,7 @@ import { resolveEmittedJavaScript } from "../../compiler/internal/resolveEmitted
|
|
|
10
10
|
import { resolveTsgo } from "../../compiler/internal/resolveTsgo";
|
|
11
11
|
import { runBuild } from "../../compiler/internal/runBuild";
|
|
12
12
|
import { outputText, spawnNative } from "../../compiler/internal/spawnNative";
|
|
13
|
+
import { inlineServedSourceMap } from "./servedSourceMap";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Synchronous Node module hooks installed (via `module.registerHooks`) in the
|
|
@@ -131,6 +132,83 @@ function manifest(): RuntimeManifest | null {
|
|
|
131
132
|
return manifestCache;
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Lowest Node.js the ttsx source runtime supports. The synchronous
|
|
137
|
+
* `module.registerHooks` (Node 22.15.0) is the highest floor among the runtime
|
|
138
|
+
* APIs these hooks depend on — `stripTypeScriptTypes` (22.13.0) and the child's
|
|
139
|
+
* `--disable-warning` flag (20.11.0) are both lower — so it sets the effective
|
|
140
|
+
* minimum. Kept in sync with `packages/ttsc/package.json#engines.node` and the
|
|
141
|
+
* documented requirement in `website/src/content/docs/development/index.mdx`.
|
|
142
|
+
*/
|
|
143
|
+
export const TTSX_MINIMUM_NODE_VERSION = "22.15.0";
|
|
144
|
+
|
|
145
|
+
const TTSX_MINIMUM_NODE_PARTS: readonly [number, number, number] = [22, 15, 0];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Report why the running (or a candidate) Node.js version cannot execute the
|
|
149
|
+
* ttsx source runtime, or `null` when it can. Returning an actionable message —
|
|
150
|
+
* rather than letting the child die with an internal `TypeError` on the missing
|
|
151
|
+
* `registerHooks`, or Node 18 rejecting `--disable-warning` with exit 9 — is
|
|
152
|
+
* what turns an opaque internal failure into a clear version diagnostic.
|
|
153
|
+
*
|
|
154
|
+
* Exported for direct exercise by the ttsx e2e suite: the built launcher can
|
|
155
|
+
* only be spawned under the Node version running the tests, so the boundary
|
|
156
|
+
* around the floor cannot otherwise be pinned on CI.
|
|
157
|
+
*/
|
|
158
|
+
export function checkNodeRuntimeSupport(version: string): string | null {
|
|
159
|
+
const parts = parseNodeVersion(version);
|
|
160
|
+
if (parts === null) {
|
|
161
|
+
// An unrecognizable version string is not proof of an unsupported runtime;
|
|
162
|
+
// let execution proceed rather than block on a parsing quirk.
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (compareVersionParts(parts, TTSX_MINIMUM_NODE_PARTS) >= 0) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return (
|
|
169
|
+
`ttsx requires Node.js ${TTSX_MINIMUM_NODE_VERSION} or later, but this ` +
|
|
170
|
+
`process is Node.js ${version}. The source runtime installs synchronous ` +
|
|
171
|
+
`module hooks (module.registerHooks, Node 22.15.0) and strips types with ` +
|
|
172
|
+
`module.stripTypeScriptTypes (Node 22.13.0), neither of which exists on ` +
|
|
173
|
+
`earlier releases. Upgrade Node.js to 22.15.0+ (or the current LTS), or ` +
|
|
174
|
+
`compile the project with \`ttsc\` and run the emitted JavaScript directly.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseNodeVersion(version: string): [number, number, number] | null {
|
|
179
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
180
|
+
if (match === null) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function compareVersionParts(
|
|
187
|
+
a: readonly [number, number, number],
|
|
188
|
+
b: readonly [number, number, number],
|
|
189
|
+
): number {
|
|
190
|
+
for (let index = 0; index < 3; index += 1) {
|
|
191
|
+
if (a[index]! !== b[index]!) {
|
|
192
|
+
return a[index]! < b[index]! ? -1 : 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Throw an actionable version error when the current Node.js cannot run the
|
|
200
|
+
* ttsx source runtime. Guards the hook-installation boundary directly (a child
|
|
201
|
+
* or grandchild that inherits the runtime preload under an unsupported Node) so
|
|
202
|
+
* the failure is diagnosed here instead of surfacing as a bare `TypeError:
|
|
203
|
+
* registerHooks is not a function`.
|
|
204
|
+
*/
|
|
205
|
+
function assertNodeRuntimeSupport(): void {
|
|
206
|
+
const message = checkNodeRuntimeSupport(process.versions.node);
|
|
207
|
+
if (message !== null) {
|
|
208
|
+
throw new Error(message);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
134
212
|
let installed = false;
|
|
135
213
|
|
|
136
214
|
/**
|
|
@@ -150,7 +228,15 @@ export function installRuntimeHooks(): void {
|
|
|
150
228
|
if (installed) {
|
|
151
229
|
return;
|
|
152
230
|
}
|
|
231
|
+
assertNodeRuntimeSupport();
|
|
153
232
|
installed = true;
|
|
233
|
+
// Map error stacks through the source maps the serve path now inlines, so a
|
|
234
|
+
// thrown frame reports the true `.ts` line:col out of the box (no
|
|
235
|
+
// `--enable-source-maps` needed). Applied before the entry loads; user code
|
|
236
|
+
// that later toggles it wins, since this is a plain runtime switch.
|
|
237
|
+
if (typeof process.setSourceMapsEnabled === "function") {
|
|
238
|
+
process.setSourceMapsEnabled(true);
|
|
239
|
+
}
|
|
154
240
|
registerHooks({ load, resolve });
|
|
155
241
|
installCommonJsHook();
|
|
156
242
|
}
|
|
@@ -356,11 +442,14 @@ function resolveServedSource(
|
|
|
356
442
|
const real = realPath(filename);
|
|
357
443
|
const served = serveEntryEmit(real);
|
|
358
444
|
if (served !== null) {
|
|
359
|
-
return {
|
|
445
|
+
return withInlineSourceMap({
|
|
446
|
+
moduleOption: manifest()?.moduleOption,
|
|
447
|
+
...served,
|
|
448
|
+
});
|
|
360
449
|
}
|
|
361
450
|
const built = serveDependencyEmit(real);
|
|
362
451
|
if (built !== null) {
|
|
363
|
-
return built;
|
|
452
|
+
return withInlineSourceMap(built);
|
|
364
453
|
}
|
|
365
454
|
return {
|
|
366
455
|
moduleOption: undefined,
|
|
@@ -369,6 +458,22 @@ function resolveServedSource(
|
|
|
369
458
|
};
|
|
370
459
|
}
|
|
371
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Inline a served emit's external source map into its text and absolutize the
|
|
463
|
+
* map's `sources`, so the JavaScript executed under the `.ts` source URL stays
|
|
464
|
+
* self-describing after the per-run emit directory is deleted. Applied to both
|
|
465
|
+
* the entry lane (`serveEntryEmit`) and the dependency lane
|
|
466
|
+
* (`serveBuiltDependency`); the orphan type-strip lane carries no emitted map.
|
|
467
|
+
*/
|
|
468
|
+
function withInlineSourceMap(served: ServedSource): ServedSource {
|
|
469
|
+
const source = inlineServedSourceMap(
|
|
470
|
+
served.source,
|
|
471
|
+
served.emittedFile,
|
|
472
|
+
served.sourceFile,
|
|
473
|
+
);
|
|
474
|
+
return source === served.source ? served : { ...served, source };
|
|
475
|
+
}
|
|
476
|
+
|
|
372
477
|
/**
|
|
373
478
|
* Transform a TypeScript source file that no tsconfig owns (a published or
|
|
374
479
|
* vendored package that ships raw `.ts`/`.cts`/`.mts` straight under
|
|
@@ -716,16 +821,163 @@ function collectCommonJsExportNames(
|
|
|
716
821
|
}
|
|
717
822
|
|
|
718
823
|
function collectStaticCommonJsExportNames(source: string): Set<string> {
|
|
824
|
+
// Scan executable syntax only. Text that merely resembles an assignment —
|
|
825
|
+
// `exports.x =` inside a comment, string, or template-literal text — must not
|
|
826
|
+
// become an ESM-visible export name, or a named import of it would link to
|
|
827
|
+
// `undefined` for a property the CommonJS module never defines. Masking the
|
|
828
|
+
// inert lexical spans before matching keeps genuine top-level assignments
|
|
829
|
+
// (and executable `${ ... }` template substitutions) while dropping the
|
|
830
|
+
// decoys.
|
|
831
|
+
const scannable = maskCommentsAndStrings(source);
|
|
719
832
|
const names = new Set<string>();
|
|
720
833
|
const pattern =
|
|
721
834
|
/(?:^|[^\w$])(?:exports|module\.exports)\.([A-Za-z_$][\w$]*)\s*=/g;
|
|
722
835
|
let match: RegExpExecArray | null;
|
|
723
|
-
while ((match = pattern.exec(
|
|
836
|
+
while ((match = pattern.exec(scannable)) !== null) {
|
|
724
837
|
names.add(match[1]!);
|
|
725
838
|
}
|
|
726
839
|
return names;
|
|
727
840
|
}
|
|
728
841
|
|
|
842
|
+
/**
|
|
843
|
+
* Blank out the interior of line comments, block comments, string literals, and
|
|
844
|
+
* template-literal text in emitted JavaScript, replacing each masked character
|
|
845
|
+
* with a space while preserving newlines, code, and template `${ ... }`
|
|
846
|
+
* substitutions verbatim. Positions and length are preserved so an offset in
|
|
847
|
+
* the masked text maps back to the same offset in the source.
|
|
848
|
+
*
|
|
849
|
+
* The input is tsgo's CommonJS emit (well-formed JavaScript), so a character
|
|
850
|
+
* scanner that tracks the standard comment/string/template states is sufficient
|
|
851
|
+
* to separate executable tokens from inert text. Regular-expression literals
|
|
852
|
+
* are intentionally not masked: distinguishing `/`-division from a regex
|
|
853
|
+
* literal needs full tokenization, and tsgo's CommonJS emit never wraps an
|
|
854
|
+
* `exports.<name> =` assignment inside a regex literal.
|
|
855
|
+
*/
|
|
856
|
+
function maskCommentsAndStrings(source: string): string {
|
|
857
|
+
const out = source.split("");
|
|
858
|
+
const n = out.length;
|
|
859
|
+
const blank = (index: number): void => {
|
|
860
|
+
const ch = out[index];
|
|
861
|
+
if (ch !== "\n" && ch !== "\r") {
|
|
862
|
+
out[index] = " ";
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
// A stack of lexical contexts. The base is code; each backtick pushes a
|
|
866
|
+
// template context, and each `${` inside a template pushes a nested code
|
|
867
|
+
// context whose `braceDepth` tracks `{}` nesting so an object literal inside
|
|
868
|
+
// the substitution does not end it early.
|
|
869
|
+
interface Context {
|
|
870
|
+
kind: "code" | "template";
|
|
871
|
+
braceDepth: number;
|
|
872
|
+
}
|
|
873
|
+
const stack: Context[] = [{ kind: "code", braceDepth: 0 }];
|
|
874
|
+
let i = 0;
|
|
875
|
+
while (i < n) {
|
|
876
|
+
const top = stack[stack.length - 1]!;
|
|
877
|
+
const ch = out[i]!;
|
|
878
|
+
if (top.kind === "template") {
|
|
879
|
+
if (ch === "\\") {
|
|
880
|
+
blank(i);
|
|
881
|
+
blank(i + 1);
|
|
882
|
+
i += 2;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (ch === "`") {
|
|
886
|
+
blank(i);
|
|
887
|
+
stack.pop();
|
|
888
|
+
i += 1;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (ch === "$" && out[i + 1] === "{") {
|
|
892
|
+
// Enter a code substitution: `${` and its contents stay executable.
|
|
893
|
+
stack.push({ kind: "code", braceDepth: 0 });
|
|
894
|
+
i += 2;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
blank(i);
|
|
898
|
+
i += 1;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
// Code context.
|
|
902
|
+
if (ch === "/" && out[i + 1] === "/") {
|
|
903
|
+
blank(i);
|
|
904
|
+
blank(i + 1);
|
|
905
|
+
i += 2;
|
|
906
|
+
while (i < n && out[i] !== "\n") {
|
|
907
|
+
blank(i);
|
|
908
|
+
i += 1;
|
|
909
|
+
}
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (ch === "/" && out[i + 1] === "*") {
|
|
913
|
+
blank(i);
|
|
914
|
+
blank(i + 1);
|
|
915
|
+
i += 2;
|
|
916
|
+
while (i < n && !(out[i] === "*" && out[i + 1] === "/")) {
|
|
917
|
+
blank(i);
|
|
918
|
+
i += 1;
|
|
919
|
+
}
|
|
920
|
+
if (i < n) {
|
|
921
|
+
blank(i);
|
|
922
|
+
blank(i + 1);
|
|
923
|
+
i += 2;
|
|
924
|
+
}
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (ch === '"' || ch === "'") {
|
|
928
|
+
blank(i);
|
|
929
|
+
i += 1;
|
|
930
|
+
while (i < n && out[i] !== ch) {
|
|
931
|
+
if (out[i] === "\\") {
|
|
932
|
+
blank(i);
|
|
933
|
+
blank(i + 1);
|
|
934
|
+
i += 2;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
// A bare newline ends an unterminated string; stop masking so the rest
|
|
938
|
+
// of the line is still scanned as code (defensive — tsgo never emits
|
|
939
|
+
// one).
|
|
940
|
+
if (out[i] === "\n") {
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
blank(i);
|
|
944
|
+
i += 1;
|
|
945
|
+
}
|
|
946
|
+
if (i < n && out[i] === ch) {
|
|
947
|
+
blank(i);
|
|
948
|
+
i += 1;
|
|
949
|
+
}
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (ch === "`") {
|
|
953
|
+
blank(i);
|
|
954
|
+
stack.push({ kind: "template", braceDepth: 0 });
|
|
955
|
+
i += 1;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (ch === "{") {
|
|
959
|
+
top.braceDepth += 1;
|
|
960
|
+
i += 1;
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (ch === "}") {
|
|
964
|
+
if (top.braceDepth === 0 && stack.length > 1) {
|
|
965
|
+
// Close the enclosing template `${ ... }` and resume template text.
|
|
966
|
+
stack.pop();
|
|
967
|
+
i += 1;
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if (top.braceDepth > 0) {
|
|
971
|
+
top.braceDepth -= 1;
|
|
972
|
+
}
|
|
973
|
+
i += 1;
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
i += 1;
|
|
977
|
+
}
|
|
978
|
+
return out.join("");
|
|
979
|
+
}
|
|
980
|
+
|
|
729
981
|
function collectSourceCommonJsExportNames(
|
|
730
982
|
sourceFile: string,
|
|
731
983
|
seen: Set<string>,
|
|
@@ -933,8 +1185,19 @@ function serveBuiltDependency(
|
|
|
933
1185
|
};
|
|
934
1186
|
}
|
|
935
1187
|
|
|
936
|
-
/**
|
|
1188
|
+
/**
|
|
1189
|
+
* On-disk completion marker for a built dependency, shared across processes.
|
|
1190
|
+
*
|
|
1191
|
+
* `generation` names the exact immutable emit directory this marker describes
|
|
1192
|
+
* (`<cacheDir>/gen-<generation>`). Binding metadata to one generation is what
|
|
1193
|
+
* makes publication atomic: a reader that parses this marker reads the emit of
|
|
1194
|
+
* the SAME generation, never old metadata combined with a different, still
|
|
1195
|
+
* partially-written directory. The marker is the last thing a build writes, and
|
|
1196
|
+
* it is written by an atomic temp-and-rename, so a reader observes either one
|
|
1197
|
+
* complete old generation or one complete new generation.
|
|
1198
|
+
*/
|
|
937
1199
|
interface DependencyCacheMeta {
|
|
1200
|
+
generation: string;
|
|
938
1201
|
rootDir: string;
|
|
939
1202
|
moduleOption?: string;
|
|
940
1203
|
}
|
|
@@ -956,25 +1219,28 @@ function ensureProjectBuilt(tsconfig: string): BuiltProject {
|
|
|
956
1219
|
if (cached !== undefined) {
|
|
957
1220
|
return cached;
|
|
958
1221
|
}
|
|
959
|
-
const {
|
|
1222
|
+
const { cacheDir, lockDir, metaPath, root } = dependencyCachePaths(tsconfig);
|
|
960
1223
|
|
|
961
|
-
const reuse = readDependencyCache(
|
|
1224
|
+
const reuse = readDependencyCache(cacheDir, metaPath);
|
|
962
1225
|
if (reuse !== null) {
|
|
963
1226
|
builtProjects.set(tsconfig, reuse);
|
|
964
1227
|
return reuse;
|
|
965
1228
|
}
|
|
966
1229
|
|
|
967
1230
|
fs.mkdirSync(root, { recursive: true });
|
|
968
|
-
const built = withBuildLock(
|
|
969
|
-
buildDependency(tsconfig,
|
|
1231
|
+
const built = withBuildLock(cacheDir, metaPath, lockDir, () =>
|
|
1232
|
+
buildDependency(tsconfig, cacheDir, metaPath),
|
|
970
1233
|
);
|
|
971
1234
|
builtProjects.set(tsconfig, built);
|
|
972
1235
|
return built;
|
|
973
1236
|
}
|
|
974
1237
|
|
|
975
1238
|
interface DependencyCachePaths {
|
|
976
|
-
|
|
1239
|
+
/** Container of this dependency's generation-stamped emit directories. */
|
|
1240
|
+
cacheDir: string;
|
|
1241
|
+
/** Fenced cross-process coordination directory (`<key>.lock`). */
|
|
977
1242
|
lockDir: string;
|
|
1243
|
+
/** Atomic completion pointer (`<key>.json`) naming the live generation. */
|
|
978
1244
|
metaPath: string;
|
|
979
1245
|
root: string;
|
|
980
1246
|
}
|
|
@@ -987,16 +1253,43 @@ function dependencyCachePaths(tsconfig: string): DependencyCachePaths {
|
|
|
987
1253
|
.slice(0, 16);
|
|
988
1254
|
const root = dependencyCacheRoot();
|
|
989
1255
|
return {
|
|
990
|
-
|
|
1256
|
+
cacheDir: path.join(root, key),
|
|
991
1257
|
lockDir: path.join(root, `${key}.lock`),
|
|
992
1258
|
metaPath: path.join(root, `${key}.json`),
|
|
993
1259
|
root,
|
|
994
1260
|
};
|
|
995
1261
|
}
|
|
996
1262
|
|
|
997
|
-
/**
|
|
998
|
-
function
|
|
999
|
-
|
|
1263
|
+
/** The immutable emit directory of one build generation under `cacheDir`. */
|
|
1264
|
+
function dependencyGenerationDir(cacheDir: string, generation: string): string {
|
|
1265
|
+
return path.join(cacheDir, `gen-${generation}`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** A fresh 128-bit build-generation identifier. */
|
|
1269
|
+
function newDependencyGeneration(): string {
|
|
1270
|
+
return crypto.randomBytes(16).toString("hex");
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** True for a well-formed 128-bit hex build generation. */
|
|
1274
|
+
function isDependencyGeneration(value: unknown): value is string {
|
|
1275
|
+
return typeof value === "string" && /^[0-9a-f]{32}$/.test(value);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Reuse a dependency another process (or an earlier import) already built.
|
|
1280
|
+
*
|
|
1281
|
+
* The completion marker names the exact emit generation, so this reads metadata
|
|
1282
|
+
* and emit as one unit: it returns a hit only when the marker parses to a valid
|
|
1283
|
+
* generation AND that generation's directory holds emitted JavaScript. A reader
|
|
1284
|
+
* that runs while a replacement build is populating a DIFFERENT generation
|
|
1285
|
+
* directory keeps returning the previous complete generation until the atomic
|
|
1286
|
+
* marker swap points at the new one — never a mix of old metadata and a partial
|
|
1287
|
+
* new emit.
|
|
1288
|
+
*
|
|
1289
|
+
* Exported for the ttsx dependency-cache regressions.
|
|
1290
|
+
*/
|
|
1291
|
+
export function readDependencyCache(
|
|
1292
|
+
cacheDir: string,
|
|
1000
1293
|
metaPath: string,
|
|
1001
1294
|
): BuiltProject | null {
|
|
1002
1295
|
let meta: DependencyCacheMeta;
|
|
@@ -1005,88 +1298,121 @@ function readDependencyCache(
|
|
|
1005
1298
|
} catch {
|
|
1006
1299
|
return null;
|
|
1007
1300
|
}
|
|
1301
|
+
if (
|
|
1302
|
+
!isDependencyGeneration(meta.generation) ||
|
|
1303
|
+
typeof meta.rootDir !== "string"
|
|
1304
|
+
) {
|
|
1305
|
+
return null;
|
|
1306
|
+
}
|
|
1307
|
+
const emitDir = dependencyGenerationDir(cacheDir, meta.generation);
|
|
1008
1308
|
if (!emittedAnything(emitDir)) {
|
|
1009
1309
|
return null;
|
|
1010
1310
|
}
|
|
1011
1311
|
return {
|
|
1012
1312
|
emitDir,
|
|
1013
1313
|
emittedFiles: undefined,
|
|
1014
|
-
moduleOption:
|
|
1314
|
+
moduleOption:
|
|
1315
|
+
typeof meta.moduleOption === "string" ? meta.moduleOption : undefined,
|
|
1015
1316
|
rootDir: meta.rootDir,
|
|
1016
1317
|
};
|
|
1017
1318
|
}
|
|
1018
1319
|
|
|
1019
1320
|
/**
|
|
1020
|
-
* Run `build` while holding
|
|
1321
|
+
* Run `build` while holding the fenced lock for this dependency, re-checking
|
|
1021
1322
|
* the cache once the lock is held (a concurrent builder may have just
|
|
1022
|
-
* finished). A
|
|
1023
|
-
*
|
|
1024
|
-
* never
|
|
1323
|
+
* finished). A loser polls for the winner's completion marker and, only when
|
|
1324
|
+
* the holding generation is provably abandoned (dead owner or the steal budget
|
|
1325
|
+
* elapsed), retires precisely that generation before retrying — never a
|
|
1326
|
+
* successor's.
|
|
1025
1327
|
*/
|
|
1026
1328
|
function withBuildLock(
|
|
1027
|
-
|
|
1329
|
+
cacheDir: string,
|
|
1028
1330
|
metaPath: string,
|
|
1029
|
-
|
|
1331
|
+
lockDir: string,
|
|
1030
1332
|
build: () => BuiltProject,
|
|
1031
1333
|
): BuiltProject {
|
|
1032
|
-
const stealAfterMs = 600_000;
|
|
1033
1334
|
for (;;) {
|
|
1335
|
+
const reuse = readDependencyCache(cacheDir, metaPath);
|
|
1336
|
+
if (reuse !== null) {
|
|
1337
|
+
return reuse;
|
|
1338
|
+
}
|
|
1339
|
+
let lease: DependencyBuildLockLease | null;
|
|
1034
1340
|
try {
|
|
1035
|
-
|
|
1341
|
+
lease = acquireDependencyBuildLock(lockDir);
|
|
1036
1342
|
} catch {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1343
|
+
// An unusable coordination directory must not silently skip the build.
|
|
1344
|
+
// Generation-stamped emit and the atomic marker swap still keep every
|
|
1345
|
+
// reader's view of publication consistent without the lock.
|
|
1346
|
+
return build();
|
|
1347
|
+
}
|
|
1348
|
+
if (lease === null) {
|
|
1349
|
+
const waited = waitForDependencyBuild(
|
|
1350
|
+
cacheDir,
|
|
1351
|
+
metaPath,
|
|
1352
|
+
lockDir,
|
|
1353
|
+
DEP_BUILD_LOCK_STEAL_MS,
|
|
1354
|
+
);
|
|
1355
|
+
if (waited.outcome === "built") {
|
|
1356
|
+
return waited.built;
|
|
1040
1357
|
}
|
|
1041
|
-
|
|
1358
|
+
if (waited.outcome === "abandoned") {
|
|
1359
|
+
// Retire only the generation this observation named. Losing the rename
|
|
1360
|
+
// race means the holder's own release (or another waiter) already made
|
|
1361
|
+
// progress, so a stale result never removes a live successor.
|
|
1362
|
+
reclaimDependencyBuildLock(lockDir, waited.fence);
|
|
1363
|
+
}
|
|
1364
|
+
// "released" needs no repair: the holder freed the lock normally, so
|
|
1365
|
+
// retry the ordinary acquisition.
|
|
1042
1366
|
continue;
|
|
1043
1367
|
}
|
|
1044
1368
|
try {
|
|
1045
|
-
const
|
|
1046
|
-
return
|
|
1369
|
+
const reuseUnderLock = readDependencyCache(cacheDir, metaPath);
|
|
1370
|
+
return reuseUnderLock ?? build();
|
|
1047
1371
|
} finally {
|
|
1048
|
-
|
|
1372
|
+
releaseDependencyBuildLock(lockDir, lease);
|
|
1049
1373
|
}
|
|
1050
1374
|
}
|
|
1051
1375
|
}
|
|
1052
1376
|
|
|
1053
|
-
/** Poll for a concurrent builder's completion, up to `timeoutMs`. */
|
|
1054
|
-
function waitForDependencyCache(
|
|
1055
|
-
emitDir: string,
|
|
1056
|
-
metaPath: string,
|
|
1057
|
-
timeoutMs: number,
|
|
1058
|
-
): BuiltProject | null {
|
|
1059
|
-
const startedAt = Date.now();
|
|
1060
|
-
for (;;) {
|
|
1061
|
-
const reuse = readDependencyCache(emitDir, metaPath);
|
|
1062
|
-
if (reuse !== null) {
|
|
1063
|
-
return reuse;
|
|
1064
|
-
}
|
|
1065
|
-
if (Date.now() - startedAt > timeoutMs) {
|
|
1066
|
-
return null;
|
|
1067
|
-
}
|
|
1068
|
-
sleepSync(50);
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
1377
|
/** Block the current (synchronous) thread for `ms` without busy-spinning. */
|
|
1073
1378
|
function sleepSync(ms: number): void {
|
|
1074
1379
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1075
1380
|
}
|
|
1076
1381
|
|
|
1077
|
-
/**
|
|
1382
|
+
/**
|
|
1383
|
+
* Compile a dependency project into a fresh generation directory and publish
|
|
1384
|
+
* its completion marker atomically.
|
|
1385
|
+
*
|
|
1386
|
+
* The emit lands in `<cacheDir>/gen-<generation>`, a directory no other
|
|
1387
|
+
* generation or process shares, so it is never mutated in place while a reader
|
|
1388
|
+
* looks at it. Only after the emit is proven non-empty is the marker written by
|
|
1389
|
+
* temp-and-rename, binding metadata to that exact generation. A build that
|
|
1390
|
+
* produced no output drops its partial directory so a failed generation can
|
|
1391
|
+
* never be reused.
|
|
1392
|
+
*/
|
|
1078
1393
|
function buildDependency(
|
|
1079
1394
|
tsconfig: string,
|
|
1080
|
-
|
|
1395
|
+
cacheDir: string,
|
|
1081
1396
|
metaPath: string,
|
|
1082
1397
|
): BuiltProject {
|
|
1083
1398
|
const project = readProjectConfig({ cwd: path.dirname(tsconfig), tsconfig });
|
|
1399
|
+
const generation = newDependencyGeneration();
|
|
1400
|
+
const emitDir = dependencyGenerationDir(cacheDir, generation);
|
|
1084
1401
|
fs.rmSync(emitDir, { force: true, recursive: true });
|
|
1402
|
+
fs.mkdirSync(emitDir, { recursive: true });
|
|
1085
1403
|
const result = runBuild({
|
|
1086
1404
|
cwd: project.root,
|
|
1087
1405
|
emit: true,
|
|
1088
1406
|
forceListEmittedFiles: true,
|
|
1089
1407
|
outDir: emitDir,
|
|
1408
|
+
// Emit a source map on the transient dependency emit (it never reaches the
|
|
1409
|
+
// dependency's published `lib/`) so the serve path can inline it under the
|
|
1410
|
+
// source URL, but only when the dependency configures none itself. Routed
|
|
1411
|
+
// as a dedicated build option, not a forwarded tsgo flag, so it never
|
|
1412
|
+
// reaches a native plugin host's argument parser (issue #353).
|
|
1413
|
+
forceRuntimeSourceMap:
|
|
1414
|
+
project.compilerOptions.sourceMap !== true &&
|
|
1415
|
+
project.compilerOptions.inlineSourceMap !== true,
|
|
1090
1416
|
// Honour the dependency's own transform plugins: a source-shipping package
|
|
1091
1417
|
// can itself depend on a transform (e.g. a fixture whose values are built
|
|
1092
1418
|
// with `typia.createRandom`), and its runtime behaviour is wrong without it.
|
|
@@ -1098,6 +1424,7 @@ function buildDependency(
|
|
|
1098
1424
|
plugins:
|
|
1099
1425
|
process.env.TTSC_PLUGIN_DESCRIPTOR_LOAD === "1" ? false : undefined,
|
|
1100
1426
|
quiet: true,
|
|
1427
|
+
resolvedProject: project,
|
|
1101
1428
|
// Emit only: the entry project's up-front check is the type gate. A
|
|
1102
1429
|
// dependency build pulls its own transitive sources into the program and
|
|
1103
1430
|
// would otherwise fail on type diagnostics that belong to those packages
|
|
@@ -1113,6 +1440,9 @@ function buildDependency(
|
|
|
1113
1440
|
// even on a clean build. A genuinely empty output directory is the real
|
|
1114
1441
|
// failure; the caller then falls back to type-stripping the one file.
|
|
1115
1442
|
if (!emittedAnything(emitDir)) {
|
|
1443
|
+
// Drop the failed generation so its partial directory can never be mistaken
|
|
1444
|
+
// for a reusable build.
|
|
1445
|
+
fs.rmSync(emitDir, { force: true, recursive: true });
|
|
1116
1446
|
throw new Error(
|
|
1117
1447
|
[
|
|
1118
1448
|
`ttsx: dependency build produced no output for ${tsconfig}`,
|
|
@@ -1130,15 +1460,33 @@ function buildDependency(
|
|
|
1130
1460
|
typeof project.compilerOptions.module === "string"
|
|
1131
1461
|
? project.compilerOptions.module
|
|
1132
1462
|
: undefined;
|
|
1133
|
-
|
|
1463
|
+
publishDependencyMeta(metaPath, { generation, moduleOption, rootDir });
|
|
1134
1464
|
return { emitDir, emittedFiles: undefined, moduleOption, rootDir };
|
|
1135
1465
|
}
|
|
1136
1466
|
|
|
1137
|
-
|
|
1467
|
+
/**
|
|
1468
|
+
* Publish the completion marker atomically: write it to a private temp name in
|
|
1469
|
+
* the same directory, then rename onto `metaPath`. Node's rename replaces an
|
|
1470
|
+
* existing file atomically on POSIX and Windows alike, so a concurrent reader
|
|
1471
|
+
* sees either the whole previous marker or the whole new one, never a
|
|
1472
|
+
* half-written file. The marker is the LAST artifact a build writes, after its
|
|
1473
|
+
* generation's emit is complete, so observing the new marker guarantees the new
|
|
1474
|
+
* generation is complete.
|
|
1475
|
+
*/
|
|
1476
|
+
function publishDependencyMeta(
|
|
1138
1477
|
metaPath: string,
|
|
1139
1478
|
meta: DependencyCacheMeta,
|
|
1140
1479
|
): void {
|
|
1141
|
-
|
|
1480
|
+
const tmp = `${metaPath}.${process.pid}.${Date.now()}.${crypto
|
|
1481
|
+
.randomBytes(6)
|
|
1482
|
+
.toString("hex")}.tmp`;
|
|
1483
|
+
fs.writeFileSync(tmp, JSON.stringify(meta), "utf8");
|
|
1484
|
+
try {
|
|
1485
|
+
fs.renameSync(tmp, metaPath);
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
fs.rmSync(tmp, { force: true });
|
|
1488
|
+
throw error;
|
|
1489
|
+
}
|
|
1142
1490
|
}
|
|
1143
1491
|
|
|
1144
1492
|
function dependencyCacheRoot(): string {
|
|
@@ -1148,6 +1496,376 @@ function dependencyCacheRoot(): string {
|
|
|
1148
1496
|
: path.join(os.tmpdir(), "ttsx-dep");
|
|
1149
1497
|
}
|
|
1150
1498
|
|
|
1499
|
+
// -----------------------------------------------------------------------------
|
|
1500
|
+
// Fenced dependency-build lock.
|
|
1501
|
+
//
|
|
1502
|
+
// The lock serialises concurrent first-builders of one dependency so the
|
|
1503
|
+
// expensive `runBuild` runs once per key while the rest wait and reuse the
|
|
1504
|
+
// published generation. It is generation-fenced so a stale observer or a former
|
|
1505
|
+
// owner can never release a successor's lock:
|
|
1506
|
+
//
|
|
1507
|
+
// * `<lockDir>/current` is the held generation — a directory carrying a
|
|
1508
|
+
// `generation` id and an `owner.json` (pid + hostname). A contender writes a
|
|
1509
|
+
// private candidate and atomically renames it onto `current`; a directory
|
|
1510
|
+
// rename cannot replace a non-empty `current`, so exactly one contender wins
|
|
1511
|
+
// with no empty-owner publication window.
|
|
1512
|
+
// * Release and reclaim both retire a generation by renaming `current` to its
|
|
1513
|
+
// deterministic tombstone `<lockDir>/retired/<generation>`. The only way to
|
|
1514
|
+
// free `current` is to create that tombstone, so a successor can acquire only
|
|
1515
|
+
// after its predecessor's tombstone exists. A late or duplicate retire of an
|
|
1516
|
+
// already-retired generation therefore finds the tombstone occupied and
|
|
1517
|
+
// fails atomically, and a reclaim that named an old generation can never move
|
|
1518
|
+
// a different successor into that old tombstone.
|
|
1519
|
+
//
|
|
1520
|
+
// This mirrors the source-plugin v2 protocol (`buildSourcePlugin.ts`) proven by
|
|
1521
|
+
// issue #452 / PR #460, minus the legacy-compatibility layer: the ttsx
|
|
1522
|
+
// dependency cache lives under a per-run directory with no shipped on-disk
|
|
1523
|
+
// format to stay compatible with.
|
|
1524
|
+
// -----------------------------------------------------------------------------
|
|
1525
|
+
|
|
1526
|
+
const DEP_BUILD_LOCK_STEAL_MS = 600_000;
|
|
1527
|
+
const DEP_BUILD_LOCK_POLL_MS = 50;
|
|
1528
|
+
const DEP_BUILD_LOCK_STALE_MS = 30_000;
|
|
1529
|
+
const DEP_BUILD_LOCK_CURRENT_DIR = "current";
|
|
1530
|
+
const DEP_BUILD_LOCK_RETIRED_DIR = "retired";
|
|
1531
|
+
const DEP_BUILD_LOCK_GENERATION_FILE = "generation";
|
|
1532
|
+
const DEP_BUILD_LOCK_OWNER_FILE = "owner.json";
|
|
1533
|
+
|
|
1534
|
+
/** Ownership token returned only to the process that acquired `current`. */
|
|
1535
|
+
export type DependencyBuildLockLease = { generation: string };
|
|
1536
|
+
|
|
1537
|
+
/** Opaque identity of one observed lock generation. */
|
|
1538
|
+
export type DependencyBuildLockFence = { generation: string };
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Atomically acquire the current generation of a dependency build lock, or
|
|
1542
|
+
* `null` when another process already holds it. Exported for the deterministic
|
|
1543
|
+
* multi-process cache regressions.
|
|
1544
|
+
*/
|
|
1545
|
+
export function acquireDependencyBuildLock(
|
|
1546
|
+
lockDir: string,
|
|
1547
|
+
): DependencyBuildLockLease | null {
|
|
1548
|
+
ensureDependencyLockRoot(lockDir);
|
|
1549
|
+
const generation = newDependencyGeneration();
|
|
1550
|
+
const candidateDir = path.join(lockDir, `candidate-${generation}`);
|
|
1551
|
+
fs.mkdirSync(candidateDir);
|
|
1552
|
+
try {
|
|
1553
|
+
fs.writeFileSync(
|
|
1554
|
+
path.join(candidateDir, DEP_BUILD_LOCK_GENERATION_FILE),
|
|
1555
|
+
`${generation}\n`,
|
|
1556
|
+
{ encoding: "utf8", flag: "wx" },
|
|
1557
|
+
);
|
|
1558
|
+
writeDependencyLockOwner(candidateDir, generation);
|
|
1559
|
+
const currentDir = path.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR);
|
|
1560
|
+
try {
|
|
1561
|
+
fs.renameSync(candidateDir, currentDir);
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
if (
|
|
1564
|
+
isMissingPathError(error) ||
|
|
1565
|
+
isRenameDestinationOccupied(error, currentDir)
|
|
1566
|
+
) {
|
|
1567
|
+
return null;
|
|
1568
|
+
}
|
|
1569
|
+
throw error;
|
|
1570
|
+
}
|
|
1571
|
+
return { generation };
|
|
1572
|
+
} finally {
|
|
1573
|
+
// The candidate name carries this process's random generation and can never
|
|
1574
|
+
// alias `current` or another contender's candidate.
|
|
1575
|
+
fs.rmSync(candidateDir, { force: true, recursive: true });
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** Retire a held generation during the holder's `finally`. */
|
|
1580
|
+
export function releaseDependencyBuildLock(
|
|
1581
|
+
lockDir: string,
|
|
1582
|
+
lease: DependencyBuildLockLease,
|
|
1583
|
+
): boolean {
|
|
1584
|
+
return retireDependencyBuildLock(lockDir, lease.generation);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Retire exactly the generation carried by an abandoned observation. Exported
|
|
1589
|
+
* for the deterministic multi-process cache regressions.
|
|
1590
|
+
*/
|
|
1591
|
+
export function reclaimDependencyBuildLock(
|
|
1592
|
+
lockDir: string,
|
|
1593
|
+
fence: DependencyBuildLockFence,
|
|
1594
|
+
): boolean {
|
|
1595
|
+
return retireDependencyBuildLock(lockDir, fence.generation);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function retireDependencyBuildLock(
|
|
1599
|
+
lockDir: string,
|
|
1600
|
+
generation: string,
|
|
1601
|
+
): boolean {
|
|
1602
|
+
if (!isDependencyGeneration(generation)) {
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
const retiredDir = path.join(lockDir, DEP_BUILD_LOCK_RETIRED_DIR);
|
|
1606
|
+
try {
|
|
1607
|
+
fs.mkdirSync(retiredDir);
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
1610
|
+
if (isMissingPathError(error)) {
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
throw error;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
const destination = path.join(retiredDir, generation);
|
|
1617
|
+
try {
|
|
1618
|
+
fs.renameSync(path.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR), destination);
|
|
1619
|
+
return true;
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
if (
|
|
1622
|
+
isMissingPathError(error) ||
|
|
1623
|
+
isRenameDestinationOccupied(error, destination)
|
|
1624
|
+
) {
|
|
1625
|
+
return false;
|
|
1626
|
+
}
|
|
1627
|
+
throw error;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/** One observation of a dependency build lock's state. */
|
|
1632
|
+
export type DependencyBuildLockObservation =
|
|
1633
|
+
| { state: "active"; owner: string; fence: DependencyBuildLockFence }
|
|
1634
|
+
| { state: "abandoned"; reason: string; fence: DependencyBuildLockFence }
|
|
1635
|
+
| { state: "released" };
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Classify the current state of a dependency build lock directory. Exported for
|
|
1639
|
+
* the deterministic multi-process cache regressions.
|
|
1640
|
+
*/
|
|
1641
|
+
export function inspectDependencyBuildLock(
|
|
1642
|
+
lockDir: string,
|
|
1643
|
+
now: number,
|
|
1644
|
+
): DependencyBuildLockObservation {
|
|
1645
|
+
const currentDir = path.join(lockDir, DEP_BUILD_LOCK_CURRENT_DIR);
|
|
1646
|
+
const generation = readDependencyLockGeneration(currentDir);
|
|
1647
|
+
if (generation === null) {
|
|
1648
|
+
if (dependencyLockAgeMs(currentDir, now) === null) {
|
|
1649
|
+
return { state: "released" };
|
|
1650
|
+
}
|
|
1651
|
+
// `current` exists without a readable generation id. Acquisition writes the
|
|
1652
|
+
// id into the candidate BEFORE the atomic rename, so this is a transient or
|
|
1653
|
+
// corrupt state; keep waiting rather than steal on ambiguous evidence.
|
|
1654
|
+
return {
|
|
1655
|
+
state: "active",
|
|
1656
|
+
owner: "lock generation without a readable id",
|
|
1657
|
+
fence: { generation: "" },
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
const fence: DependencyBuildLockFence = { generation };
|
|
1661
|
+
const owner = readDependencyLockOwner(currentDir);
|
|
1662
|
+
if (owner !== null) {
|
|
1663
|
+
const label = describeDependencyLockOwner(owner);
|
|
1664
|
+
if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
|
|
1665
|
+
return {
|
|
1666
|
+
state: "abandoned",
|
|
1667
|
+
reason: `${label} is no longer running`,
|
|
1668
|
+
fence,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
return { state: "active", owner: label, fence };
|
|
1672
|
+
}
|
|
1673
|
+
const ageMs = dependencyLockAgeMs(currentDir, now);
|
|
1674
|
+
if (ageMs === null) {
|
|
1675
|
+
return { state: "released" };
|
|
1676
|
+
}
|
|
1677
|
+
if (ageMs > DEP_BUILD_LOCK_STALE_MS) {
|
|
1678
|
+
return {
|
|
1679
|
+
state: "abandoned",
|
|
1680
|
+
reason: `lock generation has no ${DEP_BUILD_LOCK_OWNER_FILE} and is ${formatDuration(
|
|
1681
|
+
ageMs,
|
|
1682
|
+
)} old`,
|
|
1683
|
+
fence,
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
return {
|
|
1687
|
+
state: "active",
|
|
1688
|
+
owner: `lock generation with no ${DEP_BUILD_LOCK_OWNER_FILE}`,
|
|
1689
|
+
fence,
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/** Outcome of one waiting session on another process's dependency build lock. */
|
|
1694
|
+
type DependencyBuildWaitResult =
|
|
1695
|
+
| { outcome: "built"; built: BuiltProject }
|
|
1696
|
+
| { outcome: "released" }
|
|
1697
|
+
| { outcome: "abandoned"; reason: string; fence: DependencyBuildLockFence };
|
|
1698
|
+
|
|
1699
|
+
/** Poll for the locked builder to publish, up to `timeoutMs`. */
|
|
1700
|
+
function waitForDependencyBuild(
|
|
1701
|
+
cacheDir: string,
|
|
1702
|
+
metaPath: string,
|
|
1703
|
+
lockDir: string,
|
|
1704
|
+
timeoutMs: number,
|
|
1705
|
+
): DependencyBuildWaitResult {
|
|
1706
|
+
const startedAt = Date.now();
|
|
1707
|
+
for (;;) {
|
|
1708
|
+
const reuse = readDependencyCache(cacheDir, metaPath);
|
|
1709
|
+
if (reuse !== null) {
|
|
1710
|
+
return { outcome: "built", built: reuse };
|
|
1711
|
+
}
|
|
1712
|
+
const now = Date.now();
|
|
1713
|
+
const lock = inspectDependencyBuildLock(lockDir, now);
|
|
1714
|
+
if (lock.state === "released") {
|
|
1715
|
+
// The holder retired its generation between the cache check above and this
|
|
1716
|
+
// observation: prefer the marker if it landed in that window, otherwise
|
|
1717
|
+
// hand the free lock back to the caller to re-acquire.
|
|
1718
|
+
const built = readDependencyCache(cacheDir, metaPath);
|
|
1719
|
+
return built !== null
|
|
1720
|
+
? { outcome: "built", built }
|
|
1721
|
+
: { outcome: "released" };
|
|
1722
|
+
}
|
|
1723
|
+
if (lock.state === "abandoned") {
|
|
1724
|
+
return { outcome: "abandoned", reason: lock.reason, fence: lock.fence };
|
|
1725
|
+
}
|
|
1726
|
+
if (now - startedAt > timeoutMs) {
|
|
1727
|
+
return {
|
|
1728
|
+
outcome: "abandoned",
|
|
1729
|
+
reason: `timed out after ${formatDuration(now - startedAt)}`,
|
|
1730
|
+
fence: lock.fence,
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
sleepSync(DEP_BUILD_LOCK_POLL_MS);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function ensureDependencyLockRoot(lockDir: string): void {
|
|
1738
|
+
fs.mkdirSync(path.join(lockDir, DEP_BUILD_LOCK_RETIRED_DIR), {
|
|
1739
|
+
recursive: true,
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
interface DependencyLockOwner {
|
|
1744
|
+
hostname: string;
|
|
1745
|
+
pid: number;
|
|
1746
|
+
startedAt?: string;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function writeDependencyLockOwner(
|
|
1750
|
+
generationDir: string,
|
|
1751
|
+
generation: string,
|
|
1752
|
+
): void {
|
|
1753
|
+
fs.writeFileSync(
|
|
1754
|
+
path.join(generationDir, DEP_BUILD_LOCK_OWNER_FILE),
|
|
1755
|
+
`${JSON.stringify({
|
|
1756
|
+
generation,
|
|
1757
|
+
hostname: os.hostname(),
|
|
1758
|
+
pid: process.pid,
|
|
1759
|
+
startedAt: new Date().toISOString(),
|
|
1760
|
+
})}\n`,
|
|
1761
|
+
"utf8",
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function readDependencyLockOwner(
|
|
1766
|
+
generationDir: string,
|
|
1767
|
+
): DependencyLockOwner | null {
|
|
1768
|
+
try {
|
|
1769
|
+
const parsed = JSON.parse(
|
|
1770
|
+
fs.readFileSync(
|
|
1771
|
+
path.join(generationDir, DEP_BUILD_LOCK_OWNER_FILE),
|
|
1772
|
+
"utf8",
|
|
1773
|
+
),
|
|
1774
|
+
) as Record<string, unknown>;
|
|
1775
|
+
if (
|
|
1776
|
+
typeof parsed.hostname !== "string" ||
|
|
1777
|
+
typeof parsed.pid !== "number" ||
|
|
1778
|
+
!Number.isInteger(parsed.pid) ||
|
|
1779
|
+
parsed.pid <= 0
|
|
1780
|
+
) {
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
return {
|
|
1784
|
+
hostname: parsed.hostname,
|
|
1785
|
+
pid: parsed.pid,
|
|
1786
|
+
startedAt:
|
|
1787
|
+
typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
|
|
1788
|
+
};
|
|
1789
|
+
} catch {
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function readDependencyLockGeneration(generationDir: string): string | null {
|
|
1795
|
+
try {
|
|
1796
|
+
const generation = fs
|
|
1797
|
+
.readFileSync(
|
|
1798
|
+
path.join(generationDir, DEP_BUILD_LOCK_GENERATION_FILE),
|
|
1799
|
+
"utf8",
|
|
1800
|
+
)
|
|
1801
|
+
.trim();
|
|
1802
|
+
return isDependencyGeneration(generation) ? generation : null;
|
|
1803
|
+
} catch {
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/** Age of an observed lock directory, or `null` when it no longer exists. */
|
|
1809
|
+
function dependencyLockAgeMs(
|
|
1810
|
+
generationDir: string,
|
|
1811
|
+
now: number,
|
|
1812
|
+
): number | null {
|
|
1813
|
+
try {
|
|
1814
|
+
return Math.max(0, now - fs.statSync(generationDir).mtimeMs);
|
|
1815
|
+
} catch (error) {
|
|
1816
|
+
return isMissingPathError(error) ? null : 0;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function describeDependencyLockOwner(owner: DependencyLockOwner): string {
|
|
1821
|
+
const started =
|
|
1822
|
+
owner.startedAt === undefined ? "" : ` started at ${owner.startedAt}`;
|
|
1823
|
+
return `pid ${owner.pid} on ${owner.hostname}${started}`;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function isLocalHostName(hostname: string): boolean {
|
|
1827
|
+
return hostname.toLowerCase() === os.hostname().toLowerCase();
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function isProcessAlive(pid: number): boolean {
|
|
1831
|
+
try {
|
|
1832
|
+
process.kill(pid, 0);
|
|
1833
|
+
return true;
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function isMissingPathError(error: unknown): boolean {
|
|
1840
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1841
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function isRenameDestinationOccupied(
|
|
1845
|
+
error: unknown,
|
|
1846
|
+
destination: string,
|
|
1847
|
+
): boolean {
|
|
1848
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1849
|
+
if (code === "EEXIST" || code === "ENOTEMPTY") {
|
|
1850
|
+
return true;
|
|
1851
|
+
}
|
|
1852
|
+
return (code === "EACCES" || code === "EPERM") && fs.existsSync(destination);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** Render a millisecond duration for lock diagnostics (`137ms`, `42s`, `9m 3s`). */
|
|
1856
|
+
function formatDuration(ms: number): string {
|
|
1857
|
+
if (!Number.isFinite(ms)) {
|
|
1858
|
+
return "an unknown time";
|
|
1859
|
+
}
|
|
1860
|
+
if (ms < 1_000) {
|
|
1861
|
+
return `${Math.max(0, Math.round(ms))}ms`;
|
|
1862
|
+
}
|
|
1863
|
+
const seconds = Math.floor(ms / 1_000);
|
|
1864
|
+
const minutes = Math.floor(seconds / 60);
|
|
1865
|
+
const remainder = seconds % 60;
|
|
1866
|
+
return minutes === 0 ? `${seconds}s` : `${minutes}m ${remainder}s`;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1151
1869
|
/** Owning-tsconfig cache keyed by directory, mirroring `packageTypeCache`. */
|
|
1152
1870
|
const tsconfigCache = new Map<string, string | null>();
|
|
1153
1871
|
const moduleOptionCache = new Map<string, string | undefined>();
|