11 KiB
11 KiB
Multi-Threaded PoW Offloading Plan
Problem Statement
Currently, nostr_mine_event executes synchronously inside ServerContext::finish_request() via KeyStore::mine_event() and miner::miner_run(). Although miner::miner_run() spawns worker threads internally, it blocks the calling thread (handle.join()) until the target difficulty is reached or the timeout (up to 600s) expires.
Because ServerContext, the TUI loop (src/tui.rs), the GUI loop (src/gui.rs), and the headless loop (src/main.rs) run on a single thread, a 5-minute PoW mining request freezes the UI and blocks all other incoming signing requests until mining finishes.
Design Goals
- Non-blocking PoW Dispatch (Option 1 — Targeted Background Offloading):
- Keep
MnemonicState,RoleTable,KeyStore, andAlgorithmKeyCacheowned by the main thread withoutArc<Mutex<...>>lock contention or shared secret lifetimes. - Validate the
nostr_mine_eventrequest, verify any auth envelope, resolve the role selector, and ensure the role's key is derived on the main thread (microseconds). - Extract a temporary 32-byte stack copy of the role's private key (wrapped in
zeroize::Zeroizing<[u8; 32]>), take ownership of the client connection'sConnWriter, and spawn a background coordinator thread for the mining run.
- Keep
- Multiple Concurrent PoW Jobs:
- Any number of
nostr_mine_eventrequests may run concurrently alongside fast signing requests (nostr_sign_event,sign,nip44_encrypt,get_public_key, etc.). - On each poll tick, the server drains all pending incoming requests and drains any completed background mining activity messages via an
mpscchannel.
- Any number of
- Immediate Cancellation on Stop / Seed Unload:
ServerContextholds a shared cancellation token (Arc<AtomicBool>) and an active job counter (Arc<AtomicUsize>).- Calling
ServerContext::stop()(which is invoked on server stop, transport restart, role re-derivation/removal, or seed unload/quit in TUI, GUI, and headless modes) sets the cancellation flag totrue, immediately signaling all in-flightminer::mine_worker()threads to abort, zeroize their temporary key copy, and exit cleanly.
- Socket Blocking Mode Fix for Background Responses:
- Accepted sockets in
ServerContext::poll_request()are placed inset_nonblocking(true)for the initial probe/read. BeforeConnWriter::send_response()writes the response (especially from a background thread or for large payloads), ensure the stream is set back to blocking mode (or write timeout) so writing the response never fails silently withWouldBlock.
- Accepted sockets in
Architecture Diagram
sequenceDiagram
participant ClientA as Client A - PoW Mine
participant ClientB as Client B - Fast Sign
participant MainLoop as Main Thread - TUI / GUI / Headless
participant Server as ServerContext + Dispatcher
participant BgThread as Background Mine Coordinator
participant Workers as PoW Worker Threads 1..N
ClientA->>Server: nostr_mine_event request
MainLoop->>Server: poll_request + finish_request
Server->>Server: Verify auth, resolve role, derive key if needed
Server->>Server: Pre-validate event JSON and termination condition
Server-->>MainLoop: Return START activity immediately
Server->>BgThread: Spawn with ConnWriter, Zeroizing key, cancel_flag, activity_tx
BgThread->>Workers: Spawn N stride workers with cancel_flag
ClientB->>Server: nostr_sign_event request
MainLoop->>Server: poll_request + finish_request
Server->>ClientB: Signed event response in <1ms
Server-->>MainLoop: Return sign activity line
Workers-->>BgThread: Target reached or timeout or cancelled
BgThread->>BgThread: Sign best event and zeroize key copy
BgThread->>ClientA: Send JSON-RPC response via ConnWriter
BgThread->>Server: Send DONE / TIMEOUT / CANCELLED activity via activity_tx
MainLoop->>Server: poll_background_activities
Server-->>MainLoop: Drain completion activity lines to ActivityLog
Detailed Component Changes
1. Cancellable Miner (src/miner.rs)
- Add
pub cancelled: booltoMineResult. - Add
miner_run_cancellable()(and keepminer_run()as a thin wrapper passingNonefor backwards compatibility with existing unit tests):pub fn miner_run_cancellable( event_json: &str, private_key: &[u8; 32], target_difficulty: i32, thread_count: i32, timeout_sec: u64, cancel: Option<&Arc<AtomicBool>>, ) -> Result<MineResult, crate::SignerError> - Inside
mine_worker():- Check the external
cancelAtomicBool(withOrdering::Relaxed) every 1,024 iterations (alongside the sharedstopcheck) so workers exit within microseconds when cancelled without lock contention on every single hash. - Also replace the per-iteration
shared.lock()check on line 158 with anAtomicBool(stop_flag: Arc<AtomicBool>) so worker threads do not contend on aMutexon every single SHA-256 hash attempt! (This will also significantly speed up multi-threaded PoW hash rates).
- Check the external
- If cancelled via the external
cancelflag,miner_run_cancellablesetscancelled: trueonMineResult(or skips signing if shutting down).
2. Background Job Preparation in Dispatcher / KeyStore (src/dispatcher.rs & src/key_store.rs)
- Add a prepared mining request struct in
src/dispatcher.rs:pub struct PreparedMineJob { pub rpc_id: String, pub role_name: String, pub event_json: String, pub private_key: zeroize::Zeroizing<[u8; 32]>, pub difficulty: i32, pub threads: i32, pub timeout_sec: u64, } - When
handle_nostr_verbinsrc/dispatcher.rshandlesVERB_NOSTR_MINE_EVENT:- Validate
params[0](must be valid unsignedEventJSON),difficulty/timeout_sectermination condition, role selector, purpose enforcement, and key derivation synchronously. - If validation fails, return the immediate JSON-RPC error response as before.
- When invoked from
ServerContext(which supports background offloading), populatectx.pending_mine_job: Option<PreparedMineJob>with theZeroizing<[u8; 32]>key copy and validated parameters instead of blocking onkey_store.mine_event(). - Keep synchronous execution in
handle_request()whenctxis used directly in unit tests (or provide a flagoffload_mining: boolonDispatcherContext).
- Validate
3. Background Spawning & Activity Channel in ServerContext (src/server.rs)
- Extend
ServerContextwith:activity_tx: std::sync::mpsc::Sender<String>activity_rx: std::sync::mpsc::Receiver<String>cancel_flag: Arc<AtomicBool>active_jobs: Arc<AtomicUsize>
- In
ConnWriter::send_response():- Set the underlying
UnixStreamorTcpStreamback to blocking (set_nonblocking(false)) before writing the response so the response write never fails withWouldBlock.
- Set the underlying
- In
ServerContext::finish_request():- Set
dispatcher.offload_mining = true(except inListenMode::Stdio | ListenMode::Qrexecone-shot mode where the process exits after one request, or wait for active jobs before exiting). - If
process_requestyields aPreparedMineJob:- Return the
STARTactivity line (orOption<String>if already logged by two-phase caller) immediately. - Increment
active_jobs. - Spawn a background thread owning
writer: ConnWriter,job: PreparedMineJob,caller_id: String,cancel_flag: Arc<AtomicBool>,active_jobs: Arc<AtomicUsize>, andactivity_tx: Sender<String>. - The background thread runs
miner::miner_run_cancellable(...):- Drops/zeroizes
job.private_keyimmediately afterminer_run_cancellablereturns. - If not cancelled, formats the JSON-RPC response, calls
writer.send_response(&response), formats the completion activity viamine_done_activity(), and sends it overactivity_tx. - If cancelled, sends a cancellation error response (best-effort) and sends a
CANCELLEDactivity line overactivity_tx. - Decrements
active_jobs.
- Drops/zeroizes
- Return the
- Set
- Add
ServerContext::poll_activity(&mut self) -> Option<String>(anddrain_activities(&mut self) -> Vec<String>) to read completed background job messages fromactivity_rx.try_recv(). - Add
ServerContext::active_mining_count(&self) -> usizeso callers/tests/UI can inspect how many background jobs are currently in flight. - Update
ServerContext::stop(&mut self):- Set
self.cancel_flag.store(true, Ordering::SeqCst)to abort all in-flight mining workers immediately, then resetself.cancel_flag = Arc::new(AtomicBool::new(false))for any future restart.
- Set
- Update
ServerContext::handle_one():- If a pending socket request is available, handle it (for backgrounded mining, return the
STARTactivity line immediately). - Otherwise, check
self.poll_activity()and returnOk(Some(bg_activity))if a background mining job finished.
- If a pending socket request is available, handle it (for backgrounded mining, return the
4. TUI, GUI, and Headless Loop Integration (src/tui.rs, src/gui.rs, src/main.rs)
- TUI (
src/tui.rs):- In
App::service_server():- First drain
server.drain_activities()and append each line toself.activity_log. - Process any pending socket requests in a loop (or one per tick) without blocking on
nostr_mine_event: whennostr_mine_eventis dispatched infinish_request, it spawns the background thread and returns immediately, soSTARTis logged once andDONE/TIMEOUTarrives later viadrain_activities().
- First drain
- In
- GUI (
src/gui.rs):- In
GuiApp::service_server():- Drain
server.drain_activities()intoself.activity_logand request repaint when activities arrive or whileserver.active_mining_count() > 0. - Handle incoming requests non-blockingly.
- Drain
- In
- Headless (
src/main.rs):- In
run_headless():- For
Tcp/Http/Unixloop: drain andprintln!completed activities fromserver.drain_activities()on every iteration, and process incoming requests non-blockingly. - For
Stdio/Qrexecsingle-request mode: wait for any spawned job to finish (or run synchronously) before exiting the process.
- For
- In
5. Concurrency & Cancellation Integration Tests (tests/integration.rs)
- Add
test_concurrent_signing_during_pow_mining:- Start a
nostr_mine_eventrequest on Client Thread A with a high difficulty (e.g.,difficulty: 28,timeout_sec: 5) that will run for several seconds. - While Client Thread A is actively mining, send multiple
nostr_sign_event,nostr_get_public_key, andsign(ed25519) requests from Client Thread B. - Assert that all of Client Thread B's requests complete in
< 200mswhile Client Thread A is still mining. - Assert that multiple concurrent
nostr_mine_eventrequests can also run simultaneously. - Assert that calling
server.stop()cancels the in-flight mining job immediately without waiting for the full timeout.
- Start a