@specific.dev/spectest 0.127.0 → 0.128.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.
@@ -0,0 +1,24 @@
1
+ import { type CoverageAdapter } from "../coverage.js";
2
+ import { type KubernetesImage } from "../harness/component-images.js";
3
+ import type { Patch, Pod } from "./k3s-trust.js";
4
+ interface ImageInfo {
5
+ aliases: string[];
6
+ root: string;
7
+ env: Record<string, string>;
8
+ }
9
+ export interface K3sCoverageDependencies {
10
+ image(reference: string): Promise<ImageInfo>;
11
+ files(namespace: string, name: string, data: Record<string, string>): Promise<void>;
12
+ }
13
+ /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
14
+ export declare function k3sCoverage(images: readonly KubernetesImage[]): {
15
+ enabled: boolean;
16
+ volume(cluster: string): {
17
+ source: string;
18
+ target: string;
19
+ reachExempt: boolean;
20
+ };
21
+ mutate(cluster: string, podInput: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string, deps: K3sCoverageDependencies): Promise<Patch[]>;
22
+ adapter(): CoverageAdapter;
23
+ };
24
+ export {};
@@ -0,0 +1,259 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { applyCoverageAdapters, coverageAdapters } from "../coverage.js";
5
+ import { coverageHostDir, COVERAGE_MAX_BYTES_PER_SERVICE } from "../harness/coverage.js";
6
+ import { captureCoverageTarget } from "../harness/coverage-target.js";
7
+ import { canonicalImageReference } from "../harness/component-images.js";
8
+ import { WORKSPACE } from "../project-files.js";
9
+ const MOUNT = "/spectest/pod-coverage";
10
+ const ANNOTATION = "spectest.dev/coverage";
11
+ const hash = (value) => createHash("sha256").update(value).digest("hex").slice(0, 16);
12
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
13
+ /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
14
+ export function k3sCoverage(images) {
15
+ const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
16
+ reference: canonicalImageReference(image.reference), coverage: image.coverage,
17
+ }]);
18
+ for (const image of declarations) {
19
+ const adapters = coverageAdapters(image.coverage);
20
+ if (adapters.some(adapter => adapter.load || adapter.collect))
21
+ throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
22
+ applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
23
+ }
24
+ const metadata = new Map();
25
+ const service = (cluster, reference) => `${cluster}/image-${hash(reference)}`;
26
+ return {
27
+ enabled: declarations.length > 0,
28
+ volume(cluster) {
29
+ return { source: coverageHostDir(WORKSPACE, cluster), target: MOUNT, reachExempt: true };
30
+ },
31
+ async mutate(cluster, podInput, namespace, dryRun, ephemeral, uid, deps) {
32
+ if (!declarations.length || ephemeral)
33
+ return [];
34
+ const pod = podInput;
35
+ if (!pod.spec)
36
+ return [];
37
+ const existing = pod.metadata?.annotations?.[ANNOTATION];
38
+ if (existing) {
39
+ if (JSON.parse(existing).admission === uid)
40
+ return [];
41
+ throw new Error("remove inherited spectest.dev/coverage annotations before creating another Pod");
42
+ }
43
+ const patch = [];
44
+ const volumes = [...(pod.spec.volumes ?? [])];
45
+ const instances = [];
46
+ const namedVolume = (stem, volume) => {
47
+ let name = stem;
48
+ for (let i = 1; volumes.some(v => v.name === name); i++)
49
+ name = `${stem}-${i}`;
50
+ volumes.push({ name, ...volume });
51
+ return name;
52
+ };
53
+ for (const group of ["initContainers", "containers"]) {
54
+ for (const [index, container] of (pod.spec[group] ?? []).entries()) {
55
+ const requested = canonicalImageReference(container.image);
56
+ let declaration = declarations.find(image => image.reference === requested);
57
+ // Tangent deploys the prepared image by digest. Resolve aliases from
58
+ // CRI's actual image record, never by stripping a repository tag.
59
+ if (!declaration && requested.includes("@")) {
60
+ for (const candidate of declarations) {
61
+ let info = metadata.get(candidate.reference);
62
+ if (!info)
63
+ metadata.set(candidate.reference, info = await deps.image(candidate.reference));
64
+ if (info.aliases.includes(requested)) {
65
+ declaration = candidate;
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ if (!declaration)
71
+ continue;
72
+ let info = metadata.get(declaration.reference);
73
+ if (!info)
74
+ metadata.set(declaration.reference, info = await deps.image(declaration.reference));
75
+ const id = hash(`${uid}/${group}/${container.name}`);
76
+ const name = service(cluster, declaration.reference);
77
+ const env = { ...info.env };
78
+ for (const entry of container.env ?? [])
79
+ if (entry.value !== undefined)
80
+ env[entry.name] = entry.value;
81
+ const configured = applyCoverageAdapters(name, { image: { type: "registry", reference: container.image }, env, coverage: declaration.coverage });
82
+ if (Object.keys(configured).some(key => !["image", "env", "coverage", "files"].includes(key))) {
83
+ throw new Error("Kubernetes coverage adapters may inject env and files only; configure the workload's command and mounts in its manifest");
84
+ }
85
+ const changed = Object.entries(configured.env ?? {}).filter(([key, value]) => env[key] !== value);
86
+ for (const [key] of changed)
87
+ if (container.env?.some(e => e.name === key && e.valueFrom))
88
+ throw new Error(`cannot inject coverage into dynamic ${key}`);
89
+ const mounts = [...(container.volumeMounts ?? [])];
90
+ const addMount = (mountPath, mount) => {
91
+ if (mounts.some(m => mountPath === m.mountPath || mountPath.startsWith(m.mountPath.replace(/\/$/, "") + "/")))
92
+ throw new Error(`coverage mount conflicts with ${mountPath}`);
93
+ mounts.push({ mountPath, ...mount });
94
+ };
95
+ const volume = namedVolume(`spectest-cov-${id}`, { hostPath: { path: `${MOUNT}/.pods/${id}`, type: "Directory" } });
96
+ addMount("/spectest/coverage", { name: volume });
97
+ const files = configured.files ?? [];
98
+ if (files.some(file => file.user !== undefined || file.group !== undefined || (file.mode !== undefined && !["444", "0444", "644", "0644"].includes(file.mode)))) {
99
+ throw new Error("Kubernetes coverage hook files must be world-readable and need no custom ownership");
100
+ }
101
+ if (files.length) {
102
+ const data = Object.fromEntries(files.map((file, index) => [`file${index}`, file.content]));
103
+ const configMap = `spectest-coverage-${hash(JSON.stringify(data))}`;
104
+ if (!dryRun)
105
+ await deps.files(namespace, configMap, data);
106
+ const fileVolume = namedVolume(`spectest-hooks-${id}`, { configMap: { name: configMap, defaultMode: 0o444 } });
107
+ files.forEach((file, index) => addMount(file.path, { name: fileVolume, subPath: `file${index}`, readOnly: true }));
108
+ }
109
+ const instance = { id, image: declaration.reference, namespace, container: container.name, root: container.workingDir || info.root || "/" };
110
+ if (!dryRun) {
111
+ const dir = path.join(coverageHostDir(WORKSPACE, cluster), ".pods", id);
112
+ await fs.mkdir(dir, { recursive: true, mode: 0o777 });
113
+ await fs.chmod(dir, 0o777);
114
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify(instance));
115
+ }
116
+ instances.push(instance);
117
+ patch.push({ op: "add", path: `/spec/${group}/${index}/env`, value: [
118
+ ...(container.env ?? []).filter(e => !changed.some(([key]) => key === e.name)),
119
+ ...changed.map(([name, value]) => ({ name, value })),
120
+ ] }, { op: "add", path: `/spec/${group}/${index}/volumeMounts`, value: mounts });
121
+ }
122
+ }
123
+ if (!instances.length)
124
+ return [];
125
+ patch.push({ op: "add", path: "/spec/volumes", value: volumes });
126
+ patch.push({ op: "add", path: "/metadata/annotations", value: { ...pod.metadata?.annotations,
127
+ [ANNOTATION]: JSON.stringify({ admission: uid, instances: instances.map(i => i.id) }),
128
+ } });
129
+ return patch;
130
+ },
131
+ adapter() {
132
+ return {
133
+ name: "k3s-containers",
134
+ // Include nested policy AND injected hooks in the warm identity. An
135
+ // adapter's functions would otherwise disappear from JSON.stringify.
136
+ toJSON() {
137
+ return { adapter: "k3s-containers", images: declarations.map(image => ({
138
+ reference: image.reference,
139
+ config: applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage }),
140
+ })) };
141
+ },
142
+ async collect(ctx, baseline) {
143
+ const root = path.join(ctx.reportDir, ".pods");
144
+ let entries;
145
+ try {
146
+ entries = await fs.readdir(root);
147
+ }
148
+ catch (error) {
149
+ if (error.code !== "ENOENT")
150
+ throw error;
151
+ entries = [];
152
+ }
153
+ // Native adapters flush through the shared Unix sockets, including
154
+ // after Pod deletion. Only inventory or command adapters need the API.
155
+ let pods;
156
+ const findPod = async (id) => {
157
+ pods ??= ctx.exec("kubectl get pods -A -o json --request-timeout=10s")
158
+ .then(result => JSON.parse(result.stdout).items);
159
+ return (await pods).find(p => {
160
+ const ids = p.metadata?.annotations?.[ANNOTATION];
161
+ return ids && JSON.parse(ids).instances.includes(id);
162
+ });
163
+ };
164
+ const captures = new Map();
165
+ const inventories = new Map();
166
+ for (const declaration of declarations)
167
+ captures.set(declaration.reference, { service: service(ctx.service, declaration.reference), reports: [] });
168
+ const collectInstance = async (id) => {
169
+ ctx.signal.throwIfAborted();
170
+ if (!/^[a-f0-9]{16}$/.test(id))
171
+ throw new Error("invalid coverage instance directory");
172
+ const dir = path.join(root, id);
173
+ const instance = JSON.parse(await fs.readFile(path.join(dir, ".instance.json"), "utf8"));
174
+ const declaration = declarations.find(d => d.reference === instance.image);
175
+ if (!declaration)
176
+ throw new Error(`coverage image no longer declared: ${instance.image}`);
177
+ const adapters = coverageAdapters(declaration.coverage);
178
+ const livePod = async () => {
179
+ const pod = await findPod(id);
180
+ const statuses = [...(pod?.status?.containerStatuses ?? []), ...(pod?.status?.initContainerStatuses ?? [])];
181
+ return statuses.some(status => status.name === instance.container && status.state?.running) ? pod : undefined;
182
+ };
183
+ for (const adapter of adapters) {
184
+ if (adapter.name === "node") {
185
+ try {
186
+ await fs.access(path.join(dir, `.attached-${adapter.name}`));
187
+ }
188
+ catch {
189
+ throw new Error(`coverage hook never attached to ${instance.namespace}/${instance.container} (${adapter.name})`);
190
+ }
191
+ }
192
+ }
193
+ const captured = await captureCoverageTarget({
194
+ coverage: declaration.coverage, root: instance.root, baseline,
195
+ inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
196
+ counterKey: `${service(ctx.service, instance.image)}/${id}`,
197
+ context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
198
+ exec: async (command) => {
199
+ const pod = await livePod();
200
+ if (!pod?.metadata?.name)
201
+ throw new Error(`coverage command requires live container ${instance.container}`);
202
+ return ctx.exec(`kubectl exec -n ${quote(instance.namespace)} ${quote(pod.metadata.name)} -c ${quote(instance.container)} -- sh -c ${quote(command)}`);
203
+ },
204
+ },
205
+ });
206
+ return { id, instance, captured };
207
+ };
208
+ // Bound simultaneous LLVM exports and API execs. Drain all workers
209
+ // even on error so no collector can race the following VM snapshot.
210
+ const pending = entries.sort();
211
+ const results = new Map();
212
+ let cursor = 0;
213
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(8, pending.length) }, async () => {
214
+ while (cursor < pending.length) {
215
+ const id = pending[cursor++];
216
+ results.set(id, await collectInstance(id));
217
+ }
218
+ }));
219
+ const failure = workers.find(worker => worker.status === "rejected");
220
+ if (failure?.status === "rejected")
221
+ throw failure.reason;
222
+ for (const id of pending) {
223
+ const { instance, captured } = results.get(id);
224
+ const aggregate = captures.get(instance.image);
225
+ if (captured.error)
226
+ aggregate.error = [aggregate.error, `${instance.namespace}/${instance.container}: ${captured.error}`].filter(Boolean).join("; ");
227
+ if (aggregate.root && aggregate.root !== instance.root) {
228
+ aggregate.error = "coverage containers from one image have different working directories; use a common workingDir for this image's covered containers";
229
+ }
230
+ aggregate.root = instance.root;
231
+ for (const report of captured.reports) {
232
+ if (report.name === "inventory.units") {
233
+ let units = inventories.get(instance.image);
234
+ if (!units)
235
+ inventories.set(instance.image, units = new Set());
236
+ for (const unit of report.content.split("\n"))
237
+ if (unit)
238
+ units.add(unit);
239
+ }
240
+ else
241
+ aggregate.reports.push({ ...report, name: `${id}/${report.name}` });
242
+ }
243
+ }
244
+ for (const [image, units] of inventories)
245
+ captures.get(image).reports.push({ name: "inventory.units", content: [...units].sort().join("\n") });
246
+ for (const capture of captures.values()) {
247
+ if (!capture.reports.length && !capture.error)
248
+ capture.reports.push({ name: "empty.lcov", content: "TN:\n" });
249
+ if (capture.reports.reduce((bytes, report) => bytes + Buffer.byteLength(report.content), 0) > COVERAGE_MAX_BYTES_PER_SERVICE) {
250
+ capture.reports = [];
251
+ capture.error = "Kubernetes image coverage exceeds the per-service report size limit";
252
+ }
253
+ }
254
+ return [...captures.values()];
255
+ },
256
+ };
257
+ },
258
+ };
259
+ }
@@ -12,7 +12,7 @@ interface Container {
12
12
  readOnly?: boolean;
13
13
  }[];
