Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ class CefWebController {
/// `"crashed"` for a generic process death.
void Function(String reason)? onProcessGone;

/// C1: the browser was created but never painted its first frame, even after the
/// native host re-kicked a repaint. The texture is (still) blank with no other
/// signal — the consumer can use this to recover (e.g. recreate the view) rather
/// than leaving a permanently blank tile.
VoidCallback? onPaintStalled;

/// The caret rect (view-local logical px) of the active IME composition.
/// Wired by [CefWebView] to position the OS candidate window under the text;
/// you generally don't set this yourself.
Expand Down Expand Up @@ -234,10 +240,17 @@ class CefWebController {
));
break;
case 'processGone':
// The native host dropped this session (crash or cache-lock loss). The
// texture is dead; let the consumer react (show a reload affordance).
// The native host dropped this session (crash, cache-lock loss, or a
// create that failed — reason 'createFailed'). The texture is dead; let the
// consumer react (show a reload affordance / recreate).
onProcessGone?.call(a['reason'] as String? ?? 'crashed');
break;
case 'paintStalled':
// C1: the browser came up but never delivered its first frame even after a
// re-kick — the texture is (still) blank with no other signal. Surface it so
// the consumer can recover (e.g. recreate the view) instead of a silent blank.
onPaintStalled?.call();
break;
}
}

