ttsc 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/driver/emit_plugin.go +131 -0
  2. package/driver/plugins.go +37 -0
  3. package/go.mod +0 -3
  4. package/go.sum +0 -4
  5. package/lib/compiler/internal/resolveEmittedJavaScript.js +25 -4
  6. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  7. package/lib/launcher/internal/prepareExecution.d.ts +4 -1
  8. package/lib/launcher/internal/prepareExecution.js +13 -17
  9. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  10. package/lib/launcher/internal/registerRuntimeHooks.js +43 -8
  11. package/lib/launcher/internal/registerRuntimeHooks.js.map +1 -1
  12. package/lib/launcher/internal/runTtsx.js +46 -386
  13. package/lib/launcher/internal/runTtsx.js.map +1 -1
  14. package/lib/launcher/internal/runtimeHookPreload.d.ts +1 -0
  15. package/lib/launcher/internal/runtimeHookPreload.js +16 -0
  16. package/lib/launcher/internal/runtimeHookPreload.js.map +1 -0
  17. package/lib/launcher/internal/runtimeHooks.d.ts +16 -38
  18. package/lib/launcher/internal/runtimeHooks.js +672 -119
  19. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  20. package/lib/plugin/internal/buildSourcePlugin.js +120 -16
  21. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  22. package/lib/plugin/internal/loadProjectPlugins.js +15 -4
  23. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  24. package/package.json +8 -8
  25. package/shim/ast/parent.go +31 -0
  26. package/shim/compiler/emitassemble.go +30 -0
  27. package/shim/compiler/emithook.go +22 -0
  28. package/shim/compiler/go.mod +9 -0
  29. package/shim/compiler/go.sum +22 -0
  30. package/shim/core/modulekind.go +10 -0
  31. package/shim/printer/emithost.go +24 -0
  32. package/shim/printer/go.mod +9 -0
  33. package/shim/printer/go.sum +22 -0
  34. package/shim/transformers/go.mod +14 -0
  35. package/shim/transformers/go.sum +20 -0
  36. package/shim/transformers/shim.go +15 -0
  37. package/src/compiler/internal/resolveEmittedJavaScript.ts +35 -4
  38. package/src/launcher/internal/prepareExecution.ts +18 -21
  39. package/src/launcher/internal/registerRuntimeHooks.ts +53 -8
  40. package/src/launcher/internal/runTtsx.ts +61 -453
  41. package/src/launcher/internal/runtimeHookPreload.ts +14 -0
  42. package/src/launcher/internal/runtimeHooks.ts +794 -136
  43. package/src/plugin/internal/buildSourcePlugin.ts +147 -16
  44. package/src/plugin/internal/loadProjectPlugins.ts +16 -4
@@ -89,6 +89,17 @@ const EXTERNAL_GO_BUILD_ENV_KEYS: readonly string[] = [
89
89
  ];
90
90
  const CONTRIBUTIONS_FILE_NAME = "ttsc_contributions.go";
91
91
  const CONTRIB_DIRNAME = "contrib";
92
+ // A cold source-plugin build is a multi-second-to-minutes `go build`. When a
93
+ // program fans out into many processes (a `pnpm -r` running several suites in
94
+ // parallel, a benchmark, a worker pool), each inherits the same cold cache and
95
+ // would otherwise launch its own full build of the SAME cache key at the same
96
+ // instant. The atomic lock below lets one process build while the rest poll for
97
+ // its published binary, so the toolchain runs once per cache key instead of N
98
+ // times. A waiter steals an abandoned lock (builder crashed) after this timeout
99
+ // so a fan-out never wedges; it matches the dependency-build lock in
100
+ // runtimeHooks.ts.
101
+ const PLUGIN_BUILD_LOCK_STEAL_MS = 600_000;
102
+ const PLUGIN_BUILD_LOCK_POLL_MS = 50;
92
103
  const GLOBAL_CACHE_DIRNAME = "ttsc";
93
104
  const PLUGIN_CACHE_DIRNAME = "plugins";
94
105
  const CACHE_LAST_USED_FILE = ".last-used";
