Skip to content

Wallet: Fix deadlock between walletpassphrase and the lockwallet relock timer - #1889

Open
Danswar wants to merge 3 commits into
firoorg:masterfrom
Danswar:fix/walletpassphrase-relock-deadlock
Open

Wallet: Fix deadlock between walletpassphrase and the lockwallet relock timer#1889
Danswar wants to merge 3 commits into
firoorg:masterfrom
Danswar:fix/walletpassphrase-relock-deadlock

Conversation

@Danswar

@Danswar Danswar commented Jul 29, 2026

Copy link
Copy Markdown

Fixes #1888.

PR intention

walletpassphrase holds LOCK2(cs_main, pwallet->cs_wallet) across its call to RPCRunLater. RPCRunLater erases the previously scheduled lockwallet(...) timer, and destroying a libevent timer blocks until that timer's callback has finished running. When LockWallet is the callback currently executing on the HTTP event loop, it is itself blocked acquiring cs_wallet — so the RPC worker and the event loop deadlock on each other.

Since the RPC worker also holds cs_main, message handling, the scheduler and connection handling all stall behind it and the node stops entirely. Nothing is logged, and SIGTERM does not complete because the shutdown path needs the same locks. Full thread-dump evidence is in #1888.

This is a known hazard in Bitcoin Core

Core hit the same cycle and documented it in the code, directly above the relock scheduling (permalink, 21b42f3):

// rpcRunLater must be called without cs_wallet held otherwise a deadlock
// can occur. The deadlock would happen when RPCRunLater removes the
// previous timer (and waits for the callback to finish if already running)
// and the callback locks cs_wallet.
AssertLockNotHeld(wallet->cs_wallet);

Relevant Core changes:

  • bitcoin/bitcoin#18814 (merged 2020-05-13) — "Relock wallet only if most recent callback": adds the nRelockTime != relock_time generation check and m_unlock_mutex. Fixes #18811.
  • bitcoin/bitcoin#32862 (merged 2025-07-08) — "use CScheduler for relocking wallet and remove RPCTimer": moves the relock off the libevent timer entirely and deletes RPCRunLater/RPCTimerInterface. Superseded #32796, part of #31194 (remove libevent). Its motivation was libevent removal rather than this deadlock, but it does eliminate the hazard structurally.

Code changes brief

Three commits, mirroring Core's #18814.

1. Break the deadlockFix deadlock between walletpassphrase and the wallet relock timer

  • LOCK2(cs_main, pwallet->cs_wallet) moves into a block that ends before RPCRunLater is called, so the relock is scheduled with no wallet lock held. This alone breaks the cycle — the pending callback can acquire cs_wallet, finish, and let event_del return.
  • Releasing the lock earlier opens a small window in which the old timer's callback could lock a wallet that was just unlocked again. LockWallet now takes the nRelockTime it was scheduled with and returns without doing anything if pwallet->nRelockTime no longer matches. walletlock already resets nRelockTime to 0, so a callback that fires after an explicit lock is also correctly ignored.

2. Restore the serialization cs_main used to provideSerialize walletpassphrase and guard the RPC timer map

Scoping the lock also removed the mutual exclusion that holding cs_main incidentally gave the two steps after a successful unlock — publishing nRelockTime and installing the timer:

  • A new per-wallet cs_unlock is held across both, so two overlapping walletpassphrase calls cannot publish nRelockTime and schedule the timer in opposite orders, which would leave the wallet unlocked past its timeout. It deliberately is not cs_wallet: it is held across RPCRunLater, whose callback takes cs_wallet.
  • deadlineTimers in rpc/server.cpp gets its own cs_deadlineTimers. It is a plain std::map that RPCRunLater erases from and emplaces into and that StopRPC clears, previously unserialized. This also closes the pre-existing StopRPC / in-flight-walletpassphrase race, since StopRPC (init.cpp:267) runs before the HTTP workers are joined by StopHTTPServer (init.cpp:268).

The resulting lock order is acyclic — cs_unlock → cs_main → cs_wallet and cs_unlock → cs_deadlineTimers — and no timer callback acquires either new lock.

