From 5aae35845a3e99fb105b6e9a20df12d8a3ed2d24 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 13:19:42 -0700 Subject: [PATCH 1/6] fix(cef_host): inject JS-channel shim on shared-host sessions (don't drop addChannel before browser create) A page->host JS channel (window..postMessage) was never injected on a SHARED-host session (a named profile where N tiles share one cef_host), so the page's postMessage silently no-op'd. Root cause: on a shared host a session's createBrowser is QUEUED (pendingCreates), so the addChannel op arrives with browserId=0 (sent before the session's attach()), and `case kOpAddChannel: if (!slot) break;` SILENTLY DROPPED it -> the name never entered the process-global g_channels -> OnLoadStart injected no shim -> window. undefined. This is the Campus "a peer's agent_ui edit never reaches the host" bug: the peer tile rides a shared cef_host, so window.campusHost was never injected and campus.emit died before the wire (host->page still worked, so the tile rendered and received state, which masked it). Fix: don't require `slot` for kOpAddChannel. DoAddChannel now registers the name in g_channels regardless (OnLoadStart injects it on each future load) AND injects the shim into every already-loaded browser's main frame (order-independent), and is null-safe. Reproduced + verified in isolation via example/lib/channel_probe_shared.dart (two controllers on one shared cef_host): before, both sessions host=N (shim absent, postMessage dead); after, host=Y and the shim postMessage reaches each session's own handler. The single-controller case (channel_probe.dart) always passed, isolating the bug to the shared/multi-session path. Co-Authored-By: Claude Opus 4.8 --- example/lib/channel_probe.dart | 156 ++++++++++++++++ example/lib/channel_probe_shared.dart | 170 ++++++++++++++++++ .../flutter_cef_macos/native/cef_host/main.mm | 29 ++- 3 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 example/lib/channel_probe.dart create mode 100644 example/lib/channel_probe_shared.dart diff --git a/example/lib/channel_probe.dart b/example/lib/channel_probe.dart new file mode 100644 index 0000000..c29e639 --- /dev/null +++ b/example/lib/channel_probe.dart @@ -0,0 +1,156 @@ +// page->host JS-channel probe — reproduces the Campus "peer edit never reaches +// the host" symptom in isolation (no Campus, no multiplayer). +// +// A Campus PEER mounts a FRESH CefWebController, registers a JS channel +// (addJavaScriptChannel), loads an HTML doc ON the first onPageStarted (session +// ready), and the page calls window..postMessage(...). Live, that never +// invokes the Dart onMessageReceived (host->page works; page->host is dead). +// This probe exercises exactly that path and reports — via document.title (an +// INDEPENDENT page->host signal, OnTitleChange, NOT the cefQuery channel under +// test) — whether window.probeHost / window.cefQuery exist and whether a direct +// cefQuery succeeds or fails. +// +// Run (cef_host must be built; CEF cached): +// FLUTTER_CEF_HOST=<.../cef_host.app/Contents/MacOS/cef_host> \ +// flutter run -d macos -t lib/channel_probe.dart +// Result: `CEF_CHANNEL_PROBE_RESULT …` stdout + /tmp/cef_channel_probe.json. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _resultPath = '/tmp/cef_channel_probe.json'; + +const _probeHtml = r''' + +

page→host channel probe

+ +
+'''; + +void main() => runApp(const ProbeApp()); + +class ProbeApp extends StatefulWidget { + const ProbeApp({super.key}); + @override + State createState() => _ProbeAppState(); +} + +class _ProbeAppState extends State { + // Fresh, ephemeral controller — exactly like a Campus peer mirror. + final CefWebController _c = CefWebController(); + final List _received = []; + bool _hostGot = false; + bool _loaded = false; + String _status = 'starting…'; + + @override + void initState() { + super.initState(); + _c.addJavaScriptChannel('probeHost', onMessageReceived: (m) { + _received.add(m); + _hostGot = true; + // ignore: avoid_print + print('CEF_CHANNEL_PROBE host received: $m'); + }); + _c.title.addListener(() { + // ignore: avoid_print + print('CEF_CHANNEL_PROBE title=${_c.title.value}'); + }); + // Mirror _CefSurfaceView on a peer: load the doc on the first onPageStarted + // (the initial about:blank — session is up by then), NOT before it exists. + _c.onPageStarted = (url) { + // ignore: avoid_print + print('CEF_CHANNEL_PROBE onPageStarted url=$url'); + if (!_loaded) { + _loaded = true; + _c.loadHtmlString(_probeHtml); + } + }; + _c.onPageFinished = (url) { + // ignore: avoid_print + print('CEF_CHANNEL_PROBE onPageFinished url=$url'); + }; + WidgetsBinding.instance.addPostFrameCallback((_) => _run()); + } + + Future _run() async { + setState(() => _status = 'waiting for page + channel…'); + final deadline = DateTime.now().add(const Duration(seconds: 18)); + while (!_hostGot && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 200)); + } + final out = { + 'pass': _hostGot, + 'received_count': _received.length, + 'received': _received.take(5).toList(), + }; + try { + File(_resultPath).writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(out), + ); + } catch (_) {} + // ignore: avoid_print + print('CEF_CHANNEL_PROBE_RESULT ${jsonEncode(out)}'); + if (mounted) { + setState(() => _status = _hostGot + ? 'PASS — host received ${_received.length} message(s)' + : 'FAIL — host received NOTHING (page->host channel dead)'); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text('channel probe — $_status', + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: CefWebView(url: 'about:blank', controller: _c), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/channel_probe_shared.dart b/example/lib/channel_probe_shared.dart new file mode 100644 index 0000000..abb3a9c --- /dev/null +++ b/example/lib/channel_probe_shared.dart @@ -0,0 +1,170 @@ +// SHARED-HOST page->host channel probe — the single-controller channel_probe.dart +// PASSES, so the basic channel works. Campus's peer tile differs in that it runs +// on a SHARED cef_host (a named profile → one host for many sessions, per the +// #138 consolidation). This probe mounts TWO controllers on ONE named profile +// (= one shared cef_host), each registering the SAME channel name 'probeHost' +// (exactly like every Campus agent_ui uses 'campusHost'), each page posting a +// DISTINCT tag. It verifies each controller's handler receives ONLY its own tag +// — i.e. OnQuery's slot_->browser_id routing stays correct across sessions. +// +// Run: +// FLUTTER_CEF_HOST=<.../cef_host.app/Contents/MacOS/cef_host> \ +// flutter run -d macos -t lib/channel_probe_shared.dart +// Result: `CEF_SHARED_PROBE_RESULT …` + /tmp/cef_channel_probe_shared.json. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _profile = 'chprobe'; +const _resultPath = '/tmp/cef_channel_probe_shared.json'; + +String _html(String tag) => ''' +

session $tag

+'''; + +void main() => runApp(const SharedProbeApp()); + +class SharedProbeApp extends StatefulWidget { + const SharedProbeApp({super.key}); + @override + State createState() => _SharedProbeAppState(); +} + +class _SharedProbeAppState extends State { + final CefWebController _a = CefWebController(profile: _profile); + final CefWebController _b = CefWebController(profile: _profile); + final Set _aRecv = {}; + final Set _bRecv = {}; + bool _aLoaded = false, _bLoaded = false; + String _status = 'starting…'; + + void _wire(CefWebController c, String tag, Set recv, + bool Function() loaded, void Function() setLoaded) { + c.addJavaScriptChannel('probeHost', onMessageReceived: (m) { + recv.add(m); + // ignore: avoid_print + print('CEF_SHARED_PROBE $tag-handler received: $m'); + }); + c.title.addListener(() { + // ignore: avoid_print + print('CEF_SHARED_PROBE $tag title=${c.title.value}'); + }); + c.onPageStarted = (url) { + // ignore: avoid_print + print('CEF_SHARED_PROBE $tag onPageStarted ${url.length > 30 ? url.substring(0, 30) : url}'); + if (!loaded()) { + setLoaded(); + c.loadHtmlString(_html(tag)); + } + }; + c.onPageFinished = (url) { + // ignore: avoid_print + print('CEF_SHARED_PROBE $tag onPageFinished ${url.length > 30 ? url.substring(0, 30) : url}'); + }; + } + + @override + void initState() { + super.initState(); + _wire(_a, 'A', _aRecv, () => _aLoaded, () => _aLoaded = true); + _wire(_b, 'B', _bRecv, () => _bLoaded, () => _bLoaded = true); + WidgetsBinding.instance.addPostFrameCallback((_) => _run()); + } + + Future _run() async { + setState(() => _status = 'waiting for both sessions…'); + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while ((_aRecv.isEmpty || _bRecv.isEmpty) && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 200)); + } + // Correct routing = A's handler got ONLY A-tags, B's got ONLY B-tags. + final aOk = _aRecv.any((m) => m.startsWith('A')) && + !_aRecv.any((m) => m.startsWith('B')); + final bOk = _bRecv.any((m) => m.startsWith('B')) && + !_bRecv.any((m) => m.startsWith('A')); + final out = { + 'pass': aOk && bOk, + 'a_handler_received': _aRecv.toList(), + 'b_handler_received': _bRecv.toList(), + 'a_ok': aOk, + 'b_ok': bOk, + }; + try { + File(_resultPath).writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(out), + ); + } catch (_) {} + // ignore: avoid_print + print('CEF_SHARED_PROBE_RESULT ${jsonEncode(out)}'); + if (mounted) { + setState(() => _status = (aOk && bOk) + ? 'PASS — both sessions routed correctly' + : 'FAIL — A=$_aRecv B=$_bRecv (a_ok=$aOk b_ok=$bOk)'); + } + } + + @override + void dispose() { + _a.dispose(); + _b.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text('shared-host channel probe — $_status', + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: Row( + children: [ + Expanded( + child: CefWebView( + key: const ValueKey('A'), + url: 'about:blank', + controller: _a, + profile: _profile), + ), + const VerticalDivider(width: 1), + Expanded( + child: CefWebView( + key: const ValueKey('B'), + url: 'about:blank', + controller: _b, + profile: _profile), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index 975bbfe..eed07ad 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -1434,12 +1434,27 @@ void DoEvalReturning(const std::shared_ptr& slot, uint32_t id, } void DoAddChannel(const std::shared_ptr& slot, const std::string& name) { if (!IsValidChannelName(name)) { - SendLog(slot->browser_id, "addJavaScriptChannel: rejected invalid name '" + - name + "' (must be a JS identifier)"); + if (slot) + SendLog(slot->browser_id, "addJavaScriptChannel: rejected invalid name '" + + name + "' (must be a JS identifier)"); return; } + // Register process-globally: OnLoadStart injects every g_channels entry into + // each freshly-loaded frame, so this lands the shim on future loads even when + // the op arrived with browserId=0 (sent before the session's attach() — its + // createBrowser was still queued on a shared host) and `slot` is null. g_channels.insert(name); - if (slot->browser) InjectChannelShim(slot->browser->GetMainFrame(), name); + // Also inject into every browser that has ALREADY loaded a page, so a late + // registration (op arriving after a page's OnLoadStart) isn't missed. The + // channel is process-global, so this mirrors OnLoadStart's per-frame behavior. + // Copy out under the lock, then inject (ExecuteJavaScript) without holding it. + std::vector> browsers; + { + std::lock_guard lock(g_slots_mutex); + for (const auto& kv : g_slots_by_wire_id) + if (kv.second && kv.second->browser) browsers.push_back(kv.second->browser); + } + for (const auto& b : browsers) InjectChannelShim(b->GetMainFrame(), name); } // Cookie ops act on the GLOBAL cookie manager (= the shared profile jar), so a // login in one browser is visible to every browser sharing this profile. They @@ -1876,7 +1891,13 @@ void IpcReadLoop() { break; } case kOpAddChannel: { - if (!slot) break; + // Do NOT require `slot`: on a shared host a session's createBrowser may + // still be queued (pendingCreates) when this op arrives, and dropping it + // here is exactly why a peer/secondary session's window. shim was + // never injected (campus.emit silently dead). DoAddChannel registers the + // name in the process-global g_channels — OnLoadStart injects it into the + // frame once the browser loads — and injects into the current frame only + // if the browser already exists. std::string name(reinterpret_cast(p), plen); CefPostTask(TID_UI, base::BindOnce(&DoAddChannel, slot, name)); break; From a5f69ff0528462a44955184a18e4e621a0805be1 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 14:21:58 -0700 Subject: [PATCH 2/6] =?UTF-8?q?refactor(cef):=20buffer=20addChannel=20unti?= =?UTF-8?q?l=20attach()=20=E2=80=94=20fix=20the=20browserId=3D0=20race=20a?= =?UTF-8?q?t=20the=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 5aae358. Rather than relying on cef_host accepting a browserId=0 addChannel op, the Swift CefWebSession now BUFFERS channel names and flushes them (with the real wire id) in attach() — and re-sends on a re-home. So kOpAddChannel always carries a valid browserId + slot. The cef_host side is simplified back to injecting the registering session's OWN frame (+ g_channels for the OnLoadStart re-load path), dropping the broad inject-into-all-browsers loop (the O(N*M) / cross-session-injection smell). The cef_host null-safe path stays as defense. Verified on a real shared host (FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1, two controllers on one cef_host) via example/lib/channel_probe_shared.dart: host=Y on both and the shim postMessage reaches each session's own handler. Co-Authored-By: Claude Opus 4.8 --- .../macos/Classes/CefWebSession.swift | 14 ++++++++++++- .../flutter_cef_macos/native/cef_host/main.mm | 21 +++++++------------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift index e27bc41..cbc42d5 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift @@ -99,6 +99,13 @@ final class CefWebSession: NSObject, FlutterTexture { // CefProfileHost.createBrowser() allocates the id. private weak var host: CefProfileHost? private(set) var browserId: UInt32 = 0 + // JS-channel names registered for this session. Buffered here so a registration + // that arrives BEFORE attach() assigns the wire browserId — which happens on a + // shared host, where createBrowser is queued (pendingCreates) — is flushed with + // a VALID browserId once attached (and re-sent on a re-home to a new host). + // Without this the addChannel op would go out with browserId=0 and the host + // couldn't bind it to this browser, so the window. shim was never injected. + private var channels: Set = [] // C1: set once when this browser delivers its first present frame. Owned/guarded by // CefProfileHost under its browsersLock (the reader flips it there) — a cheap per-frame // first-paint check that avoids a second lock on the hot paint path. @@ -168,6 +175,9 @@ final class CefWebSession: NSObject, FlutterTexture { func attach(host: CefProfileHost, browserId: UInt32) { self.host = host self.browserId = browserId + // Flush channels registered before the wire id existed (and re-send them on a + // re-home to a new host) now that sendFrame can route with a valid browserId. + for name in channels { sendFrame(Self.opAddChannel, Array(name.utf8)) } } // MARK: FlutterTexture @@ -344,7 +354,9 @@ final class CefWebSession: NSObject, FlutterTexture { } func addChannel(_ name: String) { - sendFrame(Self.opAddChannel, Array(name.utf8)) + channels.insert(name) + // Ship now only if we already have a wire id; otherwise attach() flushes it. + if browserId != 0 { sendFrame(Self.opAddChannel, Array(name.utf8)) } } func setCookie(url: String, name: String, value: String, domain: String, diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index eed07ad..f5942e1 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -1440,21 +1440,14 @@ void DoAddChannel(const std::shared_ptr& slot, const std::string& name) { return; } // Register process-globally: OnLoadStart injects every g_channels entry into - // each freshly-loaded frame, so this lands the shim on future loads even when - // the op arrived with browserId=0 (sent before the session's attach() — its - // createBrowser was still queued on a shared host) and `slot` is null. + // each freshly-loaded frame, so the shim lands on the next load. This also + // keeps the op null-safe — if `slot` is somehow absent the registration still + // takes (defense; the Swift session now buffers addChannel until attach(), so + // in practice the op carries a valid browserId and `slot` is set). g_channels.insert(name); - // Also inject into every browser that has ALREADY loaded a page, so a late - // registration (op arriving after a page's OnLoadStart) isn't missed. The - // channel is process-global, so this mirrors OnLoadStart's per-frame behavior. - // Copy out under the lock, then inject (ExecuteJavaScript) without holding it. - std::vector> browsers; - { - std::lock_guard lock(g_slots_mutex); - for (const auto& kv : g_slots_by_wire_id) - if (kv.second && kv.second->browser) browsers.push_back(kv.second->browser); - } - for (const auto& b : browsers) InjectChannelShim(b->GetMainFrame(), name); + // Inject into the registering session's CURRENT frame too, covering the case + // where the channel is registered after its page has already loaded. + if (slot && slot->browser) InjectChannelShim(slot->browser->GetMainFrame(), name); } // Cookie ops act on the GLOBAL cookie manager (= the shared profile jar), so a // login in one browser is visible to every browser sharing this profile. They From 9b82db21d38db6e0b8dce0b3820c7af59d5b2002 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 14:43:57 -0700 Subject: [PATCH 3/6] =?UTF-8?q?fix(cef):=20shared-host=20reliability=20?= =?UTF-8?q?=E2=80=94=20EINTR-resilient=20pipe=20IO=20+=20host-death=20CDP?= =?UTF-8?q?=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two confirmed shared-host correctness bugs from the pre-pin audit: - readAll/writeAll treated an EINTR-interrupted syscall as a dead pipe and tore down the WHOLE shared host (all N browsers wedge). Now retry on EINTR, matching the C++ ReadAll/WriteAll on the host side. - handleHostDeath (unexpected cef_host death) cleared the create queues but leaked live CdpRelay listeners and stranded in-flight resolveTargetId waiters (enableAgentControl callers hung forever). Now mirror shutdown()'s teardown: stop all relays + fail all pending targetId waiters with nil. Verified: example builds clean; multiview_probe (agent-control on a shared host) passes all 13 isolation/lifecycle checks. Co-Authored-By: Claude Opus 4.8 --- .../macos/Classes/CefProfileHost.swift | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift index 74332a1..2359d88 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift @@ -1001,6 +1001,23 @@ final class CefProfileHost { spawnedPid = 0 let died = onHostDied writeLock.unlock() + // The host is gone: tear down CDP relays (free their localhost listeners + + // clients) and FAIL any in-flight targetId waiters so enableAgentControl + // callers don't hang forever. Mirrors shutdown()'s teardown — snapshot under + // each lock, act OUTSIDE it (stop()/completions may block + take other locks). + // Idempotent: a later shutdown()/terminate finds the dicts already empty. + cdpHandlerLock.lock() + let deadRelays = Array(cdpRelays.values) + cdpRelays.removeAll() + onCdpMessage = nil + cdpHandlerLock.unlock() + for r in deadRelays { r.stop() } + targetIdLock.lock() + let strandedWaiters = pendingTargetId.values.flatMap { $0 } + pendingTargetId.removeAll() + targetIdEpoch.removeAll() + targetIdLock.unlock() + for w in strandedWaiters { w(nil) } // nil = resolution failed (host died) // Resolve the exit status + invoke onHostDied off the caller's thread: this // can be the MAIN thread (a writeAll failure in send()/sendCreate()), and // terminationStatus traps if read while the process is still running — so we @@ -1390,7 +1407,12 @@ final class CefProfileHost { let n = buf.withUnsafeMutableBytes { ptr -> Int in read(fd, ptr.baseAddress!.advanced(by: off), len - off) } - if n <= 0 { return false } + if n <= 0 { + // A signal (SIGALRM/SIGCHLD/…) interrupts the syscall: retry rather than + // treat it as a dead pipe (which would tear down the whole shared host). + if n < 0 && errno == EINTR { continue } + return false + } off += n } return true @@ -1400,7 +1422,12 @@ final class CefProfileHost { var off = 0 while off < len { let n = write(fd, buf.advanced(by: off), len - off) - if n <= 0 { return false } + if n <= 0 { + // Same EINTR resilience as readAll: a signal mid-write must not be + // mistaken for a dead pipe (matches the C++ WriteAll on the host side). + if n < 0 && errno == EINTR { continue } + return false + } off += n } return true From 86d2b673e0fdc779bb8de83cd9615e0ea33bbe2b Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 14:43:57 -0700 Subject: [PATCH 4/6] =?UTF-8?q?fix(cef):=20CDP=20relay=20=E2=80=94=20don't?= =?UTF-8?q?=20block=20the=20shared=20reader=20on=20a=20stuck=20client;=20n?= =?UTF-8?q?ever=20emit=20empty=20browserContextId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two confirmed shared-host CDP bugs from the pre-pin audit: - sendRawToClient ran on the SHARED CDP reader thread, and writeFrameLocked blocks up to SO_SNDTIMEO (~2s) on a stuck client — so one wedged agent client stalled CDP delivery to EVERY sibling agent-controlled tile on the same host. Now hop onto a per-relay SERIAL queue (preserves this client's frame order; isolates the wedged client); clientFd is re-validated under clientLock inside, so a concurrent stop()/handler-close is a graceful no-op. - synthesizeAttachedToTarget could emit browserContextId:"" on reconnect (when the real id was never captured for the relay), crashing Playwright's CRBrowser assertion. Now skip the synthesized event; the real attachedToTarget carries it. Verified: multiview_probe passes all 13 checks (CDP delivery + isolation intact). Co-Authored-By: Claude Opus 4.8 --- .../macos/Classes/CdpRelay.swift | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift b/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift index b4ea1e0..d538c66 100644 --- a/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift +++ b/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift @@ -59,6 +59,12 @@ final class CdpRelay { /// Guarded by `clientLock`, which also serializes writes to it. private var clientFd: Int32 = -1 private let clientLock = NSLock() + // Per-relay SERIAL queue for client writes. sendRawToClient hops onto this so a + // stuck client (writeFrameLocked blocks up to SO_SNDTIMEO ~2s) can't stall the + // SHARED CDP reader thread — which fans delivery to every relay — and thereby + // starve sibling agent-controlled tiles on the same host. Serial = frame order + // for this client is preserved; per-relay = one wedged client is isolated. + private let clientWriteQueue = DispatchQueue(label: "flutter_cef.cdp-relay.client-write") /// In-flight connection (handler-thread) count, guarded by `stateLock`. Caps /// concurrent handshakes so a flood of half-open connections can't exhaust @@ -506,10 +512,18 @@ final class CdpRelay { } /// Write a raw (already-filtered / self-originated) JSON text frame to the client. + /// Hops onto the per-relay serial queue so the SHARED CDP reader thread is never + /// blocked by a stuck client; clientFd is re-validated under clientLock inside, so + /// a concurrent stop()/handler-close makes this a graceful no-op (or a failed + /// write that writeFrameLocked already handles). private func sendRawToClient(_ json: String) { - clientLock.lock(); defer { clientLock.unlock() } - guard clientFd >= 0 else { return } - writeFrameLocked(clientFd, opcode: 0x1, payload: Array(json.utf8)) + let payload = Array(json.utf8) + clientWriteQueue.async { [weak self] in + guard let self = self else { return } + self.clientLock.lock(); defer { self.clientLock.unlock() } + guard self.clientFd >= 0 else { return } + self.writeFrameLocked(self.clientFd, opcode: 0x1, payload: payload) + } } /// Write a server frame (unmasked). Takes clientLock itself (callers that already @@ -808,9 +822,15 @@ final class CdpRelay { // crashes the daemon). Only reached on RECONNECT, where the page is already attached // so no fresh event fires and ourBrowserContextId is already cached. filterLock.lock(); let bctx = ourBrowserContextId; filterLock.unlock() + // Playwright's CRBrowser._onAttachedToTarget asserts a NON-EMPTY + // browserContextId; synthesizing one with "" crashes the daemon. If we never + // captured the real id for this relay (the page's own attach event hadn't + // fired yet), SKIP the synthesized event rather than emit a poisoned one — the + // real Target.attachedToTarget will carry the correct id when it arrives. + guard let bctx = bctx, !bctx.isEmpty else { return } let info: [String: Any] = ["targetId": tid, "type": "page", "title": "", "url": "", "attached": true, "canAccessOpener": false, - "browserContextId": bctx ?? ""] + "browserContextId": bctx] sendClientJson(["method": "Target.attachedToTarget", "params": ["sessionId": sessionId, "targetInfo": info, "waitingForDebugger": false]]) From 1fd3e7e81bbf771a6f96773faa9f85f89ce5d4f6 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 14:55:36 -0700 Subject: [PATCH 5/6] docs(profiles): document the per-profile trust/isolation contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A named (shared) profile is one cef_host process with one cookie jar, so sessions sharing it are NOT mutually isolated: shared cookie jar + process-global JS channels (per-message routing stays per-session). Spell out the rule — co-locate only mutually-trusting content on one profile; give distrusting content its own profile or the ephemeral default. Co-Authored-By: Claude Opus 4.8 --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 3a512dd..c6ac7a4 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,19 @@ CefWebView(url: startUrl, controller: c); by **one `cef_host` process with one cookie jar** — they share one login. Cookie writes are therefore process-wide: `clearCookies()` / `deleteCookie()` clear the cookie for *all* views in the profile, by design. +- **One trust domain per profile.** Because a named profile is one process with + one cookie jar, sessions sharing a profile are **not isolated from each + other**. The cookie jar is common (a page in one view can read another's + cookies via `getCookies`), and registered JS channels + (`addJavaScriptChannel`) are process-global, so a page in one view can observe + a channel name another view registered. Per-message *routing* stays + per-session — a channel message is delivered only to the view whose page sent + it (`OnQuery` stamps the originating browser), so this is an information- + sharing boundary, not a message-spoofing one — but the rule is the same: + **co-locate only mutually-trusting content on one profile.** For + mutually-distrusting content (e.g. arbitrary third-party pages from different + authors), give each its own `profile`, or use the ephemeral default — each + gets its own process, cookie jar, and channel namespace. ### Secrets at rest From bad87c25aa81c0abaa738bba41513d36e2748844 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 14:55:36 -0700 Subject: [PATCH 6/6] test(channel): cover the shared-host page->host channel regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dart integration_test mocks the host method channel, so it cannot catch native channel-delivery regressions — which is how the shared-host page->host channel bug shipped. Add: - a fast unit test (cef_web_controller_test.dart) for call-order independence: a channel registered before create() MUST be re-sent on create(); - test/run_channel_integration.sh: builds cef_host + the example and runs the channel / shared-host-channel / agent-control(CDP) probes against a REAL host, asserting each /tmp result. This is the layer that would have caught the B->A Campus regression. Co-Authored-By: Claude Opus 4.8 --- test/cef_web_controller_test.dart | 20 ++++++++ test/run_channel_integration.sh | 79 +++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100755 test/run_channel_integration.sh diff --git a/test/cef_web_controller_test.dart b/test/cef_web_controller_test.dart index e5e6fb0..ffe53d1 100644 --- a/test/cef_web_controller_test.dart +++ b/test/cef_web_controller_test.dart @@ -454,6 +454,26 @@ void main() { expect(got, 'hello world'); }); + test('a channel added BEFORE create() is re-registered on create() ' + '(call-order independence — the shared-host channel regression)', () async { + // On a SHARED host the session attaches LATE (createBrowser is queued), so an + // addJavaScriptChannel issued before the widget mounts reaches the host before + // the browser exists. The controller must re-register every channel on + // create() so the host binds it to the now-attached browser; if this re-send + // is dropped, the window. shim is never injected and page->host messages + // die — the exact B->A Campus regression. (Native end-to-end delivery on a + // real cef_host is covered by test/run_channel_integration.sh.) + final c = CefWebController(sessionId: 'prech'); + await c.addJavaScriptChannel('Early', onMessageReceived: (_) {}); + log.clear(); + await c.create(url: 'about:blank', width: 1, height: 1); + final reSent = log.where((m) => + m.method == 'addJavaScriptChannel' && + (m.arguments as Map)['name'] == 'Early'); + expect(reSent, isNotEmpty, + reason: 'a channel registered before create() must be re-sent on create()'); + }); + test('scroll + storage conveniences forward as JavaScript', () async { final c = CefWebController(sessionId: 'sc'); await c.scrollTo(0, 100); diff --git a/test/run_channel_integration.sh b/test/run_channel_integration.sh new file mode 100755 index 0000000..525d915 --- /dev/null +++ b/test/run_channel_integration.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# flutter_cef integration probes — REAL cef_host, headless, asserting. +# +# WHY THIS EXISTS: the Dart integration_test (example/integration_test/) MOCKS +# the host method channel, so it can NOT catch native channel-delivery or +# CDP-relay regressions — which is exactly how the shared-host page->host channel +# bug shipped. These probes run the example app against a REAL cef_host and assert +# the /tmp JSON result each writes. Run this before bumping a consumer's pin. +# +# Probes (example/lib/): +# channel_probe single ephemeral host: page->host JS channel delivers +# channel_probe_shared TWO sessions on ONE shared host: channel delivers + +# routes per-session (the B->A Campus regression) +# multiview_probe agent-control / CDP relay isolation on a shared host +# +# Usage: +# ./test/run_channel_integration.sh # all probes +# ./test/run_channel_integration.sh channel_probe_shared # just one +# +# Env: +# FLUTTER flutter binary (default: `flutter` on PATH) +# FLUTTER_CEF_HOST cef_host binary (default: build/cef_host, built if absent) +# +set -uo pipefail +cd "$(dirname "$0")/.." +ROOT="$PWD" +FLUTTER="${FLUTTER:-flutter}" +APP="$ROOT/example/build/macos/Build/Products/Debug/flutter_cef_example.app/Contents/MacOS/flutter_cef_example" + +# --- resolve cef_host (build an ad-hoc one if not supplied / not present) ------ +HOST="${FLUTTER_CEF_HOST:-}" +if [ -z "$HOST" ]; then + HOST="$ROOT/build/cef_host/cef_host.app/Contents/MacOS/cef_host" + if [ ! -x "$HOST" ]; then + echo ">> building ad-hoc cef_host (needs cmake + ninja)…" + ( cd packages/flutter_cef_macos && CEF_HOST_ADHOC=ON ./native/build_cef_host.sh "$ROOT/build/cef_host" ) || { + echo "!! cef_host build failed — set FLUTTER_CEF_HOST to a prebuilt binary"; exit 2; } + fi +fi +echo ">> cef_host: $HOST" + +# An ad-hoc cef_host refuses named (shared) profiles and downgrades them to +# ephemeral — which would silently turn the shared-host probes into two separate +# hosts and mask the very regression they guard. This opt-in keeps the real +# shared host for the test. (A signed release build does not need it.) +export FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1 + +# probe : result-json : timeout-secs +PROBES=( + "channel_probe:/tmp/cef_channel_probe.json:30" + "channel_probe_shared:/tmp/cef_channel_probe_shared.json:40" + "multiview_probe:/tmp/cef_multiview_probe.json:75" +) + +want="${1:-}" +fails=0 +for entry in "${PROBES[@]}"; do + IFS=':' read -r name json timeout <<< "$entry" + [ -n "$want" ] && [ "$want" != "$name" ] && continue + echo "" + echo "=== probe: $name ===" + ( cd example && "$FLUTTER" build macos --debug -t "lib/$name.dart" ) >/tmp/cef_int_build.log 2>&1 || { + echo "!! build failed for $name (see /tmp/cef_int_build.log)"; fails=$((fails+1)); continue; } + pkill -9 -f flutter_cef_example 2>/dev/null; pkill -9 -f cef_host 2>/dev/null; rm -f "$json" + FLUTTER_CEF_HOST="$HOST" nohup "$APP" >"/tmp/cef_int_${name}.log" 2>&1 & + for _ in $(seq 1 "$timeout"); do [ -f "$json" ] && break; sleep 1; done + pkill -9 -f flutter_cef_example 2>/dev/null; pkill -9 -f cef_host 2>/dev/null + if [ ! -f "$json" ]; then echo "FAIL $name — no result (timeout); see /tmp/cef_int_${name}.log"; fails=$((fails+1)); continue; fi + if python3 -c "import json,sys; sys.exit(0 if json.load(open('$json')).get('pass') is True else 1)" 2>/dev/null; then + echo "PASS $name" + else + echo "FAIL $name — $(cat "$json")"; fails=$((fails+1)) + fi +done + +echo "" +if [ "$fails" -ne 0 ]; then echo "INTEGRATION FAILED ($fails probe(s))"; exit 1; fi +echo "ALL INTEGRATION PROBES PASSED"