From 37c2ffe18645ab21270a7fcd804c20bcd2bbd8db Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 24 Jul 2026 17:39:28 -0400 Subject: [PATCH 01/80] fix(rpc): harden mobile JSI bridge against reader races and stream desync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../react-native-kb/android/cpp-adapter.cpp | 86 +- .../main/java/com/reactnativekb/KbModule.kt | 94 +- .../react-native-kb/cpp/react-native-kb.cpp | 832 +++++++++++------- .../react-native-kb/cpp/react-native-kb.h | 57 +- rnmodules/react-native-kb/ios/Kb.mm | 153 ++-- shared/engine/index.platform.tsx | 51 +- shared/engine/rpc-transport.tsx | 24 +- shared/globals.d.ts | 3 +- 8 files changed, 862 insertions(+), 438 deletions(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index b11e52b4e737..4e8bf509ae32 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include using namespace facebook; using namespace facebook::jsi; @@ -16,58 +18,104 @@ struct JKbModule : jni::JavaClass { class KbNativeAdapter { public: jni::global_ref jModule_; - std::shared_ptr bridge_; explicit KbNativeAdapter(jni::alias_ref jModule) : jModule_(jni::make_global(jModule)) {} - void writeToGo(void *ptr, size_t size) { + bool writeToGo(void *ptr, size_t size) { jni::ThreadScope scope; auto env = jni::Environment::current(); auto jba = env->NewByteArray(size); + if (jba == nullptr) { + return false; + } env->SetByteArrayRegion(jba, 0, size, (jbyte *)ptr); static auto method = JKbModule::javaClassStatic() - ->getMethod)>("rpcOnGo"); - method(jModule_, jni::wrap_alias(jba)); + ->getMethod)>("rpcOnGo"); + auto ok = method(jModule_, jni::wrap_alias(jba)); env->DeleteLocalRef(jba); + return ok != JNI_FALSE; + } + + void onFatal() { + jni::ThreadScope scope; + static auto method = + JKbModule::javaClassStatic()->getMethod("onRpcStreamFatal"); + method(jModule_); } }; +// The bridge is created on the JS thread and consumed by the native reader +// thread, so both the adapter and the bridge live behind this lock. +static std::mutex g_mutex; static std::shared_ptr g_adapter; +static std::shared_ptr g_bridge; + +static std::shared_ptr getBridge() { + std::lock_guard lock(g_mutex); + return g_bridge; +} static jni::local_ref getBindingsInstaller(jni::alias_ref thiz) { - g_adapter = std::make_shared(thiz); + auto adapter = std::make_shared(thiz); + { + std::lock_guard lock(g_mutex); + g_adapter = adapter; + } return BindingsInstallerHolder::newObjectCxxArgs( - [adapter = g_adapter](jsi::Runtime &runtime, - const std::shared_ptr &callInvoker) { - if (adapter->bridge_) { - adapter->bridge_->teardown(); - } - adapter->bridge_ = std::make_shared(); - adapter->bridge_->install( + [weakAdapter = std::weak_ptr(adapter)]( + jsi::Runtime &runtime, + const std::shared_ptr &callInvoker) { + auto bridge = std::make_shared(); + bridge->install( runtime, callInvoker, - [weak = std::weak_ptr(adapter)](void *ptr, size_t size) { - if (auto a = weak.lock()) - a->writeToGo(ptr, size); + // false means the RPC never reached Go, so the caller fails that + // invocation instead of waiting forever for a reply. + [weakAdapter](void *ptr, size_t size) -> bool { + if (auto a = weakAdapter.lock()) { + return a->writeToGo(ptr, size); + } + return false; }, [](const std::string &err) { __android_log_print(ANDROID_LOG_ERROR, "KBBridge", "JSI error: %s", err.c_str()); + }, + // The incoming stream desynced; reset the Go connection and tell + // JS so it fails outstanding RPCs rather than hanging forever. + [weakAdapter]() { + __android_log_print(ANDROID_LOG_ERROR, "KBBridge", + "rpc stream desync, resetting connection"); + if (auto a = weakAdapter.lock()) { + a->onFatal(); + } }); + + std::shared_ptr old; + { + std::lock_guard lock(g_mutex); + old = std::move(g_bridge); + g_bridge = bridge; + } + // Only flips an atomic. The old bridge's jsi handles belong to its + // own runtime and are released by its kbTeardown host object. + if (old) { + old->markTornDown(); + } }); } static void nativeOnDataFromGo(jni::alias_ref thiz, jni::alias_ref data) { - auto adapter = g_adapter; - if (!adapter || !adapter->bridge_ || !data) + auto bridge = getBridge(); + if (!bridge || !data) return; auto pinned = data->pin(); - adapter->bridge_->onDataFromGo(reinterpret_cast(pinned.get()), - pinned.size()); + bridge->onDataFromGo(reinterpret_cast(pinned.get()), + pinned.size()); } JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index a21166f549cb..c7bebfca2927 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -33,9 +33,7 @@ import java.io.FileNotFoundException import java.io.FileReader import java.io.IOException import java.lang.reflect.Method -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import keybase.Keybase import keybase.Keybase.readArr import keybase.Keybase.version @@ -49,7 +47,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T external override fun getBindingsInstaller(): BindingsInstallerHolder private external fun nativeOnDataFromGo(data: ByteArray) - private var executor: ExecutorService? = null private var lifecycleListenerRegistered = false override fun getName(): String { @@ -472,17 +469,18 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T override fun notifyJSReady() { NativeLogger.info("JS signaled ready, starting ReadFromKBLib loop") try { - // Signal to Go that JS is ready + // Signal to Go that JS is ready. This is a sync.Once on the Go + // side, so calling it again after a reload is free. Keybase.notifyJSReady() startReadLoop() - // Register once; restart the read loop on resume, tear down on destroy. + // Register once; tear down the Go connection on destroy. The read + // loop itself is never restarted — see startReadLoop. if (!lifecycleListenerRegistered) { lifecycleListenerRegistered = true reactContext.addLifecycleEventListener(object : LifecycleEventListener { override fun onHostResume() { - startReadLoop() } override fun onHostPause() { @@ -498,26 +496,42 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } + // Exactly one reader exists for the life of the process. Go's readArr + // hands back a view of a single shared buffer and is documented as + // "called serially by the mobile run loops": a second concurrent reader + // corrupts both deliveries and the (not thread safe) msgpack unpacker + // behind nativeOnDataFromGo. Stopping it isn't possible either — a thread + // parked in the JNI readArr call ignores interrupts, so shutdownNow() + // returns while the old reader is still live and about to swallow one + // more message. So the loop outlives any module instance and forwards to + // whichever bridge is currently installed. private fun startReadLoop() { - if (executor == null) { - val ex = Executors.newSingleThreadExecutor() - executor = ex - ex.execute(ReadFromKBLib(reactContext)) + if (readLoopStarted.compareAndSet(false, true)) { + Thread(ReadFromKBLib(), "ReadFromKBLib").apply { + isDaemon = true + start() + } } } - // JSI - private inner class ReadFromKBLib(private val reactContext: ReactApplicationContext) : Runnable { + // JSI. Deliberately not an inner class: the thread outlives every module + // instance, so capturing one would pin its ReactContext (and Activity) for + // the life of the process. It forwards to whichever module is current. + private class ReadFromKBLib : Runnable { override fun run() { - do { + while (true) { try { - Thread.currentThread().name = "ReadFromKBLib" - val data: ByteArray = readArr() - if (!reactContext.hasActiveReactInstance()) { - NativeLogger.info("$NAME: JS Bridge is dead, dropping engine message") + val data: ByteArray? = readArr() + if (data == null || data.isEmpty()) { + // readArr yields nothing when the connection had + // nothing for us; without a pause this spins a core. + Thread.sleep(10) continue } - nativeOnDataFromGo(data) + instance?.nativeOnDataFromGo(data) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + return } catch (e: Exception) { if (e.message != null && e.message.equals("Read error: EOF")) { NativeLogger.info("Got EOF from read. Likely because of reset.") @@ -526,9 +540,9 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } // Back off on error to avoid spinning at full CPU speed when Go is // unavailable (e.g. during init or loopback restart). - try { Thread.sleep(100) } catch (ie: InterruptedException) { Thread.currentThread().interrupt() } + try { Thread.sleep(100) } catch (ie: InterruptedException) { Thread.currentThread().interrupt(); return } } - } while (!Thread.currentThread().isInterrupted && reactContext.hasActiveReactInstance()) + } } } @@ -539,31 +553,37 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } catch (e: Exception) { NativeLogger.error("Exception in KeybaseEngine.destroy", e) } - try { - executor?.shutdownNow() - - // We often hit this timeout during app resume, e.g. hit the back - // button to go to home screen and then tap Keybase app icon again. - if (executor?.awaitTermination(3, TimeUnit.SECONDS)== false) { - NativeLogger.warn("$NAME: Executor pool didn't shut down cleanly") - } - executor = null - } catch (e: Exception) { - NativeLogger.error("Exception in JSI.destroy", e) - } } // Called from JNI (cpp-adapter writeToGo), not from JS. DoNotStrip keeps it // from being removed/renamed by ProGuard since the only caller is reflective. + // Returns false when the payload never reached Go, so the caller can fail + // that RPC instead of leaving it outstanding forever. @DoNotStrip - fun rpcOnGo(arr: ByteArray) { - try { + fun rpcOnGo(arr: ByteArray): Boolean { + return try { writeArr(arr) + true } catch (e: Exception) { NativeLogger.error("Exception in GoJSIBridge.rpcOnGo", e) + false } } + // Called from JNI when the incoming byte stream desyncs. Resetting the Go + // connection and relaying the meta event lets JS fail its outstanding RPCs + // instead of waiting on a channel that can no longer deliver. + @DoNotStrip + fun onRpcStreamFatal() { + NativeLogger.warn("$NAME: rpc stream desync, resetting connection") + try { + Keybase.reset() + } catch (e: Exception) { + NativeLogger.error("Exception resetting after rpc desync", e) + } + reactContext.runOnUiQueueThread { relayReset() } + } + @ReactMethod override fun iosGetHasShownPushPrompt(promise: Promise) { promise.reject(Exception("wrong platform")) @@ -582,6 +602,10 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T const val NAME: String = "Kb" private const val RPC_META_EVENT_ENGINE_RESET: String = "kb-engine-reset" + + // Process-wide, not per-instance: a reload creates a new KbModule but + // must not create a second reader for the one shared Go connection. + private val readLoopStarted = AtomicBoolean(false) private const val MAX_TEXT_FILE_SIZE = 100 * 1024 // 100 kiB private val LINE_SEPARATOR: String? = System.getProperty("line.separator") diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index 21ae0b552600..9fec3544c48f 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -3,43 +3,74 @@ #include #include #include "msgpack-safe.hpp" +#include #include +#include +#include using namespace facebook; using namespace facebook::jsi; namespace kb { -struct KBBridge::MsgpackState { +namespace { +// A desynced length prefix can otherwise ask us to buffer gigabytes. Matches +// the JS-side packetizer limit. +constexpr uint64_t kMaxFrameSize = 64ull * 1024 * 1024; +// Conversion is iterative, so nesting costs heap rather than native stack. +// This is a sanity bound on pathological payloads, not a stack guard. +constexpr size_t kMaxDepth = 1024; +// Don't let one huge attachment frame permanently retain its peak size. +constexpr size_t kSendBufKeepCapacity = 4u * 1024 * 1024; +constexpr size_t kMaxCachedPropNames = 4096; +} // namespace + +struct KBBridge::RecvState { msgpack::unpacker unpacker; + ReadState state = ReadState::needSize; +}; + +struct KBBridge::SendState { msgpack::sbuffer sendBuf; }; KBBridge::KBBridge() = default; KBBridge::~KBBridge() = default; +void KBBridge::markTornDown() { isTornDown_.store(true); } + void KBBridge::teardown() { isTornDown_.store(true); - // Clear cached JSI objects while the runtime is still alive. - // This prevents stale jsi::Function destructors from crashing - // if the bridge outlives the runtime (due to shared_ptr captures). + releaseJSIState(); +} + +void KBBridge::tearup() { isTornDown_.store(false); } + +// Clears cached JSI objects. Must run on the runtime's thread while the +// runtime is still alive — destroying jsi handles elsewhere is UB. +void KBBridge::releaseJSIState() { cachedUint8ArrayCtor_.reset(); - cachedRpcOnJs_.reset(); + cachedIsView_.reset(); + cachedRpcOnJsName_.reset(); cachedPropNames_.clear(); cachedRuntime_ = nullptr; } -void KBBridge::tearup() { isTornDown_.store(false); } - void KBBridge::resetCaches(Runtime &runtime) { if (cachedRuntime_ != &runtime) { - cachedUint8ArrayCtor_.reset(); - cachedRpcOnJs_.reset(); - cachedPropNames_.clear(); + releaseJSIState(); cachedRuntime_ = &runtime; } } +void KBBridge::reportError(const std::string &msg) { + if (onError_) { + onError_(msg); + } +} + +void KBBridge::resetRecvLocked() { recv_ = std::make_unique(); } + Function &KBBridge::uint8ArrayCtor(Runtime &runtime) { resetCaches(runtime); if (!cachedUint8ArrayCtor_) { @@ -49,6 +80,21 @@ Function &KBBridge::uint8ArrayCtor(Runtime &runtime) { return *cachedUint8ArrayCtor_; } +Function &KBBridge::arrayBufferIsView(Runtime &runtime) { + resetCaches(runtime); + if (!cachedIsView_) { + auto ab = runtime.global().getPropertyAsObject(runtime, "ArrayBuffer"); + auto fn = ab.getPropertyAsFunction(runtime, "isView"); + cachedIsView_ = std::make_unique(std::move(fn)); + } + return *cachedIsView_; +} + +bool KBBridge::isArrayBufferView(Runtime &runtime, const Object &obj) { + auto res = arrayBufferIsView(runtime).call(runtime, Value(runtime, obj)); + return res.isBool() && res.getBool(); +} + namespace { // Single-pass copy into an uninitialized buffer. Constructing a // Uint8Array of the target size would zero-fill it before we memcpy @@ -68,37 +114,8 @@ class CopiedBuffer : public MutableBuffer { std::unique_ptr buf_; size_t size_; }; -} // namespace - -Value KBBridge::binaryFromBytes(Runtime &runtime, const char *ptr, - size_t size) { - ArrayBuffer buffer(runtime, std::make_shared(ptr, size)); - return uint8ArrayCtor(runtime).callAsConstructor(runtime, - std::move(buffer)); -} - -const PropNameID &KBBridge::mapKeyPropName(Runtime &runtime, const char *ptr, - size_t size) { - // RPC maps reuse a small set of string keys; caching the PropNameIDs - // avoids re-interning the same symbols on every message. - propNameScratch_.assign(ptr, size); - auto it = cachedPropNames_.find(propNameScratch_); - if (it == cachedPropNames_.end()) { - if (cachedPropNames_.size() >= 4096) { - // Some maps are keyed by dynamic IDs; cap growth and restart. - cachedPropNames_.clear(); - } - it = cachedPropNames_ - .emplace(propNameScratch_, - PropNameID::forUtf8( - runtime, reinterpret_cast(ptr), - size)) - .first; - } - return it->second; -} -static std::string mpToString(msgpack::object &o) { +std::string mpToString(const msgpack::object &o) { switch (o.type) { case msgpack::type::STR: return o.as(); @@ -107,7 +124,6 @@ static std::string mpToString(msgpack::object &o) { case msgpack::type::NEGATIVE_INTEGER: return std::to_string(o.as()); case msgpack::type::FLOAT32: - return std::to_string(o.as()); case msgpack::type::FLOAT64: return std::to_string(o.as()); default: @@ -115,245 +131,433 @@ static std::string mpToString(msgpack::object &o) { } } +void packNumber(msgpack::packer &pk, double d) { + // Doubles can exactly represent integers up to 2^53. Encode exact + // integers as msgpack int/uint (matching @msgpack/msgpack JS behavior) + // so Go's decoder sees integer types, not float64. Integer-valued + // doubles outside [INT64_MIN, UINT64_MAX] would be UB to cast, so + // those stay float64. 18446744073709551616.0 == 2^64 and + // -9223372036854775808.0 == -2^63 are both exact doubles. + if (d == std::floor(d) && std::isfinite(d)) { + if (d >= 0 && d < 18446744073709551616.0) { + pk.pack(static_cast(d)); + } else if (d < 0 && d >= -9223372036854775808.0) { + pk.pack(static_cast(d)); + } else { + pk.pack(d); + } + } else { + pk.pack(d); + } +} + +void packBytes(msgpack::packer &pk, const uint8_t *data, + size_t length) { + if (length > std::numeric_limits::max()) { + throw std::runtime_error("binary payload too large"); + } + pk.pack_bin(static_cast(length)); + pk.pack_bin_body(reinterpret_cast(data), + static_cast(length)); +} + +// Frame for the iterative msgpack -> JSI walk. Exactly one of arr/obj is set. +struct BuildFrame { + const msgpack::object *node = nullptr; + uint32_t i = 0; + bool isArray = false; + std::optional arr; + std::optional obj; +}; + +// Frame for the iterative JSI -> msgpack walk. `names` is the property-name +// snapshot for plain objects; `len` is captured up front because the msgpack +// map/array header is written before any of the children. +struct PackFrame { + size_t i = 0; + size_t len = 0; + bool isArray = false; + std::optional arr; + std::optional obj; + std::optional names; +}; +} // namespace + +Value KBBridge::binaryFromBytes(Runtime &runtime, const char *ptr, + size_t size) { + ArrayBuffer buffer(runtime, std::make_shared(ptr, size)); + return uint8ArrayCtor(runtime).callAsConstructor(runtime, + std::move(buffer)); +} + +void KBBridge::setObjectKey(Runtime &runtime, Object &obj, const char *ptr, + size_t size, const Value &value) { + // RPC maps reuse a small set of string keys; caching the PropNameIDs + // avoids re-interning the same symbols on every message. The cache is + // never cleared once full — maps keyed by dynamic IDs would otherwise + // invalidate entries other frames still reference — so overflow keys + // just build a throwaway PropNameID. + propNameScratch_.assign(ptr, size); + auto it = cachedPropNames_.find(propNameScratch_); + if (it != cachedPropNames_.end()) { + obj.setProperty(runtime, it->second, value); + return; + } + auto name = PropNameID::forUtf8( + runtime, reinterpret_cast(ptr), size); + if (cachedPropNames_.size() < kMaxCachedPropNames) { + it = cachedPropNames_.emplace(propNameScratch_, std::move(name)).first; + obj.setProperty(runtime, it->second, value); + return; + } + obj.setProperty(runtime, name, value); +} + Value KBBridge::convertMPToJSI(Runtime &runtime, void *mpObj) { - auto &o = *static_cast(mpObj); - switch (o.type) { - case msgpack::type::STR: - return jsi::String::createFromUtf8(runtime, - reinterpret_cast(o.via.str.ptr), o.via.str.size); - case msgpack::type::POSITIVE_INTEGER: - return jsi::Value(o.as()); - case msgpack::type::NEGATIVE_INTEGER: - return jsi::Value(o.as()); - case msgpack::type::FLOAT32: - return jsi::Value(o.as()); - case msgpack::type::FLOAT64: - return jsi::Value(o.as()); - case msgpack::type::BOOLEAN: - return jsi::Value(o.as()); - case msgpack::type::NIL: - return jsi::Value::null(); - case msgpack::type::EXT: - return jsi::Value::undefined(); - case msgpack::type::MAP: { - jsi::Object obj = jsi::Object(runtime); - auto *p = o.via.map.ptr; - auto *const pend = o.via.map.ptr + o.via.map.size; - for (; p < pend; ++p) { - auto val = convertMPToJSI(runtime, &p->val); - auto &k = p->key; + // Iterative rather than recursive: nesting depth is driven by the wire + // payload, and a recursive walk would overflow the native stack. + auto scalar = [&](const msgpack::object &o, Value &out) -> bool { + switch (o.type) { + case msgpack::type::STR: + out = String::createFromUtf8( + runtime, reinterpret_cast(o.via.str.ptr), + o.via.str.size); + return true; + case msgpack::type::POSITIVE_INTEGER: + case msgpack::type::NEGATIVE_INTEGER: + case msgpack::type::FLOAT32: + case msgpack::type::FLOAT64: + out = Value(o.as()); + return true; + case msgpack::type::BOOLEAN: + out = Value(o.as()); + return true; + case msgpack::type::NIL: + out = Value::null(); + return true; + case msgpack::type::BIN: + out = binaryFromBytes(runtime, o.via.bin.ptr, o.via.bin.size); + return true; + case msgpack::type::MAP: + case msgpack::type::ARRAY: + return false; + default: + // EXT and anything unknown. + out = Value::undefined(); + return true; + } + }; + + auto makeFrame = [&](const msgpack::object &o) { + BuildFrame f; + f.node = &o; + f.isArray = (o.type == msgpack::type::ARRAY); + if (f.isArray) { + f.arr.emplace(runtime, o.via.array.size); + } else { + f.obj.emplace(runtime); + } + return f; + }; + + // Attaches a finished child into its parent at the parent's cursor, then + // advances the cursor. + auto attach = [&](BuildFrame &f, const Value &value) { + if (f.isArray) { + f.arr->setValueAtIndex(runtime, f.i, value); + } else { + const auto &k = f.node->via.map.ptr[f.i].key; if (k.type == msgpack::type::STR) { - obj.setProperty( - runtime, mapKeyPropName(runtime, k.via.str.ptr, k.via.str.size), - val); + setObjectKey(runtime, *f.obj, k.via.str.ptr, k.via.str.size, value); } else { auto keyStr = mpToString(k); - obj.setProperty(runtime, - mapKeyPropName(runtime, keyStr.data(), keyStr.size()), - val); + setObjectKey(runtime, *f.obj, keyStr.data(), keyStr.size(), value); } } - return obj; - } - case msgpack::type::BIN: { - auto ptr = o.via.bin.ptr; - auto size = o.via.bin.size; - return binaryFromBytes(runtime, ptr, size); - } - case msgpack::type::ARRAY: { - auto size = o.via.array.size; - jsi::Array arr(runtime, size); - for (uint32_t i = 0; i < size; ++i) { - arr.setValueAtIndex(runtime, i, - convertMPToJSI(runtime, &o.via.array.ptr[i])); - } - return arr; + ++f.i; + }; + + const auto &root = *static_cast(mpObj); + Value out; + if (scalar(root, out)) { + return out; } - default: - return jsi::Value::undefined(); + + std::vector stack; + stack.reserve(16); + stack.push_back(makeFrame(root)); + + while (true) { + BuildFrame &f = stack.back(); + const uint32_t size = + f.isArray ? f.node->via.array.size : f.node->via.map.size; + if (f.i >= size) { + Value done = + f.isArray ? Value(std::move(*f.arr)) : Value(std::move(*f.obj)); + stack.pop_back(); + if (stack.empty()) { + return done; + } + attach(stack.back(), done); + continue; + } + + const msgpack::object &child = + f.isArray ? f.node->via.array.ptr[f.i] : f.node->via.map.ptr[f.i].val; + Value value; + if (scalar(child, value)) { + attach(f, value); + continue; + } + if (stack.size() >= kMaxDepth) { + throw std::runtime_error("msgpack nesting too deep"); + } + // `f` is invalidated by the push; nothing below touches it. + stack.push_back(makeFrame(child)); } } void KBBridge::convertJSIToMP(Runtime &runtime, const Value &value, void *packer) { auto &pk = *static_cast *>(packer); - if (value.isNull() || value.isUndefined()) { - pk.pack_nil(); - } else if (value.isBool()) { - pk.pack(value.getBool()); - } else if (value.isNumber()) { - double d = value.getNumber(); - // Doubles can exactly represent integers up to 2^53. Encode exact - // integers as msgpack int/uint (matching @msgpack/msgpack JS behavior) - // so Go's decoder sees integer types, not float64. Integer-valued - // doubles outside [INT64_MIN, UINT64_MAX] would be UB to cast, so - // those stay float64. 18446744073709551616.0 == 2^64 and - // -9223372036854775808.0 == -2^63 are both exact doubles. - if (d == std::floor(d) && std::isfinite(d)) { - if (d >= 0 && d < 18446744073709551616.0) { - pk.pack(static_cast(d)); - } else if (d < 0 && d >= -9223372036854775808.0) { - pk.pack(static_cast(d)); - } else { - pk.pack(d); - } - } else { - pk.pack(d); + std::vector stack; + stack.reserve(16); + + auto packArrayBufferRange = [&](ArrayBuffer &arrayBuf, double offsetNum, + double lengthNum) { + auto bufferSize = arrayBuf.size(runtime); + if (!std::isfinite(offsetNum) || !std::isfinite(lengthNum) || + offsetNum < 0 || lengthNum < 0 || + offsetNum != std::floor(offsetNum) || + lengthNum != std::floor(lengthNum) || + offsetNum > static_cast(bufferSize) || + lengthNum > static_cast(bufferSize)) { + throw std::runtime_error("ArrayBuffer view is out of range"); } - } else if (value.isString()) { - auto str = value.getString(runtime).utf8(runtime); - pk.pack(str); - } else if (value.isObject()) { - auto obj = value.getObject(runtime); - auto packArrayBufferBytesUnchecked = [&](ArrayBuffer &arrayBuf, - size_t offset, size_t length) { - pk.pack_bin(static_cast(length)); - pk.pack_bin_body( - reinterpret_cast(arrayBuf.data(runtime)) + offset, - static_cast(length)); - }; - auto packArrayBufferBytes = [&](ArrayBuffer &arrayBuf, size_t offset, - size_t length) { - auto bufferSize = arrayBuf.size(runtime); - if (offset > bufferSize || length > bufferSize - offset || - length > std::numeric_limits::max()) { - throw std::runtime_error("ArrayBuffer view is out of range"); - } - pk.pack_bin(static_cast(length)); - pk.pack_bin_body( - reinterpret_cast(arrayBuf.data(runtime)) + offset, - static_cast(length)); - }; + auto offset = static_cast(offsetNum); + auto length = static_cast(lengthNum); + if (offset > bufferSize || length > bufferSize - offset) { + throw std::runtime_error("ArrayBuffer view is out of range"); + } + packBytes(pk, arrayBuf.data(runtime) + offset, length); + }; + + // TypedArray/DataView detection. The cheap byteLength probe lets plain RPC + // objects bail after one property get; ArrayBuffer.isView then gives a + // definitive answer, so an object that merely happens to carry + // byteLength/buffer fields is never mistaken for binary. The range is + // always bounds-checked against the backing buffer. + auto tryPackTypedArray = [&](Object &obj) -> bool { + auto byteLengthProp = obj.getProperty(runtime, "byteLength"); + if (!byteLengthProp.isNumber()) { + return false; + } + if (!isArrayBufferView(runtime, obj)) { + return false; + } + auto bufferProp = obj.getProperty(runtime, "buffer"); + if (!bufferProp.isObject()) { + return false; + } + auto bufferObj = bufferProp.asObject(runtime); + if (!bufferObj.isArrayBuffer(runtime)) { + return false; + } + auto arrayBuf = bufferObj.getArrayBuffer(runtime); + auto byteOffsetProp = obj.getProperty(runtime, "byteOffset"); + packArrayBufferRange( + arrayBuf, byteOffsetProp.isNumber() ? byteOffsetProp.getNumber() : 0, + byteLengthProp.getNumber()); + return true; + }; + + // Packs `v`. Containers write only their header here and push a frame; the + // driver loop below walks their children. + auto packOne = [&](const Value &v) { + if (v.isNull() || v.isUndefined()) { + pk.pack_nil(); + return; + } + if (v.isBool()) { + pk.pack(v.getBool()); + return; + } + if (v.isNumber()) { + packNumber(pk, v.getNumber()); + return; + } + if (v.isString()) { + pk.pack(v.getString(runtime).utf8(runtime)); + return; + } + if (!v.isObject()) { + // Symbol/BigInt. Packing nothing would desync the frame: the enclosing + // map header already promised a value for this key. + pk.pack_nil(); + return; + } + + auto obj = v.getObject(runtime); if (obj.isArrayBuffer(runtime)) { auto buf = obj.getArrayBuffer(runtime); - packArrayBufferBytes(buf, 0, buf.size(runtime)); - } else if (obj.isArray(runtime)) { + packBytes(pk, buf.data(runtime), buf.size(runtime)); + return; + } + if (obj.isFunction(runtime)) { + pk.pack_nil(); + return; + } + if (stack.size() >= kMaxDepth) { + throw std::runtime_error("object nesting too deep"); + } + if (obj.isArray(runtime)) { auto arr = obj.getArray(runtime); auto len = arr.size(runtime); - pk.pack_array(static_cast(len)); - for (size_t i = 0; i < len; ++i) { - convertJSIToMP(runtime, arr.getValueAtIndex(runtime, i), &pk); - } - } else { - auto tryPackTypedArray = [&](bool requireUint8Array, - bool validateView) -> bool { - // Check byteLength before instanceOf: plain objects bail after a - // single property get instead of a prototype-chain walk. - auto byteLengthProp = obj.getProperty(runtime, "byteLength"); - if (!byteLengthProp.isNumber()) return false; - if (requireUint8Array && - !obj.instanceOf(runtime, uint8ArrayCtor(runtime))) { - return false; - } - auto bufferProp = obj.getProperty(runtime, "buffer"); - if (!bufferProp.isObject()) return false; - auto bufferObj = bufferProp.asObject(runtime); - if (!bufferObj.isArrayBuffer(runtime)) return false; - auto arrayBuf = bufferObj.getArrayBuffer(runtime); - auto byteOffset = obj.getProperty(runtime, "byteOffset"); - auto byteLengthNum = byteLengthProp.getNumber(); - auto byteOffsetNum = - byteOffset.isNumber() ? byteOffset.getNumber() : 0; - if (validateView) { - if (!std::isfinite(byteOffsetNum) || - !std::isfinite(byteLengthNum) || byteOffsetNum < 0 || - byteLengthNum < 0 || - byteOffsetNum != std::floor(byteOffsetNum) || - byteLengthNum != std::floor(byteLengthNum)) { - return false; - } - auto offset = static_cast(byteOffsetNum); - auto length = static_cast(byteLengthNum); - packArrayBufferBytes(arrayBuf, offset, length); - } else { - auto offset = static_cast(byteOffsetNum); - auto length = static_cast(byteLengthNum); - packArrayBufferBytesUnchecked(arrayBuf, offset, length); - } - return true; - }; - - // Uint8Array is common for RPC binary payloads; check it before - // enumerating indexed properties. - if (tryPackTypedArray(true, true)) { - return; + if (len > std::numeric_limits::max()) { + throw std::runtime_error("array too large"); } - - auto names = obj.getPropertyNames(runtime); - auto len = names.size(runtime); - - // Empty object: could be {} or empty TypedArray — must probe + pk.pack_array(static_cast(len)); if (len == 0) { - if (tryPackTypedArray(false, false)) return; - pk.pack_map(0); return; } + PackFrame f; + f.isArray = true; + f.len = len; + f.arr.emplace(std::move(arr)); + stack.push_back(std::move(f)); + return; + } + if (tryPackTypedArray(obj)) { + return; + } - // Get first property name (needed for MAP encoding anyway). - // TypedArrays have numeric index keys ("0", "1", ...); regular - // RPC objects have string keys. Only probe for TypedArray when - // the first key looks numeric — saves a JSI call per regular object. - auto firstName = names.getValueAtIndex(runtime, 0).getString(runtime); - auto firstStr = firstName.utf8(runtime); - if (!firstStr.empty() && firstStr[0] >= '0' && firstStr[0] <= '9') { - if (tryPackTypedArray(false, false)) return; - } + auto names = obj.getPropertyNames(runtime); + auto len = names.size(runtime); + if (len > std::numeric_limits::max()) { + throw std::runtime_error("object too large"); + } + pk.pack_map(static_cast(len)); + if (len == 0) { + return; + } + PackFrame f; + f.isArray = false; + f.len = len; + f.obj.emplace(std::move(obj)); + f.names.emplace(std::move(names)); + stack.push_back(std::move(f)); + }; + + packOne(value); - // Regular object — encode as MAP - pk.pack_map(static_cast(len)); - pk.pack(firstStr); - convertJSIToMP(runtime, obj.getProperty(runtime, firstName), &pk); - for (size_t i = 1; i < len; ++i) { - auto name = names.getValueAtIndex(runtime, i).getString(runtime); - auto nameStr = name.utf8(runtime); - pk.pack(nameStr); - convertJSIToMP(runtime, obj.getProperty(runtime, name), &pk); + while (!stack.empty()) { + const size_t top = stack.size() - 1; + if (stack[top].i >= stack[top].len) { + stack.pop_back(); + continue; + } + const size_t idx = stack[top].i++; + // Lengths are snapshots, so a getter that mutates the container can't + // desync the frame: missing entries read back as undefined -> nil. + Value child; + if (stack[top].isArray) { + child = stack[top].arr->getValueAtIndex(runtime, idx); + } else { + auto name = stack[top].names->getValueAtIndex(runtime, idx); + if (!name.isString()) { + // getPropertyNames doesn't enumerate symbol keys, but stay in sync + // with the map header regardless. + pk.pack(std::string()); + pk.pack_nil(); + continue; } + auto nameStr = name.getString(runtime); + pk.pack(nameStr.utf8(runtime)); + child = stack[top].obj->getProperty(runtime, nameStr); } + // May push and invalidate references into `stack`; only indices are used + // above, and nothing below touches the old frame. + packOne(child); } } -void KBBridge::packAndSend(Runtime &runtime, const Value &value) { +bool KBBridge::packAndSend(Runtime &runtime, const Value &value) { + // convertJSIToMP runs JS getters, which could re-enter rpcOnGo and clobber + // the shared scratch buffer mid-frame. + if (packing_) { + throw std::runtime_error("rpcOnGo re-entered from a property getter"); + } + packing_ = true; + struct Guard { + bool &flag; + ~Guard() { flag = false; } + } guard{packing_}; + // Reserve space for the frame header (msgpack uint32 length prefix: // 0xce followed by 4 big-endian bytes) up front, then patch it in // place after packing — avoids copying the payload into a second // buffer just to prepend the header. constexpr size_t headerLen = 5; - mp_->sendBuf.clear(); + auto &sendBuf = send_->sendBuf; + sendBuf.clear(); const char placeholder[headerLen] = {0}; - mp_->sendBuf.write(placeholder, headerLen); - msgpack::packer pk(&mp_->sendBuf); + sendBuf.write(placeholder, headerLen); + msgpack::packer pk(&sendBuf); convertJSIToMP(runtime, value, &pk); - auto contentSize = static_cast(mp_->sendBuf.size() - headerLen); - auto *header = reinterpret_cast(mp_->sendBuf.data()); + auto contentBytes = sendBuf.size() - headerLen; + if (contentBytes > kMaxFrameSize) { + throw std::runtime_error("outgoing rpc frame too large"); + } + auto contentSize = static_cast(contentBytes); + auto *header = reinterpret_cast(sendBuf.data()); header[0] = 0xce; header[1] = static_cast(contentSize >> 24); header[2] = static_cast(contentSize >> 16); header[3] = static_cast(contentSize >> 8); header[4] = static_cast(contentSize); - writeToGo_(mp_->sendBuf.data(), mp_->sendBuf.size()); + const bool ok = writeToGo_ ? writeToGo_(sendBuf.data(), sendBuf.size()) + : false; + + if (sendBuf.size() > kSendBufKeepCapacity) { + // Drop the whole buffer rather than keep one attachment's peak + // allocation alive for the rest of the session. `sendBuf` dangles past + // this point. + send_ = std::make_unique(); + } + return ok; } void KBBridge::install( Runtime &runtime, std::shared_ptr callInvoker, - std::function writeToGo, - std::function onError) { + std::function writeToGo, + std::function onError, + std::function onFatal) { callInvoker_ = std::move(callInvoker); onError_ = std::move(onError); + onFatal_ = std::move(onFatal); writeToGo_ = std::move(writeToGo); - mp_ = std::make_unique(); + send_ = std::make_unique(); + { + std::lock_guard lock(recvMutex_); + resetRecvLocked(); + } auto rpcOnGo = Function::createFromHostFunction( runtime, PropNameID::forAscii(runtime, "rpcOnGo"), 1, [self = shared_from_this()](Runtime &runtime, const Value &thisValue, const Value *arguments, size_t count) -> Value { + if (count < 1) { + throw std::runtime_error("rpcOnGo requires one argument"); + } + if (self->isTornDown_.load() || !self->send_) { + return Value(false); + } try { - self->packAndSend(runtime, arguments[0]); - return Value(true); + return Value(self->packAndSend(runtime, arguments[0])); } catch (const std::exception &e) { throw std::runtime_error("Error in rpcOnGo: " + std::string(e.what())); @@ -364,7 +568,9 @@ void KBBridge::install( runtime.global().setProperty(runtime, "rpcOnGo", std::move(rpcOnGo)); - // HostObject that calls teardown when the JS runtime is destroyed + // HostObject that tears down when the JS runtime is destroyed. Its + // destructor runs on the runtime's thread, which is the only place the + // cached jsi handles may be released. class KBTearDownSimple : public jsi::HostObject { public: KBTearDownSimple(std::weak_ptr bridge) : bridge_(bridge) { @@ -395,97 +601,129 @@ void KBBridge::install( } void KBBridge::onDataFromGo(uint8_t *data, int size) { - if (isTornDown_.load() || size <= 0) { + if (isTornDown_.load() || size <= 0 || data == nullptr) { return; } - try { - auto values = std::make_shared>(); - mp_->unpacker.reserve_buffer(size); - std::copy(data, data + size, mp_->unpacker.buffer()); - mp_->unpacker.buffer_consumed(size); - while (true) { - msgpack::object_handle result; - if (mp_->unpacker.next(result)) { - if (readState_ == ReadState::needSize) { - readState_ = ReadState::needContent; + auto values = std::make_shared>(); + bool fatal = false; + std::string fatalMsg; + + { + // Serialized against a stray second reader thread: msgpack::unpacker is + // not thread safe, and a duplicate reader would corrupt the heap. + std::lock_guard lock(recvMutex_); + if (!recv_) { + return; + } + try { + auto &up = recv_->unpacker; + up.reserve_buffer(static_cast(size)); + std::memcpy(up.buffer(), data, static_cast(size)); + up.buffer_consumed(static_cast(size)); + + while (true) { + msgpack::object_handle result; + if (!up.next(result)) { + break; + } + if (recv_->state == ReadState::needSize) { + // The framing prefix must be a msgpack uint. Anything else means + // the stream desynced; without this check the parity flips and + // every later frame is silently swallowed as a "size". + const auto &o = result.get(); + if (o.type != msgpack::type::POSITIVE_INTEGER || + o.as() > kMaxFrameSize) { + throw std::runtime_error("bad rpc frame header"); + } + recv_->state = ReadState::needContent; } else { values->push_back(std::move(result)); - readState_ = ReadState::needSize; + recv_->state = ReadState::needSize; } - } else { - break; } + + if (up.nonparsed_size() > kMaxFrameSize) { + throw std::runtime_error("rpc frame exceeds size limit"); + } + } catch (const std::exception &e) { + fatal = true; + fatalMsg = std::string("Error in onDataFromGo: ") + e.what(); + resetRecvLocked(); + } catch (...) { + fatal = true; + fatalMsg = "Unknown error in onDataFromGo"; + resetRecvLocked(); } - if (values->empty()) { - return; + } + + if (fatal) { + reportError(fatalMsg); + // The stream can no longer be trusted, so anything decoded in this batch + // is dropped. The platform layer resets the Go connection and signals JS + // so outstanding RPCs fail instead of hanging forever. + if (onFatal_) { + onFatal_(); } + return; + } - auto self = shared_from_this(); - callInvoker_->invokeAsync([values, self](jsi::Runtime &runtime) { - try { - if (self->isTornDown_.load()) { - return; - } + if (values->empty()) { + return; + } - self->resetCaches(runtime); - if (!self->cachedRpcOnJs_) { - try { - auto func = - runtime.global().getPropertyAsFunction(runtime, "rpcOnJs"); - self->cachedRpcOnJs_ = - std::make_unique(std::move(func)); - } catch (...) { - if (self->onError_) { - self->onError_("Failed to get rpcOnJs function"); - } - return; - } - } + auto self = shared_from_this(); + callInvoker_->invokeAsync([values, self](jsi::Runtime &runtime) { + try { + if (self->isTornDown_.load()) { + return; + } - if (values->size() == 1) { - // Single message: pass directly (no array wrapper) - msgpack::object obj((*values)[0].get()); - Value value = self->convertMPToJSI(runtime, &obj); - if (self->isTornDown_.load()) { - return; - } - self->cachedRpcOnJs_->call(runtime, std::move(value), - jsi::Value(1)); - } else { - // Multiple messages: batch into array, pass count - jsi::Array arr(runtime, values->size()); - for (size_t i = 0; i < values->size(); ++i) { - msgpack::object obj((*values)[i].get()); - arr.setValueAtIndex(runtime, i, - self->convertMPToJSI(runtime, &obj)); - } - if (self->isTornDown_.load()) { - return; - } - self->cachedRpcOnJs_->call( - runtime, std::move(arr), - jsi::Value(static_cast(values->size()))); + self->resetCaches(runtime); + if (!self->cachedRpcOnJsName_) { + self->cachedRpcOnJsName_ = std::make_unique( + PropNameID::forAscii(runtime, "rpcOnJs")); + } + // Deliberately re-read the global each batch instead of caching the + // function: JS installs a fresh rpcOnJs whenever the engine client is + // recreated, and a cached handle would keep feeding the dead one. + auto onJsValue = + runtime.global().getProperty(runtime, *self->cachedRpcOnJsName_); + if (!onJsValue.isObject() || + !onJsValue.getObject(runtime).isFunction(runtime)) { + self->reportError("rpcOnJs is not installed"); + return; + } + auto onJs = onJsValue.getObject(runtime).getFunction(runtime); + + if (values->size() == 1) { + // Single message: pass directly (no array wrapper) + msgpack::object obj((*values)[0].get()); + Value value = self->convertMPToJSI(runtime, &obj); + if (self->isTornDown_.load()) { + return; } - } catch (const std::exception &e) { - if (self->onError_) { - self->onError_(e.what()); + onJs.call(runtime, std::move(value), jsi::Value(1)); + } else { + // Multiple messages: batch into array, pass count + jsi::Array arr(runtime, values->size()); + for (size_t i = 0; i < values->size(); ++i) { + msgpack::object obj((*values)[i].get()); + arr.setValueAtIndex(runtime, i, + self->convertMPToJSI(runtime, &obj)); } - } catch (...) { - if (self->onError_) { - self->onError_("unknown error in onDataFromGo JS callback"); + if (self->isTornDown_.load()) { + return; } + onJs.call(runtime, std::move(arr), + jsi::Value(static_cast(values->size()))); } - }); - } catch (const std::exception &e) { - if (onError_) { - onError_(std::string("Error in onDataFromGo: ") + e.what()); + } catch (const std::exception &e) { + self->reportError(e.what()); + } catch (...) { + self->reportError("unknown error in onDataFromGo JS callback"); } - } catch (...) { - if (onError_) { - onError_("Unknown error in onDataFromGo"); - } - } + }); } } // namespace kb diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.h b/rnmodules/react-native-kb/cpp/react-native-kb.h index 2a8e0debdbaf..c4a9e7a5aec5 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.h +++ b/rnmodules/react-native-kb/cpp/react-native-kb.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -24,45 +25,79 @@ class KBBridge : public std::enable_shared_from_this { KBBridge &operator=(const KBBridge &) = delete; KBBridge(KBBridge &&) = delete; KBBridge &operator=(KBBridge &&) = delete; + + // `writeToGo` returns false if the native write failed, so the caller's + // RPC can be failed instead of hanging forever waiting for a reply. + // `onFatal` is invoked when the incoming byte stream can no longer be + // trusted (desynced framing, oversized frame). The platform layer is + // expected to reset the Go connection and emit the engine-reset meta + // event so JS fails its outstanding RPCs. void install(facebook::jsi::Runtime &runtime, std::shared_ptr callInvoker, - std::function writeToGo, - std::function onError); + std::function writeToGo, + std::function onError, + std::function onFatal); + // Any thread. void onDataFromGo(uint8_t *data, int size); + + // Any thread. Stops all further work. Does NOT touch JSI state, so it is + // safe to call from the main thread / module invalidation. + void markTornDown(); + + // JS thread ONLY — destroys cached jsi handles, which is undefined + // behavior off the runtime's thread. void teardown(); void tearup(); private: std::shared_ptr callInvoker_; std::function onError_; + std::function onFatal_; + std::function writeToGo_; std::atomic isTornDown_{false}; enum class ReadState { needSize, needContent }; - ReadState readState_ = ReadState::needSize; - struct MsgpackState; - std::unique_ptr mp_; + // Incoming stream state. Touched only from the native reader thread, and + // under recvMutex_ so a stray second reader can't corrupt the unpacker. + struct RecvState; + std::mutex recvMutex_; + std::unique_ptr recv_; + + // Outgoing scratch buffer. JS thread only. + struct SendState; + std::unique_ptr send_; + bool packing_ = false; std::unique_ptr cachedUint8ArrayCtor_; - std::unique_ptr cachedRpcOnJs_; + std::unique_ptr cachedIsView_; + std::unique_ptr cachedRpcOnJsName_; std::unordered_map cachedPropNames_; std::string propNameScratch_; facebook::jsi::Runtime *cachedRuntime_ = nullptr; - std::function writeToGo_; void resetCaches(facebook::jsi::Runtime &runtime); - const facebook::jsi::PropNameID & - mapKeyPropName(facebook::jsi::Runtime &runtime, const char *ptr, - size_t size); + void releaseJSIState(); + // Requires recvMutex_. + void resetRecvLocked(); + void reportError(const std::string &msg); + + // Sets obj[key] = value, reusing an interned PropNameID when possible. + void setObjectKey(facebook::jsi::Runtime &runtime, + facebook::jsi::Object &obj, const char *ptr, size_t size, + const facebook::jsi::Value &value); facebook::jsi::Function &uint8ArrayCtor(facebook::jsi::Runtime &runtime); + facebook::jsi::Function &arrayBufferIsView(facebook::jsi::Runtime &runtime); + bool isArrayBufferView(facebook::jsi::Runtime &runtime, + const facebook::jsi::Object &obj); facebook::jsi::Value binaryFromBytes(facebook::jsi::Runtime &runtime, const char *ptr, size_t size); facebook::jsi::Value convertMPToJSI(facebook::jsi::Runtime &runtime, void *mpObj); void convertJSIToMP(facebook::jsi::Runtime &runtime, const facebook::jsi::Value &value, void *packer); - void packAndSend(facebook::jsi::Runtime &runtime, + bool packAndSend(facebook::jsi::Runtime &runtime, const facebook::jsi::Value &value); }; diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 82e0ef81ed2f..9821881bfc5a 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -8,6 +8,8 @@ #import #import #import +#import +#import #import #import #import "RNKbSpec.h" @@ -40,6 +42,37 @@ + (id)sharedFsPathsHolder { static NSString *kbStoredDeviceToken = nil; static NSDictionary *kbInitialNotification = nil; +// The bridge is created on the JS thread and consumed by the reader thread, +// so every access goes through this lock — a plain shared_ptr member would be +// a data race between installJSIBindings/invalidate and the reader. +static std::mutex kbBridgeMutex; +static std::shared_ptr kbCurrentBridge; + +static std::shared_ptr kbGetBridge(void) { + std::lock_guard lock(kbBridgeMutex); + return kbCurrentBridge; +} + +static void kbSetBridge(std::shared_ptr bridge) { + std::shared_ptr old; + { + std::lock_guard lock(kbBridgeMutex); + old = std::move(kbCurrentBridge); + kbCurrentBridge = std::move(bridge); + } + // markTornDown only flips an atomic; releasing the old bridge's jsi handles + // is the JS runtime's job (see the kbTeardown host object). + if (old) { + old->markTornDown(); + } +} + +static void kbLogToService(NSString *message) { + KeybaseLogToService([NSString + stringWithFormat:@"dNativeLogger: [%f,\"%@\"]", + [[NSDate date] timeIntervalSince1970] * 1000, message]); +} + // from react-native-localize static bool kbUses24HourClockForLocale(NSLocale *_Nonnull locale) { NSDateFormatter *formatter = [NSDateFormatter new]; @@ -120,14 +153,7 @@ static bool kbUses24HourClockForLocale(NSLocale *_Nonnull locale) { return constants; } -@interface Kb () -@property dispatch_queue_t readQueue; -@end - -@implementation Kb { - std::shared_ptr kbBridge_; - BOOL isInvalidated_; -} +@implementation Kb RCT_EXPORT_MODULE() @@ -144,7 +170,6 @@ - (BOOL)canEmit { - (instancetype)init { self = [super init]; kbSharedInstance = self; - isInvalidated_ = NO; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleHardwareKeyPressed:) name:@"hardwareKeyPressed" @@ -218,13 +243,12 @@ + (void)handlePastedImages:(NSArray *)images { - (void)invalidate { [[NSNotificationCenter defaultCenter] removeObserver:self]; - isInvalidated_ = YES; kbPasteImageEnabled = NO; - if (kbBridge_) { - kbBridge_->teardown(); - kbBridge_.reset(); - } - self.readQueue = nil; + // Drop the bridge so the (permanent) reader thread stops delivering into a + // runtime that is going away. Only the atomic flag is touched here: this + // runs on the main thread, and releasing jsi handles off the JS thread is + // undefined behavior. + kbSetBridge(nullptr); NSError *error = nil; KeybaseReset(&error); } @@ -241,27 +265,46 @@ - (void)invalidate { // RCTTurboModuleWithJSIBindings — called automatically by RN when the module loads - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime callInvoker:(const std::shared_ptr &)callInvoker { - kbBridge_ = std::make_shared(); - kbBridge_->install(runtime, callInvoker, - // writeToGo callback - [](void *ptr, size_t size) { + auto bridge = std::make_shared(); + bridge->install(runtime, callInvoker, + // writeToGo callback; false means the RPC never reached Go, so the + // caller fails that invocation instead of waiting forever. + [](void *ptr, size_t size) -> bool { NSData *data = [NSData dataWithBytesNoCopy:ptr length:size freeWhenDone:NO]; NSError *error = nil; KeybaseWriteArr(data, &error); if (error) { - NSLog(@"Error writing data: %@", error); + kbLogToService([NSString stringWithFormat:@"rpc write failed: %@", + error.localizedDescription]); + return false; } + return true; }, // error callback [](const std::string &err) { - KeybaseLogToService([NSString - stringWithFormat:@"dNativeLogger: [%f,\"jsi error: %s\"]", - [[NSDate date] timeIntervalSince1970] * 1000, - err.c_str()]); + kbLogToService([NSString stringWithFormat:@"jsi error: %s", err.c_str()]); + }, + // fatal callback: the incoming stream desynced. Reset the Go + // connection and tell JS, so it fails outstanding RPCs rather than + // leaving every caller hanging on a channel that can't recover. + []() { + kbLogToService(@"rpc stream desync, resetting connection"); + NSError *error = nil; + KeybaseReset(&error); + if (error) { + kbLogToService([NSString stringWithFormat:@"reset after desync failed: %@", + error.localizedDescription]); + } + dispatch_async(dispatch_get_main_queue(), ^{ + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnMetaEvent:metaEventEngineReset]; + } + }); }); - KeybaseLogToService([NSString stringWithFormat:@"dNativeLogger: [%f,\"jsi install success (via installJSIBindings)\"]", - [[NSDate date] timeIntervalSince1970] * 1000]); + kbSetBridge(bridge); + kbLogToService(@"jsi install success (via installJSIBindings)"); } RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getTypedConstants) { @@ -288,42 +331,42 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime } RCT_EXPORT_METHOD(notifyJSReady) { - __weak __typeof__(self) weakSelf = self; - - NSLog(@"notifyJSReady: called from JS, queuing main thread block"); - dispatch_async(dispatch_get_main_queue(), ^{ - if (self.readQueue) { - NSLog(@"notifyJSReady: read loop already running, ignoring"); - return; - } - - self.readQueue = dispatch_queue_create("go_bridge_queue_read", DISPATCH_QUEUE_SERIAL); - - // Signal to Go that JS is ready - KeybaseNotifyJSReady(); - NSLog(@"Notified Go that JS is ready, starting ReadArr loop"); + // KeybaseNotifyJSReady is a sync.Once on the Go side, so repeat calls after + // a reload are free. It must not run on the JS thread — do it on the reader + // queue, which is also where ReadArr is serviced. + // + // Exactly one reader exists for the life of the process. Go's ReadArr hands + // back a view of a single shared buffer and is documented as "called + // serially by the mobile run loops": a second concurrent reader corrupts + // both deliveries. It can't be stopped either, because a parked ReadArr + // ignores cancellation and would swallow the next message on its way out. + // So the loop outlives any individual module instance and simply forwards + // to whichever bridge is currently installed. + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + dispatch_queue_t readQueue = + dispatch_queue_create("go_bridge_queue_read", DISPATCH_QUEUE_SERIAL); + dispatch_async(readQueue, ^{ + KeybaseNotifyJSReady(); + NSLog(@"Notified Go that JS is ready, starting ReadArr loop"); - // Start the read loop - dispatch_async(self.readQueue, ^{ while (true) { - { - __typeof__(self) strongSelf = weakSelf; - if (!strongSelf || strongSelf->isInvalidated_) { - NSLog(@"Module invalidated, bailing from ReadArr loop"); - return; - } - } - NSError *error = nil; NSData *data = KeybaseReadArr(&error); if (error) { NSLog(@"Error reading data: %@", error); [NSThread sleepForTimeInterval:0.1]; - } else if (data) { - __typeof__(self) strongSelf = weakSelf; - if (strongSelf && strongSelf->kbBridge_) { - strongSelf->kbBridge_->onDataFromGo((uint8_t *)[data bytes], (int)[data length]); - } + continue; + } + if (data.length == 0) { + // ReadArr returns (nil, nil) when the connection had nothing for + // us; without a pause this spins a core at full speed. + [NSThread sleepForTimeInterval:0.01]; + continue; + } + auto bridge = kbGetBridge(); + if (bridge) { + bridge->onDataFromGo((uint8_t *)[data bytes], (int)[data length]); } } }); diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index 1b8cee288d6a..d48656c83ab4 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -1,6 +1,6 @@ import logger from '@/logger' import {TransportShared, LocalTransport, sharedCreateClient, rpcLog} from './transport-shared' -import {makeEOFError, type RPCMessage} from './rpc-transport' +import type {RPCMessage} from './rpc-transport' import type {InvokeType, PayloadType, ConnectDisconnectCB, IncomingRPCCallbackType} from '@/engine/rpc-transport' export type {PayloadType, ConnectDisconnectCB, IncomingRPCCallbackType, InvokeType} @@ -148,22 +148,30 @@ class ProxyNativeTransport extends LocalTransport { // On account-switch reset fail outstanding invocations so pre-switch RPC // callbacks can't fire later against post-switch state override reset() { - this.failOutstanding(makeEOFError(), {}) + this.failAllOutstanding() } } // Mobile transport — only instantiated when isMobile class NativeTransportMobile extends LocalTransport { protected writeMessage(message: RPCMessage) { - try { - if (!global.rpcOnGo) { - logger.error('>>>> rpcOnGo send before rpcOnGo global?') - } - global.rpcOnGo?.(message) - } catch (e) { - logger.error('>>>> rpcOnGo JS thrown!', e) + if (!global.rpcOnGo) { + throw new Error('rpcOnGo send before rpcOnGo global') + } + // Throwing rather than swallowing is load-bearing: the transport catches + // it and fails that invocation, instead of leaving the caller waiting on + // a reply that can never arrive. rpcOnGo returns false when the native + // write to Go failed. + if (!global.rpcOnGo(message)) { + throw new Error('native rpc write failed') } } + // The Go connection can be reset underneath us (engine reset, or a stream + // desync detected natively). Nothing will answer the in-flight RPCs after + // that, so fail them rather than hang every caller. + override reset() { + this.failAllOutstanding() + } } function createClient( @@ -176,25 +184,32 @@ function createClient( new NativeTransportMobile(incomingRPCCallback, connectCallback, disconnectCallback) ) - global.rpcOnJs = (objs: unknown, count: number) => { + // Per-message try/catch: one bad message must not drop the rest of the + // batch the native side handed over. + const dispatchOne = (obj: unknown) => { try { - if (count > 1) { - const arr = objs as Array - for (const obj of arr) { - client.transport.dispatchDecodedMessage(obj) - } - } else { - client.transport.dispatchDecodedMessage(objs) - } + client.transport.dispatchDecodedMessage(obj) } catch (e) { logger.error('>>>> rpcOnJs JS thrown!', e) } } + global.rpcOnJs = (objs: unknown, count: number) => { + if (count > 1) { + for (const obj of objs as Array) { + dispatchOne(obj) + } + } else { + dispatchOne(objs) + } + } + onMetaEvent((payload: string) => { try { switch (payload) { case 'kb-engine-reset': + // Go dropped the loopback connection; anything in flight is dead. + client.transport.reset() connectCallback() } } catch (e) { diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index 6caea764c5a0..f79e1cf612a0 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -411,7 +411,12 @@ export abstract class RPCTransport { } if (this.isConnected()) { - this.writeMessage(message) + try { + this.writeMessage(message) + } catch (err) { + console.warn('Failed to write RPC message', err) + return false + } return true } if (this._explicitClose) { @@ -459,11 +464,26 @@ export abstract class RPCTransport { return encodeFrame(message) } + // Fails every outstanding invocation. Transports that sit on a connection + // which can die underneath them (mobile JSI, renderer IPC) call this when + // the engine resets; without it the callbacks are never invoked and every + // in-flight RPC hangs forever. + failAllOutstanding(err: unknown = makeEOFError()) { + this.failOutstanding(err, {}) + } + private invokeNow(method: string, args: [object], cb: InvocationCallback) { const seqid = this._seqid this._seqid += 1 this._invocations.set(seqid, cb) - this.writeMessage([MESSAGE_TYPE_INVOKE, seqid, method, args]) + try { + this.writeMessage([MESSAGE_TYPE_INVOKE, seqid, method, args]) + } catch (err) { + // The message never left, so no response is coming. Fail the caller + // rather than leaving the seqid outstanding for the rest of the session. + this._invocations.delete(seqid) + cb(err, {}) + } } private makeResponse(seqid: number): ResponseType { diff --git a/shared/globals.d.ts b/shared/globals.d.ts index 083297167dd5..7e55cb944897 100644 --- a/shared/globals.d.ts +++ b/shared/globals.d.ts @@ -37,7 +37,8 @@ declare global { var __VERSION__: string var __FILE_SUFFIX__: string var __PROFILE__: boolean - var rpcOnGo: undefined | ((msg: unknown) => void) + // Returns false if the native write to Go failed + var rpcOnGo: undefined | ((msg: unknown) => boolean) var rpcOnJs: undefined | ((objs: unknown, count: number) => void) // RN var __turboModuleProxy: unknown From 3f71bd9388b6139e810bc26e6e9ad2abae4fc989 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 24 Jul 2026 18:02:12 -0400 Subject: [PATCH 02/80] fix(rpc): clear module instance on destroy, cover write failures 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. --- .../main/java/com/reactnativekb/KbModule.kt | 8 +++ shared/engine/rpc-transport.test.ts | 60 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index c7bebfca2927..004693196042 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -547,6 +547,14 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } fun destroy() { + // The read loop outlives us and forwards to whatever `instance` holds, + // so drop ourselves from it: otherwise a torn-down module (and the + // ReactContext/Activity it pins) keeps receiving deliveries. Only clear + // if we're still the current one — a reload may have installed a newer + // module before our onHostDestroy runs. + if (instance === this) { + instance = null + } try { Keybase.reset() relayReset() diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index c12fef0bf0d2..2cb1c1cd4f69 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -10,11 +10,13 @@ import { class TestTransport extends RPCTransport { private _connected = true + private _writeError: Error | undefined sent = new Array() - constructor(p?: {connected?: boolean; incomingRPCCallback?: IncomingRPCCallbackType}) { + constructor(p?: {connected?: boolean; incomingRPCCallback?: IncomingRPCCallbackType; writeError?: Error}) { super({incomingRPCCallback: p?.incomingRPCCallback}) this._connected = p?.connected ?? true + this._writeError = p?.writeError } protected override isConnected() { @@ -22,9 +24,16 @@ class TestTransport extends RPCTransport { } protected writeMessage(message: RPCMessage) { + if (this._writeError !== undefined) { + throw this._writeError + } this.sent.push(message) } + setWriteError(err: Error | undefined) { + this._writeError = err + } + setConnected(connected: boolean) { this._connected = connected } @@ -117,6 +126,55 @@ test('incoming invoke without handler returns unknown method error', () => { ]) }) +test('invoke fails the caller when the native write throws', () => { + const writeError = new Error('native write failed') + const transport = new TestTransport({writeError}) + const cb = jest.fn() + + transport.invoke('keybase.1.test.hello', [{}], cb) + + expect(cb).toHaveBeenCalledWith(writeError, {}) +}) + +test('a failed write leaves no outstanding invocation', () => { + const transport = new TestTransport({writeError: new Error('native write failed')}) + const cb = jest.fn() + + transport.invoke('keybase.1.test.hello', [{}], cb) + expect(cb).toHaveBeenCalledTimes(1) + + // If the seqid were still outstanding, this would fail the same callback + // a second time with EOF. + transport.failAllOutstanding() + expect(cb).toHaveBeenCalledTimes(1) +}) + +test('a failed write does not consume the response for a later invoke', () => { + const transport = new TestTransport({writeError: new Error('native write failed')}) + const failed = jest.fn() + transport.invoke('keybase.1.test.hello', [{}], failed) + + transport.setWriteError(undefined) + const ok = jest.fn() + transport.invoke('keybase.1.test.hello', [{}], ok) + + const [, seqid] = transport.sent[0] as [number, number, string, [object]] + transport.dispatchDecodedMessage([1, seqid, null, {ok: true}]) + + expect(ok).toHaveBeenCalledWith(null, {ok: true}) + expect(failed).toHaveBeenCalledTimes(1) +}) + +test('send reports failure when the native write throws', () => { + const transport = new TestTransport({writeError: new Error('native write failed')}) + + expect(transport.send([1, 3, null, {ok: true}])).toBe(false) + + transport.setWriteError(undefined) + expect(transport.send([1, 3, null, {ok: true}])).toBe(true) + expect(transport.sent).toEqual([[1, 3, null, {ok: true}]]) +}) + test('cancel packets surface a cancelled response payload', () => { const incoming = jest.fn() const transport = new TestTransport({incomingRPCCallback: incoming}) From 426c073e6e394132e405bd405f24fabe92feb061 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 24 Jul 2026 19:23:28 -0400 Subject: [PATCH 03/80] fix(rpc): drain autorelease pool per ReadArr iteration The reader block never returns, so the queue's pool never drains and autoreleased objects accumulate for the life of the process. --- rnmodules/react-native-kb/ios/Kb.mm | 36 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 9821881bfc5a..26108cac374a 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -351,22 +351,26 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime NSLog(@"Notified Go that JS is ready, starting ReadArr loop"); while (true) { - NSError *error = nil; - NSData *data = KeybaseReadArr(&error); - if (error) { - NSLog(@"Error reading data: %@", error); - [NSThread sleepForTimeInterval:0.1]; - continue; - } - if (data.length == 0) { - // ReadArr returns (nil, nil) when the connection had nothing for - // us; without a pause this spins a core at full speed. - [NSThread sleepForTimeInterval:0.01]; - continue; - } - auto bridge = kbGetBridge(); - if (bridge) { - bridge->onDataFromGo((uint8_t *)[data bytes], (int)[data length]); + // The block never returns, so the queue's pool never drains on its + // own — each iteration needs its own. + @autoreleasepool { + NSError *error = nil; + NSData *data = KeybaseReadArr(&error); + if (error) { + NSLog(@"Error reading data: %@", error); + [NSThread sleepForTimeInterval:0.1]; + continue; + } + if (data.length == 0) { + // ReadArr returns (nil, nil) when the connection had nothing for + // us; without a pause this spins a core at full speed. + [NSThread sleepForTimeInterval:0.01]; + continue; + } + auto bridge = kbGetBridge(); + if (bridge) { + bridge->onDataFromGo((uint8_t *)[data bytes], (int)[data length]); + } } } }); From bd73350b3f70f03a1b6efc2a1a09f590720f56c6 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 24 Jul 2026 22:38:51 -0400 Subject: [PATCH 04/80] fix(rpc): guard rpcOnJs batch dispatch, volatile Android module instance --- .../src/main/java/com/reactnativekb/KbModule.kt | 3 +++ shared/engine/index.platform.tsx | 16 +++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 004693196042..6c6788ad06b2 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -617,6 +617,9 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T private const val MAX_TEXT_FILE_SIZE = 100 * 1024 // 100 kiB private val LINE_SEPARATOR: String? = System.getProperty("line.separator") + // Written on init/destroy, read on the permanent reader thread; needs a + // visibility guarantee so the reader never sees a stale instance. + @Volatile var instance: KbModule? = null @JvmStatic internal var initialNotificationBundle: Bundle? = null diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index d48656c83ab4..de671239c151 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -194,13 +194,19 @@ function createClient( } } + // Outer guard: this is called from native, so throwing here would abort the + // whole batch delivery and unwind into native code. global.rpcOnJs = (objs: unknown, count: number) => { - if (count > 1) { - for (const obj of objs as Array) { - dispatchOne(obj) + try { + if (count > 1 && Array.isArray(objs)) { + for (const obj of objs) { + dispatchOne(obj) + } + } else { + dispatchOne(objs) } - } else { - dispatchOne(objs) + } catch (e) { + logger.error('>>>> rpcOnJs JS thrown!', e) } } From ed5c543e50f799c3e535f5f456cb51856ec21bbe Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 24 Jul 2026 22:53:16 -0400 Subject: [PATCH 05/80] fix(rpc): spell out weak_ptr type in bindings installer capture --- rnmodules/react-native-kb/android/cpp-adapter.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index 4e8bf509ae32..2d3af0dc1b2e 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -66,7 +66,7 @@ getBindingsInstaller(jni::alias_ref thiz) { } return BindingsInstallerHolder::newObjectCxxArgs( - [weakAdapter = std::weak_ptr(adapter)]( + [weakAdapter = std::weak_ptr(adapter)]( jsi::Runtime &runtime, const std::shared_ptr &callInvoker) { auto bridge = std::make_shared(); From 91f0fb2b83c17e9935977ab339e3fa551f2fee58 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:17:36 -0400 Subject: [PATCH 06/80] chore(rpc): add native sync script and review fix plan --- plans/2026-07-26-rpc-bridge-review-fixes.md | 2105 +++++++++++++++++++ plans/scripts/sync-native-kb.sh | 13 + 2 files changed, 2118 insertions(+) create mode 100644 plans/2026-07-26-rpc-bridge-review-fixes.md create mode 100755 plans/scripts/sync-native-kb.sh diff --git a/plans/2026-07-26-rpc-bridge-review-fixes.md b/plans/2026-07-26-rpc-bridge-review-fixes.md new file mode 100644 index 000000000000..98ee796e98bb --- /dev/null +++ b/plans/2026-07-26-rpc-bridge-review-fixes.md @@ -0,0 +1,2105 @@ +# Mobile RPC Bridge — Review Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the 20 defects five independent reviewers found in the `nojima/HOTPOT-rpc-fixes` mobile JSI RPC bridge hardening, without changing the (validated) core design. + +**Architecture:** The branch's core design is correct and stays: **one permanent process-wide reader thread** per platform, forwarding into whichever `KBBridge` is currently installed via a mutex-guarded global. This is forced by `go/bind/keybase.go` — `ReadArr` returns a view of one shared 300KB global buffer, is documented "called serially by the mobile run loops", and a thread parked in it cannot be cancelled (`LoopbackConn.SetReadDeadline` is a no-op stub). The reader also serializes the non-thread-safe `msgpack::unpacker`. All defects are in two clusters: **teardown seams** (module invalidate races the next module's install) and a **half-wired reset story** (the desync leg notifies JS; the far more common read-error leg does not). + +**Tech Stack:** C++20 + JSI (Hermes), Objective-C++ (iOS TurboModule), Kotlin + fbjni (Android TurboModule), Go (gomobile bindings), TypeScript (engine transport), Jest. + +## Global Constraints + +- Branch is `nojima/HOTPOT-rpc-fixes`. Base is `master` (confirmed via `gh pr view --json baseRefName`, PR #29464). Do **not** amend or rebase existing commits — fix forward with new commits only. +- No `Co-Authored-By` trailers in commits. Ever. +- Repo root is `client/`. TS source lives in `shared/`. Use absolute paths for file ops; for Bash, `cd shared/` first for any yarn command. +- Use `--no-ext-diff` on every `git diff` / `git show` / `git log -p`. +- **Native build gotcha:** `shared/node_modules/react-native-kb` is a real directory **copy**, not a symlink, and expo autolinking resolves gradle/pod paths to it — not to `rnmodules/`. It is currently stale. After editing anything under `rnmodules/react-native-kb/`, run the sync in Task 0 before attempting any native build, and never edit the `node_modules` copy directly (it is not tracked by git). +- Never use `npm`. Always `yarn`. +- Remove unused code when editing: styles, imports, vars, params, dead helpers. +- Comments: no refactoring/change-history notes. Only add a comment when the constraint is non-obvious from the code. Several tasks here **fix wrong comments** — that is deliberate, because this design lives in its comments. +- TS validation after any `shared/` change, from `shared/`: `yarn lint:all` (= `yarn lint && yarn lint:bailouts && yarn tsc`). Baseline is 0 react-compiler bailouts; keep it there. Never delete the ESLint cache. +- Do not "fix" the `n != len(bytes)` short-write branch by assuming it is live — it is provably dead for `LoopbackConn` today (`go/libkb/loopback.go:166-174` is all-or-nothing). Task 15 hardens it as defense-in-depth only. + +--- + +## File Structure + +| File | Responsibility | Tasks | +|---|---|---| +| `rnmodules/react-native-kb/ios/Kb.mm` | iOS TurboModule: bridge install/invalidate, permanent reader loop, event emit | 1, 3, 8, 16, 17 | +| `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` | Android TurboModule: `instance` gate, reader thread, lifecycle | 2, 4, 8, 16, 17 | +| `rnmodules/react-native-kb/android/cpp-adapter.cpp` | Android JNI ↔ `KBBridge` glue, `g_adapter`/`g_bridge` globals | 4, 5, 14, 17 | +| `rnmodules/react-native-kb/cpp/react-native-kb.cpp` | Platform-independent bridge: framing, msgpack↔JSI, batch dispatch | 6, 7, 9, 10, 18 | +| `rnmodules/react-native-kb/cpp/react-native-kb.h` | `KBBridge` interface + threading contract comments | 5, 6, 16 | +| `go/bind/keybase.go` | gomobile `ReadArr`/`WriteArr`/`Reset` | 15, 19 | +| `shared/engine/rpc-transport.tsx` | Shared transport: packetizer, invocations, responses | 11, 12, 13 | +| `shared/engine/index.platform.tsx` | Mobile/renderer transports, `rpcOnJs`, meta events | 12, 13, 20 | +| `shared/engine/rpc-transport.test.ts` | Jest coverage for transport | 11, 12, 13 | + +--- + +## Task Ordering Rationale + +Tasks 1–5 are the **critical/high teardown cluster** — each is an unrecoverable, silent hang in production. Tasks 6–10 close the **reset story**. Tasks 11–13 are TS-side, are the only ones with a real test harness, and are strictly TDD. Tasks 14–20 are hardening, observability, and comment corrections. Tasks 1 and 2 are independent of everything else and should land first. + +--- + +### Task 0: Native build harness + +Establishes the sync + compile loop every native task depends on. No product change. + +**Files:** +- Create: `plans/scripts/sync-native-kb.sh` + +**Interfaces:** +- Produces: `plans/scripts/sync-native-kb.sh` — copies `rnmodules/react-native-kb/{cpp,ios,android}` over `shared/node_modules/react-native-kb/`, used by every later native task before building. + +- [ ] **Step 1: Write the sync script** + +```bash +#!/usr/bin/env bash +# shared/node_modules/react-native-kb is a `file:` COPY, not a symlink, and +# expo autolinking points gradle/pods at it. Native edits under rnmodules/ +# are invisible to a build until they are copied across. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SRC="$ROOT/rnmodules/react-native-kb" +DST="$ROOT/shared/node_modules/react-native-kb" +test -d "$DST" || { echo "missing $DST — run yarn in shared/ first" >&2; exit 1; } +for d in cpp ios android; do + rsync -a --delete "$SRC/$d/" "$DST/$d/" +done +echo "synced rnmodules/react-native-kb -> shared/node_modules/react-native-kb" +``` + +- [ ] **Step 2: Make it executable and run it** + +Run: +```bash +chmod +x plans/scripts/sync-native-kb.sh && ./plans/scripts/sync-native-kb.sh +``` +Expected: `synced rnmodules/react-native-kb -> shared/node_modules/react-native-kb` + +- [ ] **Step 3: Verify the C++-only syntax check works** + +This is the fast inner loop — it needs no gradle or xcodebuild. Run from `rnmodules/react-native-kb/cpp`: +```bash +cd rnmodules/react-native-kb/cpp +NM=../../../shared/node_modules +clang++ -std=c++20 -fsyntax-only -DMSGPACK_NO_BOOST \ + -I$NM/react-native/ReactCommon/jsi \ + -I$NM/react-native/ReactCommon/callinvoker \ + -I$NM/react-native/ReactCommon \ + -I$NM/msgpack-cxx-7.0.0/include \ + -I. react-native-kb.cpp +``` +Expected: exit 0, no output. If headers are missing, run `yarn` from `shared/` first and re-run Task 0 Step 2. + +- [ ] **Step 4: Commit** + +```bash +git add plans/scripts/sync-native-kb.sh plans/2026-07-26-rpc-bridge-review-fixes.md +git commit -m "chore(rpc): add native sync script and review fix plan" +``` + +--- + +### Task 1: iOS — compare-and-clear the bridge on invalidate (CRITICAL) + +**The bug.** `Kb.mm:251` calls `kbSetBridge(nullptr)`, and `kbSetBridge` (`Kb.mm:56-68`) has **no identity check** — it tears down whatever bridge is currently installed, not the one this module installed. The two events are genuinely unordered on a reload: + +- `RCTHost.mm:620-628` does `[_instance invalidate]; _instance = nil; … _instance = [[RCTInstance alloc] init…]` +- `RCTInstance.mm:218-247` wraps `invalidate` in `dispatchToJSThread:`, and `RCTJSThreadManager.mm:84` `runOnQueue` is **async** when called off the old JS thread (reload commands arrive on main), so `invalidate` returns immediately +- the queued block reaches `RCTTurboModuleManager.mm:1093` `dispatch_async(methodQueue, …)`, guarded only by a 10s `dispatch_group_wait` (`:1098`) that **times out and proceeds** + +So if the old JS thread is busy (long JS task, debugger pause) or `_sharedModuleQueue` is backed up, the new bridge B installs first and the stale `invalidate` then runs `B->markTornDown()`. Result: `rpcOnGo` returns false forever, the reader drops every frame, the app hangs on the loading screen — with **no desync detected and no engine-reset emitted**, because nothing looks broken from C++'s side. `Kb.mm:253`'s unconditional `KeybaseReset` compounds it by killing B's freshly dialed connection. + +**Files:** +- Modify: `rnmodules/react-native-kb/ios/Kb.mm:56-68` (add compare-and-clear helper) +- Modify: `rnmodules/react-native-kb/ios/Kb.mm:244-254` (`invalidate`) +- Modify: `rnmodules/react-native-kb/ios/Kb.mm:266-308` (`installJSIBindingsWithRuntime` — record the installed bridge) + +**Interfaces:** +- Produces: `static bool kbClearBridgeIfCurrent(const std::shared_ptr &mine)` — returns `true` only if `mine` was the installed bridge and was cleared. Task 3 and Task 8 both call `invalidate` paths that depend on this returning `false` for a stale module. +- Produces: `Kb` instance ivar `myBridge_` of type `std::shared_ptr`. + +- [ ] **Step 1: Add the compare-and-clear helper** + +Insert immediately after `kbSetBridge` (after `Kb.mm:68`): + +```objc +// Clears the installed bridge only if it is still `mine`. A module's +// invalidate can run *after* the next module already installed its bridge +// (RCTInstance::invalidate hops to the old JS thread asynchronously, and +// RCTTurboModuleManager only waits 10s before proceeding), so an +// unconditional clear would tear down the live bridge and wedge the app +// with no desync and no reset to recover from. +static bool kbClearBridgeIfCurrent(const std::shared_ptr &mine) { + std::shared_ptr old; + { + std::lock_guard lock(kbBridgeMutex); + if (!mine || kbCurrentBridge != mine) { + return false; + } + old = std::move(kbCurrentBridge); + kbCurrentBridge = nullptr; + } + old->markTornDown(); + return true; +} +``` + +- [ ] **Step 2: Record the installed bridge on the instance** + +Find the `@implementation Kb` ivar block. If there is no ivar block, add one directly after `@implementation Kb`: + +```objc +@implementation Kb { + std::shared_ptr myBridge_; +} +``` + +If an ivar block already exists, add only the `myBridge_` line to it. + +Then in `installJSIBindingsWithRuntime`, replace `Kb.mm:306`: + +```objc + kbSetBridge(bridge); +``` + +with: + +```objc + myBridge_ = bridge; + kbSetBridge(bridge); +``` + +- [ ] **Step 3: Gate invalidate on the identity check** + +Replace the whole of `invalidate` (`Kb.mm:244-254`): + +```objc +- (void)invalidate { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + kbPasteImageEnabled = NO; + // Runs on the TurboModule shared method queue (no methodQueue getter, so + // RCTTurboModuleManager assigns _sharedModuleQueue) — any thread, never the + // JS thread. Only the atomic flag may be touched here; releasing jsi + // handles off the runtime's thread is undefined behavior. + // + // Both the teardown and the Go reset are gated on still being the current + // bridge: a reload can install the next module's bridge before this runs, + // and clearing that one would leave the app wedged with no way to notice. + if (kbClearBridgeIfCurrent(myBridge_)) { + NSError *error = nil; + KeybaseReset(&error); + } + myBridge_ = nullptr; +} +``` + +Note this also corrects the false "runs on the main thread" comment (finding iOS F2) — `Kb` declares no `methodQueue` getter, so `RCTTurboModuleManager.mm:727-728` assigns `_sharedModuleQueue`. + +- [ ] **Step 4: Sync and compile** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ + -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build +``` +Expected: `** BUILD SUCCEEDED **`. If the workspace needs pods, run `yarn ios:pod:install` from `shared/` first. + +- [ ] **Step 5: Commit** + +```bash +git add rnmodules/react-native-kb/ios/Kb.mm +git commit -m "fix(rpc): only clear the iOS bridge if it is still the installed one + +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." +``` + +--- + +### Task 2: Android — stop nulling `instance` into a permanent wedge (CRITICAL) + +**The bug.** `KbModule.kt:555` clears `instance` on destroy and **nothing restores it**. `onHostResume` (`KbModule.kt:483`) is now an empty body, and `MainApplication.kt:44` holds the `ReactHost` in an application-scoped `by lazy`, so on Android `onHostDestroy` fires when the last Activity is destroyed **while the ReactInstance, ReactContext, and its TurboModule instances survive**. `KbModule` is therefore never reconstructed and `init{}` (`KbModule.kt:298-300`) never re-runs. + +Concrete: back button to home → `onHostDestroy` → `destroy()` → `instance = null` → tap the icon again → Activity recreated, `onHostResume` does nothing → `instance` stays `null` forever → `instance?.nativeOnDataFromGo(data)` (`KbModule.kt:531`) silently discards **every** inbound message for the rest of the process. Outbound still works, so the app looks alive and never receives a reply. This is the exact case the deleted comment called out ("hit the back button to go to home screen and then tap Keybase app icon again"); the old code restarted the read loop in `onHostResume` and that line was removed with nothing replacing it. + +Second symptom: `isReactNativeRunning()` (`KbModule.kt:638`) returns `instance != null`, and `KeybasePushNotificationListenerService.kt:152-158` uses it to decide whether to show fallback notifications — so after the first Activity destroy the user gets duplicate notifications while the app is warm. + +**The fix.** `instance` is a pure gate: the JNI callee (`cpp-adapter.cpp:111` `nativeOnDataFromGo`) ignores `thiz` entirely and routes through `g_bridge`. Nulling it buys nothing and costs the wedge. Remove the clear. Bridge teardown is handled properly by Task 4 instead. + +**Files:** +- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (`destroy()`, ~line 550-566) +- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt:298-300` (`init` ordering) + +- [ ] **Step 1: Remove the instance clear in `destroy()`** + +Replace this block in `destroy()`: + +```kotlin + // The read loop outlives us and forwards to whatever `instance` holds, + // so drop ourselves from it: otherwise a torn-down module (and the + // ReactContext/Activity it pins) keeps receiving deliveries. Only clear + // if we're still the current one — a reload may have installed a newer + // module before our onHostDestroy runs. + if (instance === this) { + instance = null + } +``` + +with: + +```kotlin + // `instance` is deliberately NOT cleared here. onHostDestroy fires when + // the last Activity goes away, but the ReactInstance and this module + // survive, so init{} never re-runs and nothing would ever restore it — + // every later inbound message would be dropped for the life of the + // process (back button to home, then reopen). It is only a gate: the + // JNI callee ignores the receiver and routes through g_bridge, so a + // stale instance delivers to the correct current bridge regardless. + // Bridge teardown is handled by nativeInvalidate below. +``` + +- [ ] **Step 2: Publish `instance` last in `init`** + +`instance = this` currently escapes before `misTestDevice` is assigned, so another thread can observe a partially constructed module. Replace `init`: + +```kotlin + init { + this.reactContext = reactContext!! + misTestDevice = isTestDevice(reactContext) + Thread { cachedConstants }.start() + // Published last: the reader thread reads `instance` and must never + // see a partially constructed module. + instance = this + } +``` + +- [ ] **Step 3: Compile** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:compileDebugKotlin --offline +``` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Manual verification (user drives — do NOT drive the device yourself)** + +Ask the user to: launch the app on Android, confirm chat loads, press the **back button** to exit to the home screen, then tap the Keybase icon again and confirm the inbox still loads and a sent message round-trips. Before this fix that sequence leaves the app permanently unable to receive. + +- [ ] **Step 5: Commit** + +```bash +git add rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +git commit -m "fix(rpc): stop clearing the Android module instance on host destroy + +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." +``` + +--- + +### Task 3: iOS — clear `kbSharedInstance` so a dead module stops emitting + +**The bug.** `kbSharedInstance` (`Kb.mm:40`) is `__weak` and never cleared on invalidate, and `canEmit` (`Kb.mm:166-168`) is just `_eventEmitterCallback != nullptr`. `_eventEmitterCallback` lives on the codegen'd base (`RCTTurboModule.h:170`, set via `ObjCTurboModule::setEventEmitterCallback`) and RN **never nulls it** on invalidate. The `__weak` ref only nils at `dealloc`, which lags `invalidate` (the module is released at `RCTTurboModuleManager.mm:1102-1104`, and autorelease/other retains defer it further). + +So during a reload window a push notification (`Kb.mm:548-556`), a device token registration (`Kb.mm:534-542`), or — new in this PR — the reader's fatal `emitOnMetaEvent` (`Kb.mm:298-303`) fires into the dying runtime's invoker. The fatal path is the one that runs *precisely* when a reload or reset is already in flight. Android got this fix in commit `3f71bd9388`; iOS did not. + +**Files:** +- Modify: `rnmodules/react-native-kb/ios/Kb.mm:244-254` (`invalidate`, as rewritten by Task 1) + +**Interfaces:** +- Consumes: `kbClearBridgeIfCurrent` and the `myBridge_` ivar from Task 1. + +- [ ] **Step 1: Clear the shared instance in `invalidate`** + +In the `invalidate` body written in Task 1, add the identity-guarded clear as the first statement after the observer removal: + +```objc +- (void)invalidate { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + kbPasteImageEnabled = NO; + // RN never nulls _eventEmitterCallback on invalidate and the __weak ref + // above only nils at dealloc, which lags this call — so without an explicit + // clear, canEmit stays YES and a push notification, token registration or + // (worst) the reader's desync meta event emits into the dying runtime's + // invoker. Guarded because a reload may already have installed a newer + // module as the shared instance. + if (kbSharedInstance == self) { + kbSharedInstance = nil; + } + if (kbClearBridgeIfCurrent(myBridge_)) { + NSError *error = nil; + KeybaseReset(&error); + } + myBridge_ = nullptr; +} +``` + +- [ ] **Step 2: Compile** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ + -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build +``` +Expected: `** BUILD SUCCEEDED **` + +- [ ] **Step 3: Commit** + +```bash +git add rnmodules/react-native-kb/ios/Kb.mm +git commit -m "fix(rpc): clear kbSharedInstance on iOS invalidate + +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 3f71bd9388." +``` + +--- + +### Task 4: Android — add `nativeInvalidate` to tear down the bridge and drop the globals + +**The bug (two findings).** Android has **no `markTornDown()` call site at all** — `react-native-kb.h:44-46` documents it as the module-invalidate entry point, iOS honors it, and `KbModule.destroy()` only clears `instance` and calls `Keybase.reset()`. `g_bridge` (`cpp-adapter.cpp:53`) is replaced *only* inside the bindings installer. So on a dev reload the old bridge stays in `g_bridge` with `isTornDown_ == false`; if the new runtime's installer has not run yet (module construction and JSI binding installation are separate phases), the reader routes to the **old** bridge, passes the `isTornDown_` check, and calls `callInvoker_->invokeAsync` on the **old runtime's CallInvoker while that runtime is being destroyed** — use-after-free in RuntimeScheduler. + +Separately, `g_adapter` is a static `shared_ptr` holding `jni::global_ref` and is never cleared, so it pins the old `KbModule` and through `reactContext` the Activity — exactly what the `KbModule.kt:518-519` comment says the non-inner-class reader was designed to avoid. `g_bridge` likewise pins a dead runtime's `CallInvoker`. + +**Files:** +- Modify: `rnmodules/react-native-kb/android/cpp-adapter.cpp` (add `nativeInvalidate`, register it) +- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (declare + call it in `destroy()`) + +**Interfaces:** +- Produces: `external fun nativeInvalidate()` on `KbModule` (Kotlin), backed by `static void nativeInvalidate(jni::alias_ref)` in `cpp-adapter.cpp`. Idempotent; safe from any thread; touches only atomics and `shared_ptr` slots, never jsi handles. + +- [ ] **Step 1: Add the native invalidate to `cpp-adapter.cpp`** + +Add after the `getBridge()` helper: + +```cpp +// Any thread. Drops this module's C++-side state: flips the bridge's atomic +// teardown flag so the permanent reader stops delivering into a runtime that +// is going away, and releases the globals that would otherwise pin a dead +// KbModule (and its ReactContext/Activity) and a destroyed runtime's +// CallInvoker for the life of the process. +// +// Only atomics and shared_ptr slots are touched — the old bridge's jsi +// handles belong to its own runtime and are released by its kbTeardown host +// object on the JS thread. +static void nativeInvalidate(jni::alias_ref) { + std::shared_ptr oldBridge; + std::shared_ptr oldAdapter; + { + std::lock_guard lock(g_mutex); + oldBridge = std::move(g_bridge); + oldAdapter = std::move(g_adapter); + g_bridge = nullptr; + g_adapter = nullptr; + } + if (oldBridge) { + oldBridge->markTornDown(); + } +} +``` + +- [ ] **Step 2: Register it in the JNI registration block** + +Find the `registerNatives` call in `cpp-adapter.cpp` (the block containing `makeNativeMethod("nativeOnDataFromGo", nativeOnDataFromGo)`) and add alongside it: + +```cpp + makeNativeMethod("nativeInvalidate", nativeInvalidate), +``` + +- [ ] **Step 3: Declare and call it from Kotlin** + +Next to the existing `nativeOnDataFromGo` external declaration in `KbModule.kt`, add: + +```kotlin + private external fun nativeInvalidate() +``` + +Then in `destroy()`, immediately after the comment block Task 2 left in place of the `instance` clear, add: + +```kotlin + nativeInvalidate() +``` + +so the body reads: the explanatory comment, then `nativeInvalidate()`, then the existing `try { Keybase.reset(); relayReset() }`. + +- [ ] **Step 4: Compile both halves** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline +``` +Expected: `BUILD SUCCESSFUL`. A missing `makeNativeMethod` registration surfaces at runtime, not compile time — re-read Step 2 and confirm the method name string matches the Kotlin declaration exactly. + +- [ ] **Step 5: Commit** + +```bash +git add rnmodules/react-native-kb/android/cpp-adapter.cpp \ + rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +git commit -m "fix(rpc): tear down the Android bridge and globals on module destroy + +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." +``` + +--- + +### Task 5: Android — hold the adapter strongly in the installer + +**The bug.** The installer lambda captures only `std::weak_ptr` (`cpp-adapter.cpp:69`), and the sole strong reference is the `g_adapter` slot. When a second `getBindingsInstaller` runs it overwrites `g_adapter`, destroying the old adapter **immediately** — while `g_bridge` is still the old, still-installed bridge, because the swap only happens later (`cpp-adapter.cpp:100-101`) when the new installer actually executes on the JS thread. In that window the live runtime's `rpcOnGo` hits a failed `weakAdapter.lock()` and returns `false` for **every** send: the whole app fails its RPCs. Guaranteed with two `ReactHost`s, and reachable on any path where module construction precedes runtime destruction. + +There is no cycle to fear — `KbNativeAdapter` holds a `global_ref` and never references the bridge — so a strong capture is safe, and it makes the adapter's lifetime match the bridge that actually uses it. With Task 4 clearing `g_adapter` explicitly, the slot is no longer needed as an ownership anchor. + +**Files:** +- Modify: `rnmodules/react-native-kb/android/cpp-adapter.cpp:65-95` (`getBindingsInstaller`) + +**Interfaces:** +- Consumes: `nativeInvalidate` from Task 4 (which clears `g_adapter`). + +- [ ] **Step 1: Capture the adapter by `shared_ptr`** + +In `getBindingsInstaller`, change the lambda capture from weak to strong and drop the three `weakAdapter.lock()` dances. Replace: + +```cpp + return BindingsInstallerHolder::newObjectCxxArgs( + [weakAdapter = std::weak_ptr(adapter)]( +``` + +with: + +```cpp + // Captured strongly: the adapter must outlive the bridge that calls it, and + // the g_adapter slot is not an ownership anchor -- installing a second + // module overwrites it while the first bridge is still live and installed, + // which with a weak capture failed every rpcOnGo in that window. No cycle: + // the adapter holds a global_ref to the Java module and never the bridge. + return BindingsInstallerHolder::newObjectCxxArgs( + [adapter]( +``` + +- [ ] **Step 2: Simplify the two callbacks that used `weakAdapter`** + +Replace the `writeToGo` callback: + +```cpp + [adapter](void *ptr, size_t size) -> bool { + return adapter->writeToGo(ptr, size); + }, +``` + +and the fatal callback's body: + +```cpp + [adapter]() { + __android_log_print(ANDROID_LOG_ERROR, "KBBridge", + "rpc stream desync, resetting connection"); + adapter->onFatal(); + }); +``` + +- [ ] **Step 3: Compile** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug --offline +``` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 4: Commit** + +```bash +git add rnmodules/react-native-kb/android/cpp-adapter.cpp +git commit -m "fix(rpc): hold the Android adapter strongly in the bindings installer + +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." +``` + +--- + +### Task 6: Both platforms — tell JS when the read path resets the connection + +**The bug.** `go/bind/keybase.go:642-647`: when `conn.Read` errors, `ReadArr` calls `Reset()` itself and returns the error. The native readers just log and sleep (`Kb.mm:359-362`; `KbModule.kt:536-544`, which even special-cases `"Read error: EOF"` as expected). **Nothing emits `kb-engine-reset`.** So JS is never told, `failAllOutstanding` never runs, every in-flight RPC hangs forever, and the UI keeps its spinners. + +This is the **common** reset leg — the branch closed the desync leg and left the frequent one open. It also leaves `recv_` holding mid-frame state across a connection boundary, which then trips the desync detector on the next connection. + +**Files:** +- Modify: `rnmodules/react-native-kb/ios/Kb.mm:353-375` (reader loop error branch) +- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (`ReadFromKBLib.run` catch branch) +- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.h` (declare `resetRecv`) +- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp` (define `resetRecv`) + +**Interfaces:** +- Produces: `void KBBridge::resetRecv()` — public, any thread, takes `recvMutex_` and calls the existing private `resetRecvLocked()`. Used here and by Task 9. + +- [ ] **Step 1: Expose a public `resetRecv` on the bridge** + +In `react-native-kb.h`, in the public section directly after the `onDataFromGo` declaration: + +```cpp + // Any thread. Drops any partially parsed frame. Must be called whenever the + // Go connection is replaced, or the unpacker resumes mid-frame on a fresh + // stream and the next header check fails on valid data. + void resetRecv(); +``` + +In `react-native-kb.cpp`, add next to the other small methods (after `tearup()`): + +```cpp +void KBBridge::resetRecv() { + std::lock_guard lock(recvMutex_); + if (recv_) { + resetRecvLocked(); + } +} +``` + +- [ ] **Step 2: iOS — reset framing and notify JS on a read error** + +Replace the error branch in the reader loop (`Kb.mm:359-363`): + +```objc + if (error) { + // ReadArr already called Reset() on the Go side, so the connection + // JS thinks it has is gone and every in-flight RPC is dead. Tell + // JS so it fails them instead of spinning forever, and drop any + // half-parsed frame so the next connection starts clean. + kbLogToService([NSString + stringWithFormat:@"rpc read error, connection reset: %@", + error.localizedDescription]); + if (auto bridge = kbGetBridge()) { + bridge->resetRecv(); + } + dispatch_async(dispatch_get_main_queue(), ^{ + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnMetaEvent:metaEventEngineReset]; + } + }); + [NSThread sleepForTimeInterval:0.1]; + continue; + } +``` + +- [ ] **Step 3: Android — same, in the reader's catch** + +In `ReadFromKBLib.run`, replace the `catch (e: Exception)` body: + +```kotlin + } catch (e: Exception) { + // readArr already called Reset() on the Go side, so the + // connection JS thinks it has is gone and every in-flight + // RPC is dead. EOF is the ordinary shape of this, not a + // surprise -- but JS still has to be told, or its callers + // hang forever. + if (e.message != null && e.message.equals("Read error: EOF")) { + NativeLogger.info("Got EOF from read, connection reset.") + } else { + NativeLogger.error("Exception in ReadFromKBLib.run", e) + } + instance?.onRpcConnectionReset() + Thread.sleep(100) + } +``` + +Keep the existing `catch (e: InterruptedException)` branch ahead of it unchanged. + +- [ ] **Step 4: Android — add the Kotlin handler** + +Next to `onRpcStreamFatal` in `KbModule.kt`, add: + +```kotlin + // Called from the reader thread when readArr failed and Go reset the + // connection underneath us. Unlike onRpcStreamFatal we must NOT call + // Keybase.reset() -- ReadArr already did, and a second reset would close a + // connection a concurrent writeArr may have just dialed. + fun onRpcConnectionReset() { + nativeResetRecv() + reactContext.runOnUiQueueThread { relayReset() } + } + + private external fun nativeResetRecv() +``` + +- [ ] **Step 5: Android — back `nativeResetRecv` with JNI** + +In `cpp-adapter.cpp`, add next to `nativeInvalidate`: + +```cpp +static void nativeResetRecv(jni::alias_ref) { + if (auto bridge = getBridge()) { + bridge->resetRecv(); + } +} +``` + +and register it alongside the others: + +```cpp + makeNativeMethod("nativeResetRecv", nativeResetRecv), +``` + +- [ ] **Step 6: Compile both platforms** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline +cd ../ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ + -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build +``` +Expected: `BUILD SUCCESSFUL` and `** BUILD SUCCEEDED **` + +- [ ] **Step 7: Commit** + +```bash +git add rnmodules/react-native-kb/ +git commit -m "fix(rpc): emit engine-reset when the read path drops the connection + +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." +``` + +--- + +### Task 7: C++ — validate the frame length instead of discarding it + +**The bug.** `react-native-kb.cpp:630-643` checks that the framing prefix is a `POSITIVE_INTEGER` no larger than `kMaxFrameSize`, then **throws the value away** and lets the unpacker decode "the next object" as content. The declared length is never compared against what the content actually consumed. Two reviewers converged on this independently. + +So a truncated or over-long content frame is undetected: the unpacker reads into the following frame, and the parity check only trips later, or never — if the shifted bytes happen to begin with a positive int. After a mid-stream resync the parser can land on a `0x05` fixint *inside a string payload*, accept it as a header, and hand whatever parses next to JS as an RPC message. JS then gets a bogus `[type, seqid, …]` and may resolve the wrong seqid. The detector reports "no desync" while delivering corrupt data. + +`msgpack::unpacker::parsed_size()` (`msgpack/v1/unpack.hpp:1319`) makes this exact for the cost of one comparison. + +**Files:** +- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp:625-655` (the `while (true)` parse loop in `onDataFromGo`) + +- [ ] **Step 1: Persist the framing counters in `RecvState`** + +The counters must live in `RecvState`, not in loop locals: a frame's header and its content routinely arrive in **separate** `onDataFromGo` calls, and `recv_->state` already persists across them while a local would reset to 0 and compare against garbage. + +Extend the struct (near line 28 of `react-native-kb.cpp`): + +```cpp +struct KBBridge::RecvState { + msgpack::unpacker unpacker; + ReadState state = ReadState::needSize; + // Persist across calls: a frame's header and its content routinely arrive + // in separate reads. + size_t declaredSize = 0; + size_t sizeAtHeader = 0; +}; +``` + +Read `resetRecvLocked()` and confirm it constructs a fresh `RecvState` (which zeroes these for free). If it instead mutates fields in place, add the two resets there — do not assume. + +- [ ] **Step 2: Verify content consumed exactly the declared length** + +Replace the parse loop body: + +```cpp + while (true) { + msgpack::object_handle result; + if (!up.next(result)) { + break; + } + if (recv_->state == ReadState::needSize) { + // The framing prefix must be a msgpack uint. Anything else means + // the stream desynced; without this check the parity flips and + // every later frame is silently swallowed as a "size". + const auto &o = result.get(); + if (o.type != msgpack::type::POSITIVE_INTEGER || + o.as() > kMaxFrameSize) { + throw std::runtime_error("bad rpc frame header"); + } + recv_->declaredSize = static_cast(o.as()); + recv_->sizeAtHeader = up.parsed_size(); + recv_->state = ReadState::needContent; + } else { + // The header is only a plausibility check on its own: a fixint + // sitting inside a string payload parses as a valid header after a + // resync. Requiring the content object to consume exactly the + // declared byte count makes the framing self-checking, so a + // truncated or overlong frame is caught here instead of being + // handed to JS as a bogus [type, seqid, ...]. + const size_t consumed = up.parsed_size() - recv_->sizeAtHeader; + if (consumed != recv_->declaredSize) { + throw std::runtime_error("rpc frame length mismatch"); + } + values->push_back(std::move(result)); + recv_->state = ReadState::needSize; + } + } +``` + +- [ ] **Step 3: Syntax check** + +Run the fast C++-only check from Task 0 Step 3. +Expected: exit 0. If `parsed_size` is reported as not a member, confirm the msgpack include path resolves to `msgpack-cxx-7.0.0`. + +- [ ] **Step 4: Commit** + +```bash +git add rnmodules/react-native-kb/cpp/ +git commit -m "fix(rpc): require frame content to consume exactly the declared length + +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." +``` + +--- + +### Task 8: C++ — a conversion failure must fail loudly, not eat the batch + +**The bug.** `react-native-kb.cpp:721-725` catches everything thrown by the delivery lambda and only calls `reportError`. `onFatal_` is **not** called. Throwing paths are real: `mpToString` "Invalid map key" for a BIN/ARRAY/MAP/EXT map key, "msgpack nesting too deep" (>1024), OOM in `binaryFromBytes`, and the `"rpcOnJs is not installed"` early return. + +So one message containing a map with a non-scalar key throws while building item 3 of a 10-message batch → **all 10 messages are dropped**, including the reply to seqid N. JS's `_invocations` entry for N is never resolved and that caller hangs forever. This is exactly the bug class the branch set out to fix, and the desync path 60 lines above handles it correctly. + +Two changes: convert per-message so one bad message cannot kill nine good ones, and escalate to `onFatal_` so JS fails its outstanding RPCs. + +**Files:** +- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp:700-728` (the `invokeAsync` lambda body) + +- [ ] **Step 1: Convert each message inside its own try** + +Replace the batch branch (the `else` arm handling `values->size() > 1`): + +```cpp + } else { + // Convert per message: a single bad message (non-scalar map key, + // nesting over the limit) must not take the whole batch with it and + // strand every other reply's caller. + jsi::Array arr(runtime, values->size()); + size_t converted = 0; + for (size_t i = 0; i < values->size(); ++i) { + try { + msgpack::object obj((*values)[i].get()); + arr.setValueAtIndex(runtime, converted, + self->convertMPToJSI(runtime, &obj)); + ++converted; + } catch (const std::exception &e) { + self->reportError(std::string("dropping undecodable message: ") + + e.what()); + } + } + if (converted == 0) { + return; + } + if (self->isTornDown_.load()) { + return; + } + onJs.call(runtime, std::move(arr), + jsi::Value(static_cast(converted))); + } +``` + +Note `converted` is used as both the write index and the reported count, so a dropped message leaves no hole in the array. The JS side reads `count`, not `arr.length`. + +- [ ] **Step 2: Escalate the outer catch to fatal** + +Replace the lambda's trailing catch pair: + +```cpp + } catch (const std::exception &e) { + // Nothing decoded reached JS, so every reply in this batch is lost and + // its caller would wait forever. Treat it like a desync: reset the + // connection so JS fails its outstanding RPCs. + self->reportError(e.what()); + if (self->onFatal_) { + self->onFatal_(); + } + } catch (...) { + self->reportError("unknown error in onDataFromGo JS callback"); + if (self->onFatal_) { + self->onFatal_(); + } + } +``` + +- [ ] **Step 3: Escalate the missing-`rpcOnJs` early return too** + +Replace the `"rpcOnJs is not installed"` branch: + +```cpp + if (!onJsValue.isObject() || + !onJsValue.getObject(runtime).isFunction(runtime)) { + // These messages are already consumed from the unpacker and cannot be + // replayed, so dropping them silently would strand their callers. + self->reportError("rpcOnJs is not installed"); + if (self->onFatal_) { + self->onFatal_(); + } + return; + } +``` + +- [ ] **Step 4: Syntax check** + +Run the Task 0 Step 3 command. Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add rnmodules/react-native-kb/cpp/react-native-kb.cpp +git commit -m "fix(rpc): don't let one undecodable message strand a whole batch + +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." +``` + +--- + +### Task 9: C++ — latch after a fatal so one desync isn't a reset storm + +**The bug.** Every desynced chunk calls `onFatal_()` → `KeybaseReset()` + engine-reset meta event → JS fails all outstanding RPCs. But bytes already in flight from the pre-reset stream keep arriving on the reader thread and keep failing the header check, so a single desync produces N resets in a row, each nuking whatever JS re-issued in between. Go's read buffer is 300KB (`keybase.go:348`), so a 1MB backlog is ~4 iterations. + +**Files:** +- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.h` (add the latch member) +- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp` (set it on fatal, clear it on reset) + +**Interfaces:** +- Consumes: `KBBridge::resetRecv()` from Task 6 — clearing the latch is what lets delivery resume. + +- [ ] **Step 1: Add the latch member** + +In `react-native-kb.h`, next to `isTornDown_`: + +```cpp + // Set when the incoming stream desynced and the connection is being reset. + // Bytes already in flight from the dead stream keep arriving and would each + // trigger another reset, so drop them until the platform layer confirms the + // connection was replaced (resetRecv). + std::atomic awaitingReset_{false}; +``` + +- [ ] **Step 2: Drop data while latched, and set the latch on fatal** + +In `onDataFromGo`, extend the entry guard: + +```cpp +void KBBridge::onDataFromGo(uint8_t *data, int size) { + if (isTornDown_.load() || awaitingReset_.load() || size <= 0 || + data == nullptr) { + return; + } +``` + +and in the `if (fatal)` block, set the latch before invoking `onFatal_`: + +```cpp + if (fatal) { + reportError(fatalMsg); + // The stream can no longer be trusted, so anything decoded in this batch + // is dropped. The platform layer resets the Go connection and signals JS + // so outstanding RPCs fail instead of hanging forever. Latch until that + // reset lands: the rest of the dead stream is still in flight and would + // otherwise trigger a reset per chunk, each one failing whatever JS just + // re-issued. + awaitingReset_.store(true); + if (onFatal_) { + onFatal_(); + } + return; + } +``` + +- [ ] **Step 3: Clear the latch in `resetRecv`** + +Update the `resetRecv` added in Task 6: + +```cpp +void KBBridge::resetRecv() { + { + std::lock_guard lock(recvMutex_); + if (recv_) { + resetRecvLocked(); + } + } + // The connection has been replaced, so the in-flight remnants of the old + // stream are gone and it is safe to deliver again. + awaitingReset_.store(false); +} +``` + +- [ ] **Step 4: Make the platform fatal paths clear the latch** + +The latch is only cleared by `resetRecv`, so both fatal handlers must call it after their `KeybaseReset`. In `Kb.mm`'s fatal callback, after the `KeybaseReset(&error)` block and before the `dispatch_async`: + +```objc + if (auto b = kbGetBridge()) { + b->resetRecv(); + } +``` + +In `KbModule.kt`'s `onRpcStreamFatal`, after the `Keybase.reset()` try block: + +```kotlin + nativeResetRecv() +``` + +- [ ] **Step 5: Compile both platforms** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline +cd ../ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ + -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build +``` +Expected: both succeed. + +- [ ] **Step 6: Commit** + +```bash +git add rnmodules/react-native-kb/ +git commit -m "fix(rpc): latch after a desync so one bad frame is one reset + +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 the connection was replaced." +``` + +--- + +### Task 10: TS — wire the mobile account-switch reset, or delete the dead method + +**The bug.** `index.platform.tsx` adds `NativeTransportMobile.reset()` and wires it to the `kb-engine-reset` meta event, and its comment claims it covers "engine reset". But `Engine.reset()` early-returns on mobile (`shared/engine/index.tsx:305-307`), the account-switch call sites (`shared/constants/init/index.tsx:310, 317`) go through `Engine.reset()`, and the native `engineReset` TurboModule method has **no caller in current source** — the only hits are a stale `coverage-ts` artifact and the module's own export. + +So on a mobile account switch, pre-switch outstanding invocations are never failed and their callbacks can fire against post-switch state — precisely what `ProxyNativeTransport.reset()` exists to prevent on desktop. + +Two reviewers found this independently. Resolve it rather than leaving a claim with no caller. + +**Files:** +- Modify: `shared/engine/index.tsx:305-317` (`Engine.reset`) +- Test: `shared/engine/rpc-transport.test.ts` + +**Interfaces:** +- Consumes: `LocalTransport.reset()` / `failAllOutstanding()` (existing). + +- [ ] **Step 1: Read the current mobile early-return** + +Run: +```bash +cd shared && sed -n '295,325p' engine/index.tsx +``` +Confirm the exact shape of the mobile guard before editing — it must keep skipping the desktop-only socket teardown while still resetting the transport. + +- [ ] **Step 2: Write the failing test** + +Add to `shared/engine/rpc-transport.test.ts`: + +```ts +test('reset fails every outstanding invocation exactly once', () => { + const transport = new TestTransport() + const calls: Array = [] + transport.invoke({method: 'a', param: {}}, (err: unknown) => calls.push(err)) + transport.invoke({method: 'b', param: {}}, (err: unknown) => calls.push(err)) + + transport.reset() + + expect(calls).toHaveLength(2) + expect(calls.every(e => e instanceof Error)).toBe(true) + + // A second reset must not re-fail anything already failed. + transport.reset() + expect(calls).toHaveLength(2) +}) + +test('seqids keep advancing across a reset so a late reply cannot alias', () => { + const transport = new TestTransport() + const seen: Array = [] + transport.invoke({method: 'a', param: {}}, () => {}) + seen.push(transport.lastSeqidForTest) + transport.reset() + transport.invoke({method: 'b', param: {}}, () => {}) + seen.push(transport.lastSeqidForTest) + + expect(seen[1]).toBeGreaterThan(seen[0]!) +}) +``` + +If `TestTransport` does not already expose the last seqid, add a minimal accessor to the test double in that file rather than to production code — read the existing `TestTransport` definition first and match its style. + +- [ ] **Step 3: Run the tests to see them fail or pass** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts +``` +Expected: the exactly-once test **passes** (the map-swap in `failOutstanding` already guarantees it — this is a regression guard, and a guard that passes on first run is doing its job). The seqid test should also pass. If either fails, that is a real defect — stop and report it before continuing. + +- [ ] **Step 4: Make `Engine.reset()` reset the mobile transport** + +In `shared/engine/index.tsx`, change the mobile early-return so it still resets the transport. Using the exact surrounding code you read in Step 1, the mobile branch must call the transport's `reset()` before returning, e.g.: + +```ts + reset() { + // Mobile has no socket to tear down and no reconnect to wait for, but the + // in-flight invocations still have to be failed: after an account switch + // nothing will answer them, and their callbacks would otherwise fire + // against post-switch state. + this._rpcClient.transport.reset() + if (isMobile) { + return + } + // ...existing desktop teardown unchanged... + } +``` + +Match the real property names in that file (`_rpcClient` vs `_client` etc.) — do not guess. + +- [ ] **Step 5: Delete the dead native `engineReset`, or wire it** + +`engineReset` has no caller. Deleting it spans the TurboModule spec and both platform implementations, which is a wider blast radius than this plan's other tasks. **Do not delete it silently.** Instead: + +Run: +```bash +cd /Users/chrisnojima/go/src/github.com/keybase/client && \ + grep -rn 'engineReset' --include=*.ts --include=*.tsx --include=*.mm --include=*.kt --include=*.cpp \ + shared/ rnmodules/ | grep -v node_modules | grep -v coverage-ts +``` + +Report the result to the user and ask whether to remove it or wire the account-switch path to it. Until they answer, fix the misleading comment on `NativeTransportMobile.reset()` in `index.platform.tsx` so it stops claiming coverage it does not have: + +```ts + // The Go connection can be reset underneath us (a stream desync detected + // natively, or the read path losing the connection). Nothing will answer + // the in-flight RPCs after that, so fail them rather than hang every caller. + override reset() { + this.failAllOutstanding() + } +``` + +- [ ] **Step 6: Validate** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts && yarn lint:all +``` +Expected: tests pass; lint, bailouts, and tsc all clean (0 bailouts). + +- [ ] **Step 7: Commit** + +```bash +git add shared/engine/index.tsx shared/engine/index.platform.tsx shared/engine/rpc-transport.test.ts +git commit -m "fix(engine): fail in-flight RPCs on a mobile account switch + +Engine.reset() early-returned on mobile, so the account-switch path never +reached the transport and pre-switch invocations were never failed -- their +callbacks could fire against post-switch state, which is exactly what +ProxyNativeTransport.reset() prevents on desktop. + +Add regression tests for exactly-once failure and for seqids continuing to +advance across a reset, since restarting the counter would let a late reply +from the dead connection alias a fresh invocation." +``` + +--- + +### Task 11: TS — an app-code throw must not wipe the packetizer (desktop) + +**The bug.** `rpc-transport.tsx:329` dispatches **inside** the parse loop's `try`, and the `catch` at `:331-334` calls `p.reset()`, discarding all buffered bytes. `_onEngineIncoming` (`engine/index.tsx:220-222`) is unguarded, so any app-code handler that throws unwinds out of `dispatchDecodedMessage` into that catch and wipes the packetizer **mid-stream**. The socket keeps delivering the rest of the frame, parsing resumes at an arbitrary byte offset → `Bad frame header` → reset → repeat. On the renderer (`ProxyNativeTransport`) there is no socket to reconnect, so it is terminal until app restart. + +This is the same failure mode the branch fixed for mobile, still live on desktop. + +**Files:** +- Modify: `shared/engine/rpc-transport.tsx:320-335` (`packetizeData`) +- Test: `shared/engine/rpc-transport.test.ts` + +- [ ] **Step 1: Read the current `packetizeData`** + +Run: +```bash +cd shared && sed -n '300,340p' engine/rpc-transport.tsx +``` +Note the exact variable names (`p`, `payload`) before editing. + +- [ ] **Step 2: Write the failing test** + +Add to `shared/engine/rpc-transport.test.ts`: + +```ts +test('a throwing incoming handler does not desync the packetizer', () => { + const delivered: Array = [] + let shouldThrow = true + const transport = new TestTransport() + transport.setIncomingHandler((msg: unknown) => { + if (shouldThrow) { + shouldThrow = false + throw new Error('app handler blew up') + } + delivered.push(msg) + }) + + // Two well-formed frames arriving in a single chunk. The first handler + // throws; the second frame must still be parsed and delivered. + transport.feedRawForTest(encodeTwoFramesForTest()) + + expect(delivered).toHaveLength(1) +}) +``` + +`TestTransport` almost certainly lacks `setIncomingHandler` / `feedRawForTest` / `encodeTwoFramesForTest`. Read the existing test file first and build these against whatever the real transport exposes — the required behavior is: feed two concatenated framed messages in one buffer, have the first handler throw, assert the second still arrives. If the existing `TestTransport` cannot feed raw bytes, construct a `ProxyNativeTransport` (or the shared base) directly in the test and call its packetize entry point. + +- [ ] **Step 3: Run it and watch it fail** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts -t 'does not desync the packetizer' +``` +Expected: FAIL — `delivered` is empty, because the throw reset the packetizer and the second frame's bytes were discarded. + +- [ ] **Step 4: Isolate the dispatch** + +In `packetizeData`, move the dispatch out of the framing `try` so only genuine decode/framing errors reach the reset path: + +```ts + // Dispatch outside the framing try: an app-side handler that throws must + // not reach the catch below, which resets the packetizer and discards + // every buffered byte. That leaves parsing to resume at an arbitrary + // offset -- and on the renderer transport there is no socket to + // reconnect, so it never recovers. + try { + this.dispatchDecodedMessage(payload) + } catch (e) { + logger.error('dispatchDecodedMessage threw', e) + } +``` + +Keep the outer `catch` that calls `p.reset()` for decode/framing errors only. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts +``` +Expected: PASS, all tests. + +- [ ] **Step 6: Validate and commit** + +```bash +cd shared && yarn lint:all +``` +Expected: clean, 0 bailouts. + +```bash +git add shared/engine/rpc-transport.tsx shared/engine/rpc-transport.test.ts +git commit -m "fix(engine): app-code throws must not reset the packetizer + +dispatchDecodedMessage ran inside the framing try, so any unguarded handler +throw unwound into the catch that resets the packetizer and drops every +buffered byte. Parsing then resumed mid-frame, producing Bad frame header +forever -- terminal on the renderer transport, which has no socket to +reconnect. Same failure mode the mobile side of this branch fixed." +``` + +--- + +### Task 12: TS — a throwing incoming-invoke handler must answer the call + +**The bug.** The per-message catch in `index.platform.tsx` swallows the throw and moves on, but the `response` object created at `rpc-transport.tsx:351-355` is then never settled. A custom-response handler that throws — e.g. `chat.1.chatUi.chatWatchPosition` (`engine/index.tsx:26`) — leaves Go's RPC waiting on a reply that will never be sent: a permanent per-call hang. + +The per-message catch is the right shape (failing the whole batch would drop messages N+1..M); it is just incomplete. + +**Files:** +- Modify: `shared/engine/rpc-transport.tsx:345-365` (`dispatchDecodedMessage`, `MESSAGE_TYPE_INVOKE` case) +- Test: `shared/engine/rpc-transport.test.ts` + +- [ ] **Step 1: Read the invoke case** + +Run: +```bash +cd shared && sed -n '340,370p' engine/rpc-transport.tsx +``` +Note how `payload.response` is built and which error constructor is in scope (`makeTransportError` or similar). + +- [ ] **Step 2: Write the failing test** + +```ts +test('a throwing invoke handler still answers the caller with an error', () => { + const sent: Array = [] + const transport = new TestTransport() + transport.captureSentForTest(sent) + transport.setIncomingHandler(() => { + throw new Error('handler blew up') + }) + + transport.dispatchDecodedMessage(makeInvokeFrameForTest({seqid: 7, method: 'x'})) + + // Something must go back for seqid 7 -- otherwise the service waits forever. + expect(sent).toHaveLength(1) + expect(JSON.stringify(sent[0])).toContain('7') +}) +``` + +Build `captureSentForTest` / `makeInvokeFrameForTest` against the real transport surface, following the existing file's conventions. + +- [ ] **Step 3: Run it and watch it fail** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts -t 'still answers the caller' +``` +Expected: FAIL — `sent` is empty. + +- [ ] **Step 4: Answer the call on throw** + +In the `MESSAGE_TYPE_INVOKE` case, wrap the handler invocation: + +```ts + try { + this._incomingRPCCallback(payload) + } catch (e) { + // The handler threw, so nothing will answer this seqid and the + // service side waits forever. Fail it explicitly. + logger.error('incoming invoke handler threw', e) + payload.response?.error?.(makeTransportError('UNKNOWN_METHOD')) + } +``` + +Use the error constructor actually in scope, matching the file's existing usage. + +- [ ] **Step 5: Run to verify it passes** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts +``` +Expected: PASS + +- [ ] **Step 6: Validate and commit** + +```bash +cd shared && yarn lint:all +``` + +```bash +git add shared/engine/rpc-transport.tsx shared/engine/rpc-transport.test.ts +git commit -m "fix(engine): answer the caller when an incoming invoke handler throws + +The per-message catch swallowed the throw but never settled the response, so +a throwing custom-response handler left the service waiting on a reply that +never came -- a permanent per-call hang. Keep the per-message shape (failing +the batch would drop the remaining messages) and error the response instead." +``` + +--- + +### Task 13: TS — reset must report the disconnect, and clear the packetizer + +**Two bugs, same code path.** + +First: the `kb-engine-reset` handler calls `transport.reset()` then `connectCallback()`, but never `disconnectCallback()`. So `Engine._onDisconnect()` (`engine/index.tsx:114-123`) never runs — `_cancelOutstandingSessions()` is skipped and `_onConnectedCB(false)` never fires, so `onEngineDisconnected` (`constants/init/shared.tsx:294-300`) never sets the daemon error and the UI never shows the reconnect state. Sessions survive mostly by luck (each session's `start()` invoke is outstanding, so `failAllOutstanding` → `wrappedCallback` → `session.end()`), but any session not currently holding an outstanding invocation is leaked, and a pending custom-response prompt is left on screen bound to a dead seqid — `response.result()` then writes an unknown seqid into the *new* connection and silently does nothing. The desktop socket path does `onDisconnected()` → `onConnected()`; mobile should match. + +Second: `failAllOutstanding()` (`rpc-transport.tsx:471-473`) fails invocations but leaves `_packetizer` holding a partial frame, unlike `close()` (`:456-461`) and `onDisconnected()` (`:239-243`), both of which reset it. Dead weight on mobile (no packetizer) but live on `ProxyNativeTransport.reset()` — the account-switch path — where a half-delivered pre-switch frame concatenates with post-switch bytes into one corrupt decode. + +**Files:** +- Modify: `shared/engine/index.platform.tsx` (`onMetaEvent` handler) +- Modify: `shared/engine/rpc-transport.tsx:465-475` (`failAllOutstanding`) +- Test: `shared/engine/rpc-transport.test.ts` + +- [ ] **Step 1: Write the failing test for the packetizer reset** + +```ts +test('failAllOutstanding clears buffered frame bytes', () => { + const transport = new TestTransport() + // Feed half a frame, then reset, then feed a complete frame. The stale half + // must not be prepended to the new bytes. + transport.feedRawForTest(halfFrameForTest()) + transport.failAllOutstanding() + + const delivered: Array = [] + transport.setIncomingHandler((m: unknown) => delivered.push(m)) + transport.feedRawForTest(completeFrameForTest()) + + expect(delivered).toHaveLength(1) +}) +``` + +Reuse the raw-feed helpers built in Task 11. + +- [ ] **Step 2: Run it and watch it fail** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts -t 'clears buffered frame bytes' +``` +Expected: FAIL — the stale half-frame corrupts the following decode. + +- [ ] **Step 3: Reset the packetizer in `failAllOutstanding`** + +```ts + failAllOutstanding() { + // Also drop any partial frame: on the renderer transport this runs on the + // account switch, and a half-delivered pre-switch frame would concatenate + // with post-switch bytes into one corrupt decode. + this._packetizer.reset() + // ...existing invocation-failing logic unchanged... + } +``` + +Match the real field name (`_packetizer` vs `_p`) and the reset method the sibling `close()`/`onDisconnected()` already call. + +- [ ] **Step 4: Run to verify it passes** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts +``` +Expected: PASS + +- [ ] **Step 5: Report the disconnect before the reconnect** + +In `index.platform.tsx`'s `onMetaEvent` handler: + +```ts + case 'kb-engine-reset': + // Go dropped the loopback connection; anything in flight is dead. + // Report the disconnect before the reconnect so the engine cancels + // its sessions and the UI shows the reconnect state -- the desktop + // socket path does the same pair. + client.transport.reset() + disconnectCallback() + connectCallback() +``` + +- [ ] **Step 6: Validate** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts && yarn lint:all +``` +Expected: tests pass; lint/bailouts/tsc clean. + +- [ ] **Step 7: Manual verification (user drives)** + +Ask the user to trigger a mobile engine reset (kill and restart the keybase service while the app is foregrounded) and confirm the app shows the disconnected state and then recovers, rather than sitting on stale spinners. + +- [ ] **Step 8: Commit** + +```bash +git add shared/engine/index.platform.tsx shared/engine/rpc-transport.tsx shared/engine/rpc-transport.test.ts +git commit -m "fix(engine): report the disconnect on a mobile engine reset + +The reset handler called connectCallback without ever calling +disconnectCallback, so _onDisconnect never ran: sessions were not cancelled +and the daemon error was never set, leaving the UI with no reconnect state. A +pending prompt stayed on screen bound to a dead seqid, and answering it wrote +an unknown seqid into the new connection. + +Also clear the packetizer in failAllOutstanding, matching close() and +onDisconnected() -- on the renderer transport this runs on the account switch, +where a half-delivered frame would corrupt the first post-switch decode." +``` + +--- + +### Task 14: Android — clear the pending JNI exception on allocation failure + +**The bug.** `cpp-adapter.cpp:28-31`: if `NewByteArray` fails (OOM on a large attachment payload) it returns null **and leaves `OutOfMemoryError` pending on the JS thread**. The code returns `false` without `ExceptionClear()`, so control returns through `packAndSend` → `rpcOnGo` returns `Value(false)` to JS, and the JS thread continues making further JNI calls with an exception pending: undefined behavior, and a guaranteed abort under CheckJNI. + +Also worth fixing while here: if `method(...)` throws (fbjni translating a pending Java exception into `JniException`), `DeleteLocalRef(jba)` is skipped. Using the RAII `local_ref` form removes both the leak and the manual delete. + +**Files:** +- Modify: `rnmodules/react-native-kb/android/cpp-adapter.cpp:26-40` (`KbNativeAdapter::writeToGo`) + +- [ ] **Step 1: Rewrite `writeToGo` with RAII and an exception clear** + +```cpp + bool writeToGo(void *ptr, size_t size) { + jni::ThreadScope scope; + auto env = jni::Environment::current(); + auto jba = env->NewByteArray(size); + if (jba == nullptr) { + // NewByteArray leaves OutOfMemoryError pending. Returning with it still + // set means the JS thread makes its next JNI call with a live exception + // -- UB, and an abort under CheckJNI. + env->ExceptionClear(); + return false; + } + // Adopt into a local_ref so the ref is released even if the call below + // throws a JniException. + auto arr = jni::adopt_local(static_cast(jba)); + env->SetByteArrayRegion(jba, 0, size, (jbyte *)ptr); + static auto method = + JKbModule::javaClassStatic() + ->getMethod)>("rpcOnGo"); + auto ok = method(jModule_, arr); + return ok != JNI_FALSE; + } +``` + +- [ ] **Step 2: Compile** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug --offline +``` +Expected: `BUILD SUCCESSFUL`. If `adopt_local` does not accept that cast, check the fbjni version's signature in `shared/node_modules/react-native/ReactAndroid/src/main/jni/first-party/fbjni` and adapt — the requirement is an owning local ref, not that specific spelling. + +- [ ] **Step 3: Commit** + +```bash +git add rnmodules/react-native-kb/android/cpp-adapter.cpp +git commit -m "fix(rpc): clear the pending JNI exception when NewByteArray fails + +NewByteArray leaves OutOfMemoryError pending on failure. Returning false +without clearing it meant the JS thread's next JNI call ran with a live +exception -- UB, and an abort under CheckJNI. Adopt the array into a +local_ref so the ref is also released if rpcOnGo throws." +``` + +--- + +### Task 15: Go — make a short write fatal instead of silently truncating + +**Context and scope limit.** `keybase.go:586-593`: `currentConn.Write` can return `n != len(bytes)` **after** delivering `n` bytes. `rpcOnGo` returns `false` and JS fails that one RPC, but the server-side framer now holds a truncated frame and the next write is consumed as its remainder — every subsequent outbound RPC is garbage, indefinitely, with no reset and no desync signal (the new fatal machinery is inbound-only). + +**This branch is dead today**: `LoopbackConn.Write` (`go/libkb/loopback.go:166-174`) is all-or-nothing (`lc.ch <- b`, returns `len(b)`), and both other `WriteArr` failure modes leave zero bytes in the stream. One reviewer initially rated this HIGH; a second disproved reachability. Fix it as **defense-in-depth**, and do not describe it as a live bug. + +**Files:** +- Modify: `go/bind/keybase.go:580-595` (`WriteArr`) + +- [ ] **Step 1: Read the current write path** + +Run: +```bash +sed -n '550,600p' go/bind/keybase.go +``` +Confirm the exact variable names and the existing error style before editing. + +- [ ] **Step 2: Reset the connection on a partial write** + +In the short-write branch, reset before returning so a truncated frame cannot survive into the next write: + +```go + if n != len(bytes) { + // Not reachable through LoopbackConn today, whose Write is + // all-or-nothing. If a future transport can short-write, the peer's + // framer is left holding a partial frame and every later write would + // be consumed as its remainder, so drop the connection rather than + // corrupt the stream indefinitely. + Reset() + return fmt.Errorf("keybase: short write %d of %d", n, len(bytes)) + } +``` + +Match the file's existing error-construction style (`fmt.Errorf` vs `errors.New`) and confirm `Reset()` is callable from this scope without deadlocking against a lock `WriteArr` already holds — read the surrounding locking before committing to this shape. + +- [ ] **Step 3: Build and vet** + +Run: +```bash +cd go && go build ./bind/... && go vet ./bind/... +``` +Expected: no output, exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add go/bind/keybase.go +git commit -m "fix(bind): reset the connection on a partial WriteArr + +Not reachable through LoopbackConn, whose Write is all-or-nothing, but if a +short write ever happened the peer's framer would hold a truncated frame and +consume every later write as its remainder -- corrupting the outbound stream +indefinitely with no reset and no desync signal, since the stream-fatal +machinery is inbound-only." +``` + +--- + +### Task 16: Correct the comments that assert invariants the code doesn't have + +Three reviewers independently flagged that this design lives in its comments, and that two of them are wrong. A future maintainer reasoning from them will reach false conclusions. + +**Wrong claim 1 — "ReadArr returns nil when idle, so we must sleep or spin a core."** `ReadArr` **blocks**: `LoopbackConn.Read` (`go/libkb/loopback.go:120`) parks on `<-lc.partnerCh`. The `(nil, nil)` return at `keybase.go:650` needs `n == 0 && err == nil`, which a blocking read essentially never produces. The 10ms sleep is dead code in practice — harmless, but the stated rationale is false, and the one way to actually hit it (buffer never allocated because `Init` did not run) turns into a permanent silent 100Hz spin. Three of four reviewers who checked agreed; the fourth confirmed only that the code line exists, not that it is the idle path. + +**Wrong claim 2 — "`invalidate` runs on the main thread."** Already corrected by Task 1; verify it landed. + +**Files:** +- Modify: `rnmodules/react-native-kb/ios/Kb.mm:364-369` +- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (`ReadFromKBLib.run` empty-data branch) + +- [ ] **Step 1: Fix the iOS comment and make the degenerate case visible** + +```objc + if (data.length == 0) { + // Not the idle path: ReadArr blocks in LoopbackConn.Read until + // there is data, so an empty non-error result is degenerate (it + // needs n == 0 with no error, which a blocking read does not + // produce). It is reachable if Init never ran and the shared + // buffer is zero-length, which would otherwise spin silently. + kbLogToService(@"rpc read returned no data; is Keybase initialized?"); + [NSThread sleepForTimeInterval:0.01]; + continue; + } +``` + +- [ ] **Step 2: Fix the Android comment** + +```kotlin + if (data == null || data.isEmpty()) { + // Not the idle path: readArr blocks until there is + // data, so an empty non-error result is degenerate -- + // reachable if Init never ran and the shared buffer is + // zero-length, which would otherwise spin silently. + NativeLogger.warn("$NAME: read returned no data; is Keybase initialized?") + Thread.sleep(10) + continue + } +``` + +- [ ] **Step 3: Verify the Task 1 thread comment landed** + +Run: +```bash +grep -n 'main thread' rnmodules/react-native-kb/ios/Kb.mm +``` +Expected: no hit inside `invalidate`. If one remains, correct it to "TurboModule shared method queue — any thread". + +- [ ] **Step 4: Compile both platforms** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:compileDebugKotlin --offline +cd ../ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ + -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build +``` +Expected: both succeed. + +- [ ] **Step 5: Commit** + +```bash +git add rnmodules/react-native-kb/ +git commit -m "docs(rpc): correct the reader-loop idle comments + +ReadArr blocks in LoopbackConn.Read; it does not poll and return nil when +idle, so the sleep was justified by a rationale the code does not have. The +empty-data branch is actually a degenerate case reachable only if Init never +ran and the shared buffer is zero-length -- log it instead of spinning +silently." +``` + +--- + +### Task 17: Observability — make a field log-send show what happened + +**The gap.** On a desync, a dropped frame, a failed write, or a reader wedge there is currently no counter and, on Android, no uploadable log line at all: the C++ `onError` and desync messages go to `__android_log_print` (logcat only), while iOS routes through `kbLogToService`. JS write failures use `console.warn`, not `logger`. Every incident is a single line with no rate signal, and a reader wedge is entirely unobservable. + +**Files:** +- Modify: `rnmodules/react-native-kb/android/cpp-adapter.cpp` (route `onError` and the desync message through Kotlin) +- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (add the log sink) +- Modify: `shared/engine/rpc-transport.tsx` (use `logger`, not `console.warn`) + +- [ ] **Step 1: Add a Kotlin log sink callable from JNI** + +In `KbModule.kt`, next to `onRpcStreamFatal`: + +```kotlin + // Called from JNI. Routes native bridge errors into the uploadable log -- + // __android_log_print only reaches logcat, which a field log send does not + // include. + @DoNotStrip + fun onNativeLog(message: String) { + NativeLogger.error("$NAME: $message") + } +``` + +- [ ] **Step 2: Call it from the C++ error and fatal callbacks** + +In `KbNativeAdapter`, add: + +```cpp + void onLog(const std::string &message) { + jni::ThreadScope scope; + static auto method = + JKbModule::javaClassStatic() + ->getMethod)>("onNativeLog"); + method(jModule_, jni::make_jstring(message)); + } +``` + +and in `getBindingsInstaller`, replace the `onError` callback body and add the same to the fatal callback (both now capture `adapter` strongly, per Task 5): + +```cpp + [adapter](const std::string &err) { + __android_log_print(ANDROID_LOG_ERROR, "KBBridge", + "JSI error: %s", err.c_str()); + adapter->onLog("jsi error: " + err); + }, +``` + +```cpp + [adapter]() { + __android_log_print(ANDROID_LOG_ERROR, "KBBridge", + "rpc stream desync, resetting connection"); + adapter->onLog("rpc stream desync, resetting connection"); + adapter->onFatal(); + }); +``` + +- [ ] **Step 3: Route the JS write failure through `logger`** + +In `rpc-transport.tsx`, find the `console.warn` on the write-failure path and replace it with the module's `logger.error`, matching how the rest of the file logs. Run: + +```bash +cd shared && grep -n 'console.warn' engine/rpc-transport.tsx +``` +and convert each hit on an error path. + +- [ ] **Step 4: Compile and validate** + +Run: +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline +cd .. && yarn lint:all +``` +Expected: build succeeds; lint/bailouts/tsc clean. + +- [ ] **Step 5: Commit** + +```bash +git add rnmodules/react-native-kb/android/ shared/engine/rpc-transport.tsx +git commit -m "fix(rpc): route Android native bridge errors into the uploadable log + +The C++ onError and desync messages only reached logcat, which a field log +send does not include -- so the two failure modes this branch added detection +for were invisible in exactly the reports that need them. iOS already routed +through kbLogToService. Also move the JS write-failure warn onto logger so it +appears in a log send." +``` + +--- + +### Task 18: C++ — release the receive buffer's peak, and fix the size-limit off-by-header + +**Two small bugs.** + +`msgpack::unpacker` only rewinds in place (`msgpack/v1/unpack.hpp:1128-1136`); it never shrinks the `realloc`'d buffer. The send side handles this explicitly (`kSendBufKeepCapacity = 4MB`), the receive side has no equivalent, so one large attachment frame pins up to 64MB of native RSS for the rest of the session on a phone. + +Separately, the limit check compares `up.nonparsed_size() > kMaxFrameSize`, but `nonparsed_size()` includes the header bytes plus any bytes of the *following* frame, while the header check accepts a declared size of exactly `kMaxFrameSize`. A legal maximal frame arriving in 300KB chunks therefore trips the fatal path on its last chunk. + +**Files:** +- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp` (`onDataFromGo`) + +- [ ] **Step 1: Give the size limit headroom** + +Replace the limit check: + +```cpp + // nonparsed_size() includes the frame header and any bytes of the next + // frame already in the buffer, while the header check accepts a declared + // size of exactly kMaxFrameSize -- so compare with headroom or a legal + // maximal frame trips this on its last chunk. + if (up.nonparsed_size() > kMaxFrameSize + kMaxFrameSlack) { + throw std::runtime_error("rpc frame exceeds size limit"); + } +``` + +and define the slack next to `kMaxFrameSize`: + +```cpp +// Header bytes plus whatever of the following frame arrived in the same read. +static constexpr size_t kMaxFrameSlack = 1024 * 1024; +``` + +- [ ] **Step 2: Shrink the receive buffer at a safe resync point** + +After the drain loop, still under `recvMutex_`, add: + +```cpp + // msgpack::unpacker rewinds but never shrinks, so one large frame would + // pin its peak for the rest of the session. needSize with nothing + // unparsed is a provably safe point to start over: no partial frame and + // no state to lose. + if (recv_->state == ReadState::needSize && up.nonparsed_size() == 0 && + up.parsed_size() > kRecvBufKeepCapacity) { + resetRecvLocked(); + } +``` + +with, next to the other constants: + +```cpp +static constexpr size_t kRecvBufKeepCapacity = 4 * 1024 * 1024; +``` + +Read `resetRecvLocked()` before relying on it — confirm it constructs a fresh `unpacker` (which is what actually frees the buffer) rather than only resetting flags. If it does not, this task must add that. + +- [ ] **Step 3: Syntax check** + +Run the Task 0 Step 3 command. +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add rnmodules/react-native-kb/cpp/react-native-kb.cpp +git commit -m "fix(rpc): release the receive buffer peak and fix the size-limit margin + +msgpack::unpacker rewinds but never shrinks, so a single large attachment +frame pinned up to 64MB of native RSS for the session -- the send side +already caps its retained capacity. Start over at a needSize boundary with +nothing unparsed, which is a safe resync point. + +Also give the limit check headroom: nonparsed_size() includes the header and +any of the next frame already buffered, so a legal maximal frame tripped the +fatal path on its last chunk." +``` + +--- + +### Task 19: Go — return a copy from `ReadArr` + +**Rationale.** The permanent-reader design is correct and stays regardless. But `ReadArr` returning `buffer[0:n]` — a view of one 300KB package global (`keybase.go:348, 595, 639`) — makes "exactly one reader, forever" a *load-bearing* invariant enforced only by comments across three languages. gomobile already copies at the JNI/ObjC boundary, so returning a fresh slice costs one memcpy of a few KB on a path already doing a cross-language round trip, and converts a silent-corruption footgun into a non-issue. + +The architecture reviewer recommended this explicitly as the one cheap Go-side hardening worth taking. It is last because nothing else depends on it. + +**Files:** +- Modify: `go/bind/keybase.go:636-640` (`ReadArr` return) + +- [ ] **Step 1: Read the current return** + +Run: +```bash +sed -n '625,655p' go/bind/keybase.go +``` + +- [ ] **Step 2: Return a copy** + +```go + // Copy rather than returning a view of the shared buffer. gomobile copies + // at the language boundary anyway, so this costs one memcpy of a few KB on + // a call that is already crossing into ObjC/JNI -- and it means a second + // reader can no longer silently corrupt an in-flight delivery, instead of + // that being an invariant held up only by comments in three languages. + out := make([]byte, n) + copy(out, buffer[:n]) + return out, nil +``` + +Delete the now-stale "Returning a view of the shared buffer…" comment above it. + +- [ ] **Step 3: Build and vet** + +Run: +```bash +cd go && go build ./bind/... && go vet ./bind/... +``` +Expected: exit 0, no output. + +- [ ] **Step 4: Update the serial-access comment** + +The `ReadArr` doc comment still says it must be called serially. That remains true for the `conn.Read` ordering, but is no longer true for buffer aliasing. Update it to say exactly which part still requires serialization, so a future reader does not conclude the whole constraint was lifted. + +- [ ] **Step 5: Commit** + +```bash +git add go/bind/keybase.go +git commit -m "fix(bind): return a copy from ReadArr instead of a shared-buffer view + +ReadArr handed back a view of one package-global buffer, making 'exactly one +reader, forever' a load-bearing invariant enforced only by comments across +Go, ObjC and Kotlin. gomobile copies at the language boundary regardless, so +a fresh slice costs one memcpy of a few KB on a call already crossing into +JNI/ObjC, and a stray second reader can no longer corrupt an in-flight +delivery." +``` + +--- + +### Task 20: Harden the `rpcOnJs` batch dispatch edges + +**Two small TS bugs.** + +`index.platform.tsx` handles `count > 1 && Array.isArray(objs)`, but a non-array with `count > 1` now falls through to `dispatchOne(objs)` — one "Bad input packet" warning and `count - 1` messages vanish. Native never produces this (the C++ always builds an array when `size() > 1`), so it is defensive only, but it should be loud rather than silent. + +Separately, three call sites log the identical string `'>>>> rpcOnJs JS thrown!'` — including the unrelated renderer path — which makes a real log dump ambiguous about which guard fired. + +**Files:** +- Modify: `shared/engine/index.platform.tsx` (the `rpcOnJs` assignment and the three log sites) + +- [ ] **Step 1: Make the count/shape mismatch loud and give each guard a distinct message** + +```ts + const dispatchOne = (obj: unknown) => { + try { + client.transport.dispatchDecodedMessage(obj) + } catch (e) { + logger.error('rpcOnJs: dispatch threw', e) + } + } + + global.rpcOnJs = (objs: unknown, count: number) => { + try { + if (count > 1) { + if (!Array.isArray(objs)) { + // Native always sends an array when it batches, so this means the + // two sides disagree -- and count-1 messages would vanish silently. + logger.error(`rpcOnJs: count ${count} but payload is not an array`) + return + } + for (const obj of objs) { + dispatchOne(obj) + } + } else { + dispatchOne(objs) + } + } catch (e) { + logger.error('rpcOnJs: batch guard threw', e) + } + } +``` + +- [ ] **Step 2: Rename the unrelated renderer log site** + +Run: +```bash +cd shared && grep -n 'rpcOnJs JS thrown' engine/index.platform.tsx +``` +Any remaining hit is the renderer path — give it its own message describing what it actually guards. + +- [ ] **Step 3: Validate** + +Run: +```bash +cd shared && yarn lint:all +``` +Expected: clean, 0 bailouts. + +- [ ] **Step 4: Commit** + +```bash +git add shared/engine/index.platform.tsx +git commit -m "fix(engine): fail loudly when rpcOnJs count and payload disagree + +A non-array with count > 1 fell through to a single dispatch, discarding +count-1 messages with only a generic warning. Native always batches into an +array, so a mismatch means the two sides disagree and should say so. Also +give the three identical '>>>> rpcOnJs JS thrown!' sites distinct messages -- +they guard different things and a log dump could not tell them apart." +``` + +--- + +### Task 21: TS — surface failed writes on the response and renderer paths + +**Two remaining silent-drop paths, both the mirror image of the fix this branch already made for invokes.** + +`send()` now correctly returns `false` on a write failure, but `makeResponse` (`rpc-transport.tsx:493, 496`) **ignores the return**. If `KeybaseWriteArr` fails while answering an incoming service RPC, `rpcOnGo` returns false → `writeMessage` throws → `send` returns false → nobody notices, and the service side hangs on that call forever. The invoke direction is handled properly (`invokeNow`, callback fired exactly once with the error); the response direction is not. Two reviewers flagged this independently. + +Separately, `ProxyNativeTransport.writeMessage` (`index.platform.tsx:142-145`) does `engineSend?.(message)` — if `engineSend` is undefined it silently no-ops and `send()` returns `true`, leaving the RPC outstanding forever. Identical to the mobile bug this branch fixed, still live on the renderer. + +**Files:** +- Modify: `shared/engine/rpc-transport.tsx:488-500` (`makeResponse`) +- Modify: `shared/engine/index.platform.tsx:142-145` (`ProxyNativeTransport.writeMessage`) +- Test: `shared/engine/rpc-transport.test.ts` + +- [ ] **Step 1: Read `makeResponse`** + +Run: +```bash +cd shared && sed -n '485,505p' engine/rpc-transport.tsx +``` +Note both `send()` call sites (the result path and the error path) and the surrounding logger usage. + +- [ ] **Step 2: Write the failing test** + +```ts +test('a failed response write is reported, not swallowed', () => { + const transport = new TestTransport() + transport.failNextWriteForTest() + const errors: Array = [] + jest.spyOn(logger, 'error').mockImplementation((...args) => errors.push(args)) + + const response = transport.makeResponseForTest({seqid: 11}) + response.result({}) + + expect(errors).toHaveLength(1) +}) +``` + +Build `failNextWriteForTest` / `makeResponseForTest` against the real surface, matching the conventions already in the file. If `makeResponse` is not reachable from the test, drive it through `dispatchDecodedMessage` with an invoke frame (the helper from Task 12) and answer the resulting response. + +- [ ] **Step 3: Run it and watch it fail** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts -t 'failed response write' +``` +Expected: FAIL — `errors` is empty, because the `false` return is discarded. + +- [ ] **Step 4: Report the failure in `makeResponse`** + +At both `send()` call sites: + +```ts + if (!this.send(...)) { + // The service is waiting on this reply and nothing else will tell it. + // A failed write here means the connection is gone, so the caller + // hangs forever unless this is visible. + logger.error(`failed to write response for seqid ${seqid}`) + } +``` + +Use the real argument shape at each site rather than the elision above — read them in Step 1. + +- [ ] **Step 5: Make the renderer's missing `engineSend` throw** + +```ts +class ProxyNativeTransport extends LocalTransport { + protected writeMessage(message: RPCMessage) { + const {engineSend} = KB2.functions + if (!engineSend) { + // Silently no-oping leaves the invocation outstanding forever. Throwing + // lets the transport fail it, same as the mobile path. + throw new Error('engineSend missing') + } + engineSend(message) + } +``` + +- [ ] **Step 6: Run to verify it passes** + +Run: +```bash +cd shared && yarn jest engine/rpc-transport.test.ts +``` +Expected: PASS + +- [ ] **Step 7: Validate and commit** + +```bash +cd shared && yarn lint:all +``` + +```bash +git add shared/engine/rpc-transport.tsx shared/engine/index.platform.tsx shared/engine/rpc-transport.test.ts +git commit -m "fix(engine): surface failed writes on the response and renderer paths + +makeResponse ignored send()'s new false return, so a write that failed while +answering an incoming service RPC left the service hanging forever -- the +mirror image of the invoke-direction hang this branch fixed. +ProxyNativeTransport.writeMessage had the same shape: a missing engineSend +no-oped and reported success, leaving the invocation outstanding." +``` + +--- + +## Final Verification + +- [ ] **Full TS validation** + +```bash +cd shared && yarn lint:all && yarn jest engine/ +``` +Expected: lint clean, 0 bailouts, tsc clean on both configs, all engine tests pass. + +- [ ] **Full native builds** + +```bash +./plans/scripts/sync-native-kb.sh +cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline +cd ../ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ + -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build +cd ../../go && go build ./bind/... && go vet ./bind/... +``` +Expected: all succeed. + +- [ ] **Confirm no stray edits to the node_modules copy** + +```bash +git status --short +``` +Expected: only tracked files under `rnmodules/`, `shared/engine/`, `go/bind/`, `plans/`. `shared/node_modules/` must not appear. + +- [ ] **Manual smoke (user drives — never drive the simulator or device yourself)** + +Ask the user to verify on each platform: cold start reaches the inbox; send and receive a message; background and foreground the app; on Android, back-button to home then relaunch (Task 2's case); switch accounts (Task 10's case). Report exactly what they confirm — do not claim a platform is verified without their word. + +--- + +## Deferred — needs a decision, not in scope here + +These came out of review but should not be actioned without the user choosing: + +1. **Dead `engineReset` TurboModule method** (Task 10 Step 5) — delete it across the spec plus both platform implementations, or wire the account-switch path to it. Spans the codegen'd spec; wider blast radius than the rest of this plan. +2. **No backpressure from the reader to JS** — `invokeAsync` is unbounded, so a stalled JS thread lets the reader queue batches that each retain a msgpack zone. Pre-existing, but the permanent reader makes it permanent. Needs a bounded-queue design decision. +3. **Second `ReactHost` / multi-instance** — one `g_bridge`, one `instance`, one Go `conn`; a second host starves silently. Not a regression and single-host may be a hard invariant, but nothing documents or asserts it. Either add an assert or a header comment saying so. +4. **iOS reader thread QoS** — the reader inherits the QoS of whatever submitted it (`_sharedModuleQueue`) and freezes it for the process lifetime, permanently consuming one libdispatch worker. A dedicated `NSThread` with an explicit `qualityOfService` is the canonical shape for a permanent blocking loop. +5. **`packNumber` above 2^53** — emits uint64/int64 where `@msgpack/msgpack` emits float64, so a double of 2^60 encodes as a different msgpack type than the desktop path. Harmless for current RPC shapes; the comment claiming equivalence overstates it. Also `-0` packs as uint `0`. +6. **`kbTeardown` is a plain writable global** — any JS doing `globalThis.kbTeardown = undefined` makes it collectible, and the finalizer then runs `teardown()` on a *live* runtime, permanently setting `isTornDown_` with no recovery short of a reload. Define it non-writable/non-configurable. +7. **Assorted C++ LOWs not worth their own task**, but real: `resetCaches` compares runtimes by pointer, so a new `Runtime` allocated at a freed one's address would keep stale handles (unreachable today with one bridge per runtime, but that function exists for the multi-runtime case — a generation counter is safer); `arrayBuf.data(runtime) + offset` is `nullptr + 0` for a detached ArrayBuffer, which is UB per the standard even though it is benign in practice; `callInvoker_` is dereferenced unguarded while `writeToGo_` and `recv_` are both null-checked; and `install()` is unsynchronized and its "call exactly once, before publication" requirement is enforced only by how the platform layers happen to call it. Bundle these into one cleanup pass if desired. +8. **Android copies per RPC** — three on the outbound path (`SetByteArrayRegion`, Go's load-bearing `make`/`copy`, the loopback) and an avoidable one inbound (`pin()` uses `GetByteArrayElements` and releases with mode 0, copying unmodified data back). `GetByteArrayRegion` straight into `up.buffer()` would drop one round trip. Perf only, and this is a perf branch, so worth considering — but it touches the hot path and deserves measurement first. +9. **`~KBBridge` can run on any thread** (C++ F2) — the destructor is `= default` and destroys jsi handles; the safety argument rests entirely on `KBTearDownSimple` having already nulled them on the JS thread, which nothing enforces. Either pin the bridge's lifetime to the host object with a strong `shared_ptr`, or add a debug assert so it regresses loudly. Deferred because the strong-ref option changes ownership across both platforms and deserves its own review. diff --git a/plans/scripts/sync-native-kb.sh b/plans/scripts/sync-native-kb.sh new file mode 100755 index 000000000000..37ffa13e62b4 --- /dev/null +++ b/plans/scripts/sync-native-kb.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# shared/node_modules/react-native-kb is a `file:` COPY, not a symlink, and +# expo autolinking points gradle/pods at it. Native edits under rnmodules/ +# are invisible to a build until they are copied across. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SRC="$ROOT/rnmodules/react-native-kb" +DST="$ROOT/shared/node_modules/react-native-kb" +test -d "$DST" || { echo "missing $DST — run yarn in shared/ first" >&2; exit 1; } +for d in cpp ios android; do + rsync -a --delete "$SRC/$d/" "$DST/$d/" +done +echo "synced rnmodules/react-native-kb -> shared/node_modules/react-native-kb" From 2f25ca3679d35c3443128d982d745aba0483bc18 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:21:50 -0400 Subject: [PATCH 07/80] fix(rpc): only clear the iOS bridge if it is still the installed one 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. --- rnmodules/react-native-kb/ios/Kb.mm | 45 ++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 26108cac374a..be5a5a797afd 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -67,6 +67,26 @@ static void kbSetBridge(std::shared_ptr bridge) { } } +// Clears the installed bridge only if it is still `mine`. A module's +// invalidate can run *after* the next module already installed its bridge +// (RCTInstance::invalidate hops to the old JS thread asynchronously, and +// RCTTurboModuleManager only waits 10s before proceeding), so an +// unconditional clear would tear down the live bridge and wedge the app +// with no desync and no reset to recover from. +static bool kbClearBridgeIfCurrent(const std::shared_ptr &mine) { + std::shared_ptr old; + { + std::lock_guard lock(kbBridgeMutex); + if (!mine || kbCurrentBridge != mine) { + return false; + } + old = std::move(kbCurrentBridge); + kbCurrentBridge = nullptr; + } + old->markTornDown(); + return true; +} + static void kbLogToService(NSString *message) { KeybaseLogToService([NSString stringWithFormat:@"dNativeLogger: [%f,\"%@\"]", @@ -153,7 +173,9 @@ static bool kbUses24HourClockForLocale(NSLocale *_Nonnull locale) { return constants; } -@implementation Kb +@implementation Kb { + std::shared_ptr myBridge_; +} RCT_EXPORT_MODULE() @@ -244,13 +266,19 @@ + (void)handlePastedImages:(NSArray *)images { - (void)invalidate { [[NSNotificationCenter defaultCenter] removeObserver:self]; kbPasteImageEnabled = NO; - // Drop the bridge so the (permanent) reader thread stops delivering into a - // runtime that is going away. Only the atomic flag is touched here: this - // runs on the main thread, and releasing jsi handles off the JS thread is - // undefined behavior. - kbSetBridge(nullptr); - NSError *error = nil; - KeybaseReset(&error); + // Runs on the TurboModule shared method queue (no methodQueue getter, so + // RCTTurboModuleManager assigns _sharedModuleQueue) — any thread, never the + // JS thread. Only the atomic flag may be touched here; releasing jsi + // handles off the runtime's thread is undefined behavior. + // + // Both the teardown and the Go reset are gated on still being the current + // bridge: a reload can install the next module's bridge before this runs, + // and clearing that one would leave the app wedged with no way to notice. + if (kbClearBridgeIfCurrent(myBridge_)) { + NSError *error = nil; + KeybaseReset(&error); + } + myBridge_ = nullptr; } RCT_EXPORT_METHOD(setEnablePasteImage:(BOOL)enabled) { @@ -303,6 +331,7 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime }); }); + myBridge_ = bridge; kbSetBridge(bridge); kbLogToService(@"jsi install success (via installJSIBindings)"); } From 7682e02e72a0f16bca7ef86da2067c2292ef38a2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:25:29 -0400 Subject: [PATCH 08/80] fix(rpc): stop clearing the Android module instance on host destroy 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. --- .../main/java/com/reactnativekb/KbModule.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 6c6788ad06b2..627c5cbe3e41 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -296,9 +296,11 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T init { this.reactContext = reactContext!! - instance = this misTestDevice = isTestDevice(reactContext) Thread { cachedConstants }.start() + // Published last: the reader thread reads `instance` and must never + // see a partially constructed module. + instance = this } private fun normalizePath(path: String): String { @@ -547,14 +549,14 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } fun destroy() { - // The read loop outlives us and forwards to whatever `instance` holds, - // so drop ourselves from it: otherwise a torn-down module (and the - // ReactContext/Activity it pins) keeps receiving deliveries. Only clear - // if we're still the current one — a reload may have installed a newer - // module before our onHostDestroy runs. - if (instance === this) { - instance = null - } + // `instance` is deliberately NOT cleared here. onHostDestroy fires when + // the last Activity goes away, but the ReactInstance and this module + // survive, so init{} never re-runs and nothing would ever restore it — + // every later inbound message would be dropped for the life of the + // process (back button to home, then reopen). It is only a gate: the + // JNI callee ignores the receiver and routes through g_bridge, so a + // stale instance delivers to the correct current bridge regardless. + // Bridge teardown is handled by nativeInvalidate below. try { Keybase.reset() relayReset() From 4f76a222382159280c590d04a8f560e2d0b81b2f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:28:56 -0400 Subject: [PATCH 09/80] fix(rpc): clear kbSharedInstance on iOS invalidate 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 3f71bd9388. --- rnmodules/react-native-kb/ios/Kb.mm | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index be5a5a797afd..2b32fbf71364 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -266,6 +266,15 @@ + (void)handlePastedImages:(NSArray *)images { - (void)invalidate { [[NSNotificationCenter defaultCenter] removeObserver:self]; kbPasteImageEnabled = NO; + // RN never nulls _eventEmitterCallback on invalidate and the __weak ref + // above only nils at dealloc, which lags this call — so without an explicit + // clear, canEmit stays YES and a push notification, token registration or + // (worst) the reader's desync meta event emits into the dying runtime's + // invoker. Guarded because a reload may already have installed a newer + // module as the shared instance. + if (kbSharedInstance == self) { + kbSharedInstance = nil; + } // Runs on the TurboModule shared method queue (no methodQueue getter, so // RCTTurboModuleManager assigns _sharedModuleQueue) — any thread, never the // JS thread. Only the atomic flag may be touched here; releasing jsi From 450ad67773d24f019bdf7f87ddabf2c9e9995bc5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:32:13 -0400 Subject: [PATCH 10/80] fix(rpc): make kbSharedInstance compare-and-clear atomic 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. --- rnmodules/react-native-kb/ios/Kb.mm | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 2b32fbf71364..d71114fcdf69 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -38,6 +38,12 @@ + (id)sharedFsPathsHolder { static NSString *const metaEventEngineReset = @"kb-engine-reset"; static __weak Kb *kbSharedInstance = nil; +// Guards the compare-and-clear in invalidate against init's write: init runs +// on the main thread, invalidate on the TurboModule shared method queue, and +// the __weak load/store are each individually synchronized but the +// read-then-write in invalidate is not, so without this lock a reload's new +// instance can be clobbered by the old instance's invalidate. +static std::mutex kbSharedInstanceMutex; static BOOL kbPasteImageEnabled = NO; static NSString *kbStoredDeviceToken = nil; static NSDictionary *kbInitialNotification = nil; @@ -191,7 +197,10 @@ - (BOOL)canEmit { - (instancetype)init { self = [super init]; - kbSharedInstance = self; + { + std::lock_guard lock(kbSharedInstanceMutex); + kbSharedInstance = self; + } [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleHardwareKeyPressed:) name:@"hardwareKeyPressed" @@ -271,9 +280,13 @@ - (void)invalidate { // clear, canEmit stays YES and a push notification, token registration or // (worst) the reader's desync meta event emits into the dying runtime's // invoker. Guarded because a reload may already have installed a newer - // module as the shared instance. - if (kbSharedInstance == self) { - kbSharedInstance = nil; + // module as the shared instance; the lock makes the compare-and-clear + // atomic with init's write so a newer instance can never be clobbered. + { + std::lock_guard lock(kbSharedInstanceMutex); + if (kbSharedInstance == self) { + kbSharedInstance = nil; + } } // Runs on the TurboModule shared method queue (no methodQueue getter, so // RCTTurboModuleManager assigns _sharedModuleQueue) — any thread, never the From 6f85fc250b7d0431ee5c7f111fbd20eb7ad9496e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:34:53 -0400 Subject: [PATCH 11/80] fix(rpc): tear down the Android bridge and globals on module destroy 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. --- .../react-native-kb/android/cpp-adapter.cpp | 25 +++++++++++++++++++ .../main/java/com/reactnativekb/KbModule.kt | 2 ++ 2 files changed, 27 insertions(+) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index 2d3af0dc1b2e..f47358a76f6f 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -57,6 +57,30 @@ static std::shared_ptr getBridge() { return g_bridge; } +// Any thread. Drops this module's C++-side state: flips the bridge's atomic +// teardown flag so the permanent reader stops delivering into a runtime that +// is going away, and releases the globals that would otherwise pin a dead +// KbModule (and its ReactContext/Activity) and a destroyed runtime's +// CallInvoker for the life of the process. +// +// Only atomics and shared_ptr slots are touched — the old bridge's jsi +// handles belong to its own runtime and are released by its kbTeardown host +// object on the JS thread. +static void nativeInvalidate(jni::alias_ref) { + std::shared_ptr oldBridge; + std::shared_ptr oldAdapter; + { + std::lock_guard lock(g_mutex); + oldBridge = std::move(g_bridge); + oldAdapter = std::move(g_adapter); + g_bridge = nullptr; + g_adapter = nullptr; + } + if (oldBridge) { + oldBridge->markTornDown(); + } +} + static jni::local_ref getBindingsInstaller(jni::alias_ref thiz) { auto adapter = std::make_shared(thiz); @@ -124,6 +148,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { ->registerNatives({ makeNativeMethod("getBindingsInstaller", getBindingsInstaller), makeNativeMethod("nativeOnDataFromGo", nativeOnDataFromGo), + makeNativeMethod("nativeInvalidate", nativeInvalidate), }); }); } diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 627c5cbe3e41..14d7d7608816 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -46,6 +46,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T @DoNotStrip external override fun getBindingsInstaller(): BindingsInstallerHolder private external fun nativeOnDataFromGo(data: ByteArray) + private external fun nativeInvalidate() private var lifecycleListenerRegistered = false @@ -557,6 +558,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // JNI callee ignores the receiver and routes through g_bridge, so a // stale instance delivers to the correct current bridge regardless. // Bridge teardown is handled by nativeInvalidate below. + nativeInvalidate() try { Keybase.reset() relayReset() From eb4eace398b4ac683cfb80b0771237b73743cb16 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:40:28 -0400 Subject: [PATCH 12/80] fix(rpc): hold the Android adapter strongly in the bindings installer 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. --- .../react-native-kb/android/cpp-adapter.cpp | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index f47358a76f6f..71f140f2a97a 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -89,8 +89,13 @@ getBindingsInstaller(jni::alias_ref thiz) { g_adapter = adapter; } + // Captured strongly: the adapter must outlive the bridge that calls it, and + // the g_adapter slot is not an ownership anchor -- installing a second + // module overwrites it while the first bridge is still live and installed, + // which with a weak capture failed every rpcOnGo in that window. No cycle: + // the adapter holds a global_ref to the Java module and never the bridge. return BindingsInstallerHolder::newObjectCxxArgs( - [weakAdapter = std::weak_ptr(adapter)]( + [adapter]( jsi::Runtime &runtime, const std::shared_ptr &callInvoker) { auto bridge = std::make_shared(); @@ -98,11 +103,8 @@ getBindingsInstaller(jni::alias_ref thiz) { runtime, callInvoker, // false means the RPC never reached Go, so the caller fails that // invocation instead of waiting forever for a reply. - [weakAdapter](void *ptr, size_t size) -> bool { - if (auto a = weakAdapter.lock()) { - return a->writeToGo(ptr, size); - } - return false; + [adapter](void *ptr, size_t size) -> bool { + return adapter->writeToGo(ptr, size); }, [](const std::string &err) { __android_log_print(ANDROID_LOG_ERROR, "KBBridge", @@ -110,12 +112,10 @@ getBindingsInstaller(jni::alias_ref thiz) { }, // The incoming stream desynced; reset the Go connection and tell // JS so it fails outstanding RPCs rather than hanging forever. - [weakAdapter]() { + [adapter]() { __android_log_print(ANDROID_LOG_ERROR, "KBBridge", "rpc stream desync, resetting connection"); - if (auto a = weakAdapter.lock()) { - a->onFatal(); - } + adapter->onFatal(); }); std::shared_ptr old; From e8c79af52029e809f35ee5a729982a3099921986 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:47:52 -0400 Subject: [PATCH 13/80] fix(rpc): emit engine-reset when the read path drops the connection 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. --- .../react-native-kb/android/cpp-adapter.cpp | 7 +++++++ .../main/java/com/reactnativekb/KbModule.kt | 20 ++++++++++++++++--- .../react-native-kb/cpp/react-native-kb.cpp | 7 +++++++ .../react-native-kb/cpp/react-native-kb.h | 5 +++++ rnmodules/react-native-kb/ios/Kb.mm | 17 +++++++++++++++- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index 71f140f2a97a..c1b841ca8271 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -81,6 +81,12 @@ static void nativeInvalidate(jni::alias_ref) { } } +static void nativeResetRecv(jni::alias_ref) { + if (auto bridge = getBridge()) { + bridge->resetRecv(); + } +} + static jni::local_ref getBindingsInstaller(jni::alias_ref thiz) { auto adapter = std::make_shared(thiz); @@ -149,6 +155,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { makeNativeMethod("getBindingsInstaller", getBindingsInstaller), makeNativeMethod("nativeOnDataFromGo", nativeOnDataFromGo), makeNativeMethod("nativeInvalidate", nativeInvalidate), + makeNativeMethod("nativeResetRecv", nativeResetRecv), }); }); } diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 14d7d7608816..d4c7fedd8265 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -47,6 +47,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T external override fun getBindingsInstaller(): BindingsInstallerHolder private external fun nativeOnDataFromGo(data: ByteArray) private external fun nativeInvalidate() + private external fun nativeResetRecv() private var lifecycleListenerRegistered = false @@ -536,13 +537,17 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T Thread.currentThread().interrupt() return } catch (e: Exception) { + // readArr already called Reset() on the Go side, so the + // connection JS thinks it has is gone and every in-flight + // RPC is dead. EOF is the ordinary shape of this, not a + // surprise -- but JS still has to be told, or its callers + // hang forever. if (e.message != null && e.message.equals("Read error: EOF")) { - NativeLogger.info("Got EOF from read. Likely because of reset.") + NativeLogger.info("Got EOF from read, connection reset.") } else { NativeLogger.error("Exception in ReadFromKBLib.run", e) } - // Back off on error to avoid spinning at full CPU speed when Go is - // unavailable (e.g. during init or loopback restart). + instance?.onRpcConnectionReset() try { Thread.sleep(100) } catch (ie: InterruptedException) { Thread.currentThread().interrupt(); return } } } @@ -596,6 +601,15 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T reactContext.runOnUiQueueThread { relayReset() } } + // Called from the reader thread when readArr failed and Go reset the + // connection underneath us. Unlike onRpcStreamFatal we must NOT call + // Keybase.reset() -- ReadArr already did, and a second reset would close a + // connection a concurrent writeArr may have just dialed. + fun onRpcConnectionReset() { + nativeResetRecv() + reactContext.runOnUiQueueThread { relayReset() } + } + @ReactMethod override fun iosGetHasShownPushPrompt(promise: Promise) { promise.reject(Exception("wrong platform")) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index 9fec3544c48f..9aa37ceefa77 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -46,6 +46,13 @@ void KBBridge::teardown() { void KBBridge::tearup() { isTornDown_.store(false); } +void KBBridge::resetRecv() { + std::lock_guard lock(recvMutex_); + if (recv_) { + resetRecvLocked(); + } +} + // Clears cached JSI objects. Must run on the runtime's thread while the // runtime is still alive — destroying jsi handles elsewhere is UB. void KBBridge::releaseJSIState() { diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.h b/rnmodules/react-native-kb/cpp/react-native-kb.h index c4a9e7a5aec5..91d7fdb3e45f 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.h +++ b/rnmodules/react-native-kb/cpp/react-native-kb.h @@ -41,6 +41,11 @@ class KBBridge : public std::enable_shared_from_this { // Any thread. void onDataFromGo(uint8_t *data, int size); + // Any thread. Drops any partially parsed frame. Must be called whenever the + // Go connection is replaced, or the unpacker resumes mid-frame on a fresh + // stream and the next header check fails on valid data. + void resetRecv(); + // Any thread. Stops all further work. Does NOT touch JSI state, so it is // safe to call from the main thread / module invalidation. void markTornDown(); diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index d71114fcdf69..1b23532c99d8 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -408,7 +408,22 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime NSError *error = nil; NSData *data = KeybaseReadArr(&error); if (error) { - NSLog(@"Error reading data: %@", error); + // ReadArr already called Reset() on the Go side, so the connection + // JS thinks it has is gone and every in-flight RPC is dead. Tell + // JS so it fails them instead of spinning forever, and drop any + // half-parsed frame so the next connection starts clean. + kbLogToService([NSString + stringWithFormat:@"rpc read error, connection reset: %@", + error.localizedDescription]); + if (auto bridge = kbGetBridge()) { + bridge->resetRecv(); + } + dispatch_async(dispatch_get_main_queue(), ^{ + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnMetaEvent:metaEventEngineReset]; + } + }); [NSThread sleepForTimeInterval:0.1]; continue; } From f8d52bbee3ae34a3af823404838f5cd581d6b48d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 20:54:50 -0400 Subject: [PATCH 14/80] fix(rpc): require frame content to consume exactly the declared length 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. --- .../react-native-kb/cpp/react-native-kb.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index 9aa37ceefa77..ac35840fbfac 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -28,6 +28,10 @@ constexpr size_t kMaxCachedPropNames = 4096; struct KBBridge::RecvState { msgpack::unpacker unpacker; ReadState state = ReadState::needSize; + // Persist across calls: a frame's header and its content routinely arrive + // in separate reads. + size_t declaredSize = 0; + size_t sizeAtHeader = 0; }; struct KBBridge::SendState { @@ -643,8 +647,20 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { o.as() > kMaxFrameSize) { throw std::runtime_error("bad rpc frame header"); } + recv_->declaredSize = static_cast(o.as()); + recv_->sizeAtHeader = up.parsed_size(); recv_->state = ReadState::needContent; } else { + // The header is only a plausibility check on its own: a fixint + // sitting inside a string payload parses as a valid header after a + // resync. Requiring the content object to consume exactly the + // declared byte count makes the framing self-checking, so a + // truncated or overlong frame is caught here instead of being + // handed to JS as a bogus [type, seqid, ...]. + const size_t consumed = up.parsed_size() - recv_->sizeAtHeader; + if (consumed != recv_->declaredSize) { + throw std::runtime_error("rpc frame length mismatch"); + } values->push_back(std::move(result)); recv_->state = ReadState::needSize; } From 19be6e251e74a5470940c3efe1fa3bd2f32424a7 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 21:01:14 -0400 Subject: [PATCH 15/80] fix(rpc): fix frame-length check to not rely on parsed_size() 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. --- .../react-native-kb/cpp/react-native-kb.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index ac35840fbfac..5d8597e8bc87 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -31,7 +31,11 @@ struct KBBridge::RecvState { // Persist across calls: a frame's header and its content routinely arrive // in separate reads. size_t declaredSize = 0; - size_t sizeAtHeader = 0; + size_t consumedAtHeader = 0; + // Every byte ever handed to the unpacker. parsed_size() cannot serve this + // role -- next() zeroes it on each success -- but totalFed minus + // nonparsed_size() is an accurate running count of what has been consumed. + size_t totalFed = 0; }; struct KBBridge::SendState { @@ -632,6 +636,7 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { up.reserve_buffer(static_cast(size)); std::memcpy(up.buffer(), data, static_cast(size)); up.buffer_consumed(static_cast(size)); + recv_->totalFed += static_cast(size); while (true) { msgpack::object_handle result; @@ -648,7 +653,7 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { throw std::runtime_error("bad rpc frame header"); } recv_->declaredSize = static_cast(o.as()); - recv_->sizeAtHeader = up.parsed_size(); + recv_->consumedAtHeader = recv_->totalFed - up.nonparsed_size(); recv_->state = ReadState::needContent; } else { // The header is only a plausibility check on its own: a fixint @@ -657,7 +662,13 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { // declared byte count makes the framing self-checking, so a // truncated or overlong frame is caught here instead of being // handed to JS as a bogus [type, seqid, ...]. - const size_t consumed = up.parsed_size() - recv_->sizeAtHeader; + // + // parsed_size() is NOT usable here: unpacker::next() resets it to + // 0 on every successful parse, so it can't measure a span across + // two next() calls. totalFed - nonparsed_size() is a genuine + // monotonic count of bytes consumed from the stream. + const size_t consumed = + (recv_->totalFed - up.nonparsed_size()) - recv_->consumedAtHeader; if (consumed != recv_->declaredSize) { throw std::runtime_error("rpc frame length mismatch"); } From 7594686ef70dd013453724ff9a07bd1e522587ac Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 21:07:24 -0400 Subject: [PATCH 16/80] fix(rpc): don't let one undecodable message strand a whole batch 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. --- .../react-native-kb/cpp/react-native-kb.cpp | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index 5d8597e8bc87..f9c3a72bf991 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -725,7 +725,12 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { runtime.global().getProperty(runtime, *self->cachedRpcOnJsName_); if (!onJsValue.isObject() || !onJsValue.getObject(runtime).isFunction(runtime)) { + // These messages are already consumed from the unpacker and cannot be + // replayed, so dropping them silently would strand their callers. self->reportError("rpcOnJs is not installed"); + if (self->onFatal_) { + self->onFatal_(); + } return; } auto onJs = onJsValue.getObject(runtime).getFunction(runtime); @@ -739,23 +744,51 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { } onJs.call(runtime, std::move(value), jsi::Value(1)); } else { - // Multiple messages: batch into array, pass count - jsi::Array arr(runtime, values->size()); + // Convert per message: a single bad message (non-scalar map key, + // nesting over the limit) must not take the whole batch with it and + // strand every other reply's caller. JS iterates the delivered + // array with a plain for-of (index.platform.tsx's global.rpcOnJs + // only uses `count` to decide array-vs-single, not to bound the + // loop), so the array must be sized to what actually converted -- + // a hole left at `values->size()` would hand JS an undefined + // message to dispatch instead of just skipping it. + std::vector converted; + converted.reserve(values->size()); for (size_t i = 0; i < values->size(); ++i) { - msgpack::object obj((*values)[i].get()); - arr.setValueAtIndex(runtime, i, - self->convertMPToJSI(runtime, &obj)); + try { + msgpack::object obj((*values)[i].get()); + converted.push_back(self->convertMPToJSI(runtime, &obj)); + } catch (const std::exception &e) { + self->reportError(std::string("dropping undecodable message: ") + + e.what()); + } + } + if (converted.empty()) { + return; } if (self->isTornDown_.load()) { return; } + jsi::Array arr(runtime, converted.size()); + for (size_t i = 0; i < converted.size(); ++i) { + arr.setValueAtIndex(runtime, i, std::move(converted[i])); + } onJs.call(runtime, std::move(arr), - jsi::Value(static_cast(values->size()))); + jsi::Value(static_cast(converted.size()))); } } catch (const std::exception &e) { + // Nothing decoded reached JS, so every reply in this batch is lost and + // its caller would wait forever. Treat it like a desync: reset the + // connection so JS fails its outstanding RPCs. self->reportError(e.what()); + if (self->onFatal_) { + self->onFatal_(); + } } catch (...) { self->reportError("unknown error in onDataFromGo JS callback"); + if (self->onFatal_) { + self->onFatal_(); + } } }); } From 0ed435dc8a719ee403a2fcf4e206ef5c645e16f5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 21:17:35 -0400 Subject: [PATCH 17/80] fix(rpc): latch after a desync so one bad frame is one reset 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. --- .../react-native-kb/cpp/react-native-kb.cpp | 25 +++++++++++++++---- .../react-native-kb/cpp/react-native-kb.h | 5 ++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index f9c3a72bf991..6b4c4e3445ab 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -55,10 +55,20 @@ void KBBridge::teardown() { void KBBridge::tearup() { isTornDown_.store(false); } void KBBridge::resetRecv() { - std::lock_guard lock(recvMutex_); - if (recv_) { - resetRecvLocked(); + { + std::lock_guard lock(recvMutex_); + if (recv_) { + resetRecvLocked(); + } } + // The platform layer calls this once it has confirmed the dead connection + // is gone (a read error) or replaced it, so it is safe to trust incoming + // data again. It must NOT also be called right after onFatal_ fires: that + // runs synchronously on the same serial reader thread that is still about + // to deliver the rest of the desynced stream, so clearing the latch there + // would unlatch before the next stale chunk even arrives and the storm + // this latch exists to stop would go right through it. + awaitingReset_.store(false); } // Clears cached JSI objects. Must run on the runtime's thread while the @@ -616,7 +626,8 @@ void KBBridge::install( } void KBBridge::onDataFromGo(uint8_t *data, int size) { - if (isTornDown_.load() || size <= 0 || data == nullptr) { + if (isTornDown_.load() || awaitingReset_.load() || size <= 0 || + data == nullptr) { return; } @@ -695,7 +706,11 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { reportError(fatalMsg); // The stream can no longer be trusted, so anything decoded in this batch // is dropped. The platform layer resets the Go connection and signals JS - // so outstanding RPCs fail instead of hanging forever. + // so outstanding RPCs fail instead of hanging forever. Latch until that + // reset is confirmed (resetRecv): the rest of the dead stream is still in + // flight on the reader thread and would otherwise trigger a reset per + // chunk, each one failing whatever JS just re-issued. + awaitingReset_.store(true); if (onFatal_) { onFatal_(); } diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.h b/rnmodules/react-native-kb/cpp/react-native-kb.h index 91d7fdb3e45f..1540177b5039 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.h +++ b/rnmodules/react-native-kb/cpp/react-native-kb.h @@ -61,6 +61,11 @@ class KBBridge : public std::enable_shared_from_this { std::function onFatal_; std::function writeToGo_; std::atomic isTornDown_{false}; + // Set when the incoming stream desynced and the connection is being reset. + // Bytes already in flight from the dead stream keep arriving and would each + // trigger another reset, so drop them until the platform layer confirms the + // connection was replaced (resetRecv). + std::atomic awaitingReset_{false}; enum class ReadState { needSize, needContent }; From 25469362a69116736a27aa10bd434e4bebfd3012 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sun, 26 Jul 2026 21:22:55 -0400 Subject: [PATCH 18/80] Revert "fix(rpc): latch after a desync so one bad frame is one reset" This reverts commit 0ed435dc8a719ee403a2fcf4e206ef5c645e16f5. Two problems surfaced in review: 1. Permanent wedge. awaitingReset_ was only cleared from the platform read-error branches, but the normal outcome of a fatal-triggered KeybaseReset is a successful reconnect, which never produces a read error: Reset() nils conn, the next ReadArr dials a brand new loopback conn (Reset never touches kbCtx.LoopbackListener), and LoopbackConn.Read blocks until real data arrives -- it never returns (0, nil). So once JS resends and Go replies, that successful read never calls resetRecv(), the latch is never cleared, and onDataFromGo's entry guard drops every reply forever. engineReset on both platforms also calls KeybaseReset/Keybase.reset directly without resetRecv(), so there was no user-triggered escape either. 2. Unreachable premise. The storm this was meant to fix is already closed by Task 6: onFatal_ runs synchronously on the single serial reader thread, so KeybaseReset completes before the loop's next ReadArr call, and the stale in-flight bytes are discarded wholesale with the closed connection instead of being re-read. One desync already produces exactly one reset without this latch. --- .../react-native-kb/cpp/react-native-kb.cpp | 25 ++++--------------- .../react-native-kb/cpp/react-native-kb.h | 5 ---- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index 6b4c4e3445ab..f9c3a72bf991 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -55,20 +55,10 @@ void KBBridge::teardown() { void KBBridge::tearup() { isTornDown_.store(false); } void KBBridge::resetRecv() { - { - std::lock_guard lock(recvMutex_); - if (recv_) { - resetRecvLocked(); - } + std::lock_guard lock(recvMutex_); + if (recv_) { + resetRecvLocked(); } - // The platform layer calls this once it has confirmed the dead connection - // is gone (a read error) or replaced it, so it is safe to trust incoming - // data again. It must NOT also be called right after onFatal_ fires: that - // runs synchronously on the same serial reader thread that is still about - // to deliver the rest of the desynced stream, so clearing the latch there - // would unlatch before the next stale chunk even arrives and the storm - // this latch exists to stop would go right through it. - awaitingReset_.store(false); } // Clears cached JSI objects. Must run on the runtime's thread while the @@ -626,8 +616,7 @@ void KBBridge::install( } void KBBridge::onDataFromGo(uint8_t *data, int size) { - if (isTornDown_.load() || awaitingReset_.load() || size <= 0 || - data == nullptr) { + if (isTornDown_.load() || size <= 0 || data == nullptr) { return; } @@ -706,11 +695,7 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { reportError(fatalMsg); // The stream can no longer be trusted, so anything decoded in this batch // is dropped. The platform layer resets the Go connection and signals JS - // so outstanding RPCs fail instead of hanging forever. Latch until that - // reset is confirmed (resetRecv): the rest of the dead stream is still in - // flight on the reader thread and would otherwise trigger a reset per - // chunk, each one failing whatever JS just re-issued. - awaitingReset_.store(true); + // so outstanding RPCs fail instead of hanging forever. if (onFatal_) { onFatal_(); } diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.h b/rnmodules/react-native-kb/cpp/react-native-kb.h index 1540177b5039..91d7fdb3e45f 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.h +++ b/rnmodules/react-native-kb/cpp/react-native-kb.h @@ -61,11 +61,6 @@ class KBBridge : public std::enable_shared_from_this { std::function onFatal_; std::function writeToGo_; std::atomic isTornDown_{false}; - // Set when the incoming stream desynced and the connection is being reset. - // Bytes already in flight from the dead stream keep arriving and would each - // trigger another reset, so drop them until the platform layer confirms the - // connection was replaced (resetRecv). - std::atomic awaitingReset_{false}; enum class ReadState { needSize, needContent }; From d107097df250fb1b3ef915f0860fd5729515f61e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 07:58:17 -0400 Subject: [PATCH 19/80] fix(engine): fail in-flight RPCs on a mobile account switch Engine.reset() early-returned on mobile, so the account-switch path never reached the transport and pre-switch invocations were never failed -- their callbacks could fire against post-switch state, which is exactly what ProxyNativeTransport.reset() prevents on desktop. Add regression tests for exactly-once failure and for seqids continuing to advance across a reset, since restarting the counter would let a late reply from the dead connection alias a fresh invocation. --- shared/engine/index.tsx | 5 +++++ shared/engine/rpc-transport.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/shared/engine/index.tsx b/shared/engine/index.tsx index 1f3d89edc41f..439662946241 100644 --- a/shared/engine/index.tsx +++ b/shared/engine/index.tsx @@ -303,6 +303,11 @@ class Engine { // Reset the engine reset() { + // Mobile has no socket to tear down and no reconnect to wait for, but the + // in-flight invocations still have to be failed: after an account switch + // nothing will answer them, and their callbacks would otherwise fire + // against post-switch state. + this._rpcClient.transport.reset() if (isMobile) { return } diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 2cb1c1cd4f69..c95552f7fc0d 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -175,6 +175,33 @@ test('send reports failure when the native write throws', () => { expect(transport.sent).toEqual([[1, 3, null, {ok: true}]]) }) +test('failAllOutstanding fails every outstanding invocation exactly once', () => { + const transport = new TestTransport() + const calls: Array = [] + transport.invoke('keybase.1.test.a', [{}], err => calls.push(err)) + transport.invoke('keybase.1.test.b', [{}], err => calls.push(err)) + + transport.failAllOutstanding() + + expect(calls).toHaveLength(2) + expect(calls.every(e => (e as {code?: number} | undefined)?.code === errors.EOF)).toBe(true) + + // A second call must not re-fail anything already failed. + transport.failAllOutstanding() + expect(calls).toHaveLength(2) +}) + +test('seqids keep advancing after outstanding invocations are failed, so a late reply cannot alias', () => { + const transport = new TestTransport() + transport.invoke('keybase.1.test.a', [{}], () => {}) + transport.failAllOutstanding() + transport.invoke('keybase.1.test.b', [{}], () => {}) + + const [, firstSeqid] = transport.sent[0] as [number, number, string, [object]] + const [, secondSeqid] = transport.sent[1] as [number, number, string, [object]] + expect(secondSeqid).toBeGreaterThan(firstSeqid) +}) + test('cancel packets surface a cancelled response payload', () => { const incoming = jest.fn() const transport = new TestTransport({incomingRPCCallback: incoming}) From 408d1f37411abfb26b691d3b241a21e2bd3767a4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:02:38 -0400 Subject: [PATCH 20/80] test(engine): exercise the reentrancy hazard failAllOutstanding's swap prevents The exactly-once test only caught a fully missing map clear, not a naive iterate-then-clear implementation, which would also fail a fresh invocation made by a callback while iteration is still in progress. Add a test that re-enters with a new invoke mid-callback and asserts it survives; falsified against an iterate-in-place implementation to confirm it can actually fail. Also broaden the NativeTransportMobile.reset() comment to name the mobile account-switch call site alongside the native Go-reset/desync paths. --- shared/engine/index.platform.tsx | 7 ++++--- shared/engine/rpc-transport.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index de671239c151..5ac754a00d9e 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -166,9 +166,10 @@ class NativeTransportMobile extends LocalTransport { throw new Error('native rpc write failed') } } - // The Go connection can be reset underneath us (engine reset, or a stream - // desync detected natively). Nothing will answer the in-flight RPCs after - // that, so fail them rather than hang every caller. + // Reached two ways: a mobile account switch calling Engine.reset() + // synchronously, or the Go connection resetting underneath us (a stream + // desync detected natively). Either way nothing will answer the in-flight + // RPCs after that, so fail them rather than hang every caller. override reset() { this.failAllOutstanding() } diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index c95552f7fc0d..9d7261a9aa61 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -191,6 +191,23 @@ test('failAllOutstanding fails every outstanding invocation exactly once', () => expect(calls).toHaveLength(2) }) +test('a callback that re-enters with a new invoke during failAllOutstanding is not itself failed', () => { + const transport = new TestTransport() + const calls: Array = [] + const reentrant = jest.fn() + + transport.invoke('keybase.1.test.a', [{}], err => { + calls.push(err) + transport.invoke('keybase.1.test.c', [{}], reentrant) + }) + transport.invoke('keybase.1.test.b', [{}], err => calls.push(err)) + + transport.failAllOutstanding() + + expect(calls).toHaveLength(2) + expect(reentrant).not.toHaveBeenCalled() +}) + test('seqids keep advancing after outstanding invocations are failed, so a late reply cannot alias', () => { const transport = new TestTransport() transport.invoke('keybase.1.test.a', [{}], () => {}) From 3ee7877a838eacf669f9d7b737eaa9a5f0276531 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:05:57 -0400 Subject: [PATCH 21/80] fix(engine): app-code throws must not reset the packetizer dispatchDecodedMessage ran inside the framing try, so any unguarded handler throw unwound into the catch that resets the packetizer and drops every buffered byte. Parsing then resumed mid-frame, producing Bad frame header forever -- terminal on the renderer transport, which has no socket to reconnect. Same failure mode the mobile side of this branch fixed. --- shared/engine/rpc-transport.test.ts | 26 ++++++++++++++++++++++++++ shared/engine/rpc-transport.tsx | 13 ++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 9d7261a9aa61..2119dfb3c401 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -219,6 +219,32 @@ test('seqids keep advancing after outstanding invocations are failed, so a late expect(secondSeqid).toBeGreaterThan(firstSeqid) }) +test('a throwing incoming handler does not desync the packetizer', () => { + const delivered: Array = [] + let shouldThrow = true + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + if (shouldThrow) { + shouldThrow = false + throw new Error('app handler blew up') + } + delivered.push(incoming) + }, + }) + + // Two well-formed frames arriving in a single chunk. The first frame's + // handler throws; the second frame must still be parsed and delivered. + const first = encodeFrame([2, 'keybase.1.test.first', [{}]]) + const second = encodeFrame([2, 'keybase.1.test.second', [{}]]) + const both = new Uint8Array(first.length + second.length) + both.set(first, 0) + both.set(second, first.length) + + transport.packetizeData(both) + + expect(delivered).toHaveLength(1) +}) + test('cancel packets surface a cancelled response payload', () => { const incoming = jest.fn() const transport = new TestTransport({incomingRPCCallback: incoming}) diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index f79e1cf612a0..bdd26c7fcf99 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -1,4 +1,5 @@ import {decode, encode} from '@msgpack/msgpack' +import logger from '@/logger' const MESSAGE_TYPE_INVOKE = 0 const MESSAGE_TYPE_RESPONSE = 1 @@ -326,7 +327,17 @@ export abstract class RPCTransport { } const payload = decode(payloadBytes) p.consumeBytes(payloadLen) - this.dispatchDecodedMessage(payload) + + // Dispatch outside the framing try: an app-side handler that throws must + // not reach the catch below, which resets the packetizer and discards + // every buffered byte. That leaves parsing to resume at an arbitrary + // offset -- and on the renderer transport there is no socket to + // reconnect, so it never recovers. + try { + this.dispatchDecodedMessage(payload) + } catch (e) { + logger.error('dispatchDecodedMessage threw', e) + } } } catch (err) { p.reset() From 3c7e51a6e1cf8850e91cdb13b2ab4ca5e009f53d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:06:38 -0400 Subject: [PATCH 22/80] docs(rpc): withdraw the Task 9 latch and correct the Task 7 arithmetic Task 9 is withdrawn: the reset storm it targets is unreachable now that onFatal_ runs synchronously on the single reader thread, and every way of clearing the latch either no-ops or permanently wedges delivery. Task 7's parsed_size() approach is replaced with a manual totalFed counter, because unpacker::next() zeroes parsed_size on every success. --- plans/2026-07-26-rpc-bridge-review-fixes.md | 56 ++++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/plans/2026-07-26-rpc-bridge-review-fixes.md b/plans/2026-07-26-rpc-bridge-review-fixes.md index 98ee796e98bb..32c97f60f222 100644 --- a/plans/2026-07-26-rpc-bridge-review-fixes.md +++ b/plans/2026-07-26-rpc-bridge-review-fixes.md @@ -719,7 +719,9 @@ close a connection a concurrent writeArr may have just dialed." So a truncated or over-long content frame is undetected: the unpacker reads into the following frame, and the parity check only trips later, or never — if the shifted bytes happen to begin with a positive int. After a mid-stream resync the parser can land on a `0x05` fixint *inside a string payload*, accept it as a header, and hand whatever parses next to JS as an RPC message. JS then gets a bogus `[type, seqid, …]` and may resolve the wrong seqid. The detector reports "no desync" while delivering corrupt data. -`msgpack::unpacker::parsed_size()` (`msgpack/v1/unpack.hpp:1319`) makes this exact for the cost of one comparison. +**Do NOT use `parsed_size()` for this.** It is not a cumulative stream position: `unpacker::next()` calls `reset()` on every success (`msgpack/v2/unpack.hpp:92-101`), and `parser::reset()` sets `m_parsed = 0` (`msgpack/v2/parse.hpp:969`). So `parsed_size()` reads 0 immediately after any successful `next()`, and a delta between two such reads is always `0 - 0`. An earlier revision of this task specified exactly that and would have rejected every non-empty frame — a self-inflicted DoS on the mainline decode path, invisible to `-fsyntax-only`. + +Track the consumed count manually instead. `nonparsed_size()` (`m_used - m_off`) IS accurate at any moment, so if `RecvState` accumulates every byte handed to `reserve_buffer`/`buffer_consumed`, then `totalFed - up.nonparsed_size()` is a genuine monotonic count of bytes consumed so far, unaffected by the per-object reset. **Files:** - Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp:625-655` (the `while (true)` parse loop in `onDataFromGo`) @@ -737,11 +739,15 @@ struct KBBridge::RecvState { // Persist across calls: a frame's header and its content routinely arrive // in separate reads. size_t declaredSize = 0; - size_t sizeAtHeader = 0; + size_t consumedAtHeader = 0; + // Every byte ever handed to the unpacker. parsed_size() cannot serve this + // role -- next() zeroes it on each success -- but totalFed minus + // nonparsed_size() is an accurate running count of what has been consumed. + size_t totalFed = 0; }; ``` -Read `resetRecvLocked()` and confirm it constructs a fresh `RecvState` (which zeroes these for free). If it instead mutates fields in place, add the two resets there — do not assume. +Read `resetRecvLocked()` and confirm it constructs a fresh `RecvState` (which zeroes these for free). If it instead mutates fields in place, add the three resets there — do not assume. - [ ] **Step 2: Verify content consumed exactly the declared length** @@ -763,7 +769,7 @@ Replace the parse loop body: throw std::runtime_error("bad rpc frame header"); } recv_->declaredSize = static_cast(o.as()); - recv_->sizeAtHeader = up.parsed_size(); + recv_->consumedAtHeader = recv_->totalFed - up.nonparsed_size(); recv_->state = ReadState::needContent; } else { // The header is only a plausibility check on its own: a fixint @@ -772,7 +778,8 @@ Replace the parse loop body: // declared byte count makes the framing self-checking, so a // truncated or overlong frame is caught here instead of being // handed to JS as a bogus [type, seqid, ...]. - const size_t consumed = up.parsed_size() - recv_->sizeAtHeader; + const size_t consumed = + (recv_->totalFed - up.nonparsed_size()) - recv_->consumedAtHeader; if (consumed != recv_->declaredSize) { throw std::runtime_error("rpc frame length mismatch"); } @@ -782,12 +789,28 @@ Replace the parse loop body: } ``` -- [ ] **Step 3: Syntax check** +- [ ] **Step 3: Feed `totalFed` where bytes enter the unpacker** + +In `onDataFromGo`, immediately after the existing `up.buffer_consumed(...)` call, add: + +```cpp + recv_->totalFed += static_cast(size); +``` + +Without this the counter never advances and the check is meaningless. Confirm there is exactly one place bytes enter the unpacker; if there is more than one, every such site must update `totalFed`. + +- [ ] **Step 4: Prove the arithmetic on a real frame, not just a syntax check** + +`-fsyntax-only` cannot catch wrong arithmetic here, and the previous revision of this task shipped a version that rejected every frame. Write a throwaway harness in `/tmp/` that links the real msgpack headers, packs two well-formed frames (`` twice, with different content sizes), feeds them to a `msgpack::unpacker` **split across three chunk boundaries** — including one that splits a header from its content — and asserts the computed `consumed` equals `declaredSize` for both frames. Then feed a deliberately truncated frame and assert it does NOT match. + +Run it and paste the actual output into your report. If `consumed` is 0 or mismatched for a valid frame, stop and report BLOCKED — do not commit. + +- [ ] **Step 5: Syntax check the real file** Run the fast C++-only check from Task 0 Step 3. -Expected: exit 0. If `parsed_size` is reported as not a member, confirm the msgpack include path resolves to `msgpack-cxx-7.0.0`. +Expected: exit 0. -- [ ] **Step 4: Commit** +- [ ] **Step 6: Commit** ```bash git add rnmodules/react-native-kb/cpp/ @@ -914,6 +937,21 @@ consumed and unreplayable) to onFatal so JS fails its outstanding RPCs." --- +### Task 9: WITHDRAWN — the latch is unnecessary and actively harmful + +**Do not implement this task.** It was attempted (commit `0ed435d`) and reverted. Recorded here so the reasoning is not lost and nobody re-derives it. + +Two findings killed it: + +1. **The premise is already dead.** The task assumed stale in-flight bytes keep arriving after a desync and each triggers another reset. But `onFatal_` runs *synchronously* on the single serial reader thread, so `KeybaseReset` completes **before** the loop's next `ReadArr`, and the stale bytes are discarded wholesale along with the closed connection. They are never re-read. One desync already produces exactly one reset. Task 6's synchronous fatal handling closed this scenario. + +2. **Every way of clearing the latch is broken.** Clearing it inside the fatal handler (this task's original Step 4) is a zero-width no-op — the latch is false again before the next chunk arrives, on the same synchronous call chain. Clearing it only from the platform read-error branches instead produces a **permanent wedge**: after `KeybaseReset` nils the connection, the next `ReadArr` dials a fresh one via `ensureConnection`, and `LoopbackConn.Read` (`go/libkb/loopback.go:120-138`) blocks until real data — it never returns `(0, nil)`. So the reconnect succeeds with `err == nil`, the error branch never fires, `resetRecv()` is never called, and `onDataFromGo`'s entry guard drops every byte for the rest of the process. `engineReset` also bypasses `resetRecv()`, so there is no user-triggered escape short of an app restart. + +If a genuine reset storm is ever observed in the field, the fix is a signal that distinguishes "a new connection is up and serving data" from "nothing has arrived yet" — which does not exist today — not a latch cleared by either of the paths above. + +
+Original (withdrawn) task text, kept for reference + ### Task 9: C++ — latch after a fatal so one desync isn't a reset storm **The bug.** Every desynced chunk calls `onFatal_()` → `KeybaseReset()` + engine-reset meta event → JS fails all outstanding RPCs. But bytes already in flight from the pre-reset stream keep arriving on the reader thread and keep failing the header check, so a single desync produces N resets in a row, each nuking whatever JS re-issued in between. Go's read buffer is 300KB (`keybase.go:348`), so a 1MB backlog is ~4 iterations. @@ -1025,6 +1063,8 @@ each reset failed whatever JS had re-issued since the last one. Drop incoming data until the platform layer confirms the connection was replaced." ``` +
+ --- ### Task 10: TS — wire the mobile account-switch reset, or delete the dead method From 05b67be6d6f1d7475dc9fe444ee66c15e4c05927 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:10:02 -0400 Subject: [PATCH 23/80] fix(engine): answer the caller when an incoming invoke handler throws The per-message catch swallowed the throw but never settled the response, so a throwing custom-response handler left the service waiting on a reply that never came -- a permanent per-call hang. Keep the per-message shape (failing the batch would drop the remaining messages) and error the response instead. --- shared/engine/rpc-transport.test.ts | 15 +++++++++++++++ shared/engine/rpc-transport.tsx | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 2119dfb3c401..2dda76934ee6 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -245,6 +245,21 @@ test('a throwing incoming handler does not desync the packetizer', () => { expect(delivered).toHaveLength(1) }) +test('a throwing invoke handler still answers the caller with an error', () => { + const transport = new TestTransport({ + incomingRPCCallback: () => { + throw new Error('handler blew up') + }, + }) + + transport.dispatchDecodedMessage([0, 7, 'keybase.1.test.hello', [{}]]) + + // Something must go back for seqid 7 -- otherwise the service waits forever. + expect(transport.sent).toEqual([ + [1, 7, {code: errors.UNKNOWN_METHOD, desc: 'No method available', name: 'UNKNOWN_METHOD'}, null], + ]) +}) + test('cancel packets surface a cancelled response payload', () => { const incoming = jest.fn() const transport = new TestTransport({incomingRPCCallback: incoming}) diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index bdd26c7fcf99..9dc9e346444e 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -365,7 +365,14 @@ export abstract class RPCTransport { response: this.makeResponse(seqid), } if (this._incomingRPCCallback) { - this._incomingRPCCallback(payload) + try { + this._incomingRPCCallback(payload) + } catch (e) { + // The handler threw before settling payload.response, so the + // service side would otherwise wait forever on this seqid. + logger.error('incoming invoke handler threw', e) + payload.response.error?.(makeTransportError('UNKNOWN_METHOD')) + } } else { payload.response.error?.(makeTransportError('UNKNOWN_METHOD')) } From 07057c5ff04e5f0097c39dcc279e7b1b928d046f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:14:54 -0400 Subject: [PATCH 24/80] fix(engine): make RPC response settlement idempotent Task 12's invoke-throw catch could double-settle a seqid: an auto-ack handler that calls response.result() and then throws (e.g. a throwing reducer) would have the catch call response.error() on top of the already-sent result, sending two conflicting RESPONSE messages for the same seqid. Guard settlement in makeResponse itself so result()/error() are answerable exactly once, regardless of call site, and log loudly if something attempts a second settle. --- shared/engine/rpc-transport.test.ts | 28 ++++++++++++++++++++++++++++ shared/engine/rpc-transport.tsx | 11 +++++++++++ 2 files changed, 39 insertions(+) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 2dda76934ee6..0e584c7785c7 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -260,6 +260,34 @@ test('a throwing invoke handler still answers the caller with an error', () => { ]) }) +test('a response cannot be settled twice: error() after result() is a no-op', () => { + let payload: Parameters[0] | undefined + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + payload = incoming + }, + }) + + transport.dispatchDecodedMessage([0, 9, 'keybase.1.test.hello', [{}]]) + payload?.response?.result?.({ok: true}) + payload?.response?.error?.({code: errors.UNKNOWN_METHOD, desc: 'No method available', name: 'UNKNOWN_METHOD'}) + + expect(transport.sent).toEqual([[1, 9, null, {ok: true}]]) +}) + +test('an invoke handler that settles result() and then throws does not also send an error', () => { + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + incoming.response?.result?.({ok: true}) + throw new Error('reducer blew up after acking') + }, + }) + + transport.dispatchDecodedMessage([0, 13, 'keybase.1.test.hello', [{}]]) + + expect(transport.sent).toEqual([[1, 13, null, {ok: true}]]) +}) + test('cancel packets surface a cancelled response payload', () => { const incoming = jest.fn() const transport = new TestTransport({incomingRPCCallback: incoming}) diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index 9dc9e346444e..ca8649cecb76 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -505,12 +505,23 @@ export abstract class RPCTransport { } private makeResponse(seqid: number): ResponseType { + let settled = false return { cancelled: false, error: err => { + if (settled) { + logger.error(`Attempted to settle response for seqid ${seqid} twice (error after already settled)`) + return + } + settled = true this.send([MESSAGE_TYPE_RESPONSE, seqid, err, null]) }, result: result => { + if (settled) { + logger.error(`Attempted to settle response for seqid ${seqid} twice (result after already settled)`) + return + } + settled = true this.send([MESSAGE_TYPE_RESPONSE, seqid, null, result]) }, seqid, From 0ace12ed2cc876d27bafd064100edde883e7b4fd Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:19:44 -0400 Subject: [PATCH 25/80] fix(engine): report the disconnect on a mobile engine reset The reset handler called connectCallback without ever calling disconnectCallback, so _onDisconnect never ran: sessions were not cancelled and the daemon error was never set, leaving the UI with no reconnect state. A pending prompt stayed on screen bound to a dead seqid, and answering it wrote an unknown seqid into the new connection. Also clear the packetizer in failAllOutstanding, matching close() and onDisconnected() -- on the renderer transport this runs on the account switch, where a half-delivered frame would corrupt the first post-switch decode. --- shared/engine/index.platform.tsx | 4 ++++ shared/engine/rpc-transport.test.ts | 22 ++++++++++++++++++++++ shared/engine/rpc-transport.tsx | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index 5ac754a00d9e..caeb4b7914f6 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -216,7 +216,11 @@ function createClient( switch (payload) { case 'kb-engine-reset': // Go dropped the loopback connection; anything in flight is dead. + // Report the disconnect before the reconnect so the engine cancels + // its sessions and the UI shows the reconnect state -- the desktop + // socket path does the same pair. client.transport.reset() + disconnectCallback() connectCallback() } } catch (e) { diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 0e584c7785c7..950caa781ae1 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -288,6 +288,28 @@ test('an invoke handler that settles result() and then throws does not also send expect(transport.sent).toEqual([[1, 13, null, {ok: true}]]) }) +test('failAllOutstanding clears buffered frame bytes', () => { + const delivered: Array = [] + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + delivered.push(incoming) + }, + }) + + // Feed half a frame -- the packetizer buffers a partial header+payload. + const first = encodeFrame([2, 'keybase.1.test.first', [{}]]) + transport.packetizeData(first.slice(0, first.length - 2)) + transport.failAllOutstanding() + + // A complete, unrelated frame arrives next. If the stale half-frame was not + // dropped, it prefixes these bytes and corrupts the decode. + const second = encodeFrame([2, 'keybase.1.test.second', [{}]]) + transport.packetizeData(second) + + expect(delivered).toHaveLength(1) + expect((delivered[0] as {method: string}).method).toBe('keybase.1.test.second') +}) + test('cancel packets surface a cancelled response payload', () => { const incoming = jest.fn() const transport = new TestTransport({incomingRPCCallback: incoming}) diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index ca8649cecb76..5d5d4c2f8706 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -487,6 +487,10 @@ export abstract class RPCTransport { // the engine resets; without it the callbacks are never invoked and every // in-flight RPC hangs forever. failAllOutstanding(err: unknown = makeEOFError()) { + // Also drop any partial frame: on the renderer transport this runs on the + // account switch, and a half-delivered pre-switch frame would otherwise + // concatenate with post-switch bytes into one corrupt decode. + this._packetizer.reset() this.failOutstanding(err, {}) } From 14e1e9bf33f88b18e8bdcc69273fb57db9a3eea2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:28:34 -0400 Subject: [PATCH 26/80] fix(rpc): clear the pending JNI exception when NewByteArray fails NewByteArray leaves OutOfMemoryError pending on failure. Returning false without clearing it meant the JS thread's next JNI call ran with a live exception -- UB, and an abort under CheckJNI. Adopt the array into a local_ref so the ref is also released if rpcOnGo throws. --- rnmodules/react-native-kb/android/cpp-adapter.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index c1b841ca8271..8b7347db9e7e 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -27,14 +27,20 @@ class KbNativeAdapter { auto env = jni::Environment::current(); auto jba = env->NewByteArray(size); if (jba == nullptr) { + // NewByteArray leaves OutOfMemoryError pending. Returning with it still + // set means the JS thread makes its next JNI call with a live exception + // -- UB, and an abort under CheckJNI. + env->ExceptionClear(); return false; } + // Adopt into a local_ref so the ref is released even if the call below + // throws a JniException. + auto arr = jni::adopt_local(static_cast(jba)); env->SetByteArrayRegion(jba, 0, size, (jbyte *)ptr); static auto method = JKbModule::javaClassStatic() ->getMethod)>("rpcOnGo"); - auto ok = method(jModule_, jni::wrap_alias(jba)); - env->DeleteLocalRef(jba); + auto ok = method(jModule_, arr); return ok != JNI_FALSE; } From 508f85aedf352c0286669fc1122665c67d2123f3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:34:24 -0400 Subject: [PATCH 27/80] fix(bind): reset the connection on a partial WriteArr Not reachable through LoopbackConn, whose Write is all-or-nothing, but if a short write ever happened the peer's framer would hold a truncated frame and consume every later write as its remainder -- corrupting the outbound stream indefinitely with no reset and no desync signal, since the stream-fatal machinery is inbound-only. --- go/bind/keybase.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 84983ccf4a73..5658c898664e 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -586,7 +586,15 @@ func WriteArr(b []byte) (err error) { if n != len(bytes) { log("Go: WriteArr short write conn=%s wrote=%d expected=%d appState=%s", describeConn(currentConn), n, len(bytes), appStateForLog()) - return errors.New("Did not write all the data") + // Not reachable through LoopbackConn today, whose Write is + // all-or-nothing. If a future transport can short-write, the peer's + // framer is left holding a partial frame and every later write would + // be consumed as its remainder, so drop the connection rather than + // corrupt the stream indefinitely. + if ierr := Reset(); ierr != nil { + log("failed to Reset after short write: %v", ierr) + } + return fmt.Errorf("Did not write all the data: wrote %d of %d", n, len(bytes)) } return nil } From acbfff76273861ec6bc3b8b4629c38a3c1c254e8 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:39:13 -0400 Subject: [PATCH 28/80] docs(rpc): correct the reader-loop idle comments ReadArr blocks in LoopbackConn.Read; it does not poll and return nil when idle, so the sleep was justified by a rationale the code does not have. The empty-data branch is actually a degenerate case reachable only if Init never ran and the shared buffer is zero-length -- log it once instead of spinning silently or flooding the log at 100Hz. --- .../src/main/java/com/reactnativekb/KbModule.kt | 12 ++++++++++-- rnmodules/react-native-kb/ios/Kb.mm | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index d4c7fedd8265..0fd78249208d 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -522,13 +522,21 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // instance, so capturing one would pin its ReactContext (and Activity) for // the life of the process. It forwards to whichever module is current. private class ReadFromKBLib : Runnable { + private var loggedEmptyRead = false + override fun run() { while (true) { try { val data: ByteArray? = readArr() if (data == null || data.isEmpty()) { - // readArr yields nothing when the connection had - // nothing for us; without a pause this spins a core. + // Not the idle path: readArr blocks until there is + // data, so an empty non-error result is degenerate -- + // reachable if Init never ran and the shared buffer is + // zero-length, which would otherwise spin silently. + if (!loggedEmptyRead) { + NativeLogger.warn("$NAME: read returned no data; is Keybase initialized?") + loggedEmptyRead = true + } Thread.sleep(10) continue } diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 1b23532c99d8..191d3d62a280 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -428,8 +428,16 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime continue; } if (data.length == 0) { - // ReadArr returns (nil, nil) when the connection had nothing for - // us; without a pause this spins a core at full speed. + // Not the idle path: ReadArr blocks in LoopbackConn.Read until + // there is data, so an empty non-error result is degenerate (it + // needs n == 0 with no error, which a blocking read does not + // produce). It is reachable if Init never ran and the shared + // buffer is zero-length, which would otherwise spin silently. + static BOOL loggedEmptyRead = NO; + if (!loggedEmptyRead) { + kbLogToService(@"rpc read returned no data; is Keybase initialized?"); + loggedEmptyRead = YES; + } [NSThread sleepForTimeInterval:0.01]; continue; } From 53f0bdc7bc2e447e7f731f747c748813e6d44d99 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:44:41 -0400 Subject: [PATCH 29/80] fix(rpc): route Android native bridge errors into the uploadable log The C++ onError and desync messages only reached logcat, which a field log send does not include -- so the two failure modes this branch added detection for were invisible in exactly the reports that need them. iOS already routed through kbLogToService. Also move the JS write-failure warn onto logger so it appears in a log send, and rate-limit the recurring read-error log lines on both iOS and Android so a wedged connection doesn't flood the log at ~10Hz. --- .../react-native-kb/android/cpp-adapter.cpp | 15 +++++++++++- .../main/java/com/reactnativekb/KbModule.kt | 24 +++++++++++++++++-- rnmodules/react-native-kb/ios/Kb.mm | 17 ++++++++++--- shared/engine/rpc-transport.tsx | 2 +- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index 8b7347db9e7e..4ab0e70d8da0 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -50,6 +50,17 @@ class KbNativeAdapter { JKbModule::javaClassStatic()->getMethod("onRpcStreamFatal"); method(jModule_); } + + // Called from JNI. Routes native bridge errors into the uploadable log -- + // __android_log_print only reaches logcat, which a field log send does not + // include. + void onLog(const std::string &message) { + jni::ThreadScope scope; + static auto method = + JKbModule::javaClassStatic() + ->getMethod)>("onNativeLog"); + method(jModule_, jni::make_jstring(message)); + } }; // The bridge is created on the JS thread and consumed by the native reader @@ -118,15 +129,17 @@ getBindingsInstaller(jni::alias_ref thiz) { [adapter](void *ptr, size_t size) -> bool { return adapter->writeToGo(ptr, size); }, - [](const std::string &err) { + [adapter](const std::string &err) { __android_log_print(ANDROID_LOG_ERROR, "KBBridge", "JSI error: %s", err.c_str()); + adapter->onLog("jsi error: " + err); }, // The incoming stream desynced; reset the Go connection and tell // JS so it fails outstanding RPCs rather than hanging forever. [adapter]() { __android_log_print(ANDROID_LOG_ERROR, "KBBridge", "rpc stream desync, resetting connection"); + adapter->onLog("rpc stream desync, resetting connection"); adapter->onFatal(); }); diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 0fd78249208d..8286d1aa2f22 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -523,6 +523,18 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // the life of the process. It forwards to whichever module is current. private class ReadFromKBLib : Runnable { private var loggedEmptyRead = false + private var readErrorCount = 0 + + // A read error retries every ~100ms; if the connection can't be + // re-established that is a ~10Hz flood into the uploadable log. + // Unlike loggedEmptyRead (a one-shot degenerate case) a recurring + // read error is exactly what an operator needs to see recur, so + // this logs the first few occurrences, then backs off to every + // Nth rather than going silent. + private fun shouldLogReadError(): Boolean { + readErrorCount++ + return readErrorCount <= 5 || readErrorCount % 50 == 0 + } override fun run() { while (true) { @@ -552,8 +564,8 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // hang forever. if (e.message != null && e.message.equals("Read error: EOF")) { NativeLogger.info("Got EOF from read, connection reset.") - } else { - NativeLogger.error("Exception in ReadFromKBLib.run", e) + } else if (shouldLogReadError()) { + NativeLogger.error("Exception in ReadFromKBLib.run (count=$readErrorCount)", e) } instance?.onRpcConnectionReset() try { Thread.sleep(100) } catch (ie: InterruptedException) { Thread.currentThread().interrupt(); return } @@ -618,6 +630,14 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T reactContext.runOnUiQueueThread { relayReset() } } + // Called from JNI. Routes native bridge errors into the uploadable log -- + // __android_log_print only reaches logcat, which a field log send does not + // include. + @DoNotStrip + fun onNativeLog(message: String) { + NativeLogger.error("$NAME: $message") + } + @ReactMethod override fun iosGetHasShownPushPrompt(promise: Promise) { promise.reject(Exception("wrong platform")) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 191d3d62a280..13f259f8dafc 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -412,9 +412,20 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // JS thinks it has is gone and every in-flight RPC is dead. Tell // JS so it fails them instead of spinning forever, and drop any // half-parsed frame so the next connection starts clean. - kbLogToService([NSString - stringWithFormat:@"rpc read error, connection reset: %@", - error.localizedDescription]); + // + // This retries every ~100ms below, so if the connection can't be + // re-established this is a ~10Hz flood into the uploadable log. + // Unlike the empty-read case above (a one-shot degenerate state) + // a recurring read error is exactly what an operator needs to see + // recur, so log the first few, then back off to every Nth rather + // than going silent. + static int readErrorCount = 0; + readErrorCount++; + if (readErrorCount <= 5 || readErrorCount % 50 == 0) { + kbLogToService([NSString + stringWithFormat:@"rpc read error, connection reset (count=%d): %@", + readErrorCount, error.localizedDescription]); + } if (auto bridge = kbGetBridge()) { bridge->resetRecv(); } diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index 5d5d4c2f8706..187deaa7c27c 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -432,7 +432,7 @@ export abstract class RPCTransport { try { this.writeMessage(message) } catch (err) { - console.warn('Failed to write RPC message', err) + logger.error('Failed to write RPC message', err) return false } return true From fd5963d938dd4082ca339517ea938cb93a3e3196 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:50:31 -0400 Subject: [PATCH 30/80] fix(rpc): reset the read-error rate-limit counter on a successful read Without this, an unrelated later failure episode picks up mid-backoff from an earlier one and skips its own "first 5" onset logging -- exactly the window an operator needs to see. Reset only on a genuine successful read, not on the degenerate empty-data branch, which has its own separate log-once flag. --- .../android/src/main/java/com/reactnativekb/KbModule.kt | 1 + rnmodules/react-native-kb/ios/Kb.mm | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 8286d1aa2f22..a48316f25657 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -552,6 +552,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T Thread.sleep(10) continue } + readErrorCount = 0 instance?.nativeOnDataFromGo(data) } catch (e: InterruptedException) { Thread.currentThread().interrupt() diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 13f259f8dafc..46ca89377e9b 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -401,6 +401,11 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime KeybaseNotifyJSReady(); NSLog(@"Notified Go that JS is ready, starting ReadArr loop"); + // Consecutive read-error count, used to rate-limit the log line below. + // Reset to 0 on every genuine successful read so each new failure + // episode gets its own "first 5" logging window, rather than picking + // up mid-backoff from an earlier, unrelated episode. + static int readErrorCount = 0; while (true) { // The block never returns, so the queue's pool never drains on its // own — each iteration needs its own. @@ -419,7 +424,6 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // a recurring read error is exactly what an operator needs to see // recur, so log the first few, then back off to every Nth rather // than going silent. - static int readErrorCount = 0; readErrorCount++; if (readErrorCount <= 5 || readErrorCount % 50 == 0) { kbLogToService([NSString @@ -452,6 +456,7 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime [NSThread sleepForTimeInterval:0.01]; continue; } + readErrorCount = 0; auto bridge = kbGetBridge(); if (bridge) { bridge->onDataFromGo((uint8_t *)[data bytes], (int)[data length]); From 6e2da28fc30e151ec15788b68afecb73bbb98f94 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 08:56:39 -0400 Subject: [PATCH 31/80] fix(rpc): release the receive buffer peak and fix the size-limit margin msgpack::unpacker rewinds but never shrinks, so a single large attachment frame pinned up to 64MB of native RSS for the session -- the send side already caps its retained capacity. Track the high-water mark of declared frame sizes and start over at a needSize boundary with nothing unparsed, which is a safe resync point since totalFed/consumedAtHeader reset together with the unpacker. Also give the limit check headroom: nonparsed_size() includes the header and any of the next frame already buffered, so a legal maximal frame tripped the fatal path on its last chunk. --- .../react-native-kb/cpp/react-native-kb.cpp | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index f9c3a72bf991..c6a08eb52b24 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -1,4 +1,5 @@ #include "react-native-kb.h" +#include #include #include #include @@ -22,6 +23,16 @@ constexpr uint64_t kMaxFrameSize = 64ull * 1024 * 1024; constexpr size_t kMaxDepth = 1024; // Don't let one huge attachment frame permanently retain its peak size. constexpr size_t kSendBufKeepCapacity = 4u * 1024 * 1024; +// msgpack::unpacker rewinds its buffer in place but never shrinks the +// realloc'd allocation, so this bounds how long a single large frame's peak +// stays resident. +constexpr size_t kRecvBufKeepCapacity = 4u * 1024 * 1024; +// nonparsed_size() includes the frame header plus any bytes of the next +// frame already buffered in the same read; the header check separately +// accepts a declared size of exactly kMaxFrameSize. Without this margin a +// legal maximal frame arriving in small chunks trips the limit on its last +// chunk. +constexpr size_t kMaxFrameSlack = 1024 * 1024; constexpr size_t kMaxCachedPropNames = 4096; } // namespace @@ -36,6 +47,12 @@ struct KBBridge::RecvState { // role -- next() zeroes it on each success -- but totalFed minus // nonparsed_size() is an accurate running count of what has been consumed. size_t totalFed = 0; + // High-water mark of declaredSize since the last reset. declaredSize + // itself gets overwritten by the next header before we necessarily reach a + // safe (nonparsed_size() == 0) point to act on it, so this survives that + // overwrite and lets the shrink check fire for the frame that actually + // grew the buffer. + size_t peakFrameSize = 0; }; struct KBBridge::SendState { @@ -653,6 +670,8 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { throw std::runtime_error("bad rpc frame header"); } recv_->declaredSize = static_cast(o.as()); + recv_->peakFrameSize = + std::max(recv_->peakFrameSize, recv_->declaredSize); recv_->consumedAtHeader = recv_->totalFed - up.nonparsed_size(); recv_->state = ReadState::needContent; } else { @@ -677,9 +696,19 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { } } - if (up.nonparsed_size() > kMaxFrameSize) { + if (up.nonparsed_size() > kMaxFrameSize + kMaxFrameSlack) { throw std::runtime_error("rpc frame exceeds size limit"); } + + // needSize with nothing unparsed is a provably safe resync point: no + // partial frame and no unparsed bytes to lose. resetRecvLocked() + // rebuilds RecvState (including totalFed and the unpacker) together, + // so the totalFed/consumedAtHeader invariant restarts from a + // consistent fresh baseline rather than desyncing. + if (recv_->state == ReadState::needSize && up.nonparsed_size() == 0 && + recv_->peakFrameSize > kRecvBufKeepCapacity) { + resetRecvLocked(); + } } catch (const std::exception &e) { fatal = true; fatalMsg = std::string("Error in onDataFromGo: ") + e.what(); From 6dddc5c36cb8de6e898a4f72b6f50d8ed66caaf5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:04:35 -0400 Subject: [PATCH 32/80] fix(bind): return a copy from ReadArr instead of a shared-buffer view ReadArr handed back a view of one package-global buffer, making 'exactly one reader, forever' a load-bearing invariant enforced only by comments across Go, ObjC and Kotlin. gomobile copies at the language boundary regardless, so a fresh slice costs one memcpy of a few KB on a call already crossing into JNI/ObjC, and a stray second reader can no longer corrupt an in-flight delivery. --- go/bind/keybase.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 5658c898664e..e0792b23b109 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -603,7 +603,12 @@ func WriteArr(b []byte) (err error) { var buffer []byte // ReadArr is a blocking read for msgpack rpc data. -// It is called serially by the mobile run loops. +// It must still be called serially by the mobile run loops: conn.Read +// ordering depends on it, and the msgpack::unpacker behind onDataFromGo +// on the C++ side is not thread-safe. The returned slice is now this +// call's own copy, so a second reader would no longer corrupt an +// in-flight delivery -- it would just be an ordering bug, not memory +// corruption. func ReadArr() (data []byte, err error) { defer func() { err = flattenError(err) }() @@ -641,10 +646,14 @@ func ReadArr() (data []byte, err error) { } // Deliver data even if err != nil (allowed by the net.Conn // contract); a persistent failure is returned by a later call. - // Returning a view of the shared buffer is safe because gomobile - // copies the bytes across the boundary and ReadArr is called - // serially. - return buffer[0:n], nil + // + // Copy out of the shared buffer rather than returning a view of it: + // gomobile copies again at the JNI/ObjC boundary regardless, so this + // costs one extra memcpy of a few KB on a call already crossing a + // language boundary, and it removes buffer aliasing as a hazard. + out := make([]byte, n) + copy(out, buffer[:n]) + return out, nil } if err != nil { From 189748a073fde5db7db74a6dfd00a6c944b95ea4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:08:10 -0400 Subject: [PATCH 33/80] fix(engine): fail loudly when rpcOnJs count and payload disagree A non-array with count > 1 fell through to a single dispatch, discarding count-1 messages with only a generic warning. Native always batches into an array, so a mismatch means the two sides disagree and should say so. Also give the three identical '>>>> rpcOnJs JS thrown!' sites distinct messages -- they guard different things and a log dump could not tell them apart. --- shared/engine/index.platform.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index caeb4b7914f6..0ccdb3f5d990 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -191,7 +191,7 @@ function createClient( try { client.transport.dispatchDecodedMessage(obj) } catch (e) { - logger.error('>>>> rpcOnJs JS thrown!', e) + logger.error('rpcOnJs: dispatch threw', e) } } @@ -199,7 +199,13 @@ function createClient( // whole batch delivery and unwind into native code. global.rpcOnJs = (objs: unknown, count: number) => { try { - if (count > 1 && Array.isArray(objs)) { + if (count > 1) { + if (!Array.isArray(objs)) { + // Native always sends an array when it batches, so this means the + // two sides disagree -- and count-1 messages would vanish silently. + logger.error(`rpcOnJs: count ${count} but payload is not an array`) + return + } for (const obj of objs) { dispatchOne(obj) } @@ -207,7 +213,7 @@ function createClient( dispatchOne(objs) } } catch (e) { - logger.error('>>>> rpcOnJs JS thrown!', e) + logger.error('rpcOnJs: batch guard threw', e) } } @@ -251,7 +257,7 @@ function createClient( try { client.transport.packetizeData(data as Uint8Array) } catch (e) { - logger.error('>>>> rpcOnJs JS thrown!', e) + logger.error('>>>> engineIncoming IPC JS thrown!', e) } }) From def5fb41f8884fc1421649917e3144e4eacf9950 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:11:52 -0400 Subject: [PATCH 34/80] fix(engine): surface failed writes on the response and renderer paths makeResponse ignored send()'s new false return, so a write that failed while answering an incoming service RPC left the service hanging forever -- the mirror image of the invoke-direction hang this branch fixed. ProxyNativeTransport.writeMessage had the same shape: a missing engineSend no-oped and reported success, leaving the invocation outstanding. --- shared/engine/index.platform.test.ts | 18 ++++++++++++++++++ shared/engine/index.platform.tsx | 8 +++++++- shared/engine/rpc-transport.test.ts | 21 +++++++++++++++++++++ shared/engine/rpc-transport.tsx | 13 +++++++++++-- 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 shared/engine/index.platform.test.ts diff --git a/shared/engine/index.platform.test.ts b/shared/engine/index.platform.test.ts new file mode 100644 index 000000000000..8a1ad7865c05 --- /dev/null +++ b/shared/engine/index.platform.test.ts @@ -0,0 +1,18 @@ +/// + +// jest.setup.js sets isMobile=false, isRenderer=true, and _fromPreload.functions +// with no engineSend -- exactly the "renderer up, preload never wired engineSend" +// case this test drives. +import {createClient} from './index.platform' + +test('a missing engineSend fails the write instead of silently no-oping', () => { + const client = createClient( + () => {}, + () => {}, + () => {} + ) + + const ok = client.transport.send([1, 3, null, {}]) + + expect(ok).toBe(false) +}) diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index 0ccdb3f5d990..a3d0acfab166 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -143,7 +143,13 @@ class NativeTransport extends TransportShared { class ProxyNativeTransport extends LocalTransport { protected writeMessage(message: RPCMessage) { const {engineSend} = KB2.functions - engineSend?.(message) + if (!engineSend) { + // Silently no-oping here reports success upstream (send() returns true) + // while the invocation is never delivered, leaving it outstanding + // forever. Throwing lets the transport fail it, same as mobile. + throw new Error('engineSend missing') + } + engineSend(message) } // On account-switch reset fail outstanding invocations so pre-switch RPC // callbacks can't fire later against post-switch state diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 950caa781ae1..f2d7ab95d1dd 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -1,5 +1,6 @@ /// +import logger from '@/logger' import { RPCTransport, encodeFrame, @@ -310,6 +311,26 @@ test('failAllOutstanding clears buffered frame bytes', () => { expect((delivered[0] as {method: string}).method).toBe('keybase.1.test.second') }) +test('a failed response write is reported, not swallowed', () => { + let payload: Parameters[0] | undefined + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + payload = incoming + }, + writeError: new Error('native write failed'), + }) + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}) + + transport.dispatchDecodedMessage([0, 11, 'keybase.1.test.hello', [{}]]) + payload?.response?.result?.({ok: true}) + + // send() itself already logs the generic write failure; makeResponse must + // add a second, seqid-specific log or the service-side hang is invisible. + expect(errorSpy).toHaveBeenCalledTimes(2) + expect(errorSpy.mock.calls.some(args => args.some(a => typeof a === 'string' && a.includes('11')))).toBe(true) + errorSpy.mockRestore() +}) + test('cancel packets surface a cancelled response payload', () => { const incoming = jest.fn() const transport = new TestTransport({incomingRPCCallback: incoming}) diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index 187deaa7c27c..b3c91dcb12fe 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -518,7 +518,12 @@ export abstract class RPCTransport { return } settled = true - this.send([MESSAGE_TYPE_RESPONSE, seqid, err, null]) + if (!this.send([MESSAGE_TYPE_RESPONSE, seqid, err, null])) { + // The service is waiting on this reply and nothing else will tell + // it. The write already failed (send() logged that), so there's no + // connection left to retry on -- surface which seqid was lost. + logger.error(`failed to write error response for seqid ${seqid}`) + } }, result: result => { if (settled) { @@ -526,7 +531,11 @@ export abstract class RPCTransport { return } settled = true - this.send([MESSAGE_TYPE_RESPONSE, seqid, null, result]) + if (!this.send([MESSAGE_TYPE_RESPONSE, seqid, null, result])) { + // Same as above: the write failed, the connection is gone, and + // nothing will retry this seqid. + logger.error(`failed to write response for seqid ${seqid}`) + } }, seqid, } From bba933a0d5429bb0385d8b2a4a88c64b57702f39 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:17:05 -0400 Subject: [PATCH 35/80] fix(rpc): wrap batch reply by converted count, not original batch size If all but one message in a multi-message batch failed to convert, the delivery lambda still took the array branch (values->size() > 1) and sent a length-1 jsi::Array with count=1. JS's rpcOnJs only unwraps the array when count > 1, so it handed the wrapper array itself to isRPCMessage, which rejected it and dropped the one message that did survive, with no seqid or context in the log. Branch on converted.size() instead: zero stays a no-op, exactly one goes through as a bare value like the single-message fast path, and only two or more get the array wrapper. --- .../react-native-kb/cpp/react-native-kb.cpp | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index c6a08eb52b24..ea91850bf132 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -798,12 +798,21 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { if (self->isTornDown_.load()) { return; } - jsi::Array arr(runtime, converted.size()); - for (size_t i = 0; i < converted.size(); ++i) { - arr.setValueAtIndex(runtime, i, std::move(converted[i])); + // The wire shape must match what actually survived conversion, not + // the original batch size: JS's rpcOnJs only unwraps the array when + // count > 1, so a single surviving message must go through as a bare + // value like the values->size() == 1 path above, or JS hands the + // wrapper array itself to isRPCMessage and silently drops it. + if (converted.size() == 1) { + onJs.call(runtime, std::move(converted[0]), jsi::Value(1)); + } else { + jsi::Array arr(runtime, converted.size()); + for (size_t i = 0; i < converted.size(); ++i) { + arr.setValueAtIndex(runtime, i, std::move(converted[i])); + } + onJs.call(runtime, std::move(arr), + jsi::Value(static_cast(converted.size()))); } - onJs.call(runtime, std::move(arr), - jsi::Value(static_cast(converted.size()))); } } catch (const std::exception &e) { // Nothing decoded reached JS, so every reply in this batch is lost and From 99f7a07d71dd73ecd354ca3db84d6e0694ab207a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:20:00 -0400 Subject: [PATCH 36/80] fix(rpc): escalate to onFatal when an entire batch fails to convert If every message in a multi-message batch failed conversion, the delivery lambda returned silently once converted was empty. Those messages were already consumed off the wire during parsing, so their seqids can never get a reply and the JS-side callers waiting on them would hang forever -- the same failure the single-message path already escalates via the outer catch, now handled consistently regardless of how many messages happened to be coalesced into the read. values is never empty when this lambda runs (onDataFromGo returns earlier otherwise), so this only fires when something was genuinely dropped. --- rnmodules/react-native-kb/cpp/react-native-kb.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index ea91850bf132..b4bf42746ed2 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -793,6 +793,18 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { } } if (converted.empty()) { + // Every message in the batch was already irrevocably consumed off + // the wire during parsing, so whatever seqids they carried will + // never get a reply -- their JS-side callers would hang forever. + // `values` is never empty here (onDataFromGo returns before + // scheduling this lambda otherwise), so this only fires when + // something was genuinely dropped. + self->reportError("dropped entire batch: all " + + std::to_string(values->size()) + + " message(s) failed to convert"); + if (self->onFatal_) { + self->onFatal_(); + } return; } if (self->isTornDown_.load()) { From 15647bb4d47c84b190576b8ad49d589bb2c11894 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:35:28 -0400 Subject: [PATCH 37/80] fix(android): move nativeInvalidate to invalidate(), not onHostDestroy destroy() is only reachable from onHostDestroy, which fires when the last Activity dies while the ReactInstance/TurboModules survive (MainApplication holds reactHost in an application-scoped `by lazy`). Tearing down the native bridge there permanently wedges the app: back button to home, reopen -> g_bridge stays null for the rest of the process. invalidate() is the Android analogue of iOS's -invalidate and fires on real TurboModule teardown, so that's where nativeInvalidate belongs. destroy() keeps only its Keybase.reset()/relayReset()/executor work. --- .../src/main/java/com/reactnativekb/KbModule.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index a48316f25657..e0a4096a38da 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -583,8 +583,8 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // process (back button to home, then reopen). It is only a gate: the // JNI callee ignores the receiver and routes through g_bridge, so a // stale instance delivers to the correct current bridge regardless. - // Bridge teardown is handled by nativeInvalidate below. - nativeInvalidate() + // Bridge teardown belongs to invalidate() below, which fires on real + // ReactInstance teardown, not Activity death. try { Keybase.reset() relayReset() @@ -593,6 +593,15 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } + // Fires on real ReactInstance/TurboModule teardown (reload, instance + // recreation) — unlike onHostDestroy, which fires when the last Activity + // dies while the ReactInstance and this module survive. This is the only + // place that should ever clear the native bridge. + override fun invalidate() { + nativeInvalidate() + super.invalidate() + } + // Called from JNI (cpp-adapter writeToGo), not from JS. DoNotStrip keeps it // from being removed/renamed by ProGuard since the only caller is reflective. // Returns false when the payload never reached Go, so the caller can fail From bdbeb39662b0eeedec207064cfca18076b37be0c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:35:34 -0400 Subject: [PATCH 38/80] fix(android): guard nativeInvalidate's bridge clear with identity check g_bridge was cleared unconditionally, the same TOCTOU iOS's kbClearBridgeIfCurrent guards against: a stale invalidate landing after the next module already installed a new bridge would tear down the LIVE bridge silently, with no desync and no recovery path. Compare against the adapter's own module reference before clearing, mirroring the iOS fix. KbModule.destroy() keeps its Keybase.reset()/relayReset() calls unconditional -- after the invalidate() move, destroy() runs on onHostDestroy, a different lifecycle event than bridge teardown, and was never bridge-identity-scoped even on master. It owns "tell JS the Activity is gone"; invalidate() owns the bridge's own teardown. --- rnmodules/react-native-kb/android/cpp-adapter.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index 4ab0e70d8da0..9270ed0e3fe1 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -83,11 +83,21 @@ static std::shared_ptr getBridge() { // Only atomics and shared_ptr slots are touched — the old bridge's jsi // handles belong to its own runtime and are released by its kbTeardown host // object on the JS thread. -static void nativeInvalidate(jni::alias_ref) { +// A stale invalidate can run after the next module already installed its own +// bridge -- RN's module teardown is not ordered against the next module's +// installJSIBindings/getBindingsInstaller, so without an identity check an +// unconditional clear would tear down the LIVE bridge with no desync and no +// recovery. Mirrors iOS's kbClearBridgeIfCurrent: only clear if the module +// invoking invalidate is still the one that installed the current +// adapter/bridge. +static void nativeInvalidate(jni::alias_ref thiz) { std::shared_ptr oldBridge; std::shared_ptr oldAdapter; { std::lock_guard lock(g_mutex); + if (!g_adapter || !(g_adapter->jModule_ == thiz)) { + return; + } oldBridge = std::move(g_bridge); oldAdapter = std::move(g_adapter); g_bridge = nullptr; From 9f6c4d7228be44b05a8c062a52fc7bf53ac50e3b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:40:02 -0400 Subject: [PATCH 39/80] fix(rpc): throttle the kb-engine-reset emit on a persistent read error Task 17 rate-limited the read-error log line but not the emit itself. Both platforms retry the failed read every ~100ms, so a connection that cannot be re-dialed emitted kb-engine-reset at ~10Hz. JS's disconnectCallback runs a full session-cancel sweep with its own log, and connectCallback re-dispatches the bootstrap path -- neither is free to re-run at that rate, unlike transport.reset() which is idempotent. Throttle with an exponential backoff instead of a hard cap: the first failure still emits immediately so JS learns promptly, then each subsequent failure in the same episode doubles the wait up to a several-second ceiling. Resets alongside the existing read-error log counter on the next successful read, so a later, unrelated episode gets its own prompt first emit. Uses its own counter/timer rather than reusing Task 17's log-rate counter -- the two have different cadences. --- .../main/java/com/reactnativekb/KbModule.kt | 43 +++++++++++++++++-- rnmodules/react-native-kb/ios/Kb.mm | 36 +++++++++++++--- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index e0a4096a38da..7b0535b80a3e 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -524,6 +524,8 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T private class ReadFromKBLib : Runnable { private var loggedEmptyRead = false private var readErrorCount = 0 + private var emitBackoffMs = 0L + private var emitNotBeforeMs = 0L // A read error retries every ~100ms; if the connection can't be // re-established that is a ~10Hz flood into the uploadable log. @@ -536,6 +538,25 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T return readErrorCount <= 5 || readErrorCount % 50 == 0 } + // Throttles the kb-engine-reset EMIT, separately from the log line + // above -- they have different cadences and must not share a + // counter. JS's disconnectCallback does a full session-cancel sweep + // (with a log of its own) and connectCallback re-dispatches the + // bootstrap path, so a connection that cannot be re-dialed must not + // re-trigger those at ~10Hz. The first failure emits immediately so + // JS learns promptly, then backs off exponentially to a ceiling. + // Reset alongside readErrorCount on the next successful read, so a + // later, unrelated episode again emits promptly. + private fun shouldEmitEngineReset(): Boolean { + val now = android.os.SystemClock.elapsedRealtime() + if (now < emitNotBeforeMs) { + return false + } + emitBackoffMs = if (emitBackoffMs == 0L) EMIT_BACKOFF_INITIAL_MS else minOf(emitBackoffMs * 2, EMIT_BACKOFF_CEILING_MS) + emitNotBeforeMs = now + emitBackoffMs + return true + } + override fun run() { while (true) { try { @@ -553,6 +574,8 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T continue } readErrorCount = 0 + emitBackoffMs = 0L + emitNotBeforeMs = 0L instance?.nativeOnDataFromGo(data) } catch (e: InterruptedException) { Thread.currentThread().interrupt() @@ -568,11 +591,16 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } else if (shouldLogReadError()) { NativeLogger.error("Exception in ReadFromKBLib.run (count=$readErrorCount)", e) } - instance?.onRpcConnectionReset() + instance?.onRpcConnectionReset(shouldEmitEngineReset()) try { Thread.sleep(100) } catch (ie: InterruptedException) { Thread.currentThread().interrupt(); return } } } } + + companion object { + private const val EMIT_BACKOFF_INITIAL_MS = 500L + private const val EMIT_BACKOFF_CEILING_MS = 5000L + } } fun destroy() { @@ -635,9 +663,18 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // connection underneath us. Unlike onRpcStreamFatal we must NOT call // Keybase.reset() -- ReadArr already did, and a second reset would close a // connection a concurrent writeArr may have just dialed. - fun onRpcConnectionReset() { + // + // nativeResetRecv() runs every time regardless of `emit`: it is cheap and + // must happen on every failed read. `emit` only throttles the + // kb-engine-reset meta event, which the caller rate-limits with a + // backoff -- a connection that cannot be re-dialed retries this path at + // ~10Hz, and disconnectCallback/connectCallback on the JS side are not + // free to re-run at that rate. + fun onRpcConnectionReset(emit: Boolean) { nativeResetRecv() - reactContext.runOnUiQueueThread { relayReset() } + if (emit) { + reactContext.runOnUiQueueThread { relayReset() } + } } // Called from JNI. Routes native bridge errors into the uploadable log -- diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 46ca89377e9b..2a5bdffafb8c 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -36,6 +36,10 @@ + (id)sharedFsPathsHolder { @end static NSString *const metaEventEngineReset = @"kb-engine-reset"; +// Backoff bounds for throttling the kb-engine-reset emit on a persistent +// read-error loop; see the readErrorCount/emitBackoffSeconds comment below. +static const NSTimeInterval kEngineResetEmitInitialBackoff = 0.5; +static const NSTimeInterval kEngineResetEmitBackoffCeiling = 5.0; static __weak Kb *kbSharedInstance = nil; // Guards the compare-and-clear in invalidate against init's write: init runs @@ -406,6 +410,17 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // episode gets its own "first 5" logging window, rather than picking // up mid-backoff from an earlier, unrelated episode. static int readErrorCount = 0; + // Throttles the kb-engine-reset EMIT below, separately from + // readErrorCount above -- they have different cadences and must not + // share a counter. disconnectCallback does a full session-cancel sweep + // (with its own log) and connectCallback re-dispatches the bootstrap + // path, so a connection that cannot be re-dialed must not re-trigger + // those at the ~10Hz this loop retries at. The first failure emits + // immediately so JS learns promptly; each later failure in the same + // episode backs off exponentially to a ceiling. Reset alongside + // readErrorCount on the next successful read. + static NSTimeInterval emitBackoffSeconds = 0; + static NSTimeInterval emitNotBeforeTime = 0; while (true) { // The block never returns, so the queue's pool never drains on its // own — each iteration needs its own. @@ -433,12 +448,19 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime if (auto bridge = kbGetBridge()) { bridge->resetRecv(); } - dispatch_async(dispatch_get_main_queue(), ^{ - Kb *instance = kbSharedInstance; - if (instance && [instance canEmit]) { - [instance emitOnMetaEvent:metaEventEngineReset]; - } - }); + NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; + if (now >= emitNotBeforeTime) { + emitBackoffSeconds = emitBackoffSeconds == 0 + ? kEngineResetEmitInitialBackoff + : MIN(emitBackoffSeconds * 2, kEngineResetEmitBackoffCeiling); + emitNotBeforeTime = now + emitBackoffSeconds; + dispatch_async(dispatch_get_main_queue(), ^{ + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnMetaEvent:metaEventEngineReset]; + } + }); + } [NSThread sleepForTimeInterval:0.1]; continue; } @@ -457,6 +479,8 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime continue; } readErrorCount = 0; + emitBackoffSeconds = 0; + emitNotBeforeTime = 0; auto bridge = kbGetBridge(); if (bridge) { bridge->onDataFromGo((uint8_t *)[data bytes], (int)[data length]); From 0abb9db9ae7b058907780055b3523309751cbddd Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:40:20 -0400 Subject: [PATCH 40/80] cleanup(rpc): resetRecv on the fatal path + comment/blank-line fixes a) Task 8 made onFatal_ reachable from the JS thread for conversion failures and a missing rpcOnJs, not just the reader thread's framing desync. Those paths reset the Go connection but left recv_ holding bytes from the dead stream, guaranteeing one extra desync cycle on reconnect. Add the existing resetRecv call to both platform fatal handlers (iOS Kb.mm's fatal callback via kbGetBridge(), Android's onRpcStreamFatal via nativeResetRecv()). Confirmed no deadlock: onDataFromGo releases recvMutex_ before invoking onFatal_ on the reader-thread path, and the JS-thread paths run entirely outside that lock's scope. b) Correct react-native-kb.h's onFatal doc comment: it now covers conversion failures and a missing rpcOnJs in addition to framing desync, and can fire on the JS thread as well as the reader thread. c) Reword go/bind/keybase.go's ReadArr comment to state the current copy-semantics property instead of narrating the change from a shared-buffer view. d) Reword cpp-adapter.cpp's strong-capture comment to state the invariant instead of narrating what a weak capture used to do wrong. e) Remove a stray blank line before the class close in shared/engine/rpc-transport.tsx. --- go/bind/keybase.go | 7 +++---- rnmodules/react-native-kb/android/cpp-adapter.cpp | 9 ++++----- .../src/main/java/com/reactnativekb/KbModule.kt | 12 +++++++++--- rnmodules/react-native-kb/cpp/react-native-kb.h | 9 +++++++-- rnmodules/react-native-kb/ios/Kb.mm | 12 ++++++++++++ shared/engine/rpc-transport.tsx | 1 - 6 files changed, 35 insertions(+), 15 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index e0792b23b109..2c6c039f864e 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -605,10 +605,9 @@ var buffer []byte // ReadArr is a blocking read for msgpack rpc data. // It must still be called serially by the mobile run loops: conn.Read // ordering depends on it, and the msgpack::unpacker behind onDataFromGo -// on the C++ side is not thread-safe. The returned slice is now this -// call's own copy, so a second reader would no longer corrupt an -// in-flight delivery -- it would just be an ordering bug, not memory -// corruption. +// on the C++ side is not thread-safe. The returned slice is this call's +// own copy, so a second concurrent caller could only produce an ordering +// bug, never memory corruption from an aliased in-flight delivery. func ReadArr() (data []byte, err error) { defer func() { err = flattenError(err) }() diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index 9270ed0e3fe1..e427d06b6f40 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -122,11 +122,10 @@ getBindingsInstaller(jni::alias_ref thiz) { g_adapter = adapter; } - // Captured strongly: the adapter must outlive the bridge that calls it, and - // the g_adapter slot is not an ownership anchor -- installing a second - // module overwrites it while the first bridge is still live and installed, - // which with a weak capture failed every rpcOnGo in that window. No cycle: - // the adapter holds a global_ref to the Java module and never the bridge. + // Captured strongly: the adapter must outlive the bridge that calls it, + // since installing a second module overwrites the g_adapter slot while the + // first bridge is still live and installed. No cycle: the adapter holds a + // global_ref to the Java module and never the bridge. return BindingsInstallerHolder::newObjectCxxArgs( [adapter]( jsi::Runtime &runtime, diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 7b0535b80a3e..bfbf48cb0f34 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -645,12 +645,18 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } - // Called from JNI when the incoming byte stream desyncs. Resetting the Go - // connection and relaying the meta event lets JS fail its outstanding RPCs - // instead of waiting on a channel that can no longer deliver. + // Called from JNI when the incoming byte stream desyncs -- also true + // since this can escalate for a msgpack->JSI conversion failure or a + // missing rpcOnJs, arriving on the JS thread rather than the reader + // thread. Either way recv_ still holds bytes from the now-dead + // connection; nativeResetRecv() drops them so the next connection + // doesn't desync on its very first frame. Resetting the Go connection + // and relaying the meta event lets JS fail its outstanding RPCs instead + // of waiting on a channel that can no longer deliver. @DoNotStrip fun onRpcStreamFatal() { NativeLogger.warn("$NAME: rpc stream desync, resetting connection") + nativeResetRecv() try { Keybase.reset() } catch (e: Exception) { diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.h b/rnmodules/react-native-kb/cpp/react-native-kb.h index 91d7fdb3e45f..159fc0554441 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.h +++ b/rnmodules/react-native-kb/cpp/react-native-kb.h @@ -29,8 +29,13 @@ class KBBridge : public std::enable_shared_from_this { // `writeToGo` returns false if the native write failed, so the caller's // RPC can be failed instead of hanging forever waiting for a reply. // `onFatal` is invoked when the incoming byte stream can no longer be - // trusted (desynced framing, oversized frame). The platform layer is - // expected to reset the Go connection and emit the engine-reset meta + // trusted (desynced framing, oversized frame), when a decoded message + // fails msgpack->JSI conversion (single message, or an entire batch), or + // when rpcOnJs is not installed. The first case runs synchronously on the + // native reader thread inside onDataFromGo; the latter three run + // asynchronously on the JS thread, from the callInvoker lambda scheduled + // by onDataFromGo. The platform layer is expected to reset the Go + // connection, drop any buffered recv state, and emit the engine-reset meta // event so JS fails its outstanding RPCs. void install(facebook::jsi::Runtime &runtime, std::shared_ptr callInvoker, diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 2a5bdffafb8c..a9c168064468 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -341,8 +341,20 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // fatal callback: the incoming stream desynced. Reset the Go // connection and tell JS, so it fails outstanding RPCs rather than // leaving every caller hanging on a channel that can't recover. + // + // Also true since this can escalate for a msgpack->JSI conversion + // failure or a missing rpcOnJs, arriving on the JS thread rather than + // the reader thread -- either way recv_ still holds bytes from the + // now-dead connection, so drop them here too or the next connection + // desyncs on its very first frame. Goes through kbGetBridge() rather + // than capturing `bridge` directly: this lambda is stored inside the + // bridge's own onFatal_ member, so a direct capture would be a + // shared_ptr cycle. []() { kbLogToService(@"rpc stream desync, resetting connection"); + if (auto bridge = kbGetBridge()) { + bridge->resetRecv(); + } NSError *error = nil; KeybaseReset(&error); if (error) { diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index b3c91dcb12fe..454d3630b1cf 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -540,7 +540,6 @@ export abstract class RPCTransport { seqid, } } - } export {encodeFrame, makeEOFError, makeTransportError} From 71f0801942a437b609829b8e52fb67c7efc40a47 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:50:17 -0400 Subject: [PATCH 41/80] fix(rnmodules): use monotonic clock for engine-reset emit backoff NSDate/timeIntervalSinceReferenceDate is wall-clock and can jump backward on an NTP correction during a read-error episode (plausible at cold boot), which would push emitNotBeforeTime arbitrarily far out and suppress the kb-engine-reset emit. Switch to CACurrentMediaTime, matching Android's SystemClock.elapsedRealtime(). --- rnmodules/react-native-kb/ios/Kb.mm | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index a9c168064468..b420dd0da513 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -4,6 +4,7 @@ #import #import #import +#import #import #import #import @@ -460,7 +461,12 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime if (auto bridge = kbGetBridge()) { bridge->resetRecv(); } - NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; + // CACurrentMediaTime is monotonic and immune to wall-clock/NTP + // adjustments, unlike NSDate/[NSDate timeIntervalSinceReferenceDate]: + // a backward clock correction during a read-error episode (plausible + // at cold boot) must not push emitNotBeforeTime into the future and + // suppress the kb-engine-reset emit. + CFTimeInterval now = CACurrentMediaTime(); if (now >= emitNotBeforeTime) { emitBackoffSeconds = emitBackoffSeconds == 0 ? kEngineResetEmitInitialBackoff From aeedcc1f8df98141b8a0171453bd7a0528f4e81c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 09:59:46 -0400 Subject: [PATCH 42/80] fix(ios): guard myBridge_ ivar with kbBridgeMutex myBridge_ was written on the JS thread in installJSIBindingsWithRuntime and read/cleared on the TurboModule shared method queue in invalidate with no lock of its own; only kbCurrentBridge was synchronized. Reuse kbBridgeMutex for this ivar too, keeping the compare-and-clear in kbClearBridgeIfCurrent intact and never holding the lock across a call into Objective-C or Go. --- rnmodules/react-native-kb/ios/Kb.mm | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index b420dd0da513..ddde795067f5 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -185,6 +185,11 @@ static bool kbUses24HourClockForLocale(NSLocale *_Nonnull locale) { } @implementation Kb { + // Guarded by kbBridgeMutex: written on the JS thread in + // installJSIBindingsWithRuntime, read and cleared on the TurboModule shared + // method queue in invalidate. Reusing kbBridgeMutex (rather than a second + // lock) keeps this ivar and kbCurrentBridge consistent with each other + // without ever nesting the two critical sections. std::shared_ptr myBridge_; } @@ -301,11 +306,19 @@ - (void)invalidate { // Both the teardown and the Go reset are gated on still being the current // bridge: a reload can install the next module's bridge before this runs, // and clearing that one would leave the app wedged with no way to notice. - if (kbClearBridgeIfCurrent(myBridge_)) { + std::shared_ptr mine; + { + std::lock_guard lock(kbBridgeMutex); + mine = myBridge_; + } + if (kbClearBridgeIfCurrent(mine)) { NSError *error = nil; KeybaseReset(&error); } - myBridge_ = nullptr; + { + std::lock_guard lock(kbBridgeMutex); + myBridge_ = nullptr; + } } RCT_EXPORT_METHOD(setEnablePasteImage:(BOOL)enabled) { @@ -370,7 +383,10 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime }); }); - myBridge_ = bridge; + { + std::lock_guard lock(kbBridgeMutex); + myBridge_ = bridge; + } kbSetBridge(bridge); kbLogToService(@"jsi install success (via installJSIBindings)"); } From abf70299806e57174a2ac6403462ecad45ef972b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:00:49 -0400 Subject: [PATCH 43/80] fix(rpc): throttle EOF reads and only advance reset-backoff on delivery Android's EOF read-error branch logged unconditionally at the loop's ~100ms retry cadence, an untracked ~10Hz flood into the uploadable log during a persistent outage; it now shares the existing shouldLogReadError() counter with the non-EOF branch. On both platforms the read-error engine-reset backoff advanced its window even when the emit was never going to be delivered (no shared instance / not yet able to emit), so a single dropped notification cost a full backoff window for free. Both platforms now check deliverability synchronously before committing to the backoff advance. Also route an Android writeToGo NewByteArray OOM through ExceptionDescribe() and the existing onLog sink so it reaches the uploadable log instead of only logcat. --- .../react-native-kb/android/cpp-adapter.cpp | 6 +++- .../main/java/com/reactnativekb/KbModule.kt | 33 ++++++++++++++++--- rnmodules/react-native-kb/ios/Kb.mm | 23 ++++++++----- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index e427d06b6f40..149a9a91ea77 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -29,8 +29,12 @@ class KbNativeAdapter { if (jba == nullptr) { // NewByteArray leaves OutOfMemoryError pending. Returning with it still // set means the JS thread makes its next JNI call with a live exception - // -- UB, and an abort under CheckJNI. + // -- UB, and an abort under CheckJNI. Describe it to logcat before + // clearing, and route a line through onLog so an OOM here also reaches + // the uploadable log, not just logcat. + env->ExceptionDescribe(); env->ExceptionClear(); + onLog("writeToGo: NewByteArray failed (out of memory), dropping message"); return false; } // Adopt into a local_ref so the ref is released even if the call below diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index bfbf48cb0f34..d8cd2baf1a26 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -459,6 +459,14 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } + // Synchronous, best-effort mirror of relayReset()'s own check, callable + // from the reader thread: lets a caller decide whether an emit has any + // chance of being delivered before committing to it. + internal fun canDeliverReset(): Boolean = reactContext.hasActiveReactInstance() && canEmit() + + // No current caller (kept for future use). If ever wired up, this must + // reset the parser along with the Go connection, or the next connection + // desyncs on its first frame -- see the other reset paths in this file. @ReactMethod override fun engineReset() { try { @@ -547,9 +555,15 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // JS learns promptly, then backs off exponentially to a ceiling. // Reset alongside readErrorCount on the next successful read, so a // later, unrelated episode again emits promptly. - private fun shouldEmitEngineReset(): Boolean { + // + // `deliverable` gates the backoff advance itself, not just the emit: + // an emit that has nowhere to go (no active react instance / can't + // emit yet) must not cost a full backoff window, or a dropped + // notification during e.g. a reload delays the next one that could + // actually be delivered. + private fun shouldEmitEngineReset(deliverable: Boolean): Boolean { val now = android.os.SystemClock.elapsedRealtime() - if (now < emitNotBeforeMs) { + if (now < emitNotBeforeMs || !deliverable) { return false } emitBackoffMs = if (emitBackoffMs == 0L) EMIT_BACKOFF_INITIAL_MS else minOf(emitBackoffMs * 2, EMIT_BACKOFF_CEILING_MS) @@ -587,11 +601,22 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // surprise -- but JS still has to be told, or its callers // hang forever. if (e.message != null && e.message.equals("Read error: EOF")) { - NativeLogger.info("Got EOF from read, connection reset.") + // EOF is the ordinary shape of a persistent-read + // outage, not a surprise, but at this loop's ~100ms + // retry it is still a ~10Hz flood into the uploadable + // log if left unthrottled -- share the read-error + // counter with the non-EOF branch below, since both + // are just "reads are failing". + if (shouldLogReadError()) { + NativeLogger.info("Got EOF from read, connection reset (count=$readErrorCount).") + } } else if (shouldLogReadError()) { NativeLogger.error("Exception in ReadFromKBLib.run (count=$readErrorCount)", e) } - instance?.onRpcConnectionReset(shouldEmitEngineReset()) + val inst = instance + if (inst != null) { + inst.onRpcConnectionReset(shouldEmitEngineReset(inst.canDeliverReset())) + } try { Thread.sleep(100) } catch (ie: InterruptedException) { Thread.currentThread().interrupt(); return } } } diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index ddde795067f5..79e4794a0f92 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -484,16 +484,21 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // suppress the kb-engine-reset emit. CFTimeInterval now = CACurrentMediaTime(); if (now >= emitNotBeforeTime) { - emitBackoffSeconds = emitBackoffSeconds == 0 - ? kEngineResetEmitInitialBackoff - : MIN(emitBackoffSeconds * 2, kEngineResetEmitBackoffCeiling); - emitNotBeforeTime = now + emitBackoffSeconds; - dispatch_async(dispatch_get_main_queue(), ^{ - Kb *instance = kbSharedInstance; - if (instance && [instance canEmit]) { + // Only advance the backoff window when the emit is actually + // deliverable now -- checked synchronously here rather than + // inside the dispatched block, so a dropped notification (no + // shared instance / not yet able to emit) costs nothing and the + // very next failure gets another chance to notify JS promptly. + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + emitBackoffSeconds = emitBackoffSeconds == 0 + ? kEngineResetEmitInitialBackoff + : MIN(emitBackoffSeconds * 2, kEngineResetEmitBackoffCeiling); + emitNotBeforeTime = now + emitBackoffSeconds; + dispatch_async(dispatch_get_main_queue(), ^{ [instance emitOnMetaEvent:metaEventEngineReset]; - } - }); + }); + } } [NSThread sleepForTimeInterval:0.1]; continue; From 8493d557f8602bdda0570d9340980f89fd8b045c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:01:02 -0400 Subject: [PATCH 44/80] fix(rpc): reset-ordering, catch-all, and dead-code robustness pass Reset the Go connection before resetting the C++/JNI parser in both fatal-desync handlers (iOS installJSIBindingsWithRuntime's onFatal callback, Android onRpcStreamFatal): the reader thread is still live on this path, so clearing the parser first left a window where bytes from the old connection could land in the freshly-reset unpacker mid-frame and trigger a second desync. Add a catch(...) arm to the per-message batch conversion loop in onDataFromGo, matching every other catch site in the file, so a non-std::exception throw drops just that message instead of escalating the whole batch to fatal. Document why peakFrameSize is a valid buffer-shrink trigger: it depends on Go's ReadArr reading into a fixed 300KB buffer (go/bind/keybase.go), which the C++ side had no record of. engineReset has no caller on either platform (kept for future use); give it the same parser reset the other reset paths already have, plus a comment noting it's currently unused, so it stays correct if something is ever wired up to call it. --- .../src/main/java/com/reactnativekb/KbModule.kt | 7 ++++++- .../react-native-kb/cpp/react-native-kb.cpp | 10 ++++++++++ rnmodules/react-native-kb/ios/Kb.mm | 16 +++++++++++++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index d8cd2baf1a26..8d9e8af01935 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -471,6 +471,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T override fun engineReset() { try { Keybase.reset() + nativeResetRecv() relayReset() } catch (e: Exception) { NativeLogger.error("Exception in engineReset", e) @@ -681,12 +682,16 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T @DoNotStrip fun onRpcStreamFatal() { NativeLogger.warn("$NAME: rpc stream desync, resetting connection") - nativeResetRecv() + // Reset the Go connection before the parser: the reader thread is + // still live on this path, so clearing the parser first would leave a + // window where bytes from the OLD connection land in the + // freshly-reset unpacker mid-frame, causing a second desync. try { Keybase.reset() } catch (e: Exception) { NativeLogger.error("Exception resetting after rpc desync", e) } + nativeResetRecv() reactContext.runOnUiQueueThread { relayReset() } } diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index b4bf42746ed2..9a230af71b70 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -52,6 +52,14 @@ struct KBBridge::RecvState { // safe (nonparsed_size() == 0) point to act on it, so this survives that // overwrite and lets the shrink check fire for the frame that actually // grew the buffer. + // + // This is only a meaningful shrink trigger because Go's ReadArr + // (go/bind/keybase.go) reads into a fixed 300KB buffer per call: a stream + // of many small frames delivered across many small reads can't durably + // grow the unpacker, since msgpack::unpacker rewinds its buffer in place + // once fully drained. If that Go-side buffer ever grows, peakFrameSize + // stops tracking "did one big frame arrive" and starts tracking "did + // several small frames arrive in the same read" instead. size_t peakFrameSize = 0; }; @@ -790,6 +798,8 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { } catch (const std::exception &e) { self->reportError(std::string("dropping undecodable message: ") + e.what()); + } catch (...) { + self->reportError("dropping undecodable message: unknown error"); } } if (converted.empty()) { diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 79e4794a0f92..e324e7b1fb42 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -366,15 +366,19 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // shared_ptr cycle. []() { kbLogToService(@"rpc stream desync, resetting connection"); - if (auto bridge = kbGetBridge()) { - bridge->resetRecv(); - } + // Reset the Go connection before the parser: the reader thread is + // still live on this path, so resetting the parser first would + // leave a window where bytes from the OLD connection land in the + // freshly-cleared unpacker mid-frame, causing a second desync. NSError *error = nil; KeybaseReset(&error); if (error) { kbLogToService([NSString stringWithFormat:@"reset after desync failed: %@", error.localizedDescription]); } + if (auto bridge = kbGetBridge()) { + bridge->resetRecv(); + } dispatch_async(dispatch_get_main_queue(), ^{ Kb *instance = kbSharedInstance; if (instance && [instance canEmit]) { @@ -403,9 +407,15 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime RCT_EXPORT_METHOD(shareListenersRegistered) { } +// No current caller (kept for future use). If ever wired up, this must reset +// the parser along with the Go connection, or the next connection desyncs on +// its first frame -- see the other reset paths in this file. RCT_EXPORT_METHOD(engineReset) { NSError *error = nil; KeybaseReset(&error); + if (auto bridge = kbGetBridge()) { + bridge->resetRecv(); + } if ([self canEmit]) { [self emitOnMetaEvent:metaEventEngineReset]; } From 1c841a2915984516044e80d2e8dad349614cba88 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:08:49 -0400 Subject: [PATCH 45/80] test(engine): extract dispatchRpcBatch and cover the mobile rpcOnJs path The rpcOnJs batch dispatcher was only reachable through the isMobile-gated global.rpcOnJs assignment inside createClient, so it had no test coverage. Pull the batch-expansion logic (single vs. array vs. malformed-batch guard) into an exported dispatchRpcBatch function that createClient now calls, and cover ordering, single-message dispatch, the non-array guard dropping the whole batch, and one message's dispatch throwing not stopping the rest. --- shared/engine/index.platform.test.ts | 47 ++++++++++++++++++++++++- shared/engine/index.platform.tsx | 51 +++++++++++++++++----------- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/shared/engine/index.platform.test.ts b/shared/engine/index.platform.test.ts index 8a1ad7865c05..36bb22c5845e 100644 --- a/shared/engine/index.platform.test.ts +++ b/shared/engine/index.platform.test.ts @@ -3,7 +3,7 @@ // jest.setup.js sets isMobile=false, isRenderer=true, and _fromPreload.functions // with no engineSend -- exactly the "renderer up, preload never wired engineSend" // case this test drives. -import {createClient} from './index.platform' +import {createClient, dispatchRpcBatch} from './index.platform' test('a missing engineSend fails the write instead of silently no-oping', () => { const client = createClient( @@ -16,3 +16,48 @@ test('a missing engineSend fails the write instead of silently no-oping', () => expect(ok).toBe(false) }) + +// dispatchRpcBatch backs the mobile global.rpcOnJs batch dispatcher (only +// wired up inside createClient's isMobile branch, which this desktop test +// env never takes). Exercise it directly instead. +describe('dispatchRpcBatch', () => { + test('a normal multi-message array dispatches every element in order', () => { + const dispatched: Array = [] + dispatchRpcBatch(['a', 'b', 'c'], 3, obj => dispatched.push(obj), () => {}) + expect(dispatched).toEqual(['a', 'b', 'c']) + }) + + test('a single message (count === 1) dispatches directly, not as a wrapper', () => { + const dispatched: Array = [] + dispatchRpcBatch({solo: true}, 1, obj => dispatched.push(obj), () => {}) + expect(dispatched).toEqual([{solo: true}]) + }) + + test('count > 1 with a non-array logs an error and dispatches nothing', () => { + const dispatched: Array = [] + const errors: Array = [] + dispatchRpcBatch({not: 'an array'}, 2, obj => dispatched.push(obj), msg => errors.push(msg)) + expect(dispatched).toEqual([]) + expect(errors).toEqual(['rpcOnJs: count 2 but payload is not an array']) + }) + + test("one message's dispatch throwing does not stop the remaining messages", () => { + const dispatched: Array = [] + const errors: Array = [] + // Mirrors production's dispatchOne: each item is individually try/caught + // so one bad message can't drop the rest of the batch. + const dispatchOne = (obj: unknown) => { + try { + if (obj === 'bad') { + throw new Error('dispatch threw') + } + dispatched.push(obj) + } catch (e) { + errors.push(`rpcOnJs: dispatch threw: ${String(e)}`) + } + } + dispatchRpcBatch(['a', 'bad', 'b'], 3, dispatchOne, msg => errors.push(msg)) + expect(dispatched).toEqual(['a', 'b']) + expect(errors).toEqual(['rpcOnJs: dispatch threw: Error: dispatch threw']) + }) +}) diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index a3d0acfab166..f0d73099b485 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -181,6 +181,37 @@ class NativeTransportMobile extends LocalTransport { } } +// Expands a native rpcOnJs batch into individual dispatchOne calls. Exported +// so the mobile batch path (otherwise only reachable through the +// isMobile-gated global.rpcOnJs assignment inside createClient) can be +// exercised directly in tests. +export const dispatchRpcBatch = ( + objs: unknown, + count: number, + dispatchOne: (obj: unknown) => void, + logError: (msg: string) => void +) => { + // Outer guard: this is called from native, so throwing here would abort the + // whole batch delivery and unwind into native code. + try { + if (count > 1) { + if (!Array.isArray(objs)) { + // Native always sends an array when it batches, so this means the + // two sides disagree -- and count-1 messages would vanish silently. + logError(`rpcOnJs: count ${count} but payload is not an array`) + return + } + for (const obj of objs) { + dispatchOne(obj) + } + } else { + dispatchOne(objs) + } + } catch (e) { + logError(`rpcOnJs: batch guard threw: ${String(e)}`) + } +} + function createClient( incomingRPCCallback: IncomingRPCCallbackType, connectCallback: ConnectDisconnectCB, @@ -201,26 +232,8 @@ function createClient( } } - // Outer guard: this is called from native, so throwing here would abort the - // whole batch delivery and unwind into native code. global.rpcOnJs = (objs: unknown, count: number) => { - try { - if (count > 1) { - if (!Array.isArray(objs)) { - // Native always sends an array when it batches, so this means the - // two sides disagree -- and count-1 messages would vanish silently. - logger.error(`rpcOnJs: count ${count} but payload is not an array`) - return - } - for (const obj of objs) { - dispatchOne(obj) - } - } else { - dispatchOne(objs) - } - } catch (e) { - logger.error('rpcOnJs: batch guard threw', e) - } + dispatchRpcBatch(objs, count, dispatchOne, msg => logger.error(msg)) } onMetaEvent((payload: string) => { From dd9f7d1439e19f2f9bc6741e5470dee62a2432b2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:08:56 -0400 Subject: [PATCH 46/80] fix(engine): don't log a false double-settle when a response already settled response.error() after auto-result() (e.g. a throwing reducer that runs after the auto-result() call for a non-custom-response incoming call) hit the settle guard's logger.error, even though that sequence is expected. Expose a read-only settled accessor on the response and have the INVOKE catch skip settling when the response already settled, while still logging a genuine double-settle (e.g. calling result() then error() directly). --- shared/engine/rpc-transport.test.ts | 45 +++++++++++++++++++++++++++++ shared/engine/rpc-transport.tsx | 20 +++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index f2d7ab95d1dd..0373fcae5030 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -289,6 +289,51 @@ test('an invoke handler that settles result() and then throws does not also send expect(transport.sent).toEqual([[1, 13, null, {ok: true}]]) }) +test('auto-result then a throwing handler sends exactly one message and logs no double-settle error', () => { + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + // Mirrors index.tsx: auto-result() for non-custom-response calls, + // then the next line (dispatching the action) throws. + incoming.response?.result?.({ok: true}) + throw new Error('reducer blew up after acking') + }, + }) + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}) + + transport.dispatchDecodedMessage([0, 21, 'keybase.1.test.hello', [{}]]) + + expect(transport.sent).toEqual([[1, 21, null, {ok: true}]]) + // The handler-threw log is expected; a second, double-settle log is not -- + // this is an expected sequence (auto-result then a throwing dispatch), not + // a real control-flow bug. + expect(errorSpy).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith('incoming invoke handler threw', expect.any(Error)) + expect(errorSpy.mock.calls.some(args => args.some(a => typeof a === 'string' && a.includes('twice')))).toBe( + false + ) + errorSpy.mockRestore() +}) + +test('a genuine double-settle -- result() then error() called directly -- still logs', () => { + let payload: Parameters[0] | undefined + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + payload = incoming + }, + }) + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}) + + transport.dispatchDecodedMessage([0, 22, 'keybase.1.test.hello', [{}]]) + payload?.response?.result?.({ok: true}) + payload?.response?.error?.({code: errors.UNKNOWN_METHOD, desc: 'No method available', name: 'UNKNOWN_METHOD'}) + + expect(transport.sent).toEqual([[1, 22, null, {ok: true}]]) + expect( + errorSpy.mock.calls.some(args => args.some(a => typeof a === 'string' && a.includes('twice'))) + ).toBe(true) + errorSpy.mockRestore() +}) + test('failAllOutstanding clears buffered frame bytes', () => { const delivered: Array = [] const transport = new TestTransport({ diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index 454d3630b1cf..2e1ef13e42a3 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -31,6 +31,11 @@ export type ResponseType = { seqid: number error?: (e?: ErrorType) => void result?: (r?: unknown) => void + // Read-only: true once error()/result() has settled this response. Lets a + // caller that catches after an intentional settle (e.g. a throwing + // reducer that runs after the auto-result() call) skip re-settling instead + // of tripping the double-settle guard below and logging a false alarm. + readonly settled?: boolean } export type PayloadType = { @@ -368,10 +373,16 @@ export abstract class RPCTransport { try { this._incomingRPCCallback(payload) } catch (e) { - // The handler threw before settling payload.response, so the - // service side would otherwise wait forever on this seqid. logger.error('incoming invoke handler threw', e) - payload.response.error?.(makeTransportError('UNKNOWN_METHOD')) + // If the handler already settled the response (e.g. an + // auto-result() call followed by a throwing reducer on the next + // line) it has already answered the service; settling again + // would only trip the double-settle guard and log a false + // alarm. Only settle here when nothing has answered yet -- the + // service side would otherwise wait forever on this seqid. + if (!payload.response.settled) { + payload.response.error?.(makeTransportError('UNKNOWN_METHOD')) + } } } else { payload.response.error?.(makeTransportError('UNKNOWN_METHOD')) @@ -512,6 +523,9 @@ export abstract class RPCTransport { let settled = false return { cancelled: false, + get settled() { + return settled + }, error: err => { if (settled) { logger.error(`Attempted to settle response for seqid ${seqid} twice (error after already settled)`) From d4470cae2e86502e209c18cc24ccb4136a5fba3a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:09:11 -0400 Subject: [PATCH 47/80] fix(engine): isolate disconnect/connect callbacks on kb-engine-reset Both ran inside one try/catch, so a throw from a session cancel handler during disconnectCallback (Engine._onDisconnect -> _cancelOutstandingSessions) skipped connectCallback and stranded the UI on the disconnect banner until the next reset event. Give each its own try/catch with a distinct log, keeping the reset -> disconnect -> connect ordering. --- shared/engine/index.platform.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index f0d73099b485..13b80c5183e9 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -243,10 +243,23 @@ function createClient( // Go dropped the loopback connection; anything in flight is dead. // Report the disconnect before the reconnect so the engine cancels // its sessions and the UI shows the reconnect state -- the desktop - // socket path does the same pair. + // socket path does the same pair. disconnectCallback and + // connectCallback are isolated in their own try/catch: a throw + // from a session cancel handler inside disconnectCallback must + // not strand the UI on the disconnect banner by skipping + // connectCallback (which synchronously clears the daemon error + // via startHandshake()). client.transport.reset() - disconnectCallback() - connectCallback() + try { + disconnectCallback() + } catch (e) { + logger.error('>>>> meta engine event: disconnectCallback threw', e) + } + try { + connectCallback() + } catch (e) { + logger.error('>>>> meta engine event: connectCallback threw', e) + } } } catch (e) { logger.error('>>>> meta engine event JS thrown!', e) From 57da73ab7f579994fda2c793c1507b35a0ea2852 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:13:47 -0400 Subject: [PATCH 48/80] fix(bind): make failure-driven resets compare-and-close Reset() closed whatever connection was current, with no notion of which one the caller had actually seen fail. A caller reacting to a failure on conn C1 could tear down a healthy C2 that another path had already re-dialed, silently discarding whatever had just been written to it. The Kotlin onRpcConnectionReset comment documents working around exactly this by declining to reset at all. Add a connEpoch bumped under connMutex on every successful dial, plus ResetIfCurrent(epoch), which no-ops when the epoch has moved on. ReadArr and WriteArr capture the epoch alongside the conn under the same lock and their failure branches use it. Reset() stays unconditional for callers that genuinely mean tear down whatever is current -- iOS invalidate, Android destroy and engineReset. Also correct the ReadArr comment: the returned copy protects the caller's slice, but two concurrent callers would still interleave into the shared package-level buffer and garble frame content, not merely ordering. --- go/bind/keybase.go | 82 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 2c6c039f864e..1986fec84f1b 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -58,9 +58,17 @@ var ( var ( jsReadyOnce sync.Once jsReadyCh = make(chan struct{}) - connMutex sync.Mutex // Protects conn operations + connMutex sync.Mutex // Protects conn and connEpoch ) +// connEpoch increments every time ensureConnection successfully (re)dials. +// Callers that observed a failure on a particular conn capture the epoch +// alongside it (while holding connMutex) and pass it to ResetIfCurrent, +// which only tears down the connection if nothing has re-dialed since — +// otherwise some other caller already recovered and this call is a stale, +// no-op complaint. Protected by connMutex. +var connEpoch int64 + func describeConn(c net.Conn) string { if c == nil { return "" @@ -571,6 +579,7 @@ func WriteArr(b []byte) (err error) { } } currentConn := conn + currentEpoch := connEpoch connMutex.Unlock() if currentConn == nil { @@ -590,8 +599,10 @@ func WriteArr(b []byte) (err error) { // all-or-nothing. If a future transport can short-write, the peer's // framer is left holding a partial frame and every later write would // be consumed as its remainder, so drop the connection rather than - // corrupt the stream indefinitely. - if ierr := Reset(); ierr != nil { + // corrupt the stream indefinitely. ResetIfCurrent (rather than Reset) + // so this doesn't tear down a connection some concurrent caller has + // already redialed since currentConn was captured above. + if ierr := ResetIfCurrent(currentEpoch); ierr != nil { log("failed to Reset after short write: %v", ierr) } return fmt.Errorf("Did not write all the data: wrote %d of %d", n, len(bytes)) @@ -605,9 +616,10 @@ var buffer []byte // ReadArr is a blocking read for msgpack rpc data. // It must still be called serially by the mobile run loops: conn.Read // ordering depends on it, and the msgpack::unpacker behind onDataFromGo -// on the C++ side is not thread-safe. The returned slice is this call's -// own copy, so a second concurrent caller could only produce an ordering -// bug, never memory corruption from an aliased in-flight delivery. +// on the C++ side is not thread-safe. The returned slice is this call's own +// copy, which only protects the caller of ReadArr — a second concurrent +// caller would still interleave its Read into the shared package-level +// buffer above, garbling frame content, not just ordering. func ReadArr() (data []byte, err error) { defer func() { err = flattenError(err) }() @@ -623,6 +635,7 @@ func ReadArr() (data []byte, err error) { } } currentConn := conn + currentEpoch := connEpoch connMutex.Unlock() if currentConn == nil { @@ -656,8 +669,10 @@ func ReadArr() (data []byte, err error) { } if err != nil { - // Attempt to fix the connection - if ierr := Reset(); ierr != nil { + // Attempt to fix the connection. ResetIfCurrent so a stale failure + // on an already-superseded conn doesn't tear down one a concurrent + // WriteArr just redialed. + if ierr := ResetIfCurrent(currentEpoch); ierr != nil { log("failed to Reset: %v", ierr) } return nil, fmt.Errorf("Read error: %s", err) @@ -693,21 +708,60 @@ func ensureConnection() error { log("ensureConnection: Dial failed after restart: %v", err) return fmt.Errorf("failed to dial after loopback restart: %s", err) } - log("ensureConnection: loopback server restarted successfully conn=%s appState=%s", - describeConn(conn), appStateForLog()) + connEpoch++ + log("ensureConnection: loopback server restarted successfully conn=%s epoch=%d appState=%s", + describeConn(conn), connEpoch, appStateForLog()) return nil } - log("Go: Established loopback connection conn=%s appState=%s", - describeConn(conn), appStateForLog()) + connEpoch++ + log("Go: Established loopback connection conn=%s epoch=%d appState=%s", + describeConn(conn), connEpoch, appStateForLog()) return nil } -// Reset resets the socket connection +// Reset unconditionally resets the socket connection. Use this only when the +// caller genuinely means "tear down whatever connection is current" (e.g. +// iOS invalidate, Android destroy/engineReset) — it will happily close a +// connection some concurrent failure-driven caller never saw fail. Callers +// reacting to a failure on a specific connection should use ResetIfCurrent +// instead so a stale complaint can't clobber a connection that has already +// been recovered. func Reset() error { connMutex.Lock() defer connMutex.Unlock() + return resetLocked() +} + +// ResetIfCurrent closes the connection only if epoch (obtained via +// CurrentConnEpoch, or implicitly by whoever last dialed) still matches the +// live connection's epoch. If ensureConnection has re-dialed since, the +// epoch has moved on and this call is a stale no-op: some other caller +// already recovered, and closing the newer connection would silently +// discard whatever was just written to it. +func ResetIfCurrent(epoch int64) error { + connMutex.Lock() + defer connMutex.Unlock() + if epoch != connEpoch { + log("Go: ResetIfCurrent skipped, stale epoch=%d current=%d appState=%s", + epoch, connEpoch, appStateForLog()) + return nil + } + return resetLocked() +} + +// CurrentConnEpoch returns the epoch of the connection currently installed +// in the global conn var. Must be read close to (and ideally under the same +// critical section as) the operation whose failure it will be paired with, +// so the epoch and the conn it describes stay consistent. +func CurrentConnEpoch() int64 { + connMutex.Lock() + defer connMutex.Unlock() + return connEpoch +} - log("Go: Reset start conn=%s appState=%s", describeConn(conn), appStateForLog()) +// resetLocked does the actual teardown. Must be called with connMutex held. +func resetLocked() error { + log("Go: Reset start conn=%s epoch=%d appState=%s", describeConn(conn), connEpoch, appStateForLog()) if conn != nil { conn.Close() conn = nil From 7ab4a312eb1c2111182b0e52755255007ae67d6e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:30:57 -0400 Subject: [PATCH 49/80] fix(ios): re-check liveness inside the dispatched emit block The read-error branch's kb-engine-reset emit checked kbSharedInstance and canEmit on the reader thread but then emitted unconditionally inside the main-queue dispatch, using a strongly-captured instance. An invalidate/reload landing between dispatch and execution can leave _eventEmitterCallback set on a dying runtime, so re-check both inside the block, matching the fatal-lambda shape already used elsewhere in this file. --- rnmodules/react-native-kb/ios/Kb.mm | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index e324e7b1fb42..2ea7cd9e9840 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -505,8 +505,17 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime ? kEngineResetEmitInitialBackoff : MIN(emitBackoffSeconds * 2, kEngineResetEmitBackoffCeiling); emitNotBeforeTime = now + emitBackoffSeconds; + // Re-check kbSharedInstance/canEmit inside the dispatched + // block rather than reusing the reader-thread snapshot above: + // an invalidate/reload can land between this dispatch and the + // block running, and `_eventEmitterCallback` is never cleared + // on invalidate, so emitting on a strongly-captured `instance` + // here could deliver into a dying runtime's invoker. dispatch_async(dispatch_get_main_queue(), ^{ - [instance emitOnMetaEvent:metaEventEngineReset]; + Kb *emitInstance = kbSharedInstance; + if (emitInstance && [emitInstance canEmit]) { + [emitInstance emitOnMetaEvent:metaEventEngineReset]; + } }); } } From 847c3ac3ec6cc086def7ac6ff6d7c33a7b97ad2c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:31:25 -0400 Subject: [PATCH 50/80] fix(android): give non-EOF read errors their own log throttle counter readErrorCount only resets on a successful read, so a sustained EOF outage can drive it into the hundreds before a genuine non-EOF exception arrives. Sharing the counter meant the % 50 throttle dropped ~98% of the very log line -- the only place the exception and its stack trace are logged -- at exactly the transition an operator needs to see. Give the non-EOF branch its own counter so it always logs the first few occurrences regardless of a preceding EOF flood, while keeping EOF throttled as before. --- .../main/java/com/reactnativekb/KbModule.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 8d9e8af01935..c048285390e2 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -533,6 +533,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T private class ReadFromKBLib : Runnable { private var loggedEmptyRead = false private var readErrorCount = 0 + private var nonEofErrorCount = 0 private var emitBackoffMs = 0L private var emitNotBeforeMs = 0L @@ -547,6 +548,18 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T return readErrorCount <= 5 || readErrorCount % 50 == 0 } + // Separate counter for non-EOF exceptions: readErrorCount only resets + // on a successful read, so a sustained EOF outage can drive it into + // the hundreds before a genuine non-EOF failure arrives -- sharing + // the counter would let the `% 50` throttle swallow the very log line + // (with stack trace) an operator needs at exactly that transition. + // A dedicated counter guarantees the first few non-EOF exceptions + // always log regardless of how long the preceding EOF flood ran. + private fun shouldLogNonEofError(): Boolean { + nonEofErrorCount++ + return nonEofErrorCount <= 5 || nonEofErrorCount % 50 == 0 + } + // Throttles the kb-engine-reset EMIT, separately from the log line // above -- they have different cadences and must not share a // counter. JS's disconnectCallback does a full session-cancel sweep @@ -589,6 +602,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T continue } readErrorCount = 0 + nonEofErrorCount = 0 emitBackoffMs = 0L emitNotBeforeMs = 0L instance?.nativeOnDataFromGo(data) @@ -605,14 +619,12 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // EOF is the ordinary shape of a persistent-read // outage, not a surprise, but at this loop's ~100ms // retry it is still a ~10Hz flood into the uploadable - // log if left unthrottled -- share the read-error - // counter with the non-EOF branch below, since both - // are just "reads are failing". + // log if left unthrottled. if (shouldLogReadError()) { NativeLogger.info("Got EOF from read, connection reset (count=$readErrorCount).") } - } else if (shouldLogReadError()) { - NativeLogger.error("Exception in ReadFromKBLib.run (count=$readErrorCount)", e) + } else if (shouldLogNonEofError()) { + NativeLogger.error("Exception in ReadFromKBLib.run (count=$nonEofErrorCount)", e) } val inst = instance if (inst != null) { From 72e8b30f17225d7412f19837ab441e0c50e7fb87 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:31:31 -0400 Subject: [PATCH 51/80] fix(engine): guard failOutstanding's per-callback invocation failOutstanding ran invocations.forEach(cb => cb(err, data)) with no per-callback try/catch, so one throwing RPC callback aborted the forEach -- the remaining outstanding invocations never got failed and hung forever, and the throw escaped to the outer catch in index.platform.tsx, skipping both disconnectCallback and connectCallback. Wrap each callback invocation so one throw can't prevent the others from being failed, and add a test proving three outstanding invocations all get failed even when the first throws. --- shared/engine/rpc-transport.test.ts | 16 ++++++++++++++++ shared/engine/rpc-transport.tsx | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 0373fcae5030..9e51b544f0d9 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -209,6 +209,22 @@ test('a callback that re-enters with a new invoke during failAllOutstanding is n expect(reentrant).not.toHaveBeenCalled() }) +test('a throwing callback does not stop the remaining outstanding invocations from being failed', () => { + const transport = new TestTransport() + const calls: Array = [] + + transport.invoke('keybase.1.test.a', [{}], () => { + calls.push(1) + throw new Error('boom') + }) + transport.invoke('keybase.1.test.b', [{}], () => calls.push(2)) + transport.invoke('keybase.1.test.c', [{}], () => calls.push(3)) + + expect(() => transport.failAllOutstanding()).not.toThrow() + + expect(calls).toEqual([1, 2, 3]) +}) + test('seqids keep advancing after outstanding invocations are failed, so a late reply cannot alias', () => { const transport = new TestTransport() transport.invoke('keybase.1.test.a', [{}], () => {}) diff --git a/shared/engine/rpc-transport.tsx b/shared/engine/rpc-transport.tsx index 2e1ef13e42a3..ea0b769e5051 100644 --- a/shared/engine/rpc-transport.tsx +++ b/shared/engine/rpc-transport.tsx @@ -281,7 +281,13 @@ export abstract class RPCTransport { protected failOutstanding(err: unknown, data: unknown) { const invocations = this._invocations this._invocations = new Map() - invocations.forEach(cb => cb(err, data)) + invocations.forEach(cb => { + try { + cb(err, data) + } catch (e) { + logger.error('failOutstanding callback threw', e) + } + }) } packetizeData(data: Uint8Array) { From c379922a3aa1c27fdbb1a7aecf1f136a05adb196 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:31:54 -0400 Subject: [PATCH 52/80] fix(rpc): drop unused CurrentConnEpoch, document why fatal paths stay unconditional CurrentConnEpoch had zero callers since 57da73ab7f added it -- exported dead code on both the ObjC and Java gobind surfaces. Investigated wiring it into the iOS/Android stream-desync fatal handlers (the two remaining unconditional Reset() call sites), but they run off KBBridge's shared onFatal_ callback, a bare std::function/no-arg JNI method with no epoch plumbed through it or through onDataFromGo on either platform's read loop. Threading one through would widen that signature across the ObjC and JNI call sites, not just these two methods -- too invasive for this pass. Delete the unused accessor and document why the fatal handlers are left on unconditional Reset() for now. --- go/bind/keybase.go | 16 +++------------- .../src/main/java/com/reactnativekb/KbModule.kt | 9 +++++++++ rnmodules/react-native-kb/ios/Kb.mm | 10 ++++++++++ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 1986fec84f1b..de38fff0fed6 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -732,9 +732,9 @@ func Reset() error { return resetLocked() } -// ResetIfCurrent closes the connection only if epoch (obtained via -// CurrentConnEpoch, or implicitly by whoever last dialed) still matches the -// live connection's epoch. If ensureConnection has re-dialed since, the +// ResetIfCurrent closes the connection only if epoch (obtained implicitly by +// whoever last dialed) still matches the live connection's epoch. If +// ensureConnection has re-dialed since, the // epoch has moved on and this call is a stale no-op: some other caller // already recovered, and closing the newer connection would silently // discard whatever was just written to it. @@ -749,16 +749,6 @@ func ResetIfCurrent(epoch int64) error { return resetLocked() } -// CurrentConnEpoch returns the epoch of the connection currently installed -// in the global conn var. Must be read close to (and ideally under the same -// critical section as) the operation whose failure it will be paired with, -// so the epoch and the conn it describes stay consistent. -func CurrentConnEpoch() int64 { - connMutex.Lock() - defer connMutex.Unlock() - return connEpoch -} - // resetLocked does the actual teardown. Must be called with connMutex held. func resetLocked() error { log("Go: Reset start conn=%s epoch=%d appState=%s", describeConn(conn), connEpoch, appStateForLog()) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index c048285390e2..573b171b2f23 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -698,6 +698,15 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // still live on this path, so clearing the parser first would leave a // window where bytes from the OLD connection land in the // freshly-reset unpacker mid-frame, causing a second desync. + // + // Unconditional reset() rather than resetIfCurrent(epoch): this + // fires from the shared C++ onFatal_ callback, a bare no-arg + // std::function that runs off callInvoker_->invokeAsync with + // arbitrary latency -- but no epoch is plumbed through it or through + // onDataFromGo on either platform's read loop. Widening that would + // touch the JNI and ObjC call sites too, not just this method. Left + // as unconditional reset() for now; this can race a concurrent + // re-dial the same way any unconditional reset can. try { Keybase.reset() } catch (e: Exception) { diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 2ea7cd9e9840..852b850a0db0 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -370,6 +370,16 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // still live on this path, so resetting the parser first would // leave a window where bytes from the OLD connection land in the // freshly-cleared unpacker mid-frame, causing a second desync. + // + // Unconditional Reset() rather than ResetIfCurrent(epoch): this + // callback runs off KBBridge's onFatal_, which is a bare + // std::function with no epoch parameter -- onDataFromGo + // itself never receives one from either platform's read loop. + // Threading an epoch through would mean widening onFatal_'s + // signature and onDataFromGo's across the ObjC and JNI call + // sites, not just this file. Left as unconditional Reset() for + // now; this can race a concurrent re-dial the same way any + // unconditional reset can. NSError *error = nil; KeybaseReset(&error); if (error) { From 03de2b52320710091079eb29126aacc29ebb9b57 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:32:08 -0400 Subject: [PATCH 53/80] cleanup(rpc): drop stale half of the unused-engineReset comment Both engineReset methods already reset the parser right alongside the Go connection -- the "if ever wired up, this must reset the parser" warning was describing a state the code no longer has. Keep the accurate "no current caller" note and drop the stale half. --- .../android/src/main/java/com/reactnativekb/KbModule.kt | 4 +--- rnmodules/react-native-kb/ios/Kb.mm | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 573b171b2f23..5445247269a3 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -464,9 +464,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // chance of being delivered before committing to it. internal fun canDeliverReset(): Boolean = reactContext.hasActiveReactInstance() && canEmit() - // No current caller (kept for future use). If ever wired up, this must - // reset the parser along with the Go connection, or the next connection - // desyncs on its first frame -- see the other reset paths in this file. + // No current caller (kept for future use). @ReactMethod override fun engineReset() { try { diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 852b850a0db0..b341be8e2ad8 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -417,9 +417,7 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime RCT_EXPORT_METHOD(shareListenersRegistered) { } -// No current caller (kept for future use). If ever wired up, this must reset -// the parser along with the Go connection, or the next connection desyncs on -// its first frame -- see the other reset paths in this file. +// No current caller (kept for future use). RCT_EXPORT_METHOD(engineReset) { NSError *error = nil; KeybaseReset(&error); From 3bda702b1a1c443b0ac0e8e5ba05a90ac1c5f0d2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:32:13 -0400 Subject: [PATCH 54/80] fix(android): keep writeToGo's OOM path returning false on log failure onLog allocates a jstring (jni::make_jstring) for the message it routes to the uploadable log. If that allocation itself throws -- plausible right after an OutOfMemoryError -- the fbjni JniException would escape writeToGo instead of the documented "return false", which breaks the caller's exception-handling contract. Wrap the onLog call so writeToGo always returns false on this path. --- rnmodules/react-native-kb/android/cpp-adapter.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index 149a9a91ea77..64f9e6a2be38 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -34,7 +34,15 @@ class KbNativeAdapter { // the uploadable log, not just logcat. env->ExceptionDescribe(); env->ExceptionClear(); - onLog("writeToGo: NewByteArray failed (out of memory), dropping message"); + // onLog itself allocates a jstring (jni::make_jstring); if that + // allocation throws -- plausible right after an OOM -- an fbjni + // JniException must not escape writeToGo in place of the documented + // `return false`, or the caller's exception-handling contract breaks. + try { + onLog("writeToGo: NewByteArray failed (out of memory), dropping message"); + } catch (...) { + env->ExceptionClear(); + } return false; } // Adopt into a local_ref so the ref is released even if the call below From 0bd389aba5b8bd79108765e4744d2606787c2a65 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:32:33 -0400 Subject: [PATCH 55/80] fix(engine): preserve the Error object in the batch-guard log The outer batch-guard log lost its Error object in an earlier extraction (logError(`...${String(e)}`) instead of passing e), so stack traces were gone from this path. dispatchOne's log still passes e directly. Widen logError to accept the error and thread it through, matching dispatchOne's shape. --- shared/engine/index.platform.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index 13b80c5183e9..b66707370378 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -189,7 +189,7 @@ export const dispatchRpcBatch = ( objs: unknown, count: number, dispatchOne: (obj: unknown) => void, - logError: (msg: string) => void + logError: (msg: string, e?: unknown) => void ) => { // Outer guard: this is called from native, so throwing here would abort the // whole batch delivery and unwind into native code. @@ -208,7 +208,7 @@ export const dispatchRpcBatch = ( dispatchOne(objs) } } catch (e) { - logError(`rpcOnJs: batch guard threw: ${String(e)}`) + logError('rpcOnJs: batch guard threw', e) } } @@ -233,7 +233,7 @@ function createClient( } global.rpcOnJs = (objs: unknown, count: number) => { - dispatchRpcBatch(objs, count, dispatchOne, msg => logger.error(msg)) + dispatchRpcBatch(objs, count, dispatchOne, (msg, e) => logger.error(msg, e)) } onMetaEvent((payload: string) => { From 4d3303ac121d3fde7900634c12697ee402425e79 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:32:51 -0400 Subject: [PATCH 56/80] test(engine): exercise production's dispatchOne instead of a re-implementation The "one message's dispatch throwing" test defined its own dispatchOne with the try/catch inline, so it would still pass even if production lost its catch. Extract production's dispatchOne into an exported makeDispatchOne(client) so the test can call the real code path against a fake client, rather than mirroring its shape by hand. --- shared/engine/index.platform.test.ts | 30 +++++++++++++++------------- shared/engine/index.platform.tsx | 24 +++++++++++++--------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/shared/engine/index.platform.test.ts b/shared/engine/index.platform.test.ts index 36bb22c5845e..a294d5b38417 100644 --- a/shared/engine/index.platform.test.ts +++ b/shared/engine/index.platform.test.ts @@ -3,7 +3,7 @@ // jest.setup.js sets isMobile=false, isRenderer=true, and _fromPreload.functions // with no engineSend -- exactly the "renderer up, preload never wired engineSend" // case this test drives. -import {createClient, dispatchRpcBatch} from './index.platform' +import {createClient, dispatchRpcBatch, makeDispatchOne} from './index.platform' test('a missing engineSend fails the write instead of silently no-oping', () => { const client = createClient( @@ -43,21 +43,23 @@ describe('dispatchRpcBatch', () => { test("one message's dispatch throwing does not stop the remaining messages", () => { const dispatched: Array = [] - const errors: Array = [] - // Mirrors production's dispatchOne: each item is individually try/caught - // so one bad message can't drop the rest of the batch. - const dispatchOne = (obj: unknown) => { - try { - if (obj === 'bad') { - throw new Error('dispatch threw') - } - dispatched.push(obj) - } catch (e) { - errors.push(`rpcOnJs: dispatch threw: ${String(e)}`) - } + // Exercises production's own dispatchOne (via makeDispatchOne) rather + // than a re-implementation, so this test still fails if production ever + // loses its per-message try/catch. + const fakeClient = { + transport: { + dispatchDecodedMessage: (obj: unknown) => { + if (obj === 'bad') { + throw new Error('dispatch threw') + } + dispatched.push(obj) + }, + }, } + const dispatchOne = makeDispatchOne(fakeClient) + const errors: Array = [] dispatchRpcBatch(['a', 'bad', 'b'], 3, dispatchOne, msg => errors.push(msg)) expect(dispatched).toEqual(['a', 'b']) - expect(errors).toEqual(['rpcOnJs: dispatch threw: Error: dispatch threw']) + expect(errors).toEqual([]) }) }) diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index b66707370378..5c75a81a9a06 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -212,6 +212,20 @@ export const dispatchRpcBatch = ( } } +// Per-message try/catch: one bad message must not drop the rest of the batch +// the native side handed over. Exported (alongside dispatchRpcBatch) so the +// mobile-only wiring inside createClient's isMobile branch can be exercised +// directly in tests without a mobile jest environment. +export const makeDispatchOne = (client: {transport: {dispatchDecodedMessage: (obj: unknown) => void}}) => { + return (obj: unknown) => { + try { + client.transport.dispatchDecodedMessage(obj) + } catch (e) { + logger.error('rpcOnJs: dispatch threw', e) + } + } +} + function createClient( incomingRPCCallback: IncomingRPCCallbackType, connectCallback: ConnectDisconnectCB, @@ -222,15 +236,7 @@ function createClient( new NativeTransportMobile(incomingRPCCallback, connectCallback, disconnectCallback) ) - // Per-message try/catch: one bad message must not drop the rest of the - // batch the native side handed over. - const dispatchOne = (obj: unknown) => { - try { - client.transport.dispatchDecodedMessage(obj) - } catch (e) { - logger.error('rpcOnJs: dispatch threw', e) - } - } + const dispatchOne = makeDispatchOne(client) global.rpcOnJs = (objs: unknown, count: number) => { dispatchRpcBatch(objs, count, dispatchOne, (msg, e) => logger.error(msg, e)) From abaf9f0d4e223b7c1e7906ea03dd1aaa7b2099bb Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:47:04 -0400 Subject: [PATCH 57/80] feat(bind): re-add CurrentEpoch accessor for the fatal-path reset Platform readers need to capture the epoch of the connection they just read bytes from before handing those bytes to the native bridge, so a later fatal-path reset can target that specific connection instead of whatever is current when the reset actually runs. --- go/bind/keybase.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index de38fff0fed6..217b4a84c8e9 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -732,6 +732,20 @@ func Reset() error { return resetLocked() } +// CurrentEpoch returns the epoch of the connection currently considered +// live. A caller that is about to hand off a batch of bytes it just read +// from that connection (e.g. a platform reader loop, right before passing +// the data into the native bridge for parsing) should capture this value +// there, not later once some asynchronous handler for that batch actually +// runs — ensureConnection may have re-dialed by then, and passing the +// wrong (newer) epoch to ResetIfCurrent would tear down a good connection +// instead of leaving it alone. +func CurrentEpoch() int64 { + connMutex.Lock() + defer connMutex.Unlock() + return connEpoch +} + // ResetIfCurrent closes the connection only if epoch (obtained implicitly by // whoever last dialed) still matches the live connection's epoch. If // ensureConnection has re-dialed since, the From 1a54702ef7999c8e7feaf99961fe4c6e9ab1aa3a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:47:10 -0400 Subject: [PATCH 58/80] feat(rpc): thread the read epoch through onDataFromGo/onFatal_ onDataFromGo now takes the epoch of the connection its bytes were read from, stores it on RecvState, and carries it (by parameter for the synchronous desync path, by lambda capture for the JS-thread escalation paths) to every onFatal_ call site. onFatal_ widens from a bare std::function to std::function so platform fatal handlers can call Go's ResetIfCurrent(epoch) instead of an unconditional reset. --- .../react-native-kb/cpp/react-native-kb.cpp | 33 +++++++++++++------ .../react-native-kb/cpp/react-native-kb.h | 23 +++++++++---- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index 9a230af71b70..ce5df01f30d0 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -61,6 +61,13 @@ struct KBBridge::RecvState { // stops tracking "did one big frame arrive" and starts tracking "did // several small frames arrive in the same read" instead. size_t peakFrameSize = 0; + // Epoch of the Go connection the most recent onDataFromGo call's bytes + // came from. Set at the top of onDataFromGo under recvMutex_; read back + // here only for anything that inspects RecvState directly. The fatal + // paths below use the onDataFromGo parameter (or its lambda capture) + // rather than this field, since resetRecvLocked() replaces RecvState -- + // and so this field -- before those paths run. + int64_t epoch = 0; }; struct KBBridge::SendState { @@ -574,7 +581,7 @@ void KBBridge::install( std::shared_ptr callInvoker, std::function writeToGo, std::function onError, - std::function onFatal) { + std::function onFatal) { callInvoker_ = std::move(callInvoker); onError_ = std::move(onError); onFatal_ = std::move(onFatal); @@ -640,7 +647,7 @@ void KBBridge::install( std::make_shared(shared_from_this()))); } -void KBBridge::onDataFromGo(uint8_t *data, int size) { +void KBBridge::onDataFromGo(uint8_t *data, int size, int64_t epoch) { if (isTornDown_.load() || size <= 0 || data == nullptr) { return; } @@ -656,6 +663,7 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { if (!recv_) { return; } + recv_->epoch = epoch; try { auto &up = recv_->unpacker; up.reserve_buffer(static_cast(size)); @@ -731,10 +739,11 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { if (fatal) { reportError(fatalMsg); // The stream can no longer be trusted, so anything decoded in this batch - // is dropped. The platform layer resets the Go connection and signals JS - // so outstanding RPCs fail instead of hanging forever. + // is dropped. The platform layer resets the Go connection (if `epoch` + // is still current) and signals JS so outstanding RPCs fail instead of + // hanging forever. if (onFatal_) { - onFatal_(); + onFatal_(epoch); } return; } @@ -744,7 +753,11 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { } auto self = shared_from_this(); - callInvoker_->invokeAsync([values, self](jsi::Runtime &runtime) { + // `epoch` is captured by value here, fixed at the moment this batch was + // parsed from the reader thread -- not re-queried once this lambda + // actually runs on the JS thread, which could be arbitrarily later and + // after Go has re-dialed. + callInvoker_->invokeAsync([values, self, epoch](jsi::Runtime &runtime) { try { if (self->isTornDown_.load()) { return; @@ -766,7 +779,7 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { // replayed, so dropping them silently would strand their callers. self->reportError("rpcOnJs is not installed"); if (self->onFatal_) { - self->onFatal_(); + self->onFatal_(epoch); } return; } @@ -813,7 +826,7 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { std::to_string(values->size()) + " message(s) failed to convert"); if (self->onFatal_) { - self->onFatal_(); + self->onFatal_(epoch); } return; } @@ -842,12 +855,12 @@ void KBBridge::onDataFromGo(uint8_t *data, int size) { // connection so JS fails its outstanding RPCs. self->reportError(e.what()); if (self->onFatal_) { - self->onFatal_(); + self->onFatal_(epoch); } } catch (...) { self->reportError("unknown error in onDataFromGo JS callback"); if (self->onFatal_) { - self->onFatal_(); + self->onFatal_(epoch); } } }); diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.h b/rnmodules/react-native-kb/cpp/react-native-kb.h index 159fc0554441..f168a705c606 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.h +++ b/rnmodules/react-native-kb/cpp/react-native-kb.h @@ -34,17 +34,26 @@ class KBBridge : public std::enable_shared_from_this { // when rpcOnJs is not installed. The first case runs synchronously on the // native reader thread inside onDataFromGo; the latter three run // asynchronously on the JS thread, from the callInvoker lambda scheduled - // by onDataFromGo. The platform layer is expected to reset the Go - // connection, drop any buffered recv state, and emit the engine-reset meta - // event so JS fails its outstanding RPCs. + // by onDataFromGo. Its int64_t argument is the epoch (go/bind/keybase.go's + // connEpoch) of the Go connection the parsed bytes were actually read + // from — the same value onDataFromGo was called with, carried through to + // wherever onFatal ends up running, sync or async. The platform layer is + // expected to call Go's ResetIfCurrent(epoch) (not an unconditional + // reset) so a fatal blamed on a since-superseded connection can't tear + // down a connection that has already recovered, drop any buffered recv + // state, and emit the engine-reset meta event so JS fails its outstanding + // RPCs. void install(facebook::jsi::Runtime &runtime, std::shared_ptr callInvoker, std::function writeToGo, std::function onError, - std::function onFatal); + std::function onFatal); - // Any thread. - void onDataFromGo(uint8_t *data, int size); + // Any thread. `epoch` is the epoch of the Go connection `data` was read + // from, captured by the caller at read time (see the platform reader + // loops) — not whatever Go's connection epoch happens to be when this + // function runs. + void onDataFromGo(uint8_t *data, int size, int64_t epoch); // Any thread. Drops any partially parsed frame. Must be called whenever the // Go connection is replaced, or the unpacker resumes mid-frame on a fresh @@ -63,7 +72,7 @@ class KBBridge : public std::enable_shared_from_this { private: std::shared_ptr callInvoker_; std::function onError_; - std::function onFatal_; + std::function onFatal_; std::function writeToGo_; std::atomic isTornDown_{false}; From 3b59a44e6ae04d545fb00494ff200e2f59a5b703 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:47:15 -0400 Subject: [PATCH 59/80] fix(rpc): platform fatal handlers reset by epoch, not unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reader loops now capture CurrentEpoch()/currentEpoch() immediately before their blocking read call, pass it through to onDataFromGo, and the fatal handlers (iOS's onFatal lambda, Android's onRpcStreamFatal) call ResetIfCurrent(epoch)/resetIfCurrent(epoch) instead of Reset(). A fatal blamed on a connection that a concurrent write/read has since recovered is now a no-op instead of tearing down the working one. invalidate (iOS), destroy()/engineReset (Android) still call the plain unconditional reset — they mean "tear down whatever is current". --- .../react-native-kb/android/cpp-adapter.cpp | 22 ++++++++------ .../main/java/com/reactnativekb/KbModule.kt | 30 +++++++++++-------- rnmodules/react-native-kb/ios/Kb.mm | 27 +++++++++-------- 3 files changed, 46 insertions(+), 33 deletions(-) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index 64f9e6a2be38..7cbd4ea29724 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -56,11 +56,12 @@ class KbNativeAdapter { return ok != JNI_FALSE; } - void onFatal() { + void onFatal(int64_t epoch) { jni::ThreadScope scope; static auto method = - JKbModule::javaClassStatic()->getMethod("onRpcStreamFatal"); - method(jModule_); + JKbModule::javaClassStatic()->getMethod( + "onRpcStreamFatal"); + method(jModule_, static_cast(epoch)); } // Called from JNI. Routes native bridge errors into the uploadable log -- @@ -155,13 +156,15 @@ getBindingsInstaller(jni::alias_ref thiz) { "JSI error: %s", err.c_str()); adapter->onLog("jsi error: " + err); }, - // The incoming stream desynced; reset the Go connection and tell - // JS so it fails outstanding RPCs rather than hanging forever. - [adapter]() { + // The incoming stream desynced; reset the Go connection (if + // `epoch` -- the connection the desynced bytes actually came + // from -- is still current) and tell JS so it fails outstanding + // RPCs rather than hanging forever. + [adapter](int64_t epoch) { __android_log_print(ANDROID_LOG_ERROR, "KBBridge", "rpc stream desync, resetting connection"); adapter->onLog("rpc stream desync, resetting connection"); - adapter->onFatal(); + adapter->onFatal(epoch); }); std::shared_ptr old; @@ -179,13 +182,14 @@ getBindingsInstaller(jni::alias_ref thiz) { } static void nativeOnDataFromGo(jni::alias_ref thiz, - jni::alias_ref data) { + jni::alias_ref data, + jlong epoch) { auto bridge = getBridge(); if (!bridge || !data) return; auto pinned = data->pin(); bridge->onDataFromGo(reinterpret_cast(pinned.get()), - pinned.size()); + pinned.size(), static_cast(epoch)); } JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 5445247269a3..b450c4e179ad 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -45,7 +45,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T @DoNotStrip external override fun getBindingsInstaller(): BindingsInstallerHolder - private external fun nativeOnDataFromGo(data: ByteArray) + private external fun nativeOnDataFromGo(data: ByteArray, epoch: Long) private external fun nativeInvalidate() private external fun nativeResetRecv() @@ -586,6 +586,14 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T override fun run() { while (true) { try { + // Captured immediately before the blocking read, mirroring + // how Go's own ReadArr grabs the epoch under connMutex + // before reading: this is the epoch of the connection + // `data` below is about to come from, not whatever epoch + // is current once onRpcStreamFatal actually runs + // (arbitrarily later, off the shared C++ onFatal_ + // callback). + val epoch = Keybase.currentEpoch() val data: ByteArray? = readArr() if (data == null || data.isEmpty()) { // Not the idle path: readArr blocks until there is @@ -603,7 +611,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T nonEofErrorCount = 0 emitBackoffMs = 0L emitNotBeforeMs = 0L - instance?.nativeOnDataFromGo(data) + instance?.nativeOnDataFromGo(data, epoch) } catch (e: InterruptedException) { Thread.currentThread().interrupt() return @@ -690,23 +698,21 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // and relaying the meta event lets JS fail its outstanding RPCs instead // of waiting on a channel that can no longer deliver. @DoNotStrip - fun onRpcStreamFatal() { + fun onRpcStreamFatal(epoch: Long) { NativeLogger.warn("$NAME: rpc stream desync, resetting connection") // Reset the Go connection before the parser: the reader thread is // still live on this path, so clearing the parser first would leave a // window where bytes from the OLD connection land in the // freshly-reset unpacker mid-frame, causing a second desync. // - // Unconditional reset() rather than resetIfCurrent(epoch): this - // fires from the shared C++ onFatal_ callback, a bare no-arg - // std::function that runs off callInvoker_->invokeAsync with - // arbitrary latency -- but no epoch is plumbed through it or through - // onDataFromGo on either platform's read loop. Widening that would - // touch the JNI and ObjC call sites too, not just this method. Left - // as unconditional reset() for now; this can race a concurrent - // re-dial the same way any unconditional reset can. + // resetIfCurrent(epoch), not reset(): `epoch` is the epoch of the + // connection the desynced bytes actually came from, captured by the + // reader loop at read time. If Go has already re-dialed since (e.g. a + // concurrent writeArr recovered first), epoch no longer matches and + // this is a no-op instead of tearing down a connection that already + // worked. try { - Keybase.reset() + Keybase.resetIfCurrent(epoch) } catch (e: Exception) { NativeLogger.error("Exception resetting after rpc desync", e) } diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index b341be8e2ad8..c83185603228 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -364,24 +364,21 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // than capturing `bridge` directly: this lambda is stored inside the // bridge's own onFatal_ member, so a direct capture would be a // shared_ptr cycle. - []() { + [](int64_t epoch) { kbLogToService(@"rpc stream desync, resetting connection"); // Reset the Go connection before the parser: the reader thread is // still live on this path, so resetting the parser first would // leave a window where bytes from the OLD connection land in the // freshly-cleared unpacker mid-frame, causing a second desync. // - // Unconditional Reset() rather than ResetIfCurrent(epoch): this - // callback runs off KBBridge's onFatal_, which is a bare - // std::function with no epoch parameter -- onDataFromGo - // itself never receives one from either platform's read loop. - // Threading an epoch through would mean widening onFatal_'s - // signature and onDataFromGo's across the ObjC and JNI call - // sites, not just this file. Left as unconditional Reset() for - // now; this can race a concurrent re-dial the same way any - // unconditional reset can. + // ResetIfCurrent(epoch), not Reset(): `epoch` is the epoch of the + // connection the desynced bytes actually came from, captured by + // the reader loop below at read time. If Go has already re-dialed + // since (e.g. a concurrent WriteArr recovered first), epoch no + // longer matches and this is a no-op instead of tearing down a + // connection that already worked. NSError *error = nil; - KeybaseReset(&error); + KeybaseResetIfCurrent(epoch, &error); if (error) { kbLogToService([NSString stringWithFormat:@"reset after desync failed: %@", error.localizedDescription]); @@ -472,6 +469,12 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // The block never returns, so the queue's pool never drains on its // own — each iteration needs its own. @autoreleasepool { + // Captured immediately before the blocking read, mirroring how + // ReadArr itself grabs the epoch under connMutex before reading: + // this is the epoch of the connection `data` below is about to + // come from, not whatever epoch is current once onFatal_ actually + // runs (arbitrarily later, off callInvoker_->invokeAsync). + int64_t epoch = KeybaseCurrentEpoch(); NSError *error = nil; NSData *data = KeybaseReadArr(&error); if (error) { @@ -549,7 +552,7 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime emitNotBeforeTime = 0; auto bridge = kbGetBridge(); if (bridge) { - bridge->onDataFromGo((uint8_t *)[data bytes], (int)[data length]); + bridge->onDataFromGo((uint8_t *)[data bytes], (int)[data length], epoch); } } } From b252125bc2a564f2c00831bdf16cd66f76a00447 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:51:12 -0400 Subject: [PATCH 60/80] fix(bind): make epoch capture exact via LastReadEpoch The native readers captured CurrentEpoch() before their blocking ReadArr call, but ReadArr re-reads conn/connEpoch under connMutex at call time -- a redial between the two capture points left the reader holding an epoch one lower than the connection its bytes actually came from, so a later ResetIfCurrent(epoch) could stale-no-op a reset that was genuinely needed. Exploit the single-permanent-reader invariant instead: ReadArr now records the epoch of the connection it actually reads under connMutex, exposed via LastReadEpoch(), which the one reader on each platform calls immediately after ReadArr returns. Drops CurrentEpoch() in favor of it since it had no other caller. --- go/bind/keybase.go | 33 ++++++++++++++----- .../main/java/com/reactnativekb/KbModule.kt | 18 +++++----- rnmodules/react-native-kb/ios/Kb.mm | 15 +++++---- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 217b4a84c8e9..c7ef23a66845 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -69,6 +69,11 @@ var ( // no-op complaint. Protected by connMutex. var connEpoch int64 +// lastReadEpoch is the epoch of the connection the most recent ReadArr call +// read (or is about to read) from, captured atomically with the connection +// reference itself. See LastReadEpoch. Protected by connMutex. +var lastReadEpoch int64 + func describeConn(c net.Conn) string { if c == nil { return "" @@ -636,6 +641,7 @@ func ReadArr() (data []byte, err error) { } currentConn := conn currentEpoch := connEpoch + lastReadEpoch = currentEpoch connMutex.Unlock() if currentConn == nil { @@ -732,18 +738,27 @@ func Reset() error { return resetLocked() } -// CurrentEpoch returns the epoch of the connection currently considered -// live. A caller that is about to hand off a batch of bytes it just read -// from that connection (e.g. a platform reader loop, right before passing -// the data into the native bridge for parsing) should capture this value -// there, not later once some asynchronous handler for that batch actually -// runs — ensureConnection may have re-dialed by then, and passing the -// wrong (newer) epoch to ResetIfCurrent would tear down a good connection +// LastReadEpoch returns the epoch of the connection the most recent ReadArr +// call read its bytes from. A caller that is about to hand off a batch of +// bytes it just got back from ReadArr (e.g. a platform reader loop, right +// before passing the data into the native bridge for parsing) should call +// this immediately after ReadArr returns and capture the result there, not +// later once some asynchronous handler for that batch actually runs — +// ensureConnection may have re-dialed by then, and passing the wrong +// (newer) epoch to ResetIfCurrent would tear down a good connection // instead of leaving it alone. -func CurrentEpoch() int64 { +// +// This is exact only because there is exactly one permanent ReadArr caller +// for the life of the process (enforced on both platforms: a single serial +// reader loop). With one reader, nothing can start a second ReadArr call +// between this one's internal epoch capture and this accessor being read +// afterward, so the value can never be stale by the time the caller reads +// it. A design with concurrent readers would need to thread the epoch +// through ReadArr's return value instead. +func LastReadEpoch() int64 { connMutex.Lock() defer connMutex.Unlock() - return connEpoch + return lastReadEpoch } // ResetIfCurrent closes the connection only if epoch (obtained implicitly by diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index b450c4e179ad..e3024259172a 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -586,15 +586,17 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T override fun run() { while (true) { try { - // Captured immediately before the blocking read, mirroring - // how Go's own ReadArr grabs the epoch under connMutex - // before reading: this is the epoch of the connection - // `data` below is about to come from, not whatever epoch - // is current once onRpcStreamFatal actually runs - // (arbitrarily later, off the shared C++ onFatal_ - // callback). - val epoch = Keybase.currentEpoch() val data: ByteArray? = readArr() + // Read immediately after readArr returns, not before it: + // Go's ReadArr records the epoch of the connection it + // actually read from under connMutex as part of the call, + // and with exactly one permanent reader for the life of + // the process (this loop) nothing can have started a + // second readArr in between, so this is exact for the + // bytes just returned -- unlike capturing the epoch + // before the blocking call, which could race a redial + // that happens while this read is in flight. + val epoch = Keybase.lastReadEpoch() if (data == null || data.isEmpty()) { // Not the idle path: readArr blocks until there is // data, so an empty non-error result is degenerate -- diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index c83185603228..23db63a84735 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -469,14 +469,17 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // The block never returns, so the queue's pool never drains on its // own — each iteration needs its own. @autoreleasepool { - // Captured immediately before the blocking read, mirroring how - // ReadArr itself grabs the epoch under connMutex before reading: - // this is the epoch of the connection `data` below is about to - // come from, not whatever epoch is current once onFatal_ actually - // runs (arbitrarily later, off callInvoker_->invokeAsync). - int64_t epoch = KeybaseCurrentEpoch(); NSError *error = nil; NSData *data = KeybaseReadArr(&error); + // Read immediately after ReadArr returns, not before it: ReadArr + // records the epoch of the connection it actually read from under + // connMutex as part of the call, and with exactly one permanent + // reader for the life of the process (this loop) nothing can have + // started a second ReadArr in between, so this is exact for the + // bytes just returned -- unlike capturing the epoch before the + // blocking call, which could race a redial that happens while this + // read is in flight. + int64_t epoch = KeybaseLastReadEpoch(); if (error) { // ReadArr already called Reset() on the Go side, so the connection // JS thinks it has is gone and every in-flight RPC is dead. Tell From fa04cc0b162d738bba22fc8ba13b3dddd1391bd2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:58:36 -0400 Subject: [PATCH 61/80] fix(cgo): give the two quarantineFile C shims internal linkage 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. --- go/chat/attachments/quarantine_darwin.go | 2 +- go/kbfs/simplefs/platform_darwin.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go/chat/attachments/quarantine_darwin.go b/go/chat/attachments/quarantine_darwin.go index 68dff5388c15..af8b58e88235 100644 --- a/go/chat/attachments/quarantine_darwin.go +++ b/go/chat/attachments/quarantine_darwin.go @@ -5,7 +5,7 @@ package attachments /* #cgo CFLAGS: -x objective-c -fobjc-arc #include -void quarantineFile(const char* inFilename) { +static void quarantineFile(const char* inFilename) { NSError* error = NULL; NSString* filename = [NSString stringWithUTF8String:inFilename]; NSURL* url = [NSURL fileURLWithPath:filename]; diff --git a/go/kbfs/simplefs/platform_darwin.go b/go/kbfs/simplefs/platform_darwin.go index 8f370cfac887..d06bf399c2fd 100644 --- a/go/kbfs/simplefs/platform_darwin.go +++ b/go/kbfs/simplefs/platform_darwin.go @@ -8,7 +8,7 @@ package simplefs #cgo LDFLAGS: -framework Foundation -framework CoreServices -lobjc #include -void quarantineFile(const char* inFilename) { +static void quarantineFile(const char* inFilename) { NSError* error = NULL; NSString* filename = [NSString stringWithUTF8String:inFilename]; NSURL* url = [NSURL fileURLWithPath:filename]; From f69b879539964f18e3cc6854404b0de18ae5cca9 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 10:58:44 -0400 Subject: [PATCH 62/80] test(bind): add first test suite for go/bind 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. --- go/bind/keybase_test.go | 415 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 go/bind/keybase_test.go diff --git a/go/bind/keybase_test.go b/go/bind/keybase_test.go new file mode 100644 index 000000000000..424da62cbd31 --- /dev/null +++ b/go/bind/keybase_test.go @@ -0,0 +1,415 @@ +// Copyright 2015 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package keybase + +import ( + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/logger" +) + +// fakeLogContext satisfies libkb.LogContext so tests can build a real +// libkb.LoopbackListener without standing up a full libkb.GlobalContext. +type fakeLogContext struct{} + +func (fakeLogContext) GetLog() logger.Logger { return logger.NewNull() } + +// trackingConn is a minimal net.Conn whose only job is recording whether +// Close was called on it. The embedded nil net.Conn satisfies the interface; +// none of these tests exercise Read/Write on it (that would panic), only +// Close, which is all resetLocked ever calls on the connection it tears +// down. +type trackingConn struct { + net.Conn + closed atomic.Bool +} + +func (c *trackingConn) Close() error { + c.closed.Store(true) + return nil +} + +// resetConnStateForTest snapshots every package-level var the epoch/reset +// mechanism touches, clears it to a known-empty state for the test, and +// restores the original values on cleanup. These are the real production +// globals (guarded by connMutex / initMutex), not a test-only copy — tests +// seed them directly instead of routing through ensureConnection's dial +// machinery, then exercise the real Reset/ResetIfCurrent/ReadArr/WriteArr/ +// LastReadEpoch functions against that seeded state. +func resetConnStateForTest(t *testing.T) { + t.Helper() + + connMutex.Lock() + savedConn := conn + savedEpoch := connEpoch + savedLastRead := lastReadEpoch + savedKbCtx := kbCtx + conn = nil + connEpoch = 0 + lastReadEpoch = 0 + connMutex.Unlock() + + initMutex.Lock() + savedInitComplete := initComplete + initMutex.Unlock() + + t.Cleanup(func() { + connMutex.Lock() + conn = savedConn + connEpoch = savedEpoch + lastReadEpoch = savedLastRead + kbCtx = savedKbCtx + connMutex.Unlock() + + initMutex.Lock() + initComplete = savedInitComplete + initMutex.Unlock() + }) +} + +// setConn seeds the package-level conn/connEpoch under connMutex, exactly +// the state ensureConnection would leave behind after a successful dial. +func setConn(c net.Conn, epoch int64) { + connMutex.Lock() + conn = c + connEpoch = epoch + connMutex.Unlock() +} + +func getConnState() (net.Conn, int64) { + connMutex.Lock() + defer connMutex.Unlock() + return conn, connEpoch +} + +// Test 1: ResetIfCurrent closes the connection when the epoch matches the +// live one, and is a no-op that leaves the connection intact when the epoch +// is stale. +func TestResetIfCurrent_MatchingVsStaleEpoch(t *testing.T) { + resetConnStateForTest(t) + + t.Run("matching epoch closes", func(t *testing.T) { + c := &trackingConn{} + setConn(c, 5) + + if err := ResetIfCurrent(5); err != nil { + t.Fatalf("ResetIfCurrent returned error: %v", err) + } + if !c.closed.Load() { + t.Error("expected connection to be closed when epoch matches, but it was not") + } + if gotConn, _ := getConnState(); gotConn != nil { + t.Error("expected conn to be nil after a matching-epoch reset") + } + }) + + t.Run("stale epoch is a no-op", func(t *testing.T) { + c := &trackingConn{} + setConn(c, 6) + + if err := ResetIfCurrent(5); err != nil { + t.Fatalf("ResetIfCurrent returned error: %v", err) + } + if c.closed.Load() { + t.Error("expected connection to be left open on a stale epoch, but it was closed") + } + gotConn, gotEpoch := getConnState() + if gotConn != c { + t.Error("expected conn to be left untouched on a stale epoch") + } + if gotEpoch != 6 { + t.Errorf("expected epoch to remain 6, got %d", gotEpoch) + } + }) +} + +// Test 2: the exact interleaving the mechanism exists to prevent. Caller A +// captures epoch N against connection A. Before A calls ResetIfCurrent, a +// redial happens (simulating some other caller recovering first), advancing +// to a new connection at epoch N+1. A's stale ResetIfCurrent(N) must not +// close the new connection. +func TestResetIfCurrent_StaleEpochDoesNotClobberRedial(t *testing.T) { + resetConnStateForTest(t) + + connA := &trackingConn{} + setConn(connA, 1) + + // Caller A observes a failure on connA and captures its epoch, exactly + // as ReadArr/WriteArr do under connMutex before releasing it. + capturedEpoch := int64(1) + + // A redial happens concurrently, before A gets to call ResetIfCurrent: + // this is what ensureConnection leaves behind after a successful dial. + connB := &trackingConn{} + setConn(connB, 2) + + // A's stale complaint must be a no-op. + if err := ResetIfCurrent(capturedEpoch); err != nil { + t.Fatalf("ResetIfCurrent returned error: %v", err) + } + + if connB.closed.Load() { + t.Fatal("stale ResetIfCurrent closed the redialed connection") + } + if connA.closed.Load() { + t.Error("connA should not be reachable/closed either; it was already superseded") + } + gotConn, gotEpoch := getConnState() + if gotConn != connB { + t.Error("expected the live connection to still be connB") + } + if gotEpoch != 2 { + t.Errorf("expected epoch to remain 2, got %d", gotEpoch) + } +} + +// Test 3: two callers both holding epoch N. The first reset closes the +// connection; the second, redundant call against the same (now-stale-by- +// virtue-of-nil-conn but epoch-matching) state must be harmless and must +// not panic. +func TestResetIfCurrent_DoubleResetSameEpochIsHarmless(t *testing.T) { + resetConnStateForTest(t) + + c := &trackingConn{} + setConn(c, 3) + + if err := ResetIfCurrent(3); err != nil { + t.Fatalf("first ResetIfCurrent returned error: %v", err) + } + if !c.closed.Load() { + t.Fatal("expected first ResetIfCurrent to close the connection") + } + + // resetLocked does not touch connEpoch, so the second caller, which + // captured the same epoch, still matches and will re-enter resetLocked + // with conn == nil. + if err := ResetIfCurrent(3); err != nil { + t.Fatalf("second ResetIfCurrent returned error (should be a harmless no-op): %v", err) + } + if gotConn, _ := getConnState(); gotConn != nil { + t.Error("expected conn to remain nil after the redundant reset") + } +} + +// Test 4: Reset is the unconditional escape hatch used by invalidate/ +// destroy/engineReset. It must close whatever connection is current +// regardless of any epoch bookkeeping. +func TestReset_UnconditionallyClosesCurrentConnection(t *testing.T) { + resetConnStateForTest(t) + + c := &trackingConn{} + setConn(c, 42) + + if err := Reset(); err != nil { + t.Fatalf("Reset returned error: %v", err) + } + if !c.closed.Load() { + t.Error("expected Reset to unconditionally close the current connection") + } + if gotConn, _ := getConnState(); gotConn != nil { + t.Error("expected conn to be nil after Reset") + } +} + +// Test 5: connEpoch is monotonically increasing and never reused across +// redials. This drives the real ensureConnection dial path (not a +// reimplementation of it) against a real libkb.LoopbackListener, so the +// increment under test is the actual production statement. +func TestEnsureConnection_EpochMonotonicAcrossRedials(t *testing.T) { + resetConnStateForTest(t) + + ll := libkb.NewLoopbackListener(fakeLogContext{}) + t.Cleanup(func() { _ = ll.Close() }) + + // Drain the Accept() side in the background so Dial() (called inside + // ensureConnection) doesn't block forever; ensureConnection only needs + // the dial to succeed, not a live peer. + go func() { + for { + if _, err := ll.Accept(); err != nil { + return + } + } + }() + + connMutex.Lock() + kbCtx = &libkb.GlobalContext{LoopbackListener: ll} + connMutex.Unlock() + setInited() + + seen := map[int64]bool{} + var prev int64 = -1 + for i := 0; i < 20; i++ { + connMutex.Lock() + conn = nil // force ensureConnection to dial again + err := ensureConnection() + gotEpoch := connEpoch + connMutex.Unlock() + + if err != nil { + t.Fatalf("ensureConnection failed on iteration %d: %v", i, err) + } + if gotEpoch <= prev { + t.Fatalf("epoch did not strictly increase: prev=%d got=%d", prev, gotEpoch) + } + if seen[gotEpoch] { + t.Fatalf("epoch %d reused across redials", gotEpoch) + } + seen[gotEpoch] = true + prev = gotEpoch + } +} + +// Test 6: LastReadEpoch reflects the epoch of the connection the most +// recent ReadArr call actually used. +func TestReadArr_LastReadEpochMatchesConnectionUsed(t *testing.T) { + resetConnStateForTest(t) + + savedBuffer := buffer + buffer = make([]byte, 4096) + t.Cleanup(func() { buffer = savedBuffer }) + + NotifyJSReady() // sync.Once; safe to call unconditionally + + local, remote := net.Pipe() + t.Cleanup(func() { _ = local.Close(); _ = remote.Close() }) + + setConn(remote, 9) + + go func() { + _, _ = local.Write([]byte("hello")) + }() + + data, err := ReadArr() + if err != nil { + t.Fatalf("ReadArr returned error: %v", err) + } + if string(data) != "hello" { + t.Fatalf("unexpected data: %q", data) + } + + if got := LastReadEpoch(); got != 9 { + t.Errorf("expected LastReadEpoch to be 9, got %d", got) + } + + // Now redial to a new connection/epoch and read again; LastReadEpoch + // must track the new one, not the old. + local2, remote2 := net.Pipe() + t.Cleanup(func() { _ = local2.Close(); _ = remote2.Close() }) + setConn(remote2, 10) + + go func() { + _, _ = local2.Write([]byte("world")) + }() + + if _, err := ReadArr(); err != nil { + t.Fatalf("second ReadArr returned error: %v", err) + } + if got := LastReadEpoch(); got != 10 { + t.Errorf("expected LastReadEpoch to be 10 after redial, got %d", got) + } +} + +// Test 7: concurrent redials and resets from multiple goroutines must not +// panic, deadlock, or leave torn state, under -race. +func TestConcurrentRedialsAndResets(t *testing.T) { + resetConnStateForTest(t) + + ll := libkb.NewLoopbackListener(fakeLogContext{}) + t.Cleanup(func() { _ = ll.Close() }) + + go func() { + for { + if _, err := ll.Accept(); err != nil { + return + } + } + }() + + connMutex.Lock() + kbCtx = &libkb.GlobalContext{LoopbackListener: ll} + connMutex.Unlock() + setInited() + + const goroutines = 20 + const iterations = 100 + + var wg sync.WaitGroup + wg.Add(goroutines * 3) + + // Redialers: mimic the lazy-init pattern ReadArr/WriteArr use. + for g := 0; g < goroutines; g++ { + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + connMutex.Lock() + if conn == nil { + _ = ensureConnection() + } + connMutex.Unlock() + } + }() + } + + // Failure-driven resetters: capture an epoch, then race to reset it. + for g := 0; g < goroutines; g++ { + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + connMutex.Lock() + epoch := connEpoch + connMutex.Unlock() + _ = ResetIfCurrent(epoch) + } + }() + } + + // Unconditional resetters: e.g. concurrent invalidate/engineReset. + for g := 0; g < goroutines; g++ { + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + _ = Reset() + } + }() + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("concurrent redial/reset test deadlocked") + } + + // No torn state: the mechanism must still be usable afterward. A fresh + // dial must succeed and report a strictly higher epoch than any seen + // mid-run. + connMutex.Lock() + preEpoch := connEpoch + conn = nil + err := ensureConnection() + postEpoch := connEpoch + postConn := conn + connMutex.Unlock() + + if err != nil { + t.Fatalf("post-concurrency ensureConnection failed: %v", err) + } + if postConn == nil { + t.Fatal("post-concurrency conn is nil after a successful dial") + } + if postEpoch <= preEpoch { + t.Fatalf("post-concurrency epoch did not advance: pre=%d post=%d", preEpoch, postEpoch) + } +} From 3797cd080853068dd39bd78a0298dac325b454d5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 11:21:47 -0400 Subject: [PATCH 63/80] refactor(rpc): extract FrameParser, the pure msgpack framing state machine 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. --- .../react-native-kb/android/CMakeLists.txt | 1 + .../react-native-kb/cpp/frame-parser.cpp | 73 ++++++++++++++ rnmodules/react-native-kb/cpp/frame-parser.h | 84 ++++++++++++++++ .../react-native-kb/cpp/react-native-kb.cpp | 99 +++---------------- .../react-native-kb/cpp/react-native-kb.h | 2 - .../react-native-kb/react-native-kb.podspec | 3 + shared/ios/Podfile.lock | 2 +- 7 files changed, 174 insertions(+), 90 deletions(-) create mode 100644 rnmodules/react-native-kb/cpp/frame-parser.cpp create mode 100644 rnmodules/react-native-kb/cpp/frame-parser.h diff --git a/rnmodules/react-native-kb/android/CMakeLists.txt b/rnmodules/react-native-kb/android/CMakeLists.txt index 30091fd94228..03182308880d 100644 --- a/rnmodules/react-native-kb/android/CMakeLists.txt +++ b/rnmodules/react-native-kb/android/CMakeLists.txt @@ -8,6 +8,7 @@ set (NODE_MODULES_DIR "${CMAKE_SOURCE_DIR}/../..") add_library(cpp SHARED ../cpp/react-native-kb.cpp + ../cpp/frame-parser.cpp cpp-adapter.cpp ) diff --git a/rnmodules/react-native-kb/cpp/frame-parser.cpp b/rnmodules/react-native-kb/cpp/frame-parser.cpp new file mode 100644 index 000000000000..7e0e17e981b4 --- /dev/null +++ b/rnmodules/react-native-kb/cpp/frame-parser.cpp @@ -0,0 +1,73 @@ +#include "frame-parser.h" +#include +#include + +namespace kb { + +void FrameParser::feed(const uint8_t *data, size_t size, + std::vector &out) { + unpacker_.reserve_buffer(size); + std::memcpy(unpacker_.buffer(), data, size); + unpacker_.buffer_consumed(size); + totalFed_ += size; + + while (true) { + msgpack::object_handle result; + if (!unpacker_.next(result)) { + break; + } + if (state_ == ReadState::needSize) { + // The framing prefix must be a msgpack uint. Anything else means the + // stream desynced; without this check the parity flips and every + // later frame is silently swallowed as a "size". + const auto &o = result.get(); + if (o.type != msgpack::type::POSITIVE_INTEGER || + o.as() > kMaxFrameSize) { + throw std::runtime_error("bad rpc frame header"); + } + declaredSize_ = static_cast(o.as()); + peakFrameSize_ = std::max(peakFrameSize_, declaredSize_); + consumedAtHeader_ = totalFed_ - unpacker_.nonparsed_size(); + state_ = ReadState::needContent; + } else { + // The header is only a plausibility check on its own: a fixint sitting + // inside a string payload parses as a valid header after a resync. + // Requiring the content object to consume exactly the declared byte + // count makes the framing self-checking, so a truncated or overlong + // frame is caught here instead of being handed to JS as a bogus + // [type, seqid, ...]. + // + // parsed_size() is NOT usable here: unpacker::next() resets it to 0 on + // every successful parse, so it can't measure a span across two next() + // calls. totalFed - nonparsed_size() is a genuine monotonic count of + // bytes consumed from the stream. + const size_t consumed = + (totalFed_ - unpacker_.nonparsed_size()) - consumedAtHeader_; + if (consumed != declaredSize_) { + throw std::runtime_error("rpc frame length mismatch"); + } + out.push_back(std::move(result)); + state_ = ReadState::needSize; + } + } + + if (unpacker_.nonparsed_size() > kMaxFrameSize + kMaxFrameSlack) { + throw std::runtime_error("rpc frame exceeds size limit"); + } +} + +void FrameParser::reset() { + unpacker_ = msgpack::unpacker(); + state_ = ReadState::needSize; + declaredSize_ = 0; + consumedAtHeader_ = 0; + totalFed_ = 0; + peakFrameSize_ = 0; +} + +bool FrameParser::atSafeShrinkPoint() const { + return state_ == ReadState::needSize && unpacker_.nonparsed_size() == 0 && + peakFrameSize_ > kRecvBufKeepCapacity; +} + +} // namespace kb diff --git a/rnmodules/react-native-kb/cpp/frame-parser.h b/rnmodules/react-native-kb/cpp/frame-parser.h new file mode 100644 index 000000000000..80a5c2fc9196 --- /dev/null +++ b/rnmodules/react-native-kb/cpp/frame-parser.h @@ -0,0 +1,84 @@ +// Pure msgpack framing state machine, deliberately free of jsi/React/platform +// dependencies so it can be unit tested without a JS runtime. See +// react-native-kb.cpp's onDataFromGo for how the JSI-facing half (delivery to +// JS, epoch handling, error reporting) wraps this. +#pragma once + +#include "msgpack-safe.hpp" +#include +#include +#include +#include +#include + +namespace kb { + +// Decodes a stream of `` frames, matching the +// JS-side packetizer. Not thread safe; the caller (KBBridge::onDataFromGo) +// serializes access under recvMutex_. +class FrameParser { +public: + // A desynced length prefix can otherwise ask us to buffer gigabytes. + // Matches the JS-side packetizer limit. + static constexpr uint64_t kMaxFrameSize = 64ull * 1024 * 1024; + // nonparsed_size() includes the frame header plus any bytes of the next + // frame already buffered in the same read; the header check separately + // accepts a declared size of exactly kMaxFrameSize. Without this margin a + // legal maximal frame arriving in small chunks trips the limit on its last + // chunk. + static constexpr size_t kMaxFrameSlack = 1024 * 1024; + // msgpack::unpacker rewinds its buffer in place but never shrinks the + // realloc'd allocation, so this bounds how long a single large frame's peak + // stays resident. + static constexpr size_t kRecvBufKeepCapacity = 4u * 1024 * 1024; + + FrameParser() = default; + FrameParser(const FrameParser &) = delete; + FrameParser &operator=(const FrameParser &) = delete; + + // Feeds `size` bytes of newly received data into the unpacker and decodes + // as many complete frames as are now available, appending them to `out` in + // wire order. Throws std::runtime_error (with a descriptive message) on any + // framing violation: bad header type/value, length mismatch, or exceeding + // the size limit. On throw, `out` may already contain frames decoded + // earlier in this same call -- the caller must reset() before continuing, + // since the stream can no longer be trusted from that point on. + void feed(const uint8_t *data, size_t size, + std::vector &out); + + // Drops any partially parsed frame and all buffered state, starting over + // from a fresh unpacker. Must be called after feed() throws, or whenever + // the underlying byte stream is known to have been replaced (e.g. the Go + // connection was redialed), since resuming mid-frame on a new stream would + // fail the next header check on otherwise-valid data. + void reset(); + + // True if state == needSize, nonparsed_size() == 0 (no partial frame and no + // unparsed bytes buffered), and peakFrameSize since the last reset exceeds + // kRecvBufKeepCapacity. This is a provably safe resync point at which the + // caller may choose to reset() purely to release a large realloc'd buffer, + // independent of any framing error. + bool atSafeShrinkPoint() const; + +private: + enum class ReadState { needSize, needContent }; + + msgpack::unpacker unpacker_; + ReadState state_ = ReadState::needSize; + // Persist across calls: a frame's header and its content routinely arrive + // in separate reads. + size_t declaredSize_ = 0; + size_t consumedAtHeader_ = 0; + // Every byte ever handed to the unpacker. parsed_size() cannot serve this + // role -- next() zeroes it on each success -- but totalFed minus + // nonparsed_size() is an accurate running count of what has been consumed. + size_t totalFed_ = 0; + // High-water mark of declaredSize since the last reset. declaredSize + // itself gets overwritten by the next header before we necessarily reach a + // safe (nonparsed_size() == 0) point to act on it, so this survives that + // overwrite and lets the shrink check fire for the frame that actually + // grew the buffer. + size_t peakFrameSize_ = 0; +}; + +} // namespace kb diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index ce5df01f30d0..bd8fc27d79d6 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -1,8 +1,8 @@ #include "react-native-kb.h" -#include #include #include #include +#include "frame-parser.h" #include "msgpack-safe.hpp" #include #include @@ -17,42 +17,17 @@ namespace kb { namespace { // A desynced length prefix can otherwise ask us to buffer gigabytes. Matches // the JS-side packetizer limit. -constexpr uint64_t kMaxFrameSize = 64ull * 1024 * 1024; +constexpr uint64_t kMaxFrameSize = FrameParser::kMaxFrameSize; // Conversion is iterative, so nesting costs heap rather than native stack. // This is a sanity bound on pathological payloads, not a stack guard. constexpr size_t kMaxDepth = 1024; // Don't let one huge attachment frame permanently retain its peak size. constexpr size_t kSendBufKeepCapacity = 4u * 1024 * 1024; -// msgpack::unpacker rewinds its buffer in place but never shrinks the -// realloc'd allocation, so this bounds how long a single large frame's peak -// stays resident. -constexpr size_t kRecvBufKeepCapacity = 4u * 1024 * 1024; -// nonparsed_size() includes the frame header plus any bytes of the next -// frame already buffered in the same read; the header check separately -// accepts a declared size of exactly kMaxFrameSize. Without this margin a -// legal maximal frame arriving in small chunks trips the limit on its last -// chunk. -constexpr size_t kMaxFrameSlack = 1024 * 1024; constexpr size_t kMaxCachedPropNames = 4096; } // namespace struct KBBridge::RecvState { - msgpack::unpacker unpacker; - ReadState state = ReadState::needSize; - // Persist across calls: a frame's header and its content routinely arrive - // in separate reads. - size_t declaredSize = 0; - size_t consumedAtHeader = 0; - // Every byte ever handed to the unpacker. parsed_size() cannot serve this - // role -- next() zeroes it on each success -- but totalFed minus - // nonparsed_size() is an accurate running count of what has been consumed. - size_t totalFed = 0; - // High-water mark of declaredSize since the last reset. declaredSize - // itself gets overwritten by the next header before we necessarily reach a - // safe (nonparsed_size() == 0) point to act on it, so this survives that - // overwrite and lets the shrink check fire for the frame that actually - // grew the buffer. - // + // Owns the unpacker and all pure framing arithmetic; see frame-parser.h. // This is only a meaningful shrink trigger because Go's ReadArr // (go/bind/keybase.go) reads into a fixed 300KB buffer per call: a stream // of many small frames delivered across many small reads can't durably @@ -60,7 +35,7 @@ struct KBBridge::RecvState { // once fully drained. If that Go-side buffer ever grows, peakFrameSize // stops tracking "did one big frame arrive" and starts tracking "did // several small frames arrive in the same read" instead. - size_t peakFrameSize = 0; + FrameParser parser; // Epoch of the Go connection the most recent onDataFromGo call's bytes // came from. Set at the top of onDataFromGo under recvMutex_; read back // here only for anything that inspects RecvState directly. The fatal @@ -665,64 +640,14 @@ void KBBridge::onDataFromGo(uint8_t *data, int size, int64_t epoch) { } recv_->epoch = epoch; try { - auto &up = recv_->unpacker; - up.reserve_buffer(static_cast(size)); - std::memcpy(up.buffer(), data, static_cast(size)); - up.buffer_consumed(static_cast(size)); - recv_->totalFed += static_cast(size); - - while (true) { - msgpack::object_handle result; - if (!up.next(result)) { - break; - } - if (recv_->state == ReadState::needSize) { - // The framing prefix must be a msgpack uint. Anything else means - // the stream desynced; without this check the parity flips and - // every later frame is silently swallowed as a "size". - const auto &o = result.get(); - if (o.type != msgpack::type::POSITIVE_INTEGER || - o.as() > kMaxFrameSize) { - throw std::runtime_error("bad rpc frame header"); - } - recv_->declaredSize = static_cast(o.as()); - recv_->peakFrameSize = - std::max(recv_->peakFrameSize, recv_->declaredSize); - recv_->consumedAtHeader = recv_->totalFed - up.nonparsed_size(); - recv_->state = ReadState::needContent; - } else { - // The header is only a plausibility check on its own: a fixint - // sitting inside a string payload parses as a valid header after a - // resync. Requiring the content object to consume exactly the - // declared byte count makes the framing self-checking, so a - // truncated or overlong frame is caught here instead of being - // handed to JS as a bogus [type, seqid, ...]. - // - // parsed_size() is NOT usable here: unpacker::next() resets it to - // 0 on every successful parse, so it can't measure a span across - // two next() calls. totalFed - nonparsed_size() is a genuine - // monotonic count of bytes consumed from the stream. - const size_t consumed = - (recv_->totalFed - up.nonparsed_size()) - recv_->consumedAtHeader; - if (consumed != recv_->declaredSize) { - throw std::runtime_error("rpc frame length mismatch"); - } - values->push_back(std::move(result)); - recv_->state = ReadState::needSize; - } - } - - if (up.nonparsed_size() > kMaxFrameSize + kMaxFrameSlack) { - throw std::runtime_error("rpc frame exceeds size limit"); - } - - // needSize with nothing unparsed is a provably safe resync point: no - // partial frame and no unparsed bytes to lose. resetRecvLocked() - // rebuilds RecvState (including totalFed and the unpacker) together, - // so the totalFed/consumedAtHeader invariant restarts from a - // consistent fresh baseline rather than desyncing. - if (recv_->state == ReadState::needSize && up.nonparsed_size() == 0 && - recv_->peakFrameSize > kRecvBufKeepCapacity) { + recv_->parser.feed(data, static_cast(size), *values); + + // A provably safe resync point: no partial frame and no unparsed + // bytes to lose. resetRecvLocked() rebuilds RecvState (including the + // parser's totalFed and unpacker) together, so the + // totalFed/consumedAtHeader invariant restarts from a consistent + // fresh baseline rather than desyncing. + if (recv_->parser.atSafeShrinkPoint()) { resetRecvLocked(); } } catch (const std::exception &e) { diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.h b/rnmodules/react-native-kb/cpp/react-native-kb.h index f168a705c606..1a3d937c9cc8 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.h +++ b/rnmodules/react-native-kb/cpp/react-native-kb.h @@ -76,8 +76,6 @@ class KBBridge : public std::enable_shared_from_this { std::function writeToGo_; std::atomic isTornDown_{false}; - enum class ReadState { needSize, needContent }; - // Incoming stream state. Touched only from the native reader thread, and // under recvMutex_ so a stray second reader can't corrupt the unpacker. struct RecvState; diff --git a/rnmodules/react-native-kb/react-native-kb.podspec b/rnmodules/react-native-kb/react-native-kb.podspec index 1ec4a7782d1e..d122ea4ee3db 100644 --- a/rnmodules/react-native-kb/react-native-kb.podspec +++ b/rnmodules/react-native-kb/react-native-kb.podspec @@ -20,6 +20,9 @@ Pod::Spec.new do |s| "cpp/**/*.{h,cpp}", "cpp/*.{h,cpp}" ] + # cpp/tests is a standalone plain-clang++ test binary (see + # plans/scripts/test-native-kb-framing.sh), not part of the shipped module. + s.exclude_files = "cpp/tests/**/*" s.dependency "KBCommon" diff --git a/shared/ios/Podfile.lock b/shared/ios/Podfile.lock index 4111ce9e75b7..7ec424da5edd 100644 --- a/shared/ios/Podfile.lock +++ b/shared/ios/Podfile.lock @@ -2965,7 +2965,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 1a42eda3f9d415ba9f7dcd7f1fe19d4f27925751 React-microtasksnativemodule: 354ab193a7ed66e42869c8a0c2c0d844e8e22f67 React-mutationobservernativemodule: f53ad06cbe4b44d2a33df6aa3f01ca8e9b57e83b - react-native-kb: 71289be80d93678d5abff5805cb638cd36d9e08e + react-native-kb: d3ef6bac088ef6794bcc6c7ab8d533d046c5675e react-native-keyboard-controller: 8305b19b7841bab139be15cde9f424a502abaa6e react-native-netinfo: a05f9b897e76ad24b53f615fed1cbb731932363d react-native-safe-area-context: 0bbcdfba5c4a1b3203276f1ed9128e3239fdf199 From d6f36c1c44f96d967341a7d644d6912028ab5624 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 11:21:57 -0400 Subject: [PATCH 64/80] test(rpc): add the first unit test suite for the msgpack framing logic 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. --- plans/scripts/test-native-kb-framing.sh | 26 ++ .../react-native-kb/cpp/tests/frame-builder.h | 112 ++++++ .../cpp/tests/frame-parser-test.cpp | 329 ++++++++++++++++++ .../react-native-kb/cpp/tests/test-harness.h | 87 +++++ 4 files changed, 554 insertions(+) create mode 100755 plans/scripts/test-native-kb-framing.sh create mode 100644 rnmodules/react-native-kb/cpp/tests/frame-builder.h create mode 100644 rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp create mode 100644 rnmodules/react-native-kb/cpp/tests/test-harness.h diff --git a/plans/scripts/test-native-kb-framing.sh b/plans/scripts/test-native-kb-framing.sh new file mode 100755 index 000000000000..6442a67c2bd3 --- /dev/null +++ b/plans/scripts/test-native-kb-framing.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Builds and runs the FrameParser unit tests +# (rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp). No JS runtime +# or JSI headers are needed -- FrameParser has no jsi/React dependency -- so +# this only needs a plain msgpack-cxx include path, the same one used for the +# react-native-kb.cpp syntax-only clang++ check. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CPP_DIR="$ROOT/rnmodules/react-native-kb/cpp" +MSGPACK_INCLUDE="$ROOT/shared/node_modules/msgpack-cxx-7.0.0/include" +test -d "$MSGPACK_INCLUDE" || { + echo "missing $MSGPACK_INCLUDE — run yarn in shared/ first" >&2 + exit 1 +} + +BIN="$(mktemp -d)/frame-parser-test" +trap 'rm -rf "$(dirname "$BIN")"' EXIT + +clang++ -std=c++20 -O1 -g -DMSGPACK_NO_BOOST \ + -Wall -Wextra \ + -I "$MSGPACK_INCLUDE" \ + "$CPP_DIR/frame-parser.cpp" \ + "$CPP_DIR/tests/frame-parser-test.cpp" \ + -o "$BIN" + +"$BIN" diff --git a/rnmodules/react-native-kb/cpp/tests/frame-builder.h b/rnmodules/react-native-kb/cpp/tests/frame-builder.h new file mode 100644 index 000000000000..688f72a327fe --- /dev/null +++ b/rnmodules/react-native-kb/cpp/tests/frame-builder.h @@ -0,0 +1,112 @@ +// Test-only helpers for constructing raw `` +// frames byte-for-byte, mirroring the JS-side packetizer that FrameParser +// decodes. +#pragma once + +#include "../msgpack-safe.hpp" +#include +#include +#include +#include + +namespace kbtest { + +using Bytes = std::vector; + +inline Bytes sbufferToBytes(const msgpack::sbuffer &buf) { + const auto *p = reinterpret_cast(buf.data()); + return Bytes(p, p + buf.size()); +} + +// Packs `fn(packer)` into a standalone msgpack object and returns its wire +// bytes. +inline Bytes packContent( + const std::function &)> &fn) { + msgpack::sbuffer buf; + msgpack::packer pk(&buf); + fn(pk); + return sbufferToBytes(buf); +} + +// Packs a bare msgpack unsigned integer header (the smallest encoding msgpack +// chooses for `value`), matching how the framing prefix is written on the +// wire. +inline Bytes packHeader(uint64_t value) { + msgpack::sbuffer buf; + msgpack::packer pk(&buf); + pk.pack_uint64(value); + return sbufferToBytes(buf); +} + +// A well-formed frame: header declares content.size(), content follows. +inline Bytes buildFrame(const Bytes &content) { + Bytes out = packHeader(content.size()); + out.insert(out.end(), content.begin(), content.end()); + return out; +} + +// A frame whose declared size does not match content.size() -- for testing +// the length-mismatch detection. +inline Bytes buildFrameWithDeclaredSize(uint64_t declaredSize, + const Bytes &content) { + Bytes out = packHeader(declaredSize); + out.insert(out.end(), content.begin(), content.end()); + return out; +} + +// Convenience content generators. + +inline Bytes contentSmallMap() { + return packContent([](auto &pk) { + pk.pack_map(2); + pk.pack(std::string("type")); + pk.pack(1); + pk.pack(std::string("seqid")); + pk.pack(42); + }); +} + +inline Bytes contentEmptyMap() { + return packContent([](auto &pk) { pk.pack_map(0); }); +} + +inline Bytes contentEmptyArray() { + return packContent([](auto &pk) { pk.pack_array(0); }); +} + +// Binary payload of exactly `totalWireSize` bytes on the wire (header + +// body), using bin32 encoding (5-byte header) which applies once the body +// exceeds 65535 bytes. Lets a caller hit an exact target frame size, such as +// exactly kMaxFrameSize. +inline Bytes contentBinOfWireSize(size_t totalWireSize) { + constexpr size_t kBin32HeaderLen = 5; + size_t bodyLen = totalWireSize - kBin32HeaderLen; + msgpack::sbuffer buf; + msgpack::packer pk(&buf); + pk.pack_bin(static_cast(bodyLen)); + std::string body(bodyLen, 'x'); + pk.pack_bin_body(body.data(), static_cast(bodyLen)); + return sbufferToBytes(buf); +} + +inline void packNestedArray(msgpack::packer &pk, + int depth) { + if (depth == 0) { + pk.pack(42); + return; + } + pk.pack_array(1); + packNestedArray(pk, depth - 1); +} + +inline Bytes contentNested(int depth) { + return packContent( + [depth](auto &pk) { packNestedArray(pk, depth); }); +} + +// Non-integer header content (a string), for the "bad header type" case. +inline Bytes headerAsString() { + return packContent([](auto &pk) { pk.pack(std::string("not a size")); }); +} + +} // namespace kbtest diff --git a/rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp b/rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp new file mode 100644 index 000000000000..9482a7983501 --- /dev/null +++ b/rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp @@ -0,0 +1,329 @@ +// Unit tests for FrameParser, the pure msgpack framing state machine +// extracted from KBBridge::onDataFromGo (see ../frame-parser.h). No jsi/React +// dependency: these exercise the exact production framing arithmetic without +// a JS runtime. +#include "../frame-parser.h" +#include "frame-builder.h" +#include "test-harness.h" +#include +#include + +using kb::FrameParser; +using namespace kbtest; + +namespace { + +// Feeds `frame` to `parser` in chunks of `chunkSize` bytes (last chunk may be +// smaller), appending decoded objects to `out`. +void feedInChunks(FrameParser &parser, const Bytes &frame, size_t chunkSize, + std::vector &out) { + size_t pos = 0; + while (pos < frame.size()) { + size_t n = std::min(chunkSize, frame.size() - pos); + parser.feed(frame.data() + pos, n, out); + pos += n; + } +} + +void testSingleFrameDecodesOne() { + FrameParser parser; + std::vector out; + Bytes frame = buildFrame(contentSmallMap()); + parser.feed(frame.data(), frame.size(), out); + CHECK_EQ(out.size(), 1u); + CHECK(out[0].get().type == msgpack::type::MAP); +} + +void testMultipleFramesCoalescedInOrder() { + FrameParser parser; + std::vector out; + Bytes all; + for (int i = 0; i < 5; ++i) { + Bytes content = packContent( + [i](auto &pk) { pk.pack_array(1); pk.pack(i); }); + Bytes frame = buildFrame(content); + all.insert(all.end(), frame.begin(), frame.end()); + } + parser.feed(all.data(), all.size(), out); + CHECK_EQ(out.size(), 5u); + for (int i = 0; i < 5; ++i) { + const auto &o = out[static_cast(i)].get(); + CHECK(o.type == msgpack::type::ARRAY); + CHECK_EQ(o.via.array.ptr[0].as(), i); + } +} + +void testFrameSplitAcrossTwoFeeds() { + FrameParser parser; + std::vector out; + Bytes frame = buildFrame(contentSmallMap()); + // Header in one feed, content in the next. + Bytes header = packHeader(contentSmallMap().size()); + parser.feed(frame.data(), header.size(), out); + CHECK_EQ(out.size(), 0u); + parser.feed(frame.data() + header.size(), frame.size() - header.size(), + out); + CHECK_EQ(out.size(), 1u); +} + +void testFrameSplitAtEveryByteBoundary() { + // Exercises totalFed - nonparsed_size() arithmetic at every possible split + // point across a single frame. + Bytes content = packContent([](auto &pk) { + pk.pack_map(3); + pk.pack(std::string("a")); + pk.pack(1); + pk.pack(std::string("b")); + pk.pack(std::string("some string value")); + pk.pack(std::string("c")); + pk.pack_array(3); + pk.pack(1); + pk.pack(2); + pk.pack(3); + }); + Bytes frame = buildFrame(content); + + for (size_t split = 0; split <= frame.size(); ++split) { + FrameParser parser; + std::vector out; + if (split > 0) { + parser.feed(frame.data(), split, out); + } + if (split < frame.size()) { + parser.feed(frame.data() + split, frame.size() - split, out); + } + CHECK_MSG(out.size() == 1, + "split at " + std::to_string(split) + " produced " + + std::to_string(out.size()) + " messages, want 1"); + CHECK(out[0].get().type == msgpack::type::MAP); + } +} + +void testConsumedEqualsDeclaredAcrossSizes() { + // Includes a payload well past msgpack::unpacker's default initial buffer, + // forcing at least one internal reserve/grow. + const std::vector sizes = {0, 1, 100, 4096, 1u << 20}; + for (size_t n : sizes) { + FrameParser parser; + std::vector out; + Bytes content = packContent([n](auto &pk) { + std::string body(n, 'z'); + pk.pack(body); + }); + Bytes frame = buildFrame(content); + parser.feed(frame.data(), frame.size(), out); + CHECK_MSG(out.size() == 1, "size " + std::to_string(n)); + CHECK(out[0].get().type == msgpack::type::STR); + CHECK_EQ(out[0].get().via.str.size, static_cast(n)); + } +} + +void testDeclaredLengthMismatchDetected() { + FrameParser parser; + std::vector out; + Bytes content = contentSmallMap(); + Bytes frame = buildFrameWithDeclaredSize(content.size() + 3, content); + bool threw = false; + try { + parser.feed(frame.data(), frame.size(), out); + } catch (const std::runtime_error &e) { + threw = true; + CHECK_MSG(std::string(e.what()).find("length mismatch") != + std::string::npos, + std::string("unexpected message: ") + e.what()); + } + CHECK_MSG(threw, "expected a length-mismatch throw"); +} + +void testNonIntegerHeaderDetected() { + FrameParser parser; + std::vector out; + Bytes badHeader = headerAsString(); + bool threw = false; + try { + parser.feed(badHeader.data(), badHeader.size(), out); + } catch (const std::runtime_error &e) { + threw = true; + CHECK_MSG(std::string(e.what()).find("bad rpc frame header") != + std::string::npos, + std::string("unexpected message: ") + e.what()); + } + CHECK_MSG(threw, "expected a bad-header throw"); +} + +void testDeclaredSizeZeroRejected() { + FrameParser parser; + std::vector out; + // No msgpack object is zero bytes, so a declared size of 0 can never be + // satisfied by real content -- it must surface as a length mismatch. + Bytes content = contentEmptyMap(); + Bytes frame = buildFrameWithDeclaredSize(0, content); + bool threw = false; + try { + parser.feed(frame.data(), frame.size(), out); + } catch (const std::runtime_error &) { + threw = true; + } + CHECK_MSG(threw, "expected declared size 0 to be rejected"); +} + +void testOversizedFrameRejectedAndLegalMaxNotFalselyRejected() { + // A header declaring more than kMaxFrameSize is rejected immediately. + { + FrameParser parser; + std::vector out; + Bytes header = packHeader(FrameParser::kMaxFrameSize + 1); + bool threw = false; + try { + parser.feed(header.data(), header.size(), out); + } catch (const std::runtime_error &) { + threw = true; + } + CHECK_MSG(threw, "expected a >max-size header to be rejected"); + } + + // A LEGAL maximal frame (declaredSize == kMaxFrameSize exactly) arriving in + // small chunks must NOT be falsely rejected by the nonparsed_size() vs. + // size-limit check. This proves the kMaxFrameSlack margin fix. + { + FrameParser parser; + std::vector out; + Bytes content = contentBinOfWireSize(FrameParser::kMaxFrameSize); + CHECK_EQ(content.size(), FrameParser::kMaxFrameSize); + Bytes frame = buildFrame(content); + constexpr size_t kChunkSize = 65537; // deliberately not a divisor + feedInChunks(parser, frame, kChunkSize, out); + CHECK_EQ(out.size(), 1u); + CHECK(out[0].get().type == msgpack::type::BIN); + } +} + +void testRecoveryAfterErrorAndReset() { + FrameParser parser; + std::vector out; + Bytes bad = headerAsString(); + bool threw = false; + try { + parser.feed(bad.data(), bad.size(), out); + } catch (const std::runtime_error &) { + threw = true; + } + CHECK_MSG(threw, "setup: expected the bad header to throw"); + parser.reset(); + + for (int i = 0; i < 3; ++i) { + Bytes content = packContent([i](auto &pk) { pk.pack(i); }); + Bytes frame = buildFrame(content); + parser.feed(frame.data(), frame.size(), out); + } + CHECK_EQ(out.size(), 3u); + for (int i = 0; i < 3; ++i) { + CHECK_EQ(out[static_cast(i)].get().as(), i); + } +} + +void testBufferShrinkFiresAndArithmeticHoldsAfterShrink() { + FrameParser parser; + std::vector out; + + // A frame comfortably larger than kRecvBufKeepCapacity. + const size_t bigSize = FrameParser::kRecvBufKeepCapacity + (1u << 20); + Bytes bigContent = packContent( + [bigSize](auto &pk) { pk.pack(std::string(bigSize, 'q')); }); + Bytes bigFrame = buildFrame(bigContent); + parser.feed(bigFrame.data(), bigFrame.size(), out); + CHECK_EQ(out.size(), 1u); + + // A small frame right after it. peakFrameSize must remember the earlier + // large frame, not get overwritten by this small one's declaredSize -- this + // is the regression the peakFrameSize fix addressed. + Bytes smallContent = packContent([](auto &pk) { pk.pack(7); }); + Bytes smallFrame = buildFrame(smallContent); + parser.feed(smallFrame.data(), smallFrame.size(), out); + CHECK_EQ(out.size(), 2u); + + CHECK_MSG(parser.atSafeShrinkPoint(), + "expected a safe shrink point after a big frame followed by a " + "small one"); + + // Mirror what onDataFromGo does at a safe shrink point: drop and rebuild. + parser.reset(); + + // Framing arithmetic must still hold for frames decoded after the shrink. + for (int i = 0; i < 4; ++i) { + Bytes content = packContent([i](auto &pk) { pk.pack(100 + i); }); + Bytes frame = buildFrame(content); + parser.feed(frame.data(), frame.size(), out); + } + CHECK_EQ(out.size(), 6u); + for (int i = 0; i < 4; ++i) { + CHECK_EQ(out[2 + static_cast(i)].get().as(), 100 + i); + } +} + +void testNestedAndEmptyContainersRoundtrip() { + { + FrameParser parser; + std::vector out; + Bytes frame = buildFrame(contentEmptyMap()); + parser.feed(frame.data(), frame.size(), out); + CHECK_EQ(out.size(), 1u); + CHECK(out[0].get().type == msgpack::type::MAP); + CHECK_EQ(out[0].get().via.map.size, 0u); + } + { + FrameParser parser; + std::vector out; + Bytes frame = buildFrame(contentEmptyArray()); + parser.feed(frame.data(), frame.size(), out); + CHECK_EQ(out.size(), 1u); + CHECK(out[0].get().type == msgpack::type::ARRAY); + CHECK_EQ(out[0].get().via.array.size, 0u); + } + { + // Near but under a typical application-level depth limit (1024 in + // react-native-kb.cpp); FrameParser itself has no depth limit of its + // own, so this proves the raw decode handles realistic nesting. + FrameParser parser; + std::vector out; + const int depth = 900; + Bytes frame = buildFrame(contentNested(depth)); + parser.feed(frame.data(), frame.size(), out); + CHECK_EQ(out.size(), 1u); + const msgpack::object *cur = &out[0].get(); + int seen = 0; + while (cur->type == msgpack::type::ARRAY) { + CHECK_EQ(cur->via.array.size, 1u); + cur = &cur->via.array.ptr[0]; + ++seen; + } + CHECK_EQ(seen, depth); + CHECK_EQ(cur->as(), 42); + } +} + +} // namespace + +int main() { + Runner runner; + runner.add("single_frame_decodes_one_message", testSingleFrameDecodesOne); + runner.add("multiple_frames_coalesced_decode_in_order", + testMultipleFramesCoalescedInOrder); + runner.add("frame_split_across_two_feeds", testFrameSplitAcrossTwoFeeds); + runner.add("frame_split_at_every_byte_boundary", + testFrameSplitAtEveryByteBoundary); + runner.add("consumed_equals_declared_across_sizes", + testConsumedEqualsDeclaredAcrossSizes); + runner.add("declared_length_mismatch_detected", + testDeclaredLengthMismatchDetected); + runner.add("non_integer_header_detected", testNonIntegerHeaderDetected); + runner.add("declared_size_zero_rejected", testDeclaredSizeZeroRejected); + runner.add("oversized_frame_rejected_and_legal_max_not_falsely_rejected", + testOversizedFrameRejectedAndLegalMaxNotFalselyRejected); + runner.add("recovery_after_error_and_reset", testRecoveryAfterErrorAndReset); + runner.add("buffer_shrink_fires_and_arithmetic_holds_after_shrink", + testBufferShrinkFiresAndArithmeticHoldsAfterShrink); + runner.add("nested_and_empty_containers_roundtrip", + testNestedAndEmptyContainersRoundtrip); + return runner.run(); +} diff --git a/rnmodules/react-native-kb/cpp/tests/test-harness.h b/rnmodules/react-native-kb/cpp/tests/test-harness.h new file mode 100644 index 000000000000..b1cd992cc5ee --- /dev/null +++ b/rnmodules/react-native-kb/cpp/tests/test-harness.h @@ -0,0 +1,87 @@ +// Minimal assert/report harness. No gtest/catch2 dependency so the test +// binary stays buildable with the same plain clang++ invocation already used +// for syntax-checking react-native-kb.cpp. +#pragma once + +#include +#include +#include +#include +#include + +namespace kbtest { + +struct Failure { + std::string message; +}; + +// Thrown by CHECK/CHECK_EQ on failure; caught by the runner per test case. +struct CheckFailed : std::exception { + std::string message; + explicit CheckFailed(std::string m) : message(std::move(m)) {} + const char *what() const noexcept override { return message.c_str(); } +}; + +inline void check(bool cond, const std::string &msg, const char *file, + int line) { + if (!cond) { + throw CheckFailed(std::string(file) + ":" + std::to_string(line) + ": " + + msg); + } +} + +#define CHECK(cond) \ + ::kbtest::check((cond), "CHECK failed: " #cond, __FILE__, __LINE__) +#define CHECK_MSG(cond, msg) \ + ::kbtest::check((cond), (msg), __FILE__, __LINE__) + +template +void checkEq(const A &a, const B &b, const char *aExpr, const char *bExpr, + const char *file, int line) { + if (!(a == b)) { + throw CheckFailed(std::string(file) + ":" + std::to_string(line) + + ": CHECK_EQ failed: " + aExpr + " (" + + std::to_string(a) + ") != " + bExpr + " (" + + std::to_string(b) + ")"); + } +} + +#define CHECK_EQ(a, b) \ + ::kbtest::checkEq((a), (b), #a, #b, __FILE__, __LINE__) + +class Runner { +public: + void add(std::string name, std::function fn) { + cases_.push_back({std::move(name), std::move(fn)}); + } + + // Returns 0 if every case passed, 1 otherwise. Prints PASS/FAIL per case + // and a final summary. + int run() { + int failed = 0; + for (auto &c : cases_) { + try { + c.fn(); + std::cout << "[PASS] " << c.name << "\n"; + } catch (const std::exception &e) { + ++failed; + std::cout << "[FAIL] " << c.name << ": " << e.what() << "\n"; + } catch (...) { + ++failed; + std::cout << "[FAIL] " << c.name << ": unknown exception\n"; + } + } + std::cout << (cases_.size() - failed) << "/" << cases_.size() + << " passed\n"; + return failed == 0 ? 0 : 1; + } + +private: + struct Case { + std::string name; + std::function fn; + }; + std::vector cases_; +}; + +} // namespace kbtest From 58195577f33faf8cd09612df2c1caf60cfb5c182 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 11:30:57 -0400 Subject: [PATCH 65/80] test(engine): cover the reset-cycle seqid isolation 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. --- shared/engine/rpc-transport.test.ts | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 9e51b544f0d9..9f858da62a31 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -225,6 +225,43 @@ test('a throwing callback does not stop the remaining outstanding invocations fr expect(calls).toEqual([1, 2, 3]) }) +test('a reset cycle fails outstanding invocations once and a post-reset invocation gets a fresh, non-colliding seqid that dispatches correctly', () => { + const transport = new TestTransport() + const preResetCb = jest.fn() + transport.invoke('keybase.1.test.old', [{}], preResetCb) + const [, preResetSeqid] = transport.sent[0] as [number, number, string, [object]] + + transport.failAllOutstanding() + expect(preResetCb).toHaveBeenCalledTimes(1) + + const postResetCb = jest.fn() + transport.invoke('keybase.1.test.new', [{}], postResetCb) + const [, postResetSeqid] = transport.sent[1] as [number, number, string, [object]] + + expect(postResetSeqid).not.toBe(preResetSeqid) + + transport.dispatchDecodedMessage([1, postResetSeqid, null, {ok: 'post-reset'}]) + expect(postResetCb).toHaveBeenCalledWith(null, {ok: 'post-reset'}) + // The reset must not have re-fired the pre-reset callback. + expect(preResetCb).toHaveBeenCalledTimes(1) +}) + +test('a response for a pre-reset seqid arriving after reset is ignored', () => { + const transport = new TestTransport() + const preResetCb = jest.fn() + transport.invoke('keybase.1.test.old', [{}], preResetCb) + const [, preResetSeqid] = transport.sent[0] as [number, number, string, [object]] + + transport.failAllOutstanding() + expect(preResetCb).toHaveBeenCalledTimes(1) + + // A stale response for the pre-reset seqid shows up late -- the seqid is + // gone from the invocations map, so this must be a silent no-op rather + // than re-firing the already-failed callback. + transport.dispatchDecodedMessage([1, preResetSeqid, null, {ok: 'stale'}]) + expect(preResetCb).toHaveBeenCalledTimes(1) +}) + test('seqids keep advancing after outstanding invocations are failed, so a late reply cannot alias', () => { const transport = new TestTransport() transport.invoke('keybase.1.test.a', [{}], () => {}) From 047b640c6d60064e4e6e9f6b594f55f3f2eb4737 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 11:31:11 -0400 Subject: [PATCH 66/80] test(engine): cover response-settlement ordering and no-retry-after-failed-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. --- shared/engine/rpc-transport.test.ts | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 9f858da62a31..05e004bfa565 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -367,6 +367,23 @@ test('auto-result then a throwing handler sends exactly one message and logs no errorSpy.mockRestore() }) +test('a response cannot be settled twice: result() after error() is a no-op (first settle wins)', () => { + let payload: Parameters[0] | undefined + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + payload = incoming + }, + }) + + transport.dispatchDecodedMessage([0, 10, 'keybase.1.test.hello', [{}]]) + payload?.response?.error?.({code: errors.UNKNOWN_METHOD, desc: 'No method available', name: 'UNKNOWN_METHOD'}) + payload?.response?.result?.({ok: true}) + + expect(transport.sent).toEqual([ + [1, 10, {code: errors.UNKNOWN_METHOD, desc: 'No method available', name: 'UNKNOWN_METHOD'}, null], + ]) +}) + test('a genuine double-settle -- result() then error() called directly -- still logs', () => { let payload: Parameters[0] | undefined const transport = new TestTransport({ @@ -426,6 +443,19 @@ test('a failed response write is reported, not swallowed', () => { // add a second, seqid-specific log or the service-side hang is invisible. expect(errorSpy).toHaveBeenCalledTimes(2) expect(errorSpy.mock.calls.some(args => args.some(a => typeof a === 'string' && a.includes('11')))).toBe(true) + + // The response is marked settled before the write is attempted, so a + // failed write must not leave it settleable again -- there is no + // connection left to retry on. Fixing the write error and calling + // result() again must not silently open a retry path: it should trip + // the double-settle guard and send nothing. + transport.setWriteError(undefined) + payload?.response?.result?.({ok: 'retry'}) + expect(transport.sent).toEqual([]) + expect(errorSpy).toHaveBeenCalledTimes(3) + expect( + errorSpy.mock.calls.some(args => args.some(a => typeof a === 'string' && a.includes('twice'))) + ).toBe(true) errorSpy.mockRestore() }) From 0afa02288a2b60c7130ec4d85fae79d838d7d322 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 11:31:19 -0400 Subject: [PATCH 67/80] test(engine): cover malformed-frame recovery and every split-point boundary 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. --- shared/engine/rpc-transport.test.ts | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 05e004bfa565..7805c407c2fe 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -404,6 +404,49 @@ test('a genuine double-settle -- result() then error() called directly -- still errorSpy.mockRestore() }) +test('a malformed frame resets the packetizer, and a subsequent well-formed frame still dispatches', () => { + const delivered: Array = [] + const transport = new TestTransport({ + incomingRPCCallback: incoming => { + delivered.push(incoming) + }, + }) + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + // 0x80 (msgpack fixmap) is not a fixint (<0x80) nor one of the supported + // uint8/16/32 length-prefix bytes (0xcc/0xcd/0xce): frameHeaderLength + // returns 0 for it, so packetizeData throws "Bad frame header received". + transport.packetizeData(Uint8Array.of(0x80)) + expect(delivered).toHaveLength(0) + + const good = encodeFrame([2, 'keybase.1.test.recovered', [{}]]) + transport.packetizeData(good) + + expect(delivered).toHaveLength(1) + expect((delivered[0] as {method: string}).method).toBe('keybase.1.test.recovered') + consoleSpy.mockRestore() +}) + +test('a frame split at every possible byte boundary across two packetizeData calls still dispatches exactly once', () => { + const probe = new TestTransport() + probe.invoke('keybase.1.test.hello', [{}], () => {}) + const [, probeSeqid] = probe.sent[0] as [number, number, string, [object]] + const frame = encodeFrame([1, probeSeqid, null, {ok: 'sweep', pad: 'x'.repeat(40)}]) + + for (let splitAt = 1; splitAt < frame.length; splitAt++) { + const transport = new TestTransport() + const cb = jest.fn() + transport.invoke('keybase.1.test.hello', [{}], cb) + + transport.packetizeData(frame.slice(0, splitAt)) + expect(cb).not.toHaveBeenCalled() + + transport.packetizeData(frame.slice(splitAt)) + expect(cb).toHaveBeenCalledTimes(1) + expect(cb).toHaveBeenCalledWith(null, {ok: 'sweep', pad: 'x'.repeat(40)}) + } +}) + test('failAllOutstanding clears buffered frame bytes', () => { const delivered: Array = [] const transport = new TestTransport({ From 36eb28fa4677bdf22279115acce852dd3f3dcf90 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 11:31:29 -0400 Subject: [PATCH 68/80] test(engine): cover the mobile connect/disconnect isolation and rpcOnGo 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. --- shared/engine/index.platform.mobile.test.ts | 100 ++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 shared/engine/index.platform.mobile.test.ts diff --git a/shared/engine/index.platform.mobile.test.ts b/shared/engine/index.platform.mobile.test.ts new file mode 100644 index 000000000000..51d49bc80bf9 --- /dev/null +++ b/shared/engine/index.platform.mobile.test.ts @@ -0,0 +1,100 @@ +/// + +import type {CreateClientType, IncomingRPCCallbackType, ConnectDisconnectCB} from './index.platform' + +type IndexPlatformModule = { + createClient: ( + incomingRPCCallback: IncomingRPCCallbackType, + connectCallback: ConnectDisconnectCB, + disconnectCallback: ConnectDisconnectCB + ) => CreateClientType +} + +// isMobile is a bare global (see globals.d.ts / jest.setup.js), read once at +// module load time by index.platform.tsx. To exercise the isMobile branch +// (normally never taken in this desktop test env) each test here flips the +// global, resets the module registry so index.platform re-evaluates it, and +// mocks the native imports that branch touches. +// +// local-debug.tsx calls LogBox.ignoreAllLogs() at module load time when +// isMobile is true; the desktop react-native mock has no LogBox because that +// branch is normally dead code in this test env. +const mockNativeModules = (onMetaEvent: (cb: (payload: string) => void) => void) => { + jest.doMock('react-native', () => ({ + ...jest.requireActual('react-native'), + LogBox: {ignoreAllLogs: () => {}}, + })) + jest.doMock('react-native-kb', () => ({ + onMetaEvent, + notifyJSReady: () => {}, + })) +} + +const teardownMobileMocks = (originalIsMobile: boolean, originalRpcOnGo: unknown) => { + jest.dontMock('react-native-kb') + jest.dontMock('react-native') + jest.resetModules() + global.isMobile = originalIsMobile + global.rpcOnGo = originalRpcOnGo as typeof global.rpcOnGo +} + +test('disconnectCallback throwing does not prevent connectCallback from running on kb-engine-reset', () => { + const originalIsMobile = global.isMobile + const originalRpcOnGo = global.rpcOnGo + global.isMobile = true + + let capturedMetaCb: ((payload: string) => void) | undefined + mockNativeModules(cb => { + capturedMetaCb = cb + }) + jest.resetModules() + + try { + const {createClient} = require('./index.platform') as IndexPlatformModule + const connectCallback = jest.fn() + const disconnectCallback = jest.fn(() => { + throw new Error('disconnect handler blew up') + }) + createClient(() => {}, connectCallback, disconnectCallback) + + if (!capturedMetaCb) { + throw new Error('kb-engine-reset handler was never registered') + } + capturedMetaCb('kb-engine-reset') + + expect(disconnectCallback).toHaveBeenCalledTimes(1) + // The isolation fix: disconnectCallback throwing must not skip connectCallback, + // or the UI is stranded on the disconnect banner forever. + expect(connectCallback).toHaveBeenCalledTimes(1) + } finally { + teardownMobileMocks(originalIsMobile, originalRpcOnGo) + } +}) + +test('NativeTransportMobile fails the invocation (not hang) when rpcOnGo reports failure', () => { + const originalIsMobile = global.isMobile + const originalRpcOnGo = global.rpcOnGo + global.isMobile = true + + mockNativeModules(() => {}) + jest.resetModules() + + try { + const {createClient} = require('./index.platform') as IndexPlatformModule + const client = createClient( + () => {}, + () => {}, + () => {} + ) + global.rpcOnGo = () => false + + const cb = jest.fn() + client.invoke('keybase.1.test.hello', [{}], cb) + + expect(cb).toHaveBeenCalledTimes(1) + const [err] = cb.mock.calls[0] as [unknown, unknown] + expect(err).toBeTruthy() + } finally { + teardownMobileMocks(originalIsMobile, originalRpcOnGo) + } +}) From 04f4d9968c5fa6880c527a5343b6d497a7d0fe63 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 11:58:32 -0400 Subject: [PATCH 69/80] fix(cgo): give the quarantineFile shims package-specific names 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. --- go/chat/attachments/quarantine_darwin.go | 4 ++-- go/kbfs/simplefs/platform_darwin.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go/chat/attachments/quarantine_darwin.go b/go/chat/attachments/quarantine_darwin.go index af8b58e88235..9deec2929729 100644 --- a/go/chat/attachments/quarantine_darwin.go +++ b/go/chat/attachments/quarantine_darwin.go @@ -5,7 +5,7 @@ package attachments /* #cgo CFLAGS: -x objective-c -fobjc-arc #include -static void quarantineFile(const char* inFilename) { +void chatAttachmentsQuarantineFile(const char* inFilename) { NSError* error = NULL; NSString* filename = [NSString stringWithUTF8String:inFilename]; NSURL* url = [NSURL fileURLWithPath:filename]; @@ -26,6 +26,6 @@ import ( func Quarantine(ctx context.Context, path string) error { cpath := C.CString(path) defer C.free(unsafe.Pointer(cpath)) - C.quarantineFile(cpath) + C.chatAttachmentsQuarantineFile(cpath) return nil } diff --git a/go/kbfs/simplefs/platform_darwin.go b/go/kbfs/simplefs/platform_darwin.go index d06bf399c2fd..64a65d396cb6 100644 --- a/go/kbfs/simplefs/platform_darwin.go +++ b/go/kbfs/simplefs/platform_darwin.go @@ -8,7 +8,7 @@ package simplefs #cgo LDFLAGS: -framework Foundation -framework CoreServices -lobjc #include -static void quarantineFile(const char* inFilename) { +void simpleFSQuarantineFile(const char* inFilename) { NSError* error = NULL; NSString* filename = [NSString stringWithUTF8String:inFilename]; NSURL* url = [NSURL fileURLWithPath:filename]; @@ -30,7 +30,7 @@ import ( func Quarantine(ctx context.Context, path string) error { cpath := C.CString(path) defer C.free(unsafe.Pointer(cpath)) - C.quarantineFile(cpath) + C.simpleFSQuarantineFile(cpath) return nil } From 0a23578f2147c4a5c09d9d908a0cfa1e74f4a313 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 12:01:56 -0400 Subject: [PATCH 70/80] feat(rpc): report whether ResetIfCurrent actually reset the connection 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). --- go/bind/keybase.go | 29 +++++- go/bind/keybase_test.go | 202 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 2 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index c7ef23a66845..8a0de20c60a7 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -768,14 +768,39 @@ func LastReadEpoch() int64 { // already recovered, and closing the newer connection would silently // discard whatever was just written to it. func ResetIfCurrent(epoch int64) error { + _, err := resetIfCurrent(epoch) + return err +} + +// ResetIfCurrentDidReset behaves exactly like ResetIfCurrent, additionally +// reporting whether the reset actually ran (epoch matched connEpoch) as +// opposed to being a stale no-op (epoch was already superseded). +// +// Platform readers need this distinction: they also reset their local parser +// state (resetRecv/nativeResetRecv) alongside the Go connection, and doing so +// unconditionally after a stale no-op drops bytes already in flight on a +// connection nothing here actually touched. See Kb.mm and KbModule.kt. +func ResetIfCurrentDidReset(epoch int64) bool { + didReset, err := resetIfCurrent(epoch) + if err != nil { + log("Go: ResetIfCurrentDidReset: reset error: %v", err) + } + return didReset +} + +// resetIfCurrent is the shared implementation behind ResetIfCurrent and +// ResetIfCurrentDidReset. didReset reports whether epoch matched connEpoch +// (i.e. whether resetLocked actually ran), independent of whether resetLocked +// itself returned an error. +func resetIfCurrent(epoch int64) (didReset bool, err error) { connMutex.Lock() defer connMutex.Unlock() if epoch != connEpoch { log("Go: ResetIfCurrent skipped, stale epoch=%d current=%d appState=%s", epoch, connEpoch, appStateForLog()) - return nil + return false, nil } - return resetLocked() + return true, resetLocked() } // resetLocked does the actual teardown. Must be called with connMutex held. diff --git a/go/bind/keybase_test.go b/go/bind/keybase_test.go index 424da62cbd31..60474333e214 100644 --- a/go/bind/keybase_test.go +++ b/go/bind/keybase_test.go @@ -4,6 +4,7 @@ package keybase import ( + "errors" "net" "sync" "sync/atomic" @@ -42,6 +43,13 @@ func (c *trackingConn) Close() error { // seed them directly instead of routing through ensureConnection's dial // machinery, then exercise the real Reset/ResetIfCurrent/ReadArr/WriteArr/ // LastReadEpoch functions against that seeded state. +// +// One piece of state this cannot restore: any test that calls NotifyJSReady +// closes the package-level jsReadyCh via a sync.Once, and that close is +// irreversible for the life of the test binary. Later tests that call +// ReadArr never block on jsReadyCh regardless of what this helper resets. +// That's fine for every test in this file (none rely on ReadArr blocking on +// JS readiness), but it's a latent trap for a future test that would. func resetConnStateForTest(t *testing.T) { t.Helper() @@ -413,3 +421,197 @@ func TestConcurrentRedialsAndResets(t *testing.T) { t.Fatalf("post-concurrency epoch did not advance: pre=%d post=%d", preEpoch, postEpoch) } } + +// readSignalConn wraps a net.Conn and closes entered the first time Read is +// called, letting a test observe "the reader has entered conn.Read" without +// racing on it. +type readSignalConn struct { + net.Conn + entered chan struct{} + once sync.Once +} + +func (c *readSignalConn) Read(p []byte) (int, error) { + c.once.Do(func() { close(c.entered) }) + return c.Conn.Read(p) +} + +// Test 8: the regression this whole mechanism guards against. ReadArr must +// capture lastReadEpoch from the connection/epoch pair it is actually about +// to read from, before that read can be raced by a redial — not after +// currentConn.Read returns, and not from whatever connEpoch happens to be +// when the async caller gets around to reading it back. +// +// This interleaves a redial with an in-flight ReadArr: the reader parks in +// Read() on connA/epoch A, a redial swaps in connB/epoch B while the read is +// still outstanding, and only then does connA's peer supply the bytes that +// unblock it. LastReadEpoch() must report A (the connection actually read +// from), not B (the connection live at the time the caller happens to check). +// +// Falsified: moving `lastReadEpoch = currentEpoch` in ReadArr to after +// currentConn.Read returns (the exact regression this guards against) makes +// this test fail, reporting epoch B instead of A. +func TestReadArr_LastReadEpochCapturedBeforeRaceableRead(t *testing.T) { + resetConnStateForTest(t) + + savedBuffer := buffer + buffer = make([]byte, 4096) + t.Cleanup(func() { buffer = savedBuffer }) + + NotifyJSReady() // sync.Once; safe to call unconditionally; see the note + // on resetConnStateForTest above about why this can't be undone. + + localA, remoteA := net.Pipe() + t.Cleanup(func() { _ = localA.Close(); _ = remoteA.Close() }) + + entered := make(chan struct{}) + setConn(&readSignalConn{Conn: remoteA, entered: entered}, 100) + + type readResult struct { + data []byte + err error + } + resultCh := make(chan readResult, 1) + go func() { + data, err := ReadArr() + resultCh <- readResult{data, err} + }() + + // Wait until the reader has actually entered conn.Read on connA/epoch + // 100 (and, in correct code, has already captured lastReadEpoch=100 + // under connMutex before releasing it and blocking here). + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("ReadArr never entered conn.Read") + } + + // A redial races in while the read above is still outstanding, exactly + // as ensureConnection would do for a concurrent WriteArr/ReadArr caller + // recovering from a failure on a different connection. + localB, remoteB := net.Pipe() + t.Cleanup(func() { _ = localB.Close(); _ = remoteB.Close() }) + setConn(remoteB, 101) + + // Only now does connA's peer supply the bytes that unblock the + // already-in-flight Read on connA. + go func() { _, _ = localA.Write([]byte("hello")) }() + + var res readResult + select { + case res = <-resultCh: + case <-time.After(5 * time.Second): + t.Fatal("ReadArr did not return") + } + if res.err != nil { + t.Fatalf("ReadArr returned error: %v", res.err) + } + if string(res.data) != "hello" { + t.Fatalf("unexpected data: %q", res.data) + } + + if got := LastReadEpoch(); got != 100 { + t.Errorf("expected LastReadEpoch to report the epoch actually read from (100), got %d", got) + } +} + +// erroringReadConn's Read always fails, driving ReadArr's error path. +type erroringReadConn struct { + trackingConn +} + +func (c *erroringReadConn) Read(p []byte) (int, error) { + return 0, errors.New("simulated read error") +} + +// Test 9: ReadArr's error path (a real production caller of ResetIfCurrent, +// keybase.go ~:681) must reset the connection it just failed to read from. +func TestReadArr_ErrorPathResetsConnection(t *testing.T) { + resetConnStateForTest(t) + + savedBuffer := buffer + buffer = make([]byte, 4096) + t.Cleanup(func() { buffer = savedBuffer }) + + NotifyJSReady() + + c := &erroringReadConn{} + setConn(c, 7) + + if _, err := ReadArr(); err == nil { + t.Fatal("expected ReadArr to return an error") + } + + if !c.closed.Load() { + t.Error("expected ReadArr's error path to reset (close) the connection it read from") + } + if gotConn, _ := getConnState(); gotConn != nil { + t.Error("expected conn to be nil after ReadArr's error-path reset") + } +} + +// shortWriteConn's Write always reports writing one byte fewer than given, +// with no error, driving WriteArr's short-write path. +type shortWriteConn struct { + trackingConn +} + +func (c *shortWriteConn) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + return len(p) - 1, nil +} + +// Test 10: WriteArr's short-write path (a real production caller of +// ResetIfCurrent, keybase.go ~:610) must reset the connection rather than +// leave the peer's framer holding a partial frame. +func TestWriteArr_ShortWriteResetsConnection(t *testing.T) { + resetConnStateForTest(t) + + c := &shortWriteConn{} + setConn(c, 11) + + if err := WriteArr([]byte("hello world")); err == nil { + t.Fatal("expected WriteArr to return an error on a short write") + } + + if !c.closed.Load() { + t.Error("expected WriteArr's short-write path to reset (close) the connection") + } + if gotConn, _ := getConnState(); gotConn != nil { + t.Error("expected conn to be nil after WriteArr's short-write reset") + } +} + +// Test 11: ResetIfCurrentDidReset reports whether it actually reset the +// connection (epoch matched) as opposed to being a stale no-op, which is +// exactly what platform readers (Kb.mm, KbModule.kt) now gate their local +// parser reset on. +func TestResetIfCurrentDidReset_ReportsWhetherItActed(t *testing.T) { + resetConnStateForTest(t) + + t.Run("matching epoch resets and reports true", func(t *testing.T) { + c := &trackingConn{} + setConn(c, 20) + + if didReset := ResetIfCurrentDidReset(20); !didReset { + t.Error("expected ResetIfCurrentDidReset to report true for a matching epoch") + } + if !c.closed.Load() { + t.Error("expected the connection to be closed when epoch matches") + } + }) + + t.Run("stale epoch is a no-op and reports false", func(t *testing.T) { + c := &trackingConn{} + setConn(c, 21) + + if didReset := ResetIfCurrentDidReset(20); didReset { + t.Error("expected ResetIfCurrentDidReset to report false for a stale epoch") + } + if c.closed.Load() { + t.Error("expected the connection to be left open on a stale epoch") + } + }) +} From 03572562e8a9a9d65a464640a77e773a01e6e940 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 12:02:03 -0400 Subject: [PATCH 71/80] fix(rpc): gate parser reset on whether the Go reset actually ran 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. --- .../main/java/com/reactnativekb/KbModule.kt | 22 +++++++++------ rnmodules/react-native-kb/ios/Kb.mm | 28 +++++++++---------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index e3024259172a..161b77bb4206 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -707,18 +707,24 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // window where bytes from the OLD connection land in the // freshly-reset unpacker mid-frame, causing a second desync. // - // resetIfCurrent(epoch), not reset(): `epoch` is the epoch of the - // connection the desynced bytes actually came from, captured by the - // reader loop at read time. If Go has already re-dialed since (e.g. a - // concurrent writeArr recovered first), epoch no longer matches and - // this is a no-op instead of tearing down a connection that already - // worked. + // resetIfCurrentDidReset(epoch), not reset(): `epoch` is the epoch of + // the connection the desynced bytes actually came from, captured by + // the reader loop at read time. If Go has already re-dialed since + // (e.g. a concurrent writeArr recovered first), epoch no longer + // matches and this is a stale no-op instead of tearing down a + // connection that already worked -- and in that case nativeResetRecv() + // must also be skipped, or it drops the new connection's + // already-in-flight partial frame and forces a second, needless + // fatal/reset cycle. + var didReset = false try { - Keybase.resetIfCurrent(epoch) + didReset = Keybase.resetIfCurrentDidReset(epoch) } catch (e: Exception) { NativeLogger.error("Exception resetting after rpc desync", e) } - nativeResetRecv() + if (didReset) { + nativeResetRecv() + } reactContext.runOnUiQueueThread { relayReset() } } diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 23db63a84735..abb4ae35831f 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -371,20 +371,20 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime // leave a window where bytes from the OLD connection land in the // freshly-cleared unpacker mid-frame, causing a second desync. // - // ResetIfCurrent(epoch), not Reset(): `epoch` is the epoch of the - // connection the desynced bytes actually came from, captured by - // the reader loop below at read time. If Go has already re-dialed - // since (e.g. a concurrent WriteArr recovered first), epoch no - // longer matches and this is a no-op instead of tearing down a - // connection that already worked. - NSError *error = nil; - KeybaseResetIfCurrent(epoch, &error); - if (error) { - kbLogToService([NSString stringWithFormat:@"reset after desync failed: %@", - error.localizedDescription]); - } - if (auto bridge = kbGetBridge()) { - bridge->resetRecv(); + // ResetIfCurrentDidReset(epoch), not Reset(): `epoch` is the + // epoch of the connection the desynced bytes actually came from, + // captured by the reader loop below at read time. If Go has + // already re-dialed since (e.g. a concurrent WriteArr recovered + // first), epoch no longer matches and this is a stale no-op + // instead of tearing down a connection that already worked -- + // and in that case resetRecv() must also be skipped, or it drops + // the new connection's already-in-flight partial frame and + // forces a second, needless fatal/reset cycle. + BOOL didReset = KeybaseResetIfCurrentDidReset(epoch); + if (didReset) { + if (auto bridge = kbGetBridge()) { + bridge->resetRecv(); + } } dispatch_async(dispatch_get_main_queue(), ^{ Kb *instance = kbSharedInstance; From e51ac7a98f086a358af8341c0fbd0c1a6bc0a58c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 12:02:11 -0400 Subject: [PATCH 72/80] refactor(rpc): drop dead RecvState::epoch, reset parser in place 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. --- .../react-native-kb/cpp/react-native-kb.cpp | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index bd8fc27d79d6..7dda3dc381ec 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -36,13 +36,6 @@ struct KBBridge::RecvState { // stops tracking "did one big frame arrive" and starts tracking "did // several small frames arrive in the same read" instead. FrameParser parser; - // Epoch of the Go connection the most recent onDataFromGo call's bytes - // came from. Set at the top of onDataFromGo under recvMutex_; read back - // here only for anything that inspects RecvState directly. The fatal - // paths below use the onDataFromGo parameter (or its lambda capture) - // rather than this field, since resetRecvLocked() replaces RecvState -- - // and so this field -- before those paths run. - int64_t epoch = 0; }; struct KBBridge::SendState { @@ -91,7 +84,18 @@ void KBBridge::reportError(const std::string &msg) { } } -void KBBridge::resetRecvLocked() { recv_ = std::make_unique(); } +// Resets framing state in place when a RecvState already exists (the common +// case: a fresh connection or post-error recovery), constructing one only the +// first time (recv_ starts null). RecvState currently owns nothing besides +// the parser, so recv_->parser.reset() is behavior-equivalent to discarding +// and rebuilding the whole RecvState, without the reallocation. +void KBBridge::resetRecvLocked() { + if (recv_) { + recv_->parser.reset(); + } else { + recv_ = std::make_unique(); + } +} Function &KBBridge::uint8ArrayCtor(Runtime &runtime) { resetCaches(runtime); @@ -638,15 +642,14 @@ void KBBridge::onDataFromGo(uint8_t *data, int size, int64_t epoch) { if (!recv_) { return; } - recv_->epoch = epoch; try { recv_->parser.feed(data, static_cast(size), *values); // A provably safe resync point: no partial frame and no unparsed - // bytes to lose. resetRecvLocked() rebuilds RecvState (including the - // parser's totalFed and unpacker) together, so the - // totalFed/consumedAtHeader invariant restarts from a consistent - // fresh baseline rather than desyncing. + // bytes to lose. resetRecvLocked() resets the parser's unpacker, + // totalFed, and consumedAtHeader together, so that invariant restarts + // from a consistent fresh baseline (and the realloc'd unpacker buffer + // is released) rather than desyncing. if (recv_->parser.atSafeShrinkPoint()) { resetRecvLocked(); } From e80705a86b4bc54df7919d77998d313e65611c0f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 12:02:21 -0400 Subject: [PATCH 73/80] test(rpc): cover the production header encoding, fix overclaiming comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- rnmodules/react-native-kb/cpp/frame-parser.h | 16 ++-- .../react-native-kb/cpp/tests/frame-builder.h | 29 ++++++- .../cpp/tests/frame-parser-test.cpp | 82 +++++++++++++++++-- 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/rnmodules/react-native-kb/cpp/frame-parser.h b/rnmodules/react-native-kb/cpp/frame-parser.h index 80a5c2fc9196..ea67c01652c9 100644 --- a/rnmodules/react-native-kb/cpp/frame-parser.h +++ b/rnmodules/react-native-kb/cpp/frame-parser.h @@ -21,11 +21,17 @@ class FrameParser { // A desynced length prefix can otherwise ask us to buffer gigabytes. // Matches the JS-side packetizer limit. static constexpr uint64_t kMaxFrameSize = 64ull * 1024 * 1024; - // nonparsed_size() includes the frame header plus any bytes of the next - // frame already buffered in the same read; the header check separately - // accepts a declared size of exactly kMaxFrameSize. Without this margin a - // legal maximal frame arriving in small chunks trips the limit on its last - // chunk. + // The end-of-feed() check `nonparsed_size() > kMaxFrameSize + kMaxFrameSlack` + // guards against a garbage/corrupt length prefix (e.g. a malicious or + // desynced header claiming a multi-gigabyte size) that would otherwise make + // the unpacker buffer unboundedly. It is not needed to protect legal + // traffic: nonparsed_size() at that point can only be an incomplete + // (not-yet-fully-parsed) frame, and every declared size is already checked + // against kMaxFrameSize as soon as its header is read, so a legal frame's + // nonparsed_size() can never exceed kMaxFrameSize even with zero slack. + // This margin exists purely to keep the check from being a razor's-edge + // `> kMaxFrameSize` on the garbage-detection path; it does not need to be + // large, just nonzero. static constexpr size_t kMaxFrameSlack = 1024 * 1024; // msgpack::unpacker rewinds its buffer in place but never shrinks the // realloc'd allocation, so this bounds how long a single large frame's peak diff --git a/rnmodules/react-native-kb/cpp/tests/frame-builder.h b/rnmodules/react-native-kb/cpp/tests/frame-builder.h index 688f72a327fe..3bb6db26edba 100644 --- a/rnmodules/react-native-kb/cpp/tests/frame-builder.h +++ b/rnmodules/react-native-kb/cpp/tests/frame-builder.h @@ -29,8 +29,9 @@ inline Bytes packContent( } // Packs a bare msgpack unsigned integer header (the smallest encoding msgpack -// chooses for `value`), matching how the framing prefix is written on the -// wire. +// chooses for `value`). Useful for exercising the parser against header +// encodings other than the one production actually emits (e.g. small values +// that fit in a 1-byte fixint). inline Bytes packHeader(uint64_t value) { msgpack::sbuffer buf; msgpack::packer pk(&buf); @@ -38,6 +39,22 @@ inline Bytes packHeader(uint64_t value) { return sbufferToBytes(buf); } +// Packs the frame header exactly as production writes it +// (react-native-kb.cpp's packAndSend): always 5 bytes -- a 0xce ("uint 32") +// tag byte followed by a big-endian uint32 length -- regardless of how small +// `value` is. Unlike packHeader() above, this never picks a smaller encoding, +// so it's the encoding that must be used to faithfully exercise the header +// split-boundary arithmetic. +inline Bytes packHeaderUint32(uint32_t value) { + Bytes out(5); + out[0] = 0xce; + out[1] = static_cast(value >> 24); + out[2] = static_cast(value >> 16); + out[3] = static_cast(value >> 8); + out[4] = static_cast(value); + return out; +} + // A well-formed frame: header declares content.size(), content follows. inline Bytes buildFrame(const Bytes &content) { Bytes out = packHeader(content.size()); @@ -45,6 +62,14 @@ inline Bytes buildFrame(const Bytes &content) { return out; } +// Same as buildFrame(), but with the header encoded exactly as production +// writes it (see packHeaderUint32()) rather than msgpack's minimal encoding. +inline Bytes buildFrameProdHeader(const Bytes &content) { + Bytes out = packHeaderUint32(static_cast(content.size())); + out.insert(out.end(), content.begin(), content.end()); + return out; +} + // A frame whose declared size does not match content.size() -- for testing // the length-mismatch detection. inline Bytes buildFrameWithDeclaredSize(uint64_t declaredSize, diff --git a/rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp b/rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp index 9482a7983501..e0dea642f1e6 100644 --- a/rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp +++ b/rnmodules/react-native-kb/cpp/tests/frame-parser-test.cpp @@ -66,10 +66,8 @@ void testFrameSplitAcrossTwoFeeds() { CHECK_EQ(out.size(), 1u); } -void testFrameSplitAtEveryByteBoundary() { - // Exercises totalFed - nonparsed_size() arithmetic at every possible split - // point across a single frame. - Bytes content = packContent([](auto &pk) { +Bytes contentSplitBoundarySample() { + return packContent([](auto &pk) { pk.pack_map(3); pk.pack(std::string("a")); pk.pack(1); @@ -81,8 +79,15 @@ void testFrameSplitAtEveryByteBoundary() { pk.pack(2); pk.pack(3); }); - Bytes frame = buildFrame(content); +} +// Feeds `frame` in two pieces at every possible split point (including the +// no-op splits at 0 and frame.size()) and checks that exactly one object of +// `wantType` is always decoded. Exercises totalFed - nonparsed_size() +// arithmetic, and in particular the header-partial-read bookkeeping +// (consumedAtHeader_ in frame-parser.h), at every possible split point across +// a single frame. +void sweepSplitBoundaries(const Bytes &frame, msgpack::type::object_type wantType) { for (size_t split = 0; split <= frame.size(); ++split) { FrameParser parser; std::vector out; @@ -95,7 +100,59 @@ void testFrameSplitAtEveryByteBoundary() { CHECK_MSG(out.size() == 1, "split at " + std::to_string(split) + " produced " + std::to_string(out.size()) + " messages, want 1"); - CHECK(out[0].get().type == msgpack::type::MAP); + CHECK(out[0].get().type == wantType); + } +} + +void testFrameSplitAtEveryByteBoundary() { + // Minimal msgpack encoding: for this content's small size, the header + // collapses to a 1-byte fixint rather than production's fixed 5-byte + // header, so this sweep never lands a split inside a real header. Still + // meaningful on its own merits: it covers a variable-length header (this + // content also happens to fall in the fixint range) and the general + // content-boundary arithmetic. + Bytes frame = buildFrame(contentSplitBoundarySample()); + sweepSplitBoundaries(frame, msgpack::type::MAP); +} + +void testFrameSplitAtEveryByteBoundaryProdHeader() { + // Same sweep and same content as testFrameSplitAtEveryByteBoundary, but + // with the header encoded exactly as production writes it (0xce tag + a + // fixed 4-byte big-endian length, see packHeaderUint32() and + // react-native-kb.cpp's packAndSend). This is what real traffic looks like + // for small frames, and is the only encoding that actually exercises a + // split landing inside bytes 1-4 of a real header. + Bytes frame = buildFrameProdHeader(contentSplitBoundarySample()); + sweepSplitBoundaries(frame, msgpack::type::MAP); +} + +void testMaxSizeFrameHeaderSplitBoundaries() { + // A maximal (kMaxFrameSize) frame's header already requires msgpack's + // uint32 format under minimal encoding, so this is production-accurate + // without even needing packHeaderUint32 -- but the existing max-size + // coverage (testOversizedFrameRejectedAndLegalMaxNotFalselyRejected) always + // buffers the whole 5-byte header in a single chunk (its fixed + // 65537-byte chunking never happens to split within the first 5 bytes). + // This sweeps every split point within and just past the header so the + // "header split across two feeds" case is covered for the largest frame + // the parser accepts, not just for small frames. (A full byte-by-byte + // sweep across the entire 64MB body would be O(n^2) and impractically + // slow, so this only sweeps the header region.) + Bytes content = contentBinOfWireSize(FrameParser::kMaxFrameSize); + CHECK_EQ(content.size(), FrameParser::kMaxFrameSize); + Bytes frame = buildFrameProdHeader(content); + constexpr size_t kHeaderLen = 5; + for (size_t split = 0; split <= kHeaderLen + 2; ++split) { + FrameParser parser; + std::vector out; + if (split > 0) { + parser.feed(frame.data(), split, out); + } + parser.feed(frame.data() + split, frame.size() - split, out); + CHECK_MSG(out.size() == 1, + "split at " + std::to_string(split) + " produced " + + std::to_string(out.size()) + " messages, want 1"); + CHECK(out[0].get().type == msgpack::type::BIN); } } @@ -183,8 +240,13 @@ void testOversizedFrameRejectedAndLegalMaxNotFalselyRejected() { } // A LEGAL maximal frame (declaredSize == kMaxFrameSize exactly) arriving in - // small chunks must NOT be falsely rejected by the nonparsed_size() vs. - // size-limit check. This proves the kMaxFrameSlack margin fix. + // small chunks must NOT be falsely rejected. next() consumes the msgpack + // object as soon as it's fully buffered, so by the time the + // nonparsed_size() > kMaxFrameSize + kMaxFrameSlack check runs at the end + // of feed(), nonparsed_size() is already back to 0 for this scenario -- + // this pins down that the header check's `> kMaxFrameSize` (not `>=`) + // comparison accepts a declared size of exactly kMaxFrameSize, not the + // kMaxFrameSlack margin. { FrameParser parser; std::vector out; @@ -312,6 +374,10 @@ int main() { runner.add("frame_split_across_two_feeds", testFrameSplitAcrossTwoFeeds); runner.add("frame_split_at_every_byte_boundary", testFrameSplitAtEveryByteBoundary); + runner.add("frame_split_at_every_byte_boundary_prod_header", + testFrameSplitAtEveryByteBoundaryProdHeader); + runner.add("max_size_frame_header_split_boundaries", + testMaxSizeFrameHeaderSplitBoundaries); runner.add("consumed_equals_declared_across_sizes", testConsumedEqualsDeclaredAcrossSizes); runner.add("declared_length_mismatch_detected", From 5fb01c1526010d909715964822588f96a029a740 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 12:02:32 -0400 Subject: [PATCH 74/80] test(engine): fix test-quality gaps in the RPC bridge test suite - 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. --- shared/engine/index.platform.mobile.test.ts | 33 ++++++++++++--------- shared/engine/rpc-transport.test.ts | 17 ++--------- shared/test/mocks/react-native.js | 1 + 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/shared/engine/index.platform.mobile.test.ts b/shared/engine/index.platform.mobile.test.ts index 51d49bc80bf9..29a2f7106577 100644 --- a/shared/engine/index.platform.mobile.test.ts +++ b/shared/engine/index.platform.mobile.test.ts @@ -15,32 +15,29 @@ type IndexPlatformModule = { // (normally never taken in this desktop test env) each test here flips the // global, resets the module registry so index.platform re-evaluates it, and // mocks the native imports that branch touches. -// -// local-debug.tsx calls LogBox.ignoreAllLogs() at module load time when -// isMobile is true; the desktop react-native mock has no LogBox because that -// branch is normally dead code in this test env. const mockNativeModules = (onMetaEvent: (cb: (payload: string) => void) => void) => { - jest.doMock('react-native', () => ({ - ...jest.requireActual('react-native'), - LogBox: {ignoreAllLogs: () => {}}, - })) jest.doMock('react-native-kb', () => ({ onMetaEvent, notifyJSReady: () => {}, })) } -const teardownMobileMocks = (originalIsMobile: boolean, originalRpcOnGo: unknown) => { +const teardownMobileMocks = ( + originalIsMobile: boolean, + originalRpcOnGo: unknown, + originalRpcOnJs: unknown +) => { jest.dontMock('react-native-kb') - jest.dontMock('react-native') jest.resetModules() global.isMobile = originalIsMobile global.rpcOnGo = originalRpcOnGo as typeof global.rpcOnGo + global.rpcOnJs = originalRpcOnJs as typeof global.rpcOnJs } test('disconnectCallback throwing does not prevent connectCallback from running on kb-engine-reset', () => { const originalIsMobile = global.isMobile const originalRpcOnGo = global.rpcOnGo + const originalRpcOnJs = global.rpcOnJs global.isMobile = true let capturedMetaCb: ((payload: string) => void) | undefined @@ -55,25 +52,30 @@ test('disconnectCallback throwing does not prevent connectCallback from running const disconnectCallback = jest.fn(() => { throw new Error('disconnect handler blew up') }) - createClient(() => {}, connectCallback, disconnectCallback) + const client = createClient(() => {}, connectCallback, disconnectCallback) + const resetSpy = jest.spyOn(client.transport, 'reset') if (!capturedMetaCb) { throw new Error('kb-engine-reset handler was never registered') } capturedMetaCb('kb-engine-reset') + // The reset must actually happen -- not just the two callbacks below -- + // or pre-reset in-flight invocations stay outstanding forever. + expect(resetSpy).toHaveBeenCalledTimes(1) expect(disconnectCallback).toHaveBeenCalledTimes(1) // The isolation fix: disconnectCallback throwing must not skip connectCallback, // or the UI is stranded on the disconnect banner forever. expect(connectCallback).toHaveBeenCalledTimes(1) } finally { - teardownMobileMocks(originalIsMobile, originalRpcOnGo) + teardownMobileMocks(originalIsMobile, originalRpcOnGo, originalRpcOnJs) } }) test('NativeTransportMobile fails the invocation (not hang) when rpcOnGo reports failure', () => { const originalIsMobile = global.isMobile const originalRpcOnGo = global.rpcOnGo + const originalRpcOnJs = global.rpcOnJs global.isMobile = true mockNativeModules(() => {}) @@ -93,8 +95,11 @@ test('NativeTransportMobile fails the invocation (not hang) when rpcOnGo reports expect(cb).toHaveBeenCalledTimes(1) const [err] = cb.mock.calls[0] as [unknown, unknown] - expect(err).toBeTruthy() + // rpcOnGo is defined but returns false -- this must be the "native rpc + // write failed" throw site, not the "rpcOnGo send before rpcOnGo global" + // site that fires when rpcOnGo is undefined. + expect((err as Error).message).toBe('native rpc write failed') } finally { - teardownMobileMocks(originalIsMobile, originalRpcOnGo) + teardownMobileMocks(originalIsMobile, originalRpcOnGo, originalRpcOnJs) } }) diff --git a/shared/engine/rpc-transport.test.ts b/shared/engine/rpc-transport.test.ts index 7805c407c2fe..afeb4c00e5e1 100644 --- a/shared/engine/rpc-transport.test.ts +++ b/shared/engine/rpc-transport.test.ts @@ -229,7 +229,6 @@ test('a reset cycle fails outstanding invocations once and a post-reset invocati const transport = new TestTransport() const preResetCb = jest.fn() transport.invoke('keybase.1.test.old', [{}], preResetCb) - const [, preResetSeqid] = transport.sent[0] as [number, number, string, [object]] transport.failAllOutstanding() expect(preResetCb).toHaveBeenCalledTimes(1) @@ -238,12 +237,8 @@ test('a reset cycle fails outstanding invocations once and a post-reset invocati transport.invoke('keybase.1.test.new', [{}], postResetCb) const [, postResetSeqid] = transport.sent[1] as [number, number, string, [object]] - expect(postResetSeqid).not.toBe(preResetSeqid) - transport.dispatchDecodedMessage([1, postResetSeqid, null, {ok: 'post-reset'}]) expect(postResetCb).toHaveBeenCalledWith(null, {ok: 'post-reset'}) - // The reset must not have re-fired the pre-reset callback. - expect(preResetCb).toHaveBeenCalledTimes(1) }) test('a response for a pre-reset seqid arriving after reset is ignored', () => { @@ -428,10 +423,9 @@ test('a malformed frame resets the packetizer, and a subsequent well-formed fram }) test('a frame split at every possible byte boundary across two packetizeData calls still dispatches exactly once', () => { - const probe = new TestTransport() - probe.invoke('keybase.1.test.hello', [{}], () => {}) - const [, probeSeqid] = probe.sent[0] as [number, number, string, [object]] - const frame = encodeFrame([1, probeSeqid, null, {ok: 'sweep', pad: 'x'.repeat(40)}]) + // Seqid 1: every TestTransport starts its sequence at 1, the same value + // used directly elsewhere in this file. + const frame = encodeFrame([1, 1, null, {ok: 'sweep', pad: 'x'.repeat(40)}]) for (let splitAt = 1; splitAt < frame.length; splitAt++) { const transport = new TestTransport() @@ -485,7 +479,6 @@ test('a failed response write is reported, not swallowed', () => { // send() itself already logs the generic write failure; makeResponse must // add a second, seqid-specific log or the service-side hang is invisible. expect(errorSpy).toHaveBeenCalledTimes(2) - expect(errorSpy.mock.calls.some(args => args.some(a => typeof a === 'string' && a.includes('11')))).toBe(true) // The response is marked settled before the write is attempted, so a // failed write must not leave it settleable again -- there is no @@ -495,10 +488,6 @@ test('a failed response write is reported, not swallowed', () => { transport.setWriteError(undefined) payload?.response?.result?.({ok: 'retry'}) expect(transport.sent).toEqual([]) - expect(errorSpy).toHaveBeenCalledTimes(3) - expect( - errorSpy.mock.calls.some(args => args.some(a => typeof a === 'string' && a.includes('twice'))) - ).toBe(true) errorSpy.mockRestore() }) diff --git a/shared/test/mocks/react-native.js b/shared/test/mocks/react-native.js index d5772bd2a6e1..2b9f178aca3d 100644 --- a/shared/test/mocks/react-native.js +++ b/shared/test/mocks/react-native.js @@ -16,6 +16,7 @@ exports.StyleSheet = { } exports.NativeModules = {} +exports.LogBox = {ignoreAllLogs: () => {}} exports.TurboModuleRegistry = {get: () => null, getEnforcing: () => ({})} exports.findNodeHandle = () => null From 9b4d8c9752a2823fc6af5dde281e216b1a332f53 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 12:14:22 -0400 Subject: [PATCH 75/80] chore(rpc): move the native sync and framing-test scripts out of plans/ 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. --- .gitignore | 3 + plans/2026-07-26-rpc-bridge-review-fixes.md | 2145 ----------------- .../scripts/sync-native-kb.sh | 2 +- .../react-native-kb/scripts/test-framing.sh | 2 +- 4 files changed, 5 insertions(+), 2147 deletions(-) delete mode 100644 plans/2026-07-26-rpc-bridge-review-fixes.md rename {plans => rnmodules/react-native-kb}/scripts/sync-native-kb.sh (90%) rename plans/scripts/test-native-kb-framing.sh => rnmodules/react-native-kb/scripts/test-framing.sh (93%) diff --git a/.gitignore b/.gitignore index e15aea4c0f76..8e4e6abc3444 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,6 @@ CLAUDE.md docs .codegraph .mcp.json + +# Scratch plans (not part of the codebase) +plans/2*.md diff --git a/plans/2026-07-26-rpc-bridge-review-fixes.md b/plans/2026-07-26-rpc-bridge-review-fixes.md deleted file mode 100644 index 32c97f60f222..000000000000 --- a/plans/2026-07-26-rpc-bridge-review-fixes.md +++ /dev/null @@ -1,2145 +0,0 @@ -# Mobile RPC Bridge — Review Fix Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close the 20 defects five independent reviewers found in the `nojima/HOTPOT-rpc-fixes` mobile JSI RPC bridge hardening, without changing the (validated) core design. - -**Architecture:** The branch's core design is correct and stays: **one permanent process-wide reader thread** per platform, forwarding into whichever `KBBridge` is currently installed via a mutex-guarded global. This is forced by `go/bind/keybase.go` — `ReadArr` returns a view of one shared 300KB global buffer, is documented "called serially by the mobile run loops", and a thread parked in it cannot be cancelled (`LoopbackConn.SetReadDeadline` is a no-op stub). The reader also serializes the non-thread-safe `msgpack::unpacker`. All defects are in two clusters: **teardown seams** (module invalidate races the next module's install) and a **half-wired reset story** (the desync leg notifies JS; the far more common read-error leg does not). - -**Tech Stack:** C++20 + JSI (Hermes), Objective-C++ (iOS TurboModule), Kotlin + fbjni (Android TurboModule), Go (gomobile bindings), TypeScript (engine transport), Jest. - -## Global Constraints - -- Branch is `nojima/HOTPOT-rpc-fixes`. Base is `master` (confirmed via `gh pr view --json baseRefName`, PR #29464). Do **not** amend or rebase existing commits — fix forward with new commits only. -- No `Co-Authored-By` trailers in commits. Ever. -- Repo root is `client/`. TS source lives in `shared/`. Use absolute paths for file ops; for Bash, `cd shared/` first for any yarn command. -- Use `--no-ext-diff` on every `git diff` / `git show` / `git log -p`. -- **Native build gotcha:** `shared/node_modules/react-native-kb` is a real directory **copy**, not a symlink, and expo autolinking resolves gradle/pod paths to it — not to `rnmodules/`. It is currently stale. After editing anything under `rnmodules/react-native-kb/`, run the sync in Task 0 before attempting any native build, and never edit the `node_modules` copy directly (it is not tracked by git). -- Never use `npm`. Always `yarn`. -- Remove unused code when editing: styles, imports, vars, params, dead helpers. -- Comments: no refactoring/change-history notes. Only add a comment when the constraint is non-obvious from the code. Several tasks here **fix wrong comments** — that is deliberate, because this design lives in its comments. -- TS validation after any `shared/` change, from `shared/`: `yarn lint:all` (= `yarn lint && yarn lint:bailouts && yarn tsc`). Baseline is 0 react-compiler bailouts; keep it there. Never delete the ESLint cache. -- Do not "fix" the `n != len(bytes)` short-write branch by assuming it is live — it is provably dead for `LoopbackConn` today (`go/libkb/loopback.go:166-174` is all-or-nothing). Task 15 hardens it as defense-in-depth only. - ---- - -## File Structure - -| File | Responsibility | Tasks | -|---|---|---| -| `rnmodules/react-native-kb/ios/Kb.mm` | iOS TurboModule: bridge install/invalidate, permanent reader loop, event emit | 1, 3, 8, 16, 17 | -| `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` | Android TurboModule: `instance` gate, reader thread, lifecycle | 2, 4, 8, 16, 17 | -| `rnmodules/react-native-kb/android/cpp-adapter.cpp` | Android JNI ↔ `KBBridge` glue, `g_adapter`/`g_bridge` globals | 4, 5, 14, 17 | -| `rnmodules/react-native-kb/cpp/react-native-kb.cpp` | Platform-independent bridge: framing, msgpack↔JSI, batch dispatch | 6, 7, 9, 10, 18 | -| `rnmodules/react-native-kb/cpp/react-native-kb.h` | `KBBridge` interface + threading contract comments | 5, 6, 16 | -| `go/bind/keybase.go` | gomobile `ReadArr`/`WriteArr`/`Reset` | 15, 19 | -| `shared/engine/rpc-transport.tsx` | Shared transport: packetizer, invocations, responses | 11, 12, 13 | -| `shared/engine/index.platform.tsx` | Mobile/renderer transports, `rpcOnJs`, meta events | 12, 13, 20 | -| `shared/engine/rpc-transport.test.ts` | Jest coverage for transport | 11, 12, 13 | - ---- - -## Task Ordering Rationale - -Tasks 1–5 are the **critical/high teardown cluster** — each is an unrecoverable, silent hang in production. Tasks 6–10 close the **reset story**. Tasks 11–13 are TS-side, are the only ones with a real test harness, and are strictly TDD. Tasks 14–20 are hardening, observability, and comment corrections. Tasks 1 and 2 are independent of everything else and should land first. - ---- - -### Task 0: Native build harness - -Establishes the sync + compile loop every native task depends on. No product change. - -**Files:** -- Create: `plans/scripts/sync-native-kb.sh` - -**Interfaces:** -- Produces: `plans/scripts/sync-native-kb.sh` — copies `rnmodules/react-native-kb/{cpp,ios,android}` over `shared/node_modules/react-native-kb/`, used by every later native task before building. - -- [ ] **Step 1: Write the sync script** - -```bash -#!/usr/bin/env bash -# shared/node_modules/react-native-kb is a `file:` COPY, not a symlink, and -# expo autolinking points gradle/pods at it. Native edits under rnmodules/ -# are invisible to a build until they are copied across. -set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SRC="$ROOT/rnmodules/react-native-kb" -DST="$ROOT/shared/node_modules/react-native-kb" -test -d "$DST" || { echo "missing $DST — run yarn in shared/ first" >&2; exit 1; } -for d in cpp ios android; do - rsync -a --delete "$SRC/$d/" "$DST/$d/" -done -echo "synced rnmodules/react-native-kb -> shared/node_modules/react-native-kb" -``` - -- [ ] **Step 2: Make it executable and run it** - -Run: -```bash -chmod +x plans/scripts/sync-native-kb.sh && ./plans/scripts/sync-native-kb.sh -``` -Expected: `synced rnmodules/react-native-kb -> shared/node_modules/react-native-kb` - -- [ ] **Step 3: Verify the C++-only syntax check works** - -This is the fast inner loop — it needs no gradle or xcodebuild. Run from `rnmodules/react-native-kb/cpp`: -```bash -cd rnmodules/react-native-kb/cpp -NM=../../../shared/node_modules -clang++ -std=c++20 -fsyntax-only -DMSGPACK_NO_BOOST \ - -I$NM/react-native/ReactCommon/jsi \ - -I$NM/react-native/ReactCommon/callinvoker \ - -I$NM/react-native/ReactCommon \ - -I$NM/msgpack-cxx-7.0.0/include \ - -I. react-native-kb.cpp -``` -Expected: exit 0, no output. If headers are missing, run `yarn` from `shared/` first and re-run Task 0 Step 2. - -- [ ] **Step 4: Commit** - -```bash -git add plans/scripts/sync-native-kb.sh plans/2026-07-26-rpc-bridge-review-fixes.md -git commit -m "chore(rpc): add native sync script and review fix plan" -``` - ---- - -### Task 1: iOS — compare-and-clear the bridge on invalidate (CRITICAL) - -**The bug.** `Kb.mm:251` calls `kbSetBridge(nullptr)`, and `kbSetBridge` (`Kb.mm:56-68`) has **no identity check** — it tears down whatever bridge is currently installed, not the one this module installed. The two events are genuinely unordered on a reload: - -- `RCTHost.mm:620-628` does `[_instance invalidate]; _instance = nil; … _instance = [[RCTInstance alloc] init…]` -- `RCTInstance.mm:218-247` wraps `invalidate` in `dispatchToJSThread:`, and `RCTJSThreadManager.mm:84` `runOnQueue` is **async** when called off the old JS thread (reload commands arrive on main), so `invalidate` returns immediately -- the queued block reaches `RCTTurboModuleManager.mm:1093` `dispatch_async(methodQueue, …)`, guarded only by a 10s `dispatch_group_wait` (`:1098`) that **times out and proceeds** - -So if the old JS thread is busy (long JS task, debugger pause) or `_sharedModuleQueue` is backed up, the new bridge B installs first and the stale `invalidate` then runs `B->markTornDown()`. Result: `rpcOnGo` returns false forever, the reader drops every frame, the app hangs on the loading screen — with **no desync detected and no engine-reset emitted**, because nothing looks broken from C++'s side. `Kb.mm:253`'s unconditional `KeybaseReset` compounds it by killing B's freshly dialed connection. - -**Files:** -- Modify: `rnmodules/react-native-kb/ios/Kb.mm:56-68` (add compare-and-clear helper) -- Modify: `rnmodules/react-native-kb/ios/Kb.mm:244-254` (`invalidate`) -- Modify: `rnmodules/react-native-kb/ios/Kb.mm:266-308` (`installJSIBindingsWithRuntime` — record the installed bridge) - -**Interfaces:** -- Produces: `static bool kbClearBridgeIfCurrent(const std::shared_ptr &mine)` — returns `true` only if `mine` was the installed bridge and was cleared. Task 3 and Task 8 both call `invalidate` paths that depend on this returning `false` for a stale module. -- Produces: `Kb` instance ivar `myBridge_` of type `std::shared_ptr`. - -- [ ] **Step 1: Add the compare-and-clear helper** - -Insert immediately after `kbSetBridge` (after `Kb.mm:68`): - -```objc -// Clears the installed bridge only if it is still `mine`. A module's -// invalidate can run *after* the next module already installed its bridge -// (RCTInstance::invalidate hops to the old JS thread asynchronously, and -// RCTTurboModuleManager only waits 10s before proceeding), so an -// unconditional clear would tear down the live bridge and wedge the app -// with no desync and no reset to recover from. -static bool kbClearBridgeIfCurrent(const std::shared_ptr &mine) { - std::shared_ptr old; - { - std::lock_guard lock(kbBridgeMutex); - if (!mine || kbCurrentBridge != mine) { - return false; - } - old = std::move(kbCurrentBridge); - kbCurrentBridge = nullptr; - } - old->markTornDown(); - return true; -} -``` - -- [ ] **Step 2: Record the installed bridge on the instance** - -Find the `@implementation Kb` ivar block. If there is no ivar block, add one directly after `@implementation Kb`: - -```objc -@implementation Kb { - std::shared_ptr myBridge_; -} -``` - -If an ivar block already exists, add only the `myBridge_` line to it. - -Then in `installJSIBindingsWithRuntime`, replace `Kb.mm:306`: - -```objc - kbSetBridge(bridge); -``` - -with: - -```objc - myBridge_ = bridge; - kbSetBridge(bridge); -``` - -- [ ] **Step 3: Gate invalidate on the identity check** - -Replace the whole of `invalidate` (`Kb.mm:244-254`): - -```objc -- (void)invalidate { - [[NSNotificationCenter defaultCenter] removeObserver:self]; - kbPasteImageEnabled = NO; - // Runs on the TurboModule shared method queue (no methodQueue getter, so - // RCTTurboModuleManager assigns _sharedModuleQueue) — any thread, never the - // JS thread. Only the atomic flag may be touched here; releasing jsi - // handles off the runtime's thread is undefined behavior. - // - // Both the teardown and the Go reset are gated on still being the current - // bridge: a reload can install the next module's bridge before this runs, - // and clearing that one would leave the app wedged with no way to notice. - if (kbClearBridgeIfCurrent(myBridge_)) { - NSError *error = nil; - KeybaseReset(&error); - } - myBridge_ = nullptr; -} -``` - -Note this also corrects the false "runs on the main thread" comment (finding iOS F2) — `Kb` declares no `methodQueue` getter, so `RCTTurboModuleManager.mm:727-728` assigns `_sharedModuleQueue`. - -- [ ] **Step 4: Sync and compile** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ - -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build -``` -Expected: `** BUILD SUCCEEDED **`. If the workspace needs pods, run `yarn ios:pod:install` from `shared/` first. - -- [ ] **Step 5: Commit** - -```bash -git add rnmodules/react-native-kb/ios/Kb.mm -git commit -m "fix(rpc): only clear the iOS bridge if it is still the installed one - -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." -``` - ---- - -### Task 2: Android — stop nulling `instance` into a permanent wedge (CRITICAL) - -**The bug.** `KbModule.kt:555` clears `instance` on destroy and **nothing restores it**. `onHostResume` (`KbModule.kt:483`) is now an empty body, and `MainApplication.kt:44` holds the `ReactHost` in an application-scoped `by lazy`, so on Android `onHostDestroy` fires when the last Activity is destroyed **while the ReactInstance, ReactContext, and its TurboModule instances survive**. `KbModule` is therefore never reconstructed and `init{}` (`KbModule.kt:298-300`) never re-runs. - -Concrete: back button to home → `onHostDestroy` → `destroy()` → `instance = null` → tap the icon again → Activity recreated, `onHostResume` does nothing → `instance` stays `null` forever → `instance?.nativeOnDataFromGo(data)` (`KbModule.kt:531`) silently discards **every** inbound message for the rest of the process. Outbound still works, so the app looks alive and never receives a reply. This is the exact case the deleted comment called out ("hit the back button to go to home screen and then tap Keybase app icon again"); the old code restarted the read loop in `onHostResume` and that line was removed with nothing replacing it. - -Second symptom: `isReactNativeRunning()` (`KbModule.kt:638`) returns `instance != null`, and `KeybasePushNotificationListenerService.kt:152-158` uses it to decide whether to show fallback notifications — so after the first Activity destroy the user gets duplicate notifications while the app is warm. - -**The fix.** `instance` is a pure gate: the JNI callee (`cpp-adapter.cpp:111` `nativeOnDataFromGo`) ignores `thiz` entirely and routes through `g_bridge`. Nulling it buys nothing and costs the wedge. Remove the clear. Bridge teardown is handled properly by Task 4 instead. - -**Files:** -- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (`destroy()`, ~line 550-566) -- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt:298-300` (`init` ordering) - -- [ ] **Step 1: Remove the instance clear in `destroy()`** - -Replace this block in `destroy()`: - -```kotlin - // The read loop outlives us and forwards to whatever `instance` holds, - // so drop ourselves from it: otherwise a torn-down module (and the - // ReactContext/Activity it pins) keeps receiving deliveries. Only clear - // if we're still the current one — a reload may have installed a newer - // module before our onHostDestroy runs. - if (instance === this) { - instance = null - } -``` - -with: - -```kotlin - // `instance` is deliberately NOT cleared here. onHostDestroy fires when - // the last Activity goes away, but the ReactInstance and this module - // survive, so init{} never re-runs and nothing would ever restore it — - // every later inbound message would be dropped for the life of the - // process (back button to home, then reopen). It is only a gate: the - // JNI callee ignores the receiver and routes through g_bridge, so a - // stale instance delivers to the correct current bridge regardless. - // Bridge teardown is handled by nativeInvalidate below. -``` - -- [ ] **Step 2: Publish `instance` last in `init`** - -`instance = this` currently escapes before `misTestDevice` is assigned, so another thread can observe a partially constructed module. Replace `init`: - -```kotlin - init { - this.reactContext = reactContext!! - misTestDevice = isTestDevice(reactContext) - Thread { cachedConstants }.start() - // Published last: the reader thread reads `instance` and must never - // see a partially constructed module. - instance = this - } -``` - -- [ ] **Step 3: Compile** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:compileDebugKotlin --offline -``` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 4: Manual verification (user drives — do NOT drive the device yourself)** - -Ask the user to: launch the app on Android, confirm chat loads, press the **back button** to exit to the home screen, then tap the Keybase icon again and confirm the inbox still loads and a sent message round-trips. Before this fix that sequence leaves the app permanently unable to receive. - -- [ ] **Step 5: Commit** - -```bash -git add rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt -git commit -m "fix(rpc): stop clearing the Android module instance on host destroy - -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." -``` - ---- - -### Task 3: iOS — clear `kbSharedInstance` so a dead module stops emitting - -**The bug.** `kbSharedInstance` (`Kb.mm:40`) is `__weak` and never cleared on invalidate, and `canEmit` (`Kb.mm:166-168`) is just `_eventEmitterCallback != nullptr`. `_eventEmitterCallback` lives on the codegen'd base (`RCTTurboModule.h:170`, set via `ObjCTurboModule::setEventEmitterCallback`) and RN **never nulls it** on invalidate. The `__weak` ref only nils at `dealloc`, which lags `invalidate` (the module is released at `RCTTurboModuleManager.mm:1102-1104`, and autorelease/other retains defer it further). - -So during a reload window a push notification (`Kb.mm:548-556`), a device token registration (`Kb.mm:534-542`), or — new in this PR — the reader's fatal `emitOnMetaEvent` (`Kb.mm:298-303`) fires into the dying runtime's invoker. The fatal path is the one that runs *precisely* when a reload or reset is already in flight. Android got this fix in commit `3f71bd9388`; iOS did not. - -**Files:** -- Modify: `rnmodules/react-native-kb/ios/Kb.mm:244-254` (`invalidate`, as rewritten by Task 1) - -**Interfaces:** -- Consumes: `kbClearBridgeIfCurrent` and the `myBridge_` ivar from Task 1. - -- [ ] **Step 1: Clear the shared instance in `invalidate`** - -In the `invalidate` body written in Task 1, add the identity-guarded clear as the first statement after the observer removal: - -```objc -- (void)invalidate { - [[NSNotificationCenter defaultCenter] removeObserver:self]; - kbPasteImageEnabled = NO; - // RN never nulls _eventEmitterCallback on invalidate and the __weak ref - // above only nils at dealloc, which lags this call — so without an explicit - // clear, canEmit stays YES and a push notification, token registration or - // (worst) the reader's desync meta event emits into the dying runtime's - // invoker. Guarded because a reload may already have installed a newer - // module as the shared instance. - if (kbSharedInstance == self) { - kbSharedInstance = nil; - } - if (kbClearBridgeIfCurrent(myBridge_)) { - NSError *error = nil; - KeybaseReset(&error); - } - myBridge_ = nullptr; -} -``` - -- [ ] **Step 2: Compile** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ - -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build -``` -Expected: `** BUILD SUCCEEDED **` - -- [ ] **Step 3: Commit** - -```bash -git add rnmodules/react-native-kb/ios/Kb.mm -git commit -m "fix(rpc): clear kbSharedInstance on iOS invalidate - -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 3f71bd9388." -``` - ---- - -### Task 4: Android — add `nativeInvalidate` to tear down the bridge and drop the globals - -**The bug (two findings).** Android has **no `markTornDown()` call site at all** — `react-native-kb.h:44-46` documents it as the module-invalidate entry point, iOS honors it, and `KbModule.destroy()` only clears `instance` and calls `Keybase.reset()`. `g_bridge` (`cpp-adapter.cpp:53`) is replaced *only* inside the bindings installer. So on a dev reload the old bridge stays in `g_bridge` with `isTornDown_ == false`; if the new runtime's installer has not run yet (module construction and JSI binding installation are separate phases), the reader routes to the **old** bridge, passes the `isTornDown_` check, and calls `callInvoker_->invokeAsync` on the **old runtime's CallInvoker while that runtime is being destroyed** — use-after-free in RuntimeScheduler. - -Separately, `g_adapter` is a static `shared_ptr` holding `jni::global_ref` and is never cleared, so it pins the old `KbModule` and through `reactContext` the Activity — exactly what the `KbModule.kt:518-519` comment says the non-inner-class reader was designed to avoid. `g_bridge` likewise pins a dead runtime's `CallInvoker`. - -**Files:** -- Modify: `rnmodules/react-native-kb/android/cpp-adapter.cpp` (add `nativeInvalidate`, register it) -- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (declare + call it in `destroy()`) - -**Interfaces:** -- Produces: `external fun nativeInvalidate()` on `KbModule` (Kotlin), backed by `static void nativeInvalidate(jni::alias_ref)` in `cpp-adapter.cpp`. Idempotent; safe from any thread; touches only atomics and `shared_ptr` slots, never jsi handles. - -- [ ] **Step 1: Add the native invalidate to `cpp-adapter.cpp`** - -Add after the `getBridge()` helper: - -```cpp -// Any thread. Drops this module's C++-side state: flips the bridge's atomic -// teardown flag so the permanent reader stops delivering into a runtime that -// is going away, and releases the globals that would otherwise pin a dead -// KbModule (and its ReactContext/Activity) and a destroyed runtime's -// CallInvoker for the life of the process. -// -// Only atomics and shared_ptr slots are touched — the old bridge's jsi -// handles belong to its own runtime and are released by its kbTeardown host -// object on the JS thread. -static void nativeInvalidate(jni::alias_ref) { - std::shared_ptr oldBridge; - std::shared_ptr oldAdapter; - { - std::lock_guard lock(g_mutex); - oldBridge = std::move(g_bridge); - oldAdapter = std::move(g_adapter); - g_bridge = nullptr; - g_adapter = nullptr; - } - if (oldBridge) { - oldBridge->markTornDown(); - } -} -``` - -- [ ] **Step 2: Register it in the JNI registration block** - -Find the `registerNatives` call in `cpp-adapter.cpp` (the block containing `makeNativeMethod("nativeOnDataFromGo", nativeOnDataFromGo)`) and add alongside it: - -```cpp - makeNativeMethod("nativeInvalidate", nativeInvalidate), -``` - -- [ ] **Step 3: Declare and call it from Kotlin** - -Next to the existing `nativeOnDataFromGo` external declaration in `KbModule.kt`, add: - -```kotlin - private external fun nativeInvalidate() -``` - -Then in `destroy()`, immediately after the comment block Task 2 left in place of the `instance` clear, add: - -```kotlin - nativeInvalidate() -``` - -so the body reads: the explanatory comment, then `nativeInvalidate()`, then the existing `try { Keybase.reset(); relayReset() }`. - -- [ ] **Step 4: Compile both halves** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline -``` -Expected: `BUILD SUCCESSFUL`. A missing `makeNativeMethod` registration surfaces at runtime, not compile time — re-read Step 2 and confirm the method name string matches the Kotlin declaration exactly. - -- [ ] **Step 5: Commit** - -```bash -git add rnmodules/react-native-kb/android/cpp-adapter.cpp \ - rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt -git commit -m "fix(rpc): tear down the Android bridge and globals on module destroy - -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." -``` - ---- - -### Task 5: Android — hold the adapter strongly in the installer - -**The bug.** The installer lambda captures only `std::weak_ptr` (`cpp-adapter.cpp:69`), and the sole strong reference is the `g_adapter` slot. When a second `getBindingsInstaller` runs it overwrites `g_adapter`, destroying the old adapter **immediately** — while `g_bridge` is still the old, still-installed bridge, because the swap only happens later (`cpp-adapter.cpp:100-101`) when the new installer actually executes on the JS thread. In that window the live runtime's `rpcOnGo` hits a failed `weakAdapter.lock()` and returns `false` for **every** send: the whole app fails its RPCs. Guaranteed with two `ReactHost`s, and reachable on any path where module construction precedes runtime destruction. - -There is no cycle to fear — `KbNativeAdapter` holds a `global_ref` and never references the bridge — so a strong capture is safe, and it makes the adapter's lifetime match the bridge that actually uses it. With Task 4 clearing `g_adapter` explicitly, the slot is no longer needed as an ownership anchor. - -**Files:** -- Modify: `rnmodules/react-native-kb/android/cpp-adapter.cpp:65-95` (`getBindingsInstaller`) - -**Interfaces:** -- Consumes: `nativeInvalidate` from Task 4 (which clears `g_adapter`). - -- [ ] **Step 1: Capture the adapter by `shared_ptr`** - -In `getBindingsInstaller`, change the lambda capture from weak to strong and drop the three `weakAdapter.lock()` dances. Replace: - -```cpp - return BindingsInstallerHolder::newObjectCxxArgs( - [weakAdapter = std::weak_ptr(adapter)]( -``` - -with: - -```cpp - // Captured strongly: the adapter must outlive the bridge that calls it, and - // the g_adapter slot is not an ownership anchor -- installing a second - // module overwrites it while the first bridge is still live and installed, - // which with a weak capture failed every rpcOnGo in that window. No cycle: - // the adapter holds a global_ref to the Java module and never the bridge. - return BindingsInstallerHolder::newObjectCxxArgs( - [adapter]( -``` - -- [ ] **Step 2: Simplify the two callbacks that used `weakAdapter`** - -Replace the `writeToGo` callback: - -```cpp - [adapter](void *ptr, size_t size) -> bool { - return adapter->writeToGo(ptr, size); - }, -``` - -and the fatal callback's body: - -```cpp - [adapter]() { - __android_log_print(ANDROID_LOG_ERROR, "KBBridge", - "rpc stream desync, resetting connection"); - adapter->onFatal(); - }); -``` - -- [ ] **Step 3: Compile** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug --offline -``` -Expected: `BUILD SUCCESSFUL` - -- [ ] **Step 4: Commit** - -```bash -git add rnmodules/react-native-kb/android/cpp-adapter.cpp -git commit -m "fix(rpc): hold the Android adapter strongly in the bindings installer - -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." -``` - ---- - -### Task 6: Both platforms — tell JS when the read path resets the connection - -**The bug.** `go/bind/keybase.go:642-647`: when `conn.Read` errors, `ReadArr` calls `Reset()` itself and returns the error. The native readers just log and sleep (`Kb.mm:359-362`; `KbModule.kt:536-544`, which even special-cases `"Read error: EOF"` as expected). **Nothing emits `kb-engine-reset`.** So JS is never told, `failAllOutstanding` never runs, every in-flight RPC hangs forever, and the UI keeps its spinners. - -This is the **common** reset leg — the branch closed the desync leg and left the frequent one open. It also leaves `recv_` holding mid-frame state across a connection boundary, which then trips the desync detector on the next connection. - -**Files:** -- Modify: `rnmodules/react-native-kb/ios/Kb.mm:353-375` (reader loop error branch) -- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (`ReadFromKBLib.run` catch branch) -- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.h` (declare `resetRecv`) -- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp` (define `resetRecv`) - -**Interfaces:** -- Produces: `void KBBridge::resetRecv()` — public, any thread, takes `recvMutex_` and calls the existing private `resetRecvLocked()`. Used here and by Task 9. - -- [ ] **Step 1: Expose a public `resetRecv` on the bridge** - -In `react-native-kb.h`, in the public section directly after the `onDataFromGo` declaration: - -```cpp - // Any thread. Drops any partially parsed frame. Must be called whenever the - // Go connection is replaced, or the unpacker resumes mid-frame on a fresh - // stream and the next header check fails on valid data. - void resetRecv(); -``` - -In `react-native-kb.cpp`, add next to the other small methods (after `tearup()`): - -```cpp -void KBBridge::resetRecv() { - std::lock_guard lock(recvMutex_); - if (recv_) { - resetRecvLocked(); - } -} -``` - -- [ ] **Step 2: iOS — reset framing and notify JS on a read error** - -Replace the error branch in the reader loop (`Kb.mm:359-363`): - -```objc - if (error) { - // ReadArr already called Reset() on the Go side, so the connection - // JS thinks it has is gone and every in-flight RPC is dead. Tell - // JS so it fails them instead of spinning forever, and drop any - // half-parsed frame so the next connection starts clean. - kbLogToService([NSString - stringWithFormat:@"rpc read error, connection reset: %@", - error.localizedDescription]); - if (auto bridge = kbGetBridge()) { - bridge->resetRecv(); - } - dispatch_async(dispatch_get_main_queue(), ^{ - Kb *instance = kbSharedInstance; - if (instance && [instance canEmit]) { - [instance emitOnMetaEvent:metaEventEngineReset]; - } - }); - [NSThread sleepForTimeInterval:0.1]; - continue; - } -``` - -- [ ] **Step 3: Android — same, in the reader's catch** - -In `ReadFromKBLib.run`, replace the `catch (e: Exception)` body: - -```kotlin - } catch (e: Exception) { - // readArr already called Reset() on the Go side, so the - // connection JS thinks it has is gone and every in-flight - // RPC is dead. EOF is the ordinary shape of this, not a - // surprise -- but JS still has to be told, or its callers - // hang forever. - if (e.message != null && e.message.equals("Read error: EOF")) { - NativeLogger.info("Got EOF from read, connection reset.") - } else { - NativeLogger.error("Exception in ReadFromKBLib.run", e) - } - instance?.onRpcConnectionReset() - Thread.sleep(100) - } -``` - -Keep the existing `catch (e: InterruptedException)` branch ahead of it unchanged. - -- [ ] **Step 4: Android — add the Kotlin handler** - -Next to `onRpcStreamFatal` in `KbModule.kt`, add: - -```kotlin - // Called from the reader thread when readArr failed and Go reset the - // connection underneath us. Unlike onRpcStreamFatal we must NOT call - // Keybase.reset() -- ReadArr already did, and a second reset would close a - // connection a concurrent writeArr may have just dialed. - fun onRpcConnectionReset() { - nativeResetRecv() - reactContext.runOnUiQueueThread { relayReset() } - } - - private external fun nativeResetRecv() -``` - -- [ ] **Step 5: Android — back `nativeResetRecv` with JNI** - -In `cpp-adapter.cpp`, add next to `nativeInvalidate`: - -```cpp -static void nativeResetRecv(jni::alias_ref) { - if (auto bridge = getBridge()) { - bridge->resetRecv(); - } -} -``` - -and register it alongside the others: - -```cpp - makeNativeMethod("nativeResetRecv", nativeResetRecv), -``` - -- [ ] **Step 6: Compile both platforms** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline -cd ../ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ - -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build -``` -Expected: `BUILD SUCCESSFUL` and `** BUILD SUCCEEDED **` - -- [ ] **Step 7: Commit** - -```bash -git add rnmodules/react-native-kb/ -git commit -m "fix(rpc): emit engine-reset when the read path drops the connection - -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." -``` - ---- - -### Task 7: C++ — validate the frame length instead of discarding it - -**The bug.** `react-native-kb.cpp:630-643` checks that the framing prefix is a `POSITIVE_INTEGER` no larger than `kMaxFrameSize`, then **throws the value away** and lets the unpacker decode "the next object" as content. The declared length is never compared against what the content actually consumed. Two reviewers converged on this independently. - -So a truncated or over-long content frame is undetected: the unpacker reads into the following frame, and the parity check only trips later, or never — if the shifted bytes happen to begin with a positive int. After a mid-stream resync the parser can land on a `0x05` fixint *inside a string payload*, accept it as a header, and hand whatever parses next to JS as an RPC message. JS then gets a bogus `[type, seqid, …]` and may resolve the wrong seqid. The detector reports "no desync" while delivering corrupt data. - -**Do NOT use `parsed_size()` for this.** It is not a cumulative stream position: `unpacker::next()` calls `reset()` on every success (`msgpack/v2/unpack.hpp:92-101`), and `parser::reset()` sets `m_parsed = 0` (`msgpack/v2/parse.hpp:969`). So `parsed_size()` reads 0 immediately after any successful `next()`, and a delta between two such reads is always `0 - 0`. An earlier revision of this task specified exactly that and would have rejected every non-empty frame — a self-inflicted DoS on the mainline decode path, invisible to `-fsyntax-only`. - -Track the consumed count manually instead. `nonparsed_size()` (`m_used - m_off`) IS accurate at any moment, so if `RecvState` accumulates every byte handed to `reserve_buffer`/`buffer_consumed`, then `totalFed - up.nonparsed_size()` is a genuine monotonic count of bytes consumed so far, unaffected by the per-object reset. - -**Files:** -- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp:625-655` (the `while (true)` parse loop in `onDataFromGo`) - -- [ ] **Step 1: Persist the framing counters in `RecvState`** - -The counters must live in `RecvState`, not in loop locals: a frame's header and its content routinely arrive in **separate** `onDataFromGo` calls, and `recv_->state` already persists across them while a local would reset to 0 and compare against garbage. - -Extend the struct (near line 28 of `react-native-kb.cpp`): - -```cpp -struct KBBridge::RecvState { - msgpack::unpacker unpacker; - ReadState state = ReadState::needSize; - // Persist across calls: a frame's header and its content routinely arrive - // in separate reads. - size_t declaredSize = 0; - size_t consumedAtHeader = 0; - // Every byte ever handed to the unpacker. parsed_size() cannot serve this - // role -- next() zeroes it on each success -- but totalFed minus - // nonparsed_size() is an accurate running count of what has been consumed. - size_t totalFed = 0; -}; -``` - -Read `resetRecvLocked()` and confirm it constructs a fresh `RecvState` (which zeroes these for free). If it instead mutates fields in place, add the three resets there — do not assume. - -- [ ] **Step 2: Verify content consumed exactly the declared length** - -Replace the parse loop body: - -```cpp - while (true) { - msgpack::object_handle result; - if (!up.next(result)) { - break; - } - if (recv_->state == ReadState::needSize) { - // The framing prefix must be a msgpack uint. Anything else means - // the stream desynced; without this check the parity flips and - // every later frame is silently swallowed as a "size". - const auto &o = result.get(); - if (o.type != msgpack::type::POSITIVE_INTEGER || - o.as() > kMaxFrameSize) { - throw std::runtime_error("bad rpc frame header"); - } - recv_->declaredSize = static_cast(o.as()); - recv_->consumedAtHeader = recv_->totalFed - up.nonparsed_size(); - recv_->state = ReadState::needContent; - } else { - // The header is only a plausibility check on its own: a fixint - // sitting inside a string payload parses as a valid header after a - // resync. Requiring the content object to consume exactly the - // declared byte count makes the framing self-checking, so a - // truncated or overlong frame is caught here instead of being - // handed to JS as a bogus [type, seqid, ...]. - const size_t consumed = - (recv_->totalFed - up.nonparsed_size()) - recv_->consumedAtHeader; - if (consumed != recv_->declaredSize) { - throw std::runtime_error("rpc frame length mismatch"); - } - values->push_back(std::move(result)); - recv_->state = ReadState::needSize; - } - } -``` - -- [ ] **Step 3: Feed `totalFed` where bytes enter the unpacker** - -In `onDataFromGo`, immediately after the existing `up.buffer_consumed(...)` call, add: - -```cpp - recv_->totalFed += static_cast(size); -``` - -Without this the counter never advances and the check is meaningless. Confirm there is exactly one place bytes enter the unpacker; if there is more than one, every such site must update `totalFed`. - -- [ ] **Step 4: Prove the arithmetic on a real frame, not just a syntax check** - -`-fsyntax-only` cannot catch wrong arithmetic here, and the previous revision of this task shipped a version that rejected every frame. Write a throwaway harness in `/tmp/` that links the real msgpack headers, packs two well-formed frames (`` twice, with different content sizes), feeds them to a `msgpack::unpacker` **split across three chunk boundaries** — including one that splits a header from its content — and asserts the computed `consumed` equals `declaredSize` for both frames. Then feed a deliberately truncated frame and assert it does NOT match. - -Run it and paste the actual output into your report. If `consumed` is 0 or mismatched for a valid frame, stop and report BLOCKED — do not commit. - -- [ ] **Step 5: Syntax check the real file** - -Run the fast C++-only check from Task 0 Step 3. -Expected: exit 0. - -- [ ] **Step 6: Commit** - -```bash -git add rnmodules/react-native-kb/cpp/ -git commit -m "fix(rpc): require frame content to consume exactly the declared length - -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." -``` - ---- - -### Task 8: C++ — a conversion failure must fail loudly, not eat the batch - -**The bug.** `react-native-kb.cpp:721-725` catches everything thrown by the delivery lambda and only calls `reportError`. `onFatal_` is **not** called. Throwing paths are real: `mpToString` "Invalid map key" for a BIN/ARRAY/MAP/EXT map key, "msgpack nesting too deep" (>1024), OOM in `binaryFromBytes`, and the `"rpcOnJs is not installed"` early return. - -So one message containing a map with a non-scalar key throws while building item 3 of a 10-message batch → **all 10 messages are dropped**, including the reply to seqid N. JS's `_invocations` entry for N is never resolved and that caller hangs forever. This is exactly the bug class the branch set out to fix, and the desync path 60 lines above handles it correctly. - -Two changes: convert per-message so one bad message cannot kill nine good ones, and escalate to `onFatal_` so JS fails its outstanding RPCs. - -**Files:** -- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp:700-728` (the `invokeAsync` lambda body) - -- [ ] **Step 1: Convert each message inside its own try** - -Replace the batch branch (the `else` arm handling `values->size() > 1`): - -```cpp - } else { - // Convert per message: a single bad message (non-scalar map key, - // nesting over the limit) must not take the whole batch with it and - // strand every other reply's caller. - jsi::Array arr(runtime, values->size()); - size_t converted = 0; - for (size_t i = 0; i < values->size(); ++i) { - try { - msgpack::object obj((*values)[i].get()); - arr.setValueAtIndex(runtime, converted, - self->convertMPToJSI(runtime, &obj)); - ++converted; - } catch (const std::exception &e) { - self->reportError(std::string("dropping undecodable message: ") + - e.what()); - } - } - if (converted == 0) { - return; - } - if (self->isTornDown_.load()) { - return; - } - onJs.call(runtime, std::move(arr), - jsi::Value(static_cast(converted))); - } -``` - -Note `converted` is used as both the write index and the reported count, so a dropped message leaves no hole in the array. The JS side reads `count`, not `arr.length`. - -- [ ] **Step 2: Escalate the outer catch to fatal** - -Replace the lambda's trailing catch pair: - -```cpp - } catch (const std::exception &e) { - // Nothing decoded reached JS, so every reply in this batch is lost and - // its caller would wait forever. Treat it like a desync: reset the - // connection so JS fails its outstanding RPCs. - self->reportError(e.what()); - if (self->onFatal_) { - self->onFatal_(); - } - } catch (...) { - self->reportError("unknown error in onDataFromGo JS callback"); - if (self->onFatal_) { - self->onFatal_(); - } - } -``` - -- [ ] **Step 3: Escalate the missing-`rpcOnJs` early return too** - -Replace the `"rpcOnJs is not installed"` branch: - -```cpp - if (!onJsValue.isObject() || - !onJsValue.getObject(runtime).isFunction(runtime)) { - // These messages are already consumed from the unpacker and cannot be - // replayed, so dropping them silently would strand their callers. - self->reportError("rpcOnJs is not installed"); - if (self->onFatal_) { - self->onFatal_(); - } - return; - } -``` - -- [ ] **Step 4: Syntax check** - -Run the Task 0 Step 3 command. Expected: exit 0. - -- [ ] **Step 5: Commit** - -```bash -git add rnmodules/react-native-kb/cpp/react-native-kb.cpp -git commit -m "fix(rpc): don't let one undecodable message strand a whole batch - -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." -``` - ---- - -### Task 9: WITHDRAWN — the latch is unnecessary and actively harmful - -**Do not implement this task.** It was attempted (commit `0ed435d`) and reverted. Recorded here so the reasoning is not lost and nobody re-derives it. - -Two findings killed it: - -1. **The premise is already dead.** The task assumed stale in-flight bytes keep arriving after a desync and each triggers another reset. But `onFatal_` runs *synchronously* on the single serial reader thread, so `KeybaseReset` completes **before** the loop's next `ReadArr`, and the stale bytes are discarded wholesale along with the closed connection. They are never re-read. One desync already produces exactly one reset. Task 6's synchronous fatal handling closed this scenario. - -2. **Every way of clearing the latch is broken.** Clearing it inside the fatal handler (this task's original Step 4) is a zero-width no-op — the latch is false again before the next chunk arrives, on the same synchronous call chain. Clearing it only from the platform read-error branches instead produces a **permanent wedge**: after `KeybaseReset` nils the connection, the next `ReadArr` dials a fresh one via `ensureConnection`, and `LoopbackConn.Read` (`go/libkb/loopback.go:120-138`) blocks until real data — it never returns `(0, nil)`. So the reconnect succeeds with `err == nil`, the error branch never fires, `resetRecv()` is never called, and `onDataFromGo`'s entry guard drops every byte for the rest of the process. `engineReset` also bypasses `resetRecv()`, so there is no user-triggered escape short of an app restart. - -If a genuine reset storm is ever observed in the field, the fix is a signal that distinguishes "a new connection is up and serving data" from "nothing has arrived yet" — which does not exist today — not a latch cleared by either of the paths above. - -
-Original (withdrawn) task text, kept for reference - -### Task 9: C++ — latch after a fatal so one desync isn't a reset storm - -**The bug.** Every desynced chunk calls `onFatal_()` → `KeybaseReset()` + engine-reset meta event → JS fails all outstanding RPCs. But bytes already in flight from the pre-reset stream keep arriving on the reader thread and keep failing the header check, so a single desync produces N resets in a row, each nuking whatever JS re-issued in between. Go's read buffer is 300KB (`keybase.go:348`), so a 1MB backlog is ~4 iterations. - -**Files:** -- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.h` (add the latch member) -- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp` (set it on fatal, clear it on reset) - -**Interfaces:** -- Consumes: `KBBridge::resetRecv()` from Task 6 — clearing the latch is what lets delivery resume. - -- [ ] **Step 1: Add the latch member** - -In `react-native-kb.h`, next to `isTornDown_`: - -```cpp - // Set when the incoming stream desynced and the connection is being reset. - // Bytes already in flight from the dead stream keep arriving and would each - // trigger another reset, so drop them until the platform layer confirms the - // connection was replaced (resetRecv). - std::atomic awaitingReset_{false}; -``` - -- [ ] **Step 2: Drop data while latched, and set the latch on fatal** - -In `onDataFromGo`, extend the entry guard: - -```cpp -void KBBridge::onDataFromGo(uint8_t *data, int size) { - if (isTornDown_.load() || awaitingReset_.load() || size <= 0 || - data == nullptr) { - return; - } -``` - -and in the `if (fatal)` block, set the latch before invoking `onFatal_`: - -```cpp - if (fatal) { - reportError(fatalMsg); - // The stream can no longer be trusted, so anything decoded in this batch - // is dropped. The platform layer resets the Go connection and signals JS - // so outstanding RPCs fail instead of hanging forever. Latch until that - // reset lands: the rest of the dead stream is still in flight and would - // otherwise trigger a reset per chunk, each one failing whatever JS just - // re-issued. - awaitingReset_.store(true); - if (onFatal_) { - onFatal_(); - } - return; - } -``` - -- [ ] **Step 3: Clear the latch in `resetRecv`** - -Update the `resetRecv` added in Task 6: - -```cpp -void KBBridge::resetRecv() { - { - std::lock_guard lock(recvMutex_); - if (recv_) { - resetRecvLocked(); - } - } - // The connection has been replaced, so the in-flight remnants of the old - // stream are gone and it is safe to deliver again. - awaitingReset_.store(false); -} -``` - -- [ ] **Step 4: Make the platform fatal paths clear the latch** - -The latch is only cleared by `resetRecv`, so both fatal handlers must call it after their `KeybaseReset`. In `Kb.mm`'s fatal callback, after the `KeybaseReset(&error)` block and before the `dispatch_async`: - -```objc - if (auto b = kbGetBridge()) { - b->resetRecv(); - } -``` - -In `KbModule.kt`'s `onRpcStreamFatal`, after the `Keybase.reset()` try block: - -```kotlin - nativeResetRecv() -``` - -- [ ] **Step 5: Compile both platforms** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline -cd ../ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ - -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build -``` -Expected: both succeed. - -- [ ] **Step 6: Commit** - -```bash -git add rnmodules/react-native-kb/ -git commit -m "fix(rpc): latch after a desync so one bad frame is one reset - -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 the connection was replaced." -``` - -
- ---- - -### Task 10: TS — wire the mobile account-switch reset, or delete the dead method - -**The bug.** `index.platform.tsx` adds `NativeTransportMobile.reset()` and wires it to the `kb-engine-reset` meta event, and its comment claims it covers "engine reset". But `Engine.reset()` early-returns on mobile (`shared/engine/index.tsx:305-307`), the account-switch call sites (`shared/constants/init/index.tsx:310, 317`) go through `Engine.reset()`, and the native `engineReset` TurboModule method has **no caller in current source** — the only hits are a stale `coverage-ts` artifact and the module's own export. - -So on a mobile account switch, pre-switch outstanding invocations are never failed and their callbacks can fire against post-switch state — precisely what `ProxyNativeTransport.reset()` exists to prevent on desktop. - -Two reviewers found this independently. Resolve it rather than leaving a claim with no caller. - -**Files:** -- Modify: `shared/engine/index.tsx:305-317` (`Engine.reset`) -- Test: `shared/engine/rpc-transport.test.ts` - -**Interfaces:** -- Consumes: `LocalTransport.reset()` / `failAllOutstanding()` (existing). - -- [ ] **Step 1: Read the current mobile early-return** - -Run: -```bash -cd shared && sed -n '295,325p' engine/index.tsx -``` -Confirm the exact shape of the mobile guard before editing — it must keep skipping the desktop-only socket teardown while still resetting the transport. - -- [ ] **Step 2: Write the failing test** - -Add to `shared/engine/rpc-transport.test.ts`: - -```ts -test('reset fails every outstanding invocation exactly once', () => { - const transport = new TestTransport() - const calls: Array = [] - transport.invoke({method: 'a', param: {}}, (err: unknown) => calls.push(err)) - transport.invoke({method: 'b', param: {}}, (err: unknown) => calls.push(err)) - - transport.reset() - - expect(calls).toHaveLength(2) - expect(calls.every(e => e instanceof Error)).toBe(true) - - // A second reset must not re-fail anything already failed. - transport.reset() - expect(calls).toHaveLength(2) -}) - -test('seqids keep advancing across a reset so a late reply cannot alias', () => { - const transport = new TestTransport() - const seen: Array = [] - transport.invoke({method: 'a', param: {}}, () => {}) - seen.push(transport.lastSeqidForTest) - transport.reset() - transport.invoke({method: 'b', param: {}}, () => {}) - seen.push(transport.lastSeqidForTest) - - expect(seen[1]).toBeGreaterThan(seen[0]!) -}) -``` - -If `TestTransport` does not already expose the last seqid, add a minimal accessor to the test double in that file rather than to production code — read the existing `TestTransport` definition first and match its style. - -- [ ] **Step 3: Run the tests to see them fail or pass** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -``` -Expected: the exactly-once test **passes** (the map-swap in `failOutstanding` already guarantees it — this is a regression guard, and a guard that passes on first run is doing its job). The seqid test should also pass. If either fails, that is a real defect — stop and report it before continuing. - -- [ ] **Step 4: Make `Engine.reset()` reset the mobile transport** - -In `shared/engine/index.tsx`, change the mobile early-return so it still resets the transport. Using the exact surrounding code you read in Step 1, the mobile branch must call the transport's `reset()` before returning, e.g.: - -```ts - reset() { - // Mobile has no socket to tear down and no reconnect to wait for, but the - // in-flight invocations still have to be failed: after an account switch - // nothing will answer them, and their callbacks would otherwise fire - // against post-switch state. - this._rpcClient.transport.reset() - if (isMobile) { - return - } - // ...existing desktop teardown unchanged... - } -``` - -Match the real property names in that file (`_rpcClient` vs `_client` etc.) — do not guess. - -- [ ] **Step 5: Delete the dead native `engineReset`, or wire it** - -`engineReset` has no caller. Deleting it spans the TurboModule spec and both platform implementations, which is a wider blast radius than this plan's other tasks. **Do not delete it silently.** Instead: - -Run: -```bash -cd /Users/chrisnojima/go/src/github.com/keybase/client && \ - grep -rn 'engineReset' --include=*.ts --include=*.tsx --include=*.mm --include=*.kt --include=*.cpp \ - shared/ rnmodules/ | grep -v node_modules | grep -v coverage-ts -``` - -Report the result to the user and ask whether to remove it or wire the account-switch path to it. Until they answer, fix the misleading comment on `NativeTransportMobile.reset()` in `index.platform.tsx` so it stops claiming coverage it does not have: - -```ts - // The Go connection can be reset underneath us (a stream desync detected - // natively, or the read path losing the connection). Nothing will answer - // the in-flight RPCs after that, so fail them rather than hang every caller. - override reset() { - this.failAllOutstanding() - } -``` - -- [ ] **Step 6: Validate** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts && yarn lint:all -``` -Expected: tests pass; lint, bailouts, and tsc all clean (0 bailouts). - -- [ ] **Step 7: Commit** - -```bash -git add shared/engine/index.tsx shared/engine/index.platform.tsx shared/engine/rpc-transport.test.ts -git commit -m "fix(engine): fail in-flight RPCs on a mobile account switch - -Engine.reset() early-returned on mobile, so the account-switch path never -reached the transport and pre-switch invocations were never failed -- their -callbacks could fire against post-switch state, which is exactly what -ProxyNativeTransport.reset() prevents on desktop. - -Add regression tests for exactly-once failure and for seqids continuing to -advance across a reset, since restarting the counter would let a late reply -from the dead connection alias a fresh invocation." -``` - ---- - -### Task 11: TS — an app-code throw must not wipe the packetizer (desktop) - -**The bug.** `rpc-transport.tsx:329` dispatches **inside** the parse loop's `try`, and the `catch` at `:331-334` calls `p.reset()`, discarding all buffered bytes. `_onEngineIncoming` (`engine/index.tsx:220-222`) is unguarded, so any app-code handler that throws unwinds out of `dispatchDecodedMessage` into that catch and wipes the packetizer **mid-stream**. The socket keeps delivering the rest of the frame, parsing resumes at an arbitrary byte offset → `Bad frame header` → reset → repeat. On the renderer (`ProxyNativeTransport`) there is no socket to reconnect, so it is terminal until app restart. - -This is the same failure mode the branch fixed for mobile, still live on desktop. - -**Files:** -- Modify: `shared/engine/rpc-transport.tsx:320-335` (`packetizeData`) -- Test: `shared/engine/rpc-transport.test.ts` - -- [ ] **Step 1: Read the current `packetizeData`** - -Run: -```bash -cd shared && sed -n '300,340p' engine/rpc-transport.tsx -``` -Note the exact variable names (`p`, `payload`) before editing. - -- [ ] **Step 2: Write the failing test** - -Add to `shared/engine/rpc-transport.test.ts`: - -```ts -test('a throwing incoming handler does not desync the packetizer', () => { - const delivered: Array = [] - let shouldThrow = true - const transport = new TestTransport() - transport.setIncomingHandler((msg: unknown) => { - if (shouldThrow) { - shouldThrow = false - throw new Error('app handler blew up') - } - delivered.push(msg) - }) - - // Two well-formed frames arriving in a single chunk. The first handler - // throws; the second frame must still be parsed and delivered. - transport.feedRawForTest(encodeTwoFramesForTest()) - - expect(delivered).toHaveLength(1) -}) -``` - -`TestTransport` almost certainly lacks `setIncomingHandler` / `feedRawForTest` / `encodeTwoFramesForTest`. Read the existing test file first and build these against whatever the real transport exposes — the required behavior is: feed two concatenated framed messages in one buffer, have the first handler throw, assert the second still arrives. If the existing `TestTransport` cannot feed raw bytes, construct a `ProxyNativeTransport` (or the shared base) directly in the test and call its packetize entry point. - -- [ ] **Step 3: Run it and watch it fail** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -t 'does not desync the packetizer' -``` -Expected: FAIL — `delivered` is empty, because the throw reset the packetizer and the second frame's bytes were discarded. - -- [ ] **Step 4: Isolate the dispatch** - -In `packetizeData`, move the dispatch out of the framing `try` so only genuine decode/framing errors reach the reset path: - -```ts - // Dispatch outside the framing try: an app-side handler that throws must - // not reach the catch below, which resets the packetizer and discards - // every buffered byte. That leaves parsing to resume at an arbitrary - // offset -- and on the renderer transport there is no socket to - // reconnect, so it never recovers. - try { - this.dispatchDecodedMessage(payload) - } catch (e) { - logger.error('dispatchDecodedMessage threw', e) - } -``` - -Keep the outer `catch` that calls `p.reset()` for decode/framing errors only. - -- [ ] **Step 5: Run the test to verify it passes** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -``` -Expected: PASS, all tests. - -- [ ] **Step 6: Validate and commit** - -```bash -cd shared && yarn lint:all -``` -Expected: clean, 0 bailouts. - -```bash -git add shared/engine/rpc-transport.tsx shared/engine/rpc-transport.test.ts -git commit -m "fix(engine): app-code throws must not reset the packetizer - -dispatchDecodedMessage ran inside the framing try, so any unguarded handler -throw unwound into the catch that resets the packetizer and drops every -buffered byte. Parsing then resumed mid-frame, producing Bad frame header -forever -- terminal on the renderer transport, which has no socket to -reconnect. Same failure mode the mobile side of this branch fixed." -``` - ---- - -### Task 12: TS — a throwing incoming-invoke handler must answer the call - -**The bug.** The per-message catch in `index.platform.tsx` swallows the throw and moves on, but the `response` object created at `rpc-transport.tsx:351-355` is then never settled. A custom-response handler that throws — e.g. `chat.1.chatUi.chatWatchPosition` (`engine/index.tsx:26`) — leaves Go's RPC waiting on a reply that will never be sent: a permanent per-call hang. - -The per-message catch is the right shape (failing the whole batch would drop messages N+1..M); it is just incomplete. - -**Files:** -- Modify: `shared/engine/rpc-transport.tsx:345-365` (`dispatchDecodedMessage`, `MESSAGE_TYPE_INVOKE` case) -- Test: `shared/engine/rpc-transport.test.ts` - -- [ ] **Step 1: Read the invoke case** - -Run: -```bash -cd shared && sed -n '340,370p' engine/rpc-transport.tsx -``` -Note how `payload.response` is built and which error constructor is in scope (`makeTransportError` or similar). - -- [ ] **Step 2: Write the failing test** - -```ts -test('a throwing invoke handler still answers the caller with an error', () => { - const sent: Array = [] - const transport = new TestTransport() - transport.captureSentForTest(sent) - transport.setIncomingHandler(() => { - throw new Error('handler blew up') - }) - - transport.dispatchDecodedMessage(makeInvokeFrameForTest({seqid: 7, method: 'x'})) - - // Something must go back for seqid 7 -- otherwise the service waits forever. - expect(sent).toHaveLength(1) - expect(JSON.stringify(sent[0])).toContain('7') -}) -``` - -Build `captureSentForTest` / `makeInvokeFrameForTest` against the real transport surface, following the existing file's conventions. - -- [ ] **Step 3: Run it and watch it fail** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -t 'still answers the caller' -``` -Expected: FAIL — `sent` is empty. - -- [ ] **Step 4: Answer the call on throw** - -In the `MESSAGE_TYPE_INVOKE` case, wrap the handler invocation: - -```ts - try { - this._incomingRPCCallback(payload) - } catch (e) { - // The handler threw, so nothing will answer this seqid and the - // service side waits forever. Fail it explicitly. - logger.error('incoming invoke handler threw', e) - payload.response?.error?.(makeTransportError('UNKNOWN_METHOD')) - } -``` - -Use the error constructor actually in scope, matching the file's existing usage. - -- [ ] **Step 5: Run to verify it passes** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -``` -Expected: PASS - -- [ ] **Step 6: Validate and commit** - -```bash -cd shared && yarn lint:all -``` - -```bash -git add shared/engine/rpc-transport.tsx shared/engine/rpc-transport.test.ts -git commit -m "fix(engine): answer the caller when an incoming invoke handler throws - -The per-message catch swallowed the throw but never settled the response, so -a throwing custom-response handler left the service waiting on a reply that -never came -- a permanent per-call hang. Keep the per-message shape (failing -the batch would drop the remaining messages) and error the response instead." -``` - ---- - -### Task 13: TS — reset must report the disconnect, and clear the packetizer - -**Two bugs, same code path.** - -First: the `kb-engine-reset` handler calls `transport.reset()` then `connectCallback()`, but never `disconnectCallback()`. So `Engine._onDisconnect()` (`engine/index.tsx:114-123`) never runs — `_cancelOutstandingSessions()` is skipped and `_onConnectedCB(false)` never fires, so `onEngineDisconnected` (`constants/init/shared.tsx:294-300`) never sets the daemon error and the UI never shows the reconnect state. Sessions survive mostly by luck (each session's `start()` invoke is outstanding, so `failAllOutstanding` → `wrappedCallback` → `session.end()`), but any session not currently holding an outstanding invocation is leaked, and a pending custom-response prompt is left on screen bound to a dead seqid — `response.result()` then writes an unknown seqid into the *new* connection and silently does nothing. The desktop socket path does `onDisconnected()` → `onConnected()`; mobile should match. - -Second: `failAllOutstanding()` (`rpc-transport.tsx:471-473`) fails invocations but leaves `_packetizer` holding a partial frame, unlike `close()` (`:456-461`) and `onDisconnected()` (`:239-243`), both of which reset it. Dead weight on mobile (no packetizer) but live on `ProxyNativeTransport.reset()` — the account-switch path — where a half-delivered pre-switch frame concatenates with post-switch bytes into one corrupt decode. - -**Files:** -- Modify: `shared/engine/index.platform.tsx` (`onMetaEvent` handler) -- Modify: `shared/engine/rpc-transport.tsx:465-475` (`failAllOutstanding`) -- Test: `shared/engine/rpc-transport.test.ts` - -- [ ] **Step 1: Write the failing test for the packetizer reset** - -```ts -test('failAllOutstanding clears buffered frame bytes', () => { - const transport = new TestTransport() - // Feed half a frame, then reset, then feed a complete frame. The stale half - // must not be prepended to the new bytes. - transport.feedRawForTest(halfFrameForTest()) - transport.failAllOutstanding() - - const delivered: Array = [] - transport.setIncomingHandler((m: unknown) => delivered.push(m)) - transport.feedRawForTest(completeFrameForTest()) - - expect(delivered).toHaveLength(1) -}) -``` - -Reuse the raw-feed helpers built in Task 11. - -- [ ] **Step 2: Run it and watch it fail** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -t 'clears buffered frame bytes' -``` -Expected: FAIL — the stale half-frame corrupts the following decode. - -- [ ] **Step 3: Reset the packetizer in `failAllOutstanding`** - -```ts - failAllOutstanding() { - // Also drop any partial frame: on the renderer transport this runs on the - // account switch, and a half-delivered pre-switch frame would concatenate - // with post-switch bytes into one corrupt decode. - this._packetizer.reset() - // ...existing invocation-failing logic unchanged... - } -``` - -Match the real field name (`_packetizer` vs `_p`) and the reset method the sibling `close()`/`onDisconnected()` already call. - -- [ ] **Step 4: Run to verify it passes** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -``` -Expected: PASS - -- [ ] **Step 5: Report the disconnect before the reconnect** - -In `index.platform.tsx`'s `onMetaEvent` handler: - -```ts - case 'kb-engine-reset': - // Go dropped the loopback connection; anything in flight is dead. - // Report the disconnect before the reconnect so the engine cancels - // its sessions and the UI shows the reconnect state -- the desktop - // socket path does the same pair. - client.transport.reset() - disconnectCallback() - connectCallback() -``` - -- [ ] **Step 6: Validate** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts && yarn lint:all -``` -Expected: tests pass; lint/bailouts/tsc clean. - -- [ ] **Step 7: Manual verification (user drives)** - -Ask the user to trigger a mobile engine reset (kill and restart the keybase service while the app is foregrounded) and confirm the app shows the disconnected state and then recovers, rather than sitting on stale spinners. - -- [ ] **Step 8: Commit** - -```bash -git add shared/engine/index.platform.tsx shared/engine/rpc-transport.tsx shared/engine/rpc-transport.test.ts -git commit -m "fix(engine): report the disconnect on a mobile engine reset - -The reset handler called connectCallback without ever calling -disconnectCallback, so _onDisconnect never ran: sessions were not cancelled -and the daemon error was never set, leaving the UI with no reconnect state. A -pending prompt stayed on screen bound to a dead seqid, and answering it wrote -an unknown seqid into the new connection. - -Also clear the packetizer in failAllOutstanding, matching close() and -onDisconnected() -- on the renderer transport this runs on the account switch, -where a half-delivered frame would corrupt the first post-switch decode." -``` - ---- - -### Task 14: Android — clear the pending JNI exception on allocation failure - -**The bug.** `cpp-adapter.cpp:28-31`: if `NewByteArray` fails (OOM on a large attachment payload) it returns null **and leaves `OutOfMemoryError` pending on the JS thread**. The code returns `false` without `ExceptionClear()`, so control returns through `packAndSend` → `rpcOnGo` returns `Value(false)` to JS, and the JS thread continues making further JNI calls with an exception pending: undefined behavior, and a guaranteed abort under CheckJNI. - -Also worth fixing while here: if `method(...)` throws (fbjni translating a pending Java exception into `JniException`), `DeleteLocalRef(jba)` is skipped. Using the RAII `local_ref` form removes both the leak and the manual delete. - -**Files:** -- Modify: `rnmodules/react-native-kb/android/cpp-adapter.cpp:26-40` (`KbNativeAdapter::writeToGo`) - -- [ ] **Step 1: Rewrite `writeToGo` with RAII and an exception clear** - -```cpp - bool writeToGo(void *ptr, size_t size) { - jni::ThreadScope scope; - auto env = jni::Environment::current(); - auto jba = env->NewByteArray(size); - if (jba == nullptr) { - // NewByteArray leaves OutOfMemoryError pending. Returning with it still - // set means the JS thread makes its next JNI call with a live exception - // -- UB, and an abort under CheckJNI. - env->ExceptionClear(); - return false; - } - // Adopt into a local_ref so the ref is released even if the call below - // throws a JniException. - auto arr = jni::adopt_local(static_cast(jba)); - env->SetByteArrayRegion(jba, 0, size, (jbyte *)ptr); - static auto method = - JKbModule::javaClassStatic() - ->getMethod)>("rpcOnGo"); - auto ok = method(jModule_, arr); - return ok != JNI_FALSE; - } -``` - -- [ ] **Step 2: Compile** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug --offline -``` -Expected: `BUILD SUCCESSFUL`. If `adopt_local` does not accept that cast, check the fbjni version's signature in `shared/node_modules/react-native/ReactAndroid/src/main/jni/first-party/fbjni` and adapt — the requirement is an owning local ref, not that specific spelling. - -- [ ] **Step 3: Commit** - -```bash -git add rnmodules/react-native-kb/android/cpp-adapter.cpp -git commit -m "fix(rpc): clear the pending JNI exception when NewByteArray fails - -NewByteArray leaves OutOfMemoryError pending on failure. Returning false -without clearing it meant the JS thread's next JNI call ran with a live -exception -- UB, and an abort under CheckJNI. Adopt the array into a -local_ref so the ref is also released if rpcOnGo throws." -``` - ---- - -### Task 15: Go — make a short write fatal instead of silently truncating - -**Context and scope limit.** `keybase.go:586-593`: `currentConn.Write` can return `n != len(bytes)` **after** delivering `n` bytes. `rpcOnGo` returns `false` and JS fails that one RPC, but the server-side framer now holds a truncated frame and the next write is consumed as its remainder — every subsequent outbound RPC is garbage, indefinitely, with no reset and no desync signal (the new fatal machinery is inbound-only). - -**This branch is dead today**: `LoopbackConn.Write` (`go/libkb/loopback.go:166-174`) is all-or-nothing (`lc.ch <- b`, returns `len(b)`), and both other `WriteArr` failure modes leave zero bytes in the stream. One reviewer initially rated this HIGH; a second disproved reachability. Fix it as **defense-in-depth**, and do not describe it as a live bug. - -**Files:** -- Modify: `go/bind/keybase.go:580-595` (`WriteArr`) - -- [ ] **Step 1: Read the current write path** - -Run: -```bash -sed -n '550,600p' go/bind/keybase.go -``` -Confirm the exact variable names and the existing error style before editing. - -- [ ] **Step 2: Reset the connection on a partial write** - -In the short-write branch, reset before returning so a truncated frame cannot survive into the next write: - -```go - if n != len(bytes) { - // Not reachable through LoopbackConn today, whose Write is - // all-or-nothing. If a future transport can short-write, the peer's - // framer is left holding a partial frame and every later write would - // be consumed as its remainder, so drop the connection rather than - // corrupt the stream indefinitely. - Reset() - return fmt.Errorf("keybase: short write %d of %d", n, len(bytes)) - } -``` - -Match the file's existing error-construction style (`fmt.Errorf` vs `errors.New`) and confirm `Reset()` is callable from this scope without deadlocking against a lock `WriteArr` already holds — read the surrounding locking before committing to this shape. - -- [ ] **Step 3: Build and vet** - -Run: -```bash -cd go && go build ./bind/... && go vet ./bind/... -``` -Expected: no output, exit 0. - -- [ ] **Step 4: Commit** - -```bash -git add go/bind/keybase.go -git commit -m "fix(bind): reset the connection on a partial WriteArr - -Not reachable through LoopbackConn, whose Write is all-or-nothing, but if a -short write ever happened the peer's framer would hold a truncated frame and -consume every later write as its remainder -- corrupting the outbound stream -indefinitely with no reset and no desync signal, since the stream-fatal -machinery is inbound-only." -``` - ---- - -### Task 16: Correct the comments that assert invariants the code doesn't have - -Three reviewers independently flagged that this design lives in its comments, and that two of them are wrong. A future maintainer reasoning from them will reach false conclusions. - -**Wrong claim 1 — "ReadArr returns nil when idle, so we must sleep or spin a core."** `ReadArr` **blocks**: `LoopbackConn.Read` (`go/libkb/loopback.go:120`) parks on `<-lc.partnerCh`. The `(nil, nil)` return at `keybase.go:650` needs `n == 0 && err == nil`, which a blocking read essentially never produces. The 10ms sleep is dead code in practice — harmless, but the stated rationale is false, and the one way to actually hit it (buffer never allocated because `Init` did not run) turns into a permanent silent 100Hz spin. Three of four reviewers who checked agreed; the fourth confirmed only that the code line exists, not that it is the idle path. - -**Wrong claim 2 — "`invalidate` runs on the main thread."** Already corrected by Task 1; verify it landed. - -**Files:** -- Modify: `rnmodules/react-native-kb/ios/Kb.mm:364-369` -- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (`ReadFromKBLib.run` empty-data branch) - -- [ ] **Step 1: Fix the iOS comment and make the degenerate case visible** - -```objc - if (data.length == 0) { - // Not the idle path: ReadArr blocks in LoopbackConn.Read until - // there is data, so an empty non-error result is degenerate (it - // needs n == 0 with no error, which a blocking read does not - // produce). It is reachable if Init never ran and the shared - // buffer is zero-length, which would otherwise spin silently. - kbLogToService(@"rpc read returned no data; is Keybase initialized?"); - [NSThread sleepForTimeInterval:0.01]; - continue; - } -``` - -- [ ] **Step 2: Fix the Android comment** - -```kotlin - if (data == null || data.isEmpty()) { - // Not the idle path: readArr blocks until there is - // data, so an empty non-error result is degenerate -- - // reachable if Init never ran and the shared buffer is - // zero-length, which would otherwise spin silently. - NativeLogger.warn("$NAME: read returned no data; is Keybase initialized?") - Thread.sleep(10) - continue - } -``` - -- [ ] **Step 3: Verify the Task 1 thread comment landed** - -Run: -```bash -grep -n 'main thread' rnmodules/react-native-kb/ios/Kb.mm -``` -Expected: no hit inside `invalidate`. If one remains, correct it to "TurboModule shared method queue — any thread". - -- [ ] **Step 4: Compile both platforms** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:compileDebugKotlin --offline -cd ../ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ - -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build -``` -Expected: both succeed. - -- [ ] **Step 5: Commit** - -```bash -git add rnmodules/react-native-kb/ -git commit -m "docs(rpc): correct the reader-loop idle comments - -ReadArr blocks in LoopbackConn.Read; it does not poll and return nil when -idle, so the sleep was justified by a rationale the code does not have. The -empty-data branch is actually a degenerate case reachable only if Init never -ran and the shared buffer is zero-length -- log it instead of spinning -silently." -``` - ---- - -### Task 17: Observability — make a field log-send show what happened - -**The gap.** On a desync, a dropped frame, a failed write, or a reader wedge there is currently no counter and, on Android, no uploadable log line at all: the C++ `onError` and desync messages go to `__android_log_print` (logcat only), while iOS routes through `kbLogToService`. JS write failures use `console.warn`, not `logger`. Every incident is a single line with no rate signal, and a reader wedge is entirely unobservable. - -**Files:** -- Modify: `rnmodules/react-native-kb/android/cpp-adapter.cpp` (route `onError` and the desync message through Kotlin) -- Modify: `rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt` (add the log sink) -- Modify: `shared/engine/rpc-transport.tsx` (use `logger`, not `console.warn`) - -- [ ] **Step 1: Add a Kotlin log sink callable from JNI** - -In `KbModule.kt`, next to `onRpcStreamFatal`: - -```kotlin - // Called from JNI. Routes native bridge errors into the uploadable log -- - // __android_log_print only reaches logcat, which a field log send does not - // include. - @DoNotStrip - fun onNativeLog(message: String) { - NativeLogger.error("$NAME: $message") - } -``` - -- [ ] **Step 2: Call it from the C++ error and fatal callbacks** - -In `KbNativeAdapter`, add: - -```cpp - void onLog(const std::string &message) { - jni::ThreadScope scope; - static auto method = - JKbModule::javaClassStatic() - ->getMethod)>("onNativeLog"); - method(jModule_, jni::make_jstring(message)); - } -``` - -and in `getBindingsInstaller`, replace the `onError` callback body and add the same to the fatal callback (both now capture `adapter` strongly, per Task 5): - -```cpp - [adapter](const std::string &err) { - __android_log_print(ANDROID_LOG_ERROR, "KBBridge", - "JSI error: %s", err.c_str()); - adapter->onLog("jsi error: " + err); - }, -``` - -```cpp - [adapter]() { - __android_log_print(ANDROID_LOG_ERROR, "KBBridge", - "rpc stream desync, resetting connection"); - adapter->onLog("rpc stream desync, resetting connection"); - adapter->onFatal(); - }); -``` - -- [ ] **Step 3: Route the JS write failure through `logger`** - -In `rpc-transport.tsx`, find the `console.warn` on the write-failure path and replace it with the module's `logger.error`, matching how the rest of the file logs. Run: - -```bash -cd shared && grep -n 'console.warn' engine/rpc-transport.tsx -``` -and convert each hit on an error path. - -- [ ] **Step 4: Compile and validate** - -Run: -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline -cd .. && yarn lint:all -``` -Expected: build succeeds; lint/bailouts/tsc clean. - -- [ ] **Step 5: Commit** - -```bash -git add rnmodules/react-native-kb/android/ shared/engine/rpc-transport.tsx -git commit -m "fix(rpc): route Android native bridge errors into the uploadable log - -The C++ onError and desync messages only reached logcat, which a field log -send does not include -- so the two failure modes this branch added detection -for were invisible in exactly the reports that need them. iOS already routed -through kbLogToService. Also move the JS write-failure warn onto logger so it -appears in a log send." -``` - ---- - -### Task 18: C++ — release the receive buffer's peak, and fix the size-limit off-by-header - -**Two small bugs.** - -`msgpack::unpacker` only rewinds in place (`msgpack/v1/unpack.hpp:1128-1136`); it never shrinks the `realloc`'d buffer. The send side handles this explicitly (`kSendBufKeepCapacity = 4MB`), the receive side has no equivalent, so one large attachment frame pins up to 64MB of native RSS for the rest of the session on a phone. - -Separately, the limit check compares `up.nonparsed_size() > kMaxFrameSize`, but `nonparsed_size()` includes the header bytes plus any bytes of the *following* frame, while the header check accepts a declared size of exactly `kMaxFrameSize`. A legal maximal frame arriving in 300KB chunks therefore trips the fatal path on its last chunk. - -**Files:** -- Modify: `rnmodules/react-native-kb/cpp/react-native-kb.cpp` (`onDataFromGo`) - -- [ ] **Step 1: Give the size limit headroom** - -Replace the limit check: - -```cpp - // nonparsed_size() includes the frame header and any bytes of the next - // frame already in the buffer, while the header check accepts a declared - // size of exactly kMaxFrameSize -- so compare with headroom or a legal - // maximal frame trips this on its last chunk. - if (up.nonparsed_size() > kMaxFrameSize + kMaxFrameSlack) { - throw std::runtime_error("rpc frame exceeds size limit"); - } -``` - -and define the slack next to `kMaxFrameSize`: - -```cpp -// Header bytes plus whatever of the following frame arrived in the same read. -static constexpr size_t kMaxFrameSlack = 1024 * 1024; -``` - -- [ ] **Step 2: Shrink the receive buffer at a safe resync point** - -After the drain loop, still under `recvMutex_`, add: - -```cpp - // msgpack::unpacker rewinds but never shrinks, so one large frame would - // pin its peak for the rest of the session. needSize with nothing - // unparsed is a provably safe point to start over: no partial frame and - // no state to lose. - if (recv_->state == ReadState::needSize && up.nonparsed_size() == 0 && - up.parsed_size() > kRecvBufKeepCapacity) { - resetRecvLocked(); - } -``` - -with, next to the other constants: - -```cpp -static constexpr size_t kRecvBufKeepCapacity = 4 * 1024 * 1024; -``` - -Read `resetRecvLocked()` before relying on it — confirm it constructs a fresh `unpacker` (which is what actually frees the buffer) rather than only resetting flags. If it does not, this task must add that. - -- [ ] **Step 3: Syntax check** - -Run the Task 0 Step 3 command. -Expected: exit 0. - -- [ ] **Step 4: Commit** - -```bash -git add rnmodules/react-native-kb/cpp/react-native-kb.cpp -git commit -m "fix(rpc): release the receive buffer peak and fix the size-limit margin - -msgpack::unpacker rewinds but never shrinks, so a single large attachment -frame pinned up to 64MB of native RSS for the session -- the send side -already caps its retained capacity. Start over at a needSize boundary with -nothing unparsed, which is a safe resync point. - -Also give the limit check headroom: nonparsed_size() includes the header and -any of the next frame already buffered, so a legal maximal frame tripped the -fatal path on its last chunk." -``` - ---- - -### Task 19: Go — return a copy from `ReadArr` - -**Rationale.** The permanent-reader design is correct and stays regardless. But `ReadArr` returning `buffer[0:n]` — a view of one 300KB package global (`keybase.go:348, 595, 639`) — makes "exactly one reader, forever" a *load-bearing* invariant enforced only by comments across three languages. gomobile already copies at the JNI/ObjC boundary, so returning a fresh slice costs one memcpy of a few KB on a path already doing a cross-language round trip, and converts a silent-corruption footgun into a non-issue. - -The architecture reviewer recommended this explicitly as the one cheap Go-side hardening worth taking. It is last because nothing else depends on it. - -**Files:** -- Modify: `go/bind/keybase.go:636-640` (`ReadArr` return) - -- [ ] **Step 1: Read the current return** - -Run: -```bash -sed -n '625,655p' go/bind/keybase.go -``` - -- [ ] **Step 2: Return a copy** - -```go - // Copy rather than returning a view of the shared buffer. gomobile copies - // at the language boundary anyway, so this costs one memcpy of a few KB on - // a call that is already crossing into ObjC/JNI -- and it means a second - // reader can no longer silently corrupt an in-flight delivery, instead of - // that being an invariant held up only by comments in three languages. - out := make([]byte, n) - copy(out, buffer[:n]) - return out, nil -``` - -Delete the now-stale "Returning a view of the shared buffer…" comment above it. - -- [ ] **Step 3: Build and vet** - -Run: -```bash -cd go && go build ./bind/... && go vet ./bind/... -``` -Expected: exit 0, no output. - -- [ ] **Step 4: Update the serial-access comment** - -The `ReadArr` doc comment still says it must be called serially. That remains true for the `conn.Read` ordering, but is no longer true for buffer aliasing. Update it to say exactly which part still requires serialization, so a future reader does not conclude the whole constraint was lifted. - -- [ ] **Step 5: Commit** - -```bash -git add go/bind/keybase.go -git commit -m "fix(bind): return a copy from ReadArr instead of a shared-buffer view - -ReadArr handed back a view of one package-global buffer, making 'exactly one -reader, forever' a load-bearing invariant enforced only by comments across -Go, ObjC and Kotlin. gomobile copies at the language boundary regardless, so -a fresh slice costs one memcpy of a few KB on a call already crossing into -JNI/ObjC, and a stray second reader can no longer corrupt an in-flight -delivery." -``` - ---- - -### Task 20: Harden the `rpcOnJs` batch dispatch edges - -**Two small TS bugs.** - -`index.platform.tsx` handles `count > 1 && Array.isArray(objs)`, but a non-array with `count > 1` now falls through to `dispatchOne(objs)` — one "Bad input packet" warning and `count - 1` messages vanish. Native never produces this (the C++ always builds an array when `size() > 1`), so it is defensive only, but it should be loud rather than silent. - -Separately, three call sites log the identical string `'>>>> rpcOnJs JS thrown!'` — including the unrelated renderer path — which makes a real log dump ambiguous about which guard fired. - -**Files:** -- Modify: `shared/engine/index.platform.tsx` (the `rpcOnJs` assignment and the three log sites) - -- [ ] **Step 1: Make the count/shape mismatch loud and give each guard a distinct message** - -```ts - const dispatchOne = (obj: unknown) => { - try { - client.transport.dispatchDecodedMessage(obj) - } catch (e) { - logger.error('rpcOnJs: dispatch threw', e) - } - } - - global.rpcOnJs = (objs: unknown, count: number) => { - try { - if (count > 1) { - if (!Array.isArray(objs)) { - // Native always sends an array when it batches, so this means the - // two sides disagree -- and count-1 messages would vanish silently. - logger.error(`rpcOnJs: count ${count} but payload is not an array`) - return - } - for (const obj of objs) { - dispatchOne(obj) - } - } else { - dispatchOne(objs) - } - } catch (e) { - logger.error('rpcOnJs: batch guard threw', e) - } - } -``` - -- [ ] **Step 2: Rename the unrelated renderer log site** - -Run: -```bash -cd shared && grep -n 'rpcOnJs JS thrown' engine/index.platform.tsx -``` -Any remaining hit is the renderer path — give it its own message describing what it actually guards. - -- [ ] **Step 3: Validate** - -Run: -```bash -cd shared && yarn lint:all -``` -Expected: clean, 0 bailouts. - -- [ ] **Step 4: Commit** - -```bash -git add shared/engine/index.platform.tsx -git commit -m "fix(engine): fail loudly when rpcOnJs count and payload disagree - -A non-array with count > 1 fell through to a single dispatch, discarding -count-1 messages with only a generic warning. Native always batches into an -array, so a mismatch means the two sides disagree and should say so. Also -give the three identical '>>>> rpcOnJs JS thrown!' sites distinct messages -- -they guard different things and a log dump could not tell them apart." -``` - ---- - -### Task 21: TS — surface failed writes on the response and renderer paths - -**Two remaining silent-drop paths, both the mirror image of the fix this branch already made for invokes.** - -`send()` now correctly returns `false` on a write failure, but `makeResponse` (`rpc-transport.tsx:493, 496`) **ignores the return**. If `KeybaseWriteArr` fails while answering an incoming service RPC, `rpcOnGo` returns false → `writeMessage` throws → `send` returns false → nobody notices, and the service side hangs on that call forever. The invoke direction is handled properly (`invokeNow`, callback fired exactly once with the error); the response direction is not. Two reviewers flagged this independently. - -Separately, `ProxyNativeTransport.writeMessage` (`index.platform.tsx:142-145`) does `engineSend?.(message)` — if `engineSend` is undefined it silently no-ops and `send()` returns `true`, leaving the RPC outstanding forever. Identical to the mobile bug this branch fixed, still live on the renderer. - -**Files:** -- Modify: `shared/engine/rpc-transport.tsx:488-500` (`makeResponse`) -- Modify: `shared/engine/index.platform.tsx:142-145` (`ProxyNativeTransport.writeMessage`) -- Test: `shared/engine/rpc-transport.test.ts` - -- [ ] **Step 1: Read `makeResponse`** - -Run: -```bash -cd shared && sed -n '485,505p' engine/rpc-transport.tsx -``` -Note both `send()` call sites (the result path and the error path) and the surrounding logger usage. - -- [ ] **Step 2: Write the failing test** - -```ts -test('a failed response write is reported, not swallowed', () => { - const transport = new TestTransport() - transport.failNextWriteForTest() - const errors: Array = [] - jest.spyOn(logger, 'error').mockImplementation((...args) => errors.push(args)) - - const response = transport.makeResponseForTest({seqid: 11}) - response.result({}) - - expect(errors).toHaveLength(1) -}) -``` - -Build `failNextWriteForTest` / `makeResponseForTest` against the real surface, matching the conventions already in the file. If `makeResponse` is not reachable from the test, drive it through `dispatchDecodedMessage` with an invoke frame (the helper from Task 12) and answer the resulting response. - -- [ ] **Step 3: Run it and watch it fail** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -t 'failed response write' -``` -Expected: FAIL — `errors` is empty, because the `false` return is discarded. - -- [ ] **Step 4: Report the failure in `makeResponse`** - -At both `send()` call sites: - -```ts - if (!this.send(...)) { - // The service is waiting on this reply and nothing else will tell it. - // A failed write here means the connection is gone, so the caller - // hangs forever unless this is visible. - logger.error(`failed to write response for seqid ${seqid}`) - } -``` - -Use the real argument shape at each site rather than the elision above — read them in Step 1. - -- [ ] **Step 5: Make the renderer's missing `engineSend` throw** - -```ts -class ProxyNativeTransport extends LocalTransport { - protected writeMessage(message: RPCMessage) { - const {engineSend} = KB2.functions - if (!engineSend) { - // Silently no-oping leaves the invocation outstanding forever. Throwing - // lets the transport fail it, same as the mobile path. - throw new Error('engineSend missing') - } - engineSend(message) - } -``` - -- [ ] **Step 6: Run to verify it passes** - -Run: -```bash -cd shared && yarn jest engine/rpc-transport.test.ts -``` -Expected: PASS - -- [ ] **Step 7: Validate and commit** - -```bash -cd shared && yarn lint:all -``` - -```bash -git add shared/engine/rpc-transport.tsx shared/engine/index.platform.tsx shared/engine/rpc-transport.test.ts -git commit -m "fix(engine): surface failed writes on the response and renderer paths - -makeResponse ignored send()'s new false return, so a write that failed while -answering an incoming service RPC left the service hanging forever -- the -mirror image of the invoke-direction hang this branch fixed. -ProxyNativeTransport.writeMessage had the same shape: a missing engineSend -no-oped and reported success, leaving the invocation outstanding." -``` - ---- - -## Final Verification - -- [ ] **Full TS validation** - -```bash -cd shared && yarn lint:all && yarn jest engine/ -``` -Expected: lint clean, 0 bailouts, tsc clean on both configs, all engine tests pass. - -- [ ] **Full native builds** - -```bash -./plans/scripts/sync-native-kb.sh -cd shared/android && ./gradlew :react-native-kb:externalNativeBuildDebug :react-native-kb:compileDebugKotlin --offline -cd ../ios && xcodebuild -workspace Keybase.xcworkspace -scheme react-native-kb \ - -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build -cd ../../go && go build ./bind/... && go vet ./bind/... -``` -Expected: all succeed. - -- [ ] **Confirm no stray edits to the node_modules copy** - -```bash -git status --short -``` -Expected: only tracked files under `rnmodules/`, `shared/engine/`, `go/bind/`, `plans/`. `shared/node_modules/` must not appear. - -- [ ] **Manual smoke (user drives — never drive the simulator or device yourself)** - -Ask the user to verify on each platform: cold start reaches the inbox; send and receive a message; background and foreground the app; on Android, back-button to home then relaunch (Task 2's case); switch accounts (Task 10's case). Report exactly what they confirm — do not claim a platform is verified without their word. - ---- - -## Deferred — needs a decision, not in scope here - -These came out of review but should not be actioned without the user choosing: - -1. **Dead `engineReset` TurboModule method** (Task 10 Step 5) — delete it across the spec plus both platform implementations, or wire the account-switch path to it. Spans the codegen'd spec; wider blast radius than the rest of this plan. -2. **No backpressure from the reader to JS** — `invokeAsync` is unbounded, so a stalled JS thread lets the reader queue batches that each retain a msgpack zone. Pre-existing, but the permanent reader makes it permanent. Needs a bounded-queue design decision. -3. **Second `ReactHost` / multi-instance** — one `g_bridge`, one `instance`, one Go `conn`; a second host starves silently. Not a regression and single-host may be a hard invariant, but nothing documents or asserts it. Either add an assert or a header comment saying so. -4. **iOS reader thread QoS** — the reader inherits the QoS of whatever submitted it (`_sharedModuleQueue`) and freezes it for the process lifetime, permanently consuming one libdispatch worker. A dedicated `NSThread` with an explicit `qualityOfService` is the canonical shape for a permanent blocking loop. -5. **`packNumber` above 2^53** — emits uint64/int64 where `@msgpack/msgpack` emits float64, so a double of 2^60 encodes as a different msgpack type than the desktop path. Harmless for current RPC shapes; the comment claiming equivalence overstates it. Also `-0` packs as uint `0`. -6. **`kbTeardown` is a plain writable global** — any JS doing `globalThis.kbTeardown = undefined` makes it collectible, and the finalizer then runs `teardown()` on a *live* runtime, permanently setting `isTornDown_` with no recovery short of a reload. Define it non-writable/non-configurable. -7. **Assorted C++ LOWs not worth their own task**, but real: `resetCaches` compares runtimes by pointer, so a new `Runtime` allocated at a freed one's address would keep stale handles (unreachable today with one bridge per runtime, but that function exists for the multi-runtime case — a generation counter is safer); `arrayBuf.data(runtime) + offset` is `nullptr + 0` for a detached ArrayBuffer, which is UB per the standard even though it is benign in practice; `callInvoker_` is dereferenced unguarded while `writeToGo_` and `recv_` are both null-checked; and `install()` is unsynchronized and its "call exactly once, before publication" requirement is enforced only by how the platform layers happen to call it. Bundle these into one cleanup pass if desired. -8. **Android copies per RPC** — three on the outbound path (`SetByteArrayRegion`, Go's load-bearing `make`/`copy`, the loopback) and an avoidable one inbound (`pin()` uses `GetByteArrayElements` and releases with mode 0, copying unmodified data back). `GetByteArrayRegion` straight into `up.buffer()` would drop one round trip. Perf only, and this is a perf branch, so worth considering — but it touches the hot path and deserves measurement first. -9. **`~KBBridge` can run on any thread** (C++ F2) — the destructor is `= default` and destroys jsi handles; the safety argument rests entirely on `KBTearDownSimple` having already nulled them on the JS thread, which nothing enforces. Either pin the bridge's lifetime to the host object with a strong `shared_ptr`, or add a debug assert so it regresses loudly. Deferred because the strong-ref option changes ownership across both platforms and deserves its own review. diff --git a/plans/scripts/sync-native-kb.sh b/rnmodules/react-native-kb/scripts/sync-native-kb.sh similarity index 90% rename from plans/scripts/sync-native-kb.sh rename to rnmodules/react-native-kb/scripts/sync-native-kb.sh index 37ffa13e62b4..34ca17129627 100755 --- a/plans/scripts/sync-native-kb.sh +++ b/rnmodules/react-native-kb/scripts/sync-native-kb.sh @@ -3,7 +3,7 @@ # expo autolinking points gradle/pods at it. Native edits under rnmodules/ # are invisible to a build until they are copied across. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" SRC="$ROOT/rnmodules/react-native-kb" DST="$ROOT/shared/node_modules/react-native-kb" test -d "$DST" || { echo "missing $DST — run yarn in shared/ first" >&2; exit 1; } diff --git a/plans/scripts/test-native-kb-framing.sh b/rnmodules/react-native-kb/scripts/test-framing.sh similarity index 93% rename from plans/scripts/test-native-kb-framing.sh rename to rnmodules/react-native-kb/scripts/test-framing.sh index 6442a67c2bd3..a261158333e6 100755 --- a/plans/scripts/test-native-kb-framing.sh +++ b/rnmodules/react-native-kb/scripts/test-framing.sh @@ -5,7 +5,7 @@ # this only needs a plain msgpack-cxx include path, the same one used for the # react-native-kb.cpp syntax-only clang++ check. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" CPP_DIR="$ROOT/rnmodules/react-native-kb/cpp" MSGPACK_INCLUDE="$ROOT/shared/node_modules/msgpack-cxx-7.0.0/include" test -d "$MSGPACK_INCLUDE" || { From 20af0db392681452d36dd664e3c8669146d0c2f6 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 13:14:58 -0400 Subject: [PATCH 76/80] revert(engine): drop the mobile transport reset from Engine.reset 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. --- shared/engine/index.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/shared/engine/index.tsx b/shared/engine/index.tsx index 439662946241..1f3d89edc41f 100644 --- a/shared/engine/index.tsx +++ b/shared/engine/index.tsx @@ -303,11 +303,6 @@ class Engine { // Reset the engine reset() { - // Mobile has no socket to tear down and no reconnect to wait for, but the - // in-flight invocations still have to be failed: after an account switch - // nothing will answer them, and their callbacks would otherwise fire - // against post-switch state. - this._rpcClient.transport.reset() if (isMobile) { return } From 3d41f275d99c47147a14a2d008fbc6a0d8499ad5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 13:33:39 -0400 Subject: [PATCH 77/80] test(android): guard nativeInvalidate() wiring against the back-button wedge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 15647bb4d4. Parses actual function bodies via brace matching so it survives reformatting rather than a naive substring scan. --- shared/engine/kb-module-lifecycle.test.ts | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 shared/engine/kb-module-lifecycle.test.ts diff --git a/shared/engine/kb-module-lifecycle.test.ts b/shared/engine/kb-module-lifecycle.test.ts new file mode 100644 index 000000000000..5874334e4ecb --- /dev/null +++ b/shared/engine/kb-module-lifecycle.test.ts @@ -0,0 +1,100 @@ +// Static guard for the Android "back button wedge" (HOTPOT-rpc-fixes, +// fixed in 15647bb4d4). On Android, onHostDestroy fires when the LAST +// ACTIVITY is destroyed while the ReactInstance/TurboModules survive +// (MainApplication holds the ReactHost in an application-scoped `by lazy`). +// nativeInvalidate() nulls the C++ g_bridge, which only the bindings +// installer repopulates -- and a surviving ReactInstance never re-runs +// that installer. If nativeInvalidate() is reachable from destroy()/ +// onHostDestroy() instead of from invalidate() (real TurboModule teardown, +// e.g. reload), every inbound RPC is silently dropped for the life of the +// process after pressing back to home and reopening the app. This was +// shipped past two per-task reviews; only a whole-branch review caught it. +// This test parses actual function bodies (brace-matched, not a whole-file +// substring search) so it survives reformatting but still catches the call +// moving to the wrong function. +import * as fs from 'fs' +import * as path from 'path' + +const kbModulePath = path.join( + __dirname, + '..', + '..', + 'rnmodules', + 'react-native-kb', + 'android', + 'src', + 'main', + 'java', + 'com', + 'reactnativekb', + 'KbModule.kt' +) + +// Extracts the body of a Kotlin function whose signature matches `namePattern`, +// via brace-depth counting from the first `{` after the signature -- not a +// line-based or whole-file match, so it scopes strictly to that one function +// even if other functions elsewhere also mention nativeInvalidate(). +function extractFunctionBody(source: string, namePattern: RegExp): string { + const sigMatch = namePattern.exec(source) + if (!sigMatch) { + throw new Error(`KbModule.kt: could not find a function matching ${namePattern}`) + } + const openBraceIdx = source.indexOf('{', sigMatch.index) + if (openBraceIdx === -1) { + throw new Error(`KbModule.kt: found signature for ${namePattern} but no function body`) + } + let depth = 0 + for (let i = openBraceIdx; i < source.length; i++) { + if (source[i] === '{') depth++ + else if (source[i] === '}') { + depth-- + if (depth === 0) return source.slice(openBraceIdx + 1, i) + } + } + throw new Error(`KbModule.kt: unterminated function body for ${namePattern}`) +} + +// Matches a real call `nativeInvalidate()`, not the `external fun +// nativeInvalidate()` declaration (which has no body / no call parens +// preceded by whitespace-only invocation context) -- both the declaration +// and a call end in the same text `nativeInvalidate()`, but only the +// declaration is preceded by `fun`. Since we already scope to a specific +// function body (which never contains the `external fun` declaration line), +// a plain substring check is safe here. +const CALLS_NATIVE_INVALIDATE = /\bnativeInvalidate\s*\(\s*\)/ + +describe('KbModule Android lifecycle wiring (back-button wedge guard)', () => { + const source = fs.readFileSync(kbModulePath, 'utf8') + + it('calls nativeInvalidate() from invalidate() (real TurboModule teardown)', () => { + const body = extractFunctionBody(source, /override\s+fun\s+invalidate\s*\(\s*\)/) + expect(CALLS_NATIVE_INVALIDATE.test(body)).toBe(true) + }) + + it('does NOT call nativeInvalidate() from destroy() (Activity death, not instance teardown)', () => { + const body = extractFunctionBody(source, /(? { + const body = extractFunctionBody(source, /override\s+fun\s+onHostDestroy\s*\(\s*\)/) + if (CALLS_NATIVE_INVALIDATE.test(body)) { + throw new Error( + 'onHostDestroy() calls nativeInvalidate() directly. onHostDestroy fires on Activity ' + + 'death, not ReactInstance teardown -- see the destroy() test above for the wedge this ' + + 'causes. nativeInvalidate() belongs only in invalidate().' + ) + } + }) +}) From e224fd5e15bdc185f818d1f96002b0bc9602cae5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 13:35:48 -0400 Subject: [PATCH 78/80] test(e2e): cover the Android back-button RPC wedge (Activity restart) 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. --- shared/tests/e2e/ios-appium/all.test.ts | 1 + .../flows/android-activity-restart.test.ts | 129 ++++++++++++++++++ .../tests/e2e/ios-appium/wdio.android.conf.ts | 5 +- 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 shared/tests/e2e/ios-appium/flows/android-activity-restart.test.ts diff --git a/shared/tests/e2e/ios-appium/all.test.ts b/shared/tests/e2e/ios-appium/all.test.ts index c4cd6631ab00..8b895b9bcd8a 100644 --- a/shared/tests/e2e/ios-appium/all.test.ts +++ b/shared/tests/e2e/ios-appium/all.test.ts @@ -2,6 +2,7 @@ // every flow here (side-effect imports register their describe/it blocks) runs // the whole suite in ONE session — no per-file session teardown/recreate (~800ms // each) and the app stays warm between flows. +import './flows/android-activity-restart.test' import './flows/chat-conversation.test' import './flows/chat-send-message.test' import './flows/crypto-outputs.test' diff --git a/shared/tests/e2e/ios-appium/flows/android-activity-restart.test.ts b/shared/tests/e2e/ios-appium/flows/android-activity-restart.test.ts new file mode 100644 index 000000000000..3bc4cb44cae8 --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/android-activity-restart.test.ts @@ -0,0 +1,129 @@ +import {expect} from '@wdio/globals' +import {requireSmokeUser} from '../helpers/app' +import {escapeToTabs, navigateToChat} from '../helpers/navigate' +import {anyExist, byText, el, els, waitForTestID, enterText} from '../helpers/elements' +import * as T from '../../shared/test-ids' + +// Regression coverage for the Android "back-button wedge" (fixed in +// 15647bb4d4, HOTPOT-rpc-fixes). onHostDestroy fires when the LAST ACTIVITY +// is destroyed while the ReactInstance/TurboModules SURVIVE (MainApplication +// holds the ReactHost in an application-scoped `by lazy`) — pressing back +// from the root exits to the launcher this way, without killing the process. +// The bug: nativeInvalidate() was reachable from that path and nulled the +// C++ g_bridge, which only the JSI bindings installer repopulates — and a +// surviving ReactInstance never re-runs that installer. Outbound RPC still +// "works" (writeArr doesn't check g_bridge), so every inbound reply is +// silently dropped for the life of the process; the app looks alive and +// never gets a response. Reopening the app (without a process restart) must +// still be able to complete a full RPC round trip. +const ANDROID_PACKAGE = 'io.keybase.ossifrage' + +describe('android activity restart (back-button RPC wedge)', () => { + it('keeps inbound RPC alive after the Activity (not the process) is destroyed and reopened', async () => { + if (!browser.isAndroid) return + + requireSmokeUser() + await escapeToTabs() + await navigateToChat() + + // Baseline: inbound RPC works before we touch the Activity lifecycle at + // all. If there are no conversations to prove this with, the rest of the + // test can't prove anything either. + if (!(await anyExist(T.CHAT_INBOX_ROW))) { + throw new Error('android-activity-restart: no chat conversations to establish an RPC baseline with') + } + + // Capture the process PID BEFORE destroying the Activity. `mobile: shell` + // needs the appium service started with relaxedSecurity (wdio.android.conf.ts). + const pidBefore = await getAppPid() + if (!pidBefore) { + throw new Error( + 'android-activity-restart: could not read a PID via `mobile: shell pidof` — cannot prove ' + + 'process continuity, which is the entire point of this test. Refusing to continue rather ' + + 'than risk a false green (see comment above getAppPid).' + ) + } + + // Destroy the Activity WITHOUT killing the process: press back all the + // way out to the launcher. Do NOT use terminateApp/closeApp — killing the + // process resets init{} and the bindings installer, which would make the + // wedge impossible to reproduce and this test a false green. + for (let i = 0; i < 12; i++) { + const pkg = await browser.getCurrentPackage().catch(() => '') + if (pkg !== ANDROID_PACKAGE) break + await browser.back() + await browser.pause(500) + } + const pkgAfterBack = await browser.getCurrentPackage().catch(() => '') + if (pkgAfterBack === ANDROID_PACKAGE) { + throw new Error('android-activity-restart: never left the app after 12 back presses') + } + + // Give onHostDestroy a moment to actually run before reopening. + await browser.pause(1000) + + // Reopen via activateApp, which brings the existing process's task back to + // the foreground and creates a NEW Activity — it does not relaunch the + // process. (This mirrors escapeToTabs' own recovery path.) + await browser.activateApp(ANDROID_PACKAGE) + await browser.waitUntil(async () => (await browser.getCurrentPackage().catch(() => '')) === ANDROID_PACKAGE, { + timeout: 10000, + interval: 250, + }) + + // The critical guard: if the PID changed, Android killed the process + // during step 3 (low memory, aggressive OEM task killer, etc). That means + // activateApp started a FRESH process — init{} reran, the bridge was + // reinstalled, and any pass below would prove nothing about the wedge. + // Fail loudly rather than silently pass on a meaningless run. + const pidAfter = await getAppPid() + if (pidAfter !== pidBefore) { + throw new Error( + `android-activity-restart: process PID changed (${pidBefore} -> ${pidAfter}) — Android killed ` + + 'the process instead of just the Activity, so this run cannot exercise the back-button wedge ' + + '(a fresh process reinstalls the native bridge via init{}, masking the bug). Not a real failure ' + + 'of the fix; the environment did not hold up its end. Re-run on a device/emulator with more ' + + 'headroom, or investigate why the process was killed.' + ) + } + + await escapeToTabs() + await navigateToChat() + await waitForTestID(T.CHAT_INBOX_ROW, 5000) + await els(T.CHAT_INBOX_ROW)[0]!.click() + await waitForTestID(T.CHAT_MESSAGE_LIST, 5000) + + // Strongest available proof of a live inbound RPC path: a full round trip. + const testMessage = `e2e-restart-${Date.now()}` + await waitForTestID(T.CHAT_INPUT, 5000) + await enterText(T.CHAT_INPUT, testMessage) + await waitForTestID(T.CHAT_SEND_BUTTON, 3000) + await el(T.CHAT_SEND_BUTTON).click() + + const sent = byText(testMessage) + await sent.waitForDisplayed({ + timeout: 5000, + timeoutMsg: `sent message "${testMessage}" never appeared after Activity restart — inbound RPC is likely wedged`, + }) + await expect(sent).toBeDisplayed() + }) +}) + +// `pidof` via `mobile: shell` is the process-continuity signal: it requires +// the appium service to run with relaxedSecurity (see wdio.android.conf.ts). +// Returns undefined if the command isn't available or the app isn't running, +// in which case the caller must refuse to proceed rather than assume +// continuity it can't actually prove. +async function getAppPid(): Promise { + try { + const result = (await browser.execute('mobile: shell', { + command: 'pidof', + args: [ANDROID_PACKAGE], + })) as string | {stdout?: string} | undefined + const raw = typeof result === 'string' ? result : (result?.stdout ?? '') + const pid = raw.trim().split(/\s+/)[0] + return pid || undefined + } catch { + return undefined + } +} diff --git a/shared/tests/e2e/ios-appium/wdio.android.conf.ts b/shared/tests/e2e/ios-appium/wdio.android.conf.ts index 394504e37484..5f87472dd386 100644 --- a/shared/tests/e2e/ios-appium/wdio.android.conf.ts +++ b/shared/tests/e2e/ios-appium/wdio.android.conf.ts @@ -32,7 +32,10 @@ export const config: WebdriverIO.Config = { // nav/list flake without masking real failures. Retries run ONLY on failure. mochaOpts: {ui: 'bdd', timeout: 120000, retries: 2}, reporters: ['spec'], - services: [['appium', {args: {basePath: '/', port}}]], + // relaxedSecurity: android-activity-restart.test.ts uses `mobile: shell` + // (pidof) to prove the app process survived an Activity restart — Appium + // rejects that execute-driver-script command without it. + services: [['appium', {args: {basePath: '/', port, relaxedSecurity: true}}]], // The app restores its last screen on launch and screens leak between specs, // so reset to the root tab bar before each test by climbing out of any stack. beforeTest: async test => { From 400ec390332a4440c363b938a1b7fc6c043d4cbc Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 13:38:12 -0400 Subject: [PATCH 79/80] test(e2e): make appium relaxedSecurity opt-in for the activity-restart 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. --- .../flows/android-activity-restart.test.ts | 12 ++++++++++++ shared/tests/e2e/ios-appium/wdio.android.conf.ts | 15 +++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/shared/tests/e2e/ios-appium/flows/android-activity-restart.test.ts b/shared/tests/e2e/ios-appium/flows/android-activity-restart.test.ts index 3bc4cb44cae8..8ee19cdd04f5 100644 --- a/shared/tests/e2e/ios-appium/flows/android-activity-restart.test.ts +++ b/shared/tests/e2e/ios-appium/flows/android-activity-restart.test.ts @@ -22,6 +22,18 @@ describe('android activity restart (back-button RPC wedge)', () => { it('keeps inbound RPC alive after the Activity (not the process) is destroyed and reopened', async () => { if (!browser.isAndroid) return + // Proving the process survived needs `mobile: shell`, which needs the + // appium service running with relaxedSecurity. That widens what the local + // appium server will execute, so it is opt-in. Without it this test cannot + // tell a surviving process from a fresh one, and a fresh one reinstalls the + // bridge and passes while proving nothing — so skip instead of pretending. + if (!process.env['KB_E2E_RELAXED_SECURITY']) { + console.warn( + 'android-activity-restart: SKIPPED — set KB_E2E_RELAXED_SECURITY=1 to run it (needs appium relaxedSecurity for pidof)' + ) + return + } + requireSmokeUser() await escapeToTabs() await navigateToChat() diff --git a/shared/tests/e2e/ios-appium/wdio.android.conf.ts b/shared/tests/e2e/ios-appium/wdio.android.conf.ts index 5f87472dd386..ae9980a1862e 100644 --- a/shared/tests/e2e/ios-appium/wdio.android.conf.ts +++ b/shared/tests/e2e/ios-appium/wdio.android.conf.ts @@ -32,10 +32,17 @@ export const config: WebdriverIO.Config = { // nav/list flake without masking real failures. Retries run ONLY on failure. mochaOpts: {ui: 'bdd', timeout: 120000, retries: 2}, reporters: ['spec'], - // relaxedSecurity: android-activity-restart.test.ts uses `mobile: shell` - // (pidof) to prove the app process survived an Activity restart — Appium - // rejects that execute-driver-script command without it. - services: [['appium', {args: {basePath: '/', port, relaxedSecurity: true}}]], + // relaxedSecurity lets Appium run privileged commands such as `mobile: shell`. + // android-activity-restart.test.ts needs it (pidof) to prove the app process + // survived an Activity restart, but it applies server-wide, so it stays + // opt-in: set KB_E2E_RELAXED_SECURITY=1 for that run. Without it that one + // test skips itself rather than reporting a result it cannot substantiate. + services: [ + [ + 'appium', + {args: {basePath: '/', port, ...(process.env['KB_E2E_RELAXED_SECURITY'] ? {relaxedSecurity: true} : {})}}, + ], + ], // The app restores its last screen on launch and screens leak between specs, // so reset to the root tab bar before each test by climbing out of any stack. beforeTest: async test => { From 9a8229de6cc5c4b919268b2699dd5f66de635737 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 27 Jul 2026 14:01:09 -0400 Subject: [PATCH 80/80] docs(podspec): fix stale path to framing test script --- rnmodules/react-native-kb/react-native-kb.podspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rnmodules/react-native-kb/react-native-kb.podspec b/rnmodules/react-native-kb/react-native-kb.podspec index d122ea4ee3db..a0c3981f7dce 100644 --- a/rnmodules/react-native-kb/react-native-kb.podspec +++ b/rnmodules/react-native-kb/react-native-kb.podspec @@ -21,7 +21,7 @@ Pod::Spec.new do |s| "cpp/*.{h,cpp}" ] # cpp/tests is a standalone plain-clang++ test binary (see - # plans/scripts/test-native-kb-framing.sh), not part of the shipped module. + # rnmodules/react-native-kb/scripts/test-framing.sh), not part of the shipped module. s.exclude_files = "cpp/tests/**/*" s.dependency "KBCommon"