@@ -143,47 +154,167 @@ export function buildSourcePlugin(opts: {
143
154
  return binaryPath;
144
155
  }
145
156
  fs.mkdirSync(cacheDir, { recursive: true });
146
- const label = opts.label ?? "source plugin";
147
- if (opts.quiet !== true) {
157
+ return buildUnderPluginLock(cacheDir, binaryPath, () =>
158
+ compileSourcePlugin({
159
+ binaryPath,
160
+ cacheDir,
161
+ contributors,
162
+ dir,
163
+ entry,
164
+ goBinary,
165
+ key,
166
+ label: opts.label ?? "source plugin",
167
+ overlayDirs,
168
+ pluginName: opts.pluginName,
169
+ quiet: opts.quiet === true,
170
+ source,
171
+ }),
172
+ );
173
+ }
174
+
175
+ /** Run the actual `go build` and publish the binary; assumes the lock is held. */
176
+ function compileSourcePlugin(opts: {
177
+ binaryPath: string;
178
+ cacheDir: string;
179
+ contributors: readonly ITtscBuildContributor[];
180
+ dir: string;
181
+ entry: string;
182
+ goBinary: string;
183
+ key: string;
184
+ label: string;
185
+ overlayDirs: readonly string[];
186
+ pluginName: string;
187
+ quiet: boolean;
188
+ source: string;
189
+ }): string {
190
+ if (!opts.quiet) {
148
191
  const extra =
149
- contributors.length === 0
192
+ opts.contributors.length === 0
150
193
  ? ""
151
- : ` + ${contributors.length} contributor(s): ${contributors
194
+ : ` + ${opts.contributors.length} contributor(s): ${opts.contributors
152
195
  .map((c) => c.name)
153
196
  .join(", ")}`;
154
197
  process.stderr.write(
155
- `ttsc: building ${label} "${opts.pluginName}" from ${source}${extra} (this runs once per cache key)\n`,
198
+ `ttsc: building ${opts.label} "${opts.pluginName}" from ${opts.source}${extra} (this runs once per cache key)\n`,
156
199
  );
157
200
  }
158
201
 
159
202
  const scratchDir = fs.mkdtempSync(
160
- path.join(os.tmpdir(), `ttsc-plugin-${key}-`),
203
+ path.join(os.tmpdir(), `ttsc-plugin-${opts.key}-`),
161
204
  );
162
205
  try {
163
- materializeScratchDir(dir, scratchDir);
164
- const goModReader = createGoModReader(goBinary, opts.pluginName);
165
- if (contributors.length > 0) {
206
+ materializeScratchDir(opts.dir, scratchDir);
207
+ const goModReader = createGoModReader(opts.goBinary, opts.pluginName);
208
+ if (opts.contributors.length > 0) {
166
209
  mergeContributors({
167
- contributors,
168
- entry,
210
+ contributors: opts.contributors,
211
+ entry: opts.entry,
169
212
  goModReader,
170
213
  pluginName: opts.pluginName,
171
214
  scratchDir,
172
215
  });
173
216
  }
174
- writeGoWork(scratchDir, overlayDirs, goBinary, opts.pluginName);
217
+ writeGoWork(scratchDir, opts.overlayDirs, opts.goBinary, opts.pluginName);
175
218
  const scratchBinaryName =
176
219
  process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin";
177
- runGoBuild(scratchDir, entry, scratchBinaryName, opts.pluginName, goBinary);
220
+ runGoBuild(
221
+ scratchDir,
222
+ opts.entry,
223
+ scratchBinaryName,
224
+ opts.pluginName,
225
+ opts.goBinary,
226
+ );
178
227
  const builtBinary = path.join(scratchDir, scratchBinaryName);
179
- publishBuiltBinary(builtBinary, binaryPath);
180
- touchCacheEntry(cacheDir);
181
- return binaryPath;
228
+ publishBuiltBinary(builtBinary, opts.binaryPath);
229
+ touchCacheEntry(opts.cacheDir);
230
+ return opts.binaryPath;
182
231
  } finally {
183
232
  fs.rmSync(scratchDir, { recursive: true, force: true });
184
233
  }
185
234
  }
186
235
 
236
+ /**
237
+ * Build a source plugin while holding an exclusive cross-process lock for its
238
+ * cache key, so concurrent fan-out (parallel suites, a benchmark, a worker
239
+ * pool) runs the `go build` once instead of once per process.
240
+ *
241
+ * The lock is an atomic `mkdir` on `<cacheDir>.lock`. The winner builds and
242
+ * publishes the binary; every loser polls for that binary to appear and reuses
243
+ * it. A loser that waits past `PLUGIN_BUILD_LOCK_STEAL_MS` (the builder
244
+ * crashed without publishing) steals the abandoned lock and retries. This is
245
+ * the same shape as `withBuildLock` in the runtime hooks, keyed on the plugin
246
+ * binary's existence instead of a JSON meta marker.
247
+ *
248
+ * Correctness still rests on `publishBuiltBinary`'s atomic rename: the lock is
249
+ * an optimisation to avoid duplicate work, not the only guard against a
250
+ * corrupt binary, so a stolen lock that races a slow builder cannot ship a
251
+ * half-written file.
252
+ */
253
+ function buildUnderPluginLock(
254
+ cacheDir: string,
255
+ binaryPath: string,
256
+ build: () => string,
257
+ ): string {
258
+ const lockDir = `${cacheDir}.lock`;
259
+ for (;;) {
260
+ if (fs.existsSync(binaryPath)) {
261
+ touchCacheEntry(cacheDir);
262
+ return binaryPath;
263
+ }
264
+ try {
265
+ // Plain mkdir (no `recursive`): recursive mode is idempotent and would
266
+ // not throw EEXIST, defeating the lock. The parent `cacheDir` already
267
+ // exists, so a single-level mkdir is all that is needed and its EEXIST
268
+ // is exactly the "someone else holds the lock" signal.
269
+ fs.mkdirSync(lockDir);
270
+ } catch (error) {
271
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
272
+ // A lock dir we cannot create for an unexpected reason (e.g. a
273
+ // read-only cache) must not silently skip the build. Fall back to an
274
+ // unlocked build — correctness is preserved by the atomic publish.
275
+ return build();
276
+ }
277
+ const waited = waitForPluginBinary(binaryPath, PLUGIN_BUILD_LOCK_STEAL_MS);
278
+ if (waited) {
279
+ touchCacheEntry(cacheDir);
280
+ return binaryPath;
281
+ }
282
+ // Builder appears to have crashed: steal the abandoned lock and retry.
283
+ fs.rmSync(lockDir, { force: true, recursive: true });
284
+ continue;
285
+ }
286
+ try {
287
+ // Re-check under the lock: a previous holder may have just published.
288
+ if (fs.existsSync(binaryPath)) {
289
+ touchCacheEntry(cacheDir);
290
+ return binaryPath;
291
+ }
292
+ return build();
293
+ } finally {
294
+ fs.rmSync(lockDir, { force: true, recursive: true });
295
+ }
296
+ }
297
+ }
298
+
299
+ /** Poll for the locked builder to publish its binary, up to `timeoutMs`. */
300
+ function waitForPluginBinary(binaryPath: string, timeoutMs: number): boolean {
301
+ const startedAt = Date.now();
302
+ for (;;) {
303
+ if (fs.existsSync(binaryPath)) {
304
+ return true;
305
+ }
306
+ if (Date.now() - startedAt > timeoutMs) {
307
+ return false;
308
+ }
309
+ sleepSync(PLUGIN_BUILD_LOCK_POLL_MS);
310
+ }
311
+ }
312
+
313
+ /** Block the current (synchronous) thread for `ms` without busy-spinning. */
314
+ function sleepSync(ms: number): void {
315
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
316
+ }
317
+
187
318
  /**
188
319
  * Copy every contributor's Go source into a sub-package of the host module and
189
320
  * synthesize a blank-import file alongside the host's entry package so each
@@ -346,10 +346,22 @@ function resolvePluginEntries(
346
346
  config,
347
347
  }));
348
348
  }
349
- const configured = project.compilerOptions.plugins.map((config, index) => ({
350
- baseDir: project.pluginBaseDirs[index] ?? project.root,
351
- config,
352
- }));
349
+ const configured = project.compilerOptions.plugins.map((config, index) => {
350
+ // A bare/package plugin specifier (e.g. "typia/lib/transform") must resolve
351
+ // from the project's own node_modules, not from the tsconfig that declared
352
+ // it: an `extends`ed base config (a shared `tests/config/tsconfig.json`)
353
+ // declares the plugin, but the package is installed under the consuming
354
+ // project. Only a relative specifier ("./plugin") is meaningful relative to
355
+ // the declaring config's directory. Mirrors discoverPackagePluginEntries.
356
+ const declaringDir = project.pluginBaseDirs[index];
357
+ const baseDir =
358
+ typeof config.transform === "string" &&
359
+ isRelativePluginSpecifier(config.transform) &&
360
+ declaringDir !== undefined
361
+ ? declaringDir
362
+ : project.root;
363
+ return { baseDir, config };
364
+ });
353
365
  return [...configured, ...discoverPackagePluginEntries(project, configured)];
354
366
  }
355
367