Skip to content

fix(client): adopt a replaced client after a reconnect - #18

Open
ahinoamp wants to merge 1 commit into
Kitware:mainfrom
ahinoamp:fix/adopt-replaced-client
Open

ahinoamp wants to merge 1 commit into
Kitware:mainfrom
ahinoamp:fix/adopt-replaced-client

Conversation

@ahinoamp

Copy link
Copy Markdown
Contributor

The problem

DataclassCore.connect(client) opens with

if (this.client || !client) return;

and is called only from the client components on mount. A reconnect does not reconnect a
client: trame-client builds a new one, on a new wslink session, and puts it on
trame.client. A component that stays mounted never calls connect again, and even if
it did, that first line refuses the replacement.

So after a reconnect the manager is still holding the retired client:

  • its trame.dataclass.publish subscription is on a session that is gone, so server
    deltas stop arriving
    ;
  • flushToServer calls this.client.getConnection().getSession().call(...) on that same
    dead session, so every client edit is pushed into a socket nobody is listening to;
  • pendingFetch, pendingDefinition and completeInstances still describe the retired
    server, so nothing is fetched again.

Measured in a browser against a real trame app: after dropping the socket and letting
<trame-reconnect> rebuild the connection, the page renders and interacts normally, a
server-side change to a dataclass field never reaches the page (polled 25 s), and two
clicks on a dataclass-bound checkbox change neither the page nor the server. A plain
reload of the same page recovers it, which is what identifies the client manager rather
than the application as the thing that is stuck.

What the patch does

  • connect adopts a replacement, and attach(trame) subscribes to the channel that
    announces one (trame.addConnectListener by default, overridable with
    clientChannel). Mount is not a reliable signal; the client identity change is.
  • retire() drops everything that can still speak for an instance: in-flight
    fetches, definition requests, publication chains, the completeness set, the schema the
    retired server published (a replacement may be a different process with different
    fields), the server-state cache, and both gates. Finished data is kept — a snapshot
    does not become wrong because a socket did.
  • Every unit of work carries the session it started on and re-checks it at each
    await, so a reply already on the wire when the connection was replaced writes nothing.
    Related: fetchInstance's failure cleanup became conditional on still owning the id, so
    a retired failure cannot evict the fresh fetch that replaced it.
  • The concurrency gate is per connection and the retired one's queue is woken on
    retirement. wslink does not reject the calls a dead socket swallowed, so a waiter on
    that queue would otherwise hang forever.
  • A failed push is three cases, and they want three different things. wslink
    rejects a call made with no socket using its own CLIENT_ERROR (-32099) and no
    data; a server that RAN the call and raised answers with its own code
    (EXCEPTION_ERROR, ...). Only the first can be helped by sending again, so only the
    first is re-queued — an edit is acknowledged by the server, not by a socket having
    accepted it, and leaving the cache saying "already sent" is how a typed value
    disappears with no request and no error. A batch the server REFUSED is dropped,
    because re-queueing it is how one refused field poisons the page: it returns to the
    head of the queue, every later edit of every instance merges into it, and the server
    refuses the merged batch for the same reason, forever. A batch whose session retired
    is abandoned, its outcome being unknowable and the replacement's snapshot
    authoritative. The write-back cache is rolled back in the first two, so the client
    never claims the server holds a value it does not — and in the refused case the
    server's value also goes back into the VIEW, through applyState, so a control does
    not keep showing an edit that was rejected while the next refresh silently changes it
    back. Two fields are deliberately left alone: one with no previous value in the cache,
    where there is nothing to revert to, and one the user has edited again since the batch
    left, which is both what they are looking at and what the next flush sends.
  • Nothing is pushed for an instance the live connection has not described. Between
    retirement and the replacement's definition.get, typeDefinitions is empty while
    the instance ids still name their classes — so isClientOnly and isDataClass both
    answer "no", and a CLIENT_ONLY field is sent to the server for the first time in
    its life. The guard belongs in the writer, and it costs nothing real: the re-fetch
    that follows a reconnect refreshes those values from the authoritative snapshot.
  • A retired session resolves, it does not reject. A framework callback that awaits
    its own hydration has nowhere to put an exception — in Vue it surfaces as an
    unhandled rejection in the page — and a retired session is not a failure, because the
    re-link recovers it. The error carries a retiredSession mark so a caller tests a
    property rather than matching a message, and link / getGUITemplate resolve.
  • connectionReplaced() is the subclass hook. The Vue manager re-links every mounted
    component; the React one re-fetches every instance with listeners. A provider cannot
    notice a reconnect itself — its instance prop never changed.
  • The Vue client fills the same refs object and the same ref cells on a re-fetch. A
    component's data, a parent's shared reactive object and the trigger that pushes an
    edit all hold those cells; building new ones leaves the view bound to cells nothing
    writes to. For the same reason getTrigger reads the live cell instead of closing over
    one, and an outbound watcher is registered at most once per field.

