ttsc 0.26.2 → 0.28.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 (170) hide show
  1. package/README.md +9 -0
  2. package/cmd/graphbench/main.go +26 -16
  3. package/cmd/graphdump/main.go +37 -12
  4. package/cmd/graphdump/run_declares_the_capability_its_output_carries_test.go +137 -0
  5. package/cmd/graphdump/run_reports_an_unknown_flag_without_exiting_the_process_test.go +56 -0
  6. package/cmd/graphdump/run_reports_an_unloadable_project_without_printing_a_dump_test.go +46 -0
  7. package/cmd/ttsc/api_transform.go +28 -3
  8. package/cmd/ttscgraph/auxiliary_identity_state_tracks_retargets_not_source_contents_test.go +85 -0
  9. package/cmd/ttscgraph/disk_realpath_other.go +16 -0
  10. package/cmd/ttscgraph/disk_realpath_windows.go +56 -0
  11. package/cmd/ttscgraph/dump.go +43 -11
  12. package/cmd/ttscgraph/run_dump_claims_artifacts_only_when_it_asked_test.go +135 -0
  13. package/cmd/ttscgraph/run_dump_declares_the_capability_its_output_carries_test.go +96 -0
  14. package/cmd/ttscgraph/serve.go +357 -60
  15. package/cmd/ttscgraph/serve_adopts_a_republished_artifact_set_test.go +348 -0
  16. package/cmd/ttscgraph/serve_projects_an_artifact_no_source_owns_test.go +90 -0
  17. package/cmd/ttscgraph/serve_shards.go +31 -9
  18. package/driver/config_discovery.go +121 -0
  19. package/driver/emit_plugin.go +77 -11
  20. package/driver/graph.go +64 -11
  21. package/driver/input_observation.go +273 -0
  22. package/driver/input_observation_test.go +149 -0
  23. package/driver/plugins.go +506 -15
  24. package/driver/program.go +54 -1
  25. package/driver/resolution_candidates.go +13 -5
  26. package/driver/rewrite.go +45 -2
  27. package/driver/transform_dependencies.go +283 -0
  28. package/driver/transform_dependencies_declares_every_file_without_a_contributor_test.go +28 -0
  29. package/driver/transform_dependencies_drops_a_self_reported_dependency_test.go +21 -0
  30. package/driver/transform_dependencies_keys_a_reported_path_like_the_envelope_test.go +46 -0
  31. package/driver/transform_dependencies_requires_every_contributor_to_declare_test.go +38 -0
  32. package/driver/transform_dependencies_stays_silent_for_an_undeclared_contributor_test.go +23 -0
  33. package/driver/transform_dependencies_withdraws_completeness_after_an_unusable_dependency_test.go +37 -0
  34. package/internal/graph/artifacts.go +230 -0
  35. package/internal/graph/artifacts_index_what_a_plugin_published_test.go +154 -0
  36. package/internal/graph/artifacts_survive_a_partial_rebuild_test.go +89 -0
  37. package/internal/graph/build.go +35 -0
  38. package/internal/graph/build_releases_the_scratch_it_no_longer_reads_test.go +118 -0
  39. package/internal/graph/doc_refs_are_the_use_the_checker_already_counts_test.go +93 -0
  40. package/internal/graph/doc_refs_resolve_documentation_links_test.go +335 -0
  41. package/internal/graph/doc_tags.go +416 -0
  42. package/internal/graph/doc_tags_are_claimed_before_they_are_read_test.go +131 -0
  43. package/internal/graph/doc_tags_capture_every_unrecognized_tag_test.go +165 -0
  44. package/internal/graph/doc_tags_read_only_what_the_parser_attached_test.go +97 -0
  45. package/internal/graph/doc_tags_stop_at_the_external_boundary_test.go +59 -0
  46. package/internal/graph/doc_tags_survive_line_endings_and_repeated_blocks_test.go +146 -0
  47. package/internal/graph/dump.go +60 -13
  48. package/internal/graph/dump_checkout_paths_are_stable_test.go +2 -2
  49. package/internal/graph/dump_paths.go +2 -2
  50. package/internal/graph/dump_paths_are_portable_and_injective_test.go +1 -1
  51. package/internal/graph/edge_kinds_names_every_declared_kind_test.go +49 -0
  52. package/internal/graph/edges.go +1 -0
  53. package/internal/graph/graph.go +79 -4
  54. package/internal/graph/provenance.go +29 -2
  55. package/lib/TtscCompiler.d.ts +12 -11
  56. package/lib/TtscCompiler.js +44 -29
  57. package/lib/TtscCompiler.js.map +1 -1
  58. package/lib/compiler/internal/captureProcessOutput.js +2 -2
  59. package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
  60. package/lib/compiler/internal/compileProjectInMemory.js +4 -3
  61. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  62. package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +9 -0
  63. package/lib/compiler/internal/project/createNativeProjectContextArgs.js +16 -4
  64. package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -1
  65. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
  66. package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
  67. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  68. package/lib/compiler/internal/runBuild.d.ts +1 -1
  69. package/lib/compiler/internal/runBuild.js +80 -34
  70. package/lib/compiler/internal/runBuild.js.map +1 -1
  71. package/lib/compiler/internal/runSingleFileEmit.js +2 -2
  72. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  73. package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
  74. package/lib/compiler/internal/sharedHostHelpers.js +39 -0
  75. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  76. package/lib/compiler/internal/startResidentTransform.js +12 -4
  77. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  78. package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
  79. package/lib/compiler/internal/transformProjectInMemory.js +239 -35
  80. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  81. package/lib/flags/schema.js +12 -5
  82. package/lib/flags/schema.js.map +1 -1
  83. package/lib/index.d.ts +1 -0
  84. package/lib/index.js +1 -0
  85. package/lib/index.js.map +1 -1
  86. package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
  87. package/lib/internal/createCanonicalTempDirectory.js +42 -0
  88. package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
  89. package/lib/internal/projectInputPathIdentity.d.ts +3 -0
  90. package/lib/internal/projectInputPathIdentity.js +11 -5
  91. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  92. package/lib/internal/resolveNodeBinary.d.ts +14 -0
  93. package/lib/internal/resolveNodeBinary.js +150 -0
  94. package/lib/internal/resolveNodeBinary.js.map +1 -0
  95. package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
  96. package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
  97. package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
  98. package/lib/launcher/internal/prepareExecution.d.ts +3 -0
  99. package/lib/launcher/internal/prepareExecution.js +42 -14
  100. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  101. package/lib/launcher/internal/runTtsc.js +25 -13
  102. package/lib/launcher/internal/runTtsc.js.map +1 -1
  103. package/lib/launcher/internal/runTtscserver.js +18 -3
  104. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  105. package/lib/launcher/internal/runTtsx.js +2 -0
  106. package/lib/launcher/internal/runTtsx.js.map +1 -1
  107. package/lib/launcher/internal/runtimeHooks.d.ts +47 -3
  108. package/lib/launcher/internal/runtimeHooks.js +746 -64
  109. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  110. package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
  111. package/lib/plugin/internal/buildSourcePlugin.js +754 -72
  112. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  113. package/lib/plugin/internal/capabilityResolutionCache.d.ts +76 -0
  114. package/lib/plugin/internal/capabilityResolutionCache.js +224 -0
  115. package/lib/plugin/internal/capabilityResolutionCache.js.map +1 -0
  116. package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
  117. package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
  118. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  119. package/lib/plugin/resolveCapabilityPlugins.d.ts +55 -0
  120. package/lib/plugin/resolveCapabilityPlugins.js +118 -0
  121. package/lib/plugin/resolveCapabilityPlugins.js.map +1 -0
  122. package/lib/register.d.ts +1 -0
  123. package/lib/register.js +46 -0
  124. package/lib/register.js.map +1 -0
  125. package/lib/structures/ITtscCompilerContext.d.ts +6 -3
  126. package/lib/structures/ITtscCompilerTransformation.d.ts +38 -0
  127. package/lib/structures/ITtscPlugin.d.ts +33 -0
  128. package/package.json +16 -9
  129. package/shim/ast/parent.go +16 -3
  130. package/shim/checker/enums_gen.go +11 -0
  131. package/shim/checker/go.mod +9 -0
  132. package/shim/checker/go.sum +22 -0
  133. package/shim/checker/shim.go +78 -0
  134. package/shim/compiler/emithook.go +10 -0
  135. package/shim/compiler/incremental.go +45 -8
  136. package/src/TtscCompiler.ts +55 -33
  137. package/src/compiler/internal/captureProcessOutput.ts +3 -2
  138. package/src/compiler/internal/compileProjectInMemory.ts +4 -3
  139. package/src/compiler/internal/project/createNativeProjectContextArgs.ts +19 -4
  140. package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
  141. package/src/compiler/internal/runBuild.ts +102 -39
  142. package/src/compiler/internal/runSingleFileEmit.ts +2 -2
  143. package/src/compiler/internal/sharedHostHelpers.ts +44 -0
  144. package/src/compiler/internal/startResidentTransform.ts +12 -3
  145. package/src/compiler/internal/transformProjectInMemory.ts +410 -39
  146. package/src/flags/schema.ts +12 -5
  147. package/src/index.ts +1 -0
  148. package/src/internal/createCanonicalTempDirectory.ts +53 -0
  149. package/src/internal/projectInputPathIdentity.ts +17 -4
  150. package/src/internal/resolveNodeBinary.ts +173 -0
  151. package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
  152. package/src/launcher/internal/prepareExecution.ts +50 -14
  153. package/src/launcher/internal/runTtsc.ts +24 -13
  154. package/src/launcher/internal/runTtscserver.ts +18 -3
  155. package/src/launcher/internal/runTtsx.ts +2 -0
  156. package/src/launcher/internal/runtimeHooks.ts +934 -68
  157. package/src/plugin/internal/buildSourcePlugin.ts +984 -79
  158. package/src/plugin/internal/capabilityResolutionCache.ts +305 -0
  159. package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
  160. package/src/plugin/resolveCapabilityPlugins.ts +171 -0
  161. package/src/register.ts +47 -0
  162. package/src/structures/ITtscCompilerContext.ts +6 -3
  163. package/src/structures/ITtscCompilerTransformation.ts +46 -0
  164. package/src/structures/ITtscPlugin.ts +36 -0
  165. package/tools/shim_audit/baseline.json +0 -2
  166. package/utility/host.go +18 -3
  167. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
  168. package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
  169. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
  170. package/src/internal/assertSafeExplicitCacheDirectory.ts +0 -29
