Skip to content

fix(rpc): harden mobile JSI bridge against reader races and stream desync - #29464

Open
chrisnojima wants to merge 80 commits into
masterfrom
nojima/HOTPOT-rpc-fixes
Open

fix(rpc): harden mobile JSI bridge against reader races and stream desync#29464
chrisnojima wants to merge 80 commits into
masterfrom
nojima/HOTPOT-rpc-fixes

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

The mobile RPC layer had several failure modes that leave the app alive but permanently unable to talk to the service. This fixes them across the C++ JSI bridge, both platform layers, the JS transport, and the Go bindings.

The branch has since been through a multi-reviewer audit and a full fix pass — see Audit below for what that changed, including two regressions the original commits introduced.

Reader lifecycle

Go's ReadArr returns a view of one shared global buffer and is documented as "called serially by the mobile run loops" — and a thread parked inside it cannot be cancelled (Java interrupts and dispatch cancellation are both no-ops there). The old stop-and-restart design (shutdownNow + awaitTermination on Android, readQueue nil-ing on iOS) returned while the previous reader was still live, so a resume or reload could leave two readers racing over that buffer and over the (not thread safe) msgpack::unpacker behind it.

Both platforms now run exactly one reader for the life of the process, forwarding to whichever bridge is currently installed via a mutex-guarded global instead of an instance member. ReadArr also returns a copy rather than a view, so a stray second reader can no longer corrupt an in-flight delivery — serial access is still required for conn.Read ordering and the downstream unpacker, but the memory-corruption hazard is gone.

teardown() (destroys jsi handles, JS-thread-only) is split from markTornDown() (atomic flag, any thread). Module invalidation uses the latter.

Teardown

Both platforms tore down the wrong bridge under a reload.

  • iOS: invalidate cleared whichever bridge was installed, not the one that module installed. RCTInstance::invalidate hops to the old JS thread asynchronously and RCTTurboModuleManager waits at most 10s before proceeding, so a stale invalidate could land after the next module installed — killing the live bridge with no desync, no reset, and no recovery. Now compare-and-clear, with the Go reset gated on the same check. kbSharedInstance is cleared too: RN never nulls _eventEmitterCallback, so canEmit otherwise stays true for a dead module.
  • Android: bridge teardown ran from onHostDestroy, which fires when the last Activity dies while the ReactInstance survives (the ReactHost is application-scoped). Back button to home, then reopen, left the bridge permanently null. It now runs from invalidate(), with the same identity guard as iOS.

Stream desync and connection resets

The framing state machine alternated needSize/needContent with no validation. One malformed frame flipped the parity permanently and every later message was silently swallowed — with no way back, since mobile's transport reports isConnected() unconditionally and Engine.reset() was a no-op there.

Framing is now self-checking: the size prefix must be a msgpack uint within the frame limit, and the content object must consume exactly the declared byte count. A mismatch resets the unpacker and drives onFatal, which resets the Go connection and emits kb-engine-reset.

The reset story is wired for every trigger, not just desync:

  • A read error (the common case — ReadArr resets Go itself and returns) now notifies JS, so outstanding RPCs fail instead of hanging behind stale spinners.
  • The reset path reports the disconnect before the reconnect, so sessions are cancelled and the UI shows a reconnect state.
  • Resets are epoch-checked: every successful dial bumps a generation counter, and failure-driven callers reset only the connection they actually saw fail. A stale complaint can no longer close a healthy connection that another path just re-dialed and wrote to. Genuine teardown callers (invalidate, destroy, engineReset) stay unconditional.
  • Emits are backed off, so a connection that cannot be re-dialed doesn't drive _onDisconnect/_onConnected — which are not idempotent — at 10Hz.

Hanging invocations

Nothing failed the outstanding invocation map on mobile, so an engine reset or a dropped native write left every in-flight RPC waiting forever. kb-engine-reset now fails outstanding invocations before signalling reconnect, rpcOnGo reports write failures back to JS, and invokeNow fails the caller if the message never left.

Response settlement is idempotent: makeResponse settles exactly once, so an auto-acked call whose handler then throws can't double-answer a seqid. A throwing incoming-invoke handler now errors its response instead of leaving the service waiting forever.