14
14
  }
15
- interface Pod {
15
+ export interface Pod {
16
16
  metadata?: {
17
17
  annotations?: Record<string, string>;
18
18
  };
@@ -32,7 +32,7 @@ interface Pod {
32
32
  ephemeralContainers?: Container[];
33
33
  };
34
34
  }
35
- interface Patch {
35
+ export interface Patch {
36
36
  op: "add";
37
37
  path: string;
38
38
  value: unknown;
@@ -43,6 +43,8 @@ export interface TrustDependencies {
43
43
  /** Ensure this immutable ConfigMap exists in the requesting namespace. */
44
44
  ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
45
45
  apply(configuration: unknown): Promise<void>;
46
+ /** Component-owned pod instrumentation runs after trust patching. */
47
+ mutatePod?(pod: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string): Promise<Patch[]>;
46
48
  }
47
49
  export declare function installPodTrust(options: {
48
50
  name: string;
@@ -75,6 +75,19 @@ export async function installPodTrust(options, deps) {
75
75
  return;
76
76
  }
77
77
  const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
78
+ if (deps.mutatePod) {
79
+ const pod = structuredClone(request.object);
80
+ for (const operation of patch) {
81
+ const keys = operation.path.slice(1).split("/");
82
+ const key = keys.pop();
83
+ const parent = keys.reduce((value, key) => value[key], pod);
84
+ if (key === "-")
85
+ parent.push(operation.value);
86
+ else
87
+ parent[key] = operation.value;
88
+ }
89
+ patch.push(...await deps.mutatePod(pod, request.namespace, !!request.dryRun, request.subResource === "ephemeralcontainers", request.uid));
90
+ }
78
91
  // Dry runs return the same patch but perform no API writes.
79
92
  if (patch.length && !request.dryRun)
80
93
  await deps.ensureBundle(request.namespace, configMap, options.bundle);
@@ -1,6 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import { waitForK3sNodes } from "./k3s-nodes.js";
3
3
  import { installPodTrust } from "./k3s-trust.js";
4
+ import { k3sCoverage } from "./k3s-coverage.js";
4
5
  import { waitForAll } from "../harness/concurrency.js";
5
6
  import { CONTENT_BLOBS, REGISTRY_CONTENT, REGISTRY_DATA, REGISTRY_IMAGE, registryDirectory, seedRegistry } from "../harness/registry-seed.js";
6
7
  import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
@@ -1335,6 +1336,7 @@ export function k3s(opts = {}) {
1335
1336
  function createK3sCluster(opts, count) {
1336
1337
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1337
1338
  const images = kubernetesImages(opts.images ?? []);
1339
+ const coverage = k3sCoverage(opts.images ?? []);
1338
1340
  const extra = opts.extraArgs ?? [];
1339
1341
  assertUsableExtraArgs(extra);
1340
1342
  const registryEnabled = opts.registry !== false;
@@ -1497,6 +1499,7 @@ function createK3sCluster(opts, count) {
1497
1499
  volumes: [{ name: `spectest-k3s-${cluster}-join`, target: JOIN_DIRECTORY }, ...(sticky
1498
1500
  ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1499
1501
  : [{ target: CLUSTER_STORE_TARGET }]),
1502
+ ...(coverage.enabled ? [coverage.volume(cluster)] : []),
1500
1503
  ...(server && publishBuilds.length ? [
1501
1504
  { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
1502
1505
  { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
@@ -1505,6 +1508,7 @@ function createK3sCluster(opts, count) {
1505
1508
  files: [
1506
1509
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
1507
1510
  ],
1511
+ ...(server && coverage.enabled ? { coverage: { adapters: [coverage.adapter()] } } : {}),
1508
1512
  // Agents start before the server and wait for its join token. The group
1509
1513
  // setup below gates readiness on every actual Kubernetes Node, including
1510
1514
  // the server, rather than treating container startup as cluster readiness.
@@ -1533,11 +1537,38 @@ function createK3sCluster(opts, count) {
1533
1537
  if (images.builds.length && !runtime)
1534
1538
  throw new Error(`k3s(${name}): prepared image inputs are missing`);
1535
1539
  const admission = (async () => {
1540
+ if (coverage.enabled && (!runtime?.caBundle || !caPresent()))
1541
+ throw new Error(`k3s(${name}): coverage injection requires the environment CA`);
1536
1542
  if (runtime?.caBundle && caPresent()) {
1537
1543
  const { cert, key } = await issueIngressCert([runtime.gateway]);
1538
1544
  await installPodTrust({ name, gateway: runtime.gateway, cert, key,
1539
1545
  ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
1540
1546
  }, {
1547
+ mutatePod: (pod, namespace, dryRun, ephemeral, uid) => coverage.mutate(name, pod, namespace, dryRun, ephemeral, uid, {
1548
+ async image(reference) {
1549
+ const result = await exec(name, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference]);
1550
+ if (result.exitCode !== 0)
1551
+ throw new Error(`coverage image metadata: ${result.stderr}`);
1552
+ const record = JSON.parse(result.stdout);
1553
+ const config = record.info?.imageSpec?.config;
1554
+ if (!config)
1555
+ throw new Error(`coverage image ${reference} has no OCI configuration`);
1556
+ return { aliases: record.status?.repoDigests ?? [], root: config.WorkingDir || "/",
1557
+ env: Object.fromEntries((config.Env ?? []).map((entry) => { const split = entry.indexOf("="); return [entry.slice(0, split), entry.slice(split + 1)]; })),
1558
+ };
1559
+ },
1560
+ async files(namespace, name, data) {
1561
+ try {
1562
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: { metadata: { name, namespace }, immutable: true, data } });
1563
+ }
1564
+ catch (error) {
1565
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name })).unwrap();
1566
+ if (!existing.immutable || Object.keys(existing.data ?? {}).length !== Object.keys(data).length ||
1567
+ Object.entries(data).some(([key, value]) => existing.data?.[key] !== value))
1568
+ throw error;
1569
+ }
1570
+ },
1571
+ }),
1541
1572
  apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
1542
1573
  ensureBundle: async (namespace, configMap, bundle) => {
1543
1574
  try {
@@ -0,0 +1,102 @@
1
+ // Copy to your test build as spectest-coverage.rs and include behind a coverage
2
+ // feature. Call start()? at the beginning of main in EACH instrumented binary.
3
+ // No Spectest service, compiler or network connection is needed at runtime.
4
+ use std::{ffi::CString, fs, io::{self, BufRead, Read, Write}, os::unix::net::UnixListener,
5
+ path::PathBuf, process::Command, sync::{Mutex, OnceLock}, time::{SystemTime, UNIX_EPOCH}};
6
+
7
+ unsafe extern "C" {
8
+ fn __llvm_profile_get_size_for_buffer() -> u64;
9
+ fn __llvm_profile_write_buffer(buffer: *mut u8) -> i32;
10
+ fn __llvm_profile_set_filename(filename: *const std::ffi::c_char);
11
+ fn atexit(callback: extern "C" fn()) -> i32;
12
+ }
13
+
14
+ struct Collector { dir: PathBuf, id: String, socket: PathBuf, binary: PathBuf, lock: Mutex<()> }
15
+ static COLLECTOR: OnceLock<Collector> = OnceLock::new();
16
+
17
+ fn failure(error: impl std::fmt::Display) {
18
+ if let Some(c) = COLLECTOR.get() {
19
+ let _ = fs::write(c.dir.join(format!("rust-error-{}.txt", c.id)), error.to_string());
20
+ }
21
+ }
22
+
23
+ fn tool(name: &str, default: &str, args: &[&std::ffi::OsStr]) -> io::Result<Vec<u8>> {
24
+ let output = Command::new(std::env::var_os(name).unwrap_or_else(|| default.into())).args(args).output()?;
25
+ if !output.status.success() {
26
+ return Err(io::Error::other(format!("{default}: {}", String::from_utf8_lossy(&output.stderr))));
27
+ }
28
+ Ok(output.stdout)
29
+ }
30
+
31
+ fn capture() -> io::Result<()> {
32
+ let c = COLLECTOR.get().expect("coverage initialized");
33
+ let _guard = c.lock.lock().map_err(|_| io::Error::other("coverage lock poisoned"))?;
34
+ let raw = c.dir.join(format!(".rust/{}.profraw", c.id));
35
+ let data = c.dir.join(format!(".rust/{}.profdata", c.id));
36
+ // A buffer snapshot overwrites this process's profile. write_file() with
37
+ // merge patterns would repeatedly add its inherited counters to itself.
38
+ let mut buffer = vec![0u8; unsafe { __llvm_profile_get_size_for_buffer() } as usize];
39
+ if unsafe { __llvm_profile_write_buffer(buffer.as_mut_ptr()) } != 0 {
40
+ return Err(io::Error::other("LLVM could not snapshot counters"));
41
+ }
42
+ fs::write(&raw, buffer)?;
43
+ tool("SPECTEST_LLVM_PROFDATA", "llvm-profdata", &["merge".as_ref(), "-sparse".as_ref(), raw.as_os_str(), "-o".as_ref(), data.as_os_str()])?;
44
+ let ignore = std::env::var("SPECTEST_RUST_IGNORE").unwrap_or_else(|_| "(/rustc/|/\\.cargo/registry/|/spectest-coverage\\.rs$)".into());
45
+ let report = tool("SPECTEST_LLVM_COV", "llvm-cov", &["export".as_ref(), "-format=lcov".as_ref(), "-instr-profile".as_ref(), data.as_os_str(),
46
+ c.binary.as_os_str(), format!("-ignore-filename-regex={ignore}").as_ref()])?;
47
+ if !report.starts_with(b"SF:") && !report.starts_with(b"TN:") {
48
+ return Err(io::Error::other("LLVM exported no LCOV source records"));
49
+ }
50
+ let mut report = String::from_utf8(report).map_err(io::Error::other)?;
51
+ if let Ok(root) = std::env::var("SPECTEST_RUST_BUILD_ROOT") {
52
+ let prefix = format!("SF:{}/", root.trim_end_matches('/'));
53
+ report = report.lines().map(|line| match line.strip_prefix(&prefix) {
54
+ Some(relative) => format!("SF:{relative}\n"), None => format!("{line}\n"),
55
+ }).collect();
56
+ }
57
+ let temporary = c.dir.join(format!(".rust/{}.lcov.tmp", c.id));
58
+ fs::write(&temporary, report)?;
59
+ fs::rename(temporary, c.dir.join(format!("rust-{}.lcov", c.id)))?;
60
+ Ok(())
61
+ }
62
+
63
+ extern "C" fn finish() {
64
+ if let Err(error) = capture() { failure(error); }
65
+ if let Some(c) = COLLECTOR.get() { let _ = fs::remove_file(&c.socket); }
66
+ }
67
+
68
+ /// Disabled when SPECTEST_COVERAGE_DIR is absent. Requires Unix and LLVM
69
+ /// instrumentation at link time; gate this module behind your coverage build.
70
+ pub fn start() -> io::Result<()> {
71
+ let Some(dir) = std::env::var_os("SPECTEST_COVERAGE_DIR") else { return Ok(()); };
72
+ if COLLECTOR.get().is_some() { return Ok(()); }
73
+ let dir = PathBuf::from(dir);
74
+ fs::create_dir_all(dir.join(".rust"))?;
75
+ let nonce = SystemTime::now().duration_since(UNIX_EPOCH).map_err(io::Error::other)?.as_nanos();
76
+ let id = format!("{:x}-{nonce:x}", std::process::id());
77
+ let socket = dir.join(format!(".rust-ctl-{id}"));
78
+ let listener = UnixListener::bind(&socket)?;
79
+ let binary = std::env::current_exe()?;
80
+ let exit_path = CString::new(dir.join(format!(".rust/{id}.exit.profraw")).as_os_str().as_encoded_bytes())?;
81
+ // The profiler may retain the filename until process exit.
82
+ unsafe { __llvm_profile_set_filename(exit_path.into_raw()); }
83
+ COLLECTOR.set(Collector { dir, id, socket, binary, lock: Mutex::new(()) }).map_err(|_| io::Error::other("coverage already initialized"))?;
84
+ if unsafe { atexit(finish) } != 0 { return Err(io::Error::other("could not register coverage exit handler")); }
85
+ // Establish a report before readiness; an uninstrumented/mismatched build
86
+ // must fail before it can supply an apparently empty baseline.
87
+ if let Err(error) = capture() { failure(&error); return Err(error); }
88
+ std::thread::Builder::new().name("spectest-coverage".into()).spawn(move || {
89
+ for connection in listener.incoming() {
90
+ let mut stream = match connection { Ok(stream) => stream, Err(error) => { failure(error); break; } };
91
+ let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
92
+ let mut request = String::new();
93
+ let result = io::BufReader::new(&stream).take(32).read_line(&mut request)
94
+ .and_then(|_| if request == "dump\n" { capture() } else { Err(io::Error::other("invalid coverage request")) });
95
+ match result {
96
+ Ok(()) => { let _ = stream.write_all(b"ok\n"); }
97
+ Err(error) => { failure(&error); let _ = writeln!(stream, "error {error}"); }
98
+ }
99
+ }
100
+ })?;
101
+ Ok(())
102
+ }
@@ -0,0 +1,10 @@
1
+ import { type CoverageAdapter, type NodeCoverageOptions } from "./coverage.js";
2
+ /** Bun's native precise profiler, for `bun build --target=bun` bundles.
3
+ * Keep external or inline source maps with the bundle. Raw TS/JS requiring
4
+ * runtime transpilation is rejected because Bun cannot supply its compiled
5
+ * source through the in-process inspector. */
6
+ export interface BunCoverageOptions extends NodeCoverageOptions {
7
+ /** Only for idle command containers where no Bun process has run yet. */
8
+ allowEmpty?: boolean;
9
+ }
10
+ export declare function bun(options?: BunCoverageOptions): CoverageAdapter;
@@ -0,0 +1,32 @@
1
+ import { appendEnvFlag, node, NODE_COVERAGE_HOOK, NODE_COVERAGE_HOOK_PATH } from "./coverage.js";
2
+ import { access } from "node:fs/promises";
3
+ import path from "node:path";
4
+ export function bun(options = {}) {
5
+ const adapter = node(options);
6
+ return {
7
+ ...adapter,
8
+ name: "bun",
9
+ configure(service, info) {
10
+ const configured = adapter.configure(service, info);
11
+ const env = appendEnvFlag(service.env, "BUN_OPTIONS", `--preload ${NODE_COVERAGE_HOOK_PATH}`);
12
+ return { ...configured, env: {
13
+ ...configured.env, BUN_OPTIONS: env.BUN_OPTIONS,
14
+ // The Node adapter also contributes metadata for generated bundles.
15
+ NODE_OPTIONS: service.env?.NODE_OPTIONS ?? "",
16
+ SPECTEST_COVERAGE_RUNTIME: "bun",
17
+ }, files: [...(service.files ?? []), { path: NODE_COVERAGE_HOOK_PATH, content: NODE_COVERAGE_HOOK }] };
18
+ },
19
+ inventory: ctx => adapter.inventory({ ...ctx, exec: command => ctx.exec(command.replace(/\| node -/g, "| env BUN_OPTIONS= SPECTEST_COVERAGE_DIR= bun run -")) }),
20
+ async capture(ctx) {
21
+ if (!options.allowEmpty) {
22
+ try {
23
+ await access(path.join(ctx.reportDir, ".attached-bun"));
24
+ }
25
+ catch {
26
+ throw new Error("Bun coverage hook never attached; use a supported Bun runtime and preserve BUN_OPTIONS");
27
+ }
28
+ }
29
+ await adapter.capture(ctx);
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,23 @@
1
+ import type { CoverageAdapter, CoverageSourceOptions } from "./coverage.js";
2
+ export interface RustCoverageOptions extends CoverageSourceOptions {
3
+ /** Only for idle command containers where no Rust process has run yet. */
4
+ allowEmpty?: boolean;
5
+ /** Compiler workspace directory, when different from the runtime workdir.
6
+ * Exact SF paths below it are made repository-relative before archiving. */
7
+ buildRoot?: string;
8
+ /** Matching rustup llvm-tools-preview executables, in the runtime image. */
9
+ llvmCov?: string;
10
+ llvmProfdata?: string;
11
+ /** LLVM filename regex for external/generated sources, not application code. */
12
+ ignoreFilenameRegex?: string;
13
+ }
14
+ /** Source for a test-build-only module, copied as spectest-coverage.rs. The
15
+ * application links only this std-only helper, never the JavaScript SDK. */
16
+ export declare function rustHelperSource(): string;
17
+ /** Flush a socket-backed collector. A dead process without a final flush is
18
+ * incomplete coverage, never an empty successful report. */
19
+ export declare function flushCoverageSockets(dir: string, prefix: string, signal: AbortSignal): Promise<number>;
20
+ /** Requires instrumented binaries calling the shipped coverage/rust.rs helper.
21
+ * The helper exports cumulative per-process LCOV before exit and on request;
22
+ * no compiler tools or binaries need to be copied out of terminated pods. */
23
+ export declare function rust(options?: RustCoverageOptions): CoverageAdapter;
@@ -0,0 +1,71 @@
1
+ import { promises as fs, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { connect } from "node:net";
4
+ /** Source for a test-build-only module, copied as spectest-coverage.rs. The
5
+ * application links only this std-only helper, never the JavaScript SDK. */
6
+ export function rustHelperSource() {
7
+ return readFileSync(new URL("./coverage/rust.rs", import.meta.url), "utf8");
8
+ }
9
+ /** Flush a socket-backed collector. A dead process without a final flush is
10
+ * incomplete coverage, never an empty successful report. */
11
+ export async function flushCoverageSockets(dir, prefix, signal) {
12
+ const names = (await fs.readdir(dir)).filter(name => name.startsWith(prefix));
13
+ for (const name of names) {
14
+ signal.throwIfAborted();
15
+ await new Promise((resolve, reject) => {
16
+ const socket = connect(path.join(dir, name), () => socket.end("dump\n"));
17
+ let reply = "";
18
+ const abort = () => socket.destroy(new Error(`coverage flush aborted: ${name}`));
19
+ signal.addEventListener("abort", abort, { once: true });
20
+ socket.setTimeout(55_000, () => socket.destroy(new Error(`coverage flush timed out: ${name}`)));
21
+ socket.on("data", chunk => { reply += chunk; if (reply.length > 8192)
22
+ socket.destroy(new Error("oversized coverage reply")); });
23
+ socket.on("error", error => reject(new Error(`coverage process ${name} exited without a final report or could not be reached: ${error.message}`)));
24
+ socket.on("close", () => {
25
+ signal.removeEventListener("abort", abort);
26
+ if (reply.trim() === "ok")
27
+ resolve();
28
+ else
29
+ reject(new Error(`coverage process ${name} did not flush: ${reply.trim() || "process exited without a final report"}`));
30
+ });
31
+ });
32
+ }
33
+ return names.length;
34
+ }
35
+ /** Requires instrumented binaries calling the shipped coverage/rust.rs helper.
36
+ * The helper exports cumulative per-process LCOV before exit and on request;
37
+ * no compiler tools or binaries need to be copied out of terminated pods. */
38
+ export function rust(options = {}) {
39
+ const { sourceRoot, excludeSources, llvmCov, llvmProfdata, ignoreFilenameRegex, buildRoot } = options;
40
+ if (buildRoot !== undefined && (!buildRoot.startsWith("/") || buildRoot === "/" || /[\0\r\n]/.test(buildRoot) || buildRoot.split("/").includes("..")))
41
+ throw new Error("Rust buildRoot must be an absolute compiler workspace directory");
42
+ for (const value of [llvmCov, llvmProfdata, ignoreFilenameRegex]) {
43
+ if (value !== undefined && (!value || /[\0\r\n]/.test(value)))
44
+ throw new Error("invalid Rust coverage option");
45
+ }
46
+ return {
47
+ name: "rust", reports: "cumulative",
48
+ sourceOptions: { ...(sourceRoot !== undefined ? { sourceRoot } : {}), ...(excludeSources ? { excludeSources } : {}) },
49
+ ownsReport: name => /^rust-.*\.lcov$/.test(name),
50
+ configure: service => ({ ...service, env: { ...service.env,
51
+ SPECTEST_COVERAGE_DIR: "/spectest/coverage",
52
+ ...(buildRoot ? { SPECTEST_RUST_BUILD_ROOT: buildRoot } : {}),
53
+ ...(llvmCov ? { SPECTEST_LLVM_COV: llvmCov } : {}),
54
+ ...(llvmProfdata ? { SPECTEST_LLVM_PROFDATA: llvmProfdata } : {}),
55
+ ...(ignoreFilenameRegex ? { SPECTEST_RUST_IGNORE: ignoreFilenameRegex } : {}),
56
+ } }),
57
+ async capture(ctx) {
58
+ await flushCoverageSockets(ctx.reportDir, ".rust-ctl-", ctx.signal);
59
+ const names = await fs.readdir(ctx.reportDir);
60
+ if (names.some(name => name.startsWith("rust-error-")))
61
+ throw new Error("Rust coverage helper failed; inspect rust-error report");
62
+ if (!names.some(name => /^rust-.*\.lcov$/.test(name))) {
63
+ if (options.allowEmpty) {
64
+ await ctx.writeReport("rust-empty.lcov", "TN:\n");
65
+ return;
66
+ }
67
+ throw new Error("no Rust coverage reports: build with -C instrument-coverage and call spectest_coverage::start() before application work");
68
+ }
69
+ },
70
+ };
71
+ }