@@ -2,6 +2,7 @@ import {
2
2
  type SpawnSyncOptionsWithStringEncoding,
3
3
  type SpawnSyncReturns,
4
4
  type StdioOptions,
5
+ spawn,
5
6
  spawnSync,
6
7
  } from "node:child_process";
7
8
  import crypto from "node:crypto";
@@ -10,9 +11,11 @@ import { createRequire } from "node:module";
10
11
  import os from "node:os";
11
12
  import path from "node:path";
12
13
  import { fileURLToPath } from "node:url";
14
+ import { Worker } from "node:worker_threads";
13
15
 
14
16
  import { captureProcessOutput } from "../../compiler/internal/captureProcessOutput";
15
17
  import { findNearestGoMod } from "../../compiler/internal/paths";
18
+ import { createCanonicalTempDirectory } from "../../internal/createCanonicalTempDirectory";
16
19
 
17
20
  const GO_MOD_SEARCH_MAX_DEPTH = 3;
18
21
  const TTSC_GO_MODULE_PATH = "github.com/samchon/ttsc/packages/ttsc";
@@ -149,6 +152,27 @@ const PLUGIN_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
149
152
  const PLUGIN_CACHE_TARGET_BYTES = Math.floor(PLUGIN_CACHE_MAX_BYTES * 0.8);
150
153
  const PLUGIN_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000;
151
154
 
155
+ // Go's own object-cache trim is age-based and has no size ceiling. The default
156
+ // ttsc-owned cache therefore keeps up to 8 GiB and trims oldest objects toward
157
+ // 6 GiB. The newest target-sized set used within an hour remains protected so
158
+ // crossing the ceiling cannot immediately force another cold build.
159
+ const GO_BUILD_CACHE_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
160
+ const GO_BUILD_CACHE_MAX_BYTES = 8 * 1024 * 1024 * 1024;
161
+ const GO_BUILD_CACHE_TARGET_BYTES = 6 * 1024 * 1024 * 1024;
162
+ const GO_BUILD_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000;
163
+ const GO_BUILD_CACHE_GC_MARKER_FILE = ".ttsc-gc";
164
+ const GO_BUILD_CACHE_LEASE_DIR = ".ttsc-build-leases";
165
+ const GO_BUILD_CACHE_MAINTENANCE_DIR = ".ttsc-maintenance";
166
+ const GO_BUILD_CACHE_COORDINATION_STALE_MS = 60 * 60 * 1000;
167
+ const GO_BUILD_CACHE_MAINTENANCE_STALE_MS = 60 * 1000;
168
+ const GO_BUILD_CACHE_COORDINATION_CLOCK_SKEW_MS = 5 * 60 * 1000;
169
+ const GO_BUILD_CACHE_COORDINATION_HEARTBEAT_MS = 5_000;
170
+ const GO_BUILD_CACHE_COORDINATION_POLL_MS = 25;
171
+ // Go amortizes cache-hit mtime writes over one hour. An object used inside
172
+ // ttsc's one-hour protection window may therefore still carry an mtime almost
173
+ // one additional hour old.
174
+ const GO_BUILD_CACHE_ACCESS_MTIME_GRANULARITY_MS = 60 * 60 * 1000;
175
+
152
176
  /** One contributor's resolved Go source plus its target sub-package name. */