On desktop, packetizeData dispatched inside the framing try, so any app-code throw unwound into the catch that resets the packetizer — parsing then resumed mid-stream and never recovered on the renderer transport. Dispatch is now isolated from framing.

Conversion

  • Both msgpack↔JSI walks are iterative, so nesting depth costs heap instead of native stack.
  • Typed arrays are detected with ArrayBuffer.isView, replacing a first-key-is-a-digit heuristic that could route a plain object into an unvalidated out-of-bounds read. Every range is bounds-checked.
  • Symbols and BigInts pack as nil rather than packing nothing, which had corrupted the enclosing map (N keys, N-1 values).
  • Conversion is per-message: one undecodable message is dropped alone rather than taking its whole batch — and the batch shape now depends on how many messages actually survived, so a batch of one is delivered unwrapped rather than as an array JS would reject.
  • The receive buffer releases its peak at a safe resync point instead of pinning up to 64MB for the session.

Audit

Five independent reviewers audited the original commits and found 20+ defects; the fix pass ran task-by-task with a review gate on each and a whole-branch review at the end. Notable outcomes:

  • Two regressions the original commits introduced were caught and fixed: the Android teardown-on-Activity-death above, and an iOS emit that could fire into a dying runtime.
  • One planned fix was withdrawn after review showed it was both unnecessary and harmful — applied in 0ed435d, reverted in 2546936. It was a latch meant to stop one desync from firing a reset per in-flight chunk. Two problems: the storm was already impossible, because onFatal_ runs synchronously on the single reader thread, so KeybaseReset completes before the next ReadArr and the stale bytes are discarded with the closed connection. And every way of clearing the latch was broken — clearing it inside the fatal handler is a no-op on the same synchronous call chain, while clearing it only on a read error wedges permanently, since the post-reset reconnect succeeds and never produces the error that would clear it.
  • Several comments asserted invariants the code did not have and are corrected — most notably the claim that ReadArr polls and returns nothing when idle. It blocks; the sleep those comments justified was dead code, and the empty-read branch is a degenerate case reachable only if Init never ran.

Three commit messages overstate their fix and are corrected here:

Commit Correction
7682e02 The duplicate-fallback-notification symptom was overstated — isReactNativeRunning()'s only caller logs the value and never branches on it. The wedge itself was real.
def5fb4 Described as the mirror of the invoke-direction hang fix. It is observability only: a failed response write still leaves the service hung on that seqid, it is now merely logged.
fa04cc0 Used static linkage to resolve a duplicate quarantineFile symbol; superseded by 04f4d99, which renames both shims instead.

Testing

This branch adds the first automated coverage for this layer. go/bind had no test file; the C++ framing logic had no test target at all.

Suite Before Now
go test ./bind/... -race none 15
C++ framing (rnmodules/react-native-kb/scripts/test-framing.sh) none 14
yarn jest engine/ 22 42 (5 suites)

Getting the framing logic under test required extracting it into a standalone FrameParser with no JSI dependency — that code had already shipped two serious bugs during this effort (arithmetic that would have rejected every frame, and a buffer-shrink trigger that could never fire), both caught only by review. The extraction was verified line-by-line for behavior equivalence, and the test binary links the shipped source rather than a copy.

Tests were falsified where it matters: the production code was temporarily broken to confirm each key test actually catches the regression it claims to.

Also green: yarn lint:all (0 react-compiler bailouts, tsc clean on both configs), Android :externalNativeBuildDebug + :compileDebugKotlin, iOS xcodebuild -scheme react-native-kb, go build/go vet ./bind/....

iOS device pass

Validated on the simulator with temporary instrumentation (since removed). Results:

Flow Result
Cold start PASS — exactly one reader thread, one bridge install, first dial at epoch 1
Dev reload ×5 PASS — reader stayed at exactly 1; every invalidate tore down its own bridge pointer, never a successor; epochs advanced 1→6; zero desyncs
Background / foreground PASS — no reader restart, no bridge teardown, no reconnect; same connection throughout
Chat traffic + DB nuke + re-sync PASS — zero bad rpc frame header, zero rpc frame length mismatch, zero rpc stream desync, zero write failures across ~40k chat log lines

