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
@@ -4,6 +4,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.buildSourcePlugin = buildSourcePlugin;
7
+ exports.acquirePluginBuildLock = acquirePluginBuildLock;
8
+ exports.releasePluginBuildLock = releasePluginBuildLock;
9
+ exports.reclaimPluginBuildLock = reclaimPluginBuildLock;
10
+ exports.waitForPluginBinary = waitForPluginBinary;
11
+ exports.inspectPluginBuildLock = inspectPluginBuildLock;
12
+ exports.formatDuration = formatDuration;
13
+ exports.formatGoWorkPath = formatGoWorkPath;
14
+ exports.autoQuoteGoModToken = autoQuoteGoModToken;
7
15
  exports.resolvePluginCacheRoot = resolvePluginCacheRoot;
8
16
  exports.resolveSourceBuildCachePaths = resolveSourceBuildCachePaths;
9
17
  exports.resolveCleanTargets = resolveCleanTargets;
@@ -111,6 +119,14 @@ const PLUGIN_BUILD_LOCK_POLL_MS = 50;
111
119
  const PLUGIN_BUILD_LOCK_LEGACY_STALE_MS = 30_000;
112
120
  const PLUGIN_BUILD_LOCK_STATUS_MS = 30_000;
113
121
  const PLUGIN_BUILD_LOCK_OWNER_FILE = "owner.json";
122
+ const PLUGIN_BUILD_LOCK_PROTOCOL_FILE = "protocol-v2";
123
+ const PLUGIN_BUILD_LOCK_GENERATION_FILE = "generation";
124
+ const PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR = "legacy-generation";
125
+ const PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD = "fence.json";
126
+ const PLUGIN_BUILD_LOCK_CURRENT_DIR = "current";
127
+ const PLUGIN_BUILD_LOCK_RETIRED_DIR = "retired";
128
+ const PLUGIN_BUILD_LOCK_V2_SUFFIX = ".v2";
129
+ const PLUGIN_BUILD_LOCK_PROTOCOL = "ttsc-plugin-build-lock-v2\n";
114
130
  // The default cache lives INSIDE the workspace, at
115
131
  // `<workspaceRoot>/node_modules/.cache/ttsc`, so `rm -rf node_modules` (or
116
132
  // deleting the repo) reclaims every compiled plugin binary and Go object file.
@@ -141,24 +157,35 @@ const PLUGIN_CACHE_ENTRY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
141
157
  const PLUGIN_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
142
158
  const PLUGIN_CACHE_TARGET_BYTES = Math.floor(PLUGIN_CACHE_MAX_BYTES * 0.8);
143
159
  const PLUGIN_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000;
144
- /** Build one Go source plugin into a cached executable. */
160
+ /**
161
+ * Build one Go source plugin into a cached executable.
162
+ *
163
+ * `opts.env` is the effective environment for this build — the caller merges `{
164
+ * ...process.env, ...context.env }` so a programmatic `TtscCompiler` instance
165
+ * can pin its own Go toolchain (`TTSC_GO_BINARY`), Go build cache
166
+ * (`TTSC_GO_CACHE_DIR`), and Go build variables (`GOFLAGS`, `CGO_*`, …) without
167
+ * mutating the shared `process.env`. CLI callers omit it and inherit
168
+ * `process.env`, so ambient behavior is unchanged.
169
+ */
145
170
  function buildSourcePlugin(opts) {
171
+ const env = opts.env ?? process.env;
146
172
  const { dir, entry, source } = resolveSourceBuildTarget(opts);
147
173
  const overlayDirs = [...(opts.overlayDirs ?? findTtscOverlayDirs())].sort();
148
174
  const contributors = opts.contributors ?? [];
149
- const goBinary = resolveGoCompiler();
175
+ const goBinary = resolveGoCompiler(env);
150
176
  ensureExecutableGoToolchain(goBinary);
151
177
  const key = computeCacheKey({
152
178
  contributors,
153
179
  dir,
154
180
  entry,
181
+ env,
155
182
  goBinary,
156
183
  overlayDirs,
157
184
  ttscVersion: opts.ttscVersion,
158
185
  tsgoVersion: opts.tsgoVersion,
159
186
  });
160
- const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir);
161
- maybePrunePluginCache(paths, opts.cacheDir);
187
+ const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir, env);
188
+ maybePrunePluginCache(paths, opts.cacheDir, env);
162
189
  const cacheDir = node_path_1.default.join(paths.pluginRoot, key);
163
190
  const binaryName = process.platform === "win32" ? "plugin.exe" : "plugin";
164
191
  const binaryPath = node_path_1.default.join(cacheDir, binaryName);
@@ -175,6 +202,7 @@ function buildSourcePlugin(opts) {
175
202
  contributors,
176
203
  dir,
177
204
  entry,
205
+ env,
178
206
  goBinary,
179
207
  key,
180
208
  label,
@@ -199,7 +227,7 @@ function compileSourcePlugin(opts) {
199
227
  const scratchDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), `ttsc-plugin-${opts.key}-`));
