rpc: transport errors in encoded exception descriptions, not results

results don't allow for streaming or pipelining. transporting errors in
exception texts is *bad*, but it's still better than not having streams
that actually work. this isn't a great situation, but with capnp that's
pretty much the best we can do. still beats the other rpc frameworks :/

Change-Id: I2abf17bc5ea5de2baeea44ff4d7b2f4db33e98dd
This commit is contained in:
eldritch horrors
2026-06-30 19:56:57 +00:00
parent 57c00a20c5
commit 6e19a940b1
11 changed files with 184 additions and 166 deletions
+25 -23
View File
@@ -5,6 +5,7 @@
#include "lix/libutil/file-descriptor.hh"
#include "lix/libutil/logging-rpc.hh"
#include "lix/libutil/logging.hh"
#include "lix/libutil/result.hh"
#include "lix/libutil/rpc.hh"
#include "lix/libutil/types-rpc.hh" // IWYU pragma: keep
#include "lix/libutil/types.hh"
@@ -50,7 +51,7 @@ struct Instance final : rpc::build_remote::HookInstance::Server
kj::Promise<void> init(InitContext context) override;
kj::Promise<void> buildImpl(BuildContext context);
kj::Promise<Result<void>> buildImpl(BuildContext context);
kj::Promise<void> build(BuildContext context) override;
};
}
@@ -261,7 +262,7 @@ struct AcceptedBuild final : rpc::build_remote::HookInstance::AcceptedBuild::Ser
{
}
kj::Promise<void> runImpl(RunContext context);
kj::Promise<Result<void>> runImpl(RunContext context);
kj::Promise<void> run(RunContext context) override;
};
@@ -391,16 +392,14 @@ kj::Promise<void> Instance::init(InitContext context)
initPlugins();
initialized = true;
context.getResults().initResult().setGood();
} catch (...) {
RPC_FILL(context.getResults(), initResult, std::current_exception());
rpc::rethrow_as_rpc_error();
}
return kj::READY_NOW;
}
kj::Promise<void> Instance::buildImpl(BuildContext context)
kj::Promise<Result<void>> Instance::buildImpl(BuildContext context)
try {
if (!initialized) {
throw Error("build hook not fully initialized");
@@ -427,8 +426,8 @@ try {
debug("got %d remote builders", machines.size());
if (machines.empty()) {
context.getResults().initResult().initGood().setDeclinePermanently();
co_return;
context.getResults().initResult().setDeclinePermanently();
co_return result::success();
}
auto amWilling = context.getParams().getAmWilling();
@@ -444,21 +443,23 @@ try {
if (auto immediateResponse = std::get_if<BuildRejected>(&result)) {
switch (*immediateResponse) {
case BuildRejected::Temporarily:
context.getResults().initResult().initGood().setPostpone();
co_return;
context.getResults().initResult().setPostpone();
co_return result::success();
case BuildRejected::Permanently:
context.getResults().initResult().initGood().setDecline();
co_return;
context.getResults().initResult().setDecline();
co_return result::success();
}
}
auto builder = std::get_if<BuilderConnection>(&result);
assert(builder);
auto ac = context.getResults().initResult().initGood().initAccept();
auto ac = context.getResults().initResult().initAccept();
ac.setMachine(kj::heap<AcceptedBuild>(store, drvPath, std::move(*builder)));
co_return result::success();
} catch (...) {
RPC_FILL(context.getResults(), initResult, std::current_exception());
co_return result::current_exception();
}
kj::Promise<void> Instance::build(BuildContext context)
@@ -467,11 +468,12 @@ try {
throw Error("build hooks can only accept a single job");
}
used = true; // lock out other rpc calls during processing
co_await buildImpl(context);
auto result = co_await buildImpl(context);
TRY_AWAIT(logger->flush());
used = context.getResults().getResult().getGood().isAccept();
used = result.has_value() && context.getResults().getResult().isAccept();
result.value();
} catch (...) {
RPC_FILL(context.getResults(), getResult, std::current_exception());
rpc::rethrow_as_rpc_error();
}
kj::Promise<void> AcceptedBuild::run(RunContext context)
@@ -489,14 +491,15 @@ kj::Promise<void> AcceptedBuild::run(RunContext context)
throw Error("build hooks builds are single-use items");
}
used = true;
co_await runImpl(context);
auto result = co_await runImpl(context);
TRY_AWAIT(logger->flush());
result.value();
} catch (...) {
RPC_FILL(context.getResults(), getResult, std::current_exception());
rpc::rethrow_as_rpc_error();
}
}
kj::Promise<void> AcceptedBuild::runImpl(RunContext context)
kj::Promise<Result<void>> AcceptedBuild::runImpl(RunContext context)
{
try {
auto logHandler = builder.startLogThread(
@@ -618,10 +621,9 @@ kj::Promise<void> AcceptedBuild::runImpl(RunContext context)
// drop store connection, let log handler process any remaining input
builder.sshStore = nullptr;
TRY_AWAIT(logHandler);
context.getResults().initResult().setGood();
co_return result::success();
} catch (...) {
RPC_FILL(context.getResults(), initResult, std::current_exception());
co_return result::current_exception();
}
}
+5 -5
View File
@@ -1094,8 +1094,8 @@ try {
RPC_FILL(buildReq, setNeededSystem, drv->platform);
RPC_FILL(buildReq, initDrvPath, drvPath, worker.store);
RPC_FILL(buildReq, initRequiredFeatures, parsedDrv->getRequiredSystemFeatures());
auto buildRespPromise = buildReq.send();
auto buildResp = TRY_AWAIT_RPC(buildRespPromise);
auto buildRespV = TRY_AWAIT_RPC(buildReq.send());
auto buildResp = buildRespV.getResult();
debug("hook reply is '%1%'", buildResp.toString().flatten().cStr());
@@ -1139,7 +1139,7 @@ try {
RPC_FILL(runReq, setDescription, buildDescription());
}
auto runPromise = runReq.send();
auto runPromise = LIX_WRAP_RPC_PROMISE_V1(runReq.send());
// build via hook is now properly running. wait for it to finish
actLock.reset();
@@ -1150,8 +1150,8 @@ try {
wrapChildHandler(runPromise.then([&](auto result) -> kj::Promise<Result<WorkResult>> {
try {
std::shared_ptr<Error> remoteError;
if (result.getResult().isBad()) {
remoteError = std::make_shared<Error>(from(result.getResult().getBad()));
if (result.has_error()) {
remoteError = std::make_shared<Error>(detail::wrap_exception_as_lix(result.error()));
logErrorInfo(remoteError->info().level, remoteError->info());
}
// close the rpc connection to have the hook exit
+4 -4
View File
@@ -8,14 +8,14 @@ using Types = import "/lix/libutil/types.capnp";
using Log = import "/lix/libutil/logging.capnp";
using StoreTypes = import "/lix/libstore/types.capnp";
interface HookInstance {
interface HookInstance $Types.throws(Types.v1Errors) {
interface AcceptedBuild {
run @0 (
logger :Log.LogStream,
inputs :List(StoreTypes.StorePath), # actual a set
wantedOutputs :List(Data), # actually StringSet
description :Text, # root activity description for this build
) -> (result :Types.ResultV);
);
}
struct BuildResponse {
@@ -32,11 +32,11 @@ interface HookInstance {
init @0 (
logger :Log.LogStream,
settings :Types.Settings,
) -> (result :Types.ResultV);
);
build @1 (
amWilling :Bool,
neededSystem :Data,
drvPath :StoreTypes.StorePath,
requiredFeatures :List(Data),
) -> (result :Types.Result(BuildResponse));
) -> (result :BuildResponse);
}
+5 -5
View File
@@ -21,7 +21,7 @@ interface Bootstrap {
request @1 (
clientInfo :Text,
protocol :Text,
) -> (result :T.Result(Protocol));
) -> (result :Protocol);
}
interface Protocol {
@@ -32,7 +32,7 @@ interface Protocol {
# every change to the experimental tunneling protocol may also change this protocol identifier.
const unstableLegacyTunneled :Text = "lix/legacy/ba3153c5-4153-4d66-91ec-a258c02e9a3c";
interface LegacyBoot extends(Protocol) {
interface LegacyBoot extends(Protocol) $T.throws(T.v1Errors) {
enum Trust {
unknown @0;
untrusted @1;
@@ -42,7 +42,7 @@ interface LegacyBoot extends(Protocol) {
init @0 (
logger :Log.LogStream,
replyStream :LegacyStream,
) -> (result :T.Result(InitResult));
) -> (result :InitResult);
struct InitResult {
requestStream @0 :LegacyStream;
@@ -51,8 +51,8 @@ interface LegacyBoot extends(Protocol) {
}
}
interface LegacyStream {
interface LegacyStream $T.throws(T.v1Errors) {
feed @0 (raw :Data) -> stream;
# must be called before a new op is started, otherwise errors may get lost
sync @1 () -> (result :T.ResultV);
sync @1 ();
}
+23 -19
View File
@@ -1040,34 +1040,34 @@ struct RequestStreamImpl final : LegacyStream::Server
auto req = callbacks.feedRequest();
req.initRaw(*got);
std::copy(buf.begin(), buf.begin() + *got, req.getRaw().begin());
co_await req.send();
TRY_AWAIT_RPC(req.send());
}
}
co_await callbacks.syncRequest().send();
TRY_AWAIT_RPC(callbacks.syncRequest().send());
} catch (...) {
onError(std::current_exception());
}
kj::Promise<void> feed(FeedContext context) override
try {
auto bytes = context.getParams().getRaw();
if (!error) {
TRY_AWAIT(workerSock.writeFull(bytes.begin(), bytes.size()));
if (error) {
std::rethrow_exception(error);
}
auto bytes = context.getParams().getRaw();
TRY_AWAIT(workerSock.writeFull(bytes.begin(), bytes.size()));
} catch (...) {
onError(std::current_exception());
rpc::rethrow_as_rpc_error();
}
kj::Promise<void> sync(SyncContext context) override
try {
TRY_AWAIT(logger->flush());
if (error) {
RPC_FILL(context.initResults(), initResult, error);
} else {
context.initResults().initResult().setGood();
std::rethrow_exception(error);
}
} catch (...) {
RPC_FILL(context.initResults(), initResult, std::current_exception());
rpc::rethrow_as_rpc_error();
}
};
@@ -1088,7 +1088,7 @@ struct LegacyBootImpl final : LegacyBoot::Server
logger = rpc::log::makeRpcLoggerClient(context.getParams().getLogger());
auto args = context.getParams();
auto result = context.initResults().initResult().initGood();
auto result = context.initResults().initResult();
// We and the underlying store both need to trust the client for it to be trusted.
if (!state->trusted) {
result.setTrust(LegacyBoot::Trust::UNTRUSTED);
@@ -1163,7 +1163,7 @@ struct LegacyBootImpl final : LegacyBoot::Server
);
used = true;
} catch (...) {
RPC_FILL(context.initResults(), initResult, std::current_exception());
rpc::rethrow_as_rpc_error();
}
};
@@ -1200,7 +1200,6 @@ struct BootstrapImpl final : Bootstrap::Server
kj::Exception::Type::UNIMPLEMENTED, "main", 0, kj::str("rpc sockets not enabled")
)
);
throw Error("rpc sockets not enabled");
}
auto result = context.initResults();
@@ -1213,20 +1212,25 @@ struct BootstrapImpl final : Bootstrap::Server
}
kj::Promise<void> request(RequestContext context) override
try {
{
auto id = rpc::to<std::string>(context.getParams().getProtocol());
if (used) {
throw Error("connection already initialized");
kj::throwFatalException(
kj::Exception(
kj::Exception::Type::FAILED, "main", 0, kj::str("connection already initialized")
)
);
} else if (const auto & protocol = get(protocols, id)) {
used = true;
context.initResults().initResult().setGood(protocol->factory(trusted, store));
context.initResults().setResult(protocol->factory(trusted, store));
} else {
throw Error("unsupported protocol %s", id);
kj::throwFatalException(
kj::Exception(
kj::Exception::Type::UNIMPLEMENTED, "main", 0, kj::str("unsupported protocol", id)
)
);
}
return kj::READY_NOW;
} catch (...) {
RPC_FILL(context.initResults(), initResult, std::current_exception());
return kj::READY_NOW;
}
};
}
+17 -12
View File
@@ -17,6 +17,8 @@
#include <algorithm>
#include <capnp/rpc-twoparty.h>
#include <cerrno>
#include <exception>
#include <kj/async.h>
#include <kj/encoding.h>
#include <sys/types.h>
#include <sys/stat.h>
@@ -200,13 +202,14 @@ try {
auto bootstrapReq = bootstrap.requestRequest();
bootstrapReq.setClientInfo(PACKAGE_STRING);
bootstrapReq.setProtocol(rpc::daemon::UNSTABLE_LEGACY_TUNNELED);
auto legacyBoot = TRY_AWAIT_RPC(bootstrapReq.send()).castAs<rpc::daemon::LegacyBoot>();
auto legacyBoot =
TRY_AWAIT_RPC_NOEXCEPT(bootstrapReq.send()).getResult().castAs<rpc::daemon::LegacyBoot>();
auto initReq = legacyBoot.initRequest();
initReq.setLogger(kj::heap<rpc::log::RpcLoggerServer>(con.rpc->loggerActivity));
initReq.setReplyStream(kj::heap<LegacyStreamProxy>(*con.rpc));
auto initResp = initReq.send();
auto initResult = TRY_AWAIT_RPC(initResp);
auto initResp = TRY_AWAIT_RPC(initReq.send());
auto initResult = initResp.getResult();
con.remoteTrustsUs = initResult.getTrust() == rpc::daemon::LegacyBoot::Trust::TRUSTED
? std::optional{Trusted}
: initResult.getTrust() == rpc::daemon::LegacyBoot::Trust::UNTRUSTED ? std::optional{NotTrusted}
@@ -235,10 +238,10 @@ try {
auto req = requestStream.feedRequest();
req.initRaw(*got);
std::copy(buf.begin(), buf.begin() + *got, req.getRaw().begin());
co_await req.send();
TRY_AWAIT_RPC(req.send());
}
}
co_await requestStream.syncRequest().send();
TRY_AWAIT_RPC(requestStream.syncRequest().send());
} catch (...) {
ignoreExceptionExceptInterrupt();
error = std::current_exception();
@@ -246,22 +249,24 @@ try {
kj::Promise<void> UDSRemoteStore::LegacyStreamProxy::feed(FeedContext context)
try {
if (!state.error) {
auto bytes = context.getParams().getRaw();
TRY_AWAIT(state.proxySock->writeFull(bytes.begin(), bytes.size()));
if (state.error) {
std::rethrow_exception(state.error);
}
auto bytes = context.getParams().getRaw();
TRY_AWAIT(state.proxySock->writeFull(bytes.begin(), bytes.size()));
} catch (...) {
state.error = std::current_exception();
rpc::rethrow_as_rpc_error();
}
kj::Promise<void> UDSRemoteStore::LegacyStreamProxy::sync(SyncContext context)
{
try {
if (state.error) {
RPC_FILL(context.initResults(), initResult, state.error);
} else {
context.initResults().initResult().setGood();
std::rethrow_exception(state.error);
}
return kj::READY_NOW;
} catch (...) {
rpc::rethrow_as_rpc_error();
}
kj::Promise<Result<void>> UDSRemoteStore::initConnection(RemoteStore::Connection & conn)
+1
View File
@@ -37,6 +37,7 @@ liblix_sources += files(
'processes.cc',
'references.cc',
'regex.cc',
'rpc.cc',
'serialise-async.cc',
'serialise.cc',
'shlex.cc',
+35
View File
@@ -0,0 +1,35 @@
#include "libutil/rpc.hh"
#include "libutil/error.hh"
#include "libutil/types-rpc.hh"
#include <exception>
namespace nix::rpc::detail {
std::exception_ptr unwrapErrorRaw(kj::Exception & e, std::source_location loc)
{
nix::Error fe{e.getDescription().cStr()};
fe.addAsyncTrace(loc, "RPC call");
return std::make_exception_ptr(std::move(fe));
}
std::exception_ptr unwrapErrorV1(kj::Exception & e, std::source_location loc)
{
if (auto decoded = error::v1::tryDecode(e.getDescription().cStr())) {
nix::Error fe(std::move(*decoded));
fe.addAsyncTrace(loc, "RPC call");
return std::make_exception_ptr(std::move(fe));
} else {
return unwrapErrorRaw(e, loc);
}
}
void rethrowAsErrorV1()
{
try {
throw; // NOLINT(lix-foreign-exceptions)
} catch (const nix::BaseError & e) {
kj::throwFatalException(
kj::Exception(kj::Exception::Type::FAILED, "remote", 0, kj::str(error::v1::encodeLossy(e.info())))
);
}
}
}
+69 -7
View File
@@ -1,17 +1,24 @@
#pragma once
///@file RPC helper functions
#include "lix/libutil/result.hh"
#include "lix/libutil/charptr-cast.hh"
#include "lix/libutil/rpc-fwd.hh"
#include <capnp/blob.h>
#include <capnp/common.h>
#include <capnp/list.h>
#include <concepts>
#include <kj/async.h>
#include <ranges>
#include <source_location>
#include <string>
#include <string_view>
#include <type_traits>
namespace kj {
class Exception;
}
namespace nix::rpc {
template<typename To, typename From>
@@ -181,15 +188,70 @@ inline void doFill(auto && builder, Inner (Builder::*field)(Init), From && f, au
#define LIX_RPC_FILL(fobj, ffield, fsource, ...) \
[&] { ::nix::rpc::detail::doFill(fobj, &decltype(fobj)::ffield, (fsource), ##__VA_ARGS__); }()
#define LIX_TRY_AWAIT_RPC(...) \
LIX_TRY_AWAIT_CONTEXT_MAP( \
[] { return "RPC call"; }, \
([](auto r) { return ::nix::rpc::from(r.getResult()); }), \
__VA_ARGS__ \
)
namespace detail {
std::exception_ptr unwrapErrorRaw(kj::Exception & e, std::source_location loc);
std::exception_ptr unwrapErrorV1(kj::Exception & e, std::source_location loc);
[[noreturn]]
void rethrowAsErrorV1();
kj::Promise<nix::Result<void>> inline rewrapNoexcept(
kj::Promise<void> && promise, std::source_location loc = std::source_location::current()
)
{
return promise.then(
[]() -> nix::Result<void> { return result::success(); },
[loc](kj::Exception && e) -> nix::Result<void> { return result::failure(unwrapErrorRaw(e, loc)); }
);
}
template<typename T>
kj::Promise<nix::Result<T>>
rewrapNoexcept(kj::Promise<T> && promise, std::source_location loc = std::source_location::current())
{
return promise.then(
[](T result) -> nix::Result<T> { return result; },
[loc](kj::Exception && e) -> nix::Result<T> { return result::failure(unwrapErrorRaw(e, loc)); }
);
}
kj::Promise<nix::Result<void>> inline rewrapV1(
kj::Promise<void> && promise, std::source_location loc = std::source_location::current()
)
{
return promise.then(
[]() -> nix::Result<void> { return result::success(); },
[loc](kj::Exception && e) -> nix::Result<void> { return result::failure(unwrapErrorV1(e, loc)); }
);
}
template<typename T>
kj::Promise<nix::Result<T>>
rewrapV1(kj::Promise<T> && promise, std::source_location loc = std::source_location::current())
{
return promise.then(
[](T result) -> nix::Result<T> { return result; },
[loc](kj::Exception && e) -> nix::Result<T> { return result::failure(unwrapErrorV1(e, loc)); }
);
}
}
#define LIX_WRAP_RPC_PROMISE_NOEXCEPT(...) (::nix::rpc::detail::rewrapNoexcept(__VA_ARGS__))
#define LIX_WRAP_RPC_PROMISE_V1(...) (::nix::rpc::detail::rewrapV1(__VA_ARGS__))
#define LIX_TRY_AWAIT_RPC_NOEXCEPT(...) (LIX_TRY_AWAIT(LIX_WRAP_RPC_PROMISE_NOEXCEPT(__VA_ARGS__)))
#define LIX_TRY_AWAIT_RPC_V1(...) (LIX_TRY_AWAIT(LIX_WRAP_RPC_PROMISE_V1(__VA_ARGS__)))
[[noreturn]]
inline void rethrow_as_rpc_error()
{
detail::rethrowAsErrorV1();
}
#ifdef LIX_UR_COMPILER_UWU
#define RPC_FILL LIX_RPC_FILL
#define TRY_AWAIT_RPC LIX_TRY_AWAIT_RPC
#define TRY_AWAIT_RPC_NOEXCEPT LIX_TRY_AWAIT_RPC_NOEXCEPT
#define TRY_AWAIT_RPC_V1 LIX_TRY_AWAIT_RPC_V1
#define TRY_AWAIT_RPC LIX_TRY_AWAIT_RPC_V1
#endif
}
-76
View File
@@ -55,82 +55,6 @@ struct Fill<Error, nix::ErrorInfo>
}
};
namespace detail {
inline void makeBadResult(auto rb, const std::exception_ptr & e)
{
try {
std::rethrow_exception(e);
} catch (nix::Error & e) {
LIX_RPC_FILL(rb, initBad, e.info());
} catch (std::exception & e) { // NOLINT(lix-foreign-exceptions)
LIX_RPC_FILL(rb, initBad, nix::Error("caught non-lix exception: %s", e.what()).info());
} catch (...) {
LIX_RPC_FILL(rb, initBad, nix::Error("caught non-exception! spooky").info());
}
}
template<typename>
inline constexpr bool IsResult = false;
template<typename T>
inline constexpr bool IsResult<Result<T>> = true;
template<typename T>
concept ResultReader = IsResult<typename T::Reads>;
template<typename T>
concept ResultBuilder = IsResult<typename T::Builds>;
}
inline nix::Result<void> from(const ResultV::Reader & r, auto &&... args)
{
if (r.isGood()) {
return result::success();
} else {
return result::failure(nix::Error(from(r.getBad(), args...)));
}
}
template<>
struct Fill<ResultV, nix::Result<void>>
{
static void fill(ResultV::Builder rb, const nix::Result<void> & r, auto &&...)
{
if (r.has_value()) {
rb.setGood();
} else {
detail::makeBadResult(rb, r.error());
}
}
};
template<>
struct Fill<ResultV, std::exception_ptr>
{
static void fill(ResultV::Builder rb, const std::exception_ptr & e, auto &&...)
{
detail::makeBadResult(rb, e);
}
};
inline auto from(detail::ResultReader auto r, auto &&... args)
{
using R = nix::Result<decltype(r.getGood())>;
if (r.isGood()) {
return R(result::success(r.getGood()));
} else {
return R(result::failure(nix::Error(from(r.getBad(), args...))));
}
}
template<typename T>
struct Fill<Result<T>, std::exception_ptr>
{
static void fill(Result<T>::Builder rb, const std::exception_ptr & e, auto &&...)
{
detail::makeBadResult(rb, e);
}
};
template<>
struct Fill<Settings::Setting, std::pair<const std::string, Config::SettingInfo>>
{
-15
View File
@@ -46,21 +46,6 @@ struct Error {
traces @2 :List(Data);
}
struct Result(T) {
union {
good @0 :T;
bad @1 :Error;
}
}
# primitives can't be args to generics, so we need to specialize for primitive here.
struct ResultV {
union {
good @0 :Void;
bad @1 :Error;
}
}
struct Settings {
struct Setting {
name @0 :Data;