diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 056993e..374c9c1 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -26,10 +26,14 @@ progress; `PLAN.md` is the source of intent. The authoritative wrapper spec is - Secure rectangles (workbook Part 5; Dart `lib/src/secure`, `SecureRectangleStore` / `BGSFlutterSecureRectangleStore`): Dart publishes Flutter LOGICAL pixels per view WITH the device pixel ratio it measured them at, whole state, ordered by `(epoch, sequence)` where the epoch is a RANDOM id (never a clock): a new epoch is a new isolate and wins, a retired one is a dead isolate's straggler and is ignored. The SDKs' units differ and are read from SDK source: Android 7.4.2 takes PHYSICAL PIXELS in `View.getLocationOnScreen` space (factor = the message's ratio, which must equal the density of the Flutter view's `Resources` read at every pull), iOS 7.0.0-beta7 takes POINTS in the screen's coordinate space (factor = ratio / `UIScreen.scale` = 1; NEVER multiply by `UIScreen.scale`). A ratio that does not match the display now (density changed natively before Dart laid out again) covers the whole display until Dart republishes. Scale (including the view's own on-screen scale from its and its ancestors' transforms), then add the view's screen position, THEN floor l/t and ceil r/b. Versions are derived from what is served (per display, never 0, no-op never moves them); a display that never had a rectangle serves `[0, 0]`. Anything unplaceable, non-finite, mis-scaled, rotated/skewed or without an activity/view controller serves the WHOLE display (fail closed); only a view known to be off-screen (iOS: in no window) serves nothing. A grace window (`GRACE_MS` = 0.12 s, in both stores, compared by the drift guard) serves each past set for that long AFTER IT stopped being current (not from the last change, so a long movement does not widen the cover to its path), so a moving region leaves no strip; the union shrinking back is itself a version change. Dart reports regions unclipped, including what their descendants paint outside the box, and drops one ONLY on positive evidence that it is not on screen (an ancestor that does not paint it, an IndexedStack showing another child, a clipping viewport it and what it paints lie wholly outside). A disabled TickerMode is NOT evidence (it only stops animations). A detached layer is NOT such evidence (SnapshotWidget, the Zoom page transition). Android: a pull asks the main thread to re-read the placements (throttled to 100 ms) and answers from the last completed read, so a view that moved with no relayout is served at its old place for up to about one pull; a refresh asked for and not completed for `REFRESH_STALE_MS` (1 s: a stuck main thread) covers every placed view whole until one completes (tracked from the OLDEST unanswered request, so throttle suppression cannot hide it, and independent of the pull rate; marking every pull as pending would blank the display on every pull). iOS refreshes synchronously on the main thread, where the SDK pulls today. A failed publish is retried by a timer with deterministic backoff (1 s doubling to a 30 s cap) and NEVER abandoned (native keeps the last accepted state, so giving up leaves a stale mask and a new region in the clear); frames never resend the state that just failed, and once `maxRetries` consecutive failures passed a CHANGED state (the only kind that can add a region) goes out once `retryDelay` has passed since the last attempt, never per frame (no flood; a missing plugin costs one message per 30 s while nothing moves, at most one per second while regions move). The registry's clock is injectable for tests. Every one of these rules has a test that fails when it is broken; keep it that way. - Secure rectangle limits (known, documented, not bugs): a region that has just APPEARED is recorded in the clear for its first frame or so (it has no previous place to union with, and Dart cannot publish before layout has placed it; publishing from the build phase would only publish a guess); a rotated region is its bounding box; a region partly clipped is reported whole, so it can cover a few neighbouring pixels the clip hid; pixels drawn outside the region's subtree (an Overlay magnifier or tooltip) are not known to Flutter and not covered; FP noise can add one pixel to an edge; only the implicit view (id 0) is placed on mobile; a hot-restarted isolate that has no regions publishes nothing, so the old isolate's rectangles stay served until the engine detaches (debug only, over-covers); a region with more than 2000 descendants is not dropped by the viewport test (over-covers); iOS is verified in portrait only. +- Report handlers (workbook Part 7; `ReportHandlerPipeline.java`, `BGSFlutterReportHandlerPipeline.m`, Dart `lib/src/report` + `BugseeBridge.setReportHandlers`): the SDK completion runs exactly once on every path and the pipeline NEVER blocks (iOS calls the handler on the main thread and Dart's reply needs it: a wait is a deadlock; `test/report_handlers_drift_test.dart` greps for blocking primitives and for `isTerminating` being decided before anything touches Dart, time or the report). Terminating, no registered phase, no attached Dart, or a path the SDK does not wait on: complete at once. The window is the SDK's budget less a margin of a third (at most 10 s): 20 s live of the SDK's 30 s, iOS recovery 2 s of 3 s; the SDK's numbers are EXTRACTED from SDK source into `test/fixtures/report_enums.json` (`scripts/extract_report_enums.dart`, with the iOS source ref) and compared with the wrapper's by the drift guard, never wrapper against wrapper. The SDK starts its timer when it QUEUES the handler and ours starts at entry: that wait is not observable, only the margin absorbs it (an edit after the SDK moved on can still be accepted: documented limit); the observable hop to the platform thread is deducted (`deadlineMs` sent to Dart is what is left; an over window is completed, not offered). An edit holds the per-handle lock across check-and-apply and `done` flips under it, the SDK completion/Dart/scheduler are called outside it. `resetReportHandlers` (called first by `start()`) completes a previous isolate's outstanding deliveries. The drift guard bans blocking primitives in every file of the report path and allow-lists each `synchronized`. The path is told apart by thread for the time budget only; never pass it to Dart, never infer a recovered crash (7.2.3). The proxy is live until the completion runs; native answers `false` to a late edit and Dart throws `ReportClosedError`. `onBefore` at-most-once, `onAfter` at-least-once: Dart dedupes on the stable `deliveryId`. Enums cross by value; the Pigeon bridge declares NO enum (Pigeon sends ordinals); `test/fixtures/report_enums.json` is extracted from both SDKs by `scripts/extract_report_enums.dart` (re-run when a native pin moves: the parity test fails on stale pins) and compared with Dart/Java/ObjC, and the JUnit/XCTest compare it to the real SDK enums. Attachments are Phase 6 (path-first). +- Report handler limits (known, not bugs): an edit not awaited before the handler returns is lost and throws; a re-delivery of the same report and phase in the same isolate is not re-run (the id is recorded BEFORE the handler runs, so a handler that threw or overran counts as delivered and is not retried in-process: the SDK's own redelivery after a crash is the retry, in a later process; decided, not an oversight); several engines: the most recently attached one with a handler gets the delivery; a Dart handler cannot run for an iOS recovery that happens before the engine exists (completed at once); Android early-crash recovery never reaches Dart. Handles (`rh-N`) are looked up by handle alone, with no owner check: the wrapper trusts every caller in the app's own process (same trust model as the SDK itself); write this down before the API is public. + ## Checks ```bash flutter analyze && flutter test +# report enums: scripts/extract_report_enums.dart [--check] needs the SDK source files named in its header (not a CI step: the private SDKs are not vendored) (cd example && flutter build apk --debug) # JDK 17 or 21 (cd example && flutter build ios --debug --no-codesign) dart scripts/generate_pigeon.dart --check # bridge outputs current diff --git a/.github/CI.md b/.github/CI.md index 9f3d5b7..ef5ce5c 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -19,7 +19,7 @@ and checkout with `persist-credentials: false`. |---|---|---|---| | `format` | `format` | ubuntu | `dart format --set-exit-if-changed` over `lib test example/lib scripts pigeons`, run on the floor Flutter because formatter output changes between Dart releases | | `bridge-codegen` | `bridge codegen is current` | ubuntu | `dart scripts/generate_pigeon.dart --check`: regenerates the Dart/Java/Swift bridge from `pigeons/bridge.dart` with the pinned pigeon and fails if a committed generated file differs (the tree is left untouched). Runs on the floor Flutter because the generated Dart is `dart format`ted and formatter output varies by release. It also runs `dart analyze` in `pigeons/` (excluded from the root analysis, which has no `pub get` for it). Fix: run `dart scripts/generate_pigeon.dart` and commit | -| `test` | `analyze + test + ios build (floor)`, `analyze + test + ios build (stable)` | macOS | `flutter analyze`, `flutter test` (includes the drift guards, among them `test/secure_rects_drift_test.dart` for the constants the secure-rectangle store repeats in Java and Objective-C), debug iOS build with SwiftPM, and (stable leg only) the XCTests of the Objective-C wrapper target via `xcodebuild test` on a simulator in `ios/wrapper_tests`. macOS is required: SwiftPM and the simulator only exist there | +| `test` | `analyze + test + ios build (floor)`, `analyze + test + ios build (stable)` | macOS | `flutter analyze`, `flutter test` (includes the drift guards, among them `test/secure_rects_drift_test.dart` for the constants the secure-rectangle store repeats in Java and Objective-C, and `test/report_handlers_drift_test.dart` / `test/report_enum_parity_test.dart` for the report pipeline and the report enums, which read `test/fixtures/report_enums.json`), debug iOS build with SwiftPM, and (stable leg only) the XCTests of the Objective-C wrapper target via `xcodebuild test` on a simulator in `ios/wrapper_tests`. macOS is required: SwiftPM and the simulator only exist there | | `build-android` | `build apk (floor)`, `build apk (stable)` | ubuntu | Debug APK of the example app, then the plugin's JUnit tests (`:bugsee_flutter:testDebugUnitTest`). Frees runner disk first (otherwise "No space left on device") | | `publish-check` | `pub publish dry-run` | ubuntu | Runs on the floor Flutter. Package validity only. Fails on warnings (e.g. CHANGELOG missing the pubspec version, dirty git tree). Never uploads, needs no credentials | | `ci-ok` | `ci-ok` | ubuntu | `if: always()`; fails unless every job above succeeded (failed, cancelled and skipped needed jobs all fail it). Deliberately not `!cancelled()`: that would skip `ci-ok` on a cancelled run, and a skipped required check counts as passing | diff --git a/PLAN.md b/PLAN.md index 3226aa9..6f225f1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -66,8 +66,19 @@ Sources: `specs/sdk/wrapper-workbook` (authoritative order of work), `wrapper-da (an ancestor that does not paint it, an IndexedStack showing another child, or the region and all its paint lying wholly outside a clipping viewport); neither a disabled TickerMode nor a detached layer counts as such evidence. An unmeasurable region covers the whole view. First-frame exposure of a newly appearing region remains. Known limits: see "Secure rectangle limits" in `.claude/CLAUDE.md`. -- Report handlers: always call completion; `isTerminating=true` → synchronous native-only - work, no round-trip to Dart. +- Report handlers (3c, done): native pipeline (`ReportHandlerPipeline` / `BGSFlutterReportHandlerPipeline`) + a Dart + `ReportProxy` (internal, `lib/src/report`; Phase 4 exposes it). The SDK's completion runs EXACTLY ONCE on every path; the + pipeline NEVER WAITS (the SDK already waits on its own timer; a native wait for Dart's reply deadlocks when the handler runs + on the platform thread, which iOS's live path does). `isTerminating` never reaches Dart and the report is not even read. + No Dart handler registered, or Dart not attached: complete at once. The window is the SDK's budget LESS A MARGIN (a third, at most 10 s), from SDK source (numbers extracted into `test/fixtures/report_enums.json`): Android 7.4.2 waits `ReportHandlerCallbackTimeout` (30 s, option) per handler on thread `BugseeReportHandlerThread`, ours 20 s; its recovery/terminating paths run on other threads, wait 3 s for the handler to RETURN and never for the completion, so Android completes at once there. iOS (nextgen = beta7 headers): live = MAIN thread, 30 s per handler, ours 20 s; recovery = worker thread, 3 s cap, a late completion re-persists the report, ours 2 s. Honest limit: the SDK's per-handler timer starts when it QUEUES the handler, ours at handler entry, and the wait in between (another report's slow handler holding the shared Android dispatch thread, a busy iOS main thread) is NOT observable; the margin absorbs it, and a handler queued behind more than the margin can still overrun, with an edit accepted although the SDK has moved on. What is observable (the hop to the platform thread after entry) is deducted: Dart is told what is LEFT, and a window already over is completed, not offered. An accepted edit is guaranteed to have landed before the completion ran (per-handle lock across check-and-apply). A new isolate (hot restart) completes the previous one's outstanding deliveries. A Dart handler that overruns its budget is reported even if it makes no further edit. The path is told apart by thread + (Android: name, iOS: main or not) for the time budget ONLY, never passed to Dart, never "this is a recovered crash" (7.2.3). + The workbook's table says Android recovery runs on main and is waited for; 7.4.2 source says a spawned thread, not awaited + (inline on the launching thread only when no thread can be started): source wins, spec gap to report. + Dart: edits are operations keyed by a handle; a late edit throws `ReportClosedError` (a `StateError`) and native ignores it. + `onAfter` is at-least-once: each delivery carries a stable `deliveryId` (`before|after:`) and Dart skips a repeat + within the isolate (128 remembered). Enums cross by value (the bridge has none; severity is the SDK integer; Dart + `ReportSeverity.veryLow` = Android name, `low` = iOS alias, value 1). Attachments/screenshots: Phase 6 (TODO seam in `ReportProxy`). +- `requestData` (3c): the stub answers null/nil for every type incl. `vh`, also on the replaced wrapper; tested on both platforms. ## 4. Phase 4 — Public API and options (Part 8) diff --git a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java index 965e449..aa91def 100644 --- a/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java +++ b/android/src/main/java/com/bugsee/flutter/BugseeFlutterPlugin.java @@ -5,6 +5,9 @@ import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bugsee.library.contracts.reporting.Report; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; @@ -69,6 +72,8 @@ private static void refreshPlacements() { private BinaryMessenger messenger; private LifecycleHub.Sink sink; private SecurePlacement placement; + /** Identifies this engine's Dart isolate in {@link ReportHandlerPipeline}. */ + private final Object reportOwner = new Object(); @Override public void onAttachedToEngine(@NonNull final FlutterPluginBinding binding) { @@ -82,6 +87,7 @@ public void onAttachedToEngine(@NonNull final FlutterPluginBinding binding) { placement = new SecurePlacement(owner, binding.getFlutterEngine(), store, environment); PLACEMENTS.add(placement); store.setPullHook(PULLS::request); + ReportHandlerPipeline.shared().attach(reportOwner, this::sendReportRequest); // No activity yet: where the view is is not known, so cover rather than guess. store.setPlacement(owner, SecurePlacement.IMPLICIT_VIEW_ID, SecureRectangleStore.Placement.UNKNOWN); @@ -119,6 +125,8 @@ public void onDetachedFromEngine(@NonNull final FlutterPluginBinding binding) { placement.dispose(); placement = null; } + // Completes what was handed to this engine's Dart: nothing else knows those handles. + ReportHandlerPipeline.shared().detach(reportOwner); // The engine's UI is gone; there is nothing left of it to hide. SecureRectangleStore.shared().dropOwner(owner); final LifecycleHub.Sink stale = sink; @@ -180,6 +188,156 @@ public Boolean setSecureRectangles(@NonNull final Messages.SecureRectanglesMessa return applied; } + // ---- Report handlers (workbook Part 7) -------------------------------------------- + + /** Runs on the platform thread (the pipeline posts it there). Never waits for the reply. */ + private void sendReportRequest( + @NonNull final ReportHandlerPipeline.Request request, + @NonNull final Runnable unreachable + ) { + final BinaryMessenger current = messenger; + if (current == null) { + unreachable.run(); + return; + } + final Messages.ReportHandlerRequestMessage message = + new Messages.ReportHandlerRequestMessage.Builder() + .setHandle(request.handle) + .setDeliveryId(request.deliveryId) + .setAfter(request.after) + .setReportId(request.reportId) + .setType(request.type) + .setDeadlineMs(request.deadlineMs) + .build(); + new Messages.BugseeBridgeFlutterApi(current).onReportHandlerRequest( + message, + new Messages.VoidResult() { + @Override + public void success() { + // Delivered. The SDK's completion runs when Dart completes the handle. + } + + @Override + public void error(@NonNull final Throwable error) { + // No Dart handler (hot restart, teardown): answer for it, now. + Log.w(TAG, "report handler request not delivered: " + + error.getClass().getName()); + unreachable.run(); + } + }); + } + + @Override + public void resetReportHandlers() { + ReportHandlerPipeline.shared().reset(reportOwner); + } + + @Override + public void setReportHandlerPhases(@NonNull final Boolean before, @NonNull final Boolean after) { + ReportHandlerPipeline.shared().setPhases(reportOwner, before, after); + } + + @Override + public void completeReportHandler(@NonNull final String handle) { + ReportHandlerPipeline.shared().complete(handle); + } + + @Nullable + @Override + public Messages.ReportSnapshotMessage reportRead(@NonNull final String handle) { + final Report report = ReportHandlerPipeline.shared().reportFor(handle); + if (report == null) { + return null; + } + final ReportOps.Snapshot s = ReportOps.read(report); + return new Messages.ReportSnapshotMessage.Builder() + .setId(s.id) + .setType(s.type) + .setSummary(s.summary) + .setReportDescription(s.description) + .setEmail(s.email) + .setSeverity(s.severity == null ? null : s.severity.longValue()) + .setLabels(s.labels) + .setAttributes(s.attributes) + .build(); + } + + @NonNull + @Override + public Boolean reportSetSummary(@NonNull final String handle, @Nullable final String value) { + return edit(handle, report -> report.setSummary(value)); + } + + @NonNull + @Override + public Boolean reportSetDescription(@NonNull final String handle, @Nullable final String value) { + return edit(handle, report -> report.setDescription(value)); + } + + @NonNull + @Override + public Boolean reportSetEmail(@NonNull final String handle, @Nullable final String value) { + return edit(handle, report -> report.setEmail(value)); + } + + @NonNull + @Override + public Boolean reportSetSeverity(@NonNull final String handle, @NonNull final Long severity) { + return edit(handle, report -> ReportOps.setSeverity(report, severity)); + } + + @NonNull + @Override + public Boolean reportReplaceLabels(@NonNull final String handle, @NonNull final List labels) { + return edit(handle, report -> ReportOps.replaceLabels(report, labels)); + } + + @NonNull + @Override + public Boolean reportAddLabels(@NonNull final String handle, @NonNull final List labels) { + return edit(handle, report -> ReportOps.addLabels(report, labels)); + } + + @NonNull + @Override + public Boolean reportClearLabels(@NonNull final String handle) { + return edit(handle, Report::clearLabels); + } + + @NonNull + @Override + public Boolean reportSetAttribute( + @NonNull final String handle, + @NonNull final String name, + @NonNull final Object value + ) { + return edit(handle, report -> ReportOps.setAttribute(report, name, value)); + } + + @NonNull + @Override + public Boolean reportRemoveAttribute(@NonNull final String handle, @NonNull final String name) { + return edit(handle, report -> report.removeAttribute(name)); + } + + @NonNull + @Override + public Boolean reportClearAttributes(@NonNull final String handle) { + return edit(handle, Report::clearAllAttributes); + } + + /** False: the handle is not live, nothing changed. An unusable argument is an error reply. */ + private static boolean edit( + @NonNull final String handle, + @NonNull final ReportHandlerPipeline.Edit edit + ) { + try { + return ReportHandlerPipeline.shared().edit(handle, edit); + } catch (final ReportOps.BadArgument e) { + throw new Messages.FlutterError("invalid-argument", e.getMessage(), null); + } + } + @NonNull static Messages.LifecycleEventMessage toMessage(@NonNull final LifecycleHub.Event event) { return new Messages.LifecycleEventMessage.Builder() diff --git a/android/src/main/java/com/bugsee/flutter/FlutterWrapper.java b/android/src/main/java/com/bugsee/flutter/FlutterWrapper.java index 24de3f6..5461b35 100644 --- a/android/src/main/java/com/bugsee/flutter/FlutterWrapper.java +++ b/android/src/main/java/com/bugsee/flutter/FlutterWrapper.java @@ -16,8 +16,8 @@ * *

Identity is real (workbook Part 3), and lifecycle events are forwarded * (Part 4). The rest is a deliberately inert stub until Phase 3 gives it - * behaviour: data requests are answered with {@code null}, no secure rectangles - * are contributed, and report handlers complete immediately. Every method is + * behaviour: data requests are answered with {@code null}. Report handlers go through + * {@link ReportHandlerPipeline}. Every method is * safe to be called by the SDK at any time, from any thread, and none throws. * *

This object is replaced mid-session when Dart refines the identity, so it @@ -43,6 +43,7 @@ final class FlutterWrapper implements BugseeWrapper { private final Map context; private final LifecycleHub lifecycle; private final SecureRectangleStore secureRectangles; + private final ReportHandlerPipeline reports; /** The identity knowable before Dart exists: type and version only. */ @NonNull @@ -64,7 +65,7 @@ static FlutterWrapper create( @Nullable final Object context ) { return create(type, version, build, context, - LifecycleHub.shared(), SecureRectangleStore.shared()); + LifecycleHub.shared(), SecureRectangleStore.shared(), ReportHandlerPipeline.shared()); } /** As above with the process-wide collaborators supplied; for tests. */ @@ -76,6 +77,21 @@ static FlutterWrapper create( @Nullable final Object context, @NonNull final LifecycleHub lifecycle, @NonNull final SecureRectangleStore secureRectangles + ) { + return create(type, version, build, context, lifecycle, secureRectangles, + ReportHandlerPipeline.shared()); + } + + /** As above with every process-wide collaborator supplied; for tests. */ + @NonNull + static FlutterWrapper create( + @Nullable final Object type, + @Nullable final Object version, + @Nullable final Object build, + @Nullable final Object context, + @NonNull final LifecycleHub lifecycle, + @NonNull final SecureRectangleStore secureRectangles, + @NonNull final ReportHandlerPipeline reports ) { final Map cleaned = new HashMap<>(); if (context instanceof Map) { @@ -91,7 +107,8 @@ static FlutterWrapper create( build instanceof String ? (String) build : null, cleaned, lifecycle, - secureRectangles); + secureRectangles, + reports); } private static String stringOr(@Nullable final Object value, @NonNull final String fallback) { @@ -104,10 +121,12 @@ private FlutterWrapper( @Nullable final String build, @NonNull final Map context, @NonNull final LifecycleHub lifecycle, - @NonNull final SecureRectangleStore secureRectangles + @NonNull final SecureRectangleStore secureRectangles, + @NonNull final ReportHandlerPipeline reports ) { this.lifecycle = lifecycle; this.secureRectangles = secureRectangles; + this.reports = reports; this.type = type; this.version = version; this.build = build; @@ -138,7 +157,13 @@ public Map getContext() { return context; } - /** Phase 3 replaces this. Unknown types are always answered with null. */ + /** + * Always answers, and always with null for now: the one flow that exists is {@code vh} + * (the managed view hierarchy, Phase 5), and an unknown type is answered with null + * (workbook 6.1). Silence would cost the SDK the whole 500 ms budget, so even a + * {@code vh} request is answered, with null, until Phase 5 produces a payload. + * Never throws, whatever the SDK or its callback does. + */ @Override public void requestData( @NonNull final String dataType, @@ -165,13 +190,19 @@ public int[] getSecureRectangles(final int display) { return secureRectangles.pull(display); } + /** + * Report handlers (workbook Part 7): see {@link ReportHandlerPipeline}. The SDK calls + * these on a thread of its own (never main on the healthy path, but possibly main on a + * crash path), so nothing here may block, and the SDK's completion runs exactly once + * whatever happens. The pipeline is process-wide, not part of this replaceable object. + */ @Override public void onBeforeReportCreated( @NonNull final Report report, final boolean isTerminating, @NonNull final Runnable completionCallback ) { - completionCallback.run(); + reports.dispatch(ReportHandlerPipeline.Phase.BEFORE, report, isTerminating, completionCallback); } @Override @@ -180,6 +211,6 @@ public void onAfterReportCreated( final boolean isTerminating, @NonNull final Runnable completionCallback ) { - completionCallback.run(); + reports.dispatch(ReportHandlerPipeline.Phase.AFTER, report, isTerminating, completionCallback); } } diff --git a/android/src/main/java/com/bugsee/flutter/Messages.java b/android/src/main/java/com/bugsee/flutter/Messages.java index 685d535..ad8b5b3 100644 --- a/android/src/main/java/com/bugsee/flutter/Messages.java +++ b/android/src/main/java/com/bugsee/flutter/Messages.java @@ -753,6 +753,475 @@ ArrayList toList() { } } + /** + * One report-handler delivery native hands to Dart: the report is reachable + * only through [handle] (a proxy, never a copy; workbook 7.3.1), and only until + * the handler's completion runs (7.3.4). + * + * There is deliberately NO Pigeon enum anywhere in this bridge: Pigeon sends an + * enum as its ordinal, and workbook 7.4 forbids ordinals. Severity crosses as the + * SDK's internal integer, the phase as a bool, the issue type as the SDKs' string. + * + * Generated class from Pigeon that represents data sent in messages. + */ + public static final class ReportHandlerRequestMessage { + /** + * Unique per delivery and opaque; names the live report in every report + * operation and in [BugseeBridgeHostApi.completeReportHandler]. + */ + private @NonNull String handle; + + public @NonNull String getHandle() { + return handle; + } + + public void setHandle(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"handle\" is null."); + } + this.handle = setterArg; + } + + /** + * STABLE for the same report and phase (`before:` / + * `after:`), so Dart can recognise a re-delivery: `onBefore` is + * at-most-once and may be skipped, `onAfter` is at-least-once (7.2). Unique per + * delivery (never shared) when the report's id could not be read. + */ + private @NonNull String deliveryId; + + public @NonNull String getDeliveryId() { + return deliveryId; + } + + public void setDeliveryId(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"deliveryId\" is null."); + } + this.deliveryId = setterArg; + } + + /** `false`: onBeforeReportCreated. `true`: onAfterReportCreated. */ + private @NonNull Boolean after; + + public @NonNull Boolean getAfter() { + return after; + } + + public void setAfter(@NonNull Boolean setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"after\" is null."); + } + this.after = setterArg; + } + + private @NonNull String reportId; + + public @NonNull String getReportId() { + return reportId; + } + + public void setReportId(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"reportId\" is null."); + } + this.reportId = setterArg; + } + + /** + * The SDK's issue type string (`bug`, `crash`, `error`), as it reads it. Not + * evidence that the report is a recovered crash (7.2.3). + */ + private @NonNull String type; + + public @NonNull String getType() { + return type; + } + + public void setType(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"type\" is null."); + } + this.type = setterArg; + } + + /** + * How much longer native keeps the report live, measured when this message is + * SENT (so time spent queueing for the platform thread is already deducted): it + * runs the SDK's completion itself when this has passed, after which every + * operation on the handle is refused (7.3.5). The window is the SDK's budget less a + * margin; the time the SDK spent queueing the handler before native saw it cannot be + * observed, so the margin, not this number, is what absorbs it. + */ + private @NonNull Long deadlineMs; + + public @NonNull Long getDeadlineMs() { + return deadlineMs; + } + + public void setDeadlineMs(@NonNull Long setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"deadlineMs\" is null."); + } + this.deadlineMs = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + ReportHandlerRequestMessage() {} + + @Override + public boolean equals(Object o) { + if (this == o) { return true; } + if (o == null || getClass() != o.getClass()) { return false; } + ReportHandlerRequestMessage that = (ReportHandlerRequestMessage) o; + return pigeonDeepEquals(handle, that.handle) && pigeonDeepEquals(deliveryId, that.deliveryId) && pigeonDeepEquals(after, that.after) && pigeonDeepEquals(reportId, that.reportId) && pigeonDeepEquals(type, that.type) && pigeonDeepEquals(deadlineMs, that.deadlineMs); + } + + @Override + public int hashCode() { + Object[] fields = new Object[] {getClass(), handle, deliveryId, after, reportId, type, deadlineMs}; + return pigeonDeepHashCode(fields); + } + + @Override + public String toString() { + return "ReportHandlerRequestMessage{" + "handle=" + handle + ", " + "deliveryId=" + deliveryId + ", " + "after=" + after + ", " + "reportId=" + reportId + ", " + "type=" + type + ", " + "deadlineMs=" + deadlineMs + "}"; + } + + public static final class Builder { + + private @Nullable String handle; + + @CanIgnoreReturnValue + public @NonNull Builder setHandle(@NonNull String setterArg) { + this.handle = setterArg; + return this; + } + + private @Nullable String deliveryId; + + @CanIgnoreReturnValue + public @NonNull Builder setDeliveryId(@NonNull String setterArg) { + this.deliveryId = setterArg; + return this; + } + + private @Nullable Boolean after; + + @CanIgnoreReturnValue + public @NonNull Builder setAfter(@NonNull Boolean setterArg) { + this.after = setterArg; + return this; + } + + private @Nullable String reportId; + + @CanIgnoreReturnValue + public @NonNull Builder setReportId(@NonNull String setterArg) { + this.reportId = setterArg; + return this; + } + + private @Nullable String type; + + @CanIgnoreReturnValue + public @NonNull Builder setType(@NonNull String setterArg) { + this.type = setterArg; + return this; + } + + private @Nullable Long deadlineMs; + + @CanIgnoreReturnValue + public @NonNull Builder setDeadlineMs(@NonNull Long setterArg) { + this.deadlineMs = setterArg; + return this; + } + + public @NonNull ReportHandlerRequestMessage build() { + ReportHandlerRequestMessage pigeonReturn = new ReportHandlerRequestMessage(); + pigeonReturn.setHandle(handle); + pigeonReturn.setDeliveryId(deliveryId); + pigeonReturn.setAfter(after); + pigeonReturn.setReportId(reportId); + pigeonReturn.setType(type); + pigeonReturn.setDeadlineMs(deadlineMs); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList<>(6); + toListResult.add(handle); + toListResult.add(deliveryId); + toListResult.add(after); + toListResult.add(reportId); + toListResult.add(type); + toListResult.add(deadlineMs); + return toListResult; + } + + static @NonNull ReportHandlerRequestMessage fromList(@NonNull ArrayList pigeonVar_list) { + ReportHandlerRequestMessage pigeonResult = new ReportHandlerRequestMessage(); + Object handle = pigeonVar_list.get(0); + pigeonResult.setHandle((String) handle); + Object deliveryId = pigeonVar_list.get(1); + pigeonResult.setDeliveryId((String) deliveryId); + Object after = pigeonVar_list.get(2); + pigeonResult.setAfter((Boolean) after); + Object reportId = pigeonVar_list.get(3); + pigeonResult.setReportId((String) reportId); + Object type = pigeonVar_list.get(4); + pigeonResult.setType((String) type); + Object deadlineMs = pigeonVar_list.get(5); + pigeonResult.setDeadlineMs((Long) deadlineMs); + return pigeonResult; + } + } + + /** + * A read of a live report. [severity] is the SDK's internal integer (1..5), or + * null when the SDK reports none (iOS reads 0 for "never set"; 7.4). + * + * Generated class from Pigeon that represents data sent in messages. + */ + public static final class ReportSnapshotMessage { + private @NonNull String id; + + public @NonNull String getId() { + return id; + } + + public void setId(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"id\" is null."); + } + this.id = setterArg; + } + + private @NonNull String type; + + public @NonNull String getType() { + return type; + } + + public void setType(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"type\" is null."); + } + this.type = setterArg; + } + + private @Nullable String summary; + + public @Nullable String getSummary() { + return summary; + } + + public void setSummary(@Nullable String setterArg) { + this.summary = setterArg; + } + + /** Named `reportDescription`: `description` collides with NSObject's in Swift. */ + private @Nullable String reportDescription; + + public @Nullable String getReportDescription() { + return reportDescription; + } + + public void setReportDescription(@Nullable String setterArg) { + this.reportDescription = setterArg; + } + + private @Nullable String email; + + public @Nullable String getEmail() { + return email; + } + + public void setEmail(@Nullable String setterArg) { + this.email = setterArg; + } + + private @Nullable Long severity; + + public @Nullable Long getSeverity() { + return severity; + } + + public void setSeverity(@Nullable Long setterArg) { + this.severity = setterArg; + } + + private @NonNull List labels; + + public @NonNull List getLabels() { + return labels; + } + + public void setLabels(@NonNull List setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"labels\" is null."); + } + this.labels = setterArg; + } + + /** + * Only values both SDKs accept: String, bool, int, double. Anything else the + * report holds is left out rather than stringified. + */ + private @NonNull Map attributes; + + public @NonNull Map getAttributes() { + return attributes; + } + + public void setAttributes(@NonNull Map setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"attributes\" is null."); + } + this.attributes = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + ReportSnapshotMessage() {} + + @Override + public boolean equals(Object o) { + if (this == o) { return true; } + if (o == null || getClass() != o.getClass()) { return false; } + ReportSnapshotMessage that = (ReportSnapshotMessage) o; + return pigeonDeepEquals(id, that.id) && pigeonDeepEquals(type, that.type) && pigeonDeepEquals(summary, that.summary) && pigeonDeepEquals(reportDescription, that.reportDescription) && pigeonDeepEquals(email, that.email) && pigeonDeepEquals(severity, that.severity) && pigeonDeepEquals(labels, that.labels) && pigeonDeepEquals(attributes, that.attributes); + } + + @Override + public int hashCode() { + Object[] fields = new Object[] {getClass(), id, type, summary, reportDescription, email, severity, labels, attributes}; + return pigeonDeepHashCode(fields); + } + + @Override + public String toString() { + return "ReportSnapshotMessage{" + "id=" + id + ", " + "type=" + type + ", " + "summary=" + summary + ", " + "reportDescription=" + reportDescription + ", " + "email=" + email + ", " + "severity=" + severity + ", " + "labels=" + labels + ", " + "attributes=" + attributes + "}"; + } + + public static final class Builder { + + private @Nullable String id; + + @CanIgnoreReturnValue + public @NonNull Builder setId(@NonNull String setterArg) { + this.id = setterArg; + return this; + } + + private @Nullable String type; + + @CanIgnoreReturnValue + public @NonNull Builder setType(@NonNull String setterArg) { + this.type = setterArg; + return this; + } + + private @Nullable String summary; + + @CanIgnoreReturnValue + public @NonNull Builder setSummary(@Nullable String setterArg) { + this.summary = setterArg; + return this; + } + + private @Nullable String reportDescription; + + @CanIgnoreReturnValue + public @NonNull Builder setReportDescription(@Nullable String setterArg) { + this.reportDescription = setterArg; + return this; + } + + private @Nullable String email; + + @CanIgnoreReturnValue + public @NonNull Builder setEmail(@Nullable String setterArg) { + this.email = setterArg; + return this; + } + + private @Nullable Long severity; + + @CanIgnoreReturnValue + public @NonNull Builder setSeverity(@Nullable Long setterArg) { + this.severity = setterArg; + return this; + } + + private @Nullable List labels; + + @CanIgnoreReturnValue + public @NonNull Builder setLabels(@NonNull List setterArg) { + this.labels = setterArg; + return this; + } + + private @Nullable Map attributes; + + @CanIgnoreReturnValue + public @NonNull Builder setAttributes(@NonNull Map setterArg) { + this.attributes = setterArg; + return this; + } + + public @NonNull ReportSnapshotMessage build() { + ReportSnapshotMessage pigeonReturn = new ReportSnapshotMessage(); + pigeonReturn.setId(id); + pigeonReturn.setType(type); + pigeonReturn.setSummary(summary); + pigeonReturn.setReportDescription(reportDescription); + pigeonReturn.setEmail(email); + pigeonReturn.setSeverity(severity); + pigeonReturn.setLabels(labels); + pigeonReturn.setAttributes(attributes); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList<>(8); + toListResult.add(id); + toListResult.add(type); + toListResult.add(summary); + toListResult.add(reportDescription); + toListResult.add(email); + toListResult.add(severity); + toListResult.add(labels); + toListResult.add(attributes); + return toListResult; + } + + static @NonNull ReportSnapshotMessage fromList(@NonNull ArrayList pigeonVar_list) { + ReportSnapshotMessage pigeonResult = new ReportSnapshotMessage(); + Object id = pigeonVar_list.get(0); + pigeonResult.setId((String) id); + Object type = pigeonVar_list.get(1); + pigeonResult.setType((String) type); + Object summary = pigeonVar_list.get(2); + pigeonResult.setSummary((String) summary); + Object reportDescription = pigeonVar_list.get(3); + pigeonResult.setReportDescription((String) reportDescription); + Object email = pigeonVar_list.get(4); + pigeonResult.setEmail((String) email); + Object severity = pigeonVar_list.get(5); + pigeonResult.setSeverity((Long) severity); + Object labels = pigeonVar_list.get(6); + pigeonResult.setLabels((List) labels); + Object attributes = pigeonVar_list.get(7); + pigeonResult.setAttributes((Map) attributes); + return pigeonResult; + } + } + private static class PigeonCodec extends StandardMessageCodec { public static final PigeonCodec INSTANCE = new PigeonCodec(); @@ -769,6 +1238,10 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { return SecureViewRectanglesMessage.fromList((ArrayList) readValue(buffer)); case (byte) 132: return SecureRectanglesMessage.fromList((ArrayList) readValue(buffer)); + case (byte) 133: + return ReportHandlerRequestMessage.fromList((ArrayList) readValue(buffer)); + case (byte) 134: + return ReportSnapshotMessage.fromList((ArrayList) readValue(buffer)); default: return super.readValueOfType(type, buffer); } @@ -788,6 +1261,12 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { } else if (value instanceof SecureRectanglesMessage) { stream.write(132); writeValue(stream, ((SecureRectanglesMessage) value).toList()); + } else if (value instanceof ReportHandlerRequestMessage) { + stream.write(133); + writeValue(stream, ((ReportHandlerRequestMessage) value).toList()); + } else if (value instanceof ReportSnapshotMessage) { + stream.write(134); + writeValue(stream, ((ReportSnapshotMessage) value).toList()); } else { super.writeValue(stream, value); } @@ -843,6 +1322,65 @@ public interface BugseeBridgeHostApi { */ @NonNull Boolean setSecureRectangles(@NonNull SecureRectanglesMessage state); + /** + * A new Dart isolate is up (`BugseeBridge.start` calls it once, first): native completes + * every delivery still outstanding to the previous isolate (a hot restart would otherwise + * leave each report waiting for its deadline) and forgets which phases had a handler. + */ + void resetReportHandlers(); + /** + * Tells native whether a Dart handler exists for each phase. With none, native + * completes the SDK's callback at once and never calls Dart (no waiting). Sent + * again (false, false) by every new Dart isolate, so a hot restart that has not + * registered one yet is not waited on. + */ + void setReportHandlerPhases(@NonNull Boolean before, @NonNull Boolean after); + /** + * The Dart handler is done (or failed): run the SDK's completion. Idempotent; + * a handle native already completed (its deadline) is a no-op. + */ + void completeReportHandler(@NonNull String handle); + /** + * Report operations. Each returns whether it was APPLIED: `false` means the + * handle is no longer live (the completion already ran), which Dart turns into + * an error; native ignores the call (7.3.4). An argument the SDK cannot take + * (a severity outside 1..5, an attribute value that is not a string, number or + * bool) is a PlatformException `invalid-argument` and changes nothing. + * + * null: the handle is not live. + */ + @Nullable + ReportSnapshotMessage reportRead(@NonNull String handle); + + @NonNull + Boolean reportSetSummary(@NonNull String handle, @Nullable String value); + + @NonNull + Boolean reportSetDescription(@NonNull String handle, @Nullable String value); + + @NonNull + Boolean reportSetEmail(@NonNull String handle, @Nullable String value); + /** [severity] is the SDK's internal integer, never an ordinal. */ + @NonNull + Boolean reportSetSeverity(@NonNull String handle, @NonNull Long severity); + /** Atomic: a reader never sees the report without labels in between. */ + @NonNull + Boolean reportReplaceLabels(@NonNull String handle, @NonNull List labels); + + @NonNull + Boolean reportAddLabels(@NonNull String handle, @NonNull List labels); + + @NonNull + Boolean reportClearLabels(@NonNull String handle); + + @NonNull + Boolean reportSetAttribute(@NonNull String handle, @NonNull String name, @NonNull Object value); + + @NonNull + Boolean reportRemoveAttribute(@NonNull String handle, @NonNull String name); + + @NonNull + Boolean reportClearAttributes(@NonNull String handle); /** The codec used by BugseeBridgeHostApi. */ static @NonNull MessageCodec getCodec() { @@ -857,13 +1395,80 @@ static void setUp(@NonNull BinaryMessenger binaryMessenger, @NonNull String mess { BasicMessageChannel channel = new BasicMessageChannel<>( - binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.attachLifecycle" + messageChannelSuffix, getCodec()); + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.attachLifecycle" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + try { + api.attachLifecycle(); + wrapped.add(0, null); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setWrapperIdentity" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + WrapperIdentityMessage identityArg = (WrapperIdentityMessage) args.get(0); + try { + api.setWrapperIdentity(identityArg); + wrapped.add(0, null); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setSecureRectangles" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + SecureRectanglesMessage stateArg = (SecureRectanglesMessage) args.get(0); + try { + Boolean output = api.setSecureRectangles(stateArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.resetReportHandlers" + messageChannelSuffix, getCodec()); if (api != null) { channel.setMessageHandler( (message, reply) -> { ArrayList wrapped = new ArrayList<>(); try { - api.attachLifecycle(); + api.resetReportHandlers(); wrapped.add(0, null); } catch (Throwable exception) { @@ -878,15 +1483,16 @@ static void setUp(@NonNull BinaryMessenger binaryMessenger, @NonNull String mess { BasicMessageChannel channel = new BasicMessageChannel<>( - binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setWrapperIdentity" + messageChannelSuffix, getCodec()); + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setReportHandlerPhases" + messageChannelSuffix, getCodec()); if (api != null) { channel.setMessageHandler( (message, reply) -> { ArrayList wrapped = new ArrayList<>(); ArrayList args = (ArrayList) message; - WrapperIdentityMessage identityArg = (WrapperIdentityMessage) args.get(0); + Boolean beforeArg = (Boolean) args.get(0); + Boolean afterArg = (Boolean) args.get(1); try { - api.setWrapperIdentity(identityArg); + api.setReportHandlerPhases(beforeArg, afterArg); wrapped.add(0, null); } catch (Throwable exception) { @@ -901,15 +1507,277 @@ static void setUp(@NonNull BinaryMessenger binaryMessenger, @NonNull String mess { BasicMessageChannel channel = new BasicMessageChannel<>( - binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setSecureRectangles" + messageChannelSuffix, getCodec()); + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.completeReportHandler" + messageChannelSuffix, getCodec()); if (api != null) { channel.setMessageHandler( (message, reply) -> { ArrayList wrapped = new ArrayList<>(); ArrayList args = (ArrayList) message; - SecureRectanglesMessage stateArg = (SecureRectanglesMessage) args.get(0); + String handleArg = (String) args.get(0); try { - Boolean output = api.setSecureRectangles(stateArg); + api.completeReportHandler(handleArg); + wrapped.add(0, null); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportRead" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + try { + ReportSnapshotMessage output = api.reportRead(handleArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetSummary" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + String valueArg = (String) args.get(1); + try { + Boolean output = api.reportSetSummary(handleArg, valueArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetDescription" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + String valueArg = (String) args.get(1); + try { + Boolean output = api.reportSetDescription(handleArg, valueArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetEmail" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + String valueArg = (String) args.get(1); + try { + Boolean output = api.reportSetEmail(handleArg, valueArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetSeverity" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + Long severityArg = (Long) args.get(1); + try { + Boolean output = api.reportSetSeverity(handleArg, severityArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportReplaceLabels" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + List labelsArg = (List) args.get(1); + try { + Boolean output = api.reportReplaceLabels(handleArg, labelsArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportAddLabels" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + List labelsArg = (List) args.get(1); + try { + Boolean output = api.reportAddLabels(handleArg, labelsArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportClearLabels" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + try { + Boolean output = api.reportClearLabels(handleArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetAttribute" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + String nameArg = (String) args.get(1); + Object valueArg = args.get(2); + try { + Boolean output = api.reportSetAttribute(handleArg, nameArg, valueArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportRemoveAttribute" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + String nameArg = (String) args.get(1); + try { + Boolean output = api.reportRemoveAttribute(handleArg, nameArg); + wrapped.add(0, output); + } + catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportClearAttributes" + messageChannelSuffix, getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + ArrayList args = (ArrayList) message; + String handleArg = (String) args.get(0); + try { + Boolean output = api.reportClearAttributes(handleArg); wrapped.add(0, output); } catch (Throwable exception) { @@ -967,5 +1835,31 @@ public void onLifecycleEvent(@NonNull LifecycleEventMessage eventArg, @NonNull V } }); } + /** + * A report handler delivery. Native does NOT wait for this call's reply: the + * SDK's completion runs when Dart calls `completeReportHandler`, when the + * deadline passes, or when this call fails (Dart not listening). Never + * delivered while the SDK says the process is terminating. + */ + public void onReportHandlerRequest(@NonNull ReportHandlerRequestMessage requestArg, @NonNull VoidResult result) { + final String channelName = "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeFlutterApi.onReportHandlerRequest" + messageChannelSuffix; + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, channelName, getCodec()); + channel.send( + new ArrayList<>(Collections.singletonList(requestArg)), + channelReply -> { + if (channelReply instanceof List) { + List listReply = (List) channelReply; + if (listReply.size() > 1) { + result.error(new FlutterError((String) listReply.get(0), (String) listReply.get(1), listReply.get(2))); + } else { + result.success(); + } + } else { + result.error(createConnectionError(channelName)); + } + }); + } } } diff --git a/android/src/main/java/com/bugsee/flutter/ReportHandlerDeadlines.java b/android/src/main/java/com/bugsee/flutter/ReportHandlerDeadlines.java new file mode 100644 index 0000000..514eed3 --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/ReportHandlerDeadlines.java @@ -0,0 +1,117 @@ +package com.bugsee.flutter; + +import androidx.annotation.Nullable; + +/** + * How long Dart gets to handle one report callback, and whether it gets one. + * + *

Read from {@code bugsee-android} 7.4.2 ({@code ReportHandler}, {@code ReportHandlerChain}, + * {@code BugseeIssueReportingCoordinator}); the numbers are extracted into + * {@code test/fixtures/report_enums.json} and compared by {@code test/report_enum_parity_test.dart}. + * Ours is the SDK's budget LESS A MARGIN, never more, and the margin is a third of it + * (workbook 7.3.5): + * + *

    + *
  • Healthy path ({@code isTerminating = false}): the handler is posted to the + * dedicated thread {@value #LIVE_HANDLER_THREAD} (never main) and the SDK waits for the + * completion, up to the {@code ReportHandlerCallbackTimeout} option per handler (default + * 30 s, {@code 0} disables that timer), the whole chain (wrapper then application handler) + * capped at 60 s or one timeout, whichever is longer. The SDK starts that timer when it + * QUEUES the handler ({@code ReportHandlerChain.Step.arm()}); ours starts when the + * handler is entered. The wait in between (the dispatch thread is shared, so another + * report's slow handler can hold it) is NOT observable from here, so the margin is what + * absorbs it: a handler queued behind more than the margin can still overrun the SDK's + * deadline, and an edit in that window is accepted here although the SDK has moved on. + * What IS observable, the wait for the platform thread after entry, is deducted from the + * window before Dart is asked ({@link #remainingMs}). {@link #LIVE_DEADLINE_MS} = 20 s of + * the 30 s.
  • + *
  • Terminating ({@code true}): the crashing thread waits at most 3 s for both + * handlers together, the completion is ignored and the process dies when this returns. + * We never reach Dart there.
  • + *
  • Early-crash recovery ({@code false}): a spawned + * {@code bugsee-report-handler-bounded} thread (inline on the launching thread, which can + * be main, if no thread can be started); the launching thread waits at most 3 s for the + * handler to RETURN, and the completion is a no-op the SDK never awaits. Nothing + * asynchronous can be waited for, and Dart is rarely attached yet (this runs inside + * {@code Application.onCreate}), so we complete at once.
  • + *
+ * + *

The SDK does not say which path a call is on, so the only signal is the thread's name. It is + * used for the TIME BUDGET and nothing else: it is never passed to Dart and never read as "this is + * a recovered crash" (workbook 7.2.3). A thread we do not recognise gets the safe answer, which is + * no round trip. + */ +final class ReportHandlerDeadlines { + + /** The SDK's per-handler default, {@code ReportHandlerCallbackTimeout} (30 s). */ + static final long SDK_DEFAULT_HANDLER_TIMEOUT_MS = 30_000L; + + /** The SDK's budget less its margin ({@link #marginMs}): 20 s of the default 30 s. */ + static final long LIVE_DEADLINE_MS = 20_000L; + + /** The most the margin ever is, however large the SDK's budget. */ + static final long MAX_MARGIN_MS = 10_000L; + + /** + * Below this a round trip to Dart and back cannot reliably fit, so the handler is + * completed at once instead of racing a deadline it will lose. + */ + static final long MIN_USEFUL_DEADLINE_MS = 1_000L; + + /** + * {@code BugseeHandlers.REPORT_HANDLER_THREAD_NAME}: the one thread the healthy, + * waited-for path runs on. A test runs a dispatch on a thread of this name, and + * another compares it to the SDK's constant when the SDK exposes it. + */ + static final String LIVE_HANDLER_THREAD = "BugseeReportHandlerThread"; + + private ReportHandlerDeadlines() { + } + + /** The margin kept under an SDK budget of {@code sdkMs}: a third of it, at most {@link #MAX_MARGIN_MS}. */ + static long marginMs(final long sdkMs) { + return Math.min(MAX_MARGIN_MS, sdkMs / 3L); + } + + /** + * The live deadline for the {@code ReportHandlerCallbackTimeout} option, in seconds. + * + *

{@code null} (unreadable) and {@code 0} (the SDK's per-handler timer is disabled; its 60 s + * chain cap still applies) both mean the default. The SDK normalises a negative value to + * {@code 0}, and so does this. Otherwise the option less its margin, never more than + * {@link #LIVE_DEADLINE_MS}. + */ + static long liveMs(@Nullable final Integer optionSeconds) { + if (optionSeconds == null || optionSeconds <= 0) { + return LIVE_DEADLINE_MS; + } + final long sdkMs = optionSeconds * 1000L; + return Math.min(LIVE_DEADLINE_MS, sdkMs - marginMs(sdkMs)); + } + + /** + * What is left of a window of {@code budgetMs} once {@code elapsedMs} of it has passed; never + * negative. Zero means "already over": complete the SDK's callback instead of asking Dart. + */ + static long remainingMs(final long budgetMs, final long elapsedMs) { + return Math.max(0L, budgetMs - Math.max(0L, elapsedMs)); + } + + /** Whether {@code threadName} is the SDK's live (waited-for) dispatch thread. */ + static boolean isLive(@Nullable final String threadName) { + return LIVE_HANDLER_THREAD.equals(threadName); + } + + /** What the deadline is for a dispatch on {@code threadName}; 0 means "do not ask Dart". */ + static long deadlineMs( + @Nullable final String threadName, + final boolean isTerminating, + @Nullable final Integer optionSeconds + ) { + if (isTerminating || !isLive(threadName)) { + return 0L; + } + final long ms = liveMs(optionSeconds); + return ms < MIN_USEFUL_DEADLINE_MS ? 0L : ms; + } +} diff --git a/android/src/main/java/com/bugsee/flutter/ReportHandlerPipeline.java b/android/src/main/java/com/bugsee/flutter/ReportHandlerPipeline.java new file mode 100644 index 0000000..8aad93b --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/ReportHandlerPipeline.java @@ -0,0 +1,623 @@ +package com.bugsee.flutter; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bugsee.library.Bugsee; +import com.bugsee.library.contracts.options.Options; +import com.bugsee.library.contracts.reporting.Report; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Routes the SDK's report-handler callbacks to Dart and back (workbook Part 7), + * through a table of opaque handles. + * + *

The invariant

+ * Every dispatch ends in exactly ONE run of the SDK's completion, whichever gets + * there first: Dart completing the handle, the handle's deadline, the engine + * detaching, a failure to reach Dart, or an error in here (7.1.2). The guard is one + * lock-guarded flag per handle plus an {@link AtomicBoolean} around the SDK's completion itself, so + * even a path this class did not think of cannot run it twice. Nothing in here throws + * into the SDK. + * + *

Why nothing here ever waits (the deadlock)

+ * A report handler may be called on any thread, and Dart's reply is delivered by the + * platform (main) thread. A native thread that BLOCKED for the reply would deadlock + * whenever it is the platform thread itself (it can never deliver the reply it waits + * for), and would stall the SDK's dispatch thread for nothing otherwise. So + * {@link #dispatch} only registers the handle, arms the deadline, posts the request to + * the platform thread and RETURNS; the SDK is already waiting for the completion on its + * own timer, which is the non-blocking wait this needs. The completion runs later, on + * whichever thread finishes the handle. {@code ReportHandlerPipelineTest} runs a + * dispatch on the platform thread itself and fails if a dispatch ever waits. + * + *

When Dart is not asked at all

+ * {@code isTerminating} (7.1.1: the process dies when this returns and the Dart runtime + * may already be gone, so there is nothing to wait for and nothing to do natively + * either), no registered Dart handler for the phase (no waiting), a dispatch the SDK + * does not wait on (see {@link ReportHandlerDeadlines}), or a deadline too short to fit + * a round trip: the completion runs at once. + * + *

The window, and what it cannot know

+ * Dart's window is the SDK's budget less a margin ({@link ReportHandlerDeadlines}), measured from + * the handler's ENTRY. The SDK starts its own timer when it QUEUES the handler, which cannot be + * observed, so an edit made after the SDK has moved on (the handler queued behind another report's + * slow handler for longer than the margin) can still be accepted: this class guarantees only that + * an accepted edit was applied BEFORE this class ran the completion. The wait for the platform + * thread after entry is observed and deducted: Dart is told what is left ({@code Request.deadlineMs}), + * and a window that is already over is not offered to Dart at all. + * + *

Check and apply are one step

+ * An edit holds the handle's lock while it checks that the handle is live AND applies the change, + * and the deadline (or Dart, or a detach) flips {@code done} under the same lock. So an edit that + * was reported as applied really landed before the completion ran. The lock covers only the report's + * own setters (short, they never call back into this class or Dart); the SDK's completion, Dart and + * the scheduler are always called OUTSIDE it, and nothing else is locked while it is held. + * + *

A late Dart edit

+ * The proxy is live only until the completion runs (7.3.4). {@link #reportFor} answers + * null from then on, so a late mutation is refused natively, and the Dart side raises an + * error for it instead of letting it vanish. + * + *

Process-wide, like {@link LifecycleHub}: the wrapper object that calls in is + * replaced when Dart refines the identity, and engines come and go. + */ +final class ReportHandlerPipeline { + + private static final String TAG = FlutterWrapperInitProvider.TAG; + + enum Phase { + BEFORE(false), + AFTER(true); + + final boolean after; + + Phase(final boolean after) { + this.after = after; + } + + String wire() { + return after ? "after" : "before"; + } + } + + /** What reaches Dart for one delivery. */ + static final class Request { + @NonNull final String handle; + @NonNull final String deliveryId; + final boolean after; + @NonNull final String reportId; + @NonNull final String type; + final long deadlineMs; + + Request( + @NonNull final String handle, + @NonNull final String deliveryId, + final boolean after, + @NonNull final String reportId, + @NonNull final String type, + final long deadlineMs + ) { + this.handle = handle; + this.deliveryId = deliveryId; + this.after = after; + this.reportId = reportId; + this.type = type; + this.deadlineMs = deadlineMs; + } + } + + /** Sends a delivery to one engine's Dart isolate. Runs on the platform thread. */ + interface Sink { + /** + * @param unreachable run it when Dart cannot be reached (an error reply, such as + * no handler on the Dart side after a hot restart) + */ + void deliver(@NonNull Request request, @NonNull Runnable unreachable); + } + + /** The platform (main) thread. A seam: JUnit has no Looper. */ + interface PlatformThread { + /** @return false if the task could not be queued */ + boolean post(@NonNull Runnable task); + } + + interface Cancellable { + void cancel(); + } + + /** Arms a handle's deadline. Injectable so a test controls time. */ + interface Scheduler { + Cancellable schedule(@NonNull Runnable task, long delayMs); + } + + /** Its own interface: {@code java.util.function} needs API 24, minSdk is 21. */ + interface OptionSeconds { + @Nullable + Integer read(); + } + + /** Milliseconds on a monotonic clock; injectable so a test controls time. */ + interface Clock { + long nowMs(); + } + + interface ThreadName { + @Nullable + String current(); + } + + /** One-line outcome of every dispatch; logcat in production, injectable for tests. */ + interface OutcomeLog { + void line(@NonNull String message); + } + + private static final class Registration { + final Object owner; + final Sink sink; + volatile boolean before; + volatile boolean after; + + Registration(final Object owner, final Sink sink) { + this.owner = owner; + this.sink = sink; + } + } + + private static final class Handle { + final String id; + final Report report; + final Runnable sdkCompletion; + final Object owner; + /** {@link Clock#nowMs} when the SDK called us; the window is measured from here. */ + final long enteredAtMs; + final long deadlineMs; + /** + * The exactly-once guard of this handle. Flipped only while holding the handle's monitor + * (the same one an edit holds across check-and-apply); whoever flips it runs the completion, + * after releasing it. + */ + volatile boolean done; + volatile Cancellable timer; + + Handle( + final String id, + final Report report, + final Runnable sdkCompletion, + final Object owner, + final long enteredAtMs, + final long deadlineMs + ) { + this.id = id; + this.report = report; + this.sdkCompletion = sdkCompletion; + this.owner = owner; + this.enteredAtMs = enteredAtMs; + this.deadlineMs = deadlineMs; + } + } + + private static final class Holder { + static final ReportHandlerPipeline INSTANCE = new ReportHandlerPipeline( + new DaemonScheduler(), + new MainThread(), + ReportHandlerPipeline::optionFromLaunchOptions, + () -> Thread.currentThread().getName(), + message -> Log.i(TAG, message), + () -> System.nanoTime() / 1_000_000L); + } + + @NonNull + static ReportHandlerPipeline shared() { + return Holder.INSTANCE; + } + + private final Scheduler scheduler; + private final PlatformThread platform; + private final OptionSeconds option; + private final ThreadName threadName; + private final OutcomeLog outcomes; + private final Clock clock; + /** Most recently attached first. */ + private final List registrations = new CopyOnWriteArrayList<>(); + private final Map handles = new ConcurrentHashMap<>(); + private final AtomicLong counter = new AtomicLong(); + + ReportHandlerPipeline( + @NonNull final Scheduler scheduler, + @NonNull final PlatformThread platform, + @NonNull final OptionSeconds option, + @NonNull final ThreadName threadName, + @NonNull final OutcomeLog outcomes + ) { + this(scheduler, platform, option, threadName, outcomes, () -> System.nanoTime() / 1_000_000L); + } + + ReportHandlerPipeline( + @NonNull final Scheduler scheduler, + @NonNull final PlatformThread platform, + @NonNull final OptionSeconds option, + @NonNull final ThreadName threadName, + @NonNull final OutcomeLog outcomes, + @NonNull final Clock clock + ) { + this.clock = clock; + this.scheduler = scheduler; + this.platform = platform; + this.option = option; + this.threadName = threadName; + this.outcomes = outcomes; + } + + /** Attaches one engine's Dart end. It registers no phase until it says so. */ + void attach(@NonNull final Object owner, @NonNull final Sink sink) { + detach(owner); + registrations.add(0, new Registration(owner, sink)); + } + + /** What the Dart isolate of {@code owner} has a handler for. */ + void setPhases(@NonNull final Object owner, final boolean before, final boolean after) { + for (final Registration r : registrations) { + if (r.owner == owner) { + r.before = before; + r.after = after; + } + } + } + + /** + * A new Dart isolate is up on this engine (a hot restart): nothing in it knows the deliveries + * handed to the previous one, so complete them now instead of leaving each report to wait for + * its deadline, and forget which phases had a handler. + */ + void reset(@NonNull final Object owner) { + setPhases(owner, false, false); + for (final Handle handle : handles.values()) { + if (handle.owner == owner) { + finish(handle, "reset"); + } + } + } + + /** + * The engine is gone. Completes every handle emitted to it, because nothing in any + * other isolate knows them and waiting for their deadlines would only delay the reports. + */ + void detach(@NonNull final Object owner) { + for (final Registration r : registrations) { + if (r.owner == owner) { + registrations.remove(r); + } + } + for (final Handle handle : handles.values()) { + if (handle.owner == owner) { + finish(handle, "detach"); + } + } + } + + /** + * The SDK's entry point. Returns promptly on every path, never blocks, never throws, + * and arranges for {@code sdkCompletion} to run exactly once. + */ + void dispatch( + @NonNull final Phase phase, + @Nullable final Report report, + final boolean isTerminating, + @Nullable final Runnable sdkCompletion + ) { + final Runnable once = once(sdkCompletion); + try { + dispatchOnce(phase, report, isTerminating, once); + } catch (final Throwable t) { + // Whatever went wrong, the SDK must not wait for us. + Log.w(TAG, "report handler dispatch failed: " + t.getClass().getName()); + once.run(); + } + } + + private void dispatchOnce( + @NonNull final Phase phase, + @Nullable final Report report, + final boolean isTerminating, + @NonNull final Runnable completion + ) { + if (report == null) { + completeUnminted(phase, null, completion, "no-report"); + return; + } + // The process dies when this returns; nothing asynchronous survives it and the + // Dart runtime may already be gone. + if (isTerminating) { + // Not even read: the cheapest thing on a dying process is to touch nothing. + completeUnminted(phase, null, completion, "terminating"); + return; + } + final long deadlineMs = ReportHandlerDeadlines.deadlineMs( + threadName.current(), false, readOption()); + if (deadlineMs <= 0L) { + // A path the SDK does not wait on (recovery), or no time for a round trip. + completeUnminted(phase, report, completion, "not-awaited"); + return; + } + final Registration target = registrationFor(phase); + if (target == null) { + completeUnminted(phase, report, completion, "no-handler"); + return; + } + + final String reportId = safeId(report); + final String handleId = "rh-" + counter.incrementAndGet(); + final Request request = new Request( + handleId, + // Stable for one report and phase, so Dart recognises a re-delivery; unique + // (never shared) when the id is unreadable, so distinct reports never collide. + reportId.isEmpty() ? phase.wire() + ":?" + handleId : phase.wire() + ":" + reportId, + phase.after, + reportId, + ReportOps.typeOf(report), + deadlineMs); + final Handle handle = new Handle( + handleId, report, completion, target.owner, clock.nowMs(), deadlineMs); + handles.put(handleId, handle); + // A detach that ran after the lookup above swept the table before this handle was in + // it: detach removes the registration first and sweeps second, so whichever side comes + // second catches the handle. + if (!registrations.contains(target)) { + finish(handle, "detach"); + return; + } + try { + final Cancellable timer = scheduler.schedule(() -> finish(handle, "deadline"), deadlineMs); + handle.timer = timer; + if (handle.done) { + cancelQuietly(timer); + return; + } + outcomes.line("report handler " + handleId + " phase=" + phase.wire() + + " deadline=" + deadlineMs); + // To the platform thread, never inline and never waited for. Dart's reply needs + // that thread; see the class comment. + if (!platform.post(() -> deliver(handle, target, request))) { + finish(handle, "no-handler"); + } + } catch (final Throwable t) { + // The handle is in the table: finishing it runs the completion and makes the + // report refuse edits, instead of leaving a live proxy nobody will complete. + Log.w(TAG, "report handler " + handleId + " could not be set up: " + + t.getClass().getName()); + finish(handle, "error"); + } + } + + private void deliver( + @NonNull final Handle handle, + @NonNull final Registration target, + @NonNull final Request request + ) { + if (handle.done) { + return; + } + // What is observable of the SDK's own wait: how long this request sat before the platform + // thread took it. Deduct it; a window that is already over is not offered to Dart. + final long gapMs = Math.max(0L, clock.nowMs() - handle.enteredAtMs); + final long remainingMs = ReportHandlerDeadlines.remainingMs(handle.deadlineMs, gapMs); + outcomes.line("report handler " + handle.id + " delivery gap=" + gapMs + + " ms remaining=" + remainingMs + " ms"); + if (remainingMs <= 0L) { + finish(handle, "expired"); + return; + } + final Request live = new Request( + request.handle, request.deliveryId, request.after, request.reportId, + request.type, remainingMs); + try { + target.sink.deliver(live, () -> finish(handle, "unreachable")); + } catch (final Throwable t) { + // A dead bridge throws from the send. Dart will never answer, so answer for it. + Log.w(TAG, "report handler " + handle.id + " could not reach Dart: " + + t.getClass().getName()); + finish(handle, "unreachable"); + } + } + + /** @return true only for the call that ran the SDK completion. */ + boolean complete(@NonNull final String handleId) { + final Handle handle = handles.get(handleId); + return handle != null && finish(handle, "dart"); + } + + /** The live report behind {@code handleId}, or null once it is completed or unknown. */ + @Nullable + Report reportFor(@Nullable final String handleId) { + if (handleId == null) { + return null; + } + final Handle handle = handles.get(handleId); + return handle == null || handle.done ? null : handle.report; + } + + /** One change to a live report; may refuse its argument before touching anything. */ + interface Edit { + void apply(@NonNull Report report) throws ReportOps.BadArgument; + } + + /** + * Applies {@code edit} to the live report behind {@code handleId}. + * + * @return false, having touched nothing, when the handle is not live (7.3.4): Dart turns + * that into an error, and the report is not changed + * @throws ReportOps.BadArgument when the SDK cannot take the argument; nothing changed + */ + boolean edit(@Nullable final String handleId, @NonNull final Edit edit) + throws ReportOps.BadArgument { + final Handle handle = handleId == null ? null : handles.get(handleId); + if (handle == null) { + return false; + } + // Check and apply under the handle's lock: finish() flips `done` under the same one, so a + // deadline cannot fall between the check and the apply and leave an edit "applied" after + // the SDK's completion ran. + synchronized (handle) { + if (handle.done) { + return false; + } + edit.apply(handle.report); + return true; + } + } + + private boolean finish(@NonNull final Handle handle, @NonNull final String by) { + synchronized (handle) { + if (handle.done) { + return false; + } + handle.done = true; + } + // Outside the lock from here: the SDK's completion, the timer and the log never run + // while an edit could be waiting on it. + handles.remove(handle.id, handle); + final Cancellable timer = handle.timer; + if (timer != null) { + cancelQuietly(timer); + } + outcomes.line("report handler " + handle.id + " completed by=" + by); + runQuietly(handle.sdkCompletion); + return true; + } + + /** No handle was minted; the line still carries the report for correlation. */ + private void completeUnminted( + @NonNull final Phase phase, + @Nullable final Report report, + @NonNull final Runnable completion, + @NonNull final String by + ) { + outcomes.line("report handler - completed by=" + by + " phase=" + phase.wire() + + " report=" + (report == null ? null : safeId(report))); + runQuietly(completion); + } + + @Nullable + private Registration registrationFor(@NonNull final Phase phase) { + for (final Registration r : registrations) { + if (phase.after ? r.after : r.before) { + return r; + } + } + return null; + } + + @Nullable + private Integer readOption() { + try { + return option.read(); + } catch (final Throwable t) { + return null; + } + } + + /** At most once, and a null completion (never expected) is a no-op. */ + @NonNull + private static Runnable once(@Nullable final Runnable completion) { + final AtomicBoolean ran = new AtomicBoolean(); + return () -> { + if (completion != null && ran.compareAndSet(false, true)) { + completion.run(); + } + }; + } + + /** The SDK's own fault must not escape into Dart's thread or the timer's. */ + private static void runQuietly(@NonNull final Runnable completion) { + try { + completion.run(); + } catch (final Throwable t) { + Log.w(TAG, "report handler completion threw: " + t.getClass().getName()); + } + } + + private static void cancelQuietly(@NonNull final Cancellable timer) { + try { + timer.cancel(); + } catch (final Throwable t) { + Log.w(TAG, "report handler timer cancel threw: " + t.getClass().getName()); + } + } + + /** Empty when unreadable: the empty string is also what marks a delivery un-dedupable. */ + @NonNull + private static String safeId(@NonNull final Report report) { + try { + final String id = report.getId(); + return id == null ? "" : id; + } catch (final Throwable t) { + return ""; + } + } + + /** + * The {@code ReportHandlerCallbackTimeout} option in effect, read at each dispatch + * because a relaunch can change it. Any failure reads as "unknown", which is the + * default deadline. + */ + @Nullable + private static Integer optionFromLaunchOptions() { + try { + final Object value = Bugsee.getLaunchOptions().getOption(Options.ReportHandlerCallbackTimeout); + if (value instanceof Number) { + return ((Number) value).intValue(); + } + } catch (final Throwable t) { + Log.w(TAG, "could not read the report handler timeout option: " + t.getClass().getName()); + } + return null; + } + + /** One daemon thread, started on first use, for every handle's deadline. */ + private static final class DaemonScheduler implements Scheduler { + private final ScheduledThreadPoolExecutor executor; + + DaemonScheduler() { + executor = new ScheduledThreadPoolExecutor(1, runnable -> { + final Thread thread = new Thread(runnable, "BugseeFlutter-ReportDeadline"); + thread.setDaemon(true); + return thread; + }); + // A handle completed by Dart should not leave 25 s of dead task queued. + executor.setRemoveOnCancelPolicy(true); + } + + @Override + public Cancellable schedule(@NonNull final Runnable task, final long delayMs) { + final ScheduledFuture future = executor.schedule(task, delayMs, TimeUnit.MILLISECONDS); + return () -> future.cancel(false); + } + } + + /** The Android main looper. Created on first use, so JUnit never touches it. */ + private static final class MainThread implements PlatformThread { + private volatile android.os.Handler handler; + + @Override + public boolean post(@NonNull final Runnable task) { + android.os.Handler h = handler; + if (h == null) { + h = new android.os.Handler(android.os.Looper.getMainLooper()); + handler = h; + } + return h.post(task); + } + } +} diff --git a/android/src/main/java/com/bugsee/flutter/ReportOps.java b/android/src/main/java/com/bugsee/flutter/ReportOps.java new file mode 100644 index 0000000..2b65c1f --- /dev/null +++ b/android/src/main/java/com/bugsee/flutter/ReportOps.java @@ -0,0 +1,187 @@ +package com.bugsee.flutter; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bugsee.library.contracts.options.IssueSeverity; +import com.bugsee.library.contracts.options.IssueType; +import com.bugsee.library.contracts.reporting.Report; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The operations the Dart report proxy performs on the SDK's {@link Report} + * (workbook 7.3). Plain Java so every rule here is unit-tested without Flutter. + * + *

Whether the report is still live is not decided here: the caller looks the report + * up through {@link ReportHandlerPipeline#reportFor}, which is null after the SDK's + * completion ran (7.3.4). What this class owns is what the SDK would otherwise accept + * silently and get wrong: a severity outside 1..5 ({@code IssueSeverity.fromIntValue} + * maps it to {@code VeryLow}), an attribute value the backend cannot carry, a label that + * is not a string. + * + *

Attachments and screenshots are NOT here (Phase 6, path-first: native does the + * file work and no stream or bitmap crosses the bridge; workbook 7.3.3). + */ +final class ReportOps { + + /** + * The severity range the SDKs accept: {@code IssueSeverity} 1..5 here and + * {@code BugseeSeverityLevel} 1..5 on iOS. The same two numbers are repeated in Dart + * ({@code ReportSeverity}) and Objective-C; {@code test/report_enum_parity_test.dart} + * compares all three copies, and the JUnit test compares them to the SDK's enum. + */ + static final int MIN_SEVERITY = 1; + static final int MAX_SEVERITY = 5; + + /** An argument the SDK cannot take; nothing was applied. */ + static final class BadArgument extends Exception { + BadArgument(@NonNull final String message) { + super(message); + } + } + + /** What a read returns; plain, so the pigeon message is built where Flutter is available. */ + static final class Snapshot { + @NonNull String id = ""; + @NonNull String type = ""; + @Nullable String summary; + @Nullable String description; + @Nullable String email; + /** The SDK's internal value (1..5), never an ordinal. */ + @Nullable Integer severity; + @NonNull List labels = new ArrayList<>(); + @NonNull Map attributes = new LinkedHashMap<>(); + } + + private ReportOps() { + } + + /** {@code toString()}, the wire name ({@code "crash"}); {@code name()} is "Crash". */ + @NonNull + static String typeOf(@NonNull final Report report) { + try { + final IssueType type = report.getType(); + return type == null ? "" : type.toString(); + } catch (final Throwable t) { + return ""; + } + } + + @NonNull + static Snapshot read(@NonNull final Report report) { + final Snapshot s = new Snapshot(); + final String id = report.getId(); + s.id = id == null ? "" : id; + s.type = typeOf(report); + s.summary = report.getSummary(); + s.description = report.getDescription(); + s.email = report.getEmail(); + // By value, never ordinal(): Critical is ordinal 3 and value 4. + final IssueSeverity severity = report.getSeverity(); + s.severity = severity == null ? null : severity.getValue(); + final List labels = report.getLabels(); + if (labels != null) { + for (final String label : labels) { + if (label != null) { + s.labels.add(label); + } + } + } + final Map attributes = report.getAttributes(); + if (attributes != null) { + for (final Map.Entry entry : attributes.entrySet()) { + final Object value = entry.getValue(); + // Only what both SDKs accept, so a value another platform could not take is + // not offered. Anything else has no Dart representation, and a toString would + // read as a real value. + if (entry.getKey() != null && isAllowedAttributeValue(value)) { + s.attributes.put(entry.getKey(), value); + } + } + } + return s; + } + + /** The SDK's severity for the wire value; anything outside 1..5 is refused, not coerced. */ + @NonNull + static IssueSeverity severity(final long wire) throws BadArgument { + if (wire < MIN_SEVERITY || wire > MAX_SEVERITY) { + // Never the value itself: a severity is app data, and a message may be logged. + throw new BadArgument("severity must be an SDK severity value (1..5)"); + } + return IssueSeverity.fromIntValue((int) wire); + } + + static void setSeverity(@NonNull final Report report, final long wire) throws BadArgument { + report.setSeverity(severity(wire)); + } + + /** + * Strings only. A null or non-string element is refused as a whole, so a list that is + * partly valid changes nothing. + */ + @NonNull + static ArrayList labels(@Nullable final List wire) throws BadArgument { + if (wire == null) { + throw new BadArgument("labels must be a list of strings"); + } + final ArrayList result = new ArrayList<>(wire.size()); + for (final Object item : wire) { + if (!(item instanceof String)) { + throw new BadArgument("labels must be a list of strings"); + } + result.add((String) item); + } + return result; + } + + /** One call: clear-then-add would expose a label-less report between the two. */ + static void replaceLabels(@NonNull final Report report, @Nullable final List wire) + throws BadArgument { + report.setLabels(labels(wire)); + } + + static void addLabels(@NonNull final Report report, @Nullable final List wire) + throws BadArgument { + report.addLabels(labels(wire)); + } + + /** + * The value types both SDKs take: String, Boolean and a finite number (Android also + * takes {@code Set}, iOS does not, so it is not offered). + */ + static boolean isAllowedAttributeValue(@Nullable final Object value) { + if (value instanceof String || value instanceof Boolean) { + return true; + } + if (value instanceof Integer || value instanceof Long + || value instanceof Short || value instanceof Byte) { + return true; + } + if (value instanceof Double || value instanceof Float) { + final double d = ((Number) value).doubleValue(); + return !Double.isNaN(d) && !Double.isInfinite(d); + } + return false; + } + + static void setAttribute( + @NonNull final Report report, + @Nullable final String name, + @Nullable final Object value + ) throws BadArgument { + if (name == null || name.isEmpty()) { + throw new BadArgument("an attribute needs a non-empty name"); + } + if (!isAllowedAttributeValue(value)) { + throw new BadArgument( + "an attribute value must be a string, a bool or a finite number"); + } + report.setAttribute(name, (Serializable) value); + } +} diff --git a/android/src/test/java/com/bugsee/flutter/FakeReports.java b/android/src/test/java/com/bugsee/flutter/FakeReports.java new file mode 100644 index 0000000..54f60e7 --- /dev/null +++ b/android/src/test/java/com/bugsee/flutter/FakeReports.java @@ -0,0 +1,145 @@ +package com.bugsee.flutter; + +import com.bugsee.library.contracts.options.IssueSeverity; +import com.bugsee.library.contracts.options.IssueType; +import com.bugsee.library.contracts.reporting.Report; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A {@link Report} built with {@link Proxy}: the interface has around forty methods, and a + * hand-written fake would be mostly boilerplate that drifts every time the SDK adds one. + * + *

Records every method called by name, so a test can assert on HOW the report was changed + * ({@code setLabels} rather than {@code clearLabels} + {@code addLabels}) and not only on the + * end state. A method this fake does not model throws, so a test cannot pass through a call + * the bridge was never meant to make (attachments, screenshots: Phase 6). + */ +final class FakeReports { + + private FakeReports() { + } + + static final class State { + String id = "report-1"; + /** When true, getId throws: an unreadable id. */ + boolean idThrows; + IssueType type = IssueType.Bug; + String summary; + String description; + String email; + IssueSeverity severity = IssueSeverity.Medium; + final List labels = new ArrayList<>(); + final Map attributes = new LinkedHashMap<>(); + /** Runs before / after the summary is stored; lets a test widen or observe the window. */ + Runnable beforeSetSummary; + Runnable onSummarySet; + /** Every method called, by name, in order. */ + final List calls = new ArrayList<>(); + + /** Everything but getters. */ + List mutations() { + final List result = new ArrayList<>(); + for (final String call : calls) { + if (!call.startsWith("get")) { + result.add(call); + } + } + return result; + } + } + + static Report create(final State state) { + return (Report) Proxy.newProxyInstance( + Report.class.getClassLoader(), + new Class[] {Report.class}, + (proxy, method, args) -> invoke(state, proxy, method, args)); + } + + @SuppressWarnings("unchecked") + private static Object invoke( + final State s, final Object proxy, final Method method, final Object[] args) { + final String name = method.getName(); + switch (name) { + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + case "toString": + return "FakeReport(" + s.id + ")"; + default: + break; + } + s.calls.add(name); + switch (name) { + case "getId": + if (s.idThrows) { + throw new IllegalStateException("id unreadable"); + } + return s.id; + case "getType": + return s.type; + case "getSummary": + return s.summary; + case "setSummary": + if (s.beforeSetSummary != null) { + s.beforeSetSummary.run(); + } + s.summary = (String) args[0]; + if (s.onSummarySet != null) { + s.onSummarySet.run(); + } + return null; + case "getDescription": + return s.description; + case "setDescription": + s.description = (String) args[0]; + return null; + case "getEmail": + return s.email; + case "setEmail": + s.email = (String) args[0]; + return null; + case "getSeverity": + return s.severity; + case "setSeverity": + s.severity = (IssueSeverity) args[0]; + return null; + case "getLabels": + return Collections.unmodifiableList(new ArrayList<>(s.labels)); + case "setLabels": + s.labels.clear(); + s.labels.addAll((List) args[0]); + return null; + case "clearLabels": + s.labels.clear(); + return null; + case "addLabels": + s.labels.addAll((List) args[0]); + return null; + case "addLabel": + s.labels.add((String) args[0]); + return null; + case "getAttributes": + return Collections.unmodifiableMap(new LinkedHashMap<>(s.attributes)); + case "setAttribute": + s.attributes.put((String) args[0], (Serializable) args[1]); + return null; + case "removeAttribute": + s.attributes.remove((String) args[0]); + return null; + case "clearAllAttributes": + s.attributes.clear(); + return null; + default: + throw new UnsupportedOperationException("FakeReports does not model " + name); + } + } +} diff --git a/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java b/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java index 4c227cb..4d6bb08 100644 --- a/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java +++ b/android/src/test/java/com/bugsee/flutter/FlutterWrapperTest.java @@ -8,6 +8,7 @@ import static org.junit.Assert.assertTrue; import com.bugsee.library.contracts.internal.BugseeWrapper; +import com.bugsee.library.contracts.reporting.Report; import org.junit.Test; @@ -220,12 +221,81 @@ public void refinedIdentityWithMissingFactsIsUnknown() { assertTrue(registered.get(0).getContext().isEmpty()); } + /** With no Dart handler registered (the default), the SDK is never left waiting. */ @Test - public void reportHandlersCompleteImmediately() { + public void reportHandlersCompleteImmediatelyWhenNoDartHandlerIsRegistered() { final FlutterWrapper w = FlutterWrapper.atProcessStart(); final AtomicInteger done = new AtomicInteger(); w.onBeforeReportCreated(null, false, done::incrementAndGet); w.onAfterReportCreated(null, true, done::incrementAndGet); - assertEquals(2, done.get()); + final Report report = FakeReports.create(new FakeReports.State()); + w.onBeforeReportCreated(report, false, done::incrementAndGet); + w.onAfterReportCreated(report, false, done::incrementAndGet); + w.onAfterReportCreated(report, true, done::incrementAndGet); + assertEquals(5, done.get()); + } + + /** The wrapper is replaced when Dart refines the identity; its answers must not change. */ + @Test + public void theReplacedWrapperAnswersDataRequestsToo() { + final List registered = new ArrayList<>(); + FlutterWrapperInitProvider.registerRefined( + registered::add, "flutter", "1.2.3", null, Collections.singletonMap("a", "b")); + final BugseeWrapper refined = registered.get(0); + for (final String type : new String[] {"vh", "", "something-new"}) { + final AtomicInteger calls = new AtomicInteger(); + final AtomicReference got = new AtomicReference<>("sentinel"); + refined.requestData(type, data -> { + calls.incrementAndGet(); + got.set(data); + }); + assertEquals(type, 1, calls.get()); + assertNull(type, got.get()); + } + refined.requestData("vh", data -> { + throw new IllegalStateException("callback failed"); + }); + // A null callback (never expected from the SDK) must not escape either. + refined.requestData("vh", null); + } + + /** + * The report handlers go through the pipeline of the wrapper that was handed in, so a + * replacement wrapper shares the process-wide one (the engine's Dart end is not in it). + */ + @Test + public void everyWrapperRoutesReportHandlersThroughItsPipeline() { + final List seen = new ArrayList<>(); + final ReportHandlerPipeline pipeline = new ReportHandlerPipeline( + (task, delay) -> () -> { }, + task -> { + task.run(); + return true; + }, + () -> null, + () -> ReportHandlerDeadlines.LIVE_HANDLER_THREAD, + seen::add); + final Object owner = new Object(); + final List requests = new ArrayList<>(); + pipeline.attach(owner, (request, unreachable) -> requests.add(request)); + pipeline.setPhases(owner, true, true); + final FlutterWrapper w = FlutterWrapper.create( + "flutter", "1", null, null, LifecycleHub.shared(), + new SecureRectangleStore(SecureRectangleStore.UNKNOWN_ENVIRONMENT), pipeline); + final Report report = FakeReports.create(new FakeReports.State()); + final AtomicInteger done = new AtomicInteger(); + w.onBeforeReportCreated(report, false, done::incrementAndGet); + w.onAfterReportCreated(report, false, done::incrementAndGet); + assertEquals("both reached Dart and neither is complete", 2, requests.size()); + assertEquals(0, done.get()); + assertFalse(requests.get(0).after); + assertTrue(requests.get(1).after); + // Terminating never reaches Dart, whatever is registered. + w.onBeforeReportCreated(report, true, done::incrementAndGet); + assertEquals(1, done.get()); + assertEquals(2, requests.size()); + pipeline.complete(requests.get(0).handle); + pipeline.complete(requests.get(1).handle); + assertEquals(3, done.get()); } } diff --git a/android/src/test/java/com/bugsee/flutter/ReportEnumParityTest.java b/android/src/test/java/com/bugsee/flutter/ReportEnumParityTest.java new file mode 100644 index 0000000..c4fadbb --- /dev/null +++ b/android/src/test/java/com/bugsee/flutter/ReportEnumParityTest.java @@ -0,0 +1,75 @@ +package com.bugsee.flutter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.bugsee.library.contracts.options.IssueSeverity; +import com.bugsee.library.contracts.options.IssueType; + +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Compares the fixture {@code test/fixtures/report_enums.json} (extracted from the SDK SOURCE by + * {@code scripts/extract_report_enums.dart}) with the REAL enums of the pinned + * {@code bugsee-android}, loaded here from the artifact. Together with + * {@code test/report_enum_parity_test.dart}, which compares the fixture with the iOS values, the + * Dart enums and the Java/ObjC copies, this makes a divergence in either SDK visible (workbook + * 7.4.2, 7.4.3). By value, never by ordinal. + */ +public class ReportEnumParityTest { + + private static String fixture() throws Exception { + // Unit tests run with the module (android/) as the working directory. + final File file = new File(new File("").getAbsoluteFile().getParentFile(), + "test/fixtures/report_enums.json"); + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + + /** The object that follows {@code "key": {} } in the fixture, as name -> raw value text. */ + private static Map block(final String json, final String key) { + final Matcher outer = Pattern.compile("\"" + key + "\"\\s*:\\s*\\{([^}]*)\\}").matcher(json); + assertTrue("fixture has " + key, outer.find()); + final Map result = new LinkedHashMap<>(); + final Matcher entry = Pattern.compile("\"(\\w+)\"\\s*:\\s*(\"[^\"]*\"|-?\\d+)") + .matcher(outer.group(1)); + while (entry.find()) { + result.put(entry.group(1), entry.group(2).replace("\"", "")); + } + return result; + } + + @Test + public void severityIsTheSdksByValue() throws Exception { + final Map expected = block(fixture(), "IssueSeverity"); + final Map actual = new LinkedHashMap<>(); + for (final IssueSeverity s : IssueSeverity.values()) { + actual.put(s.name(), Integer.toString(s.getValue())); + } + assertEquals(expected, actual); + } + + @Test + public void issueTypeIsTheSdksWireString() throws Exception { + final Map expected = block(fixture(), "IssueType"); + final Map actual = new LinkedHashMap<>(); + for (final IssueType t : IssueType.values()) { + actual.put(t.name(), t.toString()); + } + assertEquals(expected, actual); + } + + @Test + public void theMappingTheWrapperUsesRoundTripsEveryValue() throws Exception { + for (final IssueSeverity s : IssueSeverity.values()) { + assertEquals(s, ReportOps.severity(s.getValue())); + } + } +} diff --git a/android/src/test/java/com/bugsee/flutter/ReportHandlerDeadlinesTest.java b/android/src/test/java/com/bugsee/flutter/ReportHandlerDeadlinesTest.java new file mode 100644 index 0000000..2cffc36 --- /dev/null +++ b/android/src/test/java/com/bugsee/flutter/ReportHandlerDeadlinesTest.java @@ -0,0 +1,85 @@ +package com.bugsee.flutter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ReportHandlerDeadlinesTest { + + @Test + public void liveDeadlineSitsInsideTheSdksDefault() { + assertEquals(20_000L, ReportHandlerDeadlines.liveMs(null)); + assertEquals(20_000L, ReportHandlerDeadlines.liveMs(0)); + assertEquals(20_000L, ReportHandlerDeadlines.liveMs(-1)); + assertEquals(20_000L, ReportHandlerDeadlines.liveMs(30)); + assertTrue(ReportHandlerDeadlines.LIVE_DEADLINE_MS + < ReportHandlerDeadlines.SDK_DEFAULT_HANDLER_TIMEOUT_MS); + } + + @Test + public void aShorterOptionShortensTheDeadlineAndALongerOneDoesNotLengthenIt() { + // The margin is a third of the SDK's budget, at most 10 s. + assertEquals(6_667L, ReportHandlerDeadlines.liveMs(10)); + assertEquals(1_334L, ReportHandlerDeadlines.liveMs(2)); + assertEquals(667L, ReportHandlerDeadlines.liveMs(1)); + assertEquals(20_000L, ReportHandlerDeadlines.liveMs(120)); + } + + @Test + public void theMarginIsAThirdOfTheBudgetAtMostTenSeconds() { + assertEquals(10_000L, ReportHandlerDeadlines.marginMs(30_000L)); + assertEquals(10_000L, ReportHandlerDeadlines.marginMs(120_000L)); + assertEquals(1_000L, ReportHandlerDeadlines.marginMs(3_000L)); + assertEquals(0L, ReportHandlerDeadlines.marginMs(0L)); + assertEquals(ReportHandlerDeadlines.LIVE_DEADLINE_MS, + ReportHandlerDeadlines.SDK_DEFAULT_HANDLER_TIMEOUT_MS + - ReportHandlerDeadlines.marginMs(ReportHandlerDeadlines.SDK_DEFAULT_HANDLER_TIMEOUT_MS)); + } + + @Test + public void whatIsLeftOfAWindowNeverGoesNegative() { + assertEquals(15_000L, ReportHandlerDeadlines.remainingMs(20_000L, 5_000L)); + assertEquals(0L, ReportHandlerDeadlines.remainingMs(20_000L, 20_000L)); + assertEquals(0L, ReportHandlerDeadlines.remainingMs(20_000L, 99_000L)); + assertEquals("a clock that went backwards costs nothing", + 20_000L, ReportHandlerDeadlines.remainingMs(20_000L, -5L)); + } + + @Test + public void onlyTheSdksDispatchThreadIsTheWaitedForPath() { + assertTrue(ReportHandlerDeadlines.isLive("BugseeReportHandlerThread")); + assertFalse(ReportHandlerDeadlines.isLive("bugsee-report-handler-bounded")); + assertFalse(ReportHandlerDeadlines.isLive("main")); + assertFalse(ReportHandlerDeadlines.isLive(null)); + } + + @Test + public void terminatingAndUnawaitedPathsGetNoDartRoundTrip() { + assertEquals(0L, ReportHandlerDeadlines.deadlineMs( + ReportHandlerDeadlines.LIVE_HANDLER_THREAD, true, null)); + assertEquals(0L, ReportHandlerDeadlines.deadlineMs("bugsee-report-handler-bounded", false, null)); + assertEquals(0L, ReportHandlerDeadlines.deadlineMs( + ReportHandlerDeadlines.LIVE_HANDLER_THREAD, false, 1)); + assertEquals(20_000L, ReportHandlerDeadlines.deadlineMs( + ReportHandlerDeadlines.LIVE_HANDLER_THREAD, false, null)); + } + + /** + * The thread name is what tells the waited-for path from the others, so it must be the + * SDK's. The constant is read from the SDK when it is accessible, and the test says so + * when it is not rather than passing vacuously. + */ + @Test + public void theThreadNameIsTheSdksConstant() throws Exception { + final Class handlers; + try { + handlers = Class.forName("com.bugsee.library.shared.threading.BugseeHandlers"); + } catch (final ClassNotFoundException | LinkageError e) { + throw new AssertionError("the SDK's BugseeHandlers is not on the test classpath: " + e); + } + assertEquals(ReportHandlerDeadlines.LIVE_HANDLER_THREAD, + handlers.getField("REPORT_HANDLER_THREAD_NAME").get(null)); + } +} diff --git a/android/src/test/java/com/bugsee/flutter/ReportHandlerPipelineTest.java b/android/src/test/java/com/bugsee/flutter/ReportHandlerPipelineTest.java new file mode 100644 index 0000000..839157c --- /dev/null +++ b/android/src/test/java/com/bugsee/flutter/ReportHandlerPipelineTest.java @@ -0,0 +1,812 @@ +package com.bugsee.flutter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.bugsee.flutter.ReportHandlerPipeline.Phase; +import com.bugsee.library.contracts.reporting.Report; + +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * The one property everything here protects: the SDK's completion runs exactly once for + * every dispatch, whichever of Dart, the deadline, a detach, a failure or an error gets + * there first (workbook 7.1.2). Running it twice advances the SDK's chain twice; never + * running it stalls the report until the SDK's own timeout. + */ +public class ReportHandlerPipelineTest { + + /** Runs a task only when the test says so; the delay is the injected "clock". */ + private static final class ManualScheduler implements ReportHandlerPipeline.Scheduler { + static final class Task implements ReportHandlerPipeline.Cancellable { + final Runnable runnable; + final long delayMs; + boolean cancelled; + + Task(final Runnable runnable, final long delayMs) { + this.runnable = runnable; + this.delayMs = delayMs; + } + + @Override + public void cancel() { + cancelled = true; + } + } + + final List tasks = new ArrayList<>(); + boolean throwOnSchedule; + + @Override + public ReportHandlerPipeline.Cancellable schedule(final Runnable task, final long delayMs) { + if (throwOnSchedule) { + throw new IllegalStateException("scheduler is gone"); + } + final Task t = new Task(task, delayMs); + tasks.add(t); + return t; + } + + /** The real race: the timer thread already started the task when cancel() arrives. */ + void fireEvenIfCancelled() { + for (final Task t : new ArrayList<>(tasks)) { + t.runnable.run(); + } + } + + void fireDue() { + for (final Task t : new ArrayList<>(tasks)) { + if (!t.cancelled) { + t.runnable.run(); + } + } + } + } + + /** A platform thread whose tasks run when the test drains it. */ + private static final class QueuePlatform implements ReportHandlerPipeline.PlatformThread { + final ArrayDeque queue = new ArrayDeque<>(); + boolean accept = true; + boolean throwOnPost; + + @Override + public boolean post(final Runnable task) { + if (throwOnPost) { + throw new IllegalStateException("looper is gone"); + } + if (accept) { + queue.add(task); + } + return accept; + } + + void drain() { + while (!queue.isEmpty()) { + queue.poll().run(); + } + } + } + + private static final class RecordingSink implements ReportHandlerPipeline.Sink { + final List requests = new ArrayList<>(); + final List unreachable = new ArrayList<>(); + boolean throwOnDeliver; + + @Override + public void deliver(final ReportHandlerPipeline.Request request, final Runnable onUnreachable) { + if (throwOnDeliver) { + throw new IllegalStateException("bridge is dead"); + } + requests.add(request); + unreachable.add(onUnreachable); + } + } + + private final ManualScheduler scheduler = new ManualScheduler(); + private final QueuePlatform platform = new QueuePlatform(); + private final AtomicReference thread = + new AtomicReference<>(ReportHandlerDeadlines.LIVE_HANDLER_THREAD); + private final AtomicReference option = new AtomicReference<>(); + private final List log = Collections.synchronizedList(new ArrayList<>()); + private final RecordingSink sink = new RecordingSink(); + private final Object owner = new Object(); + private final FakeReports.State state = new FakeReports.State(); + private final Report report = FakeReports.create(state); + private final AtomicInteger completions = new AtomicInteger(); + private ReportHandlerPipeline pipeline; + + @Before + public void setUp() { + pipeline = new ReportHandlerPipeline( + scheduler, platform, option::get, thread::get, log::add); + pipeline.attach(owner, sink); + pipeline.setPhases(owner, true, true); + } + + private void dispatch(final Phase phase, final boolean terminating) { + pipeline.dispatch(phase, report, terminating, completions::incrementAndGet); + } + + private ReportHandlerPipeline.Request delivered() { + platform.drain(); + assertEquals(1, sink.requests.size()); + return sink.requests.get(0); + } + + // ---- exactly once, on every path --------------------------------------------------- + + @Test + public void dartCompletingRunsTheCompletionOnce() { + dispatch(Phase.BEFORE, false); + assertEquals("the SDK is waiting for us; nothing has completed yet", 0, completions.get()); + final ReportHandlerPipeline.Request request = delivered(); + assertTrue(pipeline.complete(request.handle)); + assertEquals(1, completions.get()); + // Everything else that can still arrive is harmless. + assertFalse(pipeline.complete(request.handle)); + scheduler.fireEvenIfCancelled(); + pipeline.detach(owner); + assertEquals(1, completions.get()); + } + + @Test + public void theDeadlineCompletesWhenDartNeverReplies() { + dispatch(Phase.AFTER, false); + delivered(); + assertEquals(0, completions.get()); + scheduler.fireDue(); + assertEquals(1, completions.get()); + scheduler.fireEvenIfCancelled(); + assertEquals(1, completions.get()); + } + + @Test + public void aLateReplyAfterTheDeadlineDoesNothing() { + dispatch(Phase.AFTER, false); + final ReportHandlerPipeline.Request request = delivered(); + scheduler.fireDue(); + assertEquals(1, completions.get()); + assertFalse("the handle is gone; Dart's late completion is a no-op", + pipeline.complete(request.handle)); + assertEquals(1, completions.get()); + } + + @Test + public void anUnreachableDartCompletesAtOnce() { + dispatch(Phase.BEFORE, false); + delivered(); + sink.unreachable.get(0).run(); + assertEquals(1, completions.get()); + sink.unreachable.get(0).run(); + scheduler.fireEvenIfCancelled(); + assertEquals(1, completions.get()); + } + + @Test + public void aSinkThatThrowsCompletesAtOnce() { + sink.throwOnDeliver = true; + dispatch(Phase.BEFORE, false); + platform.drain(); + assertEquals(1, completions.get()); + } + + @Test + public void aPlatformThreadThatCannotQueueCompletesAtOnce() { + platform.accept = false; + dispatch(Phase.BEFORE, false); + assertEquals(1, completions.get()); + platform.accept = true; + platform.throwOnPost = true; + dispatch(Phase.BEFORE, false); + assertEquals(2, completions.get()); + assertNull(pipeline.reportFor("rh-1")); + assertNull(pipeline.reportFor("rh-2")); + } + + @Test + public void aSchedulerThatThrowsCompletesAtOnceAndLeavesNoLiveProxy() { + scheduler.throwOnSchedule = true; + dispatch(Phase.BEFORE, false); + assertEquals(1, completions.get()); + assertNull("a handle nobody will complete must not stay live", pipeline.reportFor("rh-1")); + } + + @Test + public void detachingTheEngineCompletesWhatWasHandedToIt() { + dispatch(Phase.BEFORE, false); + delivered(); + pipeline.detach(owner); + assertEquals(1, completions.get()); + scheduler.fireEvenIfCancelled(); + assertEquals(1, completions.get()); + } + + @Test + public void aDetachBeforeTheRequestReachedDartSkipsDelivery() { + dispatch(Phase.BEFORE, false); + pipeline.detach(owner); + platform.drain(); + assertEquals(1, completions.get()); + assertTrue("Dart is never asked about a handle that is already over", sink.requests.isEmpty()); + } + + @Test + public void aThrowingSdkCompletionNeverEscapes() { + pipeline.dispatch(Phase.BEFORE, report, true, () -> { + throw new IllegalStateException("sdk fault"); + }); + dispatch(Phase.BEFORE, false); + delivered(); + final ReportHandlerPipeline pipelineThatThrows = pipeline; + pipelineThatThrows.dispatch(Phase.AFTER, report, false, () -> { + throw new IllegalStateException("sdk fault"); + }); + platform.drain(); + scheduler.fireDue(); + } + + @Test + public void aNullReportAndANullCompletionNeverThrow() { + pipeline.dispatch(Phase.BEFORE, null, false, completions::incrementAndGet); + assertEquals(1, completions.get()); + pipeline.dispatch(Phase.BEFORE, report, false, null); + pipeline.dispatch(Phase.AFTER, report, true, null); + } + + @Test + public void aReportThatThrowsWhenReadStillCompletesOnce() { + state.idThrows = true; + dispatch(Phase.BEFORE, false); + assertEquals(0, completions.get()); + final ReportHandlerPipeline.Request request = delivered(); + assertEquals("", request.reportId); + assertTrue(pipeline.complete(request.handle)); + assertEquals(1, completions.get()); + } + + // ---- when Dart is not asked --------------------------------------------------------- + + @Test + public void terminatingNeverReachesDartAndCompletesAtOnce() { + dispatch(Phase.BEFORE, true); + dispatch(Phase.AFTER, true); + assertEquals(2, completions.get()); + assertTrue(scheduler.tasks.isEmpty()); + assertTrue(platform.queue.isEmpty()); + platform.drain(); + assertTrue(sink.requests.isEmpty()); + assertEquals("nothing was read from the report either", Collections.emptyList(), state.calls); + } + + @Test + public void noRegisteredHandlerCompletesAtOnceWithoutWaiting() { + pipeline.setPhases(owner, false, false); + dispatch(Phase.BEFORE, false); + dispatch(Phase.AFTER, false); + assertEquals(2, completions.get()); + assertTrue(scheduler.tasks.isEmpty()); + assertTrue(platform.queue.isEmpty()); + } + + @Test + public void onlyTheRegisteredPhaseReachesDart() { + pipeline.setPhases(owner, false, true); + dispatch(Phase.BEFORE, false); + assertEquals(1, completions.get()); + dispatch(Phase.AFTER, false); + assertEquals(1, completions.get()); + assertEquals(1, delivered().deadlineMs > 0 ? 1 : 0); + assertTrue(delivered().after); + } + + @Test + public void noAttachedDartCompletesAtOnce() { + pipeline.detach(owner); + dispatch(Phase.BEFORE, false); + assertEquals(1, completions.get()); + } + + @Test + public void aDispatchTheSdkDoesNotWaitOnCompletesAtOnce() { + // Early-crash recovery runs on a spawned thread, the SDK waits only for the handler + // to return and the completion is a no-op, so a round trip cannot be waited for. + for (final String name : new String[] { + "bugsee-report-handler-bounded", "main", "BugseeBackground", "", null}) { + thread.set(name); + dispatch(Phase.AFTER, false); + } + assertEquals(5, completions.get()); + assertTrue(scheduler.tasks.isEmpty()); + assertTrue(platform.queue.isEmpty()); + } + + @Test + public void aReportThatLooksLikeARecoveredCrashIsNotTreatedAsOne() { + // 7.2.3: type + isTerminating say nothing about recovery. A crash-typed report on + // the live path is delivered like any other, with its type passed through verbatim. + state.type = com.bugsee.library.contracts.options.IssueType.Crash; + dispatch(Phase.AFTER, false); + final ReportHandlerPipeline.Request request = delivered(); + assertEquals("crash", request.type); + assertEquals(0, completions.get()); + } + + // ---- the deadline is inside the SDK's ------------------------------------------------ + + @Test + public void theDefaultDeadlineIsInsideTheSdksThirtySeconds() { + dispatch(Phase.BEFORE, false); + assertEquals(1, scheduler.tasks.size()); + final long delay = scheduler.tasks.get(0).delayMs; + assertEquals(20_000L, delay); + assertTrue(delay < ReportHandlerDeadlines.SDK_DEFAULT_HANDLER_TIMEOUT_MS); + // Dart is told what is left of the window after the hop to the platform thread. + final long told = delivered().deadlineMs; + assertTrue(told <= delay && told > delay - 2_000L); + } + + @Test + public void theDeadlineFollowsTheOptionAndStaysInsideIt() { + for (int seconds = 2; seconds <= 120; seconds++) { + option.set(seconds); + final ManualScheduler s = new ManualScheduler(); + final ReportHandlerPipeline p = new ReportHandlerPipeline( + s, platform, option::get, thread::get, log::add); + p.attach(owner, sink); + p.setPhases(owner, true, true); + p.dispatch(Phase.BEFORE, report, false, () -> { }); + assertEquals(1, s.tasks.size()); + final long delay = s.tasks.get(0).delayMs; + assertTrue("deadline " + delay + " must be inside the " + seconds + " s timeout", + delay < seconds * 1000L); + assertTrue(delay <= ReportHandlerDeadlines.LIVE_DEADLINE_MS); + assertTrue(delay >= ReportHandlerDeadlines.MIN_USEFUL_DEADLINE_MS); + } + } + + @Test + public void anOptionTooShortForARoundTripCompletesAtOnce() { + option.set(1); + dispatch(Phase.BEFORE, false); + assertEquals(1, completions.get()); + assertTrue(scheduler.tasks.isEmpty()); + } + + @Test + public void anUnreadableOptionOrOneThatIsDisabledUsesTheDefault() { + option.set(0); + dispatch(Phase.BEFORE, false); + assertEquals(20_000L, scheduler.tasks.get(0).delayMs); + option.set(-5); + dispatch(Phase.BEFORE, false); + assertEquals(20_000L, scheduler.tasks.get(1).delayMs); + final ReportHandlerPipeline throwing = new ReportHandlerPipeline( + scheduler, platform, () -> { + throw new IllegalStateException("not launched"); + }, thread::get, log::add); + throwing.attach(owner, sink); + throwing.setPhases(owner, true, true); + throwing.dispatch(Phase.BEFORE, report, false, () -> { }); + assertEquals(20_000L, scheduler.tasks.get(2).delayMs); + } + + // ---- the window is measured, and what is observable of the SDK's wait is deducted ---------- + + private static final class FakeClock implements ReportHandlerPipeline.Clock { + long now; + + @Override + public long nowMs() { + return now; + } + } + + private ReportHandlerPipeline withClock(final FakeClock clock) { + final ReportHandlerPipeline p = new ReportHandlerPipeline( + scheduler, platform, option::get, thread::get, log::add, clock); + p.attach(owner, sink); + p.setPhases(owner, true, true); + return p; + } + + @Test + public void dartIsToldWhatIsLeftOfTheWindowNotTheWholeOfIt() { + final FakeClock clock = new FakeClock(); + final ReportHandlerPipeline p = withClock(clock); + p.dispatch(Phase.BEFORE, report, false, completions::incrementAndGet); + clock.now = 7_000; // the platform thread was busy for 7 s + platform.drain(); + assertEquals(1, sink.requests.size()); + assertEquals(13_000L, sink.requests.get(0).deadlineMs); + assertTrue("the gap is logged", log.toString().contains("delivery gap=7000")); + } + + @Test + public void aWindowThatIsAlreadyOverIsNotOfferedToDart() { + final FakeClock clock = new FakeClock(); + final ReportHandlerPipeline p = withClock(clock); + p.dispatch(Phase.BEFORE, report, false, completions::incrementAndGet); + clock.now = 20_000; // the whole window passed waiting for the platform thread + platform.drain(); + assertEquals("completed, not delivered", 1, completions.get()); + assertTrue(sink.requests.isEmpty()); + assertNull(p.reportFor("rh-1")); + assertTrue(log.toString().contains("completed by=expired")); + } + + // ---- hot restart --------------------------------------------------------------------------- + + @Test + public void aNewIsolateCompletesWhatWasHandedToThePreviousOneAndForgetsItsPhases() { + dispatch(Phase.BEFORE, false); + delivered(); + assertEquals(0, completions.get()); + pipeline.reset(owner); + assertEquals("completed now, not at its deadline", 1, completions.get()); + scheduler.fireEvenIfCancelled(); + assertEquals(1, completions.get()); + dispatch(Phase.AFTER, false); + assertEquals("no phase is registered until the new isolate says so", 2, completions.get()); + assertTrue(log.toString().contains("completed by=reset")); + } + + @Test + public void resettingOneEngineLeavesTheOthersHandlesAlone() { + final Object second = new Object(); + final RecordingSink secondSink = new RecordingSink(); + pipeline.attach(second, secondSink); + pipeline.setPhases(second, true, true); + dispatch(Phase.BEFORE, false); + platform.drain(); + pipeline.reset(owner); + assertEquals(0, completions.get()); + pipeline.reset(second); + assertEquals(1, completions.get()); + } + + // ---- check-and-apply is one step ------------------------------------------------------------ + + /** + * An edit that was reported as applied must have landed BEFORE the completion ran. The + * interleaving is forced, not hoped for: the edit signals that it is INSIDE its + * check-and-apply, and only then is the deadline fired on another thread; the edit keeps the + * report busy until the deadline thread has started (and then a little longer, or until the + * completion has run, which is what a broken lock lets happen). With the lock the completion + * waits for the edit, so the edit always applies and lands first; without it the completion + * runs mid-edit and the summary lands after it. Nothing depends on how fast a thread is + * scheduled: a starved machine only makes the broken variant less likely to be caught, never + * the correct one fail. The timeouts are ceilings, not assertions about speed. + */ + @Test(timeout = 120_000) + public void anEditReportedAppliedNeverLandsAfterTheCompletion() throws Exception { + final ExecutorService pool = Executors.newFixedThreadPool(2); + try { + for (int i = 0; i < 300; i++) { + final ManualScheduler s = new ManualScheduler(); + final AtomicInteger completed = new AtomicInteger(); + final AtomicInteger landedAfterCompletion = new AtomicInteger(); + final CountDownLatch inside = new CountDownLatch(1); + final CountDownLatch deadlineStarted = new CountDownLatch(1); + final FakeReports.State st = new FakeReports.State(); + st.beforeSetSummary = () -> { + inside.countDown(); + try { + assertTrue(deadlineStarted.await(60, TimeUnit.SECONDS)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + // Give a completion that is not held back by the edit the chance to run first. + final long until = System.nanoTime() + 2_000_000; + while (System.nanoTime() < until && completed.get() == 0) { + Thread.yield(); + } + }; + final ReportHandlerPipeline p = new ReportHandlerPipeline( + s, task -> { + task.run(); + return true; + }, option::get, thread::get, log::add); + final RecordingSink rs = new RecordingSink(); + final Object o = new Object(); + p.attach(o, rs); + p.setPhases(o, true, true); + st.onSummarySet = () -> { + if (completed.get() > 0) { + landedAfterCompletion.incrementAndGet(); + } + }; + p.dispatch(Phase.BEFORE, FakeReports.create(st), false, completed::incrementAndGet); + final String handle = rs.requests.get(0).handle; + final Future edit = pool.submit(() -> p.edit(handle, r -> r.setSummary("x"))); + final Future deadline = pool.submit(() -> { + // Only once the edit is inside its check-and-apply does the deadline fire. + assertTrue(inside.await(60, TimeUnit.SECONDS)); + deadlineStarted.countDown(); + s.fireEvenIfCancelled(); + return null; + }); + deadline.get(60, TimeUnit.SECONDS); + assertTrue("round " + i + ": the edit was inside first", edit.get(60, TimeUnit.SECONDS)); + assertEquals("round " + i, 0, landedAfterCompletion.get()); + assertEquals("round " + i, 1, completed.get()); + } + } finally { + pool.shutdownNow(); + } + } + + // ---- the proxy is live only until the completion runs (7.3.4) ------------------------- + + @Test + public void aMutationBeforeCompletionAppliesAndAfterItIsIgnored() throws Exception { + dispatch(Phase.BEFORE, false); + final ReportHandlerPipeline.Request request = delivered(); + assertTrue(pipeline.edit(request.handle, r -> r.setSummary("early"))); + assertEquals("early", state.summary); + + pipeline.complete(request.handle); + assertFalse(pipeline.edit(request.handle, r -> r.setSummary("late"))); + assertEquals("early", state.summary); + assertNull(pipeline.reportFor(request.handle)); + } + + @Test + public void aMutationAfterTheDeadlineIsIgnoredToo() throws Exception { + dispatch(Phase.BEFORE, false); + final ReportHandlerPipeline.Request request = delivered(); + scheduler.fireDue(); + assertFalse(pipeline.edit(request.handle, r -> r.setSummary("late"))); + assertNull(state.summary); + assertEquals(Collections.singletonList("getId"), onlyReads(state.calls)); + } + + private static List onlyReads(final List calls) { + final List reads = new ArrayList<>(); + for (final String c : calls) { + if (c.startsWith("get") && !reads.contains(c)) { + reads.add(c); + } + } + reads.remove("getType"); + return reads; + } + + @Test + public void anUnknownHandleIsRefusedNotAnError() throws Exception { + assertFalse(pipeline.edit("rh-999", r -> r.setSummary("x"))); + assertFalse(pipeline.edit(null, r -> r.setSummary("x"))); + assertNull(pipeline.reportFor("nope")); + } + + @Test + public void aBadArgumentChangesNothingAndLeavesTheHandleLive() throws Exception { + dispatch(Phase.BEFORE, false); + final ReportHandlerPipeline.Request request = delivered(); + try { + pipeline.edit(request.handle, r -> ReportOps.setSeverity(r, 0)); + org.junit.Assert.fail(); + } catch (final ReportOps.BadArgument expected) { + // refused + } + assertEquals(Collections.emptyList(), state.mutations()); + assertNotNull(pipeline.reportFor(request.handle)); + } + + // ---- identity of a delivery ---------------------------------------------------------- + + @Test + public void aDeliveryIdIsStableForOneReportAndPhaseAndTheHandleIsNot() { + dispatch(Phase.AFTER, false); + dispatch(Phase.AFTER, false); + dispatch(Phase.BEFORE, false); + platform.drain(); + final ReportHandlerPipeline.Request a = sink.requests.get(0); + final ReportHandlerPipeline.Request b = sink.requests.get(1); + final ReportHandlerPipeline.Request c = sink.requests.get(2); + assertEquals("after:report-1", a.deliveryId); + assertEquals(a.deliveryId, b.deliveryId); + assertNotEquals(a.handle, b.handle); + assertEquals("before:report-1", c.deliveryId); + assertTrue(a.after); + assertFalse(c.after); + } + + @Test + public void anUnreadableIdIsNeverShared() { + state.idThrows = true; + dispatch(Phase.AFTER, false); + dispatch(Phase.AFTER, false); + platform.drain(); + assertNotEquals(sink.requests.get(0).deliveryId, sink.requests.get(1).deliveryId); + } + + // ---- several engines ----------------------------------------------------------------- + + @Test + public void theMostRecentEngineWithAHandlerGetsTheDelivery() { + final Object second = new Object(); + final RecordingSink secondSink = new RecordingSink(); + pipeline.attach(second, secondSink); + pipeline.setPhases(second, true, false); + dispatch(Phase.BEFORE, false); + platform.drain(); + assertEquals(1, secondSink.requests.size()); + assertTrue(sink.requests.isEmpty()); + dispatch(Phase.AFTER, false); + platform.drain(); + assertEquals("the second engine has no after handler; the first does", 1, sink.requests.size()); + } + + @Test + public void detachingOneEngineLeavesTheOthersHandlesAlone() { + final Object second = new Object(); + final RecordingSink secondSink = new RecordingSink(); + pipeline.attach(second, secondSink); + pipeline.setPhases(second, true, true); + dispatch(Phase.BEFORE, false); + platform.drain(); + pipeline.detach(owner); + assertEquals("the handle belongs to the second engine", 0, completions.get()); + pipeline.detach(second); + assertEquals(1, completions.get()); + } + + // ---- threads --------------------------------------------------------------------------- + + /** + * The deadlock the design exists to avoid. The "platform thread" is a single thread that + * must run Dart's reply. If a dispatch waited for that reply while ON that thread (iOS + * calls handlers on main; Android's crash paths can), nothing could ever deliver it. Here + * the dispatch runs ON the platform thread and the reply is queued behind it. + */ + @Test(timeout = 10_000) + public void dispatchingOnThePlatformThreadItselfNeverWaitsForDart() throws Exception { + final ExecutorService main = Executors.newSingleThreadExecutor(); + try { + final CountDownLatch completed = new CountDownLatch(1); + final AtomicReference handle = new AtomicReference<>(); + final ReportHandlerPipeline p = new ReportHandlerPipeline( + scheduler, + task -> { + main.execute(task); + return true; + }, + option::get, thread::get, log::add); + // "Dart": answers on the platform thread, as the real reply is delivered there. + p.attach(owner, (request, unreachable) -> { + handle.set(request.handle); + main.execute(() -> p.complete(request.handle)); + }); + p.setPhases(owner, true, true); + + final Future returnedBeforeCompletion = main.submit(() -> { + final long start = System.nanoTime(); + p.dispatch(Phase.BEFORE, report, false, completed::countDown); + final long elapsedMs = (System.nanoTime() - start) / 1_000_000L; + // A wait that gave up after a timeout would still "return", so time it. + return completed.getCount() == 1 && elapsedMs < 5_000; + }); + assertTrue("dispatch returned at once, before the completion ran", + returnedBeforeCompletion.get(60, TimeUnit.SECONDS)); + assertTrue("and Dart's reply, queued on the same thread, then completed it", + completed.await(60, TimeUnit.SECONDS)); + } finally { + main.shutdownNow(); + } + } + + /** The SDK's own thread is never held up by a busy or blocked platform thread either. */ + @Test(timeout = 10_000) + public void aBlockedPlatformThreadDoesNotBlockTheSdkThread() throws Exception { + final ExecutorService main = Executors.newSingleThreadExecutor(); + final CountDownLatch release = new CountDownLatch(1); + try { + main.execute(() -> { + try { + release.await(); + } catch (final InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); + final ReportHandlerPipeline p = new ReportHandlerPipeline( + scheduler, task -> { + main.execute(task); + return true; + }, option::get, thread::get, log::add); + p.attach(owner, sink); + p.setPhases(owner, true, true); + final CountDownLatch done = new CountDownLatch(1); + final ExecutorService sdk = Executors.newSingleThreadExecutor(); + try { + final long start = System.nanoTime(); + sdk.submit(() -> p.dispatch(Phase.BEFORE, report, false, done::countDown)) + .get(60, TimeUnit.SECONDS); + assertTrue("the SDK thread was not held up", + (System.nanoTime() - start) / 1_000_000L < 5_000); + } finally { + sdk.shutdownNow(); + } + assertEquals("the SDK thread came back while main was still blocked", 1, done.getCount()); + // The deadline still ends it, with main blocked the whole time. + scheduler.fireDue(); + assertTrue(done.await(60, TimeUnit.SECONDS)); + } finally { + release.countDown(); + main.shutdownNow(); + } + } + + @Test(timeout = 120_000) + public void everyRaceForTheCompletionRunsItOnce() throws Exception { + final int rounds = 100; + final ExecutorService pool = Executors.newFixedThreadPool(4); + try { + for (int i = 0; i < rounds; i++) { + final ManualScheduler s = new ManualScheduler(); + final AtomicInteger ran = new AtomicInteger(); + final ReportHandlerPipeline p = new ReportHandlerPipeline( + s, task -> { + task.run(); + return true; + }, option::get, thread::get, log::add); + final RecordingSink rs = new RecordingSink(); + final Object o = new Object(); + p.attach(o, rs); + p.setPhases(o, true, true); + p.dispatch(Phase.BEFORE, report, false, ran::incrementAndGet); + final String handle = rs.requests.get(0).handle; + final CountDownLatch go = new CountDownLatch(1); + final List> all = new ArrayList<>(); + all.add(pool.submit(() -> { await(go); p.complete(handle); })); + all.add(pool.submit(() -> { await(go); s.fireEvenIfCancelled(); })); + all.add(pool.submit(() -> { await(go); p.detach(o); })); + all.add(pool.submit(() -> { await(go); rs.unreachable.get(0).run(); })); + go.countDown(); + for (final Future f : all) { + f.get(60, TimeUnit.SECONDS); + } + assertEquals("round " + i, 1, ran.get()); + } + } finally { + pool.shutdownNow(); + } + } + + private static void await(final CountDownLatch latch) { + try { + latch.await(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // ---- the outcome is logged ------------------------------------------------------------- + + @Test + public void everyOutcomeIsOneLogLine() { + dispatch(Phase.BEFORE, true); + assertEquals(1, log.size()); + assertTrue(log.get(0).contains("completed by=terminating")); + dispatch(Phase.AFTER, false); + delivered(); + pipeline.complete(sink.requests.get(0).handle); + assertTrue(log.get(log.size() - 1).contains("completed by=dart")); + } +} diff --git a/android/src/test/java/com/bugsee/flutter/ReportOpsTest.java b/android/src/test/java/com/bugsee/flutter/ReportOpsTest.java new file mode 100644 index 0000000..5286db3 --- /dev/null +++ b/android/src/test/java/com/bugsee/flutter/ReportOpsTest.java @@ -0,0 +1,152 @@ +package com.bugsee.flutter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.bugsee.library.contracts.options.IssueSeverity; +import com.bugsee.library.contracts.options.IssueType; +import com.bugsee.library.contracts.reporting.Report; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; + +public class ReportOpsTest { + + private final FakeReports.State state = new FakeReports.State(); + private final Report report = FakeReports.create(state); + + private void assertBad(final ReportHandlerPipeline.Edit edit) { + try { + edit.apply(report); + fail("expected BadArgument"); + } catch (final ReportOps.BadArgument expected) { + // The refusal is the point; what matters next is that nothing changed. + } + assertEquals(Collections.emptyList(), state.mutations()); + } + + @Test + public void severityCrossesByValueNeverByOrdinal() throws Exception { + for (int wire = 1; wire <= 5; wire++) { + ReportOps.setSeverity(report, wire); + assertEquals(wire, state.severity.getValue()); + } + // Critical is ordinal 3 and value 4. + ReportOps.setSeverity(report, 4); + assertEquals(IssueSeverity.Critical, state.severity); + assertEquals(3, IssueSeverity.Critical.ordinal()); + assertEquals(Integer.valueOf(4), ReportOps.read(report).severity); + } + + @Test + public void severityOutsideTheSdkRangeIsRefusedNotCoerced() { + // IssueSeverity.fromIntValue maps all of these to VeryLow, silently. + for (final long wire : new long[] {0, -1, 6, 100, Long.MAX_VALUE, Long.MIN_VALUE}) { + assertBad(r -> ReportOps.setSeverity(r, wire)); + } + } + + @Test + public void severityRangeIsExactlyTheSdksValues() { + final HashSet values = new HashSet<>(); + for (final IssueSeverity s : IssueSeverity.values()) { + values.add(s.getValue()); + } + assertEquals(new HashSet<>(Arrays.asList(1, 2, 3, 4, 5)), values); + assertEquals(ReportOps.MIN_SEVERITY, Collections.min(values).intValue()); + assertEquals(ReportOps.MAX_SEVERITY, Collections.max(values).intValue()); + } + + @Test + public void typeIsTheWireStringNotTheEnumName() { + for (final IssueType t : IssueType.values()) { + state.type = t; + assertEquals(t.toString(), ReportOps.typeOf(report)); + } + state.type = IssueType.Crash; + assertEquals("crash", ReportOps.typeOf(report)); + assertEquals("Crash", IssueType.Crash.name()); + } + + @Test + public void replacingLabelsIsOneAtomicCall() throws Exception { + state.labels.addAll(Arrays.asList("old")); + ReportOps.replaceLabels(report, Arrays.asList("a", "b")); + assertEquals(Arrays.asList("a", "b"), state.labels); + // clear-then-add would expose a label-less report between the two calls. + assertEquals(Collections.singletonList("setLabels"), state.mutations()); + } + + @Test + public void aListThatIsNotAllStringsChangesNothing() { + state.labels.add("keep"); + assertBad(r -> ReportOps.replaceLabels(r, Arrays.asList("a", 3))); + assertBad(r -> ReportOps.replaceLabels(r, Arrays.asList("a", null))); + assertBad(r -> ReportOps.addLabels(r, Arrays.asList(1.5))); + assertBad(r -> ReportOps.replaceLabels(r, null)); + assertEquals(Collections.singletonList("keep"), state.labels); + } + + @Test + public void attributesTakeOnlyWhatBothSdksCarry() throws Exception { + ReportOps.setAttribute(report, "s", "text"); + ReportOps.setAttribute(report, "b", true); + ReportOps.setAttribute(report, "i", 7); + ReportOps.setAttribute(report, "l", 7_000_000_000L); + ReportOps.setAttribute(report, "d", 1.5); + assertEquals(5, state.attributes.size()); + state.calls.clear(); + assertBad(r -> ReportOps.setAttribute(r, "n", Double.NaN)); + assertBad(r -> ReportOps.setAttribute(r, "n", Double.POSITIVE_INFINITY)); + assertBad(r -> ReportOps.setAttribute(r, "n", new HashSet<>(Arrays.asList("x")))); + assertBad(r -> ReportOps.setAttribute(r, "n", Arrays.asList("x"))); + assertBad(r -> ReportOps.setAttribute(r, "n", null)); + assertBad(r -> ReportOps.setAttribute(r, "", "v")); + assertBad(r -> ReportOps.setAttribute(r, null, "v")); + assertEquals(5, state.attributes.size()); + } + + @Test + public void readReturnsTheLiveReportAndLeavesUnsupportedAttributesOut() { + state.summary = "s"; + state.description = "d"; + state.email = "e@x"; + state.severity = IssueSeverity.Blocker; + state.labels.addAll(Arrays.asList("l1", "l2")); + state.attributes.put("ok", "v"); + state.attributes.put("set", new HashSet<>(Arrays.asList("a"))); + state.attributes.put("nan", Double.NaN); + final ReportOps.Snapshot s = ReportOps.read(report); + assertEquals("report-1", s.id); + assertEquals("bug", s.type); + assertEquals("s", s.summary); + assertEquals("d", s.description); + assertEquals("e@x", s.email); + assertEquals(Integer.valueOf(5), s.severity); + assertEquals(Arrays.asList("l1", "l2"), s.labels); + assertEquals(Collections.singletonMap("ok", "v"), s.attributes); + assertNull(new FakeReportsHelper().emptyRead().summary); + } + + @Test + public void attributeValueRule() { + assertTrue(ReportOps.isAllowedAttributeValue("x")); + assertTrue(ReportOps.isAllowedAttributeValue(false)); + assertTrue(ReportOps.isAllowedAttributeValue(1)); + assertFalse(ReportOps.isAllowedAttributeValue(null)); + assertFalse(ReportOps.isAllowedAttributeValue(new Object())); + } + + /** A fresh report with nothing set. */ + private static final class FakeReportsHelper { + ReportOps.Snapshot emptyRead() { + return ReportOps.read(FakeReports.create(new FakeReports.State())); + } + } +} diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift b/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift index 7358162..f84f7b8 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift +++ b/ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift @@ -32,6 +32,10 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { private let owner: Int64 private var tracker: BGSFlutterSecurePlacementTracker? + // Report handlers (workbook Part 7). One owner token per engine identifies its Dart isolate + // in the process-wide pipeline. + private var reportOwner: Any? + init(messenger: FlutterBinaryMessenger, registrar: FlutterPluginRegistrar) { self.messenger = messenger self.owner = BugseeFlutterPlugin.newOwner() @@ -43,6 +47,24 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { owner: owner, store: store, environment: BugseeFlutterPlugin.environment, viewProvider: { [weak registrar] in registrar?.viewController?.view }) tracker?.refresh() + // No phase is registered until Dart says so (`setReportHandlerPhases`). + let dart = BugseeBridgeFlutterApi(binaryMessenger: messenger) + reportOwner = BGSFlutterReportHandlerPipeline.shared().attachSink { request, unreachable in + // On the main queue (the pipeline posts it there). Never waits for the reply: the SDK's + // completion runs when Dart completes the handle, when the deadline passes, or when this + // call fails (Dart not listening). + let message = ReportHandlerRequestMessage( + handle: request.handle, deliveryId: request.deliveryId, after: request.after, + reportId: request.reportId, type: request.type, deadlineMs: request.deadlineMs) + Task { @MainActor in + do { + try await dart.onReportHandlerRequest(request: message) + } catch { + // No Dart handler (hot restart, teardown): answer for it, now. + unreachable() + } + } + } } public static func register(with registrar: FlutterPluginRegistrar) { @@ -61,6 +83,11 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { stopForwarding() tracker?.detach() tracker = nil + // Completes what was handed to this engine's Dart: nothing else knows those handles. + if let owner = reportOwner { + BGSFlutterReportHandlerPipeline.shared().detachOwner(owner) + reportOwner = nil + } // The engine's UI is gone; there is nothing left of it to hide. BGSFlutterSecureRectangleStore.shared().dropOwner(owner) } @@ -139,4 +166,126 @@ public class BugseeFlutterPlugin: NSObject, FlutterPlugin, BugseeBridgeHostApi { } return applied } + + // MARK: Report handlers + + func resetReportHandlers() throws { + if let owner = reportOwner { + BGSFlutterReportHandlerPipeline.shared().resetOwner(owner) + } + } + + func setReportHandlerPhases(before: Bool, after: Bool) throws { + if let owner = reportOwner { + BGSFlutterReportHandlerPipeline.shared().setPhasesForOwner(owner, before: before, after: after) + } + } + + func completeReportHandler(handle: String) throws { + _ = BGSFlutterReportHandlerPipeline.shared().completeHandle(handle) + } + + func reportRead(handle: String) throws -> ReportSnapshotMessage? { + guard let report = BGSFlutterReportHandlerPipeline.shared().report(forHandle: handle) else { + return nil + } + let s = BGSFlutterReportOps.readReport(report) + return ReportSnapshotMessage( + id: s.reportId, type: s.type, summary: s.summary, reportDescription: s.reportDescription, + email: s.email, severity: s.severity?.int64Value, labels: s.labels, + attributes: s.attributes) + } + + func reportSetSummary(handle: String, value: String?) throws -> Bool { + try edit(handle) { report, _ in + BGSFlutterReportOps.setSummary(value, onReport: report) + return true + } + } + + func reportSetDescription(handle: String, value: String?) throws -> Bool { + try edit(handle) { report, _ in + BGSFlutterReportOps.setDescription(value, onReport: report) + return true + } + } + + func reportSetEmail(handle: String, value: String?) throws -> Bool { + try edit(handle) { report, _ in + BGSFlutterReportOps.setEmail(value, onReport: report) + return true + } + } + + func reportSetSeverity(handle: String, severity: Int64) throws -> Bool { + try edit(handle) { report, error in + attempt(error) { try BGSFlutterReportOps.setSeverity(severity, onReport: report) } + } + } + + func reportReplaceLabels(handle: String, labels: [String]) throws -> Bool { + try edit(handle) { report, error in + attempt(error) { try BGSFlutterReportOps.replaceLabels(labels, onReport: report) } + } + } + + func reportAddLabels(handle: String, labels: [String]) throws -> Bool { + try edit(handle) { report, error in + attempt(error) { try BGSFlutterReportOps.addLabels(labels, onReport: report) } + } + } + + func reportClearLabels(handle: String) throws -> Bool { + try edit(handle) { report, _ in + BGSFlutterReportOps.clearLabels(onReport: report) + return true + } + } + + func reportSetAttribute(handle: String, name: String, value: Any) throws -> Bool { + try edit(handle) { report, error in + attempt(error) { try BGSFlutterReportOps.setAttribute(value, name: name, onReport: report) } + } + } + + func reportRemoveAttribute(handle: String, name: String) throws -> Bool { + try edit(handle) { report, _ in + BGSFlutterReportOps.removeAttribute(name, onReport: report) + return true + } + } + + func reportClearAttributes(handle: String) throws -> Bool { + try edit(handle) { report, _ in + BGSFlutterReportOps.clearAttributes(onReport: report) + return true + } + } + + /// false: the handle is not live, nothing changed. An unusable argument is an error reply. + private func edit(_ handle: String, _ change: @escaping (Any, NSErrorPointer) -> Bool) throws + -> Bool + { + var error: NSError? + switch BGSFlutterReportHandlerPipeline.shared().editHandle(handle, edit: change, error: &error) { + case .applied: return true + case .notLive: return false + case .refused: + throw PigeonError( + code: "invalid-argument", message: error?.localizedDescription ?? "invalid argument", + details: nil) + @unknown default: return false + } + } +} + +/// The ObjC operations throw (`NSError **`); the pipeline's edit block reports through a pointer. +private func attempt(_ error: NSErrorPointer, _ body: () throws -> Void) -> Bool { + do { + try body() + return true + } catch let thrown as NSError { + error?.pointee = thrown + return false + } } diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift b/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift index 6af4973..1fcb4ae 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift +++ b/ios/bugsee_flutter/Sources/bugsee_flutter/Messages.g.swift @@ -404,6 +404,165 @@ struct SecureRectanglesMessage: Hashable, CustomStringConvertible { } } +/// One report-handler delivery native hands to Dart: the report is reachable +/// only through [handle] (a proxy, never a copy; workbook 7.3.1), and only until +/// the handler's completion runs (7.3.4). +/// +/// There is deliberately NO Pigeon enum anywhere in this bridge: Pigeon sends an +/// enum as its ordinal, and workbook 7.4 forbids ordinals. Severity crosses as the +/// SDK's internal integer, the phase as a bool, the issue type as the SDKs' string. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct ReportHandlerRequestMessage: Hashable, CustomStringConvertible { + /// Unique per delivery and opaque; names the live report in every report + /// operation and in [BugseeBridgeHostApi.completeReportHandler]. + var handle: String + /// STABLE for the same report and phase (`before:` / + /// `after:`), so Dart can recognise a re-delivery: `onBefore` is + /// at-most-once and may be skipped, `onAfter` is at-least-once (7.2). Unique per + /// delivery (never shared) when the report's id could not be read. + var deliveryId: String + /// `false`: onBeforeReportCreated. `true`: onAfterReportCreated. + var after: Bool + var reportId: String + /// The SDK's issue type string (`bug`, `crash`, `error`), as it reads it. Not + /// evidence that the report is a recovered crash (7.2.3). + var type: String + /// How much longer native keeps the report live, measured when this message is + /// SENT (so time spent queueing for the platform thread is already deducted): it + /// runs the SDK's completion itself when this has passed, after which every + /// operation on the handle is refused (7.3.5). The window is the SDK's budget less a + /// margin; the time the SDK spent queueing the handler before native saw it cannot be + /// observed, so the margin, not this number, is what absorbs it. + var deadlineMs: Int64 + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ReportHandlerRequestMessage? { + let handle = pigeonVar_list[0] as! String + let deliveryId = pigeonVar_list[1] as! String + let after = pigeonVar_list[2] as! Bool + let reportId = pigeonVar_list[3] as! String + let type = pigeonVar_list[4] as! String + let deadlineMs = pigeonVar_list[5] as! Int64 + + return ReportHandlerRequestMessage( + handle: handle, + deliveryId: deliveryId, + after: after, + reportId: reportId, + type: type, + deadlineMs: deadlineMs + ) + } + func toList() -> [Any?] { + return [ + handle, + deliveryId, + after, + reportId, + type, + deadlineMs, + ] + } + static func == (lhs: ReportHandlerRequestMessage, rhs: ReportHandlerRequestMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.handle, rhs.handle) && MessagesPigeonInternal.deepEquals(lhs.deliveryId, rhs.deliveryId) && MessagesPigeonInternal.deepEquals(lhs.after, rhs.after) && MessagesPigeonInternal.deepEquals(lhs.reportId, rhs.reportId) && MessagesPigeonInternal.deepEquals(lhs.type, rhs.type) && MessagesPigeonInternal.deepEquals(lhs.deadlineMs, rhs.deadlineMs) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("ReportHandlerRequestMessage") + MessagesPigeonInternal.deepHash(value: handle, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: deliveryId, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: after, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: reportId, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: type, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: deadlineMs, hasher: &hasher) + } + + public var description: String { + return "ReportHandlerRequestMessage(handle: \(String(describing: handle)), deliveryId: \(String(describing: deliveryId)), after: \(String(describing: after)), reportId: \(String(describing: reportId)), type: \(String(describing: type)), deadlineMs: \(String(describing: deadlineMs)))" + } +} + +/// A read of a live report. [severity] is the SDK's internal integer (1..5), or +/// null when the SDK reports none (iOS reads 0 for "never set"; 7.4). +/// +/// Generated class from Pigeon that represents data sent in messages. +struct ReportSnapshotMessage: Hashable, CustomStringConvertible { + var id: String + var type: String + var summary: String? = nil + /// Named `reportDescription`: `description` collides with NSObject's in Swift. + var reportDescription: String? = nil + var email: String? = nil + var severity: Int64? = nil + var labels: [String] + /// Only values both SDKs accept: String, bool, int, double. Anything else the + /// report holds is left out rather than stringified. + var attributes: [String: Any] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ReportSnapshotMessage? { + let id = pigeonVar_list[0] as! String + let type = pigeonVar_list[1] as! String + let summary: String? = nilOrValue(pigeonVar_list[2]) + let reportDescription: String? = nilOrValue(pigeonVar_list[3]) + let email: String? = nilOrValue(pigeonVar_list[4]) + let severity: Int64? = nilOrValue(pigeonVar_list[5]) + let labels = pigeonVar_list[6] as! [String] + let attributes = pigeonVar_list[7] as! [String: Any] + + return ReportSnapshotMessage( + id: id, + type: type, + summary: summary, + reportDescription: reportDescription, + email: email, + severity: severity, + labels: labels, + attributes: attributes + ) + } + func toList() -> [Any?] { + return [ + id, + type, + summary, + reportDescription, + email, + severity, + labels, + attributes, + ] + } + static func == (lhs: ReportSnapshotMessage, rhs: ReportSnapshotMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.id, rhs.id) && MessagesPigeonInternal.deepEquals(lhs.type, rhs.type) && MessagesPigeonInternal.deepEquals(lhs.summary, rhs.summary) && MessagesPigeonInternal.deepEquals(lhs.reportDescription, rhs.reportDescription) && MessagesPigeonInternal.deepEquals(lhs.email, rhs.email) && MessagesPigeonInternal.deepEquals(lhs.severity, rhs.severity) && MessagesPigeonInternal.deepEquals(lhs.labels, rhs.labels) && MessagesPigeonInternal.deepEquals(lhs.attributes, rhs.attributes) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("ReportSnapshotMessage") + MessagesPigeonInternal.deepHash(value: id, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: type, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: summary, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: reportDescription, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: email, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: severity, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: labels, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: attributes, hasher: &hasher) + } + + public var description: String { + return "ReportSnapshotMessage(id: \(String(describing: id)), type: \(String(describing: type)), summary: \(String(describing: summary)), reportDescription: \(String(describing: reportDescription)), email: \(String(describing: email)), severity: \(String(describing: severity)), labels: \(String(describing: labels)), attributes: \(String(describing: attributes)))" + } +} + private class MessagesPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { @@ -415,6 +574,10 @@ private class MessagesPigeonCodecReader: FlutterStandardReader { return SecureViewRectanglesMessage.fromList(self.readValue() as! [Any?]) case 132: return SecureRectanglesMessage.fromList(self.readValue() as! [Any?]) + case 133: + return ReportHandlerRequestMessage.fromList(self.readValue() as! [Any?]) + case 134: + return ReportSnapshotMessage.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -435,6 +598,12 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? SecureRectanglesMessage { super.writeByte(132) super.writeValue(value.toList()) + } else if let value = value as? ReportHandlerRequestMessage { + super.writeByte(133) + super.writeValue(value.toList()) + } else if let value = value as? ReportSnapshotMessage { + super.writeByte(134) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -470,6 +639,38 @@ protocol BugseeBridgeHostApi { /// whether the message was applied (`false`: it was older than one already /// applied, which is not an error). func setSecureRectangles(state: SecureRectanglesMessage) throws -> Bool + /// A new Dart isolate is up (`BugseeBridge.start` calls it once, first): native completes + /// every delivery still outstanding to the previous isolate (a hot restart would otherwise + /// leave each report waiting for its deadline) and forgets which phases had a handler. + func resetReportHandlers() throws + /// Tells native whether a Dart handler exists for each phase. With none, native + /// completes the SDK's callback at once and never calls Dart (no waiting). Sent + /// again (false, false) by every new Dart isolate, so a hot restart that has not + /// registered one yet is not waited on. + func setReportHandlerPhases(before: Bool, after: Bool) throws + /// The Dart handler is done (or failed): run the SDK's completion. Idempotent; + /// a handle native already completed (its deadline) is a no-op. + func completeReportHandler(handle: String) throws + /// Report operations. Each returns whether it was APPLIED: `false` means the + /// handle is no longer live (the completion already ran), which Dart turns into + /// an error; native ignores the call (7.3.4). An argument the SDK cannot take + /// (a severity outside 1..5, an attribute value that is not a string, number or + /// bool) is a PlatformException `invalid-argument` and changes nothing. + /// + /// null: the handle is not live. + func reportRead(handle: String) throws -> ReportSnapshotMessage? + func reportSetSummary(handle: String, value: String?) throws -> Bool + func reportSetDescription(handle: String, value: String?) throws -> Bool + func reportSetEmail(handle: String, value: String?) throws -> Bool + /// [severity] is the SDK's internal integer, never an ordinal. + func reportSetSeverity(handle: String, severity: Int64) throws -> Bool + /// Atomic: a reader never sees the report without labels in between. + func reportReplaceLabels(handle: String, labels: [String]) throws -> Bool + func reportAddLabels(handle: String, labels: [String]) throws -> Bool + func reportClearLabels(handle: String) throws -> Bool + func reportSetAttribute(handle: String, name: String, value: Any) throws -> Bool + func reportRemoveAttribute(handle: String, name: String) throws -> Bool + func reportClearAttributes(handle: String) throws -> Bool } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -529,6 +730,242 @@ class BugseeBridgeHostApiSetup { } else { setSecureRectanglesChannel.setMessageHandler(nil) } + /// A new Dart isolate is up (`BugseeBridge.start` calls it once, first): native completes + /// every delivery still outstanding to the previous isolate (a hot restart would otherwise + /// leave each report waiting for its deadline) and forgets which phases had a handler. + let resetReportHandlersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.resetReportHandlers\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + resetReportHandlersChannel.setMessageHandler { _, reply in + do { + try api.resetReportHandlers() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + resetReportHandlersChannel.setMessageHandler(nil) + } + /// Tells native whether a Dart handler exists for each phase. With none, native + /// completes the SDK's callback at once and never calls Dart (no waiting). Sent + /// again (false, false) by every new Dart isolate, so a hot restart that has not + /// registered one yet is not waited on. + let setReportHandlerPhasesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setReportHandlerPhases\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setReportHandlerPhasesChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let beforeArg = args[0] as! Bool + let afterArg = args[1] as! Bool + do { + try api.setReportHandlerPhases(before: beforeArg, after: afterArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setReportHandlerPhasesChannel.setMessageHandler(nil) + } + /// The Dart handler is done (or failed): run the SDK's completion. Idempotent; + /// a handle native already completed (its deadline) is a no-op. + let completeReportHandlerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.completeReportHandler\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + completeReportHandlerChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + do { + try api.completeReportHandler(handle: handleArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + completeReportHandlerChannel.setMessageHandler(nil) + } + /// Report operations. Each returns whether it was APPLIED: `false` means the + /// handle is no longer live (the completion already ran), which Dart turns into + /// an error; native ignores the call (7.3.4). An argument the SDK cannot take + /// (a severity outside 1..5, an attribute value that is not a string, number or + /// bool) is a PlatformException `invalid-argument` and changes nothing. + /// + /// null: the handle is not live. + let reportReadChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportRead\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportReadChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + do { + let result = try api.reportRead(handle: handleArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportReadChannel.setMessageHandler(nil) + } + let reportSetSummaryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetSummary\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportSetSummaryChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + let valueArg: String? = nilOrValue(args[1]) + do { + let result = try api.reportSetSummary(handle: handleArg, value: valueArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportSetSummaryChannel.setMessageHandler(nil) + } + let reportSetDescriptionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetDescription\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportSetDescriptionChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + let valueArg: String? = nilOrValue(args[1]) + do { + let result = try api.reportSetDescription(handle: handleArg, value: valueArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportSetDescriptionChannel.setMessageHandler(nil) + } + let reportSetEmailChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetEmail\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportSetEmailChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + let valueArg: String? = nilOrValue(args[1]) + do { + let result = try api.reportSetEmail(handle: handleArg, value: valueArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportSetEmailChannel.setMessageHandler(nil) + } + /// [severity] is the SDK's internal integer, never an ordinal. + let reportSetSeverityChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetSeverity\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportSetSeverityChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + let severityArg = args[1] as! Int64 + do { + let result = try api.reportSetSeverity(handle: handleArg, severity: severityArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportSetSeverityChannel.setMessageHandler(nil) + } + /// Atomic: a reader never sees the report without labels in between. + let reportReplaceLabelsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportReplaceLabels\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportReplaceLabelsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + let labelsArg = args[1] as! [String] + do { + let result = try api.reportReplaceLabels(handle: handleArg, labels: labelsArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportReplaceLabelsChannel.setMessageHandler(nil) + } + let reportAddLabelsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportAddLabels\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportAddLabelsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + let labelsArg = args[1] as! [String] + do { + let result = try api.reportAddLabels(handle: handleArg, labels: labelsArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportAddLabelsChannel.setMessageHandler(nil) + } + let reportClearLabelsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportClearLabels\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportClearLabelsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + do { + let result = try api.reportClearLabels(handle: handleArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportClearLabelsChannel.setMessageHandler(nil) + } + let reportSetAttributeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetAttribute\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportSetAttributeChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + let nameArg = args[1] as! String + let valueArg = args[2]! + do { + let result = try api.reportSetAttribute(handle: handleArg, name: nameArg, value: valueArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportSetAttributeChannel.setMessageHandler(nil) + } + let reportRemoveAttributeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportRemoveAttribute\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportRemoveAttributeChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + let nameArg = args[1] as! String + do { + let result = try api.reportRemoveAttribute(handle: handleArg, name: nameArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportRemoveAttributeChannel.setMessageHandler(nil) + } + let reportClearAttributesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportClearAttributes\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + reportClearAttributesChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let handleArg = args[0] as! String + do { + let result = try api.reportClearAttributes(handle: handleArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + reportClearAttributesChannel.setMessageHandler(nil) + } } } @@ -537,6 +974,11 @@ class BugseeBridgeHostApiSetup { /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. protocol BugseeBridgeFlutterApiProtocol { @MainActor func onLifecycleEvent(event eventArg: LifecycleEventMessage) async throws + /// A report handler delivery. Native does NOT wait for this call's reply: the + /// SDK's completion runs when Dart calls `completeReportHandler`, when the + /// deadline passes, or when this call fails (Dart not listening). Never + /// delivered while the SDK says the process is terminating. + @MainActor func onReportHandlerRequest(request requestArg: ReportHandlerRequestMessage) async throws } class BugseeBridgeFlutterApi: BugseeBridgeFlutterApiProtocol { private let binaryMessenger: FlutterBinaryMessenger @@ -568,4 +1010,28 @@ class BugseeBridgeFlutterApi: BugseeBridgeFlutterApiProtocol { } } } + /// A report handler delivery. Native does NOT wait for this call's reply: the + /// SDK's completion runs when Dart calls `completeReportHandler`, when the + /// deadline passes, or when this call fails (Dart not listening). Never + /// delivered while the SDK says the process is terminating. + @MainActor func onReportHandlerRequest(request requestArg: ReportHandlerRequestMessage) async throws { + return try await withCheckedThrowingContinuation { continuation in + let channelName: String = "dev.flutter.pigeon.bugsee_flutter.BugseeBridgeFlutterApi.onReportHandlerRequest\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([requestArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + continuation.resume(throwing: createConnectionError(withChannelName: channelName)) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + continuation.resume(throwing: PigeonError(code: code, message: message, details: details)) + } else { + continuation.resume() + } + } + } + } } diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterReportHandlerPipeline.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterReportHandlerPipeline.m new file mode 100644 index 0000000..242c253 --- /dev/null +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterReportHandlerPipeline.m @@ -0,0 +1,524 @@ +#import "BGSFlutterReportHandlerPipeline.h" + +#import + +#import "BGSFlutterReportOps.h" + +@import Bugsee; + +const int64_t BGSFlutterReportLiveDeadlineMs = 20000; +const int64_t BGSFlutterReportRecoveryDeadlineMs = 2000; +const int64_t BGSFlutterReportSdkLiveHandlerTimeoutMs = 30000; +const int64_t BGSFlutterReportSdkBoundedCapMs = 3000; + +int64_t BGSFlutterReportMarginMs(int64_t sdkMs) { + return MIN(10000, sdkMs / 3); +} + +int64_t BGSFlutterReportRemainingMs(int64_t budgetMs, int64_t elapsedMs) { + return MAX(0, budgetMs - MAX(0, elapsedMs)); +} + +int64_t BGSFlutterReportDeadlineMs(BOOL onMainThread) { + return onMainThread ? BGSFlutterReportLiveDeadlineMs : BGSFlutterReportRecoveryDeadlineMs; +} + +@interface BGSFlutterReportRequest () +- (instancetype)initWithHandle:(NSString *)handle + deliveryId:(NSString *)deliveryId + after:(BOOL)after + reportId:(NSString *)reportId + type:(NSString *)type + deadlineMs:(int64_t)deadlineMs; +@end + +@implementation BGSFlutterReportRequest +- (instancetype)initWithHandle:(NSString *)handle + deliveryId:(NSString *)deliveryId + after:(BOOL)after + reportId:(NSString *)reportId + type:(NSString *)type + deadlineMs:(int64_t)deadlineMs { + self = [super init]; + if (self) { + _handle = [handle copy]; + _deliveryId = [deliveryId copy]; + _after = after; + _reportId = [reportId copy]; + _type = [type copy]; + _deadlineMs = deadlineMs; + } + return self; +} +@end + +/// One engine's Dart end. `before`/`after` are guarded by the pipeline's lock. +@interface BGSFlutterReportRegistration : NSObject +@property(nonatomic, copy) BGSFlutterReportSink sink; +@property(nonatomic) BOOL before; +@property(nonatomic) BOOL after; +@end +@implementation BGSFlutterReportRegistration +@end + +/// One dispatch that reached Dart. `done`, `timer`, `report` and `completion` are changed only +/// under the pipeline's lock AND (for `done`, `report`, `completion`) the handle's `editLock`; +/// everything else is immutable. Lock order: `editLock`, then the pipeline's lock; no lock is held +/// across a call into the SDK's completion, Dart, the scheduler or the log. +@interface BGSFlutterReportHandle : NSObject +@property(nonatomic, copy, readonly) NSString *handleId; +/// Nilled by finish, with `completion`: the deadline task captures the handle, and a cancelled +/// dispatch block keeps its captures until its fire time. +@property(nonatomic, strong, nullable) id report; +@property(nonatomic, copy, nullable) dispatch_block_t completion; +@property(nonatomic, strong, readonly) id owner; +/// The exactly-once guard. Whoever flips it runs the completion (after unlocking). +@property(nonatomic) BOOL done; +/// Held by an edit across "is it live" + the change, and by finishing across flipping `done`. +@property(nonatomic, strong, readonly) NSLock *editLock; +@property(nonatomic, readonly) int64_t enteredAtMs; +@property(nonatomic, readonly) int64_t deadlineMs; +@property(nonatomic, strong, nullable) id timer; +@end +@implementation BGSFlutterReportHandle +- (instancetype)initWithId:(NSString *)handleId + report:(id)report + completion:(dispatch_block_t)completion + owner:(id)owner + enteredAtMs:(int64_t)enteredAtMs + deadlineMs:(int64_t)deadlineMs { + self = [super init]; + if (self) { + _editLock = [[NSLock alloc] init]; + _enteredAtMs = enteredAtMs; + _deadlineMs = deadlineMs; + _handleId = [handleId copy]; + _report = report; + _completion = [completion copy]; + _owner = owner; + } + return self; +} +@end + +static NSString *PhaseWire(BGSFlutterReportPhase phase) { + return phase == BGSFlutterReportPhaseBefore ? @"before" : @"after"; +} + +/// The SDK's own fault must not escape into Dart's queue or the timer's. +static void RunQuietly(dispatch_block_t completion) { + if (completion == nil) { + return; + } + @try { + completion(); + } @catch (NSException *exception) { + NSLog(@"[BugseeFlutter] report handler completion threw: %@", NSStringFromClass(exception.class)); + } +} + +static NSString *SafeReportId(id report) { + @try { + NSString *reportId = ((id)report).reportId; + return [reportId isKindOfClass:[NSString class]] ? reportId : @""; + } @catch (NSException *exception) { + return @""; + } +} + +@implementation BGSFlutterReportHandlerPipeline { + BGSFlutterReportScheduler _schedule; + BGSFlutterReportCancel _cancel; + BGSFlutterReportPlatformPost _platformPost; + BGSFlutterReportLog _log; + BGSFlutterReportClock _clock; + + /// Guards everything below. The SDK dispatches on main (live) or its own threads (recovery), + /// Dart completes on main, deadlines fire on the scheduler's queue: all of them meet here. + /// Never held while calling out: not the SDK's completion, not a sink, not the scheduler. + os_unfair_lock _lock; + NSMutableArray *_registrations; // most recent first + NSMutableDictionary *_handles; + uint64_t _counter; +} + ++ (instancetype)shared { + static BGSFlutterReportHandlerPipeline *shared = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + // One serial queue for every handle's deadline. A cancelled block that has not started + // never runs; one that has already started still reaches the handle's guard, which is + // what makes cancelling safe to race. + dispatch_queue_t queue = + dispatch_queue_create("com.bugsee.flutter.report-deadline", DISPATCH_QUEUE_SERIAL); + shared = [[BGSFlutterReportHandlerPipeline alloc] + initWithScheduler:^id(dispatch_block_t task, int64_t delayMs) { + dispatch_block_t block = dispatch_block_create(0, task); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayMs * (int64_t)NSEC_PER_MSEC), queue, + block); + return block; + } + cancel:^(id token) { + dispatch_block_cancel((dispatch_block_t)token); + } + platformPost:^(dispatch_block_t task) { + // Never inline, even when the caller IS the main thread (the live path): the request + // is sent after the SDK's call has returned. + dispatch_async(dispatch_get_main_queue(), task); + } + log:^(NSString *line) { + // NSLog, not os_log: it reaches the `devicectl --console` stream device tests match. + NSLog(@"%@", line); + }]; + }); + return shared; +} + +- (instancetype)initWithScheduler:(BGSFlutterReportScheduler)scheduler + cancel:(BGSFlutterReportCancel)cancel + platformPost:(BGSFlutterReportPlatformPost)platformPost + log:(BGSFlutterReportLog)log { + return [self initWithScheduler:scheduler + cancel:cancel + platformPost:platformPost + log:log + clock:^int64_t { + return (int64_t)([[NSProcessInfo processInfo] systemUptime] * 1000.0); + }]; +} + +- (instancetype)initWithScheduler:(BGSFlutterReportScheduler)scheduler + cancel:(BGSFlutterReportCancel)cancel + platformPost:(BGSFlutterReportPlatformPost)platformPost + log:(BGSFlutterReportLog)log + clock:(BGSFlutterReportClock)clock { + self = [super init]; + if (self) { + _clock = [clock copy]; + _schedule = [scheduler copy]; + _cancel = [cancel copy]; + _platformPost = [platformPost copy]; + _log = [log copy]; + _lock = OS_UNFAIR_LOCK_INIT; + _registrations = [NSMutableArray array]; + _handles = [NSMutableDictionary dictionary]; + } + return self; +} + +#pragma mark - Engines + +- (id)attachSink:(BGSFlutterReportSink)sink { + BGSFlutterReportRegistration *registration = [[BGSFlutterReportRegistration alloc] init]; + registration.sink = sink; + os_unfair_lock_lock(&_lock); + [_registrations insertObject:registration atIndex:0]; + os_unfair_lock_unlock(&_lock); + return registration; +} + +- (void)setPhasesForOwner:(id)owner before:(BOOL)before after:(BOOL)after { + os_unfair_lock_lock(&_lock); + if ([_registrations containsObject:owner]) { + ((BGSFlutterReportRegistration *)owner).before = before; + ((BGSFlutterReportRegistration *)owner).after = after; + } + os_unfair_lock_unlock(&_lock); +} + +- (void)resetOwner:(id)owner { + [self setPhasesForOwner:owner before:NO after:NO]; + NSMutableArray *stale = [NSMutableArray array]; + os_unfair_lock_lock(&_lock); + for (BGSFlutterReportHandle *handle in _handles.allValues) { + if (handle.owner == owner) { + [stale addObject:handle]; + } + } + os_unfair_lock_unlock(&_lock); + for (BGSFlutterReportHandle *handle in stale) { + [self finish:handle by:@"reset"]; + } +} + +- (void)detachOwner:(id)owner { + NSMutableArray *stale = [NSMutableArray array]; + os_unfair_lock_lock(&_lock); + [_registrations removeObject:owner]; + for (BGSFlutterReportHandle *handle in _handles.allValues) { + if (handle.owner == owner) { + [stale addObject:handle]; + } + } + os_unfair_lock_unlock(&_lock); + for (BGSFlutterReportHandle *handle in stale) { + [self finish:handle by:@"detach"]; + } +} + +#pragma mark - Dispatch + +- (void)dispatchPhase:(BGSFlutterReportPhase)phase + report:(id)report + isTerminating:(BOOL)isTerminating + onMainThread:(BOOL)onMainThread + completion:(dispatch_block_t)completion { + // At most once, whatever happens below. + NSObject *onceGuard = [[NSObject alloc] init]; + __block BOOL ran = NO; + dispatch_block_t once = ^{ + @synchronized(onceGuard) { + if (ran || completion == nil) { + return; + } + ran = YES; + } + completion(); + }; + @try { + [self dispatchOnce:phase + report:report + isTerminating:isTerminating + onMainThread:onMainThread + completion:once]; + } @catch (NSException *exception) { + // Whatever went wrong, the SDK must not wait for us. + NSLog(@"[BugseeFlutter] report handler dispatch failed: %@", NSStringFromClass(exception.class)); + RunQuietly(once); + } +} + +- (void)dispatchOnce:(BGSFlutterReportPhase)phase + report:(id)report + isTerminating:(BOOL)isTerminating + onMainThread:(BOOL)onMainThread + completion:(dispatch_block_t)completion { + if (report == nil) { + [self completeUnminted:phase reportId:nil completion:completion by:@"no-report"]; + return; + } + // The process dies when this returns; nothing asynchronous survives it and the Dart runtime + // may already be gone. Not even read from the report: touch nothing on a dying process. + if (isTerminating) { + [self completeUnminted:phase reportId:nil completion:completion by:@"terminating"]; + return; + } + + os_unfair_lock_lock(&_lock); + BGSFlutterReportRegistration *target = nil; + for (BGSFlutterReportRegistration *candidate in _registrations) { + if (phase == BGSFlutterReportPhaseBefore ? candidate.before : candidate.after) { + target = candidate; + break; + } + } + os_unfair_lock_unlock(&_lock); + if (target == nil) { + [self completeUnminted:phase reportId:SafeReportId(report) completion:completion by:@"no-handler"]; + return; + } + + const int64_t deadlineMs = BGSFlutterReportDeadlineMs(onMainThread); + NSString *reportId = SafeReportId(report); + NSString *type = [BGSFlutterReportOps typeOfReport:report]; + os_unfair_lock_lock(&_lock); + NSString *handleId = [NSString stringWithFormat:@"rh-%llu", ++_counter]; + BGSFlutterReportHandle *handle = [[BGSFlutterReportHandle alloc] initWithId:handleId + report:report + completion:completion + owner:target + enteredAtMs:_clock() + deadlineMs:deadlineMs]; + _handles[handleId] = handle; + // A detach that ran after the lookup above removed the registration, and swept the table + // before this handle was in it; whichever side comes second catches the handle. + const BOOL detached = ![_registrations containsObject:target]; + os_unfair_lock_unlock(&_lock); + if (detached) { + [self finish:handle by:@"detach"]; + return; + } + + // Stable for one report and phase, so Dart recognises a re-delivery; unique (never shared) + // when the id is unreadable, so distinct reports never collide. + NSString *deliveryId = reportId.length > 0 + ? [NSString stringWithFormat:@"%@:%@", PhaseWire(phase), reportId] + : [NSString stringWithFormat:@"%@:?%@", PhaseWire(phase), handleId]; + BGSFlutterReportRequest *request = + [[BGSFlutterReportRequest alloc] initWithHandle:handleId + deliveryId:deliveryId + after:phase == BGSFlutterReportPhaseAfter + reportId:reportId + type:type + deadlineMs:deadlineMs]; + @try { + // The task holds the handle itself, not its id: a deadline that was already running when + // Dart completed must still hit the guard. + id timer = _schedule(^{ + [self finish:handle by:@"deadline"]; + }, deadlineMs); + os_unfair_lock_lock(&_lock); + const BOOL alreadyDone = handle.done; + if (!alreadyDone) { + handle.timer = timer; + } + os_unfair_lock_unlock(&_lock); + if (alreadyDone) { + [self cancelQuietly:timer]; + return; + } + _log([NSString stringWithFormat:@"[BugseeFlutter] report handler %@ phase=%@ deadline=%lld", + handleId, PhaseWire(phase), deadlineMs]); + // To the main queue, never inline and never waited for. Dart's reply is delivered there, and + // on the live path this very call IS the main thread: see the class comment. + _platformPost(^{ + [self deliver:handle target:target request:request]; + }); + } @catch (NSException *exception) { + // The handle is in the table: finishing it runs the completion and makes the report refuse + // edits, instead of leaving a live proxy nobody will complete. + NSLog(@"[BugseeFlutter] report handler %@ could not be set up: %@", handleId, + NSStringFromClass(exception.class)); + [self finish:handle by:@"error"]; + } +} + +- (void)deliver:(BGSFlutterReportHandle *)handle + target:(BGSFlutterReportRegistration *)target + request:(BGSFlutterReportRequest *)request { + os_unfair_lock_lock(&_lock); + const BOOL over = handle.done; + os_unfair_lock_unlock(&_lock); + if (over) { + return; + } + // What is observable of the SDK's own wait: how long this request sat before the main queue took + // it. Deduct it; a window that is already over is not offered to Dart. + const int64_t gapMs = MAX(0, _clock() - handle.enteredAtMs); + const int64_t remainingMs = BGSFlutterReportRemainingMs(handle.deadlineMs, gapMs); + _log([NSString stringWithFormat:@"[BugseeFlutter] report handler %@ delivery gap=%lld ms remaining=%lld ms", + handle.handleId, gapMs, remainingMs]); + if (remainingMs <= 0) { + [self finish:handle by:@"expired"]; + return; + } + BGSFlutterReportRequest *live = [[BGSFlutterReportRequest alloc] initWithHandle:request.handle + deliveryId:request.deliveryId + after:request.after + reportId:request.reportId + type:request.type + deadlineMs:remainingMs]; + @try { + target.sink(live, ^{ + [self finish:handle by:@"unreachable"]; + }); + } @catch (NSException *exception) { + // A dead bridge throws from the send. Dart will never answer, so answer for it. + NSLog(@"[BugseeFlutter] report handler %@ could not reach Dart: %@", handle.handleId, + NSStringFromClass(exception.class)); + [self finish:handle by:@"unreachable"]; + } +} + +#pragma mark - Dart's side + +- (BOOL)completeHandle:(NSString *)handleId { + os_unfair_lock_lock(&_lock); + BGSFlutterReportHandle *handle = _handles[handleId]; + os_unfair_lock_unlock(&_lock); + return handle != nil && [self finish:handle by:@"dart"]; +} + +- (id)reportForHandle:(NSString *)handleId { + if (handleId == nil) { + return nil; + } + os_unfair_lock_lock(&_lock); + BGSFlutterReportHandle *handle = _handles[handleId]; + id report = (handle == nil || handle.done) ? nil : handle.report; + os_unfair_lock_unlock(&_lock); + return report; +} + +- (BGSFlutterReportEditResult)editHandle:(NSString *)handleId + edit:(BGSFlutterReportEdit)edit + error:(NSError **)error { + if (handleId == nil) { + return BGSFlutterReportEditNotLive; + } + os_unfair_lock_lock(&_lock); + BGSFlutterReportHandle *handle = _handles[handleId]; + os_unfair_lock_unlock(&_lock); + if (handle == nil) { + return BGSFlutterReportEditNotLive; + } + // Check and apply under the handle's lock: finishing flips `done` under the same one, so a + // deadline cannot fall between the check and the apply and leave an edit "applied" after the + // SDK's completion ran (and the report's fields are not written from two threads at once). + [handle.editLock lock]; + if (handle.done) { + [handle.editLock unlock]; + return BGSFlutterReportEditNotLive; + } + const BOOL applied = edit(handle.report, error); + [handle.editLock unlock]; + return applied ? BGSFlutterReportEditApplied : BGSFlutterReportEditRefused; +} + +#pragma mark - Private + +/// @return YES only for the call that ran the SDK completion. +- (BOOL)finish:(BGSFlutterReportHandle *)handle by:(NSString *)by { + [handle.editLock lock]; + os_unfair_lock_lock(&_lock); + if (handle.done) { + os_unfair_lock_unlock(&_lock); + [handle.editLock unlock]; + return NO; + } + handle.done = YES; + if (_handles[handle.handleId] == handle) { + [_handles removeObjectForKey:handle.handleId]; + } + id timer = handle.timer; + handle.timer = nil; + dispatch_block_t completion = handle.completion; + handle.completion = nil; + handle.report = nil; + os_unfair_lock_unlock(&_lock); + [handle.editLock unlock]; + + // Outside the locks from here: the completion re-enters the SDK, and a cancel or a log line + // has no business holding up every other handle. + if (timer != nil) { + [self cancelQuietly:timer]; + } + _log([NSString stringWithFormat:@"[BugseeFlutter] report handler %@ completed by=%@", + handle.handleId, by]); + RunQuietly(completion); + return YES; +} + +/// No handle was minted; the line still carries the report for correlation. +- (void)completeUnminted:(BGSFlutterReportPhase)phase + reportId:(nullable NSString *)reportId + completion:(dispatch_block_t)completion + by:(NSString *)by { + _log([NSString stringWithFormat:@"[BugseeFlutter] report handler - completed by=%@ phase=%@ report=%@", + by, PhaseWire(phase), reportId]); + RunQuietly(completion); +} + +- (void)cancelQuietly:(id)timer { + if (timer == nil) { + return; + } + @try { + _cancel(timer); + } @catch (NSException *exception) { + NSLog(@"[BugseeFlutter] report handler timer cancel threw: %@", + NSStringFromClass(exception.class)); + } +} + +@end diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterReportOps.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterReportOps.m new file mode 100644 index 0000000..75f8737 --- /dev/null +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterReportOps.m @@ -0,0 +1,170 @@ +#import "BGSFlutterReportOps.h" + +@import Bugsee; + +NSErrorDomain const BGSFlutterReportOpsErrorDomain = @"com.bugsee.flutter.report-ops"; + +@implementation BGSFlutterReportSnapshot +@end + +static BOOL Refuse(NSError **error, NSString *message) { + if (error != NULL) { + *error = [NSError errorWithDomain:BGSFlutterReportOpsErrorDomain + code:1 + userInfo:@{NSLocalizedDescriptionKey : message}]; + } + return NO; +} + +/// An NSNumber that is a boolean (what Dart's `bool` arrives as), not merely 0 or 1. +static BOOL IsBoolean(NSNumber *number) { + return CFGetTypeID((__bridge CFTypeRef)number) == CFBooleanGetTypeID(); +} + +@implementation BGSFlutterReportOps + ++ (NSInteger)minSeverity { + return 1; +} + ++ (NSInteger)maxSeverity { + return 5; +} + ++ (NSString *)typeOfReport:(id)report { + @try { + NSString *type = report.type; + return [type isKindOfClass:[NSString class]] ? type : @""; + } @catch (NSException *exception) { + return @""; + } +} + ++ (BGSFlutterReportSnapshot *)readReport:(id)report { + BGSFlutterReportSnapshot *s = [[BGSFlutterReportSnapshot alloc] init]; + s.reportId = report.reportId ?: @""; + s.type = [self typeOfReport:report]; + s.summary = report.summary; + s.reportDescription = report.reportDescription; + s.email = report.email; + // 0 is "never set" (only a report's own initializer produces it): not a level. + const NSInteger severity = (NSInteger)report.severity; + s.severity = (severity >= self.minSeverity && severity <= self.maxSeverity) ? @(severity) : nil; + NSMutableArray *labels = [NSMutableArray array]; + for (id label in report.labels) { // a snapshot, taken under the report's lock + if ([label isKindOfClass:[NSString class]]) { + [labels addObject:label]; + } + } + s.labels = labels; + NSMutableDictionary *attributes = [NSMutableDictionary dictionary]; + [report.attributes enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { + // Only what both SDKs accept; anything else has no Dart representation, and a description + // would read as a real value. + if ([key isKindOfClass:[NSString class]] && [self isAllowedAttributeValue:value]) { + attributes[key] = value; + } + }]; + s.attributes = attributes; + return s; +} + ++ (void)setSummary:(NSString *)summary onReport:(id)report { + report.summary = summary; +} + ++ (void)setDescription:(NSString *)description onReport:(id)report { + report.reportDescription = description; +} + ++ (void)setEmail:(NSString *)email onReport:(id)report { + report.email = email; +} + ++ (void)clearLabelsOnReport:(id)report { + [report clearLabels]; +} + ++ (void)removeAttribute:(NSString *)name onReport:(id)report { + [report removeAttributeForName:name]; +} + ++ (void)clearAttributesOnReport:(id)report { + [report clearAllAttributes]; +} + ++ (BOOL)setSeverity:(int64_t)wire onReport:(id)report error:(NSError **)error { + // The SDK ignores a value outside Low...Blocker and keeps the old one (logging the refusal), + // which would look like success here; refuse it instead, before it reaches the SDK. + if (wire < self.minSeverity || wire > self.maxSeverity) { + return Refuse(error, @"severity must be an SDK severity value (1..5)"); + } + report.severity = (BugseeSeverityLevel)wire; + return YES; +} + +/// Strings only. A list with anything else is refused as a whole, so a partly valid list +/// changes nothing. ++ (nullable NSArray *)stringsFrom:(id)labels error:(NSError **)error { + if (![labels isKindOfClass:[NSArray class]]) { + Refuse(error, @"labels must be a list of strings"); + return nil; + } + for (id item in (NSArray *)labels) { + if (![item isKindOfClass:[NSString class]]) { + Refuse(error, @"labels must be a list of strings"); + return nil; + } + } + return labels; +} + ++ (BOOL)replaceLabels:(id)labels onReport:(id)report error:(NSError **)error { + NSArray *strings = [self stringsFrom:labels error:error]; + if (strings == nil) { + return NO; + } + // One call: clear-then-add would expose a label-less report between the two. + [report replaceLabels:strings]; + return YES; +} + ++ (BOOL)addLabels:(id)labels onReport:(id)report error:(NSError **)error { + NSArray *strings = [self stringsFrom:labels error:error]; + if (strings == nil) { + return NO; + } + [report addLabels:strings]; + return YES; +} + ++ (BOOL)isAllowedAttributeValue:(id)value { + if ([value isKindOfClass:[NSString class]]) { + return YES; + } + if ([value isKindOfClass:[NSNumber class]]) { + NSNumber *number = value; + if (IsBoolean(number)) { + return YES; + } + const double d = number.doubleValue; + return isfinite(d); + } + return NO; +} + ++ (BOOL)setAttribute:(id)value + name:(NSString *)name + onReport:(id)report + error:(NSError **)error { + if (![name isKindOfClass:[NSString class]] || name.length == 0) { + return Refuse(error, @"an attribute needs a non-empty name"); + } + if (![self isAllowedAttributeValue:value]) { + return Refuse(error, @"an attribute value must be a string, a bool or a finite number"); + } + [report setAttribute:value forName:name]; + return YES; +} + +@end diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterWrapper.m b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterWrapper.m index cdbaa86..e599884 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterWrapper.m +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/BGSFlutterWrapper.m @@ -1,6 +1,7 @@ #import "BGSFlutterWrapper.h" #import "BGSFlutterLifecycleHub.h" +#import "BGSFlutterReportHandlerPipeline.h" #import "BGSFlutterSecureRectangleStore.h" @import Bugsee; @@ -27,6 +28,7 @@ @implementation BGSFlutterWrapper { // refines the identity, so nothing that must survive that lives in it (5.4.2). BGSFlutterLifecycleHub *_hub; BGSFlutterSecureRectangleStore *_secureRectangles; + BGSFlutterReportHandlerPipeline *_reports; } + (instancetype)wrapperAtProcessStart { @@ -42,7 +44,8 @@ + (instancetype)wrapperWithType:(id)type version:(id)version build:(id)build con build:build context:context hub:[BGSFlutterLifecycleHub shared] - secureRectangles:[BGSFlutterSecureRectangleStore shared]]; + secureRectangles:[BGSFlutterSecureRectangleStore shared] + reports:[BGSFlutterReportHandlerPipeline shared]]; } + (instancetype)wrapperWithType:(id)type @@ -51,9 +54,26 @@ + (instancetype)wrapperWithType:(id)type context:(id)context hub:(BGSFlutterLifecycleHub *)hub secureRectangles:(BGSFlutterSecureRectangleStore *)secureRectangles { + return [self wrapperWithType:type + version:version + build:build + context:context + hub:hub + secureRectangles:secureRectangles + reports:[BGSFlutterReportHandlerPipeline shared]]; +} + ++ (instancetype)wrapperWithType:(id)type + version:(id)version + build:(id)build + context:(id)context + hub:(BGSFlutterLifecycleHub *)hub + secureRectangles:(BGSFlutterSecureRectangleStore *)secureRectangles + reports:(BGSFlutterReportHandlerPipeline *)reports { BGSFlutterWrapper *wrapper = [[BGSFlutterWrapper alloc] init]; wrapper->_hub = hub; wrapper->_secureRectangles = secureRectangles; + wrapper->_reports = reports; wrapper->_wrapperType = [BGSFlutterStringOr(type, BGSFlutterUnknown) copy]; wrapper->_wrapperVersion = [BGSFlutterStringOr(version, BGSFlutterUnknown) copy]; wrapper->_wrapperBuild = [build isKindOfClass:[NSString class]] ? [build copy] : nil; @@ -69,6 +89,10 @@ + (instancetype)wrapperWithType:(id)type return wrapper; } +// Always answers, and always with nil for now: the one flow that exists is `vh` (the managed +// view hierarchy, Phase 5), and an unknown type is answered with nil (workbook 6.1). Silence +// would cost the SDK the whole 500 ms budget, so even a `vh` request is answered, with nil, +// until Phase 5 produces a payload. Never throws, whatever the SDK or its callback does. - (void)requestDataWithType:(NSString *)dataType callback:(id)callback { @try { [callback onResult:nil]; @@ -88,16 +112,28 @@ - (NSData *)secureRectanglesForDisplay:(NSInteger)display { return [_secureRectangles pullForDisplay:display]; } +// Report handlers (workbook Part 7): see BGSFlutterReportHandlerPipeline. On the live path the +// SDK calls these ON THE MAIN THREAD, on recovery on a worker, so nothing here may block, and the +// SDK's completion runs exactly once whatever happens. The pipeline is process-wide, not part of +// this replaceable object. - (void)onBeforeReportCreated:(id)report isTerminating:(BOOL)isTerminating completion:(BGSCallback)completion { - completion(); + [_reports dispatchPhase:BGSFlutterReportPhaseBefore + report:report + isTerminating:isTerminating + onMainThread:[NSThread isMainThread] + completion:completion]; } - (void)onAfterReportCreated:(id)report isTerminating:(BOOL)isTerminating completion:(BGSCallback)completion { - completion(); + [_reports dispatchPhase:BGSFlutterReportPhaseAfter + report:report + isTerminating:isTerminating + onMainThread:[NSThread isMainThread] + completion:completion]; } @end diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterReportHandlerPipeline.h b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterReportHandlerPipeline.h new file mode 100644 index 0000000..930cde7 --- /dev/null +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterReportHandlerPipeline.h @@ -0,0 +1,161 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// The SDK's report-handler phases. +typedef NS_ENUM(NSInteger, BGSFlutterReportPhase) { + BGSFlutterReportPhaseBefore = 0, + BGSFlutterReportPhaseAfter = 1, +}; + +/// How long Dart gets, per path. Ours is the SDK's budget LESS A MARGIN of a third of it (at most +/// 10 s), never more (workbook 7.3.5). From the iOS SDK (`origin/nextgen`, +/// `BGSIssueReportingCoordinator.{h,m}`, `BGSContracts.h` `BGSReportHandler`): +/// +/// - LIVE (a report raised by the running app): handlers are called on the MAIN thread, one +/// after another, and each completion is waited for, up to `reportHandlerCallbackTimeout` +/// (30 s; iOS has no option for it) per handler and `reportHandlerChainTimeout` (60 s) for +/// the chain. The SDK starts that timer when it QUEUES the handler (`timedCallbackWithContinuation`, +/// before the hop to main); ours starts when the handler is entered, and the wait in between (main busy) +/// cannot be observed, so the margin absorbs it: a handler that waited for main longer than the margin +/// can still overrun the SDK's deadline, and an edit then is accepted here although the SDK has moved +/// on. Ours is 20 s of the 30 s. +/// - RECOVERY (next launch): handlers are called on a GCD worker, NEVER main, the caller +/// waits at most `boundedReportHandlerCap` (3 s) for the handler to RETURN, and the +/// completion is not waited for; one that arrives later re-persists the report, until the +/// bundle is assembled. Ours is 2 s, inside the 3 s. +/// - TERMINATING (`isTerminating = YES`): no producer exists on iOS today; if one appears +/// the process dies when the handler returns. Never reaches Dart. +/// +/// The SDK does not say which of the first two it is except by the thread (`BGSContracts.h`), +/// so that is the signal, used for the time budget and nothing else: never passed to Dart and +/// never read as "this is a recovered crash" (workbook 7.2.3). +FOUNDATION_EXPORT const int64_t BGSFlutterReportLiveDeadlineMs; +FOUNDATION_EXPORT const int64_t BGSFlutterReportRecoveryDeadlineMs; +/// The SDK's own limits the two above sit inside; repeated here so a test can compare them. +FOUNDATION_EXPORT const int64_t BGSFlutterReportSdkLiveHandlerTimeoutMs; +FOUNDATION_EXPORT const int64_t BGSFlutterReportSdkBoundedCapMs; + +/// The deadline for a dispatch on the main thread (live) or off it (recovery). +FOUNDATION_EXPORT int64_t BGSFlutterReportDeadlineMs(BOOL onMainThread); +/// The margin kept under an SDK budget: a third of it, at most 10 s. +FOUNDATION_EXPORT int64_t BGSFlutterReportMarginMs(int64_t sdkMs); +/// What is left of `budgetMs` once `elapsedMs` has passed; never negative. Zero means "already over": +/// complete the SDK's callback instead of asking Dart. +FOUNDATION_EXPORT int64_t BGSFlutterReportRemainingMs(int64_t budgetMs, int64_t elapsedMs); + +/// What reaches Dart for one delivery. +@interface BGSFlutterReportRequest : NSObject +/// Unique per delivery and opaque. +@property(nonatomic, copy, readonly) NSString *handle; +/// STABLE for the same report and phase (`before:` / `after:`), so Dart +/// can recognise a re-delivery. Unique (never shared) when the id could not be read. +@property(nonatomic, copy, readonly) NSString *deliveryId; +@property(nonatomic, readonly) BOOL after; +@property(nonatomic, copy, readonly) NSString *reportId; +@property(nonatomic, copy, readonly) NSString *type; +@property(nonatomic, readonly) int64_t deadlineMs; +@end + +/// Sends a delivery to one engine's Dart isolate. Runs on the platform (main) thread. +/// `unreachable` must run when Dart cannot be reached (an error reply). +typedef void (^BGSFlutterReportSink)(BGSFlutterReportRequest *request, + dispatch_block_t unreachable); +/// Arms a deadline; returns a token for the cancel block. +typedef id _Nullable (^BGSFlutterReportScheduler)(dispatch_block_t task, int64_t delayMs); +typedef void (^BGSFlutterReportCancel)(id token); +/// Runs a block on the platform (main) thread, never inline. +typedef void (^BGSFlutterReportPlatformPost)(dispatch_block_t task); +typedef void (^BGSFlutterReportLog)(NSString *line); +/// Milliseconds on a monotonic clock; injectable so a test controls time. +typedef int64_t (^BGSFlutterReportClock)(void); + +/// One change to a live report. Return NO with `*error` set to refuse the argument +/// (nothing may have changed). +typedef BOOL (^BGSFlutterReportEdit)(id report, NSError *_Nullable *_Nullable error); + +typedef NS_ENUM(NSInteger, BGSFlutterReportEditResult) { + BGSFlutterReportEditApplied = 0, + /// The handle is not live (the completion already ran, or it is unknown): nothing changed. + BGSFlutterReportEditNotLive, + /// The argument cannot be taken; nothing changed. `error` says why. + BGSFlutterReportEditRefused, +}; + +/// Routes the SDK's report-handler callbacks to Dart and back (workbook Part 7), through a +/// table of opaque handles. +/// +/// THE INVARIANT: every dispatch ends in exactly ONE run of the SDK's completion, whichever +/// gets there first: Dart completing the handle, its deadline, the engine detaching, a failure +/// to reach Dart, or an error in here (7.1.2). Nothing in here throws into the SDK. +/// +/// WHY NOTHING HERE EVER WAITS (the deadlock): on the LIVE path the SDK calls the handler on +/// the MAIN thread, and Dart's reply is delivered by the main thread. A handler that blocked +/// for the reply would deadlock on the very thread that must deliver it. So `dispatch...` +/// registers the handle, arms the deadline, posts the request to the main queue and RETURNS; +/// the SDK is already waiting for the completion on its own timer. The completion runs later, +/// on whichever thread finishes the handle. +/// +/// WHEN DART IS NOT ASKED: `isTerminating` (the process dies when this returns and the Dart +/// runtime may be gone; nothing is even read from the report), no registered Dart handler for +/// the phase (no waiting), or no attached Dart: the completion runs at once. +/// +/// THE WINDOW: the SDK's budget less a margin, measured from the handler's entry. What is observable +/// of the SDK's wait, the time between entry and the platform thread taking the request, is deducted +/// before Dart is asked (`BGSFlutterReportRequest.deadlineMs` is what is LEFT), and a window already +/// over is completed instead of offered. What is not observable (the SDK's own queueing before entry) +/// is the margin's job. +/// +/// CHECK AND APPLY ARE ONE STEP: an edit holds the handle's lock across "is it live" and the change, +/// and finishing flips `done` under the same lock, so an edit reported as applied landed BEFORE the +/// SDK's completion ran. The SDK's completion, Dart and the scheduler are never called with a lock held. +/// +/// A LATE EDIT: the proxy is live only until the completion runs (7.3.4). `-editHandle:` and +/// `-reportForHandle:` refuse from then on, and the Dart side raises an error for it. +@interface BGSFlutterReportHandlerPipeline : NSObject + ++ (instancetype)shared; + +/// Everything injected, for tests. +- (instancetype)initWithScheduler:(BGSFlutterReportScheduler)scheduler + cancel:(BGSFlutterReportCancel)cancel + platformPost:(BGSFlutterReportPlatformPost)platformPost + log:(BGSFlutterReportLog)log; +- (instancetype)initWithScheduler:(BGSFlutterReportScheduler)scheduler + cancel:(BGSFlutterReportCancel)cancel + platformPost:(BGSFlutterReportPlatformPost)platformPost + log:(BGSFlutterReportLog)log + clock:(BGSFlutterReportClock)clock NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +/// Attaches one engine's Dart end. It registers no phase until `-setPhasesForOwner:...`. +/// Returns the owner token the other calls take. +- (id)attachSink:(BGSFlutterReportSink)sink; +- (void)setPhasesForOwner:(id)owner before:(BOOL)before after:(BOOL)after; +/// A new Dart isolate is up on this engine (a hot restart): completes the deliveries handed to the +/// previous one (nothing in the new one knows them) and forgets which phases had a handler. +- (void)resetOwner:(id)owner; +/// The engine is gone: completes every handle emitted to it (nothing else knows them). +- (void)detachOwner:(id)owner; + +/// The SDK's entry point (`id` as `id`, so this header needs no SDK +/// import). `onMainThread` is `NSThread.isMainThread` at the call, read by the caller so a +/// test can choose. Returns promptly on every path, never blocks, never throws, and arranges for +/// `completion` to run exactly once. +- (void)dispatchPhase:(BGSFlutterReportPhase)phase + report:(nullable id)report + isTerminating:(BOOL)isTerminating + onMainThread:(BOOL)onMainThread + completion:(nullable dispatch_block_t)completion; + +/// Dart completed the handle. Returns whether THIS call ran the SDK's completion. +- (BOOL)completeHandle:(NSString *)handle; +/// The live report behind `handle`, or nil once it is completed or unknown. +- (nullable id)reportForHandle:(nullable NSString *)handle; +- (BGSFlutterReportEditResult)editHandle:(nullable NSString *)handle + edit:(BGSFlutterReportEdit)edit + error:(NSError *_Nullable *_Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterReportOps.h b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterReportOps.h new file mode 100644 index 0000000..6b8eea6 --- /dev/null +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterReportOps.h @@ -0,0 +1,62 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSErrorDomain const BGSFlutterReportOpsErrorDomain; + +/// What a read returns; plain, so the Swift side builds the Pigeon message. +@interface BGSFlutterReportSnapshot : NSObject +@property(nonatomic, copy) NSString *reportId; +@property(nonatomic, copy) NSString *type; +@property(nonatomic, copy, nullable) NSString *summary; +@property(nonatomic, copy, nullable) NSString *reportDescription; +@property(nonatomic, copy, nullable) NSString *email; +/// The SDK's internal value (1..5), or nil when the SDK reads 0 ("never set"), which must +/// not become a level. +@property(nonatomic, strong, nullable) NSNumber *severity; +@property(nonatomic, copy) NSArray *labels; +/// Strings, bools and finite numbers only: what both SDKs carry. +@property(nonatomic, copy) NSDictionary *attributes; +@end + +/// The operations the Dart report proxy performs on the SDK's `BGSReportContract` (workbook +/// 7.3), as plain Objective-C so every rule is XCTested without Flutter. Whether the report +/// is still live is not decided here (see `BGSFlutterReportHandlerPipeline`); what this owns is +/// what the SDK would otherwise take silently and get wrong: a severity outside 1..5 (the SDK +/// ignores it and keeps the old one, logging), an attribute value the backend cannot carry, a +/// label that is not a string. +/// +/// Attachments and screenshots are NOT here (Phase 6, path-first, workbook 7.3.3). +@interface BGSFlutterReportOps : NSObject + +/// The severity range the SDKs accept (`BugseeSeverityLow`..`BugseeSeverityBlocker`). Repeated +/// in Dart (`ReportSeverity`) and Java (`ReportOps`); `test/report_enum_parity_test.dart` +/// compares the copies, and the XCTest compares them to the SDK's enum. ++ (NSInteger)minSeverity; ++ (NSInteger)maxSeverity; + +/// `report.type` (`bug`, `crash`, `error`), or "" if unreadable. Never throws. ++ (NSString *)typeOfReport:(id)report; ++ (BGSFlutterReportSnapshot *)readReport:(id)report; + ++ (void)setSummary:(nullable NSString *)summary onReport:(id)report; ++ (void)setDescription:(nullable NSString *)description onReport:(id)report; ++ (void)setEmail:(nullable NSString *)email onReport:(id)report; ++ (void)clearLabelsOnReport:(id)report; ++ (void)removeAttribute:(NSString *)name onReport:(id)report; ++ (void)clearAttributesOnReport:(id)report; + ++ (BOOL)setSeverity:(int64_t)wire onReport:(id)report error:(NSError *_Nullable *_Nullable)error; ++ (BOOL)replaceLabels:(nullable id)labels onReport:(id)report error:(NSError *_Nullable *_Nullable)error; ++ (BOOL)addLabels:(nullable id)labels onReport:(id)report error:(NSError *_Nullable *_Nullable)error; ++ (BOOL)setAttribute:(nullable id)value + name:(nullable NSString *)name + onReport:(id)report + error:(NSError *_Nullable *_Nullable)error; +/// String, bool, or a finite number. A bool is an `NSNumber` of boolean type; any other +/// object (dictionary, array, null) is refused. ++ (BOOL)isAllowedAttributeValue:(nullable id)value; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterWrapper.h b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterWrapper.h index 7332ca9..e1ae174 100644 --- a/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterWrapper.h +++ b/ios/bugsee_flutter/Sources/bugsee_flutter_wrapper/include/BGSFlutterWrapper.h @@ -1,6 +1,7 @@ #import @class BGSFlutterLifecycleHub; +@class BGSFlutterReportHandlerPipeline; @class BGSFlutterSecureRectangleStore; NS_ASSUME_NONNULL_BEGIN @@ -9,9 +10,9 @@ NS_ASSUME_NONNULL_BEGIN /// identity lives. Declared without conforming to `BugseeWrapper` here so the /// header needs no SDK import; the conformance is declared in the .m. /// -/// Identity is real (workbook Part 3) and lifecycle events are forwarded (Part 4). -/// The rest is a deliberately inert stub until Phase 3 gives it behaviour. None of -/// it throws. +/// Identity is real (workbook Part 3), lifecycle events are forwarded (Part 4), report +/// handlers go through `BGSFlutterReportHandlerPipeline` (Part 7), and data requests are +/// answered with nil until Phase 5. None of it throws. /// /// This object is replaced mid-session when Dart refines the identity, so it holds /// no state that must survive: lifecycle events go to the process-wide @@ -42,6 +43,15 @@ NS_ASSUME_NONNULL_BEGIN hub:(BGSFlutterLifecycleHub *)hub secureRectangles:(BGSFlutterSecureRectangleStore *)secureRectangles; +/// As above with every process-wide collaborator supplied; for tests. ++ (instancetype)wrapperWithType:(nullable id)type + version:(nullable id)version + build:(nullable id)build + context:(nullable id)context + hub:(BGSFlutterLifecycleHub *)hub + secureRectangles:(BGSFlutterSecureRectangleStore *)secureRectangles + reports:(BGSFlutterReportHandlerPipeline *)reports; + @end /// Registers the wrapper at image load, independent of the Flutter engine. diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterFakeReport.h b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterFakeReport.h new file mode 100644 index 0000000..2268022 --- /dev/null +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterFakeReport.h @@ -0,0 +1,35 @@ +@import Foundation; +@import Bugsee; + +NS_ASSUME_NONNULL_BEGIN + +/// A `BGSReportContract` that holds the state the bridge reads and writes, and records every +/// MUTATING call by selector, in order, so a test can assert HOW the report was changed +/// (`replaceLabels:` rather than `clearLabels` + `addLabels:`), not only its end state. +/// +/// A method this fake does not model raises, so a test cannot pass by accident through a call +/// the bridge was never meant to make (attachments, screenshots: Phase 6). +/// +/// The severity setter behaves like the SDK's: it ignores anything outside 1..5 and keeps the +/// current value, silently. That is the refusal the wrapper's own validation exists to turn into +/// an error. +@interface BGSFlutterFakeReport : NSObject +@property(nonatomic, copy) NSString *fakeReportId; +@property(nonatomic, copy) NSString *fakeType; +@property(nonatomic, copy, nullable) NSString *fakeSummary; +@property(nonatomic, copy, nullable) NSString *fakeDescription; +@property(nonatomic, copy, nullable) NSString *fakeEmail; +/// 0 is the SDK's "never set". +@property(nonatomic) BugseeSeverityLevel fakeSeverity; +@property(nonatomic, readonly) NSMutableArray *fakeLabels; +@property(nonatomic, readonly) NSMutableDictionary *fakeAttributes; +/// Every mutating selector called, in order. +@property(nonatomic, readonly) NSMutableArray *mutations; +/// Run before / after the summary is stored; lets a test widen or observe the window. +@property(nonatomic, copy, nullable) dispatch_block_t beforeSetSummary; +@property(nonatomic, copy, nullable) dispatch_block_t onSummarySet; +/// How many times `reportId` / `type` were read. +@property(nonatomic) NSUInteger reads; +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterFakeReport.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterFakeReport.m new file mode 100644 index 0000000..9a75102 --- /dev/null +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterFakeReport.m @@ -0,0 +1,127 @@ +#import "BGSFlutterFakeReport.h" + +static void Unmodeled(SEL selector) { + [NSException raise:NSInternalInconsistencyException + format:@"BGSFlutterFakeReport does not model %@", NSStringFromSelector(selector)]; +} + +@implementation BGSFlutterFakeReport + +- (instancetype)init { + self = [super init]; + if (self) { + _fakeReportId = @"report-1"; + _fakeType = @"bug"; + _fakeLabels = [NSMutableArray array]; + _fakeAttributes = [NSMutableDictionary dictionary]; + _mutations = [NSMutableArray array]; + } + return self; +} + +- (void)record:(SEL)selector { + @synchronized(self) { + [_mutations addObject:NSStringFromSelector(selector)]; + } +} + +#pragma mark - Modelled + +- (NSString *)reportId { + _reads++; + return _fakeReportId; +} +- (NSString *)type { + _reads++; + return _fakeType; +} + +- (NSString *)summary { return _fakeSummary; } +- (void)setSummary:(NSString *)summary { + [self record:_cmd]; + if (_beforeSetSummary != nil) { + _beforeSetSummary(); + } + _fakeSummary = [summary copy]; + if (_onSummarySet != nil) { + _onSummarySet(); + } +} + +- (NSString *)reportDescription { return _fakeDescription; } +- (void)setReportDescription:(NSString *)reportDescription { + [self record:_cmd]; + _fakeDescription = [reportDescription copy]; +} + +- (NSString *)email { return _fakeEmail; } +- (void)setEmail:(NSString *)email { + [self record:_cmd]; + _fakeEmail = [email copy]; +} + +- (BugseeSeverityLevel)severity { return _fakeSeverity; } +- (void)setSeverity:(BugseeSeverityLevel)severity { + [self record:_cmd]; + // The SDK's own refusal: out of range keeps the current value, silently. + if (severity >= BugseeSeverityLow && severity <= BugseeSeverityBlocker) { + _fakeSeverity = severity; + } +} + +- (NSMutableArray *)labels { return [_fakeLabels mutableCopy]; } +- (void)addLabel:(NSString *)label { + [self record:_cmd]; + [_fakeLabels addObject:label]; +} +- (void)addLabels:(NSArray *)labels { + [self record:_cmd]; + [_fakeLabels addObjectsFromArray:labels]; +} +- (void)clearLabels { + [self record:_cmd]; + [_fakeLabels removeAllObjects]; +} +- (void)replaceLabels:(NSArray *)labels { + [self record:_cmd]; + [_fakeLabels setArray:labels]; +} + +- (NSMutableDictionary *)attributes { return [_fakeAttributes mutableCopy]; } +- (id)attributeForName:(NSString *)name { return _fakeAttributes[name]; } +- (void)setAttribute:(id)value forName:(NSString *)name { + [self record:_cmd]; + _fakeAttributes[name] = value; +} +- (void)removeAttributeForName:(NSString *)name { + [self record:_cmd]; + [_fakeAttributes removeObjectForKey:name]; +} +- (void)clearAllAttributes { + [self record:_cmd]; + [_fakeAttributes removeAllObjects]; +} + +#pragma mark - Not modelled + +- (NSMutableArray> *)attachments { Unmodeled(_cmd); return nil; } +- (NSArray *)screenshotDisplayIds { Unmodeled(_cmd); return nil; } +- (id)createAndAddAttachmentWithName:(NSString *)name { Unmodeled(_cmd); return nil; } +- (void)addAttachment:(id)attachment { Unmodeled(_cmd); } +- (id)addAttachmentWithFilePath:(NSString *)filePath name:(NSString *)name mimeType:(NSString *)mimeType move:(BOOL)move { Unmodeled(_cmd); return nil; } +- (id)addAttachmentWithData:(NSData *)data name:(NSString *)name mimeType:(NSString *)mimeType { Unmodeled(_cmd); return nil; } +- (void)clearAttachments { Unmodeled(_cmd); } +- (void)getScreenshotForDisplayId:(NSInteger)displayId completion:(void (^)(UIImage *))completion { Unmodeled(_cmd); } +- (void)enumerateScreenshots:(void (^)(NSInteger, UIImage *))callback blockAndWait:(BOOL)blockAndWait { Unmodeled(_cmd); } +- (void)releaseScreenshotBitmaps { Unmodeled(_cmd); } +- (void)setScreenshot:(UIImage *)screenshot displayId:(NSInteger)displayId annotated:(BOOL)annotated completion:(BGSCallback)completion { Unmodeled(_cmd); } +- (void)setScreenshot:(UIImage *)screenshot displayId:(NSInteger)displayId { Unmodeled(_cmd); } +- (void)setScreenshot:(UIImage *)screenshot displayId:(NSInteger)displayId completion:(BGSCallback)completion { Unmodeled(_cmd); } +- (void)setScreenshot:(UIImage *)screenshot displayId:(NSInteger)displayId annotated:(BOOL)annotated { Unmodeled(_cmd); } +- (NSDictionary *)screenshotAttributesForDisplayId:(NSInteger)displayId { Unmodeled(_cmd); return nil; } +- (BOOL)isScreenshotChanged { Unmodeled(_cmd); return NO; } +- (void)updateFromReport:(id)report { Unmodeled(_cmd); } +- (void)updateFromReport:(id)report completion:(BGSCallback)completion { Unmodeled(_cmd); } +- (void)exportVideoToPath:(NSString *)exportPath completion:(void (^)(NSArray *))completion { Unmodeled(_cmd); } + +@end diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterReportHandlerPipelineTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterReportHandlerPipelineTests.m new file mode 100644 index 0000000..53e1d18 --- /dev/null +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterReportHandlerPipelineTests.m @@ -0,0 +1,758 @@ +#import +#import + +#import "BGSFlutterFakeReport.h" +#import "BGSFlutterReportHandlerPipeline.h" + +/// The one property everything here protects: the SDK's completion runs exactly once for every +/// dispatch, whichever of Dart, the deadline, a detach, a failure or an error gets there first +/// (workbook 7.1.2). Running it twice advances the SDK's chain twice; never running it stalls the +/// report until the SDK's own timeout. + +@interface BGSManualTask : NSObject +@property(nonatomic, copy) dispatch_block_t block; +@property(nonatomic) int64_t delayMs; +@property(nonatomic) BOOL cancelled; +@end +@implementation BGSManualTask +@end + +@interface BGSFlutterReportHandlerPipelineTests : XCTestCase +@end + +@implementation BGSFlutterReportHandlerPipelineTests { + BGSFlutterReportHandlerPipeline *_pipeline; + NSMutableArray *_tasks; + NSMutableArray *_platformQueue; + NSMutableArray *_log; + NSMutableArray *_requests; + NSMutableArray *_unreachable; + BOOL _sinkThrows; + BOOL _schedulerThrows; + id _owner; + BGSFlutterFakeReport *_report; + NSUInteger _completions; +} + +- (void)setUp { + _tasks = [NSMutableArray array]; + _platformQueue = [NSMutableArray array]; + _log = [NSMutableArray array]; + _requests = [NSMutableArray array]; + _unreachable = [NSMutableArray array]; + _sinkThrows = NO; + _schedulerThrows = NO; + _completions = 0; + _report = [[BGSFlutterFakeReport alloc] init]; + __weak typeof(self) weakSelf = self; + _pipeline = [[BGSFlutterReportHandlerPipeline alloc] + initWithScheduler:^id(dispatch_block_t task, int64_t delayMs) { + typeof(self) strongSelf = weakSelf; + if (strongSelf->_schedulerThrows) { + [NSException raise:NSInternalInconsistencyException format:@"scheduler is gone"]; + } + BGSManualTask *t = [[BGSManualTask alloc] init]; + t.block = task; + t.delayMs = delayMs; + @synchronized(strongSelf->_tasks) { + [strongSelf->_tasks addObject:t]; + } + return t; + } + cancel:^(id token) { + ((BGSManualTask *)token).cancelled = YES; + } + platformPost:^(dispatch_block_t task) { + typeof(self) strongSelf = weakSelf; + @synchronized(strongSelf->_platformQueue) { + [strongSelf->_platformQueue addObject:[task copy]]; + } + } + log:^(NSString *line) { + typeof(self) strongSelf = weakSelf; + @synchronized(strongSelf->_log) { + [strongSelf->_log addObject:line]; + } + }]; + _owner = [_pipeline attachSink:^(BGSFlutterReportRequest *request, dispatch_block_t unreachable) { + typeof(self) strongSelf = weakSelf; + if (strongSelf->_sinkThrows) { + [NSException raise:NSInternalInconsistencyException format:@"bridge is dead"]; + } + [strongSelf->_requests addObject:request]; + [strongSelf->_unreachable addObject:[unreachable copy]]; + }]; + [_pipeline setPhasesForOwner:_owner before:YES after:YES]; +} + +- (void)drainPlatform { + while (YES) { + dispatch_block_t next = nil; + @synchronized(_platformQueue) { + if (_platformQueue.count == 0) { + return; + } + next = _platformQueue.firstObject; + [_platformQueue removeObjectAtIndex:0]; + } + next(); + } +} + +- (void)dispatch:(BGSFlutterReportPhase)phase terminating:(BOOL)terminating main:(BOOL)main { + [_pipeline dispatchPhase:phase + report:_report + isTerminating:terminating + onMainThread:main + completion:^{ + @synchronized(self) { + self->_completions++; + } + }]; +} + +- (BGSFlutterReportRequest *)delivered { + [self drainPlatform]; + XCTAssertEqual(_requests.count, 1u); + return _requests.firstObject; +} + +- (void)fireDueEvenIfCancelled:(BOOL)evenIfCancelled { + NSArray *copy; + @synchronized(_tasks) { + copy = [_tasks copy]; + } + for (BGSManualTask *t in copy) { + if (evenIfCancelled || !t.cancelled) { + t.block(); + } + } +} + +#pragma mark - exactly once, on every path + +- (void)testDartCompletingRunsTheCompletionOnce { + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + XCTAssertEqual(_completions, 0u, @"the SDK is waiting for us; nothing has completed yet"); + BGSFlutterReportRequest *request = [self delivered]; + XCTAssertTrue([_pipeline completeHandle:request.handle]); + XCTAssertEqual(_completions, 1u); + XCTAssertFalse([_pipeline completeHandle:request.handle]); + [self fireDueEvenIfCancelled:YES]; + [_pipeline detachOwner:_owner]; + XCTAssertEqual(_completions, 1u); +} + +- (void)testTheDeadlineCompletesWhenDartNeverReplies { + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + [self delivered]; + XCTAssertEqual(_completions, 0u); + [self fireDueEvenIfCancelled:NO]; + XCTAssertEqual(_completions, 1u); + [self fireDueEvenIfCancelled:YES]; + XCTAssertEqual(_completions, 1u); +} + +- (void)testALateReplyAfterTheDeadlineDoesNothing { + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + BGSFlutterReportRequest *request = [self delivered]; + [self fireDueEvenIfCancelled:NO]; + XCTAssertEqual(_completions, 1u); + XCTAssertFalse([_pipeline completeHandle:request.handle]); + XCTAssertEqual(_completions, 1u); +} + +- (void)testAnUnreachableDartCompletesAtOnce { + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [self delivered]; + _unreachable.firstObject(); + XCTAssertEqual(_completions, 1u); + _unreachable.firstObject(); + [self fireDueEvenIfCancelled:YES]; + XCTAssertEqual(_completions, 1u); +} + +- (void)testASinkThatThrowsCompletesAtOnce { + _sinkThrows = YES; + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [self drainPlatform]; + XCTAssertEqual(_completions, 1u); +} + +- (void)testASchedulerThatThrowsCompletesAtOnceAndLeavesNoLiveProxy { + _schedulerThrows = YES; + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + XCTAssertEqual(_completions, 1u); + XCTAssertNil([_pipeline reportForHandle:@"rh-1"]); +} + +- (void)testDetachingTheEngineCompletesWhatWasHandedToIt { + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [self delivered]; + [_pipeline detachOwner:_owner]; + XCTAssertEqual(_completions, 1u); + [self fireDueEvenIfCancelled:YES]; + XCTAssertEqual(_completions, 1u); +} + +- (void)testADetachBeforeTheRequestReachedDartSkipsDelivery { + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [_pipeline detachOwner:_owner]; + [self drainPlatform]; + XCTAssertEqual(_completions, 1u); + XCTAssertEqual(_requests.count, 0u, @"Dart is never asked about a handle that is already over"); +} + +- (void)testANullReportAndANullCompletionNeverThrow { + __block NSUInteger n = 0; + [_pipeline dispatchPhase:BGSFlutterReportPhaseBefore + report:nil + isTerminating:NO + onMainThread:YES + completion:^{ + n++; + }]; + XCTAssertEqual(n, 1u); + [_pipeline dispatchPhase:BGSFlutterReportPhaseBefore report:_report isTerminating:NO onMainThread:YES completion:nil]; + [_pipeline dispatchPhase:BGSFlutterReportPhaseAfter report:_report isTerminating:YES onMainThread:NO completion:nil]; +} + +- (void)testAThrowingSdkCompletionNeverEscapes { + [_pipeline dispatchPhase:BGSFlutterReportPhaseBefore + report:_report + isTerminating:YES + onMainThread:YES + completion:^{ + [NSException raise:NSInternalInconsistencyException format:@"sdk fault"]; + }]; + [_pipeline dispatchPhase:BGSFlutterReportPhaseBefore + report:_report + isTerminating:NO + onMainThread:YES + completion:^{ + [NSException raise:NSInternalInconsistencyException format:@"sdk fault"]; + }]; + BGSFlutterReportRequest *request = [self delivered]; + [_pipeline completeHandle:request.handle]; +} + +#pragma mark - when Dart is not asked + +- (void)testTerminatingNeverReachesDartNorTouchesTheReport { + [self dispatch:BGSFlutterReportPhaseBefore terminating:YES main:YES]; + [self dispatch:BGSFlutterReportPhaseAfter terminating:YES main:NO]; + XCTAssertEqual(_completions, 2u); + XCTAssertEqual(_tasks.count, 0u); + XCTAssertEqual(_platformQueue.count, 0u); + XCTAssertEqual(_requests.count, 0u); + XCTAssertEqual(_report.reads, 0u, @"not even the id was read on a dying process"); +} + +- (void)testNoRegisteredHandlerCompletesAtOnceWithoutWaiting { + [_pipeline setPhasesForOwner:_owner before:NO after:NO]; + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:NO]; + XCTAssertEqual(_completions, 2u); + XCTAssertEqual(_tasks.count, 0u); + XCTAssertEqual(_platformQueue.count, 0u); +} + +- (void)testOnlyTheRegisteredPhaseReachesDart { + [_pipeline setPhasesForOwner:_owner before:NO after:YES]; + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + XCTAssertEqual(_completions, 1u); + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + XCTAssertEqual(_completions, 1u); + XCTAssertTrue([self delivered].after); +} + +- (void)testNoAttachedDartCompletesAtOnce { + [_pipeline detachOwner:_owner]; + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + XCTAssertEqual(_completions, 1u); +} + +- (void)testAReportThatLooksLikeARecoveredCrashIsNotTreatedAsOne { + // 7.2.3: type + isTerminating say nothing about recovery. A crash-typed report is delivered + // like any other, with its type passed through verbatim. + _report.fakeType = @"crash"; + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:NO]; + XCTAssertEqualObjects([self delivered].type, @"crash"); + XCTAssertEqual(_completions, 0u); +} + +#pragma mark - the deadline is inside the SDK's + +- (void)testTheLiveDeadlineIsInsideTheSdksThirtySeconds { + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + XCTAssertEqual(_tasks.count, 1u); + XCTAssertEqual(_tasks.firstObject.delayMs, 20000); + XCTAssertLessThan(_tasks.firstObject.delayMs, BGSFlutterReportSdkLiveHandlerTimeoutMs); + XCTAssertLessThanOrEqual([self delivered].deadlineMs, 20000); +} + +- (void)testTheRecoveryDeadlineIsInsideTheSdksBoundedCap { + // Off the main thread: the SDK waits 3 s for the handler to return and does not wait for the + // completion; a late completion re-persists the report. Ours is inside the cap. + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:NO]; + XCTAssertEqual(_tasks.firstObject.delayMs, 2000); + XCTAssertLessThan(_tasks.firstObject.delayMs, BGSFlutterReportSdkBoundedCapMs); + XCTAssertEqual(BGSFlutterReportDeadlineMs(YES), BGSFlutterReportLiveDeadlineMs); + XCTAssertEqual(BGSFlutterReportDeadlineMs(NO), BGSFlutterReportRecoveryDeadlineMs); +} + +- (void)testALateCompletionAfterTheRecoveryCapStillRunsTheCompletionOnce { + // The SDK re-persists the report when a late completion arrives. Ours must run it exactly + // once however late: here the deadline came first and Dart's reply after. + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:NO]; + BGSFlutterReportRequest *request = [self delivered]; + [self fireDueEvenIfCancelled:NO]; + XCTAssertEqual(_completions, 1u); + XCTAssertFalse([_pipeline completeHandle:request.handle]); + XCTAssertEqual(_completions, 1u); + XCTAssertNil([_pipeline reportForHandle:request.handle]); +} + +#pragma mark - the window is measured, and what is observable of the SDK's wait is deducted + +- (BGSFlutterReportHandlerPipeline *)pipelineWithClock:(int64_t *)clock owner:(id *)owner { + __weak typeof(self) weakSelf = self; + BGSFlutterReportHandlerPipeline *p = [[BGSFlutterReportHandlerPipeline alloc] + initWithScheduler:^id(dispatch_block_t task, int64_t delayMs) { + typeof(self) strongSelf = weakSelf; + BGSManualTask *t = [[BGSManualTask alloc] init]; + t.block = task; + t.delayMs = delayMs; + @synchronized(strongSelf->_tasks) { + [strongSelf->_tasks addObject:t]; + } + return t; + } + cancel:^(id token) { + ((BGSManualTask *)token).cancelled = YES; + } + platformPost:^(dispatch_block_t task) { + typeof(self) strongSelf = weakSelf; + @synchronized(strongSelf->_platformQueue) { + [strongSelf->_platformQueue addObject:[task copy]]; + } + } + log:^(NSString *line) { + typeof(self) strongSelf = weakSelf; + @synchronized(strongSelf->_log) { + [strongSelf->_log addObject:line]; + } + } + clock:^int64_t { + return *clock; + }]; + *owner = [p attachSink:^(BGSFlutterReportRequest *request, dispatch_block_t unreachable) { + typeof(self) strongSelf = weakSelf; + [strongSelf->_requests addObject:request]; + }]; + [p setPhasesForOwner:*owner before:YES after:YES]; + return p; +} + +- (void)testDartIsToldWhatIsLeftOfTheWindowNotTheWholeOfIt { + int64_t clock = 0; + id owner = nil; + BGSFlutterReportHandlerPipeline *p = [self pipelineWithClock:&clock owner:&owner]; + [p dispatchPhase:BGSFlutterReportPhaseBefore report:_report isTerminating:NO onMainThread:YES completion:^{ self->_completions++; }]; + clock = 7000; // main was busy for 7 s + [self drainPlatform]; + XCTAssertEqual(_requests.count, 1u); + XCTAssertEqual(_requests.firstObject.deadlineMs, 13000); + XCTAssertTrue([[_log componentsJoinedByString:@"\n"] containsString:@"delivery gap=7000"], @"the gap is logged"); +} + +- (void)testAWindowThatIsAlreadyOverIsNotOfferedToDart { + int64_t clock = 0; + id owner = nil; + BGSFlutterReportHandlerPipeline *p = [self pipelineWithClock:&clock owner:&owner]; + [p dispatchPhase:BGSFlutterReportPhaseBefore report:_report isTerminating:NO onMainThread:YES completion:^{ self->_completions++; }]; + clock = 20000; + [self drainPlatform]; + XCTAssertEqual(_completions, 1u, @"completed, not delivered"); + XCTAssertEqual(_requests.count, 0u); + XCTAssertNil([p reportForHandle:@"rh-1"]); + XCTAssertTrue([[_log componentsJoinedByString:@"\n"] containsString:@"completed by=expired"]); +} + +- (void)testTheBudgetHelpers { + XCTAssertEqual(BGSFlutterReportMarginMs(30000), 10000); + XCTAssertEqual(BGSFlutterReportMarginMs(120000), 10000); + XCTAssertEqual(BGSFlutterReportMarginMs(3000), 1000); + XCTAssertEqual(BGSFlutterReportLiveDeadlineMs, BGSFlutterReportSdkLiveHandlerTimeoutMs - BGSFlutterReportMarginMs(BGSFlutterReportSdkLiveHandlerTimeoutMs)); + XCTAssertEqual(BGSFlutterReportRecoveryDeadlineMs, BGSFlutterReportSdkBoundedCapMs - BGSFlutterReportMarginMs(BGSFlutterReportSdkBoundedCapMs)); + XCTAssertEqual(BGSFlutterReportRemainingMs(20000, 5000), 15000); + XCTAssertEqual(BGSFlutterReportRemainingMs(20000, 99000), 0); + XCTAssertEqual(BGSFlutterReportRemainingMs(20000, -5), 20000); +} + +#pragma mark - hot restart + +- (void)testANewIsolateCompletesWhatWasHandedToThePreviousOneAndForgetsItsPhases { + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [self delivered]; + XCTAssertEqual(_completions, 0u); + [_pipeline resetOwner:_owner]; + XCTAssertEqual(_completions, 1u, @"completed now, not at its deadline"); + [self fireDueEvenIfCancelled:YES]; + XCTAssertEqual(_completions, 1u); + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + XCTAssertEqual(_completions, 2u, @"no phase is registered until the new isolate says so"); + XCTAssertTrue([[_log componentsJoinedByString:@"\n"] containsString:@"completed by=reset"]); +} + +- (void)testResettingOneEngineLeavesTheOthersHandlesAlone { + id owner2 = [_pipeline attachSink:^(BGSFlutterReportRequest *request, dispatch_block_t unreachable) { + }]; + [_pipeline setPhasesForOwner:owner2 before:YES after:YES]; + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [self drainPlatform]; + [_pipeline resetOwner:_owner]; + XCTAssertEqual(_completions, 0u); + [_pipeline resetOwner:owner2]; + XCTAssertEqual(_completions, 1u); +} + +#pragma mark - check-and-apply is one step + +/// An edit reported as applied must have landed BEFORE the completion ran. The interleaving is +/// forced, not hoped for: the edit signals that it is INSIDE the check-and-apply, and only then is +/// the deadline fired on another thread; the edit keeps the report busy until the deadline thread +/// has started (and then a little longer, or until the completion has run, which is what a broken +/// lock lets happen). With the lock the completion waits for the edit, so the edit always applies +/// and lands first; without it the completion runs mid-edit and the summary lands after it. Nothing +/// here depends on how fast a thread is scheduled: a starved runner only makes the broken variant +/// less likely to be caught, never the correct one fail. The semaphores' timeouts are ceilings. +/// (The summary is `nonatomic, copy`, so the old check-then-apply race was also a data race.) +- (void)testAnEditReportedAppliedNeverLandsAfterTheCompletion { + const int64_t kCeiling = 60 * NSEC_PER_SEC; + for (NSUInteger i = 0; i < 300; i++) { + __block dispatch_block_t deadline = nil; + __block BGSFlutterReportRequest *request = nil; + NSObject *lock = [[NSObject alloc] init]; + __block NSUInteger completed = 0; + __block NSUInteger landedAfter = 0; + dispatch_semaphore_t inside = dispatch_semaphore_create(0); + dispatch_semaphore_t deadlineStarted = dispatch_semaphore_create(0); + BGSFlutterFakeReport *report = [[BGSFlutterFakeReport alloc] init]; + report.beforeSetSummary = ^{ + dispatch_semaphore_signal(inside); + XCTAssertEqual(dispatch_semaphore_wait(deadlineStarted, dispatch_time(DISPATCH_TIME_NOW, kCeiling)), 0); + // Give a completion that is not held back by the edit the chance to run first. + CFAbsoluteTime until = CFAbsoluteTimeGetCurrent() + 0.002; + while (CFAbsoluteTimeGetCurrent() < until) { + @synchronized(lock) { + if (completed > 0) { + break; + } + } + sched_yield(); + } + }; + report.onSummarySet = ^{ + @synchronized(lock) { + if (completed > 0) { + landedAfter++; + } + } + }; + BGSFlutterReportHandlerPipeline *p = [[BGSFlutterReportHandlerPipeline alloc] + initWithScheduler:^id(dispatch_block_t task, int64_t delayMs) { + deadline = [task copy]; + return @"t"; + } + cancel:^(id token) { + } + platformPost:^(dispatch_block_t task) { + task(); + } + log:^(NSString *line) { + }]; + id owner = [p attachSink:^(BGSFlutterReportRequest *r, dispatch_block_t u) { + request = r; + }]; + [p setPhasesForOwner:owner before:YES after:YES]; + [p dispatchPhase:BGSFlutterReportPhaseBefore + report:report + isTerminating:NO + onMainThread:YES + completion:^{ + @synchronized(lock) { + completed++; + } + }]; + __block BGSFlutterReportEditResult result = BGSFlutterReportEditNotLive; + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + dispatch_group_async(group, q, ^{ + result = [p editHandle:request.handle + edit:^BOOL(id r, NSError **e) { + ((id)r).summary = @"x"; + return YES; + } + error:NULL]; + }); + dispatch_group_async(group, q, ^{ + // Only once the edit is inside its check-and-apply does the deadline fire. + XCTAssertEqual(dispatch_semaphore_wait(inside, dispatch_time(DISPATCH_TIME_NOW, kCeiling)), 0); + dispatch_semaphore_signal(deadlineStarted); + deadline(); + }); + XCTAssertEqual(dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, kCeiling)), 0); + XCTAssertEqual(result, BGSFlutterReportEditApplied, @"round %lu: the edit was inside first", (unsigned long)i); + XCTAssertEqual(landedAfter, 0u, @"round %lu", (unsigned long)i); + XCTAssertEqual(completed, 1u, @"round %lu", (unsigned long)i); + } +} + +#pragma mark - the proxy is live only until the completion runs (7.3.4) + +- (void)testAMutationBeforeCompletionAppliesAndAfterItIsIgnored { + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + BGSFlutterReportRequest *request = [self delivered]; + NSError *error = nil; + XCTAssertEqual([_pipeline editHandle:request.handle + edit:^BOOL(id report, NSError **e) { + ((id)report).summary = @"early"; + return YES; + } + error:&error], + BGSFlutterReportEditApplied); + XCTAssertEqualObjects(_report.fakeSummary, @"early"); + + [_pipeline completeHandle:request.handle]; + XCTAssertEqual([_pipeline editHandle:request.handle + edit:^BOOL(id report, NSError **e) { + ((id)report).summary = @"late"; + return YES; + } + error:&error], + BGSFlutterReportEditNotLive); + XCTAssertEqualObjects(_report.fakeSummary, @"early"); + XCTAssertNil([_pipeline reportForHandle:request.handle]); +} + +- (void)testAMutationAfterTheDeadlineIsIgnoredToo { + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + BGSFlutterReportRequest *request = [self delivered]; + [self fireDueEvenIfCancelled:NO]; + XCTAssertEqual([_pipeline editHandle:request.handle + edit:^BOOL(id report, NSError **e) { + ((id)report).summary = @"late"; + return YES; + } + error:NULL], + BGSFlutterReportEditNotLive); + XCTAssertNil(_report.fakeSummary); +} + +- (void)testAnUnknownHandleIsRefusedNotAnError { + XCTAssertEqual([_pipeline editHandle:@"rh-999" edit:^BOOL(id r, NSError **e) { return YES; } error:NULL], + BGSFlutterReportEditNotLive); + XCTAssertEqual([_pipeline editHandle:nil edit:^BOOL(id r, NSError **e) { return YES; } error:NULL], + BGSFlutterReportEditNotLive); +} + +#pragma mark - identity of a delivery + +- (void)testADeliveryIdIsStableForOneReportAndPhaseAndTheHandleIsNot { + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [self drainPlatform]; + XCTAssertEqual(_requests.count, 3u); + XCTAssertEqualObjects(_requests[0].deliveryId, @"after:report-1"); + XCTAssertEqualObjects(_requests[0].deliveryId, _requests[1].deliveryId); + XCTAssertNotEqualObjects(_requests[0].handle, _requests[1].handle); + XCTAssertEqualObjects(_requests[2].deliveryId, @"before:report-1"); + XCTAssertTrue(_requests[0].after); + XCTAssertFalse(_requests[2].after); +} + +- (void)testAnUnreadableIdIsNeverShared { + _report.fakeReportId = @""; + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + [self drainPlatform]; + XCTAssertNotEqualObjects(_requests[0].deliveryId, _requests[1].deliveryId); +} + +#pragma mark - several engines + +- (void)testTheMostRecentEngineWithAHandlerGetsTheDeliveryAndDetachingOneLeavesTheOther { + NSMutableArray *second = [NSMutableArray array]; + id owner2 = [_pipeline attachSink:^(BGSFlutterReportRequest *request, dispatch_block_t unreachable) { + [second addObject:request]; + }]; + [_pipeline setPhasesForOwner:owner2 before:YES after:NO]; + [self dispatch:BGSFlutterReportPhaseBefore terminating:NO main:YES]; + [self drainPlatform]; + XCTAssertEqual(second.count, 1u); + XCTAssertEqual(_requests.count, 0u); + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + [self drainPlatform]; + XCTAssertEqual(_requests.count, 1u, @"the second engine has no after handler; the first does"); + [_pipeline detachOwner:_owner]; + XCTAssertEqual(_completions, 1u, @"only the first engine's handle was completed by its detach"); + [_pipeline detachOwner:owner2]; + XCTAssertEqual(_completions, 2u); +} + +#pragma mark - threads: the deadlock this design exists to avoid + +/// The live path calls the handler ON THE MAIN THREAD, and Dart's reply is delivered by the main +/// thread. A dispatch that waited for the reply would block the very thread that must deliver it. +/// Here the dispatch runs on main (XCTest's own thread) with the real main-queue hop: it must +/// return before the completion has run, and the reply, queued on main behind it, must then +/// complete it. A blocking implementation never comes back from the dispatch. +- (void)testDispatchingOnTheMainThreadNeverWaitsForDart { + XCTAssertTrue([NSThread isMainThread]); + __block NSUInteger completions = 0; + BGSFlutterReportHandlerPipeline *pipeline = [[BGSFlutterReportHandlerPipeline alloc] + initWithScheduler:^id(dispatch_block_t task, int64_t delayMs) { + return @"timer"; + } + cancel:^(id token) { + } + platformPost:^(dispatch_block_t task) { + dispatch_async(dispatch_get_main_queue(), task); + } + log:^(NSString *line) { + }]; + XCTestExpectation *completed = [self expectationWithDescription:@"completed by Dart's reply"]; + id owner = [pipeline attachSink:^(BGSFlutterReportRequest *request, dispatch_block_t unreachable) { + // "Dart": answers on the main queue, as the real reply is delivered there. + dispatch_async(dispatch_get_main_queue(), ^{ + [pipeline completeHandle:request.handle]; + }); + }]; + [pipeline setPhasesForOwner:owner before:YES after:YES]; + + CFAbsoluteTime start = CFAbsoluteTimeGetCurrent(); + [pipeline dispatchPhase:BGSFlutterReportPhaseBefore + report:_report + isTerminating:NO + onMainThread:[NSThread isMainThread] + completion:^{ + completions++; + [completed fulfill]; + }]; + // A wait that gave up after a timeout would still "return", so time it. + XCTAssertLessThan(CFAbsoluteTimeGetCurrent() - start, 5.0, @"dispatch returned at once (a ceiling; a blocking wait would last the whole window)"); + XCTAssertEqual(completions, 0u, @"dispatch returned before the completion ran"); + [self waitForExpectations:@[ completed ] timeout:60]; + XCTAssertEqual(completions, 1u); +} + +/// A recovery dispatch comes from a GCD worker while the main thread is busy: it must come back +/// without needing main, and the deadline still ends it. +- (void)testABlockedMainThreadDoesNotBlockARecoveryCaller { + __block NSUInteger completions = 0; + NSObject *lock = [[NSObject alloc] init]; + NSMutableArray *deadlines = [NSMutableArray array]; + BGSFlutterReportHandlerPipeline *pipeline = [[BGSFlutterReportHandlerPipeline alloc] + initWithScheduler:^id(dispatch_block_t task, int64_t delayMs) { + @synchronized(lock) { + [deadlines addObject:[task copy]]; + } + return @"timer"; + } + cancel:^(id token) { + } + platformPost:^(dispatch_block_t task) { + dispatch_async(dispatch_get_main_queue(), task); // main is blocked below + } + log:^(NSString *line) { + }]; + id owner = [pipeline attachSink:^(BGSFlutterReportRequest *request, dispatch_block_t unreachable) { + }]; + [pipeline setPhasesForOwner:owner before:YES after:YES]; + + dispatch_semaphore_t returned = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + [pipeline dispatchPhase:BGSFlutterReportPhaseAfter + report:self->_report + isTerminating:NO + onMainThread:NO + completion:^{ + @synchronized(lock) { + completions++; + } + }]; + dispatch_semaphore_signal(returned); + }); + // Main is held here, NOT servicing its queue (a semaphore, not a run loop), until the worker + // has come back: the worker's dispatch must not have needed main. + XCTAssertEqual(dispatch_semaphore_wait(returned, dispatch_time(DISPATCH_TIME_NOW, 60 * NSEC_PER_SEC)), 0, + @"the worker's dispatch returned while main was blocked"); + @synchronized(lock) { + XCTAssertEqual(completions, 0u); + XCTAssertEqual(deadlines.count, 1u); + deadlines.firstObject(); + XCTAssertEqual(completions, 1u); + } +} + +#pragma mark - concurrency + +- (void)testEveryRaceForTheCompletionRunsItOnce { + for (NSUInteger round = 0; round < 200; round++) { + __block NSUInteger ran = 0; + NSObject *lock = [[NSObject alloc] init]; + __block dispatch_block_t deadline = nil; + __block BGSFlutterReportRequest *request = nil; + __block dispatch_block_t unreachable = nil; + BGSFlutterReportHandlerPipeline *pipeline = [[BGSFlutterReportHandlerPipeline alloc] + initWithScheduler:^id(dispatch_block_t task, int64_t delayMs) { + deadline = [task copy]; + return @"t"; + } + cancel:^(id token) { + } + platformPost:^(dispatch_block_t task) { + task(); + } + log:^(NSString *line) { + }]; + id owner = [pipeline attachSink:^(BGSFlutterReportRequest *r, dispatch_block_t u) { + request = r; + unreachable = [u copy]; + }]; + [pipeline setPhasesForOwner:owner before:YES after:YES]; + [pipeline dispatchPhase:BGSFlutterReportPhaseBefore + report:_report + isTerminating:NO + onMainThread:YES + completion:^{ + @synchronized(lock) { + ran++; + } + }]; + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + dispatch_group_async(group, q, ^{ [pipeline completeHandle:request.handle]; }); + dispatch_group_async(group, q, ^{ deadline(); }); + dispatch_group_async(group, q, ^{ [pipeline detachOwner:owner]; }); + dispatch_group_async(group, q, ^{ unreachable(); }); + XCTAssertEqual(dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 60 * NSEC_PER_SEC)), 0); + XCTAssertEqual(ran, 1u, @"round %lu", (unsigned long)round); + } +} + +#pragma mark - the outcome is logged + +- (void)testEveryOutcomeIsOneLogLine { + [self dispatch:BGSFlutterReportPhaseBefore terminating:YES main:YES]; + XCTAssertEqual(_log.count, 1u); + XCTAssertTrue([_log[0] containsString:@"completed by=terminating"]); + [self dispatch:BGSFlutterReportPhaseAfter terminating:NO main:YES]; + BGSFlutterReportRequest *request = [self delivered]; + [_pipeline completeHandle:request.handle]; + XCTAssertTrue([_log.lastObject containsString:@"completed by=dart"]); +} + +@end diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterReportOpsTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterReportOpsTests.m new file mode 100644 index 0000000..80d28a4 --- /dev/null +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterReportOpsTests.m @@ -0,0 +1,166 @@ +#import + +#import "BGSFlutterFakeReport.h" +#import "BGSFlutterReportOps.h" + +@interface BGSFlutterReportOpsTests : XCTestCase +@end + +@implementation BGSFlutterReportOpsTests { + BGSFlutterFakeReport *_report; +} + +- (void)setUp { + _report = [[BGSFlutterFakeReport alloc] init]; +} + +- (void)assertRefused:(BOOL (^)(NSError **error))change { + NSError *error = nil; + XCTAssertFalse(change(&error)); + XCTAssertNotNil(error); + XCTAssertEqualObjects(error.domain, BGSFlutterReportOpsErrorDomain); + XCTAssertEqualObjects(_report.mutations, (@[]), @"a refused change touches nothing"); +} + +- (void)testSeverityCrossesByValue { + for (int64_t wire = 1; wire <= 5; wire++) { + NSError *error = nil; + XCTAssertTrue([BGSFlutterReportOps setSeverity:wire onReport:_report error:&error]); + XCTAssertEqual((int64_t)_report.fakeSeverity, wire); + } + [BGSFlutterReportOps setSeverity:4 onReport:_report error:NULL]; + XCTAssertEqual(_report.fakeSeverity, BugseeSeverityCritical); +} + +/// The SDK's setter ignores these and keeps the old value, silently: success from its point of +/// view, which would look like an applied edit from ours. +- (void)testSeverityOutsideTheSdkRangeIsRefusedNotSilentlyIgnored { + _report.fakeSeverity = BugseeSeverityHigh; + for (NSNumber *wire in @[ @0, @-1, @6, @100, @(INT64_MAX), @(INT64_MIN) ]) { + [self assertRefused:^BOOL(NSError **error) { + return [BGSFlutterReportOps setSeverity:wire.longLongValue onReport:self->_report error:error]; + }]; + } + XCTAssertEqual(_report.fakeSeverity, BugseeSeverityHigh); +} + +/// Compared with the SDK's own enum (the shipped header of the pinned version), not a copy. +- (void)testSeverityRangeIsExactlyTheSdksValues { + XCTAssertEqual(BugseeSeverityLow, 1); + XCTAssertEqual(BugseeSeverityMedium, 2); + XCTAssertEqual(BugseeSeverityHigh, 3); + XCTAssertEqual(BugseeSeverityCritical, 4); + XCTAssertEqual(BugseeSeverityBlocker, 5); + XCTAssertEqual([BGSFlutterReportOps minSeverity], (NSInteger)BugseeSeverityLow); + XCTAssertEqual([BGSFlutterReportOps maxSeverity], (NSInteger)BugseeSeverityBlocker); +} + +/// The fixture is extracted from the SDKs' SOURCE (`scripts/extract_report_enums.dart`); this +/// compares it with the enum the pinned framework actually compiles, so a stale fixture or a +/// renumbered SDK is seen. By value, never by ordinal or name (workbook 7.4.2, 7.4.3). +- (void)testTheFixtureIsTheSdksRealEnum { + NSString *path = [[[@(__FILE__) stringByDeletingLastPathComponent] + stringByAppendingPathComponent:@"../../../../test/fixtures/report_enums.json"] + stringByStandardizingPath]; + NSData *data = [NSData dataWithContentsOfFile:path]; + XCTAssertNotNil(data, @"%@", path); + NSDictionary *fixture = [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; + NSDictionary *severity = fixture[@"ios"][@"BugseeSeverityLevel"]; + XCTAssertEqualObjects(severity, (@{ + @"BugseeSeverityLow" : @(BugseeSeverityLow), + @"BugseeSeverityMedium" : @(BugseeSeverityMedium), + @"BugseeSeverityHigh" : @(BugseeSeverityHigh), + @"BugseeSeverityCritical" : @(BugseeSeverityCritical), + @"BugseeSeverityBlocker" : @(BugseeSeverityBlocker), + })); + // The iOS type strings the fixture records are what a real report states. + BGSFlutterFakeReport *r = [[BGSFlutterFakeReport alloc] init]; + NSMutableArray *types = [NSMutableArray array]; + for (NSString *t in fixture[@"ios"][@"issueTypeStrings"]) { + r.fakeType = t; + [types addObject:[BGSFlutterReportOps typeOfReport:r]]; + } + XCTAssertEqualObjects(types, (@[ @"bug", @"crash", @"error" ])); +} + +- (void)testANeverSetSeverityReadsAsNilNotALevel { + _report.fakeSeverity = (BugseeSeverityLevel)0; + XCTAssertNil([BGSFlutterReportOps readReport:_report].severity); + _report.fakeSeverity = BugseeSeverityBlocker; + XCTAssertEqualObjects([BGSFlutterReportOps readReport:_report].severity, @5); +} + +- (void)testReplacingLabelsIsOneAtomicCall { + [_report.fakeLabels addObject:@"old"]; + XCTAssertTrue(([BGSFlutterReportOps replaceLabels:@[ @"a", @"b" ] onReport:_report error:NULL])); + XCTAssertEqualObjects(_report.fakeLabels, (@[ @"a", @"b" ])); + // clear-then-add would expose a label-less report between the two calls. + XCTAssertEqualObjects(_report.mutations, (@[ @"replaceLabels:" ])); +} + +- (void)testAListThatIsNotAllStringsChangesNothing { + [_report.fakeLabels addObject:@"keep"]; + [self assertRefused:^BOOL(NSError **e) { return [BGSFlutterReportOps replaceLabels:@[ @"a", @3 ] onReport:self->_report error:e]; }]; + [self assertRefused:^BOOL(NSError **e) { return [BGSFlutterReportOps replaceLabels:@[ @"a", NSNull.null ] onReport:self->_report error:e]; }]; + [self assertRefused:^BOOL(NSError **e) { return [BGSFlutterReportOps addLabels:@[ @1.5 ] onReport:self->_report error:e]; }]; + [self assertRefused:^BOOL(NSError **e) { return [BGSFlutterReportOps replaceLabels:nil onReport:self->_report error:e]; }]; + XCTAssertEqualObjects(_report.fakeLabels, (@[ @"keep" ])); +} + +- (void)testAttributesTakeOnlyWhatBothSdksCarry { + for (id value in @[ @"text", @YES, @7, @(7000000000LL), @1.5 ]) { + XCTAssertTrue([BGSFlutterReportOps setAttribute:value name:@"k" onReport:_report error:NULL]); + } + XCTAssertEqual(_report.mutations.count, 5u); + [_report.mutations removeAllObjects]; + for (id bad in @[ @(NAN), @(INFINITY), @[ @"x" ], @{@"a" : @1}, NSNull.null, [NSObject new], [NSSet setWithObject:@"x"] ]) { + [self assertRefused:^BOOL(NSError **e) { return [BGSFlutterReportOps setAttribute:bad name:@"k" onReport:self->_report error:e]; }]; + } + [self assertRefused:^BOOL(NSError **e) { return [BGSFlutterReportOps setAttribute:@"v" name:@"" onReport:self->_report error:e]; }]; + [self assertRefused:^BOOL(NSError **e) { return [BGSFlutterReportOps setAttribute:@"v" name:nil onReport:self->_report error:e]; }]; + [self assertRefused:^BOOL(NSError **e) { return [BGSFlutterReportOps setAttribute:nil name:@"k" onReport:self->_report error:e]; }]; +} + +/// Dart's bool arrives as a boolean NSNumber; it must stay a bool, not become 1. +- (void)testABoolIsKeptAsABool { + [BGSFlutterReportOps setAttribute:@YES name:@"b" onReport:_report error:NULL]; + XCTAssertTrue([BGSFlutterReportOps isAllowedAttributeValue:@YES]); + XCTAssertTrue([BGSFlutterReportOps isAllowedAttributeValue:@0]); + XCTAssertEqual(CFGetTypeID((__bridge CFTypeRef)_report.fakeAttributes[@"b"]), CFBooleanGetTypeID()); +} + +- (void)testReadReturnsTheLiveReportAndLeavesUnsupportedAttributesOut { + _report.fakeSummary = @"s"; + _report.fakeDescription = @"d"; + _report.fakeEmail = @"e@x"; + _report.fakeSeverity = BugseeSeverityBlocker; + [_report.fakeLabels addObjectsFromArray:@[ @"l1", @"l2" ]]; + _report.fakeAttributes[@"ok"] = @"v"; + _report.fakeAttributes[@"set"] = [NSSet setWithObject:@"a"]; + _report.fakeAttributes[@"nan"] = @(NAN); + _report.fakeType = @"crash"; + BGSFlutterReportSnapshot *s = [BGSFlutterReportOps readReport:_report]; + XCTAssertEqualObjects(s.reportId, @"report-1"); + XCTAssertEqualObjects(s.type, @"crash"); + XCTAssertEqualObjects(s.summary, @"s"); + XCTAssertEqualObjects(s.reportDescription, @"d"); + XCTAssertEqualObjects(s.email, @"e@x"); + XCTAssertEqualObjects(s.severity, @5); + XCTAssertEqualObjects(s.labels, (@[ @"l1", @"l2" ])); + XCTAssertEqualObjects(s.attributes, (@{@"ok" : @"v"})); +} + +- (void)testSimpleFieldsPassThrough { + [BGSFlutterReportOps setSummary:@"s" onReport:_report]; + [BGSFlutterReportOps setDescription:@"d" onReport:_report]; + [BGSFlutterReportOps setEmail:@"e" onReport:_report]; + [BGSFlutterReportOps setSummary:nil onReport:_report]; + XCTAssertNil(_report.fakeSummary); + XCTAssertEqualObjects(_report.fakeDescription, @"d"); + XCTAssertEqualObjects(_report.fakeEmail, @"e"); + [BGSFlutterReportOps clearLabelsOnReport:_report]; + [BGSFlutterReportOps removeAttribute:@"x" onReport:_report]; + [BGSFlutterReportOps clearAttributesOnReport:_report]; +} + +@end diff --git a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m index 8fd7328..32ae76b 100644 --- a/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m +++ b/ios/wrapper_tests/Tests/BugseeFlutterWrapperTests/BGSFlutterWrapperTests.m @@ -1,6 +1,8 @@ #import +#import "BGSFlutterFakeReport.h" #import "BGSFlutterLifecycleHub.h" +#import "BGSFlutterReportHandlerPipeline.h" #import "BGSFlutterSecureRectangleStore.h" #import "BGSFlutterWrapper.h" @@ -33,6 +35,22 @@ static void PublishOnDisplay(BGSFlutterSecureRectangleStore *store, NSInteger di return [NSData dataWithBytes:ints length:sizeof(ints)]; } +/// A callback that counts its answers and records the last. +@interface BGSCountingCallback : NSObject +@property(nonatomic) NSUInteger calls; +@property(nonatomic, copy, nullable) NSString *last; +@property(nonatomic) BOOL throws; +@end +@implementation BGSCountingCallback +- (void)onResult:(NSString *)result { + self.calls++; + self.last = result ?: @""; + if (self.throws) { + [NSException raise:NSInternalInconsistencyException format:@"callback failed"]; + } +} +@end + @interface BGSFlutterWrapperTests : XCTestCase @end @@ -141,4 +159,137 @@ - (void)testReplacingTheWrapperKeepsTheSecureRectangleSet { [NSData dataWithBytes:head length:sizeof(head)]); } +#pragma mark - requestData (workbook 6.1, Part 6) + +/// Every type is answered with nil, `vh` included (its real flow is Phase 5), and never throws. +- (void)assertAnswersNilForEveryType:(id)wrapper { + for (NSString *type in @[ @"vh", @"", @"something-new" ]) { + BGSCountingCallback *callback = [[BGSCountingCallback alloc] init]; + [wrapper requestDataWithType:type callback:callback]; + XCTAssertEqual(callback.calls, 1u, @"%@", type); + XCTAssertEqualObjects(callback.last, @"", @"%@", type); + } + // A throwing callback must not escape, and a missing one is not a crash. + BGSCountingCallback *throwing = [[BGSCountingCallback alloc] init]; + throwing.throws = YES; + XCTAssertNoThrow([wrapper requestDataWithType:@"vh" callback:throwing]); + XCTAssertNoThrow([wrapper requestDataWithType:@"vh" callback:(id)nil]); +} + +- (void)testRequestDataAnswersNilForEveryTypeAndNeverThrows { + [self assertAnswersNilForEveryType:(id)[BGSFlutterWrapper wrapperAtProcessStart]]; +} + +/// The wrapper is replaced at runtime when Dart refines the identity; the replacement answers too. +- (void)testTheReplacedWrapperAnswersRequestDataToo { + NSMutableArray *registered = [NSMutableArray array]; + [BGSFlutterWrapperRegistrar setRegistrationHandlerForTests:^(id w) { + [registered addObject:w]; + }]; + XCTAssertTrue([BGSFlutterWrapperRegistrar replaceWithType:@"flutter" version:@"1.2.3" build:nil context:nil]); + XCTAssertEqual(registered.count, 1u); + [self assertAnswersNilForEveryType:registered.firstObject]; +} + +#pragma mark - report handlers (workbook Part 7) + +- (BGSFlutterReportHandlerPipeline *)pipelineCollecting:(NSMutableArray *)requests + owner:(id *)owner + deadlines:(NSMutableArray *)deadlines { + BGSFlutterReportHandlerPipeline *pipeline = [[BGSFlutterReportHandlerPipeline alloc] + initWithScheduler:^id(dispatch_block_t task, int64_t delayMs) { + @synchronized(deadlines) { + [deadlines addObject:[task copy]]; + } + return @"t"; + } + cancel:^(id token) { + } + platformPost:^(dispatch_block_t task) { + task(); + } + log:^(NSString *line) { + }]; + *owner = [pipeline attachSink:^(BGSFlutterReportRequest *request, dispatch_block_t unreachable) { + @synchronized(requests) { + [requests addObject:request]; + } + }]; + [pipeline setPhasesForOwner:*owner before:YES after:YES]; + return pipeline; +} + +/// The SDK's callbacks reach the pipeline from a NON-main thread (iOS recovery), complete +/// exactly once, and a Dart handler that never answers does not stall them. +- (void)testReportHandlersGoThroughThePipelineFromANonMainThread { + NSMutableArray *requests = [NSMutableArray array]; + NSMutableArray *deadlines = [NSMutableArray array]; + id owner = nil; + BGSFlutterReportHandlerPipeline *pipeline = [self pipelineCollecting:requests owner:&owner deadlines:deadlines]; + BGSFlutterWrapper *w = [BGSFlutterWrapper wrapperWithType:@"flutter" + version:@"1" + build:nil + context:nil + hub:[BGSFlutterLifecycleHub shared] + secureRectangles:[BGSFlutterSecureRectangleStore new] + reports:pipeline]; + BGSFlutterFakeReport *report = [[BGSFlutterFakeReport alloc] init]; + __block NSUInteger completions = 0; + NSObject *lock = [[NSObject alloc] init]; + XCTestExpectation *returned = [self expectationWithDescription:@"callbacks returned"]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + XCTAssertFalse([NSThread isMainThread]); + [(id)w onBeforeReportCreated:report isTerminating:NO completion:^{ + @synchronized(lock) { completions++; } + }]; + [(id)w onAfterReportCreated:report isTerminating:NO completion:^{ + @synchronized(lock) { completions++; } + }]; + [returned fulfill]; + }); + [self waitForExpectations:@[ returned ] timeout:5]; + XCTAssertEqual(requests.count, 2u, @"both phases reached the Dart end, off the main thread"); + XCTAssertFalse(requests[0].after); + XCTAssertTrue(requests[1].after); + // Off main, so the recovery budget (inside the SDK's 3 s cap). + XCTAssertEqual(requests[0].deadlineMs, BGSFlutterReportRecoveryDeadlineMs); + @synchronized(lock) { + XCTAssertEqual(completions, 0u, @"nothing completed: the SDK waits for Dart or the deadline"); + } + // Dart never answers: the deadlines complete both, once each, however late they come. + for (dispatch_block_t deadline in [deadlines copy]) { + deadline(); + } + for (dispatch_block_t deadline in [deadlines copy]) { + deadline(); + } + XCTAssertEqual(completions, 2u); + [pipeline detachOwner:owner]; + XCTAssertEqual(completions, 2u); +} + +- (void)testTerminatingNeverReachesDartThroughTheWrapper { + NSMutableArray *requests = [NSMutableArray array]; + id owner = nil; + BGSFlutterReportHandlerPipeline *pipeline = [self pipelineCollecting:requests owner:&owner deadlines:[NSMutableArray array]]; + BGSFlutterWrapper *w = [BGSFlutterWrapper wrapperWithType:@"flutter" version:@"1" build:nil context:nil + hub:[BGSFlutterLifecycleHub shared] + secureRectangles:[BGSFlutterSecureRectangleStore new] + reports:pipeline]; + __block NSUInteger completions = 0; + [(id)w onBeforeReportCreated:[[BGSFlutterFakeReport alloc] init] isTerminating:YES completion:^{ completions++; }]; + [(id)w onAfterReportCreated:[[BGSFlutterFakeReport alloc] init] isTerminating:YES completion:^{ completions++; }]; + XCTAssertEqual(completions, 2u); + XCTAssertEqual(requests.count, 0u); +} + +/// With no Dart handler registered (the default), the SDK is never left waiting. +- (void)testTheDefaultWrapperCompletesImmediatelyWhenNoDartHandlerIsRegistered { + BGSFlutterWrapper *w = [BGSFlutterWrapper wrapperAtProcessStart]; + __block NSUInteger completions = 0; + [(id)w onBeforeReportCreated:[[BGSFlutterFakeReport alloc] init] isTerminating:NO completion:^{ completions++; }]; + [(id)w onAfterReportCreated:[[BGSFlutterFakeReport alloc] init] isTerminating:NO completion:^{ completions++; }]; + XCTAssertEqual(completions, 2u); +} + @end diff --git a/lib/src/bridge/bridge.dart b/lib/src/bridge/bridge.dart index e77150f..d6e85f3 100644 --- a/lib/src/bridge/bridge.dart +++ b/lib/src/bridge/bridge.dart @@ -2,14 +2,24 @@ // package:bugsee_flutter; Phase 4 builds the public API on it. import 'dart:async'; +import 'package:flutter/foundation.dart' + show ErrorDescription, FlutterError, FlutterErrorDetails; import 'package:flutter/services.dart' show BinaryMessenger; +import '../report/report_proxy.dart'; import 'lifecycle.dart'; import 'messages.g.dart'; import 'runtime_facts.dart'; -/// Receives the SDK's lifecycle events from native and refines the wrapper -/// identity. +/// A report handler (`onBeforeReportCreated` / `onAfterReportCreated`). +/// +/// The [report] is live only until the returned future completes (or the SDK's +/// deadline passes, whichever is first): await every edit. An error it throws is +/// reported through [FlutterError.reportError] and the report still proceeds. +typedef ReportHandler = FutureOr Function(ReportProxy report); + +/// Receives the SDK's lifecycle events from native, delivers report-handler +/// requests, and refines the wrapper identity. /// /// Native keeps events in a bounded buffer until [start] attaches (see /// `LifecycleHub` / `BGSFlutterLifecycleHub`: capacity 64, oldest dropped), then @@ -27,7 +37,15 @@ class BugseeBridge implements BugseeBridgeFlutterApi { Future? _started; bool _attached = false; bool _identitySent = false; + bool _phasesSent = false; SdkStatus? _status; + ReportHandler? _onBefore; + ReportHandler? _onAfter; + final Set _delivered = {}; + + /// How many delivery ids are remembered for de-duplication. Old ones are + /// forgotten first. + static const int deliveredCapacity = 128; /// Every lifecycle event, in the order native delivered them, including names /// this version does not know. A broadcast stream: a listener sees events from @@ -50,6 +68,11 @@ class BugseeBridge implements BugseeBridgeFlutterApi { /// /// The handler is registered BEFORE native is told to flush, or the flushed /// events would arrive at nothing. + /// + /// It also sweeps the previous isolate's report deliveries (once, shared with + /// `setReportHandlers`, whichever comes first) and tells native which report-handler + /// phases have a Dart handler now (none, for a new isolate), so a hot restart is not + /// waited on for a handler it has not registered. Future start({RuntimeFacts? facts, String? build}) => _started ??= _start(facts ?? RuntimeFacts.current(), build); @@ -69,6 +92,16 @@ class BugseeBridge implements BugseeBridgeFlutterApi { stack = s; } } + if (!_phasesSent) { + try { + await _sweepPreviousIsolate(); + await _host.setReportHandlerPhases(_onBefore != null, _onAfter != null); + _phasesSent = true; + } catch (e, s) { + error ??= e; + stack ??= s; + } + } if (!_identitySent) { final identity = WrapperIdentity.from(facts, build: build); try { @@ -107,4 +140,139 @@ class BugseeBridge implements BugseeBridgeFlutterApi { if (s != null) _status = s; _events.add(e); } + + Future? _swept; + + /// This isolate is new (a hot restart makes a fresh one): native completes what it had + /// handed to the previous isolate instead of leaving each report to wait for its deadline. + /// Once per isolate and always BEFORE the first phase registration, whichever of `start()` + /// and `setReportHandlers` comes first (a registration before the sweep would be wiped by + /// it, and one without it would leave the old deliveries waiting). A failure is not cached. + Future _sweepPreviousIsolate() => _swept ??= () async { + try { + await _host.resetReportHandlers(); + } catch (_) { + _swept = null; + rethrow; + } + }(); + + /// Registers the report handlers (either may be `null`: no handler for that phase). + /// + /// With a phase unregistered native completes the SDK's callback at once and never + /// calls Dart for it. `onBefore` is at most once and may be skipped; `onAfter` is at + /// least once and may run again, in a later process, for a report that already carries + /// part of the first run's contribution (workbook 7.2): the handler must be idempotent. + /// Within this isolate a second delivery of the same report and phase is not run again + /// (see [deliveredCapacity]); it is completed at once. + Future setReportHandlers({ + ReportHandler? onBefore, + ReportHandler? onAfter, + }) async { + _onBefore = onBefore; + _onAfter = onAfter; + // The receiver first, then the flag: native must not call before Dart can answer. + BugseeBridgeFlutterApi.setUp(this, binaryMessenger: _messenger); + await _sweepPreviousIsolate(); + await _host.setReportHandlerPhases(onBefore != null, onAfter != null); + _phasesSent = true; + } + + @override + void onReportHandlerRequest(ReportHandlerRequestMessage request) { + // Native does not wait for this reply; the SDK's completion runs when this + // isolate completes the handle (or the deadline passes). Return at once. + unawaited(_deliverReport(request)); + } + + Future _deliverReport(ReportHandlerRequestMessage request) async { + final handler = request.after ? _onAfter : _onBefore; + if (handler == null || !_remember(request.deliveryId)) { + // Nothing to run (a stale flag natively) or this report and phase was already + // handled here: do not run the handler twice, and do not leave the SDK waiting. + await _complete(request.handle); + return; + } + final proxy = ReportProxy.internal( + id: request.reportId, + typeName: request.type, + deliveryId: request.deliveryId, + handle: request.handle, + host: _host, + ); + // Native closes the report at its deadline whatever Dart does; closing here too + // makes a late edit fail without a round trip. `deadlineMs` is what native had left + // when it sent the request, so this closes no later than native does. + var overran = false; + final deadline = Timer(Duration(milliseconds: request.deadlineMs), () { + overran = true; + proxy.markCompleted(); + }); + try { + await handler(proxy); + } catch (error, stack) { + _reportSafely( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'bugsee_flutter', + context: ErrorDescription('while running a Bugsee report handler'), + ), + ); + } finally { + deadline.cancel(); + if (overran) { + // Not only an edit that failed: a handler that ran past the budget and made no further + // edit would otherwise look like a success. + _reportSafely( + FlutterErrorDetails( + exception: StateError( + 'A Bugsee report handler for report ${request.reportId} ran past its ' + '${request.deadlineMs} ms budget (the SDK deadline less a margin). The SDK ' + 'carried on without it and any edit it made after that was not applied.', + ), + library: 'bugsee_flutter', + context: ErrorDescription('while running a Bugsee report handler'), + ), + ); + } + // Closed BEFORE the completion crosses, so nothing a stray continuation does + // afterwards can be sent as if it were still live. + proxy.markCompleted(); + await _complete(request.handle); + } + } + + /// Reports to the app's error handler; one that itself throws must not keep the SDK's + /// completion from running (native would then wait out its deadline). + void _reportSafely(FlutterErrorDetails details) { + try { + FlutterError.reportError(details); + } catch (_) { + // Nothing left to tell. + } + } + + /// Whether this delivery id is new. Remembers it either way. + bool _remember(String deliveryId) { + if (!_delivered.add(deliveryId)) { + // Most recently seen last, so the oldest is the one forgotten. + _delivered + ..remove(deliveryId) + ..add(deliveryId); + return false; + } + if (_delivered.length > deliveredCapacity) { + _delivered.remove(_delivered.first); + } + return true; + } + + Future _complete(String handle) async { + try { + await _host.completeReportHandler(handle); + } catch (_) { + // Native completes on its own deadline: nothing more to do from here. + } + } } diff --git a/lib/src/bridge/messages.g.dart b/lib/src/bridge/messages.g.dart index 1053c33..fac7190 100644 --- a/lib/src/bridge/messages.g.dart +++ b/lib/src/bridge/messages.g.dart @@ -356,6 +356,191 @@ class SecureRectanglesMessage { } } +/// One report-handler delivery native hands to Dart: the report is reachable +/// only through [handle] (a proxy, never a copy; workbook 7.3.1), and only until +/// the handler's completion runs (7.3.4). +/// +/// There is deliberately NO Pigeon enum anywhere in this bridge: Pigeon sends an +/// enum as its ordinal, and workbook 7.4 forbids ordinals. Severity crosses as the +/// SDK's internal integer, the phase as a bool, the issue type as the SDKs' string. +class ReportHandlerRequestMessage { + ReportHandlerRequestMessage({ + required this.handle, + required this.deliveryId, + required this.after, + required this.reportId, + required this.type, + required this.deadlineMs, + }); + + /// Unique per delivery and opaque; names the live report in every report + /// operation and in [BugseeBridgeHostApi.completeReportHandler]. + String handle; + + /// STABLE for the same report and phase (`before:` / + /// `after:`), so Dart can recognise a re-delivery: `onBefore` is + /// at-most-once and may be skipped, `onAfter` is at-least-once (7.2). Unique per + /// delivery (never shared) when the report's id could not be read. + String deliveryId; + + /// `false`: onBeforeReportCreated. `true`: onAfterReportCreated. + bool after; + + String reportId; + + /// The SDK's issue type string (`bug`, `crash`, `error`), as it reads it. Not + /// evidence that the report is a recovered crash (7.2.3). + String type; + + /// How much longer native keeps the report live, measured when this message is + /// SENT (so time spent queueing for the platform thread is already deducted): it + /// runs the SDK's completion itself when this has passed, after which every + /// operation on the handle is refused (7.3.5). The window is the SDK's budget less a + /// margin; the time the SDK spent queueing the handler before native saw it cannot be + /// observed, so the margin, not this number, is what absorbs it. + int deadlineMs; + + List _toList() { + return [handle, deliveryId, after, reportId, type, deadlineMs]; + } + + Object encode() { + return _toList(); + } + + static ReportHandlerRequestMessage decode(Object result) { + result as List; + return ReportHandlerRequestMessage( + handle: result[0]! as String, + deliveryId: result[1]! as String, + after: result[2]! as bool, + reportId: result[3]! as String, + type: result[4]! as String, + deadlineMs: result[5]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ReportHandlerRequestMessage || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(handle, other.handle) && + _deepEquals(deliveryId, other.deliveryId) && + _deepEquals(after, other.after) && + _deepEquals(reportId, other.reportId) && + _deepEquals(type, other.type) && + _deepEquals(deadlineMs, other.deadlineMs); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ReportHandlerRequestMessage(handle: $handle, deliveryId: $deliveryId, after: $after, reportId: $reportId, type: $type, deadlineMs: $deadlineMs)'; + } +} + +/// A read of a live report. [severity] is the SDK's internal integer (1..5), or +/// null when the SDK reports none (iOS reads 0 for "never set"; 7.4). +class ReportSnapshotMessage { + ReportSnapshotMessage({ + required this.id, + required this.type, + this.summary, + this.reportDescription, + this.email, + this.severity, + required this.labels, + required this.attributes, + }); + + String id; + + String type; + + String? summary; + + /// Named `reportDescription`: `description` collides with NSObject's in Swift. + String? reportDescription; + + String? email; + + int? severity; + + List labels; + + /// Only values both SDKs accept: String, bool, int, double. Anything else the + /// report holds is left out rather than stringified. + Map attributes; + + List _toList() { + return [ + id, + type, + summary, + reportDescription, + email, + severity, + labels, + attributes, + ]; + } + + Object encode() { + return _toList(); + } + + static ReportSnapshotMessage decode(Object result) { + result as List; + return ReportSnapshotMessage( + id: result[0]! as String, + type: result[1]! as String, + summary: result[2] as String?, + reportDescription: result[3] as String?, + email: result[4] as String?, + severity: result[5] as int?, + labels: (result[6]! as List).cast(), + attributes: (result[7]! as Map).cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ReportSnapshotMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(id, other.id) && + _deepEquals(type, other.type) && + _deepEquals(summary, other.summary) && + _deepEquals(reportDescription, other.reportDescription) && + _deepEquals(email, other.email) && + _deepEquals(severity, other.severity) && + _deepEquals(labels, other.labels) && + _deepEquals(attributes, other.attributes); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ReportSnapshotMessage(id: $id, type: $type, summary: $summary, reportDescription: $reportDescription, email: $email, severity: $severity, labels: $labels, attributes: $attributes)'; + } +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -375,6 +560,12 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is SecureRectanglesMessage) { buffer.putUint8(132); writeValue(buffer, value.encode()); + } else if (value is ReportHandlerRequestMessage) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is ReportSnapshotMessage) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -391,6 +582,10 @@ class _PigeonCodec extends StandardMessageCodec { return SecureViewRectanglesMessage.decode(readValue(buffer)!); case 132: return SecureRectanglesMessage.decode(readValue(buffer)!); + case 133: + return ReportHandlerRequestMessage.decode(readValue(buffer)!); + case 134: + return ReportSnapshotMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -481,6 +676,317 @@ class BugseeBridgeHostApi { ); return pigeonVar_replyValue! as bool; } + + /// A new Dart isolate is up (`BugseeBridge.start` calls it once, first): native completes + /// every delivery still outstanding to the previous isolate (a hot restart would otherwise + /// leave each report waiting for its deadline) and forgets which phases had a handler. + Future resetReportHandlers() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.resetReportHandlers$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + /// Tells native whether a Dart handler exists for each phase. With none, native + /// completes the SDK's callback at once and never calls Dart (no waiting). Sent + /// again (false, false) by every new Dart isolate, so a hot restart that has not + /// registered one yet is not waited on. + Future setReportHandlerPhases(bool before, bool after) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setReportHandlerPhases$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [before, after], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + /// The Dart handler is done (or failed): run the SDK's completion. Idempotent; + /// a handle native already completed (its deadline) is a no-op. + Future completeReportHandler(String handle) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.completeReportHandler$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + /// Report operations. Each returns whether it was APPLIED: `false` means the + /// handle is no longer live (the completion already ran), which Dart turns into + /// an error; native ignores the call (7.3.4). An argument the SDK cannot take + /// (a severity outside 1..5, an attribute value that is not a string, number or + /// bool) is a PlatformException `invalid-argument` and changes nothing. + /// + /// null: the handle is not live. + Future reportRead(String handle) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportRead$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + return pigeonVar_replyValue as ReportSnapshotMessage?; + } + + Future reportSetSummary(String handle, String? value) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetSummary$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle, value], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future reportSetDescription(String handle, String? value) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetDescription$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle, value], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future reportSetEmail(String handle, String? value) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetEmail$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle, value], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + /// [severity] is the SDK's internal integer, never an ordinal. + Future reportSetSeverity(String handle, int severity) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetSeverity$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle, severity], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + /// Atomic: a reader never sees the report without labels in between. + Future reportReplaceLabels(String handle, List labels) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportReplaceLabels$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle, labels], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future reportAddLabels(String handle, List labels) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportAddLabels$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle, labels], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future reportClearLabels(String handle) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportClearLabels$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future reportSetAttribute( + String handle, + String name, + Object value, + ) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportSetAttribute$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle, name, value], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future reportRemoveAttribute(String handle, String name) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportRemoveAttribute$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle, name], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future reportClearAttributes(String handle) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.reportClearAttributes$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [handle], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } } /// Native calls Dart. Always from the platform (main) thread. @@ -489,6 +995,12 @@ abstract class BugseeBridgeFlutterApi { void onLifecycleEvent(LifecycleEventMessage event); + /// A report handler delivery. Native does NOT wait for this call's reply: the + /// SDK's completion runs when Dart calls `completeReportHandler`, when the + /// deadline passes, or when this call fails (Dart not listening). Never + /// delivered while the SDK says the process is terminating. + void onReportHandlerRequest(ReportHandlerRequestMessage request); + static void setUp( BugseeBridgeFlutterApi? api, { BinaryMessenger? binaryMessenger, @@ -523,5 +1035,31 @@ abstract class BugseeBridgeFlutterApi { }); } } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeFlutterApi.onReportHandlerRequest$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final ReportHandlerRequestMessage arg_request = + args[0]! as ReportHandlerRequestMessage; + try { + api.onReportHandlerRequest(arg_request); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } } } diff --git a/lib/src/report/report_enums.dart b/lib/src/report/report_enums.dart new file mode 100644 index 0000000..ac212ef --- /dev/null +++ b/lib/src/report/report_enums.dart @@ -0,0 +1,74 @@ +// The report enums, by VALUE (workbook 7.4). Internal until Phase 4. +// +// Neither enum's `index` ever crosses the bridge: ordinals differ between +// platforms and shift between releases, and Pigeon would send an enum as its +// ordinal, so the bridge declares no enum at all (`test/report_enum_parity_test.dart` +// asserts that). What crosses is the SDK's integer (severity) or string (type). +// +// The values are repeated from the SDK sources, not invented: +// `test/fixtures/report_enums.json` is extracted from Android `IssueSeverity` / +// `IssueType` (v7.4.2) and iOS `BugseeSeverityLevel` (7.0.0-beta7) by +// `scripts/extract_report_enums.dart`, and `test/report_enum_parity_test.dart` +// compares this file with BOTH platforms, so a divergence in either is seen. + +/// How severe a report is. The SDK's internal integer is [value]. +/// +/// Value 1 is `VeryLow` on Android and `Low` on iOS; the names diverge only +/// there (2..5 agree). Dart follows Android's name, like the other Bugsee +/// wrappers, and [low] is the iOS name for the same value. Always map by +/// [value], never by [index] and never by name. +enum ReportSeverity { + veryLow(1), + medium(2), + high(3), + critical(4), + blocker(5); + + const ReportSeverity(this.value); + + /// What crosses the bridge and what the backend stores. + final int value; + + /// iOS's name (`BugseeSeverityLow`) for [veryLow]. + static const ReportSeverity low = veryLow; + + /// The smallest and largest value the SDKs accept. Repeated in Java + /// (`ReportOps.MIN_SEVERITY`/`MAX_SEVERITY`) and Objective-C + /// (`BGSFlutterReportOps`); the parity test compares all three. + static const int minValue = 1; + static const int maxValue = 5; + + /// The severity for an SDK value, or `null` for anything else, including the + /// 0 iOS reads for "never set" (which must not become a level) and values a + /// future SDK may add. + static ReportSeverity? fromValue(int? value) { + for (final s in values) { + if (s.value == value) return s; + } + return null; + } +} + +/// What kind of issue a report is: the SDKs' own strings, as the report states them. +/// +/// Evidence of nothing else: in particular not of a crash recovered on the next +/// launch, which a handler cannot tell from a live `logException` by type and +/// `isTerminating` (workbook 7.2.3). +enum ReportType { + bug('bug'), + crash('crash'), + error('error'); + + const ReportType(this.wire); + + /// Android `IssueType.toString()` and the string iOS `BGSReportContract.type` returns. + final String wire; + + /// The type for the SDK's string, or `null` for one this version does not know. + static ReportType? fromWire(String? wire) { + for (final t in values) { + if (t.wire == wire) return t; + } + return null; + } +} diff --git a/lib/src/report/report_proxy.dart b/lib/src/report/report_proxy.dart new file mode 100644 index 0000000..f194f87 --- /dev/null +++ b/lib/src/report/report_proxy.dart @@ -0,0 +1,189 @@ +// A proxy of the SDK's report, for the report handlers (workbook Part 7). +// Internal: not exported from package:bugsee_flutter; Phase 4 exposes it. +import '../bridge/messages.g.dart'; +import 'report_enums.dart'; + +/// An edit was attempted on a report the SDK has already taken back. +/// +/// A report is live only until the handler's completion runs (workbook 7.3.4), by +/// the handler returning or by the SDK's own deadline passing (7.3.5). The SDK +/// ignores an edit after that, which is indistinguishable from failure, so the +/// proxy raises this instead: loudly, on the Dart side, and nothing is sent. +class ReportClosedError extends StateError { + ReportClosedError(String reportId) + : super( + 'The report $reportId was already handed back to the SDK: its handler ' + 'finished or the SDK deadline passed, so this change would be lost. ' + 'Await every edit before the handler returns, and do slow work first.', + ); +} + +/// A read of the report at one moment. A value, not live: later edits do not change it. +final class ReportSnapshot { + const ReportSnapshot({ + required this.id, + required this.typeName, + required this.summary, + required this.description, + required this.email, + required this.severity, + required this.labels, + required this.attributes, + }); + + final String id; + + /// The type as the SDK states it; [type] is `null` for one this version does not know. + final String typeName; + ReportType? get type => ReportType.fromWire(typeName); + + final String? summary; + final String? description; + final String? email; + + /// `null` when the SDK states none (iOS reads 0 for a report nobody set) or a + /// value this version does not know. + final ReportSeverity? severity; + final List labels; + + /// Strings, bools and numbers only: what both SDKs carry. + final Map attributes; +} + +/// The live report behind one report-handler delivery: an opaque handle plus the +/// operations the contract has, not a copy (workbook 7.3.1). +/// +/// Every operation is asynchronous (it crosses to native) and **must be awaited +/// before the handler returns**: the report goes back to the SDK when the handler +/// does, and a later edit throws [ReportClosedError]. +/// +/// The handler may run again for the same report (`onAfterReportCreated` is +/// at-least-once, workbook 7.2.2), in a later process, against a report that already +/// carries part of the first run's contribution: set attributes and labels rather +/// than append, and do no one-shot work. +/// +/// Attachments and screenshots are not here yet. // TODO(phase-6): path-first +/// attachments (`addAttachment(path, name, mimeType, move)`): native does the file +/// work and no bytes or streams cross the bridge (workbook 7.3.3). +final class ReportProxy { + /// Created by the bridge, one per delivery. + ReportProxy.internal({ + required this.id, + required this.typeName, + required this.deliveryId, + required String handle, + required BugseeBridgeHostApi host, + }) : _handle = handle, + _host = host; + + /// The SDK's id of this report. Stable across a re-delivery. + final String id; + + /// The SDK's issue type string. See [ReportType]. + final String typeName; + ReportType? get type => ReportType.fromWire(typeName); + + /// Stable for this report and phase, unlike the delivery's own handle: two + /// deliveries with the same id are the same report being handled again. + final String deliveryId; + + final String _handle; + final BugseeBridgeHostApi _host; + bool _live = true; + + /// Whether edits can still reach the SDK. Native has the last word: this turns + /// `false` when the handler completes, when the deadline passes here, and when + /// native refuses an edit. + bool get isLive => _live; + + /// For the bridge: the completion is about to run (or has). Idempotent. + void markCompleted() => _live = false; + + void _ensureLive() { + if (!_live) throw ReportClosedError(id); + } + + /// Sends one operation. Returns normally only when native applied it. + Future _apply(Future Function() send) async { + _ensureLive(); + final applied = await send(); + if (!applied) { + // Native completed first (its deadline, an engine detach) and ignored the edit. + _live = false; + throw ReportClosedError(id); + } + } + + /// The report's current state. Throws [ReportClosedError] once it is not live. + Future read() async { + _ensureLive(); + final m = await _host.reportRead(_handle); + if (m == null) { + _live = false; + throw ReportClosedError(id); + } + return ReportSnapshot( + id: m.id, + typeName: m.type, + summary: m.summary, + description: m.reportDescription, + email: m.email, + severity: ReportSeverity.fromValue(m.severity), + labels: List.unmodifiable(m.labels), + attributes: Map.unmodifiable(m.attributes), + ); + } + + Future setSummary(String? summary) => + _apply(() => _host.reportSetSummary(_handle, summary)); + + Future setDescription(String? description) => + _apply(() => _host.reportSetDescription(_handle, description)); + + Future setEmail(String? email) => + _apply(() => _host.reportSetEmail(_handle, email)); + + /// Crosses as the SDK's integer ([ReportSeverity.value]), never the ordinal. + Future setSeverity(ReportSeverity severity) => + _apply(() => _host.reportSetSeverity(_handle, severity.value)); + + /// Replaces every label with [labels] in one atomic step: a reader never sees + /// the report without labels in between. The idempotent way to set labels. + Future replaceLabels(List labels) => + _apply(() => _host.reportReplaceLabels(_handle, List.of(labels))); + + /// Adds labels. Not idempotent across a re-delivery: prefer [replaceLabels]. + Future addLabels(List labels) => + _apply(() => _host.reportAddLabels(_handle, List.of(labels))); + + Future clearLabels() => _apply(() => _host.reportClearLabels(_handle)); + + /// Sets an attribute. The value must be a [String], [bool], [int] or a finite + /// [double] (the types both SDKs carry): anything else is an [ArgumentError] + /// and nothing is sent. Setting the same name again replaces it. + Future setAttribute(String name, Object value) { + if (name.isEmpty) { + throw ArgumentError.value(name, 'name', 'must not be empty'); + } + if (!isAllowedAttributeValue(value)) { + throw ArgumentError.value( + value.runtimeType, + 'value', + 'an attribute value must be a String, bool, int or a finite double', + ); + } + return _apply(() => _host.reportSetAttribute(_handle, name, value)); + } + + Future removeAttribute(String name) => + _apply(() => _host.reportRemoveAttribute(_handle, name)); + + Future clearAttributes() => + _apply(() => _host.reportClearAttributes(_handle)); + + /// The value types an attribute may have (what both SDKs carry). + static bool isAllowedAttributeValue(Object? value) { + if (value is String || value is bool || value is int) return true; + return value is double && value.isFinite; + } +} diff --git a/pigeons/bridge.dart b/pigeons/bridge.dart index 3c26990..59dc381 100644 --- a/pigeons/bridge.dart +++ b/pigeons/bridge.dart @@ -97,6 +97,80 @@ class SecureRectanglesMessage { List views; } +/// One report-handler delivery native hands to Dart: the report is reachable +/// only through [handle] (a proxy, never a copy; workbook 7.3.1), and only until +/// the handler's completion runs (7.3.4). +/// +/// There is deliberately NO Pigeon enum anywhere in this bridge: Pigeon sends an +/// enum as its ordinal, and workbook 7.4 forbids ordinals. Severity crosses as the +/// SDK's internal integer, the phase as a bool, the issue type as the SDKs' string. +class ReportHandlerRequestMessage { + ReportHandlerRequestMessage({ + required this.handle, + required this.deliveryId, + required this.after, + required this.reportId, + required this.type, + required this.deadlineMs, + }); + + /// Unique per delivery and opaque; names the live report in every report + /// operation and in [BugseeBridgeHostApi.completeReportHandler]. + String handle; + + /// STABLE for the same report and phase (`before:` / + /// `after:`), so Dart can recognise a re-delivery: `onBefore` is + /// at-most-once and may be skipped, `onAfter` is at-least-once (7.2). Unique per + /// delivery (never shared) when the report's id could not be read. + String deliveryId; + + /// `false`: onBeforeReportCreated. `true`: onAfterReportCreated. + bool after; + + String reportId; + + /// The SDK's issue type string (`bug`, `crash`, `error`), as it reads it. Not + /// evidence that the report is a recovered crash (7.2.3). + String type; + + /// How much longer native keeps the report live, measured when this message is + /// SENT (so time spent queueing for the platform thread is already deducted): it + /// runs the SDK's completion itself when this has passed, after which every + /// operation on the handle is refused (7.3.5). The window is the SDK's budget less a + /// margin; the time the SDK spent queueing the handler before native saw it cannot be + /// observed, so the margin, not this number, is what absorbs it. + int deadlineMs; +} + +/// A read of a live report. [severity] is the SDK's internal integer (1..5), or +/// null when the SDK reports none (iOS reads 0 for "never set"; 7.4). +class ReportSnapshotMessage { + ReportSnapshotMessage({ + required this.id, + required this.type, + this.summary, + this.reportDescription, + this.email, + this.severity, + required this.labels, + required this.attributes, + }); + + String id; + String type; + String? summary; + + /// Named `reportDescription`: `description` collides with NSObject's in Swift. + String? reportDescription; + String? email; + int? severity; + List labels; + + /// Only values both SDKs accept: String, bool, int, double. Anything else the + /// report holds is left out rather than stringified. + Map attributes; +} + /// Dart calls native. @HostApi() abstract class BugseeBridgeHostApi { @@ -113,10 +187,62 @@ abstract class BugseeBridgeHostApi { /// whether the message was applied (`false`: it was older than one already /// applied, which is not an error). bool setSecureRectangles(SecureRectanglesMessage state); + + /// A new Dart isolate is up (`BugseeBridge.start` calls it once, first): native completes + /// every delivery still outstanding to the previous isolate (a hot restart would otherwise + /// leave each report waiting for its deadline) and forgets which phases had a handler. + void resetReportHandlers(); + + /// Tells native whether a Dart handler exists for each phase. With none, native + /// completes the SDK's callback at once and never calls Dart (no waiting). Sent + /// again (false, false) by every new Dart isolate, so a hot restart that has not + /// registered one yet is not waited on. + void setReportHandlerPhases(bool before, bool after); + + /// The Dart handler is done (or failed): run the SDK's completion. Idempotent; + /// a handle native already completed (its deadline) is a no-op. + void completeReportHandler(String handle); + + /// Report operations. Each returns whether it was APPLIED: `false` means the + /// handle is no longer live (the completion already ran), which Dart turns into + /// an error; native ignores the call (7.3.4). An argument the SDK cannot take + /// (a severity outside 1..5, an attribute value that is not a string, number or + /// bool) is a PlatformException `invalid-argument` and changes nothing. + /// + /// null: the handle is not live. + ReportSnapshotMessage? reportRead(String handle); + + bool reportSetSummary(String handle, String? value); + + bool reportSetDescription(String handle, String? value); + + bool reportSetEmail(String handle, String? value); + + /// [severity] is the SDK's internal integer, never an ordinal. + bool reportSetSeverity(String handle, int severity); + + /// Atomic: a reader never sees the report without labels in between. + bool reportReplaceLabels(String handle, List labels); + + bool reportAddLabels(String handle, List labels); + + bool reportClearLabels(String handle); + + bool reportSetAttribute(String handle, String name, Object value); + + bool reportRemoveAttribute(String handle, String name); + + bool reportClearAttributes(String handle); } /// Native calls Dart. Always from the platform (main) thread. @FlutterApi() abstract class BugseeBridgeFlutterApi { void onLifecycleEvent(LifecycleEventMessage event); + + /// A report handler delivery. Native does NOT wait for this call's reply: the + /// SDK's completion runs when Dart calls `completeReportHandler`, when the + /// deadline passes, or when this call fails (Dart not listening). Never + /// delivered while the SDK says the process is terminating. + void onReportHandlerRequest(ReportHandlerRequestMessage request); } diff --git a/scripts/extract_report_enums.dart b/scripts/extract_report_enums.dart new file mode 100644 index 0000000..ea00141 --- /dev/null +++ b/scripts/extract_report_enums.dart @@ -0,0 +1,229 @@ +// Extracts the report enum values from the two native SDKs' SOURCE into +// test/fixtures/report_enums.json, the fixture test/report_enum_parity_test.dart +// compares the Dart enums, the Java mapping and the Objective-C mapping against +// (workbook 7.4.2: extract from SDK source, do not hand-write; 7.4.3: compare both +// platforms, because a fixture from one SDK cannot see a divergence in the other). +// +// Run (inputs are the files at the pinned versions, native-versions.json): +// +// git -C show v7.4.2:library/src/main/java/com/bugsee/library/contracts/options/IssueSeverity.java > /tmp/IssueSeverity.java +// git -C show v7.4.2:library/src/main/java/com/bugsee/library/contracts/options/IssueType.java > /tmp/IssueType.java +// # iOS: the headers the pinned xcframework ships (Bugsee.framework/Headers) +// dart scripts/extract_report_enums.dart --android-severity /tmp/IssueSeverity.java \ +// --android-type /tmp/IssueType.java --ios-constants .../BugseeConstants.h \ +// --ios-contracts .../BGSContracts.h \ +// --android-coordinator /tmp/BugseeIssueReportingCoordinator.java \ +// --android-chain /tmp/ReportHandlerChain.java --android-options /tmp/OptionsDescriptors.java \ +// --ios-coordinator /tmp/BGSIssueReportingCoordinator.m --ios-source-ref origin/nextgen@0581aa02c [--check] +// +// The same fixture also carries the SDKs' report-handler TIMINGS (workbook 7.3.5), read from +// source, so the wrapper's deadlines are compared with the SDK's real numbers and not with +// copies of themselves. The iOS timings are constants of a .m that the shipped binary does not +// expose: they come from the nextgen commit named by --ios-source-ref (no 7.0.0-beta7 tag exists +// in the iOS SDK repository; the shipped headers equal nextgen's). +// +// `--check` compares with the committed fixture instead of writing it. The fixture +// records the pins it was extracted at; test/report_enum_parity_test.dart fails when +// they stop matching native-versions.json, so bumping a native pin without +// re-extracting is caught. +import 'dart:convert'; +import 'dart:io'; + +/// Source with comments removed, so a commented-out member is not read as real. +String stripComments(String source) => source + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '') + .replaceAll(RegExp(r'//[^\n]*'), ''); + +/// `Name(1)` members of a Java enum, by the enum's declared name. +Map javaIntEnum(String source, String enumName) { + final body = _javaEnumBody(stripComments(source), enumName); + return { + for (final m in RegExp(r'(\w+)\(\s*(-?\d+)\s*\)\s*[,;]').allMatches(body)) + m.group(1)!: int.parse(m.group(2)!), + }; +} + +/// `Name("text")` members of a Java enum. +Map javaStringEnum(String source, String enumName) { + final body = _javaEnumBody(stripComments(source), enumName); + return { + for (final m in RegExp(r'(\w+)\(\s*"([^"]*)"\s*\)\s*[,;]').allMatches(body)) + m.group(1)!: m.group(2)!, + }; +} + +String _javaEnumBody(String clean, String enumName) { + final start = RegExp('enum\\s+$enumName\\b[^{]*\\{').firstMatch(clean); + if (start == null) throw FormatException('no enum $enumName'); + // The constants end at the first `;` that follows the last member. + final rest = clean.substring(start.end); + final end = rest.indexOf(';'); + return end < 0 ? rest : rest.substring(0, end + 1); +} + +/// `NAME = 3000L;` / `NAME = 60_000L;` in Java. +int javaLong(String source, String name) { + final m = RegExp( + '$name\\s*=\\s*([\\d_]+)L', + ).firstMatch(stripComments(source)); + if (m == null) throw FormatException('no constant $name'); + return int.parse(m.group(1)!.replaceAll('_', '')); +} + +/// The default of `OptionDescriptor.createAndRegister(Options.NAME, Integer.class, 30)`. +int javaOptionDefault(String source, String option) { + final m = RegExp( + 'Options\\.$option\\s*,\\s*Integer\\.class\\s*,\\s*(\\d+)', + ).firstMatch(stripComments(source)); + if (m == null) throw FormatException('no option $option'); + return int.parse(m.group(1)!); +} + +/// `static const NSTimeInterval NAME = 30.0;` in Objective-C. +num objcSeconds(String source, String name) { + final m = RegExp( + '$name\\s*=\\s*([\\d.]+)\\s*;', + ).firstMatch(stripComments(source)); + if (m == null) throw FormatException('no constant $name'); + return num.parse(m.group(1)!); +} + +/// `typedef enum : T { A = 1, B = 2 } Name;` and `typedef NS_ENUM(T, Name) { ... };`. +Map objcEnum(String source, String enumName) { + final clean = stripComments(source); + final trailing = RegExp( + r'typedef\s+enum\s*(?::\s*[\w ]+)?\s*\{([^}]*)\}\s*(\w+)\s*;', + ); + final nsEnum = RegExp( + r'typedef\s+NS_(?:ENUM|OPTIONS)\s*\(\s*[\w ]+\s*,\s*(\w+)\s*\)\s*\{([^}]*)\}', + ); + String? body; + for (final m in trailing.allMatches(clean)) { + if (m.group(2) == enumName) body = m.group(1); + } + for (final m in nsEnum.allMatches(clean)) { + if (m.group(1) == enumName) body = m.group(2); + } + if (body == null) throw FormatException('no enum $enumName'); + final out = {}; + var next = 0; + for (final entry in body.split(',')) { + final m = RegExp(r'^\s*(\w+)\s*(?:=\s*(-?\d+)\s*)?$').firstMatch(entry); + if (m == null) continue; + final value = m.group(2) == null ? next : int.parse(m.group(2)!); + out[m.group(1)!] = value; + next = value + 1; + } + return out; +} + +/// The strings iOS's `BGSReportContract.type` documents (`@"bug"`, ...), read from the +/// doc comment on the property, which is where the SDK states them. +List iosTypeStrings(String contractsHeader) { + final line = RegExp( + r'///\s*The issue type:[^\n]*', + ).firstMatch(contractsHeader); + if (line == null) throw const FormatException('no "The issue type" comment'); + return [ + for (final m in RegExp(r'`@"(\w+)"`').allMatches(line.group(0)!)) + m.group(1)!, + ]; +} + +Map extract({ + required String androidSeverity, + required String androidType, + required String iosConstants, + required String iosContracts, + required String androidCoordinator, + required String androidChain, + required String androidOptions, + required String iosCoordinator, + required String iosSourceRef, + required Map pins, +}) => { + '_comment': + 'Extracted from the native SDK sources by scripts/extract_report_enums.dart; ' + 'do not edit by hand. Compared by test/report_enum_parity_test.dart.', + 'pins': pins, + 'android': { + 'IssueSeverity': javaIntEnum(androidSeverity, 'IssueSeverity'), + 'IssueType': javaStringEnum(androidType, 'IssueType'), + 'reportHandlerTimings': { + 'terminatingCapMs': javaLong( + androidCoordinator, + 'TERMINATING_HANDLER_CAP_MILLIS', + ), + 'chainCapMs': javaLong(androidChain, 'HANDLER_CHAIN_CAP_MILLIS'), + 'handlerTimeoutDefaultSeconds': javaOptionDefault( + androidOptions, + 'ReportHandlerCallbackTimeout', + ), + }, + }, + 'ios': { + 'BugseeSeverityLevel': objcEnum(iosConstants, 'BugseeSeverityLevel'), + 'issueTypeStrings': iosTypeStrings(iosContracts), + 'reportHandlerTimings': { + 'source': iosSourceRef, + 'handlerTimeoutSeconds': objcSeconds( + iosCoordinator, + 'kBGSReportHandlerCallbackTimeoutSeconds', + ), + 'chainTimeoutSeconds': objcSeconds( + iosCoordinator, + 'kBGSReportHandlerChainTimeoutSeconds', + ), + 'boundedCapSeconds': objcSeconds( + iosCoordinator, + 'kBGSBoundedReportHandlerCapSeconds', + ), + }, + }, +}; + +String arg(List args, String name) { + final i = args.indexOf(name); + if (i < 0 || i + 1 >= args.length) { + stderr.writeln('missing $name'); + exit(2); + } + return args[i + 1]; +} + +void main(List args) { + final root = File.fromUri(Platform.script).parent.parent.path; + final versions = + jsonDecode(File('$root/native-versions.json').readAsStringSync()) as Map; + final fixture = extract( + androidSeverity: File(arg(args, '--android-severity')).readAsStringSync(), + androidType: File(arg(args, '--android-type')).readAsStringSync(), + iosConstants: File(arg(args, '--ios-constants')).readAsStringSync(), + iosContracts: File(arg(args, '--ios-contracts')).readAsStringSync(), + androidCoordinator: File( + arg(args, '--android-coordinator'), + ).readAsStringSync(), + androidChain: File(arg(args, '--android-chain')).readAsStringSync(), + androidOptions: File(arg(args, '--android-options')).readAsStringSync(), + iosCoordinator: File(arg(args, '--ios-coordinator')).readAsStringSync(), + iosSourceRef: arg(args, '--ios-source-ref'), + pins: { + 'android': (versions['android'] as Map)['sdk'], + 'ios': (versions['ios'] as Map)['sdk'], + }, + ); + final text = '${const JsonEncoder.withIndent(' ').convert(fixture)}\n'; + final path = '$root/test/fixtures/report_enums.json'; + if (args.contains('--check')) { + if (File(path).readAsStringSync() != text) { + stderr.writeln('$path is stale; re-run without --check'); + exit(1); + } + stdout.writeln('report enum fixture is current'); + return; + } + File(path) + ..createSync(recursive: true) + ..writeAsStringSync(text); + stdout.writeln('wrote $path'); +} diff --git a/test/bridge_test.dart b/test/bridge_test.dart index 1348d15..a85b384 100644 --- a/test/bridge_test.dart +++ b/test/bridge_test.dart @@ -34,6 +34,33 @@ class FakeHost extends BugseeBridgeHostApi { await onAttach?.call(); } + int resets = 0; + final List<(bool, bool)> phases = []; + + int failReset = 0; + + @override + Future resetReportHandlers() async { + calls.add('reset'); + if (failReset > 0) { + failReset--; + throw PlatformException(code: 'channel-error'); + } + resets++; + } + + int failPhases = 0; + + @override + Future setReportHandlerPhases(bool before, bool after) async { + calls.add('phases'); + if (failPhases > 0) { + failPhases--; + throw PlatformException(code: 'channel-error'); + } + phases.add((before, after)); + } + @override Future setWrapperIdentity(WrapperIdentityMessage identity) async { calls.add('identity'); @@ -227,7 +254,55 @@ void main() { await bridge.start(facts: _facts); await bridge.start(facts: _facts); await bridge.start(); - expect(host.calls, ['attach', 'identity']); + expect(host.calls, ['attach', 'reset', 'phases', 'identity']); + // A new isolate has no report handler yet: native must not wait on one. + expect(host.phases, [(false, false)]); + }); + + test('handlers registered BEFORE start still sweep the old isolate, once, ' + 'before any phase registration', () async { + await bridge.setReportHandlers(onBefore: (_) {}); + await bridge.start(facts: _facts); + expect(host.resets, 1); + expect( + host.calls.indexOf('reset'), + lessThan(host.calls.indexOf('phases')), + reason: 'the sweep also clears native phases: it must come first', + ); + expect(host.calls.where((c) => c == 'reset'), hasLength(1)); + // The handler registered before start() is what native was told. + expect(host.phases.last, (true, false)); + }); + + test('handlers registered AFTER start() do not sweep again', () async { + await bridge.start(facts: _facts); + await bridge.setReportHandlers(onAfter: (_) {}); + expect(host.resets, 1); + expect(host.phases, [(false, false), (false, true)]); + }); + + test('a failed sweep is not cached: the next registration repeats it, ' + 'before its phases', () async { + host.failReset = 1; + await expectLater( + bridge.setReportHandlers(onBefore: (_) {}), + throwsA(isA()), + ); + expect(host.calls, ['reset']); + await bridge.setReportHandlers(onBefore: (_) {}); + expect(host.calls, ['reset', 'reset', 'phases']); + }); + + test('start() and setReportHandlers racing sweep once', () async { + await Future.wait([ + bridge.setReportHandlers(onBefore: (_) {}), + bridge.start(facts: _facts), + ]); + expect(host.resets, 1); + expect( + host.calls.indexOf('reset'), + lessThan(host.calls.indexOf('phases')), + ); }); test( @@ -238,15 +313,15 @@ void main() { bridge.start(facts: _facts), throwsA(isA()), ); - expect(host.calls, ['attach', 'identity']); + expect(host.calls, ['attach', 'reset', 'phases', 'identity']); expect(host.identities, hasLength(1)); // Not cached: the next call tries again, repeating only the failed step // (the identity already went through). await bridge.start(facts: _facts); - expect(host.calls, ['attach', 'identity', 'attach']); + expect(host.calls, ['attach', 'reset', 'phases', 'identity', 'attach']); // And now it is cached. await bridge.start(facts: _facts); - expect(host.calls, hasLength(3)); + expect(host.calls, hasLength(5)); }, ); @@ -265,7 +340,7 @@ void main() { expect(host.identities, hasLength(1)); // Attach had succeeded: the retry must not attach again (native would replay // events this bridge already received). - expect(host.calls, ['attach', 'identity', 'identity']); + expect(host.calls, ['attach', 'reset', 'phases', 'identity', 'identity']); }); test('the identity carries versions, engine and build mode', () async { @@ -305,7 +380,33 @@ void main() { ]); }, ); + messenger.setMockMessageHandler( + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.resetReportHandlers', + (m) async { + calls.add('reset'); + return BugseeBridgeHostApi.pigeonChannelCodec.encodeMessage([ + null, + ]); + }, + ); + messenger.setMockMessageHandler( + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setReportHandlerPhases', + (m) async { + calls.add('phases'); + return BugseeBridgeHostApi.pigeonChannelCodec.encodeMessage([ + null, + ]); + }, + ); addTearDown(() { + messenger.setMockMessageHandler( + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.resetReportHandlers', + null, + ); + messenger.setMockMessageHandler( + 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.setReportHandlerPhases', + null, + ); messenger.setMockMessageHandler( 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi.attachLifecycle', null, @@ -316,7 +417,7 @@ void main() { ); }); await BugseeBridge().start(facts: _facts); - expect(calls, ['attach', 'flutter']); + expect(calls, ['attach', 'reset', 'phases', 'flutter']); }); }); diff --git a/test/fixtures/report_enums.json b/test/fixtures/report_enums.json new file mode 100644 index 0000000..279f7ba --- /dev/null +++ b/test/fixtures/report_enums.json @@ -0,0 +1,46 @@ +{ + "_comment": "Extracted from the native SDK sources by scripts/extract_report_enums.dart; do not edit by hand. Compared by test/report_enum_parity_test.dart.", + "pins": { + "android": "7.4.2", + "ios": "7.0.0-beta7" + }, + "android": { + "IssueSeverity": { + "VeryLow": 1, + "Medium": 2, + "High": 3, + "Critical": 4, + "Blocker": 5 + }, + "IssueType": { + "Bug": "bug", + "Crash": "crash", + "Error": "error" + }, + "reportHandlerTimings": { + "terminatingCapMs": 3000, + "chainCapMs": 60000, + "handlerTimeoutDefaultSeconds": 30 + } + }, + "ios": { + "BugseeSeverityLevel": { + "BugseeSeverityLow": 1, + "BugseeSeverityMedium": 2, + "BugseeSeverityHigh": 3, + "BugseeSeverityCritical": 4, + "BugseeSeverityBlocker": 5 + }, + "issueTypeStrings": [ + "bug", + "crash", + "error" + ], + "reportHandlerTimings": { + "source": "origin/nextgen@0581aa02c", + "handlerTimeoutSeconds": 30.0, + "chainTimeoutSeconds": 60.0, + "boundedCapSeconds": 3.0 + } + } +} diff --git a/test/lifecycle_drift_test.dart b/test/lifecycle_drift_test.dart index e1516df..b244957 100644 --- a/test/lifecycle_drift_test.dart +++ b/test/lifecycle_drift_test.dart @@ -261,7 +261,9 @@ void main() { expect(s, contains('for await _ in wakeups')); expect(s, contains('self.pending.removeFirst()')); expect( - RegExp(r'attachSink \{[^}]*Task').hasMatch(s), + RegExp( + r'BGSFlutterLifecycleHub\.shared\(\)\.attachSink \{[^}]*Task', + ).hasMatch(s), isFalse, reason: 'a Task per event does not promise delivery order', ); diff --git a/test/report_enum_parity_test.dart b/test/report_enum_parity_test.dart new file mode 100644 index 0000000..44b79d8 --- /dev/null +++ b/test/report_enum_parity_test.dart @@ -0,0 +1,274 @@ +// Drift guards for the report enums (workbook 7.4): values cross by VALUE, never by +// ordinal or name, and both platforms are compared (a fixture extracted from one SDK +// cannot see a divergence in the other). +// +// The fixture test/fixtures/report_enums.json is extracted from the two SDKs' source by +// scripts/extract_report_enums.dart. The JUnit test (ReportOpsTest, ReportEnumParityTest) +// and the XCTest (BGSFlutterReportOpsTests) compare the Java and Objective-C mappings with +// the SDKs' REAL enums at the pinned versions; this file compares everything that is +// repeated as text. +import 'dart:convert'; +import 'dart:io'; + +import 'package:bugsee_flutter/src/report/report_enums.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../scripts/extract_report_enums.dart' as extract; + +Map json(String path) => + jsonDecode(File(path).readAsStringSync()) as Map; + +String code(String path) => + extract.stripComments(File(path).readAsStringSync()); + +const java = 'android/src/main/java/com/bugsee/flutter'; +const objc = 'ios/bugsee_flutter/Sources/bugsee_flutter_wrapper'; + +String lowerFirst(String s) => s[0].toLowerCase() + s.substring(1); + +void main() { + final fixture = json('test/fixtures/report_enums.json'); + final android = fixture['android']! as Map; + final ios = fixture['ios']! as Map; + final androidSeverity = (android['IssueSeverity']! as Map) + .cast(); + final iosSeverity = (ios['BugseeSeverityLevel']! as Map).cast(); + + group('the fixture is current', () { + test('it was extracted at the pinned native versions', () { + final pins = json('native-versions.json'); + final at = fixture['pins']! as Map; + expect(at['android'], (pins['android']! as Map)['sdk']); + expect(at['ios'], (pins['ios']! as Map)['sdk']); + }); + }); + + group('IssueSeverity agrees across platforms and with Dart, by value', () { + test('both SDKs and Dart assign the same set of values', () { + final dart = ReportSeverity.values.map((s) => s.value).toSet(); + expect(androidSeverity.values.toSet(), dart); + expect(iosSeverity.values.toSet(), dart); + expect(dart, {1, 2, 3, 4, 5}); + }); + + test('every Dart severity maps to the SDKs member of the same value', () { + for (final s in ReportSeverity.values) { + expect( + androidSeverity[androidSeverity.keys.firstWhere( + (k) => androidSeverity[k] == s.value, + )], + s.value, + ); + expect(ReportSeverity.fromValue(s.value), same(s)); + } + }); + + test('names agree except for the ONE known divergence at value 1', () { + // Android VeryLow, iOS Low. A list that silently absorbed new entries would look + // like coverage, so the exception is exactly this one. + const knownIosAlias = {'Low': 'VeryLow'}; + final iosNames = { + for (final e in iosSeverity.entries) + e.value: () { + final bare = e.key.replaceFirst('BugseeSeverity', ''); + return knownIosAlias[bare] ?? bare; + }(), + }; + final androidNames = { + for (final e in androidSeverity.entries) e.value: e.key, + }; + expect(iosNames, androidNames); + expect( + {for (final s in ReportSeverity.values) s.value: s.name}, + {for (final e in androidNames.entries) e.key: lowerFirst(e.value)}, + ); + // The iOS spelling is available under its own name, for the same value. + expect(ReportSeverity.low, same(ReportSeverity.veryLow)); + expect(ReportSeverity.low.value, iosSeverity['BugseeSeverityLow']); + }); + + test('Blocker stays the highest on every platform', () { + expect(androidSeverity['Blocker'], 5); + expect(iosSeverity['BugseeSeverityBlocker'], 5); + expect(ReportSeverity.blocker.value, 5); + }); + + test('a value outside the range is no severity, "never set" included', () { + for (final v in [0, -1, 6, 100, null]) { + expect(ReportSeverity.fromValue(v), isNull, reason: '$v'); + } + }); + + test( + 'the accepted range is repeated alike in Dart, Java and Objective-C', + () { + final values = androidSeverity.values; + final lo = values.reduce((a, b) => a < b ? a : b); + final hi = values.reduce((a, b) => a > b ? a : b); + expect([ReportSeverity.minValue, ReportSeverity.maxValue], [lo, hi]); + final j = code('$java/ReportOps.java'); + expect( + RegExp(r'MIN_SEVERITY\s*=\s*(\d+)').firstMatch(j)!.group(1), + '$lo', + ); + expect( + RegExp(r'MAX_SEVERITY\s*=\s*(\d+)').firstMatch(j)!.group(1), + '$hi', + ); + final m = code('$objc/BGSFlutterReportOps.m'); + expect( + RegExp(r'minSeverity\s*\{\s*return\s+(\d+);').firstMatch(m)!.group(1), + '$lo', + ); + expect( + RegExp(r'maxSeverity\s*\{\s*return\s+(\d+);').firstMatch(m)!.group(1), + '$hi', + ); + }, + ); + }); + + group('IssueType agrees across platforms and with Dart', () { + test('Android toString(), iOS strings and Dart wire names are the same', () { + final androidWire = (android['IssueType']! as Map).cast(); + final iosWire = (ios['issueTypeStrings']! as List).cast(); + final dart = ReportType.values.map((t) => t.wire).toList(); + expect(androidWire.values.toList(), dart); + expect(iosWire, dart); + // The enum NAME is not the wire value ("Crash" vs "crash"): the wire one crosses. + expect(androidWire.keys.map(lowerFirst).toList(), dart); + expect( + androidWire.keys.toList(), + ReportType.values.map( + (t) => t.name[0].toUpperCase() + t.name.substring(1), + ), + ); + }); + + test('an unknown type is not guessed', () { + expect(ReportType.fromWire('anr'), isNull); + expect(ReportType.fromWire(''), isNull); + expect(ReportType.fromWire(null), isNull); + expect(ReportType.fromWire('crash'), ReportType.crash); + }); + }); + + group('nothing crosses by ordinal', () { + test( + 'the bridge definition declares no enum (Pigeon sends enums as ordinals)', + () { + expect( + RegExp( + r'^\s*enum\s', + multiLine: true, + ).hasMatch(code('pigeons/bridge.dart')), + isFalse, + ); + expect( + RegExp( + r'^\s*enum\s', + multiLine: true, + ).hasMatch(code('lib/src/bridge/messages.g.dart')), + isFalse, + reason: 'a generated enum would be sent as its index', + ); + }, + ); + + test('no code under lib/src/report or the report glue uses an ordinal', () { + final files = [ + for (final f in Directory('lib/src/report').listSync()) + if (f is File && f.path.endsWith('.dart')) f.path, + 'lib/src/bridge/bridge.dart', + '$java/ReportOps.java', + '$java/ReportHandlerPipeline.java', + '$java/BugseeFlutterPlugin.java', + '$objc/BGSFlutterReportOps.m', + '$objc/BGSFlutterReportHandlerPipeline.m', + 'ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift', + ]; + for (final path in files) { + final text = code(path); + expect(text, isNot(contains('.ordinal()')), reason: path); + expect(RegExp(r'\.index\b').hasMatch(text), isFalse, reason: path); + expect(text, isNot(contains('.byName')), reason: path); + } + }); + }); + + group('the extraction script reads what the SDKs write', () { + test('Java int and string enums, comments ignored', () { + const src = ''' +public enum IssueSeverity implements Foo { + /** doc mentioning Medium(2) */ + VeryLow(1), + // Hidden(9), + Medium(2), + Blocker(5); + private final int v; +}'''; + expect(extract.javaIntEnum(src, 'IssueSeverity'), { + 'VeryLow': 1, + 'Medium': 2, + 'Blocker': 5, + }); + expect( + extract.javaStringEnum( + 'enum IssueType { Bug("bug"), Crash("crash"); }', + 'IssueType', + ), + {'Bug': 'bug', 'Crash': 'crash'}, + ); + expect(() => extract.javaIntEnum(src, 'Nope'), throwsFormatException); + }); + + test('Objective-C enums: trailing typedef, NS_ENUM, implicit values', () { + const trailing = ''' +typedef enum : NSUInteger { + /** Low = 99 */ + BugseeSeverityLow = 1, + BugseeSeverityMedium = 2 +} BugseeSeverityLevel;'''; + expect(extract.objcEnum(trailing, 'BugseeSeverityLevel'), { + 'BugseeSeverityLow': 1, + 'BugseeSeverityMedium': 2, + }); + const ns = 'typedef NS_ENUM(NSInteger, BGSIssueType) { A = 0, B, C };'; + expect(extract.objcEnum(ns, 'BGSIssueType'), {'A': 0, 'B': 1, 'C': 2}); + expect(() => extract.objcEnum(ns, 'Other'), throwsFormatException); + }); + + test('timings: Java constants, the option default and Objective-C seconds', () { + expect( + extract.javaLong('static final long A_MS = 3000L; // 1L', 'A_MS'), + 3000, + ); + expect(extract.javaLong('X_CAP = 60_000L;', 'X_CAP'), 60000); + expect(() => extract.javaLong('nothing', 'X_CAP'), throwsFormatException); + const options = + 'createAndRegister(Options.ReportHandlerCallbackTimeout, Integer.class, 30);' + ' // Options.Other, Integer.class, 99'; + expect( + extract.javaOptionDefault(options, 'ReportHandlerCallbackTimeout'), + 30, + ); + expect( + () => extract.javaOptionDefault(options, 'Nope'), + throwsFormatException, + ); + const m = + 'static const NSTimeInterval kA = 30.0;\n// kB = 9.0;\nstatic const NSTimeInterval kB = 3.0;'; + expect(extract.objcSeconds(m, 'kA'), 30.0); + expect(extract.objcSeconds(m, 'kB'), 3.0); + expect(() => extract.objcSeconds(m, 'kC'), throwsFormatException); + }); + + test('the iOS type strings come from the property documentation', () { + const header = + '/// The issue type: `@"bug"`, `@"crash"` or `@"error"`. Readonly.\n' + '@property NSString *type;'; + expect(extract.iosTypeStrings(header), ['bug', 'crash', 'error']); + expect(() => extract.iosTypeStrings('nothing'), throwsFormatException); + }); + }); +} diff --git a/test/report_handlers_drift_test.dart b/test/report_handlers_drift_test.dart new file mode 100644 index 0000000..e1c6436 --- /dev/null +++ b/test/report_handlers_drift_test.dart @@ -0,0 +1,409 @@ +// Drift guards for the report-handler pipeline (workbook Part 7) that are about TEXT repeated +// across Dart, Android and iOS, or about a property that is cheap to see in source. The behaviour +// itself is tested by test/report_handlers_test.dart, the JUnit tests +// (ReportHandlerPipelineTest) and the XCTests (BGSFlutterReportHandlerPipelineTests). +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Source with comments removed, so a call that is only commented out does not satisfy a guard. +String code(String path) => File(path) + .readAsStringSync() + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '') + .replaceAll(RegExp(r'//[^\n]*'), ''); + +const java = 'android/src/main/java/com/bugsee/flutter'; +const objc = 'ios/bugsee_flutter/Sources/bugsee_flutter_wrapper'; + +int constant(String source, RegExp pattern) => + int.parse(pattern.firstMatch(source)!.group(1)!.replaceAll('_', '')); + +void main() { + final pipelineJava = code('$java/ReportHandlerPipeline.java'); + final deadlinesJava = code('$java/ReportHandlerDeadlines.java'); + final pipelineObjc = code('$objc/BGSFlutterReportHandlerPipeline.m'); + final wrapperJava = code('$java/FlutterWrapper.java'); + final wrapperObjc = code('$objc/BGSFlutterWrapper.m'); + + group( + 'the deadlines are inside the SDKs real budgets (read from SDK source)', + () { + // The SDKs' numbers come from test/fixtures/report_enums.json, extracted from their + // source by scripts/extract_report_enums.dart, NOT from the wrapper: comparing the + // wrapper's constants with each other proves nothing. + final fixture = + jsonDecode(File('test/fixtures/report_enums.json').readAsStringSync()) + as Map; + final a = (fixture['android']! as Map)['reportHandlerTimings']! as Map; + final i = (fixture['ios']! as Map)['reportHandlerTimings']! as Map; + final androidHandlerMs = + (a['handlerTimeoutDefaultSeconds']! as int) * 1000; + final iosHandlerMs = ((i['handlerTimeoutSeconds']! as num) * 1000) + .round(); + final iosCapMs = ((i['boundedCapSeconds']! as num) * 1000).round(); + + test('the SDKs agree with each other on the numbers we rely on', () { + expect(androidHandlerMs, iosHandlerMs); + expect(a['terminatingCapMs'], iosCapMs); + expect( + a['chainCapMs'], + ((i['chainTimeoutSeconds']! as num) * 1000).round(), + ); + }); + + test('the wrapper repeats the SDKs numbers it documents', () { + expect( + constant( + deadlinesJava, + RegExp(r'SDK_DEFAULT_HANDLER_TIMEOUT_MS\s*=\s*([\d_]+)L'), + ), + androidHandlerMs, + ); + expect( + constant( + pipelineObjc, + RegExp(r'BGSFlutterReportSdkLiveHandlerTimeoutMs\s*=\s*(\d+)'), + ), + iosHandlerMs, + ); + expect( + constant( + pipelineObjc, + RegExp(r'BGSFlutterReportSdkBoundedCapMs\s*=\s*(\d+)'), + ), + iosCapMs, + ); + }); + + test( + 'live: a third of the SDK budget is kept as margin, on both platforms', + () { + final android = constant( + deadlinesJava, + RegExp(r'LIVE_DEADLINE_MS\s*=\s*([\d_]+)L'), + ); + final ios = constant( + pipelineObjc, + RegExp(r'BGSFlutterReportLiveDeadlineMs\s*=\s*(\d+)'), + ); + expect(android, lessThanOrEqualTo(androidHandlerMs * 2 ~/ 3)); + expect(ios, lessThanOrEqualTo(iosHandlerMs * 2 ~/ 3)); + expect(android, ios); + // The margin rule is repeated in both languages. + expect( + constant(deadlinesJava, RegExp(r'MAX_MARGIN_MS\s*=\s*([\d_]+)L')), + 10000, + ); + expect( + deadlinesJava, + contains('Math.min(MAX_MARGIN_MS, sdkMs / 3L)'), + ); + expect(pipelineObjc, contains('MIN(10000, sdkMs / 3)')); + }, + ); + + test('recovery on iOS: a third of the bounded cap is kept as margin', () { + final recovery = constant( + pipelineObjc, + RegExp(r'BGSFlutterReportRecoveryDeadlineMs\s*=\s*(\d+)'), + ); + expect(recovery, lessThanOrEqualTo(iosCapMs * 2 ~/ 3)); + expect(recovery, greaterThanOrEqualTo(1000)); + }); + + test('Android tells its waited-for path by the SDK thread name', () { + expect( + deadlinesJava, + contains('LIVE_HANDLER_THREAD = "BugseeReportHandlerThread"'), + ); + }); + }, + ); + + group('terminating is decided before anything else', () { + // Everything inside dispatchOnce that comes before the isTerminating branch is sliced out and + // must contain none of the protected uses; each use must still exist after the branch (a + // first-occurrence search could be satisfied by a later use while an earlier one slips in). + void guardOrder( + String source, + String start, + String branch, + List uses, + ) { + final from = source.indexOf(start); + expect(from, isNonNegative, reason: start); + final terminating = source.indexOf(branch, from); + expect(terminating, greaterThan(from), reason: branch); + final before = source.substring(from, terminating); + for (final use in uses) { + expect(before, isNot(contains(use)), reason: 'before the branch: $use'); + expect( + source.indexOf(use, terminating), + greaterThan(terminating), + reason: 'after the branch: $use', + ); + } + } + + test( + 'Android: the isTerminating branch precedes every use of Dart, time or the report', + () { + guardOrder( + pipelineJava, + 'private void dispatchOnce(', + 'if (isTerminating)', + [ + 'registrationFor(', + 'scheduler.schedule(', + 'platform.post(', + 'handles.put(', + 'safeId(', + 'ReportOps.typeOf(', + 'clock.nowMs(', + 'readOption(', + 'threadName.current(', + ], + ); + }, + ); + + test( + 'iOS: the isTerminating branch precedes every use of Dart, time or the report', + () { + guardOrder( + pipelineObjc, + '- (void)dispatchOnce:', + 'if (isTerminating)', + [ + '_registrations', + '_schedule(', + '_platformPost(', + '_handles[', + 'SafeReportId', + 'typeOfReport', + '_clock(', + ], + ); + }, + ); + + test( + 'the Dart message has no isTerminating field: Dart is never asked', + () { + final bridge = code('pigeons/bridge.dart'); + expect(bridge, isNot(contains('isTerminating'))); + }, + ); + }); + + group('nothing on the report path waits (the deadlock)', () { + // The guard covers every file a report callback or a Dart reply passes through, not only + // the pipelines. An exception is listed here, with its reason, or it is a failure. + final javaFiles = [ + '$java/ReportHandlerPipeline.java', + '$java/ReportHandlerDeadlines.java', + '$java/ReportOps.java', + '$java/FlutterWrapper.java', + '$java/BugseeFlutterPlugin.java', + ]; + final objcFiles = [ + '$objc/BGSFlutterReportHandlerPipeline.m', + '$objc/BGSFlutterReportOps.m', + '$objc/BGSFlutterWrapper.m', + 'ios/bugsee_flutter/Sources/bugsee_flutter/BugseeFlutterPlugin.swift', + ]; + const javaBlocking = [ + 'CountDownLatch', + '.await(', + '.take(', + 'Thread.sleep', + '.join(', + '.wait(', + 'Semaphore', + 'LockSupport', + 'Exchanger', + 'CyclicBarrier', + 'Phaser', + ]; + const objcBlocking = [ + 'dispatch_semaphore_wait', + 'dispatch_group_wait', + 'dispatch_sync', + 'sleep(', + 'usleep', + 'NSCondition', + 'waitUntilDate', + 'DispatchSemaphore', + 'DispatchGroup', + '.wait(', + 'pthread_cond_wait', + 'pthread_join', + ]; + + test('no blocking primitive in any Java file of the report path', () { + for (final path in javaFiles) { + final text = code(path); + for (final blocking in javaBlocking) { + expect(text, isNot(contains(blocking)), reason: '$path: $blocking'); + } + // A Future's get() blocks, whatever the variable is called: no get() without a key, and no + // get(timeout, unit). (Map.get(key) and Atomic*.get() on the report path are not used.) + expect( + RegExp(r'\.get\(\s*\)|\.get\(\s*\d+\s*,').hasMatch(text), + isFalse, + reason: '$path: Future.get', + ); + } + }); + + test( + 'no blocking primitive in any Objective-C or Swift file of the report path', + () { + for (final path in objcFiles) { + final text = code(path); + for (final blocking in objcBlocking) { + expect(text, isNot(contains(blocking)), reason: '$path: $blocking'); + } + } + }, + ); + + test('monitors are allowed only where listed, each with its reason', () { + int count(String path, String word) => word.allMatches(code(path)).length; + // Report pipeline (Java): the per-handle lock, held for "check live + apply" and for + // flipping `done` (2 sites), around the report's own setters; never across the SDK's + // completion, Dart or the scheduler. + expect(count('$java/ReportHandlerPipeline.java', 'synchronized'), 2); + // Pre-existing, not on the report path: one-time creation of the shared environment. + expect(count('$java/BugseeFlutterPlugin.java', 'synchronized'), 1); + for (final path in [ + '$java/ReportHandlerDeadlines.java', + '$java/ReportOps.java', + '$java/FlutterWrapper.java', + ]) { + expect(count(path, 'synchronized'), 0, reason: path); + } + // iOS: `@synchronized` guards only the once-flag of the SDK's completion (trivial, no + // call out while held); the registrar's three are pre-existing registration code. + expect( + count('$objc/BGSFlutterReportHandlerPipeline.m', '@synchronized'), + 1, + ); + expect(count('$objc/BGSFlutterReportOps.m', '@synchronized'), 0); + expect(count('$objc/BGSFlutterWrapper.m', '@synchronized'), 3); + }); + + test('the request is posted to the platform thread, never run inline', () { + expect(pipelineJava, contains('platform.post(')); + expect(pipelineObjc, contains('_platformPost(')); + // Production hop on iOS: always asynchronous, even when the caller IS main. + expect( + pipelineObjc, + contains('dispatch_async(dispatch_get_main_queue(), task)'), + ); + }); + + test( + 'the SDK completion, Dart and the scheduler are called outside the handle lock', + () { + // Java: every `synchronized (handle)` block, braces balanced, calls none of them. + var from = 0; + var blocks = 0; + while (true) { + final at = pipelineJava.indexOf('synchronized (handle) {', from); + if (at < 0) break; + var depth = 0; + var end = at; + for ( + var i = pipelineJava.indexOf('{', at); + i < pipelineJava.length; + i++ + ) { + if (pipelineJava[i] == '{') depth++; + if (pipelineJava[i] == '}' && --depth == 0) { + end = i; + break; + } + } + final block = pipelineJava.substring(at, end); + for (final out in [ + 'sdkCompletion', + 'runQuietly', + 'scheduler.', + 'sink.deliver', + 'platform.post', + 'outcomes.', + 'cancelQuietly', + ]) { + expect(block, isNot(contains(out)), reason: out); + } + blocks++; + from = end; + } + expect(blocks, 2); + // ObjC: the edit lock is unlocked before the completion runs in finish. + final finish = pipelineObjc.substring( + pipelineObjc.indexOf('- (BOOL)finish:'), + ); + expect( + finish.lastIndexOf('[handle.editLock unlock]'), + lessThan(finish.indexOf('RunQuietly(completion)')), + ); + }, + ); + }); + + group('the wrapper routes both phases through the pipeline', () { + test('Android', () { + expect( + wrapperJava, + contains('reports.dispatch(ReportHandlerPipeline.Phase.BEFORE'), + ); + expect( + wrapperJava, + contains('reports.dispatch(ReportHandlerPipeline.Phase.AFTER'), + ); + // No direct completion: that would skip Dart entirely. + expect(wrapperJava, isNot(contains('completionCallback.run()'))); + }); + + test('iOS', () { + expect( + wrapperObjc, + contains('dispatchPhase:BGSFlutterReportPhaseBefore'), + ); + expect(wrapperObjc, contains('dispatchPhase:BGSFlutterReportPhaseAfter')); + expect(wrapperObjc, isNot(contains('completion();'))); + expect(wrapperObjc, contains('onMainThread:[NSThread isMainThread]')); + }); + }); + + group('requestData always answers null (workbook 6.1)', () { + test('Android answers null, and catches whatever the callback throws', () { + final start = wrapperJava.indexOf('public void requestData'); + final body = wrapperJava.substring( + wrapperJava.indexOf('{', start), + wrapperJava.indexOf('@Override', start), + ); + expect(body, contains('callback.onResult(null)')); + expect(body, contains('catch (Throwable')); + expect( + body, + isNot(contains('dataType')), + reason: 'no type is special yet', + ); + }); + + test('iOS answers nil, and catches whatever the callback throws', () { + final start = wrapperObjc.indexOf('requestDataWithType'); + final body = wrapperObjc.substring( + wrapperObjc.indexOf('{', start), + wrapperObjc.indexOf('- (void)onLifecycleEvent', start), + ); + expect(body, contains('onResult:nil')); + expect(body, contains('@catch')); + expect(body, isNot(contains('dataType'))); + }); + }); +} diff --git a/test/report_handlers_test.dart b/test/report_handlers_test.dart new file mode 100644 index 0000000..a1effe0 --- /dev/null +++ b/test/report_handlers_test.dart @@ -0,0 +1,629 @@ +// The Dart end of the report handlers (workbook Part 7), over the REAL generated +// channels: a fake native end answers on the pigeon channels with the real codec, +// and pushes deliveries the way native does, so the wire shape is exercised too. +import 'dart:async'; + +import 'package:bugsee_flutter/src/bridge/bridge.dart'; +import 'package:bugsee_flutter/src/bridge/messages.g.dart'; +import 'package:bugsee_flutter/src/report/report_enums.dart'; +import 'package:bugsee_flutter/src/report/report_proxy.dart'; +import 'package:flutter/foundation.dart' show FlutterError, FlutterErrorDetails; +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_test/flutter_test.dart'; + +const _host = 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeHostApi'; +const _dart = 'dev.flutter.pigeon.bugsee_flutter.BugseeBridgeFlutterApi'; +final _codec = BugseeBridgeHostApi.pigeonChannelCodec; + +/// Native's side of the report channels: a live report per handle, the calls it +/// received in order, and the rule that a completed handle refuses every edit. +class FakeNative { + FakeNative(this.messenger) { + for (final method in _methods) { + messenger.setMockMessageHandler('$_host.$method', (data) async { + // A call with no arguments (resetReportHandlers) sends no payload. + final args = data == null + ? const [] + : _codec.decodeMessage(data)! as List; + try { + return _codec.encodeMessage([_handle(method, args)]); + } on PlatformException catch (e) { + return _codec.encodeMessage([e.code, e.message, e.details]); + } + }); + } + } + + static const _methods = [ + 'resetReportHandlers', + 'setReportHandlerPhases', + 'completeReportHandler', + 'reportRead', + 'reportSetSummary', + 'reportSetDescription', + 'reportSetEmail', + 'reportSetSeverity', + 'reportReplaceLabels', + 'reportAddLabels', + 'reportClearLabels', + 'reportSetAttribute', + 'reportRemoveAttribute', + 'reportClearAttributes', + ]; + + final TestDefaultBinaryMessenger messenger; + + /// Every call, `method(args...)` in order. + final List calls = []; + final Set closed = {}; + final Map summaryOf = {}; + final Map severityOf = {}; + final Map> labelsOf = {}; + final Map> attributesOf = {}; + bool before = false; + bool after = false; + int completions = 0; + + /// Native refuses an edit as of now, as when its deadline passed first. + void closeNow(String handle) => closed.add(handle); + + Object? _handle(String method, List args) { + calls.add('$method(${args.join(', ')})'); + if (method == 'resetReportHandlers') { + before = after = false; + return null; + } + if (method == 'setReportHandlerPhases') { + before = args[0]! as bool; + after = args[1]! as bool; + return null; + } + final handle = args[0]! as String; + if (method == 'completeReportHandler') { + completions++; + closed.add(handle); + return null; + } + final live = !closed.contains(handle); + switch (method) { + case 'reportRead': + if (!live) return null; + return ReportSnapshotMessage( + id: 'r-$handle', + type: 'crash', + summary: summaryOf[handle] as String?, + severity: severityOf[handle], + labels: labelsOf[handle] ?? [], + attributes: attributesOf[handle] ?? {}, + ); + case 'reportSetSeverity': + final v = args[1]! as int; + if (v < 1 || v > 5) { + throw PlatformException(code: 'invalid-argument'); + } + if (live) severityOf[handle] = v; + case 'reportSetSummary': + if (live) summaryOf[handle] = args[1]; + case 'reportReplaceLabels': + if (live) labelsOf[handle] = List.from(args[1]! as List); + case 'reportSetAttribute': + if (live) { + (attributesOf[handle] ??= {})[args[1]! as String] = args[2]!; + } + default: + break; + } + return live; + } + + /// Native pushes one delivery to Dart, as `BugseeFlutterPlugin` does. + Future deliver({ + required String handle, + String? deliveryId, + bool after = false, + String reportId = 'r1', + String type = 'bug', + int deadlineMs = 25000, + }) async { + await messenger.handlePlatformMessage( + '$_dart.onReportHandlerRequest', + _codec.encodeMessage([ + ReportHandlerRequestMessage( + handle: handle, + deliveryId: deliveryId ?? '${after ? 'after' : 'before'}:$reportId', + after: after, + reportId: reportId, + type: type, + deadlineMs: deadlineMs, + ), + ]), + (_) {}, + ); + } + + void dispose() { + for (final method in _methods) { + messenger.setMockMessageHandler('$_host.$method', null); + } + } +} + +Future settle() => Future.delayed(const Duration(milliseconds: 20)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + late FakeNative native; + late BugseeBridge bridge; + setUp(() { + native = FakeNative(messenger); + bridge = BugseeBridge(); + }); + tearDown(() { + native.dispose(); + BugseeBridgeFlutterApi.setUp(null); + }); + + group('registration', () { + test('tells native which phases have a Dart handler', () async { + await bridge.setReportHandlers(onBefore: (_) {}); + expect([native.before, native.after], [true, false]); + await bridge.setReportHandlers(onAfter: (_) {}); + expect([native.before, native.after], [false, true]); + await bridge.setReportHandlers(); + expect([native.before, native.after], [false, false]); + }); + + test( + 'a request for a phase with no handler is completed at once', + () async { + await bridge.setReportHandlers(onBefore: (_) {}); + await native.deliver(handle: 'h1', after: true); + await settle(); + expect(native.completions, 1); + expect(native.closed, {'h1'}); + expect(native.calls.where((c) => c.startsWith('reportSet')), isEmpty); + }, + ); + + test( + 'with no handler at all every request is completed, never run', + () async { + await bridge.setReportHandlers(); + await native.deliver(handle: 'h1'); + await native.deliver(handle: 'h2', after: true); + await settle(); + expect(native.completions, 2); + }, + ); + }); + + group('delivery and the proxy', () { + test('the handler gets a proxy of the report, not a copy', () async { + ReportProxy? seen; + await bridge.setReportHandlers(onBefore: (r) => seen = r); + await native.deliver(handle: 'h1', reportId: 'abc', type: 'crash'); + await settle(); + expect(seen, isNotNull); + expect(seen!.id, 'abc'); + expect(seen!.type, ReportType.crash); + expect(seen!.typeName, 'crash'); + expect(seen!.deliveryId, 'before:abc'); + }); + + test('a type this version does not know is kept as text', () async { + ReportProxy? seen; + await bridge.setReportHandlers(onBefore: (r) => seen = r); + await native.deliver(handle: 'h1', type: 'anr'); + await settle(); + expect(seen!.type, isNull); + expect(seen!.typeName, 'anr'); + }); + + test( + 'edits cross as operations on the handle, severity by value', + () async { + await bridge.setReportHandlers( + onBefore: (r) async { + await r.setSummary('s'); + await r.setSeverity(ReportSeverity.critical); + await r.replaceLabels(['a', 'b']); + await r.setAttribute('k', 3); + await r.setAttribute('b', true); + await r.setAttribute('d', 1.5); + await r.setAttribute('t', 'x'); + }, + ); + await native.deliver(handle: 'h1'); + await settle(); + expect(native.summaryOf['h1'], 's'); + // Critical is index 3 and value 4: the value crosses. + expect(native.severityOf['h1'], 4); + expect(native.labelsOf['h1'], ['a', 'b']); + expect(native.attributesOf['h1'], { + 'k': 3, + 'b': true, + 'd': 1.5, + 't': 'x', + }); + expect( + native.calls.where((c) => c.contains('Labels')), + ['reportReplaceLabels(h1, [a, b])'], + reason: 'labels are replaced in ONE operation, never clear then add', + ); + }, + ); + + test('read returns the live report', () async { + ReportSnapshot? snap; + native.severityOf['h1'] = 5; + native.labelsOf['h1'] = ['l']; + native.attributesOf['h1'] = {'a': 'b'}; + await bridge.setReportHandlers( + onBefore: (r) async => snap = await r.read(), + ); + await native.deliver(handle: 'h1'); + await settle(); + expect(snap!.severity, ReportSeverity.blocker); + expect(snap!.labels, ['l']); + expect(snap!.attributes, {'a': 'b'}); + expect(snap!.type, ReportType.crash); + expect(() => snap!.labels.add('x'), throwsUnsupportedError); + }); + + test( + 'an iOS severity of "never set" reads as null, not as a level', + () async { + ReportSnapshot? snap; + await bridge.setReportHandlers( + onBefore: (r) async => snap = await r.read(), + ); + await native.deliver(handle: 'h1'); + await settle(); + expect(snap!.severity, isNull); + expect(ReportSeverity.fromValue(0), isNull); + expect(ReportSeverity.fromValue(6), isNull); + }, + ); + + test( + 'an attribute value both SDKs cannot carry is refused before it crosses', + () async { + Object? failure; + await bridge.setReportHandlers( + onBefore: (r) async { + for (final bad in [ + double.nan, + double.infinity, + ['x'], + {'a': 1}, + Object(), + ]) { + expect(() => r.setAttribute('k', bad), throwsArgumentError); + } + expect(() => r.setAttribute('', 'v'), throwsArgumentError); + failure = 'ran'; + }, + ); + await native.deliver(handle: 'h1'); + await settle(); + expect(failure, 'ran'); + expect( + native.calls.where((c) => c.startsWith('reportSetAttribute')), + isEmpty, + ); + }, + ); + + test( + 'native refusing an argument is an error, and the report stays live', + () async { + Object? error; + bool? live; + await bridge.setReportHandlers( + onBefore: (r) async { + try { + // Not reachable through the typed API; this is native's own guard. + await BugseeBridgeHostApi().reportSetSeverity('h1', 9); + } on PlatformException catch (e) { + error = e.code; + } + live = r.isLive; + }, + ); + await native.deliver(handle: 'h1'); + await settle(); + expect(error, 'invalid-argument'); + expect(live, isTrue); + }, + ); + }); + + group('the report is live only until the completion runs (7.3.4)', () { + test('the completion is sent once, when the handler returns', () async { + final gate = Completer(); + await bridge.setReportHandlers(onBefore: (r) => gate.future); + await native.deliver(handle: 'h1'); + await settle(); + expect(native.completions, 0); + gate.complete(); + await settle(); + expect(native.completions, 1); + }); + + test('a late mutation throws, loudly, and nothing is sent', () async { + late ReportProxy kept; + await bridge.setReportHandlers(onBefore: (r) async => kept = r); + await native.deliver(handle: 'h1'); + await settle(); + expect(kept.isLive, isFalse); + final callsBefore = native.calls.length; + await expectLater( + kept.setSummary('late'), + throwsA(isA()), + ); + await expectLater( + kept.setSeverity(ReportSeverity.high), + throwsA(isA()), + ); + await expectLater(kept.replaceLabels(['x']), throwsStateError); + await expectLater(kept.setAttribute('a', 1), throwsStateError); + await expectLater(kept.read(), throwsStateError); + expect( + native.calls.length, + callsBefore, + reason: 'no late edit reached native', + ); + expect(native.summaryOf['h1'], isNull); + }); + + test( + 'an edit an unawaited continuation makes after the return fails loudly', + () async { + final errors = []; + await runZonedGuarded(() async { + await bridge.setReportHandlers( + onBefore: (r) { + // Forgot to await: runs after the handler has returned. + Future.delayed( + const Duration(milliseconds: 60), + () => r.setSummary('late'), + ); + }, + ); + await native.deliver(handle: 'h1'); + await Future.delayed(const Duration(milliseconds: 150)); + }, (e, s) => errors.add(e)); + expect(errors.single, isA()); + expect(native.summaryOf['h1'], isNull); + }, + ); + + test( + 'native completing first (its deadline) turns the next edit into an error', + () async { + final gate = Completer(); + Object? caught; + await bridge.setReportHandlers( + onBefore: (r) async { + await r.setSummary('early'); + native.closeNow('h1'); + try { + await r.setSummary('late'); + } catch (e) { + caught = e; + } + expect(r.isLive, isFalse); + await gate.future; + }, + ); + await native.deliver(handle: 'h1'); + await settle(); + expect(caught, isA()); + expect(native.summaryOf['h1'], 'early'); + gate.complete(); + await settle(); + }, + ); + + test('Dart closes the proxy itself when the deadline passes', () async { + final gate = Completer(); + Object? caught; + int? callsWhenClosed; + await bridge.setReportHandlers( + onBefore: (r) async { + await Future.delayed(const Duration(milliseconds: 80)); + callsWhenClosed = native.calls.length; + try { + await r.setSummary('late'); + } catch (e) { + caught = e; + } + await gate.future; + }, + ); + await native.deliver(handle: 'h1', deadlineMs: 30); + await Future.delayed(const Duration(milliseconds: 150)); + expect(caught, isA()); + expect( + native.calls.length, + callsWhenClosed, + reason: 'refused without a round trip', + ); + gate.complete(); + await settle(); + }); + + test( + 'a handler that throws is reported, and the report still proceeds', + () async { + final reported = []; + final old = FlutterError.onError; + FlutterError.onError = reported.add; + addTearDown(() => FlutterError.onError = old); + await bridge.setReportHandlers( + onBefore: (r) => throw StateError('boom'), + ); + await native.deliver(handle: 'h1'); + await settle(); + expect(reported.single.exception, isA()); + expect(native.completions, 1); + }, + ); + + test('an error handler that itself throws does not keep the completion ' + 'from running', () async { + final old = FlutterError.onError; + FlutterError.onError = (_) => throw StateError('the app handler broke'); + addTearDown(() => FlutterError.onError = old); + await bridge.setReportHandlers(onBefore: (r) => throw StateError('boom')); + await native.deliver(handle: 'h1'); + await settle(); + expect(native.completions, 1); + }); + + test('an asynchronous failure in the handler also completes', () async { + final old = FlutterError.onError; + FlutterError.onError = (_) {}; + addTearDown(() => FlutterError.onError = old); + await bridge.setReportHandlers( + onAfter: (r) async { + await Future.delayed(Duration.zero); + throw ArgumentError('late failure'); + }, + ); + await native.deliver(handle: 'h1', after: true); + await settle(); + expect(native.completions, 1); + }); + }); + + group('the budget', () { + test( + 'a handler that overruns it is reported even if it edits nothing more', + () async { + final reported = []; + final old = FlutterError.onError; + FlutterError.onError = reported.add; + addTearDown(() => FlutterError.onError = old); + await bridge.setReportHandlers( + onBefore: (r) async { + await r.setSummary('early'); + await Future.delayed(const Duration(milliseconds: 120)); + // No further edit: nothing else would say that the handler ran long. + }, + ); + await native.deliver(handle: 'h1', deadlineMs: 40); + await Future.delayed(const Duration(milliseconds: 250)); + expect(reported, hasLength(1)); + expect( + reported.single.exception.toString(), + contains('ran past its 40 ms budget'), + ); + expect(native.completions, 1); + }, + ); + + test('a handler inside the budget is not reported', () async { + final reported = []; + final old = FlutterError.onError; + FlutterError.onError = reported.add; + addTearDown(() => FlutterError.onError = old); + await bridge.setReportHandlers(onBefore: (r) => r.setSummary('x')); + await native.deliver(handle: 'h1', deadlineMs: 5000); + await settle(); + expect(reported, isEmpty); + }); + }); + + group('delivery is not once (7.2)', () { + test( + 'onAfter delivered twice for one report runs the handler once', + () async { + var runs = 0; + await bridge.setReportHandlers(onAfter: (r) async => runs++); + await native.deliver(handle: 'h1', after: true, reportId: 'r1'); + await settle(); + await native.deliver(handle: 'h2', after: true, reportId: 'r1'); + await settle(); + expect(runs, 1); + expect(native.completions, 2, reason: 'the SDK is never left waiting'); + }, + ); + + test( + 'a concurrent re-delivery is completed, not run in parallel', + () async { + var runs = 0; + final gate = Completer(); + await bridge.setReportHandlers( + onAfter: (r) async { + runs++; + await gate.future; + }, + ); + await native.deliver(handle: 'h1', after: true); + await native.deliver(handle: 'h2', after: true); + await settle(); + expect(runs, 1); + expect( + native.completions, + 1, + reason: 'the duplicate completed at once', + ); + gate.complete(); + await settle(); + expect(native.completions, 2); + }, + ); + + test('other reports and the other phase are not duplicates', () async { + final ran = []; + await bridge.setReportHandlers( + onBefore: (r) => ran.add('b:${r.id}'), + onAfter: (r) => ran.add('a:${r.id}'), + ); + await native.deliver(handle: 'h1', reportId: 'r1'); + await native.deliver(handle: 'h2', reportId: 'r1', after: true); + await native.deliver(handle: 'h3', reportId: 'r2'); + await native.deliver(handle: 'h4', reportId: 'r2', after: true); + await settle(); + expect(ran, ['b:r1', 'a:r1', 'b:r2', 'a:r2']); + }); + + test( + 'a re-delivery is recognised by the stable id, not the handle', + () async { + var runs = 0; + await bridge.setReportHandlers(onAfter: (r) async => runs++); + for (var i = 0; i < 3; i++) { + await native.deliver( + handle: 'h$i', + after: true, + deliveryId: 'after:same', + ); + await settle(); + } + expect(runs, 1); + }, + ); + + test('only the most recent ids are remembered', () async { + var runs = 0; + await bridge.setReportHandlers(onAfter: (r) async => runs++); + const n = BugseeBridge.deliveredCapacity; + for (var i = 0; i < n + 1; i++) { + await native.deliver(handle: 'h$i', after: true, reportId: 'r$i'); + } + await settle(); + expect(runs, n + 1); + // The newest is still remembered; the oldest has been forgotten. + await native.deliver(handle: 'x1', after: true, reportId: 'r$n'); + await native.deliver(handle: 'x2', after: true, reportId: 'r0'); + await settle(); + expect(runs, n + 2); + }); + }); +}