@specific.dev/spectest 0.74.0 → 0.76.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.
package/dist/daemon.js CHANGED
@@ -35,7 +35,7 @@ import { isMobileApp, openPersistentMobile } from "./mobile.js";
35
35
  import { buildArgFlags, buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
36
36
  import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
37
37
  import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
38
- import { executedSteps, summarizeBuildKit } from "./harness/buildkit-progress.js";
38
+ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
39
39
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
40
40
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
41
41
  import { pollUntilReady } from "./harness/ready-poll.js";
@@ -507,23 +507,14 @@ async function ensureLocalBuildkitd() {
507
507
  }
508
508
  // eslint-disable-next-line no-console
509
509
  console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
510
- const union = await ensureBuildkitCacheUnion(paths);
511
- if (union) {
512
- // One directory for both flags: the merged cache on the layers disk
513
- // seen through this lineage's own exports on the root. See
514
- // `BUILDKIT_CACHE_UNION` for why the exporter needs the union.
515
- _localCacheDir = union;
516
- _localCacheImports = [union];
517
- }
518
- else {
519
- // Imports come from the merged cache on the read-only layers disk and
520
- // from this lineage's own exports on the root; exports go to the root,
521
- // where the merge picks them up (image_cache/merge.rs). Every cold
522
- // build re-exports every blob this way, which is the cost the union
523
- // removes.
524
- _localCacheDir = `${state}/${BUILDKIT_CACHE_DIR}`;
525
- _localCacheImports = [`${paths.layers}/${BUILDKIT_CACHE_DIR}`, `${state}/${BUILDKIT_CACHE_DIR}`];
526
- }
510
+ // The cache directory union is for the project's OWN BuildKit (the
511
+ // `/services` docs page tells it to mount `BUILDKIT_CACHE_UNION`).
512
+ // The harness's builds no longer touch it: their cache is the
513
+ // daemon's own state, which lives on the root disk and rides the
514
+ // base root into the next cold build (CONTAINER_STORE.md §20), so a
515
+ // hit is a snapshot that already exists — no tar blob to unpack
516
+ // before the first uncached step, no export pass after a miss.
517
+ await ensureBuildkitCacheUnion(paths);
527
518
  _localBuilder = true;
528
519
  return true;
529
520
  }
@@ -589,11 +580,6 @@ async function ensureBuildkitCacheUnion(paths) {
589
580
  }
590
581
  return BUILDKIT_CACHE_UNION;
591
582
  }
