diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 048d1fd..d6e89a0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,6 +13,11 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: + # Pin to the Flutter version the consumer (work_canvas) ships against + # (its .fvmrc / custom engine), not floating `stable`. Floating stable + # breaks CI on every new framework interface method the pinned engine + # doesn't carry (e.g. TextInputClient.onFocusReceived, added after 3.41). + flutter-version: 3.38.8 channel: stable - run: flutter --version - name: Install dependencies diff --git a/example/lib/multiview_probe.dart b/example/lib/multiview_probe.dart index 66619f4..ef496d1 100644 --- a/example/lib/multiview_probe.dart +++ b/example/lib/multiview_probe.dart @@ -19,8 +19,8 @@ // and presenting tile A's token to tile B's port is rejected. // F. concurrency + lifecycle: enabling both CONCURRENTLY brings up two isolated // relays (the per-browserId dict, not the P1 scalar); disabling A kills only -// A's grant (its endpoint goes dead) while B keeps driving; re-enabling A mints -// a FRESH port+token and the torn-down grant stays dead (no reuse). +// A's grant (its endpoint goes dead) while B keeps driving; and A can be +// RE-ENABLED after disable — a fresh port+token, the torn-down grant stays dead. // // Not covered here — reader-stall isolation (PLAN Test G, the SO_SNDTIMEO reaping of // a wedged client + no sibling starvation): a faithful repro needs a real CDP driver @@ -181,24 +181,29 @@ class _ProbeAppState extends State { _check('F: sibling B unaffected by A teardown (still drives its own target)', bUnaffected); - // DIAGNOSTIC (non-fatal) — re-enable after disable. This currently FAILS due - // to a PRE-EXISTING limitation (NOT a P2-step2 regression — the targetId - // resolution path is untouched by P2-step2): the 2nd Target.getTargetInfo - // resolve for the SAME browser never returns kOpTargetId, so enableAgentControl - // times out. Root cause is in cef_host's per-browser targetId re-resolution and - // is not yet isolated (one candidate: DoResolveTargetId reuses a fixed DevTools - // message id, kTargetInfoMsgId 0x7e57, on that browser's session; concurrent - // A+B resolve fine because they are distinct sessions). Recorded as a diagnostic - // so it doesn't mask the P2-step2 result; tracked for a separate cef_host fix. - // Short timeout so the suite isn't held up by the known failure. - setState(() => _status = 're-enable after teardown (diagnostic)…'); - final gA2 = await _enable(_a, timeout: const Duration(seconds: 6)); - out['reenableDiag'] = { - 'reenabledOk': gA2 != null, - 'note': gA2 != null - ? 'pre-existing re-enable limitation appears FIXED' - : 'pre-existing cef_host re-resolve limitation (see comment) — not a P2-step2 regression', - }; + // F — re-enable after disable (a tile gets driven again later): yields a + // FRESH, independent grant (new port+token) and the torn-down one stays dead. + // Needs cef_host's per-probe monotonic DevTools id (a fixed id dropped the 2nd + // resolve on the same browser) + resolveTargetId's epoch-guarded timer. + setState(() => _status = 're-enable after teardown…'); + final gA2 = await _enable(_a); + out['grantA2'] = + gA2 == null ? null : {'port': gA2.port, 'token8': gA2.token.substring(0, 8)}; + _check('F: A re-enables after disable (fresh grant)', gA2 != null); + if (gA2 != null) { + _check('F: re-enabled grant differs from the torn-down one', + gA2.port != gA.port || gA2.token != gA.token); + final ta2 = await _targets(gA2.wsUrl); + _check('F: re-enabled relay drives A again (one own target)', + ta2.length == 1 && ta2.first == ta.first); + bool oldStillDead = false; + try { + await _targets(gA.wsUrl); + } catch (_) { + oldStillDead = true; + } + _check('F: the torn-down grant stays dead after re-enable', oldStillDead); + } } } catch (e, st) { out['fatal'] = '$e'; diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index d414ad3..bcb84b9 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -207,6 +207,12 @@ // browser's CDP targetId (Target.getTargetInfo). Kept alive for the slot's life; // UI-thread only. Lazily set on the first kOpResolveTargetId. CefRefPtr devtools_reg; + // CEF-2b: the DevTools message id of the LAST Target.getTargetInfo probe on this + // browser. A FRESH, monotonically-increasing id per probe (seeded to + // kTargetInfoMsgId) — Chromium's DevTools session requires increasing command ids, + // so reusing a fixed id silently drops the 2nd+ probe, which hung a re-enable of + // agent-control (disable then enable again). UI-thread only, like dialog_next. + int target_info_msg = 0; }; // Routing map from a wire browser id to its Slot. MUTATED ONLY ON THE CEF UI @@ -1359,7 +1365,12 @@ explicit TargetIdObserver(uint32_t wire_id) : wire_id_(wire_id) {} void OnDevToolsMethodResult(CefRefPtr browser, int message_id, bool success, const void* result, size_t result_size) override { - if (message_id != kTargetInfoMsgId || !success || !result || result_size == 0) + // Match this browser's CURRENT probe id (ids now increment per resolve, so a + // fixed constant would miss every probe after the first). UI-thread, like the + // resolve that set it. + auto slot = LookupWireId(wire_id_); + if (!slot || message_id != slot->target_info_msg || !success || !result || + result_size == 0) return; std::string json(static_cast(result), result_size); // Anchor to the targetInfo object first, so a differently-named *targetId* field @@ -1383,10 +1394,15 @@ void DoResolveTargetId(const std::shared_ptr& slot) { slot->devtools_reg = host->AddDevToolsMessageObserver(new TargetIdObserver(slot->browser_id)); } + // Fresh, increasing id per probe (see Slot::target_info_msg) so a re-resolve on the + // SAME browser isn't dropped by the DevTools session's monotonic-id requirement. + slot->target_info_msg = slot->target_info_msg < kTargetInfoMsgId + ? kTargetInfoMsgId + : slot->target_info_msg + 1; // Target.getTargetInfo with no params: executed on a specific browser's DevTools // agent (a page target), it returns THAT page's own targetInfo — so this resolves // exactly this browser's targetId, with no cross-tile ambiguity. - host->ExecuteDevToolsMethod(kTargetInfoMsgId, "Target.getTargetInfo", nullptr); + host->ExecuteDevToolsMethod(slot->target_info_msg, "Target.getTargetInfo", nullptr); } void DoImeSetComposition(const std::shared_ptr& slot,