<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Blogs by Krishna]]></title><description><![CDATA[Blogs by Krishna]]></description><link>https://blog.krishnavamsi.com</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Blogs by Krishna</title><link>https://blog.krishnavamsi.com</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 15 May 2026 05:43:05 GMT</lastBuildDate><atom:link href="https://blog.krishnavamsi.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The Android WebView SIGTRAP that bypasses every crash reporter]]></title><description><![CDATA[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 vanishe]]></description><link>https://blog.krishnavamsi.com/android-webview-sigtrap-bypasses-crash-reporters</link><guid isPermaLink="true">https://blog.krishnavamsi.com/android-webview-sigtrap-bypasses-crash-reporters</guid><category><![CDATA[Android]]></category><category><![CDATA[webview]]></category><category><![CDATA[debugging]]></category><category><![CDATA[chromium]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[SIGTRAP]]></category><dc:creator><![CDATA[Krishna Vamsi]]></dc:creator><pubDate>Mon, 04 May 2026 18:52:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69e84da3e436727814d19079/38f6f49d-dd4d-4cc8-b688-d987530191b4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>My Android SDK was crashing in production and Sentry had nothing. No stack trace. No exception. Not even a thread name I recognized. Just <code>Fatal signal 5 (SIGTRAP)</code> in logcat, and a process that vanished mid-navigation while the host app shrugged and kept running.</p>
<p>Two days to find it. One line to fix it. The invisible part is why no crash reporter caught it.</p>
<h2>The symptom</h2>
<p>A user taps a link inside our embedded WebView. The page starts loading. Then the SDK process disappears.</p>
<p>If I caught logcat at the right moment, I saw something like:</p>
<pre><code class="language-plaintext">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  &gt;&gt;&gt; com.example.host:sdk &lt;&lt;&lt;
F DEBUG   : signal 5 (SIGTRAP), code 1, fault addr ...
F DEBUG   :   #00 pc 0x... /system/.../libwebviewchromium.so
</code></pre>
<p>Native crash. SIGTRAP. Inside <code>libwebviewchromium.so</code>. Not one Java frame on the stack.</p>
<h2>What I thought was happening</h2>
<p>My first guess was a Chromium bug. SIGTRAP in <code>libwebviewchromium.so</code> 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.</p>
<p>Second guess: WebView renderer OOM. We have <code>onRenderProcessGone</code> 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.</p>
<p>So it was us.</p>
<h2>What was actually happening</h2>
<p>The repro was tight. A specific link, a specific code path inside <code>shouldOverrideUrlLoading</code>. The branch did this:</p>
<pre><code class="language-java">@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
    String url = request.getUrl().toString();

    Map&lt;String, String&gt; headers = new HashMap&lt;&gt;();
    headers.put("X-My-Header", "value");
    view.loadUrl(url, headers);   // &lt;- crash
    return true;
}
</code></pre>
<p>We had three places in this method that called <code>view.loadUrl(...)</code> 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.</p>
<img src="https://mermaid.ink/img/c2VxdWVuY2VEaWFncmFtCiAgICBwYXJ0aWNpcGFudCBDbGllbnQgYXMgV2ViVmlld0NsaWVudAogICAgcGFydGljaXBhbnQgV2ViVmlldwogICAgcGFydGljaXBhbnQgTmF2IGFzIE5hdmlnYXRpb25SZXF1ZXN0CiAgICBOb3RlIG92ZXIgTmF2OiBpc19zYWZlX3RvX2RlbGV0ZV8gPSBmYWxzZQogICAgTmF2LT4+V2ViVmlldzogRGlkU3RhcnROYXZpZ2F0aW9uCiAgICBXZWJWaWV3LT4+Q2xpZW50OiBzaG91bGRPdmVycmlkZVVybExvYWRpbmcKICAgIENsaWVudC0+PldlYlZpZXc6IHZpZXcubG9hZFVybAogICAgV2ViVmlldy0+Pk5hdjogc3RhcnQgbmV3IG5hdmlnYXRpb24KICAgIE5hdi0+Pk5hdjogZGVzdHJveSBpbi1mbGlnaHQgcmVxdWVzdAogICAgTm90ZSBvdmVyIE5hdjogc3RhY2sgdW53aW5kIGhpdHMgZnJlZWQgbWVtb3J5CiAgICBOYXYtPj5DbGllbnQ6IFNJR1RSQVAsIHByb2Nlc3MgZGllcwo=?type=png&amp;bgColor=ffffff" alt="Re-entrant loadUrl during shouldOverrideUrlLoading destroys an in-flight NavigationRequest before Chromium has marked it safe to delete" style="display:block;margin:0 auto" />

