@specific.dev/spectest 0.73.0 → 0.74.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/browser.js +26 -6
- package/dist/daemon.js +110 -8
- package/dist/harness/buildkit-progress.d.ts +18 -0
- package/dist/harness/buildkit-progress.js +54 -0
- package/dist/harness/image-cache.d.ts +51 -0
- package/dist/harness/image-cache.js +67 -0
- package/dist/vendor/rrweb-record.min.js +4 -4
- package/package.json +1 -1
- package/src/browser.ts +26 -6
- package/src/daemon.ts +124 -8
- package/src/harness/buildkit-progress.test.ts +54 -1
- package/src/harness/buildkit-progress.ts +51 -0
- package/src/harness/image-cache.test.ts +65 -0
- package/src/harness/image-cache.ts +74 -0
- package/src/vendor/rrweb-record.min.js +4 -4
package/dist/browser.js
CHANGED
|
@@ -236,11 +236,17 @@ async function applySafeAreaInsets(cdp, d) {
|
|
|
236
236
|
// ────────────────────────────────────────────────────────────────────────
|
|
237
237
|
// rrweb bootstrap
|
|
238
238
|
// ────────────────────────────────────────────────────────────────────────
|
|
239
|
-
// Vendored `@rrweb/record` UMD bundle (rrweb 2.x)
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
239
|
+
// Vendored `@rrweb/record` UMD bundle (rrweb 2.x), BUILT FROM OUR FORK by
|
|
240
|
+
// `scripts/rrweb-record/build.sh` (specific-dev/rrweb, branch `spectest` =
|
|
241
|
+
// the `rrweb@2.1.0` tag + upstream PR 1802). Never copy the npm dist over
|
|
242
|
+
// it: upstream's `@rrweb/utils` lists `querySelector` as an accessor, so its
|
|
243
|
+
// pristine-prototype test always fails and `record()` appends an <iframe>
|
|
244
|
+
// to every document — measured at ~1 s per navigation (see the top-frame
|
|
245
|
+
// gate on PAGE_INIT_SCRIPT below). Its global `rrwebRecord` is a module
|
|
246
|
+
// object — the record function is `rrwebRecord.record` (the bootstrap
|
|
247
|
+
// resolves both this and the legacy function-shaped global). Read once at
|
|
248
|
+
// module init — the SDK ships this file in the base snapshot so no network
|
|
249
|
+
// fetch happens inside the VM.
|
|
244
250
|
const RRWEB_BUNDLE = (() => {
|
|
245
251
|
try {
|
|
246
252
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -711,8 +717,22 @@ const RRWEB_BOOTSTRAP = `
|
|
|
711
717
|
// call expression — the TypeError kills everything after the bundle, so
|
|
712
718
|
// the globals define but the bootstrap never runs (zero rrweb events,
|
|
713
719
|
// empty replays).
|
|
720
|
+
//
|
|
721
|
+
// The whole script runs in the TOP frame only. `Page.addScriptToEvaluateOnNewDocument`
|
|
722
|
+
// fires for every frame, and until 2026-09-06 that was a storm: rrweb's
|
|
723
|
+
// `getUntaintedPrototype` appends a hidden <iframe> to read pristine DOM
|
|
724
|
+
// prototypes, this script then ran inside it, the child's `record()` (which
|
|
725
|
+
// treats every frame as an emitting frame unless `recordCrossOriginIframes`
|
|
726
|
+
// is set) appended an iframe of its own, and so on to Chromium's frame-depth
|
|
727
|
+
// cap — 44 nested frames and 45 executions of ~180 KB of JS per navigation,
|
|
728
|
+
// ~0.8–1.3 s inside the page's `load` handler even for a one-line document
|
|
729
|
+
// (`goto` p50 was ~1.5 s in every project). The vendored bundle's fork patch
|
|
730
|
+
// removes the iframe on an unpatched page; this gate bounds it on a page
|
|
731
|
+
// whose prototypes really are patched (zone.js). Nothing is lost: rrweb
|
|
732
|
+
// records same-origin iframes from the parent, and a cross-origin child's
|
|
733
|
+
// events were never drained (they land in the child's own buffer).
|
|
714
734
|
const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
715
|
-
?
|
|
735
|
+
? `if (window === window.top) {\n${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}\n}`
|
|
716
736
|
: "";
|
|
717
737
|
// Page-side expression that atomically swaps in a fresh buffer and
|
|
718
738
|
// returns the old one. Wrapped as a single expression so view.evaluate's
|
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 { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
38
|
+
import { executedSteps, 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";
|
|
@@ -50,7 +50,7 @@ import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectC
|
|
|
50
50
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
51
51
|
import { startTlsTerminator } from "./harness/tls-terminator.js";
|
|
52
52
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
53
|
-
import { IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache } from "./harness/image-cache.js";
|
|
53
|
+
import { BUILDKIT_CACHE_DIR, BUILDKIT_CACHE_UNION, IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache, mergeCacheIndex, } from "./harness/image-cache.js";
|
|
54
54
|
import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
|
|
55
55
|
import { conflict, notFound, requireString, } from "./harness/methods.js";
|
|
56
56
|
import { openTerminal } from "./terminal.js";
|
|
@@ -507,14 +507,88 @@ 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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
+
}
|
|
515
527
|
_localBuilder = true;
|
|
516
528
|
return true;
|
|
517
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Mount the BuildKit cache union (`BUILDKIT_CACHE_UNION`): overlayfs with
|
|
532
|
+
* the layers disk's cache directory as the lower and the root disk's as
|
|
533
|
+
* the upper, so one path serves `--cache-from` and `--cache-to`.
|
|
534
|
+
*
|
|
535
|
+
* Idempotent — a delta restore restarts the harness on a guest that
|
|
536
|
+
* still has the mount. Returns null, and the caller keeps the two-directory
|
|
537
|
+
* scheme, when the lower does not exist yet (a volume that has never
|
|
538
|
+
* been merged has no cache directory on its layers disk) or the mount is
|
|
539
|
+
* refused; the build is then slower, never failed. The work directory
|
|
540
|
+
* has to share a filesystem with the upper, so it lives on the root disk
|
|
541
|
+
* beside it; the merge reads neither it nor anything but the upper.
|
|
542
|
+
*/
|
|
543
|
+
async function ensureBuildkitCacheUnion(paths) {
|
|
544
|
+
const lower = `${paths.layers}/${BUILDKIT_CACHE_DIR}`;
|
|
545
|
+
const upper = `${paths.root}/${BUILDKIT_CACHE_DIR}`;
|
|
546
|
+
const work = `${paths.root}/spectest-buildkit-work`;
|
|
547
|
+
if (!existsSync(lower)) {
|
|
548
|
+
// eslint-disable-next-line no-console
|
|
549
|
+
console.log(`[build] no merged BuildKit cache on the layers disk yet; exporting to the root directly`);
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
// overlayfs shows the upper's `index.json` INSTEAD of the lower's, so
|
|
553
|
+
// an index on the root (a lineage's exports, or one left behind with
|
|
554
|
+
// its blobs gone) would hide every merged tag and, naming manifests
|
|
555
|
+
// that are not there, make BuildKit skip the import altogether. Write
|
|
556
|
+
// the union of the two into the upper first (`mergeCacheIndex`), and
|
|
557
|
+
// say what each side held so a miss can be read off the boot log.
|
|
558
|
+
try {
|
|
559
|
+
const readIndex = (dir) => existsSync(`${dir}/index.json`) ? JSON.parse(readFileSync(`${dir}/index.json`, "utf8")) : null;
|
|
560
|
+
const lowerIdx = readIndex(lower);
|
|
561
|
+
const upperIdx = readIndex(upper);
|
|
562
|
+
if (upperIdx && lowerIdx) {
|
|
563
|
+
const merged = mergeCacheIndex(lowerIdx, upperIdx, (hex) => existsSync(`${upper}/blobs/sha256/${hex}`));
|
|
564
|
+
await fs.mkdir(upper, { recursive: true });
|
|
565
|
+
await fs.writeFile(`${upper}/index.json`, JSON.stringify(merged));
|
|
566
|
+
await fs.writeFile(`${upper}/oci-layout`, JSON.stringify({ imageLayoutVersion: "1.0.0" }));
|
|
567
|
+
// eslint-disable-next-line no-console
|
|
568
|
+
console.log(`[build] BuildKit cache union: ${lowerIdx.manifests.length} tag(s) from the merged cache, ${upperIdx.manifests.length} on this root, ${merged.manifests.length} after the merge`);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
// eslint-disable-next-line no-console
|
|
572
|
+
console.log(`[build] BuildKit cache union: ${lowerIdx?.manifests.length ?? 0} tag(s) from the merged cache, none on this root`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
// eslint-disable-next-line no-console
|
|
577
|
+
console.warn(`[build] could not reconcile the BuildKit cache indexes (${err}); the union shows the root's`);
|
|
578
|
+
}
|
|
579
|
+
const r = await shx("/bin/sh", [
|
|
580
|
+
"-c",
|
|
581
|
+
`mkdir -p "${BUILDKIT_CACHE_UNION}" "${upper}" "${work}" && ` +
|
|
582
|
+
`{ mountpoint -q "${BUILDKIT_CACHE_UNION}" || ` +
|
|
583
|
+
`mount -t overlay overlay -o "lowerdir=${lower},upperdir=${upper},workdir=${work}" "${BUILDKIT_CACHE_UNION}"; }`,
|
|
584
|
+
], 60_000);
|
|
585
|
+
if (r.code !== 0) {
|
|
586
|
+
// eslint-disable-next-line no-console
|
|
587
|
+
console.warn(`[build] could not mount the BuildKit cache union; exporting to the root directly:\n${(r.stderr || r.stdout).trim()}`);
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
return BUILDKIT_CACHE_UNION;
|
|
591
|
+
}
|
|
518
592
|
/** The exported-cache directory on the cache disk, once the in-VM builder is up. */
|
|
519
593
|
let _localCacheDir = null;
|
|
520
594
|
/** The cache directories a build imports from: the merged one on the
|
|
@@ -1018,6 +1092,10 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1018
1092
|
const useBuildKit = useLocal || (await hasBuildx());
|
|
1019
1093
|
const buildEnv = {};
|
|
1020
1094
|
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;
|
|
1021
1099
|
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1022
1100
|
// so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
|
|
1023
1101
|
const argFlags = buildArgFlags(image.buildArgs);
|
|
@@ -1030,6 +1108,10 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1030
1108
|
// intermediate layer, uncompressed so an import never inflates,
|
|
1031
1109
|
// and one tag per service so exports do not replace each other.
|
|
1032
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
|
+
];
|
|
1033
1115
|
// LANDMINE: the local cache importer reads the `latest` entry of
|
|
1034
1116
|
// the directory's index unless told otherwise, and the exporter
|
|
1035
1117
|
// below writes this service's entry under `tag=<service>`. An
|
|
@@ -1042,7 +1124,6 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1042
1124
|
"--progress=plain",
|
|
1043
1125
|
"--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
|
|
1044
1126
|
..._localCacheImports.flatMap((src) => ["--cache-from", `type=local,src=${src},tag=${cacheTag}`]),
|
|
1045
|
-
"--cache-to", `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
|
|
1046
1127
|
...argFlags,
|
|
1047
1128
|
"-f", dfPath, WORKSPACE,
|
|
1048
1129
|
];
|
|
@@ -1093,6 +1174,27 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1093
1174
|
if (build.code !== 0) {
|
|
1094
1175
|
return { ok: false, log };
|
|
1095
1176
|
}
|
|
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
|
+
}
|
|
1096
1198
|
if (useBuildKit) {
|
|
1097
1199
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
1098
1200
|
// enough to ride back in the /bootstrap response and the journal.
|
|
@@ -35,3 +35,21 @@ export interface BuildStep {
|
|
|
35
35
|
* omitting it makes a fully-cached build look like it did nothing.
|
|
36
36
|
*/
|
|
37
37
|
export declare function summarizeBuildKit(out: string): BuildStep[];
|
|
38
|
+
/**
|
|
39
|
+
* The Dockerfile steps a build actually ran — the ones a cache export
|
|
40
|
+
* would have something new to record.
|
|
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.
|
|
47
|
+
*
|
|
48
|
+
* What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
|
|
49
|
+
* in `DONE` rather than `CACHED`. What does not: BuildKit's own
|
|
50
|
+
* `[internal]`/`[auth]` bookkeeping, which is never cached and never a
|
|
51
|
+
* layer, and a `FROM` step that finished in the blink it takes to find
|
|
52
|
+
* the base image locally — a pulled base takes seconds and does count,
|
|
53
|
+
* since the export records its layers too.
|
|
54
|
+
*/
|
|
55
|
+
export declare function executedSteps(out: string): string[];
|
|
@@ -64,3 +64,57 @@ export function summarizeBuildKit(out) {
|
|
|
64
64
|
}
|
|
65
65
|
return steps.sort((a, b) => b.secs - a.secs);
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The Dockerfile steps a build actually ran — the ones a cache export
|
|
69
|
+
* would have something new to record.
|
|
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.
|
|
76
|
+
*
|
|
77
|
+
* What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
|
|
78
|
+
* in `DONE` rather than `CACHED`. What does not: BuildKit's own
|
|
79
|
+
* `[internal]`/`[auth]` bookkeeping, which is never cached and never a
|
|
80
|
+
* layer, and a `FROM` step that finished in the blink it takes to find
|
|
81
|
+
* the base image locally — a pulled base takes seconds and does count,
|
|
82
|
+
* since the export records its layers too.
|
|
83
|
+
*/
|
|
84
|
+
export function executedSteps(out) {
|
|
85
|
+
const names = new Map();
|
|
86
|
+
const stages = new Map();
|
|
87
|
+
const secs = new Map();
|
|
88
|
+
const cached = new Set();
|
|
89
|
+
for (const line of out.split("\n")) {
|
|
90
|
+
let m = line.match(/^#(\d+)\s+\[([^\]]*)\]\s+(.+)$/);
|
|
91
|
+
if (m) {
|
|
92
|
+
const id = `#${m[1]}`;
|
|
93
|
+
if (!names.has(id)) {
|
|
94
|
+
names.set(id, m[3].trim().slice(0, MAX_NAME));
|
|
95
|
+
stages.set(id, m[2].trim());
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
|
|
100
|
+
if (m) {
|
|
101
|
+
secs.set(`#${m[1]}`, parseFloat(m[2]));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
m = line.match(/^#(\d+)\s+CACHED/);
|
|
105
|
+
if (m)
|
|
106
|
+
cached.add(`#${m[1]}`);
|
|
107
|
+
}
|
|
108
|
+
const ran = [];
|
|
109
|
+
for (const [id, name] of names) {
|
|
110
|
+
if (cached.has(id))
|
|
111
|
+
continue;
|
|
112
|
+
const stage = stages.get(id) ?? "";
|
|
113
|
+
if (stage === "internal" || stage === "auth")
|
|
114
|
+
continue;
|
|
115
|
+
if (/^FROM\s/.test(name) && (secs.get(id) ?? 0) < 0.5)
|
|
116
|
+
continue;
|
|
117
|
+
ran.push(name);
|
|
118
|
+
}
|
|
119
|
+
return ran;
|
|
120
|
+
}
|
|
@@ -35,6 +35,27 @@ export declare const MKFS_EROFS_PATH = "/usr/local/bin/mkfs.erofs";
|
|
|
35
35
|
/** The guest's adopt helper (`base.rs::STORE_ADOPT_SH`), POSIX sh so a
|
|
36
36
|
* nested runtime's busybox can run the same file. */
|
|
37
37
|
export declare const STORE_ADOPT_PATH = "/usr/local/bin/spectest-store-adopt";
|
|
38
|
+
/**
|
|
39
|
+
* The BuildKit cache directory under each cache disk (`merge.rs::CACHE_DIR`):
|
|
40
|
+
* on the layers disk the merged export of every earlier build, read-only;
|
|
41
|
+
* on the root disk this lineage's own exports, which the merge harvests.
|
|
42
|
+
*/
|
|
43
|
+
export declare const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
|
|
44
|
+
/**
|
|
45
|
+
* One directory that is both: an overlayfs with the layers disk's cache
|
|
46
|
+
* directory as the read-only lower and the root disk's as the upper,
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
export declare const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
|
|
38
59
|
/** The cache's paths, or `null` when this VM carries none.
|
|
39
60
|
* `SPECTEST_IMAGE_CACHE_MANIFEST` points a test at another file. */
|
|
40
61
|
export declare function imageCachePathsSync(manifest?: string): ImageCachePaths | null;
|
|
@@ -46,3 +67,33 @@ export declare function nestedStoreDir(paths: ImageCachePaths, service: string):
|
|
|
46
67
|
* delta-restore teardown must not wipe it.
|
|
47
68
|
*/
|
|
48
69
|
export declare function isOnImageCache(hostPath: string, paths: ImageCachePaths | null): boolean;
|
|
70
|
+
/** The shape of an OCI layout's `index.json` the BuildKit local cache
|
|
71
|
+
* exporter writes: one manifest per tag, the tag in the ref-name
|
|
72
|
+
* annotation. */
|
|
73
|
+
export interface OciIndex {
|
|
74
|
+
schemaVersion?: number;
|
|
75
|
+
mediaType?: string;
|
|
76
|
+
manifests: Array<{
|
|
77
|
+
mediaType?: string;
|
|
78
|
+
digest: string;
|
|
79
|
+
size?: number;
|
|
80
|
+
annotations?: Record<string, string>;
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The index the BuildKit cache union must show: every tag of the merged
|
|
85
|
+
* cache (lower), overridden by this lineage's own exports (upper) where
|
|
86
|
+
* the upper still holds the manifest.
|
|
87
|
+
*
|
|
88
|
+
* overlayfs shadows whole files, so an `index.json` in the upper hides
|
|
89
|
+
* the lower's completely — and a root disk can carry one: the exports
|
|
90
|
+
* of a lineage that built before the union existed, or an index left
|
|
91
|
+
* behind with its blobs gone. Either way BuildKit then imports from an
|
|
92
|
+
* index naming a few tags whose manifests may not exist, skips the
|
|
93
|
+
* import, and every step runs (seen 2026-09-06: a cold build with
|
|
94
|
+
* every service's cache on the layers disk and 4 stale tags in the
|
|
95
|
+
* upper built everything from scratch). `upperHasBlob` says whether the
|
|
96
|
+
* upper holds a manifest's blob; an upper entry without one is dropped,
|
|
97
|
+
* since it would fail the whole import.
|
|
98
|
+
*/
|
|
99
|
+
export declare function mergeCacheIndex(lower: OciIndex | null, upper: OciIndex | null, upperHasBlob: (hex: string) => boolean): OciIndex;
|
|
@@ -29,6 +29,27 @@ export const MKFS_EROFS_PATH = "/usr/local/bin/mkfs.erofs";
|
|
|
29
29
|
/** The guest's adopt helper (`base.rs::STORE_ADOPT_SH`), POSIX sh so a
|
|
30
30
|
* nested runtime's busybox can run the same file. */
|
|
31
31
|
export const STORE_ADOPT_PATH = "/usr/local/bin/spectest-store-adopt";
|
|
32
|
+
/**
|
|
33
|
+
* The BuildKit cache directory under each cache disk (`merge.rs::CACHE_DIR`):
|
|
34
|
+
* on the layers disk the merged export of every earlier build, read-only;
|
|
35
|
+
* on the root disk this lineage's own exports, which the merge harvests.
|
|
36
|
+
*/
|
|
37
|
+
export const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
|
|
38
|
+
/**
|
|
39
|
+
* One directory that is both: an overlayfs with the layers disk's cache
|
|
40
|
+
* directory as the read-only lower and the root disk's as the upper,
|
|
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.
|
|
51
|
+
*/
|
|
52
|
+
export const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
|
|
32
53
|
function parse(raw) {
|
|
33
54
|
const parsed = JSON.parse(raw);
|
|
34
55
|
const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
|
|
@@ -59,5 +80,51 @@ export function nestedStoreDir(paths, service) {
|
|
|
59
80
|
export function isOnImageCache(hostPath, paths) {
|
|
60
81
|
if (!paths)
|
|
61
82
|
return false;
|
|
83
|
+
// The union is the root's cache directory seen through the layers
|
|
84
|
+
// disk: wiping it would wipe the upper, i.e. this lineage's exports.
|
|
85
|
+
if (hostPath === BUILDKIT_CACHE_UNION || hostPath.startsWith(`${BUILDKIT_CACHE_UNION}/`))
|
|
86
|
+
return true;
|
|
62
87
|
return hostPath === paths.root || hostPath.startsWith(`${paths.root}/`) || hostPath === paths.layers || hostPath.startsWith(`${paths.layers}/`);
|
|
63
88
|
}
|
|
89
|
+
const REF_NAME = "org.opencontainers.image.ref.name";
|
|
90
|
+
/**
|
|
91
|
+
* The index the BuildKit cache union must show: every tag of the merged
|
|
92
|
+
* cache (lower), overridden by this lineage's own exports (upper) where
|
|
93
|
+
* the upper still holds the manifest.
|
|
94
|
+
*
|
|
95
|
+
* overlayfs shadows whole files, so an `index.json` in the upper hides
|
|
96
|
+
* the lower's completely — and a root disk can carry one: the exports
|
|
97
|
+
* of a lineage that built before the union existed, or an index left
|
|
98
|
+
* behind with its blobs gone. Either way BuildKit then imports from an
|
|
99
|
+
* index naming a few tags whose manifests may not exist, skips the
|
|
100
|
+
* import, and every step runs (seen 2026-09-06: a cold build with
|
|
101
|
+
* every service's cache on the layers disk and 4 stale tags in the
|
|
102
|
+
* upper built everything from scratch). `upperHasBlob` says whether the
|
|
103
|
+
* upper holds a manifest's blob; an upper entry without one is dropped,
|
|
104
|
+
* since it would fail the whole import.
|
|
105
|
+
*/
|
|
106
|
+
export function mergeCacheIndex(lower, upper, upperHasBlob) {
|
|
107
|
+
const byTag = new Map();
|
|
108
|
+
const untagged = [];
|
|
109
|
+
for (const m of lower?.manifests ?? []) {
|
|
110
|
+
const tag = m.annotations?.[REF_NAME];
|
|
111
|
+
if (tag)
|
|
112
|
+
byTag.set(tag, m);
|
|
113
|
+
else
|
|
114
|
+
untagged.push(m);
|
|
115
|
+
}
|
|
116
|
+
for (const m of upper?.manifests ?? []) {
|
|
117
|
+
if (!upperHasBlob(m.digest.replace(/^sha256:/, "")))
|
|
118
|
+
continue;
|
|
119
|
+
const tag = m.annotations?.[REF_NAME];
|
|
120
|
+
if (tag)
|
|
121
|
+
byTag.set(tag, m);
|
|
122
|
+
else
|
|
123
|
+
untagged.push(m);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
schemaVersion: 2,
|
|
127
|
+
mediaType: lower?.mediaType ?? upper?.mediaType ?? "application/vnd.oci.image.index.v1+json",
|
|
128
|
+
manifests: [...untagged, ...byTag.values()],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -131,13 +131,13 @@ const testableAccessors = {
|
|
|
131
131
|
"ownerDocument"
|
|
132
132
|
],
|
|
133
133
|
ShadowRoot: ["host", "styleSheets"],
|
|
134
|
-
Element: ["shadowRoot"
|
|
134
|
+
Element: ["shadowRoot"],
|
|
135
135
|
MutationObserver: []
|
|
136
136
|
};
|
|
137
137
|
const testableMethods = {
|
|
138
138
|
Node: ["contains", "getRootNode"],
|
|
139
139
|
ShadowRoot: ["getSelection"],
|
|
140
|
-
Element: [],
|
|
140
|
+
Element: ["querySelector", "querySelectorAll"],
|
|
141
141
|
MutationObserver: ["constructor"]
|
|
142
142
|
};
|
|
143
143
|
const untaintedBasePrototype = {};
|
|
@@ -263,10 +263,10 @@ function shadowRoot(n) {
|
|
|
263
263
|
return getUntaintedAccessor("Element", n, "shadowRoot");
|
|
264
264
|
}
|
|
265
265
|
function querySelector(n, selectors) {
|
|
266
|
-
return
|
|
266
|
+
return getUntaintedMethod("Element", n, "querySelector")(selectors);
|
|
267
267
|
}
|
|
268
268
|
function querySelectorAll(n, selectors) {
|
|
269
|
-
return
|
|
269
|
+
return getUntaintedMethod("Element", n, "querySelectorAll")(selectors);
|
|
270
270
|
}
|
|
271
271
|
function mutationObserverCtor() {
|
|
272
272
|
var _a2;
|
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -615,11 +615,17 @@ async function applySafeAreaInsets(
|
|
|
615
615
|
// rrweb bootstrap
|
|
616
616
|
// ────────────────────────────────────────────────────────────────────────
|
|
617
617
|
|
|
618
|
-
// Vendored `@rrweb/record` UMD bundle (rrweb 2.x)
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
618
|
+
// Vendored `@rrweb/record` UMD bundle (rrweb 2.x), BUILT FROM OUR FORK by
|
|
619
|
+
// `scripts/rrweb-record/build.sh` (specific-dev/rrweb, branch `spectest` =
|
|
620
|
+
// the `rrweb@2.1.0` tag + upstream PR 1802). Never copy the npm dist over
|
|
621
|
+
// it: upstream's `@rrweb/utils` lists `querySelector` as an accessor, so its
|
|
622
|
+
// pristine-prototype test always fails and `record()` appends an <iframe>
|
|
623
|
+
// to every document — measured at ~1 s per navigation (see the top-frame
|
|
624
|
+
// gate on PAGE_INIT_SCRIPT below). Its global `rrwebRecord` is a module
|
|
625
|
+
// object — the record function is `rrwebRecord.record` (the bootstrap
|
|
626
|
+
// resolves both this and the legacy function-shaped global). Read once at
|
|
627
|
+
// module init — the SDK ships this file in the base snapshot so no network
|
|
628
|
+
// fetch happens inside the VM.
|
|
623
629
|
const RRWEB_BUNDLE: string = (() => {
|
|
624
630
|
try {
|
|
625
631
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -1094,8 +1100,22 @@ const RRWEB_BOOTSTRAP = `
|
|
|
1094
1100
|
// call expression — the TypeError kills everything after the bundle, so
|
|
1095
1101
|
// the globals define but the bootstrap never runs (zero rrweb events,
|
|
1096
1102
|
// empty replays).
|
|
1103
|
+
//
|
|
1104
|
+
// The whole script runs in the TOP frame only. `Page.addScriptToEvaluateOnNewDocument`
|
|
1105
|
+
// fires for every frame, and until 2026-09-06 that was a storm: rrweb's
|
|
1106
|
+
// `getUntaintedPrototype` appends a hidden <iframe> to read pristine DOM
|
|
1107
|
+
// prototypes, this script then ran inside it, the child's `record()` (which
|
|
1108
|
+
// treats every frame as an emitting frame unless `recordCrossOriginIframes`
|
|
1109
|
+
// is set) appended an iframe of its own, and so on to Chromium's frame-depth
|
|
1110
|
+
// cap — 44 nested frames and 45 executions of ~180 KB of JS per navigation,
|
|
1111
|
+
// ~0.8–1.3 s inside the page's `load` handler even for a one-line document
|
|
1112
|
+
// (`goto` p50 was ~1.5 s in every project). The vendored bundle's fork patch
|
|
1113
|
+
// removes the iframe on an unpatched page; this gate bounds it on a page
|
|
1114
|
+
// whose prototypes really are patched (zone.js). Nothing is lost: rrweb
|
|
1115
|
+
// records same-origin iframes from the parent, and a cross-origin child's
|
|
1116
|
+
// events were never drained (they land in the child's own buffer).
|
|
1097
1117
|
const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
1098
|
-
?
|
|
1118
|
+
? `if (window === window.top) {\n${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}\n}`
|
|
1099
1119
|
: "";
|
|
1100
1120
|
|
|
1101
1121
|
// Page-side expression that atomically swaps in a fresh buffer and
|
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 { summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
|
|
86
|
+
import { executedSteps, 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,
|
|
@@ -135,7 +135,16 @@ import {
|
|
|
135
135
|
} from "./harness/ingress-table.js";
|
|
136
136
|
import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
|
|
137
137
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
138
|
-
import {
|
|
138
|
+
import {
|
|
139
|
+
BUILDKIT_CACHE_DIR,
|
|
140
|
+
BUILDKIT_CACHE_UNION,
|
|
141
|
+
IMAGE_CACHE_MANIFEST,
|
|
142
|
+
imageCachePathsSync,
|
|
143
|
+
isOnImageCache,
|
|
144
|
+
mergeCacheIndex,
|
|
145
|
+
type ImageCachePaths,
|
|
146
|
+
type OciIndex,
|
|
147
|
+
} from "./harness/image-cache.js";
|
|
139
148
|
import {
|
|
140
149
|
assertAbsolute,
|
|
141
150
|
certificateHostnames,
|
|
@@ -778,14 +787,93 @@ async function ensureLocalBuildkitd(): Promise<boolean> {
|
|
|
778
787
|
}
|
|
779
788
|
// eslint-disable-next-line no-console
|
|
780
789
|
console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
+
}
|
|
786
806
|
_localBuilder = true;
|
|
787
807
|
return true;
|
|
788
808
|
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Mount the BuildKit cache union (`BUILDKIT_CACHE_UNION`): overlayfs with
|
|
812
|
+
* the layers disk's cache directory as the lower and the root disk's as
|
|
813
|
+
* the upper, so one path serves `--cache-from` and `--cache-to`.
|
|
814
|
+
*
|
|
815
|
+
* Idempotent — a delta restore restarts the harness on a guest that
|
|
816
|
+
* still has the mount. Returns null, and the caller keeps the two-directory
|
|
817
|
+
* scheme, when the lower does not exist yet (a volume that has never
|
|
818
|
+
* been merged has no cache directory on its layers disk) or the mount is
|
|
819
|
+
* refused; the build is then slower, never failed. The work directory
|
|
820
|
+
* has to share a filesystem with the upper, so it lives on the root disk
|
|
821
|
+
* beside it; the merge reads neither it nor anything but the upper.
|
|
822
|
+
*/
|
|
823
|
+
async function ensureBuildkitCacheUnion(paths: ImageCachePaths): Promise<string | null> {
|
|
824
|
+
const lower = `${paths.layers}/${BUILDKIT_CACHE_DIR}`;
|
|
825
|
+
const upper = `${paths.root}/${BUILDKIT_CACHE_DIR}`;
|
|
826
|
+
const work = `${paths.root}/spectest-buildkit-work`;
|
|
827
|
+
if (!existsSync(lower)) {
|
|
828
|
+
// eslint-disable-next-line no-console
|
|
829
|
+
console.log(`[build] no merged BuildKit cache on the layers disk yet; exporting to the root directly`);
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
// overlayfs shows the upper's `index.json` INSTEAD of the lower's, so
|
|
833
|
+
// an index on the root (a lineage's exports, or one left behind with
|
|
834
|
+
// its blobs gone) would hide every merged tag and, naming manifests
|
|
835
|
+
// that are not there, make BuildKit skip the import altogether. Write
|
|
836
|
+
// the union of the two into the upper first (`mergeCacheIndex`), and
|
|
837
|
+
// say what each side held so a miss can be read off the boot log.
|
|
838
|
+
try {
|
|
839
|
+
const readIndex = (dir: string): OciIndex | null =>
|
|
840
|
+
existsSync(`${dir}/index.json`) ? (JSON.parse(readFileSync(`${dir}/index.json`, "utf8")) as OciIndex) : null;
|
|
841
|
+
const lowerIdx = readIndex(lower);
|
|
842
|
+
const upperIdx = readIndex(upper);
|
|
843
|
+
if (upperIdx && lowerIdx) {
|
|
844
|
+
const merged = mergeCacheIndex(lowerIdx, upperIdx, (hex) => existsSync(`${upper}/blobs/sha256/${hex}`));
|
|
845
|
+
await fs.mkdir(upper, { recursive: true });
|
|
846
|
+
await fs.writeFile(`${upper}/index.json`, JSON.stringify(merged));
|
|
847
|
+
await fs.writeFile(`${upper}/oci-layout`, JSON.stringify({ imageLayoutVersion: "1.0.0" }));
|
|
848
|
+
// eslint-disable-next-line no-console
|
|
849
|
+
console.log(
|
|
850
|
+
`[build] BuildKit cache union: ${lowerIdx.manifests.length} tag(s) from the merged cache, ${upperIdx.manifests.length} on this root, ${merged.manifests.length} after the merge`,
|
|
851
|
+
);
|
|
852
|
+
} else {
|
|
853
|
+
// eslint-disable-next-line no-console
|
|
854
|
+
console.log(`[build] BuildKit cache union: ${lowerIdx?.manifests.length ?? 0} tag(s) from the merged cache, none on this root`);
|
|
855
|
+
}
|
|
856
|
+
} catch (err) {
|
|
857
|
+
// eslint-disable-next-line no-console
|
|
858
|
+
console.warn(`[build] could not reconcile the BuildKit cache indexes (${err}); the union shows the root's`);
|
|
859
|
+
}
|
|
860
|
+
const r = await shx(
|
|
861
|
+
"/bin/sh",
|
|
862
|
+
[
|
|
863
|
+
"-c",
|
|
864
|
+
`mkdir -p "${BUILDKIT_CACHE_UNION}" "${upper}" "${work}" && ` +
|
|
865
|
+
`{ mountpoint -q "${BUILDKIT_CACHE_UNION}" || ` +
|
|
866
|
+
`mount -t overlay overlay -o "lowerdir=${lower},upperdir=${upper},workdir=${work}" "${BUILDKIT_CACHE_UNION}"; }`,
|
|
867
|
+
],
|
|
868
|
+
60_000,
|
|
869
|
+
);
|
|
870
|
+
if (r.code !== 0) {
|
|
871
|
+
// eslint-disable-next-line no-console
|
|
872
|
+
console.warn(`[build] could not mount the BuildKit cache union; exporting to the root directly:\n${(r.stderr || r.stdout).trim()}`);
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
return BUILDKIT_CACHE_UNION;
|
|
876
|
+
}
|
|
789
877
|
/** The exported-cache directory on the cache disk, once the in-VM builder is up. */
|
|
790
878
|
let _localCacheDir: string | null = null;
|
|
791
879
|
/** The cache directories a build imports from: the merged one on the
|
|
@@ -1353,6 +1441,10 @@ async function runServiceBuild(
|
|
|
1353
1441
|
const useBuildKit = useLocal || (await hasBuildx());
|
|
1354
1442
|
const buildEnv: Record<string, string> = {};
|
|
1355
1443
|
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;
|
|
1356
1448
|
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1357
1449
|
// so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
|
|
1358
1450
|
const argFlags = buildArgFlags(image.buildArgs);
|
|
@@ -1365,6 +1457,10 @@ async function runServiceBuild(
|
|
|
1365
1457
|
// intermediate layer, uncompressed so an import never inflates,
|
|
1366
1458
|
// and one tag per service so exports do not replace each other.
|
|
1367
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
|
+
];
|
|
1368
1464
|
// LANDMINE: the local cache importer reads the `latest` entry of
|
|
1369
1465
|
// the directory's index unless told otherwise, and the exporter
|
|
1370
1466
|
// below writes this service's entry under `tag=<service>`. An
|
|
@@ -1377,7 +1473,6 @@ async function runServiceBuild(
|
|
|
1377
1473
|
"--progress=plain",
|
|
1378
1474
|
"--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
|
|
1379
1475
|
..._localCacheImports.flatMap((src) => ["--cache-from", `type=local,src=${src},tag=${cacheTag}`]),
|
|
1380
|
-
"--cache-to", `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
|
|
1381
1476
|
...argFlags,
|
|
1382
1477
|
"-f", dfPath, WORKSPACE,
|
|
1383
1478
|
];
|
|
@@ -1425,6 +1520,27 @@ async function runServiceBuild(
|
|
|
1425
1520
|
if (build.code !== 0) {
|
|
1426
1521
|
return { ok: false, log };
|
|
1427
1522
|
}
|
|
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
|
+
}
|
|
1428
1544
|
if (useBuildKit) {
|
|
1429
1545
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
1430
1546
|
// enough to ride back in the /bootstrap response and the journal.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { summarizeBuildKit } from "./buildkit-progress";
|
|
3
|
+
import { executedSteps, summarizeBuildKit } from "./buildkit-progress";
|
|
4
4
|
|
|
5
5
|
/** A realistic slice of `docker build --progress=plain` output. */
|
|
6
6
|
const SAMPLE = `
|
|
@@ -96,3 +96,56 @@ describe("summarizeBuildKit", () => {
|
|
|
96
96
|
expect(out[0].cached).toBe(true);
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
/** Captured from a fully cached in-VM build of a real service
|
|
101
|
+
* (2026-09-06): bookkeeping steps report DONE, the base image resolves
|
|
102
|
+
* in no time, every Dockerfile step is CACHED. */
|
|
103
|
+
const FULLY_CACHED = `
|
|
104
|
+
#1 [internal] load build definition from Dockerfile
|
|
105
|
+
#1 transferring dockerfile: 3.38kB done
|
|
106
|
+
#1 DONE 0.0s
|
|
107
|
+
#2 resolve image config for docker-image://docker.io/docker/dockerfile:1
|
|
108
|
+
#2 DONE 0.1s
|
|
109
|
+
#4 [internal] load metadata for docker.io/library/node:25-slim
|
|
110
|
+
#4 DONE 0.1s
|
|
111
|
+
#5 [stage-0 1/19] FROM docker.io/library/node:25-slim@sha256:81db02c4b671288a03915da9534dbd54f96d0e7c24d80ccc54f5b36b2e684370
|
|
112
|
+
#5 DONE 0.0s
|
|
113
|
+
#6 [internal] load build context
|
|
114
|
+
#6 transferring context: 11.87kB done
|
|
115
|
+
#6 DONE 0.0s
|
|
116
|
+
#7 [stage-0 5/19] RUN --mount=type=cache,target=/pnpm-store,sharing=locked cd config && pnpm install --frozen-lockfile
|
|
117
|
+
#7 CACHED
|
|
118
|
+
#8 [stage-0 10/19] COPY packages/tunnel/client tunnel/client
|
|
119
|
+
#8 CACHED
|
|
120
|
+
#25 exporting to image
|
|
121
|
+
#25 exporting layers done
|
|
122
|
+
#25 DONE 0.3s
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
describe("executedSteps", () => {
|
|
126
|
+
test("a fully cached build ran nothing", () => {
|
|
127
|
+
expect(executedSteps(FULLY_CACHED)).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a step that ended in DONE ran", () => {
|
|
131
|
+
expect(executedSteps(SAMPLE)).toEqual([
|
|
132
|
+
"RUN go build -o /out/api ./cmd/api",
|
|
133
|
+
"COPY --from=builder /out/api /usr/local/bin/api",
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** A base image that had to be pulled is new content the export
|
|
138
|
+
* records; one found locally in 0.0s is not. */
|
|
139
|
+
test("a pulled base image counts, a local one does not", () => {
|
|
140
|
+
const pulled = FULLY_CACHED.replace("#5 DONE 0.0s", "#5 DONE 4.2s");
|
|
141
|
+
const ran = executedSteps(pulled);
|
|
142
|
+
expect(ran).toHaveLength(1);
|
|
143
|
+
// Names are capped like `summarizeBuildKit`'s, so match the prefix.
|
|
144
|
+
expect(ran[0].startsWith("FROM docker.io/library/node:25-slim@sha256:")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("bookkeeping steps never count", () => {
|
|
148
|
+
const out = "#1 [internal] load build context\n#1 DONE 2.5s\n#2 [auth] library/node:pull token\n#2 DONE 0.4s\n";
|
|
149
|
+
expect(executedSteps(out)).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -72,3 +72,54 @@ export function summarizeBuildKit(out: string): BuildStep[] {
|
|
|
72
72
|
}
|
|
73
73
|
return steps.sort((a, b) => b.secs - a.secs);
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The Dockerfile steps a build actually ran — the ones a cache export
|
|
78
|
+
* would have something new to record.
|
|
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.
|
|
85
|
+
*
|
|
86
|
+
* What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
|
|
87
|
+
* in `DONE` rather than `CACHED`. What does not: BuildKit's own
|
|
88
|
+
* `[internal]`/`[auth]` bookkeeping, which is never cached and never a
|
|
89
|
+
* layer, and a `FROM` step that finished in the blink it takes to find
|
|
90
|
+
* the base image locally — a pulled base takes seconds and does count,
|
|
91
|
+
* since the export records its layers too.
|
|
92
|
+
*/
|
|
93
|
+
export function executedSteps(out: string): string[] {
|
|
94
|
+
const names = new Map<string, string>();
|
|
95
|
+
const stages = new Map<string, string>();
|
|
96
|
+
const secs = new Map<string, number>();
|
|
97
|
+
const cached = new Set<string>();
|
|
98
|
+
for (const line of out.split("\n")) {
|
|
99
|
+
let m = line.match(/^#(\d+)\s+\[([^\]]*)\]\s+(.+)$/);
|
|
100
|
+
if (m) {
|
|
101
|
+
const id = `#${m[1]}`;
|
|
102
|
+
if (!names.has(id)) {
|
|
103
|
+
names.set(id, m[3].trim().slice(0, MAX_NAME));
|
|
104
|
+
stages.set(id, m[2].trim());
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
|
|
109
|
+
if (m) {
|
|
110
|
+
secs.set(`#${m[1]}`, parseFloat(m[2]));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
m = line.match(/^#(\d+)\s+CACHED/);
|
|
114
|
+
if (m) cached.add(`#${m[1]}`);
|
|
115
|
+
}
|
|
116
|
+
const ran: string[] = [];
|
|
117
|
+
for (const [id, name] of names) {
|
|
118
|
+
if (cached.has(id)) continue;
|
|
119
|
+
const stage = stages.get(id) ?? "";
|
|
120
|
+
if (stage === "internal" || stage === "auth") continue;
|
|
121
|
+
if (/^FROM\s/.test(name) && (secs.get(id) ?? 0) < 0.5) continue;
|
|
122
|
+
ran.push(name);
|
|
123
|
+
}
|
|
124
|
+
return ran;
|
|
125
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { BUILDKIT_CACHE_UNION, isOnImageCache } from "./image-cache";
|
|
4
|
+
|
|
5
|
+
const paths = { root: "/var/lib/containerd", layers: "/var/lib/spectest-image-cache" };
|
|
6
|
+
|
|
7
|
+
describe("isOnImageCache", () => {
|
|
8
|
+
test("the two cache disks and anything under them", () => {
|
|
9
|
+
expect(isOnImageCache("/var/lib/containerd", paths)).toBe(true);
|
|
10
|
+
expect(isOnImageCache("/var/lib/containerd/spectest-nested/k8s", paths)).toBe(true);
|
|
11
|
+
expect(isOnImageCache("/var/lib/spectest-image-cache/spectest-buildkit-cache", paths)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** The union is the root's export directory seen through the layers
|
|
15
|
+
* disk; wiping it at a delta restore would wipe this lineage's exports. */
|
|
16
|
+
test("the BuildKit cache union counts as cache", () => {
|
|
17
|
+
expect(isOnImageCache(BUILDKIT_CACHE_UNION, paths)).toBe(true);
|
|
18
|
+
expect(isOnImageCache(`${BUILDKIT_CACHE_UNION}/blobs`, paths)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("an ordinary project volume does not", () => {
|
|
22
|
+
expect(isOnImageCache("/var/cache/spectest/volumes/db", paths)).toBe(false);
|
|
23
|
+
expect(isOnImageCache("/var/lib/containerd-not", paths)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("no cache, nothing counts", () => {
|
|
27
|
+
expect(isOnImageCache(BUILDKIT_CACHE_UNION, null)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
import { mergeCacheIndex, type OciIndex } from "./image-cache";
|
|
32
|
+
|
|
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
|
+
|
|
35
|
+
describe("mergeCacheIndex", () => {
|
|
36
|
+
const lower: OciIndex = { schemaVersion: 2, manifests: [entry("api", "a1"), entry("cli", "c1")] };
|
|
37
|
+
|
|
38
|
+
test("no upper index: the merged cache's tags, unchanged", () => {
|
|
39
|
+
const out = mergeCacheIndex(lower, null, () => false);
|
|
40
|
+
expect(out.manifests.map((m) => m.annotations?.["org.opencontainers.image.ref.name"])).toEqual(["api", "cli"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** The lineage's own export of a service wins over the merged one. */
|
|
44
|
+
test("an upper entry whose manifest exists overrides the lower's tag", () => {
|
|
45
|
+
const upper: OciIndex = { schemaVersion: 2, manifests: [entry("api", "a2")] };
|
|
46
|
+
const out = mergeCacheIndex(lower, upper, (hex) => hex === "a2");
|
|
47
|
+
const api = out.manifests.find((m) => m.annotations?.["org.opencontainers.image.ref.name"] === "api");
|
|
48
|
+
expect(api?.digest).toBe("sha256:a2");
|
|
49
|
+
expect(out.manifests).toHaveLength(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A stale upper index — blobs emptied, index left — would hide every
|
|
53
|
+
* lower tag and fail the import; its entries are dropped instead. */
|
|
54
|
+
test("an upper entry without its manifest blob is dropped", () => {
|
|
55
|
+
const upper: OciIndex = { schemaVersion: 2, manifests: [entry("api", "a2"), entry("worker", "w1")] };
|
|
56
|
+
const out = mergeCacheIndex(lower, upper, () => false);
|
|
57
|
+
expect(out.manifests.map((m) => m.digest)).toEqual(["sha256:a1", "sha256:c1"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a valid upper tag the lower lacks is added", () => {
|
|
61
|
+
const upper: OciIndex = { schemaVersion: 2, manifests: [entry("worker", "w1")] };
|
|
62
|
+
const out = mergeCacheIndex(lower, upper, () => true);
|
|
63
|
+
expect(out.manifests.map((m) => m.annotations?.["org.opencontainers.image.ref.name"])).toEqual(["api", "cli", "worker"]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -43,6 +43,29 @@ export const MKFS_EROFS_PATH = "/usr/local/bin/mkfs.erofs";
|
|
|
43
43
|
* nested runtime's busybox can run the same file. */
|
|
44
44
|
export const STORE_ADOPT_PATH = "/usr/local/bin/spectest-store-adopt";
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The BuildKit cache directory under each cache disk (`merge.rs::CACHE_DIR`):
|
|
48
|
+
* on the layers disk the merged export of every earlier build, read-only;
|
|
49
|
+
* on the root disk this lineage's own exports, which the merge harvests.
|
|
50
|
+
*/
|
|
51
|
+
export const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One directory that is both: an overlayfs with the layers disk's cache
|
|
55
|
+
* directory as the read-only lower and the root disk's as the upper,
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
export const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
|
|
68
|
+
|
|
46
69
|
function parse(raw: string): ImageCachePaths | null {
|
|
47
70
|
const parsed = JSON.parse(raw) as { disks?: { role?: string; path?: string }[] };
|
|
48
71
|
const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
|
|
@@ -75,5 +98,56 @@ export function nestedStoreDir(paths: ImageCachePaths, service: string): string
|
|
|
75
98
|
*/
|
|
76
99
|
export function isOnImageCache(hostPath: string, paths: ImageCachePaths | null): boolean {
|
|
77
100
|
if (!paths) return false;
|
|
101
|
+
// The union is the root's cache directory seen through the layers
|
|
102
|
+
// disk: wiping it would wipe the upper, i.e. this lineage's exports.
|
|
103
|
+
if (hostPath === BUILDKIT_CACHE_UNION || hostPath.startsWith(`${BUILDKIT_CACHE_UNION}/`)) return true;
|
|
78
104
|
return hostPath === paths.root || hostPath.startsWith(`${paths.root}/`) || hostPath === paths.layers || hostPath.startsWith(`${paths.layers}/`);
|
|
79
105
|
}
|
|
106
|
+
|
|
107
|
+
/** The shape of an OCI layout's `index.json` the BuildKit local cache
|
|
108
|
+
* exporter writes: one manifest per tag, the tag in the ref-name
|
|
109
|
+
* annotation. */
|
|
110
|
+
export interface OciIndex {
|
|
111
|
+
schemaVersion?: number;
|
|
112
|
+
mediaType?: string;
|
|
113
|
+
manifests: Array<{ mediaType?: string; digest: string; size?: number; annotations?: Record<string, string> }>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const REF_NAME = "org.opencontainers.image.ref.name";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The index the BuildKit cache union must show: every tag of the merged
|
|
120
|
+
* cache (lower), overridden by this lineage's own exports (upper) where
|
|
121
|
+
* the upper still holds the manifest.
|
|
122
|
+
*
|
|
123
|
+
* overlayfs shadows whole files, so an `index.json` in the upper hides
|
|
124
|
+
* the lower's completely — and a root disk can carry one: the exports
|
|
125
|
+
* of a lineage that built before the union existed, or an index left
|
|
126
|
+
* behind with its blobs gone. Either way BuildKit then imports from an
|
|
127
|
+
* index naming a few tags whose manifests may not exist, skips the
|
|
128
|
+
* import, and every step runs (seen 2026-09-06: a cold build with
|
|
129
|
+
* every service's cache on the layers disk and 4 stale tags in the
|
|
130
|
+
* upper built everything from scratch). `upperHasBlob` says whether the
|
|
131
|
+
* upper holds a manifest's blob; an upper entry without one is dropped,
|
|
132
|
+
* since it would fail the whole import.
|
|
133
|
+
*/
|
|
134
|
+
export function mergeCacheIndex(lower: OciIndex | null, upper: OciIndex | null, upperHasBlob: (hex: string) => boolean): OciIndex {
|
|
135
|
+
const byTag = new Map<string, OciIndex["manifests"][number]>();
|
|
136
|
+
const untagged: OciIndex["manifests"] = [];
|
|
137
|
+
for (const m of lower?.manifests ?? []) {
|
|
138
|
+
const tag = m.annotations?.[REF_NAME];
|
|
139
|
+
if (tag) byTag.set(tag, m);
|
|
140
|
+
else untagged.push(m);
|
|
141
|
+
}
|
|
142
|
+
for (const m of upper?.manifests ?? []) {
|
|
143
|
+
if (!upperHasBlob(m.digest.replace(/^sha256:/, ""))) continue;
|
|
144
|
+
const tag = m.annotations?.[REF_NAME];
|
|
145
|
+
if (tag) byTag.set(tag, m);
|
|
146
|
+
else untagged.push(m);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
schemaVersion: 2,
|
|
150
|
+
mediaType: lower?.mediaType ?? upper?.mediaType ?? "application/vnd.oci.image.index.v1+json",
|
|
151
|
+
manifests: [...untagged, ...byTag.values()],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -131,13 +131,13 @@ const testableAccessors = {
|
|
|
131
131
|
"ownerDocument"
|
|
132
132
|
],
|
|
133
133
|
ShadowRoot: ["host", "styleSheets"],
|
|
134
|
-
Element: ["shadowRoot"
|
|
134
|
+
Element: ["shadowRoot"],
|
|
135
135
|
MutationObserver: []
|
|
136
136
|
};
|
|
137
137
|
const testableMethods = {
|
|
138
138
|
Node: ["contains", "getRootNode"],
|
|
139
139
|
ShadowRoot: ["getSelection"],
|
|
140
|
-
Element: [],
|
|
140
|
+
Element: ["querySelector", "querySelectorAll"],
|
|
141
141
|
MutationObserver: ["constructor"]
|
|
142
142
|
};
|
|
143
143
|
const untaintedBasePrototype = {};
|
|
@@ -263,10 +263,10 @@ function shadowRoot(n) {
|
|
|
263
263
|
return getUntaintedAccessor("Element", n, "shadowRoot");
|
|
264
264
|
}
|
|
265
265
|
function querySelector(n, selectors) {
|
|
266
|
-
return
|
|
266
|
+
return getUntaintedMethod("Element", n, "querySelector")(selectors);
|
|
267
267
|
}
|
|
268
268
|
function querySelectorAll(n, selectors) {
|
|
269
|
-
return
|
|
269
|
+
return getUntaintedMethod("Element", n, "querySelectorAll")(selectors);
|
|
270
270
|
}
|
|
271
271
|
function mutationObserverCtor() {
|
|
272
272
|
var _a2;
|