From d0deb1a150d0ce07823bcaebc7b6d5e7e87d33b3 Mon Sep 17 00:00:00 2001 From: eldritch horrors Date: Tue, 30 Jun 2026 21:47:58 +0200 Subject: [PATCH] libutil: add error encoding that survives capnp exception transport using result types in capnp is fraught. while it makes some sense for interfaces that need neither streaming nor pipelining and can provide much better error fidelity there it's also fundamentally incompatible with those that *do* need streaming or pipelining: streams will never be stopped early unless an exception flies, and pipelines cannot look through result types. likely the best thing we can do is to encode an error for transport in the capnp/kj exception description strings. :( Change-Id: Icb7d16238fa9a7aaf92c00363f7be4076ac02a61 --- lix/libutil/meson.build | 1 + lix/libutil/types-rpc.cc | 61 +++++++++++++++++++++ lix/libutil/types-rpc.hh | 7 +++ lix/libutil/types.capnp | 23 ++++++++ tests/unit/libutil/rpc.cc | 111 ++++++++++++++++++++++++++++++++++++++ tests/unit/meson.build | 1 + 6 files changed, 204 insertions(+) create mode 100644 lix/libutil/types-rpc.cc create mode 100644 tests/unit/libutil/rpc.cc diff --git a/lix/libutil/meson.build b/lix/libutil/meson.build index 31d53df4d..8acae3919 100644 --- a/lix/libutil/meson.build +++ b/lix/libutil/meson.build @@ -48,6 +48,7 @@ liblix_sources += files( 'terminal.cc', 'thread-name.cc', 'thread-pool.cc', + 'types-rpc.cc', 'unix-domain-socket.cc', 'url-name.cc', 'url.cc', diff --git a/lix/libutil/types-rpc.cc b/lix/libutil/types-rpc.cc new file mode 100644 index 000000000..a5ee328bd --- /dev/null +++ b/lix/libutil/types-rpc.cc @@ -0,0 +1,61 @@ +#include "lix/libutil/types-rpc.hh" +#include "libutil/error.hh" +#include "libutil/fmt.hh" +#include "libutil/rpc.hh" +#include "types.capnp.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nix::rpc::error::v1 { + +std::string encodeLossy(const ::nix::ErrorInfo & e) +{ + capnp::MallocMessageBuilder msg; + RPC_FILL(msg, initRoot, e); + + const auto text = e.msg.str(); + + kj::VectorOutputStream out; + capnp::writePackedMessage(out, msg); + return fmt( + "%s %s%s%s", + text.length() > 128 ? "(oversize message)" : text, + V1_ERRORS->getHeader().cStr(), + kj::encodeBase64(out.getArray()).cStr(), + V1_ERRORS->getTrailer().cStr() + ); +} + +std::optional<::nix::ErrorInfo> tryDecode(std::string_view source) +{ + const auto dataStart = source.rfind(V1_ERRORS->getHeader().cStr()); + if (dataStart == source.npos) { + return std::nullopt; + } + source.remove_prefix(dataStart + V1_ERRORS->getHeader().size()); + const auto dataEnd = source.find(V1_ERRORS->getTrailer().cStr()); + if (dataEnd == source.npos) { + return std::nullopt; + } + source = source.substr(0, dataEnd); + + auto decoded = kj::decodeBase64(kj::arrayPtr(source.begin(), source.end())); + if (decoded.hadErrors) { + return std::nullopt; + } + + try { + kj::ArrayInputStream stream{decoded}; + capnp::PackedMessageReader reader{stream}; + return from(reader.getRoot()); + } catch (kj::Exception & e) { // NOLINT(lix-foreign-exceptions): capnp packet format errors + return std::nullopt; + } +} +} diff --git a/lix/libutil/types-rpc.hh b/lix/libutil/types-rpc.hh index ad5e4e092..946618d57 100644 --- a/lix/libutil/types-rpc.hh +++ b/lix/libutil/types-rpc.hh @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include namespace nix::rpc { @@ -166,4 +168,9 @@ struct Convert> return result; } }; + +namespace error::v1 { +std::string encodeLossy(const ::nix::ErrorInfo & e); +std::optional<::nix::ErrorInfo> tryDecode(std::string_view source); +} } diff --git a/lix/libutil/types.capnp b/lix/libutil/types.capnp index 54181a51e..2cdfea42f 100644 --- a/lix/libutil/types.capnp +++ b/lix/libutil/types.capnp @@ -17,6 +17,29 @@ enum Verbosity { vomit @7; } +# NOTE ON ERRORS +# +# interfaces in this system may throw errors in an encoded form. this encoded form is not +# itself meant to be read directly, but to be decoded and rethrown in a better error type +# than the (very limited) kj exceptions. transporting errors in result types inhibits all +# pipelining optimizations capnp can do for us (including early aborts in streaming types +# like encapsulated byte streams, or loggers). all interfaces should be annotated with an +# appropriate marker to document how their errors are transported. these annotations have +# NO IMPACT ON CODE GENERATION, error wrapping must still be done in each implementation. + +struct ErrorEncoding { + header @0 :Text; + trailer @1 :Text; +} + +const v1Errors :ErrorEncoding = ( + header = "{error:ODZmMTlmNjgtMjNiMy00MWE3LTgxYzUtMjY5YWUwN2ZkNDY1Cg:", + trailer = ":v1}", +); + +# annotations on interfaces also apply to methods and recursively to all child interfaces +annotation throws(interface, method) :ErrorEncoding; + struct Error { level @0 :Verbosity; message @1 :Data; diff --git a/tests/unit/libutil/rpc.cc b/tests/unit/libutil/rpc.cc new file mode 100644 index 000000000..178c112a0 --- /dev/null +++ b/tests/unit/libutil/rpc.cc @@ -0,0 +1,111 @@ +#include "lix/libutil/error.hh" +#include "lix/libutil/fmt.hh" +#include "lix/libutil/types-rpc.hh" +#include + +namespace nix { +TEST(RpcErrorV1, shortMessage) +{ + Error e{ErrorInfo{lvlWarn, HintFmt("test message %s", "data")}}; + e.addTrace(nullptr, HintFmt("trace 1 %s", "data")); + e.addTrace(nullptr, HintFmt("trace 2 %s", "data")); + + const auto encoded = rpc::error::v1::encodeLossy(e.info()); + ASSERT_EQ( + encoded, + "test message \x1B[35;1mdata\x1B[0m " + "{error:ODZmMTlmNjgtMjNiMy00MWE3LTgxYzUtMjY5YWUwN2ZkNDY1Cg:" + "EBBQAQIBAREF4hERFv90ZXN0IG1lcwJzYWdlIBtbMzU7MW1kYXRhDxtbMG0RBboRDbr/" + "dHJhY2UgMiAFG1szNTsxbWRhdGEbWzBtAHRyYWNlIDEgG1szNTsxbWRhdGEbWzBtAA==:v1}" + ); + + const auto decoded = rpc::error::v1::tryDecode("remote error: " + encoded); + ASSERT_TRUE(decoded.has_value()); + ASSERT_EQ(decoded->level, e.info().level); + ASSERT_EQ(decoded->msg.str(), e.info().msg.str()); + ASSERT_EQ(decoded->traces.size(), e.info().traces.size()); + for (auto ait = decoded->traces.begin(), bit = e.info().traces.begin(), aend = decoded->traces.end(); + ait != aend; + ++ait, ++bit) + { + ASSERT_EQ(ait->hint.str(), bit->hint.str()); + } +} + +TEST(RpcErrorV1, longMessage) +{ + Error e{ErrorInfo{lvlWarn, HintFmt("test message %s", std::string(1024, 'a'))}}; + e.addTrace(nullptr, HintFmt("trace 1 %s", "data")); + e.addTrace(nullptr, HintFmt("trace 2 %s", "data")); + + const auto encoded = rpc::error::v1::encodeLossy(e.info()); + ASSERT_EQ( + encoded, + "(oversize message) " + "{error:ODZmMTlmNjgtMjNiMy00MWE3LTgxYzUtMjY5YWUwN2ZkNDY1Cg:EI9QAQIBATEFwiATDQIW/" + "3Rlc3QgbWVzgnNhZ2UgG1szNTsxbWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY" + "WFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEbWzBtE" + "QW6EQ26/3RyYWNlIDIgBRtbMzU7MW1kYXRhG1swbQB0cmFjZSAxIBtbMzU7MW1kYXRhG1swbQA=:v1}" + ); + + const auto decoded = rpc::error::v1::tryDecode("remote error: " + encoded); + ASSERT_TRUE(decoded.has_value()); + ASSERT_EQ(decoded->level, e.info().level); + ASSERT_EQ(decoded->msg.str(), e.info().msg.str()); + ASSERT_EQ(decoded->traces.size(), e.info().traces.size()); + for (auto ait = decoded->traces.begin(), bit = e.info().traces.begin(), aend = decoded->traces.end(); + ait != aend; + ++ait, ++bit) + { + ASSERT_EQ(ait->hint.str(), bit->hint.str()); + } +} + +TEST(RpcErrorV1, noDecode) +{ + // no marker + ASSERT_FALSE( + rpc::error::v1::tryDecode( + "{rror:ODZmMTlmNjgtMjNiMy00MWE3LTgxYzUtMjY5YWUwN2ZkNDY1Cg:" + "EBBQAQIBAREF4hERFv90ZXN0IG1lcwJzYWdlIBtbMzU7MW1kYXRhDxtbMG0RBboRDbr/" + "dHJhY2UgMiAFG1szNTsxbWRhdGEbWzBtAHRyYWNlIDEgG1szNTsxbWRhdGEbWzBtAA==:v1}" + ) + ); + // no trailer + ASSERT_FALSE( + rpc::error::v1::tryDecode( + "{error:ODZmMTlmNjgtMjNiMy00MWE3LTgxYzUtMjY5YWUwN2ZkNDY1Cg:" + "EBBQAQIBAREF4hERFv90ZXN0IG1lcwJzYWdlIBtbMzU7MW1kYXRhDxtbMG0RBboRDbr/" + "dHJhY2UgMiAFG1szNTsxbWRhdGEbWzBtAHRyYWNlIDEgG1szNTsxbWRhdGEbWzBtAA==}" + ) + ); + // bad base64 + ASSERT_FALSE( + rpc::error::v1::tryDecode( + "{error:ODZmMTlmNjgtMjNiMy00MWE3LTgxYzUtMjY5YWUwN2ZkNDY1Cg:" + "EBBQAQIBAREF4hERFv90ZXN0IG1lcwJzYWdlIBtbMzU7MW1kYXRhDxtbMG0RBboRDbr/" + "dHJhY2UgMiAFG1szNTsxbWRhdGEbWzBtAHRyYWNlIDEgG1szNTsxbWRhdGEbWzBtAA=:v1}" + ) + ); + // bad capnp data + ASSERT_FALSE( + rpc::error::v1::tryDecode( + "{error:ODZmMTlmNjgtMjNiMy00MWE3LTgxYzUtMjY5YWUwN2ZkNDY1Cg:" + "eBBQAQIBAREF4hERFv90ZXN0IG1lcwJzYWdlIBtbMzU7MW1kYXRhDxtbMG0RBboRDbr/" + "dHJhY2UgMiAFG1szNTsxbWRhdGEbWzBtAHRyYWNlIDEgG1szNTsxbWRhdGEbWzBtAA==:v1}" + ) + ); +} +} diff --git a/tests/unit/meson.build b/tests/unit/meson.build index c5415bf65..d7f950a94 100644 --- a/tests/unit/meson.build +++ b/tests/unit/meson.build @@ -67,6 +67,7 @@ libutil_tests_sources = files( 'libutil/paths-setting.cc', 'libutil/pool.cc', 'libutil/references.cc', + 'libutil/rpc.cc', 'libutil/rust.cc', 'libutil/serialise.cc', 'libutil/suggestions.cc',