The reload case also exercises the read-error leg on every iteration: invalidate resets the Go connection, and the parked ReadArr returns EOF. It correctly declines to emit an engine-reset into a module that is already being torn down.

The device pass also removed a change. An earlier commit made the mobile account switch fail in-flight invocations, on the theory that a pre-switch callback could otherwise fire against post-switch state. Instrumenting the switch disproved it: across four switches only two invocations were ever outstanding at NotifySession.loggedOut, and both were the switch's own machinery — login.login and login.getConfiguredAccounts. No stale data RPC crossed. And because login.login is the call performing the switch, failing outstanding invocations there would have EOF'd the login on every switch. Reverted in 20af0db.

That change was also unreachable in practice: the account-switch call sites live in the desktop half of onEngineIncoming's isMobile split, so Engine.reset() never ran on that path at all. Static review checked that the file was shared; only running it exposed the branch.

Not covered

  • Android has not had a device pass. The highest-value case is back button to home → reopen → send/receive: that wedge broke, was fixed, was silently re-broken by a later commit in this branch, and was fixed again.
  • A read error with a live module (the emit=1 → JS kb-engine-reset → fail-outstanding chain) can't be produced on the simulator, since the service is in-process and can't be killed independently. That path is covered by the Go tests only.
  • Possible pre-existing desktop issue, unverified: desktop's loggedOut case calls getEngine().reset(), which by the same mechanism may EOF its own in-flight login.login on every account switch. Worth a separate look; untouched here.

…sync

The mobile RPC layer had several failure modes that leave the app alive but
permanently unable to talk to the service.

Reader lifecycle. Go's ReadArr returns a view of one shared global buffer and
is documented as "called serially by the mobile run loops", but a thread
parked inside it cannot be cancelled — Java interrupts and dispatch
cancellation are both no-ops there. The old stop-and-restart design
(shutdownNow + awaitTermination on Android, readQueue nil-ing on iOS) returned
while the previous reader was still live, so a resume or reload could leave
two readers racing over that buffer and over the (not thread safe)
msgpack::unpacker behind it. Both platforms now run exactly one reader for the
life of the process, forwarding to whichever bridge is currently installed via
a mutex-guarded global rather than an instance member — which also removes the
unsynchronized shared_ptr access between the reader thread and
installJSIBindings/invalidate.

Stream desync. The incoming framing state machine alternated needSize and
needContent with no validation, and left the unpacker untouched on error. One
malformed frame flipped the parity permanently and every later message was
silently swallowed as a "size", with no way back: mobile's transport reports
isConnected() unconditionally and Engine.reset() is a no-op there. The size
prefix is now checked to be a msgpack uint within the frame limit, and a
failure resets the unpacker and drives a new onFatal path that resets the Go
connection and emits kb-engine-reset.

Hanging invocations. Nothing failed the outstanding invocation map on mobile,
so an engine reset or a dropped native write left every in-flight RPC waiting
forever. kb-engine-reset now fails outstanding invocations before signalling
reconnect, rpcOnGo reports write failures back to JS, and invokeNow fails the
caller if the message never left.

Conversion. Both msgpack<->JSI walks are now iterative, so nesting depth costs
heap instead of native stack. Typed arrays are detected with
ArrayBuffer.isView behind a cheap byteLength probe, replacing a
first-key-is-a-digit heuristic that could route a plain object into an
unvalidated out-of-bounds read of the backing buffer; every range is now
bounds-checked. Symbols and BigInts pack as nil instead of packing nothing,
which had been corrupting the enclosing map. The PropNameID cache no longer
clears itself while entries are referenced, the outgoing scratch buffer is
released after an oversized frame, and rpcOnJs is re-read per batch so a
recreated engine client is never fed to a dead handle.

Also: per-message try/catch in the batch dispatch loop so one bad message
cannot drop the rest of the batch, and idle-read sleeps on both platforms
(ReadArr returns nothing when the connection is idle, which was spinning a
core).

This comment was marked as outdated.

The process-wide read loop forwards to KbModule.instance, so a torn-down
module kept receiving deliveries and pinned its ReactContext. Clear it in
destroy(), guarded so a reload that installs a newer module first wins.

Add transport tests for native write failures: invoke fails the caller,
leaves no outstanding seqid, doesn't steal a later invoke's response, and
send() reports false.

