Skip to content

feat(connect): model the connection lifecycle as an xstate machine - #117

Open
fernandomg wants to merge 22 commits into
mainfrom
feat/76-machine
Open

feat(connect): model the connection lifecycle as an xstate machine#117
fernandomg wants to merge 22 commits into
mainfrom
feat/76-machine

Conversation

@fernandomg

Copy link
Copy Markdown
Member

Summary

Part of #76

CantonConnectProvider mirrors SDK state into six useState fields written from six places. No single place owns the rules, so both of these are representable:

  • connected with no party;
  • an error beside a live session.

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 against dapp-sdk 1.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

  • One model owns, so each pairing above is either a state that names it or unreachable:
    • connecting;
    • the session;
    • the lock;
    • the account read;
    • and disconnecting.
  • The DappSDK instance lives in the machine, and two failures replace it:
    • a closed picker, because sdk.connect() keeps running with nothing able to stop it, and that abandoned call can still rewrite the client under a later attempt;
    • and a failed 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 with InitFailedError, the SDK's error riding in cause.
  • A failed account read is contained inside the session: the wallet is still connected, so the machine stays in session and records the cause, rather than treating a read failure as a lost connection
  • A wallet that stops serving requests drops the party, since a lock and a wallet-side disconnect arrive as one push that cannot be told apart. The session itself stays, so an unlock is heard and re-reads the party with no reconnect
  • A wallet that never answers the disconnect no longer traps the machine: disconnecting gives 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 timeout
  • A connect asked for during disconnecting is ignored, not queued: sdk.connect() and sdk.disconnect() both rewrite the client and must not overlap. ConnectionStatus gains 'disconnecting' so a consumer keeps its connect action disabled until the disconnect settles
  • A push without connection is dropped, and a status() answer without it counts as nothing to restore. Neither stops the actor: the guard that read it used to, and every hook with it
  • connectError.reset clears the recorded error from any state; restore is accepted from session, so a replaced sdk's listeners are torn down and the session read again
  • A state carries a tag for each question it can already answer, and none while an answer is still pending, so "has this finished?" is answered by the state rather than by matching its name from outside. Nothing reads the tags yet. They are:
    • connecting;
    • connect.settled;
    • connect.failed;
    • connect.cancelled;
    • unauthenticated;
    • and disconnect.settled.
  • A wallet may list accounts that cannot act, and those are skipped when the party is picked, so the machine never picks a party that cannot sign:
    • one whose party is still being allocated;
    • one removed from the participant;
    • and one disabled for anything but an unmatched signing provider.
  • The lifecycle modules move into a folder of their own
  • pnpm -C canton-connect coverage reports the suite, with the doubles and the barrel excluded
  • A chapter, architecture/connection-machine.md, is the reference for the states, the tags and what each actor reaches for; architecture.md indexes it

Deviations

  • Reviewed as a chain, so Closes #76 rides 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 rewrites
  • core-types moves from 1.8.0 to 1.10.0. dapp-sdk 1.5.1 depends on ^1.10.0, so the old pin had us importing types from a different copy than the SDK itself uses
  • Bound the connect wait and reconcile a late completion #105 asks for a timeout on the connect wait; the disconnect wait gets one here instead, because a review of the machine found disconnecting to 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 #105
  • The first criterion says the invalid combinations are non representable, and Model the canton-connect connection lifecycle as an XState machine #76 carries the same wording. isConnecting while disconnected was already unreachable on main, where it is derived from status. The other two are representable on purpose, because a state names which case it is instead of forbidding it:
    • connected with no party is session.unauthenticated (locked), session.authenticated.reading (read in flight), or session.authenticated.unavailable (read failed);
    • and an error beside a live session is that last one, cleared as soon as a push recovers the read.

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.

  • An xstate@5 machine owns the connection lifecycle, and the invalid combinations above are non representable by construction rather than guarded case by case
  • connect() 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 exists
  • The five races above each have a regression test, and no test infers machine state from elapsed time

Test plan

Automated tests

  1. pnpm -C canton-connect test: 154 pass. The machine suites cover every tag but disconnect.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's SimulatedClock
  2. pnpm lint && pnpm typecheck && pnpm build && pnpm knip && pnpm docs:check && pnpm run check:anatomy && pnpm test from the root

Manual verification

None, and none is possible: nothing starts the machine outside its tests.

Breaking changes

Nothing is removed or renamed. ConnectionStatus gains 'disconnecting', so an exhaustive switch over it needs one more case. The barrel gains WalletSdk, ConnectionSubscription, ConnectionInput and InitOptions.

Checklist

  • Self-reviewed my own diff
  • Tests added or updated
  • Docs updated (if applicable)
  • No unrelated changes bundled in

Screenshots

None.

- `@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
Copilot AI balanced review requested due to automatic review settings August 27, 2026 18:58
@fernandomg fernandomg self-assigned this Aug 27, 2026
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
demo.canton-dappbooster Ready Ready Preview Aug 27, 2026 6:58pm
docs.canton-dappbooster Ready Ready Preview Aug 27, 2026 6:58pm

Request Review

@fernandomg
fernandomg requested a review from nicosampler August 27, 2026 19:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via selectUsableAccounts).
  • Grows public types (WalletSdk, ConnectionSubscription, ConnectionInput, InitOptions; ConnectionStatus gains 'disconnecting') and moves the lifecycle modules into machine/, bumping @canton-network/core-types 1.8.0 → 1.10.0 to match dapp-sdk 1.5.1.
  • Adds v8 coverage tooling, a architecture/connection-machine.md chapter, and updates docs (root + subproject CLAUDE.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.

@gabitoesmiapodo
gabitoesmiapodo requested review from fzavalia and removed request for gabitoesmiapodo August 27, 2026 19:32
@fernandomg
fernandomg removed the request for review from fzavalia August 28, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

2 participants