Vitre - One Thread, Many Callers (Part 2)
Part one described Vitre as a step vocabulary for driving a WebView. The steps are the part people see. This post is about the part I spent most of the time on and none of the marketing on: the thread the WebView owns, who is allowed to touch it, and in what order.
I want to be blunt about why this matters, because it is easy to skim. A workflow is one caller running a script it wrote in advance. Add an agent and you have a second caller, arriving at times nobody planned, interleaved with the first. Add the UI and you have a third — the reload button is a caller too. Every hard bug in this library lives in the space between those callers, and the entire design is one object whose job is to make that space empty.
There is nothing to negotiate
Both platforms have already decided the question for you.
WKWebView is UIKit. Every member is main-thread-only, and calling one from another thread is not an exception, it is undefined behaviour. It usually presents as a hang or a wrong answer, which is exactly why it survives testing.
An Android WebView must be used on the thread that constructed it, which for a hosted view is the UI thread.
So the WebView thread is the platform main thread, and no amount of clever design changes that. You do not get to synchronise threads. You get to decide who may visit the one thread that exists, and in what order they queue.
The model: one crossing point
Three roles, three places, and exactly one point where anyone touches the WebView.
UI (Compose, main thread) Agent / MCP / host code (any dispatcher)
| |
| observes StateFlow | suspending calls
v v
+-------------------------------------------------------------+
| WorkflowEngine |
| Dispatchers.Default — selectors, JSON, |
| variables. Never sees the WebView thread. |
+---------------------------------+---------------------------+
| one operation at a time
+------------v------------+
| WebViewSerializer | <- the entire policy
| - confines to main |
| - totally orders ops |
| - leases, for sequences|
+------------+------------+
v
WKWebView / android.webkit.WebView
WebViewSerializer is the whole concurrency model, and it enforces two rules.
Confinement. Every platform call goes through withContext(WebViewDispatcher).
Callers run wherever they like and cross over for the duration of one operation.
WebViewDispatcher is Dispatchers.Main.immediate on mobile and Dispatchers.Swing.immediate on the desktop, so a caller already on the UI thread — the Compose host, most of the time — pays nothing to cross.
Total ordering. navigate and evaluate share one lock.
Operations against a WebView are serialised because interleaving them is not merely racy, it is meaningless: a script evaluated halfway through someone else's navigation runs against whichever document happened to be committed, which is not a result any caller asked for.
The one exception is waiting for an inbound message, which deliberately does not take the lock. A wait is not an operation, and holding the WebView while waiting for the page to speak is a deadlock, because the page usually needs a script to run before it can say anything.
Everything else falls out of those two rules.
The engine runs on Dispatchers.Default because parsing selectors and decoding JSON is business logic with no business on the UI thread.
The UI observes events and never holds a controller it drives directly.
The bugs this actually fixed
This is not architecture for its own sake. Every row below was a live bug, and most of them presented as something other than a threading problem — which is the whole reason a single serialisation point is worth having, because otherwise each one is its own investigation.
| Symptom | Cause | Fix |
|---|---|---|
| A script whose page navigated away hung the workflow forever | Both platforms drop a pending script callback when the document goes, without invoking it | evaluate is bounded and raises ScriptTimeoutException |
| A step after a click that navigated failed a workflow that was fine | Same cause, but the page started the navigation, so waiting out the timeout reported a healthy page as slow | evaluate watches for the document being replaced and resubmits once, against the page that settles in its place |
AwaitMessage hung forever when it lost a race | The page posted before the step subscribed; a no-replay SharedFlow drops that silently | WebViewInbox buffers unread messages and consumes each exactly once |
| Cancelling a run reported it as a failure | The engine caught Throwable, including CancellationException | Every self-imposed timeout becomes a domain exception where it expires, so a cancellation at the top can only be the collector's |
WaitFor(timeoutMs = 10_000) could run for a minute | Elapsed time counted poll intervals, not the round trips between them | Bounded on wall clock |
iOS could never satisfy a WaitFor | WebKit's description prints JS true as "1"; Android returns "true" | Every platform now wraps scripts in JSON.stringify |
The one I want to single out is the resubmit-once rule, because the reasoning behind "once" is the reasoning behind the whole library. When you click a submit button, the page navigates, and every platform silently drops the script callback that was in flight against the old document. The reply is not late — it is never coming. So the serializer resubmits the operation, but exactly once, and only when it sees a new document commit rather than a result arrive. Once, because a second loss is a genuine fault and should look like one. Retrying forever would turn a real failure into a hang, which is trading a visible bug for an invisible one.

