@telorun/kernel 0.58.0 → 0.60.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 (87) hide show
  1. package/dist/bundle/files-integrity.d.ts +21 -12
  2. package/dist/bundle/files-integrity.d.ts.map +1 -1
  3. package/dist/bundle/files-integrity.js +27 -14
  4. package/dist/bundle/files-integrity.js.map +1 -1
  5. package/dist/bundle/module-artifact.d.ts +134 -0
  6. package/dist/bundle/module-artifact.d.ts.map +1 -0
  7. package/dist/bundle/module-artifact.js +300 -0
  8. package/dist/bundle/module-artifact.js.map +1 -0
  9. package/dist/bundle/module-manifest.d.ts +12 -4
  10. package/dist/bundle/module-manifest.d.ts.map +1 -1
  11. package/dist/bundle/module-manifest.js +8 -4
  12. package/dist/bundle/module-manifest.js.map +1 -1
  13. package/dist/controller-loader.d.ts +3 -2
  14. package/dist/controller-loader.d.ts.map +1 -1
  15. package/dist/controller-loader.js +8 -8
  16. package/dist/controller-loader.js.map +1 -1
  17. package/dist/controller-loaders/bundle-loader.d.ts +8 -6
  18. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  19. package/dist/controller-loaders/bundle-loader.js +52 -13
  20. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  21. package/dist/controller-loaders/npm-loader.d.ts +0 -18
  22. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  23. package/dist/controller-loaders/npm-loader.js +3 -190
  24. package/dist/controller-loaders/npm-loader.js.map +1 -1
  25. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  26. package/dist/controllers/resource-definition/resource-definition-controller.js +6 -1
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/directory-lock.d.ts +27 -0
  29. package/dist/directory-lock.d.ts.map +1 -0
  30. package/dist/directory-lock.js +205 -0
  31. package/dist/directory-lock.js.map +1 -0
  32. package/dist/evaluation-context.d.ts.map +1 -1
  33. package/dist/evaluation-context.js +20 -6
  34. package/dist/evaluation-context.js.map +1 -1
  35. package/dist/index.d.ts +4 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/kernel.d.ts +32 -0
  40. package/dist/kernel.d.ts.map +1 -1
  41. package/dist/kernel.js +55 -1
  42. package/dist/kernel.js.map +1 -1
  43. package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
  44. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  45. package/dist/manifest-sources/local-manifest-cache-source.js +1 -1
  46. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  47. package/dist/resource-context.d.ts +18 -0
  48. package/dist/resource-context.d.ts.map +1 -1
  49. package/dist/resource-context.js +72 -1
  50. package/dist/resource-context.js.map +1 -1
  51. package/dist/transports/oci/oci-client.d.ts +28 -1
  52. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  53. package/dist/transports/oci/oci-client.js +24 -1
  54. package/dist/transports/oci/oci-client.js.map +1 -1
  55. package/dist/transports/oci/oci-transport.d.ts +20 -14
  56. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  57. package/dist/transports/oci/oci-transport.js +105 -46
  58. package/dist/transports/oci/oci-transport.js.map +1 -1
  59. package/dist/transports/registry-transport.d.ts +7 -2
  60. package/dist/transports/registry-transport.d.ts.map +1 -1
  61. package/dist/transports/registry-transport.js +8 -33
  62. package/dist/transports/registry-transport.js.map +1 -1
  63. package/dist/transports/transport-registry.d.ts +5 -4
  64. package/dist/transports/transport-registry.d.ts.map +1 -1
  65. package/dist/transports/transport-registry.js +4 -4
  66. package/dist/transports/transport-registry.js.map +1 -1
  67. package/dist/transports/transport.d.ts +27 -19
  68. package/dist/transports/transport.d.ts.map +1 -1
  69. package/package.json +3 -3
  70. package/src/bundle/files-integrity.ts +34 -14
  71. package/src/bundle/module-artifact.ts +375 -0
  72. package/src/bundle/module-manifest.ts +22 -6
  73. package/src/controller-loader.ts +9 -4
  74. package/src/controller-loaders/bundle-loader.ts +57 -11
  75. package/src/controller-loaders/npm-loader.ts +3 -209
  76. package/src/controllers/resource-definition/resource-definition-controller.ts +19 -0
  77. package/src/directory-lock.ts +225 -0
  78. package/src/evaluation-context.ts +17 -7
  79. package/src/index.ts +10 -2
  80. package/src/kernel.ts +66 -0
  81. package/src/manifest-sources/local-manifest-cache-source.ts +1 -1
  82. package/src/resource-context.ts +78 -1
  83. package/src/transports/oci/oci-client.ts +32 -1
  84. package/src/transports/oci/oci-transport.ts +122 -51
  85. package/src/transports/registry-transport.ts +10 -38
  86. package/src/transports/transport-registry.ts +5 -5
  87. package/src/transports/transport.ts +32 -19
