| #!/bin/bash |
| # |
| # Copyright (C) 2020 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| set -e |
| |
| function usage() { |
| echo 'NAME' |
| echo ' simplify-build-failure.sh' |
| echo |
| echo 'SYNOPSIS' |
| echo " $0 (--task <gradle task> <other gradle arguments> <error message> [--clean] | --command <shell command> ) [--continue] [--limit-to-path <file path>] [--check-lines-in <subfile path>] [--num-jobs <count>]" |
| echo |
| echo DESCRIPTION |
| echo ' Searches for a minimal set of files and/or lines required to reproduce a given build failure' |
| echo |
| echo OPTIONS |
| echo |
| echo ' --task <gradle task> <other gradle arguments> <error message>`' |
| echo ' Specifies that `./gradlew <gradle task>` must fail with error message <error message>' |
| echo |
| echo ' --command <shell command>' |
| echo ' Specifies that <shell command> must succeed.' |
| echo |
| echo ' --continue' |
| echo ' Attempts to pick up from a previous invocation of simplify-build-failure.sh' |
| echo |
| echo ' --limit-to-path <limitPath>' |
| echo ' Will check only <limitPath> (plus subdirectories, if present) for possible simplications. This can make the simplification process faster if there are paths that you know are' |
| echo ' uninteresting to you' |
| echo |
| echo ' --check-lines-in <subfile path>' |
| echo ' Specifies that individual lines in files in <subfile path> will be considered for removal, too' |
| echo |
| echo ' --num-jobs <count>' |
| echo ' Specifies the number of jobs to run at once' |
| echo |
| echo ' --clean' |
| echo ' Specifies that each build should start from a consistent state' |
| exit 1 |
| } |
| |
| function notify() { |
| echo simplify-build-failure.sh $1 |
| notify-send simplify-build-failure.sh $1 |
| } |
| |
| function failed() { |
| notify failed |
| exit 1 |
| } |
| |
| gradleTasks="" |
| gradleExtraArguments="" |
| errorMessage="" |
| gradleCommand="" |
| grepCommand="" |
| testCommand="" |
| resume=false |
| subfilePath="" |
| limitToPath="" |
| numJobs="auto" |
| clean="false" |
| |
| export ALLOW_MISSING_PROJECTS=true # so that if we delete entire projects then the AndroidX build doesn't think we made a spelling mistake |
| |
| workingDir="$(pwd)" |
| cd "$(dirname $0)" |
| scriptPath="$(pwd)" |
| cd ../.. |
| supportRoot="$(pwd)" |
| checkoutRoot="$(cd $supportRoot/../.. && pwd)" |
| tempDir="$checkoutRoot/simplify-tmp" |
| |
| pathsToNotShrink="gradlew" |
| |
| if [ ! -e "$workingDir/gradlew" ]; then |
| echo "Error; ./gradlew does not exist. Must cd to a dir containing a ./gradlew first" |
| # so that this script knows which gradlew to use (in frameworks/support or frameworks/support/ui) |
| exit 1 |
| fi |
| |
| while [ "$1" != "" ]; do |
| arg="$1" |
| shift |
| if [ "$arg" == "--continue" ]; then |
| resume=true |
| continue |
| fi |
| if [ "$arg" == "--task" ]; then |
| gradleTasks="$1" |
| if [ "$gradleTasks" == "" ]; then |
| usage |
| fi |
| shift |
| gradleExtraArguments="$1" |
| shift |
| errorMessage="$1" |
| if [ "$errorMessage" == "" ]; then |
| usage |
| fi |
| shift |
| |
| gradleCommand="OUT_DIR=out ./gradlew $gradleExtraArguments >log 2>&1" |
| grepCommand="$scriptPath/impl/grepOrTail.sh \"$errorMessage\" log" |
| continue |
| fi |
| if [ "$arg" == "--command" ]; then |
| if [ "$1" == "" ]; then |
| usage |
| fi |
| testCommand="$1" |
| shift |
| gradleCommand="" |
| grepCommand="" |
| if echo "$testCommand" | grep -v OUT_DIR 2>/dev/null; then |
| testCommand="export OUT_DIR=out; $testCommand" |
| else |
| echo "Sorry, customizing OUT_DIR is not supported at the moment because we want impl/join.sh to be able to detect it and skip deleting it" |
| exit 1 |
| fi |
| continue |
| fi |
| if [ "$arg" == "--check-lines-in" ]; then |
| subfilePath="$1" |
| # normalize path |
| subfilePath="$(realpath $subfilePath --relative-to=.)" |
| shift |
| continue |
| fi |
| if [ "$arg" == "--limit-to-path" ]; then |
| limitToPath="$1" |
| shift |
| continue |
| fi |
| if [ "$arg" == "--num-jobs" ]; then |
| numJobs="$1" |
| shift |
| continue |
| fi |
| if [ "$arg" == "--clean" ]; then |
| clean=true |
| continue |
| fi |
| echo "Unrecognized argument '$arg'" |
| usage |
| done |
| |
| if [ "$gradleCommand" == "" ]; then |
| if [ "$clean" == "true" ]; then |
| echo "Option --clean requires option --task" |
| usage |
| fi |
| if [ "$testCommand" == "" ]; then |
| usage |
| fi |
| fi |
| |
| # delete temp dir if not resuming |
| if [ "$resume" == "true" ]; then |
| if [ -d "$tempDir" ]; then |
| echo "Not deleting temp dir $tempDir" |
| fi |
| else |
| echo "Removing temp dir $tempDir" |
| rm "$tempDir" -rf |
| fi |
| |
| referencePassingDir="$tempDir/base" |
| referenceFailingDir="$tempDir/failing" |
| # backup code so user can keep editing |
| if [ ! -e "$referenceFailingDir" ]; then |
| echo backing up frameworks/support into "$referenceFailingDir" in case you want to continue to make modifications or run other builds |
| rm "$referenceFailingDir" -rf |
| mkdir -p "$tempDir" |
| cp -rT . "$referenceFailingDir" |
| # remove some unhelpful settings |
| sed -i 's/.*Werror.*//' "$referenceFailingDir/buildSrc/shared.gradle" |
| sed -i 's/.*Exception.*cannot include.*//' "$referenceFailingDir/settings.gradle" |
| # remove some generated files that we don't want diff-filterer.py to track |
| rm -rf "$referenceFailingDir/.gradle" "$referenceFailingDir/buildSrc/.gradle" "$referenceFailingDir/out" |
| rm -rf "$referenceFailingDir/tasks" # generated by simplify-build-failure and could be inadvertently copied into source by the user |
| fi |
| |
| # compute destination state, which is usually almost empty |
| rm "$referencePassingDir" -rf |
| if [ "$limitToPath" != "" ]; then |
| mkdir -p "$(dirname $referencePassingDir)" |
| cp -r "$supportRoot" "$referencePassingDir" |
| rm "$referencePassingDir/$limitToPath" -rf |
| else |
| mkdir -p "$referencePassingDir" |
| # restore any special files that we don't want to shrink |
| for path in $pathsToNotShrink; do |
| cp "$supportRoot/$path" "$referencePassingDir" |
| done |
| fi |
| |
| if [ "$subfilePath" != "" ]; then |
| if [ ! -e "$subfilePath" ]; then |
| echo "$subfilePath" does not exist |
| exit 1 |
| fi |
| fi |
| |
| # if Gradle tasks are specified, then determine the appropriate shell command |
| if [ "$gradleCommand" != "" ]; then |
| gradleCommand="$(echo "$gradleCommand" | sed 's/gradlew/gradlew --no-daemon/')" |
| # determine whether we can reduce the list of tasks we'll be running |
| # prepare directory |
| allTasksWork="$tempDir/allTasksWork" |
| allTasks="$tempDir/tasks" |
| if [ -e "$allTasks" ]; then |
| echo Skipping recalculating list of all relevant tasks, "$allTasks" already exists |
| else |
| echo Calculating list of tasks to run |
| rm -rf "$allTasksWork" |
| cp -r "$referenceFailingDir" "$allTasksWork" |
| # list tasks required for running this |
| if bash -c "cd $allTasksWork && OUT_DIR=out ./gradlew --no-daemon --dry-run $gradleTasks >log 2>&1"; then |
| echo "Expanded full list of tasks to run" |
| else |
| echo "Failed to expand full list of tasks to run; using given list of taks" |
| fi |
| # process output and split into files |
| mkdir -p "$allTasks" |
| taskListFile="$allTasksWork/tasklist" |
| # A task line will start with one or more project names separated by ":", then have a task name, and lastly either end of line or " " followed by a status (like UP-TO-DATE) |
| # We want the task path so we search for task lines and remove any trailing status |
| cat "$allTasksWork/log" | grep '^\(:[a-zA-Z0-9\-]\+\)\+\( \|$\)' | sed 's/ .*//' > "$taskListFile" |
| bash -c "cd $allTasks && split -l 1 '$taskListFile'" |
| # also include the original tasks in case either we failed to compute the list of tasks (due to the build failing during project configuration) or there are too many tasks to fit in one command line invocation |
| bash -c "cd $allTasks && echo '$gradleTasks' > givenTasks" |
| fi |
| |
| # build command for passing to diff-filterer |
| # set OUT_DIR |
| testCommand="export OUT_DIR=out" |
| # delete log |
| testCommand="$testCommand && rm -f log" |
| # make sure at least one task exists |
| testCommand="$testCommand && ls tasks/* >/dev/null" |
| # build a shell script for running each task listed in the tasks/ dir |
| # We call xargs because the full set of tasks might be too long for the shell, and xargs will |
| # split into multiple gradlew invocations if needed |
| # Also, once we reproduce the error, we stop running more Gradle commands |
| testCommand="$testCommand && echo > run.sh && cat tasks/* | xargs echo '$grepCommand && exit 0; $gradleCommand' >> run.sh" |
| |
| # run Gradle |
| testCommand="$testCommand && chmod u+x run.sh && ./run.sh >log 2>&1" |
| if [ "$clean" != "true" ]; then |
| # If the daemon is enabled, then sleep for a little bit in case Gradle fails very quickly |
| # If we run too many builds in a row with Gradle daemons enabled then the daemons might get confused |
| testCommand="$testCommand; sleep 2" |
| fi |
| # check for the error message that we want |
| testCommand="$testCommand; $grepCommand" |
| |
| # identify a minimal set of tasks to reproduce the problem |
| minTasksFailing="$tempDir/minTasksFailing" |
| minTasksGoal="$referenceFailingDir" |
| minTasksOutput="$tempDir/minTasks_output" |
| if [ -e "$minTasksOutput" ]; then |
| echo already computed the minimum set of required tasks, can be seen in $minTasksGoal |
| else |
| rm -rf "$minTasksFailing" |
| cp -r "$minTasksGoal" "$minTasksFailing" |
| cp -r "$allTasks" "$minTasksFailing/" |
| echo Asking diff-filterer for a minimal set of tasks to reproduce this problem |
| if ./development/file-utils/diff-filterer.py --work-path "$tempDir" --num-jobs "$numJobs" "$minTasksFailing" "$minTasksGoal" "$testCommand"; then |
| echo diff-filterer successfully identifed a minimal set of required tasks |
| cp -r "$tempDir/bestResults" "$minTasksOutput" |
| else |
| failed |
| fi |
| fi |
| referenceFailingDir="$minTasksOutput" |
| echo Will use goal directory of "$referenceFailingDir" |
| fi |
| |
| filtererStep1Work="$tempDir" |
| filtererStep1Output="$filtererStep1Work/bestResults" |
| fewestFilesOutputPath="$tempDir/fewestFiles" |
| if echo "$resume" | grep "true" >/dev/null && stat "$fewestFilesOutputPath" >/dev/null 2>/dev/null; then |
| echo "Skipping asking diff-filterer for a minimal set of files, $fewestFilesOutputPath already exists" |
| else |
| if [ "$resume" == "true" ]; then |
| if stat "$filtererStep1Output" >/dev/null 2>/dev/null; then |
| echo "Reusing $filtererStep1Output to resume asking diff-filterer for a minimal set of files" |
| # Copy the previous results to resume from |
| rm "$referenceFailingDir" -rf |
| cp -rT "$filtererStep1Output" "$referenceFailingDir" |
| else |
| echo "Cannot resume previous execution; neither $fewestFilesOutputPath nor $filtererStep1Output exists" |
| exit 1 |
| fi |
| fi |
| echo Running diff-filterer.py once to identify the minimal set of files needed to reproduce the error |
| if ./development/file-utils/diff-filterer.py --work-path $filtererStep1Work --num-jobs "$numJobs" "$referenceFailingDir" "$referencePassingDir" "$testCommand"; then |
| echo diff-filterer completed successfully |
| else |
| failed |
| fi |
| echo Copying minimal set of files into $fewestFilesOutputPath |
| rm -rf "$fewestFilesOutputPath" |
| cp -rT "$filtererStep1Output" "$fewestFilesOutputPath" |
| fi |
| |
| if [ "$subfilePath" == "" ]; then |
| echo Splitting files into individual lines was not enabled. Done. See results at $filtererStep1Work/bestResults |
| else |
| if [ "$subfilePath" == "." ]; then |
| subfilePath="" |
| fi |
| if echo "$resume" | grep true >/dev/null && stat $fewestFilesOutputPath >/dev/null 2>/dev/null; then |
| echo "Skipping recopying $filtererStep1Output to $fewestFilesOutputPath" |
| else |
| echo Copying minimal set of files into $fewestFilesOutputPath |
| rm -rf "$fewestFilesOutputPath" |
| cp -rT "$filtererStep1Output" "$fewestFilesOutputPath" |
| fi |
| |
| echo Creating working directory for identifying individually smallest files |
| noFunctionBodies_Passing="$tempDir/noFunctionBodies_Passing" |
| noFunctionBodies_goal="$tempDir/noFunctionBodies_goal" |
| noFunctionBodies_work="work" |
| noFunctionBodies_sandbox="$noFunctionBodies_work/$subfilePath" |
| noFunctionBodies_output="$tempDir/noFunctionBodies_output" |
| |
| # set up command for running diff-filterer against diffs within files |
| filtererOptions="--num-jobs $numJobs" |
| |
| if echo "$resume" | grep true >/dev/null && stat "$noFunctionBodies_output" >/dev/null 2>/dev/null; then |
| echo "Skipping asking diff-filterer to remove function bodies because $noFunctionBodies_output already exists" |
| else |
| echo Splitting files into smaller pieces |
| rm -rf "$noFunctionBodies_Passing" "$noFunctionBodies_goal" |
| mkdir -p "$noFunctionBodies_Passing" "$noFunctionBodies_goal" |
| cd "$noFunctionBodies_Passing" |
| cp -rT "$fewestFilesOutputPath" "$noFunctionBodies_work" |
| cp -rT "$noFunctionBodies_Passing" "$noFunctionBodies_goal" |
| |
| splitsPath="${subfilePath}.split" |
| "${scriptPath}/impl/split.sh" --consolidate-leaves "$noFunctionBodies_sandbox" "$splitsPath" |
| rm "$noFunctionBodies_sandbox" -rf |
| |
| echo Removing deepest lines |
| cd "$noFunctionBodies_goal" |
| "${scriptPath}/impl/split.sh" --remove-leaves "$noFunctionBodies_sandbox" "$splitsPath" |
| rm "$noFunctionBodies_sandbox" -rf |
| |
| # restore any special files that we don't want to shrink |
| for path in $pathsToNotShrink; do |
| relativePathFromSubfilePath="$(realpath --relative-to="./$subfilePath" "$path")" |
| if ! echo "$relativePathFromSubfilePath" | grep "^\.\." >/dev/null 2>/dev/null; then |
| # This file is contained in $subfilePath so we were going to try to shrink it if it weren't exempt |
| echo Exempting "$path" from shrinking |
| # Copy the exploded version of the file that doesn't have any missing pieces |
| rm -rf "$splitsPath/$relativePathFromSubfilePath" |
| cp -rT "$noFunctionBodies_Passing/$splitsPath/$relativePathFromSubfilePath" "$noFunctionBodies_goal/$splitsPath/$relativePathFromSubfilePath" |
| fi |
| done |
| |
| # TODO: maybe we should make diff-filterer.py directly support checking individual line differences within files rather than first running split.sh and asking diff-filterer.py to run join.sh |
| # It would be harder to implement in diff-filterer.py though because diff-filterer.py would also need to support comparing against nonempty files too |
| echo Running diff-filterer.py again to identify which function bodies can be removed |
| if "$supportRoot/development/file-utils/diff-filterer.py" $filtererOptions --allow-goal-passing --work-path "$(cd $supportRoot/../.. && pwd)" "$noFunctionBodies_Passing" "$noFunctionBodies_goal" "${scriptPath}/impl/join.sh ${splitsPath} ${noFunctionBodies_sandbox} && cd ${noFunctionBodies_work} && $testCommand"; then |
| echo diff-filterer completed successfully |
| else |
| failed |
| fi |
| |
| echo Re-joining the files |
| rm -rf "${noFunctionBodies_output}" |
| cp -rT "$(cd $supportRoot/../../bestResults && pwd)" "${noFunctionBodies_output}" |
| cd "${noFunctionBodies_output}" |
| "${scriptPath}/impl/join.sh" "${splitsPath}" "${noFunctionBodies_sandbox}" |
| fi |
| |
| # prepare for another invocation of diff-filterer, to remove other code that is now unused |
| smallestFilesInput="$tempDir/smallestFilesInput" |
| smallestFilesGoal="$tempDir/smallestFilesGoal" |
| smallestFilesWork="work" |
| smallestFilesSandbox="$smallestFilesWork/$subfilePath" |
| |
| rm -rf "$smallestFilesInput" "$smallestFilesGoal" |
| mkdir -p "$smallestFilesInput" |
| cp -rT "${noFunctionBodies_output}" "$smallestFilesInput" |
| |
| echo Splitting files into individual lines |
| cd "$smallestFilesInput" |
| splitsPath="${subfilePath}.split" |
| "${scriptPath}/impl/split.sh" "$smallestFilesSandbox" "$splitsPath" |
| rm "$smallestFilesSandbox" -rf |
| |
| # Make a dir holding the destination file state |
| if [ "$limitToPath" != "" ]; then |
| # The user said they were only interested in trying to delete files under a certain path |
| # So, our target state is the original state minus that path (and its descendants) |
| mkdir -p "$smallestFilesGoal" |
| cp -rT "$smallestFilesInput/$smallestFilesWork" "$smallestFilesGoal/$smallestFilesWork" |
| cd "$smallestFilesGoal/$smallestFilesWork" |
| rm "$limitToPath" -rf |
| cd - |
| else |
| # The user didn't request to limit the search to a specific path, so we mostly try to delete as many |
| # files as possible |
| mkdir -p "$smallestFilesGoal" |
| fi |
| echo now check $smallestFilesGoal |
| # Restore any special exempt files |
| cd "$smallestFilesGoal" |
| for path in $pathsToNotShrink; do |
| relativePathFromSubfilePath="$(realpath --relative-to="./$subfilePath" "$path")" |
| if ! echo "$relativePathFromSubfilePath" | grep "^\.\." >/dev/null 2>/dev/null; then |
| # This file is contained in $subfilePath so we were going to try to shrink it if it weren't exempt |
| # Copy the exploded version of the file that doesn't have any missing pieces |
| echo Exempting "$path" from shrinking |
| destPath="$smallestFilesGoal/$splitsPath/$relativePathFromSubfilePath" |
| rm -rf "$destPath" |
| mkdir -p "$(dirname "$destPath")" |
| echo cp -rT "$noFunctionBodies_Passing/$splitsPath/$relativePathFromSubfilePath" "$destPath" |
| cp -rT "$noFunctionBodies_Passing/$splitsPath/$relativePathFromSubfilePath" "$smallestFilesGoal/$splitsPath/$relativePathFromSubfilePath" |
| fi |
| done |
| |
| echo Running diff-filterer.py again to identify the minimal set of lines needed to reproduce the error |
| if "$supportRoot/development/file-utils/diff-filterer.py" $filtererOptions --work-path "$(cd $supportRoot/../.. && pwd)" "$smallestFilesInput" "$smallestFilesGoal" "${scriptPath}/impl/join.sh ${splitsPath} ${smallestFilesSandbox} && cd ${smallestFilesWork} && $testCommand"; then |
| echo diff-filterer completed successfully |
| else |
| failed |
| fi |
| |
| echo Re-joining the files |
| smallestFilesOutput="$tempDir/smallestFilesOutput" |
| rm -rf "$smallestFilesOutput" |
| cp -rT "$(cd $supportRoot/../../bestResults && pwd)" "${smallestFilesOutput}" |
| cd "${smallestFilesOutput}" |
| "${scriptPath}/impl/join.sh" "${splitsPath}" "${smallestFilesSandbox}" |
| |
| echo "Done. See simplest discovered reproduction test case at ${smallestFilesOutput}/${smallestFilesWork}" |
| fi |
| notify succeeded |