592
- /** The exported-cache directory on the cache disk, once the in-VM builder is up. */
593
- let _localCacheDir = null;
594
- /** The cache directories a build imports from: the merged one on the
595
- * layers disk, then this lineage's own exports. */
596
- let _localCacheImports = [];
597
583
  /**
598
584
  * A reference as containerd names it. buildx's `-t` on a remote builder
599
585
  * stores an unqualified name (`probe:bx`) that dockerd then cannot
@@ -1092,54 +1078,31 @@ async function runServiceBuild(name, image, tag) {
1092
1078
  const useBuildKit = useLocal || (await hasBuildx());
1093
1079
  const buildEnv = {};
1094
1080
  let buildArgs;
1095
- // The `--cache-to` flag, held back for a second pass that runs only
1096
- // when the first one executed a step (see below). Null off the in-VM
1097
- // builder.
1098
- let cacheExport = null;
1099
1081
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1100
- // so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
1082
+ // so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
1101
1083
  const argFlags = buildArgFlags(image.buildArgs);
1102
- if (useLocal && _localCacheDir) {
1084
+ if (useLocal) {
1103
1085
  // The in-VM builder is BuildKit's containerd worker on this VM's
1104
1086
  // own image store (CONTAINER_STORE.md): the output is an image
1105
1087
  // record in dockerd's namespace, unpacked, so there is no `--load`
1106
- // and nothing crosses a socket. The cache directory on the image cache
1107
- // disk is what outlives the VM; `mode=max` keeps every
1108
- // intermediate layer, uncompressed so an import never inflates,
1109
- // and one tag per service so exports do not replace each other.
1110
- const cacheTag = name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1111
- cacheExport = [
1112
- "--cache-to",
1113
- `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
1114
- ];
1115
- // LANDMINE: the local cache importer reads the `latest` entry of
1116
- // the directory's index unless told otherwise, and the exporter
1117
- // below writes this service's entry under `tag=<service>`. An
1118
- // import without the same tag misses every time and every
1119
- // `RUN` re-executes on a seeded cache (seen on the first deploy,
1120
- // 2026-09-05: the disk carried the blobs, the build used none).
1088
+ // and nothing crosses a socket. No `--cache-from` and no
1089
+ // `--cache-to` either, since 2026-09-07 (§20): the daemon's own
1090
+ // cache database is on the same disk as the snapshots it names
1091
+ // and rides the base root into the next cold build, so a cached
1092
+ // step is a snapshot that already exists. The exported directory
1093
+ // did the same job through tar blobs, at the price of unpacking
1094
+ // every cached parent before the first uncached step (11–79 s for
1095
+ // one `COPY` on Specific's dashboard) and a second full pass to
1096
+ // export after every miss.
1121
1097
  buildArgs = [
1122
1098
  "buildx", "build",
1123
1099
  "--builder", LOCAL_BUILDER_NAME,
1124
1100
  "--progress=plain",
1125
1101
  "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1126
- ..._localCacheImports.flatMap((src) => ["--cache-from", `type=local,src=${src},tag=${cacheTag}`]),
1127
1102
  ...argFlags,
1128
1103
  "-f", dfPath, WORKSPACE,
1129
1104
  ];
1130
1105
  }
1131
- else if (useLocal) {
1132
- // The daemon came up but reported no cache directory: build on it
1133
- // and `--load` the result into dockerd.
1134
- buildArgs = [
1135
- "buildx", "build",
1136
- "--builder", LOCAL_BUILDER_NAME,
1137
- "--load",
1138
- "--progress=plain",
1139
- ...argFlags,
1140
- "-t", tag, "-f", dfPath, WORKSPACE,
1141
- ];
1142
- }
1143
1106
  else if (useBuildKit) {
1144
1107
  buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1145
1108
  buildEnv.DOCKER_BUILDKIT = "1";
@@ -1174,27 +1137,6 @@ async function runServiceBuild(name, image, tag) {
1174
1137
  if (build.code !== 0) {
1175
1138
  return { ok: false, log };
1176
1139
  }
1177
- // Export the cache only when there is something new to export. A
1178
- // `mode=max` export costs ~3 s per service even when every blob is
1179
- // already in the destination (measured 2026-09-06, api: 2.2–3.0 s
1180
- // for the cached build alone, 4.9–7.5 s with the export), and a cold
1181
- // start of a cached project is nothing but such builds. When a step
1182
- // did run, the second pass is that same build fully cached from the
1183
- // daemon's own state plus the export — seconds on top of a build
1184
- // that took tens of them. Best-effort: the image is already in the
1185
- // store, so a failed export is a slower next cold start, not a
1186
- // failed service.
1187
- if (cacheExport) {
1188
- const ran = executedSteps(build.stderr);
1189
- if (ran.length > 0) {
1190
- progressService(name, { status: "building", detail: `exporting cache (${ran.length} step(s) ran)` });
1191
- const exported = await shxStream("docker", [...buildArgs, ...cacheExport], 1_800_000, buildEnv, () => { });
1192
- if (exported.code !== 0) {
1193
- // eslint-disable-next-line no-console
1194
- console.warn(`[build] ${name}: cache export failed; the next cold start rebuilds it:\n${exported.stderr.trim().slice(-2000)}`);
1195
- }
1196
- }
1197
- }
1198
1140
  if (useBuildKit) {
1199
1141
  // Keep only the slowest dozen steps ≥1s — enough to profile, small
1200
1142
  // enough to ride back in the /bootstrap response and the journal.
@@ -3015,31 +2957,22 @@ async function bootstrapInner() {
3015
2957
  // image is pulled and whose deps are up starts immediately; it never sits
3016
2958
  // at "image ready" waiting for an unrelated slow build elsewhere.
3017
2959
  //
3018
- // Prep concurrency: registry pulls always run in parallel (network-bound,
3019
- // low VM RAM). Dockerfile builds run inside the VM, and two or more
3020
- // concurrent builds routinely OOM a single VM on monorepos with parallel
3021
- // pnpm/npm installs (each install fans out to ~16 fetchers + lifecycle
3022
- // workers, ~70 MB/process), so builds serialize behind a FIFO chain —
3023
- // but only the builds; pulls and starts run freely alongside them.
2960
+ // Prep concurrency: everything at once. Registry pulls are
2961
+ // network-bound and cheap in RAM. Dockerfile builds all go to the one
2962
+ // in-VM buildkitd, whose `max-parallelism` (all but one vCPU,
2963
+ // `base.rs::BUILDKITD_UP_SH`) caps the steps in flight across every
2964
+ // build it holds, and whose steps run under a cgroup with a memory
2965
+ // ceiling — so a fully cached build finishes at once while a miss is
2966
+ // still running, and two installs that fan out together kill a step,
2967
+ // never the VM. Until 2026-09-07 builds queued behind one FIFO chain
2968
+ // here, the harness's own guard against that OOM (each install fans
2969
+ // out to ~16 fetchers + lifecycle workers, ~70 MB/process): a service
2970
+ // whose build was entirely cached then waited the whole length of a
2971
+ // sibling's miss, 276 s for 4.5 s of work on Specific's `cli`.
3024
2972
  const tags = new Map();
3025
- const builds = services.filter((s) => s.image.type === "dockerfile");
3026
- const buildsRunHostSide = false;
3027
- // A promise chain is a fair FIFO mutex: when builds run in-VM, each build
3028
- // waits for the previous to settle. Pulls and host-side builds bypass it.
3029
- let inVmBuildChain = Promise.resolve();
3030
- const prepImage = (svc) => {
3031
- // Bootstrap is the only dedup scope: all its builds share one
3032
- // /workspace generation (see prepareServiceImage).
3033
- const run = () => prepareServiceImage(svc, { dedup: true });
3034
- if (svc.image.type === "dockerfile" && !buildsRunHostSide) {
3035
- const next = inVmBuildChain.then(run, run);
3036
- // Keep the chain moving even if a build throws; the chain itself never
3037
- // rejects (the per-service prep promise below is what surfaces errors).
3038
- inVmBuildChain = next.then(() => undefined, () => undefined);
3039
- return next;
3040
- }
3041
- return run();
3042
- };
2973
+ // Bootstrap is the only dedup scope: all its builds share one
2974
+ // /workspace generation (see prepareServiceImage).
2975
+ const prepImage = (svc) => prepareServiceImage(svc, { dedup: true });
3043
2976
  const prep = new Map();
3044
2977
  for (const svc of services) {
3045
2978
  const p = (async () => {
@@ -39,11 +39,12 @@ export declare function summarizeBuildKit(out: string): BuildStep[];
39
39
  * The Dockerfile steps a build actually ran — the ones a cache export
40
40
  * would have something new to record.
41
41
  *
42
- * A fully cached build still has to export its cache today, and that
43
- * export costs ~3 s per service in `mode=max` even when every blob is
44
- * already in the destination (measured 2026-09-06: 2.2–3.0 s for the
45
- * build, 4.9–7.5 s with the export). So the harness builds without
46
- * `--cache-to` first and exports only when this says something ran.
42
+ * Between SDK 0.75.0 and 0.76.0 this gated the harness's cache export
43
+ * (a `mode=max` export cost ~3 s per service with nothing to write,
44
+ * measured 2026-09-06). The harness exports nothing since its cache is
45
+ * the daemon's own state (CONTAINER_STORE.md §20); the reading stays,
46
+ * since it is the one machine-readable answer to "did this build run
47
+ * anything".
47
48
  *
48
49
  * What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
49
50
  * in `DONE` rather than `CACHED`. What does not: BuildKit's own
@@ -68,11 +68,12 @@ export function summarizeBuildKit(out) {
68
68
  * The Dockerfile steps a build actually ran — the ones a cache export
69
69
  * would have something new to record.
70
70
  *
71
- * A fully cached build still has to export its cache today, and that
72
- * export costs ~3 s per service in `mode=max` even when every blob is
73
- * already in the destination (measured 2026-09-06: 2.2–3.0 s for the
74
- * build, 4.9–7.5 s with the export). So the harness builds without
75
- * `--cache-to` first and exports only when this says something ran.
71
+ * Between SDK 0.75.0 and 0.76.0 this gated the harness's cache export
72
+ * (a `mode=max` export cost ~3 s per service with nothing to write,
73
+ * measured 2026-09-06). The harness exports nothing since its cache is
74
+ * the daemon's own state (CONTAINER_STORE.md §20); the reading stays,
75
+ * since it is the one machine-readable answer to "did this build run
76
+ * anything".
76
77
  *
77
78
  * What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
78
79
  * in `DONE` rather than `CACHED`. What does not: BuildKit's own
@@ -45,17 +45,35 @@ export declare const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
45
45
  * One directory that is both: an overlayfs with the layers disk's cache
46
46
  * directory as the read-only lower and the root disk's as the upper,
47
47
  * mounted here by the harness when the in-VM builder comes up
48
- * (`daemon.ts::ensureBuildkitCacheUnion`). A build imports from it and
49
- * exports to it. BuildKit's local exporter skips a blob its destination
50
- * already holds, and through the union every merged blob is already
51
- * there — so a fully cached build exports its index and manifest and
52
- * nothing else, where a fresh directory on the root cost it every
53
- * uncompressed layer again (1.1 GB and 20 s for one service, measured
54
- * 2026-09-06). New layers land in the upper, which is exactly the
55
- * directory the merge read before. A user-run BuildKit mounts this one
56
- * path for both of its cache flags.
48
+ * (`daemon.ts::ensureBuildkitCacheUnion`). It is for the PROJECT'S OWN
49
+ * BuildKit — the `/services` docs page tells it to mount this one path
50
+ * for both of its cache flags. BuildKit's local exporter skips a blob its
51
+ * destination already holds, and through the union every merged blob is
52
+ * already there, so a fully cached build exports its index and manifest
53
+ * and nothing else (a fresh directory on the root cost every
54
+ * uncompressed layer again, 1.1 GB and 20 s for one service, measured
55
+ * 2026-09-06). New layers land in the upper, which the merge reads.
56
+ *
57
+ * The harness's own service builds used it too until SDK 0.76.0; since
58
+ * then their cache is the in-VM daemon's own state, which lives on the
59
+ * root disk and rides the base root into the next cold build
60
+ * (CONTAINER_STORE.md §20).
57
61
  */