200
228
  try {
201
229
  materializeScratchDir(opts.dir, scratchDir);
202
- const goModReader = createGoModReader(opts.goBinary, opts.pluginName);
230
+ const goModReader = createGoModReader(opts.goBinary, opts.pluginName, opts.env);
203
231
  if (opts.contributors.length > 0) {
204
232
  mergeContributors({
205
233
  contributors: opts.contributors,
@@ -209,9 +237,9 @@ function compileSourcePlugin(opts) {
209
237
  scratchDir,
210
238
  });
211
239
  }
212
- writeGoWork(scratchDir, opts.overlayDirs, opts.goBinary, opts.pluginName);
240
+ writeGoWork(scratchDir, opts.overlayDirs, opts.goBinary, opts.pluginName, opts.env);
213
241
  const scratchBinaryName = process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin";
214
- runGoBuild(scratchDir, opts.entry, scratchBinaryName, opts.pluginName, opts.goBinary, opts.goBuildCacheRoot);
242
+ runGoBuild(scratchDir, opts.entry, scratchBinaryName, opts.pluginName, opts.goBinary, opts.goBuildCacheRoot, opts.env);
215
243
  const builtBinary = node_path_1.default.join(scratchDir, scratchBinaryName);
216
244
  publishBuiltBinary(builtBinary, opts.binaryPath);
217
245
  touchCacheEntry(opts.cacheDir);
@@ -226,17 +254,28 @@ function compileSourcePlugin(opts) {
226
254
  * cache key, so concurrent fan-out (parallel suites, a benchmark, a worker
227
255
  * pool) runs the `go build` once instead of once per process.
228
256
  *
229
- * The lock is an atomic `mkdir` on `<cacheDir>.lock`. The winner builds and
230
- * publishes the binary; every loser polls for that binary to appear and reuses
231
- * it. A loser that waits past `PLUGIN_BUILD_LOCK_STEAL_MS` (the builder crashed
232
- * without publishing) steals the abandoned lock and retries. This is the same
233
- * shape as `withBuildLock` in the runtime hooks, keyed on the plugin binary's
234
- * existence instead of a JSON meta marker.
257
+ * `<cacheDir>.lock.v2` is a persistent coordination directory. The adjacent
258
+ * `<cacheDir>.lock` path remains reserved for legacy holders and is never
259
+ * reused for a v2 generation: an old holder or stale legacy reclaimer can
260
+ * therefore remove only the legacy path, never a v2 successor. A contender
261
+ * writes a non-empty candidate and atomically renames it to `current`; only one
262
+ * rename wins. The winner builds and publishes while every loser polls and
263
+ * reuses the resulting binary. A loser distinguishes two ways a generation
264
+ * stops blocking:
265
+ *
266
+ * - `released`: the holder retired `current` itself — it published, or its build
267
+ * threw and its `finally` freed the key. The loser simply retries the
268
+ * ordinary acquisition; nothing is stale and nothing is reported.
269
+ * - `abandoned`: `current` still exists but its owner is provably dead, it is an
270
+ * old metadata-less legacy lock, or the wait budget
271
+ * (`PLUGIN_BUILD_LOCK_STEAL_MS`) expired. Only then does the loser report and
272
+ * retire precisely that generation before retrying.
235
273
  *
236
- * Correctness still rests on `publishBuiltBinary`'s atomic rename: the lock is
237
- * an optimisation to avoid duplicate work, not the only guard against a corrupt
238
- * binary, so a stolen lock that races a slow builder cannot ship a half-written
239
- * file.
274
+ * Retired generations remain as non-empty tombstones. Release and reclaim both
275
+ * rename `current` to the observed generation's deterministic tombstone path.
276
+ * Once generation A is retired, a stale observer or old finalizer for A cannot
277
+ * rename successor B there because replacing the non-empty tombstone fails
278
+ * atomically. `publishBuiltBinary`'s atomic rename remains defense in depth.
240
279
  */
241
280
  function buildUnderPluginLock(cacheDir, binaryPath, lockInfo, build) {
242
281
  const lockDir = `${cacheDir}.lock`;
@@ -245,34 +284,39 @@ function buildUnderPluginLock(cacheDir, binaryPath, lockInfo, build) {
245
284
  touchCacheEntry(cacheDir);
246
285
  return binaryPath;
247
286
  }
287
+ let lease;
248
288
  try {
249
- // Plain mkdir (no `recursive`): recursive mode is idempotent and would
250
- // not throw EEXIST, defeating the lock. The parent `cacheDir` already
251
- // exists, so a single-level mkdir is all that is needed and its EEXIST
252
- // is exactly the "someone else holds the lock" signal.
253
- node_fs_1.default.mkdirSync(lockDir);
254
- writePluginBuildLockOwner(lockDir);
289
+ lease = acquirePluginBuildLock(lockDir);
255
290
  }
256
- catch (error) {
257
- if (error.code !== "EEXIST") {
258
- // A lock dir we cannot create for an unexpected reason (e.g. a
259
- // read-only cache) must not silently skip the build. Fall back to an
260
- // unlocked build — correctness is preserved by the atomic publish.
261
- return build();
262
- }
291
+ catch {
292
+ // An unusable coordination directory must not silently skip the build.
293
+ // Atomic publication still preserves binary integrity.
294
+ return build();
295
+ }
296
+ if (lease === null) {
263
297
  const waited = waitForPluginBinary({
264
298
  binaryPath,
265
299
  lockDir,
266
300
  lockInfo,
267
301
  timeoutMs: PLUGIN_BUILD_LOCK_STEAL_MS,
268
302
  });
269
- if (waited.published) {
303
+ if (waited.outcome === "published") {
270
304
  touchCacheEntry(cacheDir);
271
305
  return binaryPath;
272
306
  }
273
- reportPluginLockSteal(lockDir, binaryPath, lockInfo, waited.reason);
274
- // Builder appears to have crashed: steal the abandoned lock and retry.
275
- node_fs_1.default.rmSync(lockDir, { force: true, recursive: true });
307
+ if (waited.outcome === "abandoned") {
308
+ // Retire only the generation that produced this observation. Losing
309
+ // the rename race means another waiter (or the holder's normal
310
+ // finalizer) already made progress, so do not report a stale result as
311
+ // an abandonment.
312
+ if (reclaimPluginBuildLock(lockDir, waited.fence)) {
313
+ reportPluginLockSteal(lockDir, binaryPath, lockInfo, waited.reason);
314
+ }
315
+ }
316
+ // "released" needs no repair: the holder freed the key normally (its
317
+ // build published or failed), so retry the ordinary atomic acquisition.
318
+ // Reporting a steal or force-removing the path here would misclassify a
319
+ // routine handoff as abandonment (issue #421).
276
320
  continue;
277
321
  }
278
322
  try {
@@ -284,30 +328,203 @@ function buildUnderPluginLock(cacheDir, binaryPath, lockInfo, build) {
284
328
  return build();
285
329
  }
286
330
  finally {
287
- node_fs_1.default.rmSync(lockDir, { force: true, recursive: true });
331
+ releasePluginBuildLock(lockDir, lease);
332
+ }
333
+ }
334
+ }
335
+ /**
336
+ * Atomically acquire the current generation in a v2 coordination directory.
337
+ *
338
+ * A non-empty candidate is renamed to `current`. Directory rename cannot
339
+ * replace a non-empty `current`, so exactly one contender wins without an
340
+ * empty-owner publication window. `null` means either another v2 holder won or
341
+ * the path is a legacy lock that must be observed before it can be reclaimed.
342
+ *
343
+ * Exported for deterministic multi-process tests.
344
+ */
345
+ function acquirePluginBuildLock(lockDir) {
346
+ // A legacy holder owns the old path. Never publish v2 ownership into that
347
+ // deletable namespace; wait until the legacy generation is released or
348
+ // reclaimed, then use the orthogonal persistent v2 directory.
349
+ if (pluginBuildLockPathExists(lockDir)) {
350
+ return null;
351
+ }
352
+ const protocolDir = pluginBuildLockProtocolDir(lockDir);
353
+ ensurePluginBuildLockProtocol(protocolDir);
354
+ // Close the initialization window as far as the legacy protocol permits. A
355
+ // legacy holder that appeared while v2 was initialized still blocks this
356
+ // acquisition. (A legacy executable cannot provide a true cross-path CAS.)
357
+ if (pluginBuildLockPathExists(lockDir)) {
358
+ return null;
359
+ }
360
+ const generation = node_crypto_1.default.randomBytes(16).toString("hex");
361
+ const candidateDir = node_path_1.default.join(protocolDir, `candidate-${generation}`);
362
+ node_fs_1.default.mkdirSync(candidateDir);
363
+ try {
364
+ node_fs_1.default.writeFileSync(node_path_1.default.join(candidateDir, PLUGIN_BUILD_LOCK_GENERATION_FILE), `${generation}\n`, { encoding: "utf8", flag: "wx" });
365
+ writePluginBuildLockOwner(candidateDir, generation);
366
+ try {
367
+ node_fs_1.default.renameSync(candidateDir, node_path_1.default.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR));
368
+ }
369
+ catch (error) {
370
+ if (isMissingPathError(error) ||
371
+ isRenameDestinationOccupied(error, node_path_1.default.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR))) {
372
+ return null;
373
+ }
374
+ throw error;
375
+ }
376
+ return { protocol: "v2", generation };
377
+ }
378
+ finally {
379
+ // The candidate name contains this process's random generation and can
380
+ // never alias `current` or another contender's candidate.
381
+ node_fs_1.default.rmSync(candidateDir, { force: true, recursive: true });
382
+ }
383
+ }
384
+ /** Retire a held generation during the holder's `finally`. */
385
+ function releasePluginBuildLock(lockDir, lease) {
386
+ return retireV2PluginBuildLock(pluginBuildLockProtocolDir(lockDir), lease.generation);
387
+ }
388
+ /**
389
+ * Retire exactly the generation carried by an abandoned observation.
390
+ *
391
+ * Exported for deterministic multi-process tests.
392
+ */
393
+ function reclaimPluginBuildLock(lockDir, fence) {
394
+ if (fence.protocol === "v2") {
395
+ return retireV2PluginBuildLock(pluginBuildLockProtocolDir(lockDir), fence.generation);
396
+ }
397
+ return retireLegacyPluginBuildLock(lockDir, fence.generation);
398
+ }
399
+ function pluginBuildLockProtocolDir(lockDir) {
400
+ return `${lockDir}${PLUGIN_BUILD_LOCK_V2_SUFFIX}`;
401
+ }
402
+ function ensurePluginBuildLockProtocol(protocolDir) {
403
+ if (isPluginBuildLockProtocolV2(protocolDir)) {
404
+ return;
405
+ }
406
+ const generation = node_crypto_1.default.randomBytes(16).toString("hex");
407
+ const candidateDir = `${protocolDir}.candidate-${generation}`;
408
+ node_fs_1.default.mkdirSync(candidateDir);
409
+ try {
410
+ node_fs_1.default.mkdirSync(node_path_1.default.join(candidateDir, PLUGIN_BUILD_LOCK_RETIRED_DIR));
411
+ node_fs_1.default.writeFileSync(node_path_1.default.join(candidateDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE), PLUGIN_BUILD_LOCK_PROTOCOL, { encoding: "utf8", flag: "wx" });
412
+ try {
413
+ node_fs_1.default.renameSync(candidateDir, protocolDir);
414
+ }
415
+ catch (error) {
416
+ if (isRenameDestinationOccupied(error, protocolDir) &&
417
+ isPluginBuildLockProtocolV2(protocolDir)) {
418
+ return;
419
+ }
420
+ throw error;
421
+ }
422
+ }
423
+ finally {
424
+ node_fs_1.default.rmSync(candidateDir, { force: true, recursive: true });
425
+ }
426
+ }
427
+ function isPluginBuildLockProtocolV2(lockDir) {
428
+ try {
429
+ return (node_fs_1.default.readFileSync(node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE), "utf8") === PLUGIN_BUILD_LOCK_PROTOCOL);
430
+ }
431
+ catch {
432
+ return false;
433
+ }
434
+ }
435
+ function retireV2PluginBuildLock(lockDir, generation) {
436
+ if (!isPluginBuildLockGeneration(generation))
437
+ return false;
438
+ const retiredDir = node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_RETIRED_DIR);
439
+ try {
440
+ node_fs_1.default.mkdirSync(retiredDir);
441
+ }
442
+ catch (error) {
443
+ if (error.code !== "EEXIST") {
444
+ if (isMissingPathError(error))
445
+ return false;
446
+ throw error;
288
447
  }
289
448
  }
449
+ const destination = node_path_1.default.join(retiredDir, generation);
450
+ try {
451
+ node_fs_1.default.renameSync(node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_CURRENT_DIR), destination);
452
+ return true;
453
+ }
454
+ catch (error) {
455
+ if (isMissingPathError(error) ||
456
+ isRenameDestinationOccupied(error, destination)) {
457
+ return false;
458
+ }
459
+ throw error;
460
+ }
461
+ }
462
+ function retireLegacyPluginBuildLock(lockDir, generation) {
463
+ if (!isPluginBuildLockGeneration(generation))
464
+ return false;
465
+ const captured = readLegacyPluginBuildLockFence(node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR));
466
+ if (captured?.fence.generation !== generation) {
467
+ return false;
468
+ }
469
+ const destination = `${lockDir}.retired-${generation}`;
470
+ try {
471
+ node_fs_1.default.renameSync(lockDir, destination);
472
+ return true;
473
+ }
474
+ catch (error) {
475
+ if (isMissingPathError(error) ||
476
+ isRenameDestinationOccupied(error, destination)) {
477
+ return false;
478
+ }
479
+ throw error;
480
+ }
481
+ }
482
+ function isMissingPathError(error) {
483
+ const code = error.code;
484
+ return code === "ENOENT" || code === "ENOTDIR";
290
485
  }
