blob: 9b46645118cbc5c4e70651ea570e03ecc6044ce6 [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 (
"fmt"
"os"
"path/filepath"
"strings"
"dawn.googlesource.com/dawn/tools/src/execwrapper"
"dawn.googlesource.com/dawn/tools/src/fileutils"
)
// TODO(crbug.com/416755658): Add unittest coverage when exec calls are done
// via dependency injection.
type bisectConfig struct {
*taskConfig
failingHash string
passingHash string
isFix bool
fuzzerName string
}
// runBisect orchestrates the automated bisection of a fuzzer testcase.
// It verifies that both passing and failing points behave as expected and then executes a git bisect run.
func runBisect(t *taskConfig) error {
if t.bisectFile == "" {
return fmt.Errorf("-bisect flag requires a test case file path")
}
if t.knownFailing == "" {
return fmt.Errorf("-known-failing flag is required when bisecting")
}
bc := &bisectConfig{taskConfig: t}
bc.fuzzerName = filepath.Base(t.fuzzer)
// Verify build directory exists and is a GN build (contains args.gn)
argsGnPath := filepath.Join(t.build, "args.gn")
if !fileutils.IsFile(argsGnPath, t.osWrapper) {
return fmt.Errorf("build directory '%v' does not appear to be a GN build directory (missing args.gn), which is required for bisecting", t.build)
}
var err error
bc.failingHash, err = resolveToHash(bc.knownFailing, bc.execWrapper)
if err != nil {
return fmt.Errorf("failed to resolve known-failing '%s': %w", bc.knownFailing, err)
}
bc.passingHash, err = resolveToHash(bc.knownPassing, bc.execWrapper)
if err != nil {
return fmt.Errorf("failed to resolve known-passing '%s': %w", bc.knownPassing, err)
}
fmt.Printf("Resolved known-failing to commit: %s\n", bc.failingHash)
fmt.Printf("Resolved known-passing to commit: %s\n", bc.passingHash)
origRefBytes, err := bc.runCmd("git", "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return fmt.Errorf("failed to get original HEAD reference: %w", err)
}
origRef := strings.TrimSpace(string(origRefBytes))
if origRef == "HEAD" {
origHeadBytes, err := bc.runCmd("git", "rev-parse", "HEAD")
if err != nil {
return fmt.Errorf("failed to get original HEAD commit: %w", err)
}
origRef = strings.TrimSpace(string(origHeadBytes))
}
// Ensure repository state restore upon completion
defer func() {
fmt.Printf("Restoring repository to original state (%s)...\n", origRef)
_, _ = bc.runCmd("git", "checkout", origRef)
_, _ = bc.runCmd("gclient", "sync")
}()
// Determine ordering
mergeBaseBytes, err := bc.runCmd("git", "merge-base", bc.failingHash, bc.passingHash)
if err != nil {
return fmt.Errorf("failed to find merge-base between %s and %s: %w", bc.failingHash, bc.passingHash, err)
}
mergeBase := strings.TrimSpace(string(mergeBaseBytes))
if mergeBase == bc.failingHash {
bc.isFix = true
fmt.Println("Detected flow: FAILING commit is an ancestor of PASSING commit (looking for the FIX).")
} else if mergeBase == bc.passingHash {
bc.isFix = false
fmt.Println("Detected flow: PASSING commit is an ancestor of FAILING commit (looking for the BREAKAGE).")
} else {
return fmt.Errorf("the failing commit (%s) and passing commit (%s) are not in a direct ancestral relationship (merge-base is %s)", bc.failingHash, bc.passingHash, mergeBase)
}
// Verify failing commit behaves as expected
fmt.Printf("Verifying known-failing commit (%s)...\n", bc.failingHash)
if err := bc.verifyCommit(bc.failingHash, false); err != nil {
return fmt.Errorf("verification of known-failing commit %s failed: %w", bc.failingHash, err)
}
fmt.Println("Verification succeeded: fuzzer failed on known-failing commit as expected.")
// Verify passing commit behaves as expected
fmt.Printf("Verifying known-passing commit (%s)...\n", bc.passingHash)
if err := bc.verifyCommit(bc.passingHash, true); err != nil {
return fmt.Errorf("verification of known-passing commit %s failed: %w", bc.passingHash, err)
}
fmt.Println("Verification succeeded: fuzzer passed on known-passing commit as expected.")
// Run bisection
fmt.Println("Both bounds verified. Commencing bisection...")
if err := bc.performBisect(); err != nil {
return fmt.Errorf("bisection failed: %w", err)
}
return nil
}
type syncBuildRunResult int
const (
SyncBuildRunPassed syncBuildRunResult = iota
SyncBuildRunFailed
SyncBuildRunError // sync or build failed
)
// syncBuildRun runs gclient sync, builds the fuzzer, and runs the test case, returning the outcome.
// Note: is intentionally a receiver on taskConfig instead of bisectConfig, because bisectStep use/need the whole
//
// bisectConfig state
func (t *taskConfig) syncBuildRun() (syncBuildRunResult, error) {
fmt.Println("--> Running gclient sync...")
if _, err := t.runCmd("gclient", "sync"); err != nil {
return SyncBuildRunError, fmt.Errorf("gclient sync failed: %w", err)
}
fuzzerName := filepath.Base(t.fuzzer)
fmt.Printf("--> Building fuzzer %s...\n", fuzzerName)
if _, err := t.runCmd("autoninja", "-C", t.build, fuzzerName); err != nil {
return SyncBuildRunError, fmt.Errorf("build failed: %w", err)
}
fmt.Printf("--> Running fuzzer %s against test case...\n", fuzzerName)
_, err := t.runCmd(t.fuzzer, t.bisectFile)
if err != nil {
return SyncBuildRunFailed, nil
}
return SyncBuildRunPassed, nil
}
// verifyCommit checks out a commit, calls syncBuildRun, and asserts that it matches the expected outcome.
func (bc *bisectConfig) verifyCommit(hash string, expectPass bool) error {
if _, err := bc.runCmd("git", "checkout", hash); err != nil {
return fmt.Errorf("failed to checkout %s: %w", hash, err)
}
result, err := bc.syncBuildRun()
if result == SyncBuildRunError {
return fmt.Errorf("execution at %s failed: %w", hash, err)
}
passed := result == SyncBuildRunPassed
if passed != expectPass {
return fmt.Errorf("fuzzer execution returned pass=%v, but expected pass=%v", passed, expectPass)
}
return nil
}
// performBisect executes the standard git bisect routine by launching the current tool as an internal step subcommand.
func (bc *bisectConfig) performBisect() error {
// Guarantee git bisect reset is called when bisection finishes
defer func() {
_, _ = bc.runCmd("git", "bisect", "reset")
}()
if _, err := bc.runCmd("git", "bisect", "start"); err != nil {
return fmt.Errorf("failed to start git bisect: %w", err)
}
var err error
if bc.isFix {
// "bad" in git bisect is the target state we want to identify (the fix).
// "good" in git bisect is the starting/original state (the breakage).
_, err = bc.runCmd("git", "bisect", "bad", bc.passingHash)
if err == nil {
_, err = bc.runCmd("git", "bisect", "good", bc.failingHash)
}
} else {
// "bad" in git bisect is the target state (the breakage).
// "good" in git bisect is the working state (the passing).
_, err = bc.runCmd("git", "bisect", "bad", bc.failingHash)
if err == nil {
_, err = bc.runCmd("git", "bisect", "good", bc.passingHash)
}
}
if err != nil {
return fmt.Errorf("failed to assign good/bad bounds to git bisect: %w", err)
}
self, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get absolute path of self: %w", err)
}
// Construct args to call ourselves back as the bisect-step handler
args := []string{"bisect", "run", self, "-bisect-step", "-bisect=" + bc.bisectFile, "-build=" + bc.build}
if bc.isFix {
args = append(args, "-is-fix")
}
if bc.fuzzMode == FuzzModeIr {
args = append(args, "-ir")
}
if bc.mesaMode {
args = append(args, "-mesa")
}
if bc.verbose {
args = append(args, "-verbose")
}
// Execute git bisect run
if err := bc.runCmdUnbuffered("git", args...); err != nil {
return fmt.Errorf("git bisect run failed: %w", err)
}
// Git bisect leaves refs/bisect/bad pointing to the identified commit.
// Query refs/bisect/bad to fetch the exact found commit hash.
if foundHashBytes, err := bc.runCmd("git", "rev-parse", "refs/bisect/bad"); err == nil {
foundHash := strings.TrimSpace(string(foundHashBytes))
bannerText := "FOUND BREAKAGE"
if bc.isFix {
bannerText = "FOUND FIX"
}
fmt.Println("\n================================================================================")
fmt.Printf("%s @ %s\n", bannerText, foundHash)
if logBytes, err := bc.runCmd("git", "log", "-1", "--oneline", foundHash); err == nil {
fmt.Printf("%s", string(logBytes))
}
fmt.Println("================================================================================")
fmt.Println()
}
return nil
}
// runBisectStep implements a single git bisect step invoked by git bisect run.
// It calls syncBuildRun and returns an appropriate exit codes (0 for good, 1 for bad, 125 for skipped/unbuildable).
func runBisectStep(t *taskConfig) error {
fmt.Print("Running bisect step for commit: ")
if out, err := t.runCmd("git", "rev-parse", "HEAD"); err == nil {
fmt.Printf("%s", string(out))
} else {
fmt.Println("unknown")
}
result, err := t.syncBuildRun()
if result == SyncBuildRunError {
fmt.Printf("Step execution failed: %v. Skipping commit.\n", err)
t.exitFn(125)
return nil
}
// Translate to git bisect exit code
if t.isFix {
if result == SyncBuildRunPassed {
fmt.Println("Fuzzer passed. This commit has the fix (bad for bisect).")
t.exitFn(1)
} else {
fmt.Println("Fuzzer failed. This commit does NOT have the fix (good for bisect).")
t.exitFn(0)
}
} else {
if result == SyncBuildRunPassed {
fmt.Println("Fuzzer passed. This commit does NOT have the breakage (good for bisect).")
t.exitFn(0)
} else {
fmt.Println("Fuzzer failed. This commit has the breakage (bad for bisect).")
t.exitFn(1)
}
}
return nil
}
// resolveToHash converts a ref/hash/timestamp string to a complete git commit hash.
func resolveToHash(input string, execWrapper execwrapper.ExecWrapper) (string, error) {
if input == "" {
out, err := execWrapper.Command("git", "rev-parse", "HEAD").RunWithCombinedOutput()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// Try treating it as a hash or ref first
out, err := execWrapper.Command("git", "rev-parse", "--verify", input+"^{commit}").RunWithCombinedOutput()
if err == nil {
return strings.TrimSpace(string(out)), nil
}
// Try treating it as a date/time
out, err = execWrapper.Command("git", "log", "-1", "--format=%H", "--before="+input).RunWithCombinedOutput()
if err == nil {
hash := strings.TrimSpace(string(out))
if hash != "" {
return hash, nil
}
}
return "", fmt.Errorf("could not resolve '%s' to a commit hash", input)
}