This comment was marked as outdated.

The reader block never returns, so the queue's pool never drains and
autoreleased objects accumulate for the life of the process.

This comment was marked as outdated.

This comment was marked as outdated.

A module's invalidate can run after the next module installed its bridge,
because RCTInstance::invalidate hops to the old JS thread asynchronously and
RCTTurboModuleManager waits at most 10s before proceeding. The unconditional
kbSetBridge(nullptr) then tore down the live bridge: rpcOnGo returned false
forever and the reader dropped every frame, with no desync detected and no
engine-reset emitted, so the app hung with no path to recovery.

Also gate the Go reset on the same check, and correct the thread comment --
invalidate runs on the TurboModule shared queue, not the main thread.
onHostDestroy fires when the last Activity dies while the ReactInstance and
its TurboModules survive, so init{} never re-runs and nothing restored
instance. After a back-button exit and relaunch, nativeOnDataFromGo dropped
every inbound message for the life of the process while outbound kept
working -- the app looked alive and never got a reply. It also made
isReactNativeRunning() report false while RN was warm, producing duplicate
fallback notifications.

instance is only a gate; the JNI callee ignores the receiver and routes
through g_bridge. Bridge teardown moves to nativeInvalidate.

Also publish instance at the end of init so the reader thread cannot observe
a partially constructed module.
RN never nulls _eventEmitterCallback on invalidate, and the __weak shared
instance only nils at dealloc, which lags it. canEmit therefore stayed YES
for an invalidated module and push notifications, token registrations, and
the reader's desync meta event emitted into the dying runtime's invoker --
the desync path being the one that fires exactly when a reload is already in
flight. Mirrors the Android fix in 3f71bd9.
The check-then-clear in invalidate was a read on the TurboModule shared
method queue followed by a conditional write, racing init's write on the
main thread. A reload's new instance could win init between invalidate's
read and write, and the old instance's invalidate would then clobber it --
silently killing emit for push notifications, token registration, and the
desync meta event on the live module. Guard both sides with a dedicated
kbSharedInstanceMutex, kept separate from kbBridgeMutex.
Android had no markTornDown call site at all, so on a dev reload the old
bridge stayed in g_bridge with isTornDown_ false. If the new runtime's
installer had not run yet the reader routed to the old bridge, passed the
teardown check, and called invokeAsync on the old runtime's CallInvoker while
that runtime was being destroyed.

g_adapter and g_bridge were also never cleared, pinning the old KbModule (and
via reactContext its Activity) and a dead runtime's CallInvoker for the life
of the process -- the exact leak the non-inner-class reader was written to
avoid.
The only strong ref was the g_adapter slot, so constructing a second module
destroyed the adapter immediately while the first bridge was still installed
-- g_bridge is only swapped later, when the new installer runs on the JS
thread. Every rpcOnGo in that window failed its weak lock and returned false,
failing all RPCs. The adapter never references the bridge, so there is no
cycle to avoid.
ReadArr calls Reset() itself on a read error and returns; both readers only
logged it. JS was never told, so failAllOutstanding never ran and every
in-flight RPC hung forever with the UI still spinning. This is the common
reset leg -- the branch had closed only the desync leg.

Also drop any half-parsed frame, so the unpacker does not resume mid-frame on
the next connection and fail its header check on valid data. Deliberately no
second Keybase.reset() here: ReadArr already reset, and resetting again would
close a connection a concurrent writeArr may have just dialed.
The header check validated the prefix's type and range and then discarded the
value, so a truncated or overlong frame was undetected -- the unpacker read
into the following frame and the parity check tripped only later, or never.
After a resync the parser could accept a fixint sitting inside a string
payload as a header and hand whatever parsed next to JS as an RPC message,
reporting no desync while delivering garbage.

Comparing parsed_size() deltas against the declared size makes the framing
self-checking. The counters live in RecvState because a header and its
content routinely arrive in separate reads.
The previous commit's frame-length check compared up.parsed_size() at the
header against up.parsed_size() at the content, assuming it was a cumulative
stream position. It is not: msgpack::unpacker::next() resets parsed_size()
to 0 on every successful parse (msgpack/v2/unpack.hpp calls parser::reset(),
which zeroes m_parsed). The stored "sizeAtHeader" was therefore always 0,
"consumed" was always 0, and the mismatch check fired on every frame with
non-empty content -- fatally disconnecting the bridge on the first real RPC
message, while a frame declaring size 0 would have passed unchecked.

