| // 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) | |
| } |