Tests

client-core/tests/reconnect.test.js (17 cases) and vue-components/tests/reconnect.test.js
(7). The shared harness grows a second session and a reconnect(), unsubscribe,
per-session call recording, and transportFailure() / serverFailure() helpers so a
test has to say which kind of failure it means. One existing assertion moves from
manager.activeCalls to manager.gate.active with the per-connection gate.

Three component imports gain their .js extension. Vite resolves either spelling, but
Node's ESM loader resolves only the explicit one, so without it node --test cannot
import a component at all — which is why the component half had no coverage.

Mutations, each run: restoring if (this.client) return; fails 8 of the 14 core cases;
dropping the Vue re-link, and separately building fresh ref cells per fetch, each fail
"a mounted provider is re-hydrated on the same cells after a reconnect"; re-queueing
on any live-session failure, skipping the cache rollback on a refusal, dropping the
schema guard, and re-raising a retired-session error out of link or getGUITemplate
each fail exactly the case that names them; so does each of the revert's three clauses.
Removing the cache restore under the revert is the loud one — the Vue suite does not
fail, it runs away (echo, refusal, revert, echo) until the case timeout stops it, which
is the second reason that timeout is worth having.

--test-timeout=20000 is added to the three npm test scripts: node's default is
infinite, and a hung case hangs the run rather than failing it — measured on Node
26.5.0, a test awaiting a promise that never settles was still going when an external
timeout 60 killed it, printing nothing.

Scope, stated plainly

The React half gets the core adoption and a connectionReplaced that re-fetches what is
subscribed; it has not been exercised in a browser the way the Vue half has.

The Vue half was: a real trame app, socket dropped with
session.close() and rebuilt through <trame-reconnect>. Without the patch a
server-side change to a dataclass field never reaches the page (polled 25 s) and two
clicks on a dataclass-bound control change neither the page nor the server; with it both
directions work, twice over, with no unhandled rejection in the console.

Relation to other PRs

Independent of #16 (a delta for a collected model) and #17 (prefetch). It touches client-core/src/index.js near #17's addition, so whichever lands second needs a trivial rebase.

🤖 Generated with Claude Code

DataclassCore.connect() kept the first client forever and was only called
on mount. After a reconnect trame-client builds a new client on a new
wslink session, so server deltas stopped arriving and client edits were
pushed to the dead session.

- connect() adopts a replacement; attach(trame) listens for it.
- retire() drops what the old connection owned (in-flight fetches,
  definitions, publication chains, completeness, schema, server-state
  cache, gates); every unit of work re-checks its session after each await.
- The concurrency gate is per connection; retired waiters are woken.
- A failed push: transport failure (-32099) is rolled back and re-queued;
  a server refusal is rolled back, dropped, and reverted in the view;
  a retired session is abandoned.
- Nothing is pushed for an instance the live connection has not described.
- A retired session resolves rather than rejecting in link/getGUITemplate.
- connectionReplaced(): Vue re-links mounted components on the same ref
  cells; React re-fetches subscribed instances.
- npm test scripts get --test-timeout=20000; component imports gain .js
  so node --test can import them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant