1#!/bin/bash
2#
3#  Copyright (C) 2020 The Android Open Source Project
4#
5#  Licensed under the Apache License, Version 2.0 (the "License");
6#  you may not use this file except in compliance with the License.
7#  You may obtain a copy of the License at
8#
9#       http://www.apache.org/licenses/LICENSE-2.0
10#
11#  Unless required by applicable law or agreed to in writing, software
12#  distributed under the License is distributed on an "AS IS" BASIS,
13#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14#  See the License for the specific language governing permissions and
15#  limitations under the License.
16#
17
18set -e
19
20function usage() {
21  echo 'NAME'
22  echo '  simplify-build-failure.sh'
23  echo
24  echo 'SYNOPSIS'
25  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>]"
26  echo
27  echo DESCRIPTION
28  echo '  Searches for a minimal set of files and/or lines required to reproduce a given build failure'
29  echo
30  echo OPTIONS
31  echo
32  echo '  --task <gradle task> <other gradle arguments> <error message>`'
33  echo '    Specifies that `./gradlew <gradle task>` must fail with error message <error message>'
34  echo
35  echo '  --command <shell command>'
36  echo '    Specifies that <shell command> must succeed.'
37  echo
38  echo '  --continue'
39  echo '    Attempts to pick up from a previous invocation of simplify-build-failure.sh'
40  echo
41  echo '  --limit-to-path <limitPath>'
42  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'
43  echo '    uninteresting to you'
44  echo
45  echo '  --check-lines-in <subfile path>'
46  echo '    Specifies that individual lines in files in <subfile path> will be considered for removal, too'
47  echo
48  echo '  --num-jobs <count>'
49  echo '    Specifies the number of jobs to run at once'
50  echo
51  echo '  --clean'
52  echo '    Specifies that each build should start from a consistent state'
53  exit 1
54}
55
56function notify() {
57  echo simplify-build-failure.sh $1
58  notify-send simplify-build-failure.sh $1
59}
60
61function failed() {
62  notify failed
63  exit 1
64}
65
66gradleTasks=""
67gradleExtraArguments=""
68errorMessage=""
69gradleCommand=""
70grepCommand=""
71testCommand=""
72resume=false
73subfilePath=""
74limitToPath=""
75numJobs="auto"
76clean="false"
77
78export ALLOW_MISSING_PROJECTS=true # so that if we delete entire projects then the AndroidX build doesn't think we made a spelling mistake
79
80workingDir="$(pwd)"
81cd "$(dirname $0)"
82scriptPath="$(pwd)"
83cd ../..
84supportRoot="$(pwd)"
85checkoutRoot="$(cd $supportRoot/../.. && pwd)"
86tempDir="$checkoutRoot/simplify-tmp"
87
88pathsToNotShrink="gradlew"
89
90if [ ! -e "$workingDir/gradlew" ]; then
91  echo "Error; ./gradlew does not exist. Must cd to a dir containing a ./gradlew first"
92  # so that this script knows which gradlew to use (in frameworks/support or frameworks/support/ui)
93  exit 1
94fi
95
96while [ "$1" != "" ]; do
97  arg="$1"
98  shift
99  if [ "$arg" == "--continue" ]; then
100    resume=true
101    continue
102  fi
103  if [ "$arg" == "--task" ]; then
104    gradleTasks="$1"
105    if [ "$gradleTasks" == "" ]; then
106      usage
107    fi
108    shift
109    gradleExtraArguments="$1"
110    shift
111    errorMessage="$1"
112    if [ "$errorMessage" == "" ]; then
113      usage
114    fi
115    shift
116
117    gradleCommand="OUT_DIR=out ./gradlew $gradleExtraArguments >log 2>&1"
118    grepCommand="$scriptPath/impl/grepOrTail.sh \"$errorMessage\" log"
119    continue
120  fi
121  if [ "$arg" == "--command" ]; then
122    if [ "$1" == "" ]; then
123      usage
124    fi
125    testCommand="$1"
126    shift
127    gradleCommand=""
128    grepCommand=""
129    if echo "$testCommand" | grep -v OUT_DIR 2>/dev/null; then
130      testCommand="export OUT_DIR=out; $testCommand"
131    else
132      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"
133      exit 1
134    fi
135    continue
136  fi
137  if [ "$arg" == "--check-lines-in" ]; then
138    subfilePath="$1"
139    # normalize path
140    subfilePath="$(realpath $subfilePath --relative-to=.)"
141    shift
142    continue
143  fi
144  if [ "$arg" == "--limit-to-path" ]; then
145    limitToPath="$1"
146    shift
147    continue
148  fi
149  if [ "$arg" == "--num-jobs" ]; then
150    numJobs="$1"
151    shift
152    continue
153  fi
154  if [ "$arg" == "--clean" ]; then
155    clean=true
156    continue
157  fi
158  echo "Unrecognized argument '$arg'"
159  usage
160done
161
162if [ "$gradleCommand" == "" ]; then
163  if [ "$clean" == "true" ]; then
164    echo "Option --clean requires option --task"
165    usage
166  fi
167  if [ "$testCommand" == "" ]; then
168    usage
169  fi
170fi
171
172# delete temp dir if not resuming
173if [ "$resume" == "true" ]; then
174  if [ -d "$tempDir" ]; then
175    echo "Not deleting temp dir $tempDir"
176  fi
177else
178  echo "Removing temp dir $tempDir"
179  rm "$tempDir" -rf
180fi
181
182referencePassingDir="$tempDir/base"
183referenceFailingDir="$tempDir/failing"
184# backup code so user can keep editing
185if [ ! -e "$referenceFailingDir" ]; then
186  echo backing up frameworks/support into "$referenceFailingDir" in case you want to continue to make modifications or run other builds
187  rm "$referenceFailingDir" -rf
188  mkdir -p "$tempDir"
189  cp -rT . "$referenceFailingDir"
190  # remove some unhelpful settings
191  sed -i 's/.*Werror.*//' "$referenceFailingDir/buildSrc/shared.gradle"
192  sed -i 's/.*Exception.*cannot include.*//' "$referenceFailingDir/settings.gradle"
193  # remove some generated files that we don't want diff-filterer.py to track
194  rm -rf "$referenceFailingDir/.gradle" "$referenceFailingDir/buildSrc/.gradle" "$referenceFailingDir/out"
195  rm -rf "$referenceFailingDir/tasks" # generated by simplify-build-failure and could be inadvertently copied into source by the user
196fi
197
198# compute destination state, which is usually almost empty
199rm "$referencePassingDir" -rf
200if [ "$limitToPath" != "" ]; then
201  mkdir -p "$(dirname $referencePassingDir)"
202  cp -r "$supportRoot" "$referencePassingDir"
203  rm "$referencePassingDir/$limitToPath" -rf
204else
205  mkdir -p "$referencePassingDir"
206  # restore any special files that we don't want to shrink
207  for path in $pathsToNotShrink; do
208    cp "$supportRoot/$path" "$referencePassingDir"
209  done
210fi
211
212if [ "$subfilePath" != "" ]; then
213  if [ ! -e "$subfilePath" ]; then
214    echo "$subfilePath" does not exist
215    exit 1
216  fi
217fi
218
219# if Gradle tasks are specified, then determine the appropriate shell command
220if [ "$gradleCommand" != "" ]; then
221  gradleCommand="$(echo "$gradleCommand" | sed 's/gradlew/gradlew --no-daemon/')"
222  # determine whether we can reduce the list of tasks we'll be running
223  # prepare directory
224  allTasksWork="$tempDir/allTasksWork"
225  allTasks="$tempDir/tasks"
226  if [ -e "$allTasks" ]; then
227    echo Skipping recalculating list of all relevant tasks, "$allTasks" already exists
228  else
229    echo Calculating list of tasks to run
230    rm -rf "$allTasksWork"
231    cp -r "$referenceFailingDir" "$allTasksWork"
232    # list tasks required for running this
233    if bash -c "cd $allTasksWork && OUT_DIR=out ./gradlew --no-daemon --dry-run $gradleTasks >log 2>&1"; then
234      echo "Expanded full list of tasks to run"
235    else
236      echo "Failed to expand full list of tasks to run; using given list of taks"
237    fi
238    # process output and split into files
239    mkdir -p "$allTasks"
240    taskListFile="$allTasksWork/tasklist"
241    # 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)
242    # We want the task path so we search for task lines and remove any trailing status
243    cat "$allTasksWork/log" | grep '^\(:[a-zA-Z0-9\-]\+\)\+\( \|$\)' | sed 's/ .*//' > "$taskListFile"
244    bash -c "cd $allTasks && split -l 1 '$taskListFile'"
245    # 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
246    bash -c "cd $allTasks && echo '$gradleTasks' > givenTasks"
247  fi
248
249  # build command for passing to diff-filterer
250  # set OUT_DIR
251  testCommand="export OUT_DIR=out"
252  # delete log
253  testCommand="$testCommand && rm -f log"
254  # make sure at least one task exists
255  testCommand="$testCommand && ls tasks/* >/dev/null"
256  # build a shell script for running each task listed in the tasks/ dir
257  # We call xargs because the full set of tasks might be too long for the shell, and xargs will
258  # split into multiple gradlew invocations if needed
259  # Also, once we reproduce the error, we stop running more Gradle commands
260  testCommand="$testCommand && echo > run.sh && cat tasks/* | xargs echo '$grepCommand && exit 0; $gradleCommand' >> run.sh"
261
262  # run Gradle
263  testCommand="$testCommand && chmod u+x run.sh && ./run.sh >log 2>&1"
264  if [ "$clean" != "true" ]; then
265    # If the daemon is enabled, then sleep for a little bit in case Gradle fails very quickly
266    # If we run too many builds in a row with Gradle daemons enabled then the daemons might get confused
267    testCommand="$testCommand; sleep 2"
268  fi
269  # check for the error message that we want
270  testCommand="$testCommand; $grepCommand"
271
272  # identify a minimal set of tasks to reproduce the problem
273  minTasksFailing="$tempDir/minTasksFailing"
274  minTasksGoal="$referenceFailingDir"
275  minTasksOutput="$tempDir/minTasks_output"
276  if [ -e "$minTasksOutput" ]; then
277    echo already computed the minimum set of required tasks, can be seen in $minTasksGoal
278  else
279    rm -rf "$minTasksFailing"
280    cp -r "$minTasksGoal" "$minTasksFailing"
281    cp -r "$allTasks" "$minTasksFailing/"
282    echo Asking diff-filterer for a minimal set of tasks to reproduce this problem
283    if ./development/file-utils/diff-filterer.py --work-path "$tempDir" --num-jobs "$numJobs" "$minTasksFailing" "$minTasksGoal" "$testCommand"; then
284      echo diff-filterer successfully identifed a minimal set of required tasks
285      cp -r "$tempDir/bestResults" "$minTasksOutput"
286    else
287      failed
288    fi
289  fi
290  referenceFailingDir="$minTasksOutput"
291  echo Will use goal directory of "$referenceFailingDir"
292fi
293
294filtererStep1Work="$tempDir"
295filtererStep1Output="$filtererStep1Work/bestResults"
296fewestFilesOutputPath="$tempDir/fewestFiles"
297if echo "$resume" | grep "true" >/dev/null && stat "$fewestFilesOutputPath" >/dev/null 2>/dev/null; then
298  echo "Skipping asking diff-filterer for a minimal set of files, $fewestFilesOutputPath already exists"
299else
300  if [ "$resume" == "true" ]; then
301    if stat "$filtererStep1Output" >/dev/null 2>/dev/null; then
302      echo "Reusing $filtererStep1Output to resume asking diff-filterer for a minimal set of files"
303      # Copy the previous results to resume from
304      rm "$referenceFailingDir" -rf
305      cp -rT "$filtererStep1Output" "$referenceFailingDir"
306    else
307      echo "Cannot resume previous execution; neither $fewestFilesOutputPath nor $filtererStep1Output exists"
308      exit 1
309    fi
310  fi
311  echo Running diff-filterer.py once to identify the minimal set of files needed to reproduce the error
312  if ./development/file-utils/diff-filterer.py --work-path $filtererStep1Work --num-jobs "$numJobs" "$referenceFailingDir" "$referencePassingDir" "$testCommand"; then
313    echo diff-filterer completed successfully
314  else
315    failed
316  fi
317  echo Copying minimal set of files into $fewestFilesOutputPath
318  rm -rf "$fewestFilesOutputPath"
319  cp -rT "$filtererStep1Output" "$fewestFilesOutputPath"
320fi
321
322if [ "$subfilePath" == "" ]; then
323  echo Splitting files into individual lines was not enabled. Done. See results at $filtererStep1Work/bestResults
324else
325  if [ "$subfilePath" == "." ]; then
326    subfilePath=""
327  fi
328  if echo "$resume" | grep true >/dev/null && stat $fewestFilesOutputPath >/dev/null 2>/dev/null; then
329    echo "Skipping recopying $filtererStep1Output to $fewestFilesOutputPath"
330  else
331    echo Copying minimal set of files into $fewestFilesOutputPath
332    rm -rf "$fewestFilesOutputPath"
333    cp -rT "$filtererStep1Output" "$fewestFilesOutputPath"
334  fi
335
336  echo Creating working directory for identifying individually smallest files
337  noFunctionBodies_Passing="$tempDir/noFunctionBodies_Passing"
338  noFunctionBodies_goal="$tempDir/noFunctionBodies_goal"
339  noFunctionBodies_work="work"
340  noFunctionBodies_sandbox="$noFunctionBodies_work/$subfilePath"
341  noFunctionBodies_output="$tempDir/noFunctionBodies_output"
342
343  # set up command for running diff-filterer against diffs within files
344  filtererOptions="--num-jobs $numJobs"
345
346  if echo "$resume" | grep true >/dev/null && stat "$noFunctionBodies_output" >/dev/null 2>/dev/null; then
347    echo "Skipping asking diff-filterer to remove function bodies because $noFunctionBodies_output already exists"
348  else
349    echo Splitting files into smaller pieces
350    rm -rf "$noFunctionBodies_Passing" "$noFunctionBodies_goal"
351    mkdir -p "$noFunctionBodies_Passing" "$noFunctionBodies_goal"
352    cd "$noFunctionBodies_Passing"
353    cp -rT "$fewestFilesOutputPath" "$noFunctionBodies_work"
354    cp -rT "$noFunctionBodies_Passing" "$noFunctionBodies_goal"
355
356    splitsPath="${subfilePath}.split"
357    "${scriptPath}/impl/split.sh" --consolidate-leaves "$noFunctionBodies_sandbox" "$splitsPath"
358    rm "$noFunctionBodies_sandbox" -rf
359
360    echo Removing deepest lines
361    cd "$noFunctionBodies_goal"
362    "${scriptPath}/impl/split.sh" --remove-leaves "$noFunctionBodies_sandbox" "$splitsPath"
363    rm "$noFunctionBodies_sandbox" -rf
364
365    # restore any special files that we don't want to shrink
366    for path in $pathsToNotShrink; do
367      relativePathFromSubfilePath="$(realpath --relative-to="./$subfilePath" "$path")"
368      if ! echo "$relativePathFromSubfilePath" | grep "^\.\." >/dev/null 2>/dev/null; then
369        # This file is contained in $subfilePath so we were going to try to shrink it if it weren't exempt
370        echo Exempting "$path" from shrinking
371        # Copy the exploded version of the file that doesn't have any missing pieces
372        rm -rf "$splitsPath/$relativePathFromSubfilePath"
373        cp -rT "$noFunctionBodies_Passing/$splitsPath/$relativePathFromSubfilePath" "$noFunctionBodies_goal/$splitsPath/$relativePathFromSubfilePath"
374      fi
375    done
376
377    # 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
378    # 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
379    echo Running diff-filterer.py again to identify which function bodies can be removed
380    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
381      echo diff-filterer completed successfully
382    else
383      failed
384    fi
385
386    echo Re-joining the files
387    rm -rf "${noFunctionBodies_output}"
388    cp -rT "$(cd $supportRoot/../../bestResults && pwd)" "${noFunctionBodies_output}"
389    cd "${noFunctionBodies_output}"
390    "${scriptPath}/impl/join.sh" "${splitsPath}" "${noFunctionBodies_sandbox}"
391  fi
392
393  # prepare for another invocation of diff-filterer, to remove other code that is now unused
394  smallestFilesInput="$tempDir/smallestFilesInput"
395  smallestFilesGoal="$tempDir/smallestFilesGoal"
396  smallestFilesWork="work"
397  smallestFilesSandbox="$smallestFilesWork/$subfilePath"
398
399  rm -rf "$smallestFilesInput" "$smallestFilesGoal"
400  mkdir -p "$smallestFilesInput"
401  cp -rT "${noFunctionBodies_output}" "$smallestFilesInput"
402
403  echo Splitting files into individual lines
404  cd "$smallestFilesInput"
405  splitsPath="${subfilePath}.split"
406  "${scriptPath}/impl/split.sh" "$smallestFilesSandbox" "$splitsPath"
407  rm "$smallestFilesSandbox" -rf
408
409  # Make a dir holding the destination file state
410  if [ "$limitToPath" != "" ]; then
411    # The user said they were only interested in trying to delete files under a certain path
412    # So, our target state is the original state minus that path (and its descendants)
413    mkdir -p "$smallestFilesGoal"
414    cp -rT "$smallestFilesInput/$smallestFilesWork" "$smallestFilesGoal/$smallestFilesWork"
415    cd "$smallestFilesGoal/$smallestFilesWork"
416    rm "$limitToPath" -rf
417    cd -
418  else
419    # The user didn't request to limit the search to a specific path, so we mostly try to delete as many
420    # files as possible
421    mkdir -p "$smallestFilesGoal"
422  fi
423  echo now check $smallestFilesGoal
424  # Restore any special exempt files
425  cd "$smallestFilesGoal"
426  for path in $pathsToNotShrink; do
427    relativePathFromSubfilePath="$(realpath --relative-to="./$subfilePath" "$path")"
428    if ! echo "$relativePathFromSubfilePath" | grep "^\.\." >/dev/null 2>/dev/null; then
429      # This file is contained in $subfilePath so we were going to try to shrink it if it weren't exempt
430      # Copy the exploded version of the file that doesn't have any missing pieces
431      echo Exempting "$path" from shrinking
432      destPath="$smallestFilesGoal/$splitsPath/$relativePathFromSubfilePath"
433      rm -rf "$destPath"
434      mkdir -p "$(dirname "$destPath")"
435      echo cp -rT "$noFunctionBodies_Passing/$splitsPath/$relativePathFromSubfilePath" "$destPath"
436      cp -rT "$noFunctionBodies_Passing/$splitsPath/$relativePathFromSubfilePath" "$smallestFilesGoal/$splitsPath/$relativePathFromSubfilePath"
437    fi
438  done
439
440  echo Running diff-filterer.py again to identify the minimal set of lines needed to reproduce the error
441  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
442    echo diff-filterer completed successfully
443  else
444    failed
445  fi
446
447  echo Re-joining the files
448  smallestFilesOutput="$tempDir/smallestFilesOutput"
449  rm -rf "$smallestFilesOutput"
450  cp -rT "$(cd $supportRoot/../../bestResults && pwd)" "${smallestFilesOutput}"
451  cd "${smallestFilesOutput}"
452  "${scriptPath}/impl/join.sh" "${splitsPath}" "${smallestFilesSandbox}"
453
454  echo "Done. See simplest discovered reproduction test case at ${smallestFilesOutput}/${smallestFilesWork}"
455fi
456notify succeeded
457