From 2302ee6244c383885463f699ed3871dc1ba6605c Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:13:09 +0900 Subject: [PATCH 01/11] chore(campaign): claim runtime boundary fixes From 7fe88d3ff64d1638b0d1fc83f012ab44b59b4245 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:16:15 +0900 Subject: [PATCH 02/11] Close #1018: share factory comments across copies --- packages/factory/README.md | 2 + packages/factory/src/comments.ts | 50 +++++++++++++++-- scripts/ci/factory-package.test.cjs | 87 +++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/packages/factory/README.md b/packages/factory/README.md index 8339d6e3cb..e5f841cffd 100644 --- a/packages/factory/README.md +++ b/packages/factory/README.md @@ -123,6 +123,8 @@ new TsPrinter().print(node); Companion helpers: `addSyntheticTrailingComment`, `get`/`setSyntheticLeadingComments`, `get`/`setSyntheticTrailingComments`. +Synthetic comments are side-band data: they do not add properties to the node, so comments can be attached to frozen nodes. Compatible copies loaded in the same JavaScript realm share the versioned weak stores, including a CommonJS `require()` and an ES module `import()` of one installation or multiple installed copies. A copy from an older release that predates this registry cannot see comments written by a newer copy (or expose its private comments to one); upgrade every copy that exchanges nodes. + ## Coverage The factory and printer cover the constructs most used for code generation: identifiers, literals, the common expressions, types (keyword / reference / union / intersection / array / tuple / type-literal / function / operator / ...), statements, classes & interfaces, enums, functions & arrow functions, and import / export declarations. Coverage is easy to extend — add the node under `src/ast/`, a builder under `src/factory/`, and a `case` to the printer. diff --git a/packages/factory/src/comments.ts b/packages/factory/src/comments.ts index 398f177ddb..4ab248e07e 100644 --- a/packages/factory/src/comments.ts +++ b/packages/factory/src/comments.ts @@ -4,10 +4,11 @@ * * The legacy TypeScript compiler stores synthesized comments on a side-band * `node.emitNode` slot rather than on the node itself; this module reproduces - * that behaviour with a {@link WeakMap} so the {@link import("./ast").Node} - * interfaces stay free of printer-only metadata. {@link TsPrinter} consults - * these stores while emitting and renders the comments verbatim — a leading - * comment is printed before the node, a trailing comment after it. + * that behaviour with realm-shared {@link WeakMap}s so the + * {@link import("./ast").Node} interfaces stay free of printer-only metadata and + * frozen nodes remain valid targets. {@link TsPrinter} consults these stores + * while emitting and renders the comments verbatim — a leading comment is + * printed before the node, a trailing comment after it. * * ```typescript * import factory, { @@ -57,8 +58,45 @@ export interface SynthesizedComment { hasLeadingNewLine?: boolean; } -const leadingStore = new WeakMap(); -const trailingStore = new WeakMap(); +interface SyntheticCommentStores { + leading: WeakMap; + trailing: WeakMap; +} + +/** + * One versioned registry per JavaScript realm. + * + * A package can be loaded through both its CommonJS and ESM entry points, and + * package managers may install multiple physical copies. Module-local WeakMaps + * make those copies silently lose each other's comments even though factory + * nodes are otherwise structural. A well-known, versioned key gives every + * compatible copy the same side-band stores without writing metadata to the + * node, so `Object.freeze(node)` keeps working as it did before. + * + * The version belongs to the stored value shape. A future incompatible shape + * must use another key rather than reinterpret an older registry. + */ +const SYNTHETIC_COMMENT_STORES = Symbol.for( + "@ttsc/factory.syntheticComments.v1", +); + +const commentStores = (): SyntheticCommentStores => { + const existing: unknown = Reflect.get(globalThis, SYNTHETIC_COMMENT_STORES); + if (existing !== undefined) return existing as SyntheticCommentStores; + const created: SyntheticCommentStores = { + leading: new WeakMap(), + trailing: new WeakMap(), + }; + Object.defineProperty(globalThis, SYNTHETIC_COMMENT_STORES, { + configurable: false, + enumerable: false, + value: created, + writable: false, + }); + return created; +}; + +const { leading: leadingStore, trailing: trailingStore } = commentStores(); const append = ( store: WeakMap, diff --git a/scripts/ci/factory-package.test.cjs b/scripts/ci/factory-package.test.cjs index 00876afd86..05c06f501f 100644 --- a/scripts/ci/factory-package.test.cjs +++ b/scripts/ci/factory-package.test.cjs @@ -4,6 +4,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { createRequire } = require("node:module"); +const { pathToFileURL } = require("node:url"); const test = require("node:test"); const { @@ -62,7 +63,7 @@ test("the canonical full plans cover every publishable package build", () => { ); }); -test("the factory publication entry points load from built artifacts", () => { +test("the factory publication entry points load from built artifacts", async () => { const manifest = JSON.parse( fs.readFileSync(path.join(factoryRoot, "package.json"), "utf8"), ); @@ -91,10 +92,19 @@ test("the factory publication entry points load from built artifacts", () => { const requireFromConsumer = createRequire( path.join(workspace, "consumer.cjs"), ); - assertFactorySurface( - requireFromConsumer("@ttsc/factory"), - "CommonJS published entry", + const commonjs = requireFromConsumer("@ttsc/factory"); + assertFactorySurface(commonjs, "CommonJS published entry"); + const module = await import( + pathToFileURL(path.join(packageRoot, "lib", "index.mjs")).href ); + assertCrossCopyComments(commonjs, module, "CommonJS/ESM format split"); + + const duplicateRoot = path.join(workspace, "duplicate-factory"); + fs.cpSync(packageRoot, duplicateRoot, { recursive: true }); + const duplicate = requireFromConsumer( + path.join(duplicateRoot, "lib", "index.js"), + ); + assertCrossCopyComments(commonjs, duplicate, "physical package split"); const esmConsumer = path.join(workspace, "consumer.mjs"); fs.writeFileSync( @@ -180,6 +190,75 @@ function assertFactorySurface(exports, label) { ); } +function assertCrossCopyComments(writer, reader, label) { + assert.notEqual( + writer.addSyntheticLeadingComment, + reader.addSyntheticLeadingComment, + `${label} did not load independent modules`, + ); + + const leading = Object.freeze( + writer.default.createTypeAliasDeclaration( + undefined, + "Leading", + undefined, + writer.default.createKeywordTypeNode(writer.SyntaxKind.StringKeyword), + ), + ); + writer.addSyntheticLeadingComment( + leading, + writer.SyntaxKind.MultiLineCommentTrivia, + " shared leading ", + true, + ); + assert.equal( + reader.getSyntheticLeadingComments(leading)?.[0]?.text, + " shared leading ", + `${label} lost a leading comment`, + ); + assert.match( + new reader.TsPrinter().print(leading), + /\/\* shared leading \*\/\ntype Leading = string;/, + `${label} printer lost a leading comment`, + ); + reader.setSyntheticLeadingComments(leading, undefined); + assert.equal( + writer.getSyntheticLeadingComments(leading), + undefined, + `${label} clear did not reach the writer`, + ); + + const trailing = Object.freeze( + reader.default.createTypeAliasDeclaration( + undefined, + "Trailing", + undefined, + reader.default.createKeywordTypeNode(reader.SyntaxKind.NumberKeyword), + ), + ); + reader.addSyntheticTrailingComment( + trailing, + reader.SyntaxKind.MultiLineCommentTrivia, + " shared trailing ", + ); + assert.equal( + writer.getSyntheticTrailingComments(trailing)?.[0]?.text, + " shared trailing ", + `${label} lost a reverse-direction trailing comment`, + ); + assert.match( + new writer.TsPrinter().print(trailing), + /type Trailing = number; \/\* shared trailing \*\//, + `${label} printer lost a reverse-direction trailing comment`, + ); + writer.setSyntheticTrailingComments(trailing, []); + assert.equal( + reader.getSyntheticTrailingComments(trailing), + undefined, + `${label} reverse-direction clear did not reach the writer`, + ); +} + function assertSucceeded(result, label) { if (result.error) throw result.error; assert.equal( From 4a84a9647e8b6c379f9042c06ba7dc7fe014e6e9 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:21:36 +0900 Subject: [PATCH 03/11] fix(factory): preserve hardened-realm loading --- packages/factory/README.md | 2 +- packages/factory/src/comments.ts | 32 +++++++++++++++++++---------- scripts/ci/factory-package.test.cjs | 18 ++++++++++++++++ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/factory/README.md b/packages/factory/README.md index e5f841cffd..273a17d881 100644 --- a/packages/factory/README.md +++ b/packages/factory/README.md @@ -123,7 +123,7 @@ new TsPrinter().print(node); Companion helpers: `addSyntheticTrailingComment`, `get`/`setSyntheticLeadingComments`, `get`/`setSyntheticTrailingComments`. -Synthetic comments are side-band data: they do not add properties to the node, so comments can be attached to frozen nodes. Compatible copies loaded in the same JavaScript realm share the versioned weak stores, including a CommonJS `require()` and an ES module `import()` of one installation or multiple installed copies. A copy from an older release that predates this registry cannot see comments written by a newer copy (or expose its private comments to one); upgrade every copy that exchanges nodes. +Synthetic comments are side-band data: they do not add properties to the node, so comments can be attached to frozen nodes. Compatible copies loaded in the same JavaScript realm share the versioned weak stores, including a CommonJS `require()` and an ES module `import()` of one installation or multiple installed copies. If a hardened realm prevents adding the registry to its global object before the first copy loads, each module falls back to private weak stores: comments and frozen nodes still work within that copy, but copies cannot exchange comments. A copy from an older release that predates this registry likewise cannot see comments written by a newer copy (or expose its private comments to one); upgrade every copy that exchanges nodes. ## Coverage diff --git a/packages/factory/src/comments.ts b/packages/factory/src/comments.ts index 4ab248e07e..fee96dca8b 100644 --- a/packages/factory/src/comments.ts +++ b/packages/factory/src/comments.ts @@ -80,20 +80,30 @@ const SYNTHETIC_COMMENT_STORES = Symbol.for( "@ttsc/factory.syntheticComments.v1", ); +const createCommentStores = (): SyntheticCommentStores => ({ + leading: new WeakMap(), + trailing: new WeakMap(), +}); + +const localCommentStores = createCommentStores(); + const commentStores = (): SyntheticCommentStores => { const existing: unknown = Reflect.get(globalThis, SYNTHETIC_COMMENT_STORES); if (existing !== undefined) return existing as SyntheticCommentStores; - const created: SyntheticCommentStores = { - leading: new WeakMap(), - trailing: new WeakMap(), - }; - Object.defineProperty(globalThis, SYNTHETIC_COMMENT_STORES, { - configurable: false, - enumerable: false, - value: created, - writable: false, - }); - return created; + try { + Object.defineProperty(globalThis, SYNTHETIC_COMMENT_STORES, { + configurable: false, + enumerable: false, + value: localCommentStores, + writable: false, + }); + return localCommentStores; + } catch { + // Hardened realms can prohibit new global properties. Preserve the + // historical single-module behaviour there instead of making package load + // fail; cross-copy sharing necessarily requires a realm-owned rendezvous. + return localCommentStores; + } }; const { leading: leadingStore, trailing: trailingStore } = commentStores(); diff --git a/scripts/ci/factory-package.test.cjs b/scripts/ci/factory-package.test.cjs index 05c06f501f..1c500fb6e3 100644 --- a/scripts/ci/factory-package.test.cjs +++ b/scripts/ci/factory-package.test.cjs @@ -106,6 +106,24 @@ test("the factory publication entry points load from built artifacts", async () ); assertCrossCopyComments(commonjs, duplicate, "physical package split"); + const hardenedConsumer = [ + '"use strict";', + "Object.preventExtensions(globalThis);", + `const mod = require(${JSON.stringify(path.join(packageRoot, "lib", "index.js"))});`, + "const node = Object.freeze(mod.default.createIdentifier('hardened'));", + "mod.addSyntheticLeadingComment(node, mod.SyntaxKind.SingleLineCommentTrivia, ' retained ', true);", + "if (mod.getSyntheticLeadingComments(node)?.[0]?.text !== ' retained ') throw new Error('comment was not retained');", + "if (new mod.TsPrinter().print(node) !== '// retained\\nhardened') throw new Error('printer lost the comment');", + ].join("\n"); + assertSucceeded( + childProcess.spawnSync(process.execPath, ["-e", hardenedConsumer], { + cwd: workspace, + encoding: "utf8", + windowsHide: true, + }), + "non-extensible global fallback", + ); + const esmConsumer = path.join(workspace, "consumer.mjs"); fs.writeFileSync( esmConsumer, From e8e8da5691b1b38e5dfcbaea90b6d7ca2521fd52 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:24:06 +0900 Subject: [PATCH 04/11] Close #1019: classify lint evaluator failures --- packages/lint/src/index.ts | 52 ++------- .../src/internal/configEvaluatorFailure.ts | 72 ++++++++++++ ...rocess_failures_are_classified_by_cause.ts | 104 ++++++++++++++++++ 3 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 packages/lint/src/internal/configEvaluatorFailure.ts create mode 100644 tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index c73b5fcd82..94a9eb4851 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -7,6 +7,11 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { + CONFIG_EVALUATOR_MAX_BUFFER, + CONFIG_EVALUATOR_TIMEOUT_MS, + configEvaluatorProcessFailure, +} from "./internal/configEvaluatorFailure"; import type { ITtscLintPlugin, ITtscLintPluginConfig } from "./structures"; export * from "./defaultFormat"; @@ -1747,35 +1752,14 @@ function evaluateTtsxConfigPlugins( cwd: tempDir, env, encoding: "utf8", - maxBuffer: 1024 * 1024 * 16, + maxBuffer: CONFIG_EVALUATOR_MAX_BUFFER, stdio: ["ignore", "pipe", "pipe"], - // 60s cap so a runaway top-level await / infinite loop in the - // user's lint config can't hang the entire ttsc invocation. - timeout: 60_000, + timeout: CONFIG_EVALUATOR_TIMEOUT_MS, windowsHide: true, }); forwardConfigEvaluatorStreams(result.stdout, result.stderr); - if (result.error) { - throw new Error( - `@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`, - ); - } - if (result.signal) { - throw new Error( - `@ttsc/lint: ttsx evaluation of ${configPath} was killed by signal ${result.signal} ` + - `(likely the 60s timeout). Simplify the config or move heavy work out of top-level.`, - ); - } - if (result.status !== 0) { - // The evaluator already said why on its own stderr. Reporting only the - // exit code discards that and leaves the caller — a test, a CI log, a - // user with a typo in one contributor — holding a number. - const reason = configEvaluatorFailureReason(result.stderr); - throw new Error( - `@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}` + - (reason === "" ? "" : "\n" + reason), - ); - } + const processFailure = configEvaluatorProcessFailure(result, configPath); + if (processFailure) throw processFailure; let payload: { dependencies?: ConfigDependencyFingerprint[]; entries?: ConfigPluginEntry[]; @@ -1850,24 +1834,6 @@ function evaluateTtsxConfigPlugins( } } -/** - * What the evaluator said before it gave up, trimmed to the part that explains - * it. - * - * The build banner and progress lines are the same on success, so they say - * nothing about the failure; the tail is where the thrown reason lands. Kept to - * a few lines because this is appended to an exception message, not a log. - */ -function configEvaluatorFailureReason(stderr: string | undefined): string { - const lines = (stderr ?? "") - .split(/\r?\n/) - .map((line) => line.trimEnd()) - .filter((line) => line.trim() !== ""); - return lines.slice(-CONFIG_EVALUATOR_REASON_LINES).join("\n"); -} - -const CONFIG_EVALUATOR_REASON_LINES = 5; - function forwardConfigEvaluatorStreams( stdout: string | null | undefined, stderr: string | null | undefined, diff --git a/packages/lint/src/internal/configEvaluatorFailure.ts b/packages/lint/src/internal/configEvaluatorFailure.ts new file mode 100644 index 0000000000..2c805723e3 --- /dev/null +++ b/packages/lint/src/internal/configEvaluatorFailure.ts @@ -0,0 +1,72 @@ +export const CONFIG_EVALUATOR_MAX_BUFFER = 16 * 1024 * 1024; +export const CONFIG_EVALUATOR_TIMEOUT_MS = 60_000; + +interface ConfigEvaluatorProcessResult { + error?: Error; + signal: NodeJS.Signals | null; + status: number | null; + stderr: string | null | undefined; +} + +/** + * Classify the ways the isolated lint-config evaluator can stop. + * + * Node reports both timeout and max-buffer termination with `SIGTERM`, so the + * process error code must take precedence over the signal. A bare signal is an + * external termination and a non-zero status is an evaluator failure whose + * stderr tail contains the useful user-config diagnostic. + */ +export function configEvaluatorProcessFailure( + result: ConfigEvaluatorProcessResult, + configPath: string, +): Error | undefined { + const code = (result.error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ETIMEDOUT") { + return new Error( + `@ttsc/lint: ttsx evaluation of ${configPath} timed out after ` + + `${CONFIG_EVALUATOR_TIMEOUT_MS / 1_000} seconds. ` + + "Simplify the config or move heavy work out of top-level.", + ); + } + if (code === "ENOBUFS") { + return new Error( + `@ttsc/lint: ttsx evaluation of ${configPath} exceeded the ` + + `${CONFIG_EVALUATOR_MAX_BUFFER / (1024 * 1024)} MiB output limit. ` + + "Reduce console output from the config and its dependencies.", + ); + } + if (result.error) { + return new Error( + `@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`, + ); + } + if (result.signal) { + return new Error( + `@ttsc/lint: ttsx evaluation of ${configPath} was killed by signal ${result.signal}.`, + ); + } + if (result.status !== 0) { + const reason = configEvaluatorFailureReason(result.stderr); + return new Error( + `@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}` + + (reason === "" ? "" : "\n" + reason), + ); + } + return undefined; +} + +/** + * Return the useful tail of evaluator stderr without turning an exception into + * an unbounded duplicate of the already-forwarded child stream. + */ +function configEvaluatorFailureReason( + stderr: string | null | undefined, +): string { + const lines = (stderr ?? "") + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ""); + return lines.slice(-CONFIG_EVALUATOR_REASON_LINES).join("\n"); +} + +const CONFIG_EVALUATOR_REASON_LINES = 5; diff --git a/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts b/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts new file mode 100644 index 0000000000..caf78b0801 --- /dev/null +++ b/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; + +import { configEvaluatorProcessFailure } from "../../../../../packages/lint/lib/internal/configEvaluatorFailure.js"; + +/** + * Verifies isolated lint-config process failures preserve their real cause. + * + * Node attaches `SIGTERM` to both timeout and max-buffer errors. Treating the + * signal first made those distinct failures indistinguishable and falsely + * blamed the timeout for excessive output. + * + * 1. Classify timeout, output overflow, spawn, signal, and exit failures. + * 2. Assert process error codes take precedence over their shared signal. + * 3. Assert evaluator stderr is bounded and a successful result has no error. + */ +export const test_config_evaluator_process_failures_are_classified_by_cause = + (): void => { + const configPath = "/project/lint.config.ts"; + const timeout = configEvaluatorProcessFailure( + processResult({ + error: processError("ETIMEDOUT"), + signal: "SIGTERM", + }), + configPath, + ); + assert.match(timeout?.message ?? "", /timed out after 60 seconds/); + assert.doesNotMatch(timeout?.message ?? "", /killed by signal/); + + const overflow = configEvaluatorProcessFailure( + processResult({ + error: processError("ENOBUFS"), + signal: "SIGTERM", + }), + configPath, + ); + assert.match(overflow?.message ?? "", /exceeded the 16 MiB output limit/); + assert.doesNotMatch(overflow?.message ?? "", /killed by signal/); + + const spawn = configEvaluatorProcessFailure( + processResult({ error: processError("ENOENT") }), + configPath, + ); + assert.match(spawn?.message ?? "", /failed to spawn ttsx/); + assert.match(spawn?.message ?? "", /ENOENT/); + + const signal = configEvaluatorProcessFailure( + processResult({ signal: "SIGKILL" }), + configPath, + ); + assert.match(signal?.message ?? "", /killed by signal SIGKILL/); + assert.doesNotMatch(signal?.message ?? "", /timeout/i); + + const exit = configEvaluatorProcessFailure( + processResult({ + status: 2, + stderr: [ + "discarded one", + "discarded two", + "kept three", + "kept four", + "kept five", + "kept six", + "kept seven", + ].join("\n"), + }), + configPath, + ); + assert.match(exit?.message ?? "", /failed with exit code 2/); + assert.doesNotMatch(exit?.message ?? "", /discarded one|discarded two/); + assert.match( + exit?.message ?? "", + /kept three\nkept four\nkept five\nkept six\nkept seven$/, + ); + + assert.equal( + configEvaluatorProcessFailure(processResult({ status: 0 }), configPath), + undefined, + ); + }; + +function processError(code: string): Error { + return Object.assign(new Error(`spawnSync node ${code}`), { code }); +} + +function processResult( + input: Partial<{ + error: Error; + signal: NodeJS.Signals; + status: number; + stderr: string; + }>, +): { + error?: Error; + signal: NodeJS.Signals | null; + status: number | null; + stderr: string | null; +} { + return { + signal: null, + status: null, + stderr: null, + ...input, + }; +} From 1efc874b07c46924c520b751f2fd8a95b0ad2186 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:29:48 +0900 Subject: [PATCH 05/11] Close #1022: bound playground source-pack loads --- packages/playground/README.md | 2 + .../internal/createWorkerCompilerService.ts | 50 ++++-- .../src/compiler/loadTypiaSourcePack.ts | 168 ++++++++++++++++-- packages/playground/src/index.ts | 5 +- .../IInstallTypiaSourcePackOptions.ts | 6 +- ...a_source_pack_bounds_and_recovers_cache.ts | 145 +++++++++++++++ ...pagates_and_retries_source_pack_timeout.ts | 70 ++++++++ .../src/internal/fakeWorker.ts | 13 +- website/README.md | 2 + 9 files changed, 428 insertions(+), 33 deletions(-) create mode 100644 tests/test-playground/src/features/test_load_typia_source_pack_bounds_and_recovers_cache.ts create mode 100644 tests/test-playground/src/features/test_worker_compiler_propagates_and_retries_source_pack_timeout.ts diff --git a/packages/playground/README.md b/packages/playground/README.md index c2f26b99fa..c17edfd615 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -128,6 +128,8 @@ const service = createWorkerCompiler({ The typia pack itself is built by the site (typically with a `pack-typia-sources.cjs`-style script that bundles `typia/`, `@typia/utils`, and `@typia/interface` into a flat JSON map). See the ttsc website's [`build/pack-typia-sources.cjs`](https://github.com/samchon/ttsc/blob/master/website/build/pack-typia-sources.cjs) for the reference implementation. +Source-pack loads have a 30-second default deadline covering both the fetch and JSON body read. Calls for one URL share a single in-flight request; any caller's `signal` or deadline cancels that shared attempt, and a rejected attempt is evicted so the next compiler request retries the mount without starting another WASM runtime. Override the policy with `createTypiaSourcePackMount({ url, signal, timeoutMs })`. + ## Runtime npm dependency installer When the user types `import {v4} from "uuid"`, the shell auto-fetches `uuid` (and its transitive deps) from the npm registry, unpacks the tgz in the browser, and mounts the files into the wasm MemFS, no proxy server needed. diff --git a/packages/playground/src/compiler/internal/createWorkerCompilerService.ts b/packages/playground/src/compiler/internal/createWorkerCompilerService.ts index c80c41b915..1b1000892f 100644 --- a/packages/playground/src/compiler/internal/createWorkerCompilerService.ts +++ b/packages/playground/src/compiler/internal/createWorkerCompilerService.ts @@ -85,27 +85,51 @@ export function createWorkerCompilerService( compilerOptions: extraCompilerOptions, }); - // Cache the boot promise across calls. Pre-runtime failures clear the cache - // so the next call can retry. A post-go.run failure stays cached because the - // old runtime cannot be stopped and must never receive a replacement Ready - // bridge in the same Worker. The UI retry resets the compiler client, which - // terminates this Worker and creates a safe replacement. - let boot: Promise | null = null; - function getBoot(): Promise { - if (boot) return boot; - boot = (async () => { - const result = await deps.bootTtsc({ + // Cache the runtime separately from the optional source-pack mount. A mount + // runs after Go is ready, so retrying a failed mount must reuse that runtime + // instead of calling bootTtsc again in the same Worker. + // + // Pre-runtime failures clear the runtime cache so the next call can retry. A + // post-go.run failure stays cached because the old runtime cannot be stopped + // and must never receive a replacement Ready bridge in the same Worker. The + // UI retry resets the compiler client, which terminates this Worker and + // creates a safe replacement. + let runtimeBoot: Promise | null = null; + function getRuntimeBoot(): Promise { + if (runtimeBoot) return runtimeBoot; + let attempt!: Promise; + attempt = (async () => + deps.bootTtsc({ wasmUrl: options.wasmUrl, wasmExecUrl: options.wasmExecUrl, apiName: options.apiName, - }); + }))().catch((err) => { + if ( + !(err instanceof BootTtscWorkerTerminationError) && + runtimeBoot === attempt + ) { + runtimeBoot = null; + } + throw err; + }); + runtimeBoot = attempt; + return attempt; + } + + let boot: Promise | null = null; + function getBoot(): Promise { + if (boot) return boot; + let attempt!: Promise; + attempt = (async () => { + const result = await getRuntimeBoot(); if (typiaPlugin?.mount) await typiaPlugin.mount(result.host, workDir); return result; })().catch((err) => { - if (!(err instanceof BootTtscWorkerTerminationError)) boot = null; + if (boot === attempt) boot = null; throw err; }); - return boot; + boot = attempt; + return attempt; } // Serialize every MemFS-mutating call. tgrid queues incoming messages, but diff --git a/packages/playground/src/compiler/loadTypiaSourcePack.ts b/packages/playground/src/compiler/loadTypiaSourcePack.ts index c173d3fc27..f56ce28dfa 100644 --- a/packages/playground/src/compiler/loadTypiaSourcePack.ts +++ b/packages/playground/src/compiler/loadTypiaSourcePack.ts @@ -1,37 +1,179 @@ import type { IInstallTypiaSourcePackOptions } from "../structures/IInstallTypiaSourcePackOptions"; -const packCache = new Map>>(); +interface SourcePackEntry { + controller: AbortController; + promise: Promise>; +} + +interface SourcePackCancellationReason { + kind: "abort" | "timeout"; + reason?: unknown; + timeoutMs?: number; +} + +interface SourcePackCancellation { + promise: Promise; + dispose: () => void; +} + +export const DEFAULT_SOURCE_PACK_TIMEOUT_MS = 30_000; + +const packCache = new Map(); /** - * Fetch the typia source pack JSON. Cached per URL across calls. On rejection - * the cache entry is cleared so the next call retries — otherwise a transient - * fetch failure during the first boot would wedge every later boot through - * `getBoot`'s typiaPlugin.mount path. + * Fetch the typia source pack JSON once per URL. + * + * Concurrent callers share one load. A caller abort or deadline cancels that + * shared attempt; rejection removes it from the cache so the next call retries + * from scratch. The deadline covers both the response and its JSON body. */ export function loadTypiaSourcePack( options: IInstallTypiaSourcePackOptions, ): Promise> { + const timeoutMs = resolveSourcePackTimeout(options.timeoutMs); const cached = packCache.get(options.url); - if (cached) return cached; + if (cached) { + attachSourcePackCancellation(cached, options.signal, timeoutMs); + return cached.promise; + } + const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis); if (!fetchImpl) { throw new Error( "loadTypiaSourcePack: no fetch implementation available in this environment.", ); } + const url = options.url; + const controller = new AbortController(); + let phase = `fetching ${url}`; + const cancellation = createSourcePackCancellation( + controller.signal, + () => phase, + ); + let entry!: SourcePackEntry; const promise = (async () => { - const response = await fetchImpl(url); + const response = await raceSourcePackCancellation( + fetchImpl(url, { signal: controller.signal }), + cancellation.promise, + controller.signal, + () => phase, + ); if (!response.ok) { throw new Error( `loadTypiaSourcePack: failed to fetch ${url}: ${response.status}`, ); } - return (await response.json()) as Record; - })().catch((err) => { - packCache.delete(url); - throw err; - }); - packCache.set(url, promise); + + phase = `reading JSON from ${url}`; + return (await raceSourcePackCancellation( + response.json(), + cancellation.promise, + controller.signal, + () => phase, + )) as Record; + })() + .catch((error) => { + if (packCache.get(url) === entry) packCache.delete(url); + throw error; + }) + .finally(cancellation.dispose); + + entry = { controller, promise }; + packCache.set(url, entry); + attachSourcePackCancellation(entry, options.signal, timeoutMs); return promise; } + +function resolveSourcePackTimeout(timeoutMs: number | undefined): number { + const value = timeoutMs ?? DEFAULT_SOURCE_PACK_TIMEOUT_MS; + if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) { + throw new RangeError( + "loadTypiaSourcePack: timeoutMs must be a positive integer no greater than 2147483647.", + ); + } + return value; +} + +function attachSourcePackCancellation( + entry: SourcePackEntry, + callerSignal: AbortSignal | undefined, + timeoutMs: number, +): void { + const abortFromCaller = (): void => { + if (!entry.controller.signal.aborted) { + entry.controller.abort({ + kind: "abort", + reason: callerSignal?.reason, + } satisfies SourcePackCancellationReason); + } + }; + if (callerSignal?.aborted) abortFromCaller(); + else callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); + + const timer = setTimeout(() => { + if (!entry.controller.signal.aborted) { + entry.controller.abort({ + kind: "timeout", + timeoutMs, + } satisfies SourcePackCancellationReason); + } + }, timeoutMs); + const cleanup = (): void => { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", abortFromCaller); + }; + void entry.promise.then(cleanup, cleanup); +} + +function createSourcePackCancellation( + signal: AbortSignal, + getPhase: () => string, +): SourcePackCancellation { + let rejectCancellation!: (error: Error) => void; + const promise = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + const onAbort = (): void => { + rejectCancellation(sourcePackCancellationError(signal, getPhase())); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + return { + promise, + dispose: () => signal.removeEventListener("abort", onAbort), + }; +} + +async function raceSourcePackCancellation( + work: Promise, + cancellation: Promise, + signal: AbortSignal, + getPhase: () => string, +): Promise { + try { + return await Promise.race([work, cancellation]); + } catch (error) { + if (signal.aborted) { + throw sourcePackCancellationError(signal, getPhase()); + } + throw error; + } +} + +function sourcePackCancellationError( + signal: AbortSignal, + phase: string, +): Error { + const reason = signal.reason as SourcePackCancellationReason | undefined; + if (reason?.kind === "timeout") { + return new Error( + `loadTypiaSourcePack: timed out after ${reason.timeoutMs}ms while ${phase}.`, + ); + } + + const error = new Error(`loadTypiaSourcePack: aborted while ${phase}.`); + const cause = reason?.kind === "abort" ? reason.reason : signal.reason; + if (cause !== undefined) (error as Error & { cause?: unknown }).cause = cause; + return error; +} diff --git a/packages/playground/src/index.ts b/packages/playground/src/index.ts index 9efa3d511f..361503d086 100644 --- a/packages/playground/src/index.ts +++ b/packages/playground/src/index.ts @@ -24,7 +24,10 @@ export { DEFAULT_WORK_DIR } from "./compiler/DEFAULT_WORK_DIR"; export { installDependenciesIntoMemFS } from "./compiler/installDependenciesIntoMemFS"; export { installTypiaSourcePack } from "./compiler/installTypiaSourcePack"; export { lineColumnOf } from "./compiler/lineColumnOf"; -export { loadTypiaSourcePack } from "./compiler/loadTypiaSourcePack"; +export { + DEFAULT_SOURCE_PACK_TIMEOUT_MS, + loadTypiaSourcePack, +} from "./compiler/loadTypiaSourcePack"; export { mapDiagnostic } from "./compiler/mapDiagnostic"; export { normalizeError } from "./compiler/normalizeError"; export { normalizeNodeModulePath } from "./compiler/normalizeNodeModulePath"; diff --git a/packages/playground/src/structures/IInstallTypiaSourcePackOptions.ts b/packages/playground/src/structures/IInstallTypiaSourcePackOptions.ts index 473a52f3e1..172ec371e4 100644 --- a/packages/playground/src/structures/IInstallTypiaSourcePackOptions.ts +++ b/packages/playground/src/structures/IInstallTypiaSourcePackOptions.ts @@ -10,9 +10,13 @@ export interface IInstallTypiaSourcePackOptions { * matching `DEFAULT_WORK_DIR + "/node_modules"`. */ mountRoot?: string; + /** Cancel the shared in-flight load. */ + signal?: AbortSignal; + /** Maximum fetch and JSON-read time. Defaults to 30 seconds. */ + timeoutMs?: number; /** * Optional fetcher. Defaults to `globalThis.fetch`. Override for tests or for * sites that want their own caching strategy. */ - fetch?: (input: string) => Promise; + fetch?: (input: string, init?: RequestInit) => Promise; } diff --git a/tests/test-playground/src/features/test_load_typia_source_pack_bounds_and_recovers_cache.ts b/tests/test-playground/src/features/test_load_typia_source_pack_bounds_and_recovers_cache.ts new file mode 100644 index 0000000000..bcb47b8f67 --- /dev/null +++ b/tests/test-playground/src/features/test_load_typia_source_pack_bounds_and_recovers_cache.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; + +import { loadTypiaSourcePack } from "../../../../packages/playground/lib/src/compiler/loadTypiaSourcePack.js"; + +/** + * Verifies source-pack fetch and JSON reads are bounded, caller-cancellable, + * retryable, and single-flight while healthy. + * + * A response can deliver headers but leave its body pending forever. Because + * the URL cache held that pending promise, every later worker boot inherited + * the same dead request instead of attempting recovery. + * + * 1. Time out a stalled JSON body, then retry that URL successfully. + * 2. Abort two callers sharing a stalled fetch, then retry from a fresh fetch. + * 3. Resolve two healthy callers through one fetch and one shared promise. + */ +export const test_load_typia_source_pack_bounds_and_recovers_cache = + async (): Promise => { + assert.throws( + () => + loadTypiaSourcePack({ + url: "https://pack.invalid/deadline.json", + timeoutMs: Number.POSITIVE_INFINITY, + }), + /timeoutMs must be a positive integer/, + ); + + const jsonUrl = "https://pack.invalid/source-stalled-json.json"; + let jsonCalls = 0; + let jsonSignal: AbortSignal | undefined; + const jsonFetch = async ( + _input: string, + init?: RequestInit, + ): Promise => { + jsonSignal = init?.signal ?? undefined; + if (jsonCalls++ === 0) { + return { + ok: true, + json: () => new Promise(() => undefined), + } as Response; + } + return { + ok: true, + json: async () => ({ "typia/index.ts": "export {};" }), + } as Response; + }; + + await assert.rejects( + loadTypiaSourcePack({ + url: jsonUrl, + fetch: jsonFetch, + timeoutMs: 50, + }), + /timed out after 50ms while reading JSON from .*source-stalled-json\.json/, + ); + assert.equal(jsonSignal?.aborted, true); + assert.deepEqual( + await loadTypiaSourcePack({ + url: jsonUrl, + fetch: jsonFetch, + timeoutMs: 1_000, + }), + { "typia/index.ts": "export {};" }, + ); + assert.equal(jsonCalls, 2); + + const sharedUrl = "https://pack.invalid/source-shared-fetch.json"; + let sharedCalls = 0; + let sharedSignal: AbortSignal | undefined; + const sharedFetch = async ( + _input: string, + init?: RequestInit, + ): Promise => { + sharedSignal = init?.signal ?? undefined; + if (sharedCalls++ === 0) return new Promise(() => undefined); + return { + ok: true, + json: async () => ({ "typia/lib/index.ts": "export {};" }), + } as Response; + }; + + const first = loadTypiaSourcePack({ + url: sharedUrl, + fetch: sharedFetch, + timeoutMs: 1_000, + }); + const controller = new AbortController(); + const second = loadTypiaSourcePack({ + url: sharedUrl, + fetch: sharedFetch, + signal: controller.signal, + timeoutMs: 1_000, + }); + assert.equal(first, second); + const cause = new Error("worker boot cancelled"); + controller.abort(cause); + await assert.rejects(first, (error) => { + assert.match( + (error as Error).message, + /aborted while fetching .*source-shared-fetch\.json/, + ); + assert.equal((error as Error & { cause?: unknown }).cause, cause); + return true; + }); + await assert.rejects(second); + assert.equal(sharedSignal?.aborted, true); + assert.deepEqual( + await loadTypiaSourcePack({ + url: sharedUrl, + fetch: sharedFetch, + timeoutMs: 1_000, + }), + { "typia/lib/index.ts": "export {};" }, + ); + assert.equal(sharedCalls, 2); + + const healthyUrl = "https://pack.invalid/source-healthy.json"; + let healthyCalls = 0; + let resolveHealthy!: (response: Response) => void; + const healthyResponse = new Promise((resolve) => { + resolveHealthy = resolve; + }); + const healthyFetch = async (): Promise => { + healthyCalls++; + return healthyResponse; + }; + const healthyFirst = loadTypiaSourcePack({ + url: healthyUrl, + fetch: healthyFetch, + timeoutMs: 1_000, + }); + const healthySecond = loadTypiaSourcePack({ + url: healthyUrl, + fetch: healthyFetch, + timeoutMs: 1_000, + }); + assert.equal(healthyFirst, healthySecond); + assert.equal(healthyCalls, 1); + resolveHealthy({ + ok: true, + json: async () => ({ "typia/package.json": "{}" }), + } as Response); + assert.deepEqual(await healthyFirst, { "typia/package.json": "{}" }); + assert.deepEqual(await healthySecond, { "typia/package.json": "{}" }); + }; diff --git a/tests/test-playground/src/features/test_worker_compiler_propagates_and_retries_source_pack_timeout.ts b/tests/test-playground/src/features/test_worker_compiler_propagates_and_retries_source_pack_timeout.ts new file mode 100644 index 0000000000..2ae3d2e192 --- /dev/null +++ b/tests/test-playground/src/features/test_worker_compiler_propagates_and_retries_source_pack_timeout.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; + +import { createTypiaSourcePackMount } from "../../../../packages/playground/lib/src/compiler/createTypiaSourcePackMount.js"; +import { BASE_OPTIONS, makeFakeWorker } from "../internal/fakeWorker"; + +/** + * Verifies worker boot surfaces and recovers from a source-pack timeout. + * + * The source-pack mount is awaited inside the cached compiler boot. A pending + * mount therefore blocked compile, bundle, lint, and every retry behind one + * promise even though the WASM runtime itself had booted successfully. + * + * 1. Stall the first source-pack fetch behind a short injected deadline. + * 2. Assert compile returns the phase-specific cause before build can run. + * 3. Retry compile and assert a fresh fetch mounts the pack and builds. + */ +export const test_worker_compiler_propagates_and_retries_source_pack_timeout = + async (): Promise => { + const url = "https://pack.invalid/worker-source-pack.json"; + let fetchCalls = 0; + const fetch = async (): Promise => { + if (fetchCalls++ === 0) return new Promise(() => undefined); + return { + ok: true, + json: async () => ({ + "typia/package.json": '{"name":"typia"}', + }), + } as Response; + }; + const worker = makeFakeWorker( + { + ...BASE_OPTIONS, + typiaPlugin: { + mount: createTypiaSourcePackMount({ + url, + fetch, + timeoutMs: 50, + }), + }, + lintPlugin: false, + }, + {}, + ); + + const failed = await worker.service.compile({ + source: "export const value = 1;", + options: { typia: false }, + }); + assert.equal(failed.type, "error"); + assert.ok(failed.value && typeof failed.value === "object"); + assert.match( + String((failed.value as { message?: unknown }).message), + /loadTypiaSourcePack: timed out after 50ms while fetching .*worker-source-pack\.json/, + ); + assert.equal(worker.record.build.length, 0); + assert.equal(worker.record.boot, 1); + + const recovered = await worker.service.compile({ + source: "export const value = 2;", + options: { typia: false }, + }); + assert.equal(recovered.type, "success"); + assert.equal(fetchCalls, 2); + assert.equal(worker.record.boot, 1); + assert.equal( + worker.record.writes["/work/node_modules/typia/package.json"], + '{"name":"typia"}', + ); + assert.equal(worker.record.build.length, 1); + }; diff --git a/tests/test-playground/src/internal/fakeWorker.ts b/tests/test-playground/src/internal/fakeWorker.ts index a5e0f56ddc..ff26a55f86 100644 --- a/tests/test-playground/src/internal/fakeWorker.ts +++ b/tests/test-playground/src/internal/fakeWorker.ts @@ -38,6 +38,8 @@ export interface IFakeApi { /** Everything the service touched during a test, for oracle assertions. */ export interface IFakeRecord { + /** WASM/Go runtime boot calls. A mount retry must not start a second runtime. */ + boot: number; /** Plugin verb invocations, in order (e.g. `transform`, `check`). */ plugin: ITtscPluginOpts[]; /** Build invocations, in order. Length 0 proves the build never ran. */ @@ -75,9 +77,10 @@ export function makeFakeWorker( options: ICreateWorkerCompilerOptions, api: IFakeApi, ): IFakeWorker { - const record: IFakeRecord = { plugin: [], build: [], writes: {} }; + const record: IFakeRecord = { boot: 0, plugin: [], build: [], writes: {} }; const host = { + mkdirp(_path: string): void {}, writeFile(path: string, data: string | Uint8Array): void { record.writes[path] = typeof data === "string" ? data : String(data); }, @@ -97,10 +100,10 @@ export function makeFakeWorker( } as unknown as IBootResult["api"]; const deps: IWorkerCompilerDeps = { - bootTtsc: async (_options: IBootTtscOptions): Promise => ({ - api: fakeApi, - host, - }), + bootTtsc: async (_options: IBootTtscOptions): Promise => { + record.boot++; + return { api: fakeApi, host }; + }, parseResult: (result: ITtscResult): T | null => { try { return JSON.parse(result.result) as T; diff --git a/website/README.md b/website/README.md index ed3545ff15..aceaeb0d4f 100644 --- a/website/README.md +++ b/website/README.md @@ -67,4 +67,6 @@ The playground keeps typia's worker-boundary architecture (tgrid `WorkerConnecto 2. **`public/compiler/wasm_exec.js`**: Go's bootstrap shim, copied from `@ttsc/wasm`. 3. **`public/compiler/index.js`**: the rspack-bundled Web Worker from `src/compiler/index.ts`, which wires the site's URL conventions and the typia source pack into `createWorkerCompiler` from `@ttsc/playground`. +The Typia source-pack mount has a 30-second deadline covering the response and JSON body. Concurrent loads share one request; a timeout, caller abort, or other rejection evicts that request so the next compiler request can fetch a fresh copy without starting another WASM runtime. + `build/typia-types.cjs` packs typia's declaration sources into `src/compiler/typia-types.json` so Monaco's `addExtraLib` sees the same type surface the compiler does. From d944e35ee4ed73d6bb13b488817e8f366fcae23d Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:34:43 +0900 Subject: [PATCH 06/11] docs(playground): publish source-pack retry contract --- website/src/content/docs/playground.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/src/content/docs/playground.mdx b/website/src/content/docs/playground.mdx index bd59108ac8..c4a2a110c2 100644 --- a/website/src/content/docs/playground.mdx +++ b/website/src/content/docs/playground.mdx @@ -24,6 +24,8 @@ Everything behind the hosted playground ships as the [`@ttsc/playground`](https: `PlaygroundShell` passes each `executeBundle` call an abort signal. Source or compiler-option changes, a newer Execute, and unmount abort obsolete setup and prevent its messages from committing. Pass that signal to `loadTypiaRuntimePack`; the loader has a 30-second default deadline and removes failed shared loads so a later Execute can retry. Synchronous evaluated code cannot be interrupted by an `AbortSignal`. +The worker's Typia source-pack mount uses the same 30-second bound across both the response and JSON body. Loads for one URL share a request; any caller's signal or deadline cancels that shared attempt, and rejection evicts it so the next compiler request retries the mount on the existing WASM runtime instead of inheriting a pending fetch or starting another runtime. + The npm loader authenticates archives with registry SRI (or legacy shasum), confines files below one safe and consistent archive root, and enforces independent compressed and expanded byte limits. Metadata without either digest remains compatible but unauthenticated. These checks protect the install handoff and browser-tab availability; they do not make downloaded code trustworthy. `createSandboxRequire` is a CommonJS resolver and evaluator, not a browser security boundary. An embedder that accepts untrusted code or packages must provide an `executeBundle` policy with the origin, process, and capability isolation its site requires. Install byte limits do not constrain runtime loops, timers, network access, or globals exposed by that executor. From ce74753bceaf77e041c84956c0eca052087ba36e Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:38:52 +0900 Subject: [PATCH 07/11] Close #1023: bound plugin descriptor evaluation --- .../internal/descriptorProcessFailure.ts | 66 +++++ .../src/plugin/internal/loadProjectPlugins.ts | 38 ++- ...ailures_propagate_and_cleanup_ttsx_temp.ts | 228 ++++++++++++++++++ ...rocess_failures_are_classified_by_cause.ts | 114 +++++++++ .../docs/development/concepts/protocol.mdx | 2 + 5 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts create mode 100644 tests/test-ttsc/src/features/project/test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp.ts create mode 100644 tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts diff --git a/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts b/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts new file mode 100644 index 0000000000..34bcf7c202 --- /dev/null +++ b/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts @@ -0,0 +1,66 @@ +export const PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES = 16 * 1024 * 1024; +export const PLUGIN_DESCRIPTOR_TIMEOUT_MS = 60_000; + +interface DescriptorProcessResult { + error?: Error; + signal: NodeJS.Signals | null; + status: number | null; + stderr: string | null | undefined; +} + +/** + * Classify the ways the isolated TypeScript descriptor evaluator can stop. + * + * Node reports both timeout and max-buffer termination with `SIGTERM`, so the + * process error code must take precedence over the signal. + */ +export function pluginDescriptorProcessFailure( + result: DescriptorProcessResult, + request: string, +): Error | undefined { + const code = (result.error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ETIMEDOUT") { + return new Error( + `ttsc: plugin descriptor "${request}" evaluation through ttsx timed out after ` + + `${PLUGIN_DESCRIPTOR_TIMEOUT_MS / 1_000} seconds. ` + + "Descriptor modules and factories must finish their setup within that window.", + ); + } + if (code === "ENOBUFS") { + return new Error( + `ttsc: plugin descriptor "${request}" evaluation through ttsx exceeded the ` + + `${PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES / (1024 * 1024)} MiB process output limit. ` + + "Reduce stdout and stderr from the descriptor and its dependencies.", + ); + } + if (result.error) { + return new Error( + `ttsc: failed to launch ttsx for plugin descriptor "${request}": ${result.error.message}`, + ); + } + if (result.signal) { + return new Error( + `ttsc: plugin descriptor "${request}" evaluation through ttsx was killed by signal ${result.signal}.`, + ); + } + if (result.status !== 0) { + const reason = descriptorFailureReason(result.stderr); + return new Error( + `ttsc: plugin descriptor "${request}" evaluation through ttsx failed with exit code ${String(result.status)}` + + (reason === "" ? "" : "\n" + reason), + ); + } + return undefined; +} + +function descriptorFailureReason(stderr: string | null | undefined): string { + const bounded = (stderr ?? "").slice(-PLUGIN_DESCRIPTOR_REASON_MAX_CHARS); + const lines = bounded + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ""); + return lines.slice(-PLUGIN_DESCRIPTOR_REASON_LINES).join("\n"); +} + +const PLUGIN_DESCRIPTOR_REASON_LINES = 5; +const PLUGIN_DESCRIPTOR_REASON_MAX_CHARS = 8 * 1024; diff --git a/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts b/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts index c57983bd3d..4cb48b111a 100644 --- a/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts +++ b/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts @@ -15,6 +15,11 @@ import type { TtscPluginStage } from "../../structures/TtscPluginStage"; import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin"; import type { ITtscParsedProjectConfig } from "../../structures/internal/ITtscParsedProjectConfig"; import { buildSourcePlugin } from "./buildSourcePlugin"; +import { + PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, + PLUGIN_DESCRIPTOR_TIMEOUT_MS, + pluginDescriptorProcessFailure, +} from "./descriptorProcessFailure"; const GO_MOD_SEARCH_MAX_DEPTH = 3; @@ -767,24 +772,41 @@ function loadDescriptorViaTtsx( TTSC_PLUGIN_DESCRIPTOR_OUT: out, TTSC_PLUGIN_ENTRY: request, }, + maxBuffer: PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, + timeout: PLUGIN_DESCRIPTOR_TIMEOUT_MS, windowsHide: true, }); - if (result.status !== 0 || !fs.existsSync(out)) { + const processFailure = pluginDescriptorProcessFailure(result, request); + if (processFailure) throw processFailure; + if (!fs.existsSync(out)) { throw new Error( - [ - `ttsc: failed to load plugin descriptor "${request}" through ttsx`, - result.stderr || result.stdout || "", - ] - .filter((line) => line.trim().length !== 0) - .join("\n"), + `ttsc: plugin descriptor "${request}" evaluation through ttsx produced no descriptor output.`, + ); + } + const outputSize = fs.statSync(out).size; + if (outputSize > PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES) { + throw new Error( + `ttsc: plugin descriptor "${request}" produced ${outputSize} bytes of JSON, ` + + `exceeding the ${PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES / (1024 * 1024)} MiB descriptor output limit.`, + ); + } + const text = fs.readFileSync(out, "utf8"); + try { + return JSON.parse(text); + } catch (error) { + throw new Error( + `ttsc: plugin descriptor "${request}" produced invalid JSON: ${errorMessage(error)}`, ); } - return JSON.parse(fs.readFileSync(out, "utf8")); } finally { fs.rmSync(dir, { force: true, recursive: true }); } } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function withPluginLoaderEnv(run: () => T): T { const previousNode = process.env.TTSC_NODE_BINARY; const previousTtsx = process.env.TTSC_TTSX_BINARY; diff --git a/tests/test-ttsc/src/features/project/test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp.ts b/tests/test-ttsc/src/features/project/test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp.ts new file mode 100644 index 0000000000..e90e53eb5c --- /dev/null +++ b/tests/test-ttsc/src/features/project/test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp.ts @@ -0,0 +1,228 @@ +import { TestProject } from "@ttsc/testing"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +/** + * Verifies descriptor failures propagate across setup surfaces and clean up. + * + * The generated loader lives in a private temporary directory and executable + * descriptor evaluation precedes CLI, API, and LSP setup. Each returned failure + * must therefore preserve its cause without leaving loader artifacts; a + * successful evaluator must clean up before later descriptor validation too. + * + * 1. Drive non-zero, missing, malformed, oversized, and successful results. + * 2. Assert each API result is distinct and its loader directory is removed. + * 3. Assert the non-zero cause also reaches CLI and LSP startup unchanged. + */ +export const test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp = + (): void => { + const root = TestProject.tmpdir("ttsc-descriptor-bound-"); + const descriptorRoot = path.join(root, "descriptor"); + fs.mkdirSync(descriptorRoot, { recursive: true }); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ private: true, type: "module" }), + "utf8", + ); + fs.writeFileSync( + path.join(descriptorRoot, "runtime.ts"), + 'export const runtime = "descriptor";\n', + "utf8", + ); + fs.writeFileSync( + path.join(descriptorRoot, "index.ts"), + [ + 'export * from "./runtime";', + 'export default { name: "unreached", source: "./absent" };', + "", + ].join("\n"), + "utf8", + ); + const tsconfig = path.join(root, "tsconfig.json"); + fs.writeFileSync( + tsconfig, + JSON.stringify({ + compilerOptions: { + plugins: [{ transform: "./descriptor/index.ts" }], + }, + }), + "utf8", + ); + + const fakeTtsx = path.join(root, "fake-ttsx.cjs"); + fs.writeFileSync( + fakeTtsx, + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + "const loaderDir = path.dirname(process.argv.at(-1));", + 'fs.writeFileSync(process.env.TTSC_FAKE_DESCRIPTOR_MARKER, loaderDir, "utf8");', + "switch (process.env.TTSC_FAKE_DESCRIPTOR_MODE) {", + ' case "nonzero":', + " for (let i = 1; i <= 7; i++) console.error(`descriptor failure ${i}`);", + " process.exit(2);", + ' case "missing":', + " process.exit(0);", + ' case "malformed":', + ' fs.writeFileSync(process.env.TTSC_PLUGIN_DESCRIPTOR_OUT, "{bad", "utf8");', + " process.exit(0);", + ' case "oversize":', + " fs.writeFileSync(", + " process.env.TTSC_PLUGIN_DESCRIPTOR_OUT,", + " Buffer.alloc(16 * 1024 * 1024 + 1, 0x20),", + " );", + " process.exit(0);", + ' case "success":', + " fs.writeFileSync(", + " process.env.TTSC_PLUGIN_DESCRIPTOR_OUT,", + ' JSON.stringify({ name: "fake-success", source: "./absent-source" }),', + ' "utf8",', + " );", + " process.exit(0);", + " default:", + ' throw new Error("unknown fake descriptor mode");', + "}", + "", + ].join("\n"), + "utf8", + ); + + const apiWorker = path.join(root, "api-worker.cjs"); + fs.writeFileSync( + apiWorker, + [ + `const { TtscCompiler } = require(${JSON.stringify(path.join(TestProject.WORKSPACE_ROOT, "packages", "ttsc", "lib", "index.js"))});`, + "try {", + " new TtscCompiler({", + ` cwd: ${JSON.stringify(root)},`, + " env: process.env,", + ` tsconfig: ${JSON.stringify(tsconfig)},`, + " }).prepare();", + ' process.stderr.write("NO_ERROR\\n");', + " process.exitCode = 2;", + "} catch (error) {", + ' process.stderr.write(String(error?.message ?? error) + "\\n");', + " process.exitCode = 1;", + "}", + "", + ].join("\n"), + "utf8", + ); + + const apiCases = [ + { + mode: "nonzero", + pattern: + /failed with exit code 2\ndescriptor failure 3\ndescriptor failure 4\ndescriptor failure 5\ndescriptor failure 6\ndescriptor failure 7/, + }, + { + mode: "missing", + pattern: /produced no descriptor output/, + }, + { + mode: "malformed", + pattern: /produced invalid JSON/, + }, + { + mode: "oversize", + pattern: /exceeding the 16 MiB descriptor output limit/, + }, + { + mode: "success", + pattern: /plugin "fake-success" source does not exist/, + }, + ] as const; + for (const testCase of apiCases) { + const result = runNodeSurface({ + args: [apiWorker], + fakeTtsx, + marker: path.join(root, `api-${testCase.mode}.txt`), + mode: testCase.mode, + root, + }); + assert.equal(result.status, 1, testCase.mode); + assert.match(result.stderr, testCase.pattern, testCase.mode); + assertLoaderRemoved( + path.join(root, `api-${testCase.mode}.txt`), + testCase.mode, + ); + } + + const cliMarker = path.join(root, "cli-nonzero.txt"); + const cli = TestProject.spawn( + TestProject.TTSC_BIN, + ["prepare", "--cwd", root, "--tsconfig", tsconfig], + { + cwd: root, + env: fakeEnvironment(fakeTtsx, cliMarker, "nonzero"), + }, + ); + assert.equal(cli.status, 2); + assert.match(cli.stderr, /plugin descriptor .* failed with exit code 2/); + assertLoaderRemoved(cliMarker, "CLI"); + + const lspMarker = path.join(root, "lsp-nonzero.txt"); + const ttscserverLauncher = path.join( + TestProject.WORKSPACE_ROOT, + "packages", + "ttsc", + "lib", + "launcher", + "ttscserver.js", + ); + const lsp = TestProject.spawn( + process.execPath, + [ttscserverLauncher, "--stdio", "--cwd", root, "--tsconfig", tsconfig], + { + cwd: root, + env: { + ...fakeEnvironment(fakeTtsx, lspMarker, "nonzero"), + // Descriptor setup fails before the launcher can execute this binary. + TTSCSERVER_BINARY: TestProject.NATIVE_BINARY, + }, + }, + ); + assert.equal(lsp.status, 1); + assert.match( + lsp.stderr, + /ttscserver: plugin descriptor .* failed with exit code 2/, + ); + assertLoaderRemoved(lspMarker, "LSP"); + }; + +function runNodeSurface(options: { + args: string[]; + fakeTtsx: string; + marker: string; + mode: string; + root: string; +}): ReturnType { + return TestProject.spawn(process.execPath, options.args, { + cwd: options.root, + env: fakeEnvironment(options.fakeTtsx, options.marker, options.mode), + }); +} + +function fakeEnvironment( + fakeTtsx: string, + marker: string, + mode: string, +): NodeJS.ProcessEnv { + return { + TTSC_FAKE_DESCRIPTOR_MARKER: marker, + TTSC_FAKE_DESCRIPTOR_MODE: mode, + TTSC_NODE_BINARY: process.execPath, + TTSC_TTSX_BINARY: fakeTtsx, + }; +} + +function assertLoaderRemoved(marker: string, label: string): void { + assert.equal(fs.existsSync(marker), true, `${label} did not run fake ttsx`); + const loaderDir = fs.readFileSync(marker, "utf8"); + assert.equal( + fs.existsSync(loaderDir), + false, + `${label} retained ${loaderDir}`, + ); +} diff --git a/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts b/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts new file mode 100644 index 0000000000..697433b60e --- /dev/null +++ b/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; + +import { pluginDescriptorProcessFailure } from "../../../../../packages/ttsc/lib/plugin/internal/descriptorProcessFailure.js"; + +/** + * Verifies executable plugin-descriptor failures preserve their real cause. + * + * Node attaches `SIGTERM` to both timeout and max-buffer errors. Checking the + * signal first would hide whether descriptor setup stalled or emitted too much + * output, while an unbounded stderr echo could replace one failure with a huge + * parent diagnostic. + * + * 1. Classify timeout, output overflow, spawn, signal, and exit failures. + * 2. Assert process error codes take precedence over their shared signal. + * 3. Bound the non-zero stderr suffix and accept a successful result. + */ +export const test_plugin_descriptor_process_failures_are_classified_by_cause = + (): void => { + const request = "/project/plugin.ts"; + const timeout = pluginDescriptorProcessFailure( + processResult({ + error: processError("ETIMEDOUT"), + signal: "SIGTERM", + }), + request, + ); + assert.match(timeout?.message ?? "", /timed out after 60 seconds/); + assert.doesNotMatch(timeout?.message ?? "", /killed by signal/); + + const overflow = pluginDescriptorProcessFailure( + processResult({ + error: processError("ENOBUFS"), + signal: "SIGTERM", + }), + request, + ); + assert.match( + overflow?.message ?? "", + /exceeded the 16 MiB process output limit/, + ); + assert.doesNotMatch(overflow?.message ?? "", /killed by signal/); + + const spawn = pluginDescriptorProcessFailure( + processResult({ error: processError("ENOENT") }), + request, + ); + assert.match(spawn?.message ?? "", /failed to launch ttsx/); + assert.match(spawn?.message ?? "", /ENOENT/); + + const signal = pluginDescriptorProcessFailure( + processResult({ signal: "SIGKILL" }), + request, + ); + assert.match(signal?.message ?? "", /killed by signal SIGKILL/); + assert.doesNotMatch(signal?.message ?? "", /timeout/i); + + const exit = pluginDescriptorProcessFailure( + processResult({ + status: 2, + stderr: [ + "discarded one", + "discarded two", + "kept three", + "kept four", + "kept five", + "kept six", + "kept seven", + ].join("\n"), + }), + request, + ); + assert.match(exit?.message ?? "", /failed with exit code 2/); + assert.doesNotMatch(exit?.message ?? "", /discarded one|discarded two/); + assert.match( + exit?.message ?? "", + /kept three\nkept four\nkept five\nkept six\nkept seven$/, + ); + + const bounded = pluginDescriptorProcessFailure( + processResult({ status: 3, stderr: "x".repeat(100_000) }), + request, + ); + assert.ok((bounded?.message.length ?? Number.POSITIVE_INFINITY) < 8_500); + + assert.equal( + pluginDescriptorProcessFailure(processResult({ status: 0 }), request), + undefined, + ); + }; + +function processError(code: string): Error { + return Object.assign(new Error(`spawnSync node ${code}`), { code }); +} + +function processResult( + input: Partial<{ + error: Error; + signal: NodeJS.Signals; + status: number; + stderr: string; + }>, +): { + error?: Error; + signal: NodeJS.Signals | null; + status: number | null; + stderr: string | null; +} { + return { + signal: null, + status: null, + stderr: null, + ...input, + }; +} diff --git a/website/src/content/docs/development/concepts/protocol.mdx b/website/src/content/docs/development/concepts/protocol.mdx index 780a81e8fc..8c24254634 100644 --- a/website/src/content/docs/development/concepts/protocol.mdx +++ b/website/src/content/docs/development/concepts/protocol.mdx @@ -50,6 +50,8 @@ module.exports = (context) => ({ `context.filename` is the absolute path to the resolved descriptor module `ttsc` loaded for this entry, and `context.dirname` is its containing directory. They are the load-mode-independent replacement for `__filename`/`__dirname` (see ESM-safety below). +Keep executable descriptor setup synchronous and lightweight. When a `.ts` descriptor needs the `ttsx` fallback, `ttsc` gives its module evaluation and factory 60 seconds and caps captured process output and descriptor JSON at 16 MiB. A timeout, output overflow, launch failure, signal, non-zero exit, missing result, or invalid JSON stops CLI, API, and LSP setup with a cause-specific error; move long-running work into the native plugin command instead. + **Descriptors must be ESM-safe.** A descriptor compiled to CommonJS and loaded by `ttsc`'s direct `require()` keeps `__dirname`/`__filename`/`require`. But when `ttsc` loads a descriptor shipped as `.ts` source — through `ttsx` — or as ESM, those ambient globals are **undefined**, so a `source` derived from `__dirname` silently mis-resolves. Use `context.dirname`/`context.filename` instead — `ttsc` populates them in every load mode, so they are the direct drop-in for `__dirname`/`__filename`: ```ts From bb2690de62386e534af1b71d0cb835fedb87e4b5 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:42:42 +0900 Subject: [PATCH 08/11] fix: bound evaluator failure excerpts --- .../lint/src/internal/configEvaluatorFailure.ts | 4 +++- .../src/plugin/internal/descriptorProcessFailure.ts | 9 ++++++++- ...ator_process_failures_are_classified_by_cause.ts | 6 ++++++ ...ptor_failures_propagate_and_cleanup_ttsx_temp.ts | 7 +++++++ ...ptor_process_failures_are_classified_by_cause.ts | 13 +++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/internal/configEvaluatorFailure.ts b/packages/lint/src/internal/configEvaluatorFailure.ts index 2c805723e3..88785db54c 100644 --- a/packages/lint/src/internal/configEvaluatorFailure.ts +++ b/packages/lint/src/internal/configEvaluatorFailure.ts @@ -62,7 +62,8 @@ export function configEvaluatorProcessFailure( function configEvaluatorFailureReason( stderr: string | null | undefined, ): string { - const lines = (stderr ?? "") + const bounded = (stderr ?? "").slice(-CONFIG_EVALUATOR_REASON_MAX_CHARS); + const lines = bounded .split(/\r?\n/) .map((line) => line.trimEnd()) .filter((line) => line.trim() !== ""); @@ -70,3 +71,4 @@ function configEvaluatorFailureReason( } const CONFIG_EVALUATOR_REASON_LINES = 5; +const CONFIG_EVALUATOR_REASON_MAX_CHARS = 8 * 1024; diff --git a/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts b/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts index 34bcf7c202..18f524f900 100644 --- a/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts +++ b/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts @@ -6,6 +6,7 @@ interface DescriptorProcessResult { signal: NodeJS.Signals | null; status: number | null; stderr: string | null | undefined; + stdout: string | null | undefined; } /** @@ -44,7 +45,9 @@ export function pluginDescriptorProcessFailure( ); } if (result.status !== 0) { - const reason = descriptorFailureReason(result.stderr); + const reason = descriptorFailureReason( + hasText(result.stderr) ? result.stderr : result.stdout, + ); return new Error( `ttsc: plugin descriptor "${request}" evaluation through ttsx failed with exit code ${String(result.status)}` + (reason === "" ? "" : "\n" + reason), @@ -53,6 +56,10 @@ export function pluginDescriptorProcessFailure( return undefined; } +function hasText(value: string | null | undefined): value is string { + return value !== null && value !== undefined && value.trim() !== ""; +} + function descriptorFailureReason(stderr: string | null | undefined): string { const bounded = (stderr ?? "").slice(-PLUGIN_DESCRIPTOR_REASON_MAX_CHARS); const lines = bounded diff --git a/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts b/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts index caf78b0801..84cb811445 100644 --- a/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts +++ b/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts @@ -72,6 +72,12 @@ export const test_config_evaluator_process_failures_are_classified_by_cause = /kept three\nkept four\nkept five\nkept six\nkept seven$/, ); + const bounded = configEvaluatorProcessFailure( + processResult({ status: 3, stderr: "x".repeat(100_000) }), + configPath, + ); + assert.ok((bounded?.message.length ?? Number.POSITIVE_INFINITY) < 8_500); + assert.equal( configEvaluatorProcessFailure(processResult({ status: 0 }), configPath), undefined, diff --git a/tests/test-ttsc/src/features/project/test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp.ts b/tests/test-ttsc/src/features/project/test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp.ts index e90e53eb5c..341b6643da 100644 --- a/tests/test-ttsc/src/features/project/test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp.ts +++ b/tests/test-ttsc/src/features/project/test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp.ts @@ -62,6 +62,9 @@ export const test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp = ' case "nonzero":', " for (let i = 1; i <= 7; i++) console.error(`descriptor failure ${i}`);", " process.exit(2);", + ' case "stdout-nonzero":', + ' console.log("stdout-only descriptor failure");', + " process.exit(5);", ' case "missing":', " process.exit(0);", ' case "malformed":', @@ -116,6 +119,10 @@ export const test_plugin_descriptor_failures_propagate_and_cleanup_ttsx_temp = pattern: /failed with exit code 2\ndescriptor failure 3\ndescriptor failure 4\ndescriptor failure 5\ndescriptor failure 6\ndescriptor failure 7/, }, + { + mode: "stdout-nonzero", + pattern: /failed with exit code 5\nstdout-only descriptor failure/, + }, { mode: "missing", pattern: /produced no descriptor output/, diff --git a/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts b/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts index 697433b60e..3383e4be2f 100644 --- a/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts +++ b/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts @@ -82,6 +82,16 @@ export const test_plugin_descriptor_process_failures_are_classified_by_cause = ); assert.ok((bounded?.message.length ?? Number.POSITIVE_INFINITY) < 8_500); + const stdout = pluginDescriptorProcessFailure( + processResult({ + status: 4, + stderr: " ", + stdout: "stdout-only descriptor cause", + }), + request, + ); + assert.match(stdout?.message ?? "", /stdout-only descriptor cause$/); + assert.equal( pluginDescriptorProcessFailure(processResult({ status: 0 }), request), undefined, @@ -98,17 +108,20 @@ function processResult( signal: NodeJS.Signals; status: number; stderr: string; + stdout: string; }>, ): { error?: Error; signal: NodeJS.Signals | null; status: number | null; stderr: string | null; + stdout: string | null; } { return { signal: null, status: null, stderr: null, + stdout: null, ...input, }; } From 6b0a2cf8f2bb50b913718a77c7ab16ab2f7612a5 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 05:55:53 +0900 Subject: [PATCH 09/11] fix: enforce evaluator kill deadlines --- packages/lint/src/index.ts | 6 ++-- .../src/internal/configEvaluatorFailure.ts | 15 +++++--- .../internal/descriptorProcessFailure.ts | 11 ++++-- .../src/plugin/internal/loadProjectPlugins.ts | 5 ++- ...rocess_failures_are_classified_by_cause.ts | 36 +++++++++++++++++-- ...rocess_failures_are_classified_by_cause.ts | 36 +++++++++++++++++-- 6 files changed, 90 insertions(+), 19 deletions(-) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 94a9eb4851..98f9d9b929 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -8,8 +8,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { - CONFIG_EVALUATOR_MAX_BUFFER, - CONFIG_EVALUATOR_TIMEOUT_MS, + CONFIG_EVALUATOR_PROCESS_OPTIONS, configEvaluatorProcessFailure, } from "./internal/configEvaluatorFailure"; import type { ITtscLintPlugin, ITtscLintPluginConfig } from "./structures"; @@ -1752,9 +1751,8 @@ function evaluateTtsxConfigPlugins( cwd: tempDir, env, encoding: "utf8", - maxBuffer: CONFIG_EVALUATOR_MAX_BUFFER, + ...CONFIG_EVALUATOR_PROCESS_OPTIONS, stdio: ["ignore", "pipe", "pipe"], - timeout: CONFIG_EVALUATOR_TIMEOUT_MS, windowsHide: true, }); forwardConfigEvaluatorStreams(result.stdout, result.stderr); diff --git a/packages/lint/src/internal/configEvaluatorFailure.ts b/packages/lint/src/internal/configEvaluatorFailure.ts index 88785db54c..e251367a34 100644 --- a/packages/lint/src/internal/configEvaluatorFailure.ts +++ b/packages/lint/src/internal/configEvaluatorFailure.ts @@ -1,5 +1,10 @@ export const CONFIG_EVALUATOR_MAX_BUFFER = 16 * 1024 * 1024; export const CONFIG_EVALUATOR_TIMEOUT_MS = 60_000; +export const CONFIG_EVALUATOR_PROCESS_OPTIONS = Object.freeze({ + killSignal: "SIGKILL" as const, + maxBuffer: CONFIG_EVALUATOR_MAX_BUFFER, + timeout: CONFIG_EVALUATOR_TIMEOUT_MS, +}); interface ConfigEvaluatorProcessResult { error?: Error; @@ -11,10 +16,12 @@ interface ConfigEvaluatorProcessResult { /** * Classify the ways the isolated lint-config evaluator can stop. * - * Node reports both timeout and max-buffer termination with `SIGTERM`, so the - * process error code must take precedence over the signal. A bare signal is an - * external termination and a non-zero status is an evaluator failure whose - * stderr tail contains the useful user-config diagnostic. + * Node reports both timeout and max-buffer termination with the configured + * signal, so the process error code must take precedence over the signal. The + * evaluator uses `SIGKILL`: Node's synchronous process API otherwise keeps + * waiting when a POSIX child handles the default `SIGTERM` without exiting. A + * bare signal is an external termination and a non-zero status is an evaluator + * failure whose stderr tail contains the useful user-config diagnostic. */ export function configEvaluatorProcessFailure( result: ConfigEvaluatorProcessResult, diff --git a/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts b/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts index 18f524f900..c93b98464c 100644 --- a/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts +++ b/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts @@ -1,5 +1,10 @@ export const PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES = 16 * 1024 * 1024; export const PLUGIN_DESCRIPTOR_TIMEOUT_MS = 60_000; +export const PLUGIN_DESCRIPTOR_PROCESS_OPTIONS = Object.freeze({ + killSignal: "SIGKILL" as const, + maxBuffer: PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, + timeout: PLUGIN_DESCRIPTOR_TIMEOUT_MS, +}); interface DescriptorProcessResult { error?: Error; @@ -12,8 +17,10 @@ interface DescriptorProcessResult { /** * Classify the ways the isolated TypeScript descriptor evaluator can stop. * - * Node reports both timeout and max-buffer termination with `SIGTERM`, so the - * process error code must take precedence over the signal. + * Node reports both timeout and max-buffer termination with the configured + * signal, so the process error code must take precedence over the signal. The + * evaluator uses `SIGKILL`: Node's synchronous process API otherwise keeps + * waiting when a POSIX child handles the default `SIGTERM` without exiting. */ export function pluginDescriptorProcessFailure( result: DescriptorProcessResult, diff --git a/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts b/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts index 4cb48b111a..2230eb71e4 100644 --- a/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts +++ b/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts @@ -17,7 +17,7 @@ import type { ITtscParsedProjectConfig } from "../../structures/internal/ITtscPa import { buildSourcePlugin } from "./buildSourcePlugin"; import { PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, - PLUGIN_DESCRIPTOR_TIMEOUT_MS, + PLUGIN_DESCRIPTOR_PROCESS_OPTIONS, pluginDescriptorProcessFailure, } from "./descriptorProcessFailure"; @@ -772,8 +772,7 @@ function loadDescriptorViaTtsx( TTSC_PLUGIN_DESCRIPTOR_OUT: out, TTSC_PLUGIN_ENTRY: request, }, - maxBuffer: PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, - timeout: PLUGIN_DESCRIPTOR_TIMEOUT_MS, + ...PLUGIN_DESCRIPTOR_PROCESS_OPTIONS, windowsHide: true, }); const processFailure = pluginDescriptorProcessFailure(result, request); diff --git a/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts b/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts index 84cb811445..9f3ec4eb84 100644 --- a/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts +++ b/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; -import { configEvaluatorProcessFailure } from "../../../../../packages/lint/lib/internal/configEvaluatorFailure.js"; +import { + CONFIG_EVALUATOR_PROCESS_OPTIONS, + configEvaluatorProcessFailure, +} from "../../../../../packages/lint/lib/internal/configEvaluatorFailure.js"; /** * Verifies isolated lint-config process failures preserve their real cause. @@ -19,7 +23,7 @@ export const test_config_evaluator_process_failures_are_classified_by_cause = const timeout = configEvaluatorProcessFailure( processResult({ error: processError("ETIMEDOUT"), - signal: "SIGTERM", + signal: CONFIG_EVALUATOR_PROCESS_OPTIONS.killSignal, }), configPath, ); @@ -29,7 +33,7 @@ export const test_config_evaluator_process_failures_are_classified_by_cause = const overflow = configEvaluatorProcessFailure( processResult({ error: processError("ENOBUFS"), - signal: "SIGTERM", + signal: CONFIG_EVALUATOR_PROCESS_OPTIONS.killSignal, }), configPath, ); @@ -78,12 +82,38 @@ export const test_config_evaluator_process_failures_are_classified_by_cause = ); assert.ok((bounded?.message.length ?? Number.POSITIVE_INFINITY) < 8_500); + assertTimeoutCannotBeDefeatedBySigtermHandler(); + assert.equal( configEvaluatorProcessFailure(processResult({ status: 0 }), configPath), undefined, ); }; +function assertTimeoutCannotBeDefeatedBySigtermHandler(): void { + const result = spawnSync( + process.execPath, + [ + "-e", + [ + 'process.on("SIGTERM", () => {});', + "setTimeout(() => process.exit(0), 2_000);", + ].join(""), + ], + { + ...CONFIG_EVALUATOR_PROCESS_OPTIONS, + encoding: "utf8", + timeout: 50, + windowsHide: true, + }, + ); + assert.equal( + (result.error as NodeJS.ErrnoException | undefined)?.code, + "ETIMEDOUT", + ); + assert.equal(result.signal, "SIGKILL"); +} + function processError(code: string): Error { return Object.assign(new Error(`spawnSync node ${code}`), { code }); } diff --git a/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts b/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts index 3383e4be2f..6aadd0bdc3 100644 --- a/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts +++ b/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; -import { pluginDescriptorProcessFailure } from "../../../../../packages/ttsc/lib/plugin/internal/descriptorProcessFailure.js"; +import { + PLUGIN_DESCRIPTOR_PROCESS_OPTIONS, + pluginDescriptorProcessFailure, +} from "../../../../../packages/ttsc/lib/plugin/internal/descriptorProcessFailure.js"; /** * Verifies executable plugin-descriptor failures preserve their real cause. @@ -20,7 +24,7 @@ export const test_plugin_descriptor_process_failures_are_classified_by_cause = const timeout = pluginDescriptorProcessFailure( processResult({ error: processError("ETIMEDOUT"), - signal: "SIGTERM", + signal: PLUGIN_DESCRIPTOR_PROCESS_OPTIONS.killSignal, }), request, ); @@ -30,7 +34,7 @@ export const test_plugin_descriptor_process_failures_are_classified_by_cause = const overflow = pluginDescriptorProcessFailure( processResult({ error: processError("ENOBUFS"), - signal: "SIGTERM", + signal: PLUGIN_DESCRIPTOR_PROCESS_OPTIONS.killSignal, }), request, ); @@ -92,12 +96,38 @@ export const test_plugin_descriptor_process_failures_are_classified_by_cause = ); assert.match(stdout?.message ?? "", /stdout-only descriptor cause$/); + assertTimeoutCannotBeDefeatedBySigtermHandler(); + assert.equal( pluginDescriptorProcessFailure(processResult({ status: 0 }), request), undefined, ); }; +function assertTimeoutCannotBeDefeatedBySigtermHandler(): void { + const result = spawnSync( + process.execPath, + [ + "-e", + [ + 'process.on("SIGTERM", () => {});', + "setTimeout(() => process.exit(0), 2_000);", + ].join(""), + ], + { + ...PLUGIN_DESCRIPTOR_PROCESS_OPTIONS, + encoding: "utf8", + timeout: 50, + windowsHide: true, + }, + ); + assert.equal( + (result.error as NodeJS.ErrnoException | undefined)?.code, + "ETIMEDOUT", + ); + assert.equal(result.signal, "SIGKILL"); +} + function processError(code: string): Error { return Object.assign(new Error(`spawnSync node ${code}`), { code }); } From a8e7bf6d95f773ef5124777e6b9b276555c09e04 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 06:20:01 +0900 Subject: [PATCH 10/11] fix: bound nested evaluator runtimes --- packages/lint/src/index.ts | 17 +++- .../src/internal/configEvaluatorFailure.ts | 24 ++++- .../ttsc/src/launcher/internal/runTtsx.ts | 97 +++++++++++++++++-- .../internal/descriptorProcessFailure.ts | 26 ++++- .../src/plugin/internal/loadProjectPlugins.ts | 12 +++ ...rocess_failures_are_classified_by_cause.ts | 47 ++++++++- ...rocess_failures_are_classified_by_cause.ts | 50 +++++++++- ...r_deadline_terminates_the_runtime_child.ts | 89 +++++++++++++++++ ...tput_limit_terminates_the_runtime_child.ts | 78 +++++++++++++++ 9 files changed, 413 insertions(+), 27 deletions(-) create mode 100644 tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts create mode 100644 tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_output_limit_terminates_the_runtime_child.ts diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 98f9d9b929..93f5ca637b 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -9,6 +9,8 @@ import { pathToFileURL } from "node:url"; import { CONFIG_EVALUATOR_PROCESS_OPTIONS, + CONFIG_EVALUATOR_STATUS_FD, + configEvaluatorBoundaryEnvironment, configEvaluatorProcessFailure, } from "./internal/configEvaluatorFailure"; import type { ITtscLintPlugin, ITtscLintPluginConfig } from "./structures"; @@ -1745,14 +1747,25 @@ function evaluateTtsxConfigPlugins( if (process.env.TTSC_TSGO_BINARY) { args.unshift("--binary", process.env.TTSC_TSGO_BINARY); } - const env = nodeConfigLoaderEnv(configPath); + const env = { + ...nodeConfigLoaderEnv(configPath), + ...configEvaluatorBoundaryEnvironment(), + }; const command = ttsxThroughNodeIfNeeded(ttsxBinary); const result = spawnSync(command.binary, [...command.prefix, ...args], { cwd: tempDir, env, encoding: "utf8", ...CONFIG_EVALUATOR_PROCESS_OPTIONS, - stdio: ["ignore", "pipe", "pipe"], + stdio: [ + "ignore", + "pipe", + "pipe", + ...Array.from( + { length: CONFIG_EVALUATOR_STATUS_FD - 2 }, + () => "pipe" as const, + ), + ], windowsHide: true, }); forwardConfigEvaluatorStreams(result.stdout, result.stderr); diff --git a/packages/lint/src/internal/configEvaluatorFailure.ts b/packages/lint/src/internal/configEvaluatorFailure.ts index e251367a34..6f12876ce6 100644 --- a/packages/lint/src/internal/configEvaluatorFailure.ts +++ b/packages/lint/src/internal/configEvaluatorFailure.ts @@ -1,13 +1,16 @@ export const CONFIG_EVALUATOR_MAX_BUFFER = 16 * 1024 * 1024; export const CONFIG_EVALUATOR_TIMEOUT_MS = 60_000; +export const CONFIG_EVALUATOR_TEARDOWN_GRACE_MS = 5_000; +export const CONFIG_EVALUATOR_STATUS_FD = 3; export const CONFIG_EVALUATOR_PROCESS_OPTIONS = Object.freeze({ killSignal: "SIGKILL" as const, maxBuffer: CONFIG_EVALUATOR_MAX_BUFFER, - timeout: CONFIG_EVALUATOR_TIMEOUT_MS, + timeout: CONFIG_EVALUATOR_TIMEOUT_MS + CONFIG_EVALUATOR_TEARDOWN_GRACE_MS, }); interface ConfigEvaluatorProcessResult { error?: Error; + output?: readonly (string | null)[] | null; signal: NodeJS.Signals | null; status: number | null; stderr: string | null | undefined; @@ -28,14 +31,15 @@ export function configEvaluatorProcessFailure( configPath: string, ): Error | undefined { const code = (result.error as NodeJS.ErrnoException | undefined)?.code; - if (code === "ETIMEDOUT") { + const nestedCode = result.output?.[CONFIG_EVALUATOR_STATUS_FD]?.trim() ?? ""; + if (code === "ETIMEDOUT" || nestedCode === "ETIMEDOUT") { return new Error( `@ttsc/lint: ttsx evaluation of ${configPath} timed out after ` + `${CONFIG_EVALUATOR_TIMEOUT_MS / 1_000} seconds. ` + "Simplify the config or move heavy work out of top-level.", ); } - if (code === "ENOBUFS") { + if (code === "ENOBUFS" || nestedCode === "ENOBUFS") { return new Error( `@ttsc/lint: ttsx evaluation of ${configPath} exceeded the ` + `${CONFIG_EVALUATOR_MAX_BUFFER / (1024 * 1024)} MiB output limit. ` + @@ -62,6 +66,20 @@ export function configEvaluatorProcessFailure( return undefined; } +/** + * Pass the semantic deadline and private status pipe through the `ttsx` wrapper + * to the runtime child that actually executes the config. + */ +export function configEvaluatorBoundaryEnvironment( + now: number = Date.now(), +): NodeJS.ProcessEnv { + return { + TTSC_TTSX_EVALUATOR_DEADLINE_MS: String(now + CONFIG_EVALUATOR_TIMEOUT_MS), + TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String(CONFIG_EVALUATOR_MAX_BUFFER), + TTSC_TTSX_EVALUATOR_STATUS_FD: String(CONFIG_EVALUATOR_STATUS_FD), + }; +} + /** * Return the useful tail of evaluator stderr without turning an exception into * an unbounded duplicate of the already-forwarded child stream. diff --git a/packages/ttsc/src/launcher/internal/runTtsx.ts b/packages/ttsc/src/launcher/internal/runTtsx.ts index f7ff0a31ec..64db484794 100644 --- a/packages/ttsc/src/launcher/internal/runTtsx.ts +++ b/packages/ttsc/src/launcher/internal/runTtsx.ts @@ -299,20 +299,52 @@ function runPreparedEntry( sourceEntry, ...parsed.passthrough, ]; + const evaluatorBoundary = readEvaluatorBoundary(process.env); + const runtimeEnv: NodeJS.ProcessEnv = { + ...process.env, + NODE_OPTIONS: appendNodeOption( + process.env.NODE_OPTIONS, + `--require ${JSON.stringify(path.join(__dirname, "runtimeHookPreload.js"))}`, + ), + TTSC_TSGO_BINARY: process.env.TTSC_TSGO_BINARY ?? tsgo, + TTSX_RUNTIME_MANIFEST: manifestPath, + }; + delete runtimeEnv[TTSC_TTSX_EVALUATOR_DEADLINE_ENV]; + delete runtimeEnv[TTSC_TTSX_EVALUATOR_MAX_BUFFER_ENV]; + delete runtimeEnv[TTSC_TTSX_EVALUATOR_STATUS_FD_ENV]; + const result = spawnSync(process.execPath, args, { cwd, - env: { - ...process.env, - NODE_OPTIONS: appendNodeOption( - process.env.NODE_OPTIONS, - `--require ${JSON.stringify(path.join(__dirname, "runtimeHookPreload.js"))}`, - ), - TTSC_TSGO_BINARY: process.env.TTSC_TSGO_BINARY ?? tsgo, - TTSX_RUNTIME_MANIFEST: manifestPath, - }, - stdio: "inherit", + env: runtimeEnv, + ...(evaluatorBoundary + ? { + killSignal: "SIGKILL" as const, + timeout: Math.max( + 1, + Math.min( + MAX_CHILD_PROCESS_TIMEOUT_MS, + evaluatorBoundary.deadlineMs - Date.now(), + ), + ), + maxBuffer: evaluatorBoundary.maxBuffer, + stdio: ["inherit", "pipe", "pipe"] as const, + } + : { stdio: "inherit" as const }), windowsHide: true, }); + const nestedCode = (result.error as NodeJS.ErrnoException | undefined) + ?.code; + if ( + evaluatorBoundary && + (nestedCode === "ETIMEDOUT" || nestedCode === "ENOBUFS") + ) { + reportEvaluatorFailure(evaluatorBoundary.statusFd, nestedCode); + return 1; + } + if (evaluatorBoundary) { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + } if (result.error) { process.stderr.write(`${result.error.message}\n`); return 1; @@ -323,6 +355,51 @@ function runPreparedEntry( } } +interface TtsxEvaluatorBoundary { + deadlineMs: number; + maxBuffer: number; + statusFd: number; +} + +const TTSC_TTSX_EVALUATOR_DEADLINE_ENV = "TTSC_TTSX_EVALUATOR_DEADLINE_MS"; +const TTSC_TTSX_EVALUATOR_MAX_BUFFER_ENV = + "TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES"; +const TTSC_TTSX_EVALUATOR_STATUS_FD_ENV = "TTSC_TTSX_EVALUATOR_STATUS_FD"; +const TTSC_TTSX_EVALUATOR_STATUS_FD = 3; +const MAX_CHILD_PROCESS_TIMEOUT_MS = 2_147_483_647; + +function readEvaluatorBoundary( + env: NodeJS.ProcessEnv, +): TtsxEvaluatorBoundary | undefined { + const deadlineMs = Number(env[TTSC_TTSX_EVALUATOR_DEADLINE_ENV]); + const maxBuffer = Number(env[TTSC_TTSX_EVALUATOR_MAX_BUFFER_ENV]); + const statusFd = Number(env[TTSC_TTSX_EVALUATOR_STATUS_FD_ENV]); + if ( + !Number.isSafeInteger(deadlineMs) || + deadlineMs <= 0 || + !Number.isSafeInteger(maxBuffer) || + maxBuffer <= 0 || + maxBuffer > MAX_CHILD_PROCESS_TIMEOUT_MS || + statusFd !== TTSC_TTSX_EVALUATOR_STATUS_FD + ) { + return undefined; + } + return { deadlineMs, maxBuffer, statusFd }; +} + +function reportEvaluatorFailure( + statusFd: number, + code: "ENOBUFS" | "ETIMEDOUT", +): void { + try { + fs.writeSync(statusFd, code); + } catch { + // The boundary is an internal parent/child protocol. If a standalone ttsx + // caller supplied only the environment variables, preserve the ordinary + // non-zero bounded-process result instead of replacing it with an fd error. + } +} + function removeRuntimeOutput(directory: string): void { try { fs.rmSync(directory, { force: true, recursive: true }); diff --git a/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts b/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts index c93b98464c..74cf478e4c 100644 --- a/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts +++ b/packages/ttsc/src/plugin/internal/descriptorProcessFailure.ts @@ -1,13 +1,16 @@ export const PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES = 16 * 1024 * 1024; export const PLUGIN_DESCRIPTOR_TIMEOUT_MS = 60_000; +export const PLUGIN_DESCRIPTOR_TEARDOWN_GRACE_MS = 5_000; +export const PLUGIN_DESCRIPTOR_STATUS_FD = 3; export const PLUGIN_DESCRIPTOR_PROCESS_OPTIONS = Object.freeze({ killSignal: "SIGKILL" as const, maxBuffer: PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, - timeout: PLUGIN_DESCRIPTOR_TIMEOUT_MS, + timeout: PLUGIN_DESCRIPTOR_TIMEOUT_MS + PLUGIN_DESCRIPTOR_TEARDOWN_GRACE_MS, }); interface DescriptorProcessResult { error?: Error; + output?: readonly (string | null)[] | null; signal: NodeJS.Signals | null; status: number | null; stderr: string | null | undefined; @@ -27,14 +30,15 @@ export function pluginDescriptorProcessFailure( request: string, ): Error | undefined { const code = (result.error as NodeJS.ErrnoException | undefined)?.code; - if (code === "ETIMEDOUT") { + const nestedCode = result.output?.[PLUGIN_DESCRIPTOR_STATUS_FD]?.trim() ?? ""; + if (code === "ETIMEDOUT" || nestedCode === "ETIMEDOUT") { return new Error( `ttsc: plugin descriptor "${request}" evaluation through ttsx timed out after ` + `${PLUGIN_DESCRIPTOR_TIMEOUT_MS / 1_000} seconds. ` + "Descriptor modules and factories must finish their setup within that window.", ); } - if (code === "ENOBUFS") { + if (code === "ENOBUFS" || nestedCode === "ENOBUFS") { return new Error( `ttsc: plugin descriptor "${request}" evaluation through ttsx exceeded the ` + `${PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES / (1024 * 1024)} MiB process output limit. ` + @@ -63,6 +67,22 @@ export function pluginDescriptorProcessFailure( return undefined; } +/** + * Pass the semantic deadline and private status pipe through the `ttsx` wrapper + * to the runtime child that actually executes the descriptor. + */ +export function pluginDescriptorBoundaryEnvironment( + now: number = Date.now(), +): NodeJS.ProcessEnv { + return { + TTSC_TTSX_EVALUATOR_DEADLINE_MS: String(now + PLUGIN_DESCRIPTOR_TIMEOUT_MS), + TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String( + PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, + ), + TTSC_TTSX_EVALUATOR_STATUS_FD: String(PLUGIN_DESCRIPTOR_STATUS_FD), + }; +} + function hasText(value: string | null | undefined): value is string { return value !== null && value !== undefined && value.trim() !== ""; } diff --git a/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts b/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts index 2230eb71e4..0d4469962f 100644 --- a/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts +++ b/packages/ttsc/src/plugin/internal/loadProjectPlugins.ts @@ -18,6 +18,8 @@ import { buildSourcePlugin } from "./buildSourcePlugin"; import { PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, PLUGIN_DESCRIPTOR_PROCESS_OPTIONS, + PLUGIN_DESCRIPTOR_STATUS_FD, + pluginDescriptorBoundaryEnvironment, pluginDescriptorProcessFailure, } from "./descriptorProcessFailure"; @@ -771,8 +773,18 @@ function loadDescriptorViaTtsx( TTSC_PLUGIN_DESCRIPTOR_LOAD: "1", TTSC_PLUGIN_DESCRIPTOR_OUT: out, TTSC_PLUGIN_ENTRY: request, + ...pluginDescriptorBoundaryEnvironment(), }, ...PLUGIN_DESCRIPTOR_PROCESS_OPTIONS, + stdio: [ + "ignore", + "pipe", + "pipe", + ...Array.from( + { length: PLUGIN_DESCRIPTOR_STATUS_FD - 2 }, + () => "pipe" as const, + ), + ], windowsHide: true, }); const processFailure = pluginDescriptorProcessFailure(result, request); diff --git a/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts b/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts index 9f3ec4eb84..2ce6b0149c 100644 --- a/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts +++ b/tests/test-lint/src/features/plugin/test_config_evaluator_process_failures_are_classified_by_cause.ts @@ -2,20 +2,26 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { + CONFIG_EVALUATOR_MAX_BUFFER, CONFIG_EVALUATOR_PROCESS_OPTIONS, + CONFIG_EVALUATOR_STATUS_FD, + CONFIG_EVALUATOR_TIMEOUT_MS, + configEvaluatorBoundaryEnvironment, configEvaluatorProcessFailure, } from "../../../../../packages/lint/lib/internal/configEvaluatorFailure.js"; /** * Verifies isolated lint-config process failures preserve their real cause. * - * Node attaches `SIGTERM` to both timeout and max-buffer errors. Treating the - * signal first made those distinct failures indistinguishable and falsely - * blamed the timeout for excessive output. + * Node attaches the configured termination signal to both timeout and + * max-buffer errors. Treating the signal first made those distinct failures + * indistinguishable, while bounding only the outer ttsx wrapper left its + * runtime child alive. * * 1. Classify timeout, output overflow, spawn, signal, and exit failures. * 2. Assert process error codes take precedence over their shared signal. - * 3. Assert evaluator stderr is bounded and a successful result has no error. + * 3. Recognize the runtime child's private timeout/output status and boundary env. + * 4. Assert evaluator stderr is bounded and a successful result has no error. */ export const test_config_evaluator_process_failures_are_classified_by_cause = (): void => { @@ -82,6 +88,37 @@ export const test_config_evaluator_process_failures_are_classified_by_cause = ); assert.ok((bounded?.message.length ?? Number.POSITIVE_INFINITY) < 8_500); + const nestedTimeout = configEvaluatorProcessFailure( + processResult({ + output: [null, "", "", "ETIMEDOUT"], + status: 1, + }), + configPath, + ); + assert.match(nestedTimeout?.message ?? "", /timed out after 60 seconds/); + assert.doesNotMatch(nestedTimeout?.message ?? "", /exit code 1/); + + const nestedOverflow = configEvaluatorProcessFailure( + processResult({ + output: [null, "", "", "ENOBUFS"], + status: 1, + }), + configPath, + ); + assert.match( + nestedOverflow?.message ?? "", + /exceeded the 16 MiB output limit/, + ); + assert.doesNotMatch(nestedOverflow?.message ?? "", /exit code 1/); + + assert.deepEqual(configEvaluatorBoundaryEnvironment(1_000), { + TTSC_TTSX_EVALUATOR_DEADLINE_MS: String( + 1_000 + CONFIG_EVALUATOR_TIMEOUT_MS, + ), + TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String(CONFIG_EVALUATOR_MAX_BUFFER), + TTSC_TTSX_EVALUATOR_STATUS_FD: String(CONFIG_EVALUATOR_STATUS_FD), + }); + assertTimeoutCannotBeDefeatedBySigtermHandler(); assert.equal( @@ -124,9 +161,11 @@ function processResult( signal: NodeJS.Signals; status: number; stderr: string; + output: readonly (string | null)[]; }>, ): { error?: Error; + output?: readonly (string | null)[]; signal: NodeJS.Signals | null; status: number | null; stderr: string | null; diff --git a/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts b/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts index 6aadd0bdc3..c70d679346 100644 --- a/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts +++ b/tests/test-ttsc/src/features/project/test_plugin_descriptor_process_failures_are_classified_by_cause.ts @@ -2,21 +2,26 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { + PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, PLUGIN_DESCRIPTOR_PROCESS_OPTIONS, + PLUGIN_DESCRIPTOR_STATUS_FD, + PLUGIN_DESCRIPTOR_TIMEOUT_MS, + pluginDescriptorBoundaryEnvironment, pluginDescriptorProcessFailure, } from "../../../../../packages/ttsc/lib/plugin/internal/descriptorProcessFailure.js"; /** * Verifies executable plugin-descriptor failures preserve their real cause. * - * Node attaches `SIGTERM` to both timeout and max-buffer errors. Checking the - * signal first would hide whether descriptor setup stalled or emitted too much - * output, while an unbounded stderr echo could replace one failure with a huge - * parent diagnostic. + * Node attaches the configured termination signal to both timeout and + * max-buffer errors. Checking the signal first would hide whether descriptor + * setup stalled or emitted too much output, while bounding only the outer ttsx + * wrapper would leave its runtime child alive. * * 1. Classify timeout, output overflow, spawn, signal, and exit failures. * 2. Assert process error codes take precedence over their shared signal. - * 3. Bound the non-zero stderr suffix and accept a successful result. + * 3. Recognize the runtime child's private timeout/output status and boundary env. + * 4. Bound the non-zero stderr suffix and accept a successful result. */ export const test_plugin_descriptor_process_failures_are_classified_by_cause = (): void => { @@ -96,6 +101,39 @@ export const test_plugin_descriptor_process_failures_are_classified_by_cause = ); assert.match(stdout?.message ?? "", /stdout-only descriptor cause$/); + const nestedTimeout = pluginDescriptorProcessFailure( + processResult({ + output: [null, "", "", "ETIMEDOUT"], + status: 1, + }), + request, + ); + assert.match(nestedTimeout?.message ?? "", /timed out after 60 seconds/); + assert.doesNotMatch(nestedTimeout?.message ?? "", /exit code 1/); + + const nestedOverflow = pluginDescriptorProcessFailure( + processResult({ + output: [null, "", "", "ENOBUFS"], + status: 1, + }), + request, + ); + assert.match( + nestedOverflow?.message ?? "", + /exceeded the 16 MiB process output limit/, + ); + assert.doesNotMatch(nestedOverflow?.message ?? "", /exit code 1/); + + assert.deepEqual(pluginDescriptorBoundaryEnvironment(1_000), { + TTSC_TTSX_EVALUATOR_DEADLINE_MS: String( + 1_000 + PLUGIN_DESCRIPTOR_TIMEOUT_MS, + ), + TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String( + PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, + ), + TTSC_TTSX_EVALUATOR_STATUS_FD: String(PLUGIN_DESCRIPTOR_STATUS_FD), + }); + assertTimeoutCannotBeDefeatedBySigtermHandler(); assert.equal( @@ -139,9 +177,11 @@ function processResult( status: number; stderr: string; stdout: string; + output: readonly (string | null)[]; }>, ): { error?: Error; + output?: readonly (string | null)[]; signal: NodeJS.Signals | null; status: number | null; stderr: string | null; diff --git a/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts b/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts new file mode 100644 index 0000000000..d51bfc71f7 --- /dev/null +++ b/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts @@ -0,0 +1,89 @@ +import { TestProject } from "@ttsc/testing"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +import { PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES } from "../../../../../packages/ttsc/lib/plugin/internal/descriptorProcessFailure.js"; + +/** + * Verifies the private evaluator deadline reaches ttsx's runtime child. + * + * The lint and descriptor loaders spawn the ttsx wrapper, but user setup runs + * in the second Node process created by `runPreparedEntry`. Killing only the + * wrapper leaves that evaluator alive on POSIX. + * + * 1. Run a real ttsx entry that records startup and schedules a later marker. + * 2. Give the runtime child an absolute deadline before that later marker. + * 3. Assert ttsx reports the private timeout and the child never reaches it. + */ +export const test_ttsx_evaluator_deadline_terminates_the_runtime_child = + async (): Promise => { + const root = TestProject.commonJsProject({ + "src/node.d.ts": [ + "declare const process: {", + " env: Record;", + " exit(code: number): never;", + "};", + 'declare function require(name: "node:fs"): {', + " writeFileSync(path: string, data: string): void;", + "};", + "declare function setTimeout(callback: () => void, ms: number): unknown;", + "", + ].join("\n"), + "src/main.ts": [ + 'const fs = require("node:fs");', + 'fs.writeFileSync(String(process.env.TTSC_TEST_READY), "ready");', + "setTimeout(", + " () =>", + ' fs.writeFileSync(String(process.env.TTSC_TEST_LATE), "too late"),', + " Math.max(0, Number(process.env.TTSC_TEST_LATE_AT_MS) - Date.now()),", + ");", + "setTimeout(", + " () => process.exit(0),", + " Math.max(0, Number(process.env.TTSC_TEST_EXIT_AT_MS) - Date.now()),", + ");", + "", + ].join("\n"), + }); + const ready = path.join(root, "ready.txt"); + const late = path.join(root, "late.txt"); + const deadlineMs = Date.now() + 6_000; + const lateAtMs = deadlineMs + 750; + const exitAtMs = deadlineMs + 2_000; + + const result = TestProject.spawn( + TestProject.TTSX_BIN, + ["--cwd", root, "src/main.ts"], + { + cwd: root, + env: { + TTSC_TEST_EXIT_AT_MS: String(exitAtMs), + TTSC_TEST_LATE: late, + TTSC_TEST_LATE_AT_MS: String(lateAtMs), + TTSC_TEST_READY: ready, + TTSC_TTSX_EVALUATOR_DEADLINE_MS: String(deadlineMs), + TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String( + PLUGIN_DESCRIPTOR_MAX_BUFFER_BYTES, + ), + TTSC_TTSX_EVALUATOR_STATUS_FD: "3", + }, + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "pipe", "pipe"], + timeout: 12_000, + }, + ); + + assert.equal(result.error, undefined, result.stderr); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.output?.[3]?.trim(), "ETIMEDOUT"); + assert.equal(fs.existsSync(ready), true, "runtime child never started"); + + await new Promise((resolve) => + setTimeout(resolve, Math.max(0, lateAtMs + 500 - Date.now())), + ); + assert.equal( + fs.existsSync(late), + false, + "runtime child survived its evaluator deadline", + ); + }; diff --git a/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_output_limit_terminates_the_runtime_child.ts b/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_output_limit_terminates_the_runtime_child.ts new file mode 100644 index 0000000000..9ace03dcf2 --- /dev/null +++ b/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_output_limit_terminates_the_runtime_child.ts @@ -0,0 +1,78 @@ +import { TestProject } from "@ttsc/testing"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +/** + * Verifies the private evaluator output cap belongs to the runtime child. + * + * If only the outer ttsx wrapper owns `maxBuffer`, excessive config output + * kills that wrapper and leaves its runtime child alive. The wrapper instead + * captures and bounds the runtime streams itself, then reports the cause on the + * private status fd. + * + * 1. Run a real ttsx entry that records startup and emits beyond a small cap. + * 2. Schedule a marker that would be written if the runtime survived. + * 3. Assert ttsx reports ENOBUFS and the child never writes the late marker. + */ +export const test_ttsx_evaluator_output_limit_terminates_the_runtime_child = + async (): Promise => { + const root = TestProject.commonJsProject({ + "src/node.d.ts": [ + "declare const process: {", + " env: Record;", + " exit(code: number): never;", + " stdout: { write(data: string): void };", + "};", + 'declare function require(name: "node:fs"): {', + " writeFileSync(path: string, data: string): void;", + "};", + "declare function setTimeout(callback: () => void, ms: number): unknown;", + "", + ].join("\n"), + "src/main.ts": [ + 'const fs = require("node:fs");', + 'fs.writeFileSync(String(process.env.TTSC_TEST_READY), "ready");', + 'process.stdout.write("x".repeat(100_000));', + "setTimeout(", + " () =>", + ' fs.writeFileSync(String(process.env.TTSC_TEST_LATE), "too late"),', + " 500,", + ");", + "setTimeout(() => process.exit(0), 1_000);", + "", + ].join("\n"), + }); + const ready = path.join(root, "ready.txt"); + const late = path.join(root, "late.txt"); + + const result = TestProject.spawn( + TestProject.TTSX_BIN, + ["--cwd", root, "src/main.ts"], + { + cwd: root, + env: { + TTSC_TEST_LATE: late, + TTSC_TEST_READY: ready, + TTSC_TTSX_EVALUATOR_DEADLINE_MS: String(Date.now() + 10_000), + TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: "1024", + TTSC_TTSX_EVALUATOR_STATUS_FD: "3", + }, + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "pipe", "pipe"], + timeout: 12_000, + }, + ); + + assert.equal(result.error, undefined, result.stderr); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.output?.[3]?.trim(), "ENOBUFS"); + assert.equal(fs.existsSync(ready), true, "runtime child never started"); + + await new Promise((resolve) => setTimeout(resolve, 750)); + assert.equal( + fs.existsSync(late), + false, + "runtime child survived its evaluator output limit", + ); + }; From 764b5a57ffcbceca791ead78851b8d24571ec8c9 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Mon, 27 Jul 2026 06:27:38 +0900 Subject: [PATCH 11/11] test: pin nested evaluator hard kill --- ...test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts b/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts index d51bfc71f7..7135faf859 100644 --- a/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts +++ b/tests/test-ttsc/src/features/ttsx-runtime/test_ttsx_evaluator_deadline_terminates_the_runtime_child.ts @@ -23,6 +23,7 @@ export const test_ttsx_evaluator_deadline_terminates_the_runtime_child = "declare const process: {", " env: Record;", " exit(code: number): never;", + ' on(signal: "SIGTERM", listener: () => void): void;', "};", 'declare function require(name: "node:fs"): {', " writeFileSync(path: string, data: string): void;", @@ -32,6 +33,7 @@ export const test_ttsx_evaluator_deadline_terminates_the_runtime_child = ].join("\n"), "src/main.ts": [ 'const fs = require("node:fs");', + 'process.on("SIGTERM", () => {});', 'fs.writeFileSync(String(process.env.TTSC_TEST_READY), "ready");', "setTimeout(", " () =>",