3. Validate the timeoutValidate walletpassphrase timeout before unlock

Written by @reubenyap and cherry-picked in at his request; ported from upstream Core's walletpassphrase validation.

  • A negative timeout is rejected with RPC_INVALID_PARAMETER instead of scheduling a relock in the past.
  • The timeout is clamped to 100000000 seconds. Core's stated reason for that bound is that larger values trigger a macos/libevent bug; it also keeps GetTime() + nSleepTime from overflowing nRelockTime.
  • Both checks run before Unlock(), so a rejected call leaves wallet state untouched. Previously the timeout was only parsed after Unlock() and TopUpKeyPool(), so a non-integer value threw with the wallet already unlocked and no relock scheduled at all.
  • qa/rpc-tests/wallet-encryption.py covers the rejection and that the wallet stays locked afterwards.

Not included

I kept RPCRunLater rather than porting Core's scheduler rework (#32862), to keep the change small. Happy to do the larger version instead if you prefer — it removes the "destroy an event from inside a lock its callback needs" pattern rather than working around it.

Core's weak_ptr unload guard from #18814 does not apply here: this build has a single static pwalletMain and no dynamic wallet unload.

Testing

The deadlock was diagnosed from a symbolized thread apply all bt taken on a live hung node, which pins both sides of the cycle to exact call sites (see #1888).

qa/rpc-tests/wallet-encryption.py covers unlock, timeout relock, timeout extension and now negative-timeout rejection. It is listed in qa/pull-tester/rpc-tests.py, which the Jenkins pipeline runs as qa/pull-tester/rpc-tests.py -extended. The deadlock itself is a timing race that is impractical to test deterministically — upstream added no test for #18814 either.

I still do not have a build environment for this repo, so I have not compiled or run the patched binary myself. @reubenyap reports having compile-checked both the branch head and the timeout-validation commit locally. The Jenkins run is the real gate, and it has not run on this branch yet.

RPCRunLater() erases any previously scheduled lockwallet timer, and destroying a
libevent timer blocks until that timer's callback has finished executing.
walletpassphrase() called it while still holding LOCK2(cs_main, pwallet->cs_wallet),
so when the LockWallet() callback happened to be running at that moment it was
itself blocked acquiring cs_wallet, and the two threads deadlocked: the RPC worker
waiting on libevent, the HTTP event loop waiting on cs_wallet.

Because the RPC worker also holds cs_main, every thread that needs cs_main then
piles up behind it (net message handling, the scheduler, connection handling) and
the node stops making progress entirely. Nothing is logged, and SIGTERM does not
complete either, since the shutdown path needs the same locks.

Scope the lock so it is released before scheduling the relock, and pass the
expected nRelockTime into LockWallet() so an obsolete callback that fires after
the wallet was re-unlocked or already locked does nothing.
@codeant-ai

codeant-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown

User [email protected] does not have a PR Review subscription.

Go to Team management and add this email to the PR Review subscription.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Summary by CodeRabbit

  • Bug Fixes
    • Improved walletpassphrase timeout handling so outdated relock timers can’t re-lock wallets unexpectedly or out of order.
    • Serialized concurrent walletpassphrase requests for more consistent unlock/lock behavior.
    • Strengthened RPC delayed-timer synchronization, including during shutdown, to avoid timing-related issues.
  • Tests
    • Added coverage to ensure walletpassphrase rejects negative timeouts with the message: “Timeout cannot be negative.”

Walkthrough

Wallet relock callbacks now validate captured timestamps, concurrent walletpassphrase calls are serialized, negative timeouts are rejected, and relock timers are scheduled after wallet locks are released. RPC deadline-timer access is synchronized during scheduling and shutdown.

Changes

Wallet and RPC timer synchronization

Layer / File(s) Summary
Wallet relock coordination
src/wallet/wallet.h, src/wallet/rpcwallet.cpp, qa/rpc-tests/wallet-encryption.py
cs_unlock serializes wallet passphrase operations, relock timestamps are captured under wallet locks, stale callbacks are ignored, and negative timeout validation is tested.
RPC deadline-timer lifecycle synchronization
src/rpc/server.cpp
cs_deadlineTimers protects deadline-timer mutations during scheduling and RPC shutdown.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant walletpassphrase
  participant CWallet
  participant RPCRunLater
  participant LockWallet
  walletpassphrase->>CWallet: serialize with cs_unlock
  walletpassphrase->>CWallet: update nRelockTime
  walletpassphrase->>RPCRunLater: schedule captured relock time
  RPCRunLater->>LockWallet: invoke relock callback
  LockWallet->>CWallet: validate timestamp and lock wallet
Loading

Possibly related issues

Suggested reviewers: levoncrypto, psolstice

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: fixing a walletpassphrase relock deadlock.
Description check ✅ Passed The description follows the required template and includes both PR intention and code changes brief with relevant details.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai
coderabbitai Bot requested review from levoncrypto and psolstice July 29, 2026 17:50

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0509d602e6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/wallet/rpcwallet.cpp
Comment thread src/wallet/rpcwallet.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/wallet/rpcwallet.cpp`:
- Around line 3074-3082: Update the deadlineTimers scheduling path around
RPCRunLater to use a dedicated mutex protecting all accesses to the global
deadlineTimers map, including erase and emplacement. Ensure the mutex is held
while RPCRunLater performs its map operations, while preserving the existing
ordering that releases cs_wallet before scheduling the relock timer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 231ec610-3fa7-47c2-8130-bde566c004b3

📥 Commits

Reviewing files that changed from the base of the PR and between dde8fcb and 0509d60.

📒 Files selected for processing (1)
  • src/wallet/rpcwallet.cpp

Comment thread src/wallet/rpcwallet.cpp Outdated
Moving the relock scheduling out of the cs_wallet scope also removed the
serialization that cs_main used to provide for the two things
walletpassphrase does after a successful unlock: publishing
pwallet->nRelockTime and installing the lockwallet timer. RPCRunLater() can
now be entered concurrently by up to -rpcthreads (default 4) HTTP worker
threads, and both steps are racy:

- deadlineTimers in rpc/server.cpp is a plain std::map that RPCRunLater()
  erases from and emplaces into, and that StopRPC() clears. Nothing
  serialized those accesses other than walletpassphrase happening to hold
  cs_main, so concurrent calls can now corrupt the map.

- Two overlapping walletpassphrase calls can publish nRelockTime and
  schedule the timer in opposite orders. The slower call then installs the
  surviving timer while the faster call owns nRelockTime; when that timer
  fires, the generation check in LockWallet() correctly rejects it, no other
  timer is left, and the wallet stays unlocked past the requested timeout.

Add a per-wallet cs_unlock that walletpassphrase holds across both the
unlock and RPCRunLater(), so the two steps are atomic with respect to each
other. It deliberately is not cs_wallet: the lock is held across
RPCRunLater(), which blocks until a running relock callback returns, and
that callback takes cs_wallet - which is exactly the deadlock fixed in the
previous commit.

Give deadlineTimers its own cs_deadlineTimers as well, so the map stays
safe regardless of which RPC calls RPCRunLater() and independently of the
fact that this build only ever has one wallet. It also closes the existing
race between StopRPC() and an in-flight walletpassphrase, as StopRPC() runs
before the HTTP worker threads have been joined.

Neither new lock is acquired by any timer callback, so the resulting lock
order (cs_unlock -> cs_main -> cs_wallet, and cs_unlock -> cs_deadlineTimers)
has no cycle and cannot reintroduce the deadlock.
@Danswar

Danswar commented Jul 29, 2026

Copy link
Copy Markdown
Author

Thanks — both findings are correct, and both are addressed in 67c1623.

1. Unsynchronized deadlineTimers. Confirmed. deadlineTimers (src/rpc/server.cpp:38) is a plain std::map that RPCRunLater() erases from and emplaces into, and that StopRPC() clears, with no lock of its own. walletpassphrase is the only caller of RPCRunLater() in the tree, so before this PR those accesses were serialized only as a side effect of the RPC holding the global cs_main. With up to -rpcthreads (default 4) HTTP workers, releasing that lock earlier removes the serialization. deadlineTimers now has its own cs_deadlineTimers covering the erase/emplace in RPCRunLater() and the clear() in StopRPC() — matching upstream Bitcoin Core, which guards the same map with g_deadline_timers_mutex. That also closes a pre-existing race: StopRPC() runs before StopHTTPServer() joins the worker threads (src/init.cpp:267-268), so the clear() could already race with an in-flight walletpassphrase.

2. An older request replacing the newer timer. Confirmed, and it was a regression introduced by splitting the lock scope in the previous commit: publishing nRelockTime and installing the timer used to be atomic under cs_main, and no longer were. With the interleaving you describe, the slower call installs the surviving timer carrying its own generation, the generation check then correctly rejects it, and no timer is left — so the wallet stays unlocked past the requested timeout. Fixed with a per-wallet cs_unlock that walletpassphrase holds across both the unlock and RPCRunLater(), making the two steps atomic with respect to each other. Same approach as Bitcoin Core's CWallet::m_unlock_mutex (bitcoin/bitcoin#18814, "Prevent concurrent calls to walletpassphrase with the same wallet").

On why cs_unlock is a separate lock and not cs_wallet: it is deliberately held across RPCRunLater(), which blocks until a running relock callback returns, and that callback takes cs_wallet — reusing cs_wallet here would reintroduce exactly the deadlock this PR fixes. Neither new lock is acquired by any timer callback, so the resulting order (cs_unlockcs_maincs_wallet, and cs_unlockcs_deadlineTimers) is acyclic. I also added an AssertLockNotHeld(pwallet->cs_wallet) before the RPCRunLater() call so the invariant is machine-checked in DEBUG_LOCKORDER builds.

For context on scope: this release resolves every RPC to the single global pwalletMain (GetWalletForJSONRPCRequest() ignores the request and returns it directly), and -wallet takes a single filename, so the per-wallet lock alone would in practice serialize all RPCRunLater() calls today. I still gave the map its own lock rather than relying on that, since it depends on walletpassphrase remaining the only caller and does not cover the StopRPC() path.

Still unbuilt on my side, as noted in the PR description — the reasoning above is from reading the code, so the compile and the functional tests are worth treating as unverified until CI runs.

@reubenyap

Copy link
Copy Markdown
Member

@CodeAnt-AI review

@codeant-ai

codeant-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added the size:M This PR changes 30-99 lines, ignoring generated files label Jul 30, 2026
@codeant-ai

codeant-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

User description

Fixes #1888.

walletpassphrase holds LOCK2(cs_main, pwallet->cs_wallet) across its call to RPCRunLater. RPCRunLater erases the previously scheduled lockwallet(...) timer, and destroying a libevent timer blocks until that timer's callback has finished running. When LockWallet is the callback currently executing on the HTTP event loop, it is itself blocked acquiring cs_wallet — so the RPC worker and the event loop deadlock on each other.

Since the RPC worker also holds cs_main, message handling, the scheduler and connection handling all stall behind it and the node stops entirely. Nothing is logged, and SIGTERM does not complete because the shutdown path needs the same locks. Full thread-dump evidence is in #1888.

This is a known hazard in Bitcoin Core

Core hit the same cycle and documented it in the code, directly above the relock scheduling (permalink, 21b42f3):

// rpcRunLater must be called without cs_wallet held otherwise a deadlock
// can occur. The deadlock would happen when RPCRunLater removes the
// previous timer (and waits for the callback to finish if already running)
// and the callback locks cs_wallet.
AssertLockNotHeld(wallet->cs_wallet);

Relevant Core changes:

  • bitcoin/bitcoin#18814 (merged 2020-05-13) — "Relock wallet only if most recent callback": adds the nRelockTime != relock_time generation check and m_unlock_mutex. Fixes #18811.
  • bitcoin/bitcoin#32862 (merged 2025-07-08) — "use CScheduler for relocking wallet and remove RPCTimer": moves the relock off the libevent timer entirely and deletes RPCRunLater/RPCTimerInterface. Superseded #32796, part of #31194 (remove libevent). Its motivation was libevent removal rather than this deadlock, but it does eliminate the hazard structurally.

Change

Two parts, mirroring #18814:

  1. Scope the lock. LOCK2(cs_main, pwallet->cs_wallet) moves into a block that ends before RPCRunLater is called, so the relock is scheduled with no wallet lock held. This alone breaks the cycle — the pending callback can acquire cs_wallet, finish, and let event_del return.

  2. Retire stale callbacks. Releasing the lock earlier opens a small window in which the old timer's callback could lock a wallet that was just unlocked again. LockWallet now takes the nRelockTime it was scheduled with and returns without doing anything if pwallet->nRelockTime no longer matches. walletlock already resets nRelockTime to 0, so a callback that fires after an explicit lock is also correctly ignored.

LockWallet is static with a single call site, so the signature change is local to this file.

I kept RPCRunLater rather than porting Core's scheduler rework (#32862), to keep the change small. Happy to do the larger version instead if you prefer — it removes the "destroy an event from inside a lock its callback needs" pattern rather than working around it. I also did not add Core's AssertLockNotHeld(pwallet->cs_wallet) guard before the call; say the word and I will.

Testing

The deadlock was diagnosed from a symbolized thread apply all bt taken on a live hung node, which pins both sides of the cycle to exact call sites (see #1888).

I have not built and run the patched binary — I do not currently have a build environment for this repo, so please treat the compile as unverified. The change is confined to one file and does not alter behaviour outside the walletpassphrase relock path. The reasoning about which locks are held where comes from reading the code alongside the dump, not from an instrumented run.


CodeAnt-AI Description

Prevent wallet unlock commands from freezing the node

What Changed

  • walletpassphrase no longer deadlocks with the automatic wallet relock timer, allowing the node and shutdown process to continue responding.
  • Overlapping unlock requests for the same wallet are serialized so the latest timeout remains effective.
  • Outdated relock callbacks no longer lock or alter a wallet after it has been re-unlocked or manually locked.
  • RPC timer operations are synchronized to prevent conflicting timer updates or shutdown cleanup.

Impact

✅ Fewer node-wide hangs during wallet unlock
✅ Reliable wallet relock after repeated unlock requests
✅ Responsive shutdown while wallet timers are active

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

@codeant-ai

codeant-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Sequence Diagram

This PR prevents walletpassphrase from deadlocking with an active relock callback by releasing the wallet lock before replacing the timer. It also serializes concurrent unlock requests and ignores stale relock callbacks.

sequenceDiagram
    participant RPC Worker
    participant Wallet
    participant Timer Manager

    RPC Worker->>Wallet: Serialize unlock requests
    RPC Worker->>Wallet: Unlock and publish relock deadline
    RPC Worker->>Timer Manager: Replace relock timer after releasing wallet lock
    Timer Manager-->>Wallet: Run relock callback with expected deadline
    Wallet->>Wallet: Compare expected and current deadline
    alt Deadline is current
        Wallet->>Wallet: Lock wallet and clear deadline
    else Callback is stale
        Wallet-->>Timer Manager: Ignore callback
    end
Loading

Generated by CodeAnt AI

Comment thread src/rpc/server.cpp
Comment on lines +415 to +418
{
LOCK(cs_deadlineTimers);
deadlineTimers.clear();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: Clearing the HTTP-backed timers while the HTTP event loop is still running is unsafe. HTTPRPCTimer destruction calls event_free() without synchronizing with httpevent_callback_fn; if a callback is concurrently executing, the callback can access the freed HTTPEvent through self, causing a use-after-free during shutdown. Stop or synchronize the event loop before destroying these timers, or make timer destruction wait for callback completion. [race condition]

Severity Level: Critical 🚨
- ❌ Shutdown can crash during an executing HTTP timer callback.
- ⚠️ Wallet relock callbacks use the affected HTTP event path.
- ⚠️ Node termination becomes nondeterministic under concurrent shutdown.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/rpc/server.cpp
**Line:** 415:418
**Comment:**
	*Race Condition: Clearing the HTTP-backed timers while the HTTP event loop is still running is unsafe. `HTTPRPCTimer` destruction calls `event_free()` without synchronizing with `httpevent_callback_fn`; if a callback is concurrently executing, the callback can access the freed `HTTPEvent` through `self`, causing a use-after-free during shutdown. Stop or synchronize the event loop before destroying these timers, or make timer destruction wait for callback completion.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/rpc/server.cpp
{
if (!timerInterface)
throw JSONRPCError(RPC_INTERNAL_ERROR, "No timer handler registered for RPC");
LOCK(cs_deadlineTimers);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The new lock does not protect the lifetime of timerInterface. RPCRunLater checks timerInterface before acquiring cs_deadlineTimers, then may block on that lock while shutdown calls StopHTTPRPC(), unregisters and deletes the interface, and only afterward resumes to call Name() or NewTimer() through the dangling pointer. Synchronize interface registration and unregistration with this operation, or capture a lifetime-safe interface before releasing the lifecycle lock. [race condition]

Severity Level: Critical 🚨
- ❌ Concurrent RPC timer creation can crash during shutdown.
- ⚠️ Wallet relock scheduling uses this unprotected interface.
- ⚠️ Shutdown races with active RPC worker threads.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/rpc/server.cpp
**Line:** 628:628
**Comment:**
	*Race Condition: The new lock does not protect the lifetime of `timerInterface`. `RPCRunLater` checks `timerInterface` before acquiring `cs_deadlineTimers`, then may block on that lock while shutdown calls `StopHTTPRPC()`, unregisters and deletes the interface, and only afterward resumes to call `Name()` or `NewTimer()` through the dangling pointer. Synchronize interface registration and unregistration with this operation, or capture a lifetime-safe interface before releasing the lifecycle lock.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@codeant-ai

codeant-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

CodeAnt AI finished running the review.

@reubenyap reubenyap left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review summary

This is a correct, well-scoped fix that faithfully ports Bitcoin Core's solution. I verified the compile locally (details below) — recommend approval once CI has been run for this first-time contributor.

Verification performed

  • Compile check (the one open risk — the author noted they could not build): I built the bls-dash dependency and compiled both changed translation units at the PR head (67c1623) with GCC 13 in Release config — rpc/server.cpp and wallet/rpcwallet.cpp (which includes the modified wallet.h). Both compile cleanly with no new warnings. The constructs I specifically checked: AssertLockNotHeld exists in src/sync.h:95 with a real DEBUG_LOCKORDER implementation in sync.cpp:171 (no link failure in lock-debug builds), and the three-argument boost::bind is fine.
  • The deadlock mechanism is real: HTTPRPCTimer owns an HTTPEvent whose destructor calls event_free (httpserver.cpp:533), and evthread_use_pthreads is enabled (httpserver.cpp:405), so destroying the timer genuinely blocks until a running callback returns. The thread dumps in #1888 match this cycle.
  • Faithful to upstream, as claimed. Diffed against Bitcoin Core v28.0: the wallet side is a direct port of bitcoin/bitcoin#18814 (scoped cs_wallet block, captured relock generation, generation check in the callback, AssertLockNotHeld, and cs_unlock ↔ Core's m_unlock_mutex, correctly renamed to this codebase's cs_ convention). The server.cpp hunk is line-for-line equivalent to Core's g_deadline_timers_mutex, guarding the same two sites (RPCRunLater, StopRPC). The parts of Core's fix that were not ported are correctly inapplicable here: the weak_ptr unload guard (single static pwalletMain, no dynamic unload) and the #32862 scheduler rework (deliberately skipped to stay minimal — the right call).
  • Supporting claims all check out: walletpassphrase is the only RPCRunLater caller in the tree; walletlock resets nRelockTime under cs_wallet (rpcwallet.cpp:3186), so the generation check handles explicit locks; StopRPC runs before HTTP workers are joined (init.cpp:267-268), so cs_deadlineTimers does close a pre-existing shutdown race.

Correctness

  • Lock ordering is acyclic: cs_unlock → cs_main → cs_wallet and cs_unlock → cs_deadlineTimers; neither new lock is acquired by any timer callback, and cs_unlock has a single acquisition site.
  • A stale callback firing between the cs_wallet release and RPCRunLater fails the generation check; cs_unlock prevents the overlapping-unlock ordering bug that the first commit alone would have introduced.
  • No behavior change outside the fixed path: error paths, help text, TopUpKeyPool placement and the RPCRunLater-throws case are identical to master; the SecureString passphrase is now destroyed earlier, a marginal improvement.
  • Functional coverage exists via qa/rpc-tests/wallet-encryption.py (unlock, timeout relock, timeout extension), which runs in CI. The deadlock itself is a timing race that is impractical to test deterministically — upstream added no test for #18814 either.

Bot findings triage

  • The two Codex P2 findings and the CodeRabbit finding were real and are fixed by 67c1623; the author's replies are technically accurate. I am resolving those threads.
  • The two codeant-ai "Critical" findings can be dismissed:
    • "Use-after-free: event_free() doesn't synchronize with a running callback" — incorrect. With evthread_use_pthreads enabled, event_free/event_del from another thread blocks until the running callback returns; that blocking is precisely the deadlock mechanism this PR fixes. The StopRPC timer-clearing placement is also unchanged from master.
    • "timerInterface dangling pointer during shutdown" — a real but pre-existing theoretical race (StopHTTPRPC at init.cpp:265 deletes the interface while workers may still run until StopHTTPServer at :268 joins them). This PR neither introduces nor meaningfully widens it; it belongs in a separate cleanup, ideally a Core-#32862-style scheduler rework.

Minor notes (non-blocking)

  • Roughly half the added lines are comments — more verbose than Core's four-line equivalent, but the lock-rationale content is genuinely useful; fine to keep.
  • Pre-existing gaps Core later hardened (not regressions of this PR): no rejection of negative timeout and no clamp on huge values that can overflow GetTime() + nSleepTime. A follow-up adding Core's validation is being prepared separately.
  • Process nits, no code change needed: the PR title lacks the repo's Wallet: area prefix, and the description doesn't follow the template headings (PR intention / Code changes brief).

Generated by Claude Code

Copy link
Copy Markdown
Member

@Danswar — we'd like to fold the timeout-validation hardening mentioned in the review into this PR rather than land it separately. The commit is ready on a branch in this repo, stacked directly on your 67c1623, so it cherry-picks cleanly:

git fetch https://github.com/firoorg/firo.git claude/review-pr-1889-firo-ygv719
git cherry-pick 8b4d7393a559f426a8df2691009bdf18c5d13728
git push

What it does (mirrors upstream Bitcoin Core's walletpassphrase validation):

  • Rejects a negative timeout with RPC_INVALID_PARAMETER ("Timeout cannot be negative.") instead of scheduling a relock in the past.
  • Clamps the timeout to 100000000 seconds so GetTime() + nSleepTime cannot overflow nRelockTime.
  • Both checks run before Unlock(), so a rejected call leaves wallet state untouched.
  • Adds coverage to qa/rpc-tests/wallet-encryption.py for the rejection and for the wallet staying locked afterwards.

The commit was compile-checked against your branch head (rpcwallet.cpp builds cleanly, no new warnings) and the test file passes py_compile. Since the fork branch can't be pushed to from our side, the cherry-pick needs to come from you — happy to answer anything about it.


Generated by Claude Code

Reject a negative timeout with RPC_INVALID_PARAMETER instead of
accepting it and scheduling a relock in the past, and clamp the timeout
to 100000000 seconds so GetTime() + nSleepTime cannot overflow
nRelockTime. Both checks run before the wallet is unlocked, so a
rejected call leaves the wallet state untouched.

This mirrors the validation upstream Bitcoin Core performs in
walletpassphrase (the negative-timeout error and MAX_SLEEP_TIME clamp).

Also cover the negative-timeout rejection in wallet-encryption.py,
including that the wallet stays locked afterwards.

Follow-up to firoorg#1889.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UTd2DsfD7pTdvxDbubqASZ
@codeant-ai

codeant-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

User [email protected] does not have a PR Review subscription.

Go to Team management and add this email to the PR Review subscription.

@Danswar Danswar changed the title wallet: fix deadlock between walletpassphrase and the lockwallet relock timer Wallet: Fix deadlock between walletpassphrase and the lockwallet relock timer Jul 30, 2026
@Danswar

Danswar commented Jul 30, 2026

Copy link
Copy Markdown
Author

@reubenyap thanks, I addressed your comments

Copy link
Copy Markdown
Member

Confirmed — the cherry-pick at a477c7e matches the prepared commit exactly (tree-identical), and the description now follows the template. Everything from the review round is addressed; nothing further needed from your side.

Next step is on us: approving the CI workflow run for a first-time contributor, then review sign-off from the requested reviewers. Thanks for the quick turnaround and for a well-researched fix.


Generated by Claude Code

@Danswar

Danswar commented Jul 31, 2026

Copy link
Copy Markdown
Author

guix-x86_64-linux-gnu is red for an unrelated upstream reason. I don't have re-run rights on this repo — could a maintainer re-run that job?

The failure is in the Guix bootstrap, not in the build:

guix time-machine: error: Git error: cannot locate remote-tracking branch 'origin/keyring'

codeberg.org was degraded on 2026-07-31, so guix time-machine fell back to the Software Heritage archive. That replay only restores refs/heads/master, and without the keyring branch it cannot authenticate the pinned channel commit.

It is not specific to this PR:

  • The same guix jobs failed on the master push in run 30616408099 (there guix pull hit an HTTP 503 from the same host), and on unrelated PRs the same day.
  • On this PR's run the other four guix platforms passed on the identical commit (a477c7e).
  • The diff touches only src/rpc/server.cpp, src/wallet/rpcwallet.cpp, src/wallet/wallet.h and one functional test.

Upstream looks recovered — guix-x86_64-linux-gnu is green again on the master push in run 30659239531, so a re-run should pass.

Copy link
Copy Markdown
Member

Verified — the guix-x86_64-linux-gnu failure is the Guix channel bootstrap (cannot locate remote-tracking branch 'origin/keyring' after the Software Heritage fallback), hit before any Firo code was compiled. The other 11 checks on a477c7e are green, and the same job passed on master's latest run after codeberg recovered, so a re-run should clear it.

The re-run can't be triggered via the API from here, so it needs a manual "Re-run failed jobs" click — flagged for that. No action needed on your side.


Generated by Claude Code

@Danswar Danswar closed this Aug 4, 2026
@Danswar Danswar reopened this Aug 4, 2026
@Danswar

Danswar commented Aug 4, 2026

Copy link
Copy Markdown
Author

sorry closed by mistake, reopened

@Danswar

Danswar commented Aug 14, 2026

Copy link
Copy Markdown
Author

any chances of moving forward with this fix? anything else I can do to help? 🙏

@Danswar
Danswar requested a review from reubenyap August 14, 2026 15:33

Copy link
Copy Markdown
Member

Status update: CI was fully re-run on Aug 7 (run 30912163059) and the earlier guix infra failure is cleared — guix-x86_64-linux-gnu is green now.

The only remaining red is linux-cmake-Debug, and I've verified it's a flaky test, not this PR: all 91 unit tests passed, and of the RPC suite only dip3-deterministicmns.py failed (AssertionError: mnlists does not match provided mns at the chain-revert step) — a masternode-list test nowhere near this diff, which passed on this exact commit in the July 31 run. wallet-encryption.py, the test this PR touches, passed.

So there's nothing for you to do — the branch has effectively demonstrated green across both runs combined. It needs one more "Re-run failed jobs" click from a maintainer, then reviewer sign-off. I've flagged it again on our side; sorry for the slow turnaround here.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files

Projects

None yet

3 participants