The screenshot above is the reason the ordering lock is not academic. That is a real third-party site, typed into and clicked through, with the step trace running at step nine of nine while the page renders live. A user could tap reload at any point in that sequence, and the reload is just another caller that queues behind the operation in flight.
The async bridge had a hole in it
Here is a failure I am less proud of, and it is worth telling because the fix is more interesting than the original design.
evaluateJs needs to await promises — anything built on fetch is asynchronous, and a shop's own JSON API is frequently the better extraction path than its DOM.
The first version did this three different ways: callAsyncJavaScript on iOS, a wrapper over the bridge on Android, and a constructor flag deciding whether either happened at all, defaulting to false.
Two implementations of one contract, plus a default that preserved the old broken behaviour where a promise silently serialised as {}.
Worse, there was a forgery hole.
The Android message listener registered with setOf("*") and discarded the source origin; iOS installed the bridge script forMainFrameOnly = false and ignored the frame info.
Script results were correlated by a counter starting at zero — trivially guessable.
So a third-party iframe on a page a lane was driving could run:
window.vitre.postMessage(JSON.stringify({
id: "script:result#1", type: "script:result",
payload: { cid: 1, ok: true, value: "\"attacker's answer\"" }
}))and resolve someone's Extract with data of its choosing.
For a library whose stated job is driving third-party shops, that is the defect that mattered most.
The redesign
Three changes closed it, and they are each small.
A dedicated settle plane. Script results used to travel through the same inbox that holds page traffic for AwaitMessage and feeds the observer firehose.
That meant every settle did an O(n) predicate scan over unrelated traffic, and internal plumbing leaked into the observer stream.
A new ScriptResults object owned by each controller takes every inbound message first and claims the ones that are script results; only what it does not claim goes on to the inbox.
Correlation becomes a map lookup, the firehose never sees internal traffic, and a stale result is dropped instead of rotting in the buffer until the next navigation.
Frame- and origin-gating. The platform callbacks stop discarding what they are handed.
Each inbound message now carries whether it came from the main frame and what origin posted it.
Awaits are main-frame-gated: an embedded ad on a third-party site cannot post {"type":"ready"} and satisfy the AwaitMessage armed for the main document.
Subframe traffic still reaches the observer firehose — a subframe error is information — but it cannot answer a wait.
An unguessable nonce. Each controller generates a random nonce once, at construction.
The pending sentinel becomes __wv_pending:<nonce>:<cid> instead of a guessable string, and the result envelope carries the nonce, so a guessed counter is no longer enough.
There is an honest limit here, and I put it in the docs rather than pretending it away.
A script running in the main frame shares the JS context with the wrapper and can shadow window.vitre.postMessage before it is called.
Nothing short of an isolated content world makes main-frame results tamper-proof — but a hostile main document can already lie in its own DOM, which is where the data comes from anyway.
The boundary you can enforce is the frame boundary: only the document you are driving may answer, and its subframes may not.
With the hole closed, the flag went too.
evaluateJs now always awaits, on every platform, and await on a plain value is a no-op, so the synchronous case is unchanged in meaning.
A promise stops being a special case the caller had to opt into and then, inevitably, forget to.
Post-then-await is not a race
One more piece, because it is the kind of thing that works in the demo and fails in production. A native caller sends a request to the page and waits for a reply. The reply can arrive before the wait begins — a page handler that replies synchronously on click is the common case, not the unlucky one.
A plain SharedFlow drops that reply on the floor and the caller waits out its whole timeout.
WebViewInbox buffers unread messages and the await scans the buffer before it sleeps, so a reply already sitting there is matched immediately.
The replyTo field on the envelope is what stops the wrong buffered reply from being taken — it names the request being answered, kept separate from the message's own id so a reply is distinguishable from its request on the firehose.
None of this is visible in the API. bridge.request<Ack, Token>("issue-token", Ack(seen = true)) is one line.
That is the point of putting the whole policy in one place: the hard part is real, and it happens once, where nobody using the library has to think about it.
The last post is about the caller this concurrency model was quietly built for the whole time — an agent, which is nothing but a sequence of these operations arriving at unplanned times, and which needs one more thing that ordering alone cannot give it.