Skip to content

feat: add createClient.rejectPending and createClient.resubscribe - #52

Merged
gmaclennan merged 5 commits into
mainfrom
feat/reject-pending-and-resubscribe
Aug 19, 2026
Merged

feat: add createClient.rejectPending and createClient.resubscribe#52
gmaclennan merged 5 commits into
mainfrom
feat/reject-pending-and-resubscribe

Conversation

@gmaclennan

@gmaclennan gmaclennan commented Aug 13, 2026

Copy link
Copy Markdown
Member

In @comapeo/core-react-native the CoMapeo backend runs in an Android foreground service. Android's low-memory killer can kill and restart that service while the app keeps running: the Unix-socket transport reconnects, but the restarted server has lost every event subscription, and calls that were in flight when it died hang until they time out. Today createClient.close() is the only way to reject pending calls, and it is terminal — the client can't be reused after reconnect.

This adds two static methods on createClient, implemented the same way as createClient.close (a Symbol-keyed method on the root proxy):

createClient.rejectPending(client, error) rejects every in-flight method call with the caller-supplied error, clears the pending and collector maps, and returns the number of calls rejected. Unlike close, the client stays fully usable. Rejecting the inner promise settles the p-timeout wrapper (clearing its timer), so nothing double-settles and no timers leak; a response arriving later for a rejected msgId misses pending and is ignored with a warning, as with any unknown msgId.

createClient.resubscribe(client) re-sends [ON, eventName, propArray] for every event — root and nested sub-client events — that still has at least one listener, so a restarted server starts emitting subscribed events again. Re-sending ON is protocol-compatible (the client already sends ON on every subscribe call). Returns the number of ON messages sent.

