@specific.dev/spectest 0.39.0 → 0.41.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 (100) hide show
  1. package/dist/components/supabase.d.ts +87 -27
  2. package/dist/components/supabase.js +352 -69
  3. package/dist/daemon.d.ts +38 -0
  4. package/dist/daemon.js +388 -941
  5. package/dist/harness/build-context.d.ts +82 -0
  6. package/dist/harness/build-context.js +113 -0
  7. package/dist/harness/buildkit-progress.d.ts +37 -0
  8. package/dist/harness/buildkit-progress.js +66 -0
  9. package/dist/harness/container-run.d.ts +89 -0
  10. package/dist/harness/container-run.js +118 -0
  11. package/dist/harness/file-mounts.d.ts +91 -0
  12. package/dist/harness/file-mounts.js +119 -0
  13. package/dist/harness/hostmatch.d.ts +65 -0
  14. package/dist/harness/hostmatch.js +108 -0
  15. package/dist/harness/http-proxy.d.ts +62 -0
  16. package/dist/harness/http-proxy.js +104 -0
  17. package/dist/harness/ingress-table.d.ts +148 -0
  18. package/dist/harness/ingress-table.js +129 -0
  19. package/dist/harness/log-delta.d.ts +54 -0
  20. package/dist/harness/log-delta.js +83 -0
  21. package/dist/harness/main.d.ts +47 -0
  22. package/dist/harness/main.js +164 -0
  23. package/dist/harness/methods.d.ts +54 -0
  24. package/dist/harness/methods.js +65 -0
  25. package/dist/harness/names-registry.d.ts +63 -0
  26. package/dist/harness/names-registry.js +90 -0
  27. package/dist/harness/protocol.d.ts +88 -0
  28. package/dist/harness/protocol.js +96 -0
  29. package/dist/harness/ready-poll.d.ts +47 -0
  30. package/dist/harness/ready-poll.js +67 -0
  31. package/dist/harness/service-graph.d.ts +29 -0
  32. package/dist/harness/service-graph.js +92 -0
  33. package/dist/harness/volume-paths.d.ts +70 -0
  34. package/dist/harness/volume-paths.js +81 -0
  35. package/dist/index.d.ts +3 -3
  36. package/dist/ingress.d.ts +1 -1
  37. package/dist/resolver.js +5 -8
  38. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  39. package/dist/vendor/rrweb-record.min.js +5061 -0
  40. package/package.json +7 -1
  41. package/src/aws-sigv4.ts +218 -0
  42. package/src/browser.ts +2040 -0
  43. package/src/components/aws.ts +554 -0
  44. package/src/components/email.ts +398 -0
  45. package/src/components/expo.ts +167 -0
  46. package/src/components/index.ts +81 -0
  47. package/src/components/k3s.ts +2061 -0
  48. package/src/components/postgres.ts +132 -0
  49. package/src/components/replayFake.ts +1015 -0
  50. package/src/components/s3.ts +132 -0
  51. package/src/components/supabase.ts +1699 -0
  52. package/src/daemon.ts +5489 -0
  53. package/src/harness/build-context.test.ts +0 -0
  54. package/src/harness/build-context.ts +146 -0
  55. package/src/harness/buildkit-progress.test.ts +98 -0
  56. package/src/harness/buildkit-progress.ts +74 -0
  57. package/src/harness/container-run.test.ts +209 -0
  58. package/src/harness/container-run.ts +158 -0
  59. package/src/harness/file-mounts.test.ts +185 -0
  60. package/src/harness/file-mounts.ts +145 -0
  61. package/src/harness/hostmatch.test.ts +148 -0
  62. package/src/harness/hostmatch.ts +109 -0
  63. package/src/harness/http-proxy.test.ts +156 -0
  64. package/src/harness/http-proxy.ts +119 -0
  65. package/src/harness/ingress-rebind.test.ts +125 -0
  66. package/src/harness/ingress-table.test.ts +172 -0
  67. package/src/harness/ingress-table.ts +186 -0
  68. package/src/harness/log-delta.test.ts +125 -0
  69. package/src/harness/log-delta.ts +100 -0
  70. package/src/harness/main.test.ts +211 -0
  71. package/src/harness/main.ts +196 -0
  72. package/src/harness/methods.test.ts +63 -0
  73. package/src/harness/methods.ts +92 -0
  74. package/src/harness/names-registry.test.ts +137 -0
  75. package/src/harness/names-registry.ts +108 -0
  76. package/src/harness/protocol.test.ts +148 -0
  77. package/src/harness/protocol.ts +163 -0
  78. package/src/harness/ready-poll.test.ts +172 -0
  79. package/src/harness/ready-poll.ts +93 -0
  80. package/src/harness/service-graph.test.ts +97 -0
  81. package/src/harness/service-graph.ts +97 -0
  82. package/src/harness/volume-paths.test.ts +102 -0
  83. package/src/harness/volume-paths.ts +112 -0
  84. package/src/ids.ts +89 -0
  85. package/src/index.ts +2725 -0
  86. package/src/ingress.ts +305 -0
  87. package/src/inspect.ts +739 -0
  88. package/src/locator.ts +716 -0
  89. package/src/mobile.ts +133 -0
  90. package/src/record-secrets.ts +41 -0
  91. package/src/recorder.ts +846 -0
  92. package/src/redis.ts +202 -0
  93. package/src/replay-bundle.ts +108 -0
  94. package/src/resolver.ts +348 -0
  95. package/src/s3.ts +333 -0
  96. package/src/sql.ts +243 -0
  97. package/src/terminal.ts +740 -0
  98. package/src/url-match.ts +67 -0
  99. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  100. package/src/vendor/rrweb-record.min.js +5061 -0
package/dist/daemon.js CHANGED
@@ -15,7 +15,6 @@
15
15
  // One sandbox = one daemon. Concurrency between tests is achieved by
16
16
  // forking the sandbox; inside a single daemon we never run two tests at
17
17
  // once — that keeps stdout capture and timeouts simple.
18
- import http from "node:http";
19
18
  import { execFile, spawn } from "node:child_process";
20
19
  import { randomUUID } from "node:crypto";
21
20
  import { existsSync, promises as fs, readFileSync } from "node:fs";
@@ -25,6 +24,23 @@ import { pathToFileURL } from "node:url";
25
24
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
26
25
  import { acquirePersistentBrowser } from "./browser.js";