@@ -1,3 +1,4 @@
1
+ import { describeSelector, selectorFromQualifiers, selectorMatches } from "@telorun/analyzer";
1
2
  import { ControllerInstance, RuntimeError } from "@telorun/sdk";
2
3
  import { existsSync, readFileSync } from "fs";
3
4
  import * as fs from "fs/promises";
@@ -5,10 +6,18 @@ import { createRequire } from "module";
5
6
  import { PackageURL } from "packageurl-js";
6
7
  import * as path from "path";
7
8
  import { fileURLToPath, pathToFileURL } from "url";
9
+ import { hostPlatformTarget, type ModuleArtifact } from "../bundle/module-artifact.js";
8
10
  import type { ControllerResolveSource } from "../controller-loader.js";
9
11
  import { ControllerEnvMissingError } from "./napi-loader.js";
10
12
  import { REALM_COLLAPSE_NAMES } from "./realm.js";
11
13
 
14
+ /** A base URI whose files are already on disk: a `file://` URL or a bare
15
+ * absolute path. Everything else (`oci://`, `http(s)://`, `memory://`) names a
16
+ * module whose payload only exists inside an artifact. */
17
+ function isLocalBase(baseUri: string): boolean {
18
+ return baseUri.startsWith("file://") || path.isAbsolute(baseUri);
19
+ }
20
+
12
21
  async function pathExists(filePath: string): Promise<boolean> {
13
22
  try {
14
23
  await fs.access(filePath);
@@ -140,20 +149,23 @@ export class BundleControllerLoader {
140
149
  async load(
141
150
  purl: string,
142
151
  baseUri: string,
152
+ artifact?: ModuleArtifact,
143
153
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
144
- const { source, importInstance } = await this.resolve(purl, baseUri);
154
+ const { source, importInstance } = await this.resolve(purl, baseUri, artifact);
145
155
  return { instance: await importInstance(), source };
146
156
  }
147
157
 
148
158
  /**
149
- * Resolve without importing: parse + validate the PURL, confirm the bundle
150
- * file exists and its format is hostable, and ensure the realm symlinks — all
151
- * cheap, fail-fast checks — but defer the bundle `import()` (the eval cost)
152
- * into the returned `importInstance` thunk. Used by lazy controller loading.
159
+ * Resolve without importing: parse + validate the PURL, reject a candidate this
160
+ * host cannot run, materialize the layer that carries it, confirm the file
161
+ * exists, and ensure the realm symlinks — all fail-fast checks — but defer the
162
+ * bundle `import()` (the eval cost) into the returned `importInstance` thunk.
163
+ * Used by lazy controller loading.
153
164
  */
154
165
  async resolve(
155
166
  purl: string,
156
167
  baseUri: string,
168
+ artifact?: ModuleArtifact,
157
169
  ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
158
170
  let parsed: PackageURL;
159
171
  try {
@@ -190,16 +202,50 @@ export class BundleControllerLoader {
190
202
  );
191
203
  }
192
204
 
193
- // Bundles are local files next to the manifest. A remote (http) baseUri
194
- // can't host one, so defer to the next candidate.
195
- if (baseUri.startsWith("http://") || baseUri.startsWith("https://")) {
205
+ // Platform gate, BEFORE any materialization. A candidate list names one
206
+ // binary per platform, so checking the host first is what keeps a fallthrough
207
+ // from downloading every platform's layer on the way to the right one.
208
+ const selector = selectorFromQualifiers(format, parsed.qualifiers, `controller "${purl}"`);
209
+ const host = hostPlatformTarget();
210
+ if (!selectorMatches(selector, host)) {
196
211
  throw new ControllerEnvMissingError(
197
- `pkg:telo controller "${purl}" requires a local manifest; baseUri is remote (${baseUri})`,
212
+ `pkg:telo controller "${purl}" targets ${describeSelector(selector)}, which does not ` +
213
+ `match this host (${host.os ?? "unknown os"}/${host.arch ?? "unknown arch"}` +
214
+ `${host.libc ? `/${host.libc}` : ""})`,
198
215
  );
199
216
  }
200
217
 
201
- const basePath = baseUri.startsWith("file://") ? fileURLToPath(baseUri) : baseUri;
202
- const absFile = path.resolve(path.dirname(basePath), relPath);
218
+ // A published module's payload lives in its artifact, so materialize the one
219
+ // layer carrying this candidate and resolve `path=` inside it. Nothing is
220
+ // fetched here: the artifact handle owns the pinned ref and the verified
221
+ // layer index, so an `oci://` module ref never reaches this loader as a path.
222
+ let bundleDir: string;
223
+ if (artifact) {
224
+ // By its own selector, not by re-matching the host: this candidate IS one
225
+ // selector, and it is exactly the key of the layer that carries it.
226
+ const layer = await artifact.materializeController(selector);
227
+ if (!layer) {
228
+ throw new ControllerEnvMissingError(
229
+ `pkg:telo controller "${purl}": the module artifact ships no layer for ` +
230
+ `${describeSelector(selector)} (has: ${artifact.describeLayers()})`,
231
+ );
232
+ }
233
+ bundleDir = layer.dir;
234
+ } else {
235
+ // No artifact: a module already on disk (local development, or a manifest
236
+ // served from the on-disk cache). Its files sit next to the manifest.
237
+ if (!isLocalBase(baseUri)) {
238
+ throw new ControllerEnvMissingError(
239
+ `pkg:telo controller "${purl}" cannot be located: the declaring module resolved from ` +
240
+ `"${baseUri}", which is neither a local path nor an artifact with a layer index. ` +
241
+ `A bundled controller ships in its module's artifact — republish the module, or ` +
242
+ `import it from a local path during development.`,
243
+ );
244
+ }
245
+ bundleDir = path.dirname(baseUri.startsWith("file://") ? fileURLToPath(baseUri) : baseUri);
246
+ }
247
+
248
+ const absFile = path.resolve(bundleDir, relPath);
203
249
  if (!(await pathExists(absFile))) {
204
250
  throw new ControllerEnvMissingError(
205
251
  `pkg:telo controller bundle not found at "${absFile}" (from "${purl}")`,
@@ -10,6 +10,7 @@ import { fileURLToPath, pathToFileURL } from "url";
10
10
  import { promisify } from "util";
11
11
  import { hostEnv } from "../host-env.js";
12
12
  import { tryBuildControllerBundle } from "./bundle-builder.js";
13
+ import { withDirectoryLock } from "../directory-lock.js";
13
14
  import { ControllerEnvMissingError } from "./napi-loader.js";
14
15
  import { REALM_COLLAPSE_NAMES } from "./realm.js";
15
16
 
@@ -43,39 +44,6 @@ const PEER_INSTALL_FLAGS: ReadonlyArray<string> =
43
44
  ? ["--no-strict-peer-dependencies"]
44
45
  : [];
45
46
 
46
- /**
47
- * A held lock is refreshed (its mtime bumped) every {@link LOCK_HEARTBEAT_MS}
48
- * by the holder. Staleness is judged purely by mtime age — a lock older than
49
- * this means the holder stopped heartbeating (crashed, was killed, or its
50
- * container vanished), so it is safe to reclaim. This deliberately does NOT
51
- * probe the recorded PID for liveness: PID identity is meaningless across
52
- * container restarts and PID namespaces (deterministic PID reuse makes an
53
- * unrelated process look like the dead holder on the same hostname), which is
54
- * exactly what deadlocked container boots. The `{pid, host}` in the lock body
55
- * is diagnostics for humans, never a reclaim signal. Must be comfortably
56
- * larger than the heartbeat interval so a briefly-descheduled holder (GC
57
- * pause, busy event loop) is not reclaimed out from under itself.
58
- */
59
- const LOCK_STALE_MS = 30_000;
60
-
61
- /** How often the holder refreshes the lock mtime while `fn` runs. Well under
62
- * {@link LOCK_STALE_MS} so several heartbeats are missed before a live holder
63
- * is ever judged stale. */
64
- const LOCK_HEARTBEAT_MS = 5_000;
65
-
66
- /**
67
- * Total wall-clock cap for waiting on the install lock — enough for a slow
68
- * first install on a peer process to finish, short enough that a genuinely
69
- * deadlocked CI job fails loudly rather than hanging for hours. The retry
70
- * interval trades wakeup latency vs. wasted polls; 500ms is well below the
71
- * lock holder's typical hold time.
72
- */
73
- const LOCK_WAIT_MAX_MS = 5 * 60_000;
74
- const LOCK_RETRY_MS = 500;
75
-
76
- /** After this long waiting on a lock, emit one stderr line so a slow wait is
77
- * visible instead of looking like a silent hang. */
78
- const LOCK_WAIT_NOTICE_MS = 2_000;
79
47
 
80
48
  /**
81
49
  * Tells the dispatcher (and any UI consumer downstream) which branch the
@@ -325,7 +293,7 @@ export class NpmControllerLoader {
325
293
  return installRoot;
326
294
  }
327
295
 
328
- await withInstallLock(installRoot, async () => {
296
+ await withDirectoryLock(installRoot, "controller install", async () => {
329
297
  // Re-check inside the lock: a peer may have completed the install
330
298
  // between the fast-path miss and our acquisition.
331
299
  const existingHash = await readJsonField(stateFile, "rootHash");
@@ -447,7 +415,7 @@ export class NpmControllerLoader {
447
415
  }
448
416
 
449
417
  const work = (async () => {
450
- await withInstallLock(installRoot, async () => {
418
+ await withDirectoryLock(installRoot, "controller install", async () => {
451
419
  // Re-check inside the lock: a peer process may have installed the
452
420
  // spec between the fast-path miss and our acquisition.
453
421
  if (kind === "registry") {
@@ -557,176 +525,6 @@ async function resolveKernelPackageRoot(name: string): Promise<string | null> {
557
525
  }
558
526
  }
559
527
 
560
- /**
561
- * Serialize same-process callers for one install root, in memory, ahead of the
562
- * filesystem lock.
563
- *
564
- * Package installs dedupe per alias, so N distinct controllers are N distinct
565
- * `withInstallLock` calls against the SAME root. Without this queue they all
566
- * contend through the filesystem: one wins and the rest poll `fs.open` every
567
- * {@link LOCK_RETRY_MS} for the whole install, each crossing
568
- * {@link LOCK_WAIT_NOTICE_MS} and printing the wait notice — a notice whose
569
- * whole point is "another Telo *process* holds this", which is misleading when
570
- * the holder is us. (`telo install` fanning 52 controllers out through one
571
- * `Promise.allSettled` printed 51 of them.)
572
- *
573
- * Queuing here means exactly one caller per process reaches the fs lock, so the
574
- * notice regains its cross-process meaning and the losers do no I/O at all. The
575
- * fs lock is untouched and still provides the cross-process guarantee.
576
- */
577
- const localInstallQueues = new Map<string, Promise<unknown>>();
578
-
579
- function withLocalInstallQueue<T>(installRoot: string, fn: () => Promise<T>): Promise<T> {
580
- const prev = localInstallQueues.get(installRoot) ?? Promise.resolve();
581
- // Run on both settle paths: one failed install must not wedge the queue.
582
- const run = prev.then(fn, fn);
583
- // The stored tail never rejects — a failure neither poisons followers nor
584
- // surfaces as an unhandled rejection on the chain copy.
585
- const tail = run.then(
586
- () => {},
587
- () => {},
588
- );
589
- localInstallQueues.set(installRoot, tail);
590
- // Drop the entry once nothing further is queued, so a long-lived process that
591
- // touches many entry dirs doesn't retain a promise per root forever.
592
- void tail.then(() => {
593
- if (localInstallQueues.get(installRoot) === tail) localInstallQueues.delete(installRoot);
594
- });
595
- return run;
596
- }
597
-
598
- /**
599
- * Acquire the install lock for `installRoot` and run `fn` under it: first the
600
- * in-process queue above, then the cross-process filesystem lock.
601
- */
602
- async function withInstallLock<T>(
603
- installRoot: string,
604
- fn: () => Promise<T>,
605
- log: Logger = NOOP_LOGGER,
606
- ): Promise<T> {
607
- return withLocalInstallQueue(installRoot, () => withFileInstallLock(installRoot, fn, log));
608
- }
609
-
610
- /**
611
- * Acquire a process-portable lock on `<root>/.lock` and execute fn while
612
- * holding it. `fs.open(path, 'wx')` is atomic on POSIX and Windows, so
613
- * concurrent processes serialize naturally.
614
- *
615
- * Liveness is a heartbeat: the holder bumps the lock's mtime every
616
- * {@link LOCK_HEARTBEAT_MS} while `fn` runs, and a waiter reclaims a lock whose
617
- * mtime is older than {@link LOCK_STALE_MS} (holder crashed/killed/vanished).
618
- * mtime age is the *only* reclaim signal — the recorded `{pid, host}` is
619
- * diagnostics, never probed for liveness, because PID identity is unreliable
620
- * across container restarts and PID namespaces (the failure that deadlocked
621
- * container boots). Reclaim is via atomic rename to a unique tombstone so two
622
- * waiters that both see the lock stale can't both win.
623
- *
624
- * The lock guards the install-root manifest write, the package-manager
625
- * invocation, and any state-file writes. It does NOT serialize *reads* of
626
- * already-installed controllers — those run lock-free against a stable tree.
627
- */
628
- async function withFileInstallLock<T>(
629
- installRoot: string,
630
- fn: () => Promise<T>,
631
- log: Logger = NOOP_LOGGER,
632
- ): Promise<T> {
633
- const lockPath = path.join(installRoot, ".lock");
634
-
635
- await fs.mkdir(installRoot, { recursive: true });
636
-
637
- const lockBody = JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now() });
638
- let handle: import("fs/promises").FileHandle | null = null;
639
- const waitedSince = Date.now();
640
- let noticed = false;
641
- while (true) {
642
- try {
643
- handle = await fs.open(lockPath, "wx");
644
- await handle.writeFile(lockBody);
645
- break;
646
- } catch (err: any) {
647
- if (err?.code !== "EEXIST") throw err;
648
- // Lock exists. Reclaim it only if its heartbeat has gone silent.
649
- if (await reclaimIfStale(lockPath)) continue;
650
- const waited = Date.now() - waitedSince;
651
- if (waited > LOCK_WAIT_MAX_MS) {
652
- throw new Error(
653
- `[telo] timed out waiting for install lock at ${lockPath} ` +
654
- `(held >${LOCK_WAIT_MAX_MS / 60_000} min with a live heartbeat). ` +
655
- `Inspect the lock file or remove it manually if no other Telo process is running.`,
656
- );
657
- }
658
- if (!noticed && waited > LOCK_WAIT_NOTICE_MS) {
659
- noticed = true;
660
- log.info("waiting for controller install lock", { "telo.install.lock_path": lockPath });
661
- }
662
- await sleep(LOCK_RETRY_MS);
663
- }
664
- }
665
-
666
- // Keep the lock fresh while `fn` runs so a slow-but-live install is never
667
- // reclaimed. `unref` so the heartbeat can't by itself keep the process alive.
668
- const heartbeat = setInterval(() => {
669
- const now = new Date();
670
- fs.utimes(lockPath, now, now).catch(() => {});
671
- }, LOCK_HEARTBEAT_MS);
672
- heartbeat.unref?.();
673
-
674
- try {
675
- return await fn();
676
- } finally {
677
- clearInterval(heartbeat);
678
- // The fd close races nothing important: if it fails, the FD is reaped on
679
- // process exit. The unlink is the dangerous one — a non-ENOENT failure
680
- // (permissions, read-only mount) means every subsequent kernel waits
681
- // LOCK_STALE_MS before reclaiming. Surface it so the cause is visible
682
- // rather than hiding behind a silent hang.
683
- await handle!.close().catch(() => {});
684
- try {
685
- await fs.rm(lockPath, { force: true });
686
- } catch (err: any) {
687
- if (err?.code !== "ENOENT") {
688
- log.warn(
689
- "failed to release install lock",
690
- { "telo.install.lock_path": lockPath },
691
- { error: err },
692
- );
693
- }
694
- }
695
- }
696
- }
697
-
698
- /**
699
- * If the lock at `lockPath` is stale (mtime older than {@link LOCK_STALE_MS}, so
700
- * its holder stopped heartbeating), atomically claim and remove it and return
701
- * true; otherwise return false. The claim is a `rename` to a unique tombstone:
702
- * `rename` is atomic and fails for all but one racer, so two processes that
703
- * both observe the same stale lock cannot both reclaim it — the loser's rename
704
- * throws ENOENT (the file is already gone) and it simply retries the open.
705
- */
706
- async function reclaimIfStale(lockPath: string): Promise<boolean> {
707
- let stat: import("fs").Stats;
708
- try {
709
- stat = await fs.stat(lockPath);
710
- } catch (err: any) {
711
- // Race: lock vanished while we inspected it. Retry the open immediately.
712
- if (err?.code === "ENOENT") return true;
713
- throw err;
714
- }
715
- if (Date.now() - stat.mtimeMs < LOCK_STALE_MS) return false;
716
-
717
- // Stale — the holder's heartbeat is silent. Claim via atomic rename; only one
718
- // racer wins, the rest get ENOENT and fall back to retrying the open.
719
- const tombstone = `${lockPath}.stale.${process.pid}.${stat.mtimeMs}`;
720
- try {
721
- await fs.rename(lockPath, tombstone);
722
- } catch (err: any) {
723
- if (err?.code === "ENOENT") return true; // another waiter reclaimed it first
724
- throw err;
725
- }
726
- await fs.rm(tombstone, { force: true });
727
- return true;
728
- }
729
-
730
528
  async function runPackageManager(cwd: string, args: string[]): Promise<void> {
731
529
  try {
732
530
  await execFileAsync(PACKAGE_MANAGER, args, { cwd, maxBuffer: 32 * 1024 * 1024, env: hostEnv() });
@@ -1135,10 +933,6 @@ export const __testing__ = {
1135
933
  resolveExportTargetValue,
1136
934
  tryResolveFile,
1137
935
  computeInstallRoot,
1138
- withInstallLock,
1139
- reclaimIfStale,
1140
- LOCK_STALE_MS,
1141
- LOCK_HEARTBEAT_MS,
1142
936
  EXPORTS_MAX_DEPTH,
1143
937
  DEFAULT_RESOLVER_CONDITIONS,
1144
938
  REALM_COLLAPSE_NAMES,
@@ -14,6 +14,7 @@ import {
14
14
  inheritedCapability,
15
15
  type DefResolver,
16
16
  } from "@telorun/analyzer";
17
+ import type { ModuleArtifact } from "../../bundle/module-artifact.js";
17
18
  import { ControllerLoader } from "../../controller-loader.js";
18
19
  import { formatAjvErrors, validateResourceDefinition } from "../../manifest-schemas.js";
19
20
  import { createTemplateController } from "./resource-template-controller.js";
@@ -162,10 +163,18 @@ class ResourceDefinition implements ResourceInstance {
162
163
  // `controllers:` candidate fails fast at boot), but defer the expensive
163
164
  // import/eval and the controller's `register()` to the kind's first
164
165
  // instantiation. Definitions whose kind is never instantiated never import.
166
+ // The artifact of the module that DECLARED this kind — a bundled controller
167
+ // ships in its own module's payload, not the consumer's. It owns the pinned
168
+ // ref and the verified layer index, so the loader picks a candidate and asks
169
+ // it for that selector's directory rather than fetching anything itself.
170
+ const artifact = (ctx as unknown as ModuleArtifactHost).getModuleArtifact?.(
171
+ this.resource.metadata.source,
172
+ );
165
173
  const resolved = await loader.resolve(
166
174
  this.resource.controllers,
167
175
  this.resource.metadata.source,
168
176
  ctx.getControllerPolicy(),
177
+ artifact,
169
178
  );
170
179
  ctx.registerDefinition(this.resource);
171
180
 
@@ -198,6 +207,16 @@ class ResourceDefinition implements ResourceInstance {
198
207
  }
199
208
  }
200
209
 
210
+ /**
211
+ * Kernel-internal hook for reaching a module's artifact handle. Off the SDK
212
+ * surface deliberately: it hands back a kernel class, and only controller
213
+ * resolution needs it — module authors reach a module's files through
214
+ * `ctx.resolveModuleFile`, which returns a plain URI.
215
+ */
216
+ interface ModuleArtifactHost {
217
+ getModuleArtifact?(source: string | undefined): ModuleArtifact | undefined;
218
+ }
219
+
201
220
  /**
202
221
  * Kernel-internal hook the concrete `ResourceContextImpl` exposes for lazy
203
222
  * controller loading — deliberately off the public SDK `ResourceContext`
@@ -0,0 +1,225 @@
1
+ import { NOOP_LOGGER, type Logger } from "@telorun/sdk";
2
+ import * as fs from "fs/promises";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+
6
+ /**
7
+ * The cross-process lock guarding mutation of a shared cache directory.
8
+ *
9
+ * Two consumers hold it for the same reason — several Telo processes (and, in
10
+ * one process, several concurrent resolutions) may decide to populate the same
11
+ * directory at the same moment: `NpmControllerLoader` around its install root,
12
+ * and `ModuleArtifact` around a module's extracted layers. One implementation
13
+ * rather than two, because the reclaim rule below is subtle enough that two
14
+ * copies would drift.
15
+ */
16
+
17
+ /**
18
+ * A held lock is refreshed (its mtime bumped) every {@link LOCK_HEARTBEAT_MS}
19
+ * by the holder. Staleness is judged purely by mtime age — a lock older than
20
+ * this means the holder stopped heartbeating (crashed, was killed, or its
21
+ * container vanished), so it is safe to reclaim. This deliberately does NOT
22
+ * probe the recorded PID for liveness: PID identity is meaningless across
23
+ * container restarts and PID namespaces (deterministic PID reuse makes an
24
+ * unrelated process look like the dead holder on the same hostname), which is
25
+ * exactly what deadlocked container boots. The `{pid, host}` in the lock body
26
+ * is diagnostics for humans, never a reclaim signal. Must be comfortably
27
+ * larger than the heartbeat interval so a briefly-descheduled holder (GC
28
+ * pause, busy event loop) is not reclaimed out from under itself.
29
+ */
30
+ const LOCK_STALE_MS = 30_000;
31
+
32
+ /** How often the holder refreshes the lock mtime while `fn` runs. Well under
33
+ * {@link LOCK_STALE_MS} so several heartbeats are missed before a live holder
34
+ * is ever judged stale. */
35
+ const LOCK_HEARTBEAT_MS = 5_000;
36
+
37
+ /**
38
+ * Total wall-clock cap for waiting on the lock — enough for a slow first
39
+ * populate on a peer process to finish, short enough that a genuinely
40
+ * deadlocked CI job fails loudly rather than hanging for hours. The retry
41
+ * interval trades wakeup latency vs. wasted polls; 500ms is well below the
42
+ * lock holder's typical hold time.
43
+ */
44
+ const LOCK_WAIT_MAX_MS = 5 * 60_000;
45
+ const LOCK_RETRY_MS = 500;
46
+
47
+ /** After this long waiting on a lock, emit one line so a slow wait is visible
48
+ * instead of looking like a silent hang. */
49
+ const LOCK_WAIT_NOTICE_MS = 2_000;
50
+
51
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
52
+
53
+ /**
54
+ * In-process serialization ahead of the filesystem lock.
55
+ *
56
+ * Without it, N concurrent callers for the same directory all reach the fs lock,
57
+ * N-1 sit in the retry loop, and each crossing {@link LOCK_WAIT_NOTICE_MS}
58
+ * prints the wait notice — a notice whose whole point is "another *process* is
59
+ * populating this", printed when the holder is us. (`telo install` fanning 52
60
+ * controllers out through one `Promise.allSettled` printed 51 of them.)
61
+ *
62
+ * Queuing here means exactly one caller per process reaches the fs lock, so the
63
+ * notice regains its cross-process meaning and the losers do no I/O at all. The
64
+ * fs lock is untouched and still provides the cross-process guarantee.
65
+ */
66
+ const localQueues = new Map<string, Promise<unknown>>();
67
+
68
+ function withLocalQueue<T>(dir: string, fn: () => Promise<T>): Promise<T> {
69
+ const prev = localQueues.get(dir) ?? Promise.resolve();
70
+ // Run on both settle paths: one failure must not wedge the queue.
71
+ const run = prev.then(fn, fn);
72
+ // The stored tail never rejects — a failure neither poisons followers nor
73
+ // surfaces as an unhandled rejection on the chain copy.
74
+ const tail = run.then(
75
+ () => {},
76
+ () => {},
77
+ );
78
+ localQueues.set(dir, tail);
79
+ // Drop the entry once nothing further is queued, so a long-lived process that
80
+ // touches many directories doesn't retain a promise per directory forever.
81
+ void tail.then(() => {
82
+ if (localQueues.get(dir) === tail) localQueues.delete(dir);
83
+ });
84
+ return run;
85
+ }
86
+
87
+ /**
88
+ * Acquire the lock for `dir` and run `fn` under it: first the in-process queue
89
+ * above, then the cross-process filesystem lock. `label` names the operation in
90
+ * the wait notice and timeout error (e.g. "controller install", "module layer").
91
+ */
92
+ export async function withDirectoryLock<T>(
93
+ dir: string,
94
+ label: string,
95
+ fn: () => Promise<T>,
96
+ log: Logger = NOOP_LOGGER,
97
+ ): Promise<T> {
98
+ return withLocalQueue(dir, () => withFileLock(dir, label, fn, log));
99
+ }
100
+
101
+ /**
102
+ * Acquire a process-portable lock on `<dir>/.lock` and execute fn while
103
+ * holding it. `fs.open(path, 'wx')` is atomic on POSIX and Windows, so
104
+ * concurrent processes serialize naturally.
105
+ *
106
+ * Liveness is a heartbeat: the holder bumps the lock's mtime every
107
+ * {@link LOCK_HEARTBEAT_MS} while `fn` runs, and a waiter reclaims a lock whose
108
+ * mtime is older than {@link LOCK_STALE_MS} (holder crashed/killed/vanished).
109
+ * mtime age is the *only* reclaim signal — the recorded `{pid, host}` is
110
+ * diagnostics, never probed for liveness, because PID identity is unreliable
111
+ * across container restarts and PID namespaces (the failure that deadlocked
112
+ * container boots). Reclaim is via atomic rename to a unique tombstone so two
113
+ * waiters that both see the lock stale can't both win.
114
+ *
115
+ * The lock guards *writes* into the directory. It does NOT serialize reads of
116
+ * already-populated content — those run lock-free against a stable tree.
117
+ */
118
+ async function withFileLock<T>(
119
+ dir: string,
120
+ label: string,
121
+ fn: () => Promise<T>,
122
+ log: Logger,
123
+ ): Promise<T> {
124
+ const lockPath = path.join(dir, ".lock");
125
+
126
+ await fs.mkdir(dir, { recursive: true });
127
+
128
+ const lockBody = JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now() });
129
+ let handle: import("fs/promises").FileHandle | null = null;
130
+ const waitedSince = Date.now();
131
+ let noticed = false;
132
+ while (true) {
133
+ try {
134
+ handle = await fs.open(lockPath, "wx");
135
+ await handle.writeFile(lockBody);
136
+ break;
137
+ } catch (err: any) {
138
+ if (err?.code !== "EEXIST") throw err;
139
+ // Lock exists. Reclaim it only if its heartbeat has gone silent.
140
+ if (await reclaimIfStale(lockPath)) continue;
141
+ const waited = Date.now() - waitedSince;
142
+ if (waited > LOCK_WAIT_MAX_MS) {
143
+ throw new Error(
144
+ `[telo] timed out waiting for ${label} lock at ${lockPath} ` +
145
+ `(held >${LOCK_WAIT_MAX_MS / 60_000} min with a live heartbeat). ` +
146
+ `Inspect the lock file or remove it manually if no other Telo process is running.`,
147
+ );
148
+ }
149
+ if (!noticed && waited > LOCK_WAIT_NOTICE_MS) {
150
+ noticed = true;
151
+ log.info(`waiting for ${label} lock`, { "telo.lock.path": lockPath });
152
+ }
153
+ await sleep(LOCK_RETRY_MS);
154
+ }
155
+ }
156
+
157
+ // Keep the lock fresh while `fn` runs so a slow-but-live operation is never
158
+ // reclaimed. `unref` so the heartbeat can't by itself keep the process alive.
159
+ const heartbeat = setInterval(() => {
160
+ const now = new Date();
161
+ fs.utimes(lockPath, now, now).catch(() => {});
162
+ }, LOCK_HEARTBEAT_MS);
163
+ heartbeat.unref?.();
164
+
165
+ try {
166
+ return await fn();
167
+ } finally {
168
+ clearInterval(heartbeat);
169
+ // The fd close races nothing important: if it fails, the FD is reaped on
170
+ // process exit. The unlink is the dangerous one — a non-ENOENT failure
171
+ // (permissions, read-only mount) means every subsequent kernel waits
172
+ // LOCK_STALE_MS before reclaiming. Surface it so the cause is visible
173
+ // rather than hiding behind a silent hang.
174
+ await handle!.close().catch(() => {});
175
+ try {
176
+ await fs.rm(lockPath, { force: true });
177
+ } catch (err: any) {
178
+ if (err?.code !== "ENOENT") {
179
+ log.warn(`failed to release ${label} lock`, { "telo.lock.path": lockPath }, { error: err });
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * If the lock at `lockPath` is stale (mtime older than {@link LOCK_STALE_MS}, so
187
+ * its holder stopped heartbeating), atomically claim and remove it and return
188
+ * true; otherwise return false. The claim is a `rename` to a unique tombstone:
189
+ * `rename` is atomic and fails for all but one racer, so two processes that
190
+ * both observe the same stale lock cannot both reclaim it — the loser's rename
191
+ * throws ENOENT (the file is already gone) and it simply retries the open.
192
+ */
193
+ async function reclaimIfStale(lockPath: string): Promise<boolean> {
194
+ let stat: import("fs").Stats;
195
+ try {
196
+ stat = await fs.stat(lockPath);
197
+ } catch (err: any) {
198
+ // Race: lock vanished while we inspected it. Retry the open immediately.
199
+ if (err?.code === "ENOENT") return true;
200
+ throw err;
201
+ }
202
+ if (Date.now() - stat.mtimeMs < LOCK_STALE_MS) return false;
203
+
204
+ // Stale — the holder's heartbeat is silent. Claim via atomic rename; only one
205
+ // racer wins, the rest get ENOENT and fall back to retrying the open.
206
+ const tombstone = `${lockPath}.stale.${process.pid}.${stat.mtimeMs}`;
207
+ try {
208
+ await fs.rename(lockPath, tombstone);
209
+ } catch (err: any) {
210
+ if (err?.code === "ENOENT") return true; // another waiter reclaimed it first
211
+ throw err;
212
+ }
213
+ await fs.rm(tombstone, { force: true });
214
+ return true;
215
+ }
216
+
217
+ /**
218
+ * Internals exposed for the lock's own tests (acquire/release, stale reclaim,
219
+ * heartbeat, in-process queuing). Not part of the kernel's public API.
220
+ */
221
+ export const __testing__ = {
222
+ reclaimIfStale,
223
+ LOCK_STALE_MS,
224
+ LOCK_HEARTBEAT_MS,
225
+ };
@@ -818,13 +818,23 @@ export class EvaluationContext implements IEvaluationContext {
818
818
  child.preInitHook = (resource, childGetInstance, childIsPending) => {
819
819
  parentHook(
820
820
  resource,
821
- (name, alias) =>
822
- alias && alias !== "Self"
823
- ? parent.resolveImportedInstance(alias, name)
824
- : childGetInstance(name) ?? parent.resourceInstances.get(name)?.instance,
825
- // A scoped ref resolves against the scope's own resources or an already-inited
826
- // outer one; only a scope-local dependency can still be pending. The outer is
827
- // live by the time a scope opens, so the child's own predicate suffices.
821
+ (name, alias) => {
822
+ if (alias && alias !== "Self") return parent.resolveImportedInstance(alias, name);
823
+ const scoped = childGetInstance(name);
824
+ if (scoped) return scoped;
825
+ // A name the scope DECLARES resolves to the scope's instance and to
826
+ // nothing else. Falling through to the enclosing module while the
827
+ // scoped one is merely not initialized yet would inject the resource
828
+ // it shadows — an init-order-dependent wrong answer, and a silent
829
+ // one: the ref binds an instance that is never started, so the
830
+ // failure surfaces later as a timeout somewhere else entirely.
831
+ // Returning nothing leaves it pending, which is what makes the init
832
+ // loop come back to it.
833
+ if (child.hasManifest(name)) return undefined;
834
+ return parent.resourceInstances.get(name)?.instance;
835
+ },
836
+ // Only a scope-local dependency can still be pending — an outer resource
837
+ // is live by the time a scope opens — so the child's own predicate suffices.
828
838
  childIsPending,
829
839
  );
830
840
  };