Both are no-ops (returning 0) on a closed client. The API is additive, so existing consumers (e.g. desktop/Electron, where the server process doesn't restart independently) are unaffected.

Why these methods, and why they live here

rejectPending exists because calls in flight when the server dies can never complete: the dead process consumed (or never received) their request frames, so a restarted server has no record of them. Holding them means a guaranteed hang until options.timeout; rejecting them promptly with a caller-supplied, typed error lets the app retry as soon as the transport is back. resubscribe exists because server-side subscription state dies with the process, and only the client knows what should be re-subscribed.

Recovering from a restart needs three pieces of knowledge, each held by exactly one layer, and this PR adds only the piece that is rpc-reflector's:

  • What is subscribed lives only inside createClient's closure — the emitter, keyed by a private encoded event-name format. Nothing outside the client can enumerate subscriptions without duplicating that state and the unexported wire format.
  • Which clients exist is the consumer's knowledge: @comapeo/ipc fans these calls out over its manager, routing, and per-project clients (feat: transport-reset recovery for server restarts comapeo-ipc#89).
  • When the transport is back up is known only by the transport owner — which is why these are explicit calls, not automatic behavior.

Alternatives considered and rejected:

  • Track subscriptions in the consumer and replay raw ON frames. Duplicates state the client already holds and couples the consumer to the unexported wire format. It's also a drift hazard: e.g. the client auto-sends OFF from handleEmit when an emit finds no listeners, and an external mirror would have to observe every such path correctly, forever.
  • Automatic resubscribe inside rpc-reflector on reconnect. The MessagePortLike abstraction has no connect/disconnect lifecycle, so the client cannot know when reconnection happens. And ON frames written into a down transport can keep nudging the native transport into reconnect attempts (found in the feat: add transport-reset handling for server restarts comapeo-ipc#87 review), so timing must stay with the transport owner.
  • Tear down and recreate clients on reconnect. close() is terminal, so this invalidates every held reference and pushes listener re-registration up into the app layer — the churn that backend-owned lifecycle (feat!: backend-owned project lifecycle over stable per-project channels comapeo-ipc#88) exists to eliminate.
  • Persist server-side subscription state across restarts. The dead process has by definition lost its memory, and persisting to disk state the living client already holds is heavyweight and racy.

One deliberate limitation: rejectPending is a point-in-time sweep, not a "transport down" state. Calls made after the sweep while the transport is still down stay pending — which is the desired behavior for a brief outage (they complete normally once the transport reconnects, if the transport delivers frames written during the outage), with the per-call timeout as the backstop. A stateful pause/resume mode that fails such calls fast was considered and left out as scope creep.

An earlier revision also added createClient.emitLocal; it was dropped (added then reverted on this branch) once its only consumer — the hard-close of project wrappers in digidem/comapeo-ipc#87 — was superseded by backend-owned project lifecycle (digidem/comapeo-ipc#88/#89), which needs no local event delivery.

Give the transport owner hooks to recover from a server restart without
tearing down the client: rejectPending(client, error) fails every
in-flight call fast with a caller-supplied (distinguishable) error while
keeping the client usable, and resubscribe(client) replays an ON message
for every event (root and nested sub-clients) that still has listeners,
restoring subscriptions a restarted server has lost. Both are no-ops on
a closed client.
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.33%. Comparing base (4ba698e) to head (3ad3b64).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #52      +/-   ##
==========================================
+ Coverage   99.29%   99.33%   +0.04%     
==========================================
  Files          12       12              
  Lines        1132     1204      +72     
==========================================
+ Hits         1124     1196      +72     
  Misses          8        8              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Review follow-up: give the transport owner a way to deliver an event
that a dead server can no longer send (e.g. firing 'close' teardown
listeners when hard-closing a client after the server process died).
Listeners are stored under encoded names (propArray + eventName), so a
plain emit on the client cannot reach them; emitLocal encodes the root
propArray and emits to locally-registered root listeners only, with no
wire traffic. No-op (returning false) on a closed client.

Also drop the unreachable listenerCount guard in resubscribe:
eventemitter3's eventNames() only lists events with listeners.
gmaclennan added a commit to digidem/comapeo-core-react-native that referenced this pull request Aug 13, 2026
… backend restart

When Android kills and restarts the :ComapeoCore service, the sockets
now reconnect (PR #225) — but in-flight RPC calls still hung until the
30s timeout, and the restarted backend had lost every event
subscription, so listeners went permanently deaf.

The message socket now reports its connection state to JS as a
transportStateChange event (declared on iOS for parity; never fires
there — in-process Node death ends the app). On a drop, the module
calls @comapeo/ipc's transport-reset helpers: in-flight calls reject
with TransportClosedError (code RPC_TRANSPORT_CLOSED, re-exported
here) so callers can tell "backend restarted, a read is safe to
retry" from a real failure; subscriptions on the long-lived channels
are re-sent through the native send queue; stale per-project clients
are hard-closed so the next getProject mints a working one.

subscribeToBackendRestart() fires once the backend is STARTED again
after a drop — wire it to @comapeo/core-react's new
subscribeToBackendRestart provider prop so its query caches re-fetch.
docs/ARCHITECTURE.md §5.8 documents the recovery layers and the
host-app state (module-scope captures in comapeo-mobile) that recovery
cannot reach.

Requires @comapeo/ipc >= the release containing
digidem/comapeo-ipc#87 (which itself needs digidem/rpc-reflector#52);
the dependency bump lands here once released.
The @comapeo/ipc transport-reset design that needed it (hard-closing
project wrappers and firing their 'close' event locally,
digidem/comapeo-ipc#87) has been superseded by backend-owned project
lifecycle (digidem/comapeo-ipc#88/#89), where project references
survive a server restart and nothing emits locally. Reverts d64b36d
rather than rewriting history; a squash merge lands this PR with no
trace of the API.
The tests for these two methods asserted on the over-the-wire message shape
on the MessagePort — sniffing msgIds off REQUEST messages to forge a late
RESPONSE, and deep-comparing the ON messages resubscribe() emits. Rewrite
them against a real client/server pair following the pattern in e2e.test.js,
so they run over both a real MessageChannel and the MessagePort-like fake.

The late-response case now has the server answer a call that rejectPending()
has already given up on, and resubscribe() is checked by restarting the
server and asserting the server-side emitters regain exactly the expected
listeners and that events reach the client again.
@gmaclennan
gmaclennan merged commit b584d3f into main Aug 19, 2026
8 checks passed
@gmaclennan
gmaclennan deleted the feat/reject-pending-and-resubscribe branch August 19, 2026 11:27
gmaclennan added a commit to digidem/comapeo-core-react-native that referenced this pull request Aug 20, 2026
… backend restart

When Android kills and restarts the :ComapeoCore service, the sockets
now reconnect (PR #225) — but in-flight RPC calls still hung until the
30s timeout, and the restarted backend had lost every event
subscription, so listeners went permanently deaf.

The message socket now reports its connection state to JS as a
transportStateChange event (declared on iOS for parity; never fires
there — in-process Node death ends the app). On a drop, the module
calls @comapeo/ipc's transport-reset helpers: in-flight calls reject
with TransportClosedError (code RPC_TRANSPORT_CLOSED, re-exported
here) so callers can tell "backend restarted, a read is safe to
retry" from a real failure; subscriptions on the long-lived channels
are re-sent through the native send queue; stale per-project clients
are hard-closed so the next getProject mints a working one.

subscribeToBackendRestart() fires once the backend is STARTED again
after a drop — wire it to @comapeo/core-react's new
subscribeToBackendRestart provider prop so its query caches re-fetch.
docs/ARCHITECTURE.md §5.8 documents the recovery layers and the
host-app state (module-scope captures in comapeo-mobile) that recovery
cannot reach.

Requires @comapeo/ipc >= the release containing
digidem/comapeo-ipc#87 (which itself needs digidem/rpc-reflector#52);
the dependency bump lands here once released.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants