Skip to content

feat: add transport-reset handling for server restarts - #87

Closed
gmaclennan wants to merge 2 commits into
mainfrom
feat/transport-reset
Closed

feat: add transport-reset handling for server restarts#87
gmaclennan wants to merge 2 commits into
mainfrom
feat/transport-reset

Conversation

@gmaclennan

@gmaclennan gmaclennan commented Aug 13, 2026

Copy link
Copy Markdown
Member

@comapeo/core-react-native runs the CoMapeo backend 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, calls that were in flight hang until they time out, and cached per-project clients are bound to a server that no longer exists. This PR gives the transport owner the hooks to recover. Desktop/Electron consumers are unaffected (additive API).

The recovery contract is two-phase: notify at drop time, resubscribe once the transport is back up. Resubscribing at drop time would write ON frames into a still-down transport, which can nudge the native connection into a hot retry loop while the backend stays down.

New exports:

TransportClosedError (errors.js) with a stable code of 'RPC_TRANSPORT_CLOSED' — rejected into calls that were in flight when the transport dropped, so callers can distinguish "backend restarted; a read is safe to retry" from a real failure or a timeout.

notifyCoreClientTransportReset(client) — call at drop time. It rejects pending calls on the manager and project-routing clients with TransportClosedError, then hard-closes every open project client and drops openProjectClients / currentProjectClients, so a later getProject builds a fresh wrapper against the new server. Each hard close rejects the wrapper's pending calls, fires its 'close' event locally (via rpc-reflector's new createClient.emitLocal) so app-held once('close') teardown listeners and the wrapper's internal cache-eviction listener run — matching a server-initiated close — and then closes the client and its SubChannel. No-op after closeComapeoCoreClient.

resubscribeCoreClient(client) — call once the transport has reconnected to the restarted server. Replays manager and project-routing event subscriptions (via rpc-reflector's createClient.resubscribe). Safe to call repeatedly (the server dedupes subscriptions); no-op after close.

notifyServicesClientTransportReset(servicesClient) / resubscribeServicesClient(servicesClient) — the same reject-at-drop / resubscribe-at-recovery pair for the (single, bare) services client.

Closed-proxy behaviour is also relaxed for teardown: off / removeListener / removeAllListeners on a closed client or stale project reference are chainable no-ops instead of throwing, because removing a listener from a dead client is correct cleanup (React effect cleanup in @comapeo/core-react runs against stale wrappers). Subscribe methods (on / once / addListener) still throw synchronously.

The hard close of project wrappers is forced by the instance-id scheme: instance ids are minted by a deterministic counter (projectId:++n) that restarts with the server, so a restarted server can mint the same id a cached wrapper is bound to, and the "is the cached wrapper still current" check in resolveProjectClient would falsely pass. Stale project references held by the app behave like closed projects afterwards (they reuse the existing closed-proxy behaviour and reject with ProjectClosedError, whose "get a fresh reference via getProject" remedy is exactly right here); close() on a stale reference resolves. A getProject in flight during the reset rejects with TransportClosedError (including the narrow race where the routing response arrived just before the reset, guarded by a reset generation counter), and a retry returns a working client.

Depends on digidem/rpc-reflector#52 — this PR should merge after that one lands and is released, followed by bumping the rpc-reflector range here (the committed package.json still points at the current release, so CI will fail on the new-API tests until then).

When the process hosting the server dies and restarts while the client
stays alive (the Android foreground-service restart case), calls that
were in flight hang until timeout, the restarted server has lost every
event subscription, and cached per-project clients are bound to a dead
server. Add notifyCoreClientTransportReset and
notifyServicesClientTransportReset, which reject in-flight calls with a
new TransportClosedError (code RPC_TRANSPORT_CLOSED), replay event
subscriptions, and hard-close all project wrappers.

Project wrappers must be hard-closed rather than revalidated: instance
ids come from a counter that restarts with the server, so a restarted
server can mint an id identical to the one a cached wrapper is bound to
and the instance-id currency check would falsely pass. Stale project
references behave like closed projects afterwards; a getProject in
flight during the reset rejects with TransportClosedError.

Requires rpc-reflector rejectPending/resubscribe
(digidem/rpc-reflector#52).
…nsubscribe on closed proxies

Review follow-ups:

- Resubscribing at drop time writes ON frames into a still-down
  transport, which can nudge the native connection into a hot retry loop
  while the server stays down. notifyCoreClientTransportReset and
  notifyServicesClientTransportReset now only reject in-flight calls;
  new resubscribeCoreClient / resubscribeServicesClient replay event
  subscriptions once the transport is back up. Both are safe to call
  repeatedly (the server dedupes subscriptions) and no-ops after close.

- hardClose now fires the project wrapper's 'close' event locally
  (via the new createClient.emitLocal) before tearing the client down,
  so app-held once('close') teardown listeners and the wrapper's own
  cache-eviction listener run, matching a server-initiated close.

- Unsubscribe methods (off / removeListener / removeAllListeners) on a
  closed client or project reference are now chainable no-ops instead
  of throwing: removing a listener from a dead client is correct
  teardown (React effect cleanup runs against stale wrappers).
  Subscribe methods still throw.
@gmaclennan

Copy link
Copy Markdown
Member Author

Pushed ff2f4bf addressing the review (companion rpc-reflector change in digidem/rpc-reflector@d64b36d on #52's branch):

  • Resubscription split out of reset. Replaying ON frames at drop time can nudge the native transport into a hot retry loop while the backend is still down, so notifyCoreClientTransportReset / notifyServicesClientTransportReset now only reject in-flight calls (plus the hard close of project wrappers on the core client). New resubscribeCoreClient / resubscribeServicesClient replay subscriptions once the transport is back up; both are safe to call repeatedly (the server dedupes ON) and are no-ops after close. Contract: notify at drop, resubscribe at recovery.
  • Hard close now notifies. Before tearing a project wrapper down, its 'close' event fires locally via rpc-reflector's new createClient.emitLocal (no server exists to send it), so app-held once('close') teardown listeners and the internal cache-eviction listener run exactly as on a server-initiated close.
  • Unsubscribe on closed proxies no longer throws. off / removeListener / removeAllListeners on a closed client or stale project reference are chainable no-ops — removing a listener from a dead client is correct teardown (React effect cleanup runs against stale wrappers and was crashing mid-commit). Subscribe methods still throw.

Tests cover: reset does not resubscribe, resubscribe replays and is idempotent/no-op-after-close, hard close fires 'close' exactly once (with .off from inside the listener), and closed-proxy unsubscribe no-ops with subscribe still throwing. PR description updated to the new API shape.

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.
gmaclennan added a commit to digidem/rpc-reflector that referenced this pull request Aug 15, 2026
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.
gmaclennan added a commit to digidem/rpc-reflector that referenced this pull request Aug 19, 2026
* feat: add createClient.rejectPending and createClient.resubscribe

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.

* feat: add createClient.emitLocal for local-only event delivery

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.

* revert: drop createClient.emitLocal — no remaining consumer

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.

* docs: document createClient.rejectPending and createClient.resubscribe

* test: cover rejectPending and resubscribe via the public 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

Copy link
Copy Markdown
Member Author

Superseded by #88, which makes per-project channels stable across restarts — so recovery no longer needs the hard-close/generation machinery this PR added; the reject-pending/resubscribe halves now ride on #88 directly.

@gmaclennan gmaclennan closed this Aug 20, 2026
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