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.
Files changed (173) hide show
  1. package/README.md +5 -6
  2. package/cmd/graphbench/main.go +1 -1
  3. package/cmd/platform/main.go +1 -48
  4. package/cmd/ttsc/build_branches_test.go +5 -1
  5. package/cmd/ttsc/main.go +0 -50
  6. package/cmd/ttsc/main_alias_branches_test.go +6 -12
  7. package/cmd/ttscgraph/dump.go +13 -3
  8. package/cmd/ttscgraph/main.go +18 -12
  9. package/cmd/ttscgraph/run_help_version_and_bad_flags_exit_codes_test.go +23 -5
  10. package/cmd/ttscgraph/serve.go +863 -0
  11. package/cmd/ttscgraph/serve_protocol_rejects_invalid_request_line_test.go +43 -0
  12. package/cmd/ttscgraph/serve_protocol_retries_session_creation_test.go +83 -0
  13. package/cmd/ttscgraph/serve_protocol_reuses_session_test.go +35 -0
  14. package/cmd/ttscgraph/serve_session_applies_source_edit_incrementally_test.go +55 -0
  15. package/cmd/ttscgraph/serve_session_fails_closed_on_deleted_config_test.go +68 -0
  16. package/cmd/ttscgraph/serve_session_fails_closed_on_invalid_config_test.go +50 -0
  17. package/cmd/ttscgraph/serve_session_keeps_missing_literal_root_unchanged_test.go +46 -0
  18. package/cmd/ttscgraph/serve_session_rebuilds_import_graph_change_test.go +60 -0
  19. package/cmd/ttscgraph/serve_session_reloads_created_literal_root_test.go +44 -0
  20. package/cmd/ttscgraph/serve_session_reloads_extended_config_change_test.go +45 -0
  21. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_conditional_export_test.go +60 -0
  22. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_config_types_test.go +44 -0
  23. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_excluded_import_test.go +62 -0
  24. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_package_export_test.go +46 -0
  25. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_package_import_test.go +46 -0
  26. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_path_alias_test.go +47 -0
  27. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_reference_path_test.go +42 -0
  28. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_root_dirs_target_test.go +46 -0
  29. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_type_reference_test.go +42 -0
  30. package/cmd/ttscgraph/serve_session_reloads_root_file_set_test.go +52 -0
  31. package/cmd/ttscgraph/serve_session_reloads_source_deleted_during_load_test.go +60 -0
  32. package/cmd/ttscgraph/serve_session_reloads_transitive_project_reference_test.go +55 -0
  33. package/cmd/ttscgraph/serve_session_reloads_when_program_plugin_linked_test.go +53 -0
  34. package/cmd/ttscgraph/serve_session_reuses_unchanged_preamble_source_test.go +45 -0
  35. package/cmd/ttscgraph/serve_session_reuses_unchanged_snapshot_test.go +52 -0
  36. package/cmd/ttscgraph/serve_session_revisits_source_edited_during_load_test.go +61 -0
  37. package/cmd/ttscgraph/serve_session_tolerates_os_invalid_resolution_candidate_test.go +56 -0
  38. package/cmd/ttscgraph/serve_session_tracks_all_module_specifier_forms_test.go +41 -0
  39. package/cmd/ttscgraph/serve_session_tracks_diamond_project_references_once_test.go +61 -0
  40. package/cmd/ttscserver/main.go +5 -3
  41. package/driver/lsp.go +13 -1
  42. package/driver/plugins.go +56 -5
  43. package/driver/program.go +25 -10
  44. package/driver/rewrite.go +381 -21
  45. package/driver/session.go +11 -3
  46. package/go.mod +1 -0
  47. package/internal/graph/build.go +297 -3
  48. package/internal/graph/build_records_a_node_per_top_level_declaration_test.go +16 -2
  49. package/internal/graph/diagnostics.go +5 -4
  50. package/internal/graph/dump.go +60 -4
  51. package/internal/graph/dump_is_a_function_of_the_source_test.go +71 -0
  52. package/internal/graph/dump_serializes_the_full_graph_test.go +16 -4
  53. package/internal/graph/edges.go +122 -11
  54. package/internal/graph/exports.go +74 -17
  55. package/internal/graph/graph.go +33 -1
  56. package/internal/graph/node_identity_is_position_invariant_test.go +4 -2
  57. package/internal/graph/resolve.go +24 -0
  58. package/internal/graphsymbols/provider.go +50 -14
  59. package/internal/lspserver/lsp_native_plugin_source.go +77 -23
  60. package/internal/lspserver/lsp_plugin.go +19 -2
  61. package/internal/lspserver/lsp_proxy.go +244 -52
  62. package/internal/lspserver/lsp_server.go +35 -27
  63. package/internal/lspserver/lsp_symbols.go +25 -16
  64. package/lib/TtscCompiler.d.ts +13 -2
  65. package/lib/TtscCompiler.js +30 -20
  66. package/lib/TtscCompiler.js.map +1 -1
  67. package/lib/TtscService.js +9 -7
  68. package/lib/TtscService.js.map +1 -1
  69. package/lib/compiler/internal/compileProjectInMemory.js +15 -1
  70. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  71. package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +3 -0
  72. package/lib/compiler/internal/project/createNativeProjectContextArgs.js +14 -0
  73. package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -0
  74. package/lib/compiler/internal/project/readProjectConfig.js +53 -4
  75. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  76. package/lib/compiler/internal/project/resolveProjectConfig.d.ts +9 -2
  77. package/lib/compiler/internal/project/resolveProjectConfig.js +45 -16
  78. package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
  79. package/lib/compiler/internal/residentTransformProcess.d.ts +16 -5
  80. package/lib/compiler/internal/residentTransformProcess.js +68 -20
  81. package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
  82. package/lib/compiler/internal/runBuild.d.ts +18 -0
  83. package/lib/compiler/internal/runBuild.js +220 -24
  84. package/lib/compiler/internal/runBuild.js.map +1 -1
  85. package/lib/compiler/internal/runSingleFileEmit.js +7 -4
  86. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  87. package/lib/compiler/internal/sharedHostHelpers.d.ts +16 -0
  88. package/lib/compiler/internal/sharedHostHelpers.js +23 -0
  89. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  90. package/lib/compiler/internal/startResidentTransform.js +18 -1
  91. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  92. package/lib/compiler/internal/transformProjectInMemory.js +34 -8
  93. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  94. package/lib/flags/parser.d.ts +17 -0
  95. package/lib/flags/parser.js +11 -1
  96. package/lib/flags/parser.js.map +1 -1
  97. package/lib/flags/schema.js +7 -0
  98. package/lib/flags/schema.js.map +1 -1
  99. package/lib/launcher/internal/prepareExecution.js +21 -2
  100. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  101. package/lib/launcher/internal/runTtsc.js +13 -53
  102. package/lib/launcher/internal/runTtsc.js.map +1 -1
  103. package/lib/launcher/internal/runTtscserver.js +11 -0
  104. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  105. package/lib/launcher/internal/runTtsx.js +27 -11
  106. package/lib/launcher/internal/runTtsx.js.map +1 -1
  107. package/lib/launcher/internal/runtimeHooks.d.ts +81 -0
  108. package/lib/launcher/internal/runtimeHooks.js +632 -43
  109. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  110. package/lib/launcher/internal/servedSourceMap.d.ts +25 -0
  111. package/lib/launcher/internal/servedSourceMap.js +164 -0
  112. package/lib/launcher/internal/servedSourceMap.js.map +1 -0
  113. package/lib/plugin/internal/buildSourcePlugin.d.ts +146 -2
  114. package/lib/plugin/internal/buildSourcePlugin.js +651 -113
  115. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  116. package/lib/plugin/internal/loadProjectPlugins.d.ts +7 -0
  117. package/lib/plugin/internal/loadProjectPlugins.js +33 -10
  118. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  119. package/lib/structures/ITtscCompilerContext.d.ts +16 -2
  120. package/lib/structures/ITtscCompilerResult.d.ts +8 -5
  121. package/lib/structures/ITtscPluginCapabilities.d.ts +8 -0
  122. package/lib/structures/ITtscPluginFactoryContext.d.ts +14 -0
  123. package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +3 -0
  124. package/lib/structures/internal/ITtscProjectIdentity.d.ts +17 -0
  125. package/lib/structures/internal/ITtscProjectIdentity.js +3 -0
  126. package/lib/structures/internal/ITtscProjectIdentity.js.map +1 -0
  127. package/lib/structures/internal/TtscCommonOptions.d.ts +8 -0
  128. package/package.json +11 -8
  129. package/shim/ast/lint.go +10 -0
  130. package/shim/ast/shim.go +44 -0
  131. package/shim/checker/enums_gen.go +19 -0
  132. package/shim/checker/shim.go +130 -0
  133. package/shim/diagnosticwriter/lint.go +30 -3
  134. package/shim/printer/shim.go +22 -4
  135. package/shim/scanner/regexp.go +27 -0
  136. package/src/TtscCompiler.ts +29 -22
  137. package/src/TtscService.ts +15 -7
  138. package/src/compiler/internal/compileProjectInMemory.ts +18 -1
  139. package/src/compiler/internal/project/createNativeProjectContextArgs.ts +15 -0
  140. package/src/compiler/internal/project/readProjectConfig.ts +61 -5
  141. package/src/compiler/internal/project/resolveProjectConfig.ts +51 -21
  142. package/src/compiler/internal/residentTransformProcess.ts +95 -21
  143. package/src/compiler/internal/runBuild.ts +313 -32
  144. package/src/compiler/internal/runSingleFileEmit.ts +7 -4
  145. package/src/compiler/internal/sharedHostHelpers.ts +24 -0
  146. package/src/compiler/internal/startResidentTransform.ts +21 -1
  147. package/src/compiler/internal/transformProjectInMemory.ts +49 -6
  148. package/src/flags/parser.ts +28 -1
  149. package/src/flags/schema.ts +8 -0
  150. package/src/launcher/internal/prepareExecution.ts +22 -2
  151. package/src/launcher/internal/runTtsc.ts +13 -61
  152. package/src/launcher/internal/runTtscserver.ts +14 -0
  153. package/src/launcher/internal/runTtsx.ts +28 -13
  154. package/src/launcher/internal/runtimeHooks.ts +771 -53
  155. package/src/launcher/internal/servedSourceMap.ts +183 -0
  156. package/src/plugin/internal/buildSourcePlugin.ts +859 -122
  157. package/src/plugin/internal/loadProjectPlugins.ts +36 -6
  158. package/src/structures/ITtscCompilerContext.ts +17 -2
  159. package/src/structures/ITtscCompilerResult.ts +8 -5
  160. package/src/structures/ITtscPluginCapabilities.ts +9 -0
  161. package/src/structures/ITtscPluginFactoryContext.ts +15 -0
  162. package/src/structures/internal/ITtscParsedProjectConfig.ts +3 -0
  163. package/src/structures/internal/ITtscProjectIdentity.ts +17 -0
  164. package/src/structures/internal/TtscCommonOptions.ts +8 -0
  165. package/tools/shim_audit/baseline.json +16 -7
  166. package/tools/shim_audit/main.go +777 -71
  167. package/tools/shim_audit/main_test.go +28 -1
  168. package/tools/shim_audit/producer_exemptions_require_rationale_test.go +42 -0
  169. package/tools/shim_audit/producer_surface_includes_exposed_methods_test.go +154 -0
  170. package/tools/shim_audit/producer_surface_rejects_unproducible_reference_test.go +142 -0
  171. package/utility/host.go +46 -20
  172. package/utility/serve.go +1 -1
  173. 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
