ttsc 0.18.3 → 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/ttscgraph/main.go +18 -12
- package/cmd/ttscgraph/run_help_version_and_bad_flags_exit_codes_test.go +23 -5
- package/cmd/ttscgraph/serve.go +863 -0
- package/cmd/ttscgraph/serve_protocol_rejects_invalid_request_line_test.go +43 -0
- package/cmd/ttscgraph/serve_protocol_retries_session_creation_test.go +83 -0
- package/cmd/ttscgraph/serve_protocol_reuses_session_test.go +35 -0
- package/cmd/ttscgraph/serve_session_applies_source_edit_incrementally_test.go +55 -0
- package/cmd/ttscgraph/serve_session_fails_closed_on_deleted_config_test.go +68 -0
- package/cmd/ttscgraph/serve_session_fails_closed_on_invalid_config_test.go +50 -0
- package/cmd/ttscgraph/serve_session_keeps_missing_literal_root_unchanged_test.go +46 -0
- package/cmd/ttscgraph/serve_session_rebuilds_import_graph_change_test.go +60 -0
- package/cmd/ttscgraph/serve_session_reloads_created_literal_root_test.go +44 -0
- package/cmd/ttscgraph/serve_session_reloads_extended_config_change_test.go +45 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_conditional_export_test.go +60 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_config_types_test.go +44 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_excluded_import_test.go +62 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_package_export_test.go +46 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_package_import_test.go +46 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_path_alias_test.go +47 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_reference_path_test.go +42 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_root_dirs_target_test.go +46 -0
- package/cmd/ttscgraph/serve_session_reloads_newly_resolved_type_reference_test.go +42 -0
- package/cmd/ttscgraph/serve_session_reloads_root_file_set_test.go +52 -0
- package/cmd/ttscgraph/serve_session_reloads_source_deleted_during_load_test.go +60 -0
- package/cmd/ttscgraph/serve_session_reloads_transitive_project_reference_test.go +55 -0
- package/cmd/ttscgraph/serve_session_reloads_when_program_plugin_linked_test.go +53 -0
- package/cmd/ttscgraph/serve_session_reuses_unchanged_preamble_source_test.go +45 -0
- package/cmd/ttscgraph/serve_session_reuses_unchanged_snapshot_test.go +52 -0
- package/cmd/ttscgraph/serve_session_revisits_source_edited_during_load_test.go +61 -0
- package/cmd/ttscgraph/serve_session_tolerates_os_invalid_resolution_candidate_test.go +56 -0
- package/cmd/ttscgraph/serve_session_tracks_all_module_specifier_forms_test.go +41 -0
- package/cmd/ttscgraph/serve_session_tracks_diamond_project_references_once_test.go +61 -0
- package/cmd/ttscserver/main.go +5 -3
- package/driver/lsp.go +13 -1
- package/driver/plugins.go +56 -5
- package/driver/program.go +25 -10
- package/driver/rewrite.go +381 -21
- package/driver/session.go +11 -3
- 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 +13 -2
- package/lib/TtscCompiler.js +30 -20
- 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 +15 -1
- 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 +18 -0
- package/lib/compiler/internal/runBuild.js +220 -24
- 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/ITtscCompilerResult.d.ts +8 -5
- 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/printer/shim.go +22 -4
- package/shim/scanner/regexp.go +27 -0
- package/src/TtscCompiler.ts +29 -22
- package/src/TtscService.ts +15 -7
- package/src/compiler/internal/compileProjectInMemory.ts +18 -1
- 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 +313 -32
- 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/ITtscCompilerResult.ts +8 -5
- 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
|
@@ -107,6 +107,14 @@ const PLUGIN_BUILD_LOCK_POLL_MS = 50;
|
|
|
107
107
|
const PLUGIN_BUILD_LOCK_LEGACY_STALE_MS = 30_000;
|
|
108
108
|
const PLUGIN_BUILD_LOCK_STATUS_MS = 30_000;
|
|
109
109
|
const PLUGIN_BUILD_LOCK_OWNER_FILE = "owner.json";
|
|
110
|
+
const PLUGIN_BUILD_LOCK_PROTOCOL_FILE = "protocol-v2";
|
|
111
|
+
const PLUGIN_BUILD_LOCK_GENERATION_FILE = "generation";
|
|
112
|
+
const PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR = "legacy-generation";
|
|
113
|
+
const PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD = "fence.json";
|
|
114
|
+
const PLUGIN_BUILD_LOCK_CURRENT_DIR = "current";
|
|
115
|
+
const PLUGIN_BUILD_LOCK_RETIRED_DIR = "retired";
|
|
116
|
+
const PLUGIN_BUILD_LOCK_V2_SUFFIX = ".v2";
|
|
117
|
+
const PLUGIN_BUILD_LOCK_PROTOCOL = "ttsc-plugin-build-lock-v2\n";
|
|
110
118
|
// The default cache lives INSIDE the workspace, at
|
|
111
119
|
// `<workspaceRoot>/node_modules/.cache/ttsc`, so `rm -rf node_modules` (or
|
|
112
120
|
// deleting the repo) reclaims every compiled plugin binary and Go object file.
|
|
@@ -158,35 +166,47 @@ export interface ITtscSourceBuildCachePaths {
|
|
|
158
166
|
goBuildRootSource: "ttsc-cache" | "TTSC_GO_CACHE_DIR" | "GOCACHE";
|
|
159
167
|
}
|
|
160
168
|
|
|
161
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Build one Go source plugin into a cached executable.
|
|
171
|
+
*
|
|
172
|
+
* `opts.env` is the effective environment for this build — the caller merges `{
|
|
173
|
+
* ...process.env, ...context.env }` so a programmatic `TtscCompiler` instance
|
|
174
|
+
* can pin its own Go toolchain (`TTSC_GO_BINARY`), Go build cache
|
|
175
|
+
* (`TTSC_GO_CACHE_DIR`), and Go build variables (`GOFLAGS`, `CGO_*`, …) without
|
|
176
|
+
* mutating the shared `process.env`. CLI callers omit it and inherit
|
|
177
|
+
* `process.env`, so ambient behavior is unchanged.
|
|
178
|
+
*/
|
|
162
179
|
export function buildSourcePlugin(opts: {
|
|
163
180
|
source: string;
|
|
164
181
|
pluginName: string;
|
|
165
182
|
baseDir: string;
|
|
166
183
|
cacheDir?: string;
|
|
167
184
|
contributors?: readonly ITtscBuildContributor[];
|
|
185
|
+
env?: NodeJS.ProcessEnv;
|
|
168
186
|
label?: string;
|
|
169
187
|
overlayDirs?: readonly string[];
|
|
170
188
|
quiet?: boolean;
|
|
171
189
|
ttscVersion: string;
|
|
172
190
|
tsgoVersion: string;
|
|
173
191
|
}): string {
|
|
192
|
+
const env = opts.env ?? process.env;
|
|
174
193
|
const { dir, entry, source } = resolveSourceBuildTarget(opts);
|
|
175
194
|
const overlayDirs = [...(opts.overlayDirs ?? findTtscOverlayDirs())].sort();
|
|
176
195
|
const contributors = opts.contributors ?? [];
|
|
177
|
-
const goBinary = resolveGoCompiler();
|
|
196
|
+
const goBinary = resolveGoCompiler(env);
|
|
178
197
|
ensureExecutableGoToolchain(goBinary);
|
|
179
198
|
const key = computeCacheKey({
|
|
180
199
|
contributors,
|
|
181
200
|
dir,
|
|
182
201
|
entry,
|
|
202
|
+
env,
|
|
183
203
|
goBinary,
|
|
184
204
|
overlayDirs,
|
|
185
205
|
ttscVersion: opts.ttscVersion,
|
|
186
206
|
tsgoVersion: opts.tsgoVersion,
|
|
187
207
|
});
|
|
188
|
-
const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir);
|
|
189
|
-
maybePrunePluginCache(paths, opts.cacheDir);
|
|
208
|
+
const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir, env);
|
|
209
|
+
maybePrunePluginCache(paths, opts.cacheDir, env);
|
|
190
210
|
const cacheDir = path.join(paths.pluginRoot, key);
|
|
191
211
|
const binaryName = process.platform === "win32" ? "plugin.exe" : "plugin";
|
|
192
212
|
const binaryPath = path.join(cacheDir, binaryName);
|
|
@@ -208,6 +228,7 @@ export function buildSourcePlugin(opts: {
|
|
|
208
228
|
contributors,
|
|
209
229
|
dir,
|
|
210
230
|
entry,
|
|
231
|
+
env,
|
|
211
232
|
goBinary,
|
|
212
233
|
key,
|
|
213
234
|
label,
|
|
@@ -227,6 +248,7 @@ function compileSourcePlugin(opts: {
|
|
|
227
248
|
contributors: readonly ITtscBuildContributor[];
|
|
228
249
|
dir: string;
|
|
229
250
|
entry: string;
|
|
251
|
+
env: NodeJS.ProcessEnv;
|
|
230
252
|
goBinary: string;
|
|
231
253
|
goBuildCacheRoot: string;
|
|
232
254
|
key: string;
|
|
@@ -254,7 +276,11 @@ function compileSourcePlugin(opts: {
|
|
|
254
276
|
);
|
|
255
277
|
try {
|
|
256
278
|
materializeScratchDir(opts.dir, scratchDir);
|
|
257
|
-
const goModReader = createGoModReader(
|
|
279
|
+
const goModReader = createGoModReader(
|
|
280
|
+
opts.goBinary,
|
|
281
|
+
opts.pluginName,
|
|
282
|
+
opts.env,
|
|
283
|
+
);
|
|
258
284
|
if (opts.contributors.length > 0) {
|
|
259
285
|
mergeContributors({
|
|
260
286
|
contributors: opts.contributors,
|
|
@@ -264,7 +290,13 @@ function compileSourcePlugin(opts: {
|
|
|
264
290
|
scratchDir,
|
|
265
291
|
});
|
|
266
292
|
}
|
|
267
|
-
writeGoWork(
|
|
293
|
+
writeGoWork(
|
|
294
|
+
scratchDir,
|
|
295
|
+
opts.overlayDirs,
|
|
296
|
+
opts.goBinary,
|
|
297
|
+
opts.pluginName,
|
|
298
|
+
opts.env,
|
|
299
|
+
);
|
|
268
300
|
const scratchBinaryName =
|
|
269
301
|
process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin";
|
|
270
302
|
runGoBuild(
|
|
@@ -274,6 +306,7 @@ function compileSourcePlugin(opts: {
|
|
|
274
306
|
opts.pluginName,
|
|
275
307
|
opts.goBinary,
|
|
276
308
|
opts.goBuildCacheRoot,
|
|
309
|
+
opts.env,
|
|
277
310
|
);
|
|
278
311
|
const builtBinary = path.join(scratchDir, scratchBinaryName);
|
|
279
312
|
publishBuiltBinary(builtBinary, opts.binaryPath);
|
|
@@ -289,17 +322,28 @@ function compileSourcePlugin(opts: {
|
|
|
289
322
|
* cache key, so concurrent fan-out (parallel suites, a benchmark, a worker
|
|
290
323
|
* pool) runs the `go build` once instead of once per process.
|
|
291
324
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
325
|
+
* `<cacheDir>.lock.v2` is a persistent coordination directory. The adjacent
|
|
326
|
+
* `<cacheDir>.lock` path remains reserved for legacy holders and is never
|
|
327
|
+
* reused for a v2 generation: an old holder or stale legacy reclaimer can
|
|
328
|
+
* therefore remove only the legacy path, never a v2 successor. A contender
|
|
329
|
+
* writes a non-empty candidate and atomically renames it to `current`; only one
|
|
330
|
+
* rename wins. The winner builds and publishes while every loser polls and
|
|
331
|
+
* reuses the resulting binary. A loser distinguishes two ways a generation
|
|
332
|
+
* stops blocking:
|
|
298
333
|
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
334
|
+
* - `released`: the holder retired `current` itself — it published, or its build
|
|
335
|
+
* threw and its `finally` freed the key. The loser simply retries the
|
|
336
|
+
* ordinary acquisition; nothing is stale and nothing is reported.
|
|
337
|
+
* - `abandoned`: `current` still exists but its owner is provably dead, it is an
|
|
338
|
+
* old metadata-less legacy lock, or the wait budget
|
|
339
|
+
* (`PLUGIN_BUILD_LOCK_STEAL_MS`) expired. Only then does the loser report and
|
|
340
|
+
* retire precisely that generation before retrying.
|
|
341
|
+
*
|
|
342
|
+
* Retired generations remain as non-empty tombstones. Release and reclaim both
|
|
343
|
+
* rename `current` to the observed generation's deterministic tombstone path.
|
|
344
|
+
* Once generation A is retired, a stale observer or old finalizer for A cannot
|
|
345
|
+
* rename successor B there because replacing the non-empty tombstone fails
|
|
346
|
+
* atomically. `publishBuiltBinary`'s atomic rename remains defense in depth.
|
|
303
347
|
*/
|
|
304
348
|
function buildUnderPluginLock(
|
|
305
349
|
cacheDir: string,
|
|
@@ -317,33 +361,38 @@ function buildUnderPluginLock(
|
|
|
317
361
|
touchCacheEntry(cacheDir);
|
|
318
362
|
return binaryPath;
|
|
319
363
|
}
|
|
364
|
+
let lease: PluginBuildLockLease | null;
|
|
320
365
|
try {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
329
|
-
// A lock dir we cannot create for an unexpected reason (e.g. a
|
|
330
|
-
// read-only cache) must not silently skip the build. Fall back to an
|
|
331
|
-
// unlocked build — correctness is preserved by the atomic publish.
|
|
332
|
-
return build();
|
|
333
|
-
}
|
|
366
|
+
lease = acquirePluginBuildLock(lockDir);
|
|
367
|
+
} catch {
|
|
368
|
+
// An unusable coordination directory must not silently skip the build.
|
|
369
|
+
// Atomic publication still preserves binary integrity.
|
|
370
|
+
return build();
|
|
371
|
+
}
|
|
372
|
+
if (lease === null) {
|
|
334
373
|
const waited = waitForPluginBinary({
|
|
335
374
|
binaryPath,
|
|
336
375
|
lockDir,
|
|
337
376
|
lockInfo,
|
|
338
377
|
timeoutMs: PLUGIN_BUILD_LOCK_STEAL_MS,
|
|
339
378
|
});
|
|
340
|
-
if (waited.published) {
|
|
379
|
+
if (waited.outcome === "published") {
|
|
341
380
|
touchCacheEntry(cacheDir);
|
|
342
381
|
return binaryPath;
|
|
343
382
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
383
|
+
if (waited.outcome === "abandoned") {
|
|
384
|
+
// Retire only the generation that produced this observation. Losing
|
|
385
|
+
// the rename race means another waiter (or the holder's normal
|
|
386
|
+
// finalizer) already made progress, so do not report a stale result as
|
|
387
|
+
// an abandonment.
|
|
388
|
+
if (reclaimPluginBuildLock(lockDir, waited.fence)) {
|
|
389
|
+
reportPluginLockSteal(lockDir, binaryPath, lockInfo, waited.reason);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// "released" needs no repair: the holder freed the key normally (its
|
|
393
|
+
// build published or failed), so retry the ordinary atomic acquisition.
|
|
394
|
+
// Reporting a steal or force-removing the path here would misclassify a
|
|
395
|
+
// routine handoff as abandonment (issue #421).
|
|
347
396
|
continue;
|
|
348
397
|
}
|
|
349
398
|
try {
|
|
@@ -354,13 +403,263 @@ function buildUnderPluginLock(
|
|
|
354
403
|
}
|
|
355
404
|
return build();
|
|
356
405
|
} finally {
|
|
357
|
-
|
|
406
|
+
releasePluginBuildLock(lockDir, lease);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Opaque identity of one observed lock generation. */
|
|
412
|
+
export type PluginBuildLockFence = {
|
|
413
|
+
protocol: "legacy" | "v2";
|
|
414
|
+
generation: string;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/** Ownership token returned only to the process that acquired `current`. */
|
|
418
|
+
export type PluginBuildLockLease = {
|
|
419
|
+
protocol: "v2";
|
|
420
|
+
generation: string;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Atomically acquire the current generation in a v2 coordination directory.
|
|
425
|
+
*
|
|
426
|
+
* A non-empty candidate is renamed to `current`. Directory rename cannot
|
|
427
|
+
* replace a non-empty `current`, so exactly one contender wins without an
|
|
428
|
+
* empty-owner publication window. `null` means either another v2 holder won or
|
|
429
|
+
* the path is a legacy lock that must be observed before it can be reclaimed.
|
|
430
|
+
*
|
|
431
|
+
* Exported for deterministic multi-process tests.
|
|
432
|
+
*/
|
|
433
|
+
export function acquirePluginBuildLock(
|
|
434
|
+
lockDir: string,
|
|
435
|
+
): PluginBuildLockLease | null {
|
|
436
|
+
// A legacy holder owns the old path. Never publish v2 ownership into that
|
|
437
|
+
// deletable namespace; wait until the legacy generation is released or
|
|
438
|
+
// reclaimed, then use the orthogonal persistent v2 directory.
|
|
439
|
+
if (pluginBuildLockPathExists(lockDir)) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
const protocolDir = pluginBuildLockProtocolDir(lockDir);
|
|
443
|
+
ensurePluginBuildLockProtocol(protocolDir);
|
|
444
|
+
// Close the initialization window as far as the legacy protocol permits. A
|
|
445
|
+
// legacy holder that appeared while v2 was initialized still blocks this
|
|
446
|
+
// acquisition. (A legacy executable cannot provide a true cross-path CAS.)
|
|
447
|
+
if (pluginBuildLockPathExists(lockDir)) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const generation = crypto.randomBytes(16).toString("hex");
|
|
452
|
+
const candidateDir = path.join(protocolDir, `candidate-${generation}`);
|
|
453
|
+
fs.mkdirSync(candidateDir);
|
|
454
|
+
try {
|
|
455
|
+
fs.writeFileSync(
|
|
456
|
+
path.join(candidateDir, PLUGIN_BUILD_LOCK_GENERATION_FILE),
|
|
457
|
+
`${generation}\n`,
|
|
458
|
+
{ encoding: "utf8", flag: "wx" },
|
|
459
|
+
);
|
|
460
|
+
writePluginBuildLockOwner(candidateDir, generation);
|
|
461
|
+
try {
|
|
462
|
+
fs.renameSync(
|
|
463
|
+
candidateDir,
|
|
464
|
+
path.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR),
|
|
465
|
+
);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (
|
|
468
|
+
isMissingPathError(error) ||
|
|
469
|
+
isRenameDestinationOccupied(
|
|
470
|
+
error,
|
|
471
|
+
path.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR),
|
|
472
|
+
)
|
|
473
|
+
) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
return { protocol: "v2", generation };
|
|
479
|
+
} finally {
|
|
480
|
+
// The candidate name contains this process's random generation and can
|
|
481
|
+
// never alias `current` or another contender's candidate.
|
|
482
|
+
fs.rmSync(candidateDir, { force: true, recursive: true });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Retire a held generation during the holder's `finally`. */
|
|
487
|
+
export function releasePluginBuildLock(
|
|
488
|
+
lockDir: string,
|
|
489
|
+
lease: PluginBuildLockLease,
|
|
490
|
+
): boolean {
|
|
491
|
+
return retireV2PluginBuildLock(
|
|
492
|
+
pluginBuildLockProtocolDir(lockDir),
|
|
493
|
+
lease.generation,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Retire exactly the generation carried by an abandoned observation.
|
|
499
|
+
*
|
|
500
|
+
* Exported for deterministic multi-process tests.
|
|
501
|
+
*/
|
|
502
|
+
export function reclaimPluginBuildLock(
|
|
503
|
+
lockDir: string,
|
|
504
|
+
fence: PluginBuildLockFence,
|
|
505
|
+
): boolean {
|
|
506
|
+
if (fence.protocol === "v2") {
|
|
507
|
+
return retireV2PluginBuildLock(
|
|
508
|
+
pluginBuildLockProtocolDir(lockDir),
|
|
509
|
+
fence.generation,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
return retireLegacyPluginBuildLock(lockDir, fence.generation);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function pluginBuildLockProtocolDir(lockDir: string): string {
|
|
516
|
+
return `${lockDir}${PLUGIN_BUILD_LOCK_V2_SUFFIX}`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function ensurePluginBuildLockProtocol(protocolDir: string): void {
|
|
520
|
+
if (isPluginBuildLockProtocolV2(protocolDir)) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const generation = crypto.randomBytes(16).toString("hex");
|
|
525
|
+
const candidateDir = `${protocolDir}.candidate-${generation}`;
|
|
526
|
+
fs.mkdirSync(candidateDir);
|
|
527
|
+
try {
|
|
528
|
+
fs.mkdirSync(path.join(candidateDir, PLUGIN_BUILD_LOCK_RETIRED_DIR));
|
|
529
|
+
fs.writeFileSync(
|
|
530
|
+
path.join(candidateDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE),
|
|
531
|
+
PLUGIN_BUILD_LOCK_PROTOCOL,
|
|
532
|
+
{ encoding: "utf8", flag: "wx" },
|
|
533
|
+
);
|
|
534
|
+
try {
|
|
535
|
+
fs.renameSync(candidateDir, protocolDir);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (
|
|
538
|
+
isRenameDestinationOccupied(error, protocolDir) &&
|
|
539
|
+
isPluginBuildLockProtocolV2(protocolDir)
|
|
540
|
+
) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
} finally {
|
|
546
|
+
fs.rmSync(candidateDir, { force: true, recursive: true });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function isPluginBuildLockProtocolV2(lockDir: string): boolean {
|
|
551
|
+
try {
|
|
552
|
+
return (
|
|
553
|
+
fs.readFileSync(
|
|
554
|
+
path.join(lockDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE),
|
|
555
|
+
"utf8",
|
|
556
|
+
) === PLUGIN_BUILD_LOCK_PROTOCOL
|
|
557
|
+
);
|
|
558
|
+
} catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function retireV2PluginBuildLock(lockDir: string, generation: string): boolean {
|
|
564
|
+
if (!isPluginBuildLockGeneration(generation)) return false;
|
|
565
|
+
const retiredDir = path.join(lockDir, PLUGIN_BUILD_LOCK_RETIRED_DIR);
|
|
566
|
+
try {
|
|
567
|
+
fs.mkdirSync(retiredDir);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
570
|
+
if (isMissingPathError(error)) return false;
|
|
571
|
+
throw error;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const destination = path.join(retiredDir, generation);
|
|
576
|
+
try {
|
|
577
|
+
fs.renameSync(
|
|
578
|
+
path.join(lockDir, PLUGIN_BUILD_LOCK_CURRENT_DIR),
|
|
579
|
+
destination,
|
|
580
|
+
);
|
|
581
|
+
return true;
|
|
582
|
+
} catch (error) {
|
|
583
|
+
if (
|
|
584
|
+
isMissingPathError(error) ||
|
|
585
|
+
isRenameDestinationOccupied(error, destination)
|
|
586
|
+
) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function retireLegacyPluginBuildLock(
|
|
594
|
+
lockDir: string,
|
|
595
|
+
generation: string,
|
|
596
|
+
): boolean {
|
|
597
|
+
if (!isPluginBuildLockGeneration(generation)) return false;
|
|
598
|
+
const captured = readLegacyPluginBuildLockFence(
|
|
599
|
+
path.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR),
|
|
600
|
+
);
|
|
601
|
+
if (captured?.fence.generation !== generation) {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
const destination = `${lockDir}.retired-${generation}`;
|
|
605
|
+
try {
|
|
606
|
+
fs.renameSync(lockDir, destination);
|
|
607
|
+
return true;
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (
|
|
610
|
+
isMissingPathError(error) ||
|
|
611
|
+
isRenameDestinationOccupied(error, destination)
|
|
612
|
+
) {
|
|
613
|
+
return false;
|
|
358
614
|
}
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function isMissingPathError(error: unknown): boolean {
|
|
620
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
621
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function isRenameDestinationOccupied(
|
|
625
|
+
error: unknown,
|
|
626
|
+
destination: string,
|
|
627
|
+
): boolean {
|
|
628
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
629
|
+
if (code === "EEXIST" || code === "ENOTEMPTY") {
|
|
630
|
+
return true;
|
|
359
631
|
}
|
|
632
|
+
return (code === "EACCES" || code === "EPERM") && fs.existsSync(destination);
|
|
360
633
|
}
|
|
361
634
|
|
|
362
|
-
/**
|
|
363
|
-
|
|
635
|
+
/**
|
|
636
|
+
* Outcome of one waiting session on another process's plugin build lock.
|
|
637
|
+
*
|
|
638
|
+
* - `published`: the binary exists and can be reused.
|
|
639
|
+
* - `released`: the observed generation no longer exists and no binary appeared —
|
|
640
|
+
* the holder freed the key normally, so the caller should retry ordinary
|
|
641
|
+
* acquisition without reporting or removing anything.
|
|
642
|
+
* - `abandoned`: the lock still exists but is provably stale (dead owner, old
|
|
643
|
+
* legacy lock) or the wait budget expired; the caller may report and retire
|
|
644
|
+
* precisely the attached generation.
|
|
645
|
+
*
|
|
646
|
+
* Exported for unit tests.
|
|
647
|
+
*/
|
|
648
|
+
export type PluginBinaryWaitResult =
|
|
649
|
+
| { outcome: "published" }
|
|
650
|
+
| { outcome: "released" }
|
|
651
|
+
| {
|
|
652
|
+
outcome: "abandoned";
|
|
653
|
+
reason: string;
|
|
654
|
+
fence: PluginBuildLockFence;
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Poll for the locked builder to publish its binary, up to `timeoutMs`.
|
|
659
|
+
*
|
|
660
|
+
* Exported for unit tests.
|
|
661
|
+
*/
|
|
662
|
+
export function waitForPluginBinary(opts: {
|
|
364
663
|
binaryPath: string;
|
|
365
664
|
lockDir: string;
|
|
366
665
|
lockInfo: {
|
|
@@ -369,25 +668,36 @@ function waitForPluginBinary(opts: {
|
|
|
369
668
|
quiet: boolean;
|
|
370
669
|
};
|
|
371
670
|
timeoutMs: number;
|
|
372
|
-
}):
|
|
671
|
+
}): PluginBinaryWaitResult {
|
|
373
672
|
const startedAt = Date.now();
|
|
374
673
|
let nextStatusAt = startedAt + PLUGIN_BUILD_LOCK_STATUS_MS;
|
|
375
674
|
for (;;) {
|
|
376
675
|
if (fs.existsSync(opts.binaryPath)) {
|
|
377
|
-
return {
|
|
676
|
+
return { outcome: "published" };
|
|
378
677
|
}
|
|
379
678
|
const now = Date.now();
|
|
380
|
-
const
|
|
381
|
-
if (
|
|
679
|
+
const lock = inspectPluginBuildLock(opts.lockDir, now);
|
|
680
|
+
if (lock.state === "released") {
|
|
681
|
+
// The holder retired its generation between the binary check above and
|
|
682
|
+
// this observation. That is a normal release, not abandonment: prefer the
|
|
683
|
+
// binary when it landed inside that window, otherwise hand the free key
|
|
684
|
+
// back to the caller.
|
|
685
|
+
return fs.existsSync(opts.binaryPath)
|
|
686
|
+
? { outcome: "published" }
|
|
687
|
+
: { outcome: "released" };
|
|
688
|
+
}
|
|
689
|
+
if (lock.state === "abandoned") {
|
|
382
690
|
return {
|
|
383
|
-
|
|
384
|
-
reason:
|
|
691
|
+
outcome: "abandoned",
|
|
692
|
+
reason: lock.reason,
|
|
693
|
+
fence: lock.fence,
|
|
385
694
|
};
|
|
386
695
|
}
|
|
387
696
|
if (now - startedAt > opts.timeoutMs) {
|
|
388
697
|
return {
|
|
389
|
-
|
|
698
|
+
outcome: "abandoned",
|
|
390
699
|
reason: `timed out after ${formatDuration(now - startedAt)}`,
|
|
700
|
+
fence: lock.fence,
|
|
391
701
|
};
|
|
392
702
|
}
|
|
393
703
|
if (!opts.lockInfo.quiet && now >= nextStatusAt) {
|
|
@@ -396,7 +706,7 @@ function waitForPluginBinary(opts: {
|
|
|
396
706
|
elapsedMs: now - startedAt,
|
|
397
707
|
lockDir: opts.lockDir,
|
|
398
708
|
lockInfo: opts.lockInfo,
|
|
399
|
-
owner:
|
|
709
|
+
owner: lock.owner,
|
|
400
710
|
});
|
|
401
711
|
nextStatusAt = now + PLUGIN_BUILD_LOCK_STATUS_MS;
|
|
402
712
|
}
|
|
@@ -404,66 +714,295 @@ function waitForPluginBinary(opts: {
|
|
|
404
714
|
}
|
|
405
715
|
}
|
|
406
716
|
|
|
407
|
-
function writePluginBuildLockOwner(
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
717
|
+
function writePluginBuildLockOwner(
|
|
718
|
+
generationDir: string,
|
|
719
|
+
generation: string,
|
|
720
|
+
): void {
|
|
721
|
+
fs.writeFileSync(
|
|
722
|
+
path.join(generationDir, PLUGIN_BUILD_LOCK_OWNER_FILE),
|
|
723
|
+
`${JSON.stringify(
|
|
724
|
+
{
|
|
725
|
+
generation,
|
|
726
|
+
hostname: os.hostname(),
|
|
727
|
+
pid: process.pid,
|
|
728
|
+
startedAt: new Date().toISOString(),
|
|
729
|
+
},
|
|
730
|
+
null,
|
|
731
|
+
2,
|
|
732
|
+
)}\n`,
|
|
733
|
+
"utf8",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* One observation of a plugin build lock directory's state.
|
|
739
|
+
*
|
|
740
|
+
* - `active`: the lock exists and its owner is alive (or cannot be disproven:
|
|
741
|
+
* another host, no metadata but young). Keep waiting.
|
|
742
|
+
* - `abandoned`: the lock still exists and the evidence says nobody will ever
|
|
743
|
+
* release it — a same-host owner that is no longer running, or an old
|
|
744
|
+
* metadata-less legacy lock. Retiring its fenced generation is justified.
|
|
745
|
+
* - `released`: the observed generation no longer exists. In v2 the persistent
|
|
746
|
+
* coordination root remains while `current` is absent. This is a routine
|
|
747
|
+
* handoff, never an infinitely old abandoned lock (issue #421).
|
|
748
|
+
*
|
|
749
|
+
* Exported for unit tests.
|
|
750
|
+
*/
|
|
751
|
+
export type PluginBuildLockObservation =
|
|
752
|
+
| {
|
|
753
|
+
state: "active";
|
|
754
|
+
owner: string;
|
|
755
|
+
fence: PluginBuildLockFence;
|
|
756
|
+
}
|
|
757
|
+
| {
|
|
758
|
+
state: "abandoned";
|
|
759
|
+
reason: string;
|
|
760
|
+
fence: PluginBuildLockFence;
|
|
761
|
+
}
|
|
762
|
+
| { state: "released" };
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Classify the current state of a plugin build lock directory.
|
|
766
|
+
*
|
|
767
|
+
* Exported for unit tests.
|
|
768
|
+
*/
|
|
769
|
+
export function inspectPluginBuildLock(
|
|
770
|
+
lockDir: string,
|
|
771
|
+
now: number,
|
|
772
|
+
): PluginBuildLockObservation {
|
|
773
|
+
const protocolDir = pluginBuildLockProtocolDir(lockDir);
|
|
774
|
+
for (;;) {
|
|
775
|
+
if (isPluginBuildLockProtocolV2(protocolDir)) {
|
|
776
|
+
const v2 = inspectV2PluginBuildLock(protocolDir, now);
|
|
777
|
+
if (v2.state !== "released") {
|
|
778
|
+
return v2;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const legacy = captureLegacyPluginBuildLockFence(lockDir);
|
|
782
|
+
if (legacy !== null) {
|
|
783
|
+
return inspectLegacyPluginBuildLock(lockDir, now, legacy);
|
|
784
|
+
}
|
|
785
|
+
if (pluginBuildLockAgeMs(lockDir, now) === null) {
|
|
786
|
+
return { state: "released" };
|
|
787
|
+
}
|
|
788
|
+
// The path changed while its legacy fence was being captured. Re-observe
|
|
789
|
+
// the replacement rather than attaching the old state to a new owner.
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function inspectV2PluginBuildLock(
|
|
794
|
+
lockDir: string,
|
|
795
|
+
now: number,
|
|
796
|
+
): PluginBuildLockObservation {
|
|
797
|
+
const generationDir = path.join(lockDir, PLUGIN_BUILD_LOCK_CURRENT_DIR);
|
|
798
|
+
const generation = readPluginBuildLockGeneration(generationDir);
|
|
799
|
+
if (generation === null) {
|
|
800
|
+
if (pluginBuildLockAgeMs(generationDir, now) === null) {
|
|
801
|
+
return { state: "released" };
|
|
802
|
+
}
|
|
803
|
+
throw new Error(
|
|
804
|
+
`ttsc plugin build lock has no valid ${PLUGIN_BUILD_LOCK_GENERATION_FILE}: ${generationDir}`,
|
|
421
805
|
);
|
|
422
|
-
} catch {
|
|
423
|
-
// Best-effort metadata: the mkdir lock still serializes the build.
|
|
424
806
|
}
|
|
807
|
+
const fence: PluginBuildLockFence = { protocol: "v2", generation };
|
|
808
|
+
const owner = readPluginBuildLockOwner(generationDir);
|
|
809
|
+
if (owner !== null) {
|
|
810
|
+
const label = describePluginBuildLockOwner(owner);
|
|
811
|
+
if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
|
|
812
|
+
return {
|
|
813
|
+
state: "abandoned",
|
|
814
|
+
reason: `${label} is no longer running`,
|
|
815
|
+
fence,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
state: "active",
|
|
820
|
+
owner: label,
|
|
821
|
+
fence,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const ageMs = pluginBuildLockAgeMs(generationDir, now);
|
|
826
|
+
if (ageMs === null) {
|
|
827
|
+
return { state: "released" };
|
|
828
|
+
}
|
|
829
|
+
if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) {
|
|
830
|
+
return {
|
|
831
|
+
state: "abandoned",
|
|
832
|
+
reason:
|
|
833
|
+
`lock generation has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` +
|
|
834
|
+
`${formatDuration(ageMs)} old`,
|
|
835
|
+
fence,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
state: "active",
|
|
840
|
+
owner: `lock generation with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
|
|
841
|
+
fence,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
interface LegacyPluginBuildLockFence {
|
|
846
|
+
fence: PluginBuildLockFence;
|
|
847
|
+
legacyMtimeMs: number;
|
|
425
848
|
}
|
|
426
849
|
|
|
427
|
-
function
|
|
850
|
+
function inspectLegacyPluginBuildLock(
|
|
428
851
|
lockDir: string,
|
|
429
852
|
now: number,
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
owner: string;
|
|
433
|
-
reason?: string;
|
|
434
|
-
} {
|
|
853
|
+
legacy: LegacyPluginBuildLockFence,
|
|
854
|
+
): PluginBuildLockObservation {
|
|
435
855
|
const owner = readPluginBuildLockOwner(lockDir);
|
|
436
856
|
if (owner !== null) {
|
|
437
857
|
const label = describePluginBuildLockOwner(owner);
|
|
438
858
|
if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
|
|
439
859
|
return {
|
|
440
|
-
|
|
441
|
-
owner: label,
|
|
860
|
+
state: "abandoned",
|
|
442
861
|
reason: `${label} is no longer running`,
|
|
862
|
+
fence: legacy.fence,
|
|
443
863
|
};
|
|
444
864
|
}
|
|
445
865
|
return {
|
|
446
|
-
|
|
866
|
+
state: "active",
|
|
447
867
|
owner: label,
|
|
868
|
+
fence: legacy.fence,
|
|
448
869
|
};
|
|
449
870
|
}
|
|
450
871
|
|
|
451
|
-
const ageMs =
|
|
872
|
+
const ageMs = Math.max(0, now - legacy.legacyMtimeMs);
|
|
452
873
|
if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) {
|
|
453
874
|
return {
|
|
454
|
-
|
|
455
|
-
owner: `legacy lock with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
|
|
875
|
+
state: "abandoned",
|
|
456
876
|
reason:
|
|
457
877
|
`legacy lock has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` +
|
|
458
878
|
`${formatDuration(ageMs)} old`,
|
|
879
|
+
fence: legacy.fence,
|
|
459
880
|
};
|
|
460
881
|
}
|
|
461
882
|
return {
|
|
462
|
-
|
|
883
|
+
state: "active",
|
|
463
884
|
owner: `legacy lock with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
|
|
885
|
+
fence: legacy.fence,
|
|
464
886
|
};
|
|
465
887
|
}
|
|
466
888
|
|
|
889
|
+
function captureLegacyPluginBuildLockFence(
|
|
890
|
+
lockDir: string,
|
|
891
|
+
): LegacyPluginBuildLockFence | null {
|
|
892
|
+
if (isPluginBuildLockProtocolV2(lockDir)) {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
let legacyMtimeMs: number;
|
|
897
|
+
try {
|
|
898
|
+
legacyMtimeMs = fs.statSync(lockDir).mtimeMs;
|
|
899
|
+
} catch (error) {
|
|
900
|
+
if (isMissingPathError(error)) return null;
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const fenceDir = path.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR);
|
|
905
|
+
let captured = readLegacyPluginBuildLockFence(fenceDir);
|
|
906
|
+
if (captured === null) {
|
|
907
|
+
const generation = crypto.randomBytes(16).toString("hex");
|
|
908
|
+
// Keep candidates beside the legacy lock. Creating one inside `lockDir`
|
|
909
|
+
// would advance its mtime before a contender publishes the shared fence;
|
|
910
|
+
// a concurrent contender could then record an old lock as freshly created.
|
|
911
|
+
const candidateDir = `${lockDir}.legacy-candidate-${generation}`;
|
|
912
|
+
try {
|
|
913
|
+
fs.mkdirSync(candidateDir);
|
|
914
|
+
fs.writeFileSync(
|
|
915
|
+
path.join(candidateDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD),
|
|
916
|
+
`${JSON.stringify({ generation, legacyMtimeMs })}\n`,
|
|
917
|
+
"utf8",
|
|
918
|
+
);
|
|
919
|
+
try {
|
|
920
|
+
fs.renameSync(candidateDir, fenceDir);
|
|
921
|
+
captured = {
|
|
922
|
+
fence: { protocol: "legacy", generation },
|
|
923
|
+
legacyMtimeMs,
|
|
924
|
+
};
|
|
925
|
+
} catch (error) {
|
|
926
|
+
if (isRenameDestinationOccupied(error, fenceDir)) {
|
|
927
|
+
captured = readLegacyPluginBuildLockFence(fenceDir);
|
|
928
|
+
} else if (isMissingPathError(error)) {
|
|
929
|
+
return null;
|
|
930
|
+
} else {
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
} catch (error) {
|
|
935
|
+
if (isMissingPathError(error)) {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
throw error;
|
|
939
|
+
} finally {
|
|
940
|
+
fs.rmSync(candidateDir, { force: true, recursive: true });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (captured === null) {
|
|
944
|
+
throw new Error(`invalid legacy plugin build lock fence: ${fenceDir}`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// A stale observer can resume after the legacy holder released or another
|
|
948
|
+
// process retired the path. Confirm both the legacy layout and token after
|
|
949
|
+
// publication; v2 ownership is kept in the orthogonal sibling directory.
|
|
950
|
+
const confirmed = readLegacyPluginBuildLockFence(fenceDir);
|
|
951
|
+
if (
|
|
952
|
+
isPluginBuildLockProtocolV2(lockDir) ||
|
|
953
|
+
confirmed === null ||
|
|
954
|
+
confirmed.fence.generation !== captured.fence.generation
|
|
955
|
+
) {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
return captured;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function readLegacyPluginBuildLockFence(
|
|
962
|
+
fenceDir: string,
|
|
963
|
+
): LegacyPluginBuildLockFence | null {
|
|
964
|
+
try {
|
|
965
|
+
const parsed = JSON.parse(
|
|
966
|
+
fs.readFileSync(
|
|
967
|
+
path.join(fenceDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD),
|
|
968
|
+
"utf8",
|
|
969
|
+
),
|
|
970
|
+
) as Record<string, unknown>;
|
|
971
|
+
if (
|
|
972
|
+
!isPluginBuildLockGeneration(parsed.generation) ||
|
|
973
|
+
typeof parsed.legacyMtimeMs !== "number" ||
|
|
974
|
+
!Number.isFinite(parsed.legacyMtimeMs) ||
|
|
975
|
+
parsed.legacyMtimeMs < 0
|
|
976
|
+
) {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
fence: { protocol: "legacy", generation: parsed.generation },
|
|
981
|
+
legacyMtimeMs: parsed.legacyMtimeMs,
|
|
982
|
+
};
|
|
983
|
+
} catch {
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function readPluginBuildLockGeneration(generationDir: string): string | null {
|
|
989
|
+
try {
|
|
990
|
+
const generation = fs
|
|
991
|
+
.readFileSync(
|
|
992
|
+
path.join(generationDir, PLUGIN_BUILD_LOCK_GENERATION_FILE),
|
|
993
|
+
"utf8",
|
|
994
|
+
)
|
|
995
|
+
.trim();
|
|
996
|
+
return isPluginBuildLockGeneration(generation) ? generation : null;
|
|
997
|
+
} catch {
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function isPluginBuildLockGeneration(value: unknown): value is string {
|
|
1003
|
+
return typeof value === "string" && /^[0-9a-f]{32}$/.test(value);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
467
1006
|
function readPluginBuildLockOwner(
|
|
468
1007
|
lockDir: string,
|
|
469
1008
|
): { hostname: string; pid: number; startedAt?: string } | null {
|
|
@@ -490,11 +1029,33 @@ function readPluginBuildLockOwner(
|
|
|
490
1029
|
}
|
|
491
1030
|
}
|
|
492
1031
|
|
|
493
|
-
|
|
1032
|
+
/**
|
|
1033
|
+
* Age of an observed lock directory, or `null` when it no longer exists. The
|
|
1034
|
+
* holder may have retired it between the caller's checks. "Missing" is a
|
|
1035
|
+
* observation, never encoded as a numeric age: the previous
|
|
1036
|
+
* `Number.POSITIVE_INFINITY` encoding made a just-released lock look like an
|
|
1037
|
+
* infinitely old abandoned legacy lock (issue #421).
|
|
1038
|
+
*
|
|
1039
|
+
* A stat failure that does not prove absence (e.g. `EPERM`) clamps to age 0:
|
|
1040
|
+
* the lock is treated as fresh so a waiter never steals on ambiguous evidence,
|
|
1041
|
+
* while the caller's wait budget still bounds the stall.
|
|
1042
|
+
*/
|
|
1043
|
+
function pluginBuildLockAgeMs(lockDir: string, now: number): number | null {
|
|
494
1044
|
try {
|
|
495
1045
|
return Math.max(0, now - fs.statSync(lockDir).mtimeMs);
|
|
496
|
-
} catch {
|
|
497
|
-
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1048
|
+
return code === "ENOENT" || code === "ENOTDIR" ? null : 0;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function pluginBuildLockPathExists(lockDir: string): boolean {
|
|
1053
|
+
try {
|
|
1054
|
+
fs.statSync(lockDir);
|
|
1055
|
+
return true;
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
if (isMissingPathError(error)) return false;
|
|
1058
|
+
throw error;
|
|
498
1059
|
}
|
|
499
1060
|
}
|
|
500
1061
|
|
|
@@ -547,17 +1108,29 @@ function reportPluginLockSteal(
|
|
|
547
1108
|
pluginName: string;
|
|
548
1109
|
quiet: boolean;
|
|
549
1110
|
},
|
|
550
|
-
reason: string
|
|
1111
|
+
reason: string,
|
|
551
1112
|
): void {
|
|
552
1113
|
if (lockInfo.quiet) return;
|
|
553
|
-
const suffix = reason === undefined ? "" : ` (${reason})`;
|
|
554
1114
|
process.stderr.write(
|
|
555
1115
|
`ttsc: reclaiming abandoned ${lockInfo.label} "${lockInfo.pluginName}" ` +
|
|
556
|
-
`cache lock at ${lockDir}; binary=${binaryPath}${
|
|
1116
|
+
`cache lock at ${lockDir}; binary=${binaryPath} (${reason})\n`,
|
|
557
1117
|
);
|
|
558
1118
|
}
|
|
559
1119
|
|
|
560
|
-
|
|
1120
|
+
/**
|
|
1121
|
+
* Render a millisecond duration for lock diagnostics (`137ms`, `42s`, `9m 3s`).
|
|
1122
|
+
*
|
|
1123
|
+
* Total over every number: no caller produces a non-finite duration anymore
|
|
1124
|
+
* (the lock state machine reports "released" instead of an Infinity age), but
|
|
1125
|
+
* as defense in depth a non-finite input renders as `an unknown time` so no
|
|
1126
|
+
* public diagnostic can ever print `Infinitym NaNs` again (issue #421).
|
|
1127
|
+
*
|
|
1128
|
+
* Exported for unit tests.
|
|
1129
|
+
*/
|
|
1130
|
+
export function formatDuration(ms: number): string {
|
|
1131
|
+
if (!Number.isFinite(ms)) {
|
|
1132
|
+
return "an unknown time";
|
|
1133
|
+
}
|
|
561
1134
|
if (ms < 1_000) {
|
|
562
1135
|
return `${Math.max(0, Math.round(ms))}ms`;
|
|
563
1136
|
}
|
|
@@ -808,8 +1381,9 @@ function writeGoWork(
|
|
|
808
1381
|
useDirs: readonly string[],
|
|
809
1382
|
goBinary: string,
|
|
810
1383
|
pluginName: string,
|
|
1384
|
+
env: NodeJS.ProcessEnv,
|
|
811
1385
|
): void {
|
|
812
|
-
const goModReader = createGoModReader(goBinary, pluginName);
|
|
1386
|
+
const goModReader = createGoModReader(goBinary, pluginName, env);
|
|
813
1387
|
validateSourceReplacements(scratchDir, useDirs, goModReader, pluginName);
|
|
814
1388
|
const sourceInfo = goModReader.read(scratchDir);
|
|
815
1389
|
const effectiveUseDirs =
|
|
@@ -821,7 +1395,7 @@ function writeGoWork(
|
|
|
821
1395
|
: useDirs;
|
|
822
1396
|
const useLines = ["\t."];
|
|
823
1397
|
for (const dir of effectiveUseDirs) {
|
|
824
|
-
useLines.push(`\t${dir
|
|
1398
|
+
useLines.push(`\t${formatGoWorkPath(dir)}`);
|
|
825
1399
|
}
|
|
826
1400
|
const replaceLines = sourceBuildWorkspaceReplacements(
|
|
827
1401
|
effectiveUseDirs,
|
|
@@ -873,10 +1447,136 @@ function sourceBuildWorkspaceReplacements(
|
|
|
873
1447
|
return [];
|
|
874
1448
|
}
|
|
875
1449
|
return [
|
|
876
|
-
`replace ${TTSC_GO_MODULE_PATH} v0.0.0 => ${ttscRoot
|
|
1450
|
+
`replace ${TTSC_GO_MODULE_PATH} v0.0.0 => ${formatGoWorkPath(ttscRoot)}`,
|
|
877
1451
|
];
|
|
878
1452
|
}
|
|
879
1453
|
|
|
1454
|
+
/**
|
|
1455
|
+
* Format an absolute filesystem path as a single `go.work`/`go.mod` token.
|
|
1456
|
+
*
|
|
1457
|
+
* The modfile grammar shared by `go.mod` and `go.work` (parsed by
|
|
1458
|
+
* `golang.org/x/mod/modfile`) is whitespace-tokenized, so a `use`/`replace`
|
|
1459
|
+
* path that contains a space — a home or project directory such as `/Users/John
|
|
1460
|
+
* Smith/...` or `C:\Users\John Smith\...` — must be emitted as a quoted string
|
|
1461
|
+
* or `go` cannot parse the generated `go.work`. Normalize Windows separators to
|
|
1462
|
+
* `/` (the workspace convention) and then delegate to
|
|
1463
|
+
* {@link autoQuoteGoModToken}, which mirrors `modfile.AutoQuote`.
|
|
1464
|
+
*
|
|
1465
|
+
* Exported for unit tests.
|
|
1466
|
+
*/
|
|
1467
|
+
export function formatGoWorkPath(p: string): string {
|
|
1468
|
+
return autoQuoteGoModToken(p.replace(/\\/g, "/"));
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Quote `token` for a `go.mod`/`go.work` line exactly as
|
|
1473
|
+
* `golang.org/x/mod/modfile`'s `AutoQuote` does: return it unchanged when it is
|
|
1474
|
+
* already a clean bare token, otherwise return its Go double-quoted form so the
|
|
1475
|
+
* value round-trips through the modfile lexer. A space-free path is therefore
|
|
1476
|
+
* emitted byte-for-byte as before; only tokens that would otherwise
|
|
1477
|
+
* mis-tokenize are quoted.
|
|
1478
|
+
*
|
|
1479
|
+
* Exported for unit tests.
|
|
1480
|
+
*/
|
|
1481
|
+
export function autoQuoteGoModToken(token: string): string {
|
|
1482
|
+
return mustQuoteGoModToken(token) ? goQuoteString(token) : token;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// Mirror `modfile.MustQuote`: report whether `s` must be quoted to appear as a
|
|
1486
|
+
// single token on a modfile line.
|
|
1487
|
+
function mustQuoteGoModToken(s: string): boolean {
|
|
1488
|
+
for (const ch of s) {
|
|
1489
|
+
if (ch === " " || ch === '"' || ch === "'" || ch === "`") {
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
if (
|
|
1493
|
+
ch === "(" ||
|
|
1494
|
+
ch === ")" ||
|
|
1495
|
+
ch === "[" ||
|
|
1496
|
+
ch === "]" ||
|
|
1497
|
+
ch === "{" ||
|
|
1498
|
+
ch === "}" ||
|
|
1499
|
+
ch === ","
|
|
1500
|
+
) {
|
|
1501
|
+
// Go tests `len(s) > 1` (byte length): a lone bracket/comma is a legal
|
|
1502
|
+
// bare token, but one embedded in a longer token forces quoting.
|
|
1503
|
+
if (Buffer.byteLength(s, "utf8") > 1) {
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (!isGoGraphic(ch)) {
|
|
1509
|
+
return true;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return s === "" || s === "//" || s === "/*";
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// Mirror `strconv.Quote`: wrap in double quotes, backslash-escape `"` and `\`,
|
|
1516
|
+
// emit Go-printable runes verbatim (including the ASCII space and printable
|
|
1517
|
+
// Unicode), and escape everything else with Go's `\a\b\f\n\r\t\v` / `\xNN` /
|
|
1518
|
+
// `\uNNNN` / `\UNNNNNNNN` forms so the token round-trips through
|
|
1519
|
+
// `strconv.Unquote` in the modfile lexer.
|
|
1520
|
+
function goQuoteString(s: string): string {
|
|
1521
|
+
let out = '"';
|
|
1522
|
+
for (const ch of s) {
|
|
1523
|
+
if (ch === '"' || ch === "\\") {
|
|
1524
|
+
out += `\\${ch}`;
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (isGoPrintable(ch)) {
|
|
1528
|
+
out += ch;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
out += escapeGoRune(ch);
|
|
1532
|
+
}
|
|
1533
|
+
return `${out}"`;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function escapeGoRune(ch: string): string {
|
|
1537
|
+
switch (ch) {
|
|
1538
|
+
case "\x07":
|
|
1539
|
+
return "\\a";
|
|
1540
|
+
case "\b":
|
|
1541
|
+
return "\\b";
|
|
1542
|
+
case "\f":
|
|
1543
|
+
return "\\f";
|
|
1544
|
+
case "\n":
|
|
1545
|
+
return "\\n";
|
|
1546
|
+
case "\r":
|
|
1547
|
+
return "\\r";
|
|
1548
|
+
case "\t":
|
|
1549
|
+
return "\\t";
|
|
1550
|
+
case "\v":
|
|
1551
|
+
return "\\v";
|
|
1552
|
+
default: {
|
|
1553
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
1554
|
+
if (cp < 0x20 || cp === 0x7f) {
|
|
1555
|
+
return `\\x${cp.toString(16).padStart(2, "0")}`;
|
|
1556
|
+
}
|
|
1557
|
+
if (cp < 0x10000) {
|
|
1558
|
+
return `\\u${cp.toString(16).padStart(4, "0")}`;
|
|
1559
|
+
}
|
|
1560
|
+
return `\\U${cp.toString(16).padStart(8, "0")}`;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// `unicode.IsGraphic`: categories L, M, N, P, S, Zs (all spacing).
|
|
1566
|
+
const GO_GRAPHIC_RE = /^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]$/u;
|
|
1567
|
+
|
|
1568
|
+
function isGoGraphic(ch: string): boolean {
|
|
1569
|
+
return GO_GRAPHIC_RE.test(ch);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// `strconv.IsPrint`: the graphic categories except that the ONLY spacing
|
|
1573
|
+
// character is the ASCII space (U+0020); other Unicode spaces are escaped.
|
|
1574
|
+
const GO_PRINTABLE_RE = /^[\p{L}\p{M}\p{N}\p{P}\p{S}]$/u;
|
|
1575
|
+
|
|
1576
|
+
function isGoPrintable(ch: string): boolean {
|
|
1577
|
+
return ch === " " || GO_PRINTABLE_RE.test(ch);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
880
1580
|
interface GoModReplacement {
|
|
881
1581
|
readonly modulePath: string;
|
|
882
1582
|
}
|
|
@@ -910,7 +1610,11 @@ interface GoModJson {
|
|
|
910
1610
|
}[];
|
|
911
1611
|
}
|
|
912
1612
|
|
|
913
|
-
function createGoModReader(
|
|
1613
|
+
function createGoModReader(
|
|
1614
|
+
goBinary: string,
|
|
1615
|
+
pluginName: string,
|
|
1616
|
+
env: NodeJS.ProcessEnv,
|
|
1617
|
+
): GoModReader {
|
|
914
1618
|
const cache = new Map<string, GoModInfo>();
|
|
915
1619
|
return {
|
|
916
1620
|
read(dir) {
|
|
@@ -919,7 +1623,7 @@ function createGoModReader(goBinary: string, pluginName: string): GoModReader {
|
|
|
919
1623
|
if (cached !== undefined) {
|
|
920
1624
|
return cached;
|
|
921
1625
|
}
|
|
922
|
-
const info = readGoModInfo(resolved, goBinary, pluginName);
|
|
1626
|
+
const info = readGoModInfo(resolved, goBinary, pluginName, env);
|
|
923
1627
|
cache.set(resolved, info);
|
|
924
1628
|
return info;
|
|
925
1629
|
},
|
|
@@ -930,6 +1634,7 @@ function readGoModInfo(
|
|
|
930
1634
|
dir: string,
|
|
931
1635
|
goBinary: string,
|
|
932
1636
|
pluginName: string,
|
|
1637
|
+
env: NodeJS.ProcessEnv,
|
|
933
1638
|
): GoModInfo {
|
|
934
1639
|
if (!fs.existsSync(path.join(dir, "go.mod"))) {
|
|
935
1640
|
return emptyGoModInfo();
|
|
@@ -938,7 +1643,7 @@ function readGoModInfo(
|
|
|
938
1643
|
const result = spawnGoTool(goBinary, ["mod", "edit", "-json"], {
|
|
939
1644
|
cwd: dir,
|
|
940
1645
|
encoding: "utf8",
|
|
941
|
-
env: goBuildEnv(goBinary),
|
|
1646
|
+
env: goBuildEnv(goBinary, undefined, env),
|
|
942
1647
|
maxBuffer: 1024 * 1024 * 16,
|
|
943
1648
|
windowsHide: true,
|
|
944
1649
|
});
|
|
@@ -1023,12 +1728,13 @@ function runGoBuild(
|
|
|
1023
1728
|
pluginName: string,
|
|
1024
1729
|
goBinary: string,
|
|
1025
1730
|
goBuildCacheRoot: string,
|
|
1731
|
+
env: NodeJS.ProcessEnv,
|
|
1026
1732
|
): void {
|
|
1027
1733
|
ensureExecutableGoToolchain(goBinary);
|
|
1028
1734
|
const result = spawnGoTool(goBinary, ["build", "-o", binaryName, entry], {
|
|
1029
1735
|
cwd,
|
|
1030
1736
|
encoding: "utf8",
|
|
1031
|
-
env: goBuildEnv(goBinary, goBuildCacheRoot),
|
|
1737
|
+
env: goBuildEnv(goBinary, goBuildCacheRoot, env),
|
|
1032
1738
|
maxBuffer: 1024 * 1024 * 64,
|
|
1033
1739
|
windowsHide: true,
|
|
1034
1740
|
});
|
|
@@ -1078,8 +1784,9 @@ function shouldSpawnGoToolThroughShell(goBinary: string): boolean {
|
|
|
1078
1784
|
function goBuildEnv(
|
|
1079
1785
|
goBinary: string,
|
|
1080
1786
|
goBuildCacheRoot?: string,
|
|
1787
|
+
effectiveEnv: NodeJS.ProcessEnv = process.env,
|
|
1081
1788
|
): NodeJS.ProcessEnv {
|
|
1082
|
-
const env = { ...
|
|
1789
|
+
const env = { ...effectiveEnv };
|
|
1083
1790
|
env.GOWORK = "auto";
|
|
1084
1791
|
// Only the actual `go build` needs ttsc's GOCACHE; read-only metadata spawns
|
|
1085
1792
|
// (`go mod edit`, `go env`, `go version`) call goBuildEnv with no cache root
|
|
@@ -1188,9 +1895,10 @@ function walkForGoMod(dir: string, out: string[]): void {
|
|
|
1188
1895
|
export function resolvePluginCacheRoot(
|
|
1189
1896
|
projectRoot: string,
|
|
1190
1897
|
cacheDir?: string,
|
|
1898
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
1191
1899
|
): string {
|
|
1192
|
-
const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir);
|
|
1193
|
-
maybePrunePluginCache(paths, cacheDir);
|
|
1900
|
+
const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir, env);
|
|
1901
|
+
maybePrunePluginCache(paths, cacheDir, env);
|
|
1194
1902
|
return paths.pluginRoot;
|
|
1195
1903
|
}
|
|
1196
1904
|
|
|
@@ -1355,12 +2063,15 @@ function resolveGoBuildCacheRoot(
|
|
|
1355
2063
|
|
|
1356
2064
|
function maybePrunePluginCache(
|
|
1357
2065
|
paths: ITtscSourceBuildCachePaths,
|
|
1358
|
-
cacheDir
|
|
2066
|
+
cacheDir: string | undefined,
|
|
2067
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
1359
2068
|
): void {
|
|
1360
2069
|
// GC only the default (workspace-local) location. When the user pins an
|
|
1361
2070
|
// explicit `cacheDir`/`TTSC_CACHE_DIR`, they own its lifetime, so ttsc must
|
|
1362
|
-
// not delete entries out from under them.
|
|
1363
|
-
|
|
2071
|
+
// not delete entries out from under them. `env` is the effective instance
|
|
2072
|
+
// environment so a programmatic caller that pins `TTSC_CACHE_DIR` only in
|
|
2073
|
+
// `context.env` is honored without leaning on the shared `process.env`.
|
|
2074
|
+
if (!cacheDir && !env.TTSC_CACHE_DIR) {
|
|
1364
2075
|
prunePluginCacheRoot(paths.pluginRoot);
|
|
1365
2076
|
}
|
|
1366
2077
|
}
|
|
@@ -1444,8 +2155,8 @@ export function isPathWithin(child: string, parent: string): boolean {
|
|
|
1444
2155
|
);
|
|
1445
2156
|
}
|
|
1446
2157
|
|
|
1447
|
-
function resolveGoCompiler(): string {
|
|
1448
|
-
const explicit =
|
|
2158
|
+
function resolveGoCompiler(env: NodeJS.ProcessEnv = process.env): string {
|
|
2159
|
+
const explicit = env.TTSC_GO_BINARY;
|
|
1449
2160
|
if (explicit && explicit.length > 0) return explicit;
|
|
1450
2161
|
|
|
1451
2162
|
try {
|
|
@@ -1484,7 +2195,7 @@ function resolveGoCompiler(): string {
|
|
|
1484
2195
|
if (fs.existsSync(local)) return local;
|
|
1485
2196
|
|
|
1486
2197
|
const homeSdk = path.join(
|
|
1487
|
-
|
|
2198
|
+
env.HOME ?? "",
|
|
1488
2199
|
"go-sdk",
|
|
1489
2200
|
"go",
|
|
1490
2201
|
"bin",
|
|
@@ -1510,21 +2221,23 @@ export function computeCacheKey(inputs: {
|
|
|
1510
2221
|
contributors?: readonly ITtscBuildContributor[];
|
|
1511
2222
|
dir: string;
|
|
1512
2223
|
entry: string;
|
|
2224
|
+
env?: NodeJS.ProcessEnv;
|
|
1513
2225
|
goBinary?: string;
|
|
1514
2226
|
overlayDirs?: readonly string[];
|
|
1515
2227
|
ttscVersion: string;
|
|
1516
2228
|
tsgoVersion: string;
|
|
1517
2229
|
}): string {
|
|
2230
|
+
const env = inputs.env ?? process.env;
|
|
1518
2231
|
const hash = crypto.createHash("sha256");
|
|
1519
2232
|
hash.update(`ttsc=${inputs.ttscVersion}\n`);
|
|
1520
2233
|
hash.update(`tsgo=${inputs.tsgoVersion}\n`);
|
|
1521
2234
|
hash.update(`platform=${process.platform}/${process.arch}\n`);
|
|
1522
2235
|
hash.update(`entry=${inputs.entry}\n`);
|
|
1523
2236
|
if (inputs.goBinary !== undefined) {
|
|
1524
|
-
hash.update(`go=${resolveGoCompilerIdentity(inputs.goBinary)}\n`);
|
|
2237
|
+
hash.update(`go=${resolveGoCompilerIdentity(inputs.goBinary, env)}\n`);
|
|
1525
2238
|
}
|
|
1526
|
-
hashGoBuildEnvironment(hash, inputs.goBinary, inputs.dir);
|
|
1527
|
-
hashExternalGoBuildEnvironment(hash);
|
|
2239
|
+
hashGoBuildEnvironment(hash, inputs.goBinary, inputs.dir, env);
|
|
2240
|
+
hashExternalGoBuildEnvironment(hash, env);
|
|
1528
2241
|
hashSourceDirectory(hash, "plugin", inputs.dir);
|
|
1529
2242
|
for (const [index, dir] of [...(inputs.overlayDirs ?? [])].sort().entries()) {
|
|
1530
2243
|
hashSourceDirectory(hash, `overlay:${index}`, dir);
|
|
@@ -1619,8 +2332,11 @@ function isHashableFile(name: string): boolean {
|
|
|
1619
2332
|
// cwd/custom env (the spawn passes neither), so cwd is not part of the key.
|
|
1620
2333
|
const goCompilerIdentityCache = new Map<string, string>();
|
|
1621
2334
|
|
|
1622
|
-
function resolveGoCompilerIdentity(
|
|
1623
|
-
|
|
2335
|
+
function resolveGoCompilerIdentity(
|
|
2336
|
+
goBinary: string,
|
|
2337
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
2338
|
+
): string {
|
|
2339
|
+
const resolved = resolveExecutableIdentityPath(goBinary, env);
|
|
1624
2340
|
const memoKey = goCompilerIdentityMemoKey(goBinary, resolved);
|
|
1625
2341
|
if (memoKey !== null) {
|
|
1626
2342
|
const cached = goCompilerIdentityCache.get(memoKey);
|
|
@@ -1666,14 +2382,17 @@ function computeGoCompilerIdentity(goBinary: string, resolved: string): string {
|
|
|
1666
2382
|
return `sha256:${binaryHash}:${versionText}`;
|
|
1667
2383
|
}
|
|
1668
2384
|
|
|
1669
|
-
function resolveExecutableIdentityPath(
|
|
2385
|
+
function resolveExecutableIdentityPath(
|
|
2386
|
+
binary: string,
|
|
2387
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
2388
|
+
): string {
|
|
1670
2389
|
if (path.isAbsolute(binary)) {
|
|
1671
2390
|
return resolveRealPath(binary);
|
|
1672
2391
|
}
|
|
1673
2392
|
if (binary.includes(path.sep)) {
|
|
1674
2393
|
return resolveRealPath(path.resolve(binary));
|
|
1675
2394
|
}
|
|
1676
|
-
for (const dir of readPathEnvironment().split(path.delimiter)) {
|
|
2395
|
+
for (const dir of readPathEnvironment(env).split(path.delimiter)) {
|
|
1677
2396
|
if (dir.length === 0) continue;
|
|
1678
2397
|
const candidate = path.join(dir, binary);
|
|
1679
2398
|
if (fs.existsSync(candidate)) {
|
|
@@ -1684,7 +2403,7 @@ function resolveExecutableIdentityPath(binary: string): string {
|
|
|
1684
2403
|
// a `.cmd`/`.bat` wrapper resolves to its real file and is hashed into the
|
|
1685
2404
|
// cache key. Otherwise the wrapper reads as "missing" and a change to it
|
|
1686
2405
|
// would not invalidate the cached plugin binary.
|
|
1687
|
-
for (const ext of windowsExecutableExtensions()) {
|
|
2406
|
+
for (const ext of windowsExecutableExtensions(env)) {
|
|
1688
2407
|
const executable = `${candidate}${ext}`;
|
|
1689
2408
|
if (fs.existsSync(executable)) {
|
|
1690
2409
|
return resolveRealPath(executable);
|
|
@@ -1695,8 +2414,10 @@ function resolveExecutableIdentityPath(binary: string): string {
|
|
|
1695
2414
|
return binary;
|
|
1696
2415
|
}
|
|
1697
2416
|
|
|
1698
|
-
function windowsExecutableExtensions(
|
|
1699
|
-
|
|
2417
|
+
function windowsExecutableExtensions(
|
|
2418
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
2419
|
+
): readonly string[] {
|
|
2420
|
+
const pathext = env.PATHEXT;
|
|
1700
2421
|
const raw = pathext && pathext.length > 0 ? pathext : ".COM;.EXE;.BAT;.CMD";
|
|
1701
2422
|
return raw
|
|
1702
2423
|
.split(";")
|
|
@@ -1704,8 +2425,8 @@ function windowsExecutableExtensions(): readonly string[] {
|
|
|
1704
2425
|
.filter((ext) => ext.length > 0);
|
|
1705
2426
|
}
|
|
1706
2427
|
|
|
1707
|
-
function readPathEnvironment(): string {
|
|
1708
|
-
return
|
|
2428
|
+
function readPathEnvironment(env: NodeJS.ProcessEnv = process.env): string {
|
|
2429
|
+
return env.PATH ?? env.Path ?? "";
|
|
1709
2430
|
}
|
|
1710
2431
|
|
|
1711
2432
|
function resolveRealPath(location: string): string {
|
|
@@ -1726,8 +2447,9 @@ function hashGoBuildEnvironment(
|
|
|
1726
2447
|
hash: crypto.Hash,
|
|
1727
2448
|
goBinary: string | undefined,
|
|
1728
2449
|
cwd: string,
|
|
2450
|
+
env: NodeJS.ProcessEnv,
|
|
1729
2451
|
): void {
|
|
1730
|
-
const values = resolveGoBuildEnvironment(goBinary, cwd);
|
|
2452
|
+
const values = resolveGoBuildEnvironment(goBinary, cwd, env);
|
|
1731
2453
|
for (const key of GO_BUILD_ENV_KEYS) {
|
|
1732
2454
|
const value = values.get(key);
|
|
1733
2455
|
if (value !== undefined && value !== "") {
|
|
@@ -1739,6 +2461,7 @@ function hashGoBuildEnvironment(
|
|
|
1739
2461
|
function resolveGoBuildEnvironment(
|
|
1740
2462
|
goBinary: string | undefined,
|
|
1741
2463
|
cwd: string,
|
|
2464
|
+
env: NodeJS.ProcessEnv,
|
|
1742
2465
|
): Map<string, string> {
|
|
1743
2466
|
const values = new Map<string, string>();
|
|
1744
2467
|
if (goBinary !== undefined) {
|
|
@@ -1748,7 +2471,7 @@ function resolveGoBuildEnvironment(
|
|
|
1748
2471
|
{
|
|
1749
2472
|
cwd,
|
|
1750
2473
|
encoding: "utf8",
|
|
1751
|
-
env: goBuildEnv(goBinary),
|
|
2474
|
+
env: goBuildEnv(goBinary, undefined, env),
|
|
1752
2475
|
maxBuffer: 1024 * 1024,
|
|
1753
2476
|
windowsHide: true,
|
|
1754
2477
|
},
|
|
@@ -1759,41 +2482,48 @@ function resolveGoBuildEnvironment(
|
|
|
1759
2482
|
for (const key of GO_BUILD_ENV_KEYS) {
|
|
1760
2483
|
const raw = parsed[key];
|
|
1761
2484
|
if (typeof raw === "string" && raw !== "") {
|
|
1762
|
-
values.set(key, normalizeGoBuildEnvValue(key, raw));
|
|
2485
|
+
values.set(key, normalizeGoBuildEnvValue(key, raw, env));
|
|
1763
2486
|
}
|
|
1764
2487
|
}
|
|
1765
2488
|
} catch {
|
|
1766
|
-
// Fall back to
|
|
1767
|
-
// failing before `go build` can produce the actionable error.
|
|
2489
|
+
// Fall back to the effective env below; a cache key is still better
|
|
2490
|
+
// than failing before `go build` can produce the actionable error.
|
|
1768
2491
|
}
|
|
1769
2492
|
}
|
|
1770
2493
|
}
|
|
1771
2494
|
for (const key of GO_BUILD_ENV_KEYS) {
|
|
1772
2495
|
if (values.has(key)) continue;
|
|
1773
|
-
const value =
|
|
2496
|
+
const value = env[key];
|
|
1774
2497
|
if (value !== undefined && value !== "") {
|
|
1775
|
-
values.set(key, normalizeGoBuildEnvValue(key, value));
|
|
2498
|
+
values.set(key, normalizeGoBuildEnvValue(key, value, env));
|
|
1776
2499
|
}
|
|
1777
2500
|
}
|
|
1778
2501
|
return values;
|
|
1779
2502
|
}
|
|
1780
2503
|
|
|
1781
|
-
function normalizeGoBuildEnvValue(
|
|
2504
|
+
function normalizeGoBuildEnvValue(
|
|
2505
|
+
key: string,
|
|
2506
|
+
value: string,
|
|
2507
|
+
env: NodeJS.ProcessEnv,
|
|
2508
|
+
): string {
|
|
1782
2509
|
if (key === "GOROOT") {
|
|
1783
2510
|
return resolveGoRootCacheIdentity(value);
|
|
1784
2511
|
}
|
|
1785
2512
|
if (GO_BUILD_COMMAND_ENV_KEYS.has(key)) {
|
|
1786
|
-
return `${value}\0${resolveCommandCacheIdentity(value)}`;
|
|
2513
|
+
return `${value}\0${resolveCommandCacheIdentity(value, env)}`;
|
|
1787
2514
|
}
|
|
1788
2515
|
return value;
|
|
1789
2516
|
}
|
|
1790
2517
|
|
|
1791
|
-
function resolveCommandCacheIdentity(
|
|
2518
|
+
function resolveCommandCacheIdentity(
|
|
2519
|
+
command: string,
|
|
2520
|
+
env: NodeJS.ProcessEnv,
|
|
2521
|
+
): string {
|
|
1792
2522
|
const executable = firstCommandToken(command);
|
|
1793
2523
|
if (executable === null) {
|
|
1794
2524
|
return "command:empty";
|
|
1795
2525
|
}
|
|
1796
|
-
const resolved = resolveExecutableIdentityPath(executable);
|
|
2526
|
+
const resolved = resolveExecutableIdentityPath(executable, env);
|
|
1797
2527
|
if (!fs.existsSync(resolved)) {
|
|
1798
2528
|
return `command:missing:${executable}`;
|
|
1799
2529
|
}
|
|
@@ -1817,9 +2547,12 @@ function firstCommandToken(command: string): string | null {
|
|
|
1817
2547
|
return trimmed.split(/\s+/)[0] ?? null;
|
|
1818
2548
|
}
|
|
1819
2549
|
|
|
1820
|
-
function hashExternalGoBuildEnvironment(
|
|
2550
|
+
function hashExternalGoBuildEnvironment(
|
|
2551
|
+
hash: crypto.Hash,
|
|
2552
|
+
env: NodeJS.ProcessEnv,
|
|
2553
|
+
): void {
|
|
1821
2554
|
for (const key of EXTERNAL_GO_BUILD_ENV_KEYS) {
|
|
1822
|
-
const value =
|
|
2555
|
+
const value = env[key];
|
|
1823
2556
|
if (value !== undefined && value !== "") {
|
|
1824
2557
|
hash.update(`${key}=${value}\n`);
|
|
1825
2558
|
}
|
|
@@ -1977,7 +2710,11 @@ function collectPluginCacheEntries(
|
|
|
1977
2710
|
return entries;
|
|
1978
2711
|
}
|
|
1979
2712
|
for (const dirent of dirents) {
|
|
1980
|
-
if (
|
|
2713
|
+
if (
|
|
2714
|
+
!dirent.isDirectory() ||
|
|
2715
|
+
dirent.name.endsWith(".lock") ||
|
|
2716
|
+
dirent.name.includes(".lock.")
|
|
2717
|
+
) {
|
|
1981
2718
|
continue;
|
|
1982
2719
|
}
|
|
1983
2720
|
const dir = path.join(root, dirent.name);
|