58
62
  export declare const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
63
+ /**
64
+ * The prefix the docs reserve for spectest in that directory's one flat
65
+ * tag namespace. The harness tagged its service builds under it from
66
+ * SDK 0.75.0 to 0.76.0, after Specific's `nginx-mod` service tagged its
67
+ * own `buildctl` cache `nginx-mod` and the two overwrote each other on
68
+ * alternate boots (2026-09-06); the harness writes no tags now, and the
69
+ * prefix stays reserved so nothing a project names can ever collide
70
+ * with something spectest writes there later.
71
+ */
72
+ export declare const BUILDKIT_CACHE_TAG_PREFIX = "spectest/service/";
73
+ /** The tag a service's Dockerfile build would carry under the reserved
74
+ * prefix: the service name, lowercased with anything outside `[a-z0-9-]`
75
+ * folded to `-`. Unused by the harness since SDK 0.76.0. */
76
+ export declare function buildkitCacheTag(service: string): string;
59
77
  /** The cache's paths, or `null` when this VM carries none.
60
78
  * `SPECTEST_IMAGE_CACHE_MANIFEST` points a test at another file. */
61
79
  export declare function imageCachePathsSync(manifest?: string): ImageCachePaths | null;
@@ -39,17 +39,37 @@ export const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
39
39
  * One directory that is both: an overlayfs with the layers disk's cache