- /** Build one Go source plugin into a cached executable. */
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(opts.goBinary, opts.pluginName);
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(scratchDir, opts.overlayDirs, opts.goBinary, opts.pluginName);
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
- * The lock is an atomic `mkdir` on `<cacheDir>.lock`. The winner builds and
293
- * publishes the binary; every loser polls for that binary to appear and reuses
294
- * it. A loser that waits past `PLUGIN_BUILD_LOCK_STEAL_MS` (the builder crashed
295
- * without publishing) steals the abandoned lock and retries. This is the same
296
- * shape as `withBuildLock` in the runtime hooks, keyed on the plugin binary's
297
- * existence instead of a JSON meta marker.
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
- * Correctness still rests on `publishBuiltBinary`'s atomic rename: the lock is
300
- * an optimisation to avoid duplicate work, not the only guard against a corrupt
301
- * binary, so a stolen lock that races a slow builder cannot ship a half-written
302
- * file.
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
- // Plain mkdir (no `recursive`): recursive mode is idempotent and would
322
- // not throw EEXIST, defeating the lock. The parent `cacheDir` already
323
- // exists, so a single-level mkdir is all that is needed and its EEXIST
324
- // is exactly the "someone else holds the lock" signal.
325
- fs.mkdirSync(lockDir);
326
- writePluginBuildLockOwner(lockDir);
327
- } catch (error) {
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
- reportPluginLockSteal(lockDir, binaryPath, lockInfo, waited.reason);
345
- // Builder appears to have crashed: steal the abandoned lock and retry.
346
- fs.rmSync(lockDir, { force: true, recursive: true });
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
- fs.rmSync(lockDir, { force: true, recursive: true });
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
- /** Poll for the locked builder to publish its binary, up to `timeoutMs`. */
363
- function waitForPluginBinary(opts: {
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
- }): { published: boolean; reason?: string } {
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 { published: true };
676
+ return { outcome: "published" };
378
677
  }
379
678
  const now = Date.now();
380
- const lockStatus = inspectPluginBuildLock(opts.lockDir, now);
381
- if (lockStatus.abandoned) {
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
- published: false,
384
- reason: lockStatus.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
- published: false,
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: lockStatus.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(lockDir: string): void {
408
- try {
409
- fs.writeFileSync(
410
- path.join(lockDir, PLUGIN_BUILD_LOCK_OWNER_FILE),
411
- `${JSON.stringify(
412
- {
413
- hostname: os.hostname(),
414
- pid: process.pid,
415
- startedAt: new Date().toISOString(),
416
- },
417
- null,
418
- 2,
419
- )}\n`,
420
- "utf8",
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 inspectPluginBuildLock(
850
+ function inspectLegacyPluginBuildLock(
428
851
  lockDir: string,
429
852
  now: number,
430
- ): {
431
- abandoned: boolean;
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
- abandoned: true,
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
- abandoned: false,
866
+ state: "active",
447
867
  owner: label,
868
+ fence: legacy.fence,
448
869
  };
449
870
  }
450
871
 
451
- const ageMs = pluginBuildLockAgeMs(lockDir, now);
872
+ const ageMs = Math.max(0, now - legacy.legacyMtimeMs);
452
873
  if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) {
453
874
  return {
454
- abandoned: true,
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
- abandoned: false,
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
- function pluginBuildLockAgeMs(lockDir: string, now: number): number {
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
- return Number.POSITIVE_INFINITY;
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 | undefined,
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}${suffix}\n`,
1116
+ `cache lock at ${lockDir}; binary=${binaryPath} (${reason})\n`,
557
1117
  );
558
1118
  }
559
1119
 
560
- function formatDuration(ms: number): string {
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.replace(/\\/g, "/")}`);
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.replace(/\\/g, "/")}`,
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(goBinary: string, pluginName: string): GoModReader {
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 = { ...process.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?: string,
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
- if (!cacheDir && !process.env.TTSC_CACHE_DIR) {
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 = process.env.TTSC_GO_BINARY;
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
- process.env.HOME ?? "",
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(goBinary: string): string {
1623
- const resolved = resolveExecutableIdentityPath(goBinary);
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(binary: string): string {
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(): readonly string[] {
1699
- const pathext = process.env.PATHEXT;
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 process.env.PATH ?? process.env.Path ?? "";
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 process.env below; a cache key is still better than
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 = process.env[key];
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(key: string, value: string): string {
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(command: string): string {
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(hash: crypto.Hash): void {
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 = process.env[key];
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 (!dirent.isDirectory()) {
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);