291
- /** Poll for the locked builder to publish its binary, up to `timeoutMs`. */
486
+ function isRenameDestinationOccupied(error, destination) {
487
+ const code = error.code;
488
+ if (code === "EEXIST" || code === "ENOTEMPTY") {
489
+ return true;
490
+ }
491
+ return (code === "EACCES" || code === "EPERM") && node_fs_1.default.existsSync(destination);
492
+ }
493
+ /**
494
+ * Poll for the locked builder to publish its binary, up to `timeoutMs`.
495
+ *
496
+ * Exported for unit tests.
497
+ */
292
498
  function waitForPluginBinary(opts) {
293
499
  const startedAt = Date.now();
294
500
  let nextStatusAt = startedAt + PLUGIN_BUILD_LOCK_STATUS_MS;
295
501
  for (;;) {
296
502
  if (node_fs_1.default.existsSync(opts.binaryPath)) {
297
- return { published: true };
503
+ return { outcome: "published" };
298
504
  }
299
505
  const now = Date.now();
300
- const lockStatus = inspectPluginBuildLock(opts.lockDir, now);
301
- if (lockStatus.abandoned) {
506
+ const lock = inspectPluginBuildLock(opts.lockDir, now);
507
+ if (lock.state === "released") {
508
+ // The holder retired its generation between the binary check above and
509
+ // this observation. That is a normal release, not abandonment: prefer the
510
+ // binary when it landed inside that window, otherwise hand the free key
511
+ // back to the caller.
512
+ return node_fs_1.default.existsSync(opts.binaryPath)
513
+ ? { outcome: "published" }
514
+ : { outcome: "released" };
515
+ }
516
+ if (lock.state === "abandoned") {
302
517
  return {
303
- published: false,
304
- reason: lockStatus.reason,
518
+ outcome: "abandoned",
519
+ reason: lock.reason,
520
+ fence: lock.fence,
305
521
  };
306
522
  }
307
523
  if (now - startedAt > opts.timeoutMs) {
308
524
  return {
309
- published: false,
525
+ outcome: "abandoned",
310
526
  reason: `timed out after ${formatDuration(now - startedAt)}`,
527
+ fence: lock.fence,
311
528
  };
312
529
  }
313
530
  if (!opts.lockInfo.quiet && now >= nextStatusAt) {
@@ -316,55 +533,221 @@ function waitForPluginBinary(opts) {
316
533
  elapsedMs: now - startedAt,
317
534
  lockDir: opts.lockDir,
318
535
  lockInfo: opts.lockInfo,
319
- owner: lockStatus.owner,
536
+ owner: lock.owner,
320
537
  });
321
538
  nextStatusAt = now + PLUGIN_BUILD_LOCK_STATUS_MS;
322
539
  }
323
540
  sleepSync(PLUGIN_BUILD_LOCK_POLL_MS);
324
541
  }
325
542
  }
