diff --git a/client-core/package.json b/client-core/package.json index 26e19fa..1b774b3 100644 --- a/client-core/package.json +++ b/client-core/package.json @@ -5,6 +5,6 @@ "type": "module", "description": "Framework-agnostic client core bundled into the Vue and React components", "scripts": { - "test": "node --test tests/*.test.js" + "test": "node --test --test-timeout=20000 tests/*.test.js" } } diff --git a/client-core/src/index.js b/client-core/src/index.js index 1d0a74b..1cbe45d 100644 --- a/client-core/src/index.js +++ b/client-core/src/index.js @@ -50,6 +50,10 @@ export class DataclassCore { constructor() { this.client = null; this.subscription = null; + // The session every piece of work below belongs to. A reconnect replaces it, + // and comparing it is how a late settlement knows it is speaking for a server + // this page no longer talks to. + this.session = null; this.dataTypes = {}; // instance id -> class id this.typeDefinitions = {}; // class id -> definition, its field lists as sets // instance id -> field -> JSON of the value the server is known to hold, so @@ -62,12 +66,22 @@ export class DataclassCore { this.pendingPublications = {}; this.completeInstances = new Set(); this.pendingClientServerQueue = []; - this.pendingFlushRequest = 0; + // `{ session }` while a `state.update` is in flight, or null. The gate has an + // OWNER: a batch handed to a transport that then died waits for an answer that + // cannot arrive, and an unowned gate made every later edit wait with it. + this.activeFlush = null; + // The session a push last failed against, so held edits wait for a new + // connection instead of spinning against the dead one. + this.failedPushSession = null; // At most this many state and definition requests in flight: a graph of thousands // of instances queues behind it instead of flooding the server. this.maxConcurrentCalls = 16; - this.activeCalls = 0; - this.queuedCalls = []; + // The concurrency gate, recreated per connection. See `callServer`. + this.gate = this.newGate(null); + // The `trame` objects whose client channel is already subscribed to. Weak, + // because that subscription lives as long as the object and must not be what + // keeps it alive. + this.attached = new WeakSet(); } buildInstance() { @@ -84,25 +98,163 @@ export class DataclassCore { graphCompleted() {} + // A reconnect has replaced the connection and every cache above has been + // retired. Whatever a subclass is SHOWING has to be fetched again -- a provider + // cannot notice a reconnect itself, its instance never changed -- and anything + // not on screen is fetched by whoever next asks for it. + connectionReplaced() {} + + // Subscribe to a `trame` object's client and to its REPLACEMENTS, and connect + // whichever client is there now. Idempotent per object. + // + // A reconnect does not reconnect a client: it builds a new one and puts it on + // `trame.client`. Mount is therefore not a reliable signal -- a component that + // stays mounted across the reconnect never calls `connect` again -- so the + // channel that reports the identity change is what this listens to. + attach(trame) { + if (!trame) return; + this.connect(trame.client); + if (this.attached.has(trame)) return; + const [owner, subscribe] = this.clientChannel(trame); + if (typeof subscribe !== "function") return; + this.attached.add(trame); + subscribe.call(owner, () => this.connect(trame.client)); + } + + // Where "the client may have been replaced" is announced, as `[owner, method]`. + clientChannel(trame) { + return [trame, trame.addConnectListener]; + } + connect(client) { - if (this.client || !client) return; + if (!client) return; + // ADOPT a replacement, rather than keeping the first client for the life of + // the page. A core that kept it goes on asking a dead socket for everything: + // its session never appears to change, so none of the retirement below ever + // runs, the publication subscription stays on the retired transport, and a + // client edit is pushed into a connection nobody is listening to. The view + // comes back after a reconnect and editing does not. this.client = client; + const session = client.getConnection().getSession(); + if (this.session === session) return; - this.subscription = client - .getConnection() - .getSession() - .subscribe("trame.dataclass.publish", ([event]) => - this.enqueuePublication(event), - ); + const retired = this.session; + const retiredGate = this.gate; + if (retired && this.subscription && retired.unsubscribe) { + try { + retired.unsubscribe(this.subscription); + } catch (error) { + // A closed transport may refuse this; its subscription dies with it. + console.warn( + "Dataclass could not release a retired subscription", + error, + ); + } + } + this.session = session; + this.gate = this.newGate(session); + // The session is CAPTURED, not read when the event is delivered: unsubscribing + // cannot recall a callback that is already queued, and that callback has to be + // identifiable as retired when it runs. + this.subscription = session.subscribe( + "trame.dataclass.publish", + ([event]) => this.enqueuePublication(event, session), + ); + if (retired) this.retire(retiredGate); } - enqueuePublication(event) { + // A replaced connection retires every claim the old one made. Finished DATA is + // not session-scoped -- a snapshot does not become wrong because a socket did -- + // but everything that can still SPEAK for an instance is: the fetches and + // definition requests in flight, the publication chains, the completeness claim, + // the concurrency gate and the outbound gate. Two rules cover all of them: + // fresh work never waits behind retired work, and a late settlement from a + // retired session writes nothing. + retire(retiredGate) { + // Wake everything queued on the retired gate: each one refuses itself at the + // check in `callServer` and hands the slot on. Nothing else would wake them -- + // wslink does not reject the calls a dead socket swallowed, so the requests + // ahead of them in that queue may never settle at all. + retiredGate.queued.splice(0).forEach((resolve) => resolve()); + this.pendingFetch = {}; + this.pendingDefinition = {}; + // Chains, not just entries: an event still waiting on the old transport's + // child fetch would otherwise hold every fresh event for that instance behind + // it, with nothing left to settle it. + this.pendingPublications = {}; + // A class definition belongs to the server that published it. A replacement + // may be a different process, and a field its schema now calls a dataclass + // container would otherwise be read with the retired server's shape. + // + // `dataTypes` is deliberately KEPT -- it is data, like a snapshot, and the + // replacement overwrites it on the next fetch. What matters is that the two are + // read together: `updateServer` refuses to push anything for an instance whose + // class is not in `typeDefinitions`, so the window where the id names a class + // nobody can describe is a window where nothing is sent, rather than one where + // every field looks like an ordinary server field. + this.typeDefinitions = {}; + // "Complete" is a claim about a graph on a server; the replacement has sent + // none of it yet, so every graph is walked again. + this.completeInstances.clear(); + // What the server holds is unknown until it says so again. Keeping it would + // suppress an edit as redundant against a value only the retired server had. + this.serverState = {}; + // The outbound gate's batch was handed to a transport whose answer can never + // arrive, and the new connection is exactly the one that can carry a push the + // old one lost. + this.activeFlush = null; + this.failedPushSession = null; + this.connectionReplaced(); + if (this.pendingClientServerQueue.length) this.flushToServer(); + } + + // One concurrency gate per connection: a slot held by a request the old + // transport will never answer must not count against the new one, and a waiter + // admitted to the retired queue must be woken rather than left hanging there. + newGate(session) { + return { session, active: 0, queued: [] }; + } + + // MARKED, not matched on its message: a caller that recovers from this by asking + // the live connection again has to be able to tell it from a real failure, and a + // retired session is not one -- nothing was lost and nothing needs reporting. + retiredError(what) { + const error = new Error(`Dataclass ${what} belongs to a retired session`); + error.retiredSession = true; + return error; + } + + // Is this rejection the client's own "I have no socket", rather than an answer? + // wslink rejects a call made while the connection is down with its CLIENT_ERROR + // code (-32099) and no `data`; a server that ANSWERED with a failure sends its own + // code (EXCEPTION_ERROR -32001, AUTHENTICATION_ERROR -32000, ...). The difference + // decides whether resending can possibly help -- see `flushToServer`. + static CLIENT_ERROR = -32099; + + isTransportFailure(error) { + return error?.code === DataclassCore.CLIENT_ERROR; + } + + enqueuePublication(event, session = this.session) { + // Delivered by a transport this page no longer has: it describes a server + // that is gone, and unsubscribing could not recall it. + if (session !== this.session) return; const { id } = event; // A delta only needs this instance's own state, not the instances it points at. const hydration = this.fetchInstance(id); + // Started before it is known whether this event will still be current when its + // turn comes. If it is not, nothing awaits it, and a rejection would be + // reported against the page as an unhandled one. + hydration.catch(() => {}); const previous = this.pendingPublications[id] || Promise.resolve(); const next = previous.then(async () => { + // Checked on ENTRY is not enough: this body runs behind the instance's + // existing chain and behind its own hydration, and the connection can be + // replaced in either gap. An event admitted while the old transport was + // current must still write nothing once it is not. + if (session !== this.session) return; if (!(await hydration)) return; + if (session !== this.session) return; const { state, revision } = event; // The snapshot, or an earlier delta, already holds this one. That includes the // publication that announced the instance and started its fetch. @@ -116,6 +268,7 @@ export class DataclassCore { this.fetchInstanceRecursive(childId), ), ); + if (session !== this.session) return; // A whole asynchronous publication owns the instance until its referenced // instances are complete, and the commit below is synchronous. No newer @@ -175,6 +328,16 @@ export class DataclassCore { // A client edit. A dataclass field may hold the client's own records; they are // sent, and remembered as references, by id. updateServer(id, name, value) { + // Nothing is pushed for an instance whose SHAPE the live connection has not + // described. Every question this method asks -- is the field client-only, is it + // a dataclass container -- is answered out of `typeDefinitions`, and a missing + // schema answers "no" to all of them: between `retire()` and the replacement's + // `definition.get` landing, a client_only field would be sent to the server for + // the first time in its life. Refusing is the safe direction, and it costs + // nothing real: the re-hydration that follows a reconnect refreshes these cells + // from the authoritative snapshot, so a value pushed from inside that window is + // one the user is about to stop seeing anyway. + if (!this.typeDefinitions[this.dataTypes[id]]) return; if (this.isClientOnly(id, name)) return; if (this.isDataClass(id, name)) { value = serializeDataClass(value); @@ -185,19 +348,25 @@ export class DataclassCore { } async flushToServer() { - if (this.pendingFlushRequest) { + const session = this.session; + // The gate has an OWNER, so a gate held by a retired session is simply not + // this session's gate and does not hold a fresh edit back. + if (this.activeFlush && this.activeFlush.session === session) { return; } - this.pendingFlushRequest++; + const flush = { session }; + this.activeFlush = flush; // Edits queued while a request was in flight: the last value of each field // wins, and a field whose value the server already holds is not sent. Comparing // each edit in turn instead would drop X -> Y -> X as "X is already there" // while still sending Y. const msg = {}; - for (const [id, name, value] of this.pendingClientServerQueue.splice(0)) { + const sent = this.pendingClientServerQueue.splice(0); + for (const [id, name, value] of sent) { (msg[id] ||= {})[name] = value; } let sendingSomething = 0; + const rollback = {}; for (const [id, fields] of Object.entries(msg)) { const known = (this.serverState[id] ||= {}); for (const [name, value] of Object.entries(fields)) { @@ -205,6 +374,7 @@ export class DataclassCore { if (known[name] === encoded) { delete fields[name]; } else { + (rollback[id] ||= {})[name] = known[name]; known[name] = encoded; sendingSomething++; } @@ -213,17 +383,116 @@ export class DataclassCore { } if (sendingSomething) { try { - await this.client - .getConnection() - .getSession() - .call("trame.dataclass.state.update", [msg]); + await session.call("trame.dataclass.state.update", [msg]); } catch (error) { - console.error("Network error when pushing client state", error); + // What a failed push means depends on WHY it failed, and the three cases + // want three different things. The cache is rolled back in two of them, + // because it says "the server already holds this" and in both it does not; + // the QUEUE is only restored when resending can actually help. + const restoreCache = () => { + for (const [id, fields] of Object.entries(rollback)) { + for (const [name, previous] of Object.entries(fields)) { + if (previous === undefined) { + delete this.serverState[id][name]; + } else { + this.serverState[id][name] = previous; + } + } + } + }; + // ...and the cache is only half of it: the VIEW still shows what the user + // typed, which nobody holds. Put the server's own value back where there is + // one, through `applyState` -- the same server-origin path a publication + // takes, so a client that distinguishes the two (React's `setField` versus + // `userSetField`) treats this as what it is. + // + // What keeps that write from bouncing straight back out is the CACHE, not + // the order of these two calls: Vue's outbound watcher wakes a tick later + // and finds the restored value already there, so its echo is dropped as + // redundant. Measured: skip `restoreCache` and the Vue suite does not merely + // fail, it RUNS AWAY -- the echo is sent, refused, reverted, echoed again, + // and the file times out. React never reaches `updateServer` at all from + // this path. + const revertView = () => { + for (const [id, fields] of Object.entries(rollback)) { + if (!this.serverState[id]) continue; + const held = {}; + for (const [name, previous] of Object.entries(fields)) { + // No previous value is no value to show: that field was not in the + // cache, so there is nothing to revert TO and inventing one would be + // worse than leaving what the user typed. + if (previous === undefined) continue; + // A field the user has edited AGAIN since this batch left is not + // reverted. That newer edit is both what they are looking at and what + // the next flush sends; replacing it with an older server value would + // undo a keystroke the server has not even been told about yet. + if ( + this.pendingClientServerQueue.some( + ([queuedId, queuedName]) => + queuedId === id && queuedName === name, + ) + ) { + continue; + } + held[name] = JSON.parse(previous)[0]; + } + if (!Object.keys(held).length) continue; + this.recordReferences(id, held); + this.applyState(id, held); + } + }; + if (this.session !== session) { + // RETIRED before it settled: the outcome is unknowable and the + // replacement's snapshot is authoritative for what the server holds. + // Rolling the cache back would make a NEWER edit look redundant against + // a stale value, and re-queueing would push the dead transport's values + // over the live one -- both lose the user's latest intent. + console.error( + "Dropping a state update that belonged to a retired session", + error, + ); + } else if (this.isTransportFailure(error)) { + // The CLIENT refused to send: there is no socket. The outcome is known -- + // the server did not get it -- and an edit is acknowledged by the SERVER, + // not by a socket having accepted it, so leaving the cache saying "already + // sent" is how a value the user typed disappears with no request and no + // error. Put the cache and the queue back; it goes out on the next + // connection. + restoreCache(); + this.pendingClientServerQueue.unshift(...sent); + this.failedPushSession = session; + console.error("Network error when pushing client state", error); + } else { + // The SERVER answered, with a failure. Re-queueing here is how one + // refused field poisons the page: the batch goes back at the head of the + // queue, every later edit of every instance is merged into it, and the + // server refuses the merged batch for the same reason -- so nothing is + // ever sent again until a reconnect. Drop the batch, as upstream does, + // and roll the cache back so an identical edit can be sent again rather + // than suppressed as redundant against a value the server never took. + // + // This is also the one case where the refusal is FINAL -- there is no + // resend coming -- so it is the one case where the view has to be told: + // otherwise the control keeps showing a value the server rejected, and + // the next thing that refreshes it silently changes it back. + restoreCache(); + revertView(); + console.error( + "The server refused a state update; dropping that batch", + error, + ); + } } } - this.pendingFlushRequest--; - if (this.pendingClientServerQueue.length) { + // Only the flush that still owns the gate may open it. + if (this.activeFlush === flush) this.activeFlush = null; + if ( + this.pendingClientServerQueue.length && + this.failedPushSession !== this.session + ) { + // ...but never spin against a connection that is still down. The held queue + // goes out when `retire` sees a new one. this.flushToServer(); } } @@ -232,25 +501,29 @@ export class DataclassCore { // flight. A slot is held for one request only, never while waiting on another, so // this cannot deadlock. An `urgent` request skips the queue: a class definition // unblocks every instance of its class. + // + // The gate and the call both belong to ONE connection: a request still queued + // when the connection was replaced refuses itself rather than being issued on a + // transport its caller never asked for. async callServer(method, args, urgent = false) { - if (this.activeCalls < this.maxConcurrentCalls) { - this.activeCalls++; + const gate = this.gate; + if (gate.active < this.maxConcurrentCalls) { + gate.active++; } else { // A finishing call hands its slot straight over, so none is taken twice. await new Promise((resolve) => - urgent - ? this.queuedCalls.unshift(resolve) - : this.queuedCalls.push(resolve), + urgent ? gate.queued.unshift(resolve) : gate.queued.push(resolve), ); } try { - return await this.client.getConnection().getSession().call(method, args); + if (gate.session !== this.session) throw this.retiredError(method); + return await gate.session.call(method, args); } finally { - const next = this.queuedCalls.shift(); + const next = gate.queued.shift(); if (next) { next(); } else { - this.activeCalls--; + gate.active--; } } } @@ -263,15 +536,21 @@ export class DataclassCore { if (this.pendingFetch[id]) { return await this.pendingFetch[id]; } + const session = this.session; const pending = (async () => { const data = await this.callServer("trame.dataclass.state.get", [id]); + // A reply that was already on the wire when the connection was replaced + // describes a server this page no longer talks to: it must not be built + // into an instance the fresh session is in the middle of fetching. + if (session !== this.session) throw this.retiredError(`fetch of ${id}`); if (data.state === null) { // Not cached: the instance may exist by the time someone asks again. - delete this.pendingFetch[id]; + if (this.pendingFetch[id] === pending) delete this.pendingFetch[id]; return false; } if (!this.typeDefinitions[data.definition]) { await this.fetchDefinition(data.definition); + if (session !== this.session) throw this.retiredError(`fetch of ${id}`); } this.dataTypes[id] = data.definition; @@ -290,12 +569,17 @@ export class DataclassCore { return true; })().catch((error) => { // Forget the failed attempt, so the next request for this id starts over - // instead of every later caller re-awaiting the same rejection. - delete this.pendingFetch[id]; - delete this.dataTypes[id]; - delete this.serverState[id]; - delete this.references[id]; - this.instanceDiscarded(id); + // instead of every later caller re-awaiting the same rejection -- but only + // if it is still the attempt that owns this id. A retired session's failure + // must not evict the fresh session's fetch, which would then go out twice + // and discard whatever the first one had already built. + if (this.pendingFetch[id] === pending) { + delete this.pendingFetch[id]; + delete this.dataTypes[id]; + delete this.serverState[id]; + delete this.references[id]; + this.instanceDiscarded(id); + } throw error; }); this.pendingFetch[id] = pending; @@ -311,6 +595,7 @@ export class DataclassCore { if (this.completeInstances.has(id)) { return true; } + const session = this.session; if (!(await this.fetchInstance(id))) { return false; } @@ -329,6 +614,8 @@ export class DataclassCore { } level = next; } + // Completeness is a claim about the connection the walk ran on. + if (session !== this.session) throw this.retiredError(`graph of ${id}`); // An id the server does not know is not an instance, complete or otherwise. const completed = [...seen].filter((seenId) => this.serverState[seenId]); completed.forEach((completedId) => this.completeInstances.add(completedId)); @@ -355,11 +642,16 @@ export class DataclassCore { } async callDefinition(id) { + const session = this.session; const data = await this.callServer( "trame.dataclass.definition.get", [id], true, ); + // A retired session does not get to describe this page's classes: installing + // its schema over the live one turns a field the live server calls a dataclass + // container back into a raw id string that nothing hydrates. + if (session !== this.session) throw this.retiredError(`definition ${id}`); this.typeDefinitions[id] = { ...data, dataclass_containers: toSet(data.dataclass_containers), diff --git a/client-core/tests/core.test.js b/client-core/tests/core.test.js index b3b1d49..d6c4531 100644 --- a/client-core/tests/core.test.js +++ b/client-core/tests/core.test.js @@ -326,7 +326,7 @@ test("requests in flight never exceed maxConcurrentCalls", async () => { f.manager.maxConcurrentCalls = 4; await f.manager.fetchInstanceRecursive("parent"); assert.equal(f.maxInFlight(), 4); - assert.equal(f.manager.activeCalls, 0); + assert.equal(f.manager.gate.active, 0); assert.equal(f.value("child39", "value"), "child39"); // The definition jumps the queue rather than waiting behind every child: it goes // after the parent, its definition, the first four children and the four handed diff --git a/client-core/tests/harness.js b/client-core/tests/harness.js index 2cbe505..e9fb492 100644 --- a/client-core/tests/harness.js +++ b/client-core/tests/harness.js @@ -14,6 +14,21 @@ export function deferred() { export const tick = () => new Promise((resolve) => setImmediate(resolve)); +// What wslink's client rejects with when it is asked to call on a closed socket: +// its own CLIENT_ERROR code, and no `data`, because no server was involved. +export const transportFailure = (method = "trame.dataclass.state.update") => ({ + code: -32099, + message: `RPC call ${method} unsuccessful: connection not open`, +}); + +// ...and what a server that RAN the call and raised sends back: EXCEPTION_ERROR, +// with the method and traceback it failed in. +export const serverFailure = (method = "trame.dataclass.state.update") => ({ + code: -32001, + message: "Exception raised", + data: { method, exception: "ValueError", trace: ["..."] }, +}); + export const definition = (containers = [], extra = {}) => ({ dataclass_containers: containers, client_only: [], @@ -38,50 +53,92 @@ export function connect( ) { const calls = []; let inFlight = 0, - maxInFlight = 0, - publication; - const session = { - async call(name, args) { - calls.push([name, args]); - maxInFlight = Math.max(maxInFlight, ++inFlight); - try { - // Reply asynchronously, as a server would, so requests can overlap. - await Promise.resolve(); - if (name === "trame.dataclass.state.get") { - const original = states[args[0]]; - if (!original) return { id: args[0], state: null }; - const snapshot = structuredClone({ - definition: original.definition, - state: original.state, - revision: original.revision, - }); - if (original.hold) await original.hold.promise; - return snapshot; - } - if (name === "trame.dataclass.definition.get") { - const result = definitions[args[0]]; - return await (result.promise || result); + maxInFlight = 0; + + // A reconnect gives the page a NEW session behind the same client object, so + // one is built per connection rather than once. + function newSession() { + const session = { + calls: [], + subscriptions: [], + unsubscribed: [], + publication: null, + // `undefined` answers normally; set it to a rejection value and every call + // rejects with it. Use `transportFailure()` for "there is no socket" and a + // `serverFailure()` for "the server answered with an error" -- the client + // treats those two as opposites, so a test must say which it means. + failure: null, + async call(name, args) { + calls.push([name, args]); + session.calls.push([name, args]); + maxInFlight = Math.max(maxInFlight, ++inFlight); + try { + // Reply asynchronously, as a server would, so requests can overlap. + await Promise.resolve(); + if (session.failure) throw session.failure; + if (name === "trame.dataclass.state.get") { + const original = states[args[0]]; + if (!original) return { id: args[0], state: null }; + const snapshot = structuredClone({ + definition: original.definition, + state: original.state, + revision: original.revision, + }); + if (original.hold) await original.hold.promise; + return snapshot; + } + if (name === "trame.dataclass.definition.get") { + const result = definitions[args[0]]; + return await (result.promise || result); + } + return null; + } finally { + inFlight--; } - return null; - } finally { - inFlight--; - } - }, - subscribe(topic, callback) { - publication = callback; - return {}; - }, + }, + subscribe(topic, callback) { + session.publication = callback; + const token = { topic }; + session.subscriptions.push(token); + return token; + }, + unsubscribe(token) { + session.unsubscribed.push(token); + }, + }; + return session; + } + + let session = newSession(); + const listeners = []; + const client = { getConnection: () => ({ getSession: () => session }) }; + // A `trame`-shaped object: `attach` connects to whatever client is on it now + // and asks to be told when that becomes a different one. + const trame = { + client, + addConnectListener: (listener) => listeners.push(listener), }; - manager.connect({ getConnection: () => ({ getSession: () => session }) }); + manager.attach(trame); return { manager, + trame, calls, maxInFlight: () => maxInFlight, - publish: (id, values, revision) => - publication([{ id, state: values, revision }]), + session: () => session, + // The client object is replaced and the channel fires, exactly as a + // reconnect does it. Returns the retired session. + reconnect: () => { + const retired = session; + session = newSession(); + trame.client = { getConnection: () => ({ getSession: () => session }) }; + listeners.forEach((listener) => listener()); + return retired; + }, + publish: (id, values, revision, on = session) => + on.publication([{ id, state: values, revision }]), // The messages sent with `state.update`, in order. - updates: () => - calls + updates: (on = null) => + (on ? on.calls : calls) .filter(([name]) => name === "trame.dataclass.state.update") .map(([, args]) => args[0]), }; diff --git a/client-core/tests/reconnect.test.js b/client-core/tests/reconnect.test.js new file mode 100644 index 0000000..60b76ff --- /dev/null +++ b/client-core/tests/reconnect.test.js @@ -0,0 +1,402 @@ +// A reconnect does not reconnect the client: trame builds a NEW one, with a new +// session behind it, and puts it on `trame.client`. A core that keeps the first +// one is left talking to a socket nobody is listening to -- publications stop +// arriving and client edits go nowhere -- which presents as a page whose view +// comes back and whose editing does not. +// +// These cases are the whole contract: the replacement is adopted, everything the +// retired connection could still speak for is retired with it, and what a late +// settlement from that connection does is nothing. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DataclassCore } from "../src/index.js"; +import { + connect, + deferred, + definition, + serverFailure, + state, + tick, + transportFailure, +} from "./harness.js"; + +class PlainManager extends DataclassCore { + constructor() { + super(); + this.values = {}; + this.replaced = 0; + // Every server-origin write, in order. An assertion on the resulting VALUE + // cannot tell a revert from a field that already held that value -- which is + // exactly the shape of a test that passes against a do-nothing client. + this.applied = []; + } + + buildInstance(id, values) { + this.values[id] = { ...values }; + } + + applyState(id, values) { + this.applied.push([id, { ...values }]); + Object.assign(this.values[id], values); + } + + connectionReplaced() { + this.replaced++; + } +} + +function fixture(states, definitions) { + const f = connect(new PlainManager(), states, definitions); + return { ...f, value: (id, key) => f.manager.values[id]?.[key] }; +} + +const quiet = (t) => t.mock.method(console, "error", () => {}); + +test("the replacement client is adopted, and the retired subscription released", async () => { + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + const retired = f.session(); + + f.reconnect(); + + assert.equal(f.manager.session, f.session()); + assert.notEqual(f.manager.session, retired); + assert.deepEqual(retired.unsubscribed, retired.subscriptions); + assert.equal(f.session().subscriptions.length, 1); + assert.equal(f.manager.replaced, 1); +}); + +test("connecting the same session again changes nothing", async () => { + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + + f.manager.attach(f.trame); + f.manager.connect(f.trame.client); + + assert.equal(f.session().subscriptions.length, 1); + assert.equal(f.session().unsubscribed.length, 0); + assert.equal(f.manager.replaced, 0); +}); + +test("a publication on the new session is applied", async () => { + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + + f.reconnect(); + await f.manager.fetchInstanceRecursive("layer"); + await f.publish("layer", { visible: true }, 1); + + assert.equal(f.value("layer", "visible"), true); +}); + +test("a publication from the retired transport writes nothing", async () => { + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + const retired = f.session(); + + f.reconnect(); + await f.publish("layer", { visible: true }, 1, retired); + + assert.equal(f.value("layer", "visible"), false); +}); + +test("a state reply still in flight when the connection is replaced builds nothing", async (t) => { + quiet(t); + const hold = deferred(); + const f = fixture({ layer: state({ visible: false }, "Layer", 0, hold) }); + const abandoned = f.manager.fetchInstanceRecursive("layer"); + await tick(); + + f.reconnect(); + hold.resolve(); + await assert.rejects(abandoned, /retired session/); + assert.equal(f.manager.values.layer, undefined); + + // ...and the id is not left owned by the attempt that was abandoned. + assert.equal(await f.manager.fetchInstanceRecursive("layer"), true); + assert.equal(f.value("layer", "visible"), false); +}); + +test("a definition from the retired session is not installed", async (t) => { + quiet(t); + const gate = deferred(); + const f = fixture({ layer: state({ visible: false }) }, { Layer: gate }); + const abandoned = f.manager.fetchInstanceRecursive("layer"); + await tick(); + + f.reconnect(); + gate.resolve(definition(["visible"])); + await assert.rejects(abandoned, /retired session/); + assert.equal(f.manager.typeDefinitions.Layer, undefined); +}); + +test("the concurrency gate is not held by the connection that died", async (t) => { + quiet(t); + const held = deferred(); + const children = ["a", "b", "c"]; + const f = fixture( + { + parent: state({ children }, "Parent"), + ...Object.fromEntries( + children.map((id) => [id, state({ value: id }, "Child", 0, held)]), + ), + }, + { Parent: definition(["children"]), Child: definition() }, + ); + f.manager.maxConcurrentCalls = 1; + const abandoned = f.manager.fetchInstanceRecursive("parent"); + abandoned.catch(() => {}); + await tick(); + + // Every slot and the whole queue belong to a transport that will never answer. + f.reconnect(); + held.resolve(); + const fresh = await f.manager.fetchInstanceRecursive("a"); + + assert.equal(fresh, true); + assert.equal(f.value("a", "value"), "a"); +}); + +test("a client edit after a reconnect goes to the new connection", async () => { + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + const retired = f.session(); + + f.reconnect(); + await f.manager.fetchInstanceRecursive("layer"); + f.manager.updateServer("layer", "visible", true); + await tick(); + + assert.deepEqual(f.updates(f.session()), [{ layer: { visible: true } }]); + assert.deepEqual(f.updates(retired), []); +}); + +test("an edit the dying connection refused is sent on the next one", async (t) => { + quiet(t); + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + const retired = f.session(); + + // The socket is gone, but nothing has told this page so yet: wslink refuses the + // call itself rather than the server answering. + retired.failure = transportFailure(); + f.manager.updateServer("layer", "visible", true); + await tick(); + assert.deepEqual(f.updates(retired), [{ layer: { visible: true } }]); + assert.equal(f.manager.pendingClientServerQueue.length, 1); + + f.reconnect(); + await tick(); + + // The value is sent again, rather than remembered as delivered: an edit is + // acknowledged by the SERVER, not by a socket having accepted it. + assert.deepEqual(f.updates(f.session()), [{ layer: { visible: true } }]); +}); + +test("an edit the retired session never answered is abandoned, not replayed", async (t) => { + quiet(t); + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + const retired = f.session(); + retired.failure = transportFailure(); + + const pushed = f.manager.updateServer("layer", "visible", true); + f.reconnect(); + await Promise.resolve(pushed); + await tick(); + + // Its outcome is unknowable and the replacement's snapshot is authoritative, + // so the dead transport's value is not pushed over the live one... + assert.deepEqual(f.updates(f.session()), []); + // ...and the gate it was holding is not this session's gate. + assert.equal(f.manager.activeFlush, null); + // An edit sent once the instance has been hydrated on the NEW connection goes out + // normally. The re-hydration is a precondition, not a formality: until the + // replacement has described the class, `updateServer` refuses to push anything for + // this instance -- see "nothing is pushed while the live connection has not + // described the instance". + await f.manager.fetchInstanceRecursive("layer"); + f.manager.updateServer("layer", "visible", true); + await tick(); + assert.deepEqual(f.updates(f.session()), [{ layer: { visible: true } }]); +}); + +test("a page with no client channel still adopts on the next connect", async () => { + const manager = new PlainManager(); + const f = connect(manager, { layer: state({ visible: false }) }); + // A `trame` that announces nothing: `attach` must not throw, and a component + // calling it again on mount is then the only adoption signal there is. + const bare = { client: f.trame.client }; + manager.attach(bare); + assert.equal(manager.session, f.session()); +}); + +test("a batch the server REFUSES is dropped, and the next edit still goes out", async (t) => { + quiet(t); + const f = fixture({ layer: state({ visible: false, opacity: 1 }) }); + await f.manager.fetchInstanceRecursive("layer"); + const session = f.session(); + const raw = session.call.bind(session); + // The server runs the call and raises on one field -- an exception in + // `update_state`, which wslink answers with EXCEPTION_ERROR. + session.call = async (name, args) => { + if ( + name === "trame.dataclass.state.update" && + "visible" in (args[0].layer || {}) + ) { + session.calls.push([name, args]); + await Promise.resolve(); + throw serverFailure(); + } + return raw(name, args); + }; + + f.manager.updateServer("layer", "visible", true); + await tick(); + f.manager.updateServer("layer", "opacity", 0.5); + await tick(); + + // Re-queueing a REFUSED batch is how one field poisons the page: it goes back at + // the head of the queue, every later edit of every instance is merged into it, and + // the server refuses the merged batch for the same reason -- forever. + const sent = f.updates(session); + assert.deepEqual(sent.at(-1), { layer: { opacity: 0.5 } }); + assert.deepEqual(f.manager.pendingClientServerQueue, []); +}); + +test("a refused batch leaves no cache claiming the server holds it", async (t) => { + quiet(t); + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + const session = f.session(); + const raw = session.call.bind(session); + let refusals = 0; + session.call = async (name, args) => { + if (name === "trame.dataclass.state.update" && refusals < 1) { + refusals++; + session.calls.push([name, args]); + await Promise.resolve(); + throw serverFailure(); + } + return raw(name, args); + }; + + f.manager.updateServer("layer", "visible", true); + await tick(); + // The user tries the same edit again. The cache must not say "the server already + // has that", because it does not: it refused. + f.manager.updateServer("layer", "visible", true); + await tick(); + + assert.deepEqual(f.updates(session), [ + { layer: { visible: true } }, + { layer: { visible: true } }, + ]); +}); + +test("nothing is pushed while the live connection has not described the instance", async (t) => { + quiet(t); + const held = deferred(); + const definitions = { Layer: definition([], { client_only: ["draft"] }) }; + const f = fixture( + { layer: state({ visible: false, draft: "" }) }, + definitions, + ); + await f.manager.fetchInstanceRecursive("layer"); + + definitions.Layer = held; + f.reconnect(); + await tick(); + // `retire` cleared the schema and the replacement's has not landed. Every question + // `updateServer` asks is answered out of it, and a missing schema answers "no" to + // all of them -- so a client_only field would be sent for the first time ever. + assert.equal(f.manager.typeDefinitions.Layer, undefined); + assert.equal(f.manager.dataTypes.layer, "Layer"); + f.manager.updateServer("layer", "draft", "typed in the window"); + f.manager.updateServer("layer", "visible", true); + await tick(); + assert.deepEqual(f.updates(f.session()), []); + + held.resolve(definition([], { client_only: ["draft"] })); + await f.manager.fetchInstanceRecursive("layer"); + f.manager.updateServer("layer", "draft", "still client only"); + f.manager.updateServer("layer", "visible", true); + await tick(); + assert.deepEqual(f.updates(f.session()), [{ layer: { visible: true } }]); +}); + +// A refusal the client answers by refusing one that failed: see `revertView`. +function refusing(f, matches) { + const session = f.session(); + const raw = session.call.bind(session); + session.call = async (name, args) => { + if (name === "trame.dataclass.state.update" && matches(args[0])) { + session.calls.push([name, args]); + await Promise.resolve(); + throw serverFailure(); + } + return raw(name, args); + }; + return session; +} + +test("a refused field goes back to the value the server holds", async (t) => { + quiet(t); + const f = fixture({ layer: state({ visible: false, opacity: 1 }) }); + await f.manager.fetchInstanceRecursive("layer"); + const session = refusing(f, (msg) => "visible" in (msg.layer || {})); + + f.manager.updateServer("layer", "visible", true); + await tick(); + + // The refusal is final -- no resend is coming -- so leaving the user's value on + // screen means the control shows something nobody holds until the next thing that + // refreshes it silently changes it back. + assert.deepEqual(f.manager.applied, [["layer", { visible: false }]]); + assert.deepEqual(f.manager.values.layer, { visible: false, opacity: 1 }); + assert.equal(f.manager.serverState.layer.visible, JSON.stringify([false])); + // ...and the revert is a server-origin write, so it does not bounce back out. + assert.deepEqual(f.updates(session), [{ layer: { visible: true } }]); +}); + +test("a field with no value on the server is left as the user typed it", async (t) => { + quiet(t); + const f = fixture({ layer: state({ visible: false }) }); + await f.manager.fetchInstanceRecursive("layer"); + const session = refusing(f, () => true); + + // `fresh` was never in the cache: there is nothing to revert TO, and inventing a + // value would be worse than leaving what the user typed. + f.manager.updateServer("layer", "fresh", "typed"); + await tick(); + + assert.deepEqual(f.manager.applied, []); + assert.equal(f.manager.values.layer.fresh, undefined); + assert.equal("fresh" in f.manager.serverState.layer, false); + assert.equal(f.updates(session).length, 1); +}); + +test("an edit made while the refused batch was in flight is not reverted over", async (t) => { + quiet(t); + const f = fixture({ layer: state({ opacity: 1 }) }); + await f.manager.fetchInstanceRecursive("layer"); + const session = refusing(f, (msg) => msg.layer?.opacity === 0.5); + + f.manager.updateServer("layer", "opacity", 0.5); + // ...and the user moves the slider again before the refusal lands. Three distinct + // values on purpose: the server's 1, the refused 0.5, and 0.25, which is what they + // are looking at and what the next flush must send. Reverting to 1 would undo a + // drag the server has not even been told about. + f.manager.updateServer("layer", "opacity", 0.25); + await tick(); + await tick(); + + assert.deepEqual(f.manager.applied, [], "no revert was applied"); + assert.deepEqual(f.updates(session), [ + { layer: { opacity: 0.5 } }, + { layer: { opacity: 0.25 } }, + ]); + assert.deepEqual(f.manager.pendingClientServerQueue, []); + assert.equal(f.manager.serverState.layer.opacity, JSON.stringify([0.25])); +}); diff --git a/react-components/package.json b/react-components/package.json index 1c62bc8..3e13f86 100644 --- a/react-components/package.json +++ b/react-components/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build --emptyOutDir", "lint": "eslint . --ext .js,.jsx --fix --ignore-pattern dist", - "test": "node --test tests/*.test.js" + "test": "node --test --test-timeout=20000 tests/*.test.js" }, "peerDependencies": { "react": ">=19", diff --git a/react-components/src/TrameDataclass.jsx b/react-components/src/TrameDataclass.jsx index c4b5d43..e4a29a6 100644 --- a/react-components/src/TrameDataclass.jsx +++ b/react-components/src/TrameDataclass.jsx @@ -16,12 +16,12 @@ export default function TrameDataclass({ instance, always, render }) { const trame = window.trame; useEffect(() => { - if (trame?.client) manager.connect(trame.client); + manager.attach(trame); }, [trame]); useEffect(() => { if (!instance) return; - if (trame?.client) manager.connect(trame.client); + manager.attach(trame); manager .fetchInstanceRecursive(instance) .catch((error) => diff --git a/react-components/src/dataclassManager.js b/react-components/src/dataclassManager.js index 113fdec..95ac7e6 100644 --- a/react-components/src/dataclassManager.js +++ b/react-components/src/dataclassManager.js @@ -168,6 +168,24 @@ export class DataclassManager extends DataclassCore { inst.listeners.add(callback); return () => inst.listeners.delete(callback); } + + // Re-hydrate every instance something is still showing. The core cleared what + // it had cached for the retired connection, including completeness, so these + // instances report unavailable until their graph arrives from the new one; the + // `target` object each record proxies is kept, so a component stays bound. + connectionReplaced() { + for (const [id, inst] of Object.entries(this.states)) { + if (!inst.listeners.size) continue; + inst.notify(); + this.fetchInstanceRecursive(id).catch((error) => { + // A retired session is not a failure: another replacement has already been + // adopted and will re-fetch this instance in turn. + if (!error?.retiredSession) { + console.error(`Dataclass instance ${id} failed to reload`, error); + } + }); + } + } } let singleton = null; diff --git a/vue-components/package.json b/vue-components/package.json index 24eb379..277efdc 100644 --- a/vue-components/package.json +++ b/vue-components/package.json @@ -17,7 +17,7 @@ "build": "vite build --emptyOutDir", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore --ignore-pattern public", "semantic-release": "semantic-release", - "test": "node --test tests/*.test.js" + "test": "node --test --test-timeout=20000 tests/*.test.js" }, "dependencies": {}, "peerDependencies": { diff --git a/vue-components/src/components/TrameDataclass.js b/vue-components/src/components/TrameDataclass.js index 03258d1..0d6d30c 100644 --- a/vue-components/src/components/TrameDataclass.js +++ b/vue-components/src/components/TrameDataclass.js @@ -1,6 +1,6 @@ import { inject, toRef, reactive, onBeforeUnmount, computed, watch } from "vue"; -import { DataclassManager } from "../core"; +import { DataclassManager } from "../core.js"; export const GLOBAL_DATA_MANAGER = new DataclassManager(); let GLOBAL_VUE_INSTANCE_ID = 1; @@ -18,7 +18,7 @@ export default { const guards = { serverPush: false }; const vueInstanceId = `vueDataClass${GLOBAL_VUE_INSTANCE_ID++}`; - GLOBAL_DATA_MANAGER.connect(trame.client); + GLOBAL_DATA_MANAGER.attach(trame); watch( () => props.instance, async (instanceId) => { diff --git a/vue-components/src/components/TrameDataclassGui.js b/vue-components/src/components/TrameDataclassGui.js index 1bf663f..f3a7f9a 100644 --- a/vue-components/src/components/TrameDataclassGui.js +++ b/vue-components/src/components/TrameDataclassGui.js @@ -1,13 +1,13 @@ import { ref, watchEffect, computed, inject } from "vue"; -import { GLOBAL_DATA_MANAGER } from "./TrameDataclass"; -import { setup } from "../setup"; +import { GLOBAL_DATA_MANAGER } from "./TrameDataclass.js"; +import { setup } from "../setup.js"; export default { props: ["instance"], setup(props) { const innerTemplate = ref(""); const trame = inject("trame"); - GLOBAL_DATA_MANAGER.connect(trame.client); + GLOBAL_DATA_MANAGER.attach(trame); watchEffect(async () => { innerTemplate.value = ""; diff --git a/vue-components/src/core.js b/vue-components/src/core.js index 66995ba..66fef23 100644 --- a/vue-components/src/core.js +++ b/vue-components/src/core.js @@ -26,34 +26,79 @@ export class DataclassManager extends DataclassCore { this.triggers = {}; this.deepReactiveWatchers = {}; this.pendingDeepReactives = {}; + // `${id}::${field}` -> the cell its outbound watcher is registered on, so a + // second fetch of the same instance cannot double-send every edit. + this.outboundWatchers = {}; } buildInstance(id, state) { - const refs = { _id: id }; + // A re-fetch -- a reconnect is the only one -- fills the SAME refs object and + // the same ref cells. A component's `data` holds those cells, the trigger that + // pushes its edits closes over the instance rather than over one of them, and a + // parent's shared object was assigned them: replacing them orphans all three, + // so the view would come back bound to cells nothing writes to any more. + // + // Refs are additive for the same reason: a field the newest state omits keeps + // its cell rather than having its identity pulled from under a live binding. + const refs = this.dataStates[id]?.refs ?? { _id: id }; this.dataStates[id] = { refs }; for (const [key, value] of Object.entries(state)) { // check if nested dataclass if (this.isDataClass(id, key)) { - refs[key] = ref(null); + refs[key] ??= ref(null); this.handleNestedDataClass(id, key, value); + } else if (refs[key]) { + refs[key].value = this.wrapValue(id, key, value); } else { refs[key] = ref(this.wrapValue(id, key, value)); } if (!this.isClientOnly(id, key)) { - const trigger = this.getTrigger(id, key, refs); - watch(refs[key], trigger); + this.watchOutbound(id, key); const items = this.pendingDeepReactives[id] || []; while (items.length) { const [fullKey, r] = items.pop(); - const trigger = this.getTrigger(id, key, refs); - this.deepReactiveWatchers[fullKey] = watch(r, trigger); + this.deepReactiveWatchers[fullKey] = watch( + r, + this.getTrigger(id, key), + ); } } } } + // Exactly one outbound watcher per field, on whichever cell that field currently + // is. A cell normally survives every fetch, so this reads as "do not watch it + // twice"; if one were ever replaced, the old watcher is released rather than left + // firing into an abandoned object. + watchOutbound(id, key) { + const fullKey = `${id}::${key}`; + const cell = this.dataStates[id].refs[key]; + const existing = this.outboundWatchers[fullKey]; + if (existing) { + if (existing.cell === cell) return; + existing.unwatch(); + } + this.outboundWatchers[fullKey] = { + cell, + unwatch: watch(cell, this.getTrigger(id, key)), + }; + } + + // Re-hydrate what is on screen. A Provider cannot notice a reconnect itself -- + // its `instance` prop never changed -- and everything the core cached for the + // retired connection is gone, so a mounted component would otherwise sit on a + // graph nothing is refreshing. Anything NOT mounted is fetched again by whoever + // next asks for it. + connectionReplaced() { + for (const [componentId, internals] of Object.entries(this.vueComponents)) { + if (!internals?.id) continue; + // `link` resolves whatever happens, and reports what is worth reporting. + this.link(internals.id, componentId); + } + } + // Fill the shared reactive object of an instance that is already referenced. instanceFetched(id) { if (this.internalReactiveObjects[id]) { @@ -155,20 +200,37 @@ export class DataclassManager extends DataclassCore { } } - getTrigger(id, key, refs) { + // Reads the LIVE cell and closes over nothing but the instance: a trigger + // outlives any one fetch, and one bound to an abandoned fetch's refs would send + // that fetch's value -- which the server cache then matches, so the user's edit + // disappears with no request and no error. + getTrigger(id, key) { const fullKey = `${id}::${key}`; const fn = this.triggers[fullKey]; if (fn) { return fn; } this.triggers[fullKey] = () => { - this.updateServer(id, key, refs[key].value); + this.updateServer(id, key, this.dataStates[id].refs[key].value); }; return this.triggers[fullKey]; } async getGUITemplate(id) { - if (!id || !(await this.fetchInstance(id))) return ""; + if (!id) return ""; + // Its caller is a `watchEffect` that assigns the result, so this resolves to a + // template or to nothing -- the same reasoning as `link`. The template comes + // back when the replacement's own fetch lands and the effect runs again. + let known; + try { + known = await this.fetchInstance(id); + } catch (error) { + if (!error?.retiredSession) { + console.error(`Dataclass template failed for ${id}`, error); + } + return ""; + } + if (!known) return ""; const classId = this.dataTypes[id]; const definition = this.typeDefinitions[classId]; if (!("template" in definition)) { @@ -179,7 +241,22 @@ export class DataclassManager extends DataclassCore { async link(dataId, componentId) { // A component only ever sees a complete graph, and one the server knows. - const exists = await this.fetchInstanceRecursive(dataId); + // + // This RESOLVES whatever happens, because of who calls it: a component watcher + // that does not await it and a replaced connection that re-links every mounted + // component. A rejection reaching either one is an unhandled rejection in the + // page with no instance id in it. A retired session is not even a failure -- + // `connectionReplaced` has already scheduled this very component's re-link on + // the live connection, so it is the quiet case. + let exists; + try { + exists = await this.fetchInstanceRecursive(dataId); + } catch (error) { + if (!error?.retiredSession) { + console.error(`Dataclass hydration failed for ${dataId}`, error); + } + return; + } // It may have moved on to another instance while this one arrived. if (!exists || this.vueComponents[componentId]?.id !== dataId) { return; diff --git a/vue-components/tests/reconnect.test.js b/vue-components/tests/reconnect.test.js new file mode 100644 index 0000000..d147171 --- /dev/null +++ b/vue-components/tests/reconnect.test.js @@ -0,0 +1,140 @@ +// What a reconnect has to leave working on the Vue side: a mounted provider is +// re-hydrated without being told (its `instance` prop never changed), and it is +// re-hydrated onto the SAME ref cells -- a component's `data`, a parent's shared +// object and the trigger that pushes an edit all hold those cells, so building +// new ones would leave the view bound to cells nothing writes to any more. +import assert from "node:assert/strict"; +import test from "node:test"; +import { nextTick, reactive } from "vue"; + +import { + connect, + deferred, + definition, + serverFailure, + state, + tick, +} from "../../client-core/tests/harness.js"; +import { DataclassManager } from "../src/core.js"; + +function fixture(states, definitions) { + const f = connect(new DataclassManager(), states, definitions); + return { + ...f, + value: (id, key) => f.manager.dataStates[id].refs[key].value, + }; +} + +async function provider(f, id) { + const data = reactive({}); + await f.manager.connectVueComponent("provider", { id, data, guards: {} }); + return data; +} + +test("a mounted provider is re-hydrated on the same cells after a reconnect", async () => { + const f = fixture({ layer: state({ value: 1 }) }); + const data = await provider(f, "layer"); + const cell = f.manager.dataStates.layer.refs.value; + // `data` is reactive, so reading a field unwraps its ref: identity is compared + // on the cells themselves. + assert.equal(data.value, 1); + + cell.value = 2; + await nextTick(); + await tick(); + f.reconnect(); + await tick(); + await tick(); + + assert.equal(f.manager.dataStates.layer.refs.value, cell); + assert.equal(data.value, 1); + assert.equal(f.value("layer", "value"), 1); + assert.deepEqual(f.session().calls[0], [ + "trame.dataclass.state.get", + ["layer"], + ]); +}); + +test("an edit made after a reconnect reaches the new connection once", async () => { + const f = fixture({ layer: state({ value: 1 }) }); + const data = await provider(f, "layer"); + const retired = f.session(); + + f.reconnect(); + await tick(); + await tick(); + data.value = 3; + await nextTick(); + await tick(); + + assert.deepEqual(f.updates(f.session()), [{ layer: { value: 3 } }]); + assert.deepEqual(f.updates(retired), []); +}); + +test("a publication that arrives after a reconnect reaches the provider", async () => { + const f = fixture({ layer: state({ value: 1 }) }); + const data = await provider(f, "layer"); + + f.reconnect(); + await tick(); + await tick(); + await f.publish("layer", { value: 9 }, 1); + + assert.equal(data.value, 9); +}); + +test("a re-hydrated parent keeps the shared object its child is seen through", async () => { + const f = fixture( + { + parent: state({ child: "child" }, "Parent"), + child: state({ count: 42 }, "Child"), + }, + { Parent: definition(["child"]), Child: definition() }, + ); + const data = await provider(f, "parent"); + const shared = f.manager.internalReactiveObjects.child; + assert.equal(data.child, shared); + + f.reconnect(); + await tick(); + await tick(); + await tick(); + + assert.equal(f.manager.internalReactiveObjects.child, shared); + assert.equal(data.child, shared); + assert.equal(shared.count, 42); +}); + +test("a refused edit is reverted in the cell the user is looking at, silently", async (t) => { + t.mock.method(console, "error", () => {}); + const f = fixture({ layer: state({ visible: false }) }); + const data = await provider(f, "layer"); + const session = f.session(); + const raw = session.call.bind(session); + session.call = async (name, args) => { + if (name === "trame.dataclass.state.update") { + session.calls.push([name, args]); + await Promise.resolve(); + throw serverFailure(); + } + return raw(name, args); + }; + + data.visible = true; + await nextTick(); + await tick(); + await tick(); + await nextTick(); + await tick(); + + // The control goes back to what the server holds... + assert.equal(data.visible, false); + assert.equal(f.value("layer", "visible"), false); + // ...and that write does not bounce out as a new edit. The outbound watcher it + // wakes finds the restored cache already holding that value, so its echo is + // dropped: one request, the refused one. MUTATION SEEN RED (and it is not a quiet + // one): skip the cache restore and this file RUNS AWAY -- echo, refusal, revert, + // echo -- until the 20 s case timeout stops it. + assert.deepEqual(f.updates(session), [{ layer: { visible: true } }]); + assert.deepEqual(f.manager.pendingClientServerQueue, []); +});