Skip to content

Commit eb99856

Browse files
A Googlercopybara-github
authored andcommitted
Prevent OOM caused by loading large files into memory.
Previously when building Android APK zip archives, the source files are read entirely into memory as byte arrays. So it cannot support files larger than 2 GB and will crash with OOM when allocating the arrays. The change adds the support for an arbitrary InputStream of unknown size which will work for large files. To minimize the impact to the build speed, when a file is small enough, the whole file is still read into memory to avoid repeated disk I/O. Large files are read in a streaming fashion to limit their memory consumption at the cost of more disk I/O. Currently the boundary between small and large size is set to 512 MB empirically. The Java byte arrays can support up to 2 GB, but loading such large files into memory can also cause a high memory pressure for the JVM, so I didn't set it to 2 GB. PiperOrigin-RevId: 819382752 Change-Id: Id0b96172e1674b4a3556a61538c957fa599c5c79
1 parent 5868b20 commit eb99856

3 files changed

Lines changed: 129 additions & 7 deletions

File tree

src/tools/java/com/google/devtools/build/android/AndroidResourceOutputs.java

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.io.Closeable;
2727
import java.io.File;
2828
import java.io.IOException;
29+
import java.io.InputStream;
2930
import java.nio.ByteBuffer;
3031
import java.nio.file.FileVisitResult;
3132
import java.nio.file.Files;
@@ -58,6 +59,9 @@ static class ZipBuilder implements Closeable {
5859
// see http://www.info-zip.org/FAQ.html#limits
5960
private static final long MINIMUM_TIMESTAMP_INCREMENT = 2000L;
6061

62+
// The maximum size of an entry that will be read entirely into memory.
63+
private static final long MAX_IN_MEMORY_ENTRY_SIZE = 512 * 1024 * 1024; // 512 MB.
64+
6165
/**
6266
* Normalized timestamp for zip entries We use the system's default timezone and locale and
6367
* additionally avoid using the DOS epoch to ensure Java's zip implementation does not add the
@@ -69,6 +73,13 @@ static class ZipBuilder implements Closeable {
6973
private static final long DEFAULT_TIMESTAMP =
7074
new GregorianCalendar(1980, Calendar.FEBRUARY, 01, 0, 0).getTimeInMillis();
7175

76+
77+
/** Supplier for input streams. */
78+
protected interface InputStreamSupplier {
79+
/** Returns a new input stream for reading the content. */
80+
InputStream get() throws IOException;
81+
}
82+
7283
private final ZipOutputStream zip;
7384

7485
private ZipBuilder(ZipOutputStream zip) {
@@ -131,6 +142,80 @@ protected void addEntry(
131142
zip.closeEntry();
132143
}
133144

145+
/**
146+
* Adds a new entry to the zip archive. Use this method when the content has an unknown size and
147+
* may be too large to fit in memory.
148+
*
149+
* @param rawName The name of the entry, which may include path separators.
150+
* @param contentSupplier A supplier for creating input streams to read the content. The
151+
* supplier may be called for more than once.
152+
* @param storageMethod The storage method to use for the entry.
153+
* @param comment The comment to add to the entry.
154+
* @throws IOException if an I/O error occurs.
155+
*/
156+
protected void addEntry(
157+
String rawName,
158+
InputStreamSupplier contentSupplier,
159+
int storageMethod,
160+
@Nullable String comment)
161+
throws IOException {
162+
// Fix the path for windows.
163+
String relativeName = rawName.replace('\\', '/');
164+
// Make sure the zip entry is not absolute.
165+
Preconditions.checkArgument(
166+
!relativeName.startsWith("/"), "Cannot add absolute resources %s", relativeName);
167+
ZipEntry entry = new ZipEntry(relativeName);
168+
entry.setMethod(storageMethod);
169+
entry.setTime(normalizeTime(relativeName));
170+
if (!Strings.isNullOrEmpty(comment)) {
171+
entry.setComment(comment);
172+
}
173+
174+
long size = 0;
175+
CRC32 crc32 = new CRC32();
176+
try (InputStream content = contentSupplier.get()) {
177+
byte[] buf = new byte[8192];
178+
int read;
179+
while ((read = content.read(buf)) != -1) {
180+
crc32.update(buf, 0, read);
181+
size += read;
182+
}
183+
}
184+
entry.setSize(size);
185+
entry.setCrc(crc32.getValue());
186+
zip.putNextEntry(entry);
187+
188+
// InputStream doesn't support reset(), so to safely read the stream twice, we need to create
189+
// the stream twice.
190+
try (InputStream content = contentSupplier.get()) {
191+
content.transferTo(zip);
192+
}
193+
zip.closeEntry();
194+
}
195+
196+
/**
197+
* Adds a new entry to the zip archive.
198+
*
199+
* @param rawName The name of the entry, which may include path separators.
200+
* @param sourcePath The path to the source file.
201+
* @param storageMethod The storage method to use for the entry.
202+
* @throws IOException if an I/O error occurs.
203+
*/
204+
protected void addEntry(String rawName, Path sourcePath, int storageMethod, String comment)
205+
throws IOException {
206+
// If the file is known to be small, it's more efficient to read it entirely into memory.
207+
// Otherwise, do streaming processing to avoid using too much memory.
208+
if (Files.isRegularFile(sourcePath) && Files.size(sourcePath) <= MAX_IN_MEMORY_ENTRY_SIZE) {
209+
addEntry(rawName, Files.readAllBytes(sourcePath), storageMethod, comment);
210+
} else {
211+
addEntry(rawName, () -> Files.newInputStream(sourcePath), storageMethod, comment);
212+
}
213+
}
214+
215+
protected void addEntry(String rawName, Path sourcePath, int storageMethod) throws IOException {
216+
addEntry(rawName, sourcePath, storageMethod, /* comment= */ null);
217+
}
218+
134219
@Override
135220
public void close() throws IOException {
136221
zip.close();
@@ -310,6 +395,11 @@ protected void addEntry(String entry, byte[] content) throws IOException {
310395
zipBuilder.addEntry(entry, content, storageMethod);
311396
}
312397

398+
protected void addEntry(Path file) throws IOException {
399+
Preconditions.checkArgument(file.startsWith(root), "%s does not start with %s", file, root);
400+
zipBuilder.addEntry(directoryPrefix + root.relativize(file), file, storageMethod);
401+
}
402+
313403
public void setCompress(boolean compress) {
314404
storageMethod = compress ? ZipEntry.DEFLATED : ZipEntry.STORED;
315405
}
@@ -333,8 +423,7 @@ void writeEntries() throws IOException {
333423

334424
protected void writeEntry(Path file) throws IOException {
335425
if (!Files.isDirectory(file)) {
336-
byte[] content = Files.readAllBytes(file);
337-
addEntry(file, content);
426+
addEntry(file);
338427
}
339428
}
340429
}

src/tools/java/com/google/devtools/build/android/ResourcesZip.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,19 +182,19 @@ public void writeTo(Path output, boolean compress) throws IOException {
182182
}
183183
try {
184184
if (ids != null) {
185-
zip.addEntry("ids.txt", Files.readAllBytes(ids), ZipEntry.STORED);
185+
zip.addEntry("ids.txt", ids, ZipEntry.STORED);
186186
}
187187

188188
if (proto != null && Files.exists(proto)) {
189-
zip.addEntry("apk.pb", Files.readAllBytes(proto), ZipEntry.STORED);
189+
zip.addEntry("apk.pb", proto, ZipEntry.STORED);
190190
}
191191

192192
if (attributes != null && Files.exists(attributes)) {
193-
zip.addEntry("tools.attributes.pb", Files.readAllBytes(attributes), ZipEntry.STORED);
193+
zip.addEntry("tools.attributes.pb", attributes, ZipEntry.STORED);
194194
}
195195

196196
if (packages != null && Files.exists(packages)) {
197-
zip.addEntry("packages.txt", Files.readAllBytes(packages), ZipEntry.STORED);
197+
zip.addEntry("packages.txt", packages, ZipEntry.STORED);
198198
}
199199

200200
} catch (IOException e) {
@@ -295,7 +295,7 @@ ShrunkProtoApk writeReportTo(Path reportOut) throws IOException {
295295
@CanIgnoreReturnValue
296296
ShrunkProtoApk writeResourcesToZip(Path resourcesZip) throws IOException {
297297
try (final ZipBuilder zip = ZipBuilder.createFor(resourcesZip)) {
298-
zip.addEntry("apk.pb", Files.readAllBytes(apk.asApkPath()), ZipEntry.STORED);
298+
zip.addEntry("apk.pb", apk.asApkPath(), ZipEntry.STORED);
299299
}
300300
return this;
301301
}

src/tools/javatests/com/google/devtools/build/android/AndroidResourceOutputsTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.nio.file.Path;
2222
import java.util.ArrayList;
2323
import java.util.List;
24+
import java.util.zip.CRC32;
2425
import java.util.zip.ZipEntry;
2526
import java.util.zip.ZipInputStream;
2627
import java.util.zip.ZipOutputStream;
@@ -66,4 +67,36 @@ public void testZipEntryPaths() throws Exception {
6667
}
6768
assertThat(entries).containsExactly("some/prefix/data1.txt", "some/prefix/bar/data2.txt");
6869
}
70+
71+
@Test
72+
public void zipBuilder_addEntry_contentFactory() throws Exception {
73+
Path output = tmp.resolve("actual.zip");
74+
Path fileToAdd = tmp.resolve("foo/data1.txt");
75+
Files.createDirectories(fileToAdd.getParent());
76+
byte[] content = "hello world".getBytes(Charset.defaultCharset());
77+
Files.write(fileToAdd, content);
78+
79+
try (ZipOutputStream zout = new ZipOutputStream(Files.newOutputStream(output))) {
80+
AndroidResourceOutputs.ZipBuilder zipBuilder = AndroidResourceOutputs.ZipBuilder.wrap(zout);
81+
zipBuilder.addEntry(
82+
"a/prefix/data1.txt",
83+
() -> Files.newInputStream(fileToAdd),
84+
ZipEntry.STORED,
85+
/* comment= */ null);
86+
}
87+
88+
try (ZipInputStream zin = new ZipInputStream(Files.newInputStream(output))) {
89+
ZipEntry entry = zin.getNextEntry();
90+
assertThat(entry).isNotNull();
91+
assertThat(entry.getName()).isEqualTo("a/prefix/data1.txt");
92+
assertThat(entry.getMethod()).isEqualTo(ZipEntry.STORED);
93+
byte[] zippedContent = zin.readAllBytes();
94+
assertThat(zippedContent).isEqualTo(content);
95+
CRC32 crc32 = new CRC32();
96+
crc32.update(content);
97+
assertThat(entry.getCrc()).isEqualTo(crc32.getValue());
98+
assertThat(entry.getSize()).isEqualTo(content.length);
99+
assertThat(zin.getNextEntry()).isNull();
100+
}
101+
}
69102
}

0 commit comments

Comments
 (0)