<h2>Why re-entrant loadUrl trips the is_safe_to_delete DCHECK</h2>
<p>Chromium tracks every navigation as an in-flight <code>NavigationRequest</code> object. When <code>shouldOverrideUrlLoading</code> fires, that <code>NavigationRequest</code> is mid-flight. The browser-side state machine has not finished processing the current navigation yet.</p>
<p>When you call <code>view.loadUrl(url, headers)</code> 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 <code>loadUrl</code> causes the original <code>NavigationRequest</code> to be destroyed before Chromium has marked it safe to delete.</p>
<p>Chromium uses DCHECK assertions, debug-only sanity checks that document invariants. They're compiled out of stock Android System WebView (which builds as <code>is_official_build</code>), but they're left in the Beta, Dev, and Canary channels. One of them is <a href="https://source.chromium.org/chromium/chromium/src/+/main:content/browser/renderer_host/navigation_request.cc;l=2294"><code>is_safe_to_delete_</code></a>. The Chromium comment on it spells out exactly what goes wrong:</p>
<blockquote>
<p>If <code>is_safe_to_delete_</code> is false, it means <code>this</code> is being deleted at an unexpected time, more specifically a time that is likely to lead to crashing when the stack unwinds (use after free).</p>
</blockquote>
<p>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 <code>NavigationRequest</code>, and Chromium's <a href="https://chromium.googlesource.com/chromium/src/+/HEAD/base/memory/raw_ptr.md">MiraclePtr / BackupRefPtr</a> hardening catches the dangling-pointer access. On Android, that typically reports as <code>SIGTRAP</code> (signal 5, code 128, <code>SI_KERNEL</code>) rather than a clean <code>SIGSEGV</code>.</p>
<p>I am not the first to find this. Electron's PR <a href="https://github.com/electron/electron/pull/48004">#48004</a> describes the same root cause from the desktop side: a re-entrant <code>loadURL()</code> call from <code>did-start-navigation</code> destroys the in-flight <code>NavigationRequest</code> before it is safe to delete and trips the same DCHECK. Same mechanism, same fix shape.</p>
<p>The reason Sentry, Crashlytics, and Bugsnag all miss it: this is not a JNI exception. There is no Java frame to catch. <code>Thread.UncaughtExceptionHandler</code> 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 <code>ContentProvider</code> (<code>CrashlyticsInitProvider</code>) which only runs in the default process, so a <code>:sdk</code>-style secondary process gets neither the Java handler nor the NDK signal handler. This is a <a href="https://github.com/firebase/firebase-android-sdk/issues/1592">longstanding limitation</a> and not a config bug.</p>
<p>The Android docs do warn against this, in a Note on <code>WebViewClient</code>:</p>
<blockquote>
<p>"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."</p>
</blockquote>
<p>The docs explain <em>what</em> it does. They don't mention it can also kill your process.</p>
<h2>The fix</h2>
<p>Defer the <code>loadUrl</code> to the next message loop tick. One change per branch:</p>
<pre><code class="language-java">@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
    String url = request.getUrl().toString();

    Map&lt;String, String&gt; headers = new HashMap&lt;&gt;();
    headers.put("X-My-Header", "value");
    view.post(() -&gt; view.loadUrl(url, headers));   // deferred
    return true;
}
</code></pre>
<p><code>view.post</code> queues the runnable on the WebView's main-thread handler. By the time it runs, <code>shouldOverrideUrlLoading</code> has already returned, the in-flight <code>NavigationRequest</code> has been processed and marked safe to delete, and starting a new navigation is no longer re-entrant.</p>
<p>That is the whole fix. Three branches, three <code>view.post</code> wrappers.</p>
<h2>Pin it with a test, or it will come back</h2>
<p>Native invariants get refactored away. Six months from now somebody reads <code>view.post(() -&gt; view.loadUrl(...))</code>, decides the <code>view.post</code> is pointless ceremony, deletes it. The unit test suite passes because nothing in JVM-land tests Chromium's actual re-entrancy guard.</p>
<p>So the contract gets pinned in a JVM test:</p>
<pre><code class="language-java">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());
    }
}
</code></pre>
<p>Robolectric is the runner here because <code>Uri.parse</code> needs the Android framework, which stock JVM tests don't have.</p>
<p>This test is dumb. It checks that <code>view.post</code> was called and <code>view.loadUrl</code> was not. It will not catch a future Chromium change. It will catch the much more likely failure: someone deletes the <code>view.post</code> because they don't know why it's there.</p>
<h2>Other places this bites</h2>
<p>It is not just <code>shouldOverrideUrlLoading</code>. The same mid-state rule applies to every Chromium-invoked callback that runs while a navigation is in flight. Don't call <code>loadUrl</code>, <code>evaluateJavascript</code>, <code>goBack</code>, or <code>reload</code> synchronously from any of:</p>
<ul>
<li><p><code>shouldOverrideUrlLoading</code>: navigation in flight, <code>is_safe_to_delete_</code> is false</p>
</li>
<li><p><code>onPageStarted</code>: navigation just started, before commit (same in-flight window as <code>shouldOverrideUrlLoading</code>)</p>
</li>
<li><p><code>shouldInterceptRequest</code>: resource fetch mid-pipeline, called on a non-main thread</p>
</li>
<li><p><code>onLoadResource</code>: subresource in flight inside an active navigation</p>
</li>
</ul>
<p>If you need to issue a new navigation from any of them, defer with <code>view.post</code>. Cheap habit. Saves you a SIGTRAP.</p>
<h2>What I'd take away</h2>
<p>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.</p>
<p>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.</p>
]]></content:encoded></item></channel></rss>