326
- function writePluginBuildLockOwner(lockDir) {
327
- try {
328
- node_fs_1.default.writeFileSync(node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_OWNER_FILE), `${JSON.stringify({
329
- hostname: node_os_1.default.hostname(),
330
- pid: process.pid,
331
- startedAt: new Date().toISOString(),
332
- }, null, 2)}\n`, "utf8");
543
+ function writePluginBuildLockOwner(generationDir, generation) {
544
+ node_fs_1.default.writeFileSync(node_path_1.default.join(generationDir, PLUGIN_BUILD_LOCK_OWNER_FILE), `${JSON.stringify({
545
+ generation,
546
+ hostname: node_os_1.default.hostname(),
547
+ pid: process.pid,
548
+ startedAt: new Date().toISOString(),
549
+ }, null, 2)}\n`, "utf8");
550
+ }
551
+ /**
552
+ * Classify the current state of a plugin build lock directory.
553
+ *
554
+ * Exported for unit tests.
555
+ */
556
+ function inspectPluginBuildLock(lockDir, now) {
557
+ const protocolDir = pluginBuildLockProtocolDir(lockDir);
558
+ for (;;) {
559
+ if (isPluginBuildLockProtocolV2(protocolDir)) {
560
+ const v2 = inspectV2PluginBuildLock(protocolDir, now);
561
+ if (v2.state !== "released") {
562
+ return v2;
563
+ }
564
+ }
565
+ const legacy = captureLegacyPluginBuildLockFence(lockDir);
566
+ if (legacy !== null) {
567
+ return inspectLegacyPluginBuildLock(lockDir, now, legacy);
568
+ }
569
+ if (pluginBuildLockAgeMs(lockDir, now) === null) {
570
+ return { state: "released" };
571
+ }
572
+ // The path changed while its legacy fence was being captured. Re-observe
573
+ // the replacement rather than attaching the old state to a new owner.
333
574
  }
334
- catch {
335
- // Best-effort metadata: the mkdir lock still serializes the build.
575
+ }
576
+ function inspectV2PluginBuildLock(lockDir, now) {
577
+ const generationDir = node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_CURRENT_DIR);
578
+ const generation = readPluginBuildLockGeneration(generationDir);
579
+ if (generation === null) {
580
+ if (pluginBuildLockAgeMs(generationDir, now) === null) {
581
+ return { state: "released" };
582
+ }
583
+ throw new Error(`ttsc plugin build lock has no valid ${PLUGIN_BUILD_LOCK_GENERATION_FILE}: ${generationDir}`);
336
584
  }
585
+ const fence = { protocol: "v2", generation };
586
+ const owner = readPluginBuildLockOwner(generationDir);
587
+ if (owner !== null) {
588
+ const label = describePluginBuildLockOwner(owner);
589
+ if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
590
+ return {
591
+ state: "abandoned",
592
+ reason: `${label} is no longer running`,
593
+ fence,
594
+ };
595
+ }
596
+ return {
597
+ state: "active",
598
+ owner: label,
599
+ fence,
600
+ };
601
+ }
602
+ const ageMs = pluginBuildLockAgeMs(generationDir, now);
603
+ if (ageMs === null) {
604
+ return { state: "released" };
605
+ }
606
+ if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) {
607
+ return {
608
+ state: "abandoned",
609
+ reason: `lock generation has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` +
610
+ `${formatDuration(ageMs)} old`,
611
+ fence,
612
+ };
613
+ }
614
+ return {
615
+ state: "active",
616
+ owner: `lock generation with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
617
+ fence,
618
+ };
337
619
  }
338
- function inspectPluginBuildLock(lockDir, now) {
620
+ function inspectLegacyPluginBuildLock(lockDir, now, legacy) {
339
621
  const owner = readPluginBuildLockOwner(lockDir);
340
622
  if (owner !== null) {
341
623
  const label = describePluginBuildLockOwner(owner);
342
624
  if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
343
625
  return {
344
- abandoned: true,
345
- owner: label,
626
+ state: "abandoned",
346
627
  reason: `${label} is no longer running`,
628
+ fence: legacy.fence,
347
629
  };
348
630
  }
349
631
  return {
350
- abandoned: false,
632
+ state: "active",
351
633
  owner: label,
634
+ fence: legacy.fence,
352
635
  };
353
636
  }
354
- const ageMs = pluginBuildLockAgeMs(lockDir, now);
637
+ const ageMs = Math.max(0, now - legacy.legacyMtimeMs);
355
638
  if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) {
356
639
  return {
357
- abandoned: true,
358
- owner: `legacy lock with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
640
+ state: "abandoned",
359
641
  reason: `legacy lock has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` +
360
642
  `${formatDuration(ageMs)} old`,
643
+ fence: legacy.fence,
361
644
  };
362
645
  }