nonparsed_size() (m_used - m_off) is accurate at any instant, so RecvState
now accumulates every byte ever fed to the unpacker in totalFed, and
totalFed - nonparsed_size() gives a genuine monotonic count of bytes
consumed, immune to next()'s per-object reset. Verified against a standalone
harness that packs two well-formed frames, splits the stream across three
chunk boundaries (including one that separates a header from its content),
and confirms both frames' consumed byte counts match their declared sizes
while a deliberately mismatched frame is rejected.
convertMPToJSI throws on a non-scalar map key, nesting over the limit, or
OOM. That threw out of the batch loop and dropped all ten messages including
the reply to some seqid, whose caller then waited forever -- the exact bug
class this branch set out to fix, while the desync path right above it
handled the same situation correctly.

Convert per message so a bad one is dropped alone, and escalate the remaining
failure paths (including a missing rpcOnJs, whose messages are already
consumed and unreplayable) to onFatal so JS fails its outstanding RPCs.

The delivered array is sized to the number of messages that actually
converted rather than the original batch size: JS's global.rpcOnJs iterates
the array with a plain for-of and only uses the count argument to decide
array-vs-single, so a hole left at the original size would hand JS an
undefined message to dispatch instead of just skipping the dropped one.
Bytes already in flight from the dead stream kept arriving and each failed
the header check, so a single desync fired a reset per 300KB chunk -- and
each reset failed whatever JS had re-issued since the last one. Drop
incoming data until the platform layer confirms (via resetRecv) that the
old connection is actually gone.

The latch is deliberately NOT cleared from the platform's fatal handlers
themselves: onFatal_ runs synchronously on the same serial reader thread
that is about to deliver the rest of the desynced stream, so clearing it
there would unlatch before the next stale chunk ever arrives, making the
latch a no-op. It stays set until the existing read-error path (which
already calls resetRecv on a confirmed dead connection) clears it.
chat/attachments and kbfs/simplefs each define a global (non-static) C
function named quarantineFile with an identical body. Nothing has
linked both into one darwin binary before -- go/bind is the first
package to import both -- so this was latent: any executable pulling
in both (e.g. `go test ./bind/...`) fails at link time with a
duplicate symbol error. Mark both static; each was only ever meant to
be private to its own package.
Covers the epoch/reset mechanism the mobile RPC recovery path now
depends on: ResetIfCurrent matching vs. stale epoch, the exact
redial-vs-stale-reset interleaving the epoch guard exists to prevent,
double-reset harmlessness, Reset's unconditional teardown, epoch
monotonicity across real ensureConnection redials, LastReadEpoch
tracking the connection ReadArr actually used, and a concurrent
redial/reset stress test under -race.

Tests seed the real package-level conn/connEpoch globals directly
(same package, connMutex-guarded) and drive the real production
functions -- no reimplementation of the reset/epoch logic under test.
ensureConnection is exercised against a real libkb.LoopbackListener
rather than a fake dialer, so the epoch-increment statement under test
is the actual production code.
…chine

KBBridge::onDataFromGo mixed the framing state machine (unpacker feed,
header/length arithmetic, size-limit check, buffer-shrink trigger) with
jsi/CallInvoker-dependent delivery, so the framing logic -- which has
already shipped two serious bugs on this branch -- had zero unit
coverage. FrameParser has no jsi/React dependency and can be built and
tested with plain clang++, unblocking a real test suite.

onDataFromGo now delegates to FrameParser::feed/atSafeShrinkPoint;
observable behavior (desync detection, fatal escalation, shrink timing,
epoch handling) is unchanged.

Registers frame-parser.cpp with the Android CMake build; iOS picks it
up via the podspec's existing cpp/**/*.{h,cpp} glob. Excludes the new
cpp/tests/ directory from the podspec so the standalone test binary's
main() doesn't get pulled into the shipped framework.
Covers: single/coalesced/split frames, a split at every byte boundary,
consumed==declared across sizes including an unpacker buffer growth,
bad/oversized/mismatched/zero-length headers, a legal maximal frame
delivered in chunks, error recovery after reset, the peakFrameSize
buffer-shrink trigger surviving a subsequent small frame, and nested/
empty container round-trips.

