Repository navigation
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
DataclassCore.connect(client)opens withand 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 callsconnectagain, and even ifit did, that first line refuses the replacement.
So after a reconnect the manager is still holding the retired client:
trame.dataclass.publishsubscription is on a session that is gone, so serverdeltas stop arriving;
flushToServercallsthis.client.getConnection().getSession().call(...)on that samedead session, so every client edit is pushed into a socket nobody is listening to;
pendingFetch,pendingDefinitionandcompleteInstancesstill describe the retiredserver, 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, aserver-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
connectadopts a replacement, andattach(trame)subscribes to the channel thatannounces one (
trame.addConnectListenerby default, overridable withclientChannel). Mount is not a reliable signal; the client identity change is.retire()drops everything that can still speak for an instance: in-flightfetches, 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.
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, soa retired failure cannot evict the fresh fetch that replaced it.
retirement. wslink does not reject the calls a dead socket swallowed, so a waiter on
that queue would otherwise hang forever.
rejects a call made with no socket using its own
CLIENT_ERROR(-32099) and nodata; 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 thefirst 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 doesnot 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.
retirement and the replacement's
definition.get,typeDefinitionsis empty whilethe instance ids still name their classes — so
isClientOnlyandisDataClassbothanswer "no", and a
CLIENT_ONLYfield is sent to the server for the first time inits 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.
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
retiredSessionmark so a caller tests aproperty rather than matching a message, and
link/getGUITemplateresolve.connectionReplaced()is the subclass hook. The Vue manager re-links every mountedcomponent; the React one re-fetches every instance with listeners. A provider cannot
notice a reconnect itself — its
instanceprop never changed.component's
data, a parent's shared reactive object and the trigger that pushes anedit all hold those cells; building new ones leaves the view bound to cells nothing
writes to. For the same reason
getTriggerreads the live cell instead of closing overone, and an outbound watcher is registered at most once per field.
Tests
client-core/tests/reconnect.test.js(17 cases) andvue-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 atest has to say which kind of failure it means. One existing assertion moves from
manager.activeCallstomanager.gate.activewith the per-connection gate.Three component imports gain their
.jsextension. Vite resolves either spelling, butNode's ESM loader resolves only the explicit one, so without it
node --testcannotimport 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
linkorgetGUITemplateeach 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=20000is added to the threenpm testscripts: node's default isinfinite, 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 60killed it, printing nothing.Scope, stated plainly
The React half gets the core adoption and a
connectionReplacedthat re-fetches what issubscribed; 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 aserver-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 touchesclient-core/src/index.jsnear #17's addition, so whichever lands second needs a trivial rebase.🤖 Generated with Claude Code