Expand Down
120 changes: 117 additions & 3 deletions packages/flutter_cef_macos/macos/Classes/CdpRelay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ final class CdpRelay {
private var pipeIdToClientId: [Int: Int] = [:]
private var nextLocalId = 0
private let multiplexLock = NSLock()
// H2: this relay's OWN Target.attachToTarget pipe id (used to learn our page's CDP
// session order-independently, instead of passively witnessing a fire-once
// browser-wide attachedToTarget event we may register too late to see), plus the
// client setAutoAttach ids to ack once we've attached. multiplexLock.
//
// The relay OUTLIVES a client connection (it is browser-keyed, freed only on
// agent-control toggle / browser disposal). So this state is reset on client detach
// and the in-flight attach is stamped with the client GENERATION that issued it — a
// late self-attach response from a departed client must NOT be written to whatever
// client connected next (stale ack / spurious event). pendingAutoAttachClientIds is a
// LIST so a second browser-level setAutoAttach issued before the first resolves is
// queued (one attachToTarget in flight) and acked too, instead of orphaning the first.
private var selfAttachPipeId: Int?
private var pendingAutoAttachClientIds: [Int] = []
private var attachClientGeneration = 0 // the clientGeneration that issued the in-flight attach
private var clientGeneration = 0 // bumped on each client detach

init(sendToPipe: @escaping (String) -> Void, scopeTargetId: String? = nil, relayId: Int = 0) {
self.sendToPipe = sendToPipe
Expand Down Expand Up @@ -263,6 +279,10 @@ final class CdpRelay {
}
clientFd = fd
clientLock.unlock()
// H2: stamp a fresh client identity. A self-attach response still in flight from a
// PRIOR client connection captured the old generation, so handleSelfAttachResponse
// will refuse to deliver its synthesized event / ack to THIS connection.
multiplexLock.lock(); clientGeneration &+= 1; multiplexLock.unlock()

let accept = Data((key + CdpRelay.wsGUID).utf8)
let digest = Insecure.SHA1.hash(data: accept)
Expand All @@ -284,6 +304,16 @@ final class CdpRelay {
if owned { clientFd = -1 }
clientLock.unlock()
if owned { close(fd) }
// H2: the relay persists past this client — drop the in-flight attach so a late
// self-attach response isn't delivered to the next client (a stale ack / spurious
// attachedToTarget). The generation is bumped at the NEXT connect, so even a
// response that races this reset is gated by attachClientGeneration. ourSessionId/
// allowedSessions stay (the page is unchanged; the next client re-issues
// setAutoAttach and we synthesize from the known session).
multiplexLock.lock()
selfAttachPipeId = nil
pendingAutoAttachClientIds.removeAll()
multiplexLock.unlock()
dlog("[cef][relay] client detached")
}

Expand Down Expand Up @@ -459,6 +489,10 @@ final class CdpRelay {
func demuxPipeToClient(_ json: String) -> String? {
if scopeTargetId != nil, let m = parseJson(json), m["method"] == nil,
let pipeId = m["id"] as? Int {
// H2: our OWN Target.attachToTarget response — learn the page session + hand the
// client the synthesized attachedToTarget; never forward the raw response.
multiplexLock.lock(); let isSelfAttach = (pipeId == selfAttachPipeId); multiplexLock.unlock()
if isSelfAttach { handleSelfAttachResponse(m); return nil }
multiplexLock.lock(); let clientId = pipeIdToClientId.removeValue(forKey: pipeId); multiplexLock.unlock()
guard let clientId = clientId else { return nil } // sibling relay's response — drop
var restored = m; restored["id"] = clientId
Expand Down Expand Up @@ -549,10 +583,14 @@ final class CdpRelay {
case "Target.attachedToTarget":
let attachedTid = (params?["targetInfo"] as? [String: Any])?["targetId"] as? String
let childSession = params?["sessionId"] as? String
if sid == nil { // browser-level auto-attach of a top-level target (a tile)
if sid == nil { // browser-level attach of a top-level target (a tile)
guard attachedTid == tid else { return nil } // sibling tile — hide
// H2: learn our page session, but DON'T forward the raw browser-level event —
// we hand the client exactly one SYNTHESIZED attachedToTarget (beginPageAttach /
// handleSelfAttachResponse), so a real one our active attach may have triggered
// isn't delivered as a duplicate.
if let cs = childSession { allowedSessions.insert(cs); ourSessionId = cs }
return json
return nil
}
guard let s = sid, allowedSessions.contains(s) else { return nil } // sub-target of ours
if let cs = childSession { allowedSessions.insert(cs) }
Expand Down Expand Up @@ -629,7 +667,17 @@ final class CdpRelay {
guard (params?["flatten"] as? Bool) == true else {
sendClientError(id, "non-flatten setAutoAttach is not permitted"); return nil
}
return json
// H2: a BROWSER-LEVEL setAutoAttach (no sessionId — reached here because the
// sessionId branch above didn't claim it) is browser-context-wide. Forwarding
// it (a) lets us change a SIBLING tile's auto-attach params (cross-tile control
// leak) and (b) relies on a fire-once attachedToTarget storm we'll miss if we
// registered after a sibling already triggered it ("No page found"). Instead we
// don't forward it: we actively attach to OUR target and synthesize the page's
// attachedToTarget to the client ourselves (order-independent + scoped). A
// SESSION-scoped setAutoAttach for our page's sub-frames (sid != nil, our
// session) is forwarded by the session-routed branch above.
beginPageAttach(clientAutoAttachId: id)
return nil
case "Target.attachToTarget":
guard qTid == scopeTargetId else { sendClientError(id, "No target with given id found"); return nil }
guard (params?["flatten"] as? Bool) == true else {
Expand Down Expand Up @@ -687,6 +735,72 @@ final class CdpRelay {
sendClientJson(["id": id, "result": ["targetInfos": [info]]])
}

/// H2: ensure this relay knows its page's CDP session, then hand the client the page's
/// Target.attachedToTarget directly — independent of the browser-wide auto-attach
/// storm (fire-once, and which we must not forward: it would change sibling tiles'
/// auto-attach). If we already learned our session, synthesize now; otherwise issue
/// our OWN scoped Target.attachToTarget and finish on its response. attachToTarget on
/// an already-attached target idempotently returns the existing sessionId, so this
/// resolves regardless of relay creation order (the "No page found" fix).
private func beginPageAttach(clientAutoAttachId: Int?) {
filterLock.lock(); let known = ourSessionId; filterLock.unlock()
if let s = known {
synthesizeAttachedToTarget(sessionId: s)
synthesizeOk(clientAutoAttachId)
return
}
guard let tid = scopeTargetId else { synthesizeOk(clientAutoAttachId); return }
multiplexLock.lock()
if let ack = clientAutoAttachId { pendingAutoAttachClientIds.append(ack) }
// Only ONE self-attach in flight: a second setAutoAttach arriving before the first
// resolves just queues its ack above (the single attachToTarget resolves the session
// for both); issuing a second would leak its pipeId mapping and lose an ack.
guard selfAttachPipeId == nil else { multiplexLock.unlock(); return }
let pipeId = (relayId << 21) | (nextLocalId & 0x1FFFFF)
nextLocalId &+= 1
selfAttachPipeId = pipeId
attachClientGeneration = clientGeneration
multiplexLock.unlock()
let cmd: [String: Any] = ["id": pipeId, "method": "Target.attachToTarget",
"params": ["targetId": tid, "flatten": true]]
if let s = jsonString(cmd) { sendToPipe(s) }
}

/// H2: our scoped attachToTarget came back — record the page session, and (if the
/// client that issued it is still attached) hand it the synthesized attachedToTarget +
/// ack every queued setAutoAttach. If that client has since detached
/// (clientGeneration moved on), learn the session but write NOTHING — otherwise we'd
/// deliver a stale ack / spurious event to whatever client connected next.
private func handleSelfAttachResponse(_ m: [String: Any]) {
let sessionId = (m["result"] as? [String: Any])?["sessionId"] as? String
multiplexLock.lock()
selfAttachPipeId = nil
let acks = pendingAutoAttachClientIds
pendingAutoAttachClientIds.removeAll()
let sameClient = (attachClientGeneration == clientGeneration)
multiplexLock.unlock()
if let s = sessionId {
filterLock.lock(); allowedSessions.insert(s); ourSessionId = s; filterLock.unlock()
guard sameClient else { return } // issuing client gone — don't write to its successor
synthesizeAttachedToTarget(sessionId: s)
} else if !sameClient {
return
}
for ack in acks { synthesizeOk(ack) }
}

/// H2: fabricate the page's Target.attachedToTarget for the client (flatten mode) so
/// Playwright/connectOverCDP discovers our page without us forwarding the browser-wide
/// auto-attach — mirrors synthesizeGetTargets' single-tile view.
private func synthesizeAttachedToTarget(sessionId: String) {
guard let tid = scopeTargetId else { return }
let info: [String: Any] = ["targetId": tid, "type": "page", "title": "", "url": "",
"attached": true, "canAccessOpener": false, "browserContextId": ""]
sendClientJson(["method": "Target.attachedToTarget",
"params": ["sessionId": sessionId, "targetInfo": info,
"waitingForDebugger": false]])
}

/// Send a CDP error reply to the client. Built via JSONSerialization so the message
/// can't break the frame. No-op without an id (a notification has nothing to error).
private func sendClientError(_ id: Int?, _ message: String) {
Expand Down
Loading
Loading