ttsc 0.17.4 → 0.18.1

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.
@@ -104,6 +104,9 @@ const CONTRIB_DIRNAME = "contrib";
104
104
  // runtimeHooks.ts.
105
105
  const PLUGIN_BUILD_LOCK_STEAL_MS = 600_000;
106
106
  const PLUGIN_BUILD_LOCK_POLL_MS = 50;
107
+ const PLUGIN_BUILD_LOCK_LEGACY_STALE_MS = 30_000;
108
+ const PLUGIN_BUILD_LOCK_STATUS_MS = 30_000;
109
+ const PLUGIN_BUILD_LOCK_OWNER_FILE = "owner.json";
107
110
  // The default cache lives INSIDE the workspace, at
108
111
  // `<workspaceRoot>/node_modules/.cache/ttsc`, so `rm -rf node_modules` (or
109
112
  // deleting the repo) reclaims every compiled plugin binary and Go object file.
@@ -192,22 +195,28 @@ export function buildSourcePlugin(opts: {
192
195
  return binaryPath;
193
196
  }
194
197
  fs.mkdirSync(cacheDir, { recursive: true });
195
- return buildUnderPluginLock(cacheDir, binaryPath, () =>
196
- compileSourcePlugin({
197
- binaryPath,
198
- cacheDir,
199
- contributors,
200
- dir,
201
- entry,
202
- goBinary,
203
- key,
204
- label: opts.label ?? "source plugin",
205
- goBuildCacheRoot: paths.goBuildRoot,
206
- overlayDirs,
207
- pluginName: opts.pluginName,
208
- quiet: opts.quiet === true,
209
- source,
210
- }),
198
+ const label = opts.label ?? "source plugin";
199
+ const quiet = opts.quiet === true;
200
+ return buildUnderPluginLock(
201
+ cacheDir,
202
+ binaryPath,
203
+ { label, pluginName: opts.pluginName, quiet },
204
+ () =>
205
+ compileSourcePlugin({
206
+ binaryPath,
207
+ cacheDir,
208
+ contributors,
209
+ dir,
210
+ entry,
211
+ goBinary,
212
+ key,
213
+ label,
214
+ goBuildCacheRoot: paths.goBuildRoot,
215
+ overlayDirs,
216
+ pluginName: opts.pluginName,
217
+ quiet,
218
+ source,
219
+ }),
211
220
  );
212
221
  }
213
222
 
@@ -235,7 +244,8 @@ function compileSourcePlugin(opts: {
235
244
  .map((c) => c.name)
236
245
  .join(", ")}`;
237
246
  process.stderr.write(
238
- `ttsc: building ${opts.label} "${opts.pluginName}" from ${opts.source}${extra} (this runs once per cache key)\n`,
247
+ `ttsc: building ${opts.label} "${opts.pluginName}" from ${opts.source}${extra} ` +
248
+ `(this runs once per cache key and can take several minutes on a cold Go cache)\n`,
239
249
  );
240
250
  }
241
251
 
@@ -294,6 +304,11 @@ function compileSourcePlugin(opts: {
294
304
  function buildUnderPluginLock(
295
305
  cacheDir: string,
296
306
  binaryPath: string,
307
+ lockInfo: {
308
+ label: string;
309
+ pluginName: string;
310
+ quiet: boolean;
311
+ },
297
312
  build: () => string,
298
313
  ): string {
299
314
  const lockDir = `${cacheDir}.lock`;
@@ -308,6 +323,7 @@ function buildUnderPluginLock(
308
323
  // exists, so a single-level mkdir is all that is needed and its EEXIST
309
324
  // is exactly the "someone else holds the lock" signal.
310
325
  fs.mkdirSync(lockDir);
326
+ writePluginBuildLockOwner(lockDir);
311
327
  } catch (error) {
312
328
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
313
329
  // A lock dir we cannot create for an unexpected reason (e.g. a
@@ -315,14 +331,17 @@ function buildUnderPluginLock(
315
331
  // unlocked build — correctness is preserved by the atomic publish.
316
332
  return build();
317
333
  }
318
- const waited = waitForPluginBinary(
334
+ const waited = waitForPluginBinary({
319
335
  binaryPath,
320
- PLUGIN_BUILD_LOCK_STEAL_MS,
321
- );
322
- if (waited) {
336
+ lockDir,
337
+ lockInfo,
338
+ timeoutMs: PLUGIN_BUILD_LOCK_STEAL_MS,
339
+ });
340
+ if (waited.published) {
323
341
  touchCacheEntry(cacheDir);
324
342
  return binaryPath;
325
343
  }
344
+ reportPluginLockSteal(lockDir, binaryPath, lockInfo, waited.reason);
326
345
  // Builder appears to have crashed: steal the abandoned lock and retry.
327
346
  fs.rmSync(lockDir, { force: true, recursive: true });
328
347
  continue;
@@ -341,19 +360,216 @@ function buildUnderPluginLock(
341
360
  }
342
361
 
343
362
  /** Poll for the locked builder to publish its binary, up to `timeoutMs`. */
344
- function waitForPluginBinary(binaryPath: string, timeoutMs: number): boolean {
363
+ function waitForPluginBinary(opts: {
364
+ binaryPath: string;
365
+ lockDir: string;
366
+ lockInfo: {
367
+ label: string;
368
+ pluginName: string;
369
+ quiet: boolean;
370
+ };
371
+ timeoutMs: number;
372
+ }): { published: boolean; reason?: string } {
345
373
  const startedAt = Date.now();
374
+ let nextStatusAt = startedAt + PLUGIN_BUILD_LOCK_STATUS_MS;
346
375
  for (;;) {
347
- if (fs.existsSync(binaryPath)) {
348
- return true;
376
+ if (fs.existsSync(opts.binaryPath)) {
377
+ return { published: true };
349
378
  }
350
- if (Date.now() - startedAt > timeoutMs) {
351
- return false;
379
+ const now = Date.now();
380
+ const lockStatus = inspectPluginBuildLock(opts.lockDir, now);
381
+ if (lockStatus.abandoned) {
382
+ return {
383
+ published: false,
384
+ reason: lockStatus.reason,
385
+ };
386
+ }
387
+ if (now - startedAt > opts.timeoutMs) {
388
+ return {
389
+ published: false,
390
+ reason: `timed out after ${formatDuration(now - startedAt)}`,
391
+ };
392
+ }
393
+ if (!opts.lockInfo.quiet && now >= nextStatusAt) {
394
+ reportPluginLockWait({
395
+ binaryPath: opts.binaryPath,
396
+ elapsedMs: now - startedAt,
397
+ lockDir: opts.lockDir,
398
+ lockInfo: opts.lockInfo,
399
+ owner: lockStatus.owner,
400
+ });
401
+ nextStatusAt = now + PLUGIN_BUILD_LOCK_STATUS_MS;
352
402
  }
353
403
  sleepSync(PLUGIN_BUILD_LOCK_POLL_MS);
354
404
  }
355
405
  }
356
406
 
407
+ function writePluginBuildLockOwner(lockDir: string): void {
408
+ try {
409
+ fs.writeFileSync(
410
+ path.join(lockDir, PLUGIN_BUILD_LOCK_OWNER_FILE),
411
+ `${JSON.stringify(
412
+ {
413
+ hostname: os.hostname(),
414
+ pid: process.pid,
415
+ startedAt: new Date().toISOString(),
416
+ },
417
+ null,
418
+ 2,
419
+ )}\n`,
420
+ "utf8",
421
+ );
422
+ } catch {
423
+ // Best-effort metadata: the mkdir lock still serializes the build.
424
+ }
425
+ }
426
+
427
+ function inspectPluginBuildLock(
428
+ lockDir: string,
429
+ now: number,
430
+ ): {
431
+ abandoned: boolean;
432
+ owner: string;
433
+ reason?: string;
434
+ } {
435
+ const owner = readPluginBuildLockOwner(lockDir);
436
+ if (owner !== null) {
437
+ const label = describePluginBuildLockOwner(owner);
438
+ if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
439
+ return {
440
+ abandoned: true,
441
+ owner: label,
442
+ reason: `${label} is no longer running`,
443
+ };
444
+ }
445
+ return {
446
+ abandoned: false,
447
+ owner: label,
448
+ };
449
+ }
450
+
451
+ const ageMs = pluginBuildLockAgeMs(lockDir, now);
452
+ if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) {
453
+ return {
454
+ abandoned: true,
455
+ owner: `legacy lock with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
456
+ reason:
457
+ `legacy lock has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` +
458
+ `${formatDuration(ageMs)} old`,
459
+ };
460
+ }
461
+ return {
462
+ abandoned: false,
463
+ owner: `legacy lock with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
464
+ };
465
+ }
466
+
467
+ function readPluginBuildLockOwner(
468
+ lockDir: string,
469
+ ): { hostname: string; pid: number; startedAt?: string } | null {
470
+ try {
471
+ const parsed = JSON.parse(
472
+ fs.readFileSync(path.join(lockDir, PLUGIN_BUILD_LOCK_OWNER_FILE), "utf8"),
473
+ ) as Record<string, unknown>;
474
+ if (
475
+ typeof parsed.hostname !== "string" ||
476
+ !Number.isInteger(parsed.pid) ||
477
+ typeof parsed.pid !== "number" ||
478
+ parsed.pid <= 0
479
+ ) {
480
+ return null;
481
+ }
482
+ return {
483
+ hostname: parsed.hostname,
484
+ pid: parsed.pid,
485
+ startedAt:
486
+ typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
487
+ };
488
+ } catch {
489
+ return null;
490
+ }
491
+ }
492
+
493
+ function pluginBuildLockAgeMs(lockDir: string, now: number): number {
494
+ try {
495
+ return Math.max(0, now - fs.statSync(lockDir).mtimeMs);
496
+ } catch {
497
+ return Number.POSITIVE_INFINITY;
498
+ }
499
+ }
500
+
501
+ function isLocalHostName(hostname: string): boolean {
502
+ return hostname.toLowerCase() === os.hostname().toLowerCase();
503
+ }
504
+
505
+ function isProcessAlive(pid: number): boolean {
506
+ try {
507
+ process.kill(pid, 0);
508
+ return true;
509
+ } catch (error) {
510
+ return (error as NodeJS.ErrnoException).code === "EPERM";
511
+ }
512
+ }
513
+
514
+ function describePluginBuildLockOwner(owner: {
515
+ hostname: string;
516
+ pid: number;
517
+ startedAt?: string;
518
+ }): string {
519
+ const started =
520
+ owner.startedAt === undefined ? "" : ` started at ${owner.startedAt}`;
521
+ return `pid ${owner.pid} on ${owner.hostname}${started}`;
522
+ }
523
+
524
+ function reportPluginLockWait(opts: {
525
+ binaryPath: string;
526
+ elapsedMs: number;
527
+ lockDir: string;
528
+ lockInfo: {
529
+ label: string;
530
+ pluginName: string;
531
+ quiet: boolean;
532
+ };
533
+ owner: string;
534
+ }): void {
535
+ process.stderr.write(
536
+ `ttsc: waiting for ${opts.lockInfo.label} "${opts.lockInfo.pluginName}" ` +
537
+ `cache lock after ${formatDuration(opts.elapsedMs)}; ` +
538
+ `lock=${opts.lockDir}; binary=${opts.binaryPath}; owner=${opts.owner}\n`,
539
+ );
540
+ }
541
+
542
+ function reportPluginLockSteal(
543
+ lockDir: string,
544
+ binaryPath: string,
545
+ lockInfo: {
546
+ label: string;
547
+ pluginName: string;
548
+ quiet: boolean;
549
+ },
550
+ reason: string | undefined,
551
+ ): void {
552
+ if (lockInfo.quiet) return;
553
+ const suffix = reason === undefined ? "" : ` (${reason})`;
554
+ process.stderr.write(
555
+ `ttsc: reclaiming abandoned ${lockInfo.label} "${lockInfo.pluginName}" ` +
556
+ `cache lock at ${lockDir}; binary=${binaryPath}${suffix}\n`,
557
+ );
558
+ }
559
+
560
+ function formatDuration(ms: number): string {
561
+ if (ms < 1_000) {
562
+ return `${Math.max(0, Math.round(ms))}ms`;
563
+ }
564
+ const seconds = Math.floor(ms / 1_000);
565
+ const minutes = Math.floor(seconds / 60);
566
+ const remainder = seconds % 60;
567
+ if (minutes === 0) {
568
+ return `${seconds}s`;
569
+ }
570
+ return `${minutes}m ${remainder}s`;
571
+ }
572
+
357
573
  /** Block the current (synchronous) thread for `ms` without busy-spinning. */
358
574
  function sleepSync(ms: number): void {
359
575
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);