From 28b8eb6e092a8ada1298b9905cf32c17b83018e0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 20 Oct 2026 23:21:34 +0500 Subject: [PATCH 01/12] Filters: fail closed after a JS reload until the new runtime installs its filter (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native network, breadcrumb and log filters outlive a JS runtime, but a new runtime has not subscribed. Each bridge now tracks whether the current module's JS armed the filter; attaching a module disarms it, and a request while disarmed is dropped at once (no emit to nobody, no 9s wait, no iOS pending-forever). Android log filter no longer resets to the unfiltered pass-through on module creation, so a redaction the app relied on keeps applying (dropping lines) until setLogFilter is called again or cleared. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../reactnative/BreadcrumbFilterBridge.java | 19 ++++++- .../com/bugsee/reactnative/BugseeModule.java | 7 ++- .../bugsee/reactnative/LogFilterBridge.java | 23 +++++++- .../reactnative/NetworkFilterBridge.java | 21 +++++++ .../BreadcrumbFilterBridgeTest.java | 45 +++++++++++++++ .../reactnative/LogFilterBridgeTest.java | 57 +++++++++++++++++++ .../reactnative/NetworkFilterBridgeTest.java | 44 ++++++++++++++ packages/react-native/ios/BugseeModule.mm | 39 ++++++++++++- .../BugseeRNSupport/BGSRNFilterArming.m | 22 +++++++ .../include/BGSRNFilterArming.h | 26 +++++++++ .../BGSRNFilterArmingTests.m | 48 ++++++++++++++++ .../react-native/src/breadcrumbs/filter.ts | 5 ++ packages/react-native/src/logs/filter.ts | 5 ++ packages/react-native/src/network/filter.ts | 5 ++ 14 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNFilterArming.m create mode 100644 packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNFilterArming.h create mode 100644 packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNFilterArmingTests.m diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java index 2921f5cf..b7d10fcf 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java @@ -86,6 +86,15 @@ static BreadcrumbFilterBridge shared() { private final ConcurrentHashMap pending = new ConcurrentHashMap<>(); private final AtomicLong ids = new AtomicLong(); private final AtomicBoolean installed = new AtomicBoolean(); + + /** + * True once the CURRENT module's JS has installed its filter with + * {@code setEnabled(true)}. A reload attaches a new module whose JS has + * not subscribed yet, while this bridge (and the SDK's filter) outlive + * the old runtime. Until JS arms it again, a request would be emitted to + * nobody, so it is dropped at once instead: fail closed, no wait. + */ + private final AtomicBoolean armed = new AtomicBoolean(); private final Object sdkLock = new Object(); /** * The id of the manual {@code addBreadcrumb} on this thread. Set only @@ -115,6 +124,13 @@ public void filter( void attach(@NonNull final Sink next) { sink.set(next); + // A new module's JS has not subscribed yet; see armed. + armed.set(false); + } + + /** The current module's JS now answers requests. {@link #setEnabled}{@code (true)} calls this. */ + void arm() { + armed.set(true); } /** @@ -154,6 +170,7 @@ void withSdkLock(@NonNull final Runnable body) { void setEnabled(final boolean enabled) { withSdkLock(() -> { if (enabled) { + arm(); if (installed.compareAndSet(false, true)) { Bugsee.setBreadcrumbFilter(filter); } @@ -214,7 +231,7 @@ void ask( @NonNull final Callback1 callback ) { final Sink current = sink.get(); - if (current == null) { + if (current == null || !armed.get()) { drop(callback); return; } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 29123cb7..a6fff34f 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -146,8 +146,11 @@ public BugseeModule(final ReactApplicationContext context) { NetworkFilterBridge.shared().attach(this); LogFilterBridge.shared().attach(this); // The echo drop is installed even when the app never calls setLogFilter. - // setEnabled(false) is that pass-through; it does not remove the filter. - LogFilterBridge.shared().setEnabled(false); + // Not setEnabled(false): after a reload that would turn a redaction the + // app relied on into a silent pass-through. attach() above disarmed the + // bridge, so with a user filter still on, lines are dropped until the new + // runtime calls setLogFilter (re-arm) or clears it (pass-through). + LogFilterBridge.shared().ensureInstalled(); BreadcrumbFilterBridge.shared().attach(this); } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java index d10054d6..161741b1 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java @@ -120,6 +120,15 @@ static LogFilterBridge shared() { /** True only while the app has a log callback. False is the native pass-through. */ private final AtomicBoolean userFilter = new AtomicBoolean(); + /** + * True once the CURRENT module's JS has installed its filter with + * {@code setEnabled(true)}. A reload attaches a new module whose JS has + * not subscribed yet, while this bridge (and the SDK's filter) outlive + * the old runtime. Until JS arms it again, a request would be emitted to + * nobody, so it is dropped at once instead: fail closed, no wait. + */ + private final AtomicBoolean armed = new AtomicBoolean(); + /** Production: one daemon thread forgets pending requests at {@link #BORROW_MS}. */ private LogFilterBridge() { this(new DaemonScheduler(), System::currentTimeMillis, Bugsee::setLogEventFilter); @@ -163,6 +172,15 @@ public void filter( void attach(@NonNull final Sink next) { sink.set(next); + // A new module's JS has not subscribed yet; see armed. userFilter is + // deliberately kept: a redaction the app relied on must not silently + // stop applying across a reload. + armed.set(false); + } + + /** The current module's JS now answers requests. {@link #setEnabled}{@code (true)} calls this. */ + void arm() { + armed.set(true); } /** @@ -196,6 +214,9 @@ void detach(@NonNull final Sink current) { * {@link Bugsee#setLogEventFilter}. */ void setEnabled(final boolean enabled) { + if (enabled) { + arm(); + } userFilter.set(enabled); ensureInstalled(); } @@ -285,7 +306,7 @@ void ask( ) { final String line = event.getMessage(); final Sink current = sink.get(); - if (line == null || current == null) { + if (line == null || current == null || !armed.get()) { drop(callback); return; } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index 6c480874..e76b6719 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -112,6 +112,15 @@ static NetworkFilterBridge shared() { private final AtomicLong ids = new AtomicLong(); private final AtomicBoolean installed = new AtomicBoolean(); + /** + * True once the CURRENT module's JS has installed its filter with + * {@code setEnabled(true)}. A reload attaches a new module whose JS has + * not subscribed yet, while this bridge (and the SDK's filter) outlive + * the old runtime. Until JS arms it again, a request would be emitted to + * nobody, so it is dropped at once instead: fail closed, no wait. + */ + private final AtomicBoolean armed = new AtomicBoolean(); + /** Production: one daemon thread forgets pending requests at {@link #BORROW_MS}. */ private NetworkFilterBridge() { this(new DaemonScheduler(), NetworkFilterBridge::readDefaultSanitizerOption); @@ -144,6 +153,13 @@ public void filter( void attach(@NonNull final Sink next) { sink.set(next); + // A new module's JS has not subscribed yet; see armed. + armed.set(false); + } + + /** The current module's JS now answers requests. {@link #setEnabled}{@code (true)} calls this. */ + void arm() { + armed.set(true); } /** @@ -170,6 +186,7 @@ void detach(@NonNull final Sink current) { /** Installs the filter, or removes it. A second {@code true} is a no-op. */ void setEnabled(final boolean enabled) { if (enabled) { + arm(); if (installed.compareAndSet(false, true)) { Bugsee.setNetworkEventFilter(filter); } @@ -221,6 +238,10 @@ void ask( @NonNull final NetworkEvent event, @NonNull final Callback1 callback ) { + if (!armed.get()) { + drop(callback); + return; + } // setNetworkEventFilter skips DefaultNetworkDataSanitizer. The // capture provider runs it only when no filter is installed, and // only when CaptureNetworkUseDefaultSanitizer is on (default true). diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java index 81e46118..197e329c 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java @@ -170,6 +170,7 @@ public void snapshotUsesGetValueAndOmitsUnsetKeys() throws Exception { assertEquals(2, Breadcrumb.Level.WARNING.ordinal()); final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.level = Breadcrumb.Level.WARNING; crumb.message = "secret"; @@ -190,6 +191,7 @@ public void snapshotUsesGetValueAndOmitsUnsetKeys() throws Exception { public void snapshotIncludesDataAndAStampedTimestamp() throws Exception { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.category = "ui"; crumb.message = "tap"; @@ -213,6 +215,7 @@ public void snapshotIncludesDataAndAStampedTimestamp() throws Exception { public void aReplyWritesGetValueNotTheOrdinal() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.level = Breadcrumb.Level.FATAL; final RecordingCallback callback = new RecordingCallback(); @@ -230,6 +233,7 @@ public void aReplyWritesGetValueNotTheOrdinal() { public void aNullReplyDropsWithoutPassingTheCrumbThrough() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.message = "secret"; crumb.level = Breadcrumb.Level.ERROR; @@ -249,6 +253,7 @@ public void aNullReplyDropsWithoutPassingTheCrumbThrough() { public void aKeepRewritesTheNamedFieldsAndClearsDataWithNull() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.category = "ui"; crumb.message = "secret"; @@ -274,6 +279,7 @@ public void aKeepRewritesTheNamedFieldsAndClearsDataWithNull() { public void aReplyWritesData() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.message = "secret"; final RecordingCallback callback = new RecordingCallback(); @@ -293,6 +299,7 @@ public void aReplyWritesData() { public void aReplyThatDoesNotStickDropsTheCrumb() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.category = "secret"; crumb.stick = false; @@ -312,6 +319,7 @@ public void aReplyThatDoesNotStickDropsTheCrumb() { public void anIntegerLevelDropsTheCrumb() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.level = Breadcrumb.Level.INFO; final RecordingCallback callback = new RecordingCallback(); @@ -339,6 +347,7 @@ public void aFailedEmitDropsAndALaterReplyIsANoOp() { final RecordingSink sink = new RecordingSink(); sink.throwOnEmit = true; bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.message = "secret"; final RecordingCallback callback = new RecordingCallback(); @@ -355,6 +364,7 @@ public void aFailedEmitDropsAndALaterReplyIsANoOp() { public void detachDropsAPendingCrumbAndALaterReplyDoesNotWriteIt() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.message = "secret"; final RecordingCallback callback = new RecordingCallback(); @@ -374,6 +384,7 @@ public void detachDropsAPendingCrumbAndALaterReplyDoesNotWriteIt() { public void aSecondReplyIsANoOp() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.message = "secret"; final RecordingCallback callback = new RecordingCallback(); @@ -392,6 +403,7 @@ public void aSecondReplyIsANoOp() { public void dataThatCannotBeSerialisedDropsTheCrumb() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableCrumb crumb = new MutableCrumb(); crumb.message = "secret"; final Map data = new HashMap<>(); @@ -404,4 +416,37 @@ public void dataThatCannotBeSerialisedDropsTheCrumb() { assertTrue(callback.sawNull); assertEquals("secret", crumb.message); } + + @Test + public void aNewModuleThatHasNotArmedDropsTheCrumbAtOnce() { + final RecordingSink old = new RecordingSink(); + bridge.attach(old); + bridge.arm(); + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + + final MutableCrumb crumb = new MutableCrumb(); + crumb.message = "secret"; + final RecordingCallback callback = new RecordingCallback(); + bridge.ask(crumb, callback); + + assertEquals(0, next.json.size()); + assertEquals(1, callback.runs); + assertTrue(callback.sawNull); + } + + @Test + public void armingAgainAsksTheNewModule() { + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + bridge.arm(); + + final MutableCrumb crumb = new MutableCrumb(); + crumb.message = "secret"; + final RecordingCallback callback = new RecordingCallback(); + bridge.ask(crumb, callback); + + assertEquals(1, next.json.size()); + assertEquals(0, callback.runs); + } } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java index 84157ed5..58c6e1d9 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java @@ -139,6 +139,7 @@ public LogLevel getLevel() { public void aReplyAfterTheMessageChangedDoesNotTouchTheEntry() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableLog event = new MutableLog("secret"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -158,6 +159,7 @@ public void aReplyAfterTheMessageChangedDoesNotTouchTheEntry() { public void detachingAnOldSinkDropsItsPendingWithoutTouchingARecycledEntry() { final RecordingSink stale = new RecordingSink(); bridge.attach(stale); + bridge.arm(); final MutableLog event = new MutableLog("secret"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -166,6 +168,7 @@ public void detachingAnOldSinkDropsItsPendingWithoutTouchingARecycledEntry() { event.message = "another line"; final RecordingSink current = new RecordingSink(); bridge.attach(current); + bridge.arm(); bridge.detach(stale); assertEquals(0, callback.runs); @@ -193,6 +196,7 @@ public void aReplyAfterTheDeadlineIsANoOpWhenTheMessageIsUnchanged() { assertTrue(LogFilterBridge.BORROW_MS < 10_000L); final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableLog event = new MutableLog("secret"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -214,6 +218,7 @@ public void aReplyAfterTheDeadlineIsANoOpWhenTheMessageIsUnchanged() { public void aDeadlineForgetsThePendingWithoutCallingTheCallback() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableLog event = new MutableLog("secret"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -231,6 +236,7 @@ public void aDeadlineForgetsThePendingWithoutCallingTheCallback() { public void aReplyWhileTheEntryIsStillTheOriginalWritesItBack() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableLog event = new MutableLog("secret"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -249,6 +255,7 @@ public void aReplyWhileTheEntryIsStillTheOriginalWritesItBack() { public void detachDropsALiveBorrowAndSkipsARecycledOne() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableLog live = new MutableLog("keep-or-drop"); final RecordingCallback liveCallback = new RecordingCallback(); bridge.ask(live, liveCallback); @@ -275,6 +282,7 @@ public void detachDropsALiveBorrowAndSkipsARecycledOne() { public void routeAsksTheChannelLineAndDropsItsLogcatEcho() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); bridge.setEnabled(true); bridge.noteEcho("hello"); final RecordingCallback channelCallback = new RecordingCallback(); @@ -313,6 +321,7 @@ public void routeAsksTheChannelLineAndDropsItsLogcatEcho() { public void anEchoFilteredBeforeTheChannelLineIsDroppedAndTheChannelLineAsked() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); bridge.setEnabled(true); bridge.noteEcho("dedup-raw n"); @@ -338,6 +347,7 @@ public void anEchoFilteredBeforeTheChannelLineIsDroppedAndTheChannelLineAsked() public void twoIdenticalConsoleLinesAreAskedTwiceAndBothEchoesDropped() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); bridge.setEnabled(true); bridge.noteEcho("same"); bridge.noteEcho("same"); @@ -369,6 +379,7 @@ public void twoIdenticalConsoleLinesAreAskedTwiceAndBothEchoesDropped() { public void anEchoFilteredBeforeItsNoteIsKept() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); bridge.setEnabled(true); final RecordingCallback echoCallback = new RecordingCallback(); @@ -460,4 +471,50 @@ public String getTag() { return tag; } } + + /** + * The reload case: the app had a log filter, the new module attaches but + * its JS has not called setLogFilter. Lines are dropped, never recorded + * unfiltered, and no request is left pending. + */ + @Test + public void afterAReloadAUserFilterStaysOnAndDropsUntilJsArmsAgain() { + final RecordingSink old = new RecordingSink(); + bridge.attach(old); + bridge.setEnabled(true); + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + bridge.ensureInstalled(); + + final RecordingCallback dropped = new RecordingCallback(); + bridge.route(new MutableLog("secret"), dropped); + assertEquals(0, next.ids.size()); + assertEquals(1, dropped.runs); + assertTrue(dropped.sawNull); + assertEquals(0, scheduler.tasks.size()); + + bridge.setEnabled(true); + final RecordingCallback asked = new RecordingCallback(); + bridge.route(new MutableLog("secret"), asked); + assertEquals(1, next.ids.size()); + assertEquals(0, asked.runs); + } + + @Test + public void afterAReloadClearingTheFilterIsThePassThrough() { + final RecordingSink old = new RecordingSink(); + bridge.attach(old); + bridge.setEnabled(true); + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + + bridge.setEnabled(false); + final MutableLog event = new MutableLog("kept"); + final RecordingCallback callback = new RecordingCallback(); + bridge.route(event, callback); + + assertEquals(0, next.ids.size()); + assertEquals(1, callback.runs); + assertTrue(!callback.sawNull); + } } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java index 588c9736..b5ddd967 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java @@ -233,6 +233,7 @@ public WebSocketEventType getWebSocketEventType() { public void aReplyAfterTheUrlChangedDoesNotTouchTheEntry() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableNetwork event = new MutableNetwork("https://secret.example/token"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -252,6 +253,7 @@ public void aReplyAfterTheUrlChangedDoesNotTouchTheEntry() { public void aDeadlineForgetsThePendingWithoutCallingTheCallback() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableNetwork event = new MutableNetwork("https://secret.example/token"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -268,6 +270,7 @@ public void aDeadlineForgetsThePendingWithoutCallingTheCallback() { public void aReplyWhileTheEntryIsStillTheOriginalWritesItBack() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableNetwork event = new MutableNetwork("https://secret.example/token"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -285,6 +288,7 @@ public void aReplyWhileTheEntryIsStillTheOriginalWritesItBack() { public void aNullReplyDropsALiveBorrow() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableNetwork event = new MutableNetwork("https://secret.example/token"); final RecordingCallback callback = new RecordingCallback(); bridge.ask(event, callback); @@ -301,6 +305,7 @@ public void aNullReplyDropsALiveBorrow() { public void detachDropsALiveBorrowAndSkipsARecycledOne() { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableNetwork live = new MutableNetwork("https://live.example"); final RecordingCallback liveCallback = new RecordingCallback(); bridge.ask(live, liveCallback); @@ -328,6 +333,7 @@ public void aDefaultSanitizerRedactsTokenBeforeTheUrlIsRemembered() throws Excep final NetworkFilterBridge sanitizing = new NetworkFilterBridge(localScheduler, () -> true); final RecordingSink sink = new RecordingSink(); sanitizing.attach(sink); + sanitizing.arm(); final MutableNetwork event = new MutableNetwork( "https://api.example/v1/items?token=bugsee-secret-token-value&ok=1" ); @@ -361,6 +367,7 @@ public void aDefaultSanitizerRedactsTokenBeforeTheUrlIsRemembered() throws Excep public void snapshotCarriesTheOverrideFlagAndAReplyCannotChangeIt() throws Exception { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableNetwork start = new MutableNetwork("https://api.example/v1/items"); final MutableNetwork supplement = new MutableNetwork("https://api.example/v1/items"); supplement.override = true; @@ -388,6 +395,7 @@ public void snapshotCarriesTheOverrideFlagAndAReplyCannotChangeIt() throws Excep public void jsonNullClearsErrorFieldsAndStatusText() throws Exception { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final MutableNetwork event = new MutableNetwork("https://api.example/v1/items"); event.errorDescription = "gateway"; event.errorShortMessage = "late"; @@ -416,6 +424,7 @@ public void jsonNullClearsErrorFieldsAndStatusText() throws Exception { public void aDisabledDefaultSanitizerLeavesTheTokenQuery() throws Exception { final RecordingSink sink = new RecordingSink(); bridge.attach(sink); + bridge.arm(); final String url = "https://api.example/v1/items?token=bugsee-secret-token-value&ok=1"; final MutableNetwork event = new MutableNetwork(url); bridge.ask(event, new RecordingCallback()); @@ -454,4 +463,39 @@ private static OptionsContainer options(final OptionRead read) { } ); } + + /** + * A reload attaches a new module whose JS has not installed its filter. + * The request is dropped at once: not emitted to nobody, not left to the + * 9s forget and the SDK's 10s timeout. + */ + @Test + public void aNewModuleThatHasNotArmedDropsAtOnceAndNothingIsPending() { + final RecordingSink old = new RecordingSink(); + bridge.attach(old); + bridge.arm(); + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + + final RecordingCallback callback = new RecordingCallback(); + bridge.ask(new MutableNetwork("https://secret.example/token"), callback); + + assertEquals(0, next.ids.size()); + assertEquals(1, callback.runs); + assertTrue(callback.sawNull); + assertEquals(0, scheduler.tasks.size()); + } + + @Test + public void armingAgainAsksTheNewModule() { + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + bridge.arm(); + + final RecordingCallback callback = new RecordingCallback(); + bridge.ask(new MutableNetwork("https://secret.example/token"), callback); + + assertEquals(1, next.ids.size()); + assertEquals(0, callback.runs); + } } diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 857ce8ef..91e60ca2 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -31,6 +31,7 @@ #import #import #import +#import #import #import #import @@ -59,6 +60,7 @@ #import "BGSRNValues.h" #import "BGSRNJSON.h" #import "BGSRNNetworkFilter.h" +#import "BGSRNFilterArming.h" #import "BGSRNNetworkEvents.h" #import "BGSRNAttributes.h" #import "BGSRNDataRequestBridge.h" @@ -455,6 +457,14 @@ @implementation BGSRNNetworkFilterPending static __weak BugseeModule *BGSRNNetworkFilterModule = nil; static BOOL BGSRNNetworkFilterInstalled = NO; +// Which module's JS installed the filter. A reload leaves the filter block in +// place and attaches a new module that has not; see BGSRNFilterArming. +static BGSRNFilterArming *BGSRNNetworkFilterArming(void) { + static BGSRNFilterArming *arming; + static dispatch_once_t once; + dispatch_once(&once, ^{ arming = [BGSRNFilterArming new]; }); + return arming; +} static NSMutableDictionary *BGSRNNetworkFilterPendingTable; static int64_t BGSRNNetworkFilterNextId = 0; @@ -499,6 +509,12 @@ @implementation BGSRNLogFilterPending static __weak BugseeModule *BGSRNLogFilterModule = nil; static BOOL BGSRNLogFilterInstalled = NO; +static BGSRNFilterArming *BGSRNLogFilterArming(void) { + static BGSRNFilterArming *arming; + static dispatch_once_t once; + dispatch_once(&once, ^{ arming = [BGSRNFilterArming new]; }); + return arming; +} /** The JS round trip. Off is the native pass-through, which stays installed. */ static BOOL BGSRNLogFilterUserEnabled = NO; static NSMutableDictionary *BGSRNLogFilterPendingTable; @@ -591,7 +607,10 @@ static void BGSRNInstallLogEventFilter(void) { return; } BugseeModule *module = BGSRNLogFilterModule; - if (module == nil) { + // A reload kept the user's filter on but the new runtime has not called + // setLogFilter yet: drop, never record unfiltered and never emit to a + // module whose JS is not listening. + if (module == nil || ![BGSRNLogFilterArming() isArmedFor:module]) { decision(nil); return; } @@ -634,6 +653,12 @@ @implementation BGSRNBreadcrumbFilterPending static __weak BugseeModule *BGSRNBreadcrumbFilterModule = nil; static BOOL BGSRNBreadcrumbFilterInstalled = NO; +static BGSRNFilterArming *BGSRNBreadcrumbFilterArming(void) { + static BGSRNFilterArming *arming; + static dispatch_once_t once; + dispatch_once(&once, ^{ arming = [BGSRNFilterArming new]; }); + return arming; +} /// Bumped on every enable and every disable. A disable queued on main nils /// the filter only when this is still the generation it captured, so a later /// enable is not wiped by a nil that was already queued. @@ -1607,6 +1632,7 @@ - (NSNumber *)noteConsoleEcho:(NSString *)message { /// only takes a lock, so it is safe on the JS thread. - (NSNumber *)setNetworkFilterEnabled:(BOOL)enabled { if (enabled) { + [BGSRNNetworkFilterArming() armFor:self]; @synchronized (BGSRNNetworkFilterLock()) { if (BGSRNNetworkFilterInstalled) { return @YES; @@ -1622,7 +1648,9 @@ - (NSNumber *)setNetworkFilterEnabled:(BOOL)enabled { return; } BugseeModule *module = BGSRNNetworkFilterModule; - if (module == nil) { + // After a reload the new module's JS has not installed its filter: + // drop now rather than leave the event pending forever. + if (module == nil || ![BGSRNNetworkFilterArming() isArmedFor:module]) { decision(nil); return; } @@ -1729,6 +1757,9 @@ - (void)addNetworkEvent:(NSString *)eventJson { /// console line written on the next line could be captured before a queued /// flag flip landed. The flag write and the SDK setter only take locks. - (NSNumber *)setLogFilterEnabled:(BOOL)enabled { + if (enabled) { + [BGSRNLogFilterArming() armFor:self]; + } @synchronized (BGSRNLogFilterLock()) { BGSRNLogFilterUserEnabled = enabled; } @@ -1792,6 +1823,7 @@ - (void)replyLogFilter:(NSString *)requestId line:(NSString * _Nullable)line { /// still records. - (NSNumber *)setBreadcrumbFilterEnabled:(BOOL)enabled { if (enabled) { + [BGSRNBreadcrumbFilterArming() armFor:self]; @synchronized (BGSRNBreadcrumbFilterLock()) { BGSRNBreadcrumbFilterGeneration += 1; if (BGSRNBreadcrumbFilterInstalled) { @@ -1808,7 +1840,8 @@ - (NSNumber *)setBreadcrumbFilterEnabled:(BOOL)enabled { return; } BugseeModule *module = BGSRNBreadcrumbFilterModule; - if (module == nil) { + // Same reload case as the network filter. + if (module == nil || ![BGSRNBreadcrumbFilterArming() isArmedFor:module]) { decision(nil); return; } diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNFilterArming.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNFilterArming.m new file mode 100644 index 00000000..334f047f --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNFilterArming.m @@ -0,0 +1,22 @@ +#import "BGSRNFilterArming.h" + +@implementation BGSRNFilterArming { + __weak id _owner; +} + +- (void)armFor:(id)owner { + @synchronized (self) { + _owner = owner; + } +} + +- (BOOL)isArmedFor:(id)owner { + if (owner == nil) { + return NO; + } + @synchronized (self) { + return _owner == owner; + } +} + +@end diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNFilterArming.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNFilterArming.h new file mode 100644 index 00000000..1e760f42 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNFilterArming.h @@ -0,0 +1,26 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Which module's JS has installed a filter. +/// +/// The native filter blocks and their flags are process statics, so they +/// outlive a JavaScript reload. The new module attaches as the sink, but its +/// JS has not subscribed, and a request emitted to it would never be +/// answered: on iOS it stays pending until process death. A filter block +/// therefore asks `isArmedFor:` with the current module and drops at once +/// when this returns NO. +/// +/// The owner is held weakly: an invalidated module that is released reads as +/// not armed. Thread-safe: the SDK calls filters from any thread. +@interface BGSRNFilterArming : NSObject + +/// `owner`'s JS has installed the filter. Replaces any earlier owner. +- (void)armFor:(id)owner; + +/// YES only when `owner` is the module that last called `armFor:`. +- (BOOL)isArmedFor:(nullable id)owner; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNFilterArmingTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNFilterArmingTests.m new file mode 100644 index 00000000..87c8a0b4 --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNFilterArmingTests.m @@ -0,0 +1,48 @@ +@import XCTest; +@import BugseeRNSupport; + +/// Pins `BGSRNFilterArming`: after a JS reload the previous module's JS +/// armed a filter, the new module's has not, and the filter must drop. +@interface BGSRNFilterArmingTests : XCTestCase +@end + +@implementation BGSRNFilterArmingTests + +- (void)testNothingIsArmedUntilJsArms { + BGSRNFilterArming *arming = [BGSRNFilterArming new]; + NSObject *module = [NSObject new]; + XCTAssertFalse([arming isArmedFor:module]); + XCTAssertFalse([arming isArmedFor:nil]); +} + +- (void)testArmedForTheModuleThatArmed { + BGSRNFilterArming *arming = [BGSRNFilterArming new]; + NSObject *module = [NSObject new]; + [arming armFor:module]; + XCTAssertTrue([arming isArmedFor:module]); +} + +- (void)testANewModuleIsNotArmedByTheOldOne { + BGSRNFilterArming *arming = [BGSRNFilterArming new]; + NSObject *oldModule = [NSObject new]; + NSObject *newModule = [NSObject new]; + [arming armFor:oldModule]; + XCTAssertFalse([arming isArmedFor:newModule]); + [arming armFor:newModule]; + XCTAssertTrue([arming isArmedFor:newModule]); + XCTAssertFalse([arming isArmedFor:oldModule]); +} + +- (void)testAReleasedOwnerIsNotArmed { + BGSRNFilterArming *arming = [BGSRNFilterArming new]; + NSObject *replacement = [NSObject new]; + @autoreleasepool { + NSObject *gone = [NSObject new]; + [arming armFor:gone]; + XCTAssertTrue([arming isArmedFor:gone]); + } + XCTAssertFalse([arming isArmedFor:replacement]); + XCTAssertFalse([arming isArmedFor:nil]); +} + +@end diff --git a/packages/react-native/src/breadcrumbs/filter.ts b/packages/react-native/src/breadcrumbs/filter.ts index fb9f2bc3..19d64108 100644 --- a/packages/react-native/src/breadcrumbs/filter.ts +++ b/packages/react-native/src/breadcrumbs/filter.ts @@ -64,6 +64,11 @@ function claimBreadcrumbFilter(id: string | undefined): BreadcrumbFilter | undef * crumb filtered on the installing turn still has a listener. The subscription * stays: clearing the callback stops native from asking, and a later callback * must still be able to answer. + * + * After a JavaScript reload (Fast Refresh in development, or a recreated + * bridge) the native filter outlives the old runtime, but the new runtime has + * not installed it. Native fails closed until this is called again, so call + * it at startup on every launch of the JS runtime: crumbs are dropped, not recorded. */ export function setBreadcrumbFilter(callback?: BreadcrumbFilter | null): void { if (callback != null && typeof callback !== 'function') { diff --git a/packages/react-native/src/logs/filter.ts b/packages/react-native/src/logs/filter.ts index 74b8fc35..37a1b881 100644 --- a/packages/react-native/src/logs/filter.ts +++ b/packages/react-native/src/logs/filter.ts @@ -27,6 +27,11 @@ let subscribed = false; * emitted in that gap stays in the pending table until process death. The * subscription stays: clearing the callback stops native from asking, and a * later callback must still be able to answer. + * + * After a JavaScript reload (Fast Refresh in development, or a recreated + * bridge) the native filter outlives the old runtime, but the new runtime has + * not installed it. Native fails closed until this is called again, so call + * it at startup on every launch of the JS runtime: lines are dropped, not recorded unfiltered. */ export function setLogFilter(callback?: LogFilter | null): void { if (callback != null && typeof callback !== 'function') { diff --git a/packages/react-native/src/network/filter.ts b/packages/react-native/src/network/filter.ts index bc13e4a3..f1ec6f58 100644 --- a/packages/react-native/src/network/filter.ts +++ b/packages/react-native/src/network/filter.ts @@ -61,6 +61,11 @@ let subscribed = false; * The native request is subscribed to once and stays subscribed: clearing the * callback stops native from asking, and a later callback must still be able * to answer. + * + * After a JavaScript reload (Fast Refresh in development, or a recreated + * bridge) the native filter outlives the old runtime, but the new runtime has + * not installed it. Native fails closed until this is called again, so call + * it at startup on every launch of the JS runtime: events are dropped, not recorded. */ export function setNetworkFilter(callback?: NetworkFilter | null): void { if (callback != null && typeof callback !== 'function') { From ddc5bc3acda7828938d7b4286fdd2fcb0b4ed95c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 20 Oct 2026 23:32:38 +0500 Subject: [PATCH 02/12] Filters: arm by module identity, test the constructor sequence, document reload semantics (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android bridges now record which module armed them and compare it to the attached sink, so a stale module's late enable cannot arm the new runtime, and attach() disarms before the sink becomes visible. The log filter's constructor sequence is a bridge method (attachModule) and is tested. JSDoc names full reload and OTA reloads, asks for an unconditional startup call, and documents setXFilter(null) as the opt-out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../reactnative/BreadcrumbFilterBridge.java | 41 ++++++++++--- .../com/bugsee/reactnative/BugseeModule.java | 14 ++--- .../bugsee/reactnative/LogFilterBridge.java | 58 +++++++++++++++---- .../reactnative/NetworkFilterBridge.java | 44 ++++++++++---- .../BreadcrumbFilterBridgeTest.java | 23 ++++++++ .../reactnative/LogFilterBridgeTest.java | 39 ++++++++++++- .../reactnative/NetworkFilterBridgeTest.java | 22 +++++++ .../react-native/src/breadcrumbs/filter.ts | 13 +++-- packages/react-native/src/logs/filter.ts | 13 +++-- packages/react-native/src/network/filter.ts | 13 +++-- 10 files changed, 226 insertions(+), 54 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java index b7d10fcf..24ad2a43 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java @@ -88,13 +88,14 @@ static BreadcrumbFilterBridge shared() { private final AtomicBoolean installed = new AtomicBoolean(); /** - * True once the CURRENT module's JS has installed its filter with - * {@code setEnabled(true)}. A reload attaches a new module whose JS has + * The module whose JS has installed its filter with + * {@code setEnabled(true)}, or null. Compared to the attached sink by + * identity, so a stale module cannot arm the new one. A reload attaches a new module whose JS has * not subscribed yet, while this bridge (and the SDK's filter) outlive * the old runtime. Until JS arms it again, a request would be emitted to * nobody, so it is dropped at once instead: fail closed, no wait. */ - private final AtomicBoolean armed = new AtomicBoolean(); + private final AtomicReference armedFor = new AtomicReference<>(); private final Object sdkLock = new Object(); /** * The id of the manual {@code addBreadcrumb} on this thread. Set only @@ -123,14 +124,29 @@ public void filter( }; void attach(@NonNull final Sink next) { + // Disarm before the sink is visible: a request in the gap must not + // reach a runtime that has not subscribed yet. See armedFor. + armedFor.set(null); sink.set(next); - // A new module's JS has not subscribed yet; see armed. - armed.set(false); } - /** The current module's JS now answers requests. {@link #setEnabled}{@code (true)} calls this. */ + /** + * The module {@code caller}'s JS now answers requests. Ignored unless + * {@code caller} is still the attached module: a stale module's late + * enable must not arm the new runtime, which has not subscribed. + */ + void arm(@NonNull final Sink caller) { + if (sink.get() == caller) { + armedFor.set(caller); + } + } + + /** Arms for whichever module is attached. For tests, which have no module identity of their own. */ void arm() { - armed.set(true); + final Sink current = sink.get(); + if (current != null) { + armedFor.set(current); + } } /** @@ -168,9 +184,16 @@ void withSdkLock(@NonNull final Runnable body) { /** Installs the filter, or removes it. A second {@code true} is a no-op. */ void setEnabled(final boolean enabled) { + setEnabled(enabled, sink.get()); + } + + /** As above, on behalf of module {@code caller}; only the attached module can arm. */ + void setEnabled(final boolean enabled, final Sink caller) { withSdkLock(() -> { if (enabled) { - arm(); + if (caller != null) { + arm(caller); + } if (installed.compareAndSet(false, true)) { Bugsee.setBreadcrumbFilter(filter); } @@ -231,7 +254,7 @@ void ask( @NonNull final Callback1 callback ) { final Sink current = sink.get(); - if (current == null || !armed.get()) { + if (current == null || armedFor.get() != current) { drop(callback); return; } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index a6fff34f..0665cb48 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -144,13 +144,7 @@ public BugseeModule(final ReactApplicationContext context) { ReportHandlerBridge.shared().attach(this); DataRequestBridge.shared().attach(dataRequestSink, originTracker::currentOrigin); NetworkFilterBridge.shared().attach(this); - LogFilterBridge.shared().attach(this); - // The echo drop is installed even when the app never calls setLogFilter. - // Not setEnabled(false): after a reload that would turn a redaction the - // app relied on into a silent pass-through. attach() above disarmed the - // bridge, so with a user filter still on, lines are dropped until the new - // runtime calls setLogFilter (re-arm) or clears it (pass-through). - LogFilterBridge.shared().ensureInstalled(); + LogFilterBridge.shared().attachModule(this); BreadcrumbFilterBridge.shared().attach(this); } @@ -659,7 +653,7 @@ public void onNetworkFilterRequest(@NonNull final String requestId, @NonNull fin */ @Override public boolean setNetworkFilterEnabled(final boolean enabled) { - NetworkFilterBridge.shared().setEnabled(enabled); + NetworkFilterBridge.shared().setEnabled(enabled, this); return true; } @@ -859,7 +853,7 @@ public void onLogFilterRequest(@NonNull final String requestId, @NonNull final S */ @Override public boolean setLogFilterEnabled(final boolean enabled) { - LogFilterBridge.shared().setEnabled(enabled); + LogFilterBridge.shared().setEnabled(enabled, this); return true; } @@ -896,7 +890,7 @@ public void onBreadcrumbFilterRequest( */ @Override public boolean setBreadcrumbFilterEnabled(final boolean enabled) { - BreadcrumbFilterBridge.shared().setEnabled(enabled); + BreadcrumbFilterBridge.shared().setEnabled(enabled, this); return true; } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java index 161741b1..897c0642 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java @@ -121,13 +121,14 @@ static LogFilterBridge shared() { private final AtomicBoolean userFilter = new AtomicBoolean(); /** - * True once the CURRENT module's JS has installed its filter with - * {@code setEnabled(true)}. A reload attaches a new module whose JS has + * The module whose JS has installed its filter with + * {@code setEnabled(true)}, or null. Compared to the attached sink by + * identity, so a stale module cannot arm the new one. A reload attaches a new module whose JS has * not subscribed yet, while this bridge (and the SDK's filter) outlive * the old runtime. Until JS arms it again, a request would be emitted to * nobody, so it is dropped at once instead: fail closed, no wait. */ - private final AtomicBoolean armed = new AtomicBoolean(); + private final AtomicReference armedFor = new AtomicReference<>(); /** Production: one daemon thread forgets pending requests at {@link #BORROW_MS}. */ private LogFilterBridge() { @@ -171,16 +172,44 @@ public void filter( }; void attach(@NonNull final Sink next) { - sink.set(next); - // A new module's JS has not subscribed yet; see armed. userFilter is - // deliberately kept: a redaction the app relied on must not silently + // Disarm before the sink is visible: a request in the gap must not + // reach a runtime that has not subscribed yet. See armedFor. userFilter + // is deliberately kept: a redaction the app relied on must not silently // stop applying across a reload. - armed.set(false); + armedFor.set(null); + sink.set(next); } - /** The current module's JS now answers requests. {@link #setEnabled}{@code (true)} calls this. */ + /** + * What a new module does at construction: attach, then put the echo drop + * in place even when the app never calls setLogFilter. Not + * {@code setEnabled(false)}: after a reload that would turn a redaction the + * app relied on into a silent pass-through. The attach disarms the bridge, + * so with a user filter still on, lines are dropped until the new runtime + * calls setLogFilter (re-arm) or clears it (pass-through). + */ + void attachModule(@NonNull final Sink next) { + attach(next); + ensureInstalled(); + } + + /** + * The module {@code caller}'s JS now answers requests. Ignored unless + * {@code caller} is still the attached module: a stale module's late + * enable must not arm the new runtime, which has not subscribed. + */ + void arm(@NonNull final Sink caller) { + if (sink.get() == caller) { + armedFor.set(caller); + } + } + + /** Arms for whichever module is attached. For tests, which have no module identity of their own. */ void arm() { - armed.set(true); + final Sink current = sink.get(); + if (current != null) { + armedFor.set(current); + } } /** @@ -214,8 +243,15 @@ void detach(@NonNull final Sink current) { * {@link Bugsee#setLogEventFilter}. */ void setEnabled(final boolean enabled) { + setEnabled(enabled, sink.get()); + } + + /** As above, on behalf of module {@code caller}; only the attached module can arm. */ + void setEnabled(final boolean enabled, final Sink caller) { if (enabled) { - arm(); + if (caller != null) { + arm(caller); + } } userFilter.set(enabled); ensureInstalled(); @@ -306,7 +342,7 @@ void ask( ) { final String line = event.getMessage(); final Sink current = sink.get(); - if (line == null || current == null || !armed.get()) { + if (line == null || current == null || armedFor.get() != current) { drop(callback); return; } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index e76b6719..34eae762 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -113,13 +113,14 @@ static NetworkFilterBridge shared() { private final AtomicBoolean installed = new AtomicBoolean(); /** - * True once the CURRENT module's JS has installed its filter with - * {@code setEnabled(true)}. A reload attaches a new module whose JS has + * The module whose JS has installed its filter with + * {@code setEnabled(true)}, or null. Compared to the attached sink by + * identity, so a stale module cannot arm the new one. A reload attaches a new module whose JS has * not subscribed yet, while this bridge (and the SDK's filter) outlive * the old runtime. Until JS arms it again, a request would be emitted to * nobody, so it is dropped at once instead: fail closed, no wait. */ - private final AtomicBoolean armed = new AtomicBoolean(); + private final AtomicReference armedFor = new AtomicReference<>(); /** Production: one daemon thread forgets pending requests at {@link #BORROW_MS}. */ private NetworkFilterBridge() { @@ -152,14 +153,29 @@ public void filter( }; void attach(@NonNull final Sink next) { + // Disarm before the sink is visible: a request in the gap must not + // reach a runtime that has not subscribed yet. See armedFor. + armedFor.set(null); sink.set(next); - // A new module's JS has not subscribed yet; see armed. - armed.set(false); } - /** The current module's JS now answers requests. {@link #setEnabled}{@code (true)} calls this. */ + /** + * The module {@code caller}'s JS now answers requests. Ignored unless + * {@code caller} is still the attached module: a stale module's late + * enable must not arm the new runtime, which has not subscribed. + */ + void arm(@NonNull final Sink caller) { + if (sink.get() == caller) { + armedFor.set(caller); + } + } + + /** Arms for whichever module is attached. For tests, which have no module identity of their own. */ void arm() { - armed.set(true); + final Sink current = sink.get(); + if (current != null) { + armedFor.set(current); + } } /** @@ -185,8 +201,15 @@ void detach(@NonNull final Sink current) { /** Installs the filter, or removes it. A second {@code true} is a no-op. */ void setEnabled(final boolean enabled) { + setEnabled(enabled, sink.get()); + } + + /** As above, on behalf of module {@code caller}; only the attached module can arm. */ + void setEnabled(final boolean enabled, final Sink caller) { if (enabled) { - arm(); + if (caller != null) { + arm(caller); + } if (installed.compareAndSet(false, true)) { Bugsee.setNetworkEventFilter(filter); } @@ -238,7 +261,8 @@ void ask( @NonNull final NetworkEvent event, @NonNull final Callback1 callback ) { - if (!armed.get()) { + final Sink asked = sink.get(); + if (asked == null || armedFor.get() != asked) { drop(callback); return; } @@ -264,7 +288,7 @@ void ask( drop(callback); return; } - final Sink current = sink.get(); + final Sink current = asked; // A null URL cannot tell a recycled entry (reset nulls the URL) from // the one we were asked about, so it is dropped rather than lent. if (url == null || current == null) { diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java index 197e329c..e2807b34 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java @@ -449,4 +449,27 @@ public void armingAgainAsksTheNewModule() { assertEquals(1, next.json.size()); assertEquals(0, callback.runs); } + + /** A late enable from the module a reload replaced must not arm the new, unsubscribed runtime. */ + @Test + public void aStaleModulesEnableDoesNotArmTheNewModule() { + final RecordingSink old = new RecordingSink(); + bridge.attach(old); + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + bridge.arm(old); + + final MutableCrumb ev = new MutableCrumb(); + ev.message = "secret"; + final RecordingCallback dropped = new RecordingCallback(); + bridge.ask(ev, dropped); + assertEquals(0, next.json.size()); + assertEquals(1, dropped.runs); + + bridge.arm(next); + final RecordingCallback asked = new RecordingCallback(); + bridge.ask(ev, asked); + assertEquals(1, next.json.size()); + assertEquals(0, asked.runs); + } } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java index 58c6e1d9..8ddfebdf 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java @@ -483,8 +483,7 @@ public void afterAReloadAUserFilterStaysOnAndDropsUntilJsArmsAgain() { bridge.attach(old); bridge.setEnabled(true); final RecordingSink next = new RecordingSink(); - bridge.attach(next); - bridge.ensureInstalled(); + bridge.attachModule(next); final RecordingCallback dropped = new RecordingCallback(); bridge.route(new MutableLog("secret"), dropped); @@ -500,6 +499,20 @@ public void afterAReloadAUserFilterStaysOnAndDropsUntilJsArmsAgain() { assertEquals(0, asked.runs); } + @Test + public void attachModuleInstallsTheEchoDropAndKeepsTheUserFilter() { + final RecordingSink old = new RecordingSink(); + bridge.attachModule(old); + bridge.setEnabled(true, old); + final RecordingSink next = new RecordingSink(); + bridge.attachModule(next); + + final RecordingCallback callback = new RecordingCallback(); + bridge.route(new MutableLog("secret"), callback); + assertTrue(callback.sawNull); + assertEquals(0, next.ids.size()); + } + @Test public void afterAReloadClearingTheFilterIsThePassThrough() { final RecordingSink old = new RecordingSink(); @@ -517,4 +530,26 @@ public void afterAReloadClearingTheFilterIsThePassThrough() { assertEquals(1, callback.runs); assertTrue(!callback.sawNull); } + + /** A late enable from the module a reload replaced must not arm the new, unsubscribed runtime. */ + @Test + public void aStaleModulesEnableDoesNotArmTheNewModule() { + final RecordingSink old = new RecordingSink(); + bridge.attach(old); + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + bridge.setEnabled(true, old); + + final MutableLog ev = new MutableLog("secret"); + final RecordingCallback dropped = new RecordingCallback(); + bridge.ask(ev, dropped); + assertEquals(0, next.ids.size()); + assertEquals(1, dropped.runs); + + bridge.setEnabled(true, next); + final RecordingCallback asked = new RecordingCallback(); + bridge.ask(ev, asked); + assertEquals(1, next.ids.size()); + assertEquals(0, asked.runs); + } } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java index b5ddd967..dea49718 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java @@ -498,4 +498,26 @@ public void armingAgainAsksTheNewModule() { assertEquals(1, next.ids.size()); assertEquals(0, callback.runs); } + + /** A late enable from the module a reload replaced must not arm the new, unsubscribed runtime. */ + @Test + public void aStaleModulesEnableDoesNotArmTheNewModule() { + final RecordingSink old = new RecordingSink(); + bridge.attach(old); + final RecordingSink next = new RecordingSink(); + bridge.attach(next); + bridge.arm(old); + + final MutableNetwork ev = new MutableNetwork("https://secret.example/token"); + final RecordingCallback dropped = new RecordingCallback(); + bridge.ask(ev, dropped); + assertEquals(0, next.ids.size()); + assertEquals(1, dropped.runs); + + bridge.arm(next); + final RecordingCallback asked = new RecordingCallback(); + bridge.ask(ev, asked); + assertEquals(1, next.ids.size()); + assertEquals(0, asked.runs); + } } diff --git a/packages/react-native/src/breadcrumbs/filter.ts b/packages/react-native/src/breadcrumbs/filter.ts index 19d64108..43794b9e 100644 --- a/packages/react-native/src/breadcrumbs/filter.ts +++ b/packages/react-native/src/breadcrumbs/filter.ts @@ -65,10 +65,15 @@ function claimBreadcrumbFilter(id: string | undefined): BreadcrumbFilter | undef * stays: clearing the callback stops native from asking, and a later callback * must still be able to answer. * - * After a JavaScript reload (Fast Refresh in development, or a recreated - * bridge) the native filter outlives the old runtime, but the new runtime has - * not installed it. Native fails closed until this is called again, so call - * it at startup on every launch of the JS runtime: crumbs are dropped, not recorded. + * The native filter lives for the whole process, but the JavaScript side that + * answers it does not. After a full reload, `DevSettings.reload()`, or an OTA + * reload (`Updates.reloadAsync()`, CodePush `restartApp()`), the new runtime + * has not installed a filter yet, so native fails closed: crumbs are dropped, not recorded. + * That lasts until `setBreadcrumbFilter` is called again, which can be the rest of the + * process. Call it unconditionally at startup, on every launch of the JS + * runtime, not lazily or conditionally (after login or consent), and keep it + * in every OTA bundle. To opt out, call `setBreadcrumbFilter(null)`, which restores + * pass-through. */ export function setBreadcrumbFilter(callback?: BreadcrumbFilter | null): void { if (callback != null && typeof callback !== 'function') { diff --git a/packages/react-native/src/logs/filter.ts b/packages/react-native/src/logs/filter.ts index 37a1b881..0047f87b 100644 --- a/packages/react-native/src/logs/filter.ts +++ b/packages/react-native/src/logs/filter.ts @@ -28,10 +28,15 @@ let subscribed = false; * subscription stays: clearing the callback stops native from asking, and a * later callback must still be able to answer. * - * After a JavaScript reload (Fast Refresh in development, or a recreated - * bridge) the native filter outlives the old runtime, but the new runtime has - * not installed it. Native fails closed until this is called again, so call - * it at startup on every launch of the JS runtime: lines are dropped, not recorded unfiltered. + * The native filter lives for the whole process, but the JavaScript side that + * answers it does not. After a full reload, `DevSettings.reload()`, or an OTA + * reload (`Updates.reloadAsync()`, CodePush `restartApp()`), the new runtime + * has not installed a filter yet, so native fails closed: lines are dropped, not recorded unfiltered. + * That lasts until `setLogFilter` is called again, which can be the rest of the + * process. Call it unconditionally at startup, on every launch of the JS + * runtime, not lazily or conditionally (after login or consent), and keep it + * in every OTA bundle. To opt out, call `setLogFilter(null)`, which restores + * pass-through. */ export function setLogFilter(callback?: LogFilter | null): void { if (callback != null && typeof callback !== 'function') { diff --git a/packages/react-native/src/network/filter.ts b/packages/react-native/src/network/filter.ts index f1ec6f58..717d86ec 100644 --- a/packages/react-native/src/network/filter.ts +++ b/packages/react-native/src/network/filter.ts @@ -62,10 +62,15 @@ let subscribed = false; * callback stops native from asking, and a later callback must still be able * to answer. * - * After a JavaScript reload (Fast Refresh in development, or a recreated - * bridge) the native filter outlives the old runtime, but the new runtime has - * not installed it. Native fails closed until this is called again, so call - * it at startup on every launch of the JS runtime: events are dropped, not recorded. + * The native filter lives for the whole process, but the JavaScript side that + * answers it does not. After a full reload, `DevSettings.reload()`, or an OTA + * reload (`Updates.reloadAsync()`, CodePush `restartApp()`), the new runtime + * has not installed a filter yet, so native fails closed: events are dropped, not recorded. + * That lasts until `setNetworkFilter` is called again, which can be the rest of the + * process. Call it unconditionally at startup, on every launch of the JS + * runtime, not lazily or conditionally (after login or consent), and keep it + * in every OTA bundle. To opt out, call `setNetworkFilter(null)`, which restores + * pass-through. */ export function setNetworkFilter(callback?: NetworkFilter | null): void { if (callback != null && typeof callback !== 'function') { From 7fd918067b0074c96b245728ed5596beff471d61 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 00:14:29 +0500 Subject: [PATCH 03/12] Filters: ignore superseded modules, release the armed module on detach (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: a stale module's enable or disable no longer arms or clears the process-wide filter (Android by sink identity, iOS by the current-module pointer), and detach releases the armed reference so the bridge singleton does not pin an invalidated module. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../reactnative/BreadcrumbFilterBridge.java | 5 +++++ .../com/bugsee/reactnative/LogFilterBridge.java | 5 +++++ .../bugsee/reactnative/NetworkFilterBridge.java | 5 +++++ .../bugsee/reactnative/LogFilterBridgeTest.java | 16 ++++++++++++++++ packages/react-native/ios/BugseeModule.mm | 15 +++++++++++++++ 5 files changed, 46 insertions(+) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java index 24ad2a43..f9ff2f51 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java @@ -157,6 +157,7 @@ void arm() { */ void detach(@NonNull final Sink current) { sink.compareAndSet(current, null); + armedFor.compareAndSet(current, null); final List owned = new ArrayList<>(); for (final Pending item : pending.values()) { if (item.owner == current && pending.remove(item.id, item)) { @@ -190,6 +191,10 @@ void setEnabled(final boolean enabled) { /** As above, on behalf of module {@code caller}; only the attached module can arm. */ void setEnabled(final boolean enabled, final Sink caller) { withSdkLock(() -> { + if (sink.get() != caller) { + // A superseded module must neither arm nor disable the process-wide filter. + return; + } if (enabled) { if (caller != null) { arm(caller); diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java index 897c0642..9e17dc77 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java @@ -222,6 +222,7 @@ void arm() { */ void detach(@NonNull final Sink current) { sink.compareAndSet(current, null); + armedFor.compareAndSet(current, null); final List owned = new ArrayList<>(); for (final Pending item : pending.values()) { if (item.owner == current && pending.remove(item.id, item)) { @@ -248,6 +249,10 @@ void setEnabled(final boolean enabled) { /** As above, on behalf of module {@code caller}; only the attached module can arm. */ void setEnabled(final boolean enabled, final Sink caller) { + if (sink.get() != caller) { + // A superseded module must neither arm nor disable the process-wide filter. + return; + } if (enabled) { if (caller != null) { arm(caller); diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index 34eae762..9aa9f3a1 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -185,6 +185,7 @@ void arm() { */ void detach(@NonNull final Sink current) { sink.compareAndSet(current, null); + armedFor.compareAndSet(current, null); final List owned = new ArrayList<>(); for (final Pending item : pending.values()) { if (item.owner == current && pending.remove(item.id, item)) { @@ -206,6 +207,10 @@ void setEnabled(final boolean enabled) { /** As above, on behalf of module {@code caller}; only the attached module can arm. */ void setEnabled(final boolean enabled, final Sink caller) { + if (sink.get() != caller) { + // A superseded module must neither arm nor disable the process-wide filter. + return; + } if (enabled) { if (caller != null) { arm(caller); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java index 8ddfebdf..935ba518 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java @@ -513,6 +513,22 @@ public void attachModuleInstallsTheEchoDropAndKeepsTheUserFilter() { assertEquals(0, next.ids.size()); } + @Test + public void aStaleModulesDisableDoesNotClearTheNewModulesFilter() { + final RecordingSink old = new RecordingSink(); + bridge.attachModule(old); + final RecordingSink next = new RecordingSink(); + bridge.attachModule(next); + bridge.setEnabled(true, next); + + bridge.setEnabled(false, old); + + final RecordingCallback callback = new RecordingCallback(); + bridge.route(new MutableLog("secret"), callback); + assertEquals(1, next.ids.size()); + assertEquals(0, callback.runs); + } + @Test public void afterAReloadClearingTheFilterIsThePassThrough() { final RecordingSink old = new RecordingSink(); diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 91e60ca2..b321c6db 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -1631,6 +1631,11 @@ - (NSNumber *)noteConsoleEcho:(NSString *)message { /// install landed, with its url, headers and body unfiltered. The SDK setter /// only takes a lock, so it is safe on the JS thread. - (NSNumber *)setNetworkFilterEnabled:(BOOL)enabled { + // A reload can attach the new module before this one is invalidated. A + // superseded module must neither arm nor disable the process-wide filter. + if (BGSRNNetworkFilterModule != self) { + return @YES; + } if (enabled) { [BGSRNNetworkFilterArming() armFor:self]; @synchronized (BGSRNNetworkFilterLock()) { @@ -1757,6 +1762,11 @@ - (void)addNetworkEvent:(NSString *)eventJson { /// console line written on the next line could be captured before a queued /// flag flip landed. The flag write and the SDK setter only take locks. - (NSNumber *)setLogFilterEnabled:(BOOL)enabled { + // A reload can attach the new module before this one is invalidated. A + // superseded module must neither arm nor disable the process-wide filter. + if (BGSRNLogFilterModule != self) { + return @YES; + } if (enabled) { [BGSRNLogFilterArming() armFor:self]; } @@ -1822,6 +1832,11 @@ - (void)replyLogFilter:(NSString *)requestId line:(NSString * _Nullable)line { /// filter. There is no timer that calls `decision(nil)`. A late decision /// still records. - (NSNumber *)setBreadcrumbFilterEnabled:(BOOL)enabled { + // A reload can attach the new module before this one is invalidated. A + // superseded module must neither arm nor disable the process-wide filter. + if (BGSRNBreadcrumbFilterModule != self) { + return @YES; + } if (enabled) { [BGSRNBreadcrumbFilterArming() armFor:self]; @synchronized (BGSRNBreadcrumbFilterLock()) { From 8ff07a2e8398999202c96240920edf8ce193ac6b Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 20 Oct 2026 23:52:15 +0500 Subject: [PATCH 04/12] Auto-launch: attach() registers the wrapper identity; opt-in fail-closed filter hold at process start (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attach() now registers the full wrapper identity like launch/relaunch. New holdFilters plugin option / com.bugsee.reactnative.hold-filters meta-data installs the log, network and breadcrumb bridge filters in the init provider; they drop events until JS calls the setter (also across a reload, via the armed gate). Builds on the armed gate of #135. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/README.md | 41 +++ .../reactnative/BreadcrumbFilterBridge.java | 42 +++- .../com/bugsee/reactnative/BugseeModule.java | 2 + .../bugsee/reactnative/LogFilterBridge.java | 25 ++ .../reactnative/NetworkFilterBridge.java | 43 +++- .../ReactNativeWrapperInitProvider.java | 45 ++++ .../com/bugsee/reactnative/StartupHold.java | 233 ++++++++++++++++++ .../BreadcrumbFilterBridgeTest.java | 85 +++++++ .../reactnative/LogFilterBridgeTest.java | 104 +++++++- .../reactnative/NetworkFilterBridgeTest.java | 88 +++++++ .../ReactNativeWrapperInitProviderTest.java | 63 +++++ .../bugsee/reactnative/StartupHoldTest.java | 172 +++++++++++++ packages/react-native/plugin/build/index.d.ts | 14 ++ packages/react-native/plugin/build/index.js | 5 + .../react-native/plugin/build/index.js.map | 2 +- .../react-native/plugin/build/manifest.d.ts | 17 ++ .../react-native/plugin/build/manifest.js | 38 ++- .../react-native/plugin/build/manifest.js.map | 2 +- .../plugin/src/__tests__/with-bugsee.test.ts | 57 ++++- packages/react-native/plugin/src/index.ts | 26 +- packages/react-native/plugin/src/manifest.ts | 41 +++ .../src/__tests__/lifecycle.test.ts | 19 ++ packages/react-native/src/index.ts | 8 +- 23 files changed, 1160 insertions(+), 12 deletions(-) create mode 100644 packages/react-native/android/src/main/java/com/bugsee/reactnative/StartupHold.java create mode 100644 packages/react-native/android/src/test/java/com/bugsee/reactnative/StartupHoldTest.java diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 7c54836e..a7dc8c65 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -252,6 +252,47 @@ on Bugsee's production API. Set, it reaches every upload and the runtime: Each of these is the plugin's own, so a later prebuild with a new value replaces it, and one without `endpoint` removes it again. +### Auto-launch: what is recorded before JavaScript runs (Android) + +With `autoLaunch` the Android SDK starts at process start, from manifest +meta-data, before any JavaScript exists. Two things follow: + +- **Reports say which wrapper they came from, with context.** The native side + registers only the wrapper type and version at process start; `Bugsee.attach()` + registers the rest (React Native version, JS engine, build configuration), as + `launch()` and `relaunch()` do. Call `attach()` once at startup. +- **Filters are not installed yet.** The JS log, network and breadcrumb filters + are installed natively only when `setLogFilter`, `setNetworkFilter` and + `setBreadcrumbFilter` run, so what the SDK captures before then is recorded + unfiltered. To close that window, opt in per filter: + + ```json + ["@bugsee/react-native", { "autoLaunch": true, "appToken": "", "holdFilters": ["network", "log", "breadcrumb"] }] + ``` + + `true` holds all three. A held filter is installed natively at process start + and drops every event of its kind (log lines, network events, breadcrumbs) + until JS calls its setter, with a function (your filter applies) or with + `null` (no filter: events pass through). Call each setter unconditionally, + early, on every start of the JS runtime. Only list the filters your app + installs: an app that lists one and never calls its setter records none of + that kind, and the module says so in the log (`BugseeRN`, warning) 30 s after + JS starts. It is off by default for that reason, and an app that never uses + filters is unaffected. It writes `com.bugsee.reactnative.hold-filters` + application meta-data, removed again by a prebuild without the option. + The hold also covers a JS reload: a new runtime is held until it calls the + setter again, exactly like the start. + Two interactions to know about: a filter you declare in the manifest + (`com.bugsee.filter.log-event`, `network-event` or `breadcrumb`) is not + applied for a kind that is held, because the SDK lets a filter set from code + take its place, so do not list a kind you filter from the manifest; and + `setXFilter(null)` then leaves that kind with no filter at all. +- **Masking is not held.** Nothing from `` or `setSecureRectangles` + is masked until those mount, and the wrapper cannot hold the SDK's capture + from JS-side state. If the first frames must not show a sensitive screen, do + not auto-launch: call `Bugsee.launch()` from JavaScript (or keep `autoLaunch` + off), after your first secure view is in place. + The plugin's Gradle edits never remove or change a line of your own code, on a clean or a `--no-clean` prebuild, however often it runs: it adds its own marked lines, replaces only those, and rewrites exactly one of yours, diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java index f9ff2f51..4b337e94 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java @@ -20,6 +20,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; /** * The app's breadcrumb filter, as a round trip into JS. @@ -106,6 +107,22 @@ static BreadcrumbFilterBridge shared() { /** Tests construct their own. Production uses {@link #shared()}. */ BreadcrumbFilterBridge() { + this(Bugsee::setBreadcrumbFilter); + } + + /** Tests pass the call that installs (or, with null, removes) the SDK's filter. */ + BreadcrumbFilterBridge(@NonNull final Consumer> installer) { + this.installer = installer; + } + + private final Consumer> installer; + + /** True from {@link #holdUntilJs} until JS first calls {@link #setEnabled}. For the watchdog only. */ + private final AtomicBoolean held = new AtomicBoolean(); + + /** Whether the startup hold is still waiting for JS to call {@code setBreadcrumbFilter}. */ + boolean isHeld() { + return held.get(); } private final EventFilter filter = new EventFilter() { @@ -183,6 +200,26 @@ void withSdkLock(@NonNull final Runnable body) { } } + /** + * Installs the filter before JS has asked for it, so a manifest + * auto-launch does not record breadcrumbs unfiltered until + * {@code setBreadcrumbFilter} runs (see {@link StartupHold}). With no + * module attached yet the filter drops every crumb, and so does one whose + * JS has not armed it (the armed gate), so no crumb waits on a reply that + * cannot come. {@code + * setEnabled(false)}, which {@code setBreadcrumbFilter(null)} sends, + * removes it again. A second call, or one after {@code setEnabled(true)}, + * is a no-op. + */ + void holdUntilJs() { + withSdkLock(() -> { + held.set(true); + if (installed.compareAndSet(false, true)) { + installer.accept(filter); + } + }); + } + /** Installs the filter, or removes it. A second {@code true} is a no-op. */ void setEnabled(final boolean enabled) { setEnabled(enabled, sink.get()); @@ -195,17 +232,18 @@ void setEnabled(final boolean enabled, final Sink caller) { // A superseded module must neither arm nor disable the process-wide filter. return; } + held.set(false); if (enabled) { if (caller != null) { arm(caller); } if (installed.compareAndSet(false, true)) { - Bugsee.setBreadcrumbFilter(filter); + installer.accept(filter); } return; } if (installed.compareAndSet(true, false)) { - Bugsee.setBreadcrumbFilter(null); + installer.accept(null); } }); } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 0665cb48..487b1f17 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -146,6 +146,8 @@ public BugseeModule(final ReactApplicationContext context) { NetworkFilterBridge.shared().attach(this); LogFilterBridge.shared().attachModule(this); BreadcrumbFilterBridge.shared().attach(this); + // Says so if a startup filter hold is still waiting for JS well after this. + StartupHold.watchFromModule(); } /** diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java index 9e17dc77..a484947b 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java @@ -130,6 +130,14 @@ static LogFilterBridge shared() { */ private final AtomicReference armedFor = new AtomicReference<>(); + /** True from {@link #holdUntilJs} until JS first calls {@link #setEnabled}. For the watchdog only. */ + private final AtomicBoolean held = new AtomicBoolean(); + + /** Whether the startup hold is still waiting for JS to call {@code setLogFilter}. */ + boolean isHeld() { + return held.get(); + } + /** Production: one daemon thread forgets pending requests at {@link #BORROW_MS}. */ private LogFilterBridge() { this(new DaemonScheduler(), System::currentTimeMillis, Bugsee::setLogEventFilter); @@ -193,6 +201,22 @@ void attachModule(@NonNull final Sink next) { ensureInstalled(); } + /** + * Installs the filter before JS has asked for it, so a manifest + * auto-launch does not record log lines unfiltered until + * {@code setLogFilter} runs (see {@link StartupHold}). It is the state of + * an app whose filter is on but whose runtime has not armed it: lines are + * dropped. {@link #attachModule} keeps it, so it also holds while the + * module is constructed ahead of its JS. {@code setEnabled(true)} arms + * the filter, and {@code setEnabled(false)} (what {@code setLogFilter(null)} + * sends) releases it to the pass-through. + */ + void holdUntilJs() { + held.set(true); + userFilter.set(true); + ensureInstalled(); + } + /** * The module {@code caller}'s JS now answers requests. Ignored unless * {@code caller} is still the attached module: a stale module's late @@ -253,6 +277,7 @@ void setEnabled(final boolean enabled, final Sink caller) { // A superseded module must neither arm nor disable the process-wide filter. return; } + held.set(false); if (enabled) { if (caller != null) { arm(caller); diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index 9aa9f3a1..74320cf6 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -124,7 +124,7 @@ static NetworkFilterBridge shared() { /** Production: one daemon thread forgets pending requests at {@link #BORROW_MS}. */ private NetworkFilterBridge() { - this(new DaemonScheduler(), NetworkFilterBridge::readDefaultSanitizerOption); + this(new DaemonScheduler(), NetworkFilterBridge::readDefaultSanitizerOption, Bugsee::setNetworkEventFilter); } /** @@ -134,8 +134,28 @@ private NetworkFilterBridge() { * {@link Bugsee#getLaunchOptions()} needs an Android looper. */ NetworkFilterBridge(@NonNull final Scheduler scheduler, @NonNull final BooleanSupplier defaultSanitizer) { + this(scheduler, defaultSanitizer, installed -> { }); + } + + /** As above, and the call that installs (or, with null, removes) the SDK's filter. */ + NetworkFilterBridge( + @NonNull final Scheduler scheduler, + @NonNull final BooleanSupplier defaultSanitizer, + @NonNull final Consumer> installer + ) { this.scheduler = scheduler; this.defaultSanitizer = defaultSanitizer; + this.installer = installer; + } + + private final Consumer> installer; + + /** True from {@link #holdUntilJs} until JS first calls {@link #setEnabled}. For the watchdog only. */ + private final AtomicBoolean held = new AtomicBoolean(); + + /** Whether the startup hold is still waiting for JS to call {@code setNetworkFilter}. */ + boolean isHeld() { + return held.get(); } private final EventFilter filter = new EventFilter() { @@ -200,6 +220,22 @@ void detach(@NonNull final Sink current) { } } + /** + * Installs the filter before JS has asked for it, so a manifest + * auto-launch does not record network events unfiltered until + * {@code setNetworkFilter} runs (see {@link StartupHold}). With no module + * attached yet the filter drops every event, and so does one whose JS has + * not armed it (the armed gate), so nothing waits on a reply that cannot + * come. {@code setEnabled(false)}, which {@code setNetworkFilter(null)} sends, removes + * it again. A second call, or one after {@code setEnabled(true)}, is a no-op. + */ + void holdUntilJs() { + held.set(true); + if (installed.compareAndSet(false, true)) { + installer.accept(filter); + } + } + /** Installs the filter, or removes it. A second {@code true} is a no-op. */ void setEnabled(final boolean enabled) { setEnabled(enabled, sink.get()); @@ -211,17 +247,18 @@ void setEnabled(final boolean enabled, final Sink caller) { // A superseded module must neither arm nor disable the process-wide filter. return; } + held.set(false); if (enabled) { if (caller != null) { arm(caller); } if (installed.compareAndSet(false, true)) { - Bugsee.setNetworkEventFilter(filter); + installer.accept(filter); } return; } if (installed.compareAndSet(true, false)) { - Bugsee.setNetworkEventFilter(null); + installer.accept(null); } } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWrapperInitProvider.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWrapperInitProvider.java index 28cbe3af..500004cd 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWrapperInitProvider.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWrapperInitProvider.java @@ -1,8 +1,13 @@ package com.bugsee.reactnative; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.os.Bundle; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.bugsee.library.BugseeExtensionInitProviderBase; @@ -29,6 +34,9 @@ * bridge is up. An early report is therefore attributed to * {@code react_native} with a correct wrapper version and an empty context, * which is strictly better than the alternative of no attribution at all. + * + *

The same early hook can close the privacy window of an auto-launched + * SDK, opt-in: see {@link StartupHold}. */ public final class ReactNativeWrapperInitProvider extends BugseeExtensionInitProviderBase { @@ -39,10 +47,47 @@ protected boolean onExtensionCreate() { // Through the registrar, like every registration: setWrapperInfo // re-registers from another thread once JS is up. registerEarly(WrapperRegistrar::register); + // Opt-in: close the window before JS installs the app's filters. + holdFiltersEarly(holdFiltersMetaData(getContext()), StartupHold.BRIDGES); // Conventional for an init provider: we expose no content. return false; } + /** + * The {@value StartupHold#META_DATA} meta-data, or null when it is absent + * or cannot be read. Never throws: see {@link #registerEarly}. + */ + @Nullable + static Object holdFiltersMetaData(@Nullable final Context context) { + try { + if (context == null) { + return null; + } + final ApplicationInfo info = context.getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + return holdFiltersValueOf(info); + } catch (Throwable t) { + Log.e(TAG, "could not read " + StartupHold.META_DATA + ": " + t.getClass().getName()); + return null; + } + } + + /** The meta-data value in {@code info}, or null. */ + @Nullable + static Object holdFiltersValueOf(@NonNull final ApplicationInfo info) { + final Bundle data = info.metaData; + return data == null ? null : data.get(StartupHold.META_DATA); + } + + /** Never throws; see {@link StartupHold#apply}. */ + static void holdFiltersEarly(@Nullable final Object metaData, @NonNull final StartupHold.Targets targets) { + try { + StartupHold.apply(metaData, targets); + } catch (Throwable t) { + Log.e(TAG, "could not hold the filters at process start: " + t.getClass().getName()); + } + } + /** * Never throws, errors included. This runs before * {@code Application.onCreate} in every host app that links the library, diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/StartupHold.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/StartupHold.java new file mode 100644 index 00000000..565a7269 --- /dev/null +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/StartupHold.java @@ -0,0 +1,233 @@ +package com.bugsee.reactnative; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; + +/** + * The opt-in that closes the window in which an auto-launched SDK records + * before JavaScript has installed the app's filters. + * + *

Under manifest auto-launch the SDK starts at process start, long before a + * JS runtime exists. The JS log, network and breadcrumb filters are installed + * natively only when {@code setLogFilter} / {@code setNetworkFilter} / + * {@code setBreadcrumbFilter} run, so everything captured until then is + * recorded unfiltered. The Android SDK keeps a filter set before launch and + * applies it to the providers when they are created, so the wrapper's init + * provider can install the bridge filters first. Each bridge's filter drops an + * event while no module (or a module whose JS has not answered) is there to + * ask: fail closed. + * + *

Opt-in, per filter, by the {@value #META_DATA} manifest meta-data (the + * Expo plugin's {@code holdFilters} option): a held filter also drops + * everything for an app that never calls its {@code setXFilter}, so the + * default is not to hold. JS releases a hold by calling the setter, with a + * function or with {@code null} (no filter, pass-through). + */ +final class StartupHold { + + static final String META_DATA = "com.bugsee.reactnative.hold-filters"; + static final String NETWORK = "network"; + static final String LOG = "log"; + static final String BREADCRUMB = "breadcrumb"; + + private static final String TAG = "BugseeRN"; + + private StartupHold() { + } + + /** How long after a module attaches a hold may wait for JS before the watchdog says so. */ + static final long WATCHDOG_MS = 30_000L; + + /** Which holds JS has not released yet. */ + interface Probe { + boolean networkHeld(); + + boolean logHeld(); + + boolean breadcrumbHeld(); + } + + /** Runs a task once, after a delay. Injectable so tests control time. */ + interface Delayer { + void runAfter(long delayMs, @NonNull Runnable task); + } + + /** The holds {@code apply} puts in place. */ + interface Targets { + void holdNetwork(); + + void holdLog(); + + void holdBreadcrumb(); + } + + /** The real probe. */ + static final Probe PROBE = new Probe() { + @Override + public boolean networkHeld() { + return NetworkFilterBridge.shared().isHeld(); + } + + @Override + public boolean logHeld() { + return LogFilterBridge.shared().isHeld(); + } + + @Override + public boolean breadcrumbHeld() { + return BreadcrumbFilterBridge.shared().isHeld(); + } + }; + + private static final java.util.concurrent.atomic.AtomicBoolean WATCHING = + new java.util.concurrent.atomic.AtomicBoolean(); + + /** The real bridges. */ + static final Targets BRIDGES = new Targets() { + @Override + public void holdNetwork() { + NetworkFilterBridge.shared().holdUntilJs(); + } + + @Override + public void holdLog() { + LogFilterBridge.shared().holdUntilJs(); + } + + @Override + public void holdBreadcrumb() { + BreadcrumbFilterBridge.shared().holdUntilJs(); + } + }; + + /** + * The filters {@code raw} names: a comma list of {@link #NETWORK}, + * {@link #LOG} and {@link #BREADCRUMB}, or {@code all} / {@code true} for + * all three. Case and spaces do not matter. A name that is not one of + * them is ignored, since a typo that held nothing is no worse than the + * default, but one that threw would kill the process at start. + */ + @NonNull + static Set parse(@Nullable final Object raw) { + if (raw == null) { + return Collections.emptySet(); + } + final Set held = new LinkedHashSet<>(); + for (final String part : raw.toString().split(",")) { + final String name = part.trim().toLowerCase(Locale.ROOT); + if (name.equals("all") || name.equals("true")) { + held.add(NETWORK); + held.add(LOG); + held.add(BREADCRUMB); + } else if (name.equals(NETWORK) || name.equals(LOG) || name.equals(BREADCRUMB)) { + held.add(name); + } + } + return held; + } + + /** + * Holds each filter {@code raw} names. One that fails to hold does not + * stop the others: the rest still close their window. Returns what was + * held. Never throws, errors included; see + * {@link ReactNativeWrapperInitProvider#registerEarly}. + */ + @NonNull + static Set apply(@Nullable final Object raw, @NonNull final Targets targets) { + final Set held = new LinkedHashSet<>(); + for (final String name : parse(raw)) { + try { + if (name.equals(NETWORK)) { + targets.holdNetwork(); + } else if (name.equals(LOG)) { + targets.holdLog(); + } else { + targets.holdBreadcrumb(); + } + held.add(name); + } catch (Throwable t) { + Log.e(TAG, "could not hold the " + name + " filter at process start: " + + t.getClass().getName()); + } + } + if (!held.isEmpty()) { + Log.i(TAG, "holding the " + held + " filter(s) from process start: events are dropped until JS calls " + + "setNetworkFilter / setLogFilter / setBreadcrumbFilter (a function, or null for none)"); + } + return held; + } + + /** + * The filters {@code probe} says JS has not released, in a fixed order. + */ + @NonNull + static Set stillHeld(@NonNull final Probe probe) { + final Set waiting = new LinkedHashSet<>(); + if (probe.networkHeld()) { + waiting.add(NETWORK); + } + if (probe.logHeld()) { + waiting.add(LOG); + } + if (probe.breadcrumbHeld()) { + waiting.add(BREADCRUMB); + } + return waiting; + } + + /** + * Once per process, {@code delayMs} after the first module attaches: says so + * (and returns what, to the log) if a filter is still held, because an + * app that lists a filter and never calls its setter records none of that + * kind. A log line only; it changes no behaviour. + * + * @return whether this call scheduled the watchdog + */ + static boolean watch( + @NonNull final Probe probe, + @NonNull final Delayer delayer, + @NonNull final java.util.function.Consumer warn, + final long delayMs + ) { + if (!WATCHING.compareAndSet(false, true)) { + return false; + } + delayer.runAfter(delayMs, () -> { + final Set waiting = stillHeld(probe); + if (!waiting.isEmpty()) { + warn.accept("the " + waiting + " filter(s) are still held " + (delayMs / 1000L) + + " s after the JS runtime started: events of that kind are being dropped. Call " + + "setNetworkFilter / setLogFilter / setBreadcrumbFilter at startup (null for no filter), " + + "or remove it from holdFilters"); + } + }); + return true; + } + + /** What a module does once its bridges are attached: start the watchdog if a hold is still on. */ + static void watchFromModule() { + try { + if (stillHeld(PROBE).isEmpty()) { + return; + } + final Handler main = new Handler(Looper.getMainLooper()); + watch(PROBE, (delayMs, task) -> main.postDelayed(task, delayMs), message -> Log.w(TAG, message), WATCHDOG_MS); + } catch (Throwable t) { + Log.e(TAG, "could not start the startup hold watchdog: " + t.getClass().getName()); + } + } + + /** Test seam: lets another test start a fresh watchdog. */ + static void resetWatchForTest() { + WATCHING.set(false); + } +} diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java index e2807b34..81025e82 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java @@ -11,6 +11,7 @@ import com.bugsee.library.contracts.common.Callback1; import com.bugsee.library.contracts.exchange.Breadcrumb; +import com.bugsee.library.contracts.exchange.EventFilter; import org.json.JSONObject; import org.junit.Test; @@ -472,4 +473,88 @@ public void aStaleModulesEnableDoesNotArmTheNewModule() { assertEquals(1, next.json.size()); assertEquals(0, asked.runs); } + + // The startup hold (#134): the SDK's filter is installed before JS exists. + private final List> installs = new ArrayList<>(); + + private BreadcrumbFilterBridge installing() { + return new BreadcrumbFilterBridge(installs::add); + } + + @Test + public void holdUntilJsInstallsTheFilterOnceAndDropsBeforeAnyModule() { + final BreadcrumbFilterBridge held = installing(); + + held.holdUntilJs(); + held.holdUntilJs(); + + assertEquals(1, installs.size()); + assertTrue(held.isHeld()); + final RecordingCallback callback = new RecordingCallback(); + installs.get(0).filter(new MutableCrumb(), callback); + assertEquals(1, callback.runs); + assertTrue(callback.sawNull); + } + + @Test + public void aHeldBridgeDropsAtOnceWhileAModuleIsAttachedButNotArmed() { + final BreadcrumbFilterBridge held = installing(); + final RecordingSink sink = new RecordingSink(); + held.holdUntilJs(); + held.attach(sink); + final RecordingCallback callback = new RecordingCallback(); + + held.ask(new MutableCrumb(), callback); + + // Nothing is left pending for a reply that cannot come. + assertTrue(sink.ids.isEmpty()); + assertTrue(callback.sawNull); + } + + @Test + public void setBreadcrumbFilterWithNullRemovesTheHeldFilter() { + final BreadcrumbFilterBridge held = installing(); + final RecordingSink sink = new RecordingSink(); + held.holdUntilJs(); + held.attach(sink); + + held.setEnabled(false, sink); + + assertFalse(held.isHeld()); + assertEquals(2, installs.size()); + assertNull(installs.get(1)); + } + + @Test + public void setBreadcrumbFilterWithAFunctionKeepsTheInstalledFilterAndArmsIt() { + final BreadcrumbFilterBridge held = installing(); + final RecordingSink sink = new RecordingSink(); + held.holdUntilJs(); + held.attach(sink); + + held.setEnabled(true, sink); + + assertFalse(held.isHeld()); + assertEquals(1, installs.size()); + held.ask(new MutableCrumb(), new RecordingCallback()); + assertEquals(1, sink.ids.size()); + } + + @Test + public void aSupersededModuleCannotReleaseTheBreadcrumbHold() { + final BreadcrumbFilterBridge held = installing(); + final RecordingSink old = new RecordingSink(); + held.holdUntilJs(); + held.attach(old); + held.attach(new RecordingSink()); + + held.setEnabled(false, old); + + assertTrue(held.isHeld()); + } + + @Test + public void aBridgeThatWasNeverHeldReportsNotHeld() { + assertFalse(installing().isHeld()); + } } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java index 935ba518..0c531c37 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java @@ -1,6 +1,7 @@ package com.bugsee.reactnative; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -273,6 +274,107 @@ public void detachDropsALiveBorrowAndSkipsARecycledOne() { assertEquals(0, recycled.setMessageCalls); } + // A manifest auto-launch records before JS runs. A held bridge must not + // pass a line through unfiltered in that window, with or without a module. + @Test + public void aHeldBridgeDropsLinesBeforeAnyModuleIsAttached() { + bridge.holdUntilJs(); + final RecordingCallback callback = new RecordingCallback(); + + bridge.route(new MutableLog("secret"), callback); + + assertEquals(1, callback.runs); + assertTrue(callback.sawNull); + } + + @Test + public void aHeldBridgeDropsAtOnceOnceAModuleIsAttachedButNotArmed() { + final RecordingSink sink = new RecordingSink(); + bridge.holdUntilJs(); + bridge.attachModule(sink); + final RecordingCallback callback = new RecordingCallback(); + + bridge.route(new MutableLog("secret"), callback); + + assertTrue(sink.ids.isEmpty()); + assertEquals(1, callback.runs); + assertTrue(callback.sawNull); + } + + @Test + public void callingSetLogFilterWithNullReleasesTheHoldToPassThrough() { + final RecordingSink sink = new RecordingSink(); + bridge.holdUntilJs(); + bridge.attachModule(sink); + assertTrue(bridge.isHeld()); + + bridge.setEnabled(false, sink); + + assertFalse(bridge.isHeld()); + final MutableLog line = new MutableLog("public"); + final RecordingCallback callback = new RecordingCallback(); + bridge.route(line, callback); + assertEquals(1, callback.runs); + assertSame(line, callback.last); + } + + @Test + public void callingSetLogFilterWithAFunctionArmsItAndAsksJs() { + final RecordingSink sink = new RecordingSink(); + bridge.holdUntilJs(); + bridge.attachModule(sink); + + bridge.setEnabled(true, sink); + + assertFalse(bridge.isHeld()); + final RecordingCallback callback = new RecordingCallback(); + bridge.route(new MutableLog("secret"), callback); + assertEquals(1, sink.ids.size()); + assertEquals(0, callback.runs); + } + + @Test + public void aSupersededModuleCannotReleaseTheHold() { + final RecordingSink old = new RecordingSink(); + final RecordingSink next = new RecordingSink(); + bridge.holdUntilJs(); + bridge.attachModule(old); + bridge.attachModule(next); + + bridge.setEnabled(false, old); + + assertTrue(bridge.isHeld()); + final RecordingCallback callback = new RecordingCallback(); + bridge.route(new MutableLog("secret"), callback); + assertTrue(callback.sawNull); + } + + @Test + public void aReloadBeforeJsRunsStaysHeld() { + bridge.holdUntilJs(); + bridge.attachModule(new RecordingSink()); + bridge.attachModule(new RecordingSink()); + + final RecordingCallback callback = new RecordingCallback(); + bridge.route(new MutableLog("secret"), callback); + + assertTrue(bridge.isHeld()); + assertTrue(callback.sawNull); + } + + @Test + public void aBridgeThatWasNeverHeldIsThePassThrough() { + bridge.attachModule(new RecordingSink()); + assertFalse(bridge.isHeld()); + final MutableLog line = new MutableLog("public"); + final RecordingCallback callback = new RecordingCallback(); + + bridge.route(line, callback); + + assertSame(line, callback.last); + assertEquals(1, callback.runs); + } + /** * The console patch notes the echo before logcat is written. The channel * line is asked. The logcat echo is dropped without a second ask. A @@ -544,7 +646,7 @@ public void afterAReloadClearingTheFilterIsThePassThrough() { assertEquals(0, next.ids.size()); assertEquals(1, callback.runs); - assertTrue(!callback.sawNull); + assertFalse(callback.sawNull); } /** A late enable from the module a reload replaced must not arm the new, unsubscribed runtime. */ diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java index dea49718..6b5d96e2 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java @@ -10,6 +10,7 @@ import androidx.annotation.Nullable; import com.bugsee.library.contracts.common.Callback1; +import com.bugsee.library.contracts.exchange.EventFilter; import com.bugsee.library.contracts.exchange.NetworkEvent; import com.bugsee.library.contracts.options.Options; import com.bugsee.library.contracts.options.OptionsContainer; @@ -520,4 +521,91 @@ public void aStaleModulesEnableDoesNotArmTheNewModule() { assertEquals(1, next.ids.size()); assertEquals(0, asked.runs); } + + // The startup hold (#134): the SDK's filter is installed before JS exists. + private final List> installs = new ArrayList<>(); + + private NetworkFilterBridge installing() { + return new NetworkFilterBridge(scheduler, () -> false, installs::add); + } + + @Test + public void holdUntilJsInstallsTheFilterOnceAndDropsBeforeAnyModule() { + final NetworkFilterBridge held = installing(); + + held.holdUntilJs(); + held.holdUntilJs(); + + assertEquals(1, installs.size()); + assertTrue(installs.get(0) != null); + assertTrue(held.isHeld()); + final RecordingCallback callback = new RecordingCallback(); + installs.get(0).filter(new MutableNetwork("https://secret.example/token"), callback); + assertEquals(1, callback.runs); + assertTrue(callback.sawNull); + } + + @Test + public void aHeldBridgeDropsAtOnceWhileAModuleIsAttachedButNotArmed() { + final NetworkFilterBridge held = installing(); + final RecordingSink sink = new RecordingSink(); + held.holdUntilJs(); + held.attach(sink); + final RecordingCallback callback = new RecordingCallback(); + + held.ask(new MutableNetwork("https://secret.example/token"), callback); + + assertTrue(sink.ids.isEmpty()); + assertTrue(callback.sawNull); + assertTrue(scheduler.tasks.isEmpty()); + } + + @Test + public void setNetworkFilterWithNullRemovesTheHeldFilter() { + final NetworkFilterBridge held = installing(); + final RecordingSink sink = new RecordingSink(); + held.holdUntilJs(); + held.attach(sink); + + held.setEnabled(false, sink); + + assertFalse(held.isHeld()); + assertEquals(2, installs.size()); + assertNull(installs.get(1)); + } + + @Test + public void setNetworkFilterWithAFunctionKeepsTheInstalledFilterAndArmsIt() { + final NetworkFilterBridge held = installing(); + final RecordingSink sink = new RecordingSink(); + held.holdUntilJs(); + held.attach(sink); + + held.setEnabled(true, sink); + + assertFalse(held.isHeld()); + assertEquals(1, installs.size()); + final RecordingCallback callback = new RecordingCallback(); + held.ask(new MutableNetwork("https://secret.example/token"), callback); + assertEquals(1, sink.ids.size()); + } + + @Test + public void aSupersededModuleCannotReleaseTheNetworkHold() { + final NetworkFilterBridge held = installing(); + final RecordingSink old = new RecordingSink(); + held.holdUntilJs(); + held.attach(old); + held.attach(new RecordingSink()); + + held.setEnabled(false, old); + + assertTrue(held.isHeld()); + assertEquals(1, installs.size()); + } + + @Test + public void aBridgeThatWasNeverHeldReportsNotHeld() { + assertFalse(installing().isHeld()); + } } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactNativeWrapperInitProviderTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactNativeWrapperInitProviderTest.java index ce7aa063..c01fbb0e 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactNativeWrapperInitProviderTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactNativeWrapperInitProviderTest.java @@ -42,4 +42,67 @@ public void swallowsAnErrorFromRegistration() { throw new NoClassDefFoundError("com/bugsee/library/Bugsee"); }); } + + @Test + public void holdsTheFiltersTheMetaDataNames() { + final List held = new ArrayList<>(); + + ReactNativeWrapperInitProvider.holdFiltersEarly("log,breadcrumb", new StartupHold.Targets() { + @Override + public void holdNetwork() { + held.add("network"); + } + + @Override + public void holdLog() { + held.add("log"); + } + + @Override + public void holdBreadcrumb() { + held.add("breadcrumb"); + } + }); + + assertEquals(java.util.Arrays.asList("log", "breadcrumb"), held); + } + + @Test + public void anApplicationWithoutMetaDataHoldsNothing() { + final android.content.pm.ApplicationInfo info = new android.content.pm.ApplicationInfo(); + info.metaData = null; + org.junit.Assert.assertNull(ReactNativeWrapperInitProvider.holdFiltersValueOf(info)); + } + + @Test + public void aContextWhosePackageManagerThrowsReadsAsNoMetaData() { + // A ContextWrapper over nothing: getPackageManager() dereferences the null base. + final android.content.Context broken = new android.content.ContextWrapper(null); + org.junit.Assert.assertNull(ReactNativeWrapperInitProvider.holdFiltersMetaData(broken)); + } + + @Test + public void readsNoMetaDataFromAMissingContext() { + org.junit.Assert.assertNull(ReactNativeWrapperInitProvider.holdFiltersMetaData(null)); + } + + @Test + public void anErrorFromEveryHoldDoesNotEscape() { + ReactNativeWrapperInitProvider.holdFiltersEarly("all", new StartupHold.Targets() { + @Override + public void holdNetwork() { + throw new NoClassDefFoundError("com/bugsee/library/Bugsee"); + } + + @Override + public void holdLog() { + throw new IllegalStateException(); + } + + @Override + public void holdBreadcrumb() { + throw new OutOfMemoryError(); + } + }); + } } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/StartupHoldTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/StartupHoldTest.java new file mode 100644 index 00000000..3e2ddbc0 --- /dev/null +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/StartupHoldTest.java @@ -0,0 +1,172 @@ +package com.bugsee.reactnative; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +/** + * The startup hold runs in an init provider, before {@code Application.onCreate}. + * It must hold exactly what the manifest names, and never throw. + */ +public class StartupHoldTest { + + private static final class RecordingTargets implements StartupHold.Targets { + final List calls = new ArrayList<>(); + String failOn; + + private void call(final String name) { + calls.add(name); + if (name.equals(failOn)) { + throw new NoClassDefFoundError("com/bugsee/library/Bugsee"); + } + } + + @Override + public void holdNetwork() { + call("network"); + } + + @Override + public void holdLog() { + call("log"); + } + + @Override + public void holdBreadcrumb() { + call("breadcrumb"); + } + } + + @Test + public void nothingIsHeldWithoutTheMetaData() { + assertEquals(Collections.emptySet(), StartupHold.parse(null)); + assertEquals(Collections.emptySet(), StartupHold.parse("")); + assertEquals(Collections.emptySet(), StartupHold.parse(" , ")); + final RecordingTargets targets = new RecordingTargets(); + assertTrue(StartupHold.apply(null, targets).isEmpty()); + assertTrue(targets.calls.isEmpty()); + } + + @Test + public void aListHoldsOnlyTheFiltersItNames() { + assertEquals(new LinkedHashSet<>(Arrays.asList("log")), StartupHold.parse("log")); + assertEquals(new LinkedHashSet<>(Arrays.asList("network", "breadcrumb")), + StartupHold.parse(" Network , BREADCRUMB,network")); + } + + @Test + public void allAndTrueHoldEveryFilter() { + final LinkedHashSet everything = new LinkedHashSet<>(Arrays.asList("network", "log", "breadcrumb")); + assertEquals(everything, StartupHold.parse("all")); + assertEquals(everything, StartupHold.parse("true")); + assertEquals(everything, StartupHold.parse(Boolean.TRUE)); + assertEquals(Collections.emptySet(), StartupHold.parse(Boolean.FALSE)); + } + + @Test + public void anUnknownNameIsIgnoredNotFatal() { + assertEquals(new LinkedHashSet<>(Arrays.asList("log")), StartupHold.parse("logs,log,secure")); + assertEquals(Collections.emptySet(), StartupHold.parse("logs")); + } + + @Test + public void applyHoldsEachNamedFilterOnce() { + final RecordingTargets targets = new RecordingTargets(); + + final java.util.Set held = StartupHold.apply("log,network,log", targets); + + assertEquals(Arrays.asList("log", "network"), targets.calls); + assertEquals(new LinkedHashSet<>(Arrays.asList("log", "network")), held); + } + + @Test + public void aFailingHoldDoesNotStopTheOthersAndIsNotReportedAsHeld() { + final RecordingTargets targets = new RecordingTargets(); + targets.failOn = "network"; + + final java.util.Set held = StartupHold.apply("all", targets); + + assertEquals(Arrays.asList("network", "log", "breadcrumb"), targets.calls); + assertEquals(new LinkedHashSet<>(Arrays.asList("log", "breadcrumb")), held); + } + + private static final class FakeProbe implements StartupHold.Probe { + boolean network; + boolean log; + boolean breadcrumb; + + @Override + public boolean networkHeld() { + return network; + } + + @Override + public boolean logHeld() { + return log; + } + + @Override + public boolean breadcrumbHeld() { + return breadcrumb; + } + } + + @Test + public void stillHeldListsTheUnreleasedFiltersInAFixedOrder() { + final FakeProbe probe = new FakeProbe(); + assertTrue(StartupHold.stillHeld(probe).isEmpty()); + probe.breadcrumb = true; + probe.network = true; + assertEquals(new LinkedHashSet<>(Arrays.asList("network", "breadcrumb")), StartupHold.stillHeld(probe)); + probe.log = true; + assertEquals(new LinkedHashSet<>(Arrays.asList("network", "log", "breadcrumb")), StartupHold.stillHeld(probe)); + } + + @Test + public void theWatchdogWarnsOnceAfterTheDelayOnlyForFiltersStillHeld() { + StartupHold.resetWatchForTest(); + final FakeProbe probe = new FakeProbe(); + probe.log = true; + final List scheduled = new ArrayList<>(); + final List delays = new ArrayList<>(); + final List warnings = new ArrayList<>(); + + assertTrue(StartupHold.watch(probe, (delay, task) -> { + delays.add(delay); + scheduled.add(task); + }, warnings::add, 30_000L)); + // A second module (a reload) does not start a second watchdog. + assertFalse(StartupHold.watch(probe, (delay, task) -> scheduled.add(task), warnings::add, 30_000L)); + + assertEquals(Arrays.asList(30_000L), delays); + assertEquals(1, scheduled.size()); + assertTrue(warnings.isEmpty()); + scheduled.get(0).run(); + assertEquals(1, warnings.size()); + assertTrue(warnings.get(0).contains("[log]")); + assertTrue(warnings.get(0).contains("30 s")); + StartupHold.resetWatchForTest(); + } + + @Test + public void theWatchdogSaysNothingWhenJsReleasedEveryHold() { + StartupHold.resetWatchForTest(); + final FakeProbe probe = new FakeProbe(); + final List scheduled = new ArrayList<>(); + final List warnings = new ArrayList<>(); + StartupHold.watch(probe, (delay, task) -> scheduled.add(task), warnings::add, 1_000L); + + scheduled.get(0).run(); + + assertTrue(warnings.isEmpty()); + StartupHold.resetWatchForTest(); + } +} diff --git a/packages/react-native/plugin/build/index.d.ts b/packages/react-native/plugin/build/index.d.ts index df2d85ae..3255332c 100644 --- a/packages/react-native/plugin/build/index.d.ts +++ b/packages/react-native/plugin/build/index.d.ts @@ -1,4 +1,5 @@ import type { ConfigPlugin } from '@expo/config-plugins'; +import type { HoldableFilter } from './manifest'; export type AppTokenOption = string | { ios?: string; android?: string; @@ -71,6 +72,19 @@ export interface BugseePluginProps { * one of these again. */ endpoint?: string; + /** + * Android only; defaults off. Keeps the JS log, network and breadcrumb + * filters fail closed from process start. With `autoLaunch` the SDK records + * before JavaScript has run, so until `setLogFilter` / `setNetworkFilter` / + * `setBreadcrumbFilter` installs the app's filter, everything is recorded + * unfiltered. `true` holds all three; a list holds those named. A held + * filter drops its events (log lines, network events, breadcrumbs) until JS + * calls that `setXFilter`, with a function (filtering) or `null` (no filter, + * pass-through). An app that never calls one therefore records none of that + * kind, so only list the filters the app installs. Needs no `autoLaunch`. + * A later prebuild without it removes the meta-data again. + */ + holdFilters?: boolean | readonly HoldableFilter[]; } /** The Info.plist key the iOS native module reads `endpoint` from. */ export declare const IOS_ENDPOINT_PLIST_KEY = "BugseeReactNativeEndpoint"; diff --git a/packages/react-native/plugin/build/index.js b/packages/react-native/plugin/build/index.js index ab527902..1888f32e 100644 --- a/packages/react-native/plugin/build/index.js +++ b/packages/react-native/plugin/build/index.js @@ -74,6 +74,7 @@ const withBugsee = (config, props) => { const uploadSymbols = options.uploadSymbols !== false; const symbolicateReactNative = options.symbolicateReactNativeFrameworks === true; const endpoint = (0, endpoint_1.pluginEndpoint)(options.endpoint); + const holdFilters = (0, manifest_1.holdFiltersValue)(options.holdFilters); const versions = (0, native_versions_1.loadNativeVersions)(__dirname); const edits = { gradlePluginVersion: options.gradlePluginVersion ?? versions.gradlePlugin, @@ -127,6 +128,10 @@ const withBugsee = (config, props) => { if (edit.warning) { console.warn(edit.warning); } + config_plugins_1.AndroidConfig.Manifest.removeMetaDataItemFromMainApplication(mainApplication, manifest_1.RN_HOLD_FILTERS_META_DATA); + if (holdFilters !== null) { + config_plugins_1.AndroidConfig.Manifest.addMetaDataItemToMainApplication(mainApplication, manifest_1.RN_HOLD_FILTERS_META_DATA, holdFilters); + } if (!token) { return cfg; } diff --git a/packages/react-native/plugin/build/index.js.map b/packages/react-native/plugin/build/index.js.map index d671f771..71294cfe 100644 --- a/packages/react-native/plugin/build/index.js.map +++ b/packages/react-native/plugin/build/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/react-native/plugin/build/manifest.d.ts b/packages/react-native/plugin/build/manifest.d.ts index 686136ac..0a3257bd 100644 --- a/packages/react-native/plugin/build/manifest.d.ts +++ b/packages/react-native/plugin/build/manifest.d.ts @@ -46,3 +46,20 @@ export declare const SDK_ENDPOINT_KEPT: string; * endpoint behind, and one with a new value replaces the old one. */ export declare function endpointMetaData(input: EndpointMetaDataInput): EndpointMetaDataEdit; +/** + * The wrapper's own meta-data for the startup filter hold: a comma list of + * the filters the Android native module installs, fail closed, in its init + * provider, before the SDK can launch. See `ReactNativeWrapperInitProvider`. + * The name is the wrapper's, so a value found under it is this plugin's and is + * replaced or removed freely. + */ +export declare const RN_HOLD_FILTERS_META_DATA = "com.bugsee.reactnative.hold-filters"; +export declare const HOLDABLE_FILTERS: readonly ["network", "log", "breadcrumb"]; +export type HoldableFilter = (typeof HOLDABLE_FILTERS)[number]; +export declare const HOLD_FILTERS_NOT_VALID: string; +/** + * The `holdFilters` option as the meta-data value, or null when none is + * written (unset, `false` or an empty list). `true` holds all three. Anything + * else is refused, so a typo cannot silently leave a filter open. + */ +export declare function holdFiltersValue(option: unknown): string | null; diff --git a/packages/react-native/plugin/build/manifest.js b/packages/react-native/plugin/build/manifest.js index f19d0806..fbdbf841 100644 --- a/packages/react-native/plugin/build/manifest.js +++ b/packages/react-native/plugin/build/manifest.js @@ -1,8 +1,9 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.SDK_ENDPOINT_KEPT = exports.SDK_ENDPOINT_MARKER = exports.SDK_ENDPOINT_META_DATA = exports.RN_ENDPOINT_META_DATA = void 0; +exports.HOLD_FILTERS_NOT_VALID = exports.HOLDABLE_FILTERS = exports.RN_HOLD_FILTERS_META_DATA = exports.SDK_ENDPOINT_KEPT = exports.SDK_ENDPOINT_MARKER = exports.SDK_ENDPOINT_META_DATA = exports.RN_ENDPOINT_META_DATA = void 0; exports.manifestAutoLaunchToken = manifestAutoLaunchToken; exports.endpointMetaData = endpointMetaData; +exports.holdFiltersValue = holdFiltersValue; const properties_1 = require("./properties"); /** * The `com.bugsee.app-token` meta-data value, or null when the manifest @@ -66,4 +67,39 @@ function endpointMetaData(input) { set[exports.SDK_ENDPOINT_MARKER] = input.endpoint; return { remove, set }; } +/** + * The wrapper's own meta-data for the startup filter hold: a comma list of + * the filters the Android native module installs, fail closed, in its init + * provider, before the SDK can launch. See `ReactNativeWrapperInitProvider`. + * The name is the wrapper's, so a value found under it is this plugin's and is + * replaced or removed freely. + */ +exports.RN_HOLD_FILTERS_META_DATA = 'com.bugsee.reactnative.hold-filters'; +exports.HOLDABLE_FILTERS = ['network', 'log', 'breadcrumb']; +exports.HOLD_FILTERS_NOT_VALID = `@bugsee/react-native: holdFilters must be true, false, or a list of ${exports.HOLDABLE_FILTERS.join(', ')}`; +/** + * The `holdFilters` option as the meta-data value, or null when none is + * written (unset, `false` or an empty list). `true` holds all three. Anything + * else is refused, so a typo cannot silently leave a filter open. + */ +function holdFiltersValue(option) { + if (option === undefined || option === null || option === false) { + return null; + } + if (option === true) { + return exports.HOLDABLE_FILTERS.join(','); + } + if (!Array.isArray(option)) { + throw new Error(exports.HOLD_FILTERS_NOT_VALID); + } + const held = new Set(); + for (const name of option) { + if (!exports.HOLDABLE_FILTERS.includes(name)) { + throw new Error(exports.HOLD_FILTERS_NOT_VALID); + } + held.add(name); + } + const ordered = exports.HOLDABLE_FILTERS.filter((name) => held.has(name)); + return ordered.length === 0 ? null : ordered.join(','); +} //# sourceMappingURL=manifest.js.map \ No newline at end of file diff --git a/packages/react-native/plugin/build/manifest.js.map b/packages/react-native/plugin/build/manifest.js.map index bc8db970..484b132b 100644 --- a/packages/react-native/plugin/build/manifest.js.map +++ b/packages/react-native/plugin/build/manifest.js.map @@ -1 +1 @@ -{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":";;;AAaA,0DASC;AA6CD,4CAyBC;AA5FD,6CAAkD;AAQlD;;;;GAIG;AACH,SAAgB,uBAAuB,CAAC,KAAsB;IAC5D,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;QAC9B,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,IAAI,EAAE,CAAC;IACnC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,IAAA,+BAAkB,EAAC,KAAK,CAAC,EAAE,CAAC;QACpD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;GAKG;AACU,QAAA,qBAAqB,GAAG,iCAAiC,CAAC;AAEvE;;;;GAIG;AACU,QAAA,sBAAsB,GAAG,8BAA8B,CAAC;AACxD,QAAA,mBAAmB,GAAG,qCAAqC,CAAC;AAoB5D,QAAA,iBAAiB,GAC5B,0DAA0D,8BAAsB,0BAA0B;IAC1G,mGAAmG,CAAC;AAEtG;;;;GAIG;AACH,SAAgB,gBAAgB,CAAC,KAA4B;IAC3D,MAAM,MAAM,GAAa,CAAC,6BAAqB,CAAC,CAAC;IACjD,MAAM,GAAG,GAA2B,EAAE,CAAC;IACvC,MAAM,MAAM,GAAG,KAAK,CAAC,QAAQ,CAAC,2BAAmB,CAAC,CAAC;IACnD,MAAM,GAAG,GAAG,KAAK,CAAC,QAAQ,CAAC,8BAAsB,CAAC,CAAC;IACnD,MAAM,SAAS,GAAG,MAAM,KAAK,SAAS,IAAI,GAAG,KAAK,MAAM,CAAC;IACzD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,CAAC,IAAI,CAAC,2BAAmB,CAAC,CAAC;IACnC,CAAC;IACD,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,CAAC,IAAI,CAAC,8BAAsB,CAAC,CAAC;IACtC,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;QACpB,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACzB,CAAC;IACD,GAAG,CAAC,6BAAqB,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC;IAC5C,IAAI,KAAK,CAAC,eAAe,KAAK,IAAI,EAAE,CAAC;QACnC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACzB,CAAC;IACD,IAAI,GAAG,KAAK,SAAS,IAAI,CAAC,SAAS,EAAE,CAAC;QACpC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,yBAAiB,EAAE,CAAC;IACrD,CAAC;IACD,GAAG,CAAC,8BAAsB,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC;IAC7C,GAAG,CAAC,2BAAmB,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC;IAC1C,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;AACzB,CAAC"} \ No newline at end of file +{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/react-native/plugin/src/__tests__/with-bugsee.test.ts b/packages/react-native/plugin/src/__tests__/with-bugsee.test.ts index 4b5c4740..bb93659d 100644 --- a/packages/react-native/plugin/src/__tests__/with-bugsee.test.ts +++ b/packages/react-native/plugin/src/__tests__/with-bugsee.test.ts @@ -8,7 +8,7 @@ import type { ExpoConfig } from '@expo/config-types'; import withBugsee, { APP_GRADLE_NOT_GROOVY, gradleNotGroovy, platformToken } from '../index'; import { ENDPOINT_HAS_VERSION, ENDPOINT_NOT_VALID } from '../endpoint'; -import { SDK_ENDPOINT_KEPT } from '../manifest'; +import { HOLD_FILTERS_NOT_VALID, SDK_ENDPOINT_KEPT, holdFiltersValue } from '../manifest'; import type { BugseePluginProps } from '../index'; import { CANNOT_EDIT } from '../gradle'; import type * as NativeVersions from '../native-versions'; @@ -263,6 +263,61 @@ describe('withBugsee through the Expo mod compiler', () => { expect(read('manifest')).toContain(`android:value="${ANDROID_TOKEN}"`); }); + describe('holdFiltersValue', () => { + it.each([ + [undefined, null], + [null, null], + [false, null], + [[], null], + [true, 'network,log,breadcrumb'], + [['log'], 'log'], + [['breadcrumb', 'log', 'log'], 'log,breadcrumb'], + [['network', 'log', 'breadcrumb'], 'network,log,breadcrumb'], + ])('%j is %j', (option, expected) => { + expect(holdFiltersValue(option)).toBe(expected); + }); + + it.each([['network'], [0], [1], [{}], [['Log']], [['network', 'x']], [[null]], [''], [[['log']]]])( + 'refuses %j', + (option) => { + expect(() => holdFiltersValue(option)).toThrow(HOLD_FILTERS_NOT_VALID); + }, + ); + }); + + describe('holdFilters', () => { + const HOLD_META = 'android:name="com.bugsee.reactnative.hold-filters"'; + + it('writes nothing when it is unset, false or empty', async () => { + for (const holdFilters of [undefined, false, []] as const) { + await prebuild({ appToken: ANDROID_TOKEN, holdFilters }); + expect(read('manifest')).not.toContain('hold-filters'); + } + }); + + it('holds all three for true, and the named ones in a fixed order for a list', async () => { + await prebuild({ appToken: ANDROID_TOKEN, holdFilters: true }); + expect(read('manifest')).toContain(``); + await prebuild({ appToken: ANDROID_TOKEN, holdFilters: ['breadcrumb', 'network', 'network'] }); + expect(read('manifest')).toContain(``); + expect(read('manifest').split(HOLD_META)).toHaveLength(2); + }); + + it('is written without autoLaunch, and removed again byte for byte', async () => { + await prebuild({ appToken: ANDROID_TOKEN }); + const without = snapshot(projectRoot); + await prebuild({ appToken: ANDROID_TOKEN, holdFilters: ['log'] }); + expect(read('manifest')).toContain(`${HOLD_META} android:value="log"`); + expect(read('manifest')).not.toContain('com.bugsee.app-token'); + await prebuild({ appToken: ANDROID_TOKEN }); + expect(snapshot(projectRoot)).toEqual(without); + }); + + it.each([[['logs']], ['network'], [1], [[1]]])('fails the prebuild for %j', async (holdFilters) => { + await expect(prebuild({ holdFilters } as unknown as BugseePluginProps)).rejects.toThrow(HOLD_FILTERS_NOT_VALID); + }); + }); + it('honours a gradlePluginVersion override and rewrites it on the next run', async () => { await prebuild({ gradlePluginVersion: '9.9.9' }); expect(read('root')).toContain("id 'com.bugsee.android.gradle' version '9.9.9' apply false"); diff --git a/packages/react-native/plugin/src/index.ts b/packages/react-native/plugin/src/index.ts index 33a981b5..2cc3f7d5 100644 --- a/packages/react-native/plugin/src/index.ts +++ b/packages/react-native/plugin/src/index.ts @@ -28,7 +28,13 @@ import { ensureMavenCentral, ensureSymbolUploads, } from './gradle'; -import { endpointMetaData, manifestAutoLaunchToken } from './manifest'; +import { + RN_HOLD_FILTERS_META_DATA, + endpointMetaData, + holdFiltersValue, + manifestAutoLaunchToken, +} from './manifest'; +import type { HoldableFilter } from './manifest'; import { loadNativeVersions } from './native-versions'; import { insertSymbolicatePrebuilt, removeSymbolicatePrebuilt } from './podfile'; import { bugseePropertiesText } from './properties'; @@ -104,6 +110,19 @@ export interface BugseePluginProps { * one of these again. */ endpoint?: string; + /** + * Android only; defaults off. Keeps the JS log, network and breadcrumb + * filters fail closed from process start. With `autoLaunch` the SDK records + * before JavaScript has run, so until `setLogFilter` / `setNetworkFilter` / + * `setBreadcrumbFilter` installs the app's filter, everything is recorded + * unfiltered. `true` holds all three; a list holds those named. A held + * filter drops its events (log lines, network events, breadcrumbs) until JS + * calls that `setXFilter`, with a function (filtering) or `null` (no filter, + * pass-through). An app that never calls one therefore records none of that + * kind, so only list the filters the app installs. Needs no `autoLaunch`. + * A later prebuild without it removes the meta-data again. + */ + holdFilters?: boolean | readonly HoldableFilter[]; } /** The Info.plist key the iOS native module reads `endpoint` from. */ @@ -189,6 +208,7 @@ const withBugsee: ConfigPlugin = (config, props) => { const uploadSymbols = options.uploadSymbols !== false; const symbolicateReactNative = options.symbolicateReactNativeFrameworks === true; const endpoint = pluginEndpoint(options.endpoint); + const holdFilters = holdFiltersValue(options.holdFilters); const versions = loadNativeVersions(__dirname); const edits: GradleEdits = { gradlePluginVersion: options.gradlePluginVersion ?? versions.gradlePlugin, @@ -251,6 +271,10 @@ const withBugsee: ConfigPlugin = (config, props) => { if (edit.warning) { console.warn(edit.warning); } + AndroidConfig.Manifest.removeMetaDataItemFromMainApplication(mainApplication, RN_HOLD_FILTERS_META_DATA); + if (holdFilters !== null) { + AndroidConfig.Manifest.addMetaDataItemToMainApplication(mainApplication, RN_HOLD_FILTERS_META_DATA, holdFilters); + } if (!token) { return cfg; } diff --git a/packages/react-native/plugin/src/manifest.ts b/packages/react-native/plugin/src/manifest.ts index 348a53ac..345c769c 100644 --- a/packages/react-native/plugin/src/manifest.ts +++ b/packages/react-native/plugin/src/manifest.ts @@ -91,3 +91,44 @@ export function endpointMetaData(input: EndpointMetaDataInput): EndpointMetaData set[SDK_ENDPOINT_MARKER] = input.endpoint; return { remove, set }; } + +/** + * The wrapper's own meta-data for the startup filter hold: a comma list of + * the filters the Android native module installs, fail closed, in its init + * provider, before the SDK can launch. See `ReactNativeWrapperInitProvider`. + * The name is the wrapper's, so a value found under it is this plugin's and is + * replaced or removed freely. + */ +export const RN_HOLD_FILTERS_META_DATA = 'com.bugsee.reactnative.hold-filters'; + +export const HOLDABLE_FILTERS = ['network', 'log', 'breadcrumb'] as const; +export type HoldableFilter = (typeof HOLDABLE_FILTERS)[number]; + +export const HOLD_FILTERS_NOT_VALID = + `@bugsee/react-native: holdFilters must be true, false, or a list of ${HOLDABLE_FILTERS.join(', ')}`; + +/** + * The `holdFilters` option as the meta-data value, or null when none is + * written (unset, `false` or an empty list). `true` holds all three. Anything + * else is refused, so a typo cannot silently leave a filter open. + */ +export function holdFiltersValue(option: unknown): string | null { + if (option === undefined || option === null || option === false) { + return null; + } + if (option === true) { + return HOLDABLE_FILTERS.join(','); + } + if (!Array.isArray(option)) { + throw new Error(HOLD_FILTERS_NOT_VALID); + } + const held = new Set(); + for (const name of option) { + if (!(HOLDABLE_FILTERS as readonly unknown[]).includes(name)) { + throw new Error(HOLD_FILTERS_NOT_VALID); + } + held.add(name as string); + } + const ordered = HOLDABLE_FILTERS.filter((name) => held.has(name)); + return ordered.length === 0 ? null : ordered.join(','); +} diff --git a/packages/react-native/src/__tests__/lifecycle.test.ts b/packages/react-native/src/__tests__/lifecycle.test.ts index dd3330d5..605861e0 100644 --- a/packages/react-native/src/__tests__/lifecycle.test.ts +++ b/packages/react-native/src/__tests__/lifecycle.test.ts @@ -254,6 +254,25 @@ describe('attach', () => { expect(installConsoleCapture).not.toHaveBeenCalled(); }); + // An auto-launched session registered only type and version natively, at + // process start; the context is knowable in JS alone. + it('registers the full wrapper identity, as launch does', async () => { + await Bugsee.attach(); + expect(native.setWrapperInfo).toHaveBeenCalledTimes(1); + const identity = native.setWrapperInfo.mock.calls[0]?.[0]; + expect(identity).toEqual( + expect.objectContaining({ type: expect.any(String), version: expect.any(String), context: expect.any(Object) }), + ); + await Bugsee.launch('tok'); + expect(native.setWrapperInfo.mock.calls[1]?.[0]).toEqual(identity); + }); + + it('registers the wrapper before it reads the launch options, so a failed read still attributes', async () => { + native.getLaunchOptions.mockRejectedValue(new Error('options unavailable')); + await expect(Bugsee.attach()).rejects.toThrow(/options unavailable/); + expect(native.setWrapperInfo).toHaveBeenCalledTimes(1); + }); + it('installs console capture from the launch options it just read', async () => { const opts = { 'com.bugsee.option.capture.logs': false }; native.getLaunchOptions.mockResolvedValue(opts); diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index c2927092..4b1eab79 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -152,7 +152,7 @@ class Bugsee { /** * Tells the SDK what wrapper it is running under. * - * Idempotent and cheap, so `launch` and `relaunch` can both call it; the + * Idempotent and cheap, so `launch`, `relaunch` and `attach` can all call it; the * SDK replaces whatever was registered. */ private registerWrapper(): void { @@ -680,6 +680,11 @@ class Bugsee { * from `com.bugsee.app-token` manifest metadata. Deliberately makes no * native launch call; doing so would start a second session. * + * Registers the full wrapper identity first, as `launch` and `relaunch` + * do: the native side registered only the type and version at process + * start, so without this every report of an auto-launched session lacks + * the context (React Native version, JS engine, dev build). + * * Reads `com.bugsee.option.detect.crash` from the native launch options and * applies it before the global handlers are installed (R7). Capture is held * off during that read: the default is on, and the read yields, so a render @@ -689,6 +694,7 @@ class Bugsee { * handlers; a rejected read does not install it either. */ async attach(): Promise { + this.registerWrapper(); setExceptionCaptureEnabled(false); const options = await this.getLaunchOptions(); applyExceptionCaptureFromOptions(options); From c583cee3b51c5ab9cdbe9896c2709f271dcdd902 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 01:01:11 +0500 Subject: [PATCH 05/12] Filters: order attach against enable/disable under one lock (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../reactnative/BreadcrumbFilterBridge.java | 6 ++- .../reactnative/NetworkFilterBridge.java | 39 ++++++++++++------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java index f9ff2f51..ab006ac2 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java @@ -126,8 +126,10 @@ public void filter( void attach(@NonNull final Sink next) { // Disarm before the sink is visible: a request in the gap must not // reach a runtime that has not subscribed yet. See armedFor. - armedFor.set(null); - sink.set(next); + withSdkLock(() -> { + armedFor.set(null); + sink.set(next); + }); } /** diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index 9aa9f3a1..37f45743 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -111,6 +111,8 @@ static NetworkFilterBridge shared() { private final ConcurrentHashMap pending = new ConcurrentHashMap<>(); private final AtomicLong ids = new AtomicLong(); private final AtomicBoolean installed = new AtomicBoolean(); + /** Orders attach against setEnabled; see {@link #setEnabled(boolean, Sink)}. */ + private final Object stateLock = new Object(); /** * The module whose JS has installed its filter with @@ -155,8 +157,10 @@ public void filter( void attach(@NonNull final Sink next) { // Disarm before the sink is visible: a request in the gap must not // reach a runtime that has not subscribed yet. See armedFor. - armedFor.set(null); - sink.set(next); + synchronized (stateLock) { + armedFor.set(null); + sink.set(next); + } } /** @@ -207,21 +211,26 @@ void setEnabled(final boolean enabled) { /** As above, on behalf of module {@code caller}; only the attached module can arm. */ void setEnabled(final boolean enabled, final Sink caller) { - if (sink.get() != caller) { - // A superseded module must neither arm nor disable the process-wide filter. - return; - } - if (enabled) { - if (caller != null) { - arm(caller); + // One lock for the identity check and the install, so a stale module + // cannot pass the check, pause, and remove a filter the new module + // has since relied on. + synchronized (stateLock) { + if (sink.get() != caller) { + // A superseded module must neither arm nor disable the process-wide filter. + return; + } + if (enabled) { + if (caller != null) { + arm(caller); + } + if (installed.compareAndSet(false, true)) { + Bugsee.setNetworkEventFilter(filter); + } + return; } - if (installed.compareAndSet(false, true)) { - Bugsee.setNetworkEventFilter(filter); + if (installed.compareAndSet(true, false)) { + Bugsee.setNetworkEventFilter(null); } - return; - } - if (installed.compareAndSet(true, false)) { - Bugsee.setNetworkEventFilter(null); } } From e315390b72985f925f2eaa69a4748057e13d941b Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 01:31:25 +0500 Subject: [PATCH 06/12] Filters: order the log filter's attach against enable/disable under one lock (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bugsee/reactnative/LogFilterBridge.java | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java index 9e17dc77..ecdb38a9 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java @@ -119,6 +119,8 @@ static LogFilterBridge shared() { private final AtomicLong ids = new AtomicLong(); /** True only while the app has a log callback. False is the native pass-through. */ private final AtomicBoolean userFilter = new AtomicBoolean(); + /** Orders attach against setEnabled; see {@link #setEnabled(boolean, Sink)}. */ + private final Object stateLock = new Object(); /** * The module whose JS has installed its filter with @@ -176,8 +178,10 @@ void attach(@NonNull final Sink next) { // reach a runtime that has not subscribed yet. See armedFor. userFilter // is deliberately kept: a redaction the app relied on must not silently // stop applying across a reload. - armedFor.set(null); - sink.set(next); + synchronized (stateLock) { + armedFor.set(null); + sink.set(next); + } } /** @@ -221,8 +225,10 @@ void arm() { * forgotten and the callback is not touched. */ void detach(@NonNull final Sink current) { - sink.compareAndSet(current, null); - armedFor.compareAndSet(current, null); + synchronized (stateLock) { + sink.compareAndSet(current, null); + armedFor.compareAndSet(current, null); + } final List owned = new ArrayList<>(); for (final Pending item : pending.values()) { if (item.owner == current && pending.remove(item.id, item)) { @@ -249,16 +255,19 @@ void setEnabled(final boolean enabled) { /** As above, on behalf of module {@code caller}; only the attached module can arm. */ void setEnabled(final boolean enabled, final Sink caller) { - if (sink.get() != caller) { - // A superseded module must neither arm nor disable the process-wide filter. - return; - } - if (enabled) { - if (caller != null) { + // One lock for the identity check and the write, so a stale module + // cannot pass the check, pause, and switch off a filter the new + // module has since turned on. + synchronized (stateLock) { + if (sink.get() != caller) { + // A superseded module must neither arm nor disable the process-wide filter. + return; + } + if (enabled && caller != null) { arm(caller); } + userFilter.set(enabled); } - userFilter.set(enabled); ensureInstalled(); } From 569104e1228a397262a3f79bacfe0fd0995858d5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 01:42:12 +0500 Subject: [PATCH 07/12] Docs: accurate reload semantics, no-RN process, autoLaunch scope for holdFilters (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/README.md | 11 +++++++++-- packages/react-native/plugin/build/index.d.ts | 3 ++- packages/react-native/plugin/build/index.js.map | 2 +- packages/react-native/plugin/src/index.ts | 3 ++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index a7dc8c65..e01ab5d5 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -280,8 +280,15 @@ meta-data, before any JavaScript exists. Two things follow: JS starts. It is off by default for that reason, and an app that never uses filters is unaffected. It writes `com.bugsee.reactnative.hold-filters` application meta-data, removed again by a prebuild without the option. - The hold also covers a JS reload: a new runtime is held until it calls the - setter again, exactly like the start. + A JS reload is held only if the previous runtime had a filter function + installed (it stays dropped until the new runtime calls the setter again). If + the previous runtime called `setXFilter(null)`, that kind passes through again + from the reload until the new runtime calls the setter. The 30 s warning is + about the first runtime only. The hold is meant for `autoLaunch`; it was not + verified with a JS `launch()`, where the SDK may replace a filter set before + launch. In a process that never starts React Native (a service or push + process), a held kind drops everything native code records, for the life of + that process. Two interactions to know about: a filter you declare in the manifest (`com.bugsee.filter.log-event`, `network-event` or `breadcrumb`) is not applied for a kind that is held, because the SDK lets a filter set from code diff --git a/packages/react-native/plugin/build/index.d.ts b/packages/react-native/plugin/build/index.d.ts index 3255332c..bba92e32 100644 --- a/packages/react-native/plugin/build/index.d.ts +++ b/packages/react-native/plugin/build/index.d.ts @@ -81,7 +81,8 @@ export interface BugseePluginProps { * filter drops its events (log lines, network events, breadcrumbs) until JS * calls that `setXFilter`, with a function (filtering) or `null` (no filter, * pass-through). An app that never calls one therefore records none of that - * kind, so only list the filters the app installs. Needs no `autoLaunch`. + * kind, so only list the filters the app installs. Meant for `autoLaunch`: + * not verified with a JS `launch()`. * A later prebuild without it removes the meta-data again. */ holdFilters?: boolean | readonly HoldableFilter[]; diff --git a/packages/react-native/plugin/build/index.js.map b/packages/react-native/plugin/build/index.js.map index 71294cfe..373fa0de 100644 --- a/packages/react-native/plugin/build/index.js.map +++ b/packages/react-native/plugin/build/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/react-native/plugin/src/index.ts b/packages/react-native/plugin/src/index.ts index 2cc3f7d5..ee2bfb16 100644 --- a/packages/react-native/plugin/src/index.ts +++ b/packages/react-native/plugin/src/index.ts @@ -119,7 +119,8 @@ export interface BugseePluginProps { * filter drops its events (log lines, network events, breadcrumbs) until JS * calls that `setXFilter`, with a function (filtering) or `null` (no filter, * pass-through). An app that never calls one therefore records none of that - * kind, so only list the filters the app installs. Needs no `autoLaunch`. + * kind, so only list the filters the app installs. Meant for `autoLaunch`: + * not verified with a JS `launch()`. * A later prebuild without it removes the meta-data again. */ holdFilters?: boolean | readonly HoldableFilter[]; From 76b3608344aca9318c7e9400b9d4522e4ca50d63 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 01:51:36 +0500 Subject: [PATCH 08/12] Filters: take the state lock in detach (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../java/com/bugsee/reactnative/BreadcrumbFilterBridge.java | 6 ++++-- .../java/com/bugsee/reactnative/NetworkFilterBridge.java | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java index ab006ac2..8a5f4491 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java @@ -158,8 +158,10 @@ void arm() { * callback is run with {@code null}, which discards the crumb. */ void detach(@NonNull final Sink current) { - sink.compareAndSet(current, null); - armedFor.compareAndSet(current, null); + withSdkLock(() -> { + sink.compareAndSet(current, null); + armedFor.compareAndSet(current, null); + }); final List owned = new ArrayList<>(); for (final Pending item : pending.values()) { if (item.owner == current && pending.remove(item.id, item)) { diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index 37f45743..647c83ef 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -188,8 +188,10 @@ void arm() { * forgotten and the callback is not touched. */ void detach(@NonNull final Sink current) { - sink.compareAndSet(current, null); - armedFor.compareAndSet(current, null); + synchronized (stateLock) { + sink.compareAndSet(current, null); + armedFor.compareAndSet(current, null); + } final List owned = new ArrayList<>(); for (final Pending item : pending.values()) { if (item.owner == current && pending.remove(item.id, item)) { From 94c42767112b626a47f4c937189958e0f3f83d63 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 10:20:55 +0500 Subject: [PATCH 09/12] NetworkFilterBridge: drop the redundant sink alias in ask (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../java/com/bugsee/reactnative/NetworkFilterBridge.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index 647c83ef..32bf61e6 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -304,10 +304,9 @@ void ask( drop(callback); return; } - final Sink current = asked; // A null URL cannot tell a recycled entry (reset nulls the URL) from // the one we were asked about, so it is dropped rather than lent. - if (url == null || current == null) { + if (url == null) { drop(callback); return; } @@ -319,11 +318,11 @@ void ask( return; } final String requestId = Long.toString(ids.incrementAndGet()); - final Pending item = new Pending(requestId, event, id, url, callback, current); + final Pending item = new Pending(requestId, event, id, url, callback, asked); pending.put(requestId, item); try { item.deadline = scheduler.schedule(() -> forget(requestId), BORROW_MS); - current.onNetworkFilterRequest(requestId, eventJson); + asked.onNetworkFilterRequest(requestId, eventJson); } catch (final Throwable e) { if (pending.remove(requestId, item)) { cancel(item.deadline); From e2288e0a2589b6750f6398d10eb28eabf220f237 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 10:21:18 +0500 Subject: [PATCH 10/12] Startup hold: retry a failed early install, start the watchdog from the init provider (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/README.md | 6 ++-- .../reactnative/BreadcrumbFilterBridge.java | 26 +++++++++++++---- .../com/bugsee/reactnative/BugseeModule.java | 2 -- .../reactnative/NetworkFilterBridge.java | 26 +++++++++++++---- .../ReactNativeWrapperInitProvider.java | 5 +++- .../com/bugsee/reactnative/StartupHold.java | 14 +++++---- .../BreadcrumbFilterBridgeTest.java | 29 +++++++++++++++++++ .../reactnative/NetworkFilterBridgeTest.java | 29 +++++++++++++++++++ 8 files changed, 114 insertions(+), 23 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index e01ab5d5..cc5d979a 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -276,15 +276,15 @@ meta-data, before any JavaScript exists. Two things follow: `null` (no filter: events pass through). Call each setter unconditionally, early, on every start of the JS runtime. Only list the filters your app installs: an app that lists one and never calls its setter records none of - that kind, and the module says so in the log (`BugseeRN`, warning) 30 s after - JS starts. It is off by default for that reason, and an app that never uses + that kind, and the wrapper says so in the log (`BugseeRN`, warning) 30 s after + process start. It is off by default for that reason, and an app that never uses filters is unaffected. It writes `com.bugsee.reactnative.hold-filters` application meta-data, removed again by a prebuild without the option. A JS reload is held only if the previous runtime had a filter function installed (it stays dropped until the new runtime calls the setter again). If the previous runtime called `setXFilter(null)`, that kind passes through again from the reload until the new runtime calls the setter. The 30 s warning is - about the first runtime only. The hold is meant for `autoLaunch`; it was not + given once per process, at 30 s after process start. The hold is meant for `autoLaunch`; it was not verified with a JS `launch()`, where the SDK may replace a filter set before launch. In a process that never starts React Native (a service or push process), a held kind drops everything native code records, for the life of diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java index 60b91e05..96713693 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java @@ -117,6 +117,24 @@ static BreadcrumbFilterBridge shared() { private final Consumer> installer; + /** + * Installs the filter with the SDK. A throw (the SDK not ready that early) + * leaves {@code installed} false, so a later {@code setEnabled(true)} + * installs it instead of believing it already is, and is rethrown for the + * caller to log. + */ + private void install() { + if (installed.compareAndSet(false, true)) { + try { + installer.accept(filter); + } catch (final RuntimeException | Error e) { + installed.set(false); + throw e; + } + } + } + + /** True from {@link #holdUntilJs} until JS first calls {@link #setEnabled}. For the watchdog only. */ private final AtomicBoolean held = new AtomicBoolean(); @@ -215,10 +233,8 @@ void withSdkLock(@NonNull final Runnable body) { */ void holdUntilJs() { withSdkLock(() -> { + install(); held.set(true); - if (installed.compareAndSet(false, true)) { - installer.accept(filter); - } }); } @@ -239,9 +255,7 @@ void setEnabled(final boolean enabled, final Sink caller) { if (caller != null) { arm(caller); } - if (installed.compareAndSet(false, true)) { - installer.accept(filter); - } + install(); return; } if (installed.compareAndSet(true, false)) { diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 487b1f17..0665cb48 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -146,8 +146,6 @@ public BugseeModule(final ReactApplicationContext context) { NetworkFilterBridge.shared().attach(this); LogFilterBridge.shared().attachModule(this); BreadcrumbFilterBridge.shared().attach(this); - // Says so if a startup filter hold is still waiting for JS well after this. - StartupHold.watchFromModule(); } /** diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index 9ec96aaf..d0f977e5 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -152,6 +152,24 @@ private NetworkFilterBridge() { private final Consumer> installer; + /** + * Installs the filter with the SDK. A throw (the SDK not ready that early) + * leaves {@code installed} false, so a later {@code setEnabled(true)} + * installs it instead of believing it already is, and is rethrown for the + * caller to log. + */ + private void install() { + if (installed.compareAndSet(false, true)) { + try { + installer.accept(filter); + } catch (final RuntimeException | Error e) { + installed.set(false); + throw e; + } + } + } + + /** True from {@link #holdUntilJs} until JS first calls {@link #setEnabled}. For the watchdog only. */ private final AtomicBoolean held = new AtomicBoolean(); @@ -234,10 +252,8 @@ void detach(@NonNull final Sink current) { * it again. A second call, or one after {@code setEnabled(true)}, is a no-op. */ void holdUntilJs() { + install(); held.set(true); - if (installed.compareAndSet(false, true)) { - installer.accept(filter); - } } /** Installs the filter, or removes it. A second {@code true} is a no-op. */ @@ -260,9 +276,7 @@ void setEnabled(final boolean enabled, final Sink caller) { if (caller != null) { arm(caller); } - if (installed.compareAndSet(false, true)) { - installer.accept(filter); - } + install(); return; } if (installed.compareAndSet(true, false)) { diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWrapperInitProvider.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWrapperInitProvider.java index 500004cd..24738472 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWrapperInitProvider.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWrapperInitProvider.java @@ -82,7 +82,10 @@ static Object holdFiltersValueOf(@NonNull final ApplicationInfo info) { /** Never throws; see {@link StartupHold#apply}. */ static void holdFiltersEarly(@Nullable final Object metaData, @NonNull final StartupHold.Targets targets) { try { - StartupHold.apply(metaData, targets); + final java.util.Set held = StartupHold.apply(metaData, targets); + if (targets == StartupHold.BRIDGES) { + StartupHold.watchFromProvider(held); + } } catch (Throwable t) { Log.e(TAG, "could not hold the filters at process start: " + t.getClass().getName()); } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/StartupHold.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/StartupHold.java index 565a7269..f78a24ec 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/StartupHold.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/StartupHold.java @@ -205,18 +205,22 @@ static boolean watch( final Set waiting = stillHeld(probe); if (!waiting.isEmpty()) { warn.accept("the " + waiting + " filter(s) are still held " + (delayMs / 1000L) - + " s after the JS runtime started: events of that kind are being dropped. Call " + + " s after process start: events of that kind are being dropped. If JS runs, call " + "setNetworkFilter / setLogFilter / setBreadcrumbFilter at startup (null for no filter), " - + "or remove it from holdFilters"); + + "or remove it from holdFilters; a process that never starts React Native holds them for its whole life"); } }); return true; } - /** What a module does once its bridges are attached: start the watchdog if a hold is still on. */ - static void watchFromModule() { + /** + * Starts the watchdog from the init provider, so it runs in a process that + * never starts React Native too ("still held 30 s after process start"). + * A no-op when nothing was held. + */ + static void watchFromProvider(@NonNull final Set held) { try { - if (stillHeld(PROBE).isEmpty()) { + if (held.isEmpty()) { return; } final Handler main = new Handler(Looper.getMainLooper()); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java index 81025e82..42efa4b6 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BreadcrumbFilterBridgeTest.java @@ -557,4 +557,33 @@ public void aSupersededModuleCannotReleaseTheBreadcrumbHold() { public void aBridgeThatWasNeverHeldReportsNotHeld() { assertFalse(installing().isHeld()); } + + @Test + public void aFailedEarlyInstallIsRetriedByTheNextSetEnabled() { + final int[] attempts = {0}; + final java.util.function.Consumer> failingOnce = filter -> { + attempts[0]++; + if (attempts[0] == 1) { + throw new IllegalStateException("SDK not ready"); + } + installs.add(filter); + }; + final BreadcrumbFilterBridge bridge2 = new BreadcrumbFilterBridge(failingOnce); + final RecordingSink sink = new RecordingSink(); + bridge2.attach(sink); + + try { + bridge2.holdUntilJs(); + org.junit.Assert.fail("the install failure is rethrown for StartupHold to log"); + } catch (final IllegalStateException expected) { + // StartupHold logs it and goes on. + } + // Not installed, so not reported as held. + assertFalse(bridge2.isHeld()); + + bridge2.setEnabled(true, sink); + + assertEquals(2, attempts[0]); + assertEquals(1, installs.size()); + } } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java index 6b5d96e2..f20e26df 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java @@ -608,4 +608,33 @@ public void aSupersededModuleCannotReleaseTheNetworkHold() { public void aBridgeThatWasNeverHeldReportsNotHeld() { assertFalse(installing().isHeld()); } + + @Test + public void aFailedEarlyInstallIsRetriedByTheNextSetEnabled() { + final int[] attempts = {0}; + final java.util.function.Consumer> failingOnce = filter -> { + attempts[0]++; + if (attempts[0] == 1) { + throw new IllegalStateException("SDK not ready"); + } + installs.add(filter); + }; + final NetworkFilterBridge bridge2 = new NetworkFilterBridge(scheduler, () -> false, failingOnce); + final RecordingSink sink = new RecordingSink(); + bridge2.attach(sink); + + try { + bridge2.holdUntilJs(); + org.junit.Assert.fail("the install failure is rethrown for StartupHold to log"); + } catch (final IllegalStateException expected) { + // StartupHold logs it and goes on. + } + // Not installed, so not reported as held. + assertFalse(bridge2.isHeld()); + + bridge2.setEnabled(true, sink); + + assertEquals(2, attempts[0]); + assertEquals(1, installs.size()); + } } From 98d81103228faabd32adf31254e20c1f70b3c6bb Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 10:48:42 +0500 Subject: [PATCH 11/12] Install guard: no catch-and-rethrow, so the raw-message scan passes (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/README.md | 3 ++- .../com/bugsee/reactnative/BreadcrumbFilterBridge.java | 9 ++++++--- .../java/com/bugsee/reactnative/NetworkFilterBridge.java | 9 ++++++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index cc5d979a..14f50a05 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -284,7 +284,8 @@ meta-data, before any JavaScript exists. Two things follow: installed (it stays dropped until the new runtime calls the setter again). If the previous runtime called `setXFilter(null)`, that kind passes through again from the reload until the new runtime calls the setter. The 30 s warning is - given once per process, at 30 s after process start. The hold is meant for `autoLaunch`; it was not + given once per process, at 30 s after process start. The hold is meant + for `autoLaunch`; it was not verified with a JS `launch()`, where the SDK may replace a filter set before launch. In a process that never starts React Native (a service or push process), a held kind drops everything native code records, for the life of diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java index 96713693..ac12a1f9 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BreadcrumbFilterBridge.java @@ -125,11 +125,14 @@ static BreadcrumbFilterBridge shared() { */ private void install() { if (installed.compareAndSet(false, true)) { + boolean ok = false; try { installer.accept(filter); - } catch (final RuntimeException | Error e) { - installed.set(false); - throw e; + ok = true; + } finally { + if (!ok) { + installed.set(false); + } } } } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index d0f977e5..e0dfc358 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -160,11 +160,14 @@ private NetworkFilterBridge() { */ private void install() { if (installed.compareAndSet(false, true)) { + boolean ok = false; try { installer.accept(filter); - } catch (final RuntimeException | Error e) { - installed.set(false); - throw e; + ok = true; + } finally { + if (!ok) { + installed.set(false); + } } } } From 0ee604456948286b3ed6644acb66a736b03ff859 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 11:48:26 +0500 Subject: [PATCH 12/12] LogFilterBridge: publish the hold only after a successful install, roll back on failure (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bugsee/reactnative/LogFilterBridge.java | 20 +++++++++++-- .../reactnative/LogFilterBridgeTest.java | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java index cc3b017e..305fe458 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/LogFilterBridge.java @@ -155,7 +155,8 @@ private LogFilterBridge() { this(scheduler, clock, filter -> { }); } - private LogFilterBridge( + /** Tests pass the call that installs the SDK's filter. */ + LogFilterBridge( @NonNull final Scheduler scheduler, @NonNull final ConsoleEchoDedup.Clock clock, @NonNull final Installer installer @@ -216,9 +217,22 @@ void attachModule(@NonNull final Sink next) { * sends) releases it to the pass-through. */ void holdUntilJs() { - held.set(true); + // userFilter first: once the filter is installed no line may pass + // through before it is on. If the install throws it is rolled back + // (the throw goes on to StartupHold, which logs it) and the hold is + // not reported, as in the other two bridges; attachModule installs + // again when the module is built. userFilter.set(true); - ensureInstalled(); + boolean ok = false; + try { + ensureInstalled(); + ok = true; + } finally { + if (!ok) { + userFilter.set(false); + } + } + held.set(true); } /** diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java index 0c531c37..20d9a012 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/LogFilterBridgeTest.java @@ -362,6 +362,36 @@ public void aReloadBeforeJsRunsStaysHeld() { assertTrue(callback.sawNull); } + @Test + public void aFailedEarlyInstallIsNotReportedAsHeldAndIsRolledBack() { + final int[] attempts = {0}; + final LogFilterBridge failing = new LogFilterBridge(scheduler, System::currentTimeMillis, filter -> { + attempts[0]++; + if (attempts[0] == 1) { + throw new IllegalStateException("SDK not ready"); + } + }); + + try { + failing.holdUntilJs(); + org.junit.Assert.fail("the install failure is rethrown for StartupHold to log"); + } catch (final IllegalStateException expected) { + // StartupHold logs it and goes on. + } + + assertFalse(failing.isHeld()); + // Rolled back: not a silent drop-everything without a filter behind it. + final MutableLog line = new MutableLog("public"); + final RecordingCallback callback = new RecordingCallback(); + failing.route(line, callback); + assertSame(line, callback.last); + + // A retry installs and holds. + failing.holdUntilJs(); + assertTrue(failing.isHeld()); + assertEquals(2, attempts[0]); + } + @Test public void aBridgeThatWasNeverHeldIsThePassThrough() { bridge.attachModule(new RecordingSink());