No gtest/catch2 dependency: a small assert/report harness
(test-harness.h) keeps this buildable with the same plain clang++
already used for syntax-checking react-native-kb.cpp. Run via
plans/scripts/test-native-kb-framing.sh.
A post-reset invocation must get a seqid that cannot alias a pre-reset
one, and a stale response for a pre-reset seqid arriving after the
reset must be ignored rather than re-firing an already-failed callback.
…ailed-write

error() then result() on the same response must settle on the first
call (error), matching the already-covered result()-then-error() case.
A response whose write fails is marked settled before the write is
attempted, so retrying result() after fixing the write error must trip
the double-settle guard rather than silently opening a retry path.
…undary

A malformed frame must reset the packetizer and let a subsequent
well-formed frame dispatch normally -- genuine corruption still
recovers. Also sweep every possible split point of one frame across
two packetizeData calls (rather than only the byte-at-a-time case) to
pin down the split-boundary framing logic more broadly.
…Go failure

Exercises index.platform's isMobile branch directly: a throwing
disconnectCallback must not skip connectCallback on kb-engine-reset
(the stranded-banner fix), and NativeTransportMobile must fail an
invocation rather than hang when rpcOnGo reports a failed native write.
chat/attachments and kbfs/simplefs each defined a C function named
quarantineFile with an identical body. Nothing linked both into one darwin
binary before, so the collision was latent; go/bind imports both, and any
executable pulling it in (a go test binary, for one) failed to link with a
duplicate symbol error.

Rename each to match its package rather than relying on internal linkage, so
the symbols stay visible under their own names in a stack trace or nm dump.
Platform readers (Kb.mm, KbModule.kt) call ResetIfCurrent(epoch) and then
unconditionally clear their local parser state. When the epoch is stale
(some other caller already redialed), that clearing still happens and
drops bytes already in flight on the connection nothing here touched,
forcing a second, avoidable fatal/reset cycle. ResetIfCurrentDidReset
reports the acted/no-op distinction so callers can gate on it.

Adds Go coverage for the two real production callers of the mechanism
(ReadArr's error path, WriteArr's short-write path), for the new
reporting function, and for the epoch-capture-before-raceable-read
property the whole design rests on (falsified by moving the
lastReadEpoch write after conn.Read, confirming the new test catches
the regression).
resetRecv()/nativeResetRecv() ran unconditionally after
ResetIfCurrent(epoch), even when it was a stale no-op. That drops a
partial frame already buffered on a connection some concurrent redial
already recovered, forcing a needless second fatal/reset cycle. Both
platforms now call ResetIfCurrentDidReset and only clear the parser
when it reports the reset actually happened.
RecvState::epoch was written on every onDataFromGo call but never read
anywhere; the fatal paths use the epoch captured in onDataFromGo's own
parameter/lambda instead, since resetRecvLocked() used to discard the
whole RecvState (and epoch with it) before those paths could run.

With epoch gone, RecvState owns only the parser, so resetRecvLocked()
can reset it in place via FrameParser::reset() instead of reconstructing
RecvState from scratch, avoiding a reallocation on every safe-shrink
resync.
…ments

pack_uint64 picks the minimal msgpack encoding, so small test frames got
a 1-byte fixint header while production always writes a fixed 5-byte
0xce+uint32 header. That gap meant the split-at-every-byte-boundary
sweep never split inside a real header, and the max-frame test put the
whole header in the first chunk — exactly where consumedAtHeader_ is
most fragile. Adds packHeaderUint32 matching production's encoding and
reruns the sweep with it, plus a header-boundary sweep for a
kMaxFrameSize frame. Falsified against a broken consumedAtHeader_
computation (using the current feed()'s size instead of cumulative
totalFed_): only the new production-header sweeps and the existing
multi-feed tests caught it, confirming the new coverage closes the gap.

