blob: 44cab7883b10ed5476c7e53c8951c34697265e0a [file]
// Copyright 2026 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"math"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"dawn.googlesource.com/dawn/tools/src/fileutils"
"dawn.googlesource.com/dawn/tools/src/glob"
"dawn.googlesource.com/dawn/tools/src/oswrapper"
)
// Experiment mode path stems that get used multiple times
const (
kExperimentCorporaSubDir = "corpora"
kExperimentCorpusSubDir = "corpus"
kExperimentBinarySubDir = "bin"
kExperimentSettingsFile = "experiment.json"
kExperimentTaskStateFile = "state.json"
)
// CorpusDef defines the name and relative path of a fuzzer corpus.
type CorpusDef struct {
Name string `json:"name"`
Path string `json:"path"`
}
// DurationDef defines the configuration for a specific execution length.
// Seconds and Runs control the stopping criteria for a single fuzzer iteration and are mutually exclusive.
// Iterations provides an optional override for the experiment's default number of iterations.
type DurationDef struct {
Seconds *int `json:"seconds,omitempty"`
Runs *int `json:"runs,omitempty"`
Iterations *int `json:"iterations,omitempty"`
}
// NormalizationScore holds the mean and standard error of the mean (SEM) of the normalization profiling iterations.
type NormalizationScore struct {
Mean float64 `json:"mean"`
SEM float64 `json:"sem"`
}
// ExperimentSettings represents the structure of the experiment.json settings file.
type ExperimentSettings struct {
Name string `json:"name"`
Hash string `json:"hash"`
Fuzzers []string `json:"fuzzers"`
Timeout *int `json:"timeout,omitempty"`
BurnInEnabled *bool `json:"burnin_enabled,omitempty"`
BurnInDuration *int `json:"burnin_duration,omitempty"`
NormalizationDuration *int `json:"normalization_duration,omitempty"`
NormalizationIterations *int `json:"normalization_iterations,omitempty"`
WgslNormalizationCorpus string `json:"wgsl_normalization_corpus"`
IrNormalizationCorpus string `json:"ir_normalization_corpus"`
WgslCorpora []CorpusDef `json:"wgsl_corpora"`
IrCorpora []CorpusDef `json:"ir_corpora"`
DefaultIterations int `json:"default_iterations"`
Durations []DurationDef `json:"durations"`
}
type IterStateStatus string
const (
IterStatePending IterStateStatus = "pending"
IterStateRunning IterStateStatus = "running"
IterStateCompleted IterStateStatus = "completed"
)
func (s *IterStateStatus) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
switch IterStateStatus(str) {
case IterStatePending, IterStateRunning, IterStateCompleted:
*s = IterStateStatus(str)
return nil
default:
return fmt.Errorf("invalid status: %s", str)
}
}
type ExperimentLimitType string
const (
ExperimentLimitTypeSeconds ExperimentLimitType = "seconds"
ExperimentLimitTypeRuns ExperimentLimitType = "runs"
)
func (t *ExperimentLimitType) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
switch ExperimentLimitType(str) {
case ExperimentLimitTypeSeconds, ExperimentLimitTypeRuns:
*t = ExperimentLimitType(str)
return nil
default:
return fmt.Errorf("invalid limit_type: %s", str)
}
}
// IterState tracks the execution status and results of a single fuzzer iteration.
type IterState struct {
Status IterStateStatus `json:"status"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
NormalizationScore float64 `json:"normalization_score"`
NormalizationError float64 `json:"normalization_error"`
LimitType ExperimentLimitType `json:"limit_type"`
LimitValue int `json:"limit_value"`
ActualRuns int `json:"actual_runs"`
ActualSeconds float64 `json:"actual_seconds"`
}
// ExperimentTask represents a single unit of work to be executed by a worker.
type ExperimentTask struct {
FuzzerName string
CorpusName string
CorpusPath string
LimitType ExperimentLimitType
LimitValue int
Iteration int
TaskDir string
}
// FuzzerConfig contains the details of how a fuzzer should be built/run by the experimental framework
type FuzzerConfig struct {
mode FuzzMode
additionGnFlags map[string]string
}
var (
baseRequiredGnFlags = map[string]string{
"use_libfuzzer": "true",
"tint_build_wgsl_reader": "true",
"use_clang_coverage": "true",
"optimize_for_fuzzing": "false",
}
fuzzerConfigs = map[string]FuzzerConfig{
"tint_wgsl_fuzzer": {
mode: FuzzModeWgsl,
additionGnFlags: map[string]string{},
},
"tint_ir_fuzzer": {
mode: FuzzModeIr,
additionGnFlags: map[string]string{
"tint_build_ir_binary": "true",
"tint_has_protobuf": "true",
},
},
"tint_wgsl_mesa_fuzzer": {
mode: FuzzModeWgsl,
additionGnFlags: map[string]string{
"tint_build_mesa": "true",
"tint_build_fuzzer_vulkan_support": "true",
},
},
"tint_ir_mesa_fuzzer": {
mode: FuzzModeIr,
additionGnFlags: map[string]string{
"tint_build_ir_binary": "true",
"tint_has_protobuf": "true",
"tint_build_mesa": "true",
"tint_build_fuzzer_vulkan_support": "true",
},
},
}
)
// runExperiment is the entry point for the 'experiment' subcommand.
// It validates the environment, builds binaries if needed, and runs the task queue.
func runExperiment(t *taskConfig) error {
config, err := loadExperimentSettings(t, t.experimentPath)
if err != nil {
return err
}
binDir, err := buildExperimentBinariesIfNeeded(t, config)
if err != nil {
return err
}
machineResultsDir, err := generateResultsDirIfNeeded(t)
if err != nil {
return err
}
// 1. Calculate pending normalization tasks
normScoresPath := filepath.Join(machineResultsDir, "normalization_scores.json")
normScores := make(map[string]NormalizationScore)
if fileutils.IsFile(normScoresPath, t.osWrapper) {
normScoresBytes, err := t.osWrapper.ReadFile(normScoresPath)
if err == nil {
_ = json.Unmarshal(normScoresBytes, &normScores)
}
}
var pendingNormalizationTasks []string
for _, fuzzer := range config.Fuzzers {
if _, ok := normScores[fuzzer]; !ok {
pendingNormalizationTasks = append(pendingNormalizationTasks, fuzzer)
}
}
// 2. Calculate pending experiment tasks
tasks, err := calculateExperimentTasks(t, config, machineResultsDir)
if err != nil {
return err
}
pendingExperimentTasks := queueRemainingExperimentTasks(t, tasks)
// 3. Burn-in if either queue has values in it
burnInEnabled := true
if config.BurnInEnabled != nil {
burnInEnabled = *config.BurnInEnabled
}
hasWork := len(pendingNormalizationTasks) > 0 || len(pendingExperimentTasks) > 0
if hasWork && burnInEnabled {
if err := runBurnIn(t, &config); err != nil {
return err
}
}
// 4. Run normalization tasks
normalizationScores, err := runNormalizationTasks(t, machineResultsDir, config, pendingNormalizationTasks)
if err != nil {
return err
}
// 5. Run experiment tasks
if err = runPendingExperimentTasks(t, pendingExperimentTasks, config, normalizationScores, binDir); err != nil {
return err
}
fmt.Println("Experiment completed successfully!")
return nil
}
// loadExperimentSettings reads and parses the experiment.json file from the
// specified experiment root directory, performing both semantic validation
// and mechanical existence checks.
func loadExperimentSettings(t *taskConfig, experimentRoot string) (ExperimentSettings, error) {
if experimentRoot == "" {
return ExperimentSettings{}, fmt.Errorf("experiment root path is required")
}
configPath := filepath.Join(experimentRoot, kExperimentSettingsFile)
if !fileutils.IsFile(configPath, t.osWrapper) {
return ExperimentSettings{}, fmt.Errorf("experiment configuration file '%s' not found", configPath)
}
settingsBytes, err := t.osWrapper.ReadFile(configPath)
if err != nil {
return ExperimentSettings{}, fmt.Errorf("failed to read experiment config: %w", err)
}
var settings ExperimentSettings
if err := json.Unmarshal(settingsBytes, &settings); err != nil {
return ExperimentSettings{}, fmt.Errorf("failed to parse experiment config JSON: %w", err)
}
if len(settings.Fuzzers) < 1 {
return ExperimentSettings{}, fmt.Errorf("no fuzzers specified in 'fuzzers' list inside experiment.json")
}
activeModes := make(map[FuzzMode]bool)
for _, fuzzer := range settings.Fuzzers {
cfg, ok := fuzzerConfigs[fuzzer]
if !ok {
return ExperimentSettings{}, fmt.Errorf("unsupported fuzzer '%s'. Supported fuzzers are: %s", fuzzer, strings.Join(slices.Collect(maps.Keys(fuzzerConfigs)), ","))
}
activeModes[cfg.mode] = true
}
corporaDir := filepath.Join(experimentRoot, kExperimentCorporaSubDir)
for mode := range activeModes {
normalizationCorpus := ""
var corpora []CorpusDef
switch mode {
case FuzzModeWgsl:
normalizationCorpus = settings.WgslNormalizationCorpus
corpora = settings.WgslCorpora
case FuzzModeIr:
normalizationCorpus = settings.IrNormalizationCorpus
corpora = settings.IrCorpora
}
if normalizationCorpus == "" {
return ExperimentSettings{}, fmt.Errorf("%s_normalization_corpus is required in experiment.json because %s fuzzers are specified", mode, strings.ToUpper(mode.String()))
}
if len(corpora) < 1 {
return ExperimentSettings{}, fmt.Errorf("at least one %s_corpora definition is required in experiment.json because %s fuzzers are specified", mode, strings.ToUpper(mode.String()))
}
bcPath := filepath.Join(corporaDir, normalizationCorpus)
if !fileutils.IsDir(bcPath, t.osWrapper) {
return ExperimentSettings{}, fmt.Errorf("%s normalization corpus directory '%s' not found under corpora root '%s'", mode, normalizationCorpus, corporaDir)
}
for _, cDef := range corpora {
cPath := filepath.Join(corporaDir, cDef.Path)
if !fileutils.IsDir(cPath, t.osWrapper) {
return ExperimentSettings{}, fmt.Errorf("%s corpus directory '%s' not found under corpora root '%s'", mode, cDef.Path, corporaDir)
}
}
}
for i, dDef := range settings.Durations {
if dDef.Seconds != nil && dDef.Runs != nil {
return ExperimentSettings{}, fmt.Errorf("duration at index %d has both 'seconds' and 'runs' defined, which is not allowed", i)
}
if dDef.Seconds == nil && dDef.Runs == nil {
return ExperimentSettings{}, fmt.Errorf("duration at index %d must define either 'seconds' or 'runs'", i)
}
}
return settings, nil
}
// buildExperimentBinariesIfNeeded ensures that all required fuzzer binaries
// exist in the experiment's bin directory. If any are missing, it builds them.
// Before building, it confirms that the GN args are correct for building the
// fuzzer, and also warns the user if they have sanitizers enabled, since
// that can significantly skew results.
// Returns the binary directory as a string and nil if all binaries are present
// otherwise returns an error.
func buildExperimentBinariesIfNeeded(t *taskConfig, settings ExperimentSettings) (string, error) {
binDir := filepath.Join(t.experimentPath, kExperimentBinarySubDir)
missingBinaries := make([]string, 0, len(settings.Fuzzers))
for _, fuzzer := range settings.Fuzzers {
fPath := filepath.Join(binDir, fuzzer+fileutils.ExeExt)
if !fileutils.IsExe(fPath, t.osWrapper) {
missingBinaries = append(missingBinaries, fuzzer)
}
}
if len(missingBinaries) == 0 {
return binDir, nil
}
hasSanitizers, err := checkGnArgs(t, missingBinaries)
if err != nil {
return "", err
}
if hasSanitizers {
timeoutOverridden := settings.Timeout != nil && *settings.Timeout > 0
warnMsg := []string{
"",
"================================ WARNING ================================",
"Your build directory has compiler sanitizers (ASan, UBSan, TSan, or MSan) enabled.",
"Fuzzing with sanitizers will significantly increase the runtime per test case,",
"which will tend to dominate the results and possibly lead to false-positive timeouts.",
}
if !timeoutOverridden {
warnMsg = append(warnMsg,
"We highly recommend specifying a fuzzer timeout (by adding a \"timeout\"",
"field to your experiment.json) if you proceed with sanitizers.",
)
}
warnMsg = append(warnMsg,
"=========================================================================",
"",
)
fmt.Println(strings.Join(warnMsg, "\n"))
confirm, err := promptConfirm("Are you sure you want to proceed with sanitizers enabled?")
if err != nil {
return "", fmt.Errorf("failed to read sanitizer confirmation: %w", err)
}
if !confirm {
return "", fmt.Errorf("experiment execution aborted by user due to active sanitizers")
}
}
fmt.Println("It looks like we are running a new experiment and need to build the fuzzers.")
fmt.Println("Building will checkout commit ", settings.Hash, " and run gclient sync.")
confirm, err := promptConfirm("Do you want to proceed with the build?")
if err != nil {
return "", fmt.Errorf("failed to read build confirmation: %w", err)
}
if !confirm {
return "", fmt.Errorf("experiment execution aborted by user")
}
if err := prepareBinaries(t, &settings, binDir, missingBinaries); err != nil {
return "", err
}
return binDir, nil
}
// runNormalizationTasks ensures that normalization scores (runs/sec)
// exist for all fuzzers in the experiment. It loads existing scores from
// normalization_scores.json if they exist, and runs the normalization for any missing fuzzers,
// saving the results back to the file.
// Returns the scores in a map, otherwise an error.
func runNormalizationTasks(t *taskConfig, machineResultsDir string, settings ExperimentSettings, pending []string) (map[string]NormalizationScore, error) {
normScoresPath := filepath.Join(machineResultsDir, "normalization_scores.json")
normScores := make(map[string]NormalizationScore)
if fileutils.IsFile(normScoresPath, t.osWrapper) {
normScoresBytes, err := t.osWrapper.ReadFile(normScoresPath)
if err == nil {
_ = json.Unmarshal(normScoresBytes, &normScores)
}
}
for _, fuzzer := range pending {
fmt.Println("Running normalization for", fuzzer, "...")
score, err := runNormalization(t, t.experimentPath, fuzzer, &settings, machineResultsDir)
if err != nil {
return nil, err
}
normScores[fuzzer] = score
// Save immediately in case the top-level process gets halted
scoresBytes, _ := json.MarshalIndent(normScores, "", " ")
_ = t.osWrapper.WriteFile(normScoresPath, scoresBytes, 0644)
}
return normScores, nil
}
// generateResultsDirIfNeeded ensures that the results directory for the current
// machine exists within the experiment path. It creates the directory structure
// 'results/<machine_name>' (defaulting to 'default' if no machine name is set)
// if doesn't already exist.
// Returns the path to the machine-specific result directory if successful,
// otherwise an error.
func generateResultsDirIfNeeded(t *taskConfig) (string, error) {
machine := t.machineName
if machine == "" {
machine = "default"
}
machineResultsDir := filepath.Join(t.experimentPath, "results", machine)
if err := t.osWrapper.MkdirAll(machineResultsDir, 0755); err != nil {
return "", fmt.Errorf("failed to create machine results directory '%s': %w", machineResultsDir, err)
}
return machineResultsDir, nil
}
// calculateExperimentTasks creates the full list of ExperimentTasks based on the
// experiment configuration. It iterates through all fuzzers and their
// corresponding corpora, producing tasks for each combination and
// duration.
// Returns a slice of ExperimentTask if successful, otherwise an error.
func calculateExperimentTasks(t *taskConfig, settings ExperimentSettings, machineResultsDir string) ([]ExperimentTask, error) {
corporaDir := filepath.Join(t.experimentPath, kExperimentCorporaSubDir)
var tasks []ExperimentTask
for _, fuzzer := range settings.Fuzzers {
var corpora []CorpusDef
cfg, _ := fuzzerConfigs[fuzzer]
switch cfg.mode {
case FuzzModeWgsl:
corpora = settings.WgslCorpora
case FuzzModeIr:
corpora = settings.IrCorpora
default:
return nil, fmt.Errorf("unknown fuzz mode %d", cfg.mode)
}
for _, corpus := range corpora {
fuzzerTasks, err := calculateExperimentTasksForFuzzer(fuzzer, corpus, corporaDir, machineResultsDir, &settings)
if err != nil {
return nil, err
}
tasks = append(tasks, fuzzerTasks...)
}
}
fmt.Println("Produced", len(tasks), "total tasks based on the experiment plan!")
return tasks, nil
}
// queueRemainingExperimentTasks filters the list of tasks to identify those that are
// pending or were interrupted, based on the presence and content of a
// state.json file and valid .profraw coverage data in the task directory. It also
// ensures the task's corpus directory is prepared.
// Returns a slice of ExperimentTask containing only the remaining tasks to be run.
func queueRemainingExperimentTasks(t *taskConfig, tasks []ExperimentTask) []ExperimentTask {
// Queue up pending/interrupted tasks
var pendingTasks []ExperimentTask
for _, task := range tasks {
statePath := filepath.Join(task.TaskDir, kExperimentTaskStateFile)
if fileutils.IsFile(statePath, t.osWrapper) {
stateBytes, err := t.osWrapper.ReadFile(statePath)
if err == nil {
var state IterState
if err := json.Unmarshal(stateBytes, &state); err == nil && state.Status == IterStateCompleted && hasValidProfraw(task.TaskDir, t.osWrapper) {
continue // already completed with valid coverage data, skip
}
}
}
// Reset/Prepare task folder
corpusDir := filepath.Join(task.TaskDir, kExperimentCorpusSubDir)
_ = t.osWrapper.RemoveAll(corpusDir)
_ = t.osWrapper.MkdirAll(corpusDir, 0755)
pendingTasks = append(pendingTasks, task)
}
fmt.Println("Found", len(pendingTasks), "pending/incomplete tasks!")
return pendingTasks
}
// runPendingExperimentTasks executes the provided list of experiment tasks using a parallel worker pool.
// It manages worker synchronization and context cancellation. It returns the first error
// encountered
func runPendingExperimentTasks(t *taskConfig, pendingTasks []ExperimentTask, settings ExperimentSettings, normalizationScores map[string]NormalizationScore, binDir string) error {
if len(pendingTasks) == 0 {
return nil
}
fmt.Println("Executing pending/incomplete tasks using", t.numProcesses, "parallel jobs...")
taskChan := make(chan ExperimentTask, len(pendingTasks))
for _, task := range pendingTasks {
taskChan <- task
}
close(taskChan)
var wg sync.WaitGroup
errChan := make(chan error, t.numProcesses)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
timeoutVal := 0
if settings.Timeout != nil && *settings.Timeout > 0 {
timeoutVal = *settings.Timeout
}
for i := 0; i < t.numProcesses; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case task, ok := <-taskChan:
if !ok {
return
}
score := normalizationScores[task.FuzzerName]
if err := executeExperimentTask(ctx, t, binDir, task, score, timeoutVal); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
errChan <- err
cancel()
}
return
}
}
}
}()
}
wg.Wait()
close(errChan)
if len(errChan) > 0 {
return <-errChan // return first error encountered
}
fmt.Println("Completed executing tasks!")
return nil
}
// GnValue holds the string value of a GN argument.
type GnValue struct {
Value string `json:"value"`
}
// GnArg represents a single GN argument as returned by 'gn args --json'.
type GnArg struct {
Name string `json:"name"`
Current *GnValue `json:"current,omitempty"`
Default *GnValue `json:"default,omitempty"`
}
// GetValue returns the effective value of a GN argument, stripping quotes.
func (g *GnArg) GetValue() string {
val := ""
if g.Current != nil {
val = g.Current.Value
} else if g.Default != nil {
val = g.Default.Value
}
if strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"") {
val = strings.Trim(val, "\"")
}
return val
}
// promptConfirm displays a yes/no prompt to the user.
func promptConfirm(msg string) (bool, error) {
fmt.Printf("%s [y/N]: ", msg)
reader := bufio.NewReader(os.Stdin)
text, err := reader.ReadString('\n')
if err != nil {
return false, err
}
text = strings.TrimSpace(strings.ToLower(text))
return text == "y" || text == "yes", nil
}
// checkGnArgs verifies that the build directory has the required GN flags set for the fuzzers being tested.
// It also checks and returns true if any standard compiler sanitizers are enabled.
func checkGnArgs(t *taskConfig, fuzzers []string) (bool, error) {
out, err := t.runCmd("gn", "args", t.build, "--list", "--json")
if err != nil {
return false, fmt.Errorf("failed to query GN arguments for build directory '%s': %w", t.build, err)
}
var args []GnArg
if err := json.Unmarshal(out, &args); err != nil {
return false, fmt.Errorf("failed to parse GN arguments JSON: %w", err)
}
flagValues := make(map[string]string)
for _, arg := range args {
flagValues[arg.Name] = arg.GetValue()
}
requiredFlags := maps.Clone(baseRequiredGnFlags)
for _, fuzzer := range fuzzers {
cfg, _ := fuzzerConfigs[fuzzer]
maps.Copy(requiredFlags, cfg.additionGnFlags)
}
var incorrect []string
for flag, req := range requiredFlags {
val := flagValues[flag]
if val != req {
incorrect = append(incorrect, fmt.Sprintf("%s (expected: %s, actual: %s)", flag, req, val))
}
}
if len(incorrect) > 0 {
return false, fmt.Errorf("the build directory '%s' has incorrect/missing GN configuration flags:\n - %s\nPlease update your GN args and re-run", t.build, strings.Join(incorrect, "\n - "))
}
hasSanitizers := flagValues["is_asan"] == "true" ||
flagValues["is_ubsan"] == "true" ||
flagValues["is_tsan"] == "true" ||
flagValues["is_msan"] == "true"
return hasSanitizers, nil
}
// prepareBinaries checks out the specified git hash, syncs dependencies,
// builds the fuzzers, and copies them to the experiment's bin directory.
func prepareBinaries(t *taskConfig, settings *ExperimentSettings, binDir string, fuzzers []string) error {
return t.atGitHash(settings.Hash,
func() error {
if err := t.syncAndBuildTargets(fuzzers); err != nil {
return err
}
if err := t.osWrapper.MkdirAll(binDir, 0755); err != nil {
return fmt.Errorf("failed to create binary folder '%s': %w", binDir, err)
}
for _, fuzzer := range fuzzers {
err := copyFuzzerAndDependencies(t, fuzzer, binDir)
if err != nil {
return err
}
}
if err := copyLlvmTools(t, binDir); err != nil {
return err
}
return nil
})
}
// copyLlvmTools copies the LLVM coverage tools to the bin directory.
func copyLlvmTools(t *taskConfig, binDir string) error {
llvmBinDir := filepath.Join(fileutils.DawnRoot(t.osWrapper), "third_party", "llvm-build", "Release+Asserts", "bin")
tools := []string{"llvm-profdata", "llvm-cov"}
for _, tool := range tools {
toolExe := tool + fileutils.ExeExt
srcPath := filepath.Join(llvmBinDir, toolExe)
dstPath := filepath.Join(binDir, toolExe)
if err := fileutils.CopyFile(dstPath, srcPath, t.osWrapper); err != nil {
return fmt.Errorf("failed to copy %s to bin folder: %w", tool, err)
}
}
return nil
}
// copyFuzzerAndDependencies makes a copy of a fuzzer binary and its runtime dependencies in the target bin folder. The runtime dependencies come from the GN output file <fuzzer>.runtime_deps, but only includes the dynamic libraries listed there and none of the harness/framework files.
func copyFuzzerAndDependencies(t *taskConfig, fuzzer string, binDir string) error {
srcPath := filepath.Join(t.build, fuzzer+fileutils.ExeExt)
dstPath := filepath.Join(binDir, fuzzer+fileutils.ExeExt)
if err := fileutils.CopyFile(dstPath, srcPath, t.osWrapper); err != nil {
return fmt.Errorf("failed to copy built binary %s to bin folder: %w", fuzzer, err)
}
runtimeDepsPath := filepath.Join(t.build, fuzzer+".runtime_deps")
if fileutils.IsFile(runtimeDepsPath, t.osWrapper) {
depsBytes, err := t.osWrapper.ReadFile(runtimeDepsPath)
if err != nil {
return fmt.Errorf("failed to read runtime deps for %s: %w", fuzzer, err)
}
depsStr := string(depsBytes)
for line := range strings.SplitSeq(depsStr, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Skip copying the fuzzer binary itself, as it was copied above
if line == fuzzer || line == fuzzer+fileutils.ExeExt {
continue
}
// Only copy if it's a shared library or ICD configuration
if !shouldCopyRuntimeDep(line) {
continue
}
srcDep := filepath.Join(t.build, line)
dstDep := filepath.Join(binDir, line)
// Ensure parent directory exists
if err := t.osWrapper.MkdirAll(filepath.Dir(dstDep), 0755); err != nil {
return fmt.Errorf("failed to create directory for runtime dependency %s: %w", line, err)
}
info, err := t.osWrapper.Stat(srcDep)
if err != nil {
return fmt.Errorf("runtime dependency %s missing: %w", srcDep, err)
}
if !info.IsDir() {
// Note: Go resolves symlinks automatically in the os package, so using CopyFile leads to symlinks (e.g. libvulkan.so.1) in the destination folder being real files with the correct content.
if err := fileutils.CopyFile(dstDep, srcDep, t.osWrapper); err != nil {
return fmt.Errorf("failed to copy runtime dependency %s to bin folder: %w", line, err)
}
// Make sure relative library_path in ICD JSONs is absolute so Vulkan/Loader can load it from any working directory
if filepath.Ext(dstDep) == ".json" {
contentBytes, err := t.osWrapper.ReadFile(dstDep)
if err == nil {
re := regexp.MustCompile(`"library_path"\s*:\s*"(?:\./)?([^/"][^"]*)"`)
absBinDir, _ := filepath.Abs(filepath.Dir(dstDep))
newContent := re.ReplaceAllStringFunc(string(contentBytes), func(match string) string {
m := re.FindStringSubmatch(match)
if len(m) > 1 {
absLibPath := filepath.Join(absBinDir, m[1])
return fmt.Sprintf(`"library_path": "%s"`, absLibPath)
}
return match
})
_ = t.osWrapper.WriteFile(dstDep, []byte(newContent), 0644)
}
}
}
}
}
return nil
}
// shouldCopyRuntimeDep returns true if the path points to a shared library file (e.g. .so, .dylib, .dll, or versioned variants like .so.1) or an ICD configuration file (.json).
func shouldCopyRuntimeDep(path string) bool {
ext := filepath.Ext(path)
if ext == ".so" || ext == ".dylib" || ext == ".dll" || ext == ".json" {
return true
}
// Handle versioned shared libraries (e.g. libvulkan.so.1)
if strings.Contains(path, ".so.") || strings.Contains(path, ".dylib.") {
return true
}
return false
}
// appendLibraryArgs searches for DXC and Mesa ICD configurations in the given directory
// and appends the corresponding flags to the fuzzer arguments if found.
func appendLibraryArgs(args []string, binDir string, fsReader oswrapper.FilesystemReader) []string {
// Find DXC library
if files, err := glob.Glob(filepath.Join(binDir, "**dxcompiler*"), fsReader); err == nil {
for _, f := range files {
ext := filepath.Ext(f)
if ext == ".so" || ext == ".dylib" || ext == ".dll" {
args = append(args, fmt.Sprintf("--dxc=%s", f))
break
}
}
}
// Find Mesa ICD JSON
if files, err := glob.Glob(filepath.Join(binDir, "**lvp_icd.json"), fsReader); err == nil {
if len(files) > 0 {
args = append(args, fmt.Sprintf("--vk_icd=%s", files[0]))
}
}
return args
}
// findProfrawFiles recursively searches for and returns a list of all .profraw files within the specified directory.
func findProfrawFiles(dir string, fsReader oswrapper.FilesystemReader) ([]string, error) {
var files []string
err := fsReader.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && filepath.Ext(path) == ".profraw" {
files = append(files, path)
}
return nil
})
return files, err
}
// hasValidProfraw returns true if the specified directory contains at least one .profraw file
// and all discovered .profraw files are non-empty.
func hasValidProfraw(dir string, fsReader oswrapper.FilesystemReader) bool {
profraws, err := findProfrawFiles(dir, fsReader)
if err != nil || len(profraws) == 0 {
return false
}
for _, p := range profraws {
if info, err := fsReader.Stat(p); err != nil || info.Size() == 0 {
return false
}
}
return true
}
// executeFuzzer runs a fuzzer binary in workDir against corpusOut and corpusPath with the specified
// limitType and limitValue. It automatically configures timeouts, WGSL dictionary paths, and runtime
// library flags, and retries on transient "text file busy" errors.
// Returns the combined stdout/stderr output, the net execution duration (excluding retry delays), and any process error.
func executeFuzzer(ctx context.Context, t *taskConfig, binDir, fuzzer, workDir, corpusOut, corpusPath string, limitType ExperimentLimitType, limitValue int, timeout int) ([]byte, time.Duration, error) {
cfg, ok := fuzzerConfigs[fuzzer]
if !ok {
return nil, 0, fmt.Errorf("unknown fuzzer: %s", fuzzer)
}
absCorpusOut, err := filepath.Abs(corpusOut)
if err != nil {
return nil, 0, fmt.Errorf("failed to get absolute path for corpus out: %w", err)
}
absCorpusPath, err := filepath.Abs(corpusPath)
if err != nil {
return nil, 0, fmt.Errorf("failed to get absolute path for corpus input: %w", err)
}
binPath := filepath.Join(binDir, fuzzer+fileutils.ExeExt)
absBin, err := filepath.Abs(binPath)
if err != nil {
return nil, 0, fmt.Errorf("failed to get absolute path of fuzzer binary: %w", err)
}
args := []string{absCorpusOut, absCorpusPath}
if timeout > 0 {
args = append(args, fmt.Sprintf("-timeout=%d", timeout))
}
switch limitType {
case ExperimentLimitTypeSeconds:
args = append(args, fmt.Sprintf("-max_total_time=%d", limitValue))
case ExperimentLimitTypeRuns:
args = append(args, fmt.Sprintf("-runs=%d", limitValue))
default:
return nil, 0, fmt.Errorf("unknown limit type %s", limitType)
}
if cfg.mode == FuzzModeWgsl {
dictPath := filepath.Join(fileutils.DawnRoot(t.osWrapper), "src", "tint", "cmd", "fuzz", "wgsl", "dictionary.txt")
if fileutils.IsFile(dictPath, t.osWrapper) {
absDictPath, err := filepath.Abs(dictPath)
if err != nil {
return nil, 0, fmt.Errorf("failed to get absolute path for dictionary: %w", err)
}
args = append(args, "-dict="+absDictPath)
}
}
args = appendLibraryArgs(args, binDir, t.osWrapper)
start := time.Now()
var out []byte
retries := time.Duration(0)
for range 5 {
cmd := t.execWrapper.CommandContext(ctx, absBin, args...).WithDir(workDir)
out, err = cmd.RunWithCombinedOutput()
if err == nil {
break
}
if strings.Contains(err.Error(), "text file busy") {
time.Sleep(100 * time.Millisecond)
retries += 100 * time.Millisecond
continue
}
break
}
duration := time.Since(start) - retries
return out, duration, err
}
// executeFuzzerForRuns runs the fuzzer for a target number of runs (-runs=<runs>).
func executeFuzzerForRuns(ctx context.Context, t *taskConfig, binDir, fuzzer, workDir, corpusOut, corpusPath string, runs int, timeout int) ([]byte, time.Duration, error) {
return executeFuzzer(ctx, t, binDir, fuzzer, workDir, corpusOut, corpusPath, ExperimentLimitTypeRuns, runs, timeout)
}
// executeFuzzerForCorpusOnly runs the fuzzer over the existing corpus only (-runs=0) without generating new mutations.
func executeFuzzerForCorpusOnly(ctx context.Context, t *taskConfig, binDir, fuzzer, workDir, corpusOut, corpusPath string, timeout int) ([]byte, time.Duration, error) {
return executeFuzzerForRuns(ctx, t, binDir, fuzzer, workDir, corpusOut, corpusPath, 0, timeout)
}
// executeFuzzerForDuration runs the fuzzer for a target duration in seconds (-max_total_time=<seconds>).
func executeFuzzerForDuration(ctx context.Context, t *taskConfig, binDir, fuzzer, workDir, corpusOut, corpusPath string, seconds int, timeout int) ([]byte, time.Duration, error) {
return executeFuzzer(ctx, t, binDir, fuzzer, workDir, corpusOut, corpusPath, ExperimentLimitTypeSeconds, seconds, timeout)
}
// dumpCorpusCoverage runs the fuzzer with -runs=0 against corpusOut and corpusPath, cleanly dumping
// the accumulated coverage into workDir.
func dumpCorpusCoverage(ctx context.Context, t *taskConfig, binDir, fuzzer, workDir, corpusOut, corpusPath string, timeout int) error {
// Clean up any zero-byte or corrupted profraw files first
if profraws, err := findProfrawFiles(workDir, t.osWrapper); err == nil {
for _, p := range profraws {
_ = t.osWrapper.Remove(p)
}
}
if out, _, err := executeFuzzerForCorpusOnly(ctx, t, binDir, fuzzer, workDir, corpusOut, corpusPath, timeout); err != nil {
return fmt.Errorf("failed to dump corpus coverage for %s in %s: %w\nOutput:\n%s", fuzzer, workDir, err, string(out))
}
if !hasValidProfraw(workDir, t.osWrapper) {
return fmt.Errorf("fuzzer coverage run for %s in %s did not produce a valid .profraw file", fuzzer, workDir)
}
return nil
}
// runNormalization executes fuzzer normalization profiling multiple times to determine the execution
// speed (runs/sec) and its standard error (SEM) on the current hardware for normalization of results.
func runNormalization(t *taskConfig, root string, fuzzer string, settings *ExperimentSettings, machineResultsDir string) (NormalizationScore, error) {
var corpusPath string
cfg, _ := fuzzerConfigs[fuzzer]
switch cfg.mode {
case FuzzModeWgsl:
corpusPath = filepath.Join(root, kExperimentCorporaSubDir, settings.WgslNormalizationCorpus)
case FuzzModeIr:
corpusPath = filepath.Join(root, kExperimentCorporaSubDir, settings.IrNormalizationCorpus)
default:
return NormalizationScore{}, fmt.Errorf("unknown fuzz mode %d", cfg.mode)
}
binDir := filepath.Join(root, kExperimentBinarySubDir)
timeoutVal := 0
if settings.Timeout != nil && *settings.Timeout > 0 {
timeoutVal = *settings.Timeout
}
normalizationDuration := 60
if settings.NormalizationDuration != nil && *settings.NormalizationDuration > 0 {
normalizationDuration = *settings.NormalizationDuration
}
iters := 5
if settings.NormalizationIterations != nil && *settings.NormalizationIterations > 0 {
iters = *settings.NormalizationIterations
}
csvPath := filepath.Join(machineResultsDir, "normalization_iterations.csv")
writeHeader := !fileutils.IsFile(csvPath, t.osWrapper)
csvFile, err := t.osWrapper.OpenFile(csvPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return NormalizationScore{}, fmt.Errorf("failed to open normalization CSV: %w", err)
}
defer csvFile.Close()
if writeHeader {
_, _ = io.WriteString(csvFile, "Fuzzer,Iteration,InitRuns,InitSeconds,TotalRuns,TotalSeconds,SteadyRuns,SteadySeconds,Score\n")
}
var scores []float64
for i := 1; i <= iters; i++ {
initRuns, initElapsed, err := runNormalizationInitCmd(t, binDir, fuzzer, corpusPath, timeoutVal)
if err != nil {
return NormalizationScore{}, err
}
targetDuration := int(math.Ceil(initElapsed)) + normalizationDuration
totalRuns, totalElapsed, err := runNormalizationCmd(t, binDir, fuzzer, corpusPath, targetDuration, timeoutVal)
if err != nil {
return NormalizationScore{}, err
}
steadyRuns := totalRuns - initRuns
steadyElapsed := totalElapsed - initElapsed
if steadyRuns <= 0 || steadyElapsed <= 0 {
return NormalizationScore{}, fmt.Errorf(
"normalization iteration %d for %s did not make steady-state progress (init: %d runs in %.2fs, total: %d runs in %.2fs)",
i, fuzzer, initRuns, initElapsed, totalRuns, totalElapsed)
}
score := float64(steadyRuns) / steadyElapsed
scores = append(scores, score)
_, _ = io.WriteString(csvFile, fmt.Sprintf("%s,%d,%d,%.2f,%d,%.2f,%d,%.2f,%.2f\n",
fuzzer, i, initRuns, initElapsed, totalRuns, totalElapsed, steadyRuns, steadyElapsed, score))
fmt.Printf(" Iteration %d: init %d runs in %.2fs, total %d runs in %.2fs, steady-state %d runs in %.2fs (%.2f runs/sec)\n",
i, initRuns, initElapsed, totalRuns, totalElapsed, steadyRuns, steadyElapsed, score)
}
avg, stdDev := computeAvgAndStdDev(scores)
sem := 0.0
if len(scores) > 0 {
sem = stdDev / math.Sqrt(float64(len(scores)))
}
fmt.Printf("Normalization profiling result for %s: %.2f ± %.2f runs/sec\n", fuzzer, avg, sem)
return NormalizationScore{Mean: avg, SEM: sem}, nil
}
// runNormalizationInitCmd runs the fuzzer with -runs=0 against corpusPath to measure the
// initialization cost (number of initial corpus runs and elapsed seconds).
func runNormalizationInitCmd(t *taskConfig, binDir, fuzzer, corpusPath string, timeoutVal int) (int, float64, error) {
tmpOut, err := t.osWrapper.MkdirTemp("", "perf_init_out")
if err != nil {
return 0, 0, err
}
defer t.osWrapper.RemoveAll(tmpOut)
out, duration, err := executeFuzzerForCorpusOnly(context.Background(), t, binDir, fuzzer, tmpOut, tmpOut, corpusPath, timeoutVal)
elapsed := duration.Seconds()
if err != nil {
return 0, elapsed, fmt.Errorf("normalization initialization profiling failed: %w\nOutput:\n%s", err, string(out))
}
initRuns, err := parseActualRuns(out)
if err != nil {
return 0, elapsed, err
}
return initRuns, elapsed, nil
}
// runNormalizationCmd runs a single fuzzer normalization execution for the given duration (in seconds).
// It returns the number of runs executed, the actual elapsed time, and any error.
func runNormalizationCmd(t *taskConfig, binDir, fuzzer, corpusPath string, duration int, timeoutVal int) (int, float64, error) {
tmpOut, err := t.osWrapper.MkdirTemp("", "perf_out")
if err != nil {
return 0, 0, err
}
defer t.osWrapper.RemoveAll(tmpOut)
out, elapsedDur, err := executeFuzzerForDuration(context.Background(), t, binDir, fuzzer, tmpOut, tmpOut, corpusPath, duration, timeoutVal)
elapsed := elapsedDur.Seconds()
if err != nil {
// The fuzzer may exit with non-zero if interrupted by max_total_time or if it hit a crash report, which is
// fine, unless it exited significantly before the target duration, since that means it is finding issues
// quickly, which means that the whole experimental apparatus is not going to perform as expected
minExpected := max(1.0, float64(duration)*0.8)
if elapsed < minExpected {
return 0, elapsed, fmt.Errorf("normalization profiling terminated too quickly (%.2fs < %.2fs): %w\nOutput:\n%s", elapsed, minExpected, err, string(out))
}
}
actualRuns, err := parseActualRuns(out)
if err != nil {
return 0, elapsed, err
}
return actualRuns, elapsed, nil
}
// runBurnIn runs the burn-in phase.
// It executes the normalization benchmarks for each of the configured fuzzers in parallel matching t.numProcesses to attempt to
// get the physical hardware to a thermal steady state to avoid unexpected throttling mid-experiment.
func runBurnIn(t *taskConfig, settings *ExperimentSettings) error {
burnInDuration := 300
if settings.BurnInDuration != nil {
burnInDuration = *settings.BurnInDuration
}
fmt.Println("Starting burn-in phase for", burnInDuration, "seconds using", t.numProcesses, "parallel jobs...")
timeoutVal := 0
if settings.Timeout != nil && *settings.Timeout > 0 {
timeoutVal = *settings.Timeout
}
var wg sync.WaitGroup
errChan := make(chan error, t.numProcesses)
binDir := filepath.Join(t.experimentPath, kExperimentBinarySubDir)
for i := 0; i < t.numProcesses; i++ {
fuzzer := settings.Fuzzers[i%len(settings.Fuzzers)]
var corpusPath string
cfg, _ := fuzzerConfigs[fuzzer]
switch cfg.mode {
case FuzzModeWgsl:
corpusPath = filepath.Join(t.experimentPath, kExperimentCorporaSubDir, settings.WgslNormalizationCorpus)
case FuzzModeIr:
corpusPath = filepath.Join(t.experimentPath, kExperimentCorporaSubDir, settings.IrNormalizationCorpus)
default:
return fmt.Errorf("unknown fuzz mode %d for fuzzer %s", cfg.mode, fuzzer)
}
wg.Add(1)
go func(workerID int, f string, cp string) {
defer wg.Done()
_, _, err := runNormalizationCmd(t, binDir, f, cp, burnInDuration, timeoutVal)
if err != nil {
errChan <- fmt.Errorf("burn-in worker %d failed: %w", workerID, err)
}
}(i, fuzzer, corpusPath)
}
wg.Wait()
close(errChan)
if len(errChan) > 0 {
return <-errChan
}
fmt.Println("Burn-in phase completed successfully!")
return nil
}
// calculateExperimentTasksForFuzzer creates the list of ExperimentTasks for a specific
// fuzzer and corpus combination based on the experiment durations.
func calculateExperimentTasksForFuzzer(fuzzer string, corpus CorpusDef, corporaDir string, machineDir string, settings *ExperimentSettings) ([]ExperimentTask, error) {
var tasks []ExperimentTask
cPath := filepath.Join(corporaDir, corpus.Path)
for _, dDef := range settings.Durations {
var limitType ExperimentLimitType
limitValue := 0
if dDef.Seconds != nil {
limitType = ExperimentLimitTypeSeconds
limitValue = *dDef.Seconds
} else {
limitType = ExperimentLimitTypeRuns
limitValue = *dDef.Runs
}
iterations := settings.DefaultIterations
if dDef.Iterations != nil {
iterations = *dDef.Iterations
}
limitStr := fmt.Sprintf("%s_%d", limitType, limitValue)
for i := 1; i <= iterations; i++ {
taskDir := filepath.Join(machineDir, fuzzer, corpus.Name, limitStr, fmt.Sprintf("iter_%d", i))
tasks = append(tasks, ExperimentTask{
FuzzerName: fuzzer,
CorpusName: corpus.Name,
CorpusPath: cPath,
LimitType: limitType,
LimitValue: limitValue,
Iteration: i,
TaskDir: taskDir,
})
}
}
return tasks, nil
}
// executeExperimentTask runs a single fuzzer iteration, manages its state file,
// and captures the output logs and performance data.
func executeExperimentTask(ctx context.Context, t *taskConfig, binDir string, task ExperimentTask, score NormalizationScore, timeoutVal int) error {
statePath := filepath.Join(task.TaskDir, kExperimentTaskStateFile)
// Update state to running
state := IterState{
Status: IterStateRunning,
StartTime: time.Now().Format(time.RFC3339),
NormalizationScore: score.Mean,
NormalizationError: score.SEM,
LimitType: task.LimitType,
LimitValue: task.LimitValue,
}
stateBytes, _ := json.MarshalIndent(state, "", " ")
_ = t.osWrapper.WriteFile(statePath, stateBytes, 0644)
corpusOut := filepath.Join(task.TaskDir, kExperimentCorpusSubDir)
_ = t.osWrapper.MkdirAll(corpusOut, 0755)
taskTag := fmt.Sprintf("%s on %s (%s=%d, iter %d)", task.FuzzerName, task.CorpusName, task.LimitType, task.LimitValue, task.Iteration)
fmt.Println(fmt.Sprintf("[%s] Running: %s", time.Now().Format("15:04:05"), taskTag))
// Run the binary directly from binDir, setting working directory to TaskDir so profraw files are not combined
// between tasks
out, duration, fuzzerErr := executeFuzzer(ctx, t, binDir, task.FuzzerName, task.TaskDir, corpusOut, task.CorpusPath, task.LimitType, task.LimitValue, timeoutVal)
if fuzzerErr != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if t.verbose {
fmt.Println("Fuzzer process returned error/exit status:", fuzzerErr)
}
}
logPath := filepath.Join(task.TaskDir, "fuzzer.log")
_ = t.osWrapper.WriteFile(logPath, out, 0644)
actualRuns, err := parseActualRuns(out)
if err != nil {
return err
}
// Re-run the fuzzer with -runs=0 against the corpus to cleanly generate raw profiling
// data regardless of how the main fuzzing run exited.
fmt.Println(fmt.Sprintf("[%s] Profiling: %s", time.Now().Format("15:04:05"), taskTag))
if err := dumpCorpusCoverage(ctx, t, binDir, task.FuzzerName, task.TaskDir, corpusOut, task.CorpusPath, timeoutVal); err != nil {
return err
}
state.Status = IterStateCompleted
state.EndTime = time.Now().Format(time.RFC3339)
state.ActualRuns = actualRuns
state.ActualSeconds = duration.Seconds()
fmt.Println(fmt.Sprintf("[%s] Completed: %s in %.2fs, %d runs", time.Now().Format("15:04:05"), taskTag, duration.Seconds(), actualRuns))
stateBytes, _ = json.MarshalIndent(state, "", " ")
_ = t.osWrapper.WriteFile(statePath, stateBytes, 0644)
return nil
}
var (
reExecutedUnits = regexp.MustCompile(`stat::number_of_executed_units:\s*(\d+)`)
reDoneRuns = regexp.MustCompile(`Done\s+(\d+)\s+runs\s+in`)
reStatusPulse = regexp.MustCompile(`(?mi)^#(\d+)\s+(?:INITED|NEW|REDUCE|pulse|DONE|RELOAD)\b`)
)
// parseActualRuns extracts the number of fuzzer executions from the fuzzer output.
func parseActualRuns(output []byte) (int, error) {
outStr := string(output)
// First, try to parse the 'stat::number_of_executed_units' line.
if match := reExecutedUnits.FindStringSubmatch(outStr); len(match) > 1 {
if val, err := strconv.Atoi(match[1]); err == nil {
return val, nil
}
}
// Next, try to parse the final summary line like: Done 1234 runs in 5 second(s)
if match := reDoneRuns.FindStringSubmatch(outStr); len(match) > 1 {
if val, err := strconv.Atoi(match[1]); err == nil {
return val, nil
}
}
// Fallback to parsing libFuzzer status/pulse lines like: #1234 pulse
matches := reStatusPulse.FindAllStringSubmatch(outStr, -1)
if len(matches) > 0 {
lastMatch := matches[len(matches)-1]
if val, err := strconv.Atoi(lastMatch[1]); err == nil {
return val, nil
}
}
return 0, fmt.Errorf("failed to parse actual runs from fuzzer output")
}