feat(connect): model the connection lifecycle as an xstate machine - #117
feat(connect): model the connection lifecycle as an xstate machine#117fernandomg wants to merge 22 commits into
Conversation
- `@vitest/coverage-v8` behind a `coverage` script, with the doubles under `testing/` and `mock/` excluded: covering a double says nothing about the package - `maxWorkers: 8`, because the suite's wall clock is SDK timer waits rather than CPU - `core-types` 1.8.0 to 1.10.0, which is what `dapp-sdk` 1.5.1 depends on; the old pin had us importing types from a different copy than the SDK uses
Content unchanged; only the two internal specifiers are repointed, so this reads as a rename rather than as a delete and an add. The rewrite lands in later commits, where the diff is a diff.
A wallet may list accounts that cannot act: one whose party is still being allocated, one removed from the participant, one disabled. `selectUsableAccounts` drops them, so the party is picked from what can actually sign. `disabled` covers five causes and only one is harmless: an unmatched signing provider keeps its ledger rights and signs through the participant.
- `InitFailedError` marks an init the SDK cached as rejected, which only a replacement instance can retry - `PickerClosedError` marks a picker the user walked out on, which is a cancel rather than a failure and records no error
Invoked inside `session.authenticated`, so the read's lifetime is that state's. `reading` is what a consumer sees as still connecting; `unavailable` is a failed read that only an `accounts.changed` push moves on, never a retry from here. A push wins from any state, an in-flight read included.
`WalletSdk` is the fourteen-method slice this package actually calls, and a real `DappSDK` satisfies it structurally. `ConnectionSubscription` is the actor with `send` removed, so a consumer can read and subscribe but not ask for a transition. `ConnectionActorRef` stays off the reference: it is reachable only through the narrowed type, which is what `intentionallyNotExported` records.
One per thing the SDK is asked to do: `init`, `connect`, `restore`, `disconnect`, and `walletEvents` for the pushes it sends back. `init` is memoised per sdk instance rather than per actor, because the SDK serializes every init behind one cached promise and never clears a rejected one: a second call replays the first failure instead of retrying. That is why a failure is typed `InitFailedError`, and why only a replacement instance recovers.
Internal and unwired: the provider still owns the state the hooks read. - the `DappSDK` is machine context, built by the input's `createSdk` and replaced in `retiring` when a closed picker strands a connect - each state carries a tag for what it means to a caller, so an answer is read off the state rather than by matching its name from outside - a wallet that stops serving requests drops the party on leaving `authenticated`, since a lock and a wallet-side disconnect arrive as one indistinguishable push; the session stays, so an unlock is heard and re-reads it without a reconnect - `idle` is not `disconnected`: the boot restore has not answered yet
A method a suite did not stub returns a promise that never settles rather than one that rejects, so a test that drove the machine into a state it stubbed nothing for is left where it put it, instead of a rejection walking it somewhere else.
The actors against a stubbed sdk: the cached init rejection, a wallet decline, a closed picker, and the status read a connect falls back to. The accounts machine: a push winning over an in-flight read, a failed read that only a push moves on, and a wallet reporting no usable account.
One test per race #76 lists, plus the retirement of a stranded SDK, the party dropping on a lock and returning on the unlock, and every tag a caller waits on. No test infers state from elapsed time.
A reference rather than a narrative: one chart, what each state means to a caller, which SDK call each actor makes, and what settles each promise.
It justified holding the party across a drop to `unauthenticated`, which is the opposite of what the exit on `authenticated` now does.
- `restore` rejects a `status()` answer that carries no `connection`, so the boot lands in `disconnected` as for any other unrestorable status - `walletEvents` drops a `statusChanged` push without one rather than forwarding it - `isAuthenticated` no longer dereferences `connection`: a throwing guard ends the actor, and every hook throws with it - nothing on the SDK's window-transport path validates the wallet's frames (dapp-sdk 1.5.1)
…aps the machine - `disconnecting` had no exit but the disconnect actor settling, and `sdk.disconnect()` awaits the wallet's answer with no deadline anywhere on the window-transport path (dapp-sdk 1.5.1, core-rpc-transport 1.10.0): a silent wallet left `disconnect()` and every later `connect()` pending - after 10 s the machine takes the exit a failed disconnect takes: `disconnected`, or `connecting` when a connect was queued behind it - the sdk is replaced on the way out: the unanswered request still holds the old instance's client, and its late answer would null whichever client a later connect installs on that instance - the chapter records the deadline
- README section on why a machine over plain state: the hard part is cancelling work when the state that started it is gone - a table of the dapp-sdk gaps the layer works around and when each goes - notes the zustand spike: full parity, but the win is bundle size and one fewer dependency, not less logic
…istic disconnect - disconnecting is a single state now; a connect asked for mid-disconnect is ignored, not queued as the reconnecting sub-state - add a public 'disconnecting' status so a consumer disables its connect action until the disconnect settles, rather than the machine queueing the racing connect - remove the disconnect.superseded tag and the reconnecting/ending sub-states - update the machine suite (154 pass) and the connection-machine chapter
…hapter - the lifecycle modules moved to `machine/`; the tree still placed them at `src/` root - `InitFailedError`, `selectUsableAccounts`, `WalletSdk`, `ConnectionSubscription` join their lines - `architecture/connection-machine.md` had no index entry - "orphan" becomes "abandoned", the code's word; "behaviour" becomes "behavior" - the dapp-sdk import constraint is described instead of cited by dist line
- `machine/` is where the lifecycle lives; the layout delta said one flat layer under `src/` - #84's session states exist; the note that said they were still pending is gone - rules for editing `machine/`: `setup()`, the tags contract, `SimulatedClock`, the test inputs - `pnpm run coverage` joins the validation checklist
- the tip commit dropped the connect/disconnect supersede; the README still named it - the two combinations are states on purpose, not unconstructable, as the PR body says - the gap table names the code's symbols, and one term, timeout, for `DISCONNECT_TIMEOUT_MS` - the Testing section lists coverage, lint, typecheck and build - American spelling
… failure - `retiring --> failure`, the exit `bootSdk.onError` takes, was missing from the spine - `wallet.statusChanged` is named as the push the session reacts to - the settle columns, `useConnect().reset()` and the `hasTag` reads belong to the provider PR - four placements, not three
- mirrors d66bef2 on feat/76-docs for the one chapter this branch carries - the canton-connect distribution row names the chapter; the stack row names xstate
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR replaces CantonConnectProvider's ad-hoc useState mirroring of SDK state with an xstate@5 connection-lifecycle machine that lives under a new canton-connect/src/machine/ folder. The machine makes previously-representable invalid combinations (connected-with-no-party, error-beside-a-live-session) into named/unreachable states, and folds five separately-patched races (#76) into one model where a state's invoked work is cancelled on exit. It is the first of four progressive PRs and is intentionally unwired — nothing starts the machine outside its own tests, so the running app behaves exactly as on main.
Changes:
- Adds
connectionMachine,connectionActors,accountsMachine,accountsActors(states, actors, tags, SDK-gap workarounds:retiring,DISCONNECT_TIMEOUT_MS,InitFailedError, account-usability filtering viaselectUsableAccounts). - Grows public types (
WalletSdk,ConnectionSubscription,ConnectionInput,InitOptions;ConnectionStatusgains'disconnecting') and moves the lifecycle modules intomachine/, bumping@canton-network/core-types1.8.0 → 1.10.0 to matchdapp-sdk1.5.1. - Adds v8 coverage tooling, a
architecture/connection-machine.mdchapter, and updates docs (root + subprojectCLAUDE.md,architecture.md, README).
Reviewed changes
Copilot reviewed 29 out of 31 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
canton-connect/src/machine/connectionMachine.ts |
New lifecycle machine: states, tags, toConnectionStatus, sdk retirement, disconnect timeout |
canton-connect/src/machine/connectionActors.ts |
init caching, connect w/ session recovery, restore, walletEvents |
canton-connect/src/machine/accountsMachine.ts / accountsActors.ts |
Account read/push child machine + actors |
canton-connect/src/walletAccount.ts |
Adds selectUsableAccounts/isUsable filtering out non-actable parties |
canton-connect/src/types.ts |
WalletSdk, ConnectionSubscription, expanded ConnectionStatus |
canton-connect/src/connectError.ts |
New InitFailedError |
canton-connect/src/{index.ts,typedoc.json,vitest.config.ts,package.json} |
Barrel exports, coverage config/tooling |
canton-connect/src/testing/{connectionInput,accountsInput}.ts |
Machine test inputs (never-settling sdk doubles) |
*.test.ts (connectionActors, accountsMachine, walletAccount, guardedConnect) |
New/updated suites for the machine and actors |
Docs (architecture.md, CLAUDE.md, README, architecture/connection-machine.md) |
State-machine reference and layout deltas |
pnpm-lock.yaml, .gitignore |
Dependency graph + coverage output ignore |
Notes from investigation: I verified there are no lingering references to the old #src/connectionMachine/#src/connectionActors paths, that machine/connectionMachine.test.ts exists with comprehensive coverage (races, tags, SimulatedClock-driven disconnect timeout), that accountsActors typing matches how CantonConnectProvider already treats AccountsChangedEvent, and that the suspicious obug lockfile entry is in fact a legitimate @vitest/coverage-v8 4.1 dependency. I did not find any concrete, objective code defects meeting the bar for a review comment.
That said, this is a foundational, high-risk change: a 430-line lifecycle state machine with subtle concurrency semantics and deliberate reliance on non-public dapp-sdk internals (the SPLICE_WALLET_PICKER_RESULT drain, init-rejection caching, unbounded connect()/disconnect()), landed as the first of four progressive PRs. The lifecycle model itself warrants human judgment before approval.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Summary
Part of #76
CantonConnectProvidermirrors SDK state into sixuseStatefields written from six places. No single place owns the rules, so both of these are representable:connectedwith no party;The five races #76 lists were each patched on their own.
This lands the model that replaces them, and nothing else. No actor is started outside the machine's own tests, the provider still owns the state the hooks read, and the running app behaves exactly as on
main. #83 and #84 landed an earlier sketch of this machine; the rewrite here checks it againstdapp-sdk1.5.1's verified behavior and grows the session states, the account read and the SDK's own lifetime into it.First of four progressive PRs.
Changes
DappSDKinstance lives in the machine, and two failures replace it:sdk.connect()keeps running with nothing able to stop it, and that abandoned call can still rewrite the client under a later attempt;init(), because the SDK caches the rejection on the instance forever and only a replacement retries. The boot init and the connect actor's own both retire it; the connect actor marks its route withInitFailedError, the SDK's error riding incause.sessionand records the cause, rather than treating a read failure as a lost connectiondisconnectinggives up after 10 s (DISCONNECT_TIMEOUT_MS), takes the exit a failed disconnect takes, and retires the sdk, whose unanswered request would otherwise null a later client. The SDK's own request carries no timeoutdisconnectingis ignored, not queued:sdk.connect()andsdk.disconnect()both rewrite the client and must not overlap.ConnectionStatusgains'disconnecting'so a consumer keeps its connect action disabled until the disconnect settlesconnectionis dropped, and astatus()answer without it counts as nothing to restore. Neither stops the actor: the guard that read it used to, and every hook with itconnectError.resetclears the recorded error from any state;restoreis accepted fromsession, so a replaced sdk's listeners are torn down and the session read againconnecting;connect.settled;connect.failed;connect.cancelled;unauthenticated;disconnect.settled.pnpm -C canton-connect coveragereports the suite, with the doubles and the barrel excludedarchitecture/connection-machine.md, is the reference for the states, the tags and what each actor reaches for;architecture.mdindexes itDeviations
Closes #76rides with the last PR rather than this one. No open child issue matches this PR: Connection lifecycle machine (model only) #83 and Session states and wallet pushes #84 already closed on the earlier sketch it rewritescore-typesmoves from 1.8.0 to 1.10.0.dapp-sdk1.5.1 depends on^1.10.0, so the old pin had us importing types from a different copy than the SDK itself usesdisconnectingto be its only state with no exit the app can drive. The connect timeout stays with Bound the connect wait and reconcile a late completion #105isConnectingwhile disconnected was already unreachable onmain, where it is derived fromstatus. The other two are representable on purpose, because a state names which case it is instead of forbidding it:connectedwith no party issession.unauthenticated(locked),session.authenticated.reading(read in flight), orsession.authenticated.unavailable(read failed);Acceptance criteria
From #76, in its words. All three complete across the chain; this PR delivers the model half of the first and the machine-level half of the third.
xstate@5machine owns the connection lifecycle, and the invalid combinations above are non representable by construction rather than guarded case by caseconnect()publishes the connection state and also hands back an answer, and the wallet's pushes reach the dApp for exactly as long as a session existsTest plan
Automated tests
pnpm -C canton-connect test: 154 pass. The machine suites cover every tag butdisconnect.settled, the retirement of the sdk an abandoned connect holds, the party dropping on a lock and returning on the unlock, a push winning over an in-flight read, a connect asked for mid-disconnect being ignored, and the timeout on a silent disconnect, driven by xstate'sSimulatedClockpnpm lint && pnpm typecheck && pnpm build && pnpm knip && pnpm docs:check && pnpm run check:anatomy && pnpm testfrom the rootManual verification
None, and none is possible: nothing starts the machine outside its tests.
Breaking changes
Nothing is removed or renamed.
ConnectionStatusgains'disconnecting', so an exhaustiveswitchover it needs one more case. The barrel gainsWalletSdk,ConnectionSubscription,ConnectionInputandInitOptions.Checklist
Screenshots
None.