27
26
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
27
+ // Pure ingress hostname matching, ported out of this file (see
28
+ // harness/hostmatch.ts). Keeping ONE implementation is the point: the
29
+ // exact-then-longest-suffix rule and the one-label certificate rule are
30
+ // each easy to restate subtly differently.
31
+ import { buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
32
+ import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
33
+ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
34
+ import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
35
+ import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
36
+ import { pollUntilReady } from "./harness/ready-poll.js";
37
+ import { encodeRegistry } from "./harness/names-registry.js";
38
+ import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
39
+ import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
40
+ import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
41
+ import { runContainerArgs } from "./harness/container-run.js";
42
+ import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
43
+ import { conflict, notFound, requireString, } from "./harness/methods.js";
28
44
  import { openTerminal } from "./terminal.js";
29
45
  import { recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
30
46
  import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
@@ -33,7 +49,6 @@ import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
33
49
  function namedServices(cfg) {
34
50
  return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
35
51
  }
36
- const DEFAULT_PORT = 9876;
37
52
  const DEFAULT_TEST_TIMEOUT_MS = 60_000;
38
53
  const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
39
54
  const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
@@ -422,44 +437,6 @@ async function ensureRemoteBuilder() {
422
437
  }
423
438
  return _remoteBuilder;
424
439
  }
425
- // Parse `docker build --progress=plain` (BuildKit) output into per-step
426
- // timings, sorted slowest-first. Steps are correlated by their `#N` id:
427
- // the declaration line carries the command, the `DONE`/`CACHED` line the
428
- // duration. Best-effort — unparseable output yields an empty list.
429
- function summarizeBuildKit(out) {
430
- const names = new Map();
431
- const secs = new Map();
432
- const cached = new Set();
433
- for (const line of out.split("\n")) {
434
- let m = line.match(/^#(\d+)\s+\[[^\]]*\]\s+(.+)$/);
435
- if (m) {
436
- const id = `#${m[1]}`;
437
- if (!names.has(id))
438
- names.set(id, m[2].trim().slice(0, 80));
439
- continue;
440
- }
441
- m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
442
- if (m) {
443
- secs.set(`#${m[1]}`, parseFloat(m[2]));
444
- continue;
445
- }
446
- m = line.match(/^#(\d+)\s+CACHED/);
447
- if (m) {
448
- const id = `#${m[1]}`;
449
- cached.add(id);
450
- if (!secs.has(id))
451
- secs.set(id, 0);
452
- }
453
- }
454
- const steps = [];
455
- for (const [id, name] of names) {
456
- steps.push({ name, secs: secs.get(id) ?? 0, cached: cached.has(id) });
457
- }
458
- return steps.sort((a, b) => b.secs - a.secs);
459
- }
460
- // ────────────────────────────────────────────────────────────────────────
461
- // Bootstrap stages
462
- // ────────────────────────────────────────────────────────────────────────
463
440
  async function ensureNetwork() {
464
441
  const inspect = await docker(["network", "inspect", NETWORK_NAME], 30_000);
465
442
  if (inspect.code === 0)
@@ -469,43 +446,11 @@ async function ensureNetwork() {
469
446
  throw new Error(`docker network create ${NETWORK_NAME} failed: ${create.stderr.trim() || create.stdout.trim()}`);
470
447
  }
471
448
  }
472
- function sanitizeSegment(p) {
473
- return p
474
- .replace(/^\/+/, "")
475
- .replace(/[^A-Za-z0-9_-]/g, "-")
476
- .replace(/^-+|-+$/g, "");
477
- }
449
+ /** Host directory backing one volume mount. Rules (notably which volumes
450
+ * survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
478
451
  function resolveHostPath(service, vol) {
479
- if (vol.name) {
480
- // Named shared volume: one backing dir per name, shared by every
481
- // service that mounts the same name (storage-api ↔ imgproxy). Rooted
482
- // in the per-env state tree (or the cache tree when cache-flagged),
483
- // so teardown/fork semantics match ordinary volumes.
484
- const root = vol.cache
485
- ? ["/var/cache/spectest/volumes", "_shared"]
486
- : [WORKSPACE, ".spectest", "volumes", "_shared"];
487
- return path.join(...root, sanitizeSegment(vol.name));
488
- }
489
- if (vol.source && vol.source.startsWith("/"))
490
- return vol.source;
491
- // Cache volumes root OUTSIDE /workspace so the delta-restore teardown
492
- // (rm -rf /workspace) keeps them — they hold only content-addressed
493
- // accelerator data (see VolumeMount.cache), never env state.
494
- const root = vol.cache
495
- ? ["/var/cache/spectest/volumes", service]
496
- : [WORKSPACE, ".spectest", "volumes", service];
497
- if (vol.source) {
498
- return path.join(...root, vol.source.replace(/^\/+/, ""));
499
- }
500
- return path.join(...root, sanitizeSegment(vol.target));
452
+ return resolveVolumeHostPath(service, vol, WORKSPACE);
501
453
  }
502
- /// Where the daemon records every ABSOLUTE-source, non-cache volume dir it
503
- /// has created, one path per line. The delta-restore teardown wipes the
504
- /// listed dirs: they live outside /workspace (which the teardown removes
505
- /// wholesale) and outside /var/cache/spectest (deliberately kept), so
506
- /// without this manifest a `source: "/data/pg"` volume would carry the
507
- /// previous generation's data into a "fresh" environment. tmpfs-backed
508
- /// (/run) — survives snapshots like all guest memory, dies with the VM.
509
454
  const VOLUME_DIRS_MANIFEST = "/run/spectest-volume-dirs";
510
455
  const ABS_VOLUME_DIRS = new Set();
511
456
  async function recordAbsoluteVolumeDir(host) {
@@ -628,12 +573,9 @@ async function imageIdTables(tag) {
628
573
  async function resolveOwner(svc, tag, what, user, group) {
629
574
  if (user === undefined && group === undefined)
630
575
  return undefined;
631
- const numeric = (v) => v !== undefined && /^[0-9]+$/.test(v) ? Number(v) : undefined;
632
- let uid = numeric(user);
633
- let gid = numeric(group);
634
- const needsTables = (user !== undefined && uid === undefined) ||
635
- (group !== undefined && gid === undefined);
636
- if (needsTables) {
576
+ let uid = numericId(user);
577
+ let gid = numericId(group);
578
+ if (needsIdTables(user, group)) {
637
579
  const tables = await imageIdTables(tag);
638
580
  if (user !== undefined && uid === undefined) {
639
581
  uid = tables.users.get(user);
@@ -665,16 +607,12 @@ async function resolveOwner(svc, tag, what, user, group) {
665
607
  async function applyFileOwnership(file, mode, owner) {
666
608
  if (mode)
667
609
  await fs.chmod(file, parseInt(mode, 8));
668
- if (!owner)
610
+ if (!owner || isNoopChown(owner))
669
611
  return;
670
- let { uid, gid } = owner;
671
- if (uid < 0 || gid < 0) {
672
- const st = await fs.stat(file);
673
- if (uid < 0)
674
- uid = st.uid;
675
- if (gid < 0)
676
- gid = st.gid;
677
- }
612
+ // Bun rejects the `-1` halves, so fill them from the file's current
613
+ // owner — see `harness/file-mounts.ts` for the rule and its test.
614
+ const st = await fs.stat(file);
615
+ const { uid, gid } = resolveChownIds(owner, { uid: st.uid, gid: st.gid });
678
616
  await fs.chown(file, uid, gid);
679
617
  }
680
618
  // Materialize `svc.files` onto the VM host and return `--volume` flags
@@ -691,14 +629,12 @@ async function ensureFiles(svc, tag) {
691
629
  const dir = path.join(WORKSPACE, ".spectest", "files", svc.name);
692
630
  await fs.mkdir(dir, { recursive: true });
693
631
  for (const f of svc.files) {
694
- if (!f.path.startsWith("/")) {
695
- throw new Error(`service "${svc.name}": file path ${JSON.stringify(f.path)} must be absolute`);
696
- }
632
+ assertAbsolute(svc.name, "file path", f.path);
697
633
  // `{{SPECTEST_SERVICE}}` expands to this service's name (its
698
634
  // services-map key) so a component can author self-referential
699
635
  // config without knowing the key the user will choose — e.g. k3s's
700
636
  // registries.yaml keying on `<key>.internal:5000`.
701
- const content = f.content.replaceAll("{{SPECTEST_SERVICE}}", svc.name);
637
+ const content = expandServiceToken(f.content, svc.name);
702
638
  const host = path.join(dir, sanitizeSegment(f.path));
703
639
  await fs.writeFile(host, content);
704
640
  // A bind mount carries this inode's mode and ownership into the
@@ -707,7 +643,7 @@ async function ensureFiles(svc, tag) {
707
643
  // whoever the container actually runs as.
708
644
  const owner = await resolveOwner(svc, tag, `file ${f.path}`, f.user, f.group);
709
645
  await applyFileOwnership(host, f.mode, owner);
710
- flags.push(`--volume=${host}:${f.path}:ro`);
646
+ flags.push(mountFlag(host, f.path));
711
647
  }
712
648
  return flags;
713
649
  }
@@ -742,14 +678,9 @@ async function ensureCertificates(svc, tag) {
742
678
  ["keyPath", c.keyPath],
743
679
  ...(c.caPath ? [["caPath", c.caPath]] : []),
744
680
  ]) {
745
- if (!p.startsWith("/")) {
746
- throw new Error(`service "${svc.name}": certificate ${label} ${JSON.stringify(p)} must be absolute`);
747
- }
748
- }
749
- const hostnames = c.hostnames.map((h) => h.replaceAll("{{SPECTEST_SERVICE}}", svc.name));
750
- if (hostnames.length === 0) {
751
- throw new Error(`service "${svc.name}": certificate entry ${i} lists no hostnames`);
681
+ assertAbsolute(svc.name, `certificate ${label}`, p);
752
682
  }
683
+ const hostnames = certificateHostnames(svc.name, i, c.hostnames);
753
684
  const { cert, key } = await generateHostCert(`${svc.name}-${i}`, hostnames);
754
685
  const certHost = path.join(dir, `${i}.crt`);
755
686
  const keyHost = path.join(dir, `${i}.key`);
@@ -765,43 +696,19 @@ async function ensureCertificates(svc, tag) {
765
696
  // Declaring who reads the key also says what mode it wants: the
766
697
  // strictest one that owner can still open. Only reached when
767
698
  // `user`/`group` is set, so no existing environment changes.
768
- const keyMode = c.mode ?? (owner ? (owner.uid === -1 ? "0640" : "0600") : undefined);
699
+ const keyMode = defaultKeyMode(c.mode, owner);
769
700
  await applyFileOwnership(keyHost, keyMode, owner);
770
701
  // The certificate is public, but it follows the key's owner so a
771
702
  // server that insists on owning its whole TLS directory is happy.
772
703
  await applyFileOwnership(certHost, undefined, owner);
773
- flags.push(`--volume=${certHost}:${c.certPath}:ro`);
774
- flags.push(`--volume=${keyHost}:${c.keyPath}:ro`);
704
+ flags.push(mountFlag(certHost, c.certPath));
705
+ flags.push(mountFlag(keyHost, c.keyPath));
775
706
  if (c.caPath)
776
- flags.push(`--volume=${CA_PATH}:${c.caPath}:ro`);
707
+ flags.push(mountFlag(CA_PATH, c.caPath));
777
708
  console.log(`[bootstrap] ${svc.name}: minted certificate for ${hostnames.join(", ")}`);
778
709
  }
779
710
  return flags;
780
711
  }
781
- function imageTag(name) {
782
- return `spectest/${name}:latest`;
783
- }
784
- const DEFAULT_DOCKERIGNORE = [
785
- ".git",
786
- ".spectest",
787
- "spectest",
788
- "node_modules",
789
- "target",
790
- "__pycache__",
791
- ".venv",
792
- ".env",
793
- ".env.local",
794
- ".env.*",
795
- "dist",
796
- "build",
797
- ".next",
798
- ".turbo",
799
- ".DS_Store",
800
- ];
801
- /** First line of the `.dockerignore` we generate ourselves, so a later
802
- * bootstrap can tell our file apart from one the project ships and never
803
- * mistakes its own output for user intent. */
804
- const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated — do not edit (your own .dockerignore is honoured verbatim)";
805
712
  /**
806
713
  * The project's own `/workspace/.dockerignore`, read once per bootstrap
807
714
  * before we write anything, or `null` when it ships none.
@@ -819,7 +726,7 @@ async function readProjectDockerignore() {
819
726
  try {
820
727
  const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
821
728
  // Ours, from a previous bootstrap of this workspace — not the project's.
822
- if (text.startsWith(GENERATED_DOCKERIGNORE_HEADER))
729
+ if (isGeneratedDockerignore(text))
823
730
  return null;
824
731
  return text;
825
732
  }
@@ -827,39 +734,11 @@ async function readProjectDockerignore() {
827
734
  return null;
828
735
  }
829
736
  }
830
- /**
831
- * Ignore rules for one dockerfile build, in precedence order: our
832
- * defaults, then the project's own `.dockerignore` verbatim, then that
833
- * service's `exclude`.
834
- *
835
- * Order is load-bearing for the `**` + negations idiom — the project's
836
- * `**` subsumes our defaults, its `!` lines re-include exactly what the
837
- * build needs, and the per-service `exclude` still gets the last word.
838
- */
737
+ /** Ignore rules for one dockerfile build. Composition (and the reason the
738
+ * order matters) lives in `harness/build-context.ts`; this supplies the
739
+ * project's own file, read once per bootstrap. */
839
740
  function serviceDockerignore(exclude) {
840
- const parts = [DEFAULT_DOCKERIGNORE.join("\n")];
841
- if (PROJECT_DOCKERIGNORE !== null) {
842
- parts.push(`# --- from the project's .dockerignore ---\n${PROJECT_DOCKERIGNORE.trimEnd()}`);
843
- }
844
- if (exclude && exclude.length > 0) {
845
- parts.push(`# --- from this service's exclude ---\n${exclude.join("\n")}`);
846
- }
847
- return parts.join("\n") + "\n";
848
- }
849
- function unionDockerignore(services) {
850
- const seen = new Set(DEFAULT_DOCKERIGNORE);
851
- const extras = [];
852
- for (const s of services) {
853
- if (s.image.type === "dockerfile" && s.image.exclude) {
854
- for (const e of s.image.exclude) {
855
- if (!seen.has(e)) {
856
- seen.add(e);
857
- extras.push(e);
858
- }
859
- }
860
- }
861
- }
862
- return ([GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n");
741
+ return composeServiceDockerignore(PROJECT_DOCKERIGNORE, exclude);
863
742
  }
864
743
  /// In-flight/finished dockerfile builds of this bootstrap, keyed by
865
744
  /// sha256(dockerfile content + exclude list). Services that share an
@@ -870,11 +749,7 @@ function unionDockerignore(services) {
870
749
  /// workspace generation (runtime services started mid-test share it).
871
750
  const BUILD_DEDUP = new Map();
872
751
  function buildContentKey(image) {
873
- return new Bun.CryptoHasher("sha256")
874
- .update(image.content)
875
- .update("\0")
876
- .update(JSON.stringify(image.exclude ?? []))
877
- .digest("hex");
752
+ return computeBuildContentKey(() => new Bun.CryptoHasher("sha256"), image);
878
753
  }
879
754
  async function prepareServiceImage(svc, opts) {
880
755
  const tag = imageTag(svc.name);
@@ -1121,106 +996,21 @@ async function runContainer(svc, tag, volumeFlags,
1121
996
  extraAliases = []) {
1122
997
  // Idempotent: clean up any leftover container with the same name.
1123
998
  await docker(["rm", "-f", svc.name], 30_000);
1124
- const args = [
1125
- "run",
1126
- "-d",
1127
- "--restart=no",
1128
- `--name=${svc.name}`,
1129
- `--hostname=${svc.name}`,
1130
- `--network=${NETWORK_NAME}`,
1131
- // Every service is reachable at `<name>.internal` as well as its
1132
- // bare `<name>`. The fully-qualified form is what kubeconfigs and
1133
- // other tooling that expect a multi-label hostname should use; it's
1134
- // resolved both inside containers (Docker's embedded DNS) and on
1135
- // the VM host (spectest-resolver scans aliases).
1136
- `--network-alias=${svc.name}.internal`,
1137
- ];
1138
- // Extra peer aliases for this service — lowered from `hostnames` and any
1139
- // dnsName(h, { service }) into LOWERED.aliasesByService, plus any passed
1140
- // explicitly by a runtime startService (not present in LOWERED).
1141
- for (const h of [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases]) {
1142
- args.push(`--network-alias=${h}`);
1143
- }
1144
- // Bound TCP give-up time inside THIS container's network namespace.
1145
- // net.ipv4.tcp_retries2 is per-netns and a fresh netns resets to the kernel
1146
- // default (15 ≈ ~15 min of RTO backoff), so lowering it on the guest's init
1147
- // netns (BASE_SETUP_SH) does NOT reach containers — and the connections that
1148
- // actually wedge run here: buildkit/buildctl pulling base images + exporting
1149
- // cache, and the k3s container's containerd pulling images, all to the host
1150
- // zot over the VM↔host path. On a lost-retransmit (transient loss under
1151
- // concurrent forks) such a flow otherwise stalls a build/pull for minutes.
1152
- // Setting it per container resets a genuinely-stuck flow in ~tens of seconds
1153
- // so the client retries on a fresh connection; live connections keep getting
1154
- // ACKs and are unaffected. Safe because every service runs on the
1155
- // spectest-net bridge (own netns), never --network=host where net.* is denied.
1156
- args.push("--sysctl", "net.ipv4.tcp_retries2=6");
1157
- // Wire every ingress hostname (fakes, TLS-terminated proxies, and any
1158
- // dnsName(h, { ingress: true })) into the container's /etc/hosts so
1159
- // `fetch("http://api.stripe.com")` or `fetch("https://app.test")` from
1160
- // app code reaches the daemon's ingress listener via the bridge gateway.
1161
- // /etc/hosts beats Docker's embedded DNS (127.0.0.11), so we don't need
1162
- // to touch the container's resolver settings.
1163
- if (cachedGatewayIp) {
1164
- for (const h of LOWERED.ingressHosts) {
1165
- args.push(`--add-host=${h}:${cachedGatewayIp}`);
1166
- }
1167
- }
1168
- // Resolve `spectest-host` to the host image-cache gateway so apps can
1169
- // address the zot mirrors / shared buildkitd by name (see
1170
- // SPECTEST_HOST_NAME). Skipped where there's no host cache.
1171
- const hostGw = hostCacheGateway();
1172
- if (hostGw)
1173
- args.push(`--add-host=${SPECTEST_HOST_NAME}:${hostGw}`);
1174
- if (svc.workdir)
1175
- args.push(`--workdir=${svc.workdir}`);
1176
- // Trust the spectest root CA from inside the container. Bind-mount
1177
- // the cert + set the conventional env vars so language runtimes
1178
- // (Node, Python requests/httpx, AWS SDKs) pick it up without
1179
- // touching the image's system trust store. The per-image
1180
- // ensureCaTrustedImage layer also installs it into the system
1181
- // trust store; this env-var path is the belt-and-braces fallback
1182
- // for images where the layer step couldn't run (no
1183
- // update-ca-certificates).
1184
- args.push(`--volume=${CA_PATH}:${CA_PATH}:ro`);
1185
- // Appends to the image's roots, so it takes the bare CA.
1186
- args.push("-e", `NODE_EXTRA_CA_CERTS=${CA_PATH}`);
1187
- // These three REPLACE the roots — they must get the combined bundle or
1188
- // the container loses every public CA (see CA_BUNDLE_PATH).
1189
- const caBundle = await ensureCaBundle();
1190
- if (caBundle) {
1191
- if (caBundle !== CA_PATH)
1192
- args.push(`--volume=${caBundle}:${caBundle}:ro`);
1193
- args.push("-e", `SSL_CERT_FILE=${caBundle}`);
1194
- args.push("-e", `REQUESTS_CA_BUNDLE=${caBundle}`);
1195
- args.push("-e", `AWS_CA_BUNDLE=${caBundle}`);
1196
- }
1197
- if (svc.env) {
1198
- for (const [k, v] of Object.entries(svc.env)) {
1199
- args.push("-e", `${k}=${v}`);
1200
- }
1201
- }
1202
- for (const flag of volumeFlags)
1203
- args.push(flag);
1204
- if (svc.privileged)
1205
- args.push("--privileged");
1206
- for (const p of svc.tmpfs ?? [])
1207
- args.push(`--tmpfs=${p}`);
1208
- if (svc.cgroupns)
1209
- args.push(`--cgroupns=${svc.cgroupns}`);
1210
- // `command` runs via sh -c, replacing the image entrypoint; `args` is a
1211
- // plain CMD override (`docker run <image> <args…>`) that keeps the
1212
- // entrypoint — what init-wrapped images (postgres) need for extra flags.
1213
- if (svc.command && svc.args?.length) {
1214
- throw new Error(`service ${svc.name}: \`command\` and \`args\` are mutually exclusive ` +
1215
- `(command replaces the entrypoint with /bin/sh -c; args keeps it)`);
1216
- }
1217
- if (svc.command)
1218
- args.push("--entrypoint=/bin/sh");
1219
- args.push(tag);
1220
- if (svc.command)
1221
- args.push("-c", svc.command);
1222
- else if (svc.args?.length)
1223
- args.push(...svc.args);
999
+ // The argv, and every rule shaping it, lives in `harness/container-run.ts`
1000
+ // so it can be asserted without starting a container.
1001
+ const args = runContainerArgs({
1002
+ svc,
1003
+ tag,
1004
+ network: NETWORK_NAME,
1005
+ aliases: [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases],
1006
+ volumeFlags,
1007
+ ingressHosts: LOWERED.ingressHosts,
1008
+ gatewayIp: cachedGatewayIp,
1009
+ hostCacheGateway: hostCacheGateway(),
1010
+ hostCacheName: SPECTEST_HOST_NAME,
1011
+ caPath: CA_PATH,
1012
+ caBundle: await ensureCaBundle(),
1013
+ });
1224
1014
  const r = await docker(args, 300_000);
1225
1015
  if (r.code !== 0) {
1226
1016
  throw new Error(`docker run ${svc.name} failed: ${r.stderr.trim() || r.stdout.trim()}`);
@@ -1274,78 +1064,27 @@ async function waitForReady(svc) {
1274
1064
  if (!check)
1275
1065
  return;
1276
1066
  const timeoutSecs = check.timeoutSecs ?? 60;
1277
- const deadline = Date.now() + timeoutSecs * 1000;
1278
- // Ramped poll: a flat 500ms quantized every service's ready latency
1279
- // (and compounds down dependsOn chains). Fast early probes catch
1280
- // quick services; the ramp caps the polling load on slow ones. Exec
1281
- // probes keep a higher floor — each attempt spawns a docker exec.
1282
- const ramp = check.type === "exec" ? [250, 250, 400, 400, 500] : [50, 100, 150, 250, 400, 500];
1283
- let attempt = 0;
1284
- while (Date.now() < deadline) {
1285
- let ok = false;
1286
- if (check.type === "tcp") {
1287
- ok = await probeTcp(svc.name, check.port);
1067
+ const probe = async () => {
1068
+ if (check.type === "tcp")
1069
+ return probeTcp(svc.name, check.port);
1070
+ if (check.type === "http") {
1071
+ return probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
1288
1072
  }
1289
- else if (check.type === "http") {
1290
- ok = await probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
1291
- }
1292
- else {
1293
- ok = await probeExec(svc.name, check.command);
1294
- }
1295
- if (ok)
1296
- return;
1297
- const delay = ramp[Math.min(attempt, ramp.length - 1)];
1298
- attempt++;
1299
- await new Promise((r) => setTimeout(r, delay));
1300
- }
1073
+ return probeExec(svc.name, check.command);
1074
+ };
1075
+ // Scheduling (the ramp, and not sleeping past the deadline) lives in
1076
+ // `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
1077
+ const { ready } = await pollUntilReady(probe, { kind: check.type, timeoutSecs });
1078
+ if (ready)
1079
+ return;
1301
1080
  const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
1302
1081
  throw new Error(`service ${svc.name} not ready within ${timeoutSecs}s. Recent container logs:\n${logs.stdout}\n${logs.stderr}`);
1303
1082
  }
1304
- /**
1305
- * Validate the `dependsOn` graph and return the name→service map used to
1306
- * walk it. Rejects unknown dependencies and cycles (the same two errors
1307
- * the old level scheduler raised) so the DAG runner can assume a clean
1308
- * graph.
1309
- */
1083
+ /** Validate the `dependsOn` graph and return the name→service map used to
1084
+ * walk it. Rules live in `harness/service-graph.ts`. */
1310
1085
  function validateServiceGraph(services) {
1311
- const byName = new Map(services.map((s) => [s.name, s]));
1312
- for (const s of services) {
1313
- for (const d of s.dependsOn ?? []) {
1314
- if (!byName.has(d)) {
1315
- throw new Error(`service ${s.name} depends on unknown service ${d}`);
1316
- }
1317
- }
1318
- }
1319
- // Cycle detection via DFS coloring (white=unseen, gray=on stack, black=done).
1320
- const WHITE = 0, GRAY = 1, BLACK = 2;
1321
- const color = new Map(services.map((s) => [s.name, WHITE]));
1322
- const visit = (name) => {
1323
- color.set(name, GRAY);
1324
- for (const d of byName.get(name).dependsOn ?? []) {
1325
- const c = color.get(d);
1326
- if (c === GRAY)
1327
- throw new Error("service dependency cycle");
1328
- if (c === WHITE)
1329
- visit(d);
1330
- }
1331
- color.set(name, BLACK);
1332
- };
1333
- for (const s of services)
1334
- if (color.get(s.name) === WHITE)
1335
- visit(s.name);
1336
- return byName;
1086
+ return validateGraph(services);
1337
1087
  }
1338
- /**
1339
- * Bring up every service as early as its own dependencies allow.
1340
- *
1341
- * Each service starts the instant all of its `dependsOn` services have
1342
- * finished `startOne` (run → readyCheck → setup) — not when its whole
1343
- * topological "level" has. Independent branches run fully concurrently;
1344
- * a slow probe on one service delays only its own transitive dependents,
1345
- * never an unrelated branch. `startOne(svc)`'s promise is memoized so each
1346
- * service runs exactly once even when several dependents share a dep, and
1347
- * a dependency failure propagates by rejecting every dependent's await.
1348
- */
1349
1088
  async function startServices(services, startOne) {
1350
1089
  const byName = validateServiceGraph(services);
1351
1090
  const started = new Map();
@@ -1390,11 +1129,6 @@ async function startServices(services, startOne) {
1390
1129
  // ────────────────────────────────────────────────────────────────────────
1391
1130
  const FAKES_REGISTRY_PATH = process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
1392
1131
  const DEFAULT_FAKE_PORT = 80;
1393
- /** Fixed HTTPS port shared by every route (fakes + service-tls). */
1394
- const INGRESS_HTTPS_PORT = 443;
1395
- /** Fixed HTTP port always bound for service-tls (alongside any
1396
- * fakes whose `port` happens to be 80). */
1397
- const INGRESS_HTTP_PORT = 80;
1398
1132
  /** All loaded fakes, keyed by stable name (the `fakes` map key). Holds the
1399
1133
  * in-daemon handler, forked state, and helpers — the parts intrinsic to a
1400
1134
  * fake. Their *networking* (certs, DNS, routes) comes from `LOWERED`. */
@@ -1416,31 +1150,36 @@ const INGRESS_HTTP_SERVERS = new Map();
1416
1150
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1417
1151
  const INGRESS_HTTPS_SERVERS = new Map();
1418
1152
  /**
1419
- * Live per-port route tables, keyed by listen port (80 / 443 / fake ports).
1420
- * Each listener's `fetch` closure captures *this* Map object, so adding an
1421
- * entry takes effect immediately with no rebind — that's what lets a runtime
1422
- * `tls` (a `ctx.startService({ tls })`) bind a new ingress route after boot.
1423
- * Held at module scope so it's part of the live daemon process and forks
1424
- * with the snapshot, exactly like fake state / the names REGISTRY. Rebuilt on
1425
- * /load (cleared by {@link stopIngressServers}).
1426
- */
1427
- const INGRESS_ROUTES_BY_PORT = new Map();
1428
- /**
1429
- * The :443 SNI cert table: serverName → leaf. Unlike the route table, Bun's
1430
- * TLS config is fixed at `Bun.serve` time (reload won't add an SNI entry), so
1431
- * minting a cert for a *new* hostname requires rebinding the :443 listener
1432
- * (cheap, ~1ms — see {@link rebindHttpsListener}). A hostname already covered
1433
- * by an existing exact or wildcard cert needs no rebind, just a route entry.
1153
+ * The live ingress tables — per-port routes and the :443 SNI cert table.
1154
+ *
1155
+ * The shape, the lookup rules and the "does this bind cost a rebind?"
1156
+ * decision live in `harness/ingress-table.ts`; this is the one instance the
1157
+ * daemon process owns. It is module state on purpose: it forks with the
1158
+ * snapshot, exactly like fake state and the names REGISTRY, which is what
1159
+ * lets a route bound mid-test be inherited by a `dependsOn` child and stay
1160
+ * invisible to its siblings. Rebuilt on /load (see {@link stopIngressServers}).
1434
1161
  */
1435
- const HTTPS_CERT_BY_HOST = new Map();
1162
+ const INGRESS = emptyTables();
1436
1163
  /**
1437
1164
  * Tear down listener servers between /load calls so the new project's
1438
1165
  * routes can rebind cleanly.
1439
1166
  */
1167
+ /**
1168
+ * Tear the listeners down between `/load` calls.
1169
+ *
1170
+ * Force-closes rather than draining, deliberately. Every listener now sets
1171
+ * SO_REUSEPORT so a replacement can overlap the original during a cert
1172
+ * rebind ({@link rebindHttpsListener}) — but that also means a listener
1173
+ * left half-alive here would no longer collide with the next project's
1174
+ * bind. It would quietly share the port instead, and the kernel would hand
1175
+ * some connections to the old project's route table. A drain is right for a
1176
+ * rebind, where both listeners serve the same environment; it is wrong
1177
+ * here, where the containers behind those routes are being removed anyway.
1178
+ */
1440
1179
  function stopIngressServers() {
1441
1180
  for (const [port, srv] of INGRESS_HTTP_SERVERS) {
1442
1181
  try {
1443
- srv.stop?.();
1182
+ srv.stop?.(true);
1444
1183
  }
1445
1184
  catch (err) {
1446
1185
  // eslint-disable-next-line no-console
@@ -1450,7 +1189,7 @@ function stopIngressServers() {
1450
1189
  INGRESS_HTTP_SERVERS.clear();
1451
1190
  for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
1452
1191
  try {
1453
- srv.stop?.();
1192
+ srv.stop?.(true);
1454
1193
  }
1455
1194
  catch (err) {
1456
1195
  // eslint-disable-next-line no-console
@@ -1458,8 +1197,7 @@ function stopIngressServers() {
1458
1197
  }
1459
1198
  }
1460
1199
  INGRESS_HTTPS_SERVERS.clear();
1461
- INGRESS_ROUTES_BY_PORT.clear();
1462
- HTTPS_CERT_BY_HOST.clear();
1200
+ clearTables(INGRESS);
1463
1201
  }
1464
1202
  function buildIngress(project) {
1465
1203
  stopIngressServers();
@@ -1574,98 +1312,6 @@ async function bridgeGatewayIp() {
1574
1312
  */
1575
1313
  const NATIVE_FETCH = globalThis.fetch.bind(globalThis);
1576
1314
  /** Hop-by-hop headers per RFC 7230 §6.1 — never forwarded by a proxy. */
1577
- const HOP_BY_HOP_HEADERS = new Set([
1578
- "connection",
1579
- "keep-alive",
1580
- "proxy-authenticate",
1581
- "proxy-authorization",
1582
- "te",
1583
- "trailers",
1584
- "transfer-encoding",
1585
- "upgrade",
1586
- "host",
1587
- ]);
1588
- /**
1589
- * Is this a CORS preflight? A preflight is the browser's own probe (never
1590
- * app business logic): an `OPTIONS` carrying `Origin` +
1591
- * `Access-Control-Request-Method`. Plain `OPTIONS` calls (no `ACRM`) are real
1592
- * app requests and pass straight through to the upstream/fake.
1593
- */
1594
- function isCorsPreflight(req) {
1595
- return (req.method === "OPTIONS" &&
1596
- req.headers.has("origin") &&
1597
- req.headers.has("access-control-request-method"));
1598
- }
1599
- /**
1600
- * Answer a CORS preflight at the ingress, permissively, reflecting exactly
1601
- * what the browser asked for.
1602
- *
1603
- * Why this belongs in the platform, not the app: inside the hermetic sandbox
1604
- * the app page's origin (e.g. `http://<svc>.internal:<port>`) and every host
1605
- * it fetches through this ingress (`https://api.example.com`) are *always*
1606
- * different origins, so any request with a non-safelisted header — which
1607
- * includes `Authorization`, and crucially `Cache-Control` / `Pragma` — is
1608
- * preflighted by the browser. If we forward the `OPTIONS` to the upstream, the
1609
- * request succeeds or fails on whether *that* app happens to enumerate the
1610
- * header in its `Access-Control-Allow-Headers`. Real apps list `Authorization`
1611
- * but almost never `Cache-Control`/`Pragma`, so a client that sends those (many
1612
- * HTTP libraries add `Cache-Control: no-cache` by default) fails the preflight
1613
- * with an instant "Failed to fetch" — even though the identical request works
1614
- * in production behind a permissive edge/gateway. Reflecting
1615
- * `Access-Control-Request-Headers` verbatim makes the ingress transparent to
1616
- * whatever header vocabulary the app under test uses.
1617
- */
1618
- function corsPreflightResponse(req) {
1619
- const origin = req.headers.get("origin") ?? "*";
1620
- const reqHeaders = req.headers.get("access-control-request-headers");
1621
- const reqMethod = req.headers.get("access-control-request-method");
1622
- const headers = new Headers();
1623
- headers.set("access-control-allow-origin", origin);
1624
- // Echo the specific origin (not `*`) so credentialed requests are allowed;
1625
- // `Allow-Origin: *` + `Allow-Credentials: true` is a spec violation browsers
1626
- // reject.
1627
- headers.set("access-control-allow-credentials", "true");
1628
- headers.set("access-control-allow-methods", reqMethod && reqMethod.length > 0
1629
- ? reqMethod
1630
- : "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS");
1631
- headers.set("access-control-allow-headers", reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*");
1632
- headers.set("access-control-max-age", "600");
1633
- // The response varies by the reflected origin/headers — keep caches honest.
1634
- headers.append("vary", "Origin");
1635
- headers.append("vary", "Access-Control-Request-Headers");
1636
- return new Response(null, { status: 204, headers });
1637
- }
1638
- /**
1639
- * Make sure the browser sees an `Access-Control-Allow-Origin` it accepts on the
1640
- * *actual* cross-origin response. Only fills one in when the upstream/fake
1641
- * didn't set its own, so an app that manages CORS itself keeps full control;
1642
- * this just stops a missing header from turning an otherwise-fine 200 into a
1643
- * "Failed to fetch". No-op for same-origin requests (no `Origin`).
1644
- */
1645
- function augmentCorsResponse(req, res) {
1646
- const origin = req.headers.get("origin");
1647
- if (!origin)
1648
- return res;
1649
- if (res.headers.has("access-control-allow-origin"))
1650
- return res;
1651
- try {
1652
- res.headers.set("access-control-allow-origin", origin);
1653
- res.headers.set("access-control-allow-credentials", "true");
1654
- res.headers.append("vary", "Origin");
1655
- }
1656
- catch {
1657
- // Some responses (e.g. a 101 upgrade stub) carry guarded/immutable
1658
- // headers — leave those untouched.
1659
- }
1660
- return res;
1661
- }
1662
- /**
1663
- * Bring ingress servers up: bind one Bun.serve per unique HTTP port
1664
- * (fakes' ports plus the always-on :80 for service proxies), plus a
1665
- * shared HTTPS :443 (SNI per hostname). Build each fake's initial state,
1666
- * then write the hostname→ip registry that spectest-resolver consults
1667
- * for DNS. Idempotent — calling twice rebuilds.
1668
- */
1669
1315
  async function startIngress() {
1670
1316
  const hasIngress = FAKES.size > 0 ||
1671
1317
  LOWERED.proxies.length > 0 ||
@@ -1706,7 +1352,7 @@ async function startIngress() {
1706
1352
  continue;
1707
1353
  const leaf = await generateHostCert(group.hostnames[0], group.hostnames);
1708
1354
  for (const h of group.hostnames)
1709
- HTTPS_CERT_BY_HOST.set(h, leaf);
1355
+ INGRESS.certByHost.set(h, leaf);
1710
1356
  }
1711
1357
  }
1712
1358
  else if (LOWERED.certificates.length > 0) {
@@ -1718,11 +1364,7 @@ async function startIngress() {
1718
1364
  // route tables (module scope, so runtime `tls` can extend them later).
1719
1365
  // Fakes run an in-daemon handler on their declared port; proxies
1720
1366
  // reverse-proxy to a service:port and bind :80 (HTTPS, if any, is :443).
1721
- const ensurePort = (port) => {
1722
- const m = INGRESS_ROUTES_BY_PORT.get(port) ?? new Map();
1723
- INGRESS_ROUTES_BY_PORT.set(port, m);
1724
- return m;
1725
- };
1367
+ const ensurePort = (port) => routesFor(INGRESS, port);
1726
1368
  for (const fake of FAKES.values()) {
1727
1369
  if (fake.port === INGRESS_HTTPS_PORT)
1728
1370
  continue;
@@ -1740,7 +1382,7 @@ async function startIngress() {
1740
1382
  // covers — exactly, or through a wildcard SAN, so `*.example.com` in a
1741
1383
  // service's `tls` also puts an unrelated exact `api.example.com` route on
1742
1384
  // HTTPS. Proxies are applied after fakes so a proxy wins a shared host.
1743
- if (HTTPS_CERT_BY_HOST.size > 0) {
1385
+ if (INGRESS.certByHost.size > 0) {
1744
1386
  const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
1745
1387
  for (const fake of FAKES.values()) {
1746
1388
  for (const h of fake.hostnames) {
@@ -1755,7 +1397,7 @@ async function startIngress() {
1755
1397
  }
1756
1398
  }
1757
1399
  // ── HTTP listeners (one per non-443 port).
1758
- for (const [port, byHost] of INGRESS_ROUTES_BY_PORT) {
1400
+ for (const [port, byHost] of INGRESS.routesByPort) {
1759
1401
  if (port === INGRESS_HTTPS_PORT)
1760
1402
  continue;
1761
1403
  INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
@@ -1763,7 +1405,7 @@ async function startIngress() {
1763
1405
  console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
1764
1406
  }
1765
1407
  // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1766
- if (HTTPS_CERT_BY_HOST.size > 0)
1408
+ if (INGRESS.certByHost.size > 0)
1767
1409
  rebindHttpsListener(Bun);
1768
1410
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
1769
1411
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
@@ -1780,37 +1422,61 @@ function requireBun() {
1780
1422
  }
1781
1423
  return Bun;
1782
1424
  }
1783
- /** Flatten {@link HTTPS_CERT_BY_HOST} into Bun's TLS-entry SNI array. */
1425
+ /** Flatten the live SNI cert table into Bun's TLS-entry array. */
1784
1426
  function tlsEntriesFromCerts() {
1785
- return [...HTTPS_CERT_BY_HOST].map(([serverName, leaf]) => ({
1786
- cert: leaf.cert,
1787
- key: leaf.key,
1788
- serverName,
1789
- }));
1427
+ return certEntries(INGRESS);
1790
1428
  }
1791
1429
  /**
1792
- * (Re)bind the :443 listener from the current cert table + route map. Bun's
1793
- * TLS config is immutable per `Bun.serve`, so adding an SNI cert means
1794
- * stopping the old listener and serving a fresh one — cheap (~1ms) and the
1795
- * window is sub-millisecond. The route Map is the persistent module object,
1796
- * so the new listener closes over the same table (later route additions need
1797
- * no rebind). No-ops to a plain rebind when only routes changed.
1430
+ * (Re)bind the :443 listener from the current cert table + route map.
1431
+ *
1432
+ * Bun fixes a server's TLS config at `Bun.serve` time — `reload()` accepts a
1433
+ * new `tls` option and silently keeps serving the old certificates (measured
1434
+ * on Bun 1.3.14: after reloading with a second SNI entry, the new hostname
1435
+ * still gets the first one's leaf). So adding a certificate really does mean
1436
+ * a second listener.
1437
+ *
1438
+ * **Bind the new one before stopping the old one.** Doing it the other way
1439
+ * round — which is what this used to do, with `stop(true)` — has two teeth:
1440
+ * the force-close kills every established connection on :443, and the gap
1441
+ * before the new listener binds refuses new ones. Neither is limited to the
1442
+ * hostname being added; they hit all the unrelated traffic the listener is
1443
+ * carrying. On a project that mints a certificate per provisioned database
1444
+ * while deploys stream through the same port, that surfaced as the app under
1445
+ * test dying with `SocketError: other side closed` or `ECONNREFUSED
1446
+ * <gateway>:443` — a different test each run, and nothing pointing at
1447
+ * ingress.
1448
+ *
1449
+ * Overlapping the two needs SO_REUSEPORT on both sockets ({@link
1450
+ * bindIngressServer} sets it unconditionally for that reason), after which
1451
+ * the old listener is drained with `stop(false)` so requests in flight
1452
+ * finish. During the overlap the kernel may hand a new connection to either
1453
+ * socket, which is safe: the only hostname the two disagree about is the one
1454
+ * being added, and it does not resolve until the caller writes the names
1455
+ * registry after this returns.
1456
+ *
1457
+ * The route Map is the persistent module object, so the new listener closes
1458
+ * over the same table and later route additions need no rebind at all.
1798
1459
  */
1799
1460
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1800
1461
  function rebindHttpsListener(Bun) {
1801
- const routes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map();
1802
- INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, routes);
1462
+ // The *same* table object the previous listener closed over: the new
1463
+ // listener must serve the routes bound since, and any bound later.
1464
+ const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
1803
1465
  const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
1466
+ const server = bindIngressServer(Bun, INGRESS_HTTPS_PORT, routes, `https :${INGRESS_HTTPS_PORT}`, tlsEntriesFromCerts());
1804
1467
  if (old) {
1805
1468
  try {
1806
- old.stop(true);
1469
+ // Graceful: stop accepting, let in-flight requests finish. The old
1470
+ // server stays alive until they do, which is the point — a long
1471
+ // upload through ingress must not be collateral damage of another
1472
+ // service being provisioned.
1473
+ old.stop(false);
1807
1474
  }
1808
1475
  catch (err) {
1809
1476
  // eslint-disable-next-line no-console
1810
- console.warn("[ingress] failed to stop https listener for rebind:", err);
1477
+ console.warn("[ingress] failed to drain the previous https listener:", err);
1811
1478
  }
1812
1479
  }
1813
- const server = bindIngressServer(Bun, INGRESS_HTTPS_PORT, routes, `https :${INGRESS_HTTPS_PORT}`, tlsEntriesFromCerts());
1814
1480
  INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1815
1481
  // eslint-disable-next-line no-console
1816
1482
  console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
@@ -1822,23 +1488,11 @@ function rebindHttpsListener(Bun) {
1822
1488
  * client stops at one label. Claiming a deeper name is covered would skip
1823
1489
  * minting the leaf it actually needs and hand it a cert it rejects.
1824
1490
  */
1825
- function wildcardCoversHost(pattern, hostname) {
1826
- const suffix = wildcardSuffix(pattern); // "*.example.com" → ".example.com"
1827
- if (!hostname.endsWith(suffix))
1828
- return false;
1829
- const label = hostname.slice(0, -suffix.length);
1830
- return label.length > 0 && !label.includes(".");
1831
- }
1832
- /** True if an exact or wildcard cert already covers `hostname` for SNI. */
1491
+ /** True if an exact or wildcard cert already covers `hostname` for SNI.
1492
+ * The matching rules live in `harness/hostmatch.ts`; this just supplies
1493
+ * the live cert table. */
1833
1494
  function certCovers(hostname) {
1834
- if (HTTPS_CERT_BY_HOST.has(hostname))
1835
- return true;
1836
- for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
1837
- if (isWildcard(serverName) && wildcardCoversHost(serverName, hostname)) {
1838
- return true;
1839
- }
1840
- }
1841
- return false;
1495
+ return hostmatchCertCovers(INGRESS.certByHost.keys(), hostname);
1842
1496
  }
1843
1497
  /**
1844
1498
  * Bind a runtime ingress route for one `tls: [{ hostname, port }]` entry on a
@@ -1863,38 +1517,37 @@ async function bindRuntimeTls(hostname, service, port) {
1863
1517
  }
1864
1518
  const Bun = requireBun();
1865
1519
  const route = { kind: "proxy", service, port };
1866
- // Plain HTTP on :80 (parity with boot `tls`, which serves both schemes).
1867
- let httpRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT);
1868
- if (!httpRoutes) {
1869
- httpRoutes = new Map();
1870
- INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTP_PORT, httpRoutes);
1871
- }
1872
- httpRoutes.set(host, route);
1520
+ // Decide the cost before mutating anything: an already-covered hostname
1521
+ // (an exact duplicate, or one under a boot wildcard) needs only a route.
1522
+ const plan = planBind(INGRESS, host, {
1523
+ httpsListening: INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT),
1524
+ });
1525
+ // Routes go in before the certificate is minted, not after. Minting
1526
+ // shells out to openssl and takes on the order of a second, and both
1527
+ // schemes are already serving other hosts — so binding first means
1528
+ // `http://<host>/` starts working immediately instead of 404ing for the
1529
+ // length of an RSA keygen. Nothing here can observe a half-bound state:
1530
+ // the route is only reachable once the hostname resolves, which is the
1531
+ // registry write at the end.
1532
+ bindRoute(INGRESS, host, route);
1873
1533
  if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
1874
- INGRESS_HTTP_SERVERS.set(INGRESS_HTTP_PORT, bindIngressServer(Bun, INGRESS_HTTP_PORT, httpRoutes, `port ${INGRESS_HTTP_PORT}`));
1534
+ INGRESS_HTTP_SERVERS.set(INGRESS_HTTP_PORT, bindIngressServer(Bun, INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT), `port ${INGRESS_HTTP_PORT}`));
1875
1535
  }
1876
- // HTTPS on :443. A new cert forces a listener rebind; an already-covered
1877
- // hostname (exact dup or a boot wildcard) just needs the route entry.
1878
- const needCert = !certCovers(host);
1879
- if (needCert) {
1880
- HTTPS_CERT_BY_HOST.set(host, await generateHostCert(host, [host]));
1536
+ if (plan.needsCert) {
1537
+ INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
1881
1538
  }
1882
- const httpsRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map();
1883
- INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, httpsRoutes);
1884
- httpsRoutes.set(host, route);
1885
- if (needCert || !INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) {
1539
+ if (plan.needsHttpsRebind)
1886
1540
  rebindHttpsListener(Bun);
1887
- }
1888
1541
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1889
1542
  // A wildcard can only live in the resolver's suffix table.
1890
1543
  const gw = await bridgeGatewayIp();
1891
- if (isWildcard(host)) {
1892
- const suffix = wildcardSuffix(host);
1893
- REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
1894
- REGISTRY.wildcards.push({ suffix, ip: gw });
1544
+ const target = registryTarget(host);
1545
+ if (target.wildcard) {
1546
+ REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
1547
+ REGISTRY.wildcards.push({ suffix: target.suffix, ip: gw });
1895
1548
  }
1896
1549
  else {
1897
- REGISTRY.hosts[host] = gw;
1550
+ REGISTRY.hosts[target.host] = gw;
1898
1551
  }
1899
1552
  await writeRegistry();
1900
1553
  // eslint-disable-next-line no-console
@@ -1908,17 +1561,16 @@ async function bindRuntimeTls(hostname, service, port) {
1908
1561
  */
1909
1562
  async function unbindRuntimeTls(hostname) {
1910
1563
  const host = hostname.toLowerCase();
1911
- INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT)?.delete(host);
1912
- INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT)?.delete(host);
1913
- if (isWildcard(host)) {
1914
- const suffix = wildcardSuffix(host);
1564
+ unbindRoute(INGRESS, host);
1565
+ const target = registryTarget(host);
1566
+ if (target.wildcard) {
1915
1567
  const before = REGISTRY.wildcards.length;
1916
- REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
1568
+ REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
1917
1569
  if (REGISTRY.wildcards.length !== before)
1918
1570
  await writeRegistry();
1919
1571
  }
1920
- else if (host in REGISTRY.hosts) {
1921
- delete REGISTRY.hosts[host];
1572
+ else if (target.host in REGISTRY.hosts) {
1573
+ delete REGISTRY.hosts[target.host];
1922
1574
  await writeRegistry();
1923
1575
  }
1924
1576
  }
@@ -1942,6 +1594,12 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
1942
1594
  const opts = {
1943
1595
  port,
1944
1596
  hostname: "0.0.0.0",
1597
+ // SO_REUSEPORT on every ingress listener, so a replacement can be bound
1598
+ // while the old one is still serving. That overlap is the only way to
1599
+ // add an SNI certificate without a gap — see {@link rebindHttpsListener}
1600
+ // — and it only works if *both* sockets opt in: a second plain bind
1601
+ // fails with "Is port 443 in use?".
1602
+ reusePort: true,
1945
1603
  // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
1946
1604
  // request whose upstream takes >10s to produce bytes — under parallel
1947
1605
  // test load that surfaced as "fetch failed"/"other side closed" on
@@ -2046,21 +1704,7 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
2046
1704
  * present a valid cert over `https://`.
2047
1705
  */
2048
1706
  function matchIngressRoute(byHost, host) {
2049
- const exact = byHost.get(host);
2050
- if (exact)
2051
- return exact;
2052
- let best;
2053
- let bestLen = -1;
2054
- for (const [pattern, route] of byHost) {
2055
- if (!isWildcard(pattern))
2056
- continue;
2057
- const suffix = wildcardSuffix(pattern);
2058
- if (host.endsWith(suffix) && suffix.length > bestLen) {
2059
- best = route;
2060
- bestLen = suffix.length;
2061
- }
2062
- }
2063
- return best;
1707
+ return matchRoute(byHost, host);
2064
1708
  }
2065
1709
  /**
2066
1710
  * Per-request dispatch shared by every ingress listener. Looks up the
@@ -2072,10 +1716,9 @@ function matchIngressRoute(byHost, host) {
2072
1716
  async function dispatchIngress(req,
2073
1717
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2074
1718
  server, byHost, listenerLabel, proto) {
2075
- const host = (req.headers.get("host") ?? "")
2076
- .toLowerCase()
2077
- .split(":")[0]
2078
- .trim();
1719
+ // `hostWithoutPort` rather than split(":")[0]: the naive form turns an
1720
+ // IPv6 literal Host (`[::1]:8080`) into "[".
1721
+ const host = hostWithoutPort((req.headers.get("host") ?? "").toLowerCase());
2079
1722
  const route = matchIngressRoute(byHost, host);
2080
1723
  if (!route) {
2081
1724
  return new Response(`spectest-daemon: no ingress route bound to Host=${JSON.stringify(host)} on ${listenerLabel}\n`, { status: 404, headers: { "content-type": "text/plain" } });
@@ -2262,11 +1905,9 @@ server, service, port, listenerLabel, proto) {
2262
1905
  */
2263
1906
  const REGISTRY = { hosts: {}, wildcards: [] };
2264
1907
  async function writeRegistry() {
2265
- const body = JSON.stringify({
2266
- hosts: REGISTRY.hosts,
2267
- wildcards: REGISTRY.wildcards,
2268
- updatedAt: Date.now(),
2269
- });
1908
+ // Format + lookup semantics live in `harness/names-registry.ts`, shared
1909
+ // with the resolver that reads this file.
1910
+ const body = encodeRegistry(REGISTRY, Date.now());
2270
1911
  try {
2271
1912
  await fs.mkdir(path.dirname(FAKES_REGISTRY_PATH), { recursive: true });
2272
1913
  await fs.writeFile(FAKES_REGISTRY_PATH, body);
@@ -2278,9 +1919,6 @@ async function writeRegistry() {
2278
1919
  }
2279
1920
  }
2280
1921
  /** `*.example.com` → `.example.com` — the suffix the resolver matches. */
2281
- function wildcardSuffix(pattern) {
2282
- return pattern.slice(1); // drop the leading "*"
2283
- }
2284
1922
  /** A service container's IP on spectest-net. `null` if the container isn't
2285
1923
  * up or isn't attached to the network yet. */
2286
1924
  async function serviceContainerIp(name) {
@@ -2937,11 +2575,6 @@ function stashReplayBundle(caseId, gz) {
2937
2575
  * so it travels with the snapshot into every fork.
2938
2576
  */
2939
2577
  const LOG_MARKERS = new Map();
2940
- /** Per-(service, stream) delta byte cap. Over this we keep head+tail and
2941
- * elide the middle — the head preserves the continuation from the parent,
2942
- * the tail preserves the newest output — while still advancing the marker
2943
- * to the true line count so the chain stays aligned. */
2944
- const LOG_DELTA_MAX_BYTES = 2 * 1024 * 1024;
2945
2578
  /**
2946
2579
  * Inspect a container's run state for {@link ServiceLogDelta}: `{}` while
2947
2580
  * running (or when inspect fails — a removed container has no state left to
@@ -2961,68 +2594,6 @@ async function containerStopState(name) {
2961
2594
  * `max` (string length, a byte proxy as elsewhere here). Head+tail so an
2962
2595
  * over-long delta keeps both the parent-continuation and the newest
2963
2596
  * output. */
2964
- function capMiddle(s, max) {
2965
- if (s.length <= max)
2966
- return { value: s, truncated: false };
2967
- const half = Math.floor(max / 2);
2968
- const elided = s.length - 2 * half;
2969
- return {
2970
- value: `${s.slice(0, half)}\n… [${elided} bytes elided] …\n${s.slice(s.length - half)}`,
2971
- truncated: true,
2972
- };
2973
- }
2974
- /**
2975
- * Compute one stream's delta beyond `marker` complete lines.
2976
- * - Counts only newline-terminated lines; a trailing partial line (no
2977
- * `\n` yet) is held back from both the delta and the count, so a line
2978
- * completed by a later capture isn't split across the fork boundary.
2979
- * - Reset guard: if the stream shrank below `marker` (container recreated
2980
- * or rotated) the whole current log is re-emitted and `reset` is set.
2981
- */
2982
- function streamDelta(full, marker) {
2983
- const lastNl = full.lastIndexOf("\n");
2984
- const complete = lastNl < 0 ? "" : full.slice(0, lastNl + 1);
2985
- let total = 0;
2986
- for (let i = 0; i < complete.length; i++) {
2987
- if (complete.charCodeAt(i) === 10)
2988
- total++;
2989
- }
2990
- let reset = false;
2991
- let startLine = marker;
2992
- if (total < marker) {
2993
- reset = true;
2994
- startLine = 0;
2995
- }
2996
- let delta;
2997
- if (startLine <= 0) {
2998
- delta = complete;
2999
- }
3000
- else if (startLine >= total) {
3001
- delta = "";
3002
- }
3003
- else {
3004
- // Byte offset just past the `startLine`-th newline.
3005
- let seen = 0;
3006
- let off = 0;
3007
- for (let i = 0; i < complete.length; i++) {
3008
- if (complete.charCodeAt(i) === 10 && ++seen === startLine) {
3009
- off = i + 1;
3010
- break;
3011
- }
3012
- }
3013
- delta = complete.slice(off);
3014
- }
3015
- const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
3016
- return { delta: capped.value, total, reset, truncated: capped.truncated };
3017
- }
3018
- /**
3019
- * Capture the per-service log delta for the current case and advance the
3020
- * markers. Runs on EVERY case (pass or fail). Enumerates boot services
3021
- * (`namedServices`) plus any runtime services this fork started
3022
- * (`RUNTIME_SERVICES`), deduped by name. A `docker logs` failure surfaces
3023
- * as the service's `stderr` WITHOUT advancing the markers — a transient
3024
- * failure must never desync the chain.
3025
- */
3026
2597
  async function captureServiceLogDeltas() {
3027
2598
  const l = loaded;
3028
2599
  if (!l)
@@ -4734,344 +4305,220 @@ async function runTypecheck() {
4734
4305
  }
4735
4306
  return { status: "errors", errors, totalErrors: total, durationMs };
4736
4307
  }
4737
- // ────────────────────────────────────────────────────────────────────────
4738
- // HTTP server
4739
- // ────────────────────────────────────────────────────────────────────────
4740
- async function readBody(req) {
4741
- return new Promise((resolve, reject) => {
4742
- const chunks = [];
4743
- req.on("data", (c) => chunks.push(c));
4744
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
4745
- req.on("error", reject);
4746
- });
4308
+ /** Run `job` under a mutual-exclusion slot, refusing if one is held. */
4309
+ async function exclusive(state, slot, busy, job) {
4310
+ if (state[slot])
4311
+ throw conflict(busy);
4312
+ const p = job();
4313
+ state[slot] = p;
4314
+ try {
4315
+ return await p;
4316
+ }
4317
+ finally {
4318
+ state[slot] = null;
4319
+ }
4747
4320
  }
4748
- function jsonResponse(res, status, body) {
4749
- const payload = Buffer.from(JSON.stringify(body));
4750
- res.writeHead(status, {
4751
- "content-type": "application/json",
4752
- "content-length": payload.length,
4753
- });
4754
- res.end(payload);
4321
+ /** The environment's current catalogue, the shape three methods return. */
4322
+ function loadedSummary(l) {
4323
+ return {
4324
+ environment: l.project.environment,
4325
+ cases: casesMetadata(l.project.tests),
4326
+ fakes: fakesSummary(l.project),
4327
+ };
4755
4328
  }
4756
- async function handle(req, res, state) {
4757
- const url = req.url ?? "";
4758
- const method = req.method ?? "GET";
4759
- if (method === "GET" && url === "/health") {
4760
- res.writeHead(200, { "content-type": "text/plain" });
4761
- res.end("ok\n");
4762
- return;
4763
- }
4764
- if (method === "GET" && url === "/progress") {
4329
+ /**
4330
+ * Everything the harness can be asked to do.
4331
+ *
4332
+ * Transport-neutral by construction: params in, plain result out, failures
4333
+ * as {@link HarnessError}. The HTTP router below and the supervisor's frame
4334
+ * dispatcher both drive this same table, so a change reaches both — which
4335
+ * is the property that makes it safe to run the two side by side during the
4336
+ * cutover.
4337
+ */
4338
+ export function harnessMethods(state) {
4339
+ return {
4340
+ health: async () => ({ ok: true }),
4765
4341
  // Live bootstrap progress, polled by the control plane during
4766
- // /bootstrap and streamed into the test-run row. `{}` before the
4767
- // first bootstrap() of this daemon.
4768
- jsonResponse(res, 200, BOOTSTRAP_PROGRESS ?? {});
4769
- return;
4770
- }
4771
- if (method === "GET" && url.startsWith("/env-logs")) {
4772
- // Live logs for a running environment: the daemon's own boot log
4773
- // (bootstrap + every `setup` hook's console output) plus each
4774
- // service's container logs. Unlike the per-case deltas this reads
4775
- // without touching LOG_MARKERS, so calling it never perturbs a run.
4776
- const q = new URL(url, "http://daemon").searchParams;
4777
- const only = q.get("service") ?? undefined;
4778
- const tail = Math.max(1, Math.min(10_000, Number(q.get("tail") ?? "200") || 200));
4779
- const l = loaded;
4780
- const byName = new Map();
4781
- if (l)
4782
- for (const s of namedServices(l.project.environment))
4783
- byName.set(s.name, s);
4784
- for (const [name, s] of RUNTIME_SERVICES)
4785
- byName.set(name, s);
4786
- const names = [...byName.keys()].filter((n) => !only || n === only);
4787
- if (only && names.length === 0) {
4788
- jsonResponse(res, 404, {
4789
- error: `unknown service ${JSON.stringify(only)}; known: ${[...byName.keys()].join(", ") || "(none)"}`,
4790
- });
4791
- return;
4792
- }
4793
- const services = await Promise.all(names.map(async (name) => {
4794
- const r = await docker(["logs", "--timestamps", `--tail=${tail}`, name], 30_000);
4795
- const out = capMiddle(r.stdout, LOG_DELTA_MAX_BYTES);
4796
- const err = capMiddle(r.code === 0 ? r.stderr : r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
4797
- return {
4798
- service: name,
4799
- stdout: out.value,
4800
- stdoutTruncated: out.truncated,
4801
- stderr: err.value,
4802
- stderrTruncated: err.truncated,
4803
- };
4804
- }));
4805
- jsonResponse(res, 200, { bootLog: bootLogText(), services });
4806
- return;
4807
- }
4808
- if (method === "POST" && url === "/load") {
4342
+ // `bootstrap` and streamed into the test-run row. `{}` before the
4343
+ // first bootstrap of this harness process.
4344
+ progress: async () => BOOTSTRAP_PROGRESS ?? {},
4345
+ /**
4346
+ * Live logs for a running environment: the harness's own boot log
4347
+ * (bootstrap plus every `setup` hook's console output) and each
4348
+ * service's container logs.
4349
+ *
4350
+ * Reads without touching LOG_MARKERS, unlike the per-case deltas, so
4351
+ * watching an environment can never perturb a run in progress.
4352
+ */
4353
+ envLogs: async (params) => {
4354
+ const only = typeof params.service === "string" ? params.service : undefined;
4355
+ const tail = Math.max(1, Math.min(10_000, Number(params.tail ?? 200) || 200));
4356
+ const l = loaded;
4357
+ const byName = new Map();
4358
+ if (l)
4359
+ for (const s of namedServices(l.project.environment))
4360
+ byName.set(s.name, s);
4361
+ for (const [name, s] of RUNTIME_SERVICES)
4362
+ byName.set(name, s);
4363
+ const names = [...byName.keys()].filter((n) => !only || n === only);
4364
+ if (only && names.length === 0) {
4365
+ throw notFound(`unknown service ${JSON.stringify(only)}; known: ${[...byName.keys()].join(", ") || "(none)"}`);
4366
+ }
4367
+ const services = await Promise.all(names.map(async (name) => {
4368
+ const r = await docker(["logs", "--timestamps", `--tail=${tail}`, name], 30_000);
4369
+ const out = capMiddle(r.stdout, LOG_DELTA_MAX_BYTES);
4370
+ const err = capMiddle(r.code === 0 ? r.stderr : r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
4371
+ return {
4372
+ service: name,
4373
+ stdout: out.value,
4374
+ stdoutTruncated: out.truncated,
4375
+ stderr: err.value,
4376
+ stderrTruncated: err.truncated,
4377
+ };
4378
+ }));
4379
+ return { bootLog: bootLogText(), services };
4380
+ },
4809
4381
  // Env only — services/fakes/setup. For the legacy single-file layout the
4810
4382
  // entry also defines the tests, so `cases` is populated here; for the
4811
- // split layout `cases` is empty until /load-tests runs.
4812
- const proj = await loadEnv();
4813
- jsonResponse(res, 200, {
4814
- environment: proj.environment,
4815
- cases: casesMetadata(proj.tests),
4816
- fakes: fakesSummary(proj),
4817
- });
4818
- return;
4819
- }
4820
- if (method === "POST" && url === "/load-tests") {
4383
+ // split layout it stays empty until `loadTests`.
4384
+ load: async () => {
4385
+ const proj = await loadEnv();
4386
+ return {
4387
+ environment: proj.environment,
4388
+ cases: casesMetadata(proj.tests),
4389
+ fakes: fakesSummary(proj),
4390
+ };
4391
+ },
4821
4392
  // Import spectest/tests/** into the already-loaded env and return the
4822
4393
  // resulting catalogue. Called after the warm snapshot (cold path) or
4823
4394
  // against a freshly restored VM (warm path).
4824
- await loadTests();
4825
- // Background typecheck of the freshly-uploaded app tree — both paths
4826
- // (cold and warm) funnel through here after the upload, so the check
4827
- // always sees the current code. Never on the critical path: the reply
4828
- // doesn't wait, POST /typecheck collects.
4829
- startTypecheck();
4830
- const l = requireLoaded();
4831
- jsonResponse(res, 200, {
4832
- environment: l.project.environment,
4833
- cases: casesMetadata(l.project.tests),
4834
- fakes: fakesSummary(l.project),
4835
- });
4836
- return;
4837
- }
4838
- if (method === "POST" && url === "/typecheck") {
4839
- // Await the run kicked off at /load-tests (or start one on demand —
4840
- // e.g. a legacy single-file project loaded before this daemon shipped
4841
- // the kickoff, or a direct debug call).
4842
- if (!TYPECHECK)
4395
+ loadTests: async () => {
4396
+ await loadTests();
4397
+ // Background typecheck of the freshly-uploaded app tree — both paths
4398
+ // funnel through here after the upload, so the check always sees the
4399
+ // current code. Never on the critical path: this reply doesn't wait,
4400
+ // `typecheck` collects.
4843
4401
  startTypecheck();
4844
- jsonResponse(res, 200, await TYPECHECK);
4845
- return;
4846
- }
4847
- if (method === "POST" && url === "/unload") {
4848
- loaded = null;
4849
- jsonResponse(res, 200, { unloaded: true });
4850
- return;
4851
- }
4852
- if (method === "POST" && url === "/reload") {
4402
+ return loadedSummary(requireLoaded());
4403
+ },
4404
+ // Await the run kicked off at loadTests, or start one on demand — a
4405
+ // legacy single-file project loaded before the kickoff shipped, or a
4406
+ // direct debug call.
4407
+ typecheck: async () => {
4408
+ if (!TYPECHECK)
4409
+ startTypecheck();
4410
+ return await TYPECHECK;
4411
+ },
4412
+ unload: async () => {
4413
+ loaded = null;
4414
+ return { unloaded: true };
4415
+ },
4853
4416
  // Full reload (debug aid): re-import the env, then the tests.
4854
- await loadEnv();
4855
- await loadTests();
4856
- const l = requireLoaded();
4857
- jsonResponse(res, 200, {
4858
- environment: l.project.environment,
4859
- cases: casesMetadata(l.project.tests),
4860
- fakes: fakesSummary(l.project),
4861
- });
4862
- return;
4863
- }
4864
- if (method === "GET" && url === "/env-config") {
4865
- const l = requireLoaded();
4866
- jsonResponse(res, 200, l.project.environment);
4867
- return;
4868
- }
4869
- if (method === "GET" && url === "/cases") {
4870
- const l = requireLoaded();
4871
- jsonResponse(res, 200, { cases: casesMetadata(l.project.tests) });
4872
- return;
4873
- }
4874
- if (method === "GET" && url === "/record-secret-refs") {
4417
+ reload: async () => {
4418
+ await loadEnv();
4419
+ await loadTests();
4420
+ return loadedSummary(requireLoaded());
4421
+ },
4422
+ envConfig: async () => requireLoaded().project.environment,
4423
+ cases: async () => ({ cases: casesMetadata(requireLoaded().project.tests) }),
4875
4424
  // Union of platform secret refs the loaded fakes declare (replayFake's
4876
- // `secretRefs`). The control plane resolves these server-side and
4877
- // pushes the values on the eval path only. Empty if nothing's loaded.
4878
- const refs = new Set();
4879
- for (const fake of FAKES.values()) {
4880
- for (const ref of fake.def.secretRefs ?? [])
4881
- refs.add(ref);
4882
- }
4883
- jsonResponse(res, 200, { refs: [...refs] });
4884
- return;
4885
- }
4886
- if (method === "POST" && url === "/bootstrap") {
4887
- if (state.inFlightBootstrap) {
4888
- jsonResponse(res, 409, { error: "bootstrap already in progress" });
4889
- return;
4890
- }
4891
- const job = bootstrap();
4892
- state.inFlightBootstrap = job;
4893
- try {
4894
- const timings = await job;
4895
- jsonResponse(res, 200, { ok: true, timings });
4896
- }
4897
- finally {
4898
- state.inFlightBootstrap = null;
4899
- }
4900
- return;
4901
- }
4902
- if (method === "POST" && url === "/project-setup") {
4903
- if (state.inFlightProjectSetup) {
4904
- jsonResponse(res, 409, { error: "project-setup already in progress" });
4905
- return;
4906
- }
4907
- const job = runProjectSetup();
4908
- state.inFlightProjectSetup = job;
4909
- try {
4910
- const result = await job;
4911
- jsonResponse(res, 200, result);
4912
- }
4913
- finally {
4914
- state.inFlightProjectSetup = null;
4915
- }
4916
- return;
4917
- }
4918
- if (method === "POST" && url === "/eval") {
4919
- if (state.inFlightTest) {
4920
- jsonResponse(res, 409, { error: "a test or eval is already running" });
4921
- return;
4922
- }
4923
- const body = await readBody(req);
4924
- let parsed;
4925
- try {
4926
- parsed = JSON.parse(body || "{}");
4927
- }
4928
- catch {
4929
- jsonResponse(res, 400, { error: "invalid JSON body" });
4930
- return;
4931
- }
4932
- const code = parsed.code;
4933
- if (typeof code !== "string" || code.length === 0) {
4934
- jsonResponse(res, 400, { error: "code (string) is required" });
4935
- return;
4936
- }
4937
- // `secrets` are eval-scoped: the control plane resolves the loaded
4938
- // project's declared `replayFake` refs and pushes the values here on
4939
- // the eval path only. Never present on the /run (test) path.
4940
- const exec = evalCode(code, parsed.secrets);
4941
- state.inFlightTest = exec;
4942
- try {
4943
- const result = await exec;
4944
- jsonResponse(res, 200, result);
4945
- }
4946
- finally {
4947
- state.inFlightTest = null;
4948
- }
4949
- return;
4950
- }
4951
- if (method === "POST" && url === "/capture-log-baseline") {
4952
- // Snapshot each service's log output produced during env bring-up
4953
- // (container startup + project setup), advancing the log markers so the
4954
- // subsequent per-test deltas start AFTER setup. The control plane calls
4955
- // this once, on the main env, before the first test runs — see
4956
- // tests.rs::capture_log_baseline. Best-effort: on failure the setup lines
4957
- // simply fold into the first test's delta as before.
4958
- let serviceLogDeltas = [];
4959
- try {
4960
- serviceLogDeltas = await captureServiceLogDeltas();
4961
- }
4962
- catch (err) {
4963
- // eslint-disable-next-line no-console
4964
- console.warn("[service-logs] baseline capture failed:", err);
4965
- }
4966
- jsonResponse(res, 200, { serviceLogDeltas });
4967
- return;
4968
- }
4969
- if (method === "POST" && url === "/run") {
4970
- if (state.inFlightTest) {
4971
- jsonResponse(res, 409, { error: "another test is already running" });
4972
- return;
4973
- }
4974
- const body = await readBody(req);
4975
- let parsed;
4976
- try {
4977
- parsed = JSON.parse(body || "{}");
4978
- }
4979
- catch {
4980
- jsonResponse(res, 400, { error: "invalid JSON body" });
4981
- return;
4982
- }
4983
- const caseId = parsed.caseId;
4984
- if (!caseId) {
4985
- jsonResponse(res, 400, { error: "caseId is required" });
4986
- return;
4987
- }
4988
- const l = requireLoaded();
4989
- const tc = l.byId.get(caseId);
4990
- if (!tc) {
4991
- jsonResponse(res, 404, { error: `unknown caseId: ${caseId}` });
4992
- return;
4993
- }
4994
- const exec = runOne(tc);
4995
- state.inFlightTest = exec;
4996
- try {
4997
- const result = await exec;
4998
- // Recordings go out-of-band: encode + park the bundle, reply with a
4999
- // size ref only (see the REPLAY_BUNDLES comment). A bundle-encoding
5000
- // failure drops the recordings (warn) rather than failing the case —
5001
- // and never falls back to inlining, which is exactly the >16 MB
5002
- // response this path exists to avoid.
5003
- const { browserSessions, ...wire } = result;
5004
- let replay;
5005
- if (browserSessions.length > 0) {
5006
- try {
5007
- const gz = encodeReplayBundle(caseId, browserSessions);
5008
- stashReplayBundle(caseId, gz);
5009
- replay = { bytes: gz.length };
5010
- }
5011
- catch (err) {
5012
- // eslint-disable-next-line no-console
5013
- console.warn("[replay] bundle encode failed; dropping recordings:", err);
5014
- }
4425
+ // `secretRefs`). The control plane resolves these server-side and pushes
4426
+ // the values on the eval path only. Empty if nothing's loaded.
4427
+ recordSecretRefs: async () => {
4428
+ const refs = new Set();
4429
+ for (const fake of FAKES.values()) {
4430
+ for (const ref of fake.def.secretRefs ?? [])
4431
+ refs.add(ref);
5015
4432
  }
5016
- jsonResponse(res, 200, { ...wire, replay });
5017
- }
5018
- finally {
5019
- state.inFlightTest = null;
5020
- }
5021
- return;
5022
- }
5023
- if (method === "POST" && url === "/replay-chunk") {
5024
- // One chunk of a parked replay bundle (see REPLAY_BUNDLES). POST with
5025
- // a JSON body — case ids are arbitrary user strings, and a body dodges
5026
- // URL-encoding across both providers' daemon_http transports.
5027
- const body = await readBody(req);
5028
- let parsed;
5029
- try {
5030
- parsed = JSON.parse(body || "{}");
5031
- }
5032
- catch {
5033
- jsonResponse(res, 400, { error: "invalid JSON body" });
5034
- return;
5035
- }
5036
- if (!parsed.caseId) {
5037
- jsonResponse(res, 400, { error: "caseId is required" });
5038
- return;
5039
- }
5040
- const gz = REPLAY_BUNDLES.get(parsed.caseId);
5041
- if (!gz) {
5042
- jsonResponse(res, 404, { error: `no replay bundle for case: ${parsed.caseId}` });
5043
- return;
5044
- }
5045
- jsonResponse(res, 200, replayChunk(gz, parsed.offset, parsed.limit));
5046
- return;
5047
- }
5048
- jsonResponse(res, 404, { error: "not found" });
5049
- }
5050
- async function main() {
5051
- const state = {
5052
- inFlightTest: null,
5053
- inFlightBootstrap: null,
5054
- inFlightProjectSetup: null,
5055
- };
5056
- const server = http.createServer((req, res) => {
5057
- handle(req, res, state).catch((err) => {
5058
- const e = err;
4433
+ return { refs: [...refs] };
4434
+ },
4435
+ bootstrap: async () => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
4436
+ ok: true,
4437
+ timings: await bootstrap(),
4438
+ })),
4439
+ projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
4440
+ eval: async (params) => {
4441
+ const code = requireString(params, "code");
4442
+ // `secrets` are eval-scoped: the control plane resolves the loaded
4443
+ // project's declared `replayFake` refs and pushes the values here on
4444
+ // the eval path only. Never present on the test path.
4445
+ const secrets = params.secrets;
4446
+ return exclusive(state, "inFlightTest", "a test or eval is already running", () => evalCode(code, secrets));
4447
+ },
4448
+ /**
4449
+ * Snapshot each service's log output produced during env bring-up
4450
+ * (container startup plus project setup), advancing the log markers so
4451
+ * the per-test deltas start *after* setup. The control plane calls this
4452
+ * once, on the main env, before the first test runs.
4453
+ *
4454
+ * Best-effort: on failure the setup lines simply fold into the first
4455
+ * test's delta, as they did before this existed.
4456
+ */
4457
+ captureLogBaseline: async () => {
4458
+ let serviceLogDeltas = [];
5059
4459
  try {
5060
- jsonResponse(res, 500, { error: e.message ?? String(err), stack: e.stack });
4460
+ serviceLogDeltas = await captureServiceLogDeltas();
5061
4461
  }
5062
- catch {
5063
- // headers already sent or socket dead
4462
+ catch (err) {
4463
+ // eslint-disable-next-line no-console
4464
+ console.warn("[service-logs] baseline capture failed:", err);
5064
4465
  }
5065
- });
5066
- });
5067
- const port = Number(process.env.SPECTEST_DAEMON_PORT ?? DEFAULT_PORT);
5068
- server.listen(port, "0.0.0.0", () => {
5069
- // eslint-disable-next-line no-console
5070
- console.log(`spectest-daemon listening on :${port} (idle; awaiting POST /load)`);
5071
- });
4466
+ return { serviceLogDeltas };
4467
+ },
4468
+ run: async (params) => {
4469
+ const caseId = requireString(params, "caseId", "caseId");
4470
+ const tc = requireLoaded().byId.get(caseId);
4471
+ if (!tc)
4472
+ throw notFound(`unknown caseId: ${caseId}`);
4473
+ return exclusive(state, "inFlightTest", "another test is already running", async () => {
4474
+ const result = await runOne(tc);
4475
+ // Recordings go out-of-band: encode and park the bundle, reply with
4476
+ // a size ref only. A bundle-encoding failure drops the recordings
4477
+ // (with a warning) rather than failing the case — and never falls
4478
+ // back to inlining, which is exactly the oversized reply this path
4479
+ // exists to avoid.
4480
+ const { browserSessions, ...wire } = result;
4481
+ let replay;
4482
+ if (browserSessions.length > 0) {
4483
+ try {
4484
+ const gz = encodeReplayBundle(caseId, browserSessions);
4485
+ stashReplayBundle(caseId, gz);
4486
+ replay = { bytes: gz.length };
4487
+ }
4488
+ catch (err) {
4489
+ // eslint-disable-next-line no-console
4490
+ console.warn("[replay] bundle encode failed; dropping recordings:", err);
4491
+ }
4492
+ }
4493
+ return { ...wire, replay };
4494
+ });
4495
+ },
4496
+ // One chunk of a parked replay bundle. Case ids are arbitrary user
4497
+ // strings, so they travel in params rather than a URL path.
4498
+ replayChunk: async (params) => {
4499
+ const caseId = requireString(params, "caseId", "caseId");
4500
+ const gz = REPLAY_BUNDLES.get(caseId);
4501
+ if (!gz)
4502
+ throw notFound(`no replay bundle for case: ${caseId}`);
4503
+ return replayChunk(gz, params.offset, params.limit);
4504
+ },
4505
+ };
5072
4506
  }
5073
- main().catch((err) => {
5074
- // eslint-disable-next-line no-console
5075
- console.error("spectest-daemon: fatal:", err);
5076
- process.exit(1);
4507
+ /**
4508
+ * The method table for this process.
4509
+ *
4510
+ * Built once, so the mutual-exclusion slots are process-wide: `run` refuses
4511
+ * a second case while one is in flight, and that guarantee has to hold for
4512
+ * the process, not per caller. Two tables would each enforce the rule and
4513
+ * together break it — which is why this is a value and not a factory the
4514
+ * caller invokes.
4515
+ *
4516
+ * The harness (`sdk/src/harness/main.ts`) serves this over frames. It used
4517
+ * to be served over HTTP as well, by a listener in this file; that was the
4518
+ * in-VM daemon, and it is gone.
4519
+ */
4520
+ export const METHODS = harnessMethods({
4521
+ inFlightTest: null,
4522
+ inFlightBootstrap: null,
4523
+ inFlightProjectSetup: null,
5077
4524
  });