Skip to content

Commit 72feb6b

Browse files
committed
Add crash tolerance stress tests and data loss comparison for Mp4Muxer vs HybridMp4Muxer
1 parent ee0ddfc commit 72feb6b

2 files changed

Lines changed: 455 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/*
2+
* Copyright 2026 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+
package androidx.media3.muxer;
17+
18+
import static androidx.media3.muxer.MuxerTestUtil.FAKE_AUDIO_FORMAT;
19+
import static androidx.media3.muxer.MuxerTestUtil.FAKE_VIDEO_FORMAT;
20+
import static androidx.media3.muxer.MuxerTestUtil.getFakeSampleAndSampleInfo;
21+
import static com.google.common.truth.Truth.assertWithMessage;
22+
23+
import android.util.Pair;
24+
import androidx.media3.container.Mp4TimestampData;
25+
import androidx.media3.extractor.mp4.Mp4Extractor;
26+
import androidx.media3.test.utils.FakeExtractorOutput;
27+
import androidx.media3.test.utils.TestUtil;
28+
import java.io.FileOutputStream;
29+
import java.io.IOException;
30+
import java.nio.ByteBuffer;
31+
import org.junit.Rule;
32+
import org.junit.Test;
33+
import org.junit.rules.TemporaryFolder;
34+
import org.junit.runner.RunWith;
35+
import org.robolectric.RobolectricTestRunner;
36+
37+
/**
38+
* Stress test for {@link Mp4Muxer} crash tolerance.
39+
*
40+
* <p>Uses a {@link CrashAfterNWritesOutput} that lets the first N write calls complete
41+
* successfully, then throws on write N+1. This simulates a process kill landing between two
42+
* sequential writes inside {@link Mp4Writer} — for example between writing a new moov and updating
43+
* the mdat size in {@code safelyReplaceMoovAtEnd}.
44+
*/
45+
@RunWith(RobolectricTestRunner.class)
46+
public class Mp4MuxerCrashToleranceStressTest {
47+
@Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
48+
49+
private static final long VIDEO_FRAME_DURATION_US = 33_333; // 30fps
50+
private static final long AUDIO_FRAME_DURATION_US = 23_220; // ~43fps AAC
51+
private static final int RESERVED_MOOV_SIZE_BYTES = 10_000;
52+
private static final long RECORDING_DURATION_US = 45 * 1_000_000L;
53+
private static final int MAX_TRIALS = 1000;
54+
55+
@Test
56+
public void mp4Muxer_crashAfterNthWrite_runsUntilFailure() throws Exception {
57+
// First, do a clean run to count total write calls.
58+
String referenceOutputPath = temporaryFolder.newFile("reference.mp4").getPath();
59+
CrashAfterNWritesOutput countingOutput =
60+
new CrashAfterNWritesOutput(
61+
SeekableMuxerOutput.of(new FileOutputStream(referenceOutputPath)),
62+
/* crashAfterWrite= */ Integer.MAX_VALUE);
63+
writeFullRecording(countingOutput);
64+
countingOutput.closeUnderlying();
65+
int totalWriteCalls = countingOutput.getWriteCount();
66+
67+
// Skip early writes (ftyp, reserved space, initial mdat header) — start crashing after
68+
// at least 50% of writes to focus on the post-overflow region.
69+
int minCrashWrite = totalWriteCalls / 2;
70+
71+
int successes = 0;
72+
String failureMessage = null;
73+
74+
for (int trial = 0; trial < MAX_TRIALS; trial++) {
75+
// Evenly space crash points across the second half of the write sequence.
76+
int crashAfterWrite =
77+
minCrashWrite + ((totalWriteCalls - minCrashWrite) * trial) / MAX_TRIALS;
78+
79+
String outputPath =
80+
temporaryFolder.newFile("crash_test_" + trial + ".mp4").getPath();
81+
82+
CrashAfterNWritesOutput crashingOutput =
83+
new CrashAfterNWritesOutput(
84+
SeekableMuxerOutput.of(new FileOutputStream(outputPath)), crashAfterWrite);
85+
86+
try {
87+
writeFullRecording(crashingOutput);
88+
} catch (MuxerException e) {
89+
// Expected — the output threw IOException after the Nth write.
90+
}
91+
92+
try {
93+
crashingOutput.closeUnderlying();
94+
} catch (IOException e) {
95+
// Ignore.
96+
}
97+
98+
// Verify the abandoned file is recoverable.
99+
try {
100+
FakeExtractorOutput extractorOutput =
101+
TestUtil.extractAllSamplesFromFilePath(new Mp4Extractor(), outputPath);
102+
if (extractorOutput.numberOfTracks > 0) {
103+
successes++;
104+
} else {
105+
failureMessage =
106+
String.format(
107+
"Trial %d: crashed after write %d/%d - extracted 0 tracks",
108+
trial, crashAfterWrite, totalWriteCalls);
109+
break;
110+
}
111+
} catch (Exception e) {
112+
failureMessage =
113+
String.format(
114+
"Trial %d: crashed after write %d/%d (%.1f%%) - %s: %s",
115+
trial,
116+
crashAfterWrite,
117+
totalWriteCalls,
118+
100.0 * crashAfterWrite / totalWriteCalls,
119+
e.getClass().getSimpleName(),
120+
e.getMessage());
121+
break;
122+
}
123+
}
124+
125+
assertWithMessage(
126+
String.format(
127+
"Mp4Muxer crash tolerance: %d/%d succeeded before failure. "
128+
+ "Total write calls in clean run: %d.%s",
129+
successes,
130+
MAX_TRIALS,
131+
totalWriteCalls,
132+
failureMessage != null ? "\nFirst failure: " + failureMessage : ""))
133+
.that(successes)
134+
.isEqualTo(MAX_TRIALS);
135+
}
136+
137+
private void writeFullRecording(CrashAfterNWritesOutput muxerOutput)
138+
throws MuxerException {
139+
Mp4Muxer muxer =
140+
new Mp4Muxer.Builder(muxerOutput)
141+
.experimentalSetFreeSpaceAfterFileTypeBox(RESERVED_MOOV_SIZE_BYTES)
142+
.build();
143+
muxer.addMetadataEntry(
144+
new Mp4TimestampData(
145+
/* creationTimestampSeconds= */ 100_000_000L,
146+
/* modificationTimestampSeconds= */ 500_000_000L));
147+
148+
int videoTrackId = muxer.addTrack(FAKE_VIDEO_FORMAT);
149+
int audioTrackId = muxer.addTrack(FAKE_AUDIO_FORMAT);
150+
151+
long videoTimestampUs = 0;
152+
long audioTimestampUs = 0;
153+
154+
while (videoTimestampUs < RECORDING_DURATION_US || audioTimestampUs < RECORDING_DURATION_US) {
155+
if (videoTimestampUs <= audioTimestampUs) {
156+
Pair<ByteBuffer, BufferInfo> sample =
157+
getFakeSampleAndSampleInfo(videoTimestampUs, /* isVideo= */ true);
158+
muxer.writeSampleData(videoTrackId, sample.first, sample.second);
159+
videoTimestampUs += VIDEO_FRAME_DURATION_US;
160+
} else {
161+
Pair<ByteBuffer, BufferInfo> sample =
162+
getFakeSampleAndSampleInfo(audioTimestampUs, /* isVideo= */ false);
163+
muxer.writeSampleData(audioTrackId, sample.first, sample.second);
164+
audioTimestampUs += AUDIO_FRAME_DURATION_US;
165+
}
166+
}
167+
168+
muxer.close();
169+
}
170+
171+
/**
172+
* A {@link SeekableMuxerOutput} that lets the first N {@code write()} calls succeed, then throws
173+
* {@link IOException} on subsequent calls. All completed writes are fully flushed to disk,
174+
* simulating a process kill between two write syscalls.
175+
*/
176+
private static final class CrashAfterNWritesOutput implements SeekableMuxerOutput {
177+
private final SeekableMuxerOutput delegate;
178+
private final int crashAfterWrite;
179+
private int writeCount;
180+
private boolean crashed;
181+
182+
CrashAfterNWritesOutput(SeekableMuxerOutput delegate, int crashAfterWrite) {
183+
this.delegate = delegate;
184+
this.crashAfterWrite = crashAfterWrite;
185+
}
186+
187+
@Override
188+
public int write(ByteBuffer src) throws IOException {
189+
if (crashed) {
190+
throw new IOException("Simulated crash: process killed");
191+
}
192+
writeCount++;
193+
if (writeCount > crashAfterWrite) {
194+
crashed = true;
195+
throw new IOException(
196+
String.format("Simulated crash after write %d", crashAfterWrite));
197+
}
198+
return delegate.write(src);
199+
}
200+
201+
@Override
202+
public long getPosition() throws IOException {
203+
return delegate.getPosition();
204+
}
205+
206+
@Override
207+
public void setPosition(long position) throws IOException {
208+
if (crashed) {
209+
throw new IOException("Simulated crash: process killed");
210+
}
211+
delegate.setPosition(position);
212+
}
213+
214+
@Override
215+
public long getSize() throws IOException {
216+
return delegate.getSize();
217+
}
218+
219+
@Override
220+
public void truncate(long size) throws IOException {
221+
if (crashed) {
222+
throw new IOException("Simulated crash: process killed");
223+
}
224+
delegate.truncate(size);
225+
}
226+
227+
@Override
228+
public boolean isOpen() {
229+
return !crashed && delegate.isOpen();
230+
}
231+
232+
@Override
233+
public void close() throws IOException {
234+
crashed = true;
235+
delegate.close();
236+
}
237+
238+
void closeUnderlying() throws IOException {
239+
delegate.close();
240+
}
241+
242+
int getWriteCount() {
243+
return writeCount;
244+
}
245+
}
246+
}

0 commit comments

Comments
 (0)