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.
- package/README.md +9 -0
- package/cmd/graphbench/main.go +26 -16
- package/cmd/graphdump/main.go +37 -12
- package/cmd/graphdump/run_declares_the_capability_its_output_carries_test.go +137 -0
- package/cmd/graphdump/run_reports_an_unknown_flag_without_exiting_the_process_test.go +56 -0
- package/cmd/graphdump/run_reports_an_unloadable_project_without_printing_a_dump_test.go +46 -0
- package/cmd/ttsc/api_transform.go +28 -3
- package/cmd/ttscgraph/auxiliary_identity_state_tracks_retargets_not_source_contents_test.go +85 -0
- package/cmd/ttscgraph/disk_realpath_other.go +16 -0
- package/cmd/ttscgraph/disk_realpath_windows.go +56 -0
- package/cmd/ttscgraph/dump.go +43 -11
- package/cmd/ttscgraph/run_dump_claims_artifacts_only_when_it_asked_test.go +135 -0
- package/cmd/ttscgraph/run_dump_declares_the_capability_its_output_carries_test.go +96 -0
- package/cmd/ttscgraph/serve.go +357 -60
- package/cmd/ttscgraph/serve_adopts_a_republished_artifact_set_test.go +348 -0
- package/cmd/ttscgraph/serve_projects_an_artifact_no_source_owns_test.go +90 -0
- package/cmd/ttscgraph/serve_shards.go +31 -9
- package/driver/config_discovery.go +121 -0
- package/driver/emit_plugin.go +77 -11
- package/driver/graph.go +64 -11
- package/driver/input_observation.go +273 -0
- package/driver/input_observation_test.go +149 -0
- package/driver/plugins.go +506 -15
- package/driver/program.go +54 -1
- package/driver/resolution_candidates.go +13 -5
- package/driver/rewrite.go +45 -2
- package/driver/transform_dependencies.go +283 -0
- package/driver/transform_dependencies_declares_every_file_without_a_contributor_test.go +28 -0
- package/driver/transform_dependencies_drops_a_self_reported_dependency_test.go +21 -0
- package/driver/transform_dependencies_keys_a_reported_path_like_the_envelope_test.go +46 -0
- package/driver/transform_dependencies_requires_every_contributor_to_declare_test.go +38 -0
- package/driver/transform_dependencies_stays_silent_for_an_undeclared_contributor_test.go +23 -0
- package/driver/transform_dependencies_withdraws_completeness_after_an_unusable_dependency_test.go +37 -0
- package/internal/graph/artifacts.go +230 -0
- package/internal/graph/artifacts_index_what_a_plugin_published_test.go +154 -0
- package/internal/graph/artifacts_survive_a_partial_rebuild_test.go +89 -0
- package/internal/graph/build.go +35 -0
- package/internal/graph/build_releases_the_scratch_it_no_longer_reads_test.go +118 -0
- package/internal/graph/doc_refs_are_the_use_the_checker_already_counts_test.go +93 -0
- package/internal/graph/doc_refs_resolve_documentation_links_test.go +335 -0
- package/internal/graph/doc_tags.go +416 -0
- package/internal/graph/doc_tags_are_claimed_before_they_are_read_test.go +131 -0
- package/internal/graph/doc_tags_capture_every_unrecognized_tag_test.go +165 -0
- package/internal/graph/doc_tags_read_only_what_the_parser_attached_test.go +97 -0
- package/internal/graph/doc_tags_stop_at_the_external_boundary_test.go +59 -0
- package/internal/graph/doc_tags_survive_line_endings_and_repeated_blocks_test.go +146 -0
- package/internal/graph/dump.go +60 -13
- package/internal/graph/dump_checkout_paths_are_stable_test.go +2 -2
- package/internal/graph/dump_paths.go +2 -2
- package/internal/graph/dump_paths_are_portable_and_injective_test.go +1 -1
- package/internal/graph/edge_kinds_names_every_declared_kind_test.go +49 -0
- package/internal/graph/edges.go +1 -0
- package/internal/graph/graph.go +79 -4
- package/internal/graph/provenance.go +29 -2
- package/lib/TtscCompiler.d.ts +12 -11
- package/lib/TtscCompiler.js +44 -29
- package/lib/TtscCompiler.js.map +1 -1
- package/lib/compiler/internal/captureProcessOutput.js +2 -2
- package/lib/compiler/internal/captureProcessOutput.js.map +1 -1
- package/lib/compiler/internal/compileProjectInMemory.js +4 -3
- package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
- package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +9 -0
- package/lib/compiler/internal/project/createNativeProjectContextArgs.js +16 -4
- package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -1
- package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -0
- package/lib/compiler/internal/resolveEmittedJavaScript.js +3 -0
- package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
- package/lib/compiler/internal/runBuild.d.ts +1 -1
- package/lib/compiler/internal/runBuild.js +80 -34
- package/lib/compiler/internal/runBuild.js.map +1 -1
- package/lib/compiler/internal/runSingleFileEmit.js +2 -2
- package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
- package/lib/compiler/internal/sharedHostHelpers.d.ts +28 -0
- package/lib/compiler/internal/sharedHostHelpers.js +39 -0
- package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
- package/lib/compiler/internal/startResidentTransform.js +12 -4
- package/lib/compiler/internal/startResidentTransform.js.map +1 -1
- package/lib/compiler/internal/transformProjectInMemory.d.ts +3 -0
- package/lib/compiler/internal/transformProjectInMemory.js +239 -35
- package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
- package/lib/flags/schema.js +12 -5
- package/lib/flags/schema.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/internal/createCanonicalTempDirectory.d.ts +17 -0
- package/lib/internal/createCanonicalTempDirectory.js +42 -0
- package/lib/internal/createCanonicalTempDirectory.js.map +1 -0
- package/lib/internal/projectInputPathIdentity.d.ts +3 -0
- package/lib/internal/projectInputPathIdentity.js +11 -5
- package/lib/internal/projectInputPathIdentity.js.map +1 -1
- package/lib/internal/resolveNodeBinary.d.ts +14 -0
- package/lib/internal/resolveNodeBinary.js +150 -0
- package/lib/internal/resolveNodeBinary.js.map +1 -0
- package/lib/internal/resolveSafeCacheCleanupTargets.d.ts +15 -0
- package/lib/internal/resolveSafeCacheCleanupTargets.js +61 -0
- package/lib/internal/resolveSafeCacheCleanupTargets.js.map +1 -0
- package/lib/launcher/internal/prepareExecution.d.ts +3 -0
- package/lib/launcher/internal/prepareExecution.js +42 -14
- package/lib/launcher/internal/prepareExecution.js.map +1 -1
- package/lib/launcher/internal/runTtsc.js +25 -13
- package/lib/launcher/internal/runTtsc.js.map +1 -1
- package/lib/launcher/internal/runTtscserver.js +18 -3
- package/lib/launcher/internal/runTtscserver.js.map +1 -1
- package/lib/launcher/internal/runTtsx.js +2 -0
- package/lib/launcher/internal/runTtsx.js.map +1 -1
- package/lib/launcher/internal/runtimeHooks.d.ts +47 -3
- package/lib/launcher/internal/runtimeHooks.js +746 -64
- package/lib/launcher/internal/runtimeHooks.js.map +1 -1
- package/lib/plugin/internal/buildSourcePlugin.d.ts +62 -8
- package/lib/plugin/internal/buildSourcePlugin.js +754 -72
- package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
- package/lib/plugin/internal/capabilityResolutionCache.d.ts +76 -0
- package/lib/plugin/internal/capabilityResolutionCache.js +224 -0
- package/lib/plugin/internal/capabilityResolutionCache.js.map +1 -0
- package/lib/plugin/internal/loadProjectPlugins.d.ts +17 -5
- package/lib/plugin/internal/loadProjectPlugins.js +1339 -77
- package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
- package/lib/plugin/resolveCapabilityPlugins.d.ts +55 -0
- package/lib/plugin/resolveCapabilityPlugins.js +118 -0
- package/lib/plugin/resolveCapabilityPlugins.js.map +1 -0
- package/lib/register.d.ts +1 -0
- package/lib/register.js +46 -0
- package/lib/register.js.map +1 -0
- package/lib/structures/ITtscCompilerContext.d.ts +6 -3
- package/lib/structures/ITtscCompilerTransformation.d.ts +38 -0
- package/lib/structures/ITtscPlugin.d.ts +33 -0
- package/package.json +16 -9
- package/shim/ast/parent.go +16 -3
- package/shim/checker/enums_gen.go +11 -0
- package/shim/checker/go.mod +9 -0
- package/shim/checker/go.sum +22 -0
- package/shim/checker/shim.go +78 -0
- package/shim/compiler/emithook.go +10 -0
- package/shim/compiler/incremental.go +45 -8
- package/src/TtscCompiler.ts +55 -33
- package/src/compiler/internal/captureProcessOutput.ts +3 -2
- package/src/compiler/internal/compileProjectInMemory.ts +4 -3
- package/src/compiler/internal/project/createNativeProjectContextArgs.ts +19 -4
- package/src/compiler/internal/resolveEmittedJavaScript.ts +6 -0
- package/src/compiler/internal/runBuild.ts +102 -39
- package/src/compiler/internal/runSingleFileEmit.ts +2 -2
- package/src/compiler/internal/sharedHostHelpers.ts +44 -0
- package/src/compiler/internal/startResidentTransform.ts +12 -3
- package/src/compiler/internal/transformProjectInMemory.ts +410 -39
- package/src/flags/schema.ts +12 -5
- package/src/index.ts +1 -0
- package/src/internal/createCanonicalTempDirectory.ts +53 -0
- package/src/internal/projectInputPathIdentity.ts +17 -4
- package/src/internal/resolveNodeBinary.ts +173 -0
- package/src/internal/resolveSafeCacheCleanupTargets.ts +85 -0
- package/src/launcher/internal/prepareExecution.ts +50 -14
- package/src/launcher/internal/runTtsc.ts +24 -13
- package/src/launcher/internal/runTtscserver.ts +18 -3
- package/src/launcher/internal/runTtsx.ts +2 -0
- package/src/launcher/internal/runtimeHooks.ts +934 -68
- package/src/plugin/internal/buildSourcePlugin.ts +984 -79
- package/src/plugin/internal/capabilityResolutionCache.ts +305 -0
- package/src/plugin/internal/loadProjectPlugins.ts +1718 -104
- package/src/plugin/resolveCapabilityPlugins.ts +171 -0
- package/src/register.ts +47 -0
- package/src/structures/ITtscCompilerContext.ts +6 -3
- package/src/structures/ITtscCompilerTransformation.ts +46 -0
- package/src/structures/ITtscPlugin.ts +36 -0
- package/tools/shim_audit/baseline.json +0 -2
- package/utility/host.go +18 -3
- package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +0 -5
- package/lib/internal/assertSafeExplicitCacheDirectory.js +0 -26
- package/lib/internal/assertSafeExplicitCacheDirectory.js.map +0 -1
- 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
|
|
200
|
-
|
|
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
|
-
|
|
213
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
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
|
|
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
|
|
2180
|
-
*
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
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):
|
|
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)
|
|
2377
|
+
if (explicit && explicit.length > 0) {
|
|
2378
|
+
return { binary: explicit, bundled: false };
|
|
2379
|
+
}
|
|
2258
2380
|
|
|
2259
2381
|
try {
|
|
2260
|
-
return
|
|
2261
|
-
|
|
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))
|
|
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
|
|
2430
|
-
// a long-lived host rewrites
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
3042
|
+
hash.update(filesystem.readFile(file));
|
|
2867
3043
|
hash.update("\n");
|
|
2868
3044
|
}
|
|
2869
|
-
|
|
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
|
|
3055
|
+
function collectGoRootIdentitySnapshot(root: string): GoRootIdentitySnapshot {
|
|
2873
3056
|
const out: string[] = [];
|
|
2874
|
-
|
|
3057
|
+
const state = { complete: true };
|
|
3058
|
+
walkGoRootIdentity(root, root, out, state);
|
|
2875
3059
|
out.sort();
|
|
2876
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2951
|
-
const marker = path.join(
|
|
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 (
|
|
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
|
-
|
|
2958
|
-
|
|
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
|
-
|
|
2965
|
-
|
|
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
|
-
|
|
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 <=
|
|
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 <=
|
|
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
|
-
|
|
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
|
-
|
|
2986
|
-
|
|
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):
|
|
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
|
}
|