@specific.dev/spectest 0.131.18 → 0.131.20

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.
@@ -2,10 +2,13 @@ import { expect, test } from "bun:test";
2
2
  import { chromium } from "playwright-core";
3
3
  import { awaitBrowserSurface, captureBrowserScreenshot, reduceBrowserSurfaces } from "./browser-surface.js";
4
4
 
5
- const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ?? undefined;
6
- for (const mobile of [false, true]) {
7
- test.skipIf(!executablePath)(`reduced surface preserves ${mobile ? "mobile" : "desktop"} behavior and screenshot pixels`, async () => {
8
- const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
5
+ const engines = {
6
+ chromium: Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"),
7
+ shell: Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"),
8
+ };
9
+ for (const [engine, executablePath] of Object.entries(engines)) for (const mobile of [false, true]) {
10
+ test.skipIf(!executablePath)(`${engine}: reduced surface preserves ${mobile ? "mobile" : "desktop"} behavior and screenshot pixels`, async () => {
11
+ const browser = await chromium.launch({ executablePath: executablePath!, headless: true, args: ["--no-sandbox"] });
9
12
  const width = mobile ? 393 : 1280, height = mobile ? 852 : 720, dpr = mobile ? 3 : 1;
10
13
  try {
11
14
  const context = await browser.newContext({ viewport: { width, height }, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: dpr });
@@ -21,7 +21,7 @@ async function settings(browser: Browser): Promise<Settings> {
21
21
  return readRaw(await browser.evaluate<Settings>("read initial regional settings", "window.settings")) as unknown as Settings;
22
22
  }
23
23
 
24
- describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("browser timezone and locale", () => {
24
+ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser timezone and locale", () => {
25
25
  let server: ReturnType<typeof Bun.serve>;
26
26
  beforeAll(() => {
27
27
  server = Bun.serve({
package/src/browser.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Headless browser handle for tests. Thin wrapper around playwright-core
2
- // driving the guest's system Chromium (pipe transport — `chromium.launch`
2
+ // driving the guest's Headless Shell (pipe transport — `chromium.launch`
3
3
  // with an explicit `executablePath`). Electron attaches to the app-owned
4
4
  // Chromium over CDP instead; its client also lives inside the VM. The wrapper
5
5
  // keeps the public surface stable (we swapped from Bun.WebView to Playwright without
@@ -463,7 +463,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
463
463
  // Shared Chromium (playwright-core)
464
464
  // ────────────────────────────────────────────────────────────────────────
465
465
 
466
- // Default sessions share one Chromium per daemon, launched lazily and kept
466
+ // Browser and mobile sessions share one Headless Shell per daemon, launched lazily and kept
467
467
  // for the daemon's lifetime. Fixture sessions own a process because the audio
468
468
  // source is a process-wide flag. Both ride snapshots: the process, playwright's
469
469
  // pipe connection to it, and every open page freeze into the VM snapshot
@@ -481,19 +481,18 @@ const VIEW_CLEANUPS = new WeakMap<BrowserContext, () => Promise<void>>();
481
481
  // the page only — cookies survived and the next session was still
482
482
  // authenticated.
483
483
 
484
- function chromiumPath(): string {
485
- return (
486
- Bun.which("chromium") ??
487
- Bun.which("chromium-browser") ??
488
- Bun.which("google-chrome") ??
489
- "/usr/bin/chromium"
490
- );
484
+ function browserExecutable(): string {
485
+ const executable = Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell");
486
+ if (!executable) {
487
+ throw new Error("Chromium Headless Shell is required by this SDK but is missing from the runtime image. Rebuild the environment with a current Spectest golden image; for local development, install chromium-headless-shell on PATH.");
488
+ }
489
+ return executable;
491
490
  }
492
491
 
493
492
  async function ensurePlaywrightBrowser(): Promise<PlaywrightBrowser> {
494
493
  if (PW_BROWSER?.isConnected()) return PW_BROWSER;
495
494
  PW_BROWSER = await chromium.launch({
496
- executablePath: chromiumPath(),
495
+ executablePath: browserExecutable(),
497
496
  headless: true,
498
497
  args: CHROME_ARGV,
499
498
  });
@@ -531,7 +530,7 @@ async function newViewContext(
531
530
  let browser: PlaywrightBrowser | undefined;
532
531
  try {
533
532
  browser = fixture ? await chromium.launch({
534
- executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
533
+ executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
535
534
  }) : await ensurePlaywrightBrowser();
536
535
  const context = await browser.newContext({
537
536
  ...(device
package/src/daemon.ts CHANGED
@@ -131,7 +131,7 @@ import {
131
131
  runInstrumented,
132
132
  runUninstrumented,
133
133
  } from "./harness/instrumentation-scope.js";
134
- import { encodeRegistry } from "./harness/names-registry.js";
134
+ import { ServiceDns, ServiceOperations, type NameTarget } from "./harness/service-dns.js";
135
135
  import {
136
136
  InterceptRegistry,
137
137
  parseTarget,
@@ -164,7 +164,6 @@ import {
164
164
  clearTables,
165
165
  emptyTables,
166
166
  planBind,
167
- registryTarget,
168
167
  routesFor,
169
168
  unbindRoute,
170
169
  type Route as IngressRoute,
@@ -1543,7 +1542,9 @@ async function runContainer(
1543
1542
  extraAliases: string[] = [],
1544
1543
  ): Promise<void> {
1545
1544
  // Preserve the previous incarnation before the name is reused.
1546
- if (RUNTIME_SERVICES.has(svc.name)) await stopRuntimeService(svc.name);
1545
+ if (RUNTIME_SERVICES.has(svc.name)) await stopRuntimeServiceUnlocked(svc.name);
1546
+ PROXY_IP_CACHE.delete(svc.name);
1547
+ await SERVICE_DNS.service(svc.name, null, [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases]);
1547
1548
  STOPPED_LOG_SERVICES.delete(svc.name);
1548
1549
  LOG_MARKERS.delete(`${svc.name}\0stdout`);
1549
1550
  LOG_MARKERS.delete(`${svc.name}\0stderr`);
@@ -1572,6 +1573,10 @@ async function runContainer(
1572
1573
  `docker run ${svc.name} failed: ${r.stderr.trim() || r.stdout.trim()}`,
1573
1574
  );
1574
1575
  }
1576
+ const ip = await serviceContainerIp(svc.name);
1577
+ if (!ip) throw new Error(`Service ${svc.name} has no IP on ${NETWORK_NAME} after docker run`);
1578
+ await SERVICE_DNS.service(svc.name, ip);
1579
+ PROXY_IP_CACHE.set(svc.name, ip);
1575
1580
  }
1576
1581
 
1577
1582
  /** What a readiness probe saw. `detail` is what the plain boolean used to
@@ -1762,15 +1767,13 @@ async function withContainerPostMortem(name: string, err: unknown): Promise<unkn
1762
1767
  // same root CA, so `ctx.browser()` and peer services trust it.
1763
1768
  //
1764
1769
  // Both listeners bind on 0.0.0.0 so containers reach them via the
1765
- // bridge gateway IP (also written into /run/spectest-fakes.json for
1770
+ // bridge gateway IP (also published in the DNS registry for
1766
1771
  // spectest-resolver and injected as --add-host on every container).
1767
1772
  //
1768
1773
  // Per-fake `state` is plain JS memory and lives across snapshot/fork
1769
1774
  // along with the rest of the daemon — every fork sees its own copy.
1770
1775
  // ────────────────────────────────────────────────────────────────────────
1771
1776
 
1772
- const FAKES_REGISTRY_PATH =
1773
- process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
1774
1777
  const DEFAULT_FAKE_PORT = 80;
1775
1778
 
1776
1779
  /** Runtime record for one configured fake. */
@@ -1971,9 +1974,7 @@ async function startIngress(): Promise<void> {
1971
1974
  LOWERED.wildcards.length > 0;
1972
1975
  if (!hasIngress) {
1973
1976
  // Make sure the resolver doesn't see stale entries from a prior project.
1974
- REGISTRY.hosts = {};
1975
- REGISTRY.wildcards = [];
1976
- await writeRegistry();
1977
+ await seedNamesRegistry();
1977
1978
  return;
1978
1979
  }
1979
1980
  const gw = await bridgeGatewayIp();
@@ -2061,9 +2062,9 @@ async function startIngress(): Promise<void> {
2061
2062
 
2062
2063
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
2063
2064
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
2064
- // wildcards. Service-targeted wildcards wait for the post-container pass
2065
- // (their containers aren't up yet). Dynamic ctx.dnsName calls extend this.
2066
- await seedNamesRegistry({ servicesUp: false });
2065
+ // wildcards. Service-targeted names are reserved until runContainer
2066
+ // publishes their IPs. Dynamic ctx.dnsName calls extend this.
2067
+ await seedNamesRegistry();
2067
2068
  }
2068
2069
 
2069
2070
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2199,7 +2200,11 @@ function certCovers(hostname: string): boolean {
2199
2200
  * per-test snapshot exactly like fake state — a `dependsOn` child inherits
2200
2201
  * it, siblings forked from an earlier snapshot never see it.
2201
2202
  */
2202
- async function bindRuntimeTls(hostname: string, service: string, port: number, http2?: boolean, cors?: CorsMode): Promise<void> {
2203
+ function bindRuntimeTls(hostname: string, service: string, port: number, http2?: boolean, cors?: CorsMode): Promise<void> {
2204
+ return TLS_OPERATIONS.run(hostname.toLowerCase(), () => bindRuntimeTlsUnlocked(hostname, service, port, http2, cors));
2205
+ }
2206
+
2207
+ async function bindRuntimeTlsUnlocked(hostname: string, service: string, port: number, http2?: boolean, cors?: CorsMode): Promise<void> {
2203
2208
  // Reuse the boot primitives for validation + lowercasing; throws on a
2204
2209
  // malformed hostname / upstream just like a boot `tls` would at load.
2205
2210
  const decl = makeProxyDecl(hostname, { service, port, http2, cors });
@@ -2238,14 +2243,7 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
2238
2243
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
2239
2244
  // A wildcard can only live in the resolver's suffix table.
2240
2245
  const gw = await bridgeGatewayIp();
2241
- const target = registryTarget(host);
2242
- if (target.wildcard) {
2243
- REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
2244
- REGISTRY.wildcards.push({ suffix: target.suffix, ip: gw });
2245
- } else {
2246
- REGISTRY.hosts[target.host] = gw;
2247
- }
2248
- await writeRegistry();
2246
+ await SERVICE_DNS.bind(host, { ip: gw }, `tls:${service}`);
2249
2247
  // eslint-disable-next-line no-console
2250
2248
  console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
2251
2249
  }
@@ -2256,18 +2254,16 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
2256
2254
  * left in the SNI table — harmless without a route, and removing it would
2257
2255
  * mean an avoidable :443 rebind.
2258
2256
  */
2259
- async function unbindRuntimeTls(hostname: string): Promise<void> {
2257
+ function unbindRuntimeTls(hostname: string, service: string): Promise<void> {
2258
+ return TLS_OPERATIONS.run(hostname.toLowerCase(), () => unbindRuntimeTlsUnlocked(hostname, service));
2259
+ }
2260
+
2261
+ async function unbindRuntimeTlsUnlocked(hostname: string, service: string): Promise<void> {
2260
2262
  const host = hostname.toLowerCase();
2261
- unbindRoute(INGRESS, host);
2262
- const target = registryTarget(host);
2263
- if (target.wildcard) {
2264
- const before = REGISTRY.wildcards.length;
2265
- REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
2266
- if (REGISTRY.wildcards.length !== before) await writeRegistry();
2267
- } else if (target.host in REGISTRY.hosts) {
2268
- delete REGISTRY.hosts[target.host];
2269
- await writeRegistry();
2270
- }
2263
+ // A newer service may have taken this route/name. Do not remove its route.
2264
+ const route = INGRESS.routesByPort.get(INGRESS_HTTP_PORT)?.get(host);
2265
+ if (route?.kind === "proxy" && route.service === service) unbindRoute(INGRESS, host);
2266
+ await SERVICE_DNS.retire(host, `tls:${service}`);
2271
2267
  }
2272
2268
 
2273
2269
  /** Concurrent runtime routes must share the asynchronous first bind. */
@@ -2559,34 +2555,11 @@ async function proxyToService(
2559
2555
  );
2560
2556
  }
2561
2557
 
2562
- /**
2563
- * In-memory names registry, serialised to FAKES_REGISTRY_PATH for the
2564
- * resolver. `startIngress` seeds it from LOWERED (the static
2565
- * tls/hostnames/fakes/wildcard decls); `registerDnsName` mutates it live
2566
- * when a test calls `ctx.dnsName`. It lives in daemon memory, so it forks
2567
- * with the rest of the snapshot — a test's dynamic registration is
2568
- * isolated to its own fork, exactly like fake state.
2569
- */
2570
- const REGISTRY: {
2571
- hosts: Record<string, string>;
2572
- wildcards: Array<{ suffix: string; ip: string }>;
2573
- } = { hosts: {}, wildcards: [] };
2574
-
2575
- async function writeRegistry(): Promise<void> {
2576
- // Format + lookup semantics live in `harness/names-registry.ts`, shared
2577
- // with the resolver that reads this file.
2578
- const body = encodeRegistry(REGISTRY, Date.now());
2579
- try {
2580
- await fs.mkdir(path.dirname(FAKES_REGISTRY_PATH), { recursive: true });
2581
- await fs.writeFile(FAKES_REGISTRY_PATH, body);
2582
- } catch (err) {
2583
- // Resolver gracefully degrades; just log.
2584
- // eslint-disable-next-line no-console
2585
- console.warn(`[names] failed to write registry at ${FAKES_REGISTRY_PATH}:`, err);
2586
- }
2587
- }
2558
+ // The SDK owns desired DNS state; the platform owns serving and persistence.
2559
+ const SERVICE_DNS = new ServiceDns();
2560
+ const SERVICE_OPERATIONS = new ServiceOperations();
2561
+ const TLS_OPERATIONS = new ServiceOperations();
2588
2562
 
2589
- /** `*.example.com` → `.example.com` — the suffix the resolver matches. */
2590
2563
  /** A service container's IP on spectest-net. `null` if the container isn't
2591
2564
  * up or isn't attached to the network yet. */
2592
2565
  async function serviceContainerIp(name: string): Promise<string | null> {
@@ -2631,7 +2604,7 @@ async function proxyUpstreamHost(service: string): Promise<string> {
2631
2604
  * → that container's IP. Throws if a service target has no IP yet. */
2632
2605
  async function resolveDnsTarget(target: DnsTarget): Promise<string> {
2633
2606
  if ("ingress" in target) return bridgeGatewayIp();
2634
- const ip = await serviceContainerIp(target.service);
2607
+ const ip = SERVICE_DNS.address(target.service);
2635
2608
  if (!ip) {
2636
2609
  throw new Error(
2637
2610
  `dnsName target service ${JSON.stringify(target.service)} has no IP on ${NETWORK_NAME} (is it a running service?)`,
@@ -2640,37 +2613,19 @@ async function resolveDnsTarget(target: DnsTarget): Promise<string> {
2640
2613
  return ip;
2641
2614
  }
2642
2615
 
2643
- /**
2644
- * Seed REGISTRY from the static lowered decls and write it. Run twice per
2645
- * /bootstrap: once from startIngress (`servicesUp: false`) so ingress
2646
- * hostnames answer during container startup, then once after every
2647
- * container is up (`servicesUp: true`) so service-targeted wildcards (e.g.
2648
- * k3s `ingressDomains`) can resolve their container IPs. The post-container
2649
- * pass is what's captured into the warm template.
2650
- */
2651
- async function seedNamesRegistry(opts: { servicesUp: boolean }): Promise<void> {
2616
+ /** Reserve all declared names before containers start. Each runContainer
2617
+ * publishes its IP before readiness/setup; there is no destructive final reseed. */
2618
+ async function seedNamesRegistry(): Promise<void> {
2652
2619
  const gw = await bridgeGatewayIp();
2653
- REGISTRY.hosts = {};
2654
- REGISTRY.wildcards = [];
2655
- for (const h of LOWERED.ingressHosts) REGISTRY.hosts[h] = gw;
2656
- for (const w of LOWERED.wildcards) {
2657
- if ("ingress" in w.target) {
2658
- REGISTRY.wildcards.push({ suffix: wildcardSuffix(w.pattern), ip: gw });
2659
- continue;
2660
- }
2661
- // Service target — only resolvable once the container has an IP.
2662
- if (!opts.servicesUp) continue;
2663
- const ip = await serviceContainerIp(w.target.service);
2664
- if (ip) {
2665
- REGISTRY.wildcards.push({ suffix: wildcardSuffix(w.pattern), ip });
2666
- } else {
2667
- // eslint-disable-next-line no-console
2668
- console.warn(
2669
- `[names] wildcard ${w.pattern}: service ${JSON.stringify(w.target.service)} has no IP on ${NETWORK_NAME}`,
2670
- );
2671
- }
2672
- }
2673
- await writeRegistry();
2620
+ const services = namedServices(requireLoaded().project.environment).map(svc => ({
2621
+ name: svc.name, aliases: LOWERED.aliasesByService[svc.name] ?? [],
2622
+ }));
2623
+ const bindings: Array<{ name: string; target: NameTarget }> = [
2624
+ ...LOWERED.ingressHosts.map(name => ({ name, target: { ip: gw } })),
2625
+ ...LOWERED.wildcards.map(w => ({ name: w.pattern,
2626
+ target: "ingress" in w.target ? { ip: gw } : { service: w.target.service } })),
2627
+ ];
2628
+ await SERVICE_DNS.reset(services, bindings);
2674
2629
  }
2675
2630
 
2676
2631
  /**
@@ -2862,9 +2817,8 @@ function registerInterceptor(
2862
2817
  * Register a hostname at runtime — the implementation behind `ctx.dnsName`.
2863
2818
  * Validates via the same `dnsName` primitive the static path uses, resolves
2864
2819
  * the target to an IP, and updates + persists the registry. Exact names go
2865
- * in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver re-reads on
2866
- * the next query (it watches the file's mtime), so the name is live
2867
- * immediately — answered for VM-host/test/browser code and for peer
2820
+ * in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver acknowledges
2821
+ * activation before this returns, so the name is live immediately — answered for VM-host/test/browser code and for peer
2868
2822
  * containers (Docker forwards unknown names to the host resolver). It does
2869
2823
  * NOT land in any container's /etc/hosts.
2870
2824
  */
@@ -2873,14 +2827,7 @@ async function registerDnsName(hostname: string, target: DnsTarget): Promise<voi
2873
2827
  // Reuse the primitive purely for validation + lowercasing.
2874
2828
  const decl = makeDnsDecl(hostname, target);
2875
2829
  const ip = await resolveDnsTarget(target);
2876
- if (isWildcard(decl.hostname)) {
2877
- const suffix = wildcardSuffix(decl.hostname);
2878
- REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
2879
- REGISTRY.wildcards.push({ suffix, ip });
2880
- } else {
2881
- REGISTRY.hosts[decl.hostname] = ip;
2882
- }
2883
- await writeRegistry();
2830
+ await SERVICE_DNS.bind(decl.hostname, "service" in target ? { service: target.service } : { ip });
2884
2831
  recordEnv({ op: "dnsName", hostname: decl.hostname, ip, durationMs: 0 }, resv);
2885
2832
  }
2886
2833
 
@@ -2952,7 +2899,11 @@ function specToNamedService(spec: RuntimeServiceSpec): NamedService {
2952
2899
  * Prepares the image (pulling on first use through the host cache), runs
2953
2900
  * the container on spectest-net, and waits for its readyCheck. Returns the
2954
2901
  * container's name + IP. */
2955
- async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle> {
2902
+ function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle> {
2903
+ return SERVICE_OPERATIONS.run(spec.name, () => startRuntimeServiceUnlocked(spec));
2904
+ }
2905
+
2906
+ async function startRuntimeServiceUnlocked(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle> {
2956
2907
  if (!spec.name || spec.name.length === 0) {
2957
2908
  throw new Error("startService: `name` is required");
2958
2909
  }
@@ -2971,14 +2922,14 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2971
2922
  ...(await ensureCoverage(svc)),
2972
2923
  ];
2973
2924
  await runContainer(svc, tag, flags, aliases);
2925
+ RUNTIME_SERVICES.set(svc.name, svc);
2974
2926
  await waitForReady(svc);
2975
- const ip = (await serviceContainerIp(svc.name)) ?? "";
2927
+ const ip = SERVICE_DNS.address(svc.name)!;
2976
2928
  // `tls` is the runtime twin of a boot service's: stand up a
2977
2929
  // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2978
2930
  for (const entry of svc.tls ?? []) {
2979
2931
  await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2, entry.cors);
2980
2932
  }
2981
- RUNTIME_SERVICES.set(svc.name, svc);
2982
2933
  recordEnv({
2983
2934
  op: "startService",
2984
2935
  service: svc.name,
@@ -3001,10 +2952,16 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
3001
2952
 
3002
2953
  /** Implementation behind `ctx.stopService`. Removes the container and drops
3003
2954
  * it from the runtime registry. No-op (rc ignored) if it's already gone. */
3004
- async function stopRuntimeService(name: string): Promise<void> {
2955
+ function stopRuntimeService(name: string): Promise<void> {
2956
+ return SERVICE_OPERATIONS.run(name, () => stopRuntimeServiceUnlocked(name));
2957
+ }
2958
+
2959
+ async function stopRuntimeServiceUnlocked(name: string): Promise<void> {
3005
2960
  const t0 = Date.now();
3006
2961
  const resv = reserveEvent();
3007
2962
  const svc = RUNTIME_SERVICES.get(name);
2963
+ await SERVICE_DNS.service(name, null);
2964
+ PROXY_IP_CACHE.delete(name);
3008
2965
  // Freeze log production before reading, while Docker still owns the logs.
3009
2966
  // Keep force-stop semantics; removing first destroys the only copy.
3010
2967
  await docker(["kill", name], 30_000);
@@ -3016,7 +2973,7 @@ async function stopRuntimeService(name: string): Promise<void> {
3016
2973
  }
3017
2974
  await docker(["rm", "-f", name], 30_000);
3018
2975
  RUNTIME_SERVICES.delete(name);
3019
- for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname);
2976
+ for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname, name);
3020
2977
  recordEnv({ op: "stopService", service: name, durationMs: Date.now() - t0 }, resv);
3021
2978
  }
3022
2979
 
@@ -3329,11 +3286,6 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3329
3286
 
3330
3287
  await ensureStickyBuildHeadroom();
3331
3288
 
3332
- // Containers now have IPs — re-seed so service-targeted wildcards (e.g.
3333
- // k3s ingressDomains → the cluster container) resolve. Captured into the
3334
- // warm template, so warm starts inherit the resolved entries.
3335
- await seedNamesRegistry({ servicesUp: true });
3336
-
3337
3289
  // Browser pre-warm DISABLED (2026-06-08). We used to pre-open one view
3338
3290
  // into the pool (browser.ts VIEW_POOL) here so every fork inherited a
3339
3291
  // live renderer and the first ctx.browser() skipped the ~1.2-1.5s spawn.
@@ -0,0 +1,121 @@
1
+ /** Platform/SDK DNS control protocol. The platform persists and activates each
2
+ * complete registry before acknowledging it. No project code runs in DNS. */
3
+ import { isIPv4 } from "node:net";
4
+ import { createServer, request } from "node:http";
5
+ import { mkdir, readFile, rename, rm, writeFile, chmod } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+
8
+ export const DNS_SOCKET = process.env.SPECTEST_DNS_SOCKET ?? "/run/spectest-dns.sock";
9
+ export const DNS_REGISTRY_PATH = process.env.SPECTEST_DNS_REGISTRY ?? "/run/spectest-dns.json";
10
+ export const MAX_REGISTRY_BYTES = 4 * 1024 * 1024;
11
+ export interface DnsRegistry {
12
+ version: 1;
13
+ /** Opaque transaction ID, echoed in the acknowledgement. */
14
+ generation: string;
15
+ /** null reserves an unavailable name: it must never escape upstream. */
16
+ hosts: Record<string, string | null>;
17
+ wildcards: Array<{ suffix: string; ip: string | null }>;
18
+ }
19
+
20
+ function validName(name: string): boolean {
21
+ return name.length > 0 && name.length <= 253 && name === name.toLowerCase() &&
22
+ name.split(".").every(label => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(label));
23
+ }
24
+ export function parseDnsRegistry(text: string): DnsRegistry {
25
+ if (Buffer.byteLength(text) > MAX_REGISTRY_BYTES) throw new Error("DNS registry too large");
26
+ const doc = JSON.parse(text);
27
+ if (!doc || doc.version !== 1 || typeof doc.generation !== "string" || !doc.generation.length || doc.generation.length > 128 ||
28
+ !doc.hosts || typeof doc.hosts !== "object" || Array.isArray(doc.hosts) || !Array.isArray(doc.wildcards))
29
+ throw new Error("Unsupported or malformed DNS registry (expected version 1)");
30
+ const address = (ip: unknown) => ip === null || (typeof ip === "string" && isIPv4(ip));
31
+ for (const [name, ip] of Object.entries(doc.hosts))
32
+ if (!validName(name) || !address(ip)) throw new Error(`Invalid DNS host: ${name}`);
33
+ const suffixes = new Set<string>();
34
+ for (const w of doc.wildcards) {
35
+ if (!w || typeof w.suffix !== "string" || !w.suffix.startsWith(".") || !validName(w.suffix.slice(1)) || !address(w.ip) || suffixes.has(w.suffix))
36
+ throw new Error("Invalid or duplicate DNS wildcard");
37
+ suffixes.add(w.suffix);
38
+ }
39
+ return { version: 1, generation: doc.generation, hosts: doc.hosts, wildcards: doc.wildcards };
40
+ }
41
+
42
+ /** Compile once per update. undefined = unknown; null = owned but unavailable. */
43
+ export function compileDnsRegistry(doc: DnsRegistry): (name: string) => string | null | undefined {
44
+ const hosts = new Map(Object.entries(doc.hosts));
45
+ const wildcards = new Map(doc.wildcards.map(w => [w.suffix, w.ip]));
46
+ return name => {
47
+ if (hosts.has(name)) return hosts.get(name);
48
+ for (let i = name.indexOf("."); i !== -1; i = name.indexOf(".", i + 1)) {
49
+ const suffix = name.slice(i);
50
+ if (wildcards.has(suffix)) return wildcards.get(suffix);
51
+ }
52
+ return undefined;
53
+ };
54
+ }
55
+
56
+ /** Restore before binding DNS. A corrupt persisted registry is fatal, because
57
+ * silently forgetting owned domains could send fake traffic to real services. */
58
+ export async function serveDnsRegistry(activate: (doc: DnsRegistry) => void) {
59
+ try { activate(parseDnsRegistry(await readFile(DNS_REGISTRY_PATH, "utf8"))); }
60
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
61
+ await mkdir(dirname(DNS_SOCKET), { recursive: true });
62
+ await rm(DNS_SOCKET, { force: true });
63
+ let pending = Promise.resolve();
64
+ const server = createServer(async (req, res) => {
65
+ if (req.method !== "PUT" || req.url !== "/v1/registry") { res.writeHead(404).end(); return; }
66
+ try {
67
+ const chunks: Buffer[] = [];
68
+ let size = 0;
69
+ for await (const chunk of req) {
70
+ size += chunk.length;
71
+ if (size > MAX_REGISTRY_BYTES) { res.writeHead(413).end(); return; }
72
+ chunks.push(Buffer.from(chunk));
73
+ }
74
+ const doc = parseDnsRegistry(Buffer.concat(chunks).toString("utf8"));
75
+ const update = pending.then(async () => {
76
+ await mkdir(dirname(DNS_REGISTRY_PATH), { recursive: true });
77
+ const tmp = `${DNS_REGISTRY_PATH}.tmp`;
78
+ await writeFile(tmp, JSON.stringify(doc), { mode: 0o600 });
79
+ await rename(tmp, DNS_REGISTRY_PATH);
80
+ activate(doc);
81
+ });
82
+ pending = update.catch(() => {});
83
+ await update;
84
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ version: 1, generation: doc.generation }));
85
+ } catch (error) {
86
+ res.writeHead(400, { "content-type": "text/plain" }).end(String(error));
87
+ }
88
+ });
89
+ server.requestTimeout = 10_000;
90
+ await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(DNS_SOCKET, resolve); });
91
+ await chmod(DNS_SOCKET, 0o600);
92
+ return server;
93
+ }
94
+
95
+ /** Unix HTTP bypasses the SDK's instrumented fetch. A missing platform feature
96
+ * fails bootstrap explicitly; never silently switch back to discovery. */
97
+ export async function publishDnsRegistry(doc: DnsRegistry, socketPath = DNS_SOCKET): Promise<void> {
98
+ const body = JSON.stringify(doc);
99
+ parseDnsRegistry(body);
100
+ await new Promise<void>((resolve, reject) => {
101
+ const req = request({ socketPath, method: "PUT", path: "/v1/registry", agent: false,
102
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } }, res => {
103
+ let text = "";
104
+ res.setEncoding("utf8");
105
+ res.on("data", chunk => { text += chunk; });
106
+ res.on("error", reject);
107
+ res.on("end", () => {
108
+ try {
109
+ if (res.statusCode !== 200) throw new Error(`DNS update rejected (${res.statusCode}): ${text}`);
110
+ const ack = JSON.parse(text);
111
+ if (ack.version !== 1 || ack.generation !== doc.generation) throw new Error("DNS acknowledgement mismatch");
112
+ resolve();
113
+ } catch (error) { reject(error); }
114
+ });
115
+ });
116
+ const timer = setTimeout(() => req.destroy(new Error("DNS registry acknowledgement timed out")), 10_000);
117
+ req.on("close", () => clearTimeout(timer));
118
+ req.on("error", error => reject(new Error(`Cannot publish DNS registry; this SDK requires a platform with DNS registry v1: ${error.message}`)));
119
+ req.end(body);
120
+ });
121
+ }
@@ -1,22 +1,7 @@
1
1
  /**
2
- * The DNS names registry: the file the harness writes and
3
- * `spectest-resolver` reads.
4
- *
5
- * Ported out of `daemon.ts`. The point of a shared module is that this
6
- * format had **two independent implementations** — the writer here and
7
- * the reader in `resolver.ts` — with nothing keeping them in step. That
8
- * is the same shape as the `config.rs` ↔ SDK drift the harness split
9
- * exists to remove, just inside one language.
10
- *
11
- * The registry answers names that Docker's own DNS cannot:
12
- * - fake hostnames (`api.stripe.com` → the bridge gateway),
13
- * - names bound at runtime by `ctx.dnsName`,
14
- * - **wildcards** (`*.us-east-1.amazonaws.com`), which have no
15
- * `--add-host` or `--network-alias` equivalent and therefore *only*
16
- * exist here.
17
- *
18
- * Lookup is exact-first, then longest matching suffix — the same
19
- * precedence the ingress routes and the SNI cert table use.
2
+ * Legacy names-file format used by SDKs before 0.131.20. The platform retains
3
+ * its lookup semantics for pinned SDKs that do not publish a complete registry.
4
+ * New SDKs use dns-registry.ts (control protocol) and service-dns.ts (ownership).
20
5
  */
21
6
 
22
7
  export interface RegistryDoc {
@@ -0,0 +1,94 @@
1
+ import { expect, test } from "bun:test";
2
+ import { ServiceDns, ServiceOperations } from "./service-dns";
3
+ import { compileDnsRegistry, parseDnsRegistry, type DnsRegistry } from "./dns-registry";
4
+
5
+ function fixture() {
6
+ const docs: DnsRegistry[] = [];
7
+ const dns = new ServiceDns(async doc => { docs.push(parseDnsRegistry(JSON.stringify(doc))); });
8
+ return { dns, docs, lookup: (name: string) => compileDnsRegistry(docs.at(-1)!)(name) };
9
+ }
10
+
11
+ test("reserve startup names, publish before setup, retain setup registrations and follow service recreation", async () => {
12
+ const f = fixture();
13
+ await f.dns.reset([{ name: "api", aliases: ["api.example.com"] }, { name: "db", aliases: [] }], [
14
+ { name: "*.cluster.test", target: { service: "api" } },
15
+ { name: "fake.example.com", target: { ip: "10.0.0.1" } },
16
+ ]);
17
+ expect(f.lookup("api.internal")).toBeNull();
18
+ expect(f.lookup("api.example.com")).toBeNull();
19
+ expect(f.lookup("app.cluster.test")).toBeNull();
20
+ await f.dns.service("api", "10.0.0.2");
21
+ await f.dns.bind("setup.test", { service: "api" });
22
+ await f.dns.service("db", "10.0.0.3");
23
+ expect(f.lookup("setup.test")).toBe("10.0.0.2");
24
+ expect(f.lookup("app.cluster.test")).toBe("10.0.0.2");
25
+ await f.dns.service("api", null);
26
+ expect(f.lookup("setup.test")).toBeNull();
27
+ expect(f.lookup("api.example.com")).toBeNull();
28
+ await f.dns.service("api", "10.0.0.4");
29
+ expect(f.lookup("setup.test")).toBe("10.0.0.4");
30
+ expect(f.lookup("app.cluster.test")).toBe("10.0.0.4");
31
+ expect(f.lookup("fake.example.com")).toBe("10.0.0.1");
32
+ });
33
+
34
+ test("explicit names beat aliases; retiring old ownership cannot delete a replacement", async () => {
35
+ const f = fixture();
36
+ await f.dns.service("api", "10.0.0.2", ["shared.test", "old.test"]);
37
+ await f.dns.bind("shared.test", { ip: "10.0.0.1" }, "tls:api");
38
+ await f.dns.bind("shared.test", { ip: "10.0.0.3" }, "tls:other");
39
+ await f.dns.retire("shared.test", "tls:api");
40
+ expect(f.lookup("shared.test")).toBe("10.0.0.3");
41
+ await f.dns.retire("shared.test", "tls:other");
42
+ expect(f.lookup("shared.test")).toBeNull();
43
+ await f.dns.service("api", "10.0.0.4", ["new.test"]);
44
+ expect(f.lookup("old.test")).toBeNull();
45
+ expect(f.lookup("new.test")).toBe("10.0.0.4");
46
+ await f.dns.service("api", "10.0.0.5", ["old.test"]);
47
+ expect(f.lookup("old.test")).toBe("10.0.0.5");
48
+ });
49
+
50
+ test("concurrent publications wait for acknowledgement, reject failures and repair with a full view", async () => {
51
+ const docs: DnsRegistry[] = [];
52
+ let release!: () => void;
53
+ const gate = new Promise<void>(resolve => { release = resolve; });
54
+ let first = true;
55
+ const dns = new ServiceDns(async doc => {
56
+ docs.push(doc);
57
+ if (first) { first = false; await gate; throw new Error("lost acknowledgement"); }
58
+ });
59
+ const a = dns.service("a", "10.0.0.2");
60
+ const failed = a.catch(error => error);
61
+ const b = dns.service("b", "10.0.0.3");
62
+ await Bun.sleep(0);
63
+ expect(docs).toHaveLength(1);
64
+ release(); expect((await failed).message).toBe("lost acknowledgement"); await b;
65
+ expect(docs).toHaveLength(2);
66
+ expect(docs[1].hosts).toMatchObject({ a: "10.0.0.2", b: "10.0.0.3" });
67
+ expect(docs[0].hosts.b).toBeUndefined();
68
+ expect(docs[1].generation).not.toBe(docs[0].generation);
69
+ });
70
+
71
+ test("same-service operations serialize through failure without blocking independent services", async () => {
72
+ const ops = new ServiceOperations();
73
+ const events: string[] = [];
74
+ let release!: () => void;
75
+ const gate = new Promise<void>(resolve => { release = resolve; });
76
+ const first = ops.run("api", async () => { events.push("start"); await gate; throw new Error("failed start"); });
77
+ const failed = first.catch(error => error);
78
+ const stopped = ops.run("api", async () => { events.push("stop"); });
79
+ await ops.run("db", async () => { events.push("db"); });
80
+ expect(events).toEqual(["start", "db"]);
81
+ release(); expect((await failed).message).toBe("failed start"); await stopped;
82
+ expect(events).toEqual(["start", "db", "stop"]);
83
+ });
84
+
85
+
86
+ test("invalid desired state cannot poison later unrelated updates", async () => {
87
+ const f = fixture();
88
+ await f.dns.service("api", "10.0.0.2");
89
+ await expect(f.dns.service("invalid name", "10.0.0.3")).rejects.toThrow("Invalid DNS host");
90
+ await f.dns.service("db", "10.0.0.4");
91
+ expect(f.lookup("api")).toBe("10.0.0.2");
92
+ expect(f.lookup("db")).toBe("10.0.0.4");
93
+ expect(f.docs.at(-1)?.hosts["invalid name"]).toBeUndefined();
94
+ });