How the embedded backend is structured, how the React Native layer talks to it, and the alternatives we considered along the way. Companion docs:
ForegroundService.md— Android FGS specifics (timeout policy, user-stop handling,dataSyncservice type).root-key-storage-and-migration-plan.md— design rationale for the Kotlin/Swift-native rootkey stores.bare-architecture.md,runtime-alternatives.md— why we are onnodejs-mobileand what we'd migrate to instead.
- iOS runs everything in one process. Android runs the embedded Node.js
in a separate
:ComapeoCoreprocess behind a foreground service so it survives backgrounding. - The React Native layer talks to Node over two Unix-domain sockets:
comapeo.sock(RPC) andcontrol.sock(lifecycle + handshake). - Boot is gated on a rootkey handshake on the control socket: backend
binds → broadcasts
started→ native sends the rootkey in aninitframe → backend constructsMapeoManager→ broadcastsready. - The JS layer observes a single
ComapeoState(STOPPED/STARTING/STARTED/STOPPING/ERROR) plus structuredgetLastError()detail. The native ↔ JS plumbing differs by platform (in-process callback on iOS; second control-socket connection on Android) but the JS surface is identical. - We deliberately did not adopt
expo-secure-storefor rootkey storage, did not use Android Intent broadcasts /Messenger/ a bound service for FGS↔main state notification, and did not collapse the boot sequence into a single broadcast. Rationales below. - Optional Sentry observability is wired up via an Expo config
plugin (
app.plugin.js) and the@comapeo/core-react-native/sentrysub-export. The FGS process gets its own Sentry SDK init via a guarded bridge (zero runtime cost when the consumer doesn't use Sentry). See §7.
The Node.js runtime runs on a background Thread inside the host app
process, started by NodeJSService.runNode() (ios/NodeJSService.swift).
The thread is owned by AppLifecycleDelegate.nodeService, an
ExpoAppDelegateSubscriber singleton constructed during
application(_:didFinishLaunchingWithOptions:).
There is no second process. The control socket and the comapeo socket
are AF_UNIX sockets in the host app's Application Support directory;
both ends of every socket-related interaction live inside the same
.app bundle.
The FGS is declared with android:process=":ComapeoCore" in
android/src/main/AndroidManifest.xml. The :-prefix is Android's
syntax for a private process; the FGS gets its own zygote-forked pid,
its own Application instance, its own nodejs-mobile JNI loader, and
its own AndroidKeyStore client. The main React Native UI process
(com.comapeo.core.integration in the example app) is separate.
Why a separate process? Three reasons:
- Survival across UI restarts. React Native reloads, hot-restarts, and developer-menu actions kill the main process or its JS bridge. The FGS process keeps running, so a debug rebuild doesn't tear down the embedded sync state.
- Memory isolation.
nodejs-mobilecarries its own V8 heap plusbetter-sqlite3mmap regions plussodium-nativebuffers. Keeping that out of the RN process means the OS can kill RN under memory pressure without taking the backend with it (and vice versa). - Foreground service grammar. The
dataSyncFGS type (see ForegroundService.md) requires a long-lived service component; running it in the UI process would mean any RN-process death stops the sync notification too.
The cost is cross-process IPC: the UI process has to talk to the FGS process via the OS, not via shared memory. The two Unix-domain sockets described in §3 are how.
| Concern | iOS | Android |
|---|---|---|
| Service lifecycle | ios/NodeJSService.swift |
android/src/main/java/com/comapeo/core/NodeJSService.kt (FGS-side) |
| Service container | n/a | ComapeoCoreService.kt (FGS) |
| Expo module / RN bridge | ios/ComapeoCoreModule.swift |
android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt |
| App-level wiring | ios/AppLifecycleDelegate.swift |
example-app's MainApplication.kt |
| Rootkey store | ios/RootKeyStore.swift |
RootKeyStore.kt |
| Backend (Node-side) | backend/index.js (shared) |
backend/index.js (shared) |
| RN-side observers | src/ComapeoCoreModule.ts (shared) |
src/ComapeoCoreModule.ts (shared) |
Both sockets are AF_UNIX, length-prefixed JSON framing, bound by Node.js inside the backend process. Native code connects as a client. The control socket is also a write channel from native to Node for the init / shutdown / error-native frames listed in the table in §3.1.
Carries @comapeo/ipc request/response traffic. Bound by Node.js
(ComapeoRpcServer.listen(comapeoSocketPath)) only AFTER MapeoManager
has been constructed. The main app process connects and uses
createMapeoClient(messagePort) to expose a typed RPC client to JS
(src/ComapeoCoreModule.ts's comapeo export).
A late connection (i.e. the React Native module connects after Node has
already bound this socket) is the steady state: the backend always binds
this socket some hundreds of milliseconds after process start. The
NodeJSIPC.connectWithRetry() 50 ms-cadence retry loop handles the gap.
Carries small JSON frames that drive the boot handshake and surface
error/lifecycle transitions. Bound by Node.js
(SimpleRpcServer.listen(controlSocketPath)) BEFORE the comapeo socket,
because the backend uses this channel to receive the rootkey from native
before it can construct MapeoManager. Frames in current use:
| Direction | Frame | When |
|---|---|---|
| Node → native | {type:"started"} |
Control socket bound, backend awaiting init. |
| Native → Node | {type:"init",rootKey:"<base64>",masterKey?:"<base64>"} |
Native ships the rootkey from RootKeyStore (single-shot), plus the cached master key when it has one. |
| Node → native | {type:"master-key",masterKey:"<base64>"} |
Backend derived the master key this boot (the init frame carried none it could use) and hands it back for native to cache. Sent only to the connection that shipped the init frame, never broadcast. |
| Node → native | {type:"ready"} |
MapeoManager constructed, comapeo socket bound. |
| Node → native | {type:"stopping"} |
Backend has begun graceful shutdown (sent before any close work). |
| Node → native | {type:"error",phase,message,stack?} |
Boot failure or uncaught throw at any phase. |
| Native → Node | {type:"shutdown"} |
Native requests graceful shutdown. |
| Native → Node | {type:"error-native",phase,message} |
FGS-side local failure (rootkey, watchdog) — backend re-broadcasts as an error frame and exits. |
The init frame's two keys. rootKey is the device identity and is
required: a missing or malformed one is fatal (the backend rejects, reports
the phase, and exits). masterKey is the 32-byte key the rootkey derives to
— an Argon2id derivation costing seconds on a low-end device, so native
caches it (docs/master-key-cache-plan.md) and puts it on the frame when it
has one. It is validated just as strictly (backend/lib/parse-init.js) but a
malformed value is only logged, reported as a Sentry warning + metric, and
dropped. A cache can always be rebuilt; it must never fail a boot.
Who derives, and the round trip. Only the backend derives, inside
KeyManager under a boot.master-key-derive span, whenever the init frame
carried no usable masterKey — the first boot after install, and any boot
after the cache was dropped. It then sends the key back on the master-key
frame and native stores it against the rootkey's fingerprint, so the next
boot's init frame carries it and no derivation runs. Every failure in that
loop (frame lost, port closed, store write failed, cached value rejected)
costs one derivation on the next boot and self-heals, because the reply is
sent on exactly the boots where the cache was not usable. The reply is
targeted at the connection that sent init rather than broadcast: on
Android the main app process holds a second, read-only control connection
that must never see the key.
Replay semantics. SimpleRpcServer (backend/lib/simple-rpc.js)
remembers its last readiness phase and replays started and ready to
any client that connects after they were broadcast. Without replay a
late-connecting client (the React Native module on Android races the
FGS's IPC client; both connect to the same socket, see §4) would miss
the events that already fired.
Terminal lifecycle frames — stopping and error, both sent via
broadcast() — are also cached and replayed, but only the latest
one. The window between either frame and the natural socket close is
non-zero (~100 ms for error before process.exit(1); the duration
of Promise.all([close…]) for stopping), and a client that connects
in that window would otherwise have to infer the terminal state from
the disconnect alone. That inference is lossy in two ways: a graceful
stopping-then-close looks identical to an unexpected crash to a
client that missed the frame (STARTING/STARTED → ERROR per §5.4), and
an error frame's phase and message are replaced by a synthetic
node-runtime-unexpected. Caching the latest terminal frame closes
both gaps with a single object reference of overhead.
Three signals matter:
- The application RPC needs ordered request/response framing with no
interleaved lifecycle noise. Mixing
{type:"ready"}into the same stream asMapeoManager.something()calls would force every RPC client to filter unwanted frames. - The control socket has different bind timing from the RPC socket: it binds first (so native can hand over the rootkey before the manager is constructed). Folding them would push the manager construction into the bind path or push the rootkey handshake into the RPC path, both worse.
- The control socket's multi-client + replay semantics (§3.1) only make sense for lifecycle frames. The RPC socket is a classic 1:1 channel.
Splitting cost: ~80 LOC in simple-rpc.js for the second server class
and one extra NodeJSIPC instance on each platform.
Both sockets use the same wire format: 4-byte little-endian length prefix, then UTF-8 JSON payload. Implementations:
- iOS:
ios/NodeJSIPC.swift::receiveMessage,writeFullyfor the matching writer. - Android:
android/src/main/java/com/comapeo/core/NodeJSIPC.kt::receiveMessage. - Node:
backend/lib/message-port.js.
There is no message-id or correlation field at this layer; that lives
inside @comapeo/ipc for RPC traffic, and isn't needed for control
frames (each type is its own protocol).
Node.js backend Native (FGS or iOS)
┌─────────────┐ ┌──────────────────┐
│ │ │ │
bind │ │ │ │
control.sock │ │ │ │
─────────────┤ │ │ │
│ │ │ │
│ │ │ connect via │
│ │ ◄───────────────── │ NodeJSIPC │
│ │ │ (50 ms retry │
│ │ │ loop) │
│ │ │ │
│ │ │ │
broadcast │ │ ──{started}──► │ │
started │ │ │ │
│ │ │ │
│ │ │ loadOrInitialize│
│ │ │ (RootKeyStore) │
│ │ │ │
│ │ ◄──{init,rootKey}── │ │
construct │ │ │ │
MapeoManager │ │ │ │
bind │ │ │ │
comapeo.sock │ │ │ │
│ │ │ │
broadcast │ │ ──{ready}─────────► │ transition │
ready │ │ │ to STARTED │
│ │ │ │
│ │ │ RN module │
│ │ │ connects to │
│ │ │ comapeo.sock │
Pre-rootkey: the backend cannot construct MapeoManager (the rootkey is
a constructor argument). Pre-ready: the native side cannot guarantee
RPC will succeed. The handshake exists to make both invariants
observable rather than racy.
Native owns the rootkey because it has to: the FGS on Android starts Node BEFORE React Native is alive (the FGS may be cold-launched by the system to deliver a sync trigger), so we cannot block on a JS round-trip to fetch the key. Native loads it synchronously from the platform store and ships it on the control socket as the first thing the backend hears.
iOS doesn't have the FGS-cold-launch problem (see §2.1) but uses the same handshake for symmetry — the alternative would be a per-platform boot path with an extra Swift codepath that constructs the manager in-process. Not worth the divergence.
On Android the React Native module ALSO connects to control.sock (as a
read-only observer; only the FGS-side NodeJSService.kt sends init
and shutdown frames). This second connection is the channel by which
state transitions reach the JS layer in the main app process — see §5.
The replay semantics in SimpleRpcServer.setReadinessPhase are what
makes this late connection work: by the time the RN module's
ComapeoCoreModule is loaded, the backend has often already broadcast
both started and ready. Without replay, the module would receive
nothing and would be stuck at STOPPED until the next transition.
The main app process needs lifecycle visibility into a state machine
that physically lives in another process. Alternatives are surveyed in
§7.1; the short version is that reusing the NodeJSIPC client class
(which already exists for comapeo.sock) avoids ~100 LOC of new Android
service plumbing and keeps Node as the single source of truth for
lifecycle state — matching iOS where the equivalent is in-process and
trivial.
NodeJSService.State (both platforms) is a derived value with five
possibilities — STOPPED, STARTING, STARTED, STOPPING, ERROR
— computed by a pure function of three independently-stateful
components:
NodeRuntime ∈ { NotRunning, Running, Exited(code, reason) }
reason ∈ { Requested, Unexpected }
BackendState ∈ { Unknown, ControlBound, Ready, Stopping,
Error{phase, message} }
(sourced from control-socket frames)
stopRequested ∈ { false, true }
(set by stop(); cleared on next start())
deriveState(nodeRuntime, backendState, stopRequested) is a small
decision tree (top to bottom — earlier matches win):
- Backend reported error →
ERROR. - Runtime exited unexpectedly →
ERROR. stopRequestedis true →STOPPEDif runtime is gone, elseSTOPPING.- Backend announced stopping →
STOPPING. - Backend reached ready →
STARTED. - Runtime is running OR backend reached controlBound →
STARTING. - Otherwise →
STOPPED.
The model addresses three previously-broken paths:
- Node exits cleanly without an error frame →
nodeRuntimebecomesExited(_, Unexpected)and the derivation producesERRORdeterministically. Without per-component state, this used to land inSTOPPED(Android) or hang (iOS). - Node crashes (non-zero exit) without an error frame → same path, same outcome.
- FGS-side local failure (rootkey, watchdog) before Node can
broadcast → see §5.5 (Native→Node error forwarding). The FGS
ships an
error-nativeframe to Node, the backend re-broadcasts it as anerrorframe, and the main-app process gets a realerrorframe with the actual phase rather than a generic disconnect.
-
NodeRuntimeis set bystart()(→Running), the runtime exit point inrunNode()(→Exited(code, reason)), andcleanup()/destroy()(→Exited(0, Requested)to force a clean STOPPED). The exit reason is classified at exit time:RequestedifstopRequestedwas true ORbackendStatewasStoppingorError(an in-flight graceful shutdown OR a backend error already acknowledged). Anything else →Unexpected. -
BackendStateis set byhandleControlMessagefrom the four well-known frames (started→ControlBound,ready→Ready,stopping→Stopping,error→Error{phase,message}) and by local failure paths that share the same conceptual slot (rootkey load failure, startup watchdog timeout, missing JS file →Error{phase,message}). -
stopRequestedis set bystop()synchronously, before any I/O.
ERROR is observable and per-instance terminal: start() and
stop() are refused; only destroy() (Android) or cleanup() (iOS)
clears the slate, and after that a fresh NodeJSService instance is
required. ERROR does not tear down the node thread in
NodeJSService itself — but on Android the surrounding FGS goes one
step further (see below).
Because node::Start is single-shot per process, an ERROR'd
NodeJSService can't be restarted in place; recovery means a fresh
process. On Android the FGS makes that uniform by self-terminating
its own process a short grace period after a terminal ERROR the node
thread didn't already exit from (ComapeoCoreService.onNodeStateChange,
SELF_TERMINATE_GRACE_MS). Most errors already converge to a dead
process on their own (handleFatal → process.exit(1) → runtime thread
returns → onComplete → stopService); the self-terminate watchdog is
the backstop for the node-still-alive case (e.g. a starting-timeout
whose error-native frame was dropped — §5.7). Self-termination is
graceful-first: onDestroy runs stopForTeardown(), which ships the
shutdown frame and joins the node thread (so the backend closes
MapeoManager/SQLite/sockets and exits on its own) bounded by the 10 s
stop timeout, and only force-kills on expiry. Either way the FGS dies
without re-arming START_STICKY (it goes through stopSelf), so the
single recovery path is the app re-foregrounding: onResume →
USER_FOREGROUND cold-starts a fresh process. On iOS (single
process, once-per-process runtime) there is nothing to self-terminate;
recovery is a full app relaunch. In all cases the JS layer keeps
observing ERROR so the host can prompt the user, apply backoff, and log
a report — see ForegroundService.md "Crash
recovery and restarting".
ComapeoState (src/ComapeoCore.types.ts) is the same union, exposed
to React Native as state.getState() and the stateChange event. The
event payload carries optional errorPhase / errorMessage when the
state is ERROR; state.getLastError() returns the structured detail.
lastError persistence is intentionally asymmetric. A fresh
start() clears it (a new lifecycle starts with a clean slate);
cleanup() / destroy() preserves it (so a caller that observed
ERROR can still read getLastError() after cleanup to decide what
to do next — log, prompt, recreate the service, etc.). In normal
flows JS only reads lastError inside the stateChange callback
where state is ERROR, so the asymmetry isn't observable; callers
that poll getLastError() standalone after cleanup() see the
last-cycle error until the next start() clears it.
iOS (ios/ComapeoCoreModule.swift): in-process callback —
AppLifecycleDelegate.nodeService.onStateChange = { state in sendEvent("stateChange", payload) }. Payload includes
state.getLastError() when the state is .error.
Android (ComapeoCoreModule.kt, main app process): the main app
process is a separate process from the FGS, so it cannot read FGS-side
component state directly. Instead it derives JS-visible state from a
second NodeJSIPC connection to the control socket, combining:
- Control-socket frames (
started,ready,stopping,error). - The IPC's own connection state (
Connecting,Connected,Disconnecting,Disconnected,Error{cause}).
The disconnect-reason logic mirrors the FGS-side derivation:
| Pre-disconnect JS state | Outcome |
|---|---|
ERROR |
stay (terminal) |
STOPPING or STOPPED |
→ STOPPED (graceful) |
STARTING or STARTED |
→ ERROR with phase node-runtime-unexpected |
A stopping frame from the backend (sent by §5.5) drives the JS state
to STOPPING before the socket close, so a graceful shutdown lands
in STOPPED. An unannounced disconnect from a running state lands in
ERROR — that's the cross-process equivalent of the FGS-side
NodeRuntime.Exited(_, Unexpected) rule.
Three failure surfaces, all converging on ERROR:
-
Native-local failures (both platforms, originating in the
NodeJSServicehost — the FGS process on Android, the iOS app process on iOS): rootkey load, JS entry point not found, shutdown timeout, watchdog timeout, unexpected node exit. Tagged with phases likerootkey,node-runtime,shutdown-timeout,starting-timeout,node-runtime-unexpected. These setBackendState.Error{phase, message}(orNodeRuntime.Exited(_, Unexpected)) directly on the localNodeJSService, which derives toERRORand populates_lastError. They never go through Node'shandleFatal— the failure originated native-side and the native side reports it native-side.Cross-process attribution matters only on Android, where the main-app process is separate from the FGS and observes lifecycle only through the control socket. When the FGS-side failure is local (rootkey, watchdog) but Node is still alive, the FGS additionally ships an
error-nativeframe to Node: the backend'serror-nativehandler routes tohandleFatal, which broadcasts anerrorframe to all control clients (including the main-app process's read-only observer) and exits 1 after a 100ms flush. Without this, an FGS rootkey failure would leave Node hanging onawait initPromiseindefinitely — the FGS knows it has failed but has no way to tell the main-app process. iOS has noerror-nativechannel because the JS module readsservice.statedirectly via the in-processonStateChangecallback; there is no second process to attribute to. -
Backend-reported failures (Node-side throws, both platforms):
process.on("uncaughtException")andprocess.on("unhandledRejection")route throughhandleFatal(phase, error)which callscontrolIpcServer.broadcast({type:"error", phase, message, stack})and exits 1 after a 100ms flush wait. Boot-phase errors (init,listen-control,construct,runtime) follow the same path via the boot IIFE's catch. Theerror-nativehandler from §1 is what bridges Android FGS-local failures into this same path, so allerrorframes seen on the wire come fromhandleFatal. -
IPC-level failures (Android main-app side):
NodeJSIPC.State.Errormaps toERRORwith phaseipc. Phase distinguishes "connection layer broke" from "backend reported an error".
Frames the native control-socket parser cannot process (non-JSON,
missing type, or an unknown type) are not raised to ERROR.
They fire a messageerror event on the JS state observer instead,
mirroring the DOM MessagePort counterpart. Rationale: the lifecycle
should reflect what the service is doing (running, ready, errored),
not whether one frame was malformed. A single bad frame should be
discoverable for debugging without taking down a working session;
subsequent valid frames keep driving stateChange normally.
The event payload is { data: string } on the wire; the JS observer
wraps it in new Error(data) for ergonomics. Listeners:
state.addListener("messageerror", (error: Error) => {
console.warn("control-socket protocol error:", error.message);
});Native plumbing:
- Android:
ComapeoCoreModule.ktcallssendEvent("messageerror", ...)directly from its control-socketonMessagehandler. - iOS:
NodeJSService.swiftexposes anonMessageErrorcallback;ComapeoCoreModule.swiftwires it tosendEvent("messageerror", ...). - Android FGS-side:
NodeJSService.ktlogs the bad frame; it has no JS bridge, so there is nowhere to forward to. The main-app process'scontrolIpcreceives the same broadcast and emits the event there.
Every wait in the lifecycle path has either a local timeout or a caller-side bound. Catalogued here so the next person debugging a "why did this hang for N seconds" question doesn't have to grep three codebases.
Native side:
| # | Where | Default | Guards against | On expiry |
|---|---|---|---|---|
| 1 | iOS startupTimeout (NodeJSService.swift) |
30 s | Service stuck in STARTING (Node parked, no ready frame) |
Sets backendState = .error(starting-timeout) → derives ERROR |
| 2 | iOS stop(timeout:) |
10 s; 5 s when called from applicationWillTerminate |
Node not draining cleanly | cleanup(threadExited: false) → ERROR with phase shutdown-timeout. Node thread may still be alive — process death cleans up |
| 3 | iOS waitForFile (NodeJSIPC) |
30 s | Backend never binds the socket | IPC .error |
| 4 | iOS connectWithRetry |
~1.5 s budget (5 attempts, 100→200→400→800 ms) | File exists but accept() not yet ready |
IPC .error |
| 5 | Android startupTimeoutMs |
30 s | Mirrors #1 | Sends error-native to backend (best-effort; see #7) and sets local BackendState.Error(starting-timeout) |
| 6 | Android ComapeoCoreService.onDestroy withTimeout |
10 s | nodeJSService.stopForTeardown() drain hangs |
Catches TimeoutCancellationException → Process.killProcess. This is the only outer bound on the Android teardown drain — it has no internal timeout |
| 7 | Android SEND_ERROR_NATIVE_TIMEOUT_MS |
2 s | ipcDeferred never completes (FGS init threw before IPC was constructed) |
Frame logged as dropped, no error thrown — the FGS still sets local ERROR regardless |
| 8 | Android connectWithRetry (NodeJSIPC) |
30 s | Backend never binds the socket OR file exists but accept() never ready |
IPC State.Error |
| 11 | Android SELF_TERMINATE_GRACE_MS (ComapeoCoreService) |
3 s | Terminal ERROR with the node thread still alive (dropped error-native, hung initPromise) pinning the FGS process forever |
stopService() → onDestroy → graceful stopForTeardown() drain (bounded by #6's 10 s) → killProcess; the dead process is recovered by the next USER_FOREGROUND. Grace lets the normal onComplete→stopService win first and lets the error frame flush to the main process |
Backend (Node):
| # | Where | Default | Guards against | On expiry |
|---|---|---|---|---|
| 9 | handleFatal flush window |
100 ms | error frame not flushed before process.exit(1) |
Hard exit |
| 10 | ServerHelper.listen retry on EADDRINUSE |
~4 s (3 retries × 1 s) | Stale socket file blocking bind | Reject — caller surfaces ERROR with phase listen-control / construct |
Unbounded waits (with safety nets, not internal timeouts):
-
Backend
await initPromise(backend/index.js). No timeout on the rootkey-receive step. Relies on the native watchdog (#1 / #5) to break it viaerror-native→process.exit(1). If both the watchdog anderror-nativefail, Node parks indefinitely. On Android the FGS self-terminate watchdog (#11) bounds this: the startup watchdog (#5) has already set localERROR, so the FGS kills its own process withinSELF_TERMINATE_GRACE_MS, the control socket closes, and the main-app process converges fromSTARTINGtoERROR(syntheticnode-runtime-unexpected) instead of hanging. iOS has no equivalent self-terminate, so there the main path is still the OS killing the process. Defense-in-depth would be a 60–120 sinitPromisetimeout in the backend; not currently implemented. -
Backend
await comapeo.close()in theshutdownhandler. Bounded only by the caller-side stop timeout (#2 / #6). Ifcomapeo.close()hangs internally, the native side firesshutdown-timeoutand (on Android) the FGS process is killed. -
Android
nodeJob?.join()insideNodeJSService.stop(). No internal timeout. Only thewithTimeout(10_000)inComapeoCoreService.onDestroy(#6) bounds it. Direct callers ofstop()outside the FGS lifecycle must wrap in their own timeout. -
state.first { Connected }inNodeJSIPC.sendMessageInternal. Bounded indirectly:disconnect()cancelsconnectJob, which cancels the childsendJob, which cancels the suspendingfirst.
Worst-case latencies for cross-process error attribution (Android):
error-nativelands: ~100 ms (flush window) from FGS-side ERROR to main-app receiving the precise phase.error-nativedropped (#7): ~10–12 s from FGS-side ERROR to main-app receiving genericnode-runtime-unexpected(FGS dies viaonError → stopService → onDestroy → withTimeout(10_000) → killProcess; control socket closes; main-app's disconnect handler fires). State is correct; phase attribution is degraded.
The per-component lifecycle model in §5 closes the previously-known gaps. The remaining ones are inherent to the platform or out of scope:
-
Hard Node crashes still lack in-band detail. A SIGSEGV in a native addon, an OOM kill by the OS, or
process.abort()ends the process before any code (ours or the backend's) can broadcast anerrorframe. The control socket simply closes and the derivation lands inERRORwith the synthetic phasenode-runtime-unexpected("Backend disconnected unexpectedly"). That's honest — there is no in-band detail to surface — but it means hard-crash diagnostics belong in a separate channel (Sentry / Crashlytics native crash reporting), not ingetLastError(). -
error-nativerequires a connected control IPC. The FGS-sideerror-nativechannel (§5.5) preserves cross-process error attribution for FGS-local failures (rootkey, watchdog) when Node is alive. If the FGS fails before the control IPC has connected (a very narrow window — the IPC connect runs in the FGS service's init block alongside Node startup), the frame is dropped and the main-app process falls back to the syntheticnode-runtime-unexpectedphase. Only attribution degrades, not liveness: the FGS self-terminate watchdog (§5.7 #11) still kills the process within its grace window, so the main-app process converges toERRORrather than hanging atSTARTING. This is no worse than the pre-refactor baseline on attribution and strictly better on the hang.
Companion doc: sentry-integration-plan.md.
This section is the architectural overview; the plan has the
phasing, decision log, and per-file change list.
Sentry is opt-in. Consumers that don't register the Expo
config plugin and don't import the @comapeo/core-react-native/sentry
sub-export pay nothing — no DSN ends up in the APK/IPA, no
io.sentry classes are loaded at runtime, no Sentry-shaped
captures fire from this module.
Two tags split the emit sites in the dashboard:
proc— actual OS process. iOS is alwaysmain. Android ismainfor code in the host UI process andfgsfor code in the:ComapeoCoreforeground-service process (Kotlin FGS code AND the embedded nodejs-mobile that runs there).layer—rnfor the JS adapter,nativefor Kotlin/Swift,nodefor the embedded nodejs-mobile backend.
iOS (one process) Android
──────────────────── ─────────────────────────
layer: rn (JS adapter) proc: main proc: main
layer: native (Kotlin/Swift) proc: main proc: main + proc: fgs
layer: node (Phase 3) proc: main proc: fgs
What each emit site captures:
layer:rn—src/sentry.tsadapter. State ERROR transitions →captureException;messageerror→ warning; every state transition → breadcrumb.layer:native, proc:main(Android main process) —ComapeoCoreModule.kt(the RN bridge). Currently no native emits from this process; reserved for future use.layer:native, proc:fgs(Android FGS) /proc:main(iOS) —SentryFgsBridge(Android) / equivalent Swift bridge.comapeo.boottransaction with child spansboot.fgs-launch(Android only),boot.extract-assets(Android only, first boot after install/update),boot.node-spawnandboot.rootkey-load. The "init frame sent" → "received: ready" breadcrumb pair marks the init-frame round-trip (no span — duration is dominated by the Node-sideboot.manager-init). Plus state-transition breadcrumbs, control-frame breadcrumbs, FGS-lifecycle breadcrumbs (Android only), timeout events, rootkey-loadcaptureException.layer:node—@sentry/nodevialoader.mjs. Per-RPC method spans,handleFatalcaptureException, and two top-level boot-phase root spans that share the FGS-side trace viaSentry.continueTrace:boot.loader-init(with two children,boot.loader-import-sentry-nodeandboot.import-index) andboot.manager-init.listen-controlandinitkeep their phase tags for error attribution but no longer emit spans — they are reliably fast and (forinit) duplicated by nativeboot.rootkey-load.
The same FGS-local error (rootkey load failure, watchdog
timeout) reaches multiple scopes via the cross-process
attribution path (§5.5): the FGS captures it directly, then
error-native re-broadcasts to Node which captures from the
node layer, and the error control frame propagates to the
main-app process where the JS adapter captures again. Sentry
de-dupes via fingerprinting; each vantage point carries
distinct context (FGS logcat / foreground state, node
stacktrace, RN state-machine trail).
The Expo config plugin (app.plugin.js at module root) is the
single source of truth for DSN / environment / release / sample
rates. At expo prebuild it writes:
- Android: meta-data on the main
<application>tag (com.comapeo.core.sentry.dsn,…environment,…release,…sampleRate,…tracesSampleRate,…rpcArgsBytes,…applicationUsageDataDefault,…debugDefault). meta-data is shared across processes within the package, so both the main process and the:ComapeoCoreFGS process read the same values. - iOS: keys in
Info.plistwith theComapeoCoreprefix (e.g.ComapeoCoreSentryDsn).
Native readers — SentryConfig.kt and SentryConfig.swift —
return a typed SentryConfig? (null when DSN is absent =
"Sentry off"). release defaults to
versionName + "+" + versionCode (Android) /
CFBundleShortVersionString + "+" + CFBundleVersion (iOS) when
the plugin didn't supply one, so successive EAS builds of the
same marketing version produce distinct release tags.
The module owns the RN-side Sentry.init lifecycle via
initSentry() (exported from @comapeo/core-react-native/sentry).
The host calls it once at app entry and passes allowlisted
extensions (integrations, beforeSend, beforeBreadcrumb, tags);
initSentry throws if the host has already called Sentry.init
separately. Locked options (dsn, environment, release, sampleRate,
tracesSampleRate, sendDefaultPii=false, enableLogs, user.id) come
from the plugin so all three hubs use the same values without the
host having to copy them.
The same plugin-baked subset is also exported as sentryConfig for
read-only inspection — empty {} when the plugin isn't registered.
It is NOT meant to be spread into a separate Sentry.init call;
initSentry is the supported entrypoint. Plugin-internal fields
(rpcArgsBytes, applicationUsageDataDefault, debugDefault) stay on the
native-side SentryConfig only.
The FGS process's Sentry SDK is initialised in
ComapeoCoreService.onCreate from the manifest meta-data —
because Android creates a fresh Application instance per
process, the host's @sentry/react-native (which inits in the
main MainApplication) doesn't reach the FGS. The bridge fills
that gap.
SentryFgsBridge.kt owns SentryAndroid.init(...) inside the FGS
process. Android creates a fresh Application per process, so the
host's main-process SentryAndroid.init (from @sentry/react-native)
never reaches the FGS — the bridge re-runs init from
ComapeoCoreService.onCreate before NodeJSService is constructed.
This is the same pattern used on iOS, see §7.4.
A @Volatile initialized flag guards every public method so callers
that fire before init no-op silently. Each method also wraps its
Sentry call in a try/catch — a thrown Sentry path must never take
the FGS down.
sentry-android is now a regular implementation dep (not
compileOnly) because @sentry/react-native is a mandatory peer
dep of this module. Earlier design split the bridge into a guard
- impl pair behind a
Class.forNameclasspath probe; that indirection became unnecessary once the SDK was guaranteed on the classpath, and the two halves were merged back into a singleSentryFgsBridge.kt.
- Native crashes inside
nodejs-mobile(V8 abort, addon SIGSEGV, OOM) are not captured by us. They surface as host- process crashes thatsentry-android/sentry-cocoacatches at the JNI/Cocoa layer. We do not bundlesentry-nativeinto the embedded runtime. - iOS single-process equivalent of FGS init. iOS is
single-process, so the FGS-style separate-process init isn't
needed — but we still own sentry-cocoa init natively, from
AppLifecycleDelegate.application(_:didFinishLaunchingWithOptions:)viaSentryNativeBridge.initFromConfig. The JS layer'sSentry.initruns withautoInitializeNativeSdk: false, so the native hub is the single owner of the SDK lifecycle. This keeps the boot transaction and pre-JS-bundle native spans on a live hub — the same invariant Android gets fromComapeoCoreService.onCreate. - DSN secrecy. Sentry DSNs are not high-secret values — they identify a project rather than authenticate writes, and they appear in stripped binaries of every Sentry-using app's APK/IPA. Treating them as build-time public config is intentional.
- PII capture. Per the plan §7.4.9, observation contents, precise location, peer identities, raw project IDs, and user-entered text are never captured even with the application-usage-data toggle on.
Single source of truth for what we emit. Constants live in
SentryTags.{kt,swift} / src/sentry-tags.ts (tags) and
SentryCategories.{kt,swift} / src/sentry-tags.ts
(breadcrumb categories). Update those files first, then
this table.
| Key | Values | Where |
|---|---|---|
proc |
main (host UI process) · fgs (Android :ComapeoCore) |
Process-level on FGS-side init; per-call on iOS, RN, and main-process Android |
layer |
rn · native · node (Phase 3) |
Same as proc |
comapeo.phase |
rootkey · node-runtime · starting-timeout · shutdown-timeout · self-terminate · node-runtime-unexpected · ipc · listen-control · init · construct · runtime · errorNativeForward |
Captured exceptions and timeout messages |
comapeo.state |
STOPPED · STARTING · STARTED · STOPPING · ERROR |
ERROR captureExceptions only |
source |
control-socket · rootkey-store · startNodeWithArguments · comapeo-core (Phase 3) |
Captured exceptions, narrows the origin within a phase |
timeout |
startup · shutdown · fgsStop · selfTerminate · errorNativeForward · waitForFile · connectRetry |
captureMessage events for timeout firings |
boot.kind |
user-foreground · system-restart |
On comapeo.boot transactions (Android FGS only). user-foreground when the activity lifecycle initiated the start (stamped serviceStartTimeMs); system-restart when Android brought the FGS back without an intent — no boot.fgs-launch span and the timeline starts at NodeJSService.start(). |
| Category | What it carries | data fields |
|---|---|---|
comapeo.state |
Every state-machine transition | from, to, backendState, nodeRuntime, stopRequested (Android), from, to (iOS) |
comapeo.control |
Control-socket frames the parser accepted, plus malformed | phase+message (error frames), detail (malformed) |
comapeo.ipc |
NodeJSIPC connection-state transitions | error (only when State.Error) |
comapeo.fgs |
FGS lifecycle (Android only) | startId, action, exitCode (varies by callsite) |
comapeo.boot |
Boot-phase progress (start, asset copy, init frame, exit) | varies — dir, exitCode, etc. |
All boot phases use op = name = "boot.<phase>". sentry-java's child-
span wire format has no separate name field — Discover renders
span.name = op for child spans, so the op carries the phase
identifier directly. The Node-side name/op fields are set to the
same value so all three layers (RN, native, Node) match. Filter the
whole boot timeline with op:boot.* in Discover.
| Operation | Layer | When it opens / closes |
|---|---|---|
comapeo.boot (root tx) |
proc:fgs Android / proc:main iOS |
Opens in NodeJSService.start(); closes on first non-STARTING transition with status ok (STARTED), internal_error (ERROR), or cancelled (STOPPING/STOPPED). Forced 100% sampled; tagged boot.kind=user-foreground or system-restart. |
boot.fgs-launch (Android only) |
proc:fgs |
Backdated to serviceStartTimeMs (stamped by the activity lifecycle listener); closes immediately on entry to NodeJSService.start(). user-foreground boots only — absent on system-restart. |
boot.extract-assets (Android only) |
proc:fgs |
Recursive copy of nodejs-project/ from APK assets into internal storage. Opened only when shouldCopyAssets() returns true — i.e. first boot after install or app update. Presence in a trace is itself diagnostic ("this was a cold start after update"); absent on every subsequent boot. iOS reads the bundle in place (no extraction). |
boot.node-spawn |
proc:fgs Android / proc:main iOS |
JNI call to startNodeWithArguments (Android) / nodeEntryPoint (iOS) → backend's started frame on the control socket. Spans the C/C++ V8-bootstrap phase plus the Node-side loader/import/manager-init phases (visible as nested child transactions on the same trace). |
boot.loader-init |
layer:node |
Backdated to loader.mjs first line; closed just after Sentry.init. Covers the C/C++ → JS handover including V8 bootstrap and the iitm hook install. Has two child spans: boot.loader-import-sentry-node (brackets import("@sentry/node") — the dominant chunk on the reference device) and boot.import-index (brackets the dynamic import("./index.js")). The gap between loader-init's duration and the import-sentry-node child is parseArgs + iitm register() + smaller imports + Sentry.init (collectively <200ms on the reference device; no spans of their own). boot.import-index parents to loader-init via an explicit parentSpan reference (not via AsyncLocalStorage) so the IIFE inside index.js still captures boot.node-spawn as the parent for boot.manager-init — keeping it a top-level Node phase rather than nesting it further. Sentry renders import-index as a child whose duration extends past its parent's. |
boot.manager-init |
layer:node |
Wraps createComapeo(...) + comapeoRpcServer.listen(...) — drizzle migrations + SQLite open + hypercore init + fastify + RPC socket bind. |
boot.rootkey-load |
proc:fgs Android / proc:main iOS |
Wraps RootKeyStore.loadOrInitialize() (Android) / RootKeyStore.loadKey() (iOS) in sendInitFrame(). Span data: generated=true on first install, false on steady-state. |
boot.master-key-derive |
layer:node |
Master-key cache miss only: wraps the Argon2id derivation inside KeyManager (docs/master-key-cache-plan.md §4). Expected on the first boot after install; absent on every steady-state boot, so seeing it repeatedly on one install means the cache write or the master-key frame is failing. |
Cross-layer trace propagation: native opens comapeo.boot, then
forwards the boot.node-spawn span's sentry-trace header to the
Node process as the --sentryTrace argv flag. The Node side's
Sentry.continueTrace wraps all three Node-side boot spans so they
inherit the FGS-side trace_id and parent_span_id. Result: every boot
span across all three layers shares a single trace, viewable on a
single timeline in Sentry's Trace view. Cross-layer with the RN-side
App Start transaction is tracked in
#68.
| Message | Level | Tags |
|---|---|---|
comapeo: startup timeout fired |
error |
timeout:startup, comapeo.phase:starting-timeout |
comapeo: stop timeout fired (iOS) / comapeo: FGS stop timeout fired (Android) |
error |
timeout:shutdown (iOS) / timeout:fgsStop (Android), comapeo.phase:shutdown-timeout |
comapeo: error-native frame dropped (Android FGS) |
warning |
timeout:errorNativeForward, comapeo.phase:<inner> |
comapeo: FGS self-terminating after terminal ERROR (Android FGS) |
warning |
timeout:selfTerminate, comapeo.phase:self-terminate |
| Origin | comapeo.phase |
source |
comapeo.state |
Where |
|---|---|---|---|---|
| Rootkey-store load failure | rootkey |
rootkey-store |
ERROR |
FGS-side (Android) and iOS native |
| Node-runtime launch failure | node-runtime |
startNodeWithArguments |
ERROR |
Android FGS only (iOS lifts the throw via the same path) |
| Synthesised JS-side ERROR | from info.errorPhase |
(none) | ERROR |
src/sentry.ts |
| Control-socket parse failure | (none) | control-socket |
(none, level warning) |
src/sentry.ts |
The plan's §7.4.9 never-capture list is enforced at every emit
site (no observation contents, precise location, peer
identities, raw project IDs, or user-entered text). The
before_send/beforeBreadcrumb/beforeSendLog scrubbers
(src/sentry-scrub.ts + the mirrored backend/before-send.js)
add a defensive substring scrub on top.
The metrics layer (src/sentry-metrics.ts on RN, the mirrored
backend/lib/metrics.js on Node) emits aggregate counters /
distributions / gauges via Sentry.metrics.* (Sentry Application
Metrics). It is the always-on day-to-day performance signal,
distinct from the per-RPC and boot traces, which are
investigation-only and gated behind the debug toggle.
One metric per measurement, sliced by attributes. Application
Metrics bills by ingested volume, not cardinality (high cardinality
is the default, not a limit), so each measurement is a single
high-cardinality metric carrying every dimension you'd slice by —
status, phase, and the device tags all ride together, and you
break down with group-by / filter at query time (e.g. p95 group by device_class, or group by method, device_class). There is no
primary + .by_device split: that would double the emitted volume
for no cardinality saving and make the method × device query
impossible.
Privacy tiers. diagnosticsEnabled (default on) gates whether
anything ships. The opt-in applicationUsageData toggle (default
off) gates the dimensions that reveal what the user does — the RPC
method attribute and the sync collaboration/volume bucket counters.
Those are gated for privacy, not cardinality.
Every metric carries platform (ios/android). Latency/duration
metrics also carry the low-cardinality device_class
(low/mid/high) and os_major (<platform>.<major>) tags,
attached only inside the metrics layer so a hot call site can't emit
raw device data. The forbidden-tag list (device.*, locale,
project_id, peer_id, raw coordinates, …) is dropped defensively
by isForbiddenMetric.
| Metric | Type · unit | Attributes | Emitted by / when | What it tells us |
|---|---|---|---|---|
comapeo.rpc.client.duration_ms |
distribution · ms | status, device_class, os_major, method (usage-gated) |
RN request hook — end-to-end client-observed RPC latency (IPC + serialize + handler + response delivery) | The primary UX-latency signal: how slow core operations feel end-to-end, which device classes are sluggish, and (usage tier) which methods are slowest. |
comapeo.rpc.server.duration_ms |
distribution · ms | status, device_class, os_major, method (usage-gated) |
Node request hook — server-side handler latency (pairs with the client metric to separate handler vs IPC) | Handler-only cost. Paired with the client metric it separates "the core operation is slow" from "IPC / serialization / JS-thread overhead". |
comapeo.boot.phase_duration_ms |
distribution · ms | phase, device_class, os_major |
Node — per boot phase | Where boot time goes (which phase dominates), per device class — the input to boot- and ANR-margin optimisation. |
comapeo.boot.outcome |
count | outcome (started/error), error_phase? |
Node — once per boot | Boot success-vs-failure rate and which phase fails — the backend reliability signal. |
comapeo.storage.size_bucket |
count | bucket (<10MB/10-100MB/100MB-1GB/>1GB) |
Node — one-shot at STARTED (recursive size of the private storage dir) | The population's storage-footprint distribution — context for correlating large DBs with slow sync/query. Coarse, one-shot: context, not an alerting signal. |
comapeo.backend.heap_used_bytes |
gauge · byte | — | Node — 60s sampler (V8 JS heap; rss is omitted, it measures the whole process) |
Backend JS-heap ceiling, and across a session's samples a heap-growth (leak) signal. Coarse on iOS, where the runtime suspends in the background. |
comapeo.backend.event_loop_delay_ms |
distribution · ms | device_class, os_major |
Node — 60s sampler; each emission is the window max from monitorEventLoopDelay |
The worst event-loop stall per minute (sync work blocking async I/O). As a distribution, Sentry gives fleet percentiles of the per-minute worst stall, sliceable by device class. |
comapeo.sync.session.duration_ms |
distribution · ms | outcome, device_class, os_major |
Node — scaffolding, not yet wired | (When wired) sync-session latency by outcome and device — core sync performance. |
comapeo.sync.session.peers_bucket |
count | bucket (usage-gated) |
Node — scaffolding, not yet wired | (When wired) collaboration scale — how many peers took part in a sync. |
comapeo.sync.bytes_bucket |
count | bucket (usage-gated) |
Node — scaffolding, not yet wired | (When wired) data volume moved per sync. |
comapeo.shutdown.phase_duration_ms |
distribution · ms | phase |
Node — scaffolding, not yet wired | (When wired) shutdown timing — guards the stop-path ANR / timeout margin. |
comapeo.ipc.errors |
count | error_class |
Node — scaffolding, not yet wired | (When wired) IPC failure rate by class — a transport-reliability signal distinct from backend errors. |
comapeo.telemetry.forwarding_failures |
count | — | Node — scaffolding, not yet wired | (When wired) whether our own telemetry sink is dropping/failing — a self-health check on the metrics pipeline. |
Rows marked scaffolding, not yet wired have a metrics-layer function and tests but no production call site yet — the meaning is fixed so the emit point can be added without a dashboard change.
Three earlier metrics were dropped for carrying no actionable signal:
comapeo.fgs.uptime_s (a 60s-sampled monotonic gauge — average
meaningless, max ≈ session length and better derived from boot
frequency); comapeo.rpc.client.send_ms (redundant with the
rn.send.syncMs attribute already on the debug client span); and
comapeo.state.transitions (started/error overlapped boot.outcome,
the rest was routine lifecycle noise).
comapeo.backend.event_loop_delay_ms reports the per-window max
(worst stall per minute) rather than the mean, which buried brief
stalls. monitorEventLoopDelay is pure libuv (no addon / worker /
inspector), so it's the one mechanism that definitely runs on device.
Capturing a stall as an event with a stack trace (Sentry's ANR /
event-loop-block integrations) isn't available off the shelf. The
deprecated anrIntegration captures the stack via node:inspector,
which nodejs-mobile doesn't ship
("The V8 inspector is not available … due to a dependency on the intl
module"), so it's out. The current eventLoopBlockIntegration is a
native @sentry/node-native addon: no prebuilt binary targets the
nodejs-mobile ABIs, but this repo already builds addons for those
targets, so it's buildable in principle — gated on two unverified
questions (does its C++ compile against nodejs-mobile's headers, and
does its own stack capture avoid the absent inspector?). A stack-on-stall
event is a viable follow-up if that spike pans out; the max metric is the
always-on signal in the meantime.
Viewing. In Sentry, open Metrics (Explore → Metrics),
filter by the metric name, and group by an attribute (method,
status, phase, device_class, …) — one metric can be sliced
along any dimension it carries. The per-RPC and boot traces
(spans above) live under Explore → Traces and only exist while
debug is on.
Logs, breadcrumbs, and captured events serve different purposes and shouldn't be 1-to-1.
Logs (logcat / os_log) |
Breadcrumbs | Captured events (captureException / captureMessage) |
|
|---|---|---|---|
| Cost | Unbounded — OS rotates the buffer | Finite ring buffer (Sentry's default 100 per scope) | Counted toward Sentry quota; surface as Issues |
| Visibility | Live during local debug only | Attached to next captured event | First-class dashboard entries |
| Audience | Developer at the keyboard | Reader of a captured event later | Triage reader |
| Use for | Anything | Lifecycle context | Errors / notable non-error events |
The codebase has four helpers. log() is the foundation;
the other three compose on top of it:
log(message, level, attributes)— the single primitive. Always writes a logcat / os_log line at the matching priority; also forwards to Sentry's structured-log pipeline (Sentry.logger.*Android,SentrySDK.logger.*iOS), which the SDK gates onenableLogs. Use for debug noise, cache-check diagnostics, guard-rejection paths.logCrumb(category, message, level, data)—log()- Sentry breadcrumb. Lifecycle progress events: state transitions, control-socket frames, IPC state, FGS lifecycle, boot phases. ~20–25 emissions per app session, well under the breadcrumb buffer cap.
logException(category, throwable, message, tags)—log()(with throwable, so the stack lands in logcat via 3-argLog.e(TAG, msg, t)) +captureException. Use when you have aThrowablein hand. Examples: rootkey-load failure, node-runtime launch failure.logCapture(category, message, level, tags)—log()+captureMessage. Use when you don't have a throwable but the event is notable: timeouts (startup, shutdown, fgsStop, errorNativeForward), dropped frames, protocol violations.
Plus one more shape that doesn't go through the helpers:
breadcrumb only is used by src/sentry.ts (the JS
adapter) on Android — the matching FGS-side logCrumb
already produces the logcat line; emitting from JS too
would just round-trip through Metro's bridge.
captureException vs captureMessage — when in doubt:
have a throwable → logException; making a synthetic
exception just to use captureException would lose the
"this isn't really a stack-traceable error" semantic and
the dashboard's grouping wouldn't be useful.
iOS dual-crumb caveat. On iOS, single-process means both
the native bridge's breadcrumb and the JS adapter's breadcrumb
land on the same Sentry hub for every state transition. They
carry different data payloads (native has the full
(backendState, nodeRuntime, stopRequested) triple; JS has
the simplified state derivation), so they're complementary,
but they do roughly halve the effective lookback window. The
JS adapter is the resilience layer when the native bridge is
gated off (#if canImport(Sentry) false), so we keep both.
Phase 3 forward-look. When backend RPC tracing lands, do
NOT logCrumb every RPC method call — a noisy sync session
can fire 100+ RPCs and evict every boot/state crumb from the
buffer. The right shape is: log per RPC (cheap), crumb only
on RPC errors, and let the sampled-trace span infrastructure
(tracesSampleRate) handle the success path.
Sentry structured logs. Sentry has a separate "Logs"
pipeline (SentrySDK.logger.* / Sentry.logger.*),
queryable independently in the Logs UI and not attached
to events the way breadcrumbs are. Our log() primitive
forwards every call through this pipeline; the SDK gates
on enableLogs so it costs nothing for consumers who
haven't opted in.
To enable: set enableLogs: true on the
@comapeo/core-react-native plugin (controls the Android
FGS-process hub, where we own the SDK init) and on
Sentry.init({ enableLogs: true, ... }) in the host app
(controls the main-process hub on Android and the
single-process hub on iOS — that init is the host's, not
ours, on those paths). When enabled, every log /
logCrumb / logException / logCapture call lands in
the Logs UI in addition to its primary destination,
giving cross-process timeline reconstruction across
proc:main / proc:fgs / proc:node (breadcrumbs don't
cross scopes; logs do).
The current approach (§4.4) is a second NodeJSIPC connection to
control.sock. We considered:
| Mechanism | Late-join | Cross-process | Boilerplate | Verdict |
|---|---|---|---|---|
Intent broadcast (sendBroadcast) |
No (sticky deprecated) | Yes | Low | Reject — fire-and-forget without replay; would need a query-current-state side channel |
LocalBroadcastManager |
n/a | No | n/a | Reject — in-process only, doesn't cross :ComapeoCore boundary |
Messenger / bound service |
Manual snapshot on bind | Yes | Moderate (~100 LOC) | Reject — moves state into Kotlin, duplicates Node's state machine, breaks "Node owns the truth" |
| AIDL bound service | Same | Yes | High (~150–200 LOC) | Reject — typed RPC overkill for one-way state notifications |
ContentProvider + ContentObserver |
Free (re-query) | Yes | Moderate (~100 LOC) | Runner-up. Late-join free; only structural advantage. Not worth ~100 LOC of new manifest/provider/observer code today. |
FileObserver on a state file |
Free | Yes | Low | Reject — documented unreliability under load; bad for a state machine |
ParcelFileDescriptor.createPipe |
Manual | Yes | Moderate | Reject — a transport, not a notification mechanism; would still need a service + protocol on top |
Second NodeJSIPC (current) |
Replay in Node | Yes | Low (~30 LOC) | Selected. Reuses existing socket client class; replay logic lives in JS where the rest of the lifecycle lives; iOS uses the in-process equivalent so the "Node owns the truth" abstraction holds across platforms. |
The strongest case against the current approach is "it's a second persistent socket paying connection overhead and a 50 ms retry poll for a channel that carries ~6 messages per process lifetime." That cost is real but small. If profiling later shows it matters, **ContentProvider
- ContentObserver** is the runner-up.
Detailed comparison: see
root-key-storage-and-migration-plan.md.
The summary: expo-secure-store's core failure-mode policy is
"silently delete on BadPaddingException / KeyPermanentlyInvalidatedException,
return null." That is correct for tokens you can re-fetch; for a 16-byte
never-rotated device identity it is exactly the bug we cannot ship.
What our RootKeyStore does that expo-secure-store doesn't:
setIsStrongBoxBacked(true)with graceful fallback (Pixel/Titan-M hardware-backed keys when available).setUnlockedDeviceRequired(true)on P+ —expo-secure-store's at-rest data is decryptable by the running app even on a locked device.setRandomizedEncryptionRequired(true)explicitly.- Integer envelope version (
v: 1) with strict mismatch throw —expo-secure-storeuses aschemestring + boolean; no monotonic version for future migrations. - Read-back verification (decrypt + byte-compare after write).
- Throws rather than silently regenerates on any decrypt failure.
- Backup exclusion baked into the module (
fullBackupContent+dataExtractionRules);expo-secure-storeleaves backup policy to the host app, which is a known footgun (Auto Backup mirrors prefs to Drive, the wrapper key doesn't go with it, restored device hits the silently-delete path).
Replacing ours would require either patching the upstream module or
wrapping it to convert "returned null but blob existed" into a thrown
exception, at which point most of RootKeyStore is reimplemented plus
transitive deps on expo-modules-core, kotlinx-coroutines, and
androidx.biometric.
Net: ~215 lines of ours doing exactly what we need with stronger
at-rest properties; expo-secure-store is ~740 lines doing a
different problem.
Earlier prototypes carried lifecycle frames over the comapeo socket (prepended to the RPC stream). Rejected because:
- The bind timing diverged from RPC needs (rootkey before manager construction).
- Mixing types forced every RPC client to filter unwanted frames.
- The replay semantics for
started/readyonly make sense for the control surface; carrying replay through RPC was nonsense.
Prototype: ship the rootkey in argv to the Node process so the
backend reads it from process.argv[N] on boot, no handshake needed.
Rejected because:
- argv is process-wide visible (
/proc/<pid>/cmdlineon Android,pson iOS-with-jailbreak); secrets in argv leak to anything that can enumerate processes. - Logging the argv (which we sometimes do for debugging) would dump the rootkey. The handshake keeps the secret out of long-lived strings.
The handshake costs one round-trip plus one socket bind ordering constraint. Both are small.
backend/index.js— boot sequence,handleFatal, init handler.backend/lib/simple-rpc.js— control socket server, replay, error broadcast.ios/NodeJSService.swift,android/.../NodeJSService.kt— native lifecycle state.src/ComapeoCoreModule.ts— JS-facing observers (comapeo,state).docs/sentry-integration-plan.md— Sentry integration plan (§7 is the architectural overview).- GitHub issue #29 — original "expose Node.js lifecycle state to JS" tracking issue.