From 42f36b3fd3ad21d7d91562c6d000f66acef7063b Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:27:35 +0000 Subject: [PATCH] app: /api/state never answers {} again (paid_wei over u64::MAX broke to_value; the field is a decimal string, the error is logged once) serde_json's to_value refuses a u128 over u64::MAX (18.45 IGN) and state_json turned the error into json!({}). A paid shard is 1.23 IGN on average, so a proving machine's dashboard went blank about 15 paid shards after every app start. Unit test over the boundary; 114 app tests pass. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 15 ++++++++++++++- app/igneum-app/src/state.rs | 22 ++++++++++++++++++++++ docs/bench-log.md | 15 +++++++++++++++ docs/plans/proving-v1.md | 7 +++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 5278f089b..d67acb7e8 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -92,6 +92,8 @@ pub struct Shared { /// the app log's path: the one the uploader sends (main.rs names it; the engine must not guess the stamp) pub log_path: PathBuf, port: std::sync::atomic::AtomicU16, + /// set once `state_json` has logged a serialisation error (one line, not one per poll) + state_error_logged: std::sync::atomic::AtomicBool, } impl Shared { @@ -135,6 +137,7 @@ impl Shared { engine_log: Mutex::new(engine_log), log_path, port: std::sync::atomic::AtomicU16::new(0), + state_error_logged: std::sync::atomic::AtomicBool::new(false), } } @@ -177,7 +180,17 @@ impl Shared { st.now = crate::platform::unix_now_f(); st.uptime_s = self.started.elapsed().as_secs(); st.events = self.rings.lock().unwrap().events.iter().cloned().collect(); - serde_json::to_value(st).unwrap_or(json!({})) + match serde_json::to_value(st) { + Ok(v) => v, + Err(e) => { + // never a silent "{}": the dashboard and every PC playbook read this reply + let msg = format!("state_json: {e}"); + if !self.state_error_logged.swap(true, std::sync::atomic::Ordering::Relaxed) { + self.log(&format!("[error] {msg}")); + } + json!({ "error": msg, "version": env!("CARGO_PKG_VERSION") }) + } + } } /// A short state for the window host (menu bar, tray). diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 5cf32e57a..da3283083 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -172,6 +172,9 @@ pub struct ProvingState { pub submitted: u32, pub paid: u32, pub failed: u32, + /// wei, serialised as a decimal string: serde_json's `to_value` refuses a u128 over u64::MAX (about 18.45 IGN, + /// 15 paid shards at 1.23 IGN), and that refusal emptied the whole `/api/state` reply to "{}" (6 October 2026) + #[serde(serialize_with = "u128_string")] pub paid_wei: u128, pub current: String, pub started_at: f64, @@ -401,3 +404,22 @@ impl Rings { out } } + +/// A u128 as a decimal JSON string (the dashboard reads it with `Number()`). +pub fn u128_string(v: &u128, s: S) -> Result { + s.serialize_str(&v.to_string()) +} + +#[cfg(test)] +mod paid_wei_tests { + use super::*; + + #[test] + fn a_paid_total_over_u64_max_still_serialises_the_whole_state() { + let mut st = State::default(); + st.proving.paid_wei = u64::MAX as u128 + 1; + let v = serde_json::to_value(&st).expect("the state serialises"); + assert_eq!(v["proving"]["paid_wei"], serde_json::Value::String("18446744073709551616".into())); + assert!(v["mining"].is_object()); + } +} diff --git a/docs/bench-log.md b/docs/bench-log.md index 2976bd59b..a0ebfa9eb 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -1694,3 +1694,18 @@ Reading, with the miner-on pairs above (empty shard 15,670 MiB, full prototype s |---|---| | Unit tests | `cargo test --release -p kaspa-consensus-core -p igneum-exec --lib -- proving config::params::tests::override_params_carry_the_proving_v1 config::params::tests::consensus_digest` on this Mac (target `vendor/igneum-node/target-pv1`, 19:09Z): consensus core 13 passed (the segment record round trip, signature and the three nested sections; the credit split; the params switch and the digest that moves only once the switch is set), exec 8 passed (the segment grid and the split; the record checks: alignment, block, chain length, the veto naming the field, the deadline, the window; the chain rule both ways; the unproven restart; the shard side at 90%; the pool offering the segment section). The six full node suites go to PC 2 as a build job when the fleet is back | | The fast-time 3-node harness (`tools/proving-v1/net.mjs`, 29950+, suffix 956, every node in trust mode, three vmine voters, v0 at DAA 60, v1 at DAA 120, 4 blocks a segment, unproven after 60 DAA, a tenth to the aggregator; fork b177718e built on this Mac) | run 2, 19:13:01Z to 19:16:19Z, under the run lock: PASSED, 21 checks in 197.3 s (`tools/proving-v1/report-2026-10-05.json`). v1 start = chain block 119 on all three nodes; the native statement identical on all three. Known-finished: segment 119..122's fresh-chain record submitted to n1 at t=131.1 s, relayed, verified (trust) and PAID on n0 1.0 s later at chain block 129, 253,611,648,000,000,000 wei = a tenth of the four credits, the same on every node, the payout address holding it. Chain rule: segment 123..126's fresh-chain record refused ("does not chain to segment 119..122 ... proven (record paid at chain block 129)"), the continuing one (chain_len 8) accepted and paid. Known-failed: segment 127..130 left without a record: a fresh-chain record for 131..134 refused while 127..130 was pending ("pending until DAA 191"); at DAA 192 the status read unproven, a late record for 127..130 refused ("unproven: carried after the deadline"), the fresh-chain record for 131..134 accepted and paid with chain_len 4; `segmentsInWindow` proven 3, unproven 1. The shard side: a v1 shard's `shardWei` = 90% of its block's credit. Run 1 (19:10Z) failed in its own tooling (the signer's argument order), fixed. Run 3 on the FINAL fork tree (ece42979 on the 0.3.10 commit 21d4c73c, protocol 15, N = 8 both in the params default and `--segment 8`, the fast-time file's four fields), 20:52:41Z to 20:56:45Z: PASSED, 21 checks in 244.4 s (segments of 8: 119..126 paid in 1.0 s after submission, 127..134 refused fresh and paid continuing with chain_len 16, 135..142 left unproven and skipped, 143..150 restarted the chain) | + +### 6 October 2026, 00:4xZ, the empty `/api/state` reply (proving v1 branch) + +Reported by the aggregation-cost agent: PC 2's `/api/state` answered `{}` (2 bytes) at 22:22Z, 22:41Z and 00:18Z. Not measured on PC 2 (no job); derived from the app source and node 1's RPC, read-only on the Mac: + +| Figure | Value | Source | +|---|---|---| +| Paid shards, devnet, all provers | 663 | `curl -s 127.0.0.1:26790 -d '{"jsonrpc":"2.0","id":1,"method":"igneum_getProvingStatus","params":[]}'` at tip DAA 0x22caf | +| Paid wei, all provers | 0x2c2961a69990745400 = 814.64 IGN | same call | +| Average per paid shard | 1.23 IGN (approximate: the mean over 663) | 814.64 / 663 | +| u64::MAX in IGN | 18.45 | 2^64 - 1 over 1e18 | +| Paid shards per app start before the reply empties | 15 (approximate: at the mean payout) | 18.45 / 1.23 | + +Cause: `ProvingState.paid_wei: u128` and serde_json `to_value` (1.0.151, `value/ser.rs` `serialize_u128`: u64 range or an error); the error became `json!({})`. Fix: the field serialises as a decimal string; `state_json` logs the error once. Test `a_paid_total_over_u64_max_still_serialises_the_whole_state` (`cargo test --offline -q paid_wei`, 1 passed). + diff --git a/docs/plans/proving-v1.md b/docs/plans/proving-v1.md index 8badea489..e19e5959b 100644 --- a/docs/plans/proving-v1.md +++ b/docs/plans/proving-v1.md @@ -143,3 +143,10 @@ The aggregation-cost agent's first rows (branch agg-cost, 5 October 2026 night, The re-plans of block 344 at 2.25 M and 4.5 M pgas peak at 28.3 to 28.4 GB alone (the server's buffers step up between 4.7 M and 20 M cycles and are flat to 60 M), so no shard size between the v1 budget and the prototype one changes a tier; with the miner the adopted shard proves 3.1x slower (13.2 s against 4.2 s) and the chained aggregation 9.7 s against 2.5 s: a mining 24 GB card delivers one adopted-size shard plus one aggregation in about 23 s, inside T by 25x. +## The empty `/api/state` reply (6 October 2026) + +The aggregation-cost agent's jobs read the two bytes `{}` from `/api/state` on PC 2 at 22:22Z, 22:41Z and 00:18Z (0.3.10 and 0.3.11); the 21:01Z reply was full. Cause, from the app source and node 1's RPC: `ProvingState.paid_wei` is a `u128`, and serde_json's `to_value` refuses a u128 over u64::MAX (18,446,744,073,709,551,615 wei, 18.45 IGN); `state_json()` turned that refusal into `json!({})` with no log line. A paid shard is 1.23 IGN on average (node 1, `igneum_getProvingStatus`: 814.64 IGN over 663 shards at 00:3xZ), so the fifteenth paid shard after an app start empties the reply. PC 2's prover was blind to the root-owned socket from 20:00:56Z to 22:01Z (paid_wei stayed 0, hence the full reply at 21:01Z), proved from 22:02:13Z, and crossed 18.45 IGN inside its first 15 paid shards, before 22:22Z. Every app restart resets the counter, so the reply comes back for about 15 shards and goes again. + +What it means: the dashboard on a proving machine shows nothing within about 12 minutes of its prover's first payout; every PC playbook that reads a card from `/api/state` fails the same way (the agent's job 5 reads settings.json instead). Mining, proving and payouts are untouched; it is the status page only. + +Fix on the app branch: `paid_wei` serialises as a decimal string (the dashboard already reads it with `Number()`), `state_json` logs `[error] state_json: ...` once instead of answering `{}`, and the reply on any future serialisation error carries `error` and `version` rather than nothing; unit test `a_paid_total_over_u64_max_still_serialises_the_whole_state`. Ships with 0.3.11 if the shipper takes the new code tip, otherwise 0.3.12; until then the workaround is settings.json for the card keys.