Skip to content

fix(connect): guard re-entrant connect and surface silent lifecycle failures - #67

Open
fernandomg wants to merge 3 commits into
feat/51-partiesfrom
fix/57-connect-lifecycle
Open

fix(connect): guard re-entrant connect and surface silent lifecycle failures#67
fernandomg wants to merge 3 commits into
feat/51-partiesfrom
fix/57-connect-lifecycle

Conversation

@fernandomg

Copy link
Copy Markdown
Member

Summary

Closes #57

Three lifecycle defects in CantonConnectProvider, found by adversarial review of #47 after it went
green: overlapping connect() calls leaked event wiring, startup and restore failures passed
silently, and disconnect() left a stale connectError behind. One slice because they live in the
same three functions. Sits on #66 in the #51 stack; merge order is #47, #48, #66, this, #63.

Changes

  • A re-entrancy guard on connect(): a second call during an attempt joins the in-flight attempt
    instead of starting a second one, so no wiring is ever orphaned.
  • A failing init() or session restore surfaces as connectError instead of a silent idle or a
    bogus connected; the account read happens before the provider claims connected.
  • A failed connect always re-throws its original cause; the recovery probe can no longer replace it.
  • disconnect() clears connectError and the current wiring.

Acceptance criteria

From #57:

  • A second connect() during an attempt starts no second attempt, and no wiring outlives disconnect()
  • A failing init() or restore surfaces an error instead of a silent or bogus state
  • A failed connect always re-throws its original cause
  • disconnect() clears the connect error

disconnect() also has to clear the remembered wallet and any pending in-page choice; both states
are introduced by #63, so they are cleared there.

#47 review threads answered here

Thread Answered by
Unhandled rejections from init() and the account read; status stuck at idle 304c9d5
A rejected connect landing as connected-but-locked, error half only 767318c

The rejected-lands-as-locked thread is only half closed here: the error the caller catches now
matches the error rendered. The state itself cannot be fixed from outside the SDK, because a
rejected wallet is indistinguishable from a locked one (status() reports isConnected: false for
both and the session persists either way). The README paragraph saying so lands with #63's doc pass.

Test plan

Automated tests

pnpm -C canton-connect test: 50 to 57 tests. The ones that matter are concurrency cases the old
suite structurally could not catch: two overlapping connect() calls leave no orphaned wiring
(asserted behaviourally, by pushing an accountsChanged after disconnect() and requiring nothing
to change), and a restore whose account read fails lands disconnected and tears its wiring down.
Verified at this commit: 57 passed, tsc and biome check clean.

Manual verification

No manual steps required.

Breaking changes

None.

Checklist

  • Self-reviewed my own diff
  • Tests added or updated
  • Docs updated (if applicable): no doc changes needed for these fixes
  • No unrelated changes bundled in

Screenshots

None.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

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 hardens the wallet-connection lifecycle in canton-connect's CantonConnectProvider, fixing three defects surfaced by adversarial review of #47 (tracked as #57). It sits within the #51 stack (merge order #47, #48, #66, this, #63) and improves reliability of the wagmi-shaped hooks that wrap the dapp-sdk facade. The changes are refactored into small, named state-transition helpers plus a promise-based re-entrancy guard, and are backed by new concurrency-focused tests.

Changes:

  • Adds a re-entrancy guard so a second connect() during an in-flight attempt joins the first (via attemptRef and a non-async connect wrapper) rather than orphaning event wiring.
  • Surfaces previously silent failures: a failing init() or session restore now lands as connectError + disconnected (account read happens before claiming connected), and a failed connect always re-throws its original cause after recovery.
  • disconnect() now clears connectError (and tears down wiring) via the new resetToDisconnected helper; JSDoc updated in both CantonConnectProvider.tsx and useConnect.ts.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
canton-connect/src/CantonConnectProvider.tsx Introduces teardownWiring/markConnected/markConnectedLocked/resetToDisconnected helpers, a non-throwing syncFromStatus, init-failure handling on mount, and the attemptRef-based re-entrancy guard around runConnect.
canton-connect/src/hooks/useConnect.ts Updates UseConnectResult JSDoc to note connect idempotency and that disconnect clears the connect error.
canton-connect/src/CantonConnectProvider.test.tsx Adds tests for failed restore/account-read teardown, session-vanish teardown, re-entrant/overlapping connect, preserved original error on recovery failure, disconnect() clearing the error, and surfaced init() failure.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@fernandomg
fernandomg force-pushed the fix/57-connect-lifecycle branch from 767318c to 38a18c0 Compare August 3, 2026 13:03
@fernandomg
fernandomg force-pushed the fix/57-connect-lifecycle branch from 38a18c0 to 2a7e7e0 Compare August 3, 2026 13:05
- overlapping connect() calls each stored a teardown closure; the second overwrote the first
- the orphaned wiring survived disconnect() and kept applying wallet pushes (#57)
- guard with an attempt ref ahead of setStatus('connecting') and the listener teardown
- non-async guard: an async wrapper would re-wrap the shared promise per caller, rejecting unhandled
- keep one handler on the shared attempt so a fire-and-forget joiner cannot reject unhandled
- document the idempotent-while-in-flight contract on the context value and useConnect
…nnected state

- add a rejection handler to the mount init chain: connectError + disconnected, cancelled-aware
- syncFromStatus claims connected only after the account read completes
- a failed read tears down wiring, clears party/parties, surfaces the error, lands disconnected
- syncFromStatus never throws, so connect()'s failure path keeps its original error
- locked restore keeps its shape: no accounts to read, connected + isLocked directly
- extract the repeated transitions into named helpers instead of setter piles
… failed connects

- resetToDisconnected sets connectError unconditionally, so disconnect() clears a stale error
- tear down wiring in runConnect's no-session branch: no live listeners on a disconnected provider
- record the original error after recovery, so connectError matches what connect() rejects with
@gabitoesmiapodo
gabitoesmiapodo force-pushed the fix/57-connect-lifecycle branch from 2a7e7e0 to 6e064b3 Compare August 3, 2026 16:34
const disconnect = useCallback(async (): Promise<void> => {
teardownRef.current?.()
teardownRef.current = undefined
teardownWiring()

@gabitoesmiapodo gabitoesmiapodo Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(CC feedback)

High priority: disconnect() during an in-flight connect() is silently undone

disconnect() does not clear attemptRef or signal the in-flight attempt. With the picker open, a disconnect() resets to disconnected, then the picker resolves and runConnect runs wireEvents() + markConnected() (lines 291-294).

Verified with a probe test: after disconnect(), state went back to status: 'connected', party: 'alice::1', and a subsequent accountsChanged push was still delivered (party: 'carol::9'). This contradicts the PR's own acceptance criterion "no wiring outlives disconnect()".

cancelled = true
teardownRef.current?.()
teardownRef.current = undefined
teardownWiring()

@gabitoesmiapodo gabitoesmiapodo Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(CC feedback)

High priority: unmount during an in-flight connect() permanently leaks the listeners

runConnect tears wiring down at line 283 before opening the picker, so at unmount teardownRef.current is undefined and this cleanup is a no-op. The attempt then resolves and re-wires on a dead provider (line 291); nothing ever unwires it.

Verified with a probe test: onAccountsChanged called 1x, removeOnAccountsChanged called 0x.

@gabitoesmiapodo
gabitoesmiapodo self-requested a review August 3, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

canton-connect's provider leaks listeners on concurrent connects and hides startup failures

3 participants