UNPKG

ttsc

Version:

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

1,203 lines (1,200 loc) 95.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.buildSourcePlugin = buildSourcePlugin; exports.acquirePluginBuildLock = acquirePluginBuildLock; exports.releasePluginBuildLock = releasePluginBuildLock; exports.reclaimPluginBuildLock = reclaimPluginBuildLock; exports.waitForPluginBinary = waitForPluginBinary; exports.inspectPluginBuildLock = inspectPluginBuildLock; exports.formatDuration = formatDuration; exports.formatGoWorkPath = formatGoWorkPath; exports.autoQuoteGoModToken = autoQuoteGoModToken; exports.spawnGoTool = spawnGoTool; exports.windowsGoCommandArgs = windowsGoCommandArgs; exports.resolvePluginCacheRoot = resolvePluginCacheRoot; exports.resolveSourceBuildCachePaths = resolveSourceBuildCachePaths; exports.resolveCleanTargets = resolveCleanTargets; exports.legacyGlobalCacheTargets = legacyGlobalCacheTargets; exports.isPathWithin = isPathWithin; exports.computeCacheKey = computeCacheKey; const node_child_process_1 = require("node:child_process"); const node_crypto_1 = __importDefault(require("node:crypto")); const node_fs_1 = __importDefault(require("node:fs")); const node_module_1 = require("node:module"); const node_os_1 = __importDefault(require("node:os")); const node_path_1 = __importDefault(require("node:path")); const node_url_1 = require("node:url"); const captureProcessOutput_1 = require("../../compiler/internal/captureProcessOutput"); const paths_1 = require("../../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 = [ "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 = [ "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 = ["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; /** * 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. */ function buildSourcePlugin(opts) { 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 = node_path_1.default.join(paths.pluginRoot, key); const binaryName = process.platform === "win32" ? "plugin.exe" : "plugin"; const binaryPath = node_path_1.default.join(cacheDir, binaryName); if (node_fs_1.default.existsSync(binaryPath)) { touchCacheEntry(cacheDir); return binaryPath; } node_fs_1.default.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) { 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 = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.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 = node_path_1.default.join(scratchDir, scratchBinaryName); publishBuiltBinary(builtBinary, opts.binaryPath); touchCacheEntry(opts.cacheDir); return opts.binaryPath; } finally { node_fs_1.default.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, binaryPath, lockInfo, build) { const lockDir = `${cacheDir}.lock`; for (;;) { if (node_fs_1.default.existsSync(binaryPath)) { touchCacheEntry(cacheDir); return binaryPath; } let lease; 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 (node_fs_1.default.existsSync(binaryPath)) { touchCacheEntry(cacheDir); return binaryPath; } return build(); } finally { releasePluginBuildLock(lockDir, lease); } } } /** * 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. */ function acquirePluginBuildLock(lockDir) { // 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 = node_crypto_1.default.randomBytes(16).toString("hex"); const candidateDir = node_path_1.default.join(protocolDir, `candidate-${generation}`); node_fs_1.default.mkdirSync(candidateDir); try { node_fs_1.default.writeFileSync(node_path_1.default.join(candidateDir, PLUGIN_BUILD_LOCK_GENERATION_FILE), `${generation}\n`, { encoding: "utf8", flag: "wx" }); writePluginBuildLockOwner(candidateDir, generation); try { node_fs_1.default.renameSync(candidateDir, node_path_1.default.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR)); } catch (error) { if (isMissingPathError(error) || isRenameDestinationOccupied(error, node_path_1.default.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. node_fs_1.default.rmSync(candidateDir, { force: true, recursive: true }); } } /** Retire a held generation during the holder's `finally`. */ function releasePluginBuildLock(lockDir, lease) { return retireV2PluginBuildLock(pluginBuildLockProtocolDir(lockDir), lease.generation); } /** * Retire exactly the generation carried by an abandoned observation. * * Exported for deterministic multi-process tests. */ function reclaimPluginBuildLock(lockDir, fence) { if (fence.protocol === "v2") { return retireV2PluginBuildLock(pluginBuildLockProtocolDir(lockDir), fence.generation); } return retireLegacyPluginBuildLock(lockDir, fence.generation); } function pluginBuildLockProtocolDir(lockDir) { return `${lockDir}${PLUGIN_BUILD_LOCK_V2_SUFFIX}`; } function ensurePluginBuildLockProtocol(protocolDir) { if (isPluginBuildLockProtocolV2(protocolDir)) { return; } const generation = node_crypto_1.default.randomBytes(16).toString("hex"); const candidateDir = `${protocolDir}.candidate-${generation}`; node_fs_1.default.mkdirSync(candidateDir); try { node_fs_1.default.mkdirSync(node_path_1.default.join(candidateDir, PLUGIN_BUILD_LOCK_RETIRED_DIR)); node_fs_1.default.writeFileSync(node_path_1.default.join(candidateDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE), PLUGIN_BUILD_LOCK_PROTOCOL, { encoding: "utf8", flag: "wx" }); try { node_fs_1.default.renameSync(candidateDir, protocolDir); } catch (error) { if (isRenameDestinationOccupied(error, protocolDir) && isPluginBuildLockProtocolV2(protocolDir)) { return; } throw error; } } finally { node_fs_1.default.rmSync(candidateDir, { force: true, recursive: true }); } } function isPluginBuildLockProtocolV2(lockDir) { try { return (node_fs_1.default.readFileSync(node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE), "utf8") === PLUGIN_BUILD_LOCK_PROTOCOL); } catch { return false; } } function retireV2PluginBuildLock(lockDir, generation) { if (!isPluginBuildLockGeneration(generation)) return false; const retiredDir = node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_RETIRED_DIR); try { node_fs_1.default.mkdirSync(retiredDir); } catch (error) { if (error.code !== "EEXIST") { if (isMissingPathError(error)) return false; throw error; } } const destination = node_path_1.default.join(retiredDir, generation); try { node_fs_1.default.renameSync(node_path_1.default.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, generation) { if (!isPluginBuildLockGeneration(generation)) return false; const captured = readLegacyPluginBuildLockFence(node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR)); if (captured?.fence.generation !== generation) { return false; } const destination = `${lockDir}.retired-${generation}`; try { node_fs_1.default.renameSync(lockDir, destination); return true; } catch (error) { if (isMissingPathError(error) || isRenameDestinationOccupied(error, destination)) { return false; } throw error; } } function isMissingPathError(error) { const code = error.code; return code === "ENOENT" || code === "ENOTDIR"; } function isRenameDestinationOccupied(error, destination) { const code = error.code; if (code === "EEXIST" || code === "ENOTEMPTY") { return true; } return (code === "EACCES" || code === "EPERM") && node_fs_1.default.existsSync(destination); } /** * Poll for the locked builder to publish its binary, up to `timeoutMs`. * * Exported for unit tests. */ function waitForPluginBinary(opts) { const startedAt = Date.now(); let nextStatusAt = startedAt + PLUGIN_BUILD_LOCK_STATUS_MS; for (;;) { if (node_fs_1.default.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 node_fs_1.default.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, generation) { node_fs_1.default.writeFileSync(node_path_1.default.join(generationDir, PLUGIN_BUILD_LOCK_OWNER_FILE), `${JSON.stringify({ generation, hostname: node_os_1.default.hostname(), pid: process.pid, startedAt: new Date().toISOString(), }, null, 2)}\n`, "utf8"); } /** * Classify the current state of a plugin build lock directory. * * Exported for unit tests. */ function inspectPluginBuildLock(lockDir, now) { 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, now) { const generationDir = node_path_1.default.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 = { 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, }; } function inspectLegacyPluginBuildLock(lockDir, now, legacy) { 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) { if (isPluginBuildLockProtocolV2(lockDir)) { return null; } let legacyMtimeMs; try { legacyMtimeMs = node_fs_1.default.statSync(lockDir).mtimeMs; } catch (error) { if (isMissingPathError(error)) return null; throw error; } const fenceDir = node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR); let captured = readLegacyPluginBuildLockFence(fenceDir); if (captured === null) { const generation = node_crypto_1.default.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 { node_fs_1.default.mkdirSync(candidateDir); node_fs_1.default.writeFileSync(node_path_1.default.join(candidateDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD), `${JSON.stringify({ generation, legacyMtimeMs })}\n`, "utf8"); try { node_fs_1.default.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 { node_fs_1.default.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) { try { const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(fenceDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD), "utf8")); 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) { try { const generation = node_fs_1.default .readFileSync(node_path_1.default.join(generationDir, PLUGIN_BUILD_LOCK_GENERATION_FILE), "utf8") .trim(); return isPluginBuildLockGeneration(generation) ? generation : null; } catch { return null; } } function isPluginBuildLockGeneration(value) { return typeof value === "string" && /^[0-9a-f]{32}$/.test(value); } function readPluginBuildLockOwner(lockDir) { try { const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(lockDir, PLUGIN_BUILD_LOCK_OWNER_FILE), "utf8")); 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, now) { try { return Math.max(0, now - node_fs_1.default.statSync(lockDir).mtimeMs); } catch (error) { const code = error.code; return code === "ENOENT" || code === "ENOTDIR" ? null : 0; } } function pluginBuildLockPathExists(lockDir) { try { node_fs_1.default.statSync(lockDir); return true; } catch (error) { if (isMissingPathError(error)) return false; throw error; } } function isLocalHostName(hostname) { return hostname.toLowerCase() === node_os_1.default.hostname().toLowerCase(); } function isProcessAlive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } } function describePluginBuildLockOwner(owner) { const started = owner.startedAt === undefined ? "" : ` started at ${owner.startedAt}`; return `pid ${owner.pid} on ${owner.hostname}${started}`; } function reportPluginLockWait(opts) { 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, binaryPath, lockInfo, reason) { 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. */ function formatDuration(ms) { 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) { 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) { 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 = node_path_1.default.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 (node_fs_1.default.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.`); } node_fs_1.default.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 = []; for (const contributor of sortedContributors) { if (node_fs_1.default.existsSync(node_path_1.default.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 = node_path_1.default.join(contribRoot, contributor.name); if (node_fs_1.default.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`); } node_fs_1.default.cpSync(contributor.source, target, { recursive: true, filter: (src) => { const base = node_path_1.default.basename(src); if (shouldPruneDirectory(base)) return false; if (shouldOmitSourceFile(base)) return false; return true; }, }); imports.push(`${hostModulePath}/${CONTRIB_DIRNAME}/${contributor.name}`); } const entryDir = node_path_1.default.resolve(opts.scratchDir, opts.entry); node_fs_1.default.mkdirSync(entryDir, { recursive: true }); const contributionsPath = node_path_1.default.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 (node_fs_1.default.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, imports) { 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} ) `; node_fs_1.default.writeFileSync(filePath, body, "utf8"); } function publishBuiltBinary(builtBinary, binaryPath) { const pending = `${binaryPath}.${process.pid}.${Date.now()}-${Math.random() .toString(16) .slice(2)}.tmp`; node_fs_1.default.copyFileSync(builtBinary, pending); if (process.platform !== "win32") { node_fs_1.default.chmodSync(pending, 0o755); } try { node_fs_1.default.renameSync(pending, binaryPath); } catch (error) { node_fs_1.default.rmSync(pending, { force: true }); const code = error.code; if ((code === "EEXIST" || code === "EPERM" || code === "EACCES") && node_fs_1.default.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) { // 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 = node_path_1.default.dirname(binaryPath); const prefix = `${node_path_1.default.basename(binaryPath)}.${process.pid}.`; for (const name of node_fs_1.default.readdirSync(dir)) { if (name.startsWith(prefix) && name.endsWith(".tmp")) { node_fs_1.default.rmSync(node_path_1.default.join(dir, name), { force: true }); } } } catch { // Best-effort; never mask the underlying publish outcome. } } function resolveSourceBuildTarget(opts) { const source = node_path_1.default.isAbsolute(opts.source) ? opts.source : node_path_1.default.resolve(opts.baseDir, opts.source); if (!node_fs_1.default.existsSync(source)) { throw new Error(`ttsc: plugin "${opts.pluginName}" source does not exist: ${source}`); } const stat = node_fs_1.default.statSync(source); const packageDir = stat.isFile() && node_path_1.default.basename(source) === "go.mod" ? node_path_1.default.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 = (0, paths_1.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 = node_path_1.default.dirname(goMod); const rel = node_path_1.default.relative(dir, packageDir).replace(/\\/g, "/"); return { dir, entry: rel === "" ? "." : `./${rel}`, source, }; } function materializeScratchDir(source, scratch) { node_fs_1.default.mkdirSync(scratch, { recursive: true }); node_fs_1.default.cpSync(source, scratch, { recursive: true, filter: (src) => { const base = node_path_1.default.basename(src); if (shouldPruneDirectory(base)) return false; if (shouldOmitSourceFile(base)) return false; return true; }, }); } function writeGoWork(scratchDir, useDirs, goBinary, pluginName, env) { 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}`; node_fs_1.default.writeFileSync(node_path_1.default.join(scratchDir, "go.work"), goWork, "utf8"); } function validateSourceReplacements(scratchDir, useDirs, goModReader, pluginName) { 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, goModReader) { 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. */ function formatGoWorkPath(p) { 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. */ function autoQuoteGoModToken(token) { 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) { 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.Quote`: wrap in double quotes, backslash-escape `"` and `\`, // emit Go-printable runes verbatim (including the ASCII space and printable // Unicode), and escape everything else with Go's `\a\b\f\n\r\t\v` / `\xNN` / // `\uNNNN` / `\UNNNNNNNN` forms so the token round-trips through // `strconv.Unquote` in the modfile lexer. function goQuoteString(s) { let out = '"'; for (const ch of s) { if (ch === '"' || ch === "\\") { out += `\\${ch}`; continue; } if (isGoPrintable(ch)) { out += ch; continue; } out += escapeGoRune(ch); } return `${out}"`; } function escapeGoRune(ch) { switch (ch) { case "\x07": return "\\a"; case "\b": return "\\b"; case "\f": return "\\f"; case "\n": return "\\n"; case "\r": return "\\r"; case "\t": return "\\t"; case "\v": return "\\v"; default: { const cp = ch.codePointAt(0) ?? 0; if (cp < 0x20 || cp === 0x7f) {