Describe the bug
When a custom-protocol responder (RequestAsyncResponder::respond) is called from a thread other than the main thread, wry calls WKURLSchemeTask's didReceiveResponse / didReceiveData / didFinish directly on that calling thread via objc2, with no dispatch to the main queue:
https://github.com/tauri-apps/wry/blob/v0.53.5/src/wkwebview/class/url_scheme_handler.rs#L184-L315
(Confirmed unchanged in 0.54.2 — same closure, same direct task.didReceiveResponse(...) / task.didReceiveData(...) / task.didFinish() calls with no dispatch_async hop.)
WebKit's WKURLSchemeTaskImpl wraps every off-main-thread did* call in getExceptionTypeFromMainRunLoop → callOnMainRunLoopAndWait (Source/WebKit/UIProcess/API/Cocoa/WKURLSchemeTask.mm), which is a synchronous, unbounded wait for the app's own main run loop to become free enough to service the block. If the calling thread belongs to a bounded worker pool (for example a fixed-size async runtime backing the app's IPC layer) and the main thread is busy — doing expensive synchronous work, or simply not pumping its run loop during a state transition — every worker with an in-flight response call blocks until the main thread frees up. Once enough concurrent responses are in flight to occupy the whole pool, the pool is fully pinned and none of its other work can proceed either, including work that might otherwise help the main thread finish sooner.
Steps To Reproduce
- Register a custom protocol handler via
WebViewBuilder::with_asynchronous_custom_protocol (or equivalent) on iOS or macOS.
- Have the handler hand the
RequestAsyncResponder off to a small, fixed-size worker pool (e.g. N background threads) and call .respond(...) from one of those workers once the (already-computed) response body is ready.
- Block the main thread's run loop for several seconds (e.g. a synchronous
sleep/heavy computation on main, or a suspend/resume transition where main isn't pumping).
- Fire N or more concurrent requests against the custom protocol while main is blocked, each responding from a pool worker.
- Observe that every worker used to call
.respond(...) parks inside callOnMainRunLoopAndWait (visible in a debugger backtrace under JavaScriptCore.framework / WTF) until the main thread's run loop resumes — even though the response itself required no main-thread computation and the payload was already fully formed.
Expected behavior
Since the response payload is already computed at the point .respond(...) is called, delivering it to WKURLSchemeTask should not require the calling thread to block waiting for the main run loop. The calling thread should be freed immediately (e.g. by asynchronously dispatching the did* sequence to the main queue) rather than synchronously waiting on it.
Screenshots
N/A
Platform and Versions (please complete the following information):
OS: iOS and macOS (same wkwebview module backs both; issue reproduces via source inspection on both, verified in production on iOS)
wry: 0.53.5 and 0.54.2 (identical mechanism in both — no behavioral diff in the relevant closure)
Rustc: not toolchain-specific; this is confirmed at the source level, not build-specific
Additional context
Production evidence: in a Tauri-based iOS app, a MetricKit diagnostic captured six threads — the full worker count of the app's async runtime backing IPC (one thread per CPU core on the target device) — simultaneously blocked in the custom-protocol response path via objc2, while the main thread was busy with other work. Every blocked thread's backtrace bottomed out in callOnMainRunLoopAndWait, matching this mechanism exactly: each in-flight response call had been issued from a runtime worker and blocked until the main thread's run loop became available again, pinning the entire worker pool for the duration. Any other async work sharing that pool (including work whose completion the main thread was itself waiting on) made no progress until the main thread cleared.
Proposed fix: in the responder closure (url_scheme_handler.rs), dispatch the didReceiveResponse / didReceiveData / didFinish sequence asynchronously onto the main queue instead of calling them directly, when not already running on the main thread. Ordering matters here: the three calls must stay in order for a given task, and didFinish (or didFailWithError) must be the last call delivered for that task. Since GCD's main queue is a serial FIFO queue, queuing the entire response body (including the existing check_webview_id_valid / check_task_is_valid revalidation, which already re-checks task validity at delivery time) as a single async block onto the main queue preserves per-task ordering automatically — the validity re-checks would simply run at the point the block executes instead of at the point it's queued.
Workaround used app-side: we moved the async work whose completion triggers the custom-protocol response off the shared, bounded worker pool and onto a separate, dedicated runtime, so a main-thread stall elsewhere in the app can no longer transitively pin threads needed by unrelated concurrent work. This avoids the worker-starvation symptom but doesn't address the underlying synchronous main-thread wait, which still blocks whatever thread does end up calling .respond(...) off-main.
Related but distinct: #1083 covers the self-deadlock case where .respond(...) is called directly on the main thread. This report is about the different — and less obvious — case where the caller is off-main and the pool it belongs to gets pinned because the main thread is merely busy, not deadlocked against the same call.
Describe the bug
When a custom-protocol responder (
RequestAsyncResponder::respond) is called from a thread other than the main thread, wry callsWKURLSchemeTask'sdidReceiveResponse/didReceiveData/didFinishdirectly on that calling thread via objc2, with no dispatch to the main queue:https://github.com/tauri-apps/wry/blob/v0.53.5/src/wkwebview/class/url_scheme_handler.rs#L184-L315
(Confirmed unchanged in 0.54.2 — same closure, same direct
task.didReceiveResponse(...)/task.didReceiveData(...)/task.didFinish()calls with nodispatch_asynchop.)WebKit's
WKURLSchemeTaskImplwraps every off-main-threaddid*call ingetExceptionTypeFromMainRunLoop→callOnMainRunLoopAndWait(Source/WebKit/UIProcess/API/Cocoa/WKURLSchemeTask.mm), which is a synchronous, unbounded wait for the app's own main run loop to become free enough to service the block. If the calling thread belongs to a bounded worker pool (for example a fixed-size async runtime backing the app's IPC layer) and the main thread is busy — doing expensive synchronous work, or simply not pumping its run loop during a state transition — every worker with an in-flight response call blocks until the main thread frees up. Once enough concurrent responses are in flight to occupy the whole pool, the pool is fully pinned and none of its other work can proceed either, including work that might otherwise help the main thread finish sooner.Steps To Reproduce
WebViewBuilder::with_asynchronous_custom_protocol(or equivalent) on iOS or macOS.RequestAsyncResponderoff to a small, fixed-size worker pool (e.g. N background threads) and call.respond(...)from one of those workers once the (already-computed) response body is ready.sleep/heavy computation on main, or a suspend/resume transition where main isn't pumping)..respond(...)parks insidecallOnMainRunLoopAndWait(visible in a debugger backtrace underJavaScriptCore.framework/ WTF) until the main thread's run loop resumes — even though the response itself required no main-thread computation and the payload was already fully formed.Expected behavior
Since the response payload is already computed at the point
.respond(...)is called, delivering it toWKURLSchemeTaskshould not require the calling thread to block waiting for the main run loop. The calling thread should be freed immediately (e.g. by asynchronously dispatching thedid*sequence to the main queue) rather than synchronously waiting on it.Screenshots
N/A
Platform and Versions (please complete the following information):
OS: iOS and macOS (same
wkwebviewmodule backs both; issue reproduces via source inspection on both, verified in production on iOS)wry: 0.53.5 and 0.54.2 (identical mechanism in both — no behavioral diff in the relevant closure)
Rustc: not toolchain-specific; this is confirmed at the source level, not build-specific
Additional context
Production evidence: in a Tauri-based iOS app, a MetricKit diagnostic captured six threads — the full worker count of the app's async runtime backing IPC (one thread per CPU core on the target device) — simultaneously blocked in the custom-protocol response path via objc2, while the main thread was busy with other work. Every blocked thread's backtrace bottomed out in
callOnMainRunLoopAndWait, matching this mechanism exactly: each in-flight response call had been issued from a runtime worker and blocked until the main thread's run loop became available again, pinning the entire worker pool for the duration. Any other async work sharing that pool (including work whose completion the main thread was itself waiting on) made no progress until the main thread cleared.Proposed fix: in the responder closure (
url_scheme_handler.rs), dispatch thedidReceiveResponse/didReceiveData/didFinishsequence asynchronously onto the main queue instead of calling them directly, when not already running on the main thread. Ordering matters here: the three calls must stay in order for a given task, anddidFinish(ordidFailWithError) must be the last call delivered for that task. Since GCD's main queue is a serial FIFO queue, queuing the entire response body (including the existingcheck_webview_id_valid/check_task_is_validrevalidation, which already re-checks task validity at delivery time) as a single async block onto the main queue preserves per-task ordering automatically — the validity re-checks would simply run at the point the block executes instead of at the point it's queued.Workaround used app-side: we moved the async work whose completion triggers the custom-protocol response off the shared, bounded worker pool and onto a separate, dedicated runtime, so a main-thread stall elsewhere in the app can no longer transitively pin threads needed by unrelated concurrent work. This avoids the worker-starvation symptom but doesn't address the underlying synchronous main-thread wait, which still blocks whatever thread does end up calling
.respond(...)off-main.Related but distinct: #1083 covers the self-deadlock case where
.respond(...)is called directly on the main thread. This report is about the different — and less obvious — case where the caller is off-main and the pool it belongs to gets pinned because the main thread is merely busy, not deadlocked against the same call.