Skip to main content

supercov_engine/
go_run.rs

1//! Public, isolated Go coverage run lifecycle.
2//!
3//! Supercov owns the probes here, which means it rewrites the module's own
4//! sources. That cannot happen in the user's tree, so the run copies the
5//! project into an isolated workspace, instruments it there, and runs the
6//! user's command against the copy. Nothing in the tree the author edits is
7//! touched.
8//!
9//! Two facts about `go test` shape the rest:
10//!
11//! - It builds and runs **one test binary per package**, each its own process
12//!   with its own probe array. So each test package writes its own evidence
13//!   file and the run merges them, rather than every process racing to write
14//!   one path.
15//! - It **caches** successful results. A cached package does not run, so it
16//!   records nothing, and a run that silently measured half a suite is worse
17//!   than one that took longer. The run adds `-count=1` when the command does
18//!   not already say otherwise, and says so.
19
20use std::{
21    collections::{BTreeMap, BTreeSet},
22    ffi::OsString,
23    fs,
24    io::Write,
25    path::{Path, PathBuf},
26    time::Instant,
27};
28
29use serde::{Deserialize, Serialize};
30
31use crate::{
32    evidence_archive::write_archive,
33    frontend_protocol::validate_frontend_report_request,
34    go_instrumenter::{RUNTIME_ALIAS, RUNTIME_IMPORT, rewrite},
35    go_project::{PreparedGoProject, go_integrity_inputs, module_path, prepare_go_project},
36    go_test_harness::{instrument_test_file, probe_array_file, synthesized_harness},
37    integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
38    lifecycle::{
39        ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
40        remove_stored_tree_deferred,
41    },
42    orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
43    owned_evidence::{
44        OwnedRunInputs, OwnedTestOutcome, build_frontend_run, go_coverage_model, go_declaration,
45        merge_evidence, read_evidence,
46    },
47    process_supervision::{CommandSpec, SupervisionOptions},
48    run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
49    workspace::{canonicalize_simplified, prepare_cached_workspace, recover_cached_workspace},
50};
51
52/// Where the runtime package lands inside the workspace.
53///
54/// A plain directory rather than something hidden: the Go tool ignores any
55/// directory whose name begins with `.` or `_`, so a runtime placed out of
56/// sight would also be out of the build.
57const RUNTIME_DIRECTORY: &str = "supercov_runtime";
58
59const RUNTIME_SOURCE: &str = include_str!("../runtime-assets/go/supercov/supercov.go");
60
61/// The generated file that declares a package's probe array.
62const PROBE_FILE: &str = "supercov_probes.go";
63
64/// The generated file that arms the runtime and binds each test.
65const HARNESS_FILE: &str = "supercov_generated_test.go";
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct DirectGoRunRequest {
70    pub root: PathBuf,
71    pub command: Vec<String>,
72    pub run_id: String,
73    pub started_at: String,
74}
75
76#[derive(Debug, Clone, PartialEq)]
77pub struct DirectGoRunResult {
78    pub run_id: String,
79    pub run_directory: PathBuf,
80    pub exit_code: i32,
81    pub tests: usize,
82    pub source_files: usize,
83    pub packages: usize,
84    pub recovered_runs: Vec<String>,
85    pub metadata: RunMetadata,
86}
87
88fn elapsed_ms(started: Instant) -> f64 {
89    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
90}
91
92/// One package whose tests will run, and where its evidence lands.
93#[derive(Debug, Clone)]
94struct GoTestPackage {
95    /// Directory relative to the workspace root, `/`-separated, `.` for the
96    /// module root.
97    directory: String,
98    evidence: PathBuf,
99    /// Which file declared each test, so a result can point at its source.
100    declared_in: BTreeMap<String, String>,
101}
102
103struct InstrumentedWorkspace {
104    project: PreparedGoProject,
105    packages: Vec<GoTestPackage>,
106}
107
108fn directory_of(relative: &str) -> String {
109    match relative.rsplit_once('/') {
110        Some((directory, _)) => directory.to_owned(),
111        None => ".".to_owned(),
112    }
113}
114
115/// The `package` a Go file declares.
116///
117/// Scanned rather than parsed: the clause is the first thing in a Go file that
118/// is not a comment, so stripping comments and reading the next word gets the
119/// same answer as a parse for a fraction of the cost — and preparing a project
120/// already parses every file once, which is enough.
121fn package_name(source: &str) -> Option<String> {
122    let bytes = source.as_bytes();
123    let mut at = 0;
124    while at < bytes.len() {
125        let rest = &source[at..];
126        if rest.starts_with("//") {
127            at += rest.find('\n').map_or(rest.len(), |end| end + 1);
128        } else if rest.starts_with("/*") {
129            // An unterminated comment is not Go; there is no clause to find.
130            at += rest.find("*/").map_or(rest.len(), |end| end + 2);
131        } else if bytes[at].is_ascii_whitespace() {
132            at += 1;
133        } else {
134            return rest
135                .strip_prefix("package")
136                .and_then(|rest| rest.strip_prefix(|c: char| c.is_ascii_whitespace()))
137                .and_then(|rest| rest.split_whitespace().next())
138                .map(str::to_owned);
139        }
140    }
141    None
142}
143
144fn read(path: &Path) -> Result<String, String> {
145    fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))
146}
147
148fn write(path: &Path, contents: &str) -> Result<(), String> {
149    if let Some(parent) = path.parent() {
150        fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
151    }
152    fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
153}
154
155/// A file-system-safe name for a package's evidence, so two packages cannot
156/// collide on one path.
157fn evidence_name(directory: &str) -> String {
158    let slug = directory
159        .chars()
160        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
161        .collect::<String>();
162    format!("{slug}.bin")
163}
164
165/// Rewrite the workspace in place: instrumented sources, the runtime package
166/// they import, a probe array per package, and a harness per test package.
167fn instrument_workspace(
168    workspace: &Path,
169    evidence_directory: &Path,
170) -> Result<InstrumentedWorkspace, String> {
171    let project = prepare_go_project(workspace)?;
172
173    // A go.work repository has several modules and no module at its root, so
174    // each gets a runtime of its own: an import is resolved against the module
175    // that names it, and one module cannot import a package inside another.
176    let members = crate::go_project::workspace_modules(workspace);
177    let directories = if members.is_empty() {
178        vec![".".to_owned()]
179    } else {
180        members.into_iter().collect::<Vec<_>>()
181    };
182    let mut runtimes: Vec<(String, String)> = Vec::new();
183    for directory in &directories {
184        let at = workspace.join(directory);
185        let module = module_path(&at).ok_or_else(|| {
186            format!(
187                "{}: no module path in go.mod, so Supercov cannot name the package its runtime is imported from",
188                at.display()
189            )
190        })?;
191        write(
192            &at.join(RUNTIME_DIRECTORY).join("supercov.go"),
193            RUNTIME_SOURCE,
194        )?;
195        runtimes.push((directory.clone(), format!("{module}/{RUNTIME_DIRECTORY}")));
196    }
197    // Longest first, so a nested module wins over the root it sits under.
198    runtimes.sort_by_key(|(directory, _)| std::cmp::Reverse(directory.len()));
199    let import_for = |relative: &str| -> &str {
200        runtimes
201            .iter()
202            .find(|(directory, _)| {
203                directory == "."
204                    || relative == directory
205                    || relative.starts_with(&format!("{directory}/"))
206            })
207            .map(|(_, import)| import.as_str())
208            .unwrap_or_default()
209    };
210
211    // Probe ids are handed out across the whole module, so every package
212    // reserves the same total: a probe from one package is the same index in
213    // the binary that links it as a dependency.
214    let probe_count = project
215        .probes
216        .keys()
217        .max()
218        .map_or(0, |highest| *highest as usize + 1);
219
220    let mut source_packages: BTreeMap<String, String> = BTreeMap::new();
221    for (relative, instrumented) in &project.instrumented {
222        let instrumented = instrumented.replace(RUNTIME_IMPORT, import_for(relative));
223        let path = workspace.join(relative);
224        if let Some(name) = package_name(&instrumented) {
225            source_packages
226                .entry(directory_of(relative))
227                .or_insert(name);
228        }
229        write(&path, &instrumented)?;
230    }
231    for (directory, package) in &source_packages {
232        write(
233            &workspace.join(directory).join(PROBE_FILE),
234            &probe_array_file(
235                package,
236                RUNTIME_ALIAS,
237                import_for(&format!("{directory}/x.go")),
238                probe_count,
239            ),
240        )?;
241    }
242
243    let mut by_directory: BTreeMap<String, Vec<&String>> = BTreeMap::new();
244    for relative in &project.files.tests {
245        by_directory
246            .entry(directory_of(relative))
247            .or_default()
248            .push(relative);
249    }
250
251    let mut packages = Vec::new();
252    for (directory, tests) in by_directory {
253        let evidence = evidence_directory.join(evidence_name(&directory));
254        let evidence_literal = evidence.to_string_lossy().replace('\\', "\\\\");
255        let mut declared_in = BTreeMap::new();
256        let mut declares_test_main = false;
257        // The harness joins whichever package the tests are in. A directory
258        // can hold both `foo` and `foo_test` files; the internal one wins,
259        // because that is where the instrumented sources are.
260        let mut harness_package: Option<String> = None;
261        for relative in tests {
262            let path = workspace.join(relative);
263            let source = read(&path)?;
264            let Some(name) = package_name(&source) else {
265                continue;
266            };
267            let file = instrument_test_file(&source, RUNTIME_ALIAS, &evidence_literal)
268                .map_err(|error| format!("{relative}: {error}"))?;
269            declares_test_main |= file.declares_test_main;
270            for test in &file.tests {
271                declared_in.insert(test.clone(), relative.clone());
272            }
273            let internal = source_packages.get(&directory) == Some(&name);
274            if internal || harness_package.is_none() {
275                harness_package = Some(name);
276            }
277            if !file.edits.is_empty() {
278                write(
279                    &path,
280                    &rewrite(&source, &file.edits).replace(RUNTIME_IMPORT, import_for(relative)),
281                )?;
282            }
283        }
284        let Some(package) = harness_package else {
285            continue;
286        };
287        write(
288            &workspace.join(&directory).join(HARNESS_FILE),
289            &synthesized_harness(
290                &package,
291                RUNTIME_ALIAS,
292                import_for(&format!("{directory}/x.go")),
293                probe_count,
294                &project.decision_widths,
295                &evidence_literal,
296                declares_test_main,
297            ),
298        )?;
299        packages.push(GoTestPackage {
300            directory,
301            evidence,
302            declared_in,
303        });
304    }
305    Ok(InstrumentedWorkspace { project, packages })
306}
307
308/// `go test` caches a package that passed, and a cached package does not run.
309fn command_with_fresh_results(command: &[String]) -> (Vec<String>, bool) {
310    if command
311        .iter()
312        .any(|argument| argument == "-count" || argument.starts_with("-count="))
313    {
314        return (command.to_vec(), false);
315    }
316    let mut updated = command.to_vec();
317    // After the subcommand, so `go -count=1 test` is never produced.
318    let position = updated
319        .iter()
320        .position(|argument| argument == "test")
321        .map_or(updated.len(), |index| index + 1);
322    updated.insert(position, "-count=1".into());
323    (updated, true)
324}
325
326/// The fingerprint a later query compares against the stored run.
327pub fn current_go_integrity(
328    root: &Path,
329    command: &[String],
330) -> Result<crate::run_store::RunIntegrity, String> {
331    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
332    let files = crate::go_project::discover_go_files(&root)?;
333    create_explicit_run_integrity(
334        &root,
335        &go_integrity_inputs(&files, command),
336        &FrontendIntegrityInputs::embedded_go(),
337    )
338    .map_err(|error| error.to_string())
339}
340
341pub fn run_direct_go(
342    request: &DirectGoRunRequest,
343    diagnostics: &mut dyn Write,
344) -> Result<DirectGoRunResult, String> {
345    if request.command.is_empty() {
346        return Err("test command must not be empty".into());
347    }
348    let total_started = Instant::now();
349    let initialization_started = Instant::now();
350    let root = canonicalize_simplified(&request.root)
351        .map_err(|error| format!("{}: {error}", request.root.display()))?;
352    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
353        .map_err(|error| error.to_string())?;
354    let initialization_ms = elapsed_ms(initialization_started);
355    let work_directory = root.join(".supercov/work").join(&request.run_id);
356    let result = (|| {
357        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
358            .map_err(|error| error.to_string())?;
359        if !recovered_runs.is_empty() {
360            writeln!(
361                diagnostics,
362                "[supercov] recovered abandoned run(s): {}",
363                recovered_runs.join(", ")
364            )
365            .map_err(|error| error.to_string())?;
366        }
367
368        let adapter_started = Instant::now();
369        let files = crate::go_project::discover_go_files(&root)?;
370        let integrity_inputs = go_integrity_inputs(&files, &request.command);
371        let assertion_inputs =
372            crate::assertion_inputs::capture(&root, "go", integrity_inputs.assertion_paths())?;
373        let integrity = create_explicit_run_integrity(
374            &root,
375            &integrity_inputs,
376            &FrontendIntegrityInputs::embedded_go(),
377        )
378        .map_err(|error| error.to_string())?;
379
380        let workspace_started = Instant::now();
381        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
382        let workspace =
383            prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
384        let evidence_directory = work_directory.join("go/evidence");
385        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
386        let instrumented = instrument_workspace(&workspace, &evidence_directory)?;
387        let workspace_preparation_ms = elapsed_ms(workspace_started);
388        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
389        writeln!(
390            diagnostics,
391            "[supercov] detected Go; instrumenting {} source file(s) across {} test package(s) in isolated workspace {}",
392            instrumented.project.instrumented.len(),
393            instrumented.packages.len(),
394            workspace.display()
395        )
396        .map_err(|error| error.to_string())?;
397        for (file, reason) in &instrumented.project.unparseable {
398            writeln!(
399                diagnostics,
400                "[supercov] could not parse {file}: {reason}; it carries no obligations"
401            )
402            .map_err(|error| error.to_string())?;
403        }
404        if instrumented.packages.is_empty() {
405            return Err(
406                "no Go test packages were found, so a run would measure nothing: Supercov needs at least one _test.go file"
407                    .into(),
408            );
409        }
410
411        let (command, forced_fresh) = command_with_fresh_results(&request.command);
412        if forced_fresh {
413            writeln!(
414                diagnostics,
415                "[supercov] added -count=1: `go test` caches passing packages, and a cached package does not run, so it would record no coverage"
416            )
417            .map_err(|error| error.to_string())?;
418        }
419        let test_started = Instant::now();
420        let plan = ExecutionPlan {
421            preparation: Vec::new(),
422            test: ExecutionPhase {
423                name: "test".into(),
424                kind: PhaseKind::Test,
425                command: CommandSpec {
426                    program: command[0].clone().into(),
427                    arguments: command[1..].iter().map(OsString::from).collect(),
428                    cwd: workspace.clone(),
429                    environment: None,
430                    captured_output: None,
431                },
432            },
433        };
434        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
435        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
436            .map_err(|error| error.to_string())?;
437        let test_command_ms = elapsed_ms(test_started);
438        if let Some(signal) = execution.interrupted_signal {
439            return Err(format!(
440                "the test command was interrupted by {signal:?}; no run was published"
441            ));
442        }
443        let exit_code = execution.exit_code;
444
445        let publication_started = Instant::now();
446        let mut parts = Vec::new();
447        let mut outcomes = Vec::new();
448        let mut silent = Vec::new();
449        for package in &instrumented.packages {
450            let Ok(bytes) = fs::read(&package.evidence) else {
451                silent.push(package.directory.clone());
452                continue;
453            };
454            let evidence = read_evidence(&bytes)
455                .map_err(|error| format!("{}: {error}", package.evidence.display()))?;
456            for test in &evidence.tests {
457                outcomes.push(OwnedTestOutcome {
458                    name: test.name.clone(),
459                    runner: test.runner.clone(),
460                    package: package.directory.clone(),
461                    file: package.declared_in.get(&test.name).cloned(),
462                    status: test.status.clone(),
463                });
464            }
465            parts.push(evidence);
466        }
467        if !silent.is_empty() {
468            writeln!(
469                diagnostics,
470                "[supercov] {} test package(s) wrote no evidence and are absent from this run: {}",
471                silent.len(),
472                silent.join(", ")
473            )
474            .map_err(|error| error.to_string())?;
475        }
476        if outcomes.is_empty() {
477            return Err(format!(
478                "no Go test recorded evidence (the command exited {exit_code}); a run that measured nothing is not published"
479            ));
480        }
481        let evidence = merge_evidence(parts);
482        let run = build_frontend_run(OwnedRunInputs {
483            declaration: go_declaration(),
484            environment: "go",
485            manifest: &instrumented.project.manifest,
486            probes: &instrumented.project.probes,
487            evidence: &evidence,
488            outcomes: &outcomes,
489            run_id: &request.run_id,
490            generated_at: &request.started_at,
491            test_exit_code: exit_code,
492            coverage_model: go_coverage_model(),
493        })
494        .map_err(|error| error.to_string())?;
495        // Before anything is written: a run that cannot be read back is not a
496        // run, and finding that out at publication is far better than finding
497        // it out when someone asks for the report.
498        validate_frontend_report_request(&run.declaration, &run.request)
499            .map_err(|error| error.to_string())?;
500        let archive_path = work_directory.join("evidence.raw.gz");
501        let raw = write_archive(
502            crate::assertion_inputs::append(
503                run.archive_entries().map_err(|error| error.to_string())?,
504                &assertion_inputs,
505            )?,
506            &archive_path,
507        )
508        .map_err(|error| error.to_string())?;
509        let evidence_publication_ms = elapsed_ms(publication_started);
510
511        let timings = RunTimings {
512            initialization_ms,
513            workspace_preparation_ms,
514            adapter_setup_ms,
515            instrumented_build_ms: 0.0,
516            test_command_ms,
517            evidence_publication_ms,
518        };
519        let metadata = RunMetadata {
520            id: request.run_id.clone(),
521            started_at: request.started_at.clone(),
522            duration_ms: elapsed_ms(total_started),
523            command: request.command.clone(),
524            test_exit_code: Some(exit_code),
525            integrity,
526            raw_evidence: RawEvidenceMetadata {
527                schema_version: raw.schema_version,
528                format: raw.format.into(),
529                file: raw.file.into(),
530                files: raw.files,
531                uncompressed_bytes: raw.uncompressed_bytes,
532                compressed_bytes: raw.compressed_bytes,
533            },
534            isolated_build: Some(true),
535            instrumented_build_cache: None,
536            timings: Some(timings),
537            merged: None,
538            parents: None,
539        };
540        let run_directory =
541            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
542        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
543        Ok(DirectGoRunResult {
544            run_id: request.run_id.clone(),
545            run_directory,
546            exit_code,
547            tests: outcomes
548                .iter()
549                .map(|outcome| outcome.name.as_str())
550                .collect::<BTreeSet<_>>()
551                .len(),
552            source_files: instrumented.project.instrumented.len(),
553            packages: instrumented.packages.len(),
554            recovered_runs,
555            metadata,
556        })
557    })();
558    if result.is_err() {
559        let _ = remove_stored_tree_deferred(&root, &work_directory);
560    }
561    let release = lock.release().map_err(|error| error.to_string());
562    match (result, release) {
563        (Ok(result), Ok(())) => Ok(result),
564        (Err(error), _) => Err(error),
565        (Ok(_), Err(error)) => Err(error),
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn the_package_clause_is_found_past_whatever_precedes_it() {
575        // A build tag, a licence header, a block comment: all of them sit
576        // above the clause in real Go, and a reader that took the first line
577        // would find none of them.
578        assert_eq!(package_name("package main\n").as_deref(), Some("main"));
579        assert_eq!(
580            package_name("//go:build linux\n\n// Copyright.\npackage worker\n").as_deref(),
581            Some("worker")
582        );
583        assert_eq!(
584            package_name("/*\nA package comment\nspanning lines.\n*/\npackage doc\n").as_deref(),
585            Some("doc")
586        );
587        assert_eq!(
588            package_name("/* one line */ package inline\n").as_deref(),
589            Some("inline")
590        );
591        // A trailing comment is not part of the name.
592        assert_eq!(
593            package_name("package api // the public surface\n").as_deref(),
594            Some("api")
595        );
596        assert_eq!(package_name("import \"fmt\"\n"), None);
597    }
598
599    #[test]
600    fn caching_is_disabled_unless_the_author_already_chose() {
601        // `go test` skips a package whose result it has cached, and a package
602        // that does not run records nothing. A run that measured half a suite
603        // without saying so would be worse than a slow one.
604        let (command, forced) =
605            command_with_fresh_results(&["go".into(), "test".into(), "./...".into()]);
606        assert!(forced);
607        assert_eq!(command, ["go", "test", "-count=1", "./..."]);
608
609        // An author who already said how many times to run means it.
610        let (command, forced) =
611            command_with_fresh_results(&["go".into(), "test".into(), "-count=3".into()]);
612        assert!(!forced);
613        assert_eq!(command, ["go", "test", "-count=3"]);
614    }
615
616    #[test]
617    fn each_package_gets_an_evidence_file_of_its_own() {
618        // One test binary per package, each its own process: sharing a path
619        // would mean the last one to finish overwrote everyone else.
620        assert_ne!(evidence_name("internal/auth"), evidence_name("internal/db"));
621        assert_eq!(evidence_name("."), "_.bin");
622        assert!(!evidence_name("internal/auth").contains('/'));
623    }
624}