153
177
  export interface ITtscBuildContributor {
154
178
  /** Sub-package suffix: scratch lands at `<host>/contrib/<name>/`. */
@@ -169,6 +193,16 @@ export interface ITtscSourceBuildCachePaths {
169
193
  goBuildRootSource: "ttsc-cache" | "TTSC_GO_CACHE_DIR" | "GOCACHE";
170
194
  }
171
195
 
196
+ /** Synchronous filesystem reads used to fingerprint external Go toolchains. */
197
+ export interface SourceBuildFilesystemOperations {
198
+ readFile(location: string): Buffer;
199
+ }
200
+
201
+ const DEFAULT_SOURCE_BUILD_FILESYSTEM: SourceBuildFilesystemOperations =
202
+ Object.freeze({
203
+ readFile: (location: string) => fs.readFileSync(location),
204
+ });
205
+
172
206
  /**
173
207
  * Build one Go source plugin into a cached executable.
174
208
  *
@@ -186,6 +220,7 @@ export function buildSourcePlugin(opts: {
186
220
  cacheDir?: string;
187
221
  contributors?: readonly ITtscBuildContributor[];
188
222
  env?: NodeJS.ProcessEnv;
223
+ filesystem?: Partial<SourceBuildFilesystemOperations>;
189
224
  label?: string;
190
225
  overlayDirs?: readonly string[];
191
226
  quiet?: boolean;
@@ -196,21 +231,34 @@ export function buildSourcePlugin(opts: {
196
231
  const { dir, entry, source } = resolveSourceBuildTarget(opts);
197
232
  const overlayDirs = [...(opts.overlayDirs ?? findTtscOverlayDirs())].sort();
198
233
  const contributors = opts.contributors ?? [];
199
- const goBinary = resolveGoToolForBuild(resolveGoCompiler(env), env, dir);
200
- ensureExecutableGoToolchain(goBinary);
234
+ const compiler = resolveGoCompiler(env);
235
+ const goBinary = resolveGoToolForBuild(compiler.binary, env, dir);
236
+ ensureExecutableGoToolchain(goBinary, compiler.bundled);
201
237
  const key = computeCacheKey({
202
238
  contributors,
203
239
  dir,
204
240
  entry,
205
241
  env,
242
+ filesystem: opts.filesystem,
206
243
  goBinary,
207
244
  overlayDirs,
208
245
  ttscVersion: opts.ttscVersion,
209
246
  tsgoVersion: opts.tsgoVersion,
210
247
  });
211
248
  const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir, env);
212
- maybePrunePluginCache(paths, opts.cacheDir, env);
213
- const cacheDir = path.join(paths.pluginRoot, key);
249
+ const managePluginCache = !opts.cacheDir && !env.TTSC_CACHE_DIR;
250
+ const manageGoBuildCache = shouldManageSourceBuildCaches(
251
+ paths,
252
+ opts.cacheDir,
253
+ env,
254
+ );
255
+ const pluginRoot = managePluginCache
256
+ ? canonicalPluginCacheRoot(paths.pluginRoot)
257
+ : paths.pluginRoot;
258
+ maybePruneSourceBuildCaches({ ...paths, pluginRoot }, opts.cacheDir, env);
259
+ const cacheDir = managePluginCache
260
+ ? canonicalPluginCacheEntry(pluginRoot, key)
261
+ : path.join(pluginRoot, key);
214
262
  const binaryName = process.platform === "win32" ? "plugin.exe" : "plugin";
215
263
  const binaryPath = path.join(cacheDir, binaryName);
216
264
  if (fs.existsSync(binaryPath)) {
@@ -220,7 +268,7 @@ export function buildSourcePlugin(opts: {
220
268
  fs.mkdirSync(cacheDir, { recursive: true });
221
269
  const label = opts.label ?? "source plugin";
222
270
  const quiet = opts.quiet === true;
223
- return buildUnderPluginLock(
271
+ const built = buildUnderPluginLock(
224
272
  cacheDir,
225
273
  binaryPath,
226
274
  { label, pluginName: opts.pluginName, quiet },
@@ -233,15 +281,27 @@ export function buildSourcePlugin(opts: {
233
281
  entry,
234
282
  env,
235
283
  goBinary,
284
+ normalizeGoToolPermissions: compiler.bundled,
236
285
  key,
237
286
  label,
238
287
  goBuildCacheRoot: paths.goBuildRoot,
288
+ manageGoBuildCache,
239
289
  overlayDirs,
240
290
  pluginName: opts.pluginName,
241
291
  quiet,
242
292
  source,
243
293
  }),
244
294
  );
295
+ if (managePluginCache) {
296
+ // The pre-build daily pass cannot account for the binary this cold build
297
+ // just published. Enforce the size policy after publication, once this
298
+ // process has released its per-key build lock.
299
+ prunePluginCacheRoot(pluginRoot, {
300
+ force: true,
301
+ protectedEntries: [cacheDir],
302
+ });
303
+ }
304
+ return built;
245
305
  }
246
306
 
247
307
  /** Run the actual `go build` and publish the binary; assumes the lock is held. */
@@ -254,6 +314,8 @@ function compileSourcePlugin(opts: {
254
314
  env: NodeJS.ProcessEnv;
255
315
  goBinary: string;
256
316
  goBuildCacheRoot: string;
317
+ manageGoBuildCache: boolean;
318
+ normalizeGoToolPermissions: boolean;
257
319
  key: string;
258
320
  label: string;
259
321
  overlayDirs: readonly string[];
@@ -275,9 +337,7 @@ function compileSourcePlugin(opts: {
275
337
  );
276
338
  }
277
339
 
278
- const scratchDir = fs.mkdtempSync(
279
- path.join(os.tmpdir(), `ttsc-plugin-${opts.key}-`),
280
- );
340
+ const scratchDir = createCanonicalTempDirectory(`ttsc-plugin-${opts.key}-`);
281
341
  try {
282
342
  materializeScratchDir(opts.dir, scratchDir);
283
343
  const goModReader = createGoModReader(
@@ -303,15 +363,34 @@ function compileSourcePlugin(opts: {
303
363
  );
304
364
  const scratchBinaryName =
305
365
  process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin";
306
- runGoBuild(
307
- scratchDir,
308
- opts.entry,
309
- scratchBinaryName,
310
- opts.pluginName,
311
- opts.goBinary,
312
- opts.goBuildCacheRoot,
313
- opts.env,
314
- );
366
+ let attemptedGoBuildCacheRoot: string | undefined;
367
+ try {
368
+ withGoBuildCacheLease(
369
+ opts.goBuildCacheRoot,
370
+ opts.manageGoBuildCache,
371
+ (goBuildCacheRoot) => {
372
+ attemptedGoBuildCacheRoot = goBuildCacheRoot;
373
+ runGoBuild(
374
+ scratchDir,
375
+ opts.entry,
376
+ scratchBinaryName,
377
+ opts.pluginName,
378
+ opts.goBinary,
379
+ goBuildCacheRoot,
380
+ opts.env,
381
+ opts.normalizeGoToolPermissions,
382
+ );
383
+ },
384
+ );
385
+ } finally {
386
+ if (opts.manageGoBuildCache && attemptedGoBuildCacheRoot !== undefined) {
387
+ // The daily pre-build pass cannot see objects the build is about to
388
+ // add, including objects left behind by a failed compile. Enforce the
389
+ // size policy after every actual cold-build attempt so churn cannot
390
+ // grow the cache unchecked behind a fresh daily marker.
391
+ pruneGoBuildCacheRoot(attemptedGoBuildCacheRoot, { force: true });
392
+ }
393
+ }
315
394
  const builtBinary = path.join(scratchDir, scratchBinaryName);
316
395
  publishBuiltBinary(builtBinary, opts.binaryPath);
317
396
  touchCacheEntry(opts.cacheDir);
@@ -553,6 +632,8 @@ function ensurePluginBuildLockProtocol(protocolDir: string): void {
553
632
 
554
633
  function isPluginBuildLockProtocolV2(lockDir: string): boolean {
555
634
  try {
635
+ const stats = fs.lstatSync(lockDir);
636
+ if (!stats.isDirectory() || stats.isSymbolicLink()) return false;
556
637
  return (
557
638
  fs.readFileSync(
558
639
  path.join(lockDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE),
@@ -1055,7 +1136,10 @@ function pluginBuildLockAgeMs(lockDir: string, now: number): number | null {
1055
1136
 
1056
1137
  function pluginBuildLockPathExists(lockDir: string): boolean {
1057
1138
  try {
1058
- fs.statSync(lockDir);
1139
+ const stats = fs.lstatSync(lockDir);
1140
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
1141
+ throw new Error(`ttsc: unsafe plugin build lock path: ${lockDir}`);
1142
+ }
1059
1143
  return true;
1060
1144
  } catch (error) {
1061
1145
  if (isMissingPathError(error)) return false;
@@ -1732,8 +1816,9 @@ function runGoBuild(
1732
1816
  goBinary: string,
1733
1817
  goBuildCacheRoot: string,
1734
1818
  env: NodeJS.ProcessEnv,
1819
+ normalizeGoToolPermissions: boolean,
1735
1820
  ): void {
1736
- ensureExecutableGoToolchain(goBinary);
1821
+ ensureExecutableGoToolchain(goBinary, normalizeGoToolPermissions);
1737
1822
  const result = spawnGoTool(goBinary, ["build", "-o", binaryName, entry], {
1738
1823
  cwd,
1739
1824
  encoding: "utf8",
@@ -1907,25 +1992,43 @@ function inferGoRoot(goBinary: string): string | null {
1907
1992
  return fs.existsSync(path.join(goRoot, "src", "runtime")) ? goRoot : null;
1908
1993
  }
1909
1994
 
1910
- function ensureExecutableGoToolchain(goBinary: string): void {
1995
+ export function ensureExecutableGoToolchain(
1996
+ goBinary: string,
1997
+ normalizeBundledPermissions: boolean,
1998
+ ): void {
1911
1999
  if (process.platform === "win32") return;
1912
2000
  if (!path.isAbsolute(goBinary) || !fs.existsSync(goBinary)) return;
1913
2001
  try {
1914
- fs.chmodSync(goBinary, 0o755);
2002
+ ensureExecutableFile(goBinary, normalizeBundledPermissions);
1915
2003
  const goRoot = inferGoRoot(goBinary);
1916
2004
  if (!goRoot) return;
1917
2005
  const gofmt = path.join(path.dirname(goBinary), "gofmt");
1918
- if (fs.existsSync(gofmt)) fs.chmodSync(gofmt, 0o755);
2006
+ if (fs.existsSync(gofmt)) {
2007
+ ensureExecutableFile(gofmt, normalizeBundledPermissions);
2008
+ }
1919
2009
  const toolDir = path.join(goRoot, "pkg", "tool");
1920
2010
  if (!fs.existsSync(toolDir)) return;
1921
2011
  for (const file of walkToolFiles(toolDir)) {
1922
- fs.chmodSync(file, 0o755);
2012
+ ensureExecutableFile(file, normalizeBundledPermissions);
1923
2013
  }
1924
2014
  } catch {
1925
2015
  // Let the subsequent go build spawn fail with the real OS error.
1926
2016
  }
1927
2017
  }
1928
2018
 
2019
+ /** Repair tool execution without widening an explicitly selected toolchain. */
2020
+ function ensureExecutableFile(
2021
+ file: string,
2022
+ normalizeBundledPermissions: boolean,
2023
+ ): void {
2024
+ const mode = fs.statSync(file).mode & 0o7777;
2025
+ if (normalizeBundledPermissions) {
2026
+ if (mode !== 0o755) fs.chmodSync(file, 0o755);
2027
+ } else if ((mode & 0o111) === 0) {
2028
+ fs.chmodSync(file, mode | 0o100);
2029
+ }
2030
+ }
2031
+
1929
2032
  function walkToolFiles(dir: string): string[] {
1930
2033
  const out: string[] = [];
1931
2034
  let entries: fs.Dirent[];
@@ -1995,7 +2098,7 @@ export function resolvePluginCacheRoot(
1995
2098
  env: NodeJS.ProcessEnv = process.env,
1996
2099
  ): string {
1997
2100
  const paths = resolveSourceBuildCachePaths(projectRoot, cacheDir, env);
1998
- maybePrunePluginCache(paths, cacheDir, env);
2101
+ maybePruneSourceBuildCaches(paths, cacheDir, env);
1999
2102
  return paths.pluginRoot;
2000
2103
  }
2001
2104
 
@@ -2003,9 +2106,9 @@ export function resolvePluginCacheRoot(
2003
2106
  * Resolve all source-plugin build cache directories for one invocation.
2004
2107
  *
2005
2108
  * `pluginRoot` stores compiled plugin binaries; `goBuildRoot` is the Go object
2006
- * cache passed as `GOCACHE` while ttsc builds those binaries. Both live under a
2007
- * single `root`, so persisting one directory covers the whole source-build
2008
- * cache without depending on ttsc internals.
2109
+ * cache passed as `GOCACHE` while ttsc builds those binaries. The default Go
2110
+ * cache lives under `root`; an explicit `TTSC_GO_CACHE_DIR` or `GOCACHE` keeps
2111
+ * its independently resolved location and ownership policy.
2009
2112
  */
2010
2113
  export function resolveSourceBuildCachePaths(
2011
2114
  projectRoot: string,
@@ -2158,7 +2261,7 @@ function resolveGoBuildCacheRoot(
2158
2261
  };
2159
2262
  }
2160
2263
 
2161
- function maybePrunePluginCache(
2264
+ function maybePruneSourceBuildCaches(
2162
2265
  paths: ITtscSourceBuildCachePaths,
2163
2266
  cacheDir: string | undefined,
2164
2267
  env: NodeJS.ProcessEnv = process.env,
@@ -2170,17 +2273,31 @@ function maybePrunePluginCache(
2170
2273
  // `context.env` is honored without leaning on the shared `process.env`.
2171
2274
  if (!cacheDir && !env.TTSC_CACHE_DIR) {
2172
2275
  prunePluginCacheRoot(paths.pluginRoot);
2276
+ if (paths.goBuildRootSource === "ttsc-cache") {
2277
+ pruneGoBuildCacheRoot(paths.goBuildRoot);
2278
+ }
2173
2279
  }
2174
2280
  }
2175
2281
 
2282
+ /** Report whether this invocation owns and automatically maintains both caches. */
2283
+ function shouldManageSourceBuildCaches(
2284
+ paths: ITtscSourceBuildCachePaths,
2285
+ cacheDir: string | undefined,
2286
+ env: NodeJS.ProcessEnv,
2287
+ ): boolean {
2288
+ return (
2289
+ !cacheDir && !env.TTSC_CACHE_DIR && paths.goBuildRootSource === "ttsc-cache"
2290
+ );
2291
+ }
2292
+
2176
2293
  /**
2177
2294
  * Return every directory `ttsc clean` should remove for `projectRoot`.
2178
2295
  *
2179
- * Covers the resolved cache root (which holds `plugins/` and, when ttsc-owned,
2180
- * `go-build/`), a ttsc-owned Go build cache that lives OUTSIDE that root
2181
- * (`TTSC_GO_CACHE_DIR`), and the two legacy project-local caches. A
2182
- * user-provided `GOCACHE` is never removed. Pure over `env`, so the CLI passes
2183
- * `process.env` and a programmatic caller can pass an injected environment.
2296
+ * Covers the resolved plugin-binary root, a safely named nested `go-build/`, a
2297
+ * ttsc-owned Go build cache that lives OUTSIDE that root (`TTSC_GO_CACHE_DIR`),
2298
+ * and the two legacy project-local caches. A user-provided `GOCACHE` is never
2299
+ * removed. Pure over `env`, so the CLI passes `process.env` and a programmatic
2300
+ * caller can pass an injected environment.
2184
2301
  */
2185
2302
  export function resolveCleanTargets(
2186
2303
  projectRoot: string,
@@ -2252,14 +2369,22 @@ export function isPathWithin(child: string, parent: string): boolean {
2252
2369
  );
2253
2370
  }
2254
2371
 
2255
- function resolveGoCompiler(env: NodeJS.ProcessEnv = process.env): string {
2372
+ function resolveGoCompiler(env: NodeJS.ProcessEnv = process.env): {
2373
+ binary: string;
2374
+ bundled: boolean;
2375
+ } {
2256
2376
  const explicit = env.TTSC_GO_BINARY;
2257
- if (explicit && explicit.length > 0) return explicit;
2377
+ if (explicit && explicit.length > 0) {
2378
+ return { binary: explicit, bundled: false };
2379
+ }
2258
2380
 
2259
2381
  try {
2260
- return createRequire(__filename).resolve(
2261
- `@ttsc/${process.platform}-${process.arch}/bin/go/bin/${process.platform === "win32" ? "go.exe" : "go"}`,
2262
- );
2382
+ return {
2383
+ binary: createRequire(__filename).resolve(
2384
+ `@ttsc/${process.platform}-${process.arch}/bin/go/bin/${process.platform === "win32" ? "go.exe" : "go"}`,
2385
+ ),
2386
+ bundled: true,
2387
+ };
2263
2388
  } catch {
2264
2389
  /* fall through */
2265
2390
  }
@@ -2276,7 +2401,9 @@ function resolveGoCompiler(env: NodeJS.ProcessEnv = process.env): string {
2276
2401
  "bin",
2277
2402
  process.platform === "win32" ? "go.exe" : "go",
2278
2403
  );
2279
- if (fs.existsSync(platformPackage)) return platformPackage;
2404
+ if (fs.existsSync(platformPackage)) {
2405
+ return { binary: platformPackage, bundled: true };
2406
+ }
2280
2407
 
2281
2408
  const local = path.resolve(
2282
2409
  __dirname,
@@ -2289,7 +2416,7 @@ function resolveGoCompiler(env: NodeJS.ProcessEnv = process.env): string {
2289
2416
  "bin",
2290
2417
  process.platform === "win32" ? "go.exe" : "go",
2291
2418
  );
2292
- if (fs.existsSync(local)) return local;
2419
+ if (fs.existsSync(local)) return { binary: local, bundled: true };
2293
2420
 
2294
2421
  const homeSdk = path.join(
2295
2422
  env.HOME ?? "",
@@ -2298,9 +2425,9 @@ function resolveGoCompiler(env: NodeJS.ProcessEnv = process.env): string {
2298
2425
  "bin",
2299
2426
  process.platform === "win32" ? "go.exe" : "go",
2300
2427
  );
2301
- if (fs.existsSync(homeSdk)) return homeSdk;
2428
+ if (fs.existsSync(homeSdk)) return { binary: homeSdk, bundled: false };
2302
2429
 
2303
- return "go";
2430
+ return { binary: "go", bundled: false };
2304
2431
  }
2305
2432
 
2306
2433
  /**
@@ -2319,12 +2446,17 @@ export function computeCacheKey(inputs: {
2319
2446
  dir: string;
2320
2447
  entry: string;
2321
2448
  env?: NodeJS.ProcessEnv;
2449
+ filesystem?: Partial<SourceBuildFilesystemOperations>;
2322
2450
  goBinary?: string;
2323
2451
  overlayDirs?: readonly string[];
2324
2452
  ttscVersion: string;
2325
2453
  tsgoVersion: string;
2326
2454
  }): string {
2327
2455
  const env = inputs.env ?? process.env;
2456
+ const filesystem: SourceBuildFilesystemOperations = {
2457
+ readFile:
2458
+ inputs.filesystem?.readFile ?? DEFAULT_SOURCE_BUILD_FILESYSTEM.readFile,
2459
+ };
2328
2460
  const goBinary =
2329
2461
  inputs.goBinary === undefined
2330
2462
  ? undefined
@@ -2337,7 +2469,7 @@ export function computeCacheKey(inputs: {
2337
2469
  if (goBinary !== undefined) {
2338
2470
  hash.update(`go=${resolveGoCompilerIdentity(goBinary, env, inputs.dir)}\n`);
2339
2471
  }
2340
- hashGoBuildEnvironment(hash, goBinary, inputs.dir, env);
2472
+ hashGoBuildEnvironment(hash, goBinary, inputs.dir, env, filesystem);
2341
2473
  hashExternalGoBuildEnvironment(hash, env);
2342
2474
  hashSourceDirectory(hash, "plugin", inputs.dir);
2343
2475
  for (const [index, dir] of [...(inputs.overlayDirs ?? [])].sort().entries()) {
@@ -2426,8 +2558,9 @@ function isHashableFile(name: string): boolean {
2426
2558
  // binary hashes for a value that does not change between plugins. The result
2427
2559
  // is a pure function of the go binary's resolved real path plus its on-disk
2428
2560
  // content; the memo key therefore mixes the resolved real path with a cheap
2429
- // content signature (byte size + nanosecond mtime). That signature changes if
2430
- // a long-lived host rewrites the binary in place between calls, so the memo
2561
+ // content signature (filesystem identity, mode, byte size, and nanosecond
2562
+ // change/modify times). That signature changes if a long-lived host rewrites
2563
+ // or atomically replaces the binary between calls, so the memo
2431
2564
  // re-derives the identity exactly as the un-memoized code would and the
2432
2565
  // cache-key bytes stay byte-for-byte identical to today. The selected compiler
2433
2566
  // path is shared by every build subprocess on Windows, while `go version` uses
@@ -2481,7 +2614,7 @@ function goCompilerIdentityMemoKey(
2481
2614
  cwd: string,
2482
2615
  ): string | null {
2483
2616
  try {
2484
- const stat = fs.statSync(resolved);
2617
+ const stat = fs.statSync(resolved, { bigint: true });
2485
2618
  const context = crypto.createHash("sha256");
2486
2619
  context.update(cwd);
2487
2620
  for (const [key, value] of Object.entries(env).sort(([left], [right]) =>
@@ -2490,7 +2623,17 @@ function goCompilerIdentityMemoKey(
2490
2623
  if (value === undefined) continue;
2491
2624
  context.update(`\0${key.length}:${key}${value.length}:${value}`);
2492
2625
  }
2493
- return `${goBinary}\0${resolved}\0${stat.size}\0${stat.mtimeMs}\0${context.digest("hex")}`;
2626
+ return [
2627
+ goBinary,
2628
+ resolved,
2629
+ stat.dev,
2630
+ stat.ino,
2631
+ stat.mode,
2632
+ stat.size,
2633
+ stat.mtimeNs,
2634
+ stat.ctimeNs,
2635
+ context.digest("hex"),
2636
+ ].join("\0");
2494
2637
  } catch {
2495
2638
  return null;
2496
2639
  }
@@ -2744,8 +2887,9 @@ function hashGoBuildEnvironment(
2744
2887
  goBinary: string | undefined,
2745
2888
  cwd: string,
2746
2889
  env: NodeJS.ProcessEnv,
2890
+ filesystem: SourceBuildFilesystemOperations,
2747
2891
  ): void {
2748
- const values = resolveGoBuildEnvironment(goBinary, cwd, env);
2892
+ const values = resolveGoBuildEnvironment(goBinary, cwd, env, filesystem);
2749
2893
  for (const key of GO_BUILD_ENV_KEYS) {
2750
2894
  const value = values.get(key);
2751
2895
  if (value !== undefined && value !== "") {
@@ -2758,6 +2902,7 @@ function resolveGoBuildEnvironment(
2758
2902
  goBinary: string | undefined,
2759
2903
  cwd: string,
2760
2904
  env: NodeJS.ProcessEnv,
2905
+ filesystem: SourceBuildFilesystemOperations,
2761
2906
  ): Map<string, string> {
2762
2907
  const values = new Map<string, string>();
2763
2908
  if (goBinary !== undefined) {
@@ -2777,7 +2922,10 @@ function resolveGoBuildEnvironment(
2777
2922
  for (const key of GO_BUILD_ENV_KEYS) {
2778
2923
  const raw = parsed[key];
2779
2924
  if (typeof raw === "string" && raw !== "") {
2780
- values.set(key, normalizeGoBuildEnvValue(key, raw, env));
2925
+ values.set(
2926
+ key,
2927
+ normalizeGoBuildEnvValue(key, raw, env, filesystem),
2928
+ );
2781
2929
  }
2782
2930
  }
2783
2931
  } catch {
@@ -2790,7 +2938,7 @@ function resolveGoBuildEnvironment(
2790
2938
  if (values.has(key)) continue;
2791
2939
  const value = env[key];
2792
2940
  if (value !== undefined && value !== "") {
2793
- values.set(key, normalizeGoBuildEnvValue(key, value, env));
2941
+ values.set(key, normalizeGoBuildEnvValue(key, value, env, filesystem));
2794
2942
  }
2795
2943
  }
2796
2944
  return values;
@@ -2800,9 +2948,10 @@ function normalizeGoBuildEnvValue(
2800
2948
  key: string,
2801
2949
  value: string,
2802
2950
  env: NodeJS.ProcessEnv,
2951
+ filesystem: SourceBuildFilesystemOperations,
2803
2952
  ): string {
2804
2953
  if (key === "GOROOT") {
2805
- return resolveGoRootCacheIdentity(value);
2954
+ return resolveGoRootCacheIdentity(value, filesystem);
2806
2955
  }
2807
2956
  if (GO_BUILD_COMMAND_ENV_KEYS.has(key)) {
2808
2957
  return `${value}\0${resolveCommandCacheIdentity(value, env)}`;
@@ -2854,33 +3003,103 @@ function hashExternalGoBuildEnvironment(
2854
3003
  }
2855
3004
  }
2856
3005
 
2857
- function resolveGoRootCacheIdentity(goRoot: string): string {
3006
+ interface GoRootIdentitySnapshot {
3007
+ complete: boolean;
3008
+ files: string[];
3009
+ signature: string;
3010
+ }
3011
+
3012
+ interface GoRootIdentityCacheEntry {
3013
+ identity: string;
3014
+ signature: string;
3015
+ }
3016
+
3017
+ // GOROOT is immutable during normal use but contributes roughly 140 MiB of
3018
+ // source/tool content to every plugin key. Retain only the final pathless
3019
+ // content identity, guarded by a fresh metadata manifest on every call. A
3020
+ // changed or incomplete manifest falls through to the historical full read.
3021
+ const goRootIdentityCache = new Map<string, GoRootIdentityCacheEntry>();
3022
+
3023
+ function resolveGoRootCacheIdentity(
3024
+ goRoot: string,
3025
+ filesystem: SourceBuildFilesystemOperations,
3026
+ ): string {
2858
3027
  const resolved = resolveRealPath(goRoot);
2859
3028
  if (!fs.existsSync(resolved)) {
2860
3029
  return `missing:${goRoot}`;
2861
3030
  }
3031
+ const snapshot = collectGoRootIdentitySnapshot(resolved);
3032
+ if (snapshot.complete) {
3033
+ const cached = goRootIdentityCache.get(resolved);
3034
+ if (cached?.signature === snapshot.signature) {
3035
+ return cached.identity;
3036
+ }
3037
+ }
2862
3038
  const hash = crypto.createHash("sha256");
2863
- for (const file of collectGoRootIdentityFiles(resolved)) {
3039
+ for (const file of snapshot.files) {
2864
3040
  const relative = path.relative(resolved, file).replace(/\\/g, "/");
2865
3041
  hash.update(`f=${relative}\n`);
2866
- hash.update(fs.readFileSync(file));
3042
+ hash.update(filesystem.readFile(file));
2867
3043
  hash.update("\n");
2868
3044
  }
2869
- return `sha256:${hash.digest("hex")}`;
3045
+ const identity = `sha256:${hash.digest("hex")}`;
3046
+ if (snapshot.complete) {
3047
+ goRootIdentityCache.set(resolved, {
3048
+ identity,
3049
+ signature: snapshot.signature,
3050
+ });
3051
+ }
3052
+ return identity;
2870
3053
  }
2871
3054
 
2872
- function collectGoRootIdentityFiles(root: string): string[] {
3055
+ function collectGoRootIdentitySnapshot(root: string): GoRootIdentitySnapshot {
2873
3056
  const out: string[] = [];
2874
- walkGoRootIdentity(root, root, out);
3057
+ const state = { complete: true };
3058
+ walkGoRootIdentity(root, root, out, state);
2875
3059
  out.sort();
2876
- return out;
3060
+ const signature = crypto.createHash("sha256");
3061
+ for (const file of out) {
3062
+ const relative = path.relative(root, file).replace(/\\/g, "/");
3063
+ try {
3064
+ const stats = fs.statSync(file, { bigint: true });
3065
+ if (!stats.isFile()) {
3066
+ state.complete = false;
3067
+ continue;
3068
+ }
3069
+ signature.update(
3070
+ [
3071
+ relative,
3072
+ stats.dev,
3073
+ stats.ino,
3074
+ stats.mode,
3075
+ stats.size,
3076
+ stats.mtimeNs,
3077
+ stats.ctimeNs,
3078
+ ].join("\0"),
3079
+ );
3080
+ signature.update("\n");
3081
+ } catch {
3082
+ state.complete = false;
3083
+ }
3084
+ }
3085
+ return {
3086
+ complete: state.complete,
3087
+ files: out,
3088
+ signature: signature.digest("hex"),
3089
+ };
2877
3090
  }
2878
3091
 
2879
- function walkGoRootIdentity(root: string, dir: string, out: string[]): void {
3092
+ function walkGoRootIdentity(
3093
+ root: string,
3094
+ dir: string,
3095
+ out: string[],
3096
+ state: { complete: boolean },
3097
+ ): void {
2880
3098
  let entries: fs.Dirent[];
2881
3099
  try {
2882
3100
  entries = fs.readdirSync(dir, { withFileTypes: true });
2883
3101
  } catch {
3102
+ state.complete = false;
2884
3103
  return;
2885
3104
  }
2886
3105
  for (const entry of entries) {
@@ -2888,7 +3107,7 @@ function walkGoRootIdentity(root: string, dir: string, out: string[]): void {
2888
3107
  const rel = path.relative(root, file).replace(/\\/g, "/");
2889
3108
  if (entry.isDirectory()) {
2890
3109
  if (shouldHashGoRootPath(rel, true)) {
2891
- walkGoRootIdentity(root, file, out);
3110
+ walkGoRootIdentity(root, file, out, state);
2892
3111
  }
2893
3112
  } else if (entry.isFile() && shouldHashGoRootPath(rel, false)) {
2894
3113
  out.push(file);
@@ -2936,7 +3155,7 @@ function shouldHashGoRootPath(rel: string, isDir: boolean): boolean {
2936
3155
  function touchCacheEntry(cacheDir: string): void {
2937
3156
  try {
2938
3157
  fs.mkdirSync(cacheDir, { recursive: true });
2939
- fs.writeFileSync(
3158
+ replaceCacheMetadataFile(
2940
3159
  path.join(cacheDir, CACHE_LAST_USED_FILE),
2941
3160
  `${Date.now()}\n`,
2942
3161
  );
@@ -2945,45 +3164,709 @@ function touchCacheEntry(cacheDir: string): void {
2945
3164
  }
2946
3165
  }
2947
3166
 
2948
- function prunePluginCacheRoot(root: string): void {
3167
+ /** Test/internal controls for deterministic plugin-binary cache maintenance. */
3168
+ export interface IPluginCachePruneOptions {
3169
+ /** Ignore the once-daily marker. */
3170
+ force?: boolean;
3171
+ /** Size that triggers LRU pruning. */
3172
+ maxBytes?: number;
3173
+ /** Injected clock for deterministic tests. */
3174
+ now?: number;
3175
+ /** Recent-entry protection window. */
3176
+ protectedAgeMs?: number;
3177
+ /** Entries whose binary was returned by the current cold build. */
3178
+ protectedEntries?: readonly string[];
3179
+ /** Size to prune toward once the ceiling is crossed. */
3180
+ targetBytes?: number;
3181
+ }
3182
+
3183
+ export function prunePluginCacheRoot(
3184
+ root: string,
3185
+ options: IPluginCachePruneOptions = {},
3186
+ ): void {
2949
3187
  try {
2950
- fs.mkdirSync(root, { recursive: true });
2951
- const marker = path.join(root, CACHE_GC_MARKER_FILE);
2952
- const now = Date.now();
3188
+ const cacheRoot = canonicalPluginCacheRoot(root);
3189
+ const marker = path.join(cacheRoot, CACHE_GC_MARKER_FILE);
3190
+ const now = options.now ?? Date.now();
2953
3191
  const lastRun = readTimestamp(marker);
2954
- if (lastRun !== null && now - lastRun < PLUGIN_CACHE_GC_INTERVAL_MS) {
3192
+ if (
3193
+ options.force !== true &&
3194
+ lastRun !== null &&
3195
+ lastRun <= now &&
3196
+ now - lastRun < PLUGIN_CACHE_GC_INTERVAL_MS
3197
+ ) {
2955
3198
  return;
2956
3199
  }
2957
- fs.writeFileSync(marker, `${now}\n`);
2958
- prunePluginCacheEntries(root, now);
3200
+ const remainingBytes = prunePluginCacheEntries(cacheRoot, {
3201
+ maxBytes: options.maxBytes ?? PLUGIN_CACHE_MAX_BYTES,
3202
+ now,
3203
+ protectedEntries: canonicalPluginCacheProtectedEntries(
3204
+ cacheRoot,
3205
+ options.protectedEntries ?? [],
3206
+ ),
3207
+ protectedAgeMs: options.protectedAgeMs ?? PLUGIN_CACHE_PROTECTED_AGE_MS,
3208
+ targetBytes: options.targetBytes ?? PLUGIN_CACHE_TARGET_BYTES,
3209
+ });
3210
+ const maxBytes = options.maxBytes ?? PLUGIN_CACHE_MAX_BYTES;
3211
+ const protectedAgeMs =
3212
+ options.protectedAgeMs ?? PLUGIN_CACHE_PROTECTED_AGE_MS;
3213
+ const markerTimestamp =
3214
+ remainingBytes > maxBytes
3215
+ ? now - PLUGIN_CACHE_GC_INTERVAL_MS + protectedAgeMs
3216
+ : now;
3217
+ replaceCacheMetadataFile(marker, `${markerTimestamp}\n`);
2959
3218
  } catch {
2960
3219
  // Plugin-cache GC is opportunistic; builds still proceed when it fails.
2961
3220
  }
2962
3221
  }
2963
3222
 
2964
- function prunePluginCacheEntries(root: string, now: number): void {
2965
- const entries = collectPluginCacheEntries(root, now);
3223
+ /** Resolve explicit GC exclusions without allowing an alias outside root. */
3224
+ function canonicalPluginCacheProtectedEntries(
3225
+ root: string,
3226
+ entries: readonly string[],
3227
+ ): Set<string> {
3228
+ const protectedEntries = new Set<string>();
2966
3229
  for (const entry of entries) {
2967
- if (now - entry.lastUsedAt <= PLUGIN_CACHE_ENTRY_MAX_AGE_MS) {
3230
+ const candidate = path.resolve(entry);
3231
+ const stats = fs.lstatSync(candidate);
3232
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
3233
+ throw new Error(`ttsc: unsafe protected plugin cache entry: ${entry}`);
3234
+ }
3235
+ const physical = fs.realpathSync.native(candidate);
3236
+ if (path.dirname(physical) !== root) {
3237
+ throw new Error(
3238
+ `ttsc: protected plugin cache entry escaped root: ${entry}`,
3239
+ );
3240
+ }
3241
+ protectedEntries.add(physical);
3242
+ }
3243
+ return protectedEntries;
3244
+ }
3245
+
3246
+ /** Pin the default plugin cache to one ordinary physical directory. */
3247
+ function canonicalPluginCacheRoot(root: string): string {
3248
+ fs.mkdirSync(root, { recursive: true });
3249
+ const physicalParent = fs.realpathSync.native(path.dirname(root));
3250
+ const stats = fs.lstatSync(root);
3251
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
3252
+ throw new Error(`ttsc: unsafe plugin cache root: ${root}`);
3253
+ }
3254
+ // If an ancestor alias is retargeted after this point, all later cache work
3255
+ // stays on the original physical directory rather than following it.
3256
+ const physicalRoot = fs.realpathSync.native(root);
3257
+ if (path.dirname(physicalRoot) !== physicalParent) {
3258
+ throw new Error(`ttsc: plugin cache root escaped its parent: ${root}`);
3259
+ }
3260
+ return physicalRoot;
3261
+ }
3262
+
3263
+ /** Create one content-addressed cache entry without following a leaf alias. */
3264
+ function canonicalPluginCacheEntry(root: string, key: string): string {
3265
+ const directory = path.join(root, key);
3266
+ fs.mkdirSync(directory, { recursive: true });
3267
+ const stats = fs.lstatSync(directory);
3268
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
3269
+ throw new Error(`ttsc: unsafe plugin cache entry: ${directory}`);
3270
+ }
3271
+ const physicalDirectory = fs.realpathSync.native(directory);
3272
+ if (path.dirname(physicalDirectory) !== root) {
3273
+ throw new Error(`ttsc: plugin cache entry escaped its root: ${directory}`);
3274
+ }
3275
+ return physicalDirectory;
3276
+ }
3277
+
3278
+ function prunePluginCacheEntries(
3279
+ root: string,
3280
+ options: {
3281
+ maxBytes: number;
3282
+ now: number;
3283
+ protectedAgeMs: number;
3284
+ protectedEntries: ReadonlySet<string>;
3285
+ targetBytes: number;
3286
+ },
3287
+ ): number {
3288
+ const entries = collectPluginCacheEntries(root, options.now);
3289
+ for (const entry of entries) {
3290
+ if (
3291
+ options.now - entry.lastUsedAt <= PLUGIN_CACHE_ENTRY_MAX_AGE_MS ||
3292
+ options.protectedEntries.has(entry.dir) ||
3293
+ pluginCacheEntryHasActiveBuild(entry, options.now)
3294
+ ) {
2968
3295
  continue;
2969
3296
  }
2970
3297
  removeCacheEntry(entry);
2971
3298
  }
2972
3299
 
2973
- const remaining = collectPluginCacheEntries(root, now);
3300
+ const remaining = collectPluginCacheEntries(root, options.now);
2974
3301
  let total = remaining.reduce((sum, entry) => sum + entry.size, 0);
2975
- if (total <= PLUGIN_CACHE_MAX_BYTES) {
2976
- return;
3302
+ if (total <= options.maxBytes) {
3303
+ return total;
3304
+ }
3305
+ const protectedEntries = new Set<string>(options.protectedEntries);
3306
+ let protectedBytes = 0;
3307
+ for (const entry of [...remaining].sort(
3308
+ (a, b) => b.lastUsedAt - a.lastUsedAt,
3309
+ )) {
3310
+ if (pluginCacheEntryHasActiveBuild(entry, options.now)) {
3311
+ protectedEntries.add(entry.dir);
3312
+ continue;
3313
+ }
3314
+ if (options.now - entry.lastUsedAt > options.protectedAgeMs) continue;
3315
+ if (protectedBytes >= options.targetBytes) continue;
3316
+ if (protectedBytes + entry.size > options.targetBytes) continue;
3317
+ protectedEntries.add(entry.dir);
3318
+ protectedBytes += entry.size;
2977
3319
  }
2978
3320
  for (const entry of remaining.sort((a, b) => a.lastUsedAt - b.lastUsedAt)) {
2979
- if (total <= PLUGIN_CACHE_TARGET_BYTES) {
3321
+ if (total <= options.targetBytes) {
3322
+ return total;
3323
+ }
3324
+ if (protectedEntries.has(entry.dir)) continue;
3325
+ if (removeCacheEntry(entry)) total -= entry.size;
3326
+ }
3327
+ return total;
3328
+ }
3329
+
3330
+ /** Conservatively protect an entry while any build generation owns its key. */
3331
+ function pluginCacheEntryHasActiveBuild(
3332
+ entry: PluginCacheEntry,
3333
+ now: number,
3334
+ ): boolean {
3335
+ const lockDir = `${entry.dir}.lock`;
3336
+ try {
3337
+ return inspectPluginBuildLock(lockDir, now).state === "active";
3338
+ } catch {
3339
+ // Malformed or unreadable coordination state cannot disprove ownership.
3340
+ return true;
3341
+ }
3342
+ }
3343
+
3344
+ /** Test/internal controls for deterministic Go object-cache maintenance. */
3345
+ export interface IGoBuildCachePruneOptions {
3346
+ /** Ignore the once-daily marker. */
3347
+ force?: boolean;
3348
+ /** Size that triggers LRU pruning. */
3349
+ maxBytes?: number;
3350
+ /** Size to prune toward once the ceiling is crossed. */
3351
+ targetBytes?: number;
3352
+ /** Injected clock for deterministic tests. */
3353
+ now?: number;
3354
+ /** Recent-file protection window. */
3355
+ protectedAgeMs?: number;
3356
+ }
3357
+
3358
+ /**
3359
+ * Opportunistically bound one ttsc-owned Go object cache.
3360
+ *
3361
+ * A maintenance intent blocks new build leases. If any existing lease remains,
3362
+ * maintenance yields without touching the cache; the next invocation retries.
3363
+ * Only Go's two-hex object directories are scanned, so coordination metadata
3364
+ * and Go's own trim marker remain outside the size policy.
3365
+ */
3366
+ export function pruneGoBuildCacheRoot(
3367
+ root: string,
3368
+ options: IGoBuildCachePruneOptions = {},
3369
+ ): void {
3370
+ let intent: GoBuildCacheCoordinationRecord | undefined;
3371
+ try {
3372
+ const cacheRoot = canonicalGoBuildCacheRoot(root);
3373
+ const marker = path.join(cacheRoot, GO_BUILD_CACHE_GC_MARKER_FILE);
3374
+ const now = options.now ?? Date.now();
3375
+ const lastRun = readTimestamp(marker);
3376
+ if (
3377
+ options.force !== true &&
3378
+ lastRun !== null &&
3379
+ lastRun <= now &&
3380
+ now - lastRun < GO_BUILD_CACHE_GC_INTERVAL_MS
3381
+ ) {
3382
+ return;
3383
+ }
3384
+
3385
+ intent = createGoBuildCacheCoordinationRecord(
3386
+ cacheRoot,
3387
+ GO_BUILD_CACHE_MAINTENANCE_DIR,
3388
+ );
3389
+ if (!intent.startHeartbeat()) {
3390
+ // Maintenance is opportunistic. Without an independent heartbeat a
3391
+ // synchronous scan could look abandoned while it is still deleting, so
3392
+ // yield instead of weakening the build/maintenance exclusion.
3393
+ return;
3394
+ }
3395
+ if (
3396
+ collectLiveGoBuildCacheCoordinationRecords(
3397
+ cacheRoot,
3398
+ GO_BUILD_CACHE_LEASE_DIR,
3399
+ now,
3400
+ ).length !== 0
3401
+ ) {
2980
3402
  return;
2981
3403
  }
2982
- if (now - entry.lastUsedAt <= PLUGIN_CACHE_PROTECTED_AGE_MS) {
3404
+
3405
+ const remainingBytes = pruneGoBuildCacheEntries(cacheRoot, {
3406
+ maxBytes: options.maxBytes ?? GO_BUILD_CACHE_MAX_BYTES,
3407
+ now,
3408
+ protectedAgeMs: options.protectedAgeMs ?? GO_BUILD_CACHE_PROTECTED_AGE_MS,
3409
+ targetBytes: options.targetBytes ?? GO_BUILD_CACHE_TARGET_BYTES,
3410
+ });
3411
+ // If recent protection or a transient delete failure left the cache above
3412
+ // the ceiling, retry after the protection window instead of suppressing
3413
+ // every maintenance attempt for a full day.
3414
+ const maxBytes = options.maxBytes ?? GO_BUILD_CACHE_MAX_BYTES;
3415
+ const protectedAgeMs =
3416
+ options.protectedAgeMs ?? GO_BUILD_CACHE_PROTECTED_AGE_MS;
3417
+ const markerTimestamp =
3418
+ remainingBytes > maxBytes
3419
+ ? now - GO_BUILD_CACHE_GC_INTERVAL_MS + protectedAgeMs
3420
+ : now;
3421
+ replaceCacheMetadataFile(marker, `${markerTimestamp}\n`);
3422
+ } catch {
3423
+ // Go-cache GC is opportunistic; builds still proceed when it fails.
3424
+ } finally {
3425
+ intent?.finish();
3426
+ }
3427
+ }
3428
+
3429
+ /**
3430
+ * Run one Go build under a cross-process cache lease when ttsc owns the cache.
3431
+ *
3432
+ * The lease is published before checking maintenance. A maintenance process
3433
+ * that arrived first keeps its intent visible, so this builder withdraws and
3434
+ * retries; one that arrives second sees the lease and yields. That ordering
3435
+ * closes the scan/start race without serializing independent Go builds.
3436
+ */
3437
+ export function withGoBuildCacheLease<T>(
3438
+ root: string,
3439
+ managed: boolean,
3440
+ callback: (cacheRoot: string) => T,
3441
+ ): T {
3442
+ if (!managed) {
3443
+ return callback(root);
3444
+ }
3445
+ const cacheRoot = canonicalGoBuildCacheRoot(root);
3446
+ const started = Date.now();
3447
+ for (;;) {
3448
+ const lease = createGoBuildCacheCoordinationRecord(
3449
+ cacheRoot,
3450
+ GO_BUILD_CACHE_LEASE_DIR,
3451
+ );
3452
+ const maintenance = collectLiveGoBuildCacheCoordinationRecords(
3453
+ cacheRoot,
3454
+ GO_BUILD_CACHE_MAINTENANCE_DIR,
3455
+ Date.now(),
3456
+ );
3457
+ if (maintenance.length === 0) {
3458
+ try {
3459
+ if (!lease.startHeartbeat()) {
3460
+ throw new Error(
3461
+ `ttsc: unable to start Go build cache lease heartbeat at ${cacheRoot}`,
3462
+ );
3463
+ }
3464
+ return callback(cacheRoot);
3465
+ } finally {
3466
+ lease.finish();
3467
+ }
3468
+ }
3469
+ lease.finish();
3470
+ if (Date.now() - started > PLUGIN_BUILD_LOCK_STEAL_MS) {
3471
+ throw new Error(
3472
+ `ttsc: timed out waiting for Go build cache maintenance at ${cacheRoot}`,
3473
+ );
3474
+ }
3475
+ sleepSync(GO_BUILD_CACHE_COORDINATION_POLL_MS);
3476
+ }
3477
+ }
3478
+
3479
+ /**
3480
+ * Create and pin the owned Go cache to an ordinary physical directory.
3481
+ *
3482
+ * The leaf may be user-controlled inside `node_modules/.cache`; accepting a
3483
+ * symlink or junction there would let LRU deletion escape into an arbitrary
3484
+ * two-hex directory. Returning the canonical spelling also keeps the build,
3485
+ * leases, and maintenance on the same directory if an ancestor alias moves.
3486
+ */
3487
+ function canonicalGoBuildCacheRoot(root: string): string {
3488
+ fs.mkdirSync(root, { recursive: true });
3489
+ const physicalParent = fs.realpathSync.native(path.dirname(root));
3490
+ const stats = fs.lstatSync(root);
3491
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
3492
+ throw new Error(`ttsc: unsafe Go build cache root: ${root}`);
3493
+ }
3494
+ const physicalRoot = fs.realpathSync.native(root);
3495
+ if (path.dirname(physicalRoot) !== physicalParent) {
3496
+ throw new Error(`ttsc: Go build cache root escaped its parent: ${root}`);
3497
+ }
3498
+ return physicalRoot;
3499
+ }
3500
+
3501
+ interface GoBuildCacheObject {
3502
+ file: string;
3503
+ lastUsedAt: number;
3504
+ size: number;
3505
+ }
3506
+
3507
+ /** Prune oldest Go cache objects to the requested deterministic size target. */
3508
+ function pruneGoBuildCacheEntries(
3509
+ root: string,
3510
+ options: {
3511
+ maxBytes: number;
3512
+ now: number;
3513
+ protectedAgeMs: number;
3514
+ targetBytes: number;
3515
+ },
3516
+ ): number {
3517
+ const entries = collectGoBuildCacheObjects(root);
3518
+ let total = entries.reduce((sum, entry) => sum + entry.size, 0);
3519
+ if (total <= options.maxBytes) {
3520
+ return total;
3521
+ }
3522
+ // Protect the newest recent objects only up to one target-sized cohort
3523
+ // reserve. Protecting every object younger than an hour would let repeated
3524
+ // 2.9 GiB cold builds grow without bound during that hour.
3525
+ const protectedFiles = new Set<string>();
3526
+ let protectedBytes = 0;
3527
+ for (const entry of [...entries].sort(
3528
+ (a, b) => b.lastUsedAt - a.lastUsedAt,
3529
+ )) {
3530
+ if (
3531
+ options.now - entry.lastUsedAt >
3532
+ options.protectedAgeMs + GO_BUILD_CACHE_ACCESS_MTIME_GRANULARITY_MS
3533
+ ) {
2983
3534
  continue;
2984
3535
  }
2985
- removeCacheEntry(entry);
2986
- total -= entry.size;
3536
+ if (protectedBytes >= options.targetBytes) {
3537
+ break;
3538
+ }
3539
+ if (protectedBytes + entry.size > options.targetBytes) {
3540
+ break;
3541
+ }
3542
+ protectedFiles.add(entry.file);
3543
+ protectedBytes += entry.size;
3544
+ }
3545
+ for (const entry of entries.sort((a, b) => a.lastUsedAt - b.lastUsedAt)) {
3546
+ if (total <= options.targetBytes) {
3547
+ return total;
3548
+ }
3549
+ if (protectedFiles.has(entry.file)) {
3550
+ continue;
3551
+ }
3552
+ try {
3553
+ fs.rmSync(entry.file, { force: true });
3554
+ total -= entry.size;
3555
+ } catch {
3556
+ // A concurrent antivirus/indexer can transiently hold a file on Windows.
3557
+ }
3558
+ }
3559
+ return total;
3560
+ }
3561
+
3562
+ function collectGoBuildCacheObjects(root: string): GoBuildCacheObject[] {
3563
+ const output: GoBuildCacheObject[] = [];
3564
+ let buckets: fs.Dirent[];
3565
+ try {
3566
+ buckets = fs.readdirSync(root, { withFileTypes: true });
3567
+ } catch {
3568
+ return output;
3569
+ }
3570
+ for (const bucket of buckets) {
3571
+ if (!bucket.isDirectory() || !/^[0-9a-f]{2}$/.test(bucket.name)) {
3572
+ continue;
3573
+ }
3574
+ const directory = path.join(root, bucket.name);
3575
+ let files: fs.Dirent[];
3576
+ try {
3577
+ files = fs.readdirSync(directory, { withFileTypes: true });
3578
+ } catch {
3579
+ continue;
3580
+ }
3581
+ for (const file of files) {
3582
+ if (!file.isFile()) {
3583
+ continue;
3584
+ }
3585
+ const absolute = path.join(directory, file.name);
3586
+ try {
3587
+ const stats = fs.statSync(absolute);
3588
+ output.push({
3589
+ file: absolute,
3590
+ lastUsedAt: stats.mtimeMs,
3591
+ size: stats.size,
3592
+ });
3593
+ } catch {}
3594
+ }
3595
+ }
3596
+ return output;
3597
+ }
3598
+
3599
+ interface GoBuildCacheCoordinationRecord {
3600
+ file: string;
3601
+ finish: () => void;
3602
+ startHeartbeat: () => boolean;
3603
+ }
3604
+
3605
+ function createGoBuildCacheCoordinationRecord(
3606
+ root: string,
3607
+ directoryName: string,
3608
+ ): GoBuildCacheCoordinationRecord {
3609
+ const directory = goBuildCacheCoordinationDirectory(
3610
+ root,
3611
+ directoryName,
3612
+ true,
3613
+ )!;
3614
+ const record = path.join(
3615
+ directory,
3616
+ `${process.pid}-${crypto.randomBytes(16).toString("hex")}.json`,
3617
+ );
3618
+ const metadata = {
3619
+ directoryName,
3620
+ hostname: os.hostname(),
3621
+ pid: process.pid,
3622
+ startedAt: Date.now(),
3623
+ };
3624
+ writeGoBuildCacheCoordinationRecord(record, metadata, "active");
3625
+ let heartbeat: GoBuildCacheHeartbeat | undefined;
3626
+ return {
3627
+ file: record,
3628
+ finish: () => {
3629
+ heartbeat?.stop();
3630
+ heartbeat = undefined;
3631
+ // A failed unlink must not leave a completed task looking active until
3632
+ // its stale timeout. Persist completion first; collectors discard it.
3633
+ try {
3634
+ writeGoBuildCacheCoordinationRecord(record, metadata, "complete");
3635
+ } catch {}
3636
+ try {
3637
+ fs.rmSync(record, { force: true });
3638
+ } catch {}
3639
+ },
3640
+ startHeartbeat: () => {
3641
+ heartbeat ??= startGoBuildCacheHeartbeat(record);
3642
+ return heartbeat !== undefined;
3643
+ },
3644
+ };
3645
+ }
3646
+
3647
+ function writeGoBuildCacheCoordinationRecord(
3648
+ file: string,
3649
+ metadata: {
3650
+ directoryName: string;
3651
+ hostname: string;
3652
+ pid: number;
3653
+ startedAt: number;
3654
+ },
3655
+ status: "active" | "complete",
3656
+ ): void {
3657
+ replaceCacheMetadataFile(
3658
+ file,
3659
+ `${JSON.stringify({ ...metadata, status, version: 1 })}\n`,
3660
+ );
3661
+ }
3662
+
3663
+ interface GoBuildCacheHeartbeat {
3664
+ stop: () => void;
3665
+ }
3666
+
3667
+ /** Refresh one synchronous cache task's record from a background worker. */
3668
+ function startGoBuildCacheHeartbeat(
3669
+ file: string,
3670
+ ): GoBuildCacheHeartbeat | undefined {
3671
+ const control = new SharedArrayBuffer(4);
3672
+ const state = new Int32Array(control);
3673
+ try {
3674
+ const worker = new Worker(
3675
+ [
3676
+ 'const fs = process.getBuiltinModule("node:fs");',
3677
+ 'const { workerData } = process.getBuiltinModule("node:worker_threads");',
3678
+ "const state = new Int32Array(workerData.control);",
3679
+ "for (;;) {",
3680
+ " const result = Atomics.wait(state, 0, 0, workerData.interval);",
3681
+ ' if (result !== "timed-out" || Atomics.load(state, 0) !== 0) break;',
3682
+ " try {",
3683
+ " const now = new Date();",
3684
+ " fs.utimesSync(workerData.file, now, now);",
3685
+ " } catch {}",
3686
+ "}",
3687
+ ].join("\n"),
3688
+ {
3689
+ eval: true,
3690
+ workerData: {
3691
+ control,
3692
+ file,
3693
+ interval: GO_BUILD_CACHE_COORDINATION_HEARTBEAT_MS,
3694
+ },
3695
+ },
3696
+ );
3697
+ worker.unref();
3698
+ return {
3699
+ stop: () => {
3700
+ Atomics.store(state, 0, 1);
3701
+ Atomics.notify(state, 0);
3702
+ void worker.terminate();
3703
+ },
3704
+ };
3705
+ } catch {}
3706
+
3707
+ // Node's permission model can deny Worker construction while still allowing
3708
+ // the child process required for `go build`. An IPC-bound helper provides
3709
+ // the same independent heartbeat and exits automatically if its parent dies.
3710
+ try {
3711
+ const child = spawn(
3712
+ process.execPath,
3713
+ [
3714
+ "-e",
3715
+ [
3716
+ 'const fs = require("node:fs");',
3717
+ "const file = process.argv[1];",
3718
+ "const interval = Number(process.argv[2]);",
3719
+ "const timer = setInterval(() => {",
3720
+ " try {",
3721
+ " const now = new Date();",
3722
+ " fs.utimesSync(file, now, now);",
3723
+ " } catch {}",
3724
+ "}, interval);",
3725
+ 'process.on("disconnect", () => {',
3726
+ " clearInterval(timer);",
3727
+ " process.exit(0);",
3728
+ "});",
3729
+ ].join("\n"),
3730
+ file,
3731
+ String(GO_BUILD_CACHE_COORDINATION_HEARTBEAT_MS),
3732
+ ],
3733
+ {
3734
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
3735
+ windowsHide: true,
3736
+ },
3737
+ );
3738
+ child.unref();
3739
+ child.channel?.unref();
3740
+ return {
3741
+ stop: () => {
3742
+ if (child.connected) child.disconnect();
3743
+ child.kill();
3744
+ },
3745
+ };
3746
+ } catch {
3747
+ return undefined;
3748
+ }
3749
+ }
3750
+
3751
+ function collectLiveGoBuildCacheCoordinationRecords(
3752
+ root: string,
3753
+ directoryName: string,
3754
+ now: number,
3755
+ ): string[] {
3756
+ const directory = goBuildCacheCoordinationDirectory(
3757
+ root,
3758
+ directoryName,
3759
+ false,
3760
+ );
3761
+ if (directory === undefined) return [];
3762
+ let records: fs.Dirent[];
3763
+ try {
3764
+ records = fs.readdirSync(directory, { withFileTypes: true });
3765
+ } catch (error) {
3766
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
3767
+ throw error;
3768
+ }
3769
+ const live: string[] = [];
3770
+ for (const record of records) {
3771
+ if (!record.isFile()) {
3772
+ continue;
3773
+ }
3774
+ const file = path.join(directory, record.name);
3775
+ if (goBuildCacheCoordinationRecordIsLive(file, directoryName, now)) {
3776
+ live.push(file);
3777
+ continue;
3778
+ }
3779
+ try {
3780
+ fs.rmSync(file, { force: true });
3781
+ } catch {}
3782
+ }
3783
+ return live;
3784
+ }
3785
+
3786
+ /**
3787
+ * Resolve one private coordination directory without following a project-
3788
+ * supplied symlink or junction outside the owned Go cache.
3789
+ */
3790
+ function goBuildCacheCoordinationDirectory(
3791
+ root: string,
3792
+ directoryName: string,
3793
+ create: boolean,
3794
+ ): string | undefined {
3795
+ if (create) fs.mkdirSync(root, { recursive: true });
3796
+ const directory = path.join(root, directoryName);
3797
+ if (create) {
3798
+ try {
3799
+ fs.mkdirSync(directory);
3800
+ } catch (error) {
3801
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
3802
+ }
3803
+ }
3804
+ let stats: fs.Stats;
3805
+ try {
3806
+ stats = fs.lstatSync(directory);
3807
+ } catch (error) {
3808
+ if (!create && (error as NodeJS.ErrnoException).code === "ENOENT") {
3809
+ return undefined;
3810
+ }
3811
+ throw error;
3812
+ }
3813
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
3814
+ throw new Error(
3815
+ `ttsc: unsafe Go build cache coordination directory: ${directory}`,
3816
+ );
3817
+ }
3818
+ const physicalRoot = fs.realpathSync.native(root);
3819
+ const physicalDirectory = fs.realpathSync.native(directory);
3820
+ if (path.dirname(physicalDirectory) !== physicalRoot) {
3821
+ throw new Error(
3822
+ `ttsc: Go build cache coordination directory escaped its root: ${directory}`,
3823
+ );
3824
+ }
3825
+ return physicalDirectory;
3826
+ }
3827
+
3828
+ function goBuildCacheCoordinationRecordIsLive(
3829
+ file: string,
3830
+ directoryName: string,
3831
+ now: number,
3832
+ ): boolean {
3833
+ let contents: string | undefined;
3834
+ try {
3835
+ contents = fs.readFileSync(file, "utf8");
3836
+ const parsed = JSON.parse(contents) as Record<string, unknown>;
3837
+ if (parsed.status === "complete") return false;
3838
+ // Parse valid records for forward compatibility, but do not equate a PID's
3839
+ // lifetime with one task. A failed unlink can leave a record owned by a
3840
+ // still-running Vite process, while a dead Node parent can leave its
3841
+ // spawnSync Go child alive. The heartbeat/grace below models the task.
3842
+ void parsed;
3843
+ } catch {}
3844
+ try {
3845
+ const age = now - fs.statSync(file).mtimeMs;
3846
+ if (age < -GO_BUILD_CACHE_COORDINATION_CLOCK_SKEW_MS) {
3847
+ // A restored cache can carry a far-future timestamp, but the same state
3848
+ // also occurs when the system clock moves backward during a real build.
3849
+ // Rebase the record and grant one ordinary grace period; an active
3850
+ // heartbeat keeps refreshing it, while an orphan then expires normally.
3851
+ // Treat a failed rebase as live too: the safe failure mode is to defer
3852
+ // opportunistic maintenance, never to delete under a possibly live Go.
3853
+ // Replace the directory entry rather than changing its inode in place.
3854
+ // A restored or user-modified cache can contain a hard-linked record;
3855
+ // utimesSync(file) would then mutate metadata outside the owned cache.
3856
+ if (contents !== undefined) {
3857
+ try {
3858
+ replaceCacheMetadataFile(file, contents);
3859
+ } catch {}
3860
+ }
3861
+ return true;
3862
+ }
3863
+ const staleMs =
3864
+ directoryName === GO_BUILD_CACHE_MAINTENANCE_DIR
3865
+ ? GO_BUILD_CACHE_MAINTENANCE_STALE_MS
3866
+ : GO_BUILD_CACHE_COORDINATION_STALE_MS;
3867
+ return age <= staleMs;
3868
+ } catch (error) {
3869
+ return (error as NodeJS.ErrnoException).code !== "ENOENT";
2987
3870
  }
2988
3871
  }
2989
3872
 
@@ -3055,6 +3938,26 @@ function readTimestamp(file: string): number | null {
3055
3938
  }
3056
3939
  }
3057
3940
 
3941
+ /** Replace cache metadata without following a pre-existing link or hard link. */
3942
+ function replaceCacheMetadataFile(file: string, contents: string): void {
3943
+ const temporary = path.join(
3944
+ path.dirname(file),
3945
+ `.${path.basename(file)}.${process.pid}-${crypto
3946
+ .randomBytes(16)
3947
+ .toString("hex")}.tmp`,
3948
+ );
3949
+ try {
3950
+ fs.writeFileSync(temporary, contents, { encoding: "utf8", flag: "wx" });
3951
+ // rename replaces the directory entry itself. Unlike writeFile(file), it
3952
+ // cannot follow a symlink or mutate another hard link to the old inode.
3953
+ fs.renameSync(temporary, file);
3954
+ } finally {
3955
+ try {
3956
+ fs.rmSync(temporary, { force: true });
3957
+ } catch {}
3958
+ }
3959
+ }
3960
+
3058
3961
  function directorySize(dir: string): number {
3059
3962
  let total = 0;
3060
3963
  let entries: fs.Dirent[];
@@ -3076,10 +3979,12 @@ function directorySize(dir: string): number {
3076
3979
  return total;
3077
3980
  }
3078
3981
 
3079
- function removeCacheEntry(entry: PluginCacheEntry): void {
3982
+ function removeCacheEntry(entry: PluginCacheEntry): boolean {
3080
3983
  try {
3081
3984
  fs.rmSync(entry.dir, { recursive: true, force: true });
3985
+ return !fs.existsSync(entry.dir);
3082
3986
  } catch {
3083
3987
  // Windows may reject removal while a plugin binary is still running.
3988
+ return false;
3084
3989
  }
3085
3990
  }