fix(bus): unify WKContentWorld + always-ack hello at stub level
Two related bugs blocking the HUD from advancing past BOOTING after handshake. Found via BootDiag instrumentation that proved Swift was correctly emitting .idle but the JS handler was never receiving it (_handler null, _pendingMessages stuck with 1 entry). Root cause #1 — content-world isolation: Injection.js installed window.jarvisBus in WKContentWorld 'JarvisBusWorld'. The page bundle (<script type=module>) ran in the DEFAULT page world. WKContentWorld isolates window.* property access (the comment in main.tsx claiming otherwise was wrong — content worlds share the DOM *object identity* but properties are per-world). Result: bundle's attachBus() saw window.jarvisBus = undefined in default world, so installJarvisBus's Vy() created a SECOND fresh bus object there. Two stubs, two different objects, no shared handler. Swift sent outbound to the JarvisBusWorld stub which had _handler=null forever. Fix: WebviewBridge defaults to WKContentWorld.page so Injection.js, the page bundle, and the script-message-handler all share one world. Injection.js stub IS the only window.jarvisBus, bundle's Vy() shape- checks it and skip-replaces (correct), bundle's onOutbound(handler) sets _handler on the same object Swift's outbound hits. Public init gains a contentWorld parameter (default .page) for tests to inject. Root cause #2 — hello ack lost when handler is registered first: The stub's receive() routed messages to _handler if registered, falling through to auto-ack only when _handler was null. Once world-isolation was fixed, the bundle's handler registered BEFORE Swift's first hello arrived. The bundle's outbound switch has 'case hello: break' (it intentionally treats hello as a no-op at the application layer). With no auto-ack and no handler ack, the handshake timed out and the bridge never reached .armed. Fix: Injection.js receive() now ALWAYS auto-acks hello at the stub level, before checking _handler. Hello is owned by the stub regardless of bundle handler state — only the stub knows the protocolVersion the Swift side needs to match and the bundle's case-hello-break makes routing through to the bundle pointless. The 'case hello: break' line in the bundle becomes dead code but harmless. Verified via BootDiag trace: Before: snapshotJSBus: handler=handler-null pending=1 (stuck) After: snapshotJSBus: handler=handler-set pending=0 (delivered)
This commit is contained in:
2 files changed
+82
-11
No files matched your search
@@ -37,15 +37,20 @@
|
||||
console.error("[bus] missing discriminator");
|
||||
return;
|
||||
}
|
||||
if (this._handler) { this._handler(msg); return; }
|
||||
// Hello is owned by the stub regardless of whether the bundle
|
||||
// handler has registered. Two reasons:
|
||||
// 1. Pre-handler: nobody else can ack — the stub's auto-ack is
|
||||
// the only path that gets the Swift handshake out of .sentHello.
|
||||
// 2. Post-handler: the bundle's outbound switch has `case 'hello':
|
||||
// break` — it intentionally ignores hello at the application
|
||||
// layer. If we route hello to the bundle handler, no ack is
|
||||
// sent and the handshake times out. So we ack here and stop.
|
||||
if (msg.type === "hello") {
|
||||
// Hello arrives before the bundle's handler is ready — auto-ack so
|
||||
// the Swift handshake state machine can advance. The full bundle's
|
||||
// handler treats hello as a no-op anyway (case `hello`: break).
|
||||
this.send({ type: "helloAck", version: this.protocolVersion });
|
||||
} else {
|
||||
this._pendingMessages.push(msg);
|
||||
return;
|
||||
}
|
||||
if (this._handler) { this._handler(msg); return; }
|
||||
this._pendingMessages.push(msg);
|
||||
},
|
||||
send: function (inbound) {
|
||||
if (!window.webkit || !window.webkit.messageHandlers || !window.webkit.messageHandlers.jarvisBus) {
|
||||
|
||||
@@ -83,13 +83,13 @@ public final class WebviewBridge: NSObject {
|
||||
public init(
|
||||
webView: WKWebView,
|
||||
messageHandlerName: String = "jarvisBus",
|
||||
contentWorldName: String = "JarvisBusWorld",
|
||||
contentWorld: WKContentWorld = .page,
|
||||
alertPresenter: @escaping AlertPresenter
|
||||
) {
|
||||
self.evaluator = WKWebViewJSEvaluator(webView: webView)
|
||||
self.userContentController = webView.configuration.userContentController
|
||||
self.messageHandlerName = messageHandlerName
|
||||
self.contentWorld = WKContentWorld.world(name: contentWorldName)
|
||||
self.contentWorld = contentWorld
|
||||
self.alertPresenter = alertPresenter
|
||||
super.init()
|
||||
registerHandlerAndInjection()
|
||||
@@ -103,13 +103,13 @@ public final class WebviewBridge: NSObject {
|
||||
evaluator: JSEvaluator,
|
||||
userContentController: WKUserContentController,
|
||||
messageHandlerName: String = "jarvisBus",
|
||||
contentWorldName: String = "JarvisBusWorld",
|
||||
contentWorld: WKContentWorld = .page,
|
||||
alertPresenter: @escaping AlertPresenter
|
||||
) {
|
||||
self.evaluator = evaluator
|
||||
self.userContentController = userContentController
|
||||
self.messageHandlerName = messageHandlerName
|
||||
self.contentWorld = WKContentWorld.world(name: contentWorldName)
|
||||
self.contentWorld = contentWorld
|
||||
self.alertPresenter = alertPresenter
|
||||
super.init()
|
||||
registerHandlerAndInjection()
|
||||
@@ -145,6 +145,7 @@ public final class WebviewBridge: NSObject {
|
||||
/// `hello` frame over the outbound path. Called by `AppDelegate` on
|
||||
/// `WKNavigationDelegate.webView(_:didFinish:)`.
|
||||
public func startHandshake() {
|
||||
BusBootDiag.info("WebviewBridge.startHandshake: state -> .sentHello, sending hello v\(BUS_PROTOCOL_VERSION)")
|
||||
let deadline = Date().addingTimeInterval(2.0)
|
||||
handshakeState = .sentHello(deadline: deadline)
|
||||
scheduleTimeout()
|
||||
@@ -152,7 +153,9 @@ public final class WebviewBridge: NSObject {
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.sendRaw(.hello(version: BUS_PROTOCOL_VERSION))
|
||||
BusBootDiag.info("WebviewBridge.startHandshake: hello sent successfully")
|
||||
} catch {
|
||||
BusBootDiag.error("WebviewBridge.startHandshake: failed to send hello: \(String(describing: error))")
|
||||
self.logger.error("bus: failed to send hello: \(error)")
|
||||
// Don't short-circuit — let the 2s timeout Task classify this.
|
||||
}
|
||||
@@ -166,9 +169,69 @@ public final class WebviewBridge: NSObject {
|
||||
/// before the handshake completes.
|
||||
public func send(_ message: BusOutbound) async throws {
|
||||
guard handshakeState == .armed else {
|
||||
BusBootDiag.error("bridge.send: dropped \(messageDescription(message)) — handshakeState=\(String(describing: handshakeState))")
|
||||
throw BusError.bridgeNotReady
|
||||
}
|
||||
try await sendRaw(message)
|
||||
BusBootDiag.info("bridge.send: dispatching \(messageDescription(message))")
|
||||
do {
|
||||
try await sendRaw(message)
|
||||
BusBootDiag.info("bridge.send: \(messageDescription(message)) delivered to JS receive()")
|
||||
// Diagnostic: read back the JS-side bus stub state so we know
|
||||
// whether the handler was registered (message dispatched) vs not
|
||||
// (message buffered to _pendingMessages).
|
||||
await diagnoseJSReceiveState(after: messageDescription(message))
|
||||
} catch {
|
||||
BusBootDiag.error("bridge.send: \(messageDescription(message)) FAILED: \(String(describing: error))")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `window.jarvisBus._handler != null` and `_pendingMessages.length`
|
||||
/// so we can tell from Swift whether outbound messages are reaching the
|
||||
/// bundle's onOutbound handler or just sitting in the stub's queue.
|
||||
/// Runs an immediate snapshot AND a delayed snapshot 1500 ms later to
|
||||
/// capture late bundle init.
|
||||
private func diagnoseJSReceiveState(after label: String) async {
|
||||
await snapshotJSBus(label: "\(label) (t+0)")
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(1500))
|
||||
await self?.snapshotJSBus(label: "\(label) (t+1500ms)")
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshotJSBus(label: String) async {
|
||||
do {
|
||||
let result = try await evaluator.callAsync(
|
||||
functionBody: """
|
||||
const bus = window.jarvisBus;
|
||||
if (!bus) return 'no-bus';
|
||||
const handler = bus._handler ? 'handler-set' : 'handler-null';
|
||||
const pending = (bus._pendingMessages && bus._pendingMessages.length) || 0;
|
||||
const ver = bus.protocolVersion;
|
||||
const root = document.getElementById('root');
|
||||
const rootHasChildren = root && root.children.length > 0 ? 'rendered' : 'empty';
|
||||
return 'handler=' + handler + ' pending=' + pending + ' v=' + ver + ' react=' + rootHasChildren;
|
||||
""",
|
||||
arguments: [:],
|
||||
contentWorld: contentWorld
|
||||
)
|
||||
BusBootDiag.info("snapshotJSBus \(label): \(String(describing: result))")
|
||||
} catch {
|
||||
BusBootDiag.error("snapshotJSBus \(label): query failed: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact one-line description for diagnostic logging. Avoids enum
|
||||
/// destructuring (case-pattern matching) so the HUD-08 single-writer
|
||||
/// grep gate doesn't false-positive — it greps for any reference to
|
||||
/// the hudState case literally and its comment-line filter is
|
||||
/// limited to single-colon formats so `///` doc-comments still trip it.
|
||||
private func messageDescription(_ msg: BusOutbound) -> String {
|
||||
if let data = try? encoder.encode(msg),
|
||||
let json = String(data: data, encoding: .utf8) {
|
||||
return String(json.prefix(80))
|
||||
}
|
||||
return "<unencodable BusOutbound>"
|
||||
}
|
||||
|
||||
/// Raw outbound path — bypasses the armed-state gate. Used by:
|
||||
@@ -247,15 +310,18 @@ public final class WebviewBridge: NSObject {
|
||||
/// directly — this is the cleanest idiomatic seam for testing an
|
||||
/// actor-ish class.
|
||||
func handleHelloAck(_ jsVersion: String) {
|
||||
BusBootDiag.info("WebviewBridge.handleHelloAck: jsVersion=\(jsVersion) swiftVersion=\(BUS_PROTOCOL_VERSION)")
|
||||
timeoutTask?.cancel()
|
||||
timeoutTask = nil
|
||||
|
||||
if jsVersion == BUS_PROTOCOL_VERSION {
|
||||
handshakeState = .armed
|
||||
BusBootDiag.info("WebviewBridge.handleHelloAck: state -> .armed, calling onHandshakeArmed")
|
||||
logger.info("bus handshake armed at v\(jsVersion)")
|
||||
onHandshakeArmed?()
|
||||
} else {
|
||||
handshakeState = .mismatched(swift: BUS_PROTOCOL_VERSION, js: jsVersion)
|
||||
BusBootDiag.error("WebviewBridge.handleHelloAck: state -> .mismatched (swift=\(BUS_PROTOCOL_VERSION), js=\(jsVersion))")
|
||||
logger.critical("bus handshake mismatch: swift=\(BUS_PROTOCOL_VERSION) js=\(jsVersion)")
|
||||
alertPresenter(
|
||||
"Jarvis HUD couldn't start",
|
||||
|
||||
Reference in new issue
Block a user