From 28b8eb6e092a8ada1298b9905cf32c17b83018e0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 20 Oct 2026 23:21:34 +0500 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 c583cee3b51c5ab9cdbe9896c2709f271dcdd902 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 01:01:11 +0500 Subject: [PATCH 4/9] 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 5/9] 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 76b3608344aca9318c7e9400b9d4522e4ca50d63 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 01:51:36 +0500 Subject: [PATCH 6/9] 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 7/9] 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 547ec381bc73b0979445ecf7ea7e9daaf2963b11 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 22:08:09 +0500 Subject: [PATCH 8/9] iOS filters: order module attach and invalidate against the setters' ownership check (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A superseded module could pass the current-module check, pause, and arm or disable after the replacement attached. The pointer claim, the check and the arm (and the log flag write) now share one lock; no SDK call is made under it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/ios/BugseeModule.mm | 86 ++++++++++++++++------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index bd1f2db0..095a4aa8 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -468,6 +468,16 @@ @implementation BGSRNNetworkFilterPending 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. +/// Orders a module's claim of the three filter pointers against a setter's +/// ownership check and arm, so a superseded module cannot pass the check, +/// pause, and arm or disable after the replacement attached. Held only for +/// the check and the arm or flag write, never across an SDK call. +static id BGSRNFilterOwnerLock(void) { + static id lock; + static dispatch_once_t once; + dispatch_once(&once, ^{ lock = [NSObject new]; }); + return lock; +} static BGSRNFilterArming *BGSRNNetworkFilterArming(void) { static BGSRNFilterArming *arming; static dispatch_once_t once; @@ -1079,14 +1089,16 @@ - (void)attachToBridges { // After the emitter exists. A network filter installed earlier reads this // pointer when an event arrives; it is nil until then, and a nil module // drops the event. - BGSRNNetworkFilterModule = self; - // After the emitter exists. The native log filter is already installed. - // While a user callback is on, a nil module drops the line. With no user - // callback the same filter returns the line. - BGSRNLogFilterModule = self; - // Same lifetime as the log filter: nil until the emitter exists, and a nil - // module drops the crumb. - BGSRNBreadcrumbFilterModule = self; + @synchronized (BGSRNFilterOwnerLock()) { + BGSRNNetworkFilterModule = self; + // After the emitter exists. The native log filter is already installed. + // While a user callback is on, a nil module drops the line. With no user + // callback the same filter returns the line. + BGSRNLogFilterModule = self; + // Same lifetime as the log filter: nil until the emitter exists, and a nil + // module drops the crumb. + BGSRNBreadcrumbFilterModule = self; + } } /// Identity-checked inside the bus: a reload can construct and attach the NEW @@ -1112,20 +1124,26 @@ - (void)invalidate { // Drop this module's unanswered network events. A reload may already have // attached the new module; only clear the pointer when it is still us. BGSRNDropNetworkFiltersOwnedBy(self); - if (BGSRNNetworkFilterModule == self) { - BGSRNNetworkFilterModule = nil; + @synchronized (BGSRNFilterOwnerLock()) { + if (BGSRNNetworkFilterModule == self) { + BGSRNNetworkFilterModule = nil; + } } // Drop this module's unanswered log lines. A reload may already have // attached the new module; only clear the pointer when it is still us. BGSRNDropLogFiltersOwnedBy(self); - if (BGSRNLogFilterModule == self) { - BGSRNLogFilterModule = nil; + @synchronized (BGSRNFilterOwnerLock()) { + if (BGSRNLogFilterModule == self) { + BGSRNLogFilterModule = nil; + } } // Drop this module's unanswered crumbs. A reload may already have attached // the new module; only clear the pointer when it is still us. BGSRNDropBreadcrumbFiltersOwnedBy(self); - if (BGSRNBreadcrumbFilterModule == self) { - BGSRNBreadcrumbFilterModule = nil; + @synchronized (BGSRNFilterOwnerLock()) { + if (BGSRNBreadcrumbFilterModule == self) { + BGSRNBreadcrumbFilterModule = nil; + } } // The next runtime cannot know these handles, so nothing would ever finish // them and the SDK would keep the transactions open. Cancel what is still @@ -1678,11 +1696,17 @@ - (NSNumber *)noteConsoleEcho:(NSString *)message { - (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) { + BOOL owner = NO; + @synchronized (BGSRNFilterOwnerLock()) { + owner = BGSRNNetworkFilterModule == self; + if (owner && enabled) { + [BGSRNNetworkFilterArming() armFor:self]; + } + } + if (!owner) { return @YES; } if (enabled) { - [BGSRNNetworkFilterArming() armFor:self]; @synchronized (BGSRNNetworkFilterLock()) { if (BGSRNNetworkFilterInstalled) { return @YES; @@ -1804,14 +1828,20 @@ - (void)addNetworkEvent:(NSString *)eventJson { - (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]; + BOOL owner = NO; + @synchronized (BGSRNFilterOwnerLock()) { + owner = BGSRNLogFilterModule == self; + if (owner) { + if (enabled) { + [BGSRNLogFilterArming() armFor:self]; + } + @synchronized (BGSRNLogFilterLock()) { + BGSRNLogFilterUserEnabled = enabled; + } + } } - @synchronized (BGSRNLogFilterLock()) { - BGSRNLogFilterUserEnabled = enabled; + if (!owner) { + return @YES; } BGSRNInstallLogEventFilter(); return @YES; @@ -1872,11 +1902,17 @@ - (void)replyLogFilter:(NSString *)requestId line:(NSString * _Nullable)line { - (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) { + BOOL owner = NO; + @synchronized (BGSRNFilterOwnerLock()) { + owner = BGSRNBreadcrumbFilterModule == self; + if (owner && enabled) { + [BGSRNBreadcrumbFilterArming() armFor:self]; + } + } + if (!owner) { return @YES; } if (enabled) { - [BGSRNBreadcrumbFilterArming() armFor:self]; @synchronized (BGSRNBreadcrumbFilterLock()) { BGSRNBreadcrumbFilterGeneration += 1; if (BGSRNBreadcrumbFilterInstalled) { From c84cdb330c3a5d002bf13f7f2a636a7f633cd971 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 21 Oct 2026 22:18:38 +0500 Subject: [PATCH 9/9] iOS filters: hold the owner lock through the network and breadcrumb uninstall (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stale setter that passed the ownership check could still uninstall the replacement's filter. The disable paths now re-check ownership and flip the installed flag under the owner lock, ahead of the SDK setter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/ios/BugseeModule.mm | 36 +++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 095a4aa8..5a7c5014 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -470,8 +470,10 @@ @implementation BGSRNNetworkFilterPending // place and attaches a new module that has not; see BGSRNFilterArming. /// Orders a module's claim of the three filter pointers against a setter's /// ownership check and arm, so a superseded module cannot pass the check, -/// pause, and arm or disable after the replacement attached. Held only for -/// the check and the arm or flag write, never across an SDK call. +/// pause, and arm or disable after the replacement attached. Lock order is +/// this lock, then a filter's own lock. The setters hold it across the SDK +/// uninstall on the disable paths (those calls only take locks); the filter +/// blocks the SDK calls never take it. static id BGSRNFilterOwnerLock(void) { static id lock; static dispatch_once_t once; @@ -1756,10 +1758,18 @@ - (NSNumber *)setNetworkFilterEnabled:(BOOL)enabled { }]; return @YES; } - @synchronized (BGSRNNetworkFilterLock()) { - BGSRNNetworkFilterInstalled = NO; + // Ownership is checked again under the owner lock and held through the + // uninstall: a stale module that passed the check above must not remove a + // filter the replacement has since relied on. The SDK setter only takes + // locks. + @synchronized (BGSRNFilterOwnerLock()) { + if (BGSRNNetworkFilterModule == self) { + @synchronized (BGSRNNetworkFilterLock()) { + BGSRNNetworkFilterInstalled = NO; + } + [Bugsee setNetworkEventFilter:nil]; + } } - [Bugsee setNetworkEventFilter:nil]; return @YES; } @@ -1980,12 +1990,20 @@ - (NSNumber *)setBreadcrumbFilterEnabled:(BOOL)enabled { generation = BGSRNBreadcrumbFilterGeneration; } BGSRNRunOnMain(^{ - @synchronized (BGSRNBreadcrumbFilterLock()) { - if (BGSRNBreadcrumbFilterGeneration != generation) { + // The generation does not catch a stale disable that captured it after + // the replacement's enable, so ownership is checked here too, under the + // owner lock held through the uninstall. + @synchronized (BGSRNFilterOwnerLock()) { + if (BGSRNBreadcrumbFilterModule != self) { return; } - BGSRNBreadcrumbFilterInstalled = NO; - [Bugsee setBreadcrumbFilter:nil]; + @synchronized (BGSRNBreadcrumbFilterLock()) { + if (BGSRNBreadcrumbFilterGeneration != generation) { + return; + } + BGSRNBreadcrumbFilterInstalled = NO; + [Bugsee setBreadcrumbFilter:nil]; + } } }); return @YES;