1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package libcore.heapmetrics;
18 
19 import com.android.ahat.heapdump.AhatSnapshot;
20 import com.android.ahat.heapdump.Diff;
21 import com.android.ahat.heapdump.HprofFormatException;
22 import com.android.ahat.heapdump.Parser;
23 import com.android.ahat.proguard.ProguardMap;
24 import com.android.tradefed.device.DeviceNotAvailableException;
25 import com.android.tradefed.device.ITestDevice;
26 import com.android.tradefed.result.FileInputStreamSource;
27 import com.android.tradefed.result.LogDataType;
28 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestLogData;
29 import com.android.tradefed.util.FileUtil;
30 
31 import java.io.File;
32 import java.io.IOException;
33 import java.time.Instant;
34 
35 /**
36  * Helper class that runs the metric instrumentations on a test device.
37  */
38 class MetricsRunner {
39 
40     private final ITestDevice testDevice;
41     private final String deviceParentDirectory;
42     private final TestLogData logs;
43     private final String timestampedLabel;
44 
45     /**
46      * Creates a helper using the given {@link ITestDevice}, uploading heap dumps to the given
47      * {@link TestLogData}.
48      */
create(ITestDevice testDevice, TestLogData logs)49     static MetricsRunner create(ITestDevice testDevice, TestLogData logs)
50             throws DeviceNotAvailableException {
51         String deviceParentDirectory =
52                 testDevice.executeShellCommand("echo -n ${EXTERNAL_STORAGE}");
53         return new MetricsRunner(testDevice, deviceParentDirectory, logs);
54     }
55 
MetricsRunner( ITestDevice testDevice, String deviceParentDirectory, TestLogData logs)56     private MetricsRunner(
57             ITestDevice testDevice, String deviceParentDirectory, TestLogData logs) {
58         this.testDevice = testDevice;
59         this.deviceParentDirectory = deviceParentDirectory;
60         this.logs = logs;
61         this.timestampedLabel = "LibcoreHeapMetricsTest-" + getCurrentTimeIso8601();
62     }
63 
64     /**
65      * Contains the results of running the instrumentation.
66      */
67     static class Result {
68 
69         private final AhatSnapshot afterDump;
70         private final int beforeTotalPssKb;
71         private final int afterTotalPssKb;
72 
Result( AhatSnapshot beforeDump, AhatSnapshot afterDump, int beforeTotalPssKb, int afterTotalPssKb)73         private Result(
74                 AhatSnapshot beforeDump, AhatSnapshot afterDump,
75                 int beforeTotalPssKb, int afterTotalPssKb) {
76             Diff.snapshots(afterDump, beforeDump);
77             this.beforeTotalPssKb = beforeTotalPssKb;
78             this.afterTotalPssKb = afterTotalPssKb;
79             this.afterDump = afterDump;
80         }
81 
82         /**
83          * Returns the parsed form of the heap dump captured when the instrumentation starts.
84          */
getBeforeDump()85         AhatSnapshot getBeforeDump() {
86             return afterDump.getBaseline();
87         }
88 
89         /**
90          * Returns the parsed form of the heap dump captured after the instrumentation action has
91          * been executed. The first heap dump will be set as the baseline for this second one.
92          */
getAfterDump()93         AhatSnapshot getAfterDump() {
94             return afterDump;
95         }
96 
97         /**
98          * Returns the PSS measured when the instrumentation starts, in kB.
99          */
getBeforeTotalPssKb()100         int getBeforeTotalPssKb() {
101             return beforeTotalPssKb;
102         }
103 
104         /**
105          * Returns the PSS measured after the instrumentation action has been executed, in kB.
106          */
getAfterTotalPssKb()107         int getAfterTotalPssKb() {
108             return afterTotalPssKb;
109         }
110     }
111 
112     /**
113      * Runs all the instrumentation and fetches the metrics.
114      *
115      * @param action The name of the action to run, to be sent as an argument to the instrumentation
116      * @return The combined results of the instrumentations.
117      */
runAllInstrumentations(String action)118     Result runAllInstrumentations(String action)
119             throws DeviceNotAvailableException, IOException, HprofFormatException {
120         String relativeDirectoryName = String.format("%s-%s", timestampedLabel, action);
121         String deviceDirectoryName =
122                 String.format("%s/%s", deviceParentDirectory, relativeDirectoryName);
123         testDevice.executeShellCommand(String.format("mkdir %s", deviceDirectoryName));
124         try {
125             runInstrumentation(
126                     action, relativeDirectoryName, deviceDirectoryName,
127                     "libcore.heapdumper/.HeapDumpInstrumentation");
128             runInstrumentation(
129                     action, relativeDirectoryName, deviceDirectoryName,
130                     "libcore.heapdumper/.PssInstrumentation");
131             AhatSnapshot beforeDump = fetchHeapDump(deviceDirectoryName, "before.hprof", action);
132             AhatSnapshot afterDump = fetchHeapDump(deviceDirectoryName, "after.hprof", action);
133             int beforeTotalPssKb = fetchTotalPssKb(deviceDirectoryName, "before.pss.txt");
134             int afterTotalPssKb = fetchTotalPssKb(deviceDirectoryName, "after.pss.txt");
135             return new Result(beforeDump, afterDump, beforeTotalPssKb, afterTotalPssKb);
136         } finally {
137             testDevice.executeShellCommand(String.format("rm -r %s", deviceDirectoryName));
138         }
139     }
140 
141     /**
142      * Runs a given instrumentation.
143      *
144      * <p>After the instrumentation has been run, checks for any reported errors and throws a
145      * {@link ApplicationException} if any are found.
146      *
147      * @param action The name of the action to run, to be sent as an argument to the instrumentation
148      * @param relativeDirectoryName The relative directory name for files on the device, to be sent
149      *     as an argument to the instrumentation
150      * @param deviceDirectoryName The absolute directory name for files on the device
151      * @param apk The name of the APK, in the form {@code test_package/runner_class}
152      */
runInstrumentation( String action, String relativeDirectoryName, String deviceDirectoryName, String apk)153     private void runInstrumentation(
154             String action, String relativeDirectoryName, String deviceDirectoryName, String apk)
155             throws DeviceNotAvailableException, IOException {
156         String command = String.format(
157                 "am instrument -w -e dumpdir %s -e action %s  %s",
158                 relativeDirectoryName, action, apk);
159         testDevice.executeShellCommand(command);
160         checkForErrorFile(deviceDirectoryName);
161     }
162 
163     /**
164      * Looks for a file called {@code error} in the named device directory, and throws an
165      * {@link ApplicationException} using the first line of that file as the message if found.
166      */
checkForErrorFile(String deviceDirectoryName)167     private void checkForErrorFile(String deviceDirectoryName)
168             throws DeviceNotAvailableException, IOException {
169         String[] deviceDirectoryContents =
170                 testDevice.executeShellCommand("ls " + deviceDirectoryName).split("\\s");
171         for (String deviceFileName : deviceDirectoryContents) {
172             if (deviceFileName.equals("error")) {
173                 throw new ApplicationException(readErrorFile(deviceDirectoryName));
174             }
175         }
176     }
177 
178     /**
179      * Returns the first line read from a file called {@code error} on the device in the named
180      * directory.
181      *
182      * <p>The file is pulled into a temporary location on the host, and deleted after reading.
183      */
readErrorFile(String deviceDirectoryName)184     private String readErrorFile(String deviceDirectoryName)
185             throws IOException, DeviceNotAvailableException {
186         File file = testDevice.pullFile(String.format("%s/error", deviceDirectoryName));
187         if (file == null) {
188             throw new RuntimeException(
189                     "Failed to pull error log from directory " + deviceDirectoryName);
190         }
191         try {
192             return FileUtil.readStringFromFile(file);
193         } finally {
194             file.delete();
195         }
196     }
197 
198     /**
199      * Returns an {@link AhatSnapshot} parsed from an {@code hprof} file on the device at the
200      * given directory and relative filename.
201      *
202      * <p>The file is pulled into a temporary location on the host, and deleted after reading.
203      * It is also logged via {@link TestLogData} under a name formed from the action and the
204      * relative filename (e.g. {@code noop-before.hprof}).
205      */
fetchHeapDump( String deviceDirectoryName, String relativeDumpFilename, String action)206     private AhatSnapshot fetchHeapDump(
207             String deviceDirectoryName, String relativeDumpFilename, String action)
208             throws DeviceNotAvailableException, IOException, HprofFormatException {
209         String deviceFileName = String
210                 .format("%s/%s", deviceDirectoryName, relativeDumpFilename);
211         File file = testDevice.pullFile(deviceFileName);
212         if (file == null) {
213             throw new RuntimeException("Failed to pull dump: " + deviceFileName);
214         }
215         try {
216             logHeapDump(file, String.format("%s-%s", action, relativeDumpFilename));
217             return Parser.parseHeapDump(file, new ProguardMap());
218         } finally {
219             file.delete();
220         }
221     }
222 
223     /**
224      * Returns the total PSS in kB read from a stringified integer in a file on the device at the
225      * given directory and relative filename.
226      */
fetchTotalPssKb( String deviceDirectoryName, String relativeFilename)227     private int fetchTotalPssKb(
228             String deviceDirectoryName, String relativeFilename)
229             throws DeviceNotAvailableException, IOException, HprofFormatException {
230         String shellCommand = String.format("cat %s/%s", deviceDirectoryName, relativeFilename);
231         String totalPssKbStr = testDevice.executeShellCommand(shellCommand);
232         return Integer.parseInt(totalPssKbStr);
233     }
234 
235     /**
236      * Logs the heap dump from the given file via {@link TestLogData} with the given log
237      * filename.
238      */
logHeapDump(File file, String logFilename)239     private void logHeapDump(File file, String logFilename) {
240         try (FileInputStreamSource dataStream = new FileInputStreamSource(file)) {
241             logs.addTestLog(logFilename, LogDataType.HPROF, dataStream);
242         }
243     }
244 
245     /**
246      * Returns the ISO 8601 form of the current time in UTC, for use as a timestamp in filenames.
247      * (Note that using UTC avoids an issue where the timezone indicator includes a + sign for the
248      * offset, which triggers an issue with URL encoding in tradefed, which causes the calls to
249      * {@code testDevice.pullFile()} to fail. See b/149018916.)
250      */
getCurrentTimeIso8601()251     private static String getCurrentTimeIso8601() {
252         return Instant.now().toString();
253     }
254 
255     /**
256      * An exception indicating that the activity on the device encountered an error which it
257      * passed
258      * back to the host.
259      */
260     private static class ApplicationException extends RuntimeException {
261 
262         private static final long serialVersionUID = 0;
263 
ApplicationException(String applicationError)264         ApplicationException(String applicationError) {
265             super("Error encountered running application on device: " + applicationError);
266         }
267     }
268 }
269