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
This commit is contained in:
eldritch horrors
2026-06-30 19:55:45 +00:00
parent 22e2ecdc43
commit d0deb1a150
6 changed files with 204 additions and 0 deletions
+1
View File
@@ -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',
+61
View File
@@ -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 <capnp/message.h>
#include <capnp/serialize-packed.h>
#include <kj/common.h>
#include <kj/encoding.h>
#include <kj/exception.h>
#include <kj/io.h>
#include <optional>
#include <string_view>
namespace nix::rpc::error::v1 {
std::string encodeLossy(const ::nix::ErrorInfo & e)
{
capnp::MallocMessageBuilder msg;
RPC_FILL(msg, initRoot<Error>, 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<Error>());
} catch (kj::Exception & e) { // NOLINT(lix-foreign-exceptions): capnp packet format errors
return std::nullopt;
}
}
}
+7
View File
@@ -11,6 +11,8 @@
#include <cstdint>
#include <exception>
#include <ranges>
#include <string>
#include <string_view>
#include <type_traits>
namespace nix::rpc {
@@ -166,4 +168,9 @@ struct Convert<Settings, std::map<std::string, std::string>>
return result;
}
};
namespace error::v1 {
std::string encodeLossy(const ::nix::ErrorInfo & e);
std::optional<::nix::ErrorInfo> tryDecode(std::string_view source);
}
}
+23
View File
@@ -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;
+111
View File
@@ -0,0 +1,111 @@
#include "lix/libutil/error.hh"
#include "lix/libutil/fmt.hh"
#include "lix/libutil/types-rpc.hh"
#include <gtest/gtest.h>
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}"
)
);
}
}
+1
View File
@@ -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',