Also corrects two comments that overclaimed what they tested:
frame-parser-test.cpp's "proves kMaxFrameSlack" claim actually pins a
header boundary condition (next() drains nonparsed_size() to 0 before
the slack check runs), and frame-parser.h's kMaxFrameSlack rationale
described a legal-frame scenario that can't occur (declared sizes are
already bounded by kMaxFrameSize at header time). kMaxFrameSlack is kept
since dropping it would be a real, if minor, garbage-detection behavior
change; only the rationale is corrected to describe its actual purpose.
- Drop fragile log-count/substring assertions in rpc-transport.test.ts
  that redden on any reworded/added log line with zero behavior change;
  the load-bearing assertions (transport.sent, error counts) already
  cover the real behavior.
- Simplify the reset-cycle seqid test: drop a redundant restatement of
  the adjacent ordering test and a tautological callback-count check
  that asserted a dispatch to a map key the callback was never
  registered under, keeping the one property (a fresh post-reset call
  round-trips end to end) nothing else in the file covers.
- Assert that transport.reset() actually fires on kb-engine-reset in
  index.platform.mobile.test.ts, instead of letting it execute unasserted.
- Pin the exact error message so the malformed-input test can tell which
  of two throw sites fired, instead of a bare toBeTruthy().
- Fix teardownMobileMocks leaking global.rpcOnJs across test files.
- Drop the local react-native jest.requireActual workaround, which
  doesn't bypass moduleNameMapper and so re-spread the shared stub for
  no effect; add the LogBox export it needed to the shared mock instead.
- Remove a throwaway TestTransport built only to derive a seqid that's
  deterministically 1 and already hardcoded elsewhere in the file.
These are build and test tooling, not planning documents, so they belong
next to the module they operate on. plans/ holds scratch working notes that
are not part of the codebase; ignore dated plan files so they stop being
committed.

This comment was marked as outdated.

Added on the theory that a mobile account switch could let a pre-switch RPC
callback fire against post-switch state. Runtime validation on the simulator
disproved it: across four switches only two invocations were ever in flight
at NotifySession.loggedOut, and both were the switch's own machinery --
login.login and login.getConfiguredAccounts. No stale data RPC crossed the
boundary.

Worse, login.login is the call that performs the switch; the service emits
loggedOut while still handling it. Failing outstanding invocations there
would EOF the login itself on every switch.

The line was also unreachable on mobile in practice: the account-switch
call sites live in the desktop half of onEngineIncoming's isMobile split, so
Engine.reset() is never invoked on that path.

Keeps the tests added alongside it -- they exercise failAllOutstanding and
seqid behavior on the transport directly and stand on their own.
@chrisnojima
chrisnojima requested a review from Copilot July 27, 2026 17:26
@chrisnojima-zoom chrisnojima-zoom changed the title fix(rpc): harden mobile JSI bridge against reader races and stream desync WIP: fix(rpc): harden mobile JSI bridge against reader races and stream desync Jul 27, 2026

This comment was marked as outdated.

…n wedge

Static check that nativeInvalidate() is only called from invalidate()
(real TurboModule teardown) and never from destroy()/onHostDestroy
(Activity death with a surviving ReactInstance) — the bug fixed in
15647bb. Parses actual function bodies via brace matching so it
survives reformatting rather than a naive substring scan.
New Android-only flow: destroy the Activity via back (not
terminateApp) so the process/ReactInstance survive, reopen via
activateApp, and prove inbound RPC still works with a full chat
send/receive round trip. Verifies process continuity via `mobile:
shell pidof` before/after and refuses to pass if the PID changed,
since a killed process would reinstall the bridge and mask the bug.
Needs relaxedSecurity on the Android appium service for the shell
command.
…t test

relaxedSecurity applies to the whole appium server, not just the one command
that needs it, so enabling it by default widens what a locally spawned appium
will execute for every Android run. Gate it behind KB_E2E_RELAXED_SECURITY
instead.

The activity-restart test needs `mobile: shell` (pidof) to distinguish a
surviving process from a fresh one -- a fresh process reinstalls the bridge
and would pass while proving nothing. Without the flag it now skips with a
message saying so, rather than reporting a result it cannot substantiate.
@chrisnojima
chrisnojima requested a review from Copilot July 27, 2026 18:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chrisnojima chrisnojima changed the title WIP: fix(rpc): harden mobile JSI bridge against reader races and stream desync fix(rpc): harden mobile JSI bridge against reader races and stream desync Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants