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
1 change: 1 addition & 0 deletions lib/flutter_cef.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export 'package:flutter_cef_platform_interface/flutter_cef_platform_interface.da
CefFindResult,
CefJsDialogRequest,
CefLoadError,
CefSessionStats,
CefMediaPermissionRequest,
CefMediaSetting,
CefMediaState,
Expand Down
19 changes: 19 additions & 0 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,25 @@ class CefWebController {
}


/// Read this session's pixel-liveness counters (see [CefSessionStats]).
///
/// Returns null when the platform has no such session (never created, or
/// already disposed). Cheap — the counters are maintained where present
/// frames already arrive, so this adds no IPC to the host.
Future<CefSessionStats?> sessionStats() async {
final raw = await _channel.invokeMapMethod<String, dynamic>(
'sessionStats',
{'sessionId': sessionId},
);
if (raw == null) return null;
return CefSessionStats(
presentCount: (raw['presentCount'] as num?)?.toInt() ?? 0,
lastPresentAgoMs: (raw['lastPresentAgoMs'] as num?)?.toInt(),
firstPresentSeen: raw['firstPresentSeen'] as bool? ?? false,
frozen: raw['frozen'] as bool? ?? false,
);
}

/// Mute or unmute the page's audio output. Besides silencing it, a hidden
/// AND muted page regains Chromium's intensive wake-up throttling (audible
/// pages are exempt), so muting on hide keeps a background tile's timers
Expand Down
32 changes: 32 additions & 0 deletions packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,35 @@ final class CefWebSession: NSObject, FlutterTexture {
let sessionId: String
private(set) var textureId: Int64 = 0

// ── Present liveness counters ────────────────────────────────────────────
// Pixel liveness had NO observable signal: a probe could prove the renderer's
// JS loop was alive (eval round-trip) while the texture had been frozen for
// minutes — the exact "JS alive, pixels dead" wedge. These count what actually
// reaches the texture, so a consumer can tell a live page from a stale one
// without guessing. Written on the reader thread, read from the main thread
// via sessionStats(), so both go through presentStatsLock.
private let presentStatsLock = NSLock()
private var _presentCount: UInt64 = 0
private var _lastPresentUptimeNs: UInt64 = 0

/// Total presents this session has delivered, the wall-clock age of the most
/// recent one (nil if none), and whether it ever painted.
func presentStats() -> (count: UInt64, lastAgoMs: Int?, firstSeen: Bool) {
presentStatsLock.lock()
defer { presentStatsLock.unlock() }
let ago: Int? = _lastPresentUptimeNs == 0
? nil
: Int((DispatchTime.now().uptimeNanoseconds &- _lastPresentUptimeNs) / 1_000_000)
return (_presentCount, ago, _presentCount > 0)
}

private func notePresent() {
presentStatsLock.lock()
_presentCount &+= 1
_lastPresentUptimeNs = DispatchTime.now().uptimeNanoseconds
presentStatsLock.unlock()
}

// Wire binding to the owning host: the host this session is multiplexed on and
// the Swift-assigned browserId it routes by. Set once via attach() right after
// CefProfileHost.createBrowser() allocates the id.
Expand Down Expand Up @@ -649,6 +678,9 @@ final class CefWebSession: NSObject, FlutterTexture {
func handleFrame(_ op: UInt8, _ payload: [UInt8]) {
switch op {
case Self.opPresent:
// Count FIRST: a present that arrives but fails to adopt is still proof
// the producer is painting, which is what liveness asks about.
notePresent()
// Read textureId under bufferLock — dispose() writes it under the same
// lock on the main thread, so this avoids a data race on the Int64.
bufferLock.lock()
Expand Down
15 changes: 15 additions & 0 deletions packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
case "loadTrusted": loadTrusted(args, result)
case "resize": resize(args, result)
case "getFrameSurface": getFrameSurface(args, result)
case "sessionStats":
// Pixel liveness, for a consumer that needs to tell a live page from a
// stale texture. A JS round-trip proves only that the renderer executes
// script; presentCount/lastPresentAgoMs prove frames are still reaching
// the texture, which is what "frozen" actually means to a user.
guard let sid = args["sessionId"] as? String, let s = sessions[sid] else {
result(nil); return
}
let st = s.presentStats()
result([
"presentCount": Int(clamping: st.count),
"lastPresentAgoMs": st.lastAgoMs as Any,
"firstPresentSeen": st.firstSeen,
"frozen": frozenSessions.contains(sid),
])
case "dispose": destroy(args, result)
case "pointer": pointer(args, result)
case "key": key(args, result)
Expand Down
55 changes: 55 additions & 0 deletions packages/flutter_cef_macos/native/cef_host/main.mm
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@

#include <algorithm>
#include <atomic>
#include <chrono>
#include <cctype>
#include <cstdint>
#include <cstdio>
Expand Down Expand Up @@ -177,6 +178,50 @@
// the SendFrame `< 0` check against the teardown `= -1` store (UB, and benign
// only because a closed-fd write is a safe no-op); the atomic makes it defined.
std::atomic<int> g_ipc_fd{-1};

// ── Renderer crash-loop detector ─────────────────────────────────────────────
// A renderer that dies is normally recoverable: OnRenderProcessTerminated
// reloads and the fresh child takes over. But if the child can no longer be
// SPAWNED at all, that reload re-crashes instantly and loops forever, and the
// host never notices: the browser process is healthy, so the IPC pipe stays up,
// no processGone is ever emitted, and the embedder sees a tile stuck on its last
// frame with no signal and no way back. The observed trigger is the app bundle
// being replaced under a running host (a rebuild/relaunch): every child then
// SIGTRAPs at startup resolving the CEF framework it was launched from.
//
// So make the unrecoverable case LOOK like the recoverable one. Past a burst
// threshold, exit deliberately: the pipe EOFs, the plugin reports processGone,
// and the embedder's existing recreate funnel spawns a fresh host — which
// re-resolves the binary from disk and therefore picks up the NEW bundle. A
// silent wedge becomes the recovery path that already works.
std::mutex g_renderer_crash_mutex;
int g_renderer_crash_count = 0;
std::chrono::steady_clock::time_point g_renderer_crash_window_start;
// Tuned to be unreachable by ordinary flakiness: an isolated renderer crash (or
// a few across unrelated tabs) reloads normally and never trips this.
constexpr int kRendererCrashBurstLimit = 4;
constexpr std::chrono::seconds kRendererCrashWindow{10};

void DoShutdown(); // defined below; the crash-loop exit reuses it

/// Record a renderer death and report whether they are arriving as a burst —
/// i.e. the reload is re-crashing rather than recovering. UI-thread only (the
/// single caller is OnRenderProcessTerminated), but locked anyway since the
/// counter is process-global across every slot: a stale-bundle host fails for
/// ALL its browsers at once, which is exactly the signal we want to add up.
bool NoteRendererCrashAndCheckLoop() {
const auto now = std::chrono::steady_clock::now();
std::lock_guard<std::mutex> lock(g_renderer_crash_mutex);
if (g_renderer_crash_count == 0 ||
now - g_renderer_crash_window_start > kRendererCrashWindow) {
// First death, or the previous burst aged out: start a fresh window so
// unrelated one-off crashes over a long session never accumulate.
g_renderer_crash_window_start = now;
g_renderer_crash_count = 1;
return false;
}
return ++g_renderer_crash_count >= kRendererCrashBurstLimit;
}
std::mutex g_ipc_write_mutex;

// Per-browser state. One cef_host process now multiplexes N browsers (one per
Expand Down Expand Up @@ -1519,6 +1564,16 @@ void OnRenderProcessTerminated(CefRefPtr<CefBrowser> browser,
SendLog(slot_->browser_id, "renderer terminated (status " +
std::to_string(status) + ") — reloading");
if (router_) router_->OnRenderProcessTerminated(browser);
if (NoteRendererCrashAndCheckLoop()) {
// Reloading again would just re-crash: the children can't start. Exit so
// the embedder's processGone → recreate path takes over (see the detector).
SendLog(0,
"renderer crash LOOP (" + std::to_string(kRendererCrashBurstLimit) +
" in " + std::to_string(kRendererCrashWindow.count()) +
"s) — children cannot start; exiting so the host is respawned");
DoShutdown();
return;
}
if (browser) browser->ReloadIgnoreCache();
}

Expand Down
40 changes: 40 additions & 0 deletions packages/flutter_cef_platform_interface/lib/src/cef_events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,46 @@ class CefMediaState {
'CefMediaState(video: $videoActive, audio: $audioActive, $setting)';
}

/// Pixel-liveness counters for a session: how many frames have actually
/// reached the texture, and how long ago the last one did.
///
/// The signal that was missing. A JavaScript round-trip proves the renderer
/// still executes script, which a page can do for minutes after compositing
/// has died — the "JS alive, pixels dead" wedge. [presentCount] advancing is
/// the only honest evidence the page is still being drawn.
class CefSessionStats {
const CefSessionStats({
required this.presentCount,
required this.lastPresentAgoMs,
required this.firstPresentSeen,
required this.frozen,
});

/// Frames delivered to the texture since this session was created. Compare
/// two readings over time: unchanged while the page should be animating
/// means the pixels are wedged, whatever a JS probe says.
final int presentCount;

/// Milliseconds since the most recent present, or null if none has arrived.
/// High on a genuinely static page too — pair it with [presentCount] deltas
/// rather than treating it as a fault on its own.
final int? lastPresentAgoMs;

/// Whether this session has ever painted. False means establishment never
/// completed; the first-paint watchdog owns that case.
final bool firstPresentSeen;

/// Whether the native browser is currently discarded (the texture is holding
/// the last painted frame on purpose). A frozen session produces no presents
/// BY DESIGN, so this distinguishes intended stillness from a wedge.
final bool frozen;

@override
String toString() =>
'CefSessionStats(presents: $presentCount, lastAgoMs: $lastPresentAgoMs, '
'painted: $firstPresentSeen, frozen: $frozen)';
}

/// The live frame surface backing a session: the global IOSurface id its
/// off-screen CVPixelBuffer is wrapped over, plus the surface's PHYSICAL
/// (Retina) pixel dimensions. Delivered by [CefWebController.onSurface] on each
Expand Down
Loading