40
40
  * directory as the read-only lower and the root disk's as the upper,
41
41
  * mounted here by the harness when the in-VM builder comes up
42
- * (`daemon.ts::ensureBuildkitCacheUnion`). A build imports from it and
43
- * exports to it. BuildKit's local exporter skips a blob its destination
44
- * already holds, and through the union every merged blob is already
45
- * there — so a fully cached build exports its index and manifest and
46
- * nothing else, where a fresh directory on the root cost it every
47
- * uncompressed layer again (1.1 GB and 20 s for one service, measured
48
- * 2026-09-06). New layers land in the upper, which is exactly the
49
- * directory the merge read before. A user-run BuildKit mounts this one
50
- * path for both of its cache flags.
42
+ * (`daemon.ts::ensureBuildkitCacheUnion`). It is for the PROJECT'S OWN
43
+ * BuildKit — the `/services` docs page tells it to mount this one path
44
+ * for both of its cache flags. BuildKit's local exporter skips a blob its
45
+ * destination already holds, and through the union every merged blob is
46
+ * already there, so a fully cached build exports its index and manifest
47
+ * and nothing else (a fresh directory on the root cost every
48
+ * uncompressed layer again, 1.1 GB and 20 s for one service, measured
49
+ * 2026-09-06). New layers land in the upper, which the merge reads.
50
+ *
51
+ * The harness's own service builds used it too until SDK 0.76.0; since
52
+ * then their cache is the in-VM daemon's own state, which lives on the
53
+ * root disk and rides the base root into the next cold build
54
+ * (CONTAINER_STORE.md §20).
51
55
  */
52
56
  export const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
