fix(rpc): harden mobile JSI bridge against reader races and stream desync - #29464
Open
chrisnojima wants to merge 80 commits into
Open
fix(rpc): harden mobile JSI bridge against reader races and stream desync#29464chrisnojima wants to merge 80 commits into
chrisnojima wants to merge 80 commits into
Conversation
…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).
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.
The reader block never returns, so the queue's pool never drains and autoreleased objects accumulate for the life of the process.
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.
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ReadArrreturns 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+awaitTerminationon Android,readQueuenil-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::unpackerbehind 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.
ReadArralso 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 forconn.Readordering and the downstream unpacker, but the memory-corruption hazard is gone.teardown()(destroys jsi handles, JS-thread-only) is split frommarkTornDown()(atomic flag, any thread). Module invalidation uses the latter.Teardown
Both platforms tore down the wrong bridge under a reload.
invalidatecleared whichever bridge was installed, not the one that module installed.RCTInstance::invalidatehops to the old JS thread asynchronously andRCTTurboModuleManagerwaits 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.kbSharedInstanceis cleared too: RN never nulls_eventEmitterCallback, socanEmitotherwise stays true for a dead module.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 frominvalidate(), with the same identity guard as iOS.Stream desync and connection resets
The framing state machine alternated
needSize/needContentwith no validation. One malformed frame flipped the parity permanently and every later message was silently swallowed — with no way back, since mobile's transport reportsisConnected()unconditionally andEngine.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 emitskb-engine-reset.The reset story is wired for every trigger, not just desync:
ReadArrresets Go itself and returns) now notifies JS, so outstanding RPCs fail instead of hanging behind stale spinners.invalidate,destroy,engineReset) stay unconditional._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-resetnow fails outstanding invocations before signalling reconnect,rpcOnGoreports write failures back to JS, andinvokeNowfails the caller if the message never left.Response settlement is idempotent:
makeResponsesettles 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,
packetizeDatadispatched inside the framingtry, 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
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.nilrather than packing nothing, which had corrupted the enclosing map (N keys, N-1 values).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:
0ed435d, reverted in2546936. It was a latch meant to stop one desync from firing a reset per in-flight chunk. Two problems: the storm was already impossible, becauseonFatal_runs synchronously on the single reader thread, soKeybaseResetcompletes before the nextReadArrand 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.ReadArrpolls 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 ifInitnever ran.Three commit messages overstate their fix and are corrected here:
7682e02isReactNativeRunning()'s only caller logs the value and never branches on it. The wedge itself was real.def5fb4fa04cc0staticlinkage to resolve a duplicatequarantineFilesymbol; superseded by04f4d99, which renames both shims instead.Testing
This branch adds the first automated coverage for this layer.
go/bindhad no test file; the C++ framing logic had no test target at all.go test ./bind/... -racernmodules/react-native-kb/scripts/test-framing.sh)yarn jest engine/Getting the framing logic under test required extracting it into a standalone
FrameParserwith 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, iOSxcodebuild -scheme react-native-kb,go build/go vet ./bind/....iOS device pass
Validated on the simulator with temporary instrumentation (since removed). Results:
invalidatetore down its own bridge pointer, never a successor; epochs advanced 1→6; zero desyncsbad rpc frame header, zerorpc frame length mismatch, zerorpc stream desync, zero write failures across ~40k chat log linesThe reload case also exercises the read-error leg on every iteration:
invalidateresets the Go connection, and the parkedReadArrreturns 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.loginandlogin.getConfiguredAccounts. No stale data RPC crossed. And becauselogin.loginis the call performing the switch, failing outstanding invocations there would have EOF'd the login on every switch. Reverted in20af0db.That change was also unreachable in practice: the account-switch call sites live in the desktop half of
onEngineIncoming'sisMobilesplit, soEngine.reset()never ran on that path at all. Static review checked that the file was shared; only running it exposed the branch.Not covered
emit=1→ JSkb-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.loggedOutcase callsgetEngine().reset(), which by the same mechanism may EOF its own in-flightlogin.loginon every account switch. Worth a separate look; untouched here.