363
646
  return {
364
- abandoned: false,
647
+ state: "active",
365
648
  owner: `legacy lock with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
649
+ fence: legacy.fence,
366
650
  };
367
651
  }
652
+ function captureLegacyPluginBuildLockFence(lockDir) {
653
+ if (isPluginBuildLockProtocolV2(lockDir)) {
654
+ return null;
655
+ }
656
+ let legacyMtimeMs;
657
+ try {
658
+ legacyMtimeMs = node_fs_1.default.statSync(lockDir).mtimeMs;
659
+ }
660
+ catch (error) {
661
+ if (isMissingPathError(error))
662
+ return null;
663
+ throw error;
664
+ }
665
+ const fenceDir = node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR);
666
+ let captured = readLegacyPluginBuildLockFence(fenceDir);
667
+ if (captured === null) {
668
+ const generation = node_crypto_1.default.randomBytes(16).toString("hex");
669
+ // Keep candidates beside the legacy lock. Creating one inside `lockDir`
670
+ // would advance its mtime before a contender publishes the shared fence;
671
+ // a concurrent contender could then record an old lock as freshly created.
672
+ const candidateDir = `${lockDir}.legacy-candidate-${generation}`;
673
+ try {
674
+ node_fs_1.default.mkdirSync(candidateDir);
675
+ node_fs_1.default.writeFileSync(node_path_1.default.join(candidateDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD), `${JSON.stringify({ generation, legacyMtimeMs })}\n`, "utf8");
676
+ try {
677
+ node_fs_1.default.renameSync(candidateDir, fenceDir);
678
+ captured = {
679
+ fence: { protocol: "legacy", generation },
680
+ legacyMtimeMs,
681
+ };
682
+ }
683
+ catch (error) {
684
+ if (isRenameDestinationOccupied(error, fenceDir)) {
685
+ captured = readLegacyPluginBuildLockFence(fenceDir);
686
+ }
687
+ else if (isMissingPathError(error)) {
688
+ return null;
689
+ }
690
+ else {
691
+ throw error;
692
+ }
693
+ }
694
+ }
695
+ catch (error) {
696
+ if (isMissingPathError(error)) {
697
+ return null;
698
+ }
699
+ throw error;
700
+ }
701
+ finally {
702
+ node_fs_1.default.rmSync(candidateDir, { force: true, recursive: true });
703
+ }
704
+ }
705
+ if (captured === null) {
706
+ throw new Error(`invalid legacy plugin build lock fence: ${fenceDir}`);
707
+ }
708
+ // A stale observer can resume after the legacy holder released or another
709
+ // process retired the path. Confirm both the legacy layout and token after
710
+ // publication; v2 ownership is kept in the orthogonal sibling directory.
711
+ const confirmed = readLegacyPluginBuildLockFence(fenceDir);
712
+ if (isPluginBuildLockProtocolV2(lockDir) ||
713
+ confirmed === null ||
714
+ confirmed.fence.generation !== captured.fence.generation) {
715
+ return null;
716
+ }
717
+ return captured;
718
+ }
719
+ function readLegacyPluginBuildLockFence(fenceDir) {
720
+ try {
721
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(fenceDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD), "utf8"));
722
+ if (!isPluginBuildLockGeneration(parsed.generation) ||
723
+ typeof parsed.legacyMtimeMs !== "number" ||
724
+ !Number.isFinite(parsed.legacyMtimeMs) ||
725
+ parsed.legacyMtimeMs < 0) {
726
+ return null;
727
+ }
728
+ return {
729
+ fence: { protocol: "legacy", generation: parsed.generation },
730
+ legacyMtimeMs: parsed.legacyMtimeMs,
731
+ };
732
+ }
733
+ catch {
734
+ return null;
735
+ }
736
+ }
737
+ function readPluginBuildLockGeneration(generationDir) {
738
+ try {
739
+ const generation = node_fs_1.default
740
+ .readFileSync(node_path_1.default.join(generationDir, PLUGIN_BUILD_LOCK_GENERATION_FILE), "utf8")
741
+ .trim();
742
+ return isPluginBuildLockGeneration(generation) ? generation : null;
743
+ }
744
+ catch {
745
+ return null;
746
+ }
747
+ }
748
+ function isPluginBuildLockGeneration(value) {
749
+ return typeof value === "string" && /^[0-9a-f]{32}$/.test(value);
750
+ }
368
751
  function readPluginBuildLockOwner(lockDir) {
369
752
  try {
370
753
  const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_OWNER_FILE), "utf8"));
@@ -384,12 +767,35 @@ function readPluginBuildLockOwner(lockDir) {
384
767
  return null;
385
768
  }
386
769
  }
770
+ /**
771
+ * Age of an observed lock directory, or `null` when it no longer exists. The
772
+ * holder may have retired it between the caller's checks. "Missing" is a
773
+ * observation, never encoded as a numeric age: the previous
774
+ * `Number.POSITIVE_INFINITY` encoding made a just-released lock look like an
775
+ * infinitely old abandoned legacy lock (issue #421).
776
+ *
777
+ * A stat failure that does not prove absence (e.g. `EPERM`) clamps to age 0:
778
+ * the lock is treated as fresh so a waiter never steals on ambiguous evidence,
779
+ * while the caller's wait budget still bounds the stall.
780
+ */
387
781
  function pluginBuildLockAgeMs(lockDir, now) {
388
782
  try {
389
783
  return Math.max(0, now - node_fs_1.default.statSync(lockDir).mtimeMs);
390
784
  }
391
- catch {
392
- return Number.POSITIVE_INFINITY;
785
+ catch (error) {
786
+ const code = error.code;
787
+ return code === "ENOENT" || code === "ENOTDIR" ? null : 0;
788
+ }
789
+ }
790
+ function pluginBuildLockPathExists(lockDir) {
791
+ try {
792
+ node_fs_1.default.statSync(lockDir);
793
+ return true;
794
+ }
795
+ catch (error) {
796
+ if (isMissingPathError(error))
797
+ return false;
798
+ throw error;
393
799
  }
394
800
  }
395
801
  function isLocalHostName(hostname) {
@@ -416,11 +822,23 @@ function reportPluginLockWait(opts) {
416
822
  function reportPluginLockSteal(lockDir, binaryPath, lockInfo, reason) {
417
823
  if (lockInfo.quiet)
418
824
  return;
419
- const suffix = reason === undefined ? "" : ` (${reason})`;
420
825
  process.stderr.write(`ttsc: reclaiming abandoned ${lockInfo.label} "${lockInfo.pluginName}" ` +
421
- `cache lock at ${lockDir}; binary=${binaryPath}${suffix}\n`);
826
+ `cache lock at ${lockDir}; binary=${binaryPath} (${reason})\n`);
422
827
  }
828
+ /**
829
+ * Render a millisecond duration for lock diagnostics (`137ms`, `42s`, `9m 3s`).
830
+ *
831
+ * Total over every number: no caller produces a non-finite duration anymore
832
+ * (the lock state machine reports "released" instead of an Infinity age), but
833
+ * as defense in depth a non-finite input renders as `an unknown time` so no
834
+ * public diagnostic can ever print `Infinitym NaNs` again (issue #421).
835
+ *
836
+ * Exported for unit tests.
837
+ */
423
838
  function formatDuration(ms) {
839
+ if (!Number.isFinite(ms)) {
840
+ return "an unknown time";
841
+ }
424
842
  if (ms < 1_000) {
425
843
  return `${Math.max(0, Math.round(ms))}ms`;
426
844
  }
@@ -628,8 +1046,8 @@ function materializeScratchDir(source, scratch) {
628
1046
  },
629
1047
  });
630
1048
  }
631
- function writeGoWork(scratchDir, useDirs, goBinary, pluginName) {
632
- const goModReader = createGoModReader(goBinary, pluginName);
1049
+ function writeGoWork(scratchDir, useDirs, goBinary, pluginName, env) {
1050
+ const goModReader = createGoModReader(goBinary, pluginName, env);
633
1051
  validateSourceReplacements(scratchDir, useDirs, goModReader, pluginName);
634
1052
  const sourceInfo = goModReader.read(scratchDir);
635
1053
  const effectiveUseDirs = sourceInfo.modulePath === TTSC_GO_MODULE_PATH
@@ -640,7 +1058,7 @@ function writeGoWork(scratchDir, useDirs, goBinary, pluginName) {
640
1058
  : useDirs;
641
1059
  const useLines = ["\t."];
642
1060
  for (const dir of effectiveUseDirs) {
643
- useLines.push(`\t${dir.replace(/\\/g, "/")}`);
1061
+ useLines.push(`\t${formatGoWorkPath(dir)}`);
644
1062
  }
645
1063
  const replaceLines = sourceBuildWorkspaceReplacements(effectiveUseDirs, goModReader);
646
1064
  const replaceBlock = replaceLines.length === 0 ? "" : `\n\n${replaceLines.join("\n")}\n`;
@@ -672,10 +1090,125 @@ function sourceBuildWorkspaceReplacements(useDirs, goModReader) {
672
1090
  return [];
673
1091
  }
674
1092
  return [
675
- `replace ${TTSC_GO_MODULE_PATH} v0.0.0 => ${ttscRoot.replace(/\\/g, "/")}`,
1093
+ `replace ${TTSC_GO_MODULE_PATH} v0.0.0 => ${formatGoWorkPath(ttscRoot)}`,
676
1094
  ];
677
1095
  }
678
- function createGoModReader(goBinary, pluginName) {
1096
+ /**
1097
+ * Format an absolute filesystem path as a single `go.work`/`go.mod` token.
1098
+ *
1099
+ * The modfile grammar shared by `go.mod` and `go.work` (parsed by
1100
+ * `golang.org/x/mod/modfile`) is whitespace-tokenized, so a `use`/`replace`
1101
+ * path that contains a space — a home or project directory such as `/Users/John
1102
+ * Smith/...` or `C:\Users\John Smith\...` — must be emitted as a quoted string
1103
+ * or `go` cannot parse the generated `go.work`. Normalize Windows separators to
1104
+ * `/` (the workspace convention) and then delegate to
1105
+ * {@link autoQuoteGoModToken}, which mirrors `modfile.AutoQuote`.
1106
+ *
1107
+ * Exported for unit tests.
1108
+ */
1109
+ function formatGoWorkPath(p) {
1110
+ return autoQuoteGoModToken(p.replace(/\\/g, "/"));
1111
+ }
1112
+ /**
1113
+ * Quote `token` for a `go.mod`/`go.work` line exactly as
1114
+ * `golang.org/x/mod/modfile`'s `AutoQuote` does: return it unchanged when it is
1115
+ * already a clean bare token, otherwise return its Go double-quoted form so the
1116
+ * value round-trips through the modfile lexer. A space-free path is therefore
1117
+ * emitted byte-for-byte as before; only tokens that would otherwise
1118
+ * mis-tokenize are quoted.
1119
+ *
1120
+ * Exported for unit tests.
1121
+ */
1122
+ function autoQuoteGoModToken(token) {
1123
+ return mustQuoteGoModToken(token) ? goQuoteString(token) : token;
1124
+ }
1125
+ // Mirror `modfile.MustQuote`: report whether `s` must be quoted to appear as a
1126
+ // single token on a modfile line.
1127
+ function mustQuoteGoModToken(s) {
1128
+ for (const ch of s) {
1129
+ if (ch === " " || ch === '"' || ch === "'" || ch === "`") {
1130
+ return true;
1131
+ }
1132
+ if (ch === "(" ||
1133
+ ch === ")" ||
1134
+ ch === "[" ||
1135
+ ch === "]" ||
1136
+ ch === "{" ||
1137
+ ch === "}" ||
1138
+ ch === ",") {
1139
+ // Go tests `len(s) > 1` (byte length): a lone bracket/comma is a legal
1140
+ // bare token, but one embedded in a longer token forces quoting.
1141
+ if (Buffer.byteLength(s, "utf8") > 1) {
1142
+ return true;
1143
+ }
1144
+ continue;
1145
+ }
1146
+ if (!isGoGraphic(ch)) {
1147
+ return true;
1148
+ }
1149
+ }
1150
+ return s === "" || s === "//" || s === "/*";
1151
+ }
1152
+ // Mirror `strconv.Quote`: wrap in double quotes, backslash-escape `"` and `\`,
1153
+ // emit Go-printable runes verbatim (including the ASCII space and printable
1154
+ // Unicode), and escape everything else with Go's `\a\b\f\n\r\t\v` / `\xNN` /
1155
+ // `\uNNNN` / `\UNNNNNNNN` forms so the token round-trips through
1156
+ // `strconv.Unquote` in the modfile lexer.
1157
+ function goQuoteString(s) {
1158
+ let out = '"';
1159
+ for (const ch of s) {
1160
+ if (ch === '"' || ch === "\\") {
1161
+ out += `\\${ch}`;
1162
+ continue;
1163
+ }
1164
+ if (isGoPrintable(ch)) {
1165
+ out += ch;
1166
+ continue;
1167
+ }
1168
+ out += escapeGoRune(ch);
1169
+ }
1170
+ return `${out}"`;
1171
+ }
1172
+ function escapeGoRune(ch) {
1173
+ switch (ch) {
1174
+ case "\x07":
1175
+ return "\\a";
1176
+ case "\b":
1177
+ return "\\b";
1178
+ case "\f":
1179
+ return "\\f";
1180
+ case "\n":
1181
+ return "\\n";
1182
+ case "\r":
1183
+ return "\\r";
1184
+ case "\t":
1185
+ return "\\t";
1186
+ case "\v":
1187
+ return "\\v";
1188
+ default: {
1189
+ const cp = ch.codePointAt(0) ?? 0;
1190
+ if (cp < 0x20 || cp === 0x7f) {
1191
+ return `\\x${cp.toString(16).padStart(2, "0")}`;
1192
+ }
1193
+ if (cp < 0x10000) {
1194
+ return `\\u${cp.toString(16).padStart(4, "0")}`;
1195
+ }
1196
+ return `\\U${cp.toString(16).padStart(8, "0")}`;
1197
+ }
1198
+ }
1199
+ }
1200
+ // `unicode.IsGraphic`: categories L, M, N, P, S, Zs (all spacing).
1201
+ const GO_GRAPHIC_RE = /^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]$/u;
1202
+ function isGoGraphic(ch) {
1203
+ return GO_GRAPHIC_RE.test(ch);
1204
+ }
1205
+ // `strconv.IsPrint`: the graphic categories except that the ONLY spacing
1206
+ // character is the ASCII space (U+0020); other Unicode spaces are escaped.
1207
+ const GO_PRINTABLE_RE = /^[\p{L}\p{M}\p{N}\p{P}\p{S}]$/u;
1208
+ function isGoPrintable(ch) {
1209
+ return ch === " " || GO_PRINTABLE_RE.test(ch);
1210
+ }
1211
+ function createGoModReader(goBinary, pluginName, env) {
679
1212
  const cache = new Map();
680
1213
  return {
681
1214
  read(dir) {
@@ -684,20 +1217,20 @@ function createGoModReader(goBinary, pluginName) {
684
1217
  if (cached !== undefined) {
685
1218
  return cached;
686
1219
  }
687
- const info = readGoModInfo(resolved, goBinary, pluginName);
1220
+ const info = readGoModInfo(resolved, goBinary, pluginName, env);
688
1221
  cache.set(resolved, info);
689
1222
  return info;
690
1223
  },
691
1224
  };
692
1225
  }
693
- function readGoModInfo(dir, goBinary, pluginName) {
1226
+ function readGoModInfo(dir, goBinary, pluginName, env) {
694
1227
  if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, "go.mod"))) {
695
1228
  return emptyGoModInfo();
696
1229
  }
697
1230
  const result = spawnGoTool(goBinary, ["mod", "edit", "-json"], {
698
1231
  cwd: dir,
699
1232
  encoding: "utf8",
700
- env: goBuildEnv(goBinary),
1233
+ env: goBuildEnv(goBinary, undefined, env),
701
1234
  maxBuffer: 1024 * 1024 * 16,
702
1235
  windowsHide: true,
703
1236
  });
@@ -754,12 +1287,12 @@ function isTtscManagedModulePath(modulePath) {
754
1287
  modulePath === TSGO_GO_MODULE_PATH ||
755
1288
  modulePath.startsWith("github.com/microsoft/typescript-go/shim/"));
756
1289
  }
757
- function runGoBuild(cwd, entry, binaryName, pluginName, goBinary, goBuildCacheRoot) {
1290
+ function runGoBuild(cwd, entry, binaryName, pluginName, goBinary, goBuildCacheRoot, env) {
758
1291
  ensureExecutableGoToolchain(goBinary);
759
1292
  const result = spawnGoTool(goBinary, ["build", "-o", binaryName, entry], {
760
1293
  cwd,
761
1294
  encoding: "utf8",
762
- env: goBuildEnv(goBinary, goBuildCacheRoot),
1295
+ env: goBuildEnv(goBinary, goBuildCacheRoot, env),
763
1296
  maxBuffer: 1024 * 1024 * 64,
764
1297
  windowsHide: true,
765
1298
  });
@@ -792,8 +1325,8 @@ function spawnGoTool(goBinary, args, options) {
792
1325
  function shouldSpawnGoToolThroughShell(goBinary) {
793
1326
  return process.platform === "win32" && /\.(?:bat|cmd)$/i.test(goBinary);
794
1327
  }
795
- function goBuildEnv(goBinary, goBuildCacheRoot) {
796
- const env = { ...process.env };
1328
+ function goBuildEnv(goBinary, goBuildCacheRoot, effectiveEnv = process.env) {
1329
+ const env = { ...effectiveEnv };
797
1330
  env.GOWORK = "auto";
798
1331
  // Only the actual `go build` needs ttsc's GOCACHE; read-only metadata spawns
799
1332
  // (`go mod edit`, `go env`, `go version`) call goBuildEnv with no cache root
@@ -906,9 +1439,9 @@ function walkForGoMod(dir, out) {
906
1439
  * callers (and tests) that only need the plugin-binary root. Triggers the
907
1440
  * opportunistic project-cache GC as a side effect for the default location.
908
1441
  */
909
- function resolvePluginCacheRoot(projectRoot, cacheDir) {
910
- const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir);
911
- maybePrunePluginCache(paths, cacheDir);
1442
+ function resolvePluginCacheRoot(projectRoot, cacheDir, env = process.env) {
1443
+ const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir, env);
1444
+ maybePrunePluginCache(paths, cacheDir, env);
912
1445
  return paths.pluginRoot;
913
1446
  }
914
1447
  /**
@@ -1044,11 +1577,13 @@ function resolveGoBuildCacheRoot(root, projectRoot, env) {
1044
1577
  source: "ttsc-cache",
1045
1578
  };
1046
1579
  }
1047
- function maybePrunePluginCache(paths, cacheDir) {
1580
+ function maybePrunePluginCache(paths, cacheDir, env = process.env) {
1048
1581
  // GC only the default (workspace-local) location. When the user pins an
1049
1582
  // explicit `cacheDir`/`TTSC_CACHE_DIR`, they own its lifetime, so ttsc must
1050
- // not delete entries out from under them.
1051
- if (!cacheDir && !process.env.TTSC_CACHE_DIR) {
1583
+ // not delete entries out from under them. `env` is the effective instance
1584
+ // environment so a programmatic caller that pins `TTSC_CACHE_DIR` only in
1585
+ // `context.env` is honored without leaning on the shared `process.env`.
1586
+ if (!cacheDir && !env.TTSC_CACHE_DIR) {
1052
1587
  prunePluginCacheRoot(paths.pluginRoot);
1053
1588
  }
1054
1589
  }
@@ -1122,8 +1657,8 @@ function isPathWithin(child, parent) {
1122
1657
  return (rel === "" ||
1123
1658
  (rel !== ".." && !rel.startsWith(`..${node_path_1.default.sep}`) && !node_path_1.default.isAbsolute(rel)));
1124
1659
  }
1125
- function resolveGoCompiler() {
1126
- const explicit = process.env.TTSC_GO_BINARY;
1660
+ function resolveGoCompiler(env = process.env) {
1661
+ const explicit = env.TTSC_GO_BINARY;
1127
1662
  if (explicit && explicit.length > 0)
1128
1663
  return explicit;
1129
1664
  try {
@@ -1138,7 +1673,7 @@ function resolveGoCompiler() {
1138
1673
  const local = node_path_1.default.resolve(__dirname, "..", "..", "..", "..", "native", "go", "bin", process.platform === "win32" ? "go.exe" : "go");
1139
1674
  if (node_fs_1.default.existsSync(local))
1140
1675
  return local;
1141
- const homeSdk = node_path_1.default.join(process.env.HOME ?? "", "go-sdk", "go", "bin", process.platform === "win32" ? "go.exe" : "go");
1676
+ const homeSdk = node_path_1.default.join(env.HOME ?? "", "go-sdk", "go", "bin", process.platform === "win32" ? "go.exe" : "go");
1142
1677
  if (node_fs_1.default.existsSync(homeSdk))
1143
1678
  return homeSdk;
1144
1679
  return "go";
@@ -1155,16 +1690,17 @@ function resolveGoCompiler() {
1155
1690
  * Exposed for testing and for the `ttsc cache` CLI command.
1156
1691
  */
1157
1692
  function computeCacheKey(inputs) {
1693
+ const env = inputs.env ?? process.env;
1158
1694
  const hash = node_crypto_1.default.createHash("sha256");
1159
1695
  hash.update(`ttsc=${inputs.ttscVersion}\n`);
1160
1696
  hash.update(`tsgo=${inputs.tsgoVersion}\n`);
1161
1697
  hash.update(`platform=${process.platform}/${process.arch}\n`);
1162
1698
  hash.update(`entry=${inputs.entry}\n`);
1163
1699
  if (inputs.goBinary !== undefined) {
1164
- hash.update(`go=${resolveGoCompilerIdentity(inputs.goBinary)}\n`);
1700
+ hash.update(`go=${resolveGoCompilerIdentity(inputs.goBinary, env)}\n`);
1165
1701
  }
1166
- hashGoBuildEnvironment(hash, inputs.goBinary, inputs.dir);
1167
- hashExternalGoBuildEnvironment(hash);
1702
+ hashGoBuildEnvironment(hash, inputs.goBinary, inputs.dir, env);
1703
+ hashExternalGoBuildEnvironment(hash, env);
1168
1704
  hashSourceDirectory(hash, "plugin", inputs.dir);
1169
1705
  for (const [index, dir] of [...(inputs.overlayDirs ?? [])].sort().entries()) {
1170
1706
  hashSourceDirectory(hash, `overlay:${index}`, dir);
@@ -1249,8 +1785,8 @@ function isHashableFile(name) {
1249
1785
  // cache-key bytes stay byte-for-byte identical to today. `go version` reads no
1250
1786
  // cwd/custom env (the spawn passes neither), so cwd is not part of the key.
1251
1787
  const goCompilerIdentityCache = new Map();
1252
- function resolveGoCompilerIdentity(goBinary) {
1253
- const resolved = resolveExecutableIdentityPath(goBinary);
1788
+ function resolveGoCompilerIdentity(goBinary, env = process.env) {
1789
+ const resolved = resolveExecutableIdentityPath(goBinary, env);
1254
1790
  const memoKey = goCompilerIdentityMemoKey(goBinary, resolved);
1255
1791
  if (memoKey !== null) {
1256
1792
  const cached = goCompilerIdentityCache.get(memoKey);
@@ -1290,14 +1826,14 @@ function computeGoCompilerIdentity(goBinary, resolved) {
1290
1826
  const binaryHash = hashFile(resolved);
1291
1827
  return `sha256:${binaryHash}:${versionText}`;
1292
1828
  }
1293
- function resolveExecutableIdentityPath(binary) {
1829
+ function resolveExecutableIdentityPath(binary, env = process.env) {
1294
1830
  if (node_path_1.default.isAbsolute(binary)) {
1295
1831
  return resolveRealPath(binary);
1296
1832
  }
1297
1833
  if (binary.includes(node_path_1.default.sep)) {
1298
1834
  return resolveRealPath(node_path_1.default.resolve(binary));
1299
1835
  }
1300
- for (const dir of readPathEnvironment().split(node_path_1.default.delimiter)) {
1836
+ for (const dir of readPathEnvironment(env).split(node_path_1.default.delimiter)) {
1301
1837
  if (dir.length === 0)
1302
1838
  continue;
1303
1839
  const candidate = node_path_1.default.join(dir, binary);
@@ -1309,7 +1845,7 @@ function resolveExecutableIdentityPath(binary) {
1309
1845
  // a `.cmd`/`.bat` wrapper resolves to its real file and is hashed into the
1310
1846
  // cache key. Otherwise the wrapper reads as "missing" and a change to it
1311
1847
  // would not invalidate the cached plugin binary.
1312
- for (const ext of windowsExecutableExtensions()) {
1848
+ for (const ext of windowsExecutableExtensions(env)) {
1313
1849
  const executable = `${candidate}${ext}`;
1314
1850
  if (node_fs_1.default.existsSync(executable)) {
1315
1851
  return resolveRealPath(executable);
@@ -1319,16 +1855,16 @@ function resolveExecutableIdentityPath(binary) {
1319
1855
  }
1320
1856
  return binary;
1321
1857
  }
1322
- function windowsExecutableExtensions() {
1323
- const pathext = process.env.PATHEXT;
1858
+ function windowsExecutableExtensions(env = process.env) {
1859
+ const pathext = env.PATHEXT;
1324
1860
  const raw = pathext && pathext.length > 0 ? pathext : ".COM;.EXE;.BAT;.CMD";
1325
1861
  return raw
1326
1862
  .split(";")
1327
1863
  .map((ext) => ext.trim().toLowerCase())
1328
1864
  .filter((ext) => ext.length > 0);
1329
1865
  }
1330
- function readPathEnvironment() {
1331
- return process.env.PATH ?? process.env.Path ?? "";
1866
+ function readPathEnvironment(env = process.env) {
1867
+ return env.PATH ?? env.Path ?? "";
1332
1868
  }
1333
1869
  function resolveRealPath(location) {
1334
1870
  try {
@@ -1343,8 +1879,8 @@ function hashFile(file) {
1343
1879
  hash.update(node_fs_1.default.readFileSync(file));
1344
1880
  return hash.digest("hex");
1345
1881
  }
1346
- function hashGoBuildEnvironment(hash, goBinary, cwd) {
1347
- const values = resolveGoBuildEnvironment(goBinary, cwd);
1882
+ function hashGoBuildEnvironment(hash, goBinary, cwd, env) {
1883
+ const values = resolveGoBuildEnvironment(goBinary, cwd, env);
1348
1884
  for (const key of GO_BUILD_ENV_KEYS) {
1349
1885
  const value = values.get(key);
1350
1886
  if (value !== undefined && value !== "") {
@@ -1352,13 +1888,13 @@ function hashGoBuildEnvironment(hash, goBinary, cwd) {
1352
1888
  }
1353
1889
  }
1354
1890
  }
1355
- function resolveGoBuildEnvironment(goBinary, cwd) {
1891
+ function resolveGoBuildEnvironment(goBinary, cwd, env) {
1356
1892
  const values = new Map();
1357
1893
  if (goBinary !== undefined) {
1358
1894
  const result = spawnGoTool(goBinary, ["env", "-json", ...GO_BUILD_ENV_KEYS], {
1359
1895
  cwd,
1360
1896
  encoding: "utf8",
1361
- env: goBuildEnv(goBinary),
1897
+ env: goBuildEnv(goBinary, undefined, env),
1362
1898
  maxBuffer: 1024 * 1024,
1363
1899
  windowsHide: true,
1364
1900
  });
@@ -1368,41 +1904,41 @@ function resolveGoBuildEnvironment(goBinary, cwd) {
1368
1904
  for (const key of GO_BUILD_ENV_KEYS) {
1369
1905
  const raw = parsed[key];
1370
1906
  if (typeof raw === "string" && raw !== "") {
1371
- values.set(key, normalizeGoBuildEnvValue(key, raw));
1907
+ values.set(key, normalizeGoBuildEnvValue(key, raw, env));
1372
1908
  }
1373
1909
  }
1374
1910
  }
1375
1911
  catch {
1376
- // Fall back to process.env below; a cache key is still better than
1377
- // failing before `go build` can produce the actionable error.
1912
+ // Fall back to the effective env below; a cache key is still better
1913
+ // than failing before `go build` can produce the actionable error.
1378
1914
  }
1379
1915
  }
1380
1916
  }
1381
1917
  for (const key of GO_BUILD_ENV_KEYS) {
1382
1918
  if (values.has(key))
1383
1919
  continue;
1384
- const value = process.env[key];
1920
+ const value = env[key];
1385
1921
  if (value !== undefined && value !== "") {
1386
- values.set(key, normalizeGoBuildEnvValue(key, value));
1922
+ values.set(key, normalizeGoBuildEnvValue(key, value, env));
1387
1923
  }
1388
1924
  }
1389
1925
  return values;
1390
1926
  }
1391
- function normalizeGoBuildEnvValue(key, value) {
1927
+ function normalizeGoBuildEnvValue(key, value, env) {
1392
1928
  if (key === "GOROOT") {
1393
1929
  return resolveGoRootCacheIdentity(value);
1394
1930
  }
1395
1931
  if (GO_BUILD_COMMAND_ENV_KEYS.has(key)) {
1396
- return `${value}\0${resolveCommandCacheIdentity(value)}`;
1932
+ return `${value}\0${resolveCommandCacheIdentity(value, env)}`;
1397
1933
  }
1398
1934
  return value;
1399
1935
  }
1400
- function resolveCommandCacheIdentity(command) {
1936
+ function resolveCommandCacheIdentity(command, env) {
1401
1937
  const executable = firstCommandToken(command);
1402
1938
  if (executable === null) {
1403
1939
  return "command:empty";
1404
1940
  }
1405
- const resolved = resolveExecutableIdentityPath(executable);
1941
+ const resolved = resolveExecutableIdentityPath(executable, env);
1406
1942
  if (!node_fs_1.default.existsSync(resolved)) {
1407
1943
  return `command:missing:${executable}`;
1408
1944
  }
@@ -1425,9 +1961,9 @@ function firstCommandToken(command) {
1425
1961
  }
1426
1962
  return trimmed.split(/\s+/)[0] ?? null;
1427
1963
  }
1428
- function hashExternalGoBuildEnvironment(hash) {
1964
+ function hashExternalGoBuildEnvironment(hash, env) {
1429
1965
  for (const key of EXTERNAL_GO_BUILD_ENV_KEYS) {
1430
- const value = process.env[key];
1966
+ const value = env[key];
1431
1967
  if (value !== undefined && value !== "") {
1432
1968
  hash.update(`${key}=${value}\n`);
1433
1969
  }
@@ -1571,7 +2107,9 @@ function collectPluginCacheEntries(root, now) {
1571
2107
  return entries;
1572
2108
  }
1573
2109
  for (const dirent of dirents) {
1574
- if (!dirent.isDirectory()) {
2110
+ if (!dirent.isDirectory() ||
2111
+ dirent.name.endsWith(".lock") ||
2112
+ dirent.name.includes(".lock.")) {
1575
2113
  continue;
1576
2114
  }
1577
2115
  const dir = node_path_1.default.join(root, dirent.name);