57
+ /**
58
+ * The prefix the docs reserve for spectest in that directory's one flat
59
+ * tag namespace. The harness tagged its service builds under it from
60
+ * SDK 0.75.0 to 0.76.0, after Specific's `nginx-mod` service tagged its
61
+ * own `buildctl` cache `nginx-mod` and the two overwrote each other on
62
+ * alternate boots (2026-09-06); the harness writes no tags now, and the
63
+ * prefix stays reserved so nothing a project names can ever collide
64
+ * with something spectest writes there later.
65
+ */
66
+ export const BUILDKIT_CACHE_TAG_PREFIX = "spectest/service/";
67
+ /** The tag a service's Dockerfile build would carry under the reserved
68
+ * prefix: the service name, lowercased with anything outside `[a-z0-9-]`
69
+ * folded to `-`. Unused by the harness since SDK 0.76.0. */
70
+ export function buildkitCacheTag(service) {
71
+ return `${BUILDKIT_CACHE_TAG_PREFIX}${service.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
72
+ }
53
73
  function parse(raw) {
54
74
  const parsed = JSON.parse(raw);
55
75
  const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -83,7 +83,7 @@ import {
83
83
  type CaseMeta,
84
84
  type GroupMeta,
85
85
  } from "./harness/catalogue.js";
86
- import { executedSteps, summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
86
+ import { summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
87
87
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
88
88
  import {
89
89
  resolveHostPath as resolveVolumeHostPath,
@@ -787,22 +787,14 @@ async function ensureLocalBuildkitd(): Promise<boolean> {
787
787
  }
788
788
  // eslint-disable-next-line no-console
789
789
  console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
790
- const union = await ensureBuildkitCacheUnion(paths);
791
- if (union) {
792
- // One directory for both flags: the merged cache on the layers disk
793
- // seen through this lineage's own exports on the root. See
794
- // `BUILDKIT_CACHE_UNION` for why the exporter needs the union.
795
- _localCacheDir = union;
796
- _localCacheImports = [union];
797
- } else {
798
- // Imports come from the merged cache on the read-only layers disk and
799
- // from this lineage's own exports on the root; exports go to the root,
800
- // where the merge picks them up (image_cache/merge.rs). Every cold
801
- // build re-exports every blob this way, which is the cost the union
802
- // removes.
803
- _localCacheDir = `${state}/${BUILDKIT_CACHE_DIR}`;
804
- _localCacheImports = [`${paths.layers}/${BUILDKIT_CACHE_DIR}`, `${state}/${BUILDKIT_CACHE_DIR}`];
805
- }
790
+ // The cache directory union is for the project's OWN BuildKit (the
791
+ // `/services` docs page tells it to mount `BUILDKIT_CACHE_UNION`).
792
+ // The harness's builds no longer touch it: their cache is the
793
+ // daemon's own state, which lives on the root disk and rides the
794
+ // base root into the next cold build (CONTAINER_STORE.md §20), so a
795
+ // hit is a snapshot that already exists — no tar blob to unpack
796
+ // before the first uncached step, no export pass after a miss.
797
+ await ensureBuildkitCacheUnion(paths);
806
798
  _localBuilder = true;
807
799
  return true;
808
800
  }
@@ -874,11 +866,6 @@ async function ensureBuildkitCacheUnion(paths: ImageCachePaths): Promise<string
874
866
  }
875
867
  return BUILDKIT_CACHE_UNION;
876
868
  }
877
- /** The exported-cache directory on the cache disk, once the in-VM builder is up. */
878
- let _localCacheDir: string | null = null;
879
- /** The cache directories a build imports from: the merged one on the
880
- * layers disk, then this lineage's own exports. */
881
- let _localCacheImports: string[] = [];
882
869
  /**
883
870
  * A reference as containerd names it. buildx's `-t` on a remote builder
884
871
  * stores an unqualified name (`probe:bx`) that dockerd then cannot
@@ -1441,52 +1428,30 @@ async function runServiceBuild(
1441
1428
  const useBuildKit = useLocal || (await hasBuildx());
1442
1429
  const buildEnv: Record<string, string> = {};
1443
1430
  let buildArgs: string[];
1444
- // The `--cache-to` flag, held back for a second pass that runs only
1445
- // when the first one executed a step (see below). Null off the in-VM
1446
- // builder.
1447
- let cacheExport: string[] | null = null;
1448
1431
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1449
- // so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
1432
+ // so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
1450
1433
  const argFlags = buildArgFlags(image.buildArgs);
1451
- if (useLocal && _localCacheDir) {
1434
+ if (useLocal) {
1452
1435
  // The in-VM builder is BuildKit's containerd worker on this VM's
1453
1436
  // own image store (CONTAINER_STORE.md): the output is an image
1454
1437
  // record in dockerd's namespace, unpacked, so there is no `--load`
1455
- // and nothing crosses a socket. The cache directory on the image cache
1456
- // disk is what outlives the VM; `mode=max` keeps every
1457
- // intermediate layer, uncompressed so an import never inflates,
1458
- // and one tag per service so exports do not replace each other.
1459
- const cacheTag = name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1460
- cacheExport = [
1461
- "--cache-to",
1462
- `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
1463
- ];
1464
- // LANDMINE: the local cache importer reads the `latest` entry of
1465
- // the directory's index unless told otherwise, and the exporter
1466
- // below writes this service's entry under `tag=<service>`. An
1467
- // import without the same tag misses every time and every
1468
- // `RUN` re-executes on a seeded cache (seen on the first deploy,
1469
- // 2026-09-05: the disk carried the blobs, the build used none).
1438
+ // and nothing crosses a socket. No `--cache-from` and no
1439
+ // `--cache-to` either, since 2026-09-07 (§20): the daemon's own
1440
+ // cache database is on the same disk as the snapshots it names
1441
+ // and rides the base root into the next cold build, so a cached
1442
+ // step is a snapshot that already exists. The exported directory
1443
+ // did the same job through tar blobs, at the price of unpacking
1444
+ // every cached parent before the first uncached step (11–79 s for
1445
+ // one `COPY` on Specific's dashboard) and a second full pass to
1446
+ // export after every miss.
1470
1447
  buildArgs = [
1471
1448
  "buildx", "build",
1472
1449
  "--builder", LOCAL_BUILDER_NAME,
1473
1450
  "--progress=plain",
1474
1451
  "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1475
- ..._localCacheImports.flatMap((src) => ["--cache-from", `type=local,src=${src},tag=${cacheTag}`]),
1476
1452
  ...argFlags,
1477
1453
  "-f", dfPath, WORKSPACE,
1478
1454
  ];
