Summary
@metamask/wallet-cli's daemon client (sendCommand) automatically retries a request once on transient connection errors. It retries on bothECONNREFUSED and ECONNRESET, and ECONNRESET is not safe to blindly retry for a state-changing request: the daemon may have already received and executed the request before the connection dropped. This must be tightened before any mutating RPC (sign / send / transfer) becomes reachable, or a dropped connection could cause the same action to run twice (e.g. a transaction broadcast twice).
Current behavior
packages/wallet-cli/src/daemon/daemon-client.ts (sendCommand):
try{returnawaitattempt();}catch(error: unknown){if(!isErrorWithCode(error,'ECONNREFUSED')&&!isErrorWithCode(error,'ECONNRESET')){throwerror;}awaitnewPromise((resolve)=>setTimeout(resolve,100));returnattempt();// resends the whole request}ECONNREFUSED means the connection was never established — the daemon provably never received the request, so retrying is safe.ECONNRESET can occur after the request line was written and the daemon began (or finished) processing it. Retrying then re-sends a request that may already have taken effect.
The request id (randomUUID()) is stable across the retry, but the server does not deduplicate by id, so a second identical request is executed as new.
Why it matters (the failure scenario)
- CLI sends a mutating request (e.g. "send transaction") to the daemon.
- The daemon receives it and broadcasts the transaction.
- The connection resets before the response reaches the CLI (
ECONNRESET). - The CLI's retry re-sends the same request → the daemon broadcasts it again.
- Result: a duplicated, non-reversible action.
The technical term: sending value is not idempotent (doing it twice ≠ doing it once), so a naive resend is a double-execution hazard.
Scope — why it's latent today but must be fixed before send lands
Today's sendCommand callers are effectively idempotent, so the bug is currently latent:
getStatus (ping), status, list — read-only.shutdown (stop-daemon) — idempotent.unlock → KeyringController:submitPassword — idempotent.
But mm daemon call <Controller>:<method> is a general dispatch surface that can already route mutating actions (e.g. KeyringController:setLocked), and it is the exact path a future TransactionController send/transfer action would travel. The safety guarantee erodes the moment a genuinely non-idempotent action becomes callable. So this is a prerequisite for shipping any send/sign/transfer command, not a standalone bug to fix opportunistically.
Proposed fix (pick one or combine)
- Restrict connection-error retry to
ECONNREFUSED only — drop ECONNRESET from the retry condition. Simplest; ECONNREFUSED is the only code that provably means "never received." (ECONNRESET would then surface as an error the caller handles.) - Gate retry to idempotent methods — add an explicit
retryable/idempotent flag to SendCommandOptions; only auto-retry read/idempotent methods (getStatus, listActions, getState, …), never arbitrary call dispatch or send/sign. - Server-side dedup by request id — have the daemon track recently-seen request ids and return the prior result (or reject) on a duplicate. The id is already stable across the current retry, so this is feasible; heavier, but robust if retry-on-
ECONNRESET is desired.
Recommended: restrict to ECONNREFUSED now (small, safe), and introduce the per-method idempotency flag when the send command is designed.
Acceptance criteria
sendCommand no longer blindly re-sends a request that may have already reached the daemon (i.e. does not auto-retry a state-changing request on ECONNRESET).- The chosen mechanism is documented in
sendCommand's JSDoc (currently states it "retries once … on ECONNREFUSED, ECONNRESET"). - Tests cover: retry still happens for the safe/connect-phase case; no retry (or a documented dedup) for the unsafe case; a case where both attempts fail.
- 100% coverage maintained;
build, test, yarn lint:fix, yarn lint, changelog:validate pass.
References
packages/wallet-cli/src/daemon/daemon-client.ts — sendCommand retry block (~lines 91–102) and the getStatus ping caller.- Callers:
src/daemon/stop-daemon.ts (shutdown), src/commands/daemon/{status,call,list}.ts, src/commands/wallet/unlock.ts.
Owning teams: @MetaMask/core-platform@MetaMask/ocap-kernel.
Related
Summary
@metamask/wallet-cli's daemon client (sendCommand) automatically retries a request once on transient connection errors. It retries on bothECONNREFUSEDandECONNRESET, andECONNRESETis not safe to blindly retry for a state-changing request: the daemon may have already received and executed the request before the connection dropped. This must be tightened before any mutating RPC (sign / send / transfer) becomes reachable, or a dropped connection could cause the same action to run twice (e.g. a transaction broadcast twice).Current behavior
packages/wallet-cli/src/daemon/daemon-client.ts(sendCommand):ECONNREFUSEDmeans the connection was never established — the daemon provably never received the request, so retrying is safe.ECONNRESETcan occur after the request line was written and the daemon began (or finished) processing it. Retrying then re-sends a request that may already have taken effect.The request
id(randomUUID()) is stable across the retry, but the server does not deduplicate by id, so a second identical request is executed as new.Why it matters (the failure scenario)
ECONNRESET).The technical term: sending value is not idempotent (doing it twice ≠ doing it once), so a naive resend is a double-execution hazard.
Scope — why it's latent today but must be fixed before send lands
Today's
sendCommandcallers are effectively idempotent, so the bug is currently latent:getStatus(ping),status,list— read-only.shutdown(stop-daemon) — idempotent.unlock→KeyringController:submitPassword— idempotent.But
mm daemon call <Controller>:<method>is a general dispatch surface that can already route mutating actions (e.g.KeyringController:setLocked), and it is the exact path a futureTransactionControllersend/transfer action would travel. The safety guarantee erodes the moment a genuinely non-idempotent action becomes callable. So this is a prerequisite for shipping any send/sign/transfer command, not a standalone bug to fix opportunistically.Proposed fix (pick one or combine)
ECONNREFUSEDonly — dropECONNRESETfrom the retry condition. Simplest;ECONNREFUSEDis the only code that provably means "never received." (ECONNRESETwould then surface as an error the caller handles.)retryable/idempotentflag toSendCommandOptions; only auto-retry read/idempotent methods (getStatus,listActions,getState, …), never arbitrarycalldispatch or send/sign.ECONNRESETis desired.Recommended: restrict to
ECONNREFUSEDnow (small, safe), and introduce the per-method idempotency flag when the send command is designed.Acceptance criteria
sendCommandno longer blindly re-sends a request that may have already reached the daemon (i.e. does not auto-retry a state-changing request onECONNRESET).sendCommand's JSDoc (currently states it "retries once … on ECONNREFUSED, ECONNRESET").build,test,yarn lint:fix,yarn lint,changelog:validatepass.References
packages/wallet-cli/src/daemon/daemon-client.ts—sendCommandretry block (~lines 91–102) and thegetStatusping caller.src/daemon/stop-daemon.ts(shutdown),src/commands/daemon/{status,call,list}.ts,src/commands/wallet/unlock.ts.Owning teams:
@MetaMask/core-platform@MetaMask/ocap-kernel.Related
wallet-cli: add themmsend-transaction command #9513 — hard prerequisite for: themmsend command (a mutating RPC must not be blindly retried).wallet-cli: make the daemon transaction-capable — consumeGasFeeController+ headless auto-approval #9512. Siblings in the "enable sending from the CLI" effort: WireGasFeeControllerinto@metamask/walletdefault initialization #9510 (GasFeeControllerwiring), feat(wallet-cli): wire the TransactionController slot in the daemon wallet #9509 (TransactionControllerslot).