The Android WebView SIGTRAP that bypasses every crash reporter

My Android SDK was crashing in production and Sentry had nothing. No stack trace. No exception. Not even a thread name I recognized. Just Fatal signal 5 (SIGTRAP) in logcat, and a process that vanished mid-navigation while the host app shrugged and kept running.
Two days to find it. One line to fix it. The invisible part is why no crash reporter caught it.
The symptom
A user taps a link inside our embedded WebView. The page starts loading. Then the SDK process disappears.
If I caught logcat at the right moment, I saw something like:
F libc : Fatal signal 5 (SIGTRAP), code 1 in tid 12345 (Chrome_InProcRe)
F libc : Cmdline: com.example.host:sdk
F libc : pid: 12345, tid: 12345, name: Chrome_InProcRe >>> com.example.host:sdk <<<
F DEBUG : signal 5 (SIGTRAP), code 1, fault addr ...
F DEBUG : #00 pc 0x... /system/.../libwebviewchromium.so
Native crash. SIGTRAP. Inside libwebviewchromium.so. Not one Java frame on the stack.
What I thought was happening
My first guess was a Chromium bug. SIGTRAP in libwebviewchromium.so looks like a broken WebView build. I checked Android System WebView versions across the affected devices. They were fine. I rolled some devices back to older builds. Same crash.
Second guess: WebView renderer OOM. We have onRenderProcessGone returning true, so the renderer dying shouldn't kill our app, but I added paranoid logging anyway. The renderer was healthy. The crash was in our process.
So it was us.
What was actually happening
The repro was tight. A specific link, a specific code path inside shouldOverrideUrlLoading. The branch did this:
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
Map<String, String> headers = new HashMap<>();
headers.put("X-My-Header", "value");
view.loadUrl(url, headers); // <- crash
return true;
}
We had three places in this method that called view.loadUrl(...) synchronously inside the callback. Every one of them could crash. None of them ever showed up in Sentry because none of them threw a Java exception.
Why re-entrant loadUrl trips the is_safe_to_delete DCHECK
Chromium tracks every navigation as an in-flight NavigationRequest object. When shouldOverrideUrlLoading fires, that NavigationRequest is mid-flight. The browser-side state machine has not finished processing the current navigation yet.
When you call view.loadUrl(url, headers) from inside the callback, you are asking the same controller to start a brand new navigation while it is still in the middle of the previous one. That second loadUrl causes the original NavigationRequest to be destroyed before Chromium has marked it safe to delete.
Chromium uses DCHECK assertions, debug-only sanity checks that document invariants. They're compiled out of stock Android System WebView (which builds as is_official_build), but they're left in the Beta, Dev, and Canary channels. One of them is is_safe_to_delete_. The Chromium comment on it spells out exactly what goes wrong:
If
is_safe_to_delete_is false, it meansthisis being deleted at an unexpected time, more specifically a time that is likely to lead to crashing when the stack unwinds (use after free).
So in the WebView Beta or Dev your QA team probably runs, the DCHECK fires and aborts the process cleanly. In the stock release WebView your users have, the DCHECK is gone but the use-after-free is still there. The stack unwinds back into navigation code that touches the freed NavigationRequest, and Chromium's MiraclePtr / BackupRefPtr hardening catches the dangling-pointer access. On Android, that typically reports as SIGTRAP (signal 5, code 128, SI_KERNEL) rather than a clean SIGSEGV.
I am not the first to find this. Electron's PR #48004 describes the same root cause from the desktop side: a re-entrant loadURL() call from did-start-navigation destroys the in-flight NavigationRequest before it is safe to delete and trips the same DCHECK. Same mechanism, same fix shape.
The reason Sentry, Crashlytics, and Bugsnag all miss it: this is not a JNI exception. There is no Java frame to catch. Thread.UncaughtExceptionHandler never fires because no Java thread crashed. The process is killed by signal directly. None of the popular crash reporters install a native signal handler in a non-default Android process by default. Crashlytics in particular auto-initializes via a ContentProvider (CrashlyticsInitProvider) which only runs in the default process, so a :sdk-style secondary process gets neither the Java handler nor the NDK signal handler. This is a longstanding limitation and not a config bug.
The Android docs do warn against this, in a Note on WebViewClient:
"Do not call WebView.loadUrl(String) with the request's URL and then return true. This unnecessarily cancels the current load and starts a new load with the same URL."
The docs explain what it does. They don't mention it can also kill your process.
The fix
Defer the loadUrl to the next message loop tick. One change per branch:
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
Map<String, String> headers = new HashMap<>();
headers.put("X-My-Header", "value");
view.post(() -> view.loadUrl(url, headers)); // deferred
return true;
}
view.post queues the runnable on the WebView's main-thread handler. By the time it runs, shouldOverrideUrlLoading has already returned, the in-flight NavigationRequest has been processed and marked safe to delete, and starting a new navigation is no longer re-entrant.
That is the whole fix. Three branches, three view.post wrappers.
Pin it with a test, or it will come back
Native invariants get refactored away. Six months from now somebody reads view.post(() -> view.loadUrl(...)), decides the view.post is pointless ceremony, deletes it. The unit test suite passes because nothing in JVM-land tests Chromium's actual re-entrancy guard.
So the contract gets pinned in a JVM test:
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import android.net.Uri;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
@RunWith(RobolectricTestRunner.class)
public class MyWebViewClientTest {
private final MyWebViewClient subject = new MyWebViewClient();
@Test
public void shouldOverrideUrlLoading_defersLoadUrl() {
WebView view = mock(WebView.class);
WebResourceRequest request = mock(WebResourceRequest.class);
when(request.getUrl()).thenReturn(Uri.parse("https://example.com/path"));
boolean handled = subject.shouldOverrideUrlLoading(view, request);
assertTrue(handled);
verify(view).post(any(Runnable.class));
verify(view, never()).loadUrl(anyString(), anyMap());
}
}
Robolectric is the runner here because Uri.parse needs the Android framework, which stock JVM tests don't have.
This test is dumb. It checks that view.post was called and view.loadUrl was not. It will not catch a future Chromium change. It will catch the much more likely failure: someone deletes the view.post because they don't know why it's there.
Other places this bites
It is not just shouldOverrideUrlLoading. The same mid-state rule applies to every Chromium-invoked callback that runs while a navigation is in flight. Don't call loadUrl, evaluateJavascript, goBack, or reload synchronously from any of:
shouldOverrideUrlLoading: navigation in flight,is_safe_to_delete_is falseonPageStarted: navigation just started, before commit (same in-flight window asshouldOverrideUrlLoading)shouldInterceptRequest: resource fetch mid-pipeline, called on a non-main threadonLoadResource: subresource in flight inside an active navigation
If you need to issue a new navigation from any of them, defer with view.post. Cheap habit. Saves you a SIGTRAP.
What I'd take away
If you ship anything in a non-default Android process, install a native signal handler there. Sentry, Crashlytics, Bugsnag wired up in your default process is not enough. A whole class of crash will be silent until you do.
And don't synchronously call back into the WebView from inside a WebView callback. The Android docs whisper this. They should be shouting it. Now you know why.