1479
- } else if (useLocal) {
1480
- // The daemon came up but reported no cache directory: build on it
1481
- // and `--load` the result into dockerd.
1482
- buildArgs = [
1483
- "buildx", "build",
1484
- "--builder", LOCAL_BUILDER_NAME,
1485
- "--load",
1486
- "--progress=plain",
1487
- ...argFlags,
1488
- "-t", tag, "-f", dfPath, WORKSPACE,
1489
- ];
1490
1455
  } else if (useBuildKit) {
1491
1456
  buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1492
1457
  buildEnv.DOCKER_BUILDKIT = "1";
@@ -1520,27 +1485,6 @@ async function runServiceBuild(
1520
1485
  if (build.code !== 0) {
1521
1486
  return { ok: false, log };
1522
1487
  }
1523
- // Export the cache only when there is something new to export. A
1524
- // `mode=max` export costs ~3 s per service even when every blob is
1525
- // already in the destination (measured 2026-09-06, api: 2.2–3.0 s
1526
- // for the cached build alone, 4.9–7.5 s with the export), and a cold
1527
- // start of a cached project is nothing but such builds. When a step
1528
- // did run, the second pass is that same build fully cached from the
1529
- // daemon's own state plus the export — seconds on top of a build
1530
- // that took tens of them. Best-effort: the image is already in the
1531
- // store, so a failed export is a slower next cold start, not a
1532
- // failed service.
1533
- if (cacheExport) {
1534
- const ran = executedSteps(build.stderr);
1535
- if (ran.length > 0) {
1536
- progressService(name, { status: "building", detail: `exporting cache (${ran.length} step(s) ran)` });
1537
- const exported = await shxStream("docker", [...buildArgs, ...cacheExport], 1_800_000, buildEnv, () => {});
1538
- if (exported.code !== 0) {
1539
- // eslint-disable-next-line no-console
1540
- console.warn(`[build] ${name}: cache export failed; the next cold start rebuilds it:\n${exported.stderr.trim().slice(-2000)}`);
1541
- }
1542
- }
1543
- }
1544
1488
  if (useBuildKit) {
1545
1489
  // Keep only the slowest dozen steps ≥1s — enough to profile, small
1546
1490
  // enough to ride back in the /bootstrap response and the journal.
@@ -3577,36 +3521,23 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3577
3521
  // image is pulled and whose deps are up starts immediately; it never sits
3578
3522
  // at "image ready" waiting for an unrelated slow build elsewhere.
3579
3523
  //
3580
- // Prep concurrency: registry pulls always run in parallel (network-bound,
3581
- // low VM RAM). Dockerfile builds run inside the VM, and two or more
3582
- // concurrent builds routinely OOM a single VM on monorepos with parallel
3583
- // pnpm/npm installs (each install fans out to ~16 fetchers + lifecycle
3584
- // workers, ~70 MB/process), so builds serialize behind a FIFO chain —
3585
- // but only the builds; pulls and starts run freely alongside them.
3524
+ // Prep concurrency: everything at once. Registry pulls are
3525
+ // network-bound and cheap in RAM. Dockerfile builds all go to the one
3526
+ // in-VM buildkitd, whose `max-parallelism` (all but one vCPU,
3527
+ // `base.rs::BUILDKITD_UP_SH`) caps the steps in flight across every
3528
+ // build it holds, and whose steps run under a cgroup with a memory
3529
+ // ceiling — so a fully cached build finishes at once while a miss is
3530
+ // still running, and two installs that fan out together kill a step,
3531
+ // never the VM. Until 2026-09-07 builds queued behind one FIFO chain
3532
+ // here, the harness's own guard against that OOM (each install fans
3533
+ // out to ~16 fetchers + lifecycle workers, ~70 MB/process): a service
3534
+ // whose build was entirely cached then waited the whole length of a
3535
+ // sibling's miss, 276 s for 4.5 s of work on Specific's `cli`.
3586
3536
  const tags = new Map<string, string>();
3587
- const builds = services.filter((s) => s.image.type === "dockerfile");
3588
- const buildsRunHostSide = false;
3589
- // A promise chain is a fair FIFO mutex: when builds run in-VM, each build
3590
- // waits for the previous to settle. Pulls and host-side builds bypass it.
3591
- let inVmBuildChain: Promise<unknown> = Promise.resolve();
3592
- const prepImage = (
3593
- svc: NamedService,
3594
- ): Promise<{ tag: string; buildSteps?: BuildStep[] }> => {
3595
- // Bootstrap is the only dedup scope: all its builds share one
3596
- // /workspace generation (see prepareServiceImage).
3597
- const run = () => prepareServiceImage(svc, { dedup: true });
3598
- if (svc.image.type === "dockerfile" && !buildsRunHostSide) {
3599
- const next = inVmBuildChain.then(run, run);
3600
- // Keep the chain moving even if a build throws; the chain itself never
3601
- // rejects (the per-service prep promise below is what surfaces errors).
3602
- inVmBuildChain = next.then(
3603
- () => undefined,
3604
- () => undefined,
3605
- );
3606
- return next;
3607
- }
3608
- return run();
3609
- };
3537
+ // Bootstrap is the only dedup scope: all its builds share one
3538
+ // /workspace generation (see prepareServiceImage).
3539
+ const prepImage = (svc: NamedService): Promise<{ tag: string; buildSteps?: BuildStep[] }> =>
3540
+ prepareServiceImage(svc, { dedup: true });
3610
3541
  const prep = new Map<string, Promise<void>>();
3611
3542
  for (const svc of services) {
3612
3543
  const p = (async () => {
@@ -77,11 +77,12 @@ export function summarizeBuildKit(out: string): BuildStep[] {
77
77
  * The Dockerfile steps a build actually ran — the ones a cache export
78
78
  * would have something new to record.
79
79
  *
80
- * A fully cached build still has to export its cache today, and that
81
- * export costs ~3 s per service in `mode=max` even when every blob is
82
- * already in the destination (measured 2026-09-06: 2.2–3.0 s for the
83
- * build, 4.9–7.5 s with the export). So the harness builds without
84
- * `--cache-to` first and exports only when this says something ran.
80
+ * Between SDK 0.75.0 and 0.76.0 this gated the harness's cache export
81
+ * (a `mode=max` export cost ~3 s per service with nothing to write,
82
+ * measured 2026-09-06). The harness exports nothing since its cache is
83
+ * the daemon's own state (CONTAINER_STORE.md §20); the reading stays,
84
+ * since it is the one machine-readable answer to "did this build run
85
+ * anything".
85
86
  *
86
87
  * What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
87
88
  * in `DONE` rather than `CACHED`. What does not: BuildKit's own
@@ -28,7 +28,7 @@ describe("isOnImageCache", () => {
28
28
  });
29
29
  });
30
30
 
31
- import { mergeCacheIndex, type OciIndex } from "./image-cache";
31
+ import { BUILDKIT_CACHE_TAG_PREFIX, buildkitCacheTag, mergeCacheIndex, type OciIndex } from "./image-cache";
32
32
 
33
33
  const entry = (tag: string, hex: string) => ({ mediaType: "application/vnd.oci.image.manifest.v1+json", digest: `sha256:${hex}`, size: 1, annotations: { "org.opencontainers.image.ref.name": tag } });
34
34
 
@@ -62,4 +62,20 @@ describe("mergeCacheIndex", () => {
62
62
  const out = mergeCacheIndex(lower, upper, () => true);
63
63
  expect(out.manifests.map((m) => m.annotations?.["org.opencontainers.image.ref.name"])).toEqual(["api", "cli", "worker"]);
64
64
  });
65
+
66
+ /** The directory is one tag namespace shared with the project's own
67
+ * BuildKit. The harness's tags are prefixed so a project tag named after
68
+ * the same service sits beside it instead of replacing it. */
69
+ test("a project's tag and the harness's tag for the same service both survive", () => {
70
+ const both: OciIndex = { schemaVersion: 2, manifests: [entry("nginx-mod", "p1"), entry(buildkitCacheTag("nginx-mod"), "h1")] };
71
+ const out = mergeCacheIndex(both, null, () => false);
72
+ expect(out.manifests.map((m) => m.digest)).toEqual(["sha256:p1", "sha256:h1"]);
73
+ });
74
+ });
75
+
76
+ describe("buildkitCacheTag", () => {
77
+ test("the reserved prefix plus the normalised service name", () => {
78
+ expect(buildkitCacheTag("nginx-mod")).toBe(`${BUILDKIT_CACHE_TAG_PREFIX}nginx-mod`);
79
+ expect(buildkitCacheTag("Api_v2")).toBe("spectest/service/api-v2");
80
+ });
65
81
  });
@@ -54,18 +54,40 @@ export const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
54
54
  * One directory that is both: an overlayfs with the layers disk's cache
55
55
  * directory as the read-only lower and the root disk's as the upper,
56
56
  * mounted here by the harness when the in-VM builder comes up
57
- * (`daemon.ts::ensureBuildkitCacheUnion`). A build imports from it and
58
- * exports to it. BuildKit's local exporter skips a blob its destination
59
- * already holds, and through the union every merged blob is already
60
- * there — so a fully cached build exports its index and manifest and
61
- * nothing else, where a fresh directory on the root cost it every
62
- * uncompressed layer again (1.1 GB and 20 s for one service, measured
63
- * 2026-09-06). New layers land in the upper, which is exactly the
64
- * directory the merge read before. A user-run BuildKit mounts this one
65
- * path for both of its cache flags.
57
+ * (`daemon.ts::ensureBuildkitCacheUnion`). It is for the PROJECT'S OWN
58
+ * BuildKit — the `/services` docs page tells it to mount this one path
59
+ * for both of its cache flags. BuildKit's local exporter skips a blob its
60
+ * destination already holds, and through the union every merged blob is
61
+ * already there, so a fully cached build exports its index and manifest
62
+ * and nothing else (a fresh directory on the root cost every
63
+ * uncompressed layer again, 1.1 GB and 20 s for one service, measured
64
+ * 2026-09-06). New layers land in the upper, which the merge reads.
65
+ *
66
+ * The harness's own service builds used it too until SDK 0.76.0; since
67
+ * then their cache is the in-VM daemon's own state, which lives on the
68
+ * root disk and rides the base root into the next cold build
69
+ * (CONTAINER_STORE.md §20).
66
70
  */
67
71
  export const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
68
72
 
73
+ /**
74
+ * The prefix the docs reserve for spectest in that directory's one flat
75
+ * tag namespace. The harness tagged its service builds under it from
76
+ * SDK 0.75.0 to 0.76.0, after Specific's `nginx-mod` service tagged its
77
+ * own `buildctl` cache `nginx-mod` and the two overwrote each other on
78
+ * alternate boots (2026-09-06); the harness writes no tags now, and the
79
+ * prefix stays reserved so nothing a project names can ever collide
80
+ * with something spectest writes there later.
81
+ */
82
+ export const BUILDKIT_CACHE_TAG_PREFIX = "spectest/service/";
83
+
84
+ /** The tag a service's Dockerfile build would carry under the reserved
85
+ * prefix: the service name, lowercased with anything outside `[a-z0-9-]`
86
+ * folded to `-`. Unused by the harness since SDK 0.76.0. */
87
+ export function buildkitCacheTag(service: string): string {
88
+ return `${BUILDKIT_CACHE_TAG_PREFIX}${service.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
89
+ }
90
+
69
91
  function parse(raw: string): ImageCachePaths | null {
70
92
  const parsed = JSON.parse(raw) as { disks?: { role?: string; path?: string }[] };
71
93
  const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;