UNPKG

ttsc

Version:

General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.

1,526 lines (1,457 loc) 96.8 kB
import { type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns, type StdioOptions, spawnSync, } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { captureProcessOutput } from "../../compiler/internal/captureProcessOutput"; import { findNearestGoMod } from "../../compiler/internal/paths"; const GO_MOD_SEARCH_MAX_DEPTH = 3; const TTSC_GO_MODULE_PATH = "github.com/samchon/ttsc/packages/ttsc"; const TSGO_GO_MODULE_PATH = "github.com/microsoft/typescript-go"; const PRUNE_DIRS = new Set(["node_modules", ".git", ".ttsc"]); const GENERATED_WORKSPACE_FILES = new Set(["go.work", "go.work.sum"]); // Go build environment values that can change the produced binary or decide // whether `go build` succeeds. Hashed into the plugin cache key so target, // build-tag, cgo, FIPS, and external-link variants never collide. const GO_BUILD_ENV_KEYS: readonly string[] = [ "GOOS", "GOARCH", "GOAMD64", "GOARM", "GOARM64", "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM", "GOFLAGS", "GOEXPERIMENT", "GOFIPS140", "GO_EXTLINK_ENABLED", "GCCGO", "GCCGOTOOLDIR", "CGO_ENABLED", "AR", "CC", "CXX", "FC", "PKG_CONFIG", "CGO_CFLAGS", "CGO_CFLAGS_ALLOW", "CGO_CFLAGS_DISALLOW", "CGO_CPPFLAGS", "CGO_CPPFLAGS_ALLOW", "CGO_CPPFLAGS_DISALLOW", "CGO_CXXFLAGS", "CGO_CXXFLAGS_ALLOW", "CGO_CXXFLAGS_DISALLOW", "CGO_FFLAGS", "CGO_FFLAGS_ALLOW", "CGO_FFLAGS_DISALLOW", "CGO_LDFLAGS", "CGO_LDFLAGS_ALLOW", "CGO_LDFLAGS_DISALLOW", "GOTOOLCHAIN", "GOROOT", ]; const GO_BUILD_COMMAND_ENV_KEYS = new Set([ "AR", "CC", "CXX", "FC", "GCCGO", "PKG_CONFIG", ]); const EXTERNAL_GO_BUILD_ENV_KEYS: readonly string[] = [ "CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "DYLD_LIBRARY_PATH", "INCLUDE", "LD_LIBRARY_PATH", "LIB", "LIBRARY_PATH", "LIBPATH", "MACOSX_DEPLOYMENT_TARGET", "OBJC_INCLUDE_PATH", "PKG_CONFIG_ALLOW_SYSTEM_CFLAGS", "PKG_CONFIG_ALLOW_SYSTEM_LIBS", "PKG_CONFIG_LIBDIR", "PKG_CONFIG_PATH", "PKG_CONFIG_SYSROOT_DIR", "PKG_CONFIG_TOP_BUILD_DIR", "SDKROOT", ]; const CONTRIBUTIONS_FILE_NAME = "ttsc_contributions.go"; const CONTRIB_DIRNAME = "contrib"; // A cold source-plugin build is a multi-second-to-minutes `go build`. When a // program fans out into many processes (a `pnpm -r` running several suites in // parallel, a benchmark, a worker pool), each inherits the same cold cache and // would otherwise launch its own full build of the SAME cache key at the same // instant. The atomic lock below lets one process build while the rest poll for // its published binary, so the toolchain runs once per cache key instead of N // times. A waiter steals an abandoned lock (builder crashed) after this timeout // so a fan-out never wedges; it matches the dependency-build lock in // runtimeHooks.ts. const PLUGIN_BUILD_LOCK_STEAL_MS = 600_000; const PLUGIN_BUILD_LOCK_POLL_MS = 50; const PLUGIN_BUILD_LOCK_LEGACY_STALE_MS = 30_000; const PLUGIN_BUILD_LOCK_STATUS_MS = 30_000; const PLUGIN_BUILD_LOCK_OWNER_FILE = "owner.json"; const PLUGIN_BUILD_LOCK_PROTOCOL_FILE = "protocol-v2"; const PLUGIN_BUILD_LOCK_GENERATION_FILE = "generation"; const PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR = "legacy-generation"; const PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD = "fence.json"; const PLUGIN_BUILD_LOCK_CURRENT_DIR = "current"; const PLUGIN_BUILD_LOCK_RETIRED_DIR = "retired"; const PLUGIN_BUILD_LOCK_V2_SUFFIX = ".v2"; const PLUGIN_BUILD_LOCK_PROTOCOL = "ttsc-plugin-build-lock-v2\n"; // The default cache lives INSIDE the workspace, at // `<workspaceRoot>/node_modules/.cache/ttsc`, so `rm -rf node_modules` (or // deleting the repo) reclaims every compiled plugin binary and Go object file. // This is the `find-cache-dir` convention (Babel, webpack, ESLint, Nuxt, …): a // disposable build cache under `node_modules/.cache/<tool>`. ttsc keeps NO // global (`~/.cache`) cache — a machine-wide cache silently grew to hundreds of // GB across tsgo/plugin version bumps, so it was removed outright. See // resolveSourceBuildCacheRoot for the (override → workspace-local) priority. const NODE_MODULES_DIRNAME = "node_modules"; const LOCAL_CACHE_PARENT_DIRNAME = ".cache"; const TTSC_CACHE_DIRNAME = "ttsc"; const PLUGIN_CACHE_DIRNAME = "plugins"; const GO_BUILD_CACHE_DIRNAME = "go-build"; // Directories whose presence marks a monorepo/workspace root, so every package // in the workspace shares ONE cache and a plugin builds once, not once per // package. `package.json` with a `workspaces` field (yarn/npm/bun) is checked // separately in isWorkspaceRootDir. const WORKSPACE_ROOT_MARKER_FILES: readonly string[] = ["pnpm-workspace.yaml"]; const CACHE_LAST_USED_FILE = ".last-used"; const CACHE_GC_MARKER_FILE = ".gc-last-run"; // The plugin binary cache is content-keyed, so a project that bumps tsgo/typia // many times leaves one stale entry per superseded key. An opportunistic GC // (once/day) evicts entries unused for 30 days and, past a 2 GB ceiling, the // least-recently-used down to 80%. It is scoped to the resolved cache root only // — ttsc never scans a shared or global location. const PLUGIN_CACHE_GC_INTERVAL_MS = 24 * 60 * 60 * 1000; const PLUGIN_CACHE_ENTRY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; const PLUGIN_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024; const PLUGIN_CACHE_TARGET_BYTES = Math.floor(PLUGIN_CACHE_MAX_BYTES * 0.8); const PLUGIN_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000; /** One contributor's resolved Go source plus its target sub-package name. */ export interface ITtscBuildContributor { /** Sub-package suffix: scratch lands at `<host>/contrib/<name>/`. */ name: string; /** Absolute path to the contributor's source directory. */ source: string; } /** Source-plugin cache locations resolved for one ttsc invocation. */ export interface ITtscSourceBuildCachePaths { /** Root directory containing all ttsc-owned source build caches. */ root: string; /** Directory containing content-addressed compiled plugin binaries. */ pluginRoot: string; /** Directory passed to Go as `GOCACHE` for source-plugin builds. */ goBuildRoot: string; /** How `goBuildRoot` was selected. */ goBuildRootSource: "ttsc-cache" | "TTSC_GO_CACHE_DIR" | "GOCACHE"; } /** * Build one Go source plugin into a cached executable. * * `opts.env` is the effective environment for this build — the caller merges `{ * ...process.env, ...context.env }` so a programmatic `TtscCompiler` instance * can pin its own Go toolchain (`TTSC_GO_BINARY`), Go build cache * (`TTSC_GO_CACHE_DIR`), and Go build variables (`GOFLAGS`, `CGO_*`, …) without * mutating the shared `process.env`. CLI callers omit it and inherit * `process.env`, so ambient behavior is unchanged. */ export function buildSourcePlugin(opts: { source: string; pluginName: string; baseDir: string; cacheDir?: string; contributors?: readonly ITtscBuildContributor[]; env?: NodeJS.ProcessEnv; label?: string; overlayDirs?: readonly string[]; quiet?: boolean; ttscVersion: string; tsgoVersion: string; }): string { const env = opts.env ?? process.env; const { dir, entry, source } = resolveSourceBuildTarget(opts); const overlayDirs = [...(opts.overlayDirs ?? findTtscOverlayDirs())].sort(); const contributors = opts.contributors ?? []; const goBinary = resolveGoToolForBuild(resolveGoCompiler(env), env, dir); ensureExecutableGoToolchain(goBinary); const key = computeCacheKey({ contributors, dir, entry, env, goBinary, overlayDirs, ttscVersion: opts.ttscVersion, tsgoVersion: opts.tsgoVersion, }); const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir, env); maybePrunePluginCache(paths, opts.cacheDir, env); const cacheDir = path.join(paths.pluginRoot, key); const binaryName = process.platform === "win32" ? "plugin.exe" : "plugin"; const binaryPath = path.join(cacheDir, binaryName); if (fs.existsSync(binaryPath)) { touchCacheEntry(cacheDir); return binaryPath; } fs.mkdirSync(cacheDir, { recursive: true }); const label = opts.label ?? "source plugin"; const quiet = opts.quiet === true; return buildUnderPluginLock( cacheDir, binaryPath, { label, pluginName: opts.pluginName, quiet }, () => compileSourcePlugin({ binaryPath, cacheDir, contributors, dir, entry, env, goBinary, key, label, goBuildCacheRoot: paths.goBuildRoot, overlayDirs, pluginName: opts.pluginName, quiet, source, }), ); } /** Run the actual `go build` and publish the binary; assumes the lock is held. */ function compileSourcePlugin(opts: { binaryPath: string; cacheDir: string; contributors: readonly ITtscBuildContributor[]; dir: string; entry: string; env: NodeJS.ProcessEnv; goBinary: string; goBuildCacheRoot: string; key: string; label: string; overlayDirs: readonly string[]; pluginName: string; quiet: boolean; source: string; }): string { if (!opts.quiet) { const extra = opts.contributors.length === 0 ? "" : ` + ${opts.contributors.length} contributor(s): ${opts.contributors .map((c) => c.name) .join(", ")}`; process.stderr.write( `ttsc: building ${opts.label} "${opts.pluginName}" from ${opts.source}${extra} ` + `(this runs once per cache key and can take several minutes on a cold Go cache) ` + `See https://ttsc.dev/docs/ttsc/compile#plugin-cache to persist it across builds.\n`, ); } const scratchDir = fs.mkdtempSync( path.join(os.tmpdir(), `ttsc-plugin-${opts.key}-`), ); try { materializeScratchDir(opts.dir, scratchDir); const goModReader = createGoModReader( opts.goBinary, opts.pluginName, opts.env, ); if (opts.contributors.length > 0) { mergeContributors({ contributors: opts.contributors, entry: opts.entry, goModReader, pluginName: opts.pluginName, scratchDir, }); } writeGoWork( scratchDir, opts.overlayDirs, opts.goBinary, opts.pluginName, opts.env, ); const scratchBinaryName = process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin"; runGoBuild( scratchDir, opts.entry, scratchBinaryName, opts.pluginName, opts.goBinary, opts.goBuildCacheRoot, opts.env, ); const builtBinary = path.join(scratchDir, scratchBinaryName); publishBuiltBinary(builtBinary, opts.binaryPath); touchCacheEntry(opts.cacheDir); return opts.binaryPath; } finally { fs.rmSync(scratchDir, { recursive: true, force: true }); } } /** * Build a source plugin while holding an exclusive cross-process lock for its * cache key, so concurrent fan-out (parallel suites, a benchmark, a worker * pool) runs the `go build` once instead of once per process. * * `<cacheDir>.lock.v2` is a persistent coordination directory. The adjacent * `<cacheDir>.lock` path remains reserved for legacy holders and is never * reused for a v2 generation: an old holder or stale legacy reclaimer can * therefore remove only the legacy path, never a v2 successor. A contender * writes a non-empty candidate and atomically renames it to `current`; only one * rename wins. The winner builds and publishes while every loser polls and * reuses the resulting binary. A loser distinguishes two ways a generation * stops blocking: * * - `released`: the holder retired `current` itself — it published, or its build * threw and its `finally` freed the key. The loser simply retries the * ordinary acquisition; nothing is stale and nothing is reported. * - `abandoned`: `current` still exists but its owner is provably dead, it is an * old metadata-less legacy lock, or the wait budget * (`PLUGIN_BUILD_LOCK_STEAL_MS`) expired. Only then does the loser report and * retire precisely that generation before retrying. * * Retired generations remain as non-empty tombstones. Release and reclaim both * rename `current` to the observed generation's deterministic tombstone path. * Once generation A is retired, a stale observer or old finalizer for A cannot * rename successor B there because replacing the non-empty tombstone fails * atomically. `publishBuiltBinary`'s atomic rename remains defense in depth. */ function buildUnderPluginLock( cacheDir: string, binaryPath: string, lockInfo: { label: string; pluginName: string; quiet: boolean; }, build: () => string, ): string { const lockDir = `${cacheDir}.lock`; for (;;) { if (fs.existsSync(binaryPath)) { touchCacheEntry(cacheDir); return binaryPath; } let lease: PluginBuildLockLease | null; try { lease = acquirePluginBuildLock(lockDir); } catch { // An unusable coordination directory must not silently skip the build. // Atomic publication still preserves binary integrity. return build(); } if (lease === null) { const waited = waitForPluginBinary({ binaryPath, lockDir, lockInfo, timeoutMs: PLUGIN_BUILD_LOCK_STEAL_MS, }); if (waited.outcome === "published") { touchCacheEntry(cacheDir); return binaryPath; } if (waited.outcome === "abandoned") { // Retire only the generation that produced this observation. Losing // the rename race means another waiter (or the holder's normal // finalizer) already made progress, so do not report a stale result as // an abandonment. if (reclaimPluginBuildLock(lockDir, waited.fence)) { reportPluginLockSteal(lockDir, binaryPath, lockInfo, waited.reason); } } // "released" needs no repair: the holder freed the key normally (its // build published or failed), so retry the ordinary atomic acquisition. // Reporting a steal or force-removing the path here would misclassify a // routine handoff as abandonment (issue #421). continue; } try { // Re-check under the lock: a previous holder may have just published. if (fs.existsSync(binaryPath)) { touchCacheEntry(cacheDir); return binaryPath; } return build(); } finally { releasePluginBuildLock(lockDir, lease); } } } /** Opaque identity of one observed lock generation. */ export type PluginBuildLockFence = { protocol: "legacy" | "v2"; generation: string; }; /** Ownership token returned only to the process that acquired `current`. */ export type PluginBuildLockLease = { protocol: "v2"; generation: string; }; /** * Atomically acquire the current generation in a v2 coordination directory. * * A non-empty candidate is renamed to `current`. Directory rename cannot * replace a non-empty `current`, so exactly one contender wins without an * empty-owner publication window. `null` means either another v2 holder won or * the path is a legacy lock that must be observed before it can be reclaimed. * * Exported for deterministic multi-process tests. */ export function acquirePluginBuildLock( lockDir: string, ): PluginBuildLockLease | null { // A legacy holder owns the old path. Never publish v2 ownership into that // deletable namespace; wait until the legacy generation is released or // reclaimed, then use the orthogonal persistent v2 directory. if (pluginBuildLockPathExists(lockDir)) { return null; } const protocolDir = pluginBuildLockProtocolDir(lockDir); ensurePluginBuildLockProtocol(protocolDir); // Close the initialization window as far as the legacy protocol permits. A // legacy holder that appeared while v2 was initialized still blocks this // acquisition. (A legacy executable cannot provide a true cross-path CAS.) if (pluginBuildLockPathExists(lockDir)) { return null; } const generation = crypto.randomBytes(16).toString("hex"); const candidateDir = path.join(protocolDir, `candidate-${generation}`); fs.mkdirSync(candidateDir); try { fs.writeFileSync( path.join(candidateDir, PLUGIN_BUILD_LOCK_GENERATION_FILE), `${generation}\n`, { encoding: "utf8", flag: "wx" }, ); writePluginBuildLockOwner(candidateDir, generation); try { fs.renameSync( candidateDir, path.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR), ); } catch (error) { if ( isMissingPathError(error) || isRenameDestinationOccupied( error, path.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR), ) ) { return null; } throw error; } return { protocol: "v2", generation }; } finally { // The candidate name contains this process's random generation and can // never alias `current` or another contender's candidate. fs.rmSync(candidateDir, { force: true, recursive: true }); } } /** Retire a held generation during the holder's `finally`. */ export function releasePluginBuildLock( lockDir: string, lease: PluginBuildLockLease, ): boolean { return retireV2PluginBuildLock( pluginBuildLockProtocolDir(lockDir), lease.generation, ); } /** * Retire exactly the generation carried by an abandoned observation. * * Exported for deterministic multi-process tests. */ export function reclaimPluginBuildLock( lockDir: string, fence: PluginBuildLockFence, ): boolean { if (fence.protocol === "v2") { return retireV2PluginBuildLock( pluginBuildLockProtocolDir(lockDir), fence.generation, ); } return retireLegacyPluginBuildLock(lockDir, fence.generation); } function pluginBuildLockProtocolDir(lockDir: string): string { return `${lockDir}${PLUGIN_BUILD_LOCK_V2_SUFFIX}`; } function ensurePluginBuildLockProtocol(protocolDir: string): void { if (isPluginBuildLockProtocolV2(protocolDir)) { return; } const generation = crypto.randomBytes(16).toString("hex"); const candidateDir = `${protocolDir}.candidate-${generation}`; fs.mkdirSync(candidateDir); try { fs.mkdirSync(path.join(candidateDir, PLUGIN_BUILD_LOCK_RETIRED_DIR)); fs.writeFileSync( path.join(candidateDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE), PLUGIN_BUILD_LOCK_PROTOCOL, { encoding: "utf8", flag: "wx" }, ); try { fs.renameSync(candidateDir, protocolDir); } catch (error) { if ( isRenameDestinationOccupied(error, protocolDir) && isPluginBuildLockProtocolV2(protocolDir) ) { return; } throw error; } } finally { fs.rmSync(candidateDir, { force: true, recursive: true }); } } function isPluginBuildLockProtocolV2(lockDir: string): boolean { try { return ( fs.readFileSync( path.join(lockDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE), "utf8", ) === PLUGIN_BUILD_LOCK_PROTOCOL ); } catch { return false; } } function retireV2PluginBuildLock(lockDir: string, generation: string): boolean { if (!isPluginBuildLockGeneration(generation)) return false; const retiredDir = path.join(lockDir, PLUGIN_BUILD_LOCK_RETIRED_DIR); try { fs.mkdirSync(retiredDir); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") { if (isMissingPathError(error)) return false; throw error; } } const destination = path.join(retiredDir, generation); try { fs.renameSync( path.join(lockDir, PLUGIN_BUILD_LOCK_CURRENT_DIR), destination, ); return true; } catch (error) { if ( isMissingPathError(error) || isRenameDestinationOccupied(error, destination) ) { return false; } throw error; } } function retireLegacyPluginBuildLock( lockDir: string, generation: string, ): boolean { if (!isPluginBuildLockGeneration(generation)) return false; const captured = readLegacyPluginBuildLockFence( path.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR), ); if (captured?.fence.generation !== generation) { return false; } const destination = `${lockDir}.retired-${generation}`; try { fs.renameSync(lockDir, destination); return true; } catch (error) { if ( isMissingPathError(error) || isRenameDestinationOccupied(error, destination) ) { return false; } throw error; } } function isMissingPathError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException).code; return code === "ENOENT" || code === "ENOTDIR"; } function isRenameDestinationOccupied( error: unknown, destination: string, ): boolean { const code = (error as NodeJS.ErrnoException).code; if (code === "EEXIST" || code === "ENOTEMPTY") { return true; } return (code === "EACCES" || code === "EPERM") && fs.existsSync(destination); } /** * Outcome of one waiting session on another process's plugin build lock. * * - `published`: the binary exists and can be reused. * - `released`: the observed generation no longer exists and no binary appeared — * the holder freed the key normally, so the caller should retry ordinary * acquisition without reporting or removing anything. * - `abandoned`: the lock still exists but is provably stale (dead owner, old * legacy lock) or the wait budget expired; the caller may report and retire * precisely the attached generation. * * Exported for unit tests. */ export type PluginBinaryWaitResult = | { outcome: "published" } | { outcome: "released" } | { outcome: "abandoned"; reason: string; fence: PluginBuildLockFence; }; /** * Poll for the locked builder to publish its binary, up to `timeoutMs`. * * Exported for unit tests. */ export function waitForPluginBinary(opts: { binaryPath: string; lockDir: string; lockInfo: { label: string; pluginName: string; quiet: boolean; }; timeoutMs: number; }): PluginBinaryWaitResult { const startedAt = Date.now(); let nextStatusAt = startedAt + PLUGIN_BUILD_LOCK_STATUS_MS; for (;;) { if (fs.existsSync(opts.binaryPath)) { return { outcome: "published" }; } const now = Date.now(); const lock = inspectPluginBuildLock(opts.lockDir, now); if (lock.state === "released") { // The holder retired its generation between the binary check above and // this observation. That is a normal release, not abandonment: prefer the // binary when it landed inside that window, otherwise hand the free key // back to the caller. return fs.existsSync(opts.binaryPath) ? { outcome: "published" } : { outcome: "released" }; } if (lock.state === "abandoned") { return { outcome: "abandoned", reason: lock.reason, fence: lock.fence, }; } if (now - startedAt > opts.timeoutMs) { return { outcome: "abandoned", reason: `timed out after ${formatDuration(now - startedAt)}`, fence: lock.fence, }; } if (!opts.lockInfo.quiet && now >= nextStatusAt) { reportPluginLockWait({ binaryPath: opts.binaryPath, elapsedMs: now - startedAt, lockDir: opts.lockDir, lockInfo: opts.lockInfo, owner: lock.owner, }); nextStatusAt = now + PLUGIN_BUILD_LOCK_STATUS_MS; } sleepSync(PLUGIN_BUILD_LOCK_POLL_MS); } } function writePluginBuildLockOwner( generationDir: string, generation: string, ): void { fs.writeFileSync( path.join(generationDir, PLUGIN_BUILD_LOCK_OWNER_FILE), `${JSON.stringify( { generation, hostname: os.hostname(), pid: process.pid, startedAt: new Date().toISOString(), }, null, 2, )}\n`, "utf8", ); } /** * One observation of a plugin build lock directory's state. * * - `active`: the lock exists and its owner is alive (or cannot be disproven: * another host, no metadata but young). Keep waiting. * - `abandoned`: the lock still exists and the evidence says nobody will ever * release it — a same-host owner that is no longer running, or an old * metadata-less legacy lock. Retiring its fenced generation is justified. * - `released`: the observed generation no longer exists. In v2 the persistent * coordination root remains while `current` is absent. This is a routine * handoff, never an infinitely old abandoned lock (issue #421). * * Exported for unit tests. */ export type PluginBuildLockObservation = | { state: "active"; owner: string; fence: PluginBuildLockFence; } | { state: "abandoned"; reason: string; fence: PluginBuildLockFence; } | { state: "released" }; /** * Classify the current state of a plugin build lock directory. * * Exported for unit tests. */ export function inspectPluginBuildLock( lockDir: string, now: number, ): PluginBuildLockObservation { const protocolDir = pluginBuildLockProtocolDir(lockDir); for (;;) { if (isPluginBuildLockProtocolV2(protocolDir)) { const v2 = inspectV2PluginBuildLock(protocolDir, now); if (v2.state !== "released") { return v2; } } const legacy = captureLegacyPluginBuildLockFence(lockDir); if (legacy !== null) { return inspectLegacyPluginBuildLock(lockDir, now, legacy); } if (pluginBuildLockAgeMs(lockDir, now) === null) { return { state: "released" }; } // The path changed while its legacy fence was being captured. Re-observe // the replacement rather than attaching the old state to a new owner. } } function inspectV2PluginBuildLock( lockDir: string, now: number, ): PluginBuildLockObservation { const generationDir = path.join(lockDir, PLUGIN_BUILD_LOCK_CURRENT_DIR); const generation = readPluginBuildLockGeneration(generationDir); if (generation === null) { if (pluginBuildLockAgeMs(generationDir, now) === null) { return { state: "released" }; } throw new Error( `ttsc plugin build lock has no valid ${PLUGIN_BUILD_LOCK_GENERATION_FILE}: ${generationDir}`, ); } const fence: PluginBuildLockFence = { protocol: "v2", generation }; const owner = readPluginBuildLockOwner(generationDir); if (owner !== null) { const label = describePluginBuildLockOwner(owner); if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) { return { state: "abandoned", reason: `${label} is no longer running`, fence, }; } return { state: "active", owner: label, fence, }; } const ageMs = pluginBuildLockAgeMs(generationDir, now); if (ageMs === null) { return { state: "released" }; } if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) { return { state: "abandoned", reason: `lock generation has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` + `${formatDuration(ageMs)} old`, fence, }; } return { state: "active", owner: `lock generation with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`, fence, }; } interface LegacyPluginBuildLockFence { fence: PluginBuildLockFence; legacyMtimeMs: number; } function inspectLegacyPluginBuildLock( lockDir: string, now: number, legacy: LegacyPluginBuildLockFence, ): PluginBuildLockObservation { const owner = readPluginBuildLockOwner(lockDir); if (owner !== null) { const label = describePluginBuildLockOwner(owner); if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) { return { state: "abandoned", reason: `${label} is no longer running`, fence: legacy.fence, }; } return { state: "active", owner: label, fence: legacy.fence, }; } const ageMs = Math.max(0, now - legacy.legacyMtimeMs); if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) { return { state: "abandoned", reason: `legacy lock has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` + `${formatDuration(ageMs)} old`, fence: legacy.fence, }; } return { state: "active", owner: `legacy lock with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`, fence: legacy.fence, }; } function captureLegacyPluginBuildLockFence( lockDir: string, ): LegacyPluginBuildLockFence | null { if (isPluginBuildLockProtocolV2(lockDir)) { return null; } let legacyMtimeMs: number; try { legacyMtimeMs = fs.statSync(lockDir).mtimeMs; } catch (error) { if (isMissingPathError(error)) return null; throw error; } const fenceDir = path.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR); let captured = readLegacyPluginBuildLockFence(fenceDir); if (captured === null) { const generation = crypto.randomBytes(16).toString("hex"); // Keep candidates beside the legacy lock. Creating one inside `lockDir` // would advance its mtime before a contender publishes the shared fence; // a concurrent contender could then record an old lock as freshly created. const candidateDir = `${lockDir}.legacy-candidate-${generation}`; try { fs.mkdirSync(candidateDir); fs.writeFileSync( path.join(candidateDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD), `${JSON.stringify({ generation, legacyMtimeMs })}\n`, "utf8", ); try { fs.renameSync(candidateDir, fenceDir); captured = { fence: { protocol: "legacy", generation }, legacyMtimeMs, }; } catch (error) { if (isRenameDestinationOccupied(error, fenceDir)) { captured = readLegacyPluginBuildLockFence(fenceDir); } else if (isMissingPathError(error)) { return null; } else { throw error; } } } catch (error) { if (isMissingPathError(error)) { return null; } throw error; } finally { fs.rmSync(candidateDir, { force: true, recursive: true }); } } if (captured === null) { throw new Error(`invalid legacy plugin build lock fence: ${fenceDir}`); } // A stale observer can resume after the legacy holder released or another // process retired the path. Confirm both the legacy layout and token after // publication; v2 ownership is kept in the orthogonal sibling directory. const confirmed = readLegacyPluginBuildLockFence(fenceDir); if ( isPluginBuildLockProtocolV2(lockDir) || confirmed === null || confirmed.fence.generation !== captured.fence.generation ) { return null; } return captured; } function readLegacyPluginBuildLockFence( fenceDir: string, ): LegacyPluginBuildLockFence | null { try { const parsed = JSON.parse( fs.readFileSync( path.join(fenceDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD), "utf8", ), ) as Record<string, unknown>; if ( !isPluginBuildLockGeneration(parsed.generation) || typeof parsed.legacyMtimeMs !== "number" || !Number.isFinite(parsed.legacyMtimeMs) || parsed.legacyMtimeMs < 0 ) { return null; } return { fence: { protocol: "legacy", generation: parsed.generation }, legacyMtimeMs: parsed.legacyMtimeMs, }; } catch { return null; } } function readPluginBuildLockGeneration(generationDir: string): string | null { try { const generation = fs .readFileSync( path.join(generationDir, PLUGIN_BUILD_LOCK_GENERATION_FILE), "utf8", ) .trim(); return isPluginBuildLockGeneration(generation) ? generation : null; } catch { return null; } } function isPluginBuildLockGeneration(value: unknown): value is string { return typeof value === "string" && /^[0-9a-f]{32}$/.test(value); } function readPluginBuildLockOwner( lockDir: string, ): { hostname: string; pid: number; startedAt?: string } | null { try { const parsed = JSON.parse( fs.readFileSync(path.join(lockDir, PLUGIN_BUILD_LOCK_OWNER_FILE), "utf8"), ) as Record<string, unknown>; if ( typeof parsed.hostname !== "string" || !Number.isInteger(parsed.pid) || typeof parsed.pid !== "number" || parsed.pid <= 0 ) { return null; } return { hostname: parsed.hostname, pid: parsed.pid, startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined, }; } catch { return null; } } /** * Age of an observed lock directory, or `null` when it no longer exists. The * holder may have retired it between the caller's checks. "Missing" is a * observation, never encoded as a numeric age: the previous * `Number.POSITIVE_INFINITY` encoding made a just-released lock look like an * infinitely old abandoned legacy lock (issue #421). * * A stat failure that does not prove absence (e.g. `EPERM`) clamps to age 0: * the lock is treated as fresh so a waiter never steals on ambiguous evidence, * while the caller's wait budget still bounds the stall. */ function pluginBuildLockAgeMs(lockDir: string, now: number): number | null { try { return Math.max(0, now - fs.statSync(lockDir).mtimeMs); } catch (error) { const code = (error as NodeJS.ErrnoException).code; return code === "ENOENT" || code === "ENOTDIR" ? null : 0; } } function pluginBuildLockPathExists(lockDir: string): boolean { try { fs.statSync(lockDir); return true; } catch (error) { if (isMissingPathError(error)) return false; throw error; } } function isLocalHostName(hostname: string): boolean { return hostname.toLowerCase() === os.hostname().toLowerCase(); } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } } function describePluginBuildLockOwner(owner: { hostname: string; pid: number; startedAt?: string; }): string { const started = owner.startedAt === undefined ? "" : ` started at ${owner.startedAt}`; return `pid ${owner.pid} on ${owner.hostname}${started}`; } function reportPluginLockWait(opts: { binaryPath: string; elapsedMs: number; lockDir: string; lockInfo: { label: string; pluginName: string; quiet: boolean; }; owner: string; }): void { process.stderr.write( `ttsc: waiting for ${opts.lockInfo.label} "${opts.lockInfo.pluginName}" ` + `cache lock after ${formatDuration(opts.elapsedMs)}; ` + `lock=${opts.lockDir}; binary=${opts.binaryPath}; owner=${opts.owner}\n`, ); } function reportPluginLockSteal( lockDir: string, binaryPath: string, lockInfo: { label: string; pluginName: string; quiet: boolean; }, reason: string, ): void { if (lockInfo.quiet) return; process.stderr.write( `ttsc: reclaiming abandoned ${lockInfo.label} "${lockInfo.pluginName}" ` + `cache lock at ${lockDir}; binary=${binaryPath} (${reason})\n`, ); } /** * Render a millisecond duration for lock diagnostics (`137ms`, `42s`, `9m 3s`). * * Total over every number: no caller produces a non-finite duration anymore * (the lock state machine reports "released" instead of an Infinity age), but * as defense in depth a non-finite input renders as `an unknown time` so no * public diagnostic can ever print `Infinitym NaNs` again (issue #421). * * Exported for unit tests. */ export function formatDuration(ms: number): string { if (!Number.isFinite(ms)) { return "an unknown time"; } if (ms < 1_000) { return `${Math.max(0, Math.round(ms))}ms`; } const seconds = Math.floor(ms / 1_000); const minutes = Math.floor(seconds / 60); const remainder = seconds % 60; if (minutes === 0) { return `${seconds}s`; } return `${minutes}m ${remainder}s`; } /** Block the current (synchronous) thread for `ms` without busy-spinning. */ function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } /** * Copy every contributor's Go source into a sub-package of the host module and * synthesize a blank-import file alongside the host's entry package so each * contributor's `init()` runs before `main`. * * - Sources land at `<scratch>/<CONTRIB_DIRNAME>/<name>/` (recursive copy with * the same pruning rules used for the host source). * - The entry directory receives `<CONTRIBUTIONS_FILE_NAME>` containing one * blank-import per contributor. The host's module path is read from the * materialized go.mod, so the import path is always correct for the host * plugin's actual module declaration. * - Contributors that ship their own `go.mod` are rejected — the design relies on * the contributor living inside the host's module so that workspace overlay * rules and the host's `go.sum` cover transitive dependencies. This also * closes the supply-chain hole where a contributor could otherwise pull in * arbitrary Go modules. */ function mergeContributors(opts: { contributors: readonly ITtscBuildContributor[]; entry: string; goModReader: GoModReader; pluginName: string; scratchDir: string; }): void { const hostModulePath = opts.goModReader.read(opts.scratchDir).modulePath; if (hostModulePath === null || hostModulePath === "") { throw new Error( `ttsc: plugin "${opts.pluginName}" cannot accept contributors because its module ` + `root has no resolvable go.mod module path`, ); } const contribRoot = path.join(opts.scratchDir, CONTRIB_DIRNAME); // Refuse to merge when the host plugin's own source already owns a // `contrib/` directory. We'd otherwise silently merge contributor // files into a pre-populated host package and ship a hybrid binary // whose contents nobody declared. Loud failure is the only safe // option — the host plugin must rename its directory or the // contributor system must use a different sub-package root. if (fs.existsSync(contribRoot)) { throw new Error( `ttsc: plugin "${opts.pluginName}" already ships a ${CONTRIB_DIRNAME}/ directory in its source; ` + `contributor merge would silently overwrite. Rename the host plugin's directory to a different name.`, ); } fs.mkdirSync(contribRoot, { recursive: true }); // Sort contributors by name so the synthesized `ttsc_contributions.go` // emits blank imports in a deterministic order independent of // declaration order. The cache key is already sort-stable // (`computeCacheKey` sorts contributors by name), so without this // matching sort the SAME cache key could correspond to two distinct // binaries whose `init()` sequence across contributors differs by // import order. const sortedContributors = [...opts.contributors].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0, ); const imports: string[] = []; for (const contributor of sortedContributors) { if (fs.existsSync(path.join(contributor.source, "go.mod"))) { throw new Error( `ttsc: plugin "${opts.pluginName}" contributor "${contributor.name}" must ship Go ` + `source as a package, not a module (go.mod found at ${contributor.source}/go.mod). ` + `Remove go.mod so the contributor compiles inside the host module's dependency graph.`, ); } const target = path.join(contribRoot, contributor.name); if (fs.existsSync(target)) { // Defensive: validatePluginContributors already rejects duplicate // names, and the contribRoot-existence guard above blocks the // host plugin from pre-shipping a `contrib/` directory. Reaching // this branch implies an upstream contract break. Fail loud // rather than overwrite. throw new Error( `ttsc: plugin "${opts.pluginName}" contributor "${contributor.name}" target ${target} already exists; ` + `contributor names must be unique within one plugin build`, ); } fs.cpSync(contributor.source, target, { recursive: true, filter: (src) => { const base = path.basename(src); if (shouldPruneDirectory(base)) return false; if (shouldOmitSourceFile(base)) return false; return true; }, }); imports.push(`${hostModulePath}/${CONTRIB_DIRNAME}/${contributor.name}`); } const entryDir = path.resolve(opts.scratchDir, opts.entry); fs.mkdirSync(entryDir, { recursive: true }); const contributionsPath = path.join(entryDir, CONTRIBUTIONS_FILE_NAME); // Same reasoning as the contribRoot guard: when entry resolves to the // module root (`entry === "."`), entryDir == scratchDir and a // pre-existing `ttsc_contributions.go` from the host plugin's own // source would be silently overwritten by the generator below. if (fs.existsSync(contributionsPath)) { throw new Error( `ttsc: plugin "${opts.pluginName}" already ships ${CONTRIBUTIONS_FILE_NAME} in its entry package; ` + `that filename is reserved for the contributor blank-import generator. Rename the host's file.`, ); } writeContributionsFile(contributionsPath, imports); } function writeContributionsFile(filePath: string, imports: string[]): void { const importLines = imports .map((spec) => `\t_ ${JSON.stringify(spec)}`) .join("\n"); const body = `// Code generated by ttsc — DO NOT EDIT. // // This file is synthesized by ttsc's plugin builder when the host plugin // descriptor declares "contributors". The blank imports below pull each // contributor sub-package into the build so its init() runs before main. package main import ( ${importLines} ) `; fs.writeFileSync(filePath, body, "utf8"); } function publishBuiltBinary(builtBinary: string, binaryPath: string): void { const pending = `${binaryPath}.${process.pid}.${Date.now()}-${Math.random() .toString(16) .slice(2)}.tmp`; fs.copyFileSync(builtBinary, pending); if (process.platform !== "win32") { fs.chmodSync(pending, 0o755); } try { fs.renameSync(pending, binaryPath); } catch (error) { fs.rmSync(pending, { force: true }); const code = (error as NodeJS.ErrnoException).code; if ( (code === "EEXIST" || code === "EPERM" || code === "EACCES") && fs.existsSync(binaryPath) ) { return; } throw error; } finally { // Best-effort sweep of any leftover `.tmp` siblings from a prior // crash between copyFileSync and renameSync. Same-directory pending // names guarantee the rename stays a same-filesystem atomic op, so // we accept the GC cost rather than move pending files to os.tmpdir. pruneOrphanPendingBinaries(binaryPath); } } function pruneOrphanPendingBinaries(binaryPath: string): void { // Only sweep pending files owned by THIS process. Concurrent ttsc // invocations (two `ttsc --watch` shells against the same project) // may have their own `<binary>.<their-pid>.*.tmp` mid-flight, and // deleting them would race their renameSync into ENOENT. try { const dir = path.dirname(binaryPath); const prefix = `${path.basename(binaryPath)}.${process.pid}.`; for (const name of fs.readdirSync(dir)) { if (name.startsWith(prefix) && name.endsWith(".tmp")) { fs.rmSync(path.join(dir, name), { force: true }); } } } catch { // Best-effort; never mask the underlying publish outcome. } } function resolveSourceBuildTarget(opts: { source: string; pluginName: string; baseDir: string; }): { dir: string; entry: string; source: string; } { const source = path.isAbsolute(opts.source) ? opts.source : path.resolve(opts.baseDir, opts.source); if (!fs.existsSync(source)) { throw new Error( `ttsc: plugin "${opts.pluginName}" source does not exist: ${source}`, ); } const stat = fs.statSync(source); const packageDir = stat.isFile() && path.basename(source) === "go.mod" ? path.dirname(source) : stat.isDirectory() ? source : null; if (packageDir === null) { throw new Error( `ttsc: plugin "${opts.pluginName}" source must be a Go package directory or go.mod file: ${source}`, ); } const goMod = findNearestGoMod(packageDir, GO_MOD_SEARCH_MAX_DEPTH); if (goMod === null) { throw new Error( `ttsc: plugin "${opts.pluginName}" source must be inside a Go module with go.mod within ${GO_MOD_SEARCH_MAX_DEPTH} parent directories: ${source}`, ); } const dir = path.dirname(goMod); const rel = path.relative(dir, packageDir).replace(/\\/g, "/"); return { dir, entry: rel === "" ? "." : `./${rel}`, source, }; } function materializeScratchDir(source: string, scratch: string): void { fs.mkdirSync(scratch, { recursive: true }); fs.cpSync(source, scratch, { recursive: true, filter: (src) => { const base = path.basename(src); if (shouldPruneDirectory(base)) return false; if (shouldOmitSourceFile(base)) return false; return true; }, }); } function writeGoWork( scratchDir: string, useDirs: readonly string[], goBinary: string, pluginName: string, env: NodeJS.ProcessEnv, ): void { const goModReader = createGoModReader(goBinary, pluginName, env); validateSourceReplacements(scratchDir, useDirs, goModReader, pluginName); const sourceInfo = goModReader.read(scratchDir); const effectiveUseDirs = sourceInfo.modulePath === TTSC_GO_MODULE_PATH ? useDirs.filter((dir) => { const modulePath = goModReader.read(dir).modulePath; return modulePath !== null && !isTtscManagedModulePath(modulePath); }) : useDirs; const useLines = ["\t."]; for (const dir of effectiveUseDirs) { useLines.push(`\t${formatGoWorkPath(dir)}`); } const replaceLines = sourceBuildWorkspaceReplacements( effectiveUseDirs, goModReader, ); const replaceBlock = replaceLines.length === 0 ? "" : `\n\n${replaceLines.join("\n")}\n`; const goWork = `go 1.26\n\nuse (\n${useLines.join("\n")}\n)${replaceBlock}`; fs.writeFileSync(path.join(scratchDir, "go.work"), goWork, "utf8"); } function validateSourceReplacements( scratchDir: string, useDirs: readonly string[], goModReader: GoModReader, pluginName: string, ): void { const sourceInfo = goModReader.read(scratchDir); if (sourceInfo.modulePath === TTSC_GO_MODULE_PATH) { return; } const sourceReplacements = sourceInfo.replacements; if (sourceReplacements.length === 0) { return; } const overlayModules = collectOverlayModulePaths(useDirs, goModReader); for (const replacement of sourceReplacements) { if ( isTtscManagedModulePath(replacement.modulePath) || overlayModules.has(replacement.modulePath) ) { throw new Error( `ttsc: plugin "${pluginName}" go.mod replaces ttsc-managed module ` + `${JSON.stringify(replacement.modulePath)}. Remove this replace directive; ` + `ttsc supplies its own compiler and shim modules while building source plugins.`, ); } } } function sourceBuildWorkspaceReplacements( useDirs: readonly string[], goModReader: GoModReader, ): string[] { const ttscRoot = useDirs.find( (dir) => goModReader.read(dir).modulePath === TTSC_GO_MODULE_PATH, ); if (!ttscRoot) { return []; } return [ `replace ${TTSC_GO_MODULE_PATH} v0.0.0 => ${formatGoWorkPath(ttscRoot)}`, ]; } /** * Format an absolute filesystem path as a single `go.work`/`go.mod` token. * * The modfile grammar shared by `go.mod` and `go.work` (parsed by * `golang.org/x/mod/modfile`) is whitespace-tokenized, so a `use`/`replace` * path that contains a space — a home or project directory such as `/Users/John * Smith/...` or `C:\Users\John Smith\...` — must be emitted as a quoted string * or `go` cannot parse the generated `go.work`. Normalize Windows separators to * `/` (the workspace convention) and then delegate to * {@link autoQuoteGoModToken}, which mirrors `modfile.AutoQuote`. * * Separator normalization is itself a quoting trigger. A Windows UNC * (`\\server\share\...`) or extended-length (`\\?\C:\...`) path normalizes into * a token that starts with `//`, and the modfile lexer reads `//` as a line * comment wherever it appears. Emitted bare, such a token turns its whole * `use`/`replace` line into a comment: `go` exits 0, reports nothing, and the * overlay module simply disappears from the workspace. * * Exported for unit tests. */ export function formatGoWorkPath(p: string): string { return autoQuoteGoModToken(p.replace(/\\/g, "/")); } /** * Quote `token` for a `go.mod`/`go.work` line exactly as * `golang.org/x/mod/modfile`'s `AutoQuote` does: return it unchanged when it is * already a clean bare token, otherwise return its Go double-quoted form so the * value round-trips through the modfile lexer. A clean bare token is therefore * emitted byte-for-byte as before; only tokens that would otherwise be split or * interpreted as comments are quoted. * * Exported for unit tests. */ export function autoQuoteGoModToken(token: string): string { return mustQuoteGoModToken(token) ? goQuoteString(token) : token; } // Mirror `modfile.MustQuote`: report whether `s` must be quoted to appear as a // single token on a modfile line. function mustQuoteGoModToken(s: string): boolean { for (const ch of s) { if (ch === " " || ch === '"' || ch === "'" || ch === "`") { return true; } if ( ch === "(" || ch === ")" || ch === "[" || ch === "]" || ch === "{" || ch === "}" || ch === "," ) { // Go tests `len(s) > 1` (byte length): a lone bracket/comma is a legal // bare token, but one embedded in a longer token forces quoting. if (Buffer.byteLength(s, "utf8") > 1) { return true; } continue; } if (!isGoPrintable(ch)) { return true; } } return s === "" || s.includes("//") || s.includes("/*"); } // Mirror `strconv.Qu