cln-plugin: include the full error chain when given a context
What changed, and why it matters
This commit improves error messages in Core Lightning's Rust plugin library. When a plugin method fails and the error has extra context attached (using the popular Rust 'anyhow' error library), the plugin now returns the full chain of error messages to the caller instead of only the last message. This is a diagnostic-quality improvement, not a fix for a vulnerability.
No security action required. Treat as a normal quality/diagnostics improvement.
Security signals we found
No security-relevant signals detected in the diff or commit message.
Change is purely error-message formatting/diagnostics in Rust plugin framework.
No input validation, memory safety, authentication, or authorization changes.
Evidence from the diff
The change modifies cln-plugin’s error serialization. Previously parse_error took a String from e.to_string(), which for anyhow::Error only yields the outermost context. Now it takes &anyhow::Error and uses .chain() to collect all causal messages joined by ‘: ‘. A test is added verifying the chain is included. An example RPC method ‘test-error’ and a Python test exercise the behavior. No cryptographic, network, or authorization logic is changed.
Changed components
plugins/src/lib.rsplugins/examples/cln-plugin-startup.rstests/test_cln_rs.pyInspect captured patch +44 / −8
diff --git a/plugins/examples/cln-plugin-startup.rs b/plugins/examples/cln-plugin-startup.rs
index c2889559..39173534 100644
--- a/plugins/examples/cln-plugin-startup.rs
+++ b/plugins/examples/cln-plugin-startup.rs
@@ -2,12 +2,14 @@
//! plugins using the Rust API against Core Lightning.
#[macro_use]
extern crate serde_json;
+use anyhow::Context;
use cln_plugin::options::{
self, BooleanConfigOption, DefaultIntegerArrayConfigOption, DefaultIntegerConfigOption,
DefaultStringArrayConfigOption, IntegerArrayConfigOption, IntegerConfigOption,
StringArrayConfigOption,
};
use cln_plugin::{Builder, Error, HookBuilder, Plugin, messages};
+use cln_rpc::ClnRpc;
const TEST_NOTIF_TAG: &str = "test_custom_notification";
@@ -77,6 +79,7 @@ async fn main() -> Result<(), anyhow::Error> {
test_send_custom_notification,
)
.rpcmethod("test-log-levels", "send on all log levels", test_log_levels)
+ .rpcmethod("test-error", "test error with context", test_error)
.subscribe("connect", connect_handler)
.subscribe("test_custom_notification", test_receive_custom_notification)
.hook_from_builder(
@@ -179,3 +182,12 @@ async fn test_log_levels(
log::error!("log::error! working");
Ok(json!({}))
}
+
+async fn test_error(p: Plugin<()>, _v: serde_json::Value) -> Result<serde_json::Value, Error> {
+ let mut rpc = ClnRpc::new(p.configuration().rpc_file).await?;
+ let gi: serde_json::Value = rpc
+ .call_raw("00000000000", &json!({}))
+ .await
+ .context("context given")?;
+ Ok(gi)
+}
diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs
index 483f9fed..101c2d51 100644
--- a/plugins/src/lib.rs
+++ b/plugins/src/lib.rs
@@ -2,8 +2,8 @@ use crate::codec::{JsonCodec, JsonRpcCodec};
pub use anyhow::anyhow;
use anyhow::{Context, Result};
use futures::sink::SinkExt;
-use serde::de::DeserializeOwned;
use serde::Serialize;
+use serde::de::DeserializeOwned;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
extern crate log;
use log::trace;
@@ -973,7 +973,7 @@ where
.send(json!({
"jsonrpc": "2.0",
"id": id,
- "error": parse_error(e.to_string()),
+ "error": parse_error(&e),
}))
.await
.context("returning custom error"),
@@ -1112,14 +1112,20 @@ struct RpcError {
pub message: String,
pub data: Option<serde_json::Value>,
}
-fn parse_error(error: String) -> RpcError {
- match serde_json::from_str::<RpcError>(&error) {
- Ok(o) => o,
- Err(_) => RpcError {
+fn parse_error(error: &anyhow::Error) -> RpcError {
+ if let Ok(o) = serde_json::from_str::<RpcError>(&error.to_string()) {
+ o
+ } else {
+ let message = error
+ .chain()
+ .map(std::string::ToString::to_string)
+ .collect::<Vec<_>>()
+ .join(": ");
+ RpcError {
code: Some(-32700),
- message: error,
+ message,
data: None,
- },
+ }
}
}
@@ -1133,4 +1139,19 @@ mod test {
let builder = Builder::new(tokio::io::stdin(), tokio::io::stdout());
let _ = builder.start(state);
}
+ #[test]
+ fn parse_error_includes_full_anyhow_chain() {
+ let err = anyhow!("No such file or directory (os error 2)")
+ .context("config file missing")
+ .context("failed to load config");
+
+ let rpc_error = parse_error(&err);
+
+ assert_eq!(rpc_error.code, Some(-32700));
+ assert_eq!(
+ rpc_error.message,
+ "failed to load config: config file missing: No such file or directory (os error 2)"
+ );
+ assert_eq!(rpc_error.data, None);
+ }
}
diff --git a/tests/test_cln_rs.py b/tests/test_cln_rs.py
index fab0d98a..37af56cf 100644
--- a/tests/test_cln_rs.py
+++ b/tests/test_cln_rs.py
@@ -78,6 +78,9 @@ def test_plugin_start(node_factory):
assert not l1.rpc.listconfigs("test-dynamic-option")["configs"]["test-dynamic-option"]["value_bool"]
wait_for(lambda: l1.daemon.is_in_log(r'cln-plugin-startup: Got dynamic option change: test-dynamic-option "false"'))
+ with pytest.raises(RpcError, match="context given: .* Unknown command"):
+ l1.rpc.test_error()
+
def test_plugin_options_handle_defaults(node_factory):
"""Start a minimal plugin and ensure it is well-behaved
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.