diff --git a/deps/rust/Cargo.lock b/deps/rust/Cargo.lock index 26162790ba2..a37fe168c8d 100644 --- a/deps/rust/Cargo.lock +++ b/deps/rust/Cargo.lock @@ -444,6 +444,7 @@ dependencies = [ "ada-url", "anyhow", "async-trait", + "bytes", "capnp", "capnp-rpc", "capnpc", @@ -467,6 +468,7 @@ dependencies = [ "scratch", "serde", "serde_json", + "socket2", "static_assertions", "swc_common", "swc_ts_fast_strip", @@ -535,6 +537,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1048,9 +1060,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1532,6 +1544,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2025,13 +2047,27 @@ version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tracing" version = "0.1.44" diff --git a/deps/rust/Cargo.toml b/deps/rust/Cargo.toml index e7577fd599b..4249130a312 100644 --- a/deps/rust/Cargo.toml +++ b/deps/rust/Cargo.toml @@ -32,6 +32,7 @@ syn = { version = "2", features = ["full"] } ada-url = { version = "4", default-features = false, features = ["std"] } anyhow = "1" async-trait = { version = "0", default-features = false } +bytes = "1" capnp = "0" capnpc = "0" capnp-rpc = "0" @@ -48,9 +49,9 @@ ruff_python_parser = { git = "https://github.com/astral-sh/ruff", tag = "0.12.1" # param_extractor depends on unbounded_depth feature serde_json = { version = "1", features = ["unbounded_depth"] } serde = { version = "1", features = ["derive"] } +socket2 = "0.6" thiserror = "2" -# tokio is huge, let's enable only features when we actually need them. -tokio = { version = "1", default-features = false, features = ["net", "rt", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1", default-features = false, features = ["io-util", "macros", "net", "rt", "rt-multi-thread", "signal", "sync", "time"] } tracing = { version = "0", default-features = false, features = ["std"] } swc_common = "25" swc_ts_fast_strip = "57" diff --git a/src/rust/cxx/AGENTS.md b/src/rust/cxx/AGENTS.md index 4c807313a19..49ab1fd4b2c 100644 --- a/src/rust/cxx/AGENTS.md +++ b/src/rust/cxx/AGENTS.md @@ -36,6 +36,10 @@ Bazel module, Cargo workspace, toolchain configuration, or external `workerd-cxx - `kj-rs-tokio/` — `TokioEventPort`: a `kj::EventPort` backed by a per-thread tokio `current_thread` runtime, plus `setupTokioAsyncIo()` (no I/O providers) and `kj_rs_tokio::spawn()` +- `kj-rs-io/` — KJ async I/O interfaces over tokio (`kj::AsyncIoStream`, listeners, + `kj::Network`, providers, signals, file watching), `kj_rs_io::setupTokioAsyncIo()` as the + drop-in `kj::setupAsyncIo()` replacement, the stream unwrap fast path, and + `serve_kj_stream()` for Rust servers consuming KJ streams - `tests/` and `kj-rs/tests/` — Rust and C++ bridge integration tests - `tools/bazel/` — Bazel bridge-generation macro used by this component's tests diff --git a/src/rust/cxx/kj-rs-io/BUILD.bazel b/src/rust/cxx/kj-rs-io/BUILD.bazel new file mode 100644 index 00000000000..cceafee0af8 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/BUILD.bazel @@ -0,0 +1,79 @@ +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") +load("//:build/wd_cc_library.bzl", "wd_cc_library") +load("//src/rust/cxx/tools/bazel:rust_cxx_bridge.bzl", "rust_cxx_bridge") + +wd_cc_library( + name = "kj-rs-io-lib", + srcs = glob(["*.c++"]), + hdrs = glob(["*.h"]), + include_prefix = "kj-rs-io", + linkstatic = select({ + "@platforms//os:windows": True, + "//conditions:default": False, + }), + strip_include_prefix = "/src/rust/cxx/kj-rs-io", + visibility = ["//visibility:public"], + deps = [ + ":bridge", + "//src/rust/cxx/kj-rs-tokio:kj-rs-tokio-lib", + ], +) + +rust_library( + name = "kj-rs-io", + srcs = glob(["*.rs"]), + compile_data = glob(["*.h"]), + edition = "2024", + link_deps = [ + ":bridge", + ":kj-rs-io-lib", + ], + target_compatible_with = select({ + "@//build/config:no_build": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + visibility = ["//visibility:public"], + deps = [ + "//src/rust/cxx", + "//src/rust/cxx/kj-rs", + "//src/rust/cxx/kj-rs-tokio", + "@crates_vendor//:bytes", + "@crates_vendor//:libc", + "@crates_vendor//:socket2", + "@crates_vendor//:tokio", + ], +) + +rust_test( + name = "kj-rs-io_test", + crate = "kj-rs-io", + edition = "2024", + target_compatible_with = select({ + "@//build/config:no_build": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) + +rust_cxx_bridge( + name = "bridge", + src = "ffi.rs", + hdrs = ["unwrap.h"], + include_prefix = "kj-rs-io", + visibility = ["//visibility:public"], + deps = [ + "//src/rust/cxx/kj-rs", + "@capnp-cpp//src/kj:kj", + # kj-rs-io IS the kj-side implementation of the rust I/O backend: it derives from the + # abstract kj::AsyncIoStream / kj::Network / kj::LowLevelAsyncIoProvider interfaces and + # reuses portable helpers (kj::newOneWayPipe, kj::CidrRange, the AsyncInputStream / + # AsyncOutputStream default-method vtable slots). Those live in the ABSTRACT layers + # :kj-async-core (Promise machinery) + :kj-async-io (abstract streams / Network / CIDR; + # no event loop). It does NOT call kj::setupAsyncIo / kj::UnixEventPort, so it never + # needs :kj-async-os (the concrete OS event loop) -- the tokio-backed EventPort + # (kj-rs-tokio) replaces it. A stray setupAsyncIo would be an undefined-symbol link + # error, the intended backstop. + "@capnp-cpp//src/kj:kj-async-core", + "@capnp-cpp//src/kj:kj-async-io", + "//src/rust/cxx:core", + ], +) diff --git a/src/rust/cxx/kj-rs-io/async-io.c++ b/src/rust/cxx/kj-rs-io/async-io.c++ new file mode 100644 index 00000000000..71638c10269 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/async-io.c++ @@ -0,0 +1,531 @@ +#include "kj-rs-io/async-io.h" + +#include + +#include + +#if _WIN32 +#include +#else +#include +#include +#include +#include +#if __APPLE__ || __FreeBSD__ || __OpenBSD__ || __NetBSD__ || __DragonFly__ +#include +#endif +#endif + +namespace kj_rs_io { + +// ======================================================================================= +// TokioAsyncIoStream + +kj::Promise TokioAsyncIoStream::tryRead(void *buffer, size_t minBytes, size_t maxBytes) { + return stream_try_read( + *inner, ::rust::Slice(reinterpret_cast(buffer), maxBytes), minBytes); +} + +kj::Promise TokioAsyncIoStream::write(kj::ArrayPtr buffer) { + return stream_write(*inner, ::rust::Slice(buffer.begin(), buffer.size())); +} + +kj::Promise TokioAsyncIoStream::write( + kj::ArrayPtr> pieces) { + // Sequential write-all of each piece (each single write is eager-by-default, preserving + // hot-write semantics for the whole sequence). + // TODO(perf): vectored writes via try_write_vectored. + return writePieces(pieces); +} + +kj::Promise TokioAsyncIoStream::writePieces( + kj::ArrayPtr> pieces) { + for (auto piece: pieces) { + co_await write(piece); + } +} + +kj::Promise TokioAsyncIoStream::whenWriteDisconnected() { + return stream_when_write_disconnected(*inner); +} + +void TokioAsyncIoStream::shutdownWrite() { + stream_shutdown_write(*inner); +} + +void TokioAsyncIoStream::getsockopt(int level, int option, void *value, kj::uint *length) { + // The platform seam lives on the Rust side (stream_getsockopt); errors surface as kj + // exceptions, like KJ_SYSCALL. Raw socklen in/out semantics: the syscall's reported length + // is mirrored back verbatim. + *length = stream_getsockopt( + *inner, level, option, ::rust::Slice(reinterpret_cast(value), *length)); +} + +void TokioAsyncIoStream::setsockopt(int level, int option, const void *value, kj::uint length) { + stream_setsockopt(*inner, level, option, + ::rust::Slice(reinterpret_cast(value), length)); +} + +void TokioAsyncIoStream::getsockname(struct sockaddr *addr, kj::uint *length) { + auto bytes = stream_local_addr(*inner); + // Mirror the raw syscall's truncation semantics: copy what fits into the caller's buffer, + // report the address's full length. + memcpy(addr, bytes.data(), kj::min(bytes.size(), *length)); + *length = bytes.size(); +} + +void TokioAsyncIoStream::getpeername(struct sockaddr *addr, kj::uint *length) { + auto bytes = stream_peer_addr(*inner); + memcpy(addr, bytes.data(), kj::min(bytes.size(), *length)); + *length = bytes.size(); +} + +kj::Maybe TokioAsyncIoStream::getFd() const { +#if _WIN32 + // On Windows the underlying handle is a winsock SOCKET, not a Unix fd; it is exposed via + // getWin32Handle() below instead. (Validated by Windows CI.) + return kj::none; +#else + int64_t handle = -1; + if (kj::runCatchingExceptions([&]() { handle = stream_raw_handle(*inner); }) == kj::none) { + // On unix the raw socket handle is the fd, widened losslessly to int64 by the bridge. + return static_cast(handle); + } + return kj::none; +#endif +} + +#if _WIN32 +// Validated by Windows CI; mirrors the unix getFd() arm (and kj's own win32 AsyncStreamFd, +// which returns its SOCKET cast to void* -- capnproto async-io-win32.c++). +kj::Maybe TokioAsyncIoStream::getWin32Handle() const { + int64_t handle = -1; + if (kj::runCatchingExceptions([&]() { handle = stream_raw_handle(*inner); }) == kj::none) { + return reinterpret_cast(static_cast(handle)); + } + return kj::none; +} +#endif + +::rust::Box unwrapTokioStream(kj::AsyncIoStream &stream) { + KJ_IF_SOME(tokioStream, kj::dynamicDowncastIfAvailable(stream)) { + return tokioStream.unwrap(); + } + KJ_FAIL_REQUIRE("stream is not a kj-rs-io tokio-backed stream; cannot unwrap"); +} + +// ======================================================================================= +// TokioConnectionReceiver + +namespace { + +// Builds the accepted connection's kj::PeerIdentity, mirroring KJ's SocketAddress::getIdentity() +// (kj/async-io-unix.c++): NetworkPeerIdentity wrapping the peer's address for TCP peers (its +// toString() is "ip:port" / "[v6]:port", byte-identical to KJ's format -- workerd's HTTP +// listener puts this string in the cf blob's clientIp), LocalPeerIdentity with the peer's +// process credentials for unix sockets, UnknownPeerIdentity otherwise. +kj::Own peerIdentityFromSockaddr( + struct sockaddr *sa, kj::uint addrlen, [[maybe_unused]] kj::AsyncIoStream &stream) { + switch (sa->sa_family) { + case AF_INET: + case AF_INET6: { + // The identity's NetworkAddress uses an allow-all filter (not the listener's): it exists + // for toString()/getAddress(); restrictPeers enforcement on this listener already happened + // in the accept loop. (KJ instead threads the listener's filter through, which only + // matters if a caller connect()s back through the identity address -- see PeerFilter's + // immobility note for why we don't hold a reference to a possibly-narrower filter here.) + static PeerFilter allowAll; + auto address = network_get_sockaddr( + ::rust::Slice(reinterpret_cast(sa), addrlen)); + return kj::NetworkPeerIdentity::newInstance( + kj::heap(kj::mv(address), allowAll)); + } +#if !_WIN32 + case AF_UNIX: { + // Same credential sources and invalid-value handling as KJ (SO_PEERCRED on Linux, + // LOCAL_PEERCRED/LOCAL_PEERPID on BSDs/macOS; OpenBSD defines SO_PEERCRED but with a + // different interface, so it uses the LOCAL_PEERCRED arm). + kj::LocalPeerIdentity::Credentials result; +#if defined(SO_PEERCRED) && !__OpenBSD__ + struct ucred creds; + kj::uint length = sizeof(creds); + stream.getsockopt(SOL_SOCKET, SO_PEERCRED, &creds, &length); + if (creds.pid > 0) { + result.pid = creds.pid; + } + if (creds.uid != static_cast(-1)) { + result.uid = creds.uid; + } +#elifdef LOCAL_PEERCRED + struct xucred creds; + kj::uint length = sizeof(creds); + stream.getsockopt(SOL_LOCAL, LOCAL_PEERCRED, &creds, &length); + KJ_ASSERT(length == sizeof(creds)); + if (creds.cr_uid != static_cast(-1)) { + result.uid = creds.cr_uid; + } +#ifdef LOCAL_PEERPID + pid_t pid; + length = sizeof(pid); + stream.getsockopt(SOL_LOCAL, LOCAL_PEERPID, &pid, &length); + KJ_ASSERT(length == sizeof(pid)); + if (pid > 0) { + result.pid = pid; + } +#endif +#endif + return kj::LocalPeerIdentity::newInstance(result); + } +#endif // !_WIN32 + default: + return kj::UnknownPeerIdentity::newInstance(); + } +} + +} // namespace + +kj::Promise> TokioConnectionReceiver::accept() { + return acceptImpl(false).then( + [](kj::AuthenticatedStream authenticated) { return kj::mv(authenticated.stream); }); +} + +kj::Promise TokioConnectionReceiver::acceptAuthenticated() { + return acceptImpl(true); +} + +kj::Promise TokioConnectionReceiver::acceptImpl(bool authenticated) { + for (;;) { + auto stream = co_await listener_accept(*inner); + // restrictPeers / NetworkFilter enforcement, mirroring KJ: a connection from a disallowed + // peer is silently dropped and we keep accepting. + struct sockaddr_storage addr; + memset(&addr, 0, sizeof(addr)); + kj::uint addrlen = 0; + KJ_IF_SOME(exception, kj::runCatchingExceptions([&]() { + auto bytes = stream_peer_addr(*stream); + KJ_ASSERT(bytes.size() <= sizeof(addr), "sockaddr too large"); + memcpy(&addr, bytes.data(), bytes.size()); + addrlen = bytes.size(); + })) { + // The peer can reset the connection between tokio's accept() and this call, in which + // case getpeername fails (EINVAL on macOS, ENOTCONN elsewhere). The connection is dead; + // drop it and keep accepting. This must NOT throw: an exception here propagates out of + // the server's accept loop and takes down the whole process (observed as a fatal + // uncaught kj::Exception under client abort storms). Unlike KJ's native accept path, + // which gets the peer address atomically from accept4(), we re-derive it and so must + // tolerate the race. Log at INFO (off by default) for observability under abort storms. + KJ_LOG(INFO, "dropping accepted connection; could not read peer address", exception); + continue; + } + if (!filter.shouldAllow(reinterpret_cast(&addr), addrlen)) { + // Drop the disallowed connection and wait for the next one. + continue; + } + kj::AuthenticatedStream result; + result.stream = kj::heap(kj::mv(stream)); + if (authenticated) { + result.peerIdentity = peerIdentityFromSockaddr( + reinterpret_cast(&addr), addrlen, *result.stream); + } else { + result.peerIdentity = kj::UnknownPeerIdentity::newInstance(); + } + co_return kj::mv(result); + } +} + +kj::uint TokioConnectionReceiver::getPort() { + return listener_port(*inner); +} + +void TokioConnectionReceiver::getsockopt(int level, int option, void *value, kj::uint *length) { + *length = listener_getsockopt( + *inner, level, option, ::rust::Slice(reinterpret_cast(value), *length)); +} + +void TokioConnectionReceiver::setsockopt( + int level, int option, const void *value, kj::uint length) { + listener_setsockopt(*inner, level, option, + ::rust::Slice(reinterpret_cast(value), length)); +} + +void TokioConnectionReceiver::getsockname(struct sockaddr *addr, kj::uint *length) { + auto bytes = listener_local_addr(*inner); + // Mirror the raw syscall's truncation semantics (see TokioAsyncIoStream::getsockname). + memcpy(addr, bytes.data(), kj::min(bytes.size(), *length)); + *length = bytes.size(); +} + +// ======================================================================================= +// TokioNetworkAddress / TokioNetwork + +kj::Promise> TokioNetworkAddress::connect() { + // KJ contract (NetworkAddressImpl::connect() in kj/async-io-unix.c++): callers may drop the + // NetworkAddress while the returned promise is still pending. We honor this by cloning the + // resolved address list into a coroutine frame local: the frame owns the copy, so it + // survives every co_await and is dropped on completion/cancellation. connect() may be called + // repeatedly on the same address, so we clone rather than move `*inner` out of `this`. + // `filter` is the network's filter (provider-owned, shared) and must outlive the promise, + // per KJ (where it lives in the long-lived provider). + auto addr = address_clone(*inner); + size_t count = address_count(*addr); + KJ_REQUIRE(count > 0, "no addresses to connect to"); + + // Try each resolved address in order; a filter block or connect error falls through to the + // next one, and the last address's exception propagates (KJ parity). + kj::Maybe lastException; + for (size_t i = 0; i < count; i++) { + kj::Maybe> stream; + try { + auto raw = address_raw_sockaddr(*addr, i); + // Copy into sockaddr_storage for alignment (rust::Vec data is 1-aligned). + struct sockaddr_storage storage; + memset(&storage, 0, sizeof(storage)); + KJ_REQUIRE(raw.size() <= sizeof(storage), "sockaddr too large"); + memcpy(&storage, raw.data(), raw.size()); + if (!filter.shouldAllow(reinterpret_cast(&storage), raw.size())) { + // Exact KJ error text; error-string parity matters to callers. + lastException = KJ_EXCEPTION(FAILED, "connect() blocked by restrictPeers()"); + } else { + stream = kj::heap(co_await address_connect_index(*addr, i)); + } + } catch (...) { + // Note: getCaughtExceptionAsKj() rethrows kj::CanceledException, so cancellation still + // propagates out of this coroutine instead of being folded into lastException. + lastException = kj::getCaughtExceptionAsKj(); + } + KJ_IF_SOME(s, stream) { + co_return kj::mv(s); + } + } + + kj::throwFatalException(kj::mv(KJ_ASSERT_NONNULL(lastException))); +} + +kj::Own TokioNetworkAddress::listen() { + return kj::heap(address_listen(*inner), filter); +} + +kj::Own TokioNetworkAddress::clone() { + return kj::heap(address_clone(*inner), filter); +} + +kj::String TokioNetworkAddress::toString() { + auto text = address_to_string(*inner); + return kj::heapString(text.data(), text.size()); +} + +kj::Promise> TokioNetwork::parseAddress( + kj::StringPtr addr, kj::uint portHint) { + KJ_REQUIRE(portHint < 65536, "port hint too large", portHint); + // The Rust side takes an owned copy: the caller's buffer need not outlive this call. + // + // Note: unlike KJ, disallowed (restrictPeers) DNS results are not dropped here; they are + // rejected at connect()/accept() time instead. See TokioNetworkAddress. + return network_parse_address( + ::rust::String(addr.begin(), addr.size()), static_cast(portHint)) + .then([this](::rust::Box address) -> kj::Own { + return kj::heap(kj::mv(address), filter); + }); +} + +kj::Own TokioNetwork::getSockaddr(const void *sockaddr, kj::uint len) { + // KJ parity: getSockaddr() rejects filtered addresses eagerly (same error text as KJ). + KJ_REQUIRE(filter.shouldAllow(reinterpret_cast(sockaddr), len), + "address blocked by restrictPeers()"); + return kj::heap(network_get_sockaddr(::rust::Slice( + reinterpret_cast(sockaddr), len)), + filter); +} + +kj::Own TokioNetwork::restrictPeers( + kj::ArrayPtr allow, kj::ArrayPtr deny) { + // The child references this network's filter chain: this network must outlive the returned + // one (same constraint as KJ's networks). + return kj::heap(*this, allow, deny); +} + +// ======================================================================================= +// TokioLowLevelAsyncIoProvider + +namespace { + +#if _WIN32 +// Normalizes KJ's fd-wrapping flags so Rust always receives a SOCKET it owns, in non-blocking +// mode: the windows arm of prepareFd, mirroring the unix arm below in *effect*, not mechanism. +// kj's own win32 provider (capnproto async-io-win32.c++: OwnedFd, NEW_FD_FLAGS) never dups a +// borrowed socket -- it merely skips closesocket() on destruction when TAKE_OWNERSHIP is +// absent -- ignores ALREADY_CLOEXEC entirely (there is no CLOEXEC on Windows; handle +// inheritance is the analogue), and never toggles non-blocking mode (it uses overlapped I/O, +// not readiness). Rust's OwnedSocket has no "don't close" mode, so a borrowed socket is +// duplicated (WSADuplicateSocketW + WSASocketW, non-inheritable) into a handle Rust can own; +// and tokio/mio's readiness model requires non-blocking sockets, so FIONBIO is set unless the +// caller declared ALREADY_NONBLOCK (the duplicate shares the underlying socket state, so this +// is observed through the caller's handle too, matching the unix dup()+O_NONBLOCK behavior). +// Validated by Windows CI. +uintptr_t prepareFd(uintptr_t fd, kj::uint flags) { + SOCKET sock = static_cast(fd); + if ((flags & kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP) == 0) { + WSAPROTOCOL_INFOW info; + KJ_WINSOCK(WSADuplicateSocketW(sock, GetCurrentProcessId(), &info)); + SOCKET duped = WSASocketW(FROM_PROTOCOL_INFO, FROM_PROTOCOL_INFO, FROM_PROTOCOL_INFO, &info, 0, + WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT); + if (duped == INVALID_SOCKET) { + KJ_FAIL_WIN32("WSASocketW()", WSAGetLastError()); + } + sock = duped; + } + // ALREADY_NONBLOCK does not exist on Windows (kj declares it under #if !_WIN32), so callers + // cannot assert pre-set non-blocking mode; always enable it (idempotent). + u_long mode = 1; + KJ_WINSOCK(ioctlsocket(sock, FIONBIO, &mode)); + return static_cast(sock); +} +#else +// Normalizes KJ's fd-wrapping flags so Rust always receives an fd it owns, with CLOEXEC set and +// in non-blocking mode. +int prepareFd(int fd, kj::uint flags) { + if ((flags & kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP) == 0) { + // dup() shares the open file description — the O_NONBLOCK set below is observed through the + // caller's fd too, matching KJ (which sets O_NONBLOCK on the caller's fd directly) — while + // giving Rust a descriptor it can own and close. + int duped; + KJ_SYSCALL(duped = ::dup(fd)); + fd = duped; + KJ_SYSCALL(fcntl(fd, F_SETFD, FD_CLOEXEC)); + } else if ((flags & kj::LowLevelAsyncIoProvider::ALREADY_CLOEXEC) == 0) { + KJ_SYSCALL(fcntl(fd, F_SETFD, FD_CLOEXEC)); + } + if ((flags & kj::LowLevelAsyncIoProvider::ALREADY_NONBLOCK) == 0) { + int fl; + KJ_SYSCALL(fl = fcntl(fd, F_GETFL)); + if ((fl & O_NONBLOCK) == 0) { + KJ_SYSCALL(fcntl(fd, F_SETFL, fl | O_NONBLOCK)); + } + } + return fd; +} + +// kj::AsyncInputStream over an arbitrary readable fd (pipe, socket, character device). +class TokioInputStreamFd final: public kj::AsyncInputStream { + public: + explicit TokioInputStreamFd(::rust::Box inner): inner(kj::mv(inner)) {} + + kj::Promise tryRead(void *buffer, size_t minBytes, size_t maxBytes) override { + return input_fd_try_read( + *inner, ::rust::Slice(reinterpret_cast(buffer), maxBytes), minBytes); + } + + private: + ::rust::Box inner; +}; + +// kj::AsyncOutputStream over an arbitrary writable fd. +class TokioOutputStreamFd final: public kj::AsyncOutputStream { + public: + explicit TokioOutputStreamFd(::rust::Box inner): inner(kj::mv(inner)) {} + + kj::Promise write(kj::ArrayPtr buffer) override { + return output_fd_write(*inner, ::rust::Slice(buffer.begin(), buffer.size())); + } + + kj::Promise write(kj::ArrayPtr> pieces) override { + for (auto piece: pieces) { + co_await write(piece); + } + } + + // Pipes/arbitrary fds have no portable disconnect detection here; KJ allows a never-resolving + // promise for such streams. + kj::Promise whenWriteDisconnected() override { + return kj::NEVER_DONE; + } + + private: + ::rust::Box inner; +}; +#endif // _WIN32 (prepareFd platform arms; the pipe-fd stream classes above are unix-only) + +} // namespace + +kj::Own TokioLowLevelAsyncIoProvider::wrapInputFd(Fd fd, kj::uint flags) { +#if _WIN32 + // KJ parity: on win32, LowLevelAsyncIoProvider::Fd is documented as a SOCKET (async-io.h: + // "On Windows, the `fd` parameter to each of these methods must be a SOCKET"), and kj's own + // win32 provider implements wrapInputFd/wrapOutputFd *identically* to wrapSocketFd + // (async-io-win32.c++: all three wrap the SOCKET in AsyncStreamFd) -- even kj's "pipes" on + // Windows are loopback-TCP socketpairs (newOsSocketpair). So there is no pipe-HANDLE tier to + // implement; delegate to the tested socket path. Validated by Windows CI. + return kj::heap(wrap_socket_fd(static_cast(prepareFd(fd, flags)))); +#else + return kj::heap(wrap_input_fd(prepareFd(fd, flags))); +#endif +} + +kj::Own TokioLowLevelAsyncIoProvider::wrapOutputFd(Fd fd, kj::uint flags) { +#if _WIN32 + // See wrapInputFd above: win32 Fd is a SOCKET and kj's win32 wrapOutputFd == wrapSocketFd. + // Validated by Windows CI. + return kj::heap(wrap_socket_fd(static_cast(prepareFd(fd, flags)))); +#else + return kj::heap(wrap_output_fd(prepareFd(fd, flags))); +#endif +} + +kj::Own TokioLowLevelAsyncIoProvider::wrapSocketFd(Fd fd, kj::uint flags) { + // `Fd` is int on unix and uintptr_t (SOCKET) on win32; the bridge carries it widened to + // int64 (a "raw socket handle") either way, with -1 == INVALID_SOCKET as the one sentinel. + return kj::heap(wrap_socket_fd(static_cast(prepareFd(fd, flags)))); +} + +kj::Promise> TokioLowLevelAsyncIoProvider::wrapConnectingSocketFd( + Fd fd, const struct sockaddr *addr, kj::uint addrlen, kj::uint flags) { + int64_t prepared = static_cast(prepareFd(fd, flags)); + // The Rust side takes an owned copy of the sockaddr: the caller's pointer need not outlive + // this call (KJ's own implementation copies too). + ::rust::Vec addrCopy; + addrCopy.reserve(addrlen); + const uint8_t *addrBytes = reinterpret_cast(addr); + for (kj::uint i = 0; i < addrlen; i++) { + addrCopy.push_back(addrBytes[i]); + } + return wrap_connecting_socket_fd(prepared, kj::mv(addrCopy)) + .then([](::rust::Box stream) -> kj::Own { + return kj::heap(kj::mv(stream)); + }); +} + +kj::Own TokioLowLevelAsyncIoProvider::wrapListenSocketFd( + Fd fd, NetworkFilter &filter, kj::uint flags) { + // `filter` applies to accepted connections (KJ parity); it must outlive the receiver. + return kj::heap( + wrap_listen_fd(static_cast(prepareFd(fd, flags))), filter); +} + +// ======================================================================================= +// TokioAsyncIoProvider / setup + +kj::AsyncIoProvider::PipeThread TokioAsyncIoProvider::newPipeThread( + kj::Function startFunc) { + KJ_UNIMPLEMENTED("kj-rs-io does not implement newPipeThread() (workerd does not use it)"); +} + +TokioAsyncIoContext setupTokioAsyncIo() { + auto port = kj::heap(); + auto loop = kj::heap(*port); + auto waitScope = kj::heap(*loop); + auto lowLevelProvider = kj::heap(port->getTimer()); + auto provider = kj::heap(port->getTimer()); + return TokioAsyncIoContext{ + kj::mv(port), kj::mv(loop), kj::mv(waitScope), kj::mv(lowLevelProvider), kj::mv(provider)}; +} + +// ======================================================================================= +// Signals + +kj::Promise onSignal(int signum) { + // The bridged future is eager-by-default, so the tokio signal handler is registered as soon + // as the event loop runs, even if the caller parks the promise without awaiting it immediately. + return wait_for_signal(signum); +} + +} // namespace kj_rs_io diff --git a/src/rust/cxx/kj-rs-io/async-io.h b/src/rust/cxx/kj-rs-io/async-io.h new file mode 100644 index 00000000000..0418abd9b48 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/async-io.h @@ -0,0 +1,262 @@ +#pragma once +// kj-rs-io: tokio-backed implementations of KJ's async I/O interfaces. +// +// Everything here wraps an opaque Rust object (a native tokio TcpStream/UnixStream/TcpListener/ +// address list) and implements the corresponding KJ interface by calling `async fn`s across the +// cxx bridge, which return kj::Promises. Design points: +// +// - All promises must be awaited on the thread owning the kj_rs_tokio::TokioEventPort: the +// tokio I/O driver that delivers readiness for these sockets only runs while that KJ loop +// sleeps in the port's wait()/poll(). +// - Cancellation: dropping a returned kj::Promise drops the underlying Rust future, which +// releases the socket's readiness interest. A stream with a canceled read remains usable. +// - Unwrap fast path: every Rust-originated stream can be recovered as its native tokio object +// (see unwrapTokioStream), so Rust servers can serve a connection natively +// instead of crossing the FFI per read. Foreign kj streams are not unwrappable. +// +// Known stubs (all throw UNIMPLEMENTED, documented per method): newPipeThread(), capability +// streams (SCM_RIGHTS fd passing), datagram sockets, and named-service / abstract-unix-socket +// address forms. restrictPeers() IS implemented (via PeerFilter, a port of KJ's NetworkFilter); see +// TokioNetwork for enforcement points and the parse-time-filtering deviation. + +#include "kj-rs-io/ffi.rs.h" +#include "kj-rs-io/peer-filter.h" +#include "kj-rs-tokio/tokio-event-port.h" + +#include +#include + +namespace kj_rs_io { + +// A kj::AsyncIoStream backed by a native tokio TcpStream or UnixStream. +class TokioAsyncIoStream final: public kj::AsyncIoStream { + public: + explicit TokioAsyncIoStream(::rust::Box inner): inner(kj::mv(inner)) {} + + // AsyncInputStream. tryRead honors KJ's min-bytes contract: resolves with >= minBytes unless + // EOF is reached first (in which case the short count signals EOF). + kj::Promise tryRead(void *buffer, size_t minBytes, size_t maxBytes) override; + + // AsyncOutputStream. write() has write-all semantics; the multi-piece overload writes the + // pieces sequentially (no vectored-write optimization yet). + kj::Promise write(kj::ArrayPtr buffer) override; + kj::Promise write(kj::ArrayPtr> pieces) override; + + // Resolves when new writes are doomed (peer reset/hangup observed). Does not fire on a mere + // half-close (peer FIN), mirroring KJ. On non-Unix platforms the promise never resolves + // (KJ-on-Windows behavior). Safe to call multiple times concurrently. + kj::Promise whenWriteDisconnected() override; + + // AsyncIoStream. + void shutdownWrite() override; + void getsockopt(int level, int option, void *value, kj::uint *length) override; + void setsockopt(int level, int option, const void *value, kj::uint length) override; + void getsockname(struct sockaddr *addr, kj::uint *length) override; + void getpeername(struct sockaddr *addr, kj::uint *length) override; + kj::Maybe getFd() const override; +#if _WIN32 + // Validated by Windows CI; mirrors getFd(): on Windows the underlying socket is a winsock + // SOCKET, exposed as a void* handle (kj convention; getFd() returns none there). + kj::Maybe getWin32Handle() const override; +#endif + + // Unwrap fast path: moves the native tokio stream out, leaving this wrapper hollow (all + // further operations throw). No I/O promises may be in flight. Prefer the free function + // unwrapTokioStream() when holding only a kj::AsyncIoStream&. + ::rust::Box unwrap() { + return stream_take(*inner); + } + + private: + kj::Promise writePieces(kj::ArrayPtr> pieces); + + ::rust::Box inner; +}; + +// A kj::ConnectionReceiver backed by a native tokio TcpListener or UnixListener. Incoming +// connections from peers disallowed by `filter` (restrictPeers) are silently dropped and the +// accept loop continues, mirroring KJ. `filter` must outlive this receiver. +class TokioConnectionReceiver final: public kj::ConnectionReceiver { + public: + TokioConnectionReceiver( + ::rust::Box inner, kj::LowLevelAsyncIoProvider::NetworkFilter &filter) + : inner(kj::mv(inner)), + filter(filter) {} + + kj::Promise> accept() override; + kj::Promise acceptAuthenticated() override; + kj::uint getPort() override; + void getsockopt(int level, int option, void *value, kj::uint *length) override; + void setsockopt(int level, int option, const void *value, kj::uint length) override; + void getsockname(struct sockaddr *addr, kj::uint *length) override; + + private: + kj::Promise acceptImpl(bool authenticated); + + ::rust::Box inner; + kj::LowLevelAsyncIoProvider::NetworkFilter &filter; +}; + +// A kj::NetworkAddress holding pre-resolved socket addresses (DNS happens at parseAddress time, +// like KJ). connect() tries each address in order; listen() binds the first. +// +// connect() honors KJ's lifetime contract (NetworkAddressImpl::connect() in +// kj/async-io-unix.c++): the returned promise is a coroutine whose frame owns a copy of the +// resolved address list, so the caller may drop this NetworkAddress while the connect is still +// pending. +// +// `filter` is the restrictPeers filter of the kj::Network this address came from (allow-all +// for an unrestricted network) and must outlive this address and any promises it returns +// (in KJ the filter equally lives in the long-lived provider, so this matches upstream). +// Filtering is enforced at connect() time per address ("connect() blocked by +// restrictPeers()", KJ parity) and at accept() time on listeners; unlike KJ, disallowed DNS +// results are not already dropped at parse time (they fail at connect instead). +class TokioNetworkAddress final: public kj::NetworkAddress { + public: + TokioNetworkAddress(::rust::Box inner, PeerFilter &filter) + : inner(kj::mv(inner)), + filter(filter) {} + + kj::Promise> connect() override; + kj::Own listen() override; + kj::Own clone() override; + kj::String toString() override; + + private: + ::rust::Box inner; + PeerFilter &filter; +}; + +// The tokio-backed kj::Network. Supports the KJ address grammar subset workerd uses; see +// net.rs for the exact forms and documented deviations (no named services, no unix-abstract, +// no IPv6 scope IDs). +// +// restrictPeers() uses PeerFilter, a faithful port of KJ's NetworkFilter (semantics identical to +// kj::setupAsyncIo()'s networks). The returned network references this one's filter chain, so +// a network must outlive any networks derived from it via restrictPeers() (same constraint as +// KJ). Enforcement points: per-address connect()-time checks and accept()-time peer checks; +// parse-time DNS-result dropping is NOT implemented (blocked addresses fail at connect +// instead) -- see TokioNetworkAddress. +class TokioNetwork final: public kj::Network { + public: + TokioNetwork() = default; + TokioNetwork(TokioNetwork &parent, + kj::ArrayPtr allow, + kj::ArrayPtr deny) + : filter(allow, deny, parent.filter) {} + + kj::Promise> parseAddress( + kj::StringPtr addr, kj::uint portHint) override; + kj::Own getSockaddr(const void *sockaddr, kj::uint len) override; + kj::Own restrictPeers( + kj::ArrayPtr allow, kj::ArrayPtr deny) override; + + private: + // Default-constructed = allow everything (matches KJ's root networks). + PeerFilter filter; +}; + +// The tokio-backed kj::LowLevelAsyncIoProvider. Implements the socket-wrapping entry points on +// Unix and Windows (each wrap*Fd normalizes KJ's TAKE_OWNERSHIP/ALREADY_CLOEXEC/ALREADY_NONBLOCK +// flags, then hands an owned, non-blocking raw socket handle -- a Unix fd or a win32 SOCKET, +// widened to int64 -- to Rust). The pipe tier (wrapInputFd/wrapOutputFd) is Unix-only for now. +// wrapUnixSocketFd (capability streams) and wrapDatagramSocketFd keep their default-throwing +// implementations. +class TokioLowLevelAsyncIoProvider final: public kj::LowLevelAsyncIoProvider { + public: + explicit TokioLowLevelAsyncIoProvider(kj::Timer &timer): timer(timer) {} + + kj::Own wrapInputFd(Fd fd, kj::uint flags) override; + kj::Own wrapOutputFd(Fd fd, kj::uint flags) override; + kj::Own wrapSocketFd(Fd fd, kj::uint flags) override; + kj::Promise> wrapConnectingSocketFd( + Fd fd, const struct sockaddr *addr, kj::uint addrlen, kj::uint flags) override; + // `filter` applies to accepted connections (disallowed peers are dropped and the accept + // loop continues, like KJ); it must outlive the returned receiver. + kj::Own wrapListenSocketFd( + Fd fd, NetworkFilter &filter, kj::uint flags) override; + kj::Timer &getTimer() override { + return timer; + } + + private: + kj::Timer &timer; +}; + +// The tokio-backed kj::AsyncIoProvider. Pipes are KJ's in-memory pipes (port-agnostic, like +// kj::newOneWayPipe/newTwoWayPipe themselves); newPipeThread throws UNIMPLEMENTED (workerd does +// not use it); newCapabilityPipe keeps its default-throwing implementation. +class TokioAsyncIoProvider final: public kj::AsyncIoProvider { + public: + explicit TokioAsyncIoProvider(kj::Timer &timer): timer(timer) {} + + kj::OneWayPipe newOneWayPipe() override { + return kj::newOneWayPipe(); + } + kj::TwoWayPipe newTwoWayPipe() override { + return kj::newTwoWayPipe(); + } + kj::Network &getNetwork() override { + return network; + } + PipeThread newPipeThread( + kj::Function startFunc) + override; + kj::Timer &getTimer() override { + return timer; + } + + private: + TokioNetwork network; + kj::Timer &timer; +}; + +// Mirror of kj::AsyncIoContext (kj/async-io.h) for the tokio-backed loop: a drop-in replacement +// for kj::setupAsyncIo() at workerd.c++:1570. Additionally owns the event port / loop / +// WaitScope (which kj::setupAsyncIo keeps in thread-locals). +struct TokioAsyncIoContext { + // Destroyed in reverse declaration order: providers first (their rust::Boxes drop while the + // runtime still exists), then waitScope, then loop (asserts its queue is empty), then port + // (dropping the tokio runtime, canceling still-pending spawned tasks). I/O objects created + // *through* the providers (streams, listeners, addresses) must be destroyed before the + // context, as with kj::setupAsyncIo(). + kj::Own port; + kj::Own loop; + kj::Own waitScope; + kj::Own lowLevelProvider; + kj::Own provider; + + kj_rs_tokio::TokioEventPort &getPort() { + return *port; + } + kj::WaitScope &getWaitScope() { + return *waitScope; + } + kj::Timer &getTimer() { + return port->getTimer(); + } + kj::Network &getNetwork() { + return provider->getNetwork(); + } + kj::AsyncIoProvider &getProvider() { + return *provider; + } + kj::LowLevelAsyncIoProvider &getLowLevelProvider() { + return *lowLevelProvider; + } +}; + +// Sets up the current thread with a tokio-driven KJ event loop plus tokio-backed I/O providers: +// the kj::setupAsyncIo() equivalent for the tokio loop. One per thread. +TokioAsyncIoContext setupTokioAsyncIo(); + +// Resolves when the process receives signal `signum`: the tokio-loop replacement for +// kj::UnixEventPort::onSignal() (workerd's SIGTERM graceful drain). Must be awaited on the +// thread owning the TokioEventPort. Unlike UnixEventPort, KJ does not block/capture the signal +// beforehand: the tokio handler is registered when the promise is first polled, so a signal +// delivered before the event loop first runs takes its default disposition (see signal.rs). +// On Windows, SIGTERM/SIGINT are mapped to the ctrl_shutdown/ctrl_c console control events; +// the promise rejects for other signums. +kj::Promise onSignal(int signum); + +} // namespace kj_rs_io diff --git a/src/rust/cxx/kj-rs-io/error.rs b/src/rust/cxx/kj-rs-io/error.rs new file mode 100644 index 00000000000..1742cf2085b --- /dev/null +++ b/src/rust/cxx/kj-rs-io/error.rs @@ -0,0 +1,130 @@ +//! Error mapping: `std::io::Error` -> `kj::Exception`, preserving KJ's exception-type taxonomy. +//! +//! kj-http and capnp RPC change behavior based on `kj::Exception::Type` (e.g. `DISCONNECTED` +//! failures are treated as clean peer hangups rather than bugs), so the mapping of connection +//! errors matters for behavioral parity with `kj::setupAsyncIo()`. + +use cxx::IntoKjException; +use cxx::KjError; +use cxx::KjException; +use cxx::KjExceptionType; + +pub type Result = std::result::Result; + +/// An `std::io::Error` (plus operation context) that converts into a `kj::Exception` with an +/// appropriate exception type. +#[derive(Debug)] +pub struct KjIoError { + /// Name of the failing operation, included in the exception description the way KJ's + /// `KJ_SYSCALL` includes the syscall name (e.g. "`connect()`: Connection refused ..."). + op: &'static str, + inner: std::io::Error, +} + +impl KjIoError { + pub(crate) fn other(op: &'static str, message: impl std::fmt::Display) -> Self { + Self { + op, + inner: std::io::Error::other(message.to_string()), + } + } +} + +/// Attaches an operation name to `io::Error`s, for use with `Result::map_err`. +pub fn op(name: &'static str) -> impl Fn(std::io::Error) -> KjIoError { + move |inner| KjIoError { op: name, inner } +} + +fn exception_type(error: &std::io::Error) -> KjExceptionType { + use std::io::ErrorKind; + // Primary classification: by raw errno, mirroring KJ's own table (`typeOfErrno()` in + // kj/debug.c++) errno-for-errno. Consumers (kj-http, capnp-rpc) change behavior on the + // exception type, so the classes must match `kj::setupAsyncIo()` exactly — e.g. ETIMEDOUT + // is OVERLOADED in KJ (retry-later), NOT DISCONNECTED (clean peer hangup), and std's + // `ErrorKind` buckets have no stable kinds at all for KJ's fd/memory-exhaustion OVERLOADED + // set (EMFILE/ENFILE/ENOBUFS/...), hence the raw match. + #[cfg(unix)] + if let Some(errno) = error.raw_os_error() { + return errno_exception_type(errno); + } + // Fallback for synthetic (non-OS) errors — and all errors on Windows, where + // `raw_os_error()` is a Win32/WSA code, not an errno (KJ classifies those in + // `typeOfWin32Error()`; the buckets below agree with it for the kinds tokio surfaces). + match error.kind() { + // KJ's DISCONNECTED class: connection teardown, treated as a clean peer hangup. + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::BrokenPipe + | ErrorKind::NotConnected + | ErrorKind::UnexpectedEof + | ErrorKind::HostUnreachable + | ErrorKind::NetworkUnreachable + | ErrorKind::NetworkDown => KjExceptionType::Disconnected, + // KJ's OVERLOADED class: temporary lack of resources (ETIMEDOUT/WSAETIMEDOUT and + // ENOMEM land here in KJ's tables). + ErrorKind::TimedOut | ErrorKind::OutOfMemory => KjExceptionType::Overloaded, + ErrorKind::Unsupported => KjExceptionType::Unimplemented, + _ => KjExceptionType::Failed, + } +} + +/// Exact mirror of KJ's `typeOfErrno()` (kj/debug.c++), so `kj::Exception::Type` matches the +/// native `kj::setupAsyncIo()` backend errno-for-errno. +#[cfg(unix)] +fn errno_exception_type(errno: i32) -> KjExceptionType { + // Errnos that are `#ifdef`-conditional in KJ's table for platform reasons, mirrored here + // with `cfg`: ENONET exists only on Linux; EOPNOTSUPP aliases ENOTSUP on Linux (KJ compiles + // its case only `#if EOPNOTSUPP != ENOTSUP` — an or-pattern with both would be an + // unreachable pattern there). + #[cfg(any(target_os = "linux", target_os = "android"))] + if errno == libc::ENONET { + return KjExceptionType::Disconnected; + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + if errno == libc::EOPNOTSUPP { + return KjExceptionType::Unimplemented; + } + match errno { + // OVERLOADED: the call failed because of a temporary lack of resources. + libc::EDQUOT + | libc::EMFILE + | libc::ENFILE + | libc::ENOBUFS + | libc::ENOLCK + | libc::ENOMEM + | libc::ENOSPC + | libc::ETIMEDOUT + | libc::EUSERS => KjExceptionType::Overloaded, + // DISCONNECTED: communication over a connection that has been lost. + libc::ENOTCONN + | libc::ECONNABORTED + | libc::ECONNREFUSED + | libc::ECONNRESET + | libc::EHOSTDOWN + | libc::EHOSTUNREACH + | libc::ENETDOWN + | libc::ENETRESET + | libc::ENETUNREACH + | libc::EPIPE => KjExceptionType::Disconnected, + // UNIMPLEMENTED: the "not supported" family (ENOTSOCK is really "syscall not + // implemented for non-sockets", per KJ's own comment). + libc::ENOSYS | libc::ENOTSUP | libc::ENOPROTOOPT | libc::ENOTSOCK => { + KjExceptionType::Unimplemented + } + _ => KjExceptionType::Failed, + } +} + +impl From for KjError { + fn from(error: KjIoError) -> Self { + let description = format!("{}: {}", error.op, error.inner); + Self::new(exception_type(&error.inner), description) + } +} + +impl IntoKjException for KjIoError { + fn into_kj_exception(self, file: &str, line: u32) -> KjException { + KjError::from(self).into_kj_exception(file, line) + } +} diff --git a/src/rust/cxx/kj-rs-io/ffi.rs b/src/rust/cxx/kj-rs-io/ffi.rs new file mode 100644 index 00000000000..f247dbb18a7 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/ffi.rs @@ -0,0 +1,832 @@ +//! The FFI island of kj-rs-io: the `#[cxx::bridge]` wire plus the crate's hand-written `unsafe` +//! boundary, in one dedicated file (file-top `#![allow(unsafe_code)]`). +//! +//! It holds: +//! +//! - the `#[cxx::bridge] mod bridge` (namespace `kj_rs_io`) — the cxx-generated C++ <-> Rust wire, +//! re-exported as `crate::ffi::*`; and +//! - every hand-written `unsafe` the macro does not generate, so the serve / net / stream modules +//! can carry the crate-root `#![deny(unsafe_code)]` and be *compiler-proven* free of hand-written +//! unsafe. Three kinds of raw thing cross the FFI boundary and are laundered into safe Rust types +//! here: +//! +//! 1. **Raw OS socket handles** arriving from C++ as an `i64` (a Unix fd or a win32 `SOCKET`; +//! see [`own_socket_from_raw`], the one platform conversion point) — the C++ side has +//! already normalized KJ's `TAKE_OWNERSHIP` / `ALREADY_CLOEXEC` flags, dup'ing when not +//! transferring ownership — plus [`dup_raw_fd`] (dup a borrowed fd into an owned one; the +//! unix-only handle tier of [`take_kj_socket`]) and [`own_fd_from_raw`] (the unix-only +//! pipe tier's `i32` fds). +//! 2. **`struct sockaddr` bytes** crossing in both directions — [`sockaddr_to_bytes`] / +//! [`sockaddr_from_bytes`]. +//! 3. **Raw `getsockopt(2)` / `setsockopt(2)`** — the socket-option passthrough behind +//! `kj::AsyncIoStream` / `kj::ConnectionReceiver` carries caller-owned option buffers whose +//! length semantics (socklen in/out) no safe std/socket2 API expresses, so the raw syscalls +//! live here ([`stream_getsockopt`] and friends). +//! +//! Because this file opts back into `unsafe` (`#![allow(unsafe_code)]`), it is the one module in +//! the crate whose soundness must be audited by hand; the rest is enforced by the compiler. +#![allow(unsafe_code)] + +use core::pin::Pin; + +/// Opaque binding of `kj::AsyncIoStream`, and the cxx-bridged operations on it (shared-receiver +/// shims; safe to call). Re-exported as `crate::ffi::*` so the rest of the crate (and the +/// crate-root re-exports) keep using `ffi::`. +pub use bridge::KjAsyncIoStream; +pub use bridge::kj_stream_get_handle; +pub use bridge::unwrap_tokio_stream; +use cxx::KjException; +use kj_rs::KjOwn; + +use crate::error::Result; +use crate::error::op; +use crate::net::TokioAddress; +use crate::net::TokioListener; +use crate::net::address_clone; +use crate::net::address_connect_index; +use crate::net::address_count; +use crate::net::address_listen; +use crate::net::address_raw_sockaddr; +use crate::net::address_to_string; +use crate::net::listener_accept; +use crate::net::listener_local_addr; +use crate::net::listener_port; +use crate::net::network_get_sockaddr; +use crate::net::network_parse_address; +use crate::net::wrap_connecting_socket_fd; +use crate::net::wrap_listen_fd; +use crate::net::wrap_socket_fd; +use crate::readiness::wait_fd_readable; +use crate::signal::wait_for_signal; +use crate::stream::TokioInputFd; +use crate::stream::TokioOutputFd; +use crate::stream::TokioStream; +use crate::stream::input_fd_try_read; +use crate::stream::output_fd_write; +use crate::stream::stream_local_addr; +use crate::stream::stream_peer_addr; +use crate::stream::stream_raw_handle; +use crate::stream::stream_shutdown_write; +use crate::stream::stream_take; +use crate::stream::stream_try_read; +use crate::stream::stream_when_write_disconnected; +use crate::stream::stream_write; +use crate::stream::wrap_input_fd; +use crate::stream::wrap_output_fd; + +#[cxx::bridge(namespace = "kj_rs_io")] +// FFI island: the cxx bridge macro generates the `unsafe` extern shims, and this module declares +// the hand-written `unsafe extern "C++"` / `async unsafe fn` bridge surface. +// unnecessary_box_returns: returning an opaque Rust type to C++ as `Box` is the cxx idiom. +#[expect(clippy::unnecessary_box_returns)] +// missing_safety_doc fires (or not) deep inside the macro expansion depending on which bridge +// items are publicly re-exported, so an `#[expect]` could go unfulfilled. +#[expect(clippy::allow_attributes)] +#[allow(clippy::missing_safety_doc)] +mod bridge { + extern "Rust" { + type TokioStream; + type TokioListener; + type TokioAddress; + type TokioInputFd; + type TokioOutputFd; + + // ================================================================================== + // Streams (TCP or Unix domain, behind kj::AsyncIoStream) + + /// Reads until at least `min_bytes` are available (or EOF), up to `buf.len()`. Returns + /// the number of bytes read; fewer than `min_bytes` indicates EOF. The kj `tryRead` + /// contract. + async unsafe fn stream_try_read<'a>( + stream: &'a TokioStream, + buf: &'a mut [u8], + min_bytes: usize, + ) -> Result; + + /// Writes the entire buffer (write-all semantics). + async unsafe fn stream_write<'a>(stream: &'a TokioStream, buf: &'a [u8]) -> Result<()>; + + /// Resolves when the stream has become disconnected such that new writes will fail. + /// See `TokioStream::when_write_disconnected` for the mechanism and platform caveats. + async unsafe fn stream_when_write_disconnected<'a>(stream: &'a TokioStream) -> Result<()>; + + /// `shutdown(SHUT_WR)`: cleanly shut down the write end, keeping the read end open. + fn stream_shutdown_write(stream: &TokioStream) -> Result<()>; + + /// The underlying raw OS socket handle (fd on unix, `SOCKET` on windows) as an `i64`, + /// backing `kj::AsyncIoStream::getFd()` / `getWin32Handle()`. + fn stream_raw_handle(stream: &TokioStream) -> Result; + + /// Raw `struct sockaddr` bytes of the socket's locally-bound address (the + /// `getsockname()` passthrough). + fn stream_local_addr(stream: &TokioStream) -> Result>; + + /// Raw `struct sockaddr` bytes of the connected peer's address (the `getpeername()` + /// passthrough, also used by the accept-loop peer-filter check). + fn stream_peer_addr(stream: &TokioStream) -> Result>; + + /// `getsockopt(2)` on the underlying socket. `value.len()` is the caller's in-length + /// (the kernel truncates the option value to it); returns the syscall's reported + /// out-length, which the caller must mirror back exactly (raw socklen in/out + /// semantics). + fn stream_getsockopt( + stream: &TokioStream, + level: i32, + option: i32, + value: &mut [u8], + ) -> Result; + + /// `setsockopt(2)` on the underlying socket. + fn stream_setsockopt( + stream: &TokioStream, + level: i32, + option: i32, + value: &[u8], + ) -> Result<()>; + + /// Moves the native stream out, leaving `stream` hollow (all further ops error). + /// Unsafe contract: no I/O futures may currently borrow `stream`. + fn stream_take(stream: &mut TokioStream) -> Result>; + + // ================================================================================== + // Network addresses (kj::Network::parseAddress grammar subset) + + /// Parses a KJ address string ("1.2.3.4:80", "[::1]:80", "host:80", "*", "*:80", + /// "unix:/path"), resolving hostnames via DNS. `port_hint` fills in a missing port. + /// (Owned `String`: the future must not borrow the caller's buffer across the DNS + /// suspension.) + async fn network_parse_address(addr: String, port_hint: u16) -> Result>; + + /// Builds an address from a raw `struct sockaddr` (AF_INET / AF_INET6 / AF_UNIX). + fn network_get_sockaddr(sockaddr: &[u8]) -> Result>; + + /// Connects to exactly the `index`th resolved address (no fallback). The C++ side + /// drives the try-each-address loop so it can apply restrictPeers() filtering per + /// address (KJ parity). + async unsafe fn address_connect_index<'a>( + addr: &'a TokioAddress, + index: usize, + ) -> Result>; + + /// Number of resolved socket addresses behind this address (>= 1). + fn address_count(addr: &TokioAddress) -> usize; + + /// Raw `struct sockaddr` bytes of the `index`th resolved address, for C++-side + /// kj::_::NetworkFilter (restrictPeers) checks. + fn address_raw_sockaddr(addr: &TokioAddress, index: usize) -> Result>; + + /// Binds + listens on the (first) address. Wildcard addresses bind dual-stack. + fn address_listen(addr: &TokioAddress) -> Result>; + + fn address_clone(addr: &TokioAddress) -> Box; + fn address_to_string(addr: &TokioAddress) -> String; + + // ================================================================================== + // Listeners (kj::ConnectionReceiver) + + async unsafe fn listener_accept<'a>( + listener: &'a TokioListener, + ) -> Result>; + + /// The locally-bound port (0 for Unix domain sockets, mirroring KJ). + fn listener_port(listener: &TokioListener) -> Result; + + /// Raw `struct sockaddr` bytes of the listener's bound address (the `getsockname()` + /// passthrough). + fn listener_local_addr(listener: &TokioListener) -> Result>; + + /// `getsockopt(2)` on the listening socket; same length semantics as + /// `stream_getsockopt`. + fn listener_getsockopt( + listener: &TokioListener, + level: i32, + option: i32, + value: &mut [u8], + ) -> Result; + + /// `setsockopt(2)` on the listening socket. + fn listener_setsockopt( + listener: &TokioListener, + level: i32, + option: i32, + value: &[u8], + ) -> Result<()>; + + // ================================================================================== + // Socket-handle wrapping (kj::LowLevelAsyncIoProvider). + // + // The `i64` is a raw OS socket handle: a Unix fd or a win32 `SOCKET` (`i64` fits both + // losslessly, with `-1` ≡ `INVALID_SOCKET` as the shared sentinel). All of these take + // ownership of the handle. The C++ side normalizes + // TAKE_OWNERSHIP/ALREADY_CLOEXEC/ALREADY_NONBLOCK flags (dup'ing when not taking + // ownership) before calling in; [`own_socket_from_raw`] is the single point where the + // raw handle becomes an owned socket. + + /// Wraps a connected stream socket handle (TCP or Unix domain, detected automatically). + fn wrap_socket_fd(handle: i64) -> Result>; + + /// Wraps a bound+listening socket handle (TCP or Unix domain, detected automatically). + fn wrap_listen_fd(handle: i64) -> Result>; + + /// Wraps an unconnected TCP socket handle and connects it to `sockaddr` (a raw + /// `struct sockaddr`, AF_INET/AF_INET6 only; owned copy, since the caller's pointer + /// need not outlive the call). + async fn wrap_connecting_socket_fd( + handle: i64, + sockaddr: Vec, + ) -> Result>; + + /// Wraps a readable fd (pipe, character device, socket). Regular files are rejected by + /// the OS readiness API (same as KJ's epoll-based provider). Unix only (the pipe tier + /// keeps `i32` fds). + fn wrap_input_fd(fd: i32) -> Result>; + + async unsafe fn input_fd_try_read<'a>( + stream: &'a TokioInputFd, + buf: &'a mut [u8], + min_bytes: usize, + ) -> Result; + + /// Wraps a writable fd (pipe, character device, socket). + fn wrap_output_fd(fd: i32) -> Result>; + + async unsafe fn output_fd_write<'a>(stream: &'a TokioOutputFd, buf: &'a [u8]) + -> Result<()>; + + // ================================================================================== + // Signals (kj::UnixEventPort::onSignal replacement; see signal.rs for semantics) + + /// Resolves when the process receives signal `signum` (on Windows: the mapped + /// SIGTERM/SIGINT console control event). + async fn wait_for_signal(signum: i32) -> Result<()>; + + // ================================================================================== + // Fd readiness (kj::UnixEventPort::FdObserver::whenBecomesReadable replacement, + // backing kj_rs_io::FileWatcher in file-watcher.h; see readiness.rs for semantics) + + /// Resolves when `fd` becomes readable (readiness already pending at call time is + /// reported immediately). The caller must keep `fd` open until the returned promise + /// resolves or is dropped, and must not watch the same fd twice concurrently. + /// Unix only. + async fn wait_fd_readable(fd: i32) -> Result<()>; + } + + unsafe extern "C++" { + include!("kj-rs-io/unwrap.h"); + + /// `kj::AsyncIoStream`, opaque. Used by [`unwrap_kj_stream`]. + #[namespace = "kj"] + #[cxx_name = "AsyncIoStream"] + type KjAsyncIoStream; + + /// Implemented in `async-io.c++`: downcasts to the kj-rs-io wrapper and moves the native + /// stream out. Throws (surfaced as `Err`) for foreign streams. + #[cxx_name = "unwrapTokioStream"] + fn unwrap_tokio_stream(stream: Pin<&mut KjAsyncIoStream>) -> Result>; + + // Bridged operations on a foreign `kj::AsyncIoStream`, backing `serve_kj_stream`'s + // duplex-pump fallback (serve.rs). Shared receivers (`&KjAsyncIoStream`, const_cast + // shims in unwrap.h): a kj two-way stream supports one concurrent read and one write, + // which the pump models as concurrent shared borrows of the stream it owns. All + // returned futures must be polled on the KJ event-loop thread owning the stream. + + /// Corresponds to `kj::AsyncIoStream::tryRead(buffer, min_bytes, buffer.len())`. + #[cxx_name = "kjStreamTryRead"] + async fn kj_stream_try_read( + stream: &KjAsyncIoStream, + buffer: &mut [u8], + min_bytes: usize, + ) -> Result; + + /// Corresponds to `kj::AsyncIoStream::write(buffer)` (write-all semantics). + #[cxx_name = "kjStreamWrite"] + async fn kj_stream_write(stream: &KjAsyncIoStream, buffer: &[u8]) -> Result<()>; + + /// Corresponds to `kj::AsyncIoStream::shutdownWrite()`. + #[cxx_name = "kjStreamShutdownWrite"] + fn kj_stream_shutdown_write(stream: &KjAsyncIoStream); + + /// The stream's underlying raw OS socket handle (fd on unix, `SOCKET` on windows; + /// `kj::AsyncIoStream::getFd()` / `getWin32Handle()`) as an `i64`, or -1 if it exposes + /// none. Backs the handle tier of [`take_kj_socket`]. + #[cxx_name = "kjStreamGetHandle"] + fn kj_stream_get_handle(stream: &KjAsyncIoStream) -> i64; + } +} + +// ====================================================================================== +// Raw socket handles / file descriptors. + +/// Materializes an owned file descriptor from a raw `i32` that arrived across the FFI bridge +/// (the unix-only pipe tier — `wrap_input_fd`/`wrap_output_fd`; sockets go through +/// [`own_socket_from_raw`]). +/// +/// Callable from safe code: the invariant it relies on (`fd` is open and its ownership has been +/// transferred to us) is structurally upheld by the cxx bridge — the C++ side normalizes KJ's +/// `TAKE_OWNERSHIP` / `ALREADY_CLOEXEC` flags and dup's the fd when the caller is not handing +/// over ownership. The returned `OwnedFd` becomes the sole owner and closes it on drop. +#[cfg(unix)] +#[must_use] +pub fn own_fd_from_raw(fd: i32) -> std::os::fd::OwnedFd { + use std::os::fd::FromRawFd; + // `OwnedFd`'s invariant is "an open fd, never -1" (-1 is its niche), so a negative value + // here would be library-level UB rather than an error. C++ callers normalize through + // prepareFd, but its all-flags-set path (TAKE_OWNERSHIP|ALREADY_CLOEXEC|ALREADY_NONBLOCK) + // performs no syscall that would catch a bad fd — enforce the contract at THE conversion + // point instead of inheriting the UB. + assert!(fd >= 0, "invalid fd crossed the FFI bridge: {fd}"); + // Safety: per the bridge contract `fd` is open and owned by us from this point on. + unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) } +} + +/// Materializes an owned socket from a raw OS socket handle that arrived across the FFI bridge +/// as an `i64`: a Unix fd here, a win32 `SOCKET` in the `cfg(windows)` twin below. This is THE +/// one platform conversion point — behind it everything is a uniform `socket2::Socket` / +/// std/tokio socket type. +/// +/// Callable from safe code: the invariant it relies on (`handle` is an open socket whose +/// ownership has been transferred to us) is structurally upheld by the cxx bridge — the C++ +/// side normalizes KJ's fd-wrapping flags, dup'ing when the caller is not handing over +/// ownership. The returned socket becomes the sole owner and closes it on drop. +#[cfg(unix)] +#[must_use] +pub fn own_socket_from_raw(handle: i64) -> socket2::Socket { + // A unix fd is a non-negative int: the bridge widened it losslessly to i64, so the + // narrowing back to i32 cannot truncate for any legitimate handle. Enforce that (rejecting + // -1/garbage) here at THE conversion point rather than inheriting `OwnedFd`'s niche UB. + assert!( + (0..=i64::from(i32::MAX)).contains(&handle), + "invalid socket fd crossed the FFI bridge: {handle}" + ); + #[expect(clippy::cast_possible_truncation)] + let fd = handle as i32; + socket2::Socket::from(own_fd_from_raw(fd)) +} + +/// The `cfg(windows)` twin of [`own_socket_from_raw`]: the raw handle is a winsock `SOCKET` +/// (`u64`-shaped `RawSocket`; a live SOCKET fits in an `i64` without colliding with the -1 +/// sentinel, which is `INVALID_SOCKET` and never crosses the bridge as an owned handle). +// Validated by Windows CI; mirrors the unix arm. +#[cfg(windows)] +#[must_use] +pub fn own_socket_from_raw(handle: i64) -> socket2::Socket { + use std::os::windows::io::FromRawSocket; + use std::os::windows::io::OwnedSocket; + use std::os::windows::io::RawSocket; + // Live SOCKET values are non-negative in i64 (the -1 sentinel is INVALID_SOCKET, which + // `OwnedSocket` forbids as its niche and which must never cross the bridge as an owned + // handle) — enforce at THE conversion point rather than inheriting the niche UB. + assert!( + handle >= 0, + "invalid SOCKET crossed the FFI bridge: {handle}" + ); + // The bridge carries the SOCKET's bits verbatim. + #[allow(clippy::cast_sign_loss)] + let raw = handle as RawSocket; + // Safety: per the bridge contract `handle` is an open SOCKET owned by us from this point on. + let owned = unsafe { OwnedSocket::from_raw_socket(raw) }; + socket2::Socket::from(owned) +} + +/// Duplicates a *borrowed* raw fd into an independently-owned fd (`F_DUPFD_CLOEXEC`), for the +/// handle tier of [`take_kj_socket`]: the kj stream keeps its own fd, we get a fresh dup. +/// +/// Unix only, deliberately: no windows twin is needed. The handle tier only fires for *foreign* +/// handle-backed kj streams, and under the all-rust mode on Windows every socket-backed stream +/// originates in kj-rs-io (tier-1 unwrap); the other in-process dup users don't dup on windows +/// either (`when_write_disconnected` is unix-only — never-resolving on windows, KJ parity — and +/// windows `shutdown_write` borrows via `SockRef` instead of dup'ing). If a windows twin is +/// ever needed, `std::os::windows::io::BorrowedSocket::try_clone_to_owned` +/// (`WSADuplicateSocketW`) is the same-process equivalent. +/// +/// # Errors +/// +/// Returns the `dup()` `io::Error` (mapped to a `kj::Exception`) if the syscall fails. +#[cfg(unix)] +pub fn dup_raw_fd(fd: i32) -> Result { + // Safety: the caller (take_kj_socket) holds the kj stream, which keeps `fd` open for the + // duration of the call; we immediately dup it into an independently-owned fd. + unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) } + .try_clone_to_owned() + .map_err(op("dup()")) +} + +// ====================================================================================== +// `struct sockaddr` <-> bytes. + +/// Copies a `socket2::SockAddr`'s initialized `struct sockaddr` bytes into an owned `Vec`, to +/// hand across the bridge for the C++ side's `kj::_::NetworkFilter` (restrictPeers) checks. +#[must_use] +pub fn sockaddr_to_bytes(sockaddr: &socket2::SockAddr) -> Vec { + // Safety: as_ptr()/len() delimit an initialized sockaddr owned by `sockaddr`. + let bytes = unsafe { + std::slice::from_raw_parts(sockaddr.as_ptr().cast::(), sockaddr.len() as usize) + }; + bytes.to_vec() +} + +/// Decodes raw `struct sockaddr` bytes (arriving from C++) into a `socket2::SockAddr`. +/// +/// # Errors +/// +/// Errors if the byte length is zero or exceeds `sockaddr_storage`. +#[cfg(any(unix, windows))] +pub fn sockaddr_from_bytes(bytes: &[u8]) -> Result { + use crate::error::KjIoError; + + let mut storage = socket2::SockAddrStorage::zeroed(); + let storage_size = std::mem::size_of::(); + if bytes.is_empty() || bytes.len() > storage_size { + return Err(KjIoError::other("sockaddr", "invalid sockaddr length")); + } + // Safety: SockAddrStorage is plain-old-data large enough for any sockaddr; we copy + // `bytes.len() <= size_of::()` bytes into it. + unsafe { + std::ptr::copy_nonoverlapping( + bytes.as_ptr(), + std::ptr::from_mut(&mut storage).cast::(), + bytes.len(), + ); + } + #[expect(clippy::cast_possible_truncation)] + let len = bytes.len() as socket2::socklen_t; + // Safety: `storage` is a zeroed sockaddr_storage with the caller's `len` bytes copied in, + // satisfying SockAddr::new's layout/length requirements. The address *family* is NOT + // validated here: for families socket2 does not understand (e.g. AF_NETLINK), accessors + // like `as_socket()`/`as_pathname()` return `None`, and callers must surface that as an + // "unsupported sockaddr family" error (see `net.rs::network_get_sockaddr`) rather than + // assume a known family. + Ok(unsafe { socket2::SockAddr::new(storage, len) }) +} + +// ====================================================================================== +// Raw `getsockopt(2)` / `setsockopt(2)`. +// +// The socket-option passthrough behind `kj::AsyncIoStream::get/setsockopt` and +// `kj::ConnectionReceiver::get/setsockopt`. The option buffer is caller-owned opaque bytes with +// raw socklen in/out semantics (the caller's buffer may be smaller than the option value, and the +// syscall's reported length must be surfaced verbatim), which no safe std/socket2 API expresses — +// so the raw syscalls are declared and called here, in the unsafe island. + +/// Raw `getsockopt(2)` on a borrowed socket fd. `value.len()` is passed as the in `optlen` (the +/// kernel truncates the option value to it); the syscall's reported out `optlen` is returned so +/// the C++ caller can mirror `*length = socklen` exactly as `KJ_SYSCALL(::getsockopt(...))` did. +#[cfg(unix)] +fn getsockopt_raw( + fd: std::os::fd::BorrowedFd<'_>, + level: i32, + option: i32, + value: &mut [u8], +) -> Result { + use core::ffi::c_int; + use core::ffi::c_void; + use std::os::fd::AsRawFd; + unsafe extern "C" { + fn getsockopt( + sockfd: c_int, + level: c_int, + optname: c_int, + optval: *mut c_void, + optlen: *mut socket2::socklen_t, + ) -> c_int; + } + #[expect(clippy::cast_possible_truncation)] + let mut optlen = value.len() as socket2::socklen_t; + // Safety: simple syscall wrapper. `fd` is a live socket fd (borrowed from the tokio object + // for the duration of the call); `value.as_mut_ptr()` with in-`optlen == value.len()` + // delimits writable caller memory the kernel fills (never past `optlen`); `&raw mut optlen` + // is a valid in/out pointer for the call. + let rc = unsafe { + getsockopt( + fd.as_raw_fd(), + level, + option, + value.as_mut_ptr().cast::(), + &raw mut optlen, + ) + }; + if rc != 0 { + return Err(op("getsockopt()")(std::io::Error::last_os_error())); + } + Ok(optlen as usize) +} + +/// Raw `setsockopt(2)` on a borrowed socket fd. +#[cfg(unix)] +fn setsockopt_raw( + fd: std::os::fd::BorrowedFd<'_>, + level: i32, + option: i32, + value: &[u8], +) -> Result<()> { + use core::ffi::c_int; + use core::ffi::c_void; + use std::os::fd::AsRawFd; + unsafe extern "C" { + fn setsockopt( + sockfd: c_int, + level: c_int, + optname: c_int, + optval: *const c_void, + optlen: socket2::socklen_t, + ) -> c_int; + } + #[expect(clippy::cast_possible_truncation)] + let optlen = value.len() as socket2::socklen_t; + // Safety: simple syscall wrapper. `fd` is a live socket fd (borrowed from the tokio object + // for the duration of the call); `value.as_ptr()` with `optlen == value.len()` delimits + // readable caller memory the kernel only reads. + let rc = unsafe { + setsockopt( + fd.as_raw_fd(), + level, + option, + value.as_ptr().cast::(), + optlen, + ) + }; + if rc != 0 { + return Err(op("setsockopt()")(std::io::Error::last_os_error())); + } + Ok(()) +} + +/// Raw ws2_32 `getsockopt` on a borrowed `SOCKET`. Same socklen in/out semantics as the unix +/// arm above: `value.len()` is passed as the in `optlen`, and the reported out `optlen` is +/// returned verbatim. +// Validated by Windows CI; mirrors the unix arm. +#[cfg(windows)] +fn getsockopt_raw( + sock: std::os::windows::io::BorrowedSocket<'_>, + level: i32, + option: i32, + value: &mut [u8], +) -> Result { + use core::ffi::c_char; + use core::ffi::c_int; + use std::os::windows::io::AsRawSocket; + use std::os::windows::io::RawSocket; + #[link(name = "ws2_32")] + unsafe extern "system" { + fn getsockopt( + s: RawSocket, + level: c_int, + optname: c_int, + optval: *mut c_char, + optlen: *mut c_int, + ) -> c_int; + } + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let mut optlen = value.len() as c_int; + // Safety: simple syscall wrapper. `sock` is a live `SOCKET` (borrowed from the tokio object + // for the duration of the call); `value.as_mut_ptr()` with in-`optlen == value.len()` + // delimits writable caller memory winsock fills (never past `optlen`); `&raw mut optlen` is + // a valid in/out pointer for the call. + let rc = unsafe { + getsockopt( + sock.as_raw_socket(), + level, + option, + value.as_mut_ptr().cast::(), + &raw mut optlen, + ) + }; + if rc != 0 { + // rc is SOCKET_ERROR (-1); `last_os_error()` reads `WSAGetLastError()` on Windows. + return Err(op("getsockopt()")(std::io::Error::last_os_error())); + } + // The out-length winsock reports is non-negative (and bounded by the in-length). + #[allow(clippy::cast_sign_loss)] + let reported = optlen as usize; + Ok(reported) +} + +/// Raw ws2_32 `setsockopt` on a borrowed `SOCKET`. +// Validated by Windows CI; mirrors the unix arm. +#[cfg(windows)] +fn setsockopt_raw( + sock: std::os::windows::io::BorrowedSocket<'_>, + level: i32, + option: i32, + value: &[u8], +) -> Result<()> { + use core::ffi::c_char; + use core::ffi::c_int; + use std::os::windows::io::AsRawSocket; + use std::os::windows::io::RawSocket; + #[link(name = "ws2_32")] + unsafe extern "system" { + fn setsockopt( + s: RawSocket, + level: c_int, + optname: c_int, + optval: *const c_char, + optlen: c_int, + ) -> c_int; + } + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + let optlen = value.len() as c_int; + // Safety: simple syscall wrapper. `sock` is a live `SOCKET` (borrowed from the tokio object + // for the duration of the call); `value.as_ptr()` with `optlen == value.len()` delimits + // readable caller memory winsock only reads. + let rc = unsafe { + setsockopt( + sock.as_raw_socket(), + level, + option, + value.as_ptr().cast::(), + optlen, + ) + }; + if rc != 0 { + // rc is SOCKET_ERROR (-1); `last_os_error()` reads `WSAGetLastError()` on Windows. + return Err(op("setsockopt()")(std::io::Error::last_os_error())); + } + Ok(()) +} + +pub fn stream_getsockopt( + stream: &TokioStream, + level: i32, + option: i32, + value: &mut [u8], +) -> Result { + #[cfg(unix)] + { + getsockopt_raw(stream.as_borrowed_fd()?, level, option, value) + } + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + { + getsockopt_raw(stream.as_borrowed_socket()?, level, option, value) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (stream, level, option, value); + Err(crate::error::KjIoError::other( + "getsockopt", + "not implemented by kj-rs-io on this platform", + )) + } +} + +pub fn stream_setsockopt( + stream: &TokioStream, + level: i32, + option: i32, + value: &[u8], +) -> Result<()> { + #[cfg(unix)] + { + setsockopt_raw(stream.as_borrowed_fd()?, level, option, value) + } + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + { + setsockopt_raw(stream.as_borrowed_socket()?, level, option, value) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (stream, level, option, value); + Err(crate::error::KjIoError::other( + "setsockopt", + "not implemented by kj-rs-io on this platform", + )) + } +} + +pub fn listener_getsockopt( + listener: &TokioListener, + level: i32, + option: i32, + value: &mut [u8], +) -> Result { + #[cfg(unix)] + { + getsockopt_raw(listener.as_borrowed_fd(), level, option, value) + } + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + { + getsockopt_raw(listener.as_borrowed_socket(), level, option, value) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (listener, level, option, value); + Err(crate::error::KjIoError::other( + "getsockopt", + "not implemented by kj-rs-io on this platform", + )) + } +} + +pub fn listener_setsockopt( + listener: &TokioListener, + level: i32, + option: i32, + value: &[u8], +) -> Result<()> { + #[cfg(unix)] + { + setsockopt_raw(listener.as_borrowed_fd(), level, option, value) + } + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + { + setsockopt_raw(listener.as_borrowed_socket(), level, option, value) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (listener, level, option, value); + Err(crate::error::KjIoError::other( + "setsockopt", + "not implemented by kj-rs-io on this platform", + )) + } +} + +// ====================================================================================== +// Typed read/write halves of a pumped `kj::AsyncIoStream`. +// +// kj's stream contract — at most one read and one write may be in flight at once — is prose in +// kj; these halves make the borrow checker enforce it. Each half's operations take `&mut self`, +// so an in-flight operation's future exclusively borrows its half (a second overlapping read is +// a compile error), and `split_kj_stream` takes the owner's `&mut`, so while the halves live +// nothing else (an unwrap, another split) can touch the stream. The bridged operations behind +// them are not re-exported: the halves are the only way to drive a foreign stream. + +/// The read direction of a pumped stream. See the module comment above. +pub struct KjStreamReadHalf<'a>(&'a KjAsyncIoStream); + +/// The write direction of a pumped stream (writes and the write-side shutdown). See the module +/// comment above. +pub struct KjStreamWriteHalf<'a>(&'a KjAsyncIoStream); + +/// Splits the owned stream into its two directions. Holding the owner's `&mut` for the halves' +/// lifetime proves exactly one pair exists and reserves the stream for them. +// The unused `&mut` is the point (see the doc comment): it reserves the stream for the halves. +#[expect(clippy::needless_pass_by_ref_mut)] +pub fn split_kj_stream( + stream: &mut KjOwn, +) -> (KjStreamReadHalf<'_>, KjStreamWriteHalf<'_>) { + let stream = &**stream; + (KjStreamReadHalf(stream), KjStreamWriteHalf(stream)) +} + +impl KjStreamReadHalf<'_> { + /// `kj::AsyncIoStream::tryRead(buffer, min_bytes, buffer.len())`. + // The unused `&mut self` is the point: an in-flight read's future exclusively borrows the + // read half (kj's one-read-in-flight contract), see the section comment above. + #[expect(clippy::needless_pass_by_ref_mut)] + pub(crate) async fn try_read( + &mut self, + buf: &mut [u8], + min_bytes: usize, + ) -> std::result::Result { + bridge::kj_stream_try_read(self.0, buf, min_bytes).await + } +} + +impl KjStreamWriteHalf<'_> { + /// `kj::AsyncIoStream::write(buffer)` (write-all semantics). + // The unused `&mut self` is the point: an in-flight write's future exclusively borrows the + // write half (kj's one-write-in-flight contract), see the section comment above. + #[expect(clippy::needless_pass_by_ref_mut)] + pub(crate) async fn write(&mut self, buf: &[u8]) -> std::result::Result<(), KjException> { + bridge::kj_stream_write(self.0, buf).await + } + + /// `kj::AsyncIoStream::shutdownWrite()`. + // The unused `&mut self` is the point: an exclusive borrow of the write half serializes + // write-side operations (kj's one-write-in-flight contract), see the section comment above. + #[expect(clippy::needless_pass_by_ref_mut)] + pub(crate) fn shutdown_write(&mut self) { + bridge::kj_stream_shutdown_write(self.0); + } +} + +// ====================================================================================== +// Pub `unsafe fn` FFI entry point (`Pin<&mut kj::AsyncIoStream>`). +// +// The owning native-serve entry points (`take_kj_socket`, `serve_kj_stream`) are safe fns in +// [`crate::serve`] — ownership arrives as a `KjOwn` and no raw pointer crosses the crate's +// public surface. Only the borrow-based unwrap remains here: C++ keeps the (hollow) wrapper, +// so its "no I/O in flight" precondition cannot be expressed structurally. + +/// Recovers the native [`TokioStream`] out of a `kj::AsyncIoStream` that was created by +/// kj-rs-io, leaving the C++ wrapper hollow (any further I/O through it fails). +/// +/// # Errors +/// +/// Returns an error if the stream is not a kj-rs-io tokio-backed stream, or was already +/// unwrapped. +/// +/// # Safety +/// +/// No I/O operations (reads, writes, `whenWriteDisconnected`) may be in flight on the stream: +/// their futures borrow the same native object this function moves out. +pub unsafe fn unwrap_kj_stream( + stream: Pin<&mut KjAsyncIoStream>, +) -> std::result::Result, KjException> { + bridge::unwrap_tokio_stream(stream) +} diff --git a/src/rust/cxx/kj-rs-io/file-watcher.c++ b/src/rust/cxx/kj-rs-io/file-watcher.c++ new file mode 100644 index 00000000000..c37369593fa --- /dev/null +++ b/src/rust/cxx/kj-rs-io/file-watcher.c++ @@ -0,0 +1,213 @@ +// kj_rs_io::FileWatcher implementation. Each platform backend is a line-for-line port of +// workerd's kj-mode FileWatcher (workerd.c++), with the one difference that waiting for the +// notification fd to become readable goes through tokio's AsyncFd (wait_fd_readable, see +// readiness.rs) instead of kj::UnixEventPort::FdObserver::whenBecomesReadable(). Everything +// else -- which fds get created, which watch masks are used, how events are drained and +// filtered -- is kept identical so that --watch behaves the same on either event loop. + +#include "kj-rs-io/file-watcher.h" + +#include "kj-rs-io/ffi.rs.h" + +#include +#include +#include +#include + +#include + +#if __linux__ +#include +#include +#include +#elif __APPLE__ || __FreeBSD__ || __OpenBSD__ || __NetBSD__ || __DragonFly__ +#define KJ_RS_IO_USE_KQUEUE_FOR_FILE_WATCHER 1 +#include +#include +#include +#include +#include +#endif + +namespace kj_rs_io { + +#if __linux__ + +// inotify backend. Watches each file's parent directory (IN_DELETE | IN_MODIFY | IN_MOVE | +// IN_CREATE) and filters events by basename, so files replaced by rename (editors' atomic +// saves) or deleted-and-recreated keep firing, and the watched file itself need not exist yet. +struct FileWatcher::Impl { + kj::OwnFd inotifyFd; + + kj::HashMap watches; + kj::HashMap> filesWatched; + + Impl(): inotifyFd(KJ_SYSCALL_FD(inotify_init1(IN_NONBLOCK | IN_CLOEXEC))) {} + + bool isSupported() { + return true; + } + + void watch(kj::PathPtr path, kj::Maybe file) { + // The inotify backend doesn't use `file`; it watches the parent directory. + + auto pathStr = path.parent().toNativeString(true); + + int wd = watches.findOrCreate(pathStr, [&]() { + int wd; + uint32_t mask = IN_DELETE | IN_MODIFY | IN_MOVE | IN_CREATE; + KJ_SYSCALL(wd = inotify_add_watch(inotifyFd, pathStr.cStr(), mask)); + return decltype(watches)::Entry{kj::mv(pathStr), wd}; + }); + + auto &files = + filesWatched.findOrCreate(wd, [&]() { return decltype(filesWatched)::Entry{wd, {}}; }); + + files.upsert(kj::str(path.basename()[0]), [](auto &&...) {}); + } + + kj::Promise onChange() { + kj::byte buffer[4096]{}; + + for (;;) { + ssize_t n; + KJ_NONBLOCKING_SYSCALL(n = read(inotifyFd, buffer, sizeof(buffer))); + + if (n < 0) { + // No more data to read: wait for the inotify fd to become readable again. + co_await wait_fd_readable(inotifyFd.get()); + continue; + } + + kj::byte *ptr = buffer; + while (n > 0) { + KJ_ASSERT(n >= sizeof(struct inotify_event)); + + auto &event = *reinterpret_cast(ptr); + size_t eventSize = sizeof(struct inotify_event) + event.len; + KJ_ASSERT(n >= eventSize); + KJ_ASSERT(eventSize % sizeof(void *) == 0); + ptr += eventSize; + n -= eventSize; + + if (event.len > 0 && event.name[0] != '\0') { + auto &watched = KJ_ASSERT_NONNULL(filesWatched.find(event.wd)); + if (watched.find(kj::StringPtr(event.name)) != kj::none) { + // HIT! We saw a change. + co_return; + } + } + } + } + } +}; + +#elif KJ_RS_IO_USE_KQUEUE_FOR_FILE_WATCHER + +// kqueue backend. One EVFILT_VNODE registration per watched file (dup of the already-open +// config fd when available, else opened by path -- so the path must exist). NOTE_DELETE / +// NOTE_RENAME on the old inode cover atomic-rename saves. kqueue doesn't scale to whole +// directory trees, but we only watch the specific files opened while parsing the config. +struct FileWatcher::Impl { + kj::OwnFd kqueueFd; + kj::Vector filesWatched; + + Impl(): kqueueFd(makeKqueue()) {} + + bool isSupported() { + return true; + } + + void watch(kj::PathPtr path, kj::Maybe file) { + KJ_IF_SOME(f, file) { + KJ_IF_SOME(fd, f.getFd()) { + // We need to duplicate the fd because the original will probably be closed later, and + // closing the fd unregisters it from kqueue. + watchFd(KJ_SYSCALL_FD(dup(fd))); + return; + } + } + + // No existing file, open from disk. + watchFd(KJ_SYSCALL_FD(open(path.toNativeString(true).cStr(), O_RDONLY))); + } + + kj::Promise onChange() { + for (;;) { + struct kevent event; + struct timespec timeout; + memset(&event, 0, sizeof(event)); + memset(&timeout, 0, sizeof(timeout)); + + int n; + KJ_SYSCALL(n = kevent(kqueueFd, nullptr, 0, &event, 1, &timeout)); + + if (n == 0) { + // No events: wait for the kqueue fd to become readable, indicating an event has been + // delivered. + co_await wait_fd_readable(kqueueFd.get()); + continue; + } else { + // We only registered for events that indicate changes in the first place, so there's + // no need to examine the event: it definitely means something changed. + co_return; + } + } + } + + static kj::OwnFd makeKqueue() { + auto fd = KJ_SYSCALL_FD(kqueue()); + KJ_SYSCALL(fcntl(fd, F_SETFD, FD_CLOEXEC)); + return kj::mv(fd); + } + + void watchFd(kj::OwnFd fd) { + KJ_SYSCALL(fcntl(fd, F_SETFD, FD_CLOEXEC)); + + struct kevent change; + memset(&change, 0, sizeof(change)); + change.ident = fd.get(); + change.filter = EVFILT_VNODE; + change.flags = EV_ADD | EV_CLEAR; + change.fflags = NOTE_WRITE | NOTE_EXTEND | NOTE_DELETE | NOTE_RENAME; + KJ_SYSCALL(kevent(kqueueFd, &change, 1, nullptr, 0, nullptr)); + filesWatched.add(kj::mv(fd)); + } +}; + +#else + +// Dummy backend for platforms without an implementation (Windows, ...), mirroring workerd's: +// isSupported() returns false, which workerd surfaces as a clean CLI error for --watch +// ("File watching is not yet implemented on your OS") rather than a crash. A real Windows +// backend (e.g. ReadDirectoryChangesW, perhaps via the notify crate) is a potential follow-up. +struct FileWatcher::Impl { + bool isSupported() { + return false; + } + + void watch(kj::PathPtr path, kj::Maybe file) {} + + kj::Promise onChange() { + return kj::NEVER_DONE; + } +}; + +#endif + +FileWatcher::FileWatcher(): impl(kj::heap()) {} +FileWatcher::~FileWatcher() noexcept(false) = default; + +bool FileWatcher::isSupported() { + return impl->isSupported(); +} + +void FileWatcher::watch(kj::PathPtr path, kj::Maybe file) { + impl->watch(path, file); +} + +kj::Promise FileWatcher::onChange() { + return impl->onChange(); +} + +} // namespace kj_rs_io diff --git a/src/rust/cxx/kj-rs-io/file-watcher.h b/src/rust/cxx/kj-rs-io/file-watcher.h new file mode 100644 index 00000000000..6b2a85b2fe6 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/file-watcher.h @@ -0,0 +1,54 @@ +#pragma once +// kj_rs_io::FileWatcher: the tokio-loop replacement for workerd's `--watch` file watcher. +// +// Watches a set of individual files and resolves onChange() when any of them changes. The +// platform backends mirror workerd's kj FileWatcher exactly — inotify on the parent directory +// on Linux, kqueue EVFILT_VNODE per open file on macOS/BSD — but the notification fd's +// readiness is awaited through tokio's AsyncFd (kj_rs_io::wait_fd_readable) instead of +// kj::UnixEventPort::FdObserver, so it works on the tokio-backed event loop where no +// UnixEventPort exists. +// +// Behavior notes (all matching the kj version): +// - Multiple rapid changes coalesce: onChange() resolves once for whatever is queued; calling +// it again drains the queue before waiting, so changes are never lost between calls. +// - Linux: the watched file itself need not exist (only its parent directory must), and a +// file deleted and re-created is picked up again. macOS/BSD: watch() opens the file (or +// dups the provided already-open fd), so watching a nonexistent file throws; a +// replaced-by-rename file still fires on the old inode. +// - All internal fds are CLOEXEC: --watch reloads via execve(), which must not leak them. +// - Unsupported platforms (Windows, ...): isSupported() returns false, watch() is a no-op and +// onChange() never resolves, mirroring workerd's dummy watcher. +// +// onChange() must be awaited on the thread owning the kj_rs_tokio::TokioEventPort, and at most +// one onChange() promise may be outstanding at a time (workerd awaits it sequentially). The +// promise must be dropped before the FileWatcher is destroyed. + +#include +#include + +namespace kj_rs_io { + +class FileWatcher { + public: + FileWatcher(); + ~FileWatcher() noexcept(false); + KJ_DISALLOW_COPY_AND_MOVE(FileWatcher); + + // False on platforms with no watcher implementation (callers should report an error). + bool isSupported(); + + // Adds `path` to the watched set. `file`, if provided, is an already-open handle for the + // same path (the kqueue backend watches it directly via a dup'd fd; the inotify backend + // ignores it and watches the parent directory by name). + void watch(kj::PathPtr path, kj::Maybe file); + + // Resolves the next time any watched file changes (immediately, if a change is already + // queued). Eagerly evaluated, per kj-rs-io convention for I/O promises. + kj::Promise onChange(); + + private: + struct Impl; + kj::Own impl; +}; + +} // namespace kj_rs_io diff --git a/src/rust/cxx/kj-rs-io/lib.rs b/src/rust/cxx/kj-rs-io/lib.rs new file mode 100644 index 00000000000..f9a071b6c5c --- /dev/null +++ b/src/rust/cxx/kj-rs-io/lib.rs @@ -0,0 +1,75 @@ +//! Rust half of kj-rs-io: tokio-backed implementations of KJ's async I/O interfaces. +//! +//! The C++ side (`async-io.h`) implements `kj::AsyncIoStream`, `kj::ConnectionReceiver`, +//! `kj::NetworkAddress`, `kj::Network`, `kj::AsyncIoProvider` and `kj::LowLevelAsyncIoProvider` +//! as thin wrappers over the opaque Rust types in this crate. All async operations are plain +//! `async fn`s bridged to `kj::Promise` by workerd-cxx; dropping the promise drops the Rust +//! future, which releases any tokio readiness interest (cancellation is implicit). +//! +//! Every future returned from this crate must be polled on the thread that owns the +//! `kj_rs_tokio::TokioEventPort` runtime: tokio I/O objects register with that runtime's I/O +//! driver, which is only driven while the KJ event loop sleeps inside the port's +//! `wait()`/`poll()`. +//! +//! A Rust-originated stream wrapped as `kj::AsyncIoStream` can be recovered as its native +//! tokio object so Rust servers can drive the connection without crossing the +//! FFI per read: [`unwrap_kj_stream`] from Rust, `kj_rs_io::unwrapTokioStream()` from C++. +//! Foreign streams fail to unwrap with a `kj::Exception`. The native-serve entry points +//! ([`serve_kj_stream`], [`take_kj_socket`]) build on the [`serve`] module's `ServeIo` / pump +//! machinery. + +// Safety & panic enforcement walls. Test code exempted. +// +// `unsafe` is quarantined into a single named FFI island: the crate root denies `unsafe_code`, so +// the serve / net / stream / error / runtime / readiness / signal business logic is +// *compiler-proven* free of hand-written unsafe. The one island that opts back in via +// `#![allow(unsafe_code)]` is `ffi.rs`: it holds the `#[cxx::bridge] mod bridge` (re-exported as +// `crate::ffi::*`) plus all the fd / sockaddr laundering and the one remaining `pub unsafe fn` +// FFI entry point (`unwrap_kj_stream` — borrow-based, C++ keeps the wrapper). The owning entry +// points (`take_kj_socket`, `serve_kj_stream`) are safe fns in `serve.rs`: ownership arrives as +// a `KjOwn`, the pump drives it through compiler-checked shared borrows (shared-receiver shims, +// see unwrap.h), and no raw pointer crosses the public surface. +#![deny(unsafe_op_in_unsafe_fn)] +#![deny(unsafe_code)] +#![deny(clippy::undocumented_unsafe_blocks)] +#![deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::todo, + clippy::unimplemented +)] +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::todo, + clippy::unimplemented + ) +)] + +pub use stream::TokioStream; + +mod error; +mod ffi; +mod net; +mod readiness; +mod runtime; +pub mod serve; +mod signal; +mod stream; + +/// Opaque binding of `kj::AsyncIoStream` (see [`unwrap_kj_stream`]). +pub use ffi::KjAsyncIoStream; +pub use ffi::unwrap_kj_stream; +pub use serve::ServeIo; +pub use serve::ServePath; +pub use serve::ServedKjStream; +pub use serve::StreamPump; +pub use serve::TakeSocketError; +pub use serve::serve_kj_stream; +pub use serve::take_kj_socket; diff --git a/src/rust/cxx/kj-rs-io/net.rs b/src/rust/cxx/kj-rs-io/net.rs new file mode 100644 index 00000000000..c2a83c18f91 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/net.rs @@ -0,0 +1,631 @@ +//! Tokio-backed `kj::Network` / `kj::NetworkAddress` / `kj::ConnectionReceiver` backends. +//! +//! Address-string grammar follows KJ's `SocketAddress::parse` (kj/async-io-unix.c++) for the +//! subset workerd feeds it: +//! +//! - IPv4: `"1.2.3.4"`, `"1.2.3.4:80"` +//! - IPv6: `"1234:5678::abcd"`, `"[1234:5678::abcd]:80"` +//! - Wildcard (dual-stack): `"*"`, `"*:80"` +//! - Hostnames (DNS via blocking `getaddrinfo` on tokio's blocking pool, its completion delivered +//! same-thread through a tokio runtime task — see [`resolve_host`]): `"example.com"`, +//! `"example.com:80"` +//! - Unix domain: `"unix:/path/to/socket"` (Unix only) +//! +//! Known deviations from KJ, all erroring loudly rather than misbehaving: named services +//! (`"host:http"`), `unix-abstract:` addresses, and IPv6 scope IDs (`"fe80::1%eth0"`) are not +//! supported. + +use std::net::IpAddr; +use std::net::SocketAddr; +use std::net::ToSocketAddrs; + +use tokio::net::TcpListener; +use tokio::net::TcpStream; +#[cfg(unix)] +use tokio::net::UnixListener; +#[cfg(unix)] +use tokio::net::UnixStream; + +use crate::error::KjIoError; +use crate::error::Result; +use crate::error::op; +use crate::runtime::runtime_handle; +use crate::runtime::with_runtime; +use crate::stream::TokioStream; + +const LISTEN_BACKLOG: i32 = 1024; + +/// A parsed network address: one or more socket addresses to try in order. +pub struct TokioAddress { + spec: Spec, +} + +#[derive(Clone)] +enum Spec { + Ip { + /// Resolved addresses, tried in order by `connect()`; `listen()` binds the first one + /// (mirroring KJ, which also only listens on the first result). + addrs: Vec, + /// `"*"`: listen on `[::]` with `IPV6_V6ONLY` disabled (dual-stack), reject `connect()`. + wildcard: bool, + }, + #[cfg(unix)] + Unix { path: std::path::PathBuf }, +} + +impl TokioAddress { + async fn parse(text: &str, port_hint: u16) -> Result { + if let Some(path) = text.strip_prefix("unix:") { + #[cfg(unix)] + { + return Ok(Self { + spec: Spec::Unix { path: path.into() }, + }); + } + #[cfg(not(unix))] + { + let _ = path; + return Err(KjIoError::other( + "parseAddress", + "Unix domain sockets are not supported on this platform", + )); + } + } + if text.starts_with("unix-abstract:") { + return Err(KjIoError::other( + "parseAddress", + "abstract Unix domain sockets are not implemented by kj-rs-io", + )); + } + + // Split into address and port parts, exactly like KJ's SocketAddress::parse. + let (addr_part, port_part) = if let Some(rest) = text.strip_prefix('[') { + // Bracketed IPv6, optionally "[..]:port". + let close = rest.rfind(']').ok_or_else(|| { + KjIoError::other("parseAddress", format!("Unclosed '[' in address: {text}")) + })?; + let addr = &rest[..close]; + let tail = &rest[close + 1..]; + if tail.is_empty() { + (addr, None) + } else if let Some(port) = tail.strip_prefix(':') { + (addr, Some(port)) + } else { + return Err(KjIoError::other( + "parseAddress", + format!("Expected port suffix after ']': {text}"), + )); + } + } else if let Some(colon) = text.find(':') { + if text[colon + 1..].contains(':') { + // Two or more colons, no brackets: a bare IPv6 address with no port. + (text, None) + } else { + // Exactly one colon: ip4/hostname with port. + (&text[..colon], Some(&text[colon + 1..])) + } + } else { + (text, None) + }; + + let port = match port_part { + Some(port_text) => port_text.parse::().map_err(|_| { + // KJ falls back to getaddrinfo service-name resolution here; tokio's resolver + // only accepts numeric ports. + KjIoError::other( + "parseAddress", + format!("invalid port (named services are not supported): {port_text}"), + ) + })?, + None => port_hint, + }; + + if addr_part == "*" { + return Ok(Self { + spec: Spec::Ip { + addrs: vec![SocketAddr::new( + IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), + port, + )], + wildcard: true, + }, + }); + } + + if let Ok(ip) = addr_part.parse::() { + return Ok(Self { + spec: Spec::Ip { + addrs: vec![SocketAddr::new(ip, port)], + wildcard: false, + }, + }); + } + + // Not a literal: resolve the hostname via getaddrinfo. To honor kj-rs's single-thread + // axiom, the blocking getaddrinfo runs on tokio's blocking pool but its completion is + // absorbed by tokio's own scheduler (via a runtime task) and forwarded on the loop thread, + // so this await resumes same-thread (never cross-thread). See `resolve_host`. + let addrs: Vec = resolve_host(addr_part, port).await?; + if addrs.is_empty() { + return Err(KjIoError::other( + "getaddrinfo()", + format!("no addresses found for host: {addr_part}"), + )); + } + Ok(Self { + spec: Spec::Ip { + addrs, + wildcard: false, + }, + }) + } + + async fn connect_index(&self, index: usize) -> Result> { + match &self.spec { + Spec::Ip { addrs, wildcard } => { + if *wildcard { + return Err(KjIoError::other( + "connect()", + "cannot connect() to a wildcard address", + )); + } + let addr = addrs + .get(index) + .ok_or_else(|| KjIoError::other("connect()", "address index out of range"))?; + let stream = TcpStream::connect(addr).await.map_err(op("connect()"))?; + Ok(Box::new(TokioStream::from_tcp(stream))) + } + #[cfg(unix)] + Spec::Unix { path } => { + if index != 0 { + return Err(KjIoError::other("connect()", "address index out of range")); + } + let stream = UnixStream::connect(path).await.map_err(op("connect()"))?; + Ok(Box::new(TokioStream::from_unix(stream))) + } + } + } + + fn count(&self) -> usize { + match &self.spec { + Spec::Ip { addrs, .. } => addrs.len(), + #[cfg(unix)] + Spec::Unix { .. } => 1, + } + } + + fn raw_sockaddr(&self, index: usize) -> Result> { + let sockaddr: socket2::SockAddr = match &self.spec { + Spec::Ip { addrs, .. } => (*addrs + .get(index) + .ok_or_else(|| KjIoError::other("sockaddr", "address index out of range"))?) + .into(), + #[cfg(unix)] + Spec::Unix { path } => { + if index != 0 { + return Err(KjIoError::other("sockaddr", "address index out of range")); + } + socket2::SockAddr::unix(path).map_err(op("sockaddr"))? + } + }; + Ok(crate::ffi::sockaddr_to_bytes(&sockaddr)) + } + + fn listen(&self) -> Result> { + let handle = runtime_handle()?; + match &self.spec { + Spec::Ip { addrs, wildcard } => { + let addr = *addrs + .first() + .ok_or_else(|| KjIoError::other("listen()", "no addresses to bind"))?; + let domain = socket2::Domain::for_address(addr); + let socket = socket2::Socket::new(domain, socket2::Type::STREAM, None) + .map_err(op("socket()"))?; + // KJ parity: SO_REUSEADDR on listeners; wildcard sockets accept both address + // families (IPV6_V6ONLY off). + socket.set_reuse_address(true).map_err(op("setsockopt()"))?; + if *wildcard { + socket.set_only_v6(false).map_err(op("setsockopt()"))?; + } + socket.bind(&addr.into()).map_err(op("bind()"))?; + socket.listen(LISTEN_BACKLOG).map_err(op("listen()"))?; + socket.set_nonblocking(true).map_err(op("fcntl()"))?; + // Registering with the I/O driver requires the runtime context. + let _guard = handle.enter(); + let listener = TcpListener::from_std(socket.into()).map_err(op("wrap listener"))?; + Ok(Box::new(TokioListener { + inner: ListenerInner::Tcp(listener), + })) + } + #[cfg(unix)] + Spec::Unix { path } => { + // Like KJ, no unlink(): binding an existing path fails. + let listener = + std::os::unix::net::UnixListener::bind(path).map_err(op("bind()"))?; + listener.set_nonblocking(true).map_err(op("fcntl()"))?; + let _guard = handle.enter(); + let listener = UnixListener::from_std(listener).map_err(op("wrap listener"))?; + Ok(Box::new(TokioListener { + inner: ListenerInner::Unix(listener), + })) + } + } + } + + fn to_display_string(&self) -> String { + match &self.spec { + Spec::Ip { addrs, wildcard } => { + if *wildcard { + format!("*:{}", addrs[0].port()) + } else { + let parts: Vec = addrs.iter().map(ToString::to_string).collect(); + parts.join(",") + } + } + #[cfg(unix)] + Spec::Unix { path } => format!("unix:{}", path.display()), + } + } +} + +/// A listening socket (`kj::ConnectionReceiver` backend). +pub struct TokioListener { + inner: ListenerInner, +} + +enum ListenerInner { + Tcp(TcpListener), + #[cfg(unix)] + Unix(UnixListener), +} + +impl TokioListener { + async fn accept(&self) -> Result> { + match &self.inner { + ListenerInner::Tcp(listener) => { + let (stream, _peer) = listener.accept().await.map_err(op("accept()"))?; + let _ = stream.set_nodelay(true); + Ok(Box::new(TokioStream::from_tcp(stream))) + } + #[cfg(unix)] + ListenerInner::Unix(listener) => { + let (stream, _peer) = listener.accept().await.map_err(op("accept()"))?; + Ok(Box::new(TokioStream::from_unix(stream))) + } + } + } + + fn port(&self) -> Result { + match &self.inner { + ListenerInner::Tcp(listener) => { + Ok(listener.local_addr().map_err(op("getsockname()"))?.port()) + } + // KJ returns 0 for non-IP listeners. + #[cfg(unix)] + ListenerInner::Unix(_) => Ok(0), + } + } + + /// Borrows the live listener socket's fd (tokio listeners implement `AsFd`), for the + /// sockopt/sockname passthrough behind `kj::ConnectionReceiver`. + #[cfg(unix)] + pub(crate) fn as_borrowed_fd(&self) -> std::os::fd::BorrowedFd<'_> { + use std::os::fd::AsFd; + match &self.inner { + ListenerInner::Tcp(listener) => listener.as_fd(), + ListenerInner::Unix(listener) => listener.as_fd(), + } + } + + /// Borrows the live listener socket's `SOCKET` (tokio's `TcpListener` implements + /// `AsSocket`): the Windows counterpart of [`TokioListener::as_borrowed_fd`]. On Windows + /// only the Tcp variant of `ListenerInner` exists. + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + pub(crate) fn as_borrowed_socket(&self) -> std::os::windows::io::BorrowedSocket<'_> { + use std::os::windows::io::AsSocket; + match &self.inner { + ListenerInner::Tcp(listener) => listener.as_socket(), + } + } + + /// Raw `struct sockaddr` bytes of the listener's bound address (the `getsockname()` + /// passthrough behind `kj::ConnectionReceiver::getsockname`). + #[cfg(any(unix, windows))] + fn local_addr_bytes(&self) -> Result> { + #[cfg(unix)] + let sock = self.as_borrowed_fd(); + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + let sock = self.as_borrowed_socket(); + let addr = socket2::SockRef::from(&sock) + .local_addr() + .map_err(op("getsockname()"))?; + Ok(crate::ffi::sockaddr_to_bytes(&addr)) + } + + #[cfg(not(any(unix, windows)))] + fn local_addr_bytes(&self) -> Result> { + Err(KjIoError::other( + "getsockname", + "not implemented by kj-rs-io on this platform", + )) + } +} + +// ====================================================================================== +// Bridge entry points (see lib.rs). + +/// Resolves a hostname via blocking `getaddrinfo` on tokio's blocking pool — purely in Rust and +/// same-thread. A tokio *runtime* task owns the blocking `JoinHandle`, so the blocking-pool +/// completion wakes tokio's own (`Send + Sync`) scheduler waker — which unparks this loop — +/// rather than the bridged future's kj waker. The task then runs on the loop thread and hands the +/// result back over a oneshot, waking the awaiting future same-thread. No cross-thread rust waker +/// is involved (that is the whole reason we do not use `tokio::net::lookup_host`, whose +/// `JoinHandle` wake lands on the caller's waker cross-thread). Same blocking `getaddrinfo` call +/// and NSS/`/etc/hosts` parity as `lookup_host`. +async fn resolve_host(host: &str, port: u16) -> Result> { + let host = host.to_owned(); + let (tx, rx) = tokio::sync::oneshot::channel::>>(); + + // The runtime task's waker is tokio's own scheduler waker (Send + Sync), so the cross-thread + // completion from the blocking pool terminates inside tokio's scheduler (unparking this loop), + // never at a rust cross-thread waker. The task forwards the result on the loop thread, waking + // the awaiting future same-thread. + let task = tokio::spawn(async move { + let resolved = match tokio::task::spawn_blocking(move || { + (host.as_str(), port) + .to_socket_addrs() + .map(std::iter::Iterator::collect::>) + }) + .await + { + Ok(result) => result, + Err(_) => Err(std::io::Error::other("getaddrinfo task failed")), + }; + let _ = tx.send(resolved); + }); + // If this future is dropped (KJ promise cancelled), abort the forwarding task rather than + // leaving it to run to completion for nobody. The blocking getaddrinfo call itself cannot be + // interrupted once started (an OS limitation shared with KJ's own resolver and tokio's + // `lookup_host`), so its blocking-pool slot is reclaimed only when the syscall returns. + let _abort_guard = crate::runtime::AbortOnDrop(task); + + match rx.await { + Ok(result) => result.map_err(op("getaddrinfo()")), + Err(_) => Err(KjIoError::other( + "getaddrinfo()", + "DNS resolver task dropped", + )), + } +} + +pub async fn network_parse_address(addr: String, port_hint: u16) -> Result> { + with_runtime(async move { Ok(Box::new(TokioAddress::parse(&addr, port_hint).await?)) }).await +} + +pub fn network_get_sockaddr(sockaddr: &[u8]) -> Result> { + #[cfg(any(unix, windows))] + { + let addr = sockaddr_from_bytes(sockaddr)?; + if let Some(socket_addr) = addr.as_socket() { + return Ok(Box::new(TokioAddress { + spec: Spec::Ip { + addrs: vec![socket_addr], + wildcard: false, + }, + })); + } + #[cfg(unix)] + if let Some(path) = addr.as_pathname() { + return Ok(Box::new(TokioAddress { + spec: Spec::Unix { path: path.into() }, + })); + } + Err(KjIoError::other( + "getSockaddr", + "unsupported sockaddr family", + )) + } + #[cfg(not(any(unix, windows)))] + { + let _ = sockaddr; + Err(KjIoError::other( + "getSockaddr", + "not implemented on this platform", + )) + } +} + +/// Connects to exactly the `index`th resolved address (no fallback). The C++ side drives the +/// try-each-address loop itself so it can apply `restrictPeers()` filtering per address before +/// initiating each connection attempt (KJ parity: a blocked address contributes a +/// "`connect()` blocked by `restrictPeers()`" failure; only the last address's error propagates). +pub async fn address_connect_index(addr: &TokioAddress, index: usize) -> Result> { + with_runtime(addr.connect_index(index)).await +} + +/// Number of resolved socket addresses behind this address (>= 1). +pub fn address_count(addr: &TokioAddress) -> usize { + addr.count() +} + +/// Raw `struct sockaddr` bytes of the `index`th resolved address, for the C++ side's +/// `kj::_::NetworkFilter` (restrictPeers) checks. +pub fn address_raw_sockaddr(addr: &TokioAddress, index: usize) -> Result> { + addr.raw_sockaddr(index) +} + +pub fn address_listen(addr: &TokioAddress) -> Result> { + addr.listen() +} + +#[expect(clippy::unnecessary_box_returns)] // Opaque cxx types must cross the bridge boxed. +pub fn address_clone(addr: &TokioAddress) -> Box { + Box::new(TokioAddress { + spec: addr.spec.clone(), + }) +} + +pub fn address_to_string(addr: &TokioAddress) -> String { + addr.to_display_string() +} + +pub async fn listener_accept(listener: &TokioListener) -> Result> { + with_runtime(listener.accept()).await +} + +pub fn listener_port(listener: &TokioListener) -> Result { + listener.port() +} + +pub fn listener_local_addr(listener: &TokioListener) -> Result> { + listener.local_addr_bytes() +} + +// ====================================================================================== +// Socket-handle wrapping. All handles arrive owned and non-blocking as an `i64` "raw socket +// handle" — a Unix fd or a win32 SOCKET (the C++ side normalizes KJ's TAKE_OWNERSHIP / +// ALREADY_CLOEXEC / ALREADY_NONBLOCK flags, dup'ing when not taking ownership). The platform +// split lives entirely in `ffi::own_socket_from_raw` (the one conversion point); everything +// here operates on the uniform `socket2::Socket` / std / tokio types. + +#[cfg(any(unix, windows))] +fn socket_from_raw(handle: i64) -> socket2::Socket { + crate::ffi::own_socket_from_raw(handle) +} + +#[cfg(any(unix, windows))] +fn sockaddr_from_bytes(bytes: &[u8]) -> Result { + crate::ffi::sockaddr_from_bytes(bytes) +} + +/// The handle tier of [`crate::take_kj_socket`] (unix only; see that function's docs): wraps +/// an *owned*, connected stream-socket fd (TCP or Unix domain, detected automatically) as a +/// [`crate::serve::ServeIo`]. Unlike [`wrap_socket_fd`] the fd is a fresh dup of a kj stream's +/// socket, so non-blocking mode is forced rather than assumed (the original may have come from +/// anywhere). +#[cfg(unix)] +pub fn serve_io_from_owned_fd(fd: std::os::fd::OwnedFd) -> Result { + let socket = socket2::Socket::from(fd); + socket.set_nonblocking(true).map_err(op("fcntl()"))?; + let local = socket.local_addr().map_err(op("getsockname()"))?; + let _guard = runtime_handle()?.enter(); + match local.domain() { + socket2::Domain::IPV4 | socket2::Domain::IPV6 => { + let stream = TcpStream::from_std(socket.into()).map_err(op("takeKjSocket"))?; + Ok(crate::serve::ServeIo::Tcp(stream)) + } + socket2::Domain::UNIX => { + let stream = UnixStream::from_std(socket.into()).map_err(op("takeKjSocket"))?; + Ok(crate::serve::ServeIo::Unix(stream)) + } + _ => Err(KjIoError::other( + "takeKjSocket", + "unsupported socket family", + )), + } +} + +pub fn wrap_socket_fd(handle: i64) -> Result> { + #[cfg(any(unix, windows))] + { + let socket = socket_from_raw(handle); + let local = socket.local_addr().map_err(op("getsockname()"))?; + let _guard = runtime_handle()?.enter(); + match local.domain() { + socket2::Domain::IPV4 | socket2::Domain::IPV6 => { + let stream = TcpStream::from_std(socket.into()).map_err(op("wrapSocketFd"))?; + Ok(Box::new(TokioStream::from_tcp(stream))) + } + #[cfg(unix)] + socket2::Domain::UNIX => { + let stream = UnixStream::from_std(socket.into()).map_err(op("wrapSocketFd"))?; + Ok(Box::new(TokioStream::from_unix(stream))) + } + _ => Err(KjIoError::other( + "wrapSocketFd", + "unsupported socket family", + )), + } + } + #[cfg(not(any(unix, windows)))] + { + let _ = handle; + Err(KjIoError::other( + "wrapSocketFd", + "not implemented on this platform", + )) + } +} + +pub fn wrap_listen_fd(handle: i64) -> Result> { + #[cfg(any(unix, windows))] + { + let socket = socket_from_raw(handle); + let local = socket.local_addr().map_err(op("getsockname()"))?; + let _guard = runtime_handle()?.enter(); + match local.domain() { + socket2::Domain::IPV4 | socket2::Domain::IPV6 => { + let listener = + TcpListener::from_std(socket.into()).map_err(op("wrapListenSocketFd"))?; + Ok(Box::new(TokioListener { + inner: ListenerInner::Tcp(listener), + })) + } + #[cfg(unix)] + socket2::Domain::UNIX => { + let listener = + UnixListener::from_std(socket.into()).map_err(op("wrapListenSocketFd"))?; + Ok(Box::new(TokioListener { + inner: ListenerInner::Unix(listener), + })) + } + _ => Err(KjIoError::other( + "wrapListenSocketFd", + "unsupported socket family", + )), + } + } + #[cfg(not(any(unix, windows)))] + { + let _ = handle; + Err(KjIoError::other( + "wrapListenSocketFd", + "not implemented on this platform", + )) + } +} + +pub async fn wrap_connecting_socket_fd(handle: i64, sockaddr: Vec) -> Result> { + #[cfg(any(unix, windows))] + { + with_runtime(async move { + let addr = sockaddr_from_bytes(&sockaddr)?; + let socket_addr = addr.as_socket().ok_or_else(|| { + KjIoError::other( + "wrapConnectingSocketFd", + "only AF_INET/AF_INET6 sockaddrs are supported", + ) + })?; + let socket = socket_from_raw(handle); + // TcpSocket::connect handles the nonblocking connect dance (EINPROGRESS, wait for + // writability, check SO_ERROR) and registers with the I/O driver. + let tcp_socket = tokio::net::TcpSocket::from_std_stream(socket.into()); + let stream = tcp_socket + .connect(socket_addr) + .await + .map_err(op("connect()"))?; + Ok(Box::new(TokioStream::from_tcp(stream))) + }) + .await + } + #[cfg(not(any(unix, windows)))] + { + let _ = (handle, sockaddr); + Err(KjIoError::other( + "wrapConnectingSocketFd", + "not implemented on this platform", + )) + } +} diff --git a/src/rust/cxx/kj-rs-io/peer-filter.c++ b/src/rust/cxx/kj-rs-io/peer-filter.c++ new file mode 100644 index 00000000000..61a3ccce563 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/peer-filter.c++ @@ -0,0 +1,201 @@ +// Port of KJ's kj::_::NetworkFilter (kj/async-io.c++, MIT-licensed, Sandstorm Development +// Group and contributors) — see peer-filter.h for why this is a port rather than a reuse. +// Behavior must be kept in lockstep with upstream KJ. + +#include "kj-rs-io/peer-filter.h" + +#include + +#if _WIN32 +#include +#else +#include +#include +#include +#endif + +namespace kj_rs_io { +namespace { + +using kj::CidrRange; + +kj::ArrayPtr localCidrs() { + static const CidrRange result[] = { + // localhost + "127.0.0.0/8"_kj, + "::1/128"_kj, + + // Trying to *connect* to 0.0.0.0 on many systems is equivalent to connecting to + // localhost. (wat) + "0.0.0.0/32"_kj, + "::/128"_kj, + }; + return kj::arrayPtr(result, kj::size(result)); +} + +kj::ArrayPtr privateCidrs() { + static const CidrRange result[] = { + "10.0.0.0/8"_kj, // RFC1918 reserved for internal network + "100.64.0.0/10"_kj, // RFC6598 "shared address space" for carrier-grade NAT + "169.254.0.0/16"_kj, // RFC3927 "link local" (auto-configured LAN in absence of DHCP) + "172.16.0.0/12"_kj, // RFC1918 reserved for internal network + "192.168.0.0/16"_kj, // RFC1918 reserved for internal network + + "fc00::/7"_kj, // RFC4193 unique private network + "fe80::/10"_kj, // RFC4291 "link local" (auto-configured LAN in absence of DHCP) + }; + return kj::arrayPtr(result, kj::size(result)); +} + +kj::ArrayPtr reservedCidrs() { + // Address ranges reserved by RFCs for specific alternative protocols. These are not + // considered part of "public", "private", "network", nor "local". But, we will allow apps to + // explicitly allowlist CIDRs in this range if they really want, because some people actually + // use these ranges as if they were private ranges. + static const CidrRange result[] = { + "192.0.0.0/24"_kj, // RFC6890 reserved for special protocols + "224.0.0.0/4"_kj, // RFC1112 multicast + "240.0.0.0/4"_kj, // RFC1112 multicast / reserved for future use + "255.255.255.255/32"_kj, // RFC0919 broadcast address + + "2001::/23"_kj, // RFC2928 reserved for special protocols + "ff00::/8"_kj, // RFC4291 multicast + }; + return kj::arrayPtr(result, kj::size(result)); +} + +bool matchesAny(kj::ArrayPtr cidrs, const struct sockaddr *addr) { + for (auto &cidr: cidrs) { + if (cidr.matches(addr)) return true; + } + return false; +} + +#if !_WIN32 +// sockaddr_un::sun_path is not required to have a NUL terminator, so it must be read carefully. +kj::ArrayPtr safeUnixPath(const struct sockaddr_un *addr, kj::uint addrlen) { + KJ_REQUIRE(addr->sun_family == AF_UNIX, "not a unix address"); + KJ_REQUIRE(addrlen >= offsetof(sockaddr_un, sun_path), "invalid unix address"); + + size_t maxPathlen = addrlen - offsetof(sockaddr_un, sun_path); + + size_t pathlen; + if (maxPathlen > 0 && addr->sun_path[0] == '\0') { + // Linux "abstract" unix address + pathlen = strnlen(addr->sun_path + 1, maxPathlen - 1) + 1; + } else { + pathlen = strnlen(addr->sun_path, maxPathlen); + } + return kj::arrayPtr(addr->sun_path, pathlen); +} +#endif // !_WIN32 + +} // namespace + +PeerFilter::PeerFilter(): allowUnix(true), allowAbstractUnix(true) { + allowCidrs.add(CidrRange::inet4({0, 0, 0, 0}, 0)); + allowCidrs.add(CidrRange::inet6({}, {}, 0)); +} + +PeerFilter::PeerFilter(kj::ArrayPtr allow, + kj::ArrayPtr deny, + PeerFilter &next) + : allowUnix(false), + allowAbstractUnix(false), + next(next) { + for (auto rule: allow) { + if (rule == "local") { + allowCidrs.addAll(localCidrs()); + } else if (rule == "network") { + // Can't be represented as a simple union of CIDRs, so we handle in shouldAllow(). + allowNetwork = true; + } else if (rule == "private") { + allowCidrs.addAll(privateCidrs()); + allowCidrs.addAll(localCidrs()); + } else if (rule == "public") { + // Can't be represented as a simple union of CIDRs, so we handle in shouldAllow(). + allowPublic = true; + } else if (rule == "unix") { + allowUnix = true; + } else if (rule == "unix-abstract") { + allowAbstractUnix = true; + } else { + allowCidrs.add(CidrRange(rule)); + } + } + + for (auto rule: deny) { + if (rule == "local") { + denyCidrs.addAll(localCidrs()); + } else if (rule == "network") { + KJ_FAIL_REQUIRE("don't deny 'network', allow 'local' instead"); + } else if (rule == "private") { + denyCidrs.addAll(privateCidrs()); + } else if (rule == "public") { + // Tricky: What if we allow 'network' and deny 'public'? + KJ_FAIL_REQUIRE("don't deny 'public', allow 'private' instead"); + } else if (rule == "unix") { + allowUnix = false; + } else if (rule == "unix-abstract") { + allowAbstractUnix = false; + } else { + denyCidrs.add(CidrRange(rule)); + } + } +} + +bool PeerFilter::shouldAllow(const struct sockaddr *addr, kj::uint addrlen) { + KJ_REQUIRE(addrlen >= sizeof(addr->sa_family)); + +#if !_WIN32 + if (addr->sa_family == AF_UNIX) { + auto path = safeUnixPath(reinterpret_cast(addr), addrlen); + if (path.size() > 0 && path[0] == '\0') { + return allowAbstractUnix; + } else { + return allowUnix; + } + } +#endif + + bool allowed = false; + kj::uint allowSpecificity = 0; + + if (allowPublic) { + if ((addr->sa_family == AF_INET || addr->sa_family == AF_INET6) && + !matchesAny(privateCidrs(), addr) && !matchesAny(localCidrs(), addr) && + !matchesAny(reservedCidrs(), addr)) { + allowed = true; + // Don't adjust allowSpecificity as this match has an effective specificity of zero. + } + } + + if (allowNetwork) { + if ((addr->sa_family == AF_INET || addr->sa_family == AF_INET6) && + !matchesAny(localCidrs(), addr) && !matchesAny(reservedCidrs(), addr)) { + allowed = true; + // Don't adjust allowSpecificity as this match has an effective specificity of zero. + } + } + + for (auto &cidr: allowCidrs) { + if (cidr.matches(addr)) { + allowSpecificity = kj::max(allowSpecificity, cidr.getSpecificity()); + allowed = true; + } + } + if (!allowed) return false; + for (auto &cidr: denyCidrs) { + if (cidr.matches(addr)) { + if (cidr.getSpecificity() >= allowSpecificity) return false; + } + } + + KJ_IF_SOME(n, next) { + return n.shouldAllow(addr, addrlen); + } else { + return true; + } +} + +} // namespace kj_rs_io diff --git a/src/rust/cxx/kj-rs-io/peer-filter.h b/src/rust/cxx/kj-rs-io/peer-filter.h new file mode 100644 index 00000000000..9745acce2b3 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/peer-filter.h @@ -0,0 +1,54 @@ +#pragma once +// PeerFilter: a faithful port of KJ's kj::_::NetworkFilter (kj/async-io.c++), backing +// kj-rs-io's Network::restrictPeers() support. +// +// Ported rather than reused because kj::_::NetworkFilter lives in KJ's internal header +// (kj/async-io-internal.h), whose quoted includes ("vector.h") only resolve inside the KJ +// source tree — it is not includable through Bazel's virtual include dirs. The allow/deny +// grammar ("public"/"private"/"local"/"network"/"unix"/"unix-abstract"/CIDRs), the RFC CIDR +// tables, the specificity tie-breaking between allow and deny rules, and the filter-chaining +// semantics are kept identical so restrictPeers() behaves exactly like kj::setupAsyncIo()'s +// networks. kj::CidrRange itself IS reused (kj/cidr.h is a clean public header). + +#include +#include +#include + +namespace kj_rs_io { + +class PeerFilter final: public kj::LowLevelAsyncIoProvider::NetworkFilter { + public: + // Allow-everything filter (matches KJ's root networks). + PeerFilter(); + + // Restriction layered on `next` (which must outlive this filter). Grammar identical to + // kj::Network::restrictPeers(). + PeerFilter(kj::ArrayPtr allow, + kj::ArrayPtr deny, + PeerFilter &next); + + // Read-only despite the non-const signature: this override matches + // kj::LowLevelAsyncIoProvider::NetworkFilter::shouldAllow (declared non-const upstream), but the + // implementation only *reads* the CIDR tables / flags and recurses into `next` — it mutates no + // member and has no interior mutability, so concurrent callers sharing a filter are safe. Keep + // it read-only. + bool shouldAllow(const struct sockaddr *addr, kj::uint addrlen) override; + + // Immobile like KJ's own kj::_::NetworkFilter: a restricted filter's `next` points at a parent + // filter, and derived (restrictPeers) filters point back at this one, so a move would dangle the + // chain. Every instance is either an owning member of a heap-allocated network/provider or + // kj::heap(), so nothing moves one; this guards the latent hazard. + KJ_DISALLOW_COPY_AND_MOVE(PeerFilter); + + private: + kj::Vector allowCidrs; + kj::Vector denyCidrs; + bool allowUnix; + bool allowAbstractUnix; + bool allowPublic = false; + bool allowNetwork = false; + + kj::Maybe next; +}; + +} // namespace kj_rs_io diff --git a/src/rust/cxx/kj-rs-io/readiness.rs b/src/rust/cxx/kj-rs-io/readiness.rs new file mode 100644 index 00000000000..63b6202e67a --- /dev/null +++ b/src/rust/cxx/kj-rs-io/readiness.rs @@ -0,0 +1,49 @@ +//! tokio-backed fd readiness watching. +//! +//! This backs `kj_rs_io::FileWatcher` (file-watcher.h), the tokio-loop replacement for +//! workerd's `--watch` file watcher. The C++ side owns the platform notification fd (inotify on +//! Linux, kqueue on macOS/BSD) and does all the event parsing; the Rust side only supplies +//! "resolve when this fd becomes readable", replacing +//! `kj::UnixEventPort::FdObserver::whenBecomesReadable()`. +//! +//! Semantics: +//! +//! - The fd is registered with the tokio I/O driver per call (edge-triggered underneath, but +//! both epoll and kqueue report readiness that already exists at registration time, so events +//! queued on the fd before the call are not missed). +//! - Dropping the future deregisters the fd without consuming anything. +//! - The caller must keep the fd open until the future resolves or is dropped, and should not +//! have the same fd registered through this function twice concurrently (tokio's I/O driver +//! does not support duplicate registrations of one fd). + +use crate::error::Result; +use crate::runtime::with_runtime; + +/// Resolves when `fd` becomes readable. Unix only; errors immediately on other platforms. +pub async fn wait_fd_readable(fd: i32) -> Result<()> { + #[cfg(unix)] + { + use tokio::io::Interest; + use tokio::io::unix::AsyncFd; + + use crate::error::op; + with_runtime(async move { + let afd = AsyncFd::with_interest(fd, Interest::READABLE).map_err(op("AsyncFd"))?; + // The guard's readiness state is intentionally not cleared: the AsyncFd is + // deregistered immediately below (drop), and the next call re-registers, at which + // point still-pending readiness is reported again. + let _guard = afd.readable().await.map_err(op("readable"))?; + Ok(()) + }) + .await + } + #[cfg(not(unix))] + { + use crate::error::KjIoError; + let _ = fd; + Err(KjIoError::other( + "wait_fd_readable", + "kj-rs-io fd readiness watching is only implemented on Unix", + )) + } +} diff --git a/src/rust/cxx/kj-rs-io/runtime.rs b/src/rust/cxx/kj-rs-io/runtime.rs new file mode 100644 index 00000000000..914a51567ec --- /dev/null +++ b/src/rust/cxx/kj-rs-io/runtime.rs @@ -0,0 +1,50 @@ +//! Runtime-context plumbing: tokio I/O objects must be created (registered with the I/O driver) +//! from within a tokio runtime context, but kj-rs bridge futures are polled by the KJ event +//! loop, outside any `block_on`. These helpers enter this thread's `kj_rs_tokio` runtime context +//! around each poll / each synchronous operation. + +use std::future::Future; + +use tokio::runtime::Handle; + +use crate::error::KjIoError; +use crate::error::Result; + +/// Returns a handle to this thread's KJ-loop tokio runtime, or a `kj::Exception`-convertible +/// error if there is no `TokioEventPort` on this thread. +pub fn runtime_handle() -> Result { + kj_rs_tokio::current_handle().ok_or_else(|| { + KjIoError::other( + "kj_rs_io", + "no kj-rs-tokio runtime on this thread; kj-rs-io requires a TokioEventPort \ + (see kj_rs_io::setupTokioAsyncIo())", + ) + }) +} + +/// Aborts the wrapped tokio task when dropped. Used by the "runtime task forwards a result over +/// a oneshot" pattern (see `net.rs::resolve_host`): if the awaiting bridged future is dropped +/// (KJ promise cancelled), the forwarding task is aborted instead of lingering until its +/// underlying operation completes on its own. +pub struct AbortOnDrop(pub tokio::task::JoinHandle<()>); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// Runs `fut` with the current thread's KJ-loop runtime context entered around every poll, so +/// tokio resources created inside it can register with the runtime's I/O driver and timers. +pub async fn with_runtime(fut: impl Future>) -> Result { + let handle = runtime_handle()?; + // Pin the future on the stack, then poll it through `poll_fn` with the runtime guard held + // across each poll. This needs no manual pin-projection (`std::pin::pin!` gives a safe + // `Pin<&mut _>`), so the whole helper is safe. + let mut fut = std::pin::pin!(fut); + std::future::poll_fn(|cx| { + let _guard = handle.enter(); + fut.as_mut().poll(cx) + }) + .await +} diff --git a/src/rust/cxx/kj-rs-io/serve.rs b/src/rust/cxx/kj-rs-io/serve.rs new file mode 100644 index 00000000000..9b1897a3744 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/serve.rs @@ -0,0 +1,431 @@ +//! The native-serve entry points: give a Rust server (any tokio consumer) the +//! best-available tokio-side byte stream for an owned `kj::AsyncIoStream`. +//! +//! Three tiers, two entry points: +//! (1) kj-rs-io-originated streams give up their native tokio socket (the hollow +//! wrapper is destroyed); (2) [`take_kj_socket`] only — a foreign fd-backed stream's fd is +//! duplicated into a fresh tokio socket; (3) [`serve_kj_stream`] only — any other foreign +//! stream is bridged through an in-memory duplex plus a pump future that owns it. Ownership +//! arrives as a [`KjOwn`], so both entry points are safe functions: there is no +//! keep-it-alive caller contract, and the stream is destroyed by the tier that consumed it. +//! See the two entry points' docs for the remaining semantics, in particular why the fd tier +//! is caller-asserted and never automatic. +//! +//! # Pump semantics (matching the hand-built pumps this subsumes) +//! +//! - Bidirectional; each direction ends independently. +//! - Half-close propagates both ways: kj-side EOF shuts down the duplex write half (the tokio +//! consumer reads EOF); the consumer shutting down (or dropping) its duplex end results in +//! `shutdownWrite()` on the kj stream. +//! - Peer-teardown-shaped kj failures (DISCONNECTED reads/writes) are treated as normal EOF, +//! not errors — abrupt client disconnects are normal server load. +//! - Dropping the pump future cancels the in-flight bridged kj promises synchronously, drops +//! the kj-side duplex end (the tokio consumer observes EOF), and destroys the owned kj +//! stream — the peer observes teardown, not a zombie half-open connection (abort-on-drop). + +use std::future::Future; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; + +use cxx::KjError; +use cxx::KjException; +use cxx::KjExceptionType; +use kj_rs::KjOwn; +use tokio::io::AsyncRead; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWrite; +use tokio::io::AsyncWriteExt; +use tokio::io::DuplexStream; +use tokio::io::ReadBuf; +use tokio::net::TcpStream; +#[cfg(unix)] +use tokio::net::UnixStream; + +use crate::ffi::KjAsyncIoStream; +#[cfg(unix)] +use crate::ffi::dup_raw_fd; +#[cfg(unix)] +use crate::ffi::kj_stream_get_handle; +use crate::ffi::split_kj_stream; +use crate::ffi::unwrap_tokio_stream; + +/// Read chunk size for the pump fallback. +const PUMP_BUF: usize = 8192; + +/// In-memory buffer per direction of the pump's duplex (how far the two sides may run ahead +/// of each other before backpressure). +pub(crate) const DUPLEX_CAPACITY: usize = 4 * PUMP_BUF; + +/// Which transport path [`serve_kj_stream`] produced (perf observability: `Pumped` costs FFI +/// promise round-trips per buffer, `Native` costs none). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServePath { + /// A native tokio socket (unwrap fast path). + Native, + /// An in-memory duplex fed by the FFI stream pump. + Pumped, +} + +impl std::fmt::Display for ServePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Native => "native", + Self::Pumped => "pumped", + }) + } +} + +/// The tokio-side byte stream for a served kj stream: a native socket or the consumer end of +/// the pump's duplex. +/// +/// Implements `AsyncRead + AsyncWrite`, so it drops into any tokio +/// consumer -- but see [`ServedKjStream::io`] for the thread-affinity contract: only the +/// native variants may be driven off the KJ event-loop thread; `Duplex` is loop-thread-only. +pub enum ServeIo { + Tcp(TcpStream), + #[cfg(unix)] + Unix(UnixStream), + Duplex(DuplexStream), +} + +impl ServeIo { + /// Which transport path this stream is on (see [`ServePath`]). + #[must_use] + pub fn path(&self) -> ServePath { + match self { + Self::Tcp(_) => ServePath::Native, + #[cfg(unix)] + Self::Unix(_) => ServePath::Native, + Self::Duplex(_) => ServePath::Pumped, + } + } + + /// Sets `TCP_NODELAY` on the underlying socket where applicable (a no-op for Unix-domain + /// and duplex transports, which have no Nagle to disable). + /// + /// # Errors + /// + /// Returns the underlying `std::io::Error` if setting the socket option fails. + pub fn set_nodelay(&self, nodelay: bool) -> std::io::Result<()> { + match self { + Self::Tcp(s) => s.set_nodelay(nodelay), + #[cfg(unix)] + Self::Unix(_) => Ok(()), + Self::Duplex(_) => Ok(()), + } + } +} + +impl AsyncRead for ServeIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Tcp(s) => Pin::new(s).poll_read(cx, buf), + #[cfg(unix)] + Self::Unix(s) => Pin::new(s).poll_read(cx, buf), + Self::Duplex(s) => Pin::new(s).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for ServeIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Tcp(s) => Pin::new(s).poll_write(cx, buf), + #[cfg(unix)] + Self::Unix(s) => Pin::new(s).poll_write(cx, buf), + Self::Duplex(s) => Pin::new(s).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Tcp(s) => Pin::new(s).poll_flush(cx), + #[cfg(unix)] + Self::Unix(s) => Pin::new(s).poll_flush(cx), + Self::Duplex(s) => Pin::new(s).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Tcp(s) => Pin::new(s).poll_shutdown(cx), + #[cfg(unix)] + Self::Unix(s) => Pin::new(s).poll_shutdown(cx), + Self::Duplex(s) => Pin::new(s).poll_shutdown(cx), + } + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[std::io::IoSlice<'_>], + ) -> Poll> { + match self.get_mut() { + Self::Tcp(s) => Pin::new(s).poll_write_vectored(cx, bufs), + #[cfg(unix)] + Self::Unix(s) => Pin::new(s).poll_write_vectored(cx, bufs), + Self::Duplex(s) => Pin::new(s).poll_write_vectored(cx, bufs), + } + } + + fn is_write_vectored(&self) -> bool { + match self { + Self::Tcp(s) => s.is_write_vectored(), + #[cfg(unix)] + Self::Unix(s) => s.is_write_vectored(), + Self::Duplex(s) => s.is_write_vectored(), + } + } +} + +/// The KJ-side pump future of the fallback path. +/// +/// Not `Send`: it awaits bridged `kj::Promise`s and must be polled on the KJ event-loop thread +/// owning the stream. Resolves when both directions are done; dropping it aborts the +/// connection bridge (see the module docs). +pub type StreamPump = Pin>>>; + +/// The result of [`serve_kj_stream`]. +pub struct ServedKjStream { + /// The tokio-side stream. + /// + /// Thread affinity (load-bearing, not advisory): the NATIVE variants (`Tcp`/`Unix`) may be + /// handed to a connection task on any runtime -- they stay registered with the I/O driver + /// that created them (for kj-rs-io streams, this thread's KJ-loop runtime) and wake their + /// consumer via tokio's own `Send + Sync` task waker. The [`ServeIo::Duplex`] variant must + /// be driven ONLY on the KJ event-loop thread: its peer end lives inside `pump`, which is + /// polled as a bridged future, so the waker parked in the duplex's internal waker slots is + /// a `kj_rs` `FutureWakerCell` clone -- non-atomic and loop-thread-only by design. A + /// read/write/drop of the duplex from any other thread wakes that cell cross-thread: a data + /// race on its refcount plus a cross-thread `Event::armDepthFirst()` on the KJ event loop + /// (undefined behavior, not merely a logic error). The type is `Send` solely for the native + /// variants' sake; check [`ServedKjStream::path`] before moving it to another thread. + pub io: ServeIo, + /// Present iff `io` is [`ServeIo::Duplex`]: the pump that actually moves the bytes, owning + /// the kj stream it bridges. The caller must poll it on the KJ event-loop thread until it + /// settles or is dropped; dropping it destroys the stream (see the module docs). + pub pump: Option, +} + +impl ServedKjStream { + /// Which transport path was taken (see [`ServePath`]). + #[must_use] + pub fn path(&self) -> ServePath { + self.io.path() + } +} + +// ======================================================================================= +// Entry points + +/// [`take_kj_socket`]'s error: the failure, plus the untouched stream handed back so the +/// caller can fall back to [`serve_kj_stream`]'s pump tier (or destroy it). +pub struct TakeSocketError { + /// The stream `take_kj_socket` consumed, returned untouched. + pub stream: KjOwn, + /// Why the socket could not be taken natively. + pub error: KjError, +} + +impl std::fmt::Debug for TakeSocketError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TakeSocketError") + .field("error", &self.error) + .finish_non_exhaustive() + } +} + +impl std::fmt::Display for TakeSocketError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.error) + } +} + +/// Drops the handed-back stream (on the current — KJ event-loop — thread) and keeps the error. +impl From for KjError { + fn from(e: TakeSocketError) -> Self { + e.error + } +} + +/// Tier-1 unwrap: if the owned stream is kj-rs-io-originated, moves its native tokio socket +/// out (leaving the C++ wrapper hollow) and returns it. `None` for a foreign stream. +fn unwrap_native(stream: &mut KjOwn) -> Option { + unwrap_tokio_stream(stream.as_mut()) + .ok() + .and_then(|native| native.into_serve_io()) +} + +/// Takes the stream's socket natively (tiers 1 + 2). +/// +/// Tier 1 moves the native tokio object out of a kj-rs-io stream; tier 2 — unix only — +/// duplicates the stream's OS fd (`F_DUPFD_CLOEXEC`, forced non-blocking) into a fresh tokio +/// socket. Either way the result is an owned, KJ-independent [`ServeIo`] (never +/// [`ServeIo::Duplex`]) and the consumed kj stream is destroyed before returning. +/// +/// The handle tier (tier 2) is `cfg(unix)`: under the all-rust mode on Windows every +/// socket-backed stream originates in kj-rs-io, so tier 1 always applies and a windows dup arm +/// would be dead code (see `dup_raw_fd`'s docs for the analysis and the +/// `BorrowedSocket::try_clone_to_owned` escape hatch should that ever change). +/// +/// The caller asserts the stream is a **plain stream socket**: if it exposes an fd, that fd +/// carries the stream's own bytes. Byte-transforming wrappers (TLS — `kj::TlsConnection` +/// forwards `getFd()` to the ciphertext transport socket) violate this and must go through +/// [`serve_kj_stream`] instead. As with destroying any kj stream, no I/O promises may be +/// outstanding on it when ownership is handed over. +/// +/// # Errors +/// +/// Errors when the stream is neither kj-rs-io native nor fd-backed (in-memory pipes, promised +/// streams, non-Unix platforms' foreign streams): such transports can only be served through +/// [`serve_kj_stream`]'s pump tier — the error hands the stream back for exactly that +/// fallback. Also errors if the dup or tokio registration fails. +pub fn take_kj_socket( + stream: KjOwn, +) -> std::result::Result { + let mut stream = stream; + if let Some(io) = unwrap_native(&mut stream) { + // Tier 1: the wrapper is hollow; destroy it now. + drop(stream); + return Ok(io); + } + #[cfg(unix)] + { + let handle = kj_stream_get_handle(&stream); + if handle >= 0 { + // A unix fd is a non-negative int, widened losslessly to i64 by the bridge. + #[expect(clippy::cast_possible_truncation)] + let fd = handle as i32; + let result = dup_raw_fd(fd) + .map_err(KjError::from) + .and_then(|owned| crate::net::serve_io_from_owned_fd(owned).map_err(KjError::from)); + return match result { + Ok(io) => { + // Tier 2: the dup is independent; the original stream (and its fd) can go. + drop(stream); + Ok(io) + } + Err(error) => Err(TakeSocketError { stream, error }), + }; + } + } + Err(TakeSocketError { + stream, + error: KjError::new( + KjExceptionType::Failed, + "cannot take the stream's socket natively: not a kj-rs-io stream and no underlying \ + OS fd (foreign non-socket transport; serve it through serve_kj_stream's pump instead)" + .to_owned(), + ), + }) +} + +/// Yields the best-available tokio-side stream for the owned `stream`. +/// +/// That is the native tokio object when `stream` originated in kj-rs-io, else an in-memory +/// duplex bridged by a pump future that owns the stream. Never extracts a foreign stream's fd +/// — kj wrappers forward `getFd()` to their transport socket, so a byte-transforming wrapper's +/// fd carries the wrong bytes (TLS ciphertext); callers that can assert a plain socket should +/// prefer [`take_kj_socket`]. +/// +/// On the native path the hollow wrapper is destroyed before returning; on the pump path the +/// stream lives inside the pump and is destroyed when the pump settles or is dropped. The pump +/// must only be polled from the KJ event-loop thread owning the stream. As with destroying any +/// kj stream, no I/O promises may be outstanding on it when ownership is handed over. +#[must_use] +pub fn serve_kj_stream(stream: KjOwn) -> ServedKjStream { + let mut stream = stream; + if let Some(io) = unwrap_native(&mut stream) { + // Native path: the wrapper is hollow; destroy it now. + drop(stream); + return ServedKjStream { io, pump: None }; + } + + // Foreign stream (or, pathologically, an already-hollow wrapper, whose pump reads will + // surface the "already unwrapped" error): bridge through a duplex pump owning the stream. + let (consumer_end, kj_end) = tokio::io::duplex(DUPLEX_CAPACITY); + let pump = Box::pin(pump_kj_stream(stream, kj_end)); + ServedKjStream { + io: ServeIo::Duplex(consumer_end), + pump: Some(pump), + } +} + +/// Whether a bridged kj exception is peer-teardown-shaped (treated as EOF by the pump). +fn is_disconnected(exception: &KjException) -> bool { + exception.r#type() == KjExceptionType::Disconnected +} + +/// The duplex pump: bridges the owned `stream` (via bridged `kj::io` promises, on the calling +/// KJ thread) to `kj_end`, the pump-side end of the consumer's duplex. Owns the stream: it is +/// destroyed when this future settles or is dropped. +/// +/// Unsafe-free and compiler-checked end to end: the stream is split into typed read/write +/// halves ([`split_kj_stream`]), so the borrow checker enforces kj's stream contract — at most +/// one read and one write in flight, nothing else touching the stream while the halves live — +/// and proves the owner outlives every in-flight bridged promise. +pub(crate) async fn pump_kj_stream( + mut stream: KjOwn, + kj_end: DuplexStream, +) -> Result<(), KjError> { + let (mut rd, mut wr) = split_kj_stream(&mut stream); + let (mut from_consumer, mut to_consumer) = tokio::io::split(kj_end); + + // kj stream -> consumer. Ends (shutting down the duplex write half, i.e. EOF to the + // consumer) at kj-side EOF — or when the peer disconnects abruptly (DISCONNECTED read + // failures are normal client behavior, treated as EOF). + let kj_to_consumer = async { + let mut buf = vec![0u8; PUMP_BUF]; + loop { + let n = match rd.try_read(&mut buf, 1).await { + Ok(n) => n, + Err(e) if is_disconnected(&e) => 0, + Err(e) => return Err(KjError::from(e)), + }; + if n == 0 { + let _ = to_consumer.shutdown().await; + return Ok::<(), KjError>(()); + } + if to_consumer.write_all(&buf[..n]).await.is_err() { + // The consumer dropped its duplex end: the connection was abandoned + // deliberately; nothing more to deliver in this direction. + return Ok(()); + } + } + }; + + // consumer -> kj stream. Ends (with a kj-side shutdownWrite) when the consumer shuts + // down or drops its end — or, without an error, when the kj peer already went away (a + // DISCONNECTED write failure: the consumer's remaining output has nowhere to go). + let consumer_to_kj = async { + let mut buf = vec![0u8; PUMP_BUF]; + loop { + let n = match from_consumer.read(&mut buf).await { + // Duplex reads only fail if the consumer end vanished ungracefully; either + // way this direction is over. + Ok(0) | Err(_) => 0, + Ok(n) => n, + }; + if n == 0 { + wr.shutdown_write(); + return Ok::<(), KjError>(()); + } + match wr.write(&buf[..n]).await { + Ok(()) => {} + Err(e) if is_disconnected(&e) => return Ok(()), + Err(e) => return Err(KjError::from(e)), + } + } + }; + + tokio::try_join!(kj_to_consumer, consumer_to_kj).map(|((), ())| ()) +} diff --git a/src/rust/cxx/kj-rs-io/signal.rs b/src/rust/cxx/kj-rs-io/signal.rs new file mode 100644 index 00000000000..f4169dccaa7 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/signal.rs @@ -0,0 +1,144 @@ +//! tokio-backed signal watching: POSIX signals on Unix, the corresponding console control +//! events on Windows. +//! +//! This backs `kj_rs_io::onSignal()` (async-io.h), the tokio-loop replacement for +//! `kj::UnixEventPort::onSignal()` -- workerd uses it for SIGTERM graceful drain. +//! +//! Semantics differences vs `UnixEventPort::onSignal()` (acceptable for the drain use case): +//! +//! - No `siginfo_t` is reported; the promise just resolves. +//! - The handler is registered when the returned future is first polled (tokio registers with +//! the process-global signal registry at `signal()` time), not at call time, and KJ does not +//! block the signal beforehand the way `UnixEventPort::captureSignal()` does. A signal +//! delivered before the first poll takes its default disposition. +//! - tokio's signal registration is process-wide and persists for the life of the process +//! (dropping the future stops *watching*, but does not restore `SIG_DFL`). +//! +//! Both arms route the `recv()` through a tokio runtime task rather than awaiting the stream +//! from the bridged future: tokio's signal registry is process-global and its broadcast can run +//! on a different thread (another runtime's loop thread on unix, the console-ctrl thread on +//! Windows), which must never wake a bridged loop-thread-only waker directly. See the comments +//! in each arm. +//! +//! On Windows the signums workerd actually passes are mapped to their conventional console +//! control events: SIGTERM -> `ctrl_shutdown`, SIGINT -> `ctrl_c`. Anything else errors. + +use crate::error::KjIoError; +use crate::error::Result; +use crate::runtime::with_runtime; + +/// Resolves when the process receives signal `signum` (on Windows: the console control event +/// conventionally mapped to it). Errors immediately for unmapped signums / other platforms. +pub async fn wait_for_signal(signum: i32) -> Result<()> { + #[cfg(unix)] + { + use crate::error::op; + with_runtime(async move { + let kind = tokio::signal::unix::SignalKind::from_raw(signum); + // Create the stream here (inside the runtime context, at first poll of the bridged + // future) so the process-global handler registration happens as early as possible. + let mut sig = tokio::signal::unix::signal(kind).map_err(op("signal"))?; + + // Do NOT `sig.recv().await` directly from this (bridged) future. tokio's signal + // registry is process-global: when several tokio runtimes exist in the process (one + // per KJ event loop thread — e.g. workerd's main loop plus the inspector thread's + // loop), the runtime whose driver consumes the signal's wake byte performs the + // broadcast, so the stored waker can be woken FROM THAT OTHER THREAD. A directly + // parked waker here would be the bridged future's loop-thread-only, non-atomic + // `kj_rs` `FutureWakerCell`: waking it cross-thread is UB under the bridge's + // single-thread waker axiom, and in practice loses the wakeup (observed as workerd + // ignoring SIGTERM whenever the inspector thread's runtime won the race). So, like + // `net.rs::resolve_host` (the model citizen for this pattern), a tokio *runtime* + // task owns the `recv()`: the cross-thread broadcast terminates at tokio's own + // `Send + Sync` scheduler waker (which unparks this loop), the task then runs on the + // loop thread and hands the result back over a oneshot, waking the bridged future + // same-thread. + let (tx, rx) = tokio::sync::oneshot::channel::>(); + let task = tokio::spawn(async move { + let result = sig + .recv() + .await + .ok_or_else(|| KjIoError::other("signal", "signal stream closed unexpectedly")); + let _ = tx.send(result); + }); + // If this future is dropped (KJ promise cancelled), abort the watcher task so its + // signal-stream registration is torn down instead of lingering for the process + // lifetime. + let _abort_guard = crate::runtime::AbortOnDrop(task); + match rx.await { + Ok(result) => result, + Err(_) => Err(KjIoError::other("signal", "signal watcher task dropped")), + } + }) + .await + } + // Validated by Windows CI. + // + // Same forwarding-task pattern as the unix arm, for the Windows flavor of the same hazard: + // tokio's `SetConsoleCtrlHandler` handler runs on an OS-spawned console-ctrl thread and + // broadcasts to every registered watcher's stored waker FROM THAT THREAD. Awaiting the + // stream directly here would park a clone of the bridged future's waker -- a + // loop-thread-only, non-atomic `kj_rs` `FutureWakerCell` -- in tokio's signal registry, and + // one Ctrl-C/shutdown event would wake it cross-thread: UB under the bridge's single-thread + // waker axiom. The tokio *runtime* task owning the `recv()` terminates the cross-thread + // broadcast at tokio's own `Send + Sync` scheduler waker (which unparks this loop); the task + // then runs on the loop thread and hands the result back over a oneshot, waking the bridged + // future same-thread. + #[cfg(windows)] + { + use crate::error::op; + // `` values as the C++ callers pass them (MSVC defines SIGINT=2, SIGTERM=15). + // workerd's only caller passes SIGTERM (graceful drain; server/cli-io-backend.c++); + // SIGINT is mapped for completeness. + const SIGINT: i32 = 2; + const SIGTERM: i32 = 15; + with_runtime(async move { + if signum != SIGTERM && signum != SIGINT { + return Err(KjIoError::other( + "signal", + "kj-rs-io only watches SIGTERM/SIGINT on Windows", + )); + } + let (tx, rx) = tokio::sync::oneshot::channel::>(); + let task = tokio::spawn(async move { + let result = async { + // SIGTERM -> ctrl_shutdown, SIGINT -> ctrl_c (the conventional mappings). + let received = match signum { + SIGTERM => { + let mut sig = + tokio::signal::windows::ctrl_shutdown().map_err(op("signal"))?; + sig.recv().await + } + // SIGINT; anything else already errored before the spawn. + _ => { + let mut sig = tokio::signal::windows::ctrl_c().map_err(op("signal"))?; + sig.recv().await + } + }; + received.ok_or_else(|| { + KjIoError::other("signal", "signal stream closed unexpectedly") + }) + } + .await; + let _ = tx.send(result); + }); + // If this future is dropped (KJ promise cancelled), abort the watcher task so its + // signal-stream registration is torn down instead of lingering for the process + // lifetime. + let _abort_guard = crate::runtime::AbortOnDrop(task); + match rx.await { + Ok(result) => result, + Err(_) => Err(KjIoError::other("signal", "signal watcher task dropped")), + } + }) + .await + } + #[cfg(not(any(unix, windows)))] + { + let _ = signum; + Err(KjIoError::other( + "signal", + "kj-rs-io signal watching is not implemented on this platform", + )) + } +} diff --git a/src/rust/cxx/kj-rs-io/stream.rs b/src/rust/cxx/kj-rs-io/stream.rs new file mode 100644 index 00000000000..77aa99b30ba --- /dev/null +++ b/src/rust/cxx/kj-rs-io/stream.rs @@ -0,0 +1,559 @@ +//! Tokio-backed byte streams behind KJ's stream interfaces. +//! +//! `TokioStream` (TCP or Unix domain) implements the `kj::AsyncIoStream` operations; all I/O +//! uses tokio's `&self` readiness API (`ready()` + `try_read`/`try_write`), which supports +//! concurrent reads and writes on one stream and is cancel-safe: dropping a pending future +//! (i.e. dropping the wrapping `kj::Promise`) merely deregisters the waker, releasing the +//! readiness interest so the stream can be reused or dropped sanely. + +use std::io::Read; +use std::io::Write; + +use tokio::io::Interest; +use tokio::net::TcpStream; +#[cfg(unix)] +use tokio::net::UnixStream; + +use crate::error::KjIoError; +use crate::error::Result; +use crate::error::op; +use crate::runtime::with_runtime; + +/// A native tokio stream (the "unwrap fast path" object). +/// +/// C++ holds one of these inside every kj-rs-io `kj::AsyncIoStream`; Rust code can take it +/// back out via [`crate::unwrap_kj_stream`] and drive the connection natively. +pub struct TokioStream { + /// `None` after the native stream has been moved out by [`TokioStream::take`] (the C++ + /// wrapper is then "hollow" and every operation fails). + inner: Option, +} + +enum Inner { + Tcp(TcpStream), + #[cfg(unix)] + Unix(UnixStream), +} + +impl TokioStream { + #[must_use] + pub fn from_tcp(stream: TcpStream) -> Self { + Self { + inner: Some(Inner::Tcp(stream)), + } + } + + #[cfg(unix)] + #[must_use] + pub fn from_unix(stream: UnixStream) -> Self { + Self { + inner: Some(Inner::Unix(stream)), + } + } + + /// Recovers the native tokio TCP stream, if this is a (non-hollow) TCP stream. + #[must_use] + pub fn into_tcp_stream(self) -> Option { + match self.inner { + Some(Inner::Tcp(stream)) => Some(stream), + _ => None, + } + } + + /// Recovers the native tokio Unix-domain stream, if this is one. + #[cfg(unix)] + #[must_use] + pub fn into_unix_stream(self) -> Option { + match self.inner { + Some(Inner::Unix(stream)) => Some(stream), + _ => None, + } + } + + fn inner(&self) -> Result<&Inner> { + self.inner + .as_ref() + .ok_or_else(|| KjIoError::other("kj_rs_io", "stream was unwrapped (hollow wrapper)")) + } + + async fn ready(&self, interest: Interest) -> Result<()> { + match self.inner()? { + Inner::Tcp(s) => s.ready(interest).await, + #[cfg(unix)] + Inner::Unix(s) => s.ready(interest).await, + } + .map_err(op("poll()"))?; + Ok(()) + } + + #[expect( + clippy::expect_used, + reason = "only called from try_read_min/write_all, which call self.inner()? first, so `inner` is Some here; None occurs only for a hollow (unwrapped) wrapper, which is never read" + )] + fn try_read(&self, buf: &mut [u8]) -> std::io::Result { + match self.inner.as_ref().expect("checked by caller") { + Inner::Tcp(s) => s.try_read(buf), + #[cfg(unix)] + Inner::Unix(s) => s.try_read(buf), + } + } + + #[expect( + clippy::expect_used, + reason = "only called from write_all, which calls self.inner()? first, so `inner` is Some here; None occurs only for a hollow (unwrapped) wrapper, which is never written" + )] + fn try_write(&self, buf: &[u8]) -> std::io::Result { + match self.inner.as_ref().expect("checked by caller") { + Inner::Tcp(s) => s.try_write(buf), + #[cfg(unix)] + Inner::Unix(s) => s.try_write(buf), + } + } + + /// KJ `tryRead` semantics: loop until at least `min_bytes` (or EOF), up to `buf.len()`. + async fn try_read_min(&self, buf: &mut [u8], min_bytes: usize) -> Result { + self.inner()?; + let min_bytes = min_bytes.min(buf.len()); + let mut total = 0; + while total < min_bytes { + match self.try_read(&mut buf[total..]) { + Ok(0) => break, // EOF: return what we have (< min_bytes signals EOF to KJ). + Ok(n) => total += n, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + self.ready(Interest::READABLE).await?; + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(op("read()")(e)), + } + } + Ok(total) + } + + /// Write-all semantics. + async fn write_all(&self, buf: &[u8]) -> Result<()> { + self.inner()?; + let mut written = 0; + while written < buf.len() { + match self.try_write(&buf[written..]) { + Ok(0) => { + // try_write on a socket signals "would block" via Err(WouldBlock), so a + // zero-byte result for a non-empty buffer means the connection is gone. + return Err(KjIoError::other( + "write()", + "wrote zero bytes (connection closed)", + )); + } + Ok(n) => written += n, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + self.ready(Interest::WRITABLE).await?; + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(op("write()")(e)), + } + } + Ok(()) + } + + /// Resolves once new writes are doomed to fail (peer reset / hangup observed). + /// + /// tokio has no direct primitive for this, so we register a *duplicate* of the socket fd + /// with the I/O driver for WRITABLE interest and wait — explicitly clearing plain-writable + /// readiness — until the OS reports write-closed (kqueue: `EV_EOF` on the write filter; + /// epoll: `EPOLLHUP`/`EPOLLERR`) or an error. Like KJ's own implementation this does *not* + /// fire on a mere half-close (peer FIN / `EPOLLRDHUP`): reads hitting EOF must not count as + /// write-disconnect. + /// + /// Windows behavior (see the arm below): a never-resolving future, which IS KJ + /// parity — KJ's *current* Windows behavior (`whenWriteDisconnected` returns `NEVER_DONE`). + /// Win32 has no documented primitive for detecting disconnect without a read/write; KJ's own + /// TODO points at the undocumented-but-stable `IOCTL_AFD_POLL` ioctl (capnproto + /// `async-io-win32.c++:289` — the mechanism `select()` itself is built on). An AFD-poll + /// implementation remains an optional upgrade over this documented parity behavior. + #[cfg(unix)] + async fn when_write_disconnected(&self) -> Result<()> { + use std::os::fd::AsRawFd; + + // Borrow the live socket's fd directly (tokio streams implement `AsFd`) and dup it, so + // no raw fd is ever materialized without an owner. + let borrowed = self.as_borrowed_fd()?; + let owned = borrowed.try_clone_to_owned().map_err(op("dup()"))?; + debug_assert_ne!(owned.as_raw_fd(), borrowed.as_raw_fd()); + // The dup shares the underlying open socket (and its O_NONBLOCK status), but has its own + // registration with the I/O driver, so clearing readiness here never disturbs reads or + // writes on the primary registration. + let async_fd = tokio::io::unix::AsyncFd::with_interest(owned, Interest::WRITABLE) + .map_err(op("whenWriteDisconnected"))?; + loop { + let mut guard = async_fd + .ready(Interest::WRITABLE) + .await + .map_err(op("whenWriteDisconnected"))?; + let ready = guard.ready(); + if ready.is_write_closed() || ready.is_error() { + return Ok(()); + } + // Plain "writable": clear it so the next wait sleeps until an actual state-change + // event (edge-triggered), rather than spinning on an always-writable socket. + guard.clear_ready(); + } + } + + /// Never resolves: KJ parity, not a gap — capnproto's win32 `whenWriteDisconnected` returns + /// `NEVER_DONE` today (its `IOCTL_AFD_POLL` idea is only a TODO; see the Windows-behavior note + /// on the unix arm above). Validated by Windows CI. + #[cfg(windows)] + async fn when_write_disconnected(&self) -> Result<()> { + self.inner()?; + std::future::pending::<()>().await; + unreachable!() + } + + #[cfg(not(any(unix, windows)))] + async fn when_write_disconnected(&self) -> Result<()> { + self.inner()?; + Err(KjIoError::other( + "whenWriteDisconnected", + "not implemented by kj-rs-io on this platform", + )) + } + + fn shutdown_write(&self) -> Result<()> { + #[cfg(unix)] + { + let dup = self + .as_borrowed_fd()? + .try_clone_to_owned() + .map_err(op("dup()"))?; + // shutdown() acts on the socket itself, so performing it through a dup'd fd + // affects the shared socket, and dropping the dup only closes the duplicate. + let result = match self.inner()? { + Inner::Tcp(_) => std::net::TcpStream::from(dup).shutdown(std::net::Shutdown::Write), + Inner::Unix(_) => { + std::os::unix::net::UnixStream::from(dup).shutdown(std::net::Shutdown::Write) + } + }; + result.map_err(op("shutdown(SHUT_WR)")) + } + // Validated by Windows CI; mirrors the unix arm. No dup: `with_sock_ref` borrows the + // live socket (`SockRef`), and winsock `shutdown` acts on the underlying socket either + // way — the unix arm dups only because std's `shutdown` is a method on owning types, + // whereas the windows `BorrowedSocket::try_clone_to_owned` equivalent + // (WSADuplicateSocketW) would be strictly heavier than the borrow. + #[cfg(windows)] + { + self.with_sock_ref("shutdown(SD_SEND)", |sock| { + sock.shutdown(std::net::Shutdown::Write) + }) + } + #[cfg(not(any(unix, windows)))] + { + Err(KjIoError::other( + "shutdownWrite", + "not implemented on this platform", + )) + } + } + + /// Borrows the live tokio socket's fd (tokio streams implement `AsFd`), for dup-based + /// operations that must not conjure a raw fd out of an integer. Errs if the wrapper is + /// hollow. + #[cfg(unix)] + pub(crate) fn as_borrowed_fd(&self) -> Result> { + use std::os::fd::AsFd; + Ok(match self.inner()? { + Inner::Tcp(s) => s.as_fd(), + Inner::Unix(s) => s.as_fd(), + }) + } + + /// Borrows the live tokio socket's `SOCKET` (tokio's `TcpStream` implements `AsSocket`): + /// the Windows counterpart of [`TokioStream::as_borrowed_fd`]. Errs if the wrapper is + /// hollow. On Windows only the Tcp variant of `Inner` exists. + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + pub(crate) fn as_borrowed_socket(&self) -> Result> { + use std::os::windows::io::AsSocket; + Ok(match self.inner()? { + Inner::Tcp(s) => s.as_socket(), + }) + } + + /// Runs `f` on a `socket2::SockRef` borrowing the live socket — the shared body of the + /// `getsockname()`/`getpeername()` passthroughs; only the socket borrow is per-platform. + fn with_sock_ref( + &self, + op_name: &'static str, + f: impl FnOnce(&socket2::SockRef<'_>) -> std::io::Result, + ) -> Result { + #[cfg(unix)] + { + let fd = self.as_borrowed_fd()?; + f(&socket2::SockRef::from(&fd)).map_err(op(op_name)) + } + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + { + let sock = self.as_borrowed_socket()?; + f(&socket2::SockRef::from(&sock)).map_err(op(op_name)) + } + #[cfg(not(any(unix, windows)))] + { + let _ = f; + self.inner()?; + Err(KjIoError::other( + op_name, + "not implemented by kj-rs-io on this platform", + )) + } + } + + /// Raw `struct sockaddr` bytes of the socket's locally-bound address (the `getsockname()` + /// passthrough behind `kj::AsyncIoStream::getsockname`). + fn local_addr_bytes(&self) -> Result> { + let addr = self.with_sock_ref("getsockname()", |sock| sock.local_addr())?; + Ok(crate::ffi::sockaddr_to_bytes(&addr)) + } + + /// Raw `struct sockaddr` bytes of the connected peer's address (the `getpeername()` + /// passthrough behind `kj::AsyncIoStream::getpeername` and the accept-loop peer-filter + /// check). + fn peer_addr_bytes(&self) -> Result> { + let addr = self.with_sock_ref("getpeername()", |sock| sock.peer_addr())?; + Ok(crate::ffi::sockaddr_to_bytes(&addr)) + } + + /// The underlying raw OS socket handle, widened to `i64`: a Unix fd + /// (`kj::AsyncIoStream::getFd()`) or a win32 `SOCKET` (`getWin32Handle()`). + fn raw_handle(&self) -> Result { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + Ok(i64::from(match self.inner()? { + Inner::Tcp(s) => s.as_raw_fd(), + Inner::Unix(s) => s.as_raw_fd(), + })) + } + // Validated by Windows CI; mirrors the unix arm. + #[cfg(windows)] + { + use std::os::windows::io::AsRawSocket; + let raw = match self.inner()? { + Inner::Tcp(s) => s.as_raw_socket(), + }; + // A live SOCKET fits in i64 (Windows handles fit in 32 bits); the bridge carries + // its bits verbatim. + #[allow(clippy::cast_possible_wrap)] + Ok(raw as i64) + } + #[cfg(not(any(unix, windows)))] + { + self.inner()?; + Err(KjIoError::other( + "getFd", + "file descriptors are not available on this platform", + )) + } + } + + /// Recovers whichever native tokio object this is, as a [`crate::serve::ServeIo`] + /// (the unwrap fast path of [`crate::serve_kj_stream`]). `None` if hollow. + pub(crate) fn into_serve_io(self) -> Option { + match self.inner? { + Inner::Tcp(stream) => Some(crate::serve::ServeIo::Tcp(stream)), + #[cfg(unix)] + Inner::Unix(stream) => Some(crate::serve::ServeIo::Unix(stream)), + } + } + + fn take(&mut self) -> Result> { + let inner = self.inner.take().ok_or_else(|| { + KjIoError::other("kj_rs_io", "stream was already unwrapped (hollow wrapper)") + })?; + Ok(Box::new(Self { inner: Some(inner) })) + } +} + +// ====================================================================================== +// Bridge entry points (see lib.rs). Every async fn wraps its body in `with_runtime` so tokio +// resources created while polling on the KJ thread can reach the loop runtime's I/O driver. + +pub async fn stream_try_read( + stream: &TokioStream, + buf: &mut [u8], + min_bytes: usize, +) -> Result { + with_runtime(stream.try_read_min(buf, min_bytes)).await +} + +pub async fn stream_write(stream: &TokioStream, buf: &[u8]) -> Result<()> { + with_runtime(stream.write_all(buf)).await +} + +pub async fn stream_when_write_disconnected(stream: &TokioStream) -> Result<()> { + with_runtime(stream.when_write_disconnected()).await +} + +pub fn stream_shutdown_write(stream: &TokioStream) -> Result<()> { + stream.shutdown_write() +} + +pub fn stream_raw_handle(stream: &TokioStream) -> Result { + stream.raw_handle() +} + +pub fn stream_local_addr(stream: &TokioStream) -> Result> { + stream.local_addr_bytes() +} + +pub fn stream_peer_addr(stream: &TokioStream) -> Result> { + stream.peer_addr_bytes() +} + +pub fn stream_take(stream: &mut TokioStream) -> Result> { + stream.take() +} + +// ====================================================================================== +// Arbitrary readable/writable fds (kj::LowLevelAsyncIoProvider::wrapInputFd/wrapOutputFd). +// Unix only: implemented over AsyncFd, which supports pipes, character devices and sockets +// (regular files are rejected by epoll/kqueue, matching KJ's fd-observer-based provider). +// Deliberately no windows arm: kj's win32 LowLevelAsyncIoProvider has no pipe-fd tier — its +// `Fd` is documented as a SOCKET (capnproto async-io.h) and its wrapInputFd/wrapOutputFd are +// implemented identically to wrapSocketFd (async-io-win32.c++) — so the C++ side +// (async-io.c++) routes win32 wrapInputFd/wrapOutputFd through the tested socket path +// (`wrap_socket_fd`) and never calls these entry points there; the `not(unix)` arms below are +// totality backstops only. + +#[cfg(unix)] +type FdIo = tokio::io::unix::AsyncFd; + +pub struct TokioInputFd { + #[cfg(unix)] + inner: FdIo, +} + +pub struct TokioOutputFd { + #[cfg(unix)] + inner: FdIo, +} + +#[cfg(unix)] +fn fd_io_from_raw(fd: i32, interest: Interest) -> Result { + let owned = crate::ffi::own_fd_from_raw(fd); + let _guard = crate::runtime::runtime_handle()?.enter(); + tokio::io::unix::AsyncFd::with_interest(std::fs::File::from(owned), interest) + .map_err(op("wrapFd")) +} + +pub fn wrap_input_fd(fd: i32) -> Result> { + #[cfg(unix)] + { + Ok(Box::new(TokioInputFd { + inner: fd_io_from_raw(fd, Interest::READABLE)?, + })) + } + #[cfg(not(unix))] + { + let _ = fd; + Err(KjIoError::other( + "wrapInputFd", + "not implemented on this platform", + )) + } +} + +pub fn wrap_output_fd(fd: i32) -> Result> { + #[cfg(unix)] + { + Ok(Box::new(TokioOutputFd { + inner: fd_io_from_raw(fd, Interest::WRITABLE)?, + })) + } + #[cfg(not(unix))] + { + let _ = fd; + Err(KjIoError::other( + "wrapOutputFd", + "not implemented on this platform", + )) + } +} + +pub async fn input_fd_try_read( + stream: &TokioInputFd, + buf: &mut [u8], + min_bytes: usize, +) -> Result { + #[cfg(unix)] + { + with_runtime(async move { + let min_bytes = min_bytes.min(buf.len()); + let mut total = 0; + while total < min_bytes { + let mut guard = stream + .inner + .ready(Interest::READABLE) + .await + .map_err(op("poll()"))?; + match guard.try_io(|inner| { + let mut file: &std::fs::File = inner.get_ref(); + file.read(&mut buf[total..]) + }) { + Ok(Ok(0)) => break, // EOF + Ok(Ok(n)) => total += n, + Ok(Err(e)) if e.kind() == std::io::ErrorKind::Interrupted => {} + Ok(Err(e)) => return Err(op("read()")(e)), + Err(_would_block) => {} + } + } + Ok(total) + }) + .await + } + #[cfg(not(unix))] + { + let _ = (stream, buf, min_bytes); + Err(KjIoError::other( + "read()", + "not implemented on this platform", + )) + } +} + +pub async fn output_fd_write(stream: &TokioOutputFd, buf: &[u8]) -> Result<()> { + #[cfg(unix)] + { + with_runtime(async move { + let mut written = 0; + while written < buf.len() { + let mut guard = stream + .inner + .ready(Interest::WRITABLE) + .await + .map_err(op("poll()"))?; + match guard.try_io(|inner| { + let mut file: &std::fs::File = inner.get_ref(); + file.write(&buf[written..]) + }) { + Ok(Ok(0)) => { + return Err(KjIoError::other("write()", "wrote zero bytes")); + } + Ok(Ok(n)) => written += n, + Ok(Err(e)) if e.kind() == std::io::ErrorKind::Interrupted => {} + Ok(Err(e)) => return Err(op("write()")(e)), + Err(_would_block) => {} + } + } + Ok(()) + }) + .await + } + #[cfg(not(unix))] + { + let _ = (stream, buf); + Err(KjIoError::other( + "write()", + "not implemented on this platform", + )) + } +} diff --git a/src/rust/cxx/kj-rs-io/tests/BUILD.bazel b/src/rust/cxx/kj-rs-io/tests/BUILD.bazel new file mode 100644 index 00000000000..a12f68d05ce --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/BUILD.bazel @@ -0,0 +1,138 @@ +load("@rules_cc//cc:cc_test.bzl", "cc_test") +load("@rules_rust//rust:defs.bzl", "rust_library") +load("//src/rust/cxx/tools/bazel:rust_cxx_bridge.bzl", "rust_cxx_bridge") + +rust_library( + name = "tests", + srcs = glob(["*.rs"]), + edition = "2024", + target_compatible_with = select({ + "@//build/config:no_build": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + deps = [ + ":bridge", + "//src/rust/cxx", + "//src/rust/cxx/kj-rs", + "//src/rust/cxx/kj-rs-io", + "//src/rust/cxx/kj-rs-tokio", + "@crates_vendor//:bytes", + "@crates_vendor//:tokio", + ], +) + +rust_cxx_bridge( + name = "bridge", + src = "lib.rs", + include_prefix = "kj-rs-io-test", + deps = [ + "//src/rust/cxx/kj-rs", + "//src/rust/cxx/kj-rs-io:bridge", + ], +) + +cc_test( + name = "async-io-test", + # medium: expectConnectFailure carries a 30 s diagnostic bound and a 60 s watchdog for a + # Windows CI wedge; small's 60 s budget would race the watchdog. + size = "medium", + srcs = ["async-io-test.c++"], + linkstatic = select({ + "@platforms//os:windows": True, + "//conditions:default": False, + }), + target_compatible_with = select({ + "@//build/config:no_build": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + deps = [ + ":bridge", + ":tests", + "//src/rust/cxx/kj-rs-io:kj-rs-io-lib", + "//src/rust/cxx/third-party:runtime", + "@capnp-cpp//src/kj:kj-test", + ], +) + +cc_test( + name = "file-watcher-test", + size = "small", + srcs = ["file-watcher-test.c++"], + linkstatic = select({ + "@platforms//os:windows": True, + "//conditions:default": False, + }), + target_compatible_with = select({ + "@//build/config:no_build": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + deps = [ + ":tests", + "//src/rust/cxx/kj-rs-io:kj-rs-io-lib", + "//src/rust/cxx/third-party:runtime", + "@capnp-cpp//src/kj:kj-test", + ], +) + +cc_test( + name = "http-test", + size = "small", + srcs = ["http-test.c++"], + linkstatic = select({ + "@platforms//os:windows": True, + "//conditions:default": False, + }), + target_compatible_with = select({ + "@//build/config:no_build": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + deps = [ + ":tests", + "//src/rust/cxx/kj-rs-io:kj-rs-io-lib", + "//src/rust/cxx/third-party:runtime", + "@capnp-cpp//src/kj:kj-test", + "@capnp-cpp//src/kj/compat:kj-http", + ], +) + +cc_test( + name = "serve-test", + size = "small", + srcs = ["serve-test.c++"], + linkstatic = select({ + "@platforms//os:windows": True, + "//conditions:default": False, + }), + target_compatible_with = select({ + "@//build/config:no_build": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + deps = [ + ":bridge", + ":tests", + "//src/rust/cxx/kj-rs-io:kj-rs-io-lib", + "//src/rust/cxx/third-party:runtime", + "@capnp-cpp//src/kj:kj-test", + ], +) + +cc_test( + name = "capnp-rpc-test", + size = "small", + srcs = ["capnp-rpc-test.c++"], + linkstatic = select({ + "@platforms//os:windows": True, + "//conditions:default": False, + }), + target_compatible_with = select({ + "@//build/config:no_build": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + deps = [ + ":tests", + "//src/rust/cxx/kj-rs-io:kj-rs-io-lib", + "//src/rust/cxx/third-party:runtime", + "@capnp-cpp//src/capnp:capnp-rpc", + "@capnp-cpp//src/kj:kj-test", + ], +) diff --git a/src/rust/cxx/kj-rs-io/tests/async-io-test.c++ b/src/rust/cxx/kj-rs-io/tests/async-io-test.c++ new file mode 100644 index 00000000000..f3b96c50a53 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/async-io-test.c++ @@ -0,0 +1,801 @@ +// Tests for kj-rs-io: tokio-backed implementations of KJ's async I/O interfaces, driven by a +// kj::EventLoop on a TokioEventPort. Following kj-rs conventions, C++ KJ_TESTs drive; Rust +// helpers (tests/lib.rs) provide the native-side behaviors (unwrap fast path, pre-bound fds). + +#include "kj-rs-io-test/lib.rs.h" +#include "kj-rs-io/async-io.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#if _WIN32 +#include // GetProcessTimes, for the wedge watchdog below. + +// After windows.h: un-breaks macros it leaks over KJ's, notably ERROR (which otherwise breaks +// the KJ_LOG(ERROR, ...) inside KJ_FAIL_* expansions). +#include +#else +#include +#include +#include +#include +#include +#include +#endif + +namespace kj_rs_io_test { +namespace { + +using kj_rs_io::setupTokioAsyncIo; +using kj_rs_io::TokioAsyncIoContext; + +// ======================================================================================= +// Helpers + +struct ConnectedPair { + kj::Own listener; + kj::Own client; + kj::Own server; +}; + +kj::Own parseNow( + TokioAsyncIoContext &io, kj::StringPtr addr, kj::uint portHint = 0) { + return io.getNetwork().parseAddress(addr, portHint).wait(io.getWaitScope()); +} + +ConnectedPair makeTcpPair(TokioAsyncIoContext &io) { + auto &ws = io.getWaitScope(); + auto listener = parseNow(io, "127.0.0.1")->listen(); + auto connectAddr = parseNow(io, kj::str("127.0.0.1:", listener->getPort())); + auto acceptPromise = listener->accept(); + auto client = connectAddr->connect().wait(ws); + auto server = acceptPromise.wait(ws); + return ConnectedPair{kj::mv(listener), kj::mv(client), kj::mv(server)}; +} + +kj::Array makePatternedData(size_t size, kj::byte seed) { + auto data = kj::heapArray(size); + for (size_t i = 0; i < size; i++) { + data[i] = static_cast((i * 31 + seed) & 0xff); + } + return data; +} + +::rust::Slice toRust(kj::ArrayPtr data) { + return ::rust::Slice(data.begin(), data.size()); +} + +::rust::Vec toRustVec(kj::ArrayPtr data) { + ::rust::Vec vec; + vec.reserve(data.size()); + for (auto b: data) { + vec.push_back(b); + } + return vec; +} + +// Writes `data` to `out` in chunks (exercising write-all + backpressure). +kj::Promise pumpOut(kj::AsyncIoStream &out, kj::ArrayPtr data) { + constexpr size_t CHUNK = 64 * 1024; + size_t offset = 0; + while (offset < data.size()) { + size_t n = kj::min(CHUNK, data.size() - offset); + co_await out.write(data.slice(offset, offset + n)); + offset += n; + } +} + +// Reads exactly `expected.size()` bytes from `in` and verifies they match `expected`. +kj::Promise drainAndCheck(kj::AsyncIoStream &in, kj::ArrayPtr expected) { + auto buffer = kj::heapArray(expected.size()); + size_t total = co_await in.tryRead(buffer.begin(), buffer.size(), buffer.size()); + KJ_ASSERT(total == expected.size(), total, expected.size()); + KJ_ASSERT(memcmp(buffer.begin(), expected.begin(), expected.size()) == 0); +} + +// CPU seconds consumed by this process so far. Used by the wedge watchdog to distinguish a +// spinning event loop from one that is parked and never woken. +double processCpuSeconds() { +#if _WIN32 + // clock() is WALL time on Windows (CRT quirk), so use GetProcessTimes. + FILETIME creationTime, exitTime, kernelTime, userTime; + GetProcessTimes(GetCurrentProcess(), &creationTime, &exitTime, &kernelTime, &userTime); + auto toSeconds = [](const FILETIME &ft) { + return static_cast( + (static_cast(ft.dwHighDateTime) << 32) | ft.dwLowDateTime) * + 1e-7; + }; + return toSeconds(kernelTime) + toSeconds(userTime); +#else + return static_cast(clock()) / CLOCKS_PER_SEC; +#endif +} + +// Waits for `connectPromise` (a connect to a certainly-closed port) to fail and returns the +// exception. Instrumented for a wedge observed (flakily) on Windows CI, where such a connect +// neither succeeded nor failed and a bare wait() silently ate the whole binary's bazel timeout: +// +// - A 30 s KJ timer bounds the wait, so a lost connect-readiness wake fails the test with a +// message instead. (A pending timer also makes the event port park with a timeout rather than +// indefinitely, so if the wedge is a lost wake on an indefinite park, the timer tick itself +// recovers it -- the run then passes, which is a data point too: the one CI run carrying this +// bound passed while both runs with a bare wait() timed out.) +// - A watchdog thread aborts after 60 s in case the loop stops servicing even timers, reporting +// process CPU use to distinguish a spinning loop (high) from one parked without wakeups (~0). +kj::Exception expectConnectFailure( + TokioAsyncIoContext &io, kj::Promise> connectPromise) { + std::atomic done{false}; + std::thread watchdog([&done]() { + double cpuBefore = processCpuSeconds(); + for (int i = 0; i < 600; i++) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (done.load()) return; + } + double cpuUsed = processCpuSeconds() - cpuBefore; + fprintf(stderr, + "expectConnectFailure watchdog: the event loop serviced neither the connect nor the 30s " + "timer for 60s; the process used %.1f CPU-seconds meanwhile (high = loop spinning, ~0 = " + "parked and never woken). Aborting rather than eating the bazel timeout.\n", + cpuUsed); + fflush(stderr); + abort(); + }); + KJ_DEFER({ + done.store(true); + watchdog.join(); + }); + + auto timeout = + io.getTimer().afterDelay(30 * kj::SECONDS).then([]() -> kj::Own { + KJ_FAIL_ASSERT("connect() to a closed port neither succeeded nor failed within 30s; " + "the connect-failure readiness wake was likely lost"); + }); + return KJ_ASSERT_NONNULL(kj::runCatchingExceptions([&]() { + connectPromise.exclusiveJoin(kj::mv(timeout)).wait(io.getWaitScope()); + }), + "connect() to a closed port unexpectedly succeeded"); +} + +// ======================================================================================= +// Stream contract + +KJ_TEST("tryRead waits for minBytes, then returns what is available up to " + "maxBytes") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + kj::byte buffer[16]; + + // Exactly-min: 3 bytes written, min 3 -> resolves with 3. + pair.client->write("abc"_kjb).wait(ws); + KJ_EXPECT(pair.server->tryRead(buffer, 3, sizeof(buffer)).wait(ws) == 3); + KJ_EXPECT(kj::ArrayPtr(buffer, 3) == "abc"_kjb); + + // Blocks until minBytes: 2 available < min 5 -> pending; 3 more arrive -> resolves with 5. + pair.client->write("de"_kjb).wait(ws); + auto readPromise = pair.server->tryRead(buffer, 5, sizeof(buffer)); + KJ_EXPECT(!readPromise.poll(ws)); + pair.client->write("fgh"_kjb).wait(ws); + KJ_EXPECT(readPromise.wait(ws) == 5); + KJ_EXPECT(kj::ArrayPtr(buffer, 5) == "defgh"_kjb); +} + +KJ_TEST("EOF before minBytes returns a short count; half-close keeps the other " + "direction usable") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + pair.client->write("ab"_kjb).wait(ws); + pair.client->shutdownWrite(); + + // EOF-before-min: only 2 bytes then FIN -> tryRead(min 5) resolves with 2. + kj::byte buffer[16]; + KJ_EXPECT(pair.server->tryRead(buffer, 5, sizeof(buffer)).wait(ws) == 2); + KJ_EXPECT(kj::ArrayPtr(buffer, 2) == "ab"_kjb); + // Subsequent reads keep reporting EOF. + KJ_EXPECT(pair.server->tryRead(buffer, 1, sizeof(buffer)).wait(ws) == 0); + + // Half-close: server -> client direction still works after client's shutdownWrite. + pair.server->write("reply"_kjb).wait(ws); + KJ_EXPECT(pair.client->tryRead(buffer, 5, sizeof(buffer)).wait(ws) == 5); + KJ_EXPECT(kj::ArrayPtr(buffer, 5) == "reply"_kjb); +} + +KJ_TEST("multi-megabyte transfers in both directions with concurrent read+write " + "per stream") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + constexpr size_t SIZE = 8 * 1024 * 1024; + auto dataA = makePatternedData(SIZE, 1); + auto dataB = makePatternedData(SIZE, 2); + + // All four directions at once: each stream is simultaneously reading and writing, and each + // transfer is far larger than the socket buffers (forcing many readiness round-trips). + auto builder = kj::heapArrayBuilder>(4); + builder.add(pumpOut(*pair.client, dataA)); + builder.add(drainAndCheck(*pair.server, dataA)); + builder.add(pumpOut(*pair.server, dataB)); + builder.add(drainAndCheck(*pair.client, dataB)); + kj::joinPromisesFailFast(builder.finish()).wait(ws); +} + +KJ_TEST("multi-piece write() writes all pieces in order") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + const kj::ArrayPtr pieces[] = {"one,"_kjb, "two,"_kjb, "three"_kjb}; + pair.client->write(kj::arrayPtr(pieces, 3)).wait(ws); + + kj::byte buffer[32]; + KJ_EXPECT(pair.server->tryRead(buffer, 13, sizeof(buffer)).wait(ws) == 13); + KJ_EXPECT(kj::ArrayPtr(buffer, 13) == "one,two,three"_kjb); +} + +KJ_TEST("canceling a blocked read releases the socket for reuse") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + kj::byte buffer[16]; + { + // A read blocked in tokio (registered with the I/O driver, no data available)... + auto blocked = pair.server->tryRead(buffer, 1, sizeof(buffer)); + KJ_EXPECT(!blocked.poll(ws)); + // ...is canceled by dropping the promise, which must drop the Rust future and release the + // read interest. + } + + // The stream remains fully usable: a fresh read gets the next bytes. + pair.client->write("later"_kjb).wait(ws); + KJ_EXPECT(pair.server->tryRead(buffer, 5, sizeof(buffer)).wait(ws) == 5); + KJ_EXPECT(kj::ArrayPtr(buffer, 5) == "later"_kjb); + + // Canceling mid-large-write also leaves the process sane (bytes may be lost, like KJ). + { + auto data = makePatternedData(16 * 1024 * 1024, 7); + auto bigWrite = pair.client->write(data); + if (bigWrite.poll(ws)) { + bigWrite.wait(ws); + } + } +} + +#if !_WIN32 +KJ_TEST("whenWriteDisconnected resolves on peer reset, not on half-close") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + auto disconnected = pair.client->whenWriteDisconnected(); + KJ_EXPECT(!disconnected.poll(ws)); + + // A peer half-close (FIN) must NOT count as write-disconnect: the client can still write. + pair.server->shutdownWrite(); + kj::byte buffer[16]; + KJ_EXPECT(pair.client->tryRead(buffer, 1, sizeof(buffer)).wait(ws) == 0); // observe EOF + KJ_EXPECT(!disconnected.poll(ws)); + + // Destroying the server end with SO_LINGER=0 sends an RST; now writes are doomed. + struct linger lin; + lin.l_onoff = 1; + lin.l_linger = 0; + pair.server->setsockopt(SOL_SOCKET, SO_LINGER, &lin, sizeof(lin)); + pair.server = nullptr; + + disconnected.wait(ws); +} +#endif + +KJ_TEST("acceptAuthenticated reports the TCP peer's NetworkPeerIdentity") { + // workerd's HTTP listener builds the cf blob's clientIp (-> the CF-Connecting-IP header) from + // this identity; UnknownPeerIdentity (kj's base-class default) silently yields an empty + // client IP. + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + auto listener = parseNow(io, "127.0.0.1")->listen(); + auto acceptPromise = listener->acceptAuthenticated(); + auto client = parseNow(io, kj::str("127.0.0.1:", listener->getPort()))->connect().wait(ws); + auto server = acceptPromise.wait(ws); + + auto &identity = + KJ_ASSERT_NONNULL(kj::tryDowncast(*server.peerIdentity)); + // KJ's "ip:port" format, byte-identical to the native backend. + auto text = identity.toString(); + KJ_EXPECT(text.startsWith("127.0.0.1:"), text); +} + +#if !_WIN32 +KJ_TEST("acceptAuthenticated reports LocalPeerIdentity credentials on unix sockets") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + // /tmp rather than TEST_TMPDIR: sun_path is limited to ~104 bytes. + auto path = kj::str("/tmp/kj-rs-io-auth-test-", getpid(), ".sock"); + auto addr = parseNow(io, kj::str("unix:", path)); + + auto listener = addr->listen(); + auto acceptPromise = listener->acceptAuthenticated(); + auto client = addr->connect().wait(ws); + auto server = acceptPromise.wait(ws); + + auto &identity = KJ_ASSERT_NONNULL(kj::tryDowncast(*server.peerIdentity)); + auto creds = identity.getCredentials(); + // The peer is this very process. + KJ_EXPECT(KJ_ASSERT_NONNULL(creds.pid) == getpid()); + KJ_EXPECT(KJ_ASSERT_NONNULL(creds.uid) == getuid()); + + unlink(path.cStr()); +} +#endif + +KJ_TEST("sockname/peername/sockopt/getFd passthrough") { +#if _WIN32 + return; +#else + auto io = setupTokioAsyncIo(); + auto pair = makeTcpPair(io); + + // getFd is populated. + KJ_EXPECT(KJ_ASSERT_NONNULL(pair.client->getFd()) >= 0); + + // The client's peer is the server's local socket. + struct sockaddr_in peer, local; + kj::uint peerLen = sizeof(peer), localLen = sizeof(local); + pair.client->getpeername(reinterpret_cast(&peer), &peerLen); + pair.server->getsockname(reinterpret_cast(&local), &localLen); + KJ_EXPECT(peer.sin_port == local.sin_port); + KJ_EXPECT(peer.sin_addr.s_addr == local.sin_addr.s_addr); + + // setsockopt/getsockopt round-trip (this is also how setNoDelay-style options are applied). + int on = 1; + pair.client->setsockopt(IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)); + int result = 0; + kj::uint resultLen = sizeof(result); + pair.client->getsockopt(IPPROTO_TCP, TCP_NODELAY, &result, &resultLen); + KJ_EXPECT(result != 0); +#endif +} + +// ======================================================================================= +// Network / addresses + +KJ_TEST("parseAddress handles IP literals, port hints, and toString round-trips") { + auto io = setupTokioAsyncIo(); + + KJ_EXPECT(parseNow(io, "1.2.3.4:80")->toString() == "1.2.3.4:80"); + KJ_EXPECT(parseNow(io, "1.2.3.4", 99)->toString() == "1.2.3.4:99"); + KJ_EXPECT(parseNow(io, "[1234:5678::abcd]:80")->toString() == "[1234:5678::abcd]:80"); + KJ_EXPECT(parseNow(io, "1234:5678::abcd", 80)->toString() == "[1234:5678::abcd]:80"); + KJ_EXPECT(parseNow(io, "*:80")->toString() == "*:80"); + KJ_EXPECT(parseNow(io, "*")->toString() == "*:0"); + + // clone() produces an equivalent address. + auto addr = parseNow(io, "127.0.0.1:1234"); + KJ_EXPECT(addr->clone()->toString() == addr->toString()); +} + +KJ_TEST("wildcard listen binds dual-stack and reports its port; port 0 picks a " + "free port") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + auto listener = parseNow(io, "*:0")->listen(); + kj::uint port = listener->getPort(); + KJ_EXPECT(port != 0); + + // Reachable over both IPv4 and IPv6 loopback (IPV6_V6ONLY off, like KJ). + kj::String addrTexts[] = {kj::str("127.0.0.1:", port), kj::str("[::1]:", port)}; + for (auto &addrText: addrTexts) { + auto acceptPromise = listener->accept(); + auto client = parseNow(io, addrText)->connect().wait(ws); + auto server = acceptPromise.wait(ws); + client->write("ping"_kjb).wait(ws); + kj::byte buffer[4]; + KJ_EXPECT(server->tryRead(buffer, 4, sizeof(buffer)).wait(ws) == 4); + } +} + +KJ_TEST("parseAddress resolves hostnames via DNS and connect tries addresses in " + "order") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + // Listen on IPv4 loopback only. "localhost" typically resolves to both ::1 and 127.0.0.1; + // connect() must try each in order until one succeeds. + auto listener = parseNow(io, "127.0.0.1")->listen(); + auto addr = parseNow(io, kj::str("localhost:", listener->getPort())); + + auto acceptPromise = listener->accept(); + auto client = addr->connect().wait(ws); + auto server = acceptPromise.wait(ws); + client->write("dns!"_kjb).wait(ws); + kj::byte buffer[4]; + KJ_EXPECT(server->tryRead(buffer, 4, sizeof(buffer)).wait(ws) == 4); + KJ_EXPECT(kj::ArrayPtr(buffer, 4) == "dns!"_kjb); +} + +KJ_TEST("connect to a closed port surfaces a DISCONNECTED kj::Exception " + "mentioning the refusal") { + auto io = setupTokioAsyncIo(); + + // Find a port that is certainly closed: bind one, note it, close it. + kj::uint port; + { + auto listener = parseNow(io, "127.0.0.1")->listen(); + port = listener->getPort(); + } + + auto addr = parseNow(io, kj::str("127.0.0.1:", port)); + auto exception = expectConnectFailure(io, addr->connect()); + // Exact text (recorded): "connect(): Connection refused (os error 61)" on macOS / + // "... (os error 111)" on Linux. KJ's native text would be "connect(): Connection refused". + KJ_EXPECT( + strstr(exception.getDescription().cStr(), "refused") != nullptr, exception.getDescription()); + KJ_EXPECT(exception.getType() == kj::Exception::Type::DISCONNECTED); +} + +KJ_TEST("the address may be dropped while connect() is pending (KJ lifetime " + "contract)") { + // Upstream KJ heap-copies the resolved address list into the connect promise + // (NetworkAddressImpl::connect() in kj/async-io-unix.c++), so callers may legally drop + // the kj::NetworkAddress right after calling connect(). Verify this port honors the same + // contract, on both the success path and the error/retry path. + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + // Success path: drop the address immediately, then complete the connect. + { + auto listener = parseNow(io, "127.0.0.1")->listen(); + auto acceptPromise = listener->accept(); + + kj::Promise> connectPromise = nullptr; + { + auto addr = parseNow(io, kj::str("127.0.0.1:", listener->getPort())); + connectPromise = addr->connect(); + // `addr` is destroyed here, while the connect is still in flight. + } + + auto client = connectPromise.wait(ws); + auto server = acceptPromise.wait(ws); + client->write("hello"_kjb).wait(ws); + kj::byte buffer[5] = {}; + KJ_EXPECT(server->tryRead(buffer, 5, 5).wait(ws) == 5); + KJ_EXPECT(kj::arrayPtr(buffer, 5) == "hello"_kjb); + } + + // Error path: connect to a certainly-closed port with the address already dropped; the + // failure continuation (which re-reads the address list) must still be safe and surface + // the normal exception. + { + kj::uint port; + { + auto listener = parseNow(io, "127.0.0.1")->listen(); + port = listener->getPort(); + } + + kj::Promise> connectPromise = nullptr; + { + auto addr = parseNow(io, kj::str("127.0.0.1:", port)); + connectPromise = addr->connect(); + } + + auto exception = expectConnectFailure(io, kj::mv(connectPromise)); + KJ_EXPECT(strstr(exception.getDescription().cStr(), "refused") != nullptr, + exception.getDescription()); + KJ_EXPECT(exception.getType() == kj::Exception::Type::DISCONNECTED); + } +} + +KJ_TEST("connecting to a wildcard address is an error") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto addr = parseNow(io, "*:1234"); + KJ_EXPECT_THROW_MESSAGE("wildcard", addr->connect().wait(ws)); +} + +#if !_WIN32 +KJ_TEST("unix domain sockets: parse, listen, connect, toString") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + // Note: /tmp rather than TEST_TMPDIR because sun_path is limited to ~104 bytes. + auto path = kj::str("/tmp/kj-rs-io-test-", getpid(), ".sock"); + auto addrText = kj::str("unix:", path); + + auto addr = parseNow(io, addrText); + KJ_EXPECT(addr->toString() == addrText); + + auto listener = addr->listen(); + KJ_EXPECT(listener->getPort() == 0); // KJ reports 0 for non-IP listeners. + auto acceptPromise = listener->accept(); + auto client = addr->connect().wait(ws); + auto server = acceptPromise.wait(ws); + + client->write("via unix"_kjb).wait(ws); + client->shutdownWrite(); + kj::byte buffer[16]; + KJ_EXPECT(server->tryRead(buffer, 16, sizeof(buffer)).wait(ws) == 8); + KJ_EXPECT(kj::ArrayPtr(buffer, 8) == "via unix"_kjb); + + unlink(path.cStr()); +} + +KJ_TEST("getSockaddr builds a connectable address from a raw struct sockaddr") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + auto listener = parseNow(io, "127.0.0.1")->listen(); + + struct sockaddr_in sin; + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = htons(static_cast(listener->getPort())); + sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + auto addr = io.getNetwork().getSockaddr(&sin, sizeof(sin)); + KJ_EXPECT(addr->toString() == kj::str("127.0.0.1:", listener->getPort())); + + auto acceptPromise = listener->accept(); + auto client = addr->connect().wait(ws); + auto server = acceptPromise.wait(ws); + client->write("hi"_kjb).wait(ws); + kj::byte buffer[2]; + KJ_EXPECT(server->tryRead(buffer, 2, sizeof(buffer)).wait(ws) == 2); +} +#endif + +KJ_TEST("newPipeThread is a documented stub") { + auto io = setupTokioAsyncIo(); + KJ_EXPECT_THROW_MESSAGE("newPipeThread", + io.getProvider().newPipeThread( + [](kj::AsyncIoProvider &, kj::AsyncIoStream &, kj::WaitScope &) {})); +} + +// ======================================================================================= +// Provider odds and ends + +KJ_TEST("provider pipes (in-memory) and timer work under the tokio loop") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + auto pipe = io.getProvider().newTwoWayPipe(); + auto writePromise = pipe.ends[0]->write("pipe data"_kjb).eagerlyEvaluate(nullptr); + kj::byte buffer[16]; + KJ_EXPECT(pipe.ends[1]->tryRead(buffer, 9, sizeof(buffer)).wait(ws) == 9); + writePromise.wait(ws); + + auto &timer = io.getProvider().getTimer(); + auto before = timer.now(); + timer.afterDelay(5 * kj::MILLISECONDS).wait(ws); + KJ_EXPECT(timer.now() - before >= 5 * kj::MILLISECONDS); +} + +// ======================================================================================= +// Unwrap fast path + +KJ_TEST("unwrap fast path: recover the native tokio stream and write from Rust") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + // Recover the native tokio TcpStream out of the kj wrapper (free-function form, as a Rust + // server would after being handed a kj::AsyncIoStream&)... + auto native = kj_rs_io::unwrapTokioStream(*pair.server); + + // ...the hollow wrapper now refuses I/O... + kj::byte buffer[32]; + KJ_EXPECT_THROW_MESSAGE("unwrapped", pair.server->tryRead(buffer, 1, sizeof(buffer)).wait(ws)); + + // ...and Rust can drive the connection natively: it writes via the tokio readiness API and + // closes; C++ reads the bytes plus EOF through the (still wrapped) client end. + auto writeDone = native_write_via_unwrap(kj::mv(native), toRustVec("native write"_kjb)); + KJ_EXPECT(pair.client->tryRead(buffer, 12, sizeof(buffer)).wait(ws) == 12); + KJ_EXPECT(kj::ArrayPtr(buffer, 12) == "native write"_kjb); + writeDone.wait(ws); + KJ_EXPECT(pair.client->tryRead(buffer, 1, sizeof(buffer)).wait(ws) == 0); // EOF +} + +KJ_TEST("unwrap fast path: Rust-side unwrap_kj_stream() from a " + "kj::AsyncIoStream&") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + // Rust receives only a kj::AsyncIoStream& and performs the unwrap + native write itself. + auto writeDone = native_write_via_kj_unwrap(*pair.server, toRust("rust unwrap"_kjb)); + kj::byte buffer[32]; + KJ_EXPECT(pair.client->tryRead(buffer, 11, sizeof(buffer)).wait(ws) == 11); + KJ_EXPECT(kj::ArrayPtr(buffer, 11) == "rust unwrap"_kjb); + writeDone.wait(ws); + + // Unwrapping a foreign (non-kj-rs-io) stream fails cleanly. + auto pipe = io.getProvider().newTwoWayPipe(); + KJ_EXPECT_THROW_MESSAGE("cannot unwrap", kj_rs_io::unwrapTokioStream(*pipe.ends[0])); +} + +// ======================================================================================= +// Fd wrapping (kj::LowLevelAsyncIoProvider) + +#if !_WIN32 +KJ_TEST("wrapListenSocketFd accepts connections on a pre-bound listener (the " + "--socket-fd case)") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + // Rust binds a *blocking* std listener (like an fd inherited from a supervisor) and hands us + // the raw fd; wrapListenSocketFd must take ownership and make it usable. + auto prebound = create_prebound_listener_fd(); + auto receiver = io.getLowLevelProvider().wrapListenSocketFd( + prebound.fd, kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP); + KJ_EXPECT(receiver->getPort() == prebound.port); + + auto acceptPromise = receiver->accept(); + auto client = parseNow(io, kj::str("127.0.0.1:", prebound.port))->connect().wait(ws); + auto server = acceptPromise.wait(ws); + + client->write("fd listen"_kjb).wait(ws); + kj::byte buffer[16]; + KJ_EXPECT(server->tryRead(buffer, 9, sizeof(buffer)).wait(ws) == 9); + KJ_EXPECT(kj::ArrayPtr(buffer, 9) == "fd listen"_kjb); +} + +KJ_TEST("wrapSocketFd wraps both ends of a socketpair") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + int fds[2]; + KJ_SYSCALL(socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + auto end0 = + io.getLowLevelProvider().wrapSocketFd(fds[0], kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP); + auto end1 = + io.getLowLevelProvider().wrapSocketFd(fds[1], kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP); + + end0->write("socketpair"_kjb).wait(ws); + kj::byte buffer[16]; + KJ_EXPECT(end1->tryRead(buffer, 10, sizeof(buffer)).wait(ws) == 10); + KJ_EXPECT(kj::ArrayPtr(buffer, 10) == "socketpair"_kjb); +} + +KJ_TEST("wrapConnectingSocketFd completes a nonblocking connect") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + auto listener = parseNow(io, "127.0.0.1")->listen(); + + int fd; + KJ_SYSCALL(fd = socket(AF_INET, SOCK_STREAM, 0)); + struct sockaddr_in sin; + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = htons(static_cast(listener->getPort())); + sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + auto acceptPromise = listener->accept(); + auto client = io.getLowLevelProvider() + .wrapConnectingSocketFd(fd, reinterpret_cast(&sin), + sizeof(sin), kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP) + .wait(ws); + auto server = acceptPromise.wait(ws); + + client->write("connected"_kjb).wait(ws); + kj::byte buffer[16]; + KJ_EXPECT(server->tryRead(buffer, 9, sizeof(buffer)).wait(ws) == 9); +} + +KJ_TEST("wrapInputFd/wrapOutputFd move bytes through an OS pipe and observe EOF") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + int fds[2]; + KJ_SYSCALL(pipe(fds)); + auto input = + io.getLowLevelProvider().wrapInputFd(fds[0], kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP); + auto output = + io.getLowLevelProvider().wrapOutputFd(fds[1], kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP); + + // Blocked read completes once data is written ("pipe fd io" is 10 bytes; cap the first read + // at 9 so one byte remains). + kj::byte buffer[16]; + auto readPromise = input->tryRead(buffer, 9, 9); + KJ_EXPECT(!readPromise.poll(ws)); + auto writePromise = output->write("pipe fd io"_kjb); + KJ_EXPECT(readPromise.wait(ws) == 9); + writePromise.wait(ws); + KJ_EXPECT(input->tryRead(buffer, 1, sizeof(buffer)).wait(ws) == 1); // "o" + + // Dropping the output stream closes the write end -> EOF. + output = nullptr; + KJ_EXPECT(input->tryRead(buffer, 1, sizeof(buffer)).wait(ws) == 0); +} + +KJ_TEST("restrictPeers blocks disallowed connect() with KJ's error text") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + kj_rs_io::TokioNetwork network; + auto restricted = network.restrictPeers({"public"_kj}, {}); + + // Loopback is not "public": blocked before any connection attempt. + auto blockedAddr = restricted->parseAddress("127.0.0.1:1").wait(ws); + KJ_EXPECT_THROW_MESSAGE("connect() blocked by restrictPeers()", blockedAddr->connect().wait(ws)); + + // getSockaddr is rejected eagerly, like KJ. + struct sockaddr_in sin; + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = htons(1); + sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + KJ_EXPECT_THROW_MESSAGE( + "address blocked by restrictPeers()", restricted->getSockaddr(&sin, sizeof(sin))); + + // An allowing restriction still connects. + auto allowed = network.restrictPeers({"private"_kj}, {}); + auto listener = network.parseAddress("127.0.0.1", 0).wait(ws)->listen(); + auto acceptPromise = listener->accept(); + auto client = allowed->parseAddress(kj::str("127.0.0.1:", listener->getPort())) + .wait(ws) + ->connect() + .wait(ws); + auto server = acceptPromise.wait(ws); + client->write("ok"_kjb).wait(ws); + kj::byte buffer[2]; + KJ_EXPECT(server->tryRead(buffer, 2, 2).wait(ws) == 2); +} + +KJ_TEST("restrictPeers filters accepted peers (disallowed peers are dropped, " + "accept keeps waiting)") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + kj_rs_io::TokioNetwork network; + auto restricted = network.restrictPeers({"public"_kj}, {}); + + auto listener = restricted->parseAddress("127.0.0.1", 0).wait(ws)->listen(); + auto acceptPromise = listener->accept(); + + // Connect via the unrestricted network; the loopback peer is not "public", so the listener + // silently drops it: accept() stays pending and the client observes EOF. + auto client = network.parseAddress(kj::str("127.0.0.1:", listener->getPort()), 0) + .wait(ws) + ->connect() + .wait(ws); + KJ_EXPECT(!acceptPromise.poll(ws)); + kj::byte buffer[1]; + KJ_EXPECT(client->tryRead(buffer, 1, 1).wait(ws) == 0); +} + +KJ_TEST("onSignal resolves when the process receives the signal") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + auto promise = kj_rs_io::onSignal(SIGUSR2); + // Pump the loop so the (cold, first-poll-registered) tokio signal handler is installed + // before we raise; raising first would take SIGUSR2's default disposition (terminate). + KJ_EXPECT(!promise.poll(ws)); + + KJ_SYSCALL(kill(getpid(), SIGUSR2)); + promise.wait(ws); + + // A second watcher works too (the process-global registration is reusable). + auto again = kj_rs_io::onSignal(SIGUSR2); + KJ_EXPECT(!again.poll(ws)); + KJ_SYSCALL(kill(getpid(), SIGUSR2)); + again.wait(ws); +} +#endif + +} // namespace +} // namespace kj_rs_io_test diff --git a/src/rust/cxx/kj-rs-io/tests/capnp-rpc-test.c++ b/src/rust/cxx/kj-rs-io/tests/capnp-rpc-test.c++ new file mode 100644 index 00000000000..b1ea271c8b3 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/capnp-rpc-test.c++ @@ -0,0 +1,79 @@ +// capnp RPC integration: capnp::TwoPartyServer / capnp::TwoPartyClient running over kj-rs-io +// (tokio-backed) streams on the tokio event loop — bootstrap plus call round-trips. Uses a +// schema-less capability (raw dispatchCall / typelessRequest with Text payloads) to avoid +// needing capnp codegen in this repo. + +#include "kj-rs-io/async-io.h" + +#include +#include +#include +#include +#include +#include + +namespace kj_rs_io_test { +namespace { + +using kj_rs_io::setupTokioAsyncIo; + +constexpr uint64_t ECHO_INTERFACE_ID = 0xabcd1234abcd1234ull; +constexpr uint16_t ECHO_METHOD_ID = 0; + +// A schema-less capability: method 0 takes a Text param and returns "echo:" + text. +class EchoCapability final: public capnp::Capability::Server { + public: + DispatchCallResult dispatchCall(uint64_t interfaceId, + uint16_t methodId, + capnp::CallContext context) override { + KJ_ASSERT(interfaceId == ECHO_INTERFACE_ID); + KJ_ASSERT(methodId == ECHO_METHOD_ID); + auto params = kj::str(context.getParams().getAs()); + context.releaseParams(); + context.getResults(capnp::MessageSize{16, 0}).setAs(kj::str("echo:", params)); + return DispatchCallResult{kj::READY_NOW, false, true}; + } +}; + +KJ_TEST("capnp two-party RPC bootstrap and call round-trips over kj-rs-io " + "streams on the tokio loop") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + // Server: TwoPartyServer accepting from a kj-rs-io ConnectionReceiver. + capnp::TwoPartyServer server(kj::heap()); + auto listener = io.getNetwork().parseAddress("127.0.0.1", 0).wait(ws)->listen(); + auto listenTask = server.listen(*listener).eagerlyEvaluate( + [](kj::Exception &&e) { KJ_FAIL_EXPECT("RPC server failed", e); }); + + // Client: TwoPartyClient over a kj-rs-io connection. + auto addr = io.getNetwork().parseAddress(kj::str("127.0.0.1:", listener->getPort())).wait(ws); + auto connection = addr->connect().wait(ws); + capnp::TwoPartyClient client(*connection); + auto cap = client.bootstrap(); + + // Single call round trip. + { + auto request = cap.typelessRequest(ECHO_INTERFACE_ID, ECHO_METHOD_ID, kj::none, {}); + request.setAs("hello tokio"); + auto response = request.send().wait(ws); + KJ_EXPECT(response.getAs() == "echo:hello tokio"); + } + + // A pile of pipelined calls in flight at once. + { + constexpr int COUNT = 64; + auto builder = kj::heapArrayBuilder>(COUNT); + for (int i = 0; i < COUNT; i++) { + auto request = cap.typelessRequest(ECHO_INTERFACE_ID, ECHO_METHOD_ID, kj::none, {}); + request.setAs(kj::str("msg", i)); + builder.add(request.send().then([i](capnp::Response response) { + KJ_EXPECT(response.getAs() == kj::str("echo:msg", i)); + })); + } + kj::joinPromisesFailFast(builder.finish()).wait(ws); + } +} + +} // namespace +} // namespace kj_rs_io_test diff --git a/src/rust/cxx/kj-rs-io/tests/file-watcher-test.c++ b/src/rust/cxx/kj-rs-io/tests/file-watcher-test.c++ new file mode 100644 index 00000000000..b3ad24168e6 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/file-watcher-test.c++ @@ -0,0 +1,274 @@ +// Tests for kj_rs_io::FileWatcher (file-watcher.h), the tokio-loop replacement for workerd's +// --watch FileWatcher. Exercises the behaviors workerd depends on: plain modification, atomic +// replace-by-rename (editor saves), event queueing/coalescing across onChange() calls, the +// already-open-fd watch path (kqueue backends), missing-file handling, and teardown/cancel +// while a watch promise is armed. + +#include "kj-rs-io/async-io.h" +#include "kj-rs-io/file-watcher.h" + +#include +#include +#include +#include + +#include +#include + +#if !_WIN32 +#include +#include +#include +#endif + +namespace kj_rs_io_test { +namespace { + +using kj_rs_io::FileWatcher; +using kj_rs_io::setupTokioAsyncIo; +using kj_rs_io::TokioAsyncIoContext; + +#if !_WIN32 + +// ======================================================================================= +// Helpers + +// Waits for `promise` to resolve, returning true, or false after `timeout`. +bool resolvesWithin(kj::Promise promise, TokioAsyncIoContext &io, kj::Duration timeout) { + auto timedOut = io.getTimer().afterDelay(timeout).then([]() { return false; }); + return promise.then([]() { return true; }) + .exclusiveJoin(kj::mv(timedOut)) + .wait(io.getWaitScope()); +} + +// Generous bound for "the change fires"; file events are near-immediate on both backends. +constexpr kj::Duration FIRE_TIMEOUT = 5 * kj::SECONDS; +// Short bound for "nothing fires" checks. +constexpr kj::Duration QUIET_TIMEOUT = 200 * kj::MILLISECONDS; + +struct TempDir { + kj::String path; + + TempDir() { + const char *base = getenv("TEST_TMPDIR"); + if (base == nullptr) base = "/tmp"; + auto tmpl = kj::str(base, "/kj-rs-io-file-watcher-test.XXXXXX"); + KJ_ASSERT(mkdtemp(tmpl.begin()) != nullptr, strerror(errno)); + path = kj::mv(tmpl); + } + + ~TempDir() noexcept(false) { + // Best-effort cleanup; TEST_TMPDIR is wiped by bazel anyway. + auto cmd = kj::str("rm -rf ", path); + (void)system(cmd.cStr()); + } + + kj::String fileName(kj::StringPtr name) { + return kj::str(path, "/", name); + } + + kj::Path filePath(kj::StringPtr name) { + auto full = fileName(name); + KJ_ASSERT(full.startsWith("/")); + return kj::Path::parse(full.slice(1)); + } +}; + +void writeFile(kj::StringPtr path, kj::StringPtr content) { + kj::OwnFd fd = KJ_SYSCALL_FD(open(path.cStr(), O_WRONLY | O_CREAT | O_TRUNC, 0644)); + KJ_SYSCALL(write(fd, content.begin(), content.size())); +} + +void appendFile(kj::StringPtr path, kj::StringPtr content) { + kj::OwnFd fd = KJ_SYSCALL_FD(open(path.cStr(), O_WRONLY | O_APPEND)); + KJ_SYSCALL(write(fd, content.begin(), content.size())); +} + +// After a change fired, drains any further already-queued events so the next onChange() call +// starts from a quiet state (mirrors what workerd's waitForChanges() settle loop achieves). +void drain(FileWatcher &watcher, TokioAsyncIoContext &io) { + while (resolvesWithin(watcher.onChange(), io, QUIET_TIMEOUT)) {} +} + +// ======================================================================================= +// Tests + +KJ_TEST("FileWatcher: supported on this platform") { + auto io = setupTokioAsyncIo(); + FileWatcher watcher; + KJ_EXPECT(watcher.isSupported()); +} + +KJ_TEST("FileWatcher: modification fires onChange") { + auto io = setupTokioAsyncIo(); + TempDir dir; + writeFile(dir.fileName("a.txt"), "one"); + + FileWatcher watcher; + watcher.watch(dir.filePath("a.txt"), kj::none); + + auto change = watcher.onChange(); + appendFile(dir.fileName("a.txt"), " two"); + KJ_EXPECT(resolvesWithin(kj::mv(change), io, FIRE_TIMEOUT)); +} + +KJ_TEST("FileWatcher: change before onChange() is called is not lost") { + auto io = setupTokioAsyncIo(); + TempDir dir; + writeFile(dir.fileName("a.txt"), "one"); + + FileWatcher watcher; + watcher.watch(dir.filePath("a.txt"), kj::none); + + // Modify before anyone is waiting: the event queues in the kernel. + appendFile(dir.fileName("a.txt"), " two"); + KJ_EXPECT(resolvesWithin(watcher.onChange(), io, FIRE_TIMEOUT)); +} + +KJ_TEST("FileWatcher: atomic replace-by-rename fires onChange") { + // Editors typically save by writing a temporary file and rename(2)ing it over the target. + auto io = setupTokioAsyncIo(); + TempDir dir; + writeFile(dir.fileName("a.txt"), "one"); + + FileWatcher watcher; + watcher.watch(dir.filePath("a.txt"), kj::none); + + auto change = watcher.onChange(); + writeFile(dir.fileName("a.txt.tmp"), "two"); + KJ_SYSCALL(rename(dir.fileName("a.txt.tmp").cStr(), dir.fileName("a.txt").cStr())); + KJ_EXPECT(resolvesWithin(kj::mv(change), io, FIRE_TIMEOUT)); +} + +KJ_TEST("FileWatcher: watching via an already-open file handle") { + // workerd passes the config files' already-open kj::ReadableFile to watch(); the kqueue + // backend watches a dup of that fd (the inotify backend ignores it and uses the path). + auto io = setupTokioAsyncIo(); + TempDir dir; + writeFile(dir.fileName("a.txt"), "one"); + + auto file = kj::newDiskReadableFile(KJ_SYSCALL_FD(open(dir.fileName("a.txt").cStr(), O_RDONLY))); + + FileWatcher watcher; + watcher.watch(dir.filePath("a.txt"), *file); + file = nullptr; // The original handle may be closed; the watch must survive. + + auto change = watcher.onChange(); + appendFile(dir.fileName("a.txt"), " two"); + KJ_EXPECT(resolvesWithin(kj::mv(change), io, FIRE_TIMEOUT)); +} + +KJ_TEST("FileWatcher: rapid changes coalesce; watcher stays armed for later " + "changes") { + auto io = setupTokioAsyncIo(); + TempDir dir; + writeFile(dir.fileName("a.txt"), "one"); + + FileWatcher watcher; + watcher.watch(dir.filePath("a.txt"), kj::none); + + // A burst of changes produces one resolution per onChange() call (not one per event), ... + auto change = watcher.onChange(); + appendFile(dir.fileName("a.txt"), " two"); + appendFile(dir.fileName("a.txt"), " three"); + appendFile(dir.fileName("a.txt"), " four"); + KJ_EXPECT(resolvesWithin(kj::mv(change), io, FIRE_TIMEOUT)); + + // ... and once the queue is drained, the watcher is quiet ... + drain(watcher, io); + + // ... but still armed: a fresh change fires a fresh onChange(). + auto later = watcher.onChange(); + appendFile(dir.fileName("a.txt"), " five"); + KJ_EXPECT(resolvesWithin(kj::mv(later), io, FIRE_TIMEOUT)); +} + +KJ_TEST("FileWatcher: unrelated files in the same directory do not fire " + "(inotify filtering)") { + auto io = setupTokioAsyncIo(); + TempDir dir; + writeFile(dir.fileName("a.txt"), "one"); + writeFile(dir.fileName("other.txt"), "other"); + + FileWatcher watcher; + watcher.watch(dir.filePath("a.txt"), kj::none); + + appendFile(dir.fileName("other.txt"), " more"); + KJ_EXPECT(!resolvesWithin(watcher.onChange(), io, QUIET_TIMEOUT)); +} + +#if __linux__ +KJ_TEST("FileWatcher: watching a not-yet-existing file fires when it is created") { + // The inotify backend watches the parent directory, so the file itself need not exist yet. + // (The kqueue backend opens the file and so requires it to exist; see the test below.) + auto io = setupTokioAsyncIo(); + TempDir dir; + + FileWatcher watcher; + watcher.watch(dir.filePath("missing.txt"), kj::none); + + auto change = watcher.onChange(); + writeFile(dir.fileName("missing.txt"), "now it exists"); + KJ_EXPECT(resolvesWithin(kj::mv(change), io, FIRE_TIMEOUT)); +} +#else +KJ_TEST("FileWatcher: watching a nonexistent file throws (kqueue backend)") { + // Same behavior as workerd's kj-mode kqueue watcher: watch() opens the path with + // KJ_SYSCALL, which throws if it doesn't exist. + auto io = setupTokioAsyncIo(); + TempDir dir; + + FileWatcher watcher; + auto exception = + kj::runCatchingExceptions([&]() { watcher.watch(dir.filePath("missing.txt"), kj::none); }); + KJ_EXPECT(exception != kj::none); +} +#endif + +KJ_TEST("FileWatcher: canceling an armed onChange() and re-arming works") { + auto io = setupTokioAsyncIo(); + TempDir dir; + writeFile(dir.fileName("a.txt"), "one"); + + FileWatcher watcher; + watcher.watch(dir.filePath("a.txt"), kj::none); + + { + auto armed = watcher.onChange(); + KJ_EXPECT(!armed.poll(io.getWaitScope())); + // Dropped here while armed (fd registered with the tokio I/O driver). + } + + auto change = watcher.onChange(); + appendFile(dir.fileName("a.txt"), " two"); + KJ_EXPECT(resolvesWithin(kj::mv(change), io, FIRE_TIMEOUT)); +} + +KJ_TEST("FileWatcher: teardown while a watch promise is armed") { + auto io = setupTokioAsyncIo(); + TempDir dir; + writeFile(dir.fileName("a.txt"), "one"); + + auto watcher = kj::heap(); + watcher->watch(dir.filePath("a.txt"), kj::none); + + auto armed = watcher->onChange(); + KJ_EXPECT(!armed.poll(io.getWaitScope())); + + // Promise first (it borrows the watcher's fd), then the watcher itself. + armed = nullptr; + watcher = nullptr; +} + +#else // _WIN32 + +KJ_TEST("FileWatcher: reports unsupported on this platform") { + auto io = setupTokioAsyncIo(); + FileWatcher watcher; + KJ_EXPECT(!watcher.isSupported()); +} + +#endif + +} // namespace +} // namespace kj_rs_io_test diff --git a/src/rust/cxx/kj-rs-io/tests/http-test.c++ b/src/rust/cxx/kj-rs-io/tests/http-test.c++ new file mode 100644 index 00000000000..55dfb9506f9 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/http-test.c++ @@ -0,0 +1,147 @@ +// kj-http integration ("the acid test"): a real kj::HttpServer serving on a kj-rs-io listener +// and a kj::HttpClient over a kj-rs-io connection, all driven by the tokio event loop. Proves +// that kj-http works unchanged over tokio-backed streams. + +#include "kj-rs-io/async-io.h" + +#include +#include +#include +#include + +#include + +namespace kj_rs_io_test { +namespace { + +using kj_rs_io::setupTokioAsyncIo; + +// Echoes the request body back as the response body, streaming (pumpTo), preserving the +// content length when known. +class EchoService final: public kj::HttpService { + public: + explicit EchoService(kj::HttpHeaderTable &table): table(table) {} + + kj::Promise request(kj::HttpMethod method, + kj::StringPtr url, + const kj::HttpHeaders &headers, + kj::AsyncInputStream &requestBody, + Response &response) override { + kj::HttpHeaders responseHeaders(table); + auto body = response.send(200, "OK", responseHeaders, requestBody.tryGetLength()); + co_await requestBody.pumpTo(*body); + } + + private: + kj::HttpHeaderTable &table; +}; + +kj::Array makeBody(size_t size) { + auto data = kj::heapArray(size); + for (size_t i = 0; i < size; i++) { + data[i] = static_cast(('A' + i / 8192 + i * 13) & 0xff); + } + return data; +} + +// Writes `data` in chunks to the request body stream, then closes it. +kj::Promise writeBody( + kj::Own body, kj::ArrayPtr data) { + constexpr size_t CHUNK = 128 * 1024; + size_t offset = 0; + while (offset < data.size()) { + size_t n = kj::min(CHUNK, data.size() - offset); + co_await body->write(data.slice(offset, offset + n)); + offset += n; + } + // Dropping `body` (coroutine frame teardown) finishes the request body. +} + +KJ_TEST("bodyless GET works (regression: kj-http never awaits its header-write " + "queue for bodyless requests, relying on KJ hot-promise write semantics)") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + kj::HttpHeaderTable table; + EchoService service(table); + kj::HttpServer server(io.getTimer(), table, service); + auto listener = io.getNetwork().parseAddress("127.0.0.1", 0).wait(ws)->listen(); + auto listenTask = server.listenHttp(*listener).eagerlyEvaluate(nullptr); + auto addr = io.getNetwork().parseAddress(kj::str("127.0.0.1:", listener->getPort())).wait(ws); + auto connection = addr->connect().wait(ws); + auto client = kj::newHttpClient(table, *connection); + kj::HttpHeaders headers(table); + auto request = client->request(kj::HttpMethod::GET, "/x", headers, static_cast(0)); + auto response = request.response.wait(ws); + KJ_EXPECT(response.statusCode == 200); + auto body = response.body->readAllBytes().wait(ws); + KJ_EXPECT(body.size() == 0); +} + +KJ_TEST("kj-http round trip with streaming bodies over kj-rs-io streams on the " + "tokio loop") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + kj::HttpHeaderTable table; + EchoService service(table); + kj::HttpServer server(io.getTimer(), table, service); + + // Server side: kj::HttpServer accepting from a kj-rs-io ConnectionReceiver. + auto listener = io.getNetwork().parseAddress("127.0.0.1", 0).wait(ws)->listen(); + auto listenTask = server.listenHttp(*listener).eagerlyEvaluate( + [](kj::Exception &&e) { KJ_FAIL_EXPECT("HTTP server failed", e); }); + + // Client side: kj::HttpClient over a kj-rs-io connection. + auto addr = io.getNetwork().parseAddress(kj::str("127.0.0.1:", listener->getPort())).wait(ws); + auto connection = addr->connect().wait(ws); + auto client = kj::newHttpClient(table, *connection); + + // Round trip 1: 4 MB POST with a streamed request body, echoed back and read while the + // request body is still being written (full-duplex streaming through the tokio loop). + { + constexpr size_t SIZE = 4 * 1024 * 1024; + auto data = makeBody(SIZE); + + kj::HttpHeaders headers(table); + auto request = + client->request(kj::HttpMethod::POST, "/echo", headers, static_cast(SIZE)); + + auto writeTask = writeBody(kj::mv(request.body), data).eagerlyEvaluate(nullptr); + auto response = request.response.wait(ws); + KJ_EXPECT(response.statusCode == 200); + KJ_EXPECT(KJ_ASSERT_NONNULL(response.body->tryGetLength()) == SIZE); + + auto echoed = response.body->readAllBytes(SIZE + 1).wait(ws); + KJ_ASSERT(echoed.size() == SIZE); + KJ_ASSERT(memcmp(echoed.begin(), data.begin(), SIZE) == 0); + writeTask.wait(ws); + } + + // Round trip 2 on the same connection (keep-alive): small GET, empty echoed body. + { + kj::HttpHeaders headers(table); + auto request = + client->request(kj::HttpMethod::GET, "/again", headers, static_cast(0)); + auto response = request.response.wait(ws); + KJ_EXPECT(response.statusCode == 200); + auto body = response.body->readAllBytes().wait(ws); + KJ_EXPECT(body.size() == 0); + } + + // Round trip 3: chunked request body (no expected size -> Transfer-Encoding: chunked). + { + kj::HttpHeaders headers(table); + auto request = client->request(kj::HttpMethod::POST, "/chunked", headers); + auto data = makeBody(64 * 1024); + auto writeTask = writeBody(kj::mv(request.body), data).eagerlyEvaluate(nullptr); + auto response = request.response.wait(ws); + KJ_EXPECT(response.statusCode == 200); + auto echoed = response.body->readAllBytes().wait(ws); + KJ_ASSERT(echoed.size() == data.size()); + KJ_ASSERT(memcmp(echoed.begin(), data.begin(), data.size()) == 0); + writeTask.wait(ws); + } +} + +} // namespace +} // namespace kj_rs_io_test diff --git a/src/rust/cxx/kj-rs-io/tests/lib.rs b/src/rust/cxx/kj-rs-io/tests/lib.rs new file mode 100644 index 00000000000..b62a0893e5a --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/lib.rs @@ -0,0 +1,84 @@ +#![allow(clippy::unused_async)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::must_use_candidate)] +#![allow(clippy::missing_panics_doc)] +#![allow(clippy::missing_safety_doc)] +#![allow(clippy::unnecessary_box_returns)] // cxx bridge functions return Box by contract + +mod serve_helpers; +mod test_helpers; + +use serve_helpers::ServeEchoSession; +use serve_helpers::start_serve_echo; +use serve_helpers::start_take_socket_echo; +use test_helpers::create_prebound_listener_fd; +use test_helpers::native_write_via_kj_unwrap; +use test_helpers::native_write_via_unwrap; + +#[cxx::bridge(namespace = "kj_rs_io_test")] +mod ffi { + /// A pre-bound, *blocking* std TCP listener handed to C++ as a raw fd (the `--socket-fd` + /// scenario for `wrapListenSocketFd`). + struct PreboundListener { + fd: i32, + port: u16, + } + + extern "Rust" { + /// Recovers the native tokio TcpStream from an unwrapped kj-rs-io stream Box and writes + /// `data` natively (tokio readiness API, no FFI-per-byte), then closes the connection. + async fn native_write_via_unwrap(stream: Box, data: Vec) -> Result<()>; + + /// Same, but starts from a `kj::AsyncIoStream&`: unwraps it from the Rust side via + /// `kj_rs_io::unwrap_kj_stream` (the API a native Rust server's glue will use), then writes + /// natively. Leaves the C++ wrapper hollow. + async unsafe fn native_write_via_kj_unwrap<'a>( + stream: Pin<&'a mut KjAsyncIoStream>, + data: &'a [u8], + ) -> Result<()>; + + /// Binds 127.0.0.1:0 with std (blocking mode, like an inherited `--socket-fd` listener) + /// and releases it as a raw fd owned by the caller. + fn create_prebound_listener_fd() -> Result; + + // --- serve_kj_stream (serve_helpers.rs) + + /// One echo server over a served kj stream: `start_serve_echo` picks the transport + /// path (unwrap fast path or duplex pump) via `kj_rs_io::serve_kj_stream` — taking + /// ownership of the stream — and spawns the echo consumer on the loop runtime; + /// `drive()` runs the connection (the pump, on the pumped path) to completion — + /// dropping the `drive()` promise mid-connection is the abort-on-drop path, which + /// also destroys the owned stream. + type ServeEchoSession; + + fn start_serve_echo(stream: KjOwn) -> Box; + + /// Like `start_serve_echo`, but through the native-only `take_kj_socket` entry point + /// (unwrap tier, else fd-dup tier): errors — instead of pumping — for streams that + /// are neither. The consumed stream is destroyed before this returns. + fn start_take_socket_echo(stream: KjOwn) -> Result>; + + /// Whether the unwrap fast path was taken (perf observability surface). + fn is_native(self: &ServeEchoSession) -> bool; + + /// Runs the connection to completion (see the type's docs). May only be called once. + async unsafe fn drive<'a>(self: &'a ServeEchoSession) -> Result<()>; + + /// Resolves once the echo task has exited — observing that dropping `drive()` EOFs + /// the pumped consumer. + async unsafe fn wait_echo_done<'a>(self: &'a ServeEchoSession); + } + + extern "Rust" { + #[namespace = "kj_rs_io"] + type TokioStream = kj_rs_io::TokioStream; + } + + unsafe extern "C++" { + include!("kj-rs-io/unwrap.h"); + + #[namespace = "kj"] + #[cxx_name = "AsyncIoStream"] + type KjAsyncIoStream = kj_rs_io::KjAsyncIoStream; + } +} diff --git a/src/rust/cxx/kj-rs-io/tests/serve-test.c++ b/src/rust/cxx/kj-rs-io/tests/serve-test.c++ new file mode 100644 index 00000000000..18629c784b7 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/serve-test.c++ @@ -0,0 +1,284 @@ +// Tests for kj_rs_io::serve_kj_stream (serve.rs): the native-serve entry +// point Rust servers use to drive a kj::AsyncIoStream's connection natively. Following kj-rs +// conventions, C++ KJ_TESTs drive; Rust helpers (tests/serve_helpers.rs) run the echo server +// side over whichever transport path the entry point picks. + +#include "kj-rs-io-test/lib.rs.h" +#include "kj-rs-io/async-io.h" + +#include +#include +#include +#include +#include + +#include + +#if !_WIN32 +#include // getpid()/unlink() for the unix-socket pair helper +#endif + +namespace kj_rs_io_test { +namespace { + +using kj_rs_io::setupTokioAsyncIo; +using kj_rs_io::TokioAsyncIoContext; + +struct ConnectedPair { + kj::Own listener; + kj::Own client; + kj::Own server; +}; + +ConnectedPair makeTcpPair(TokioAsyncIoContext &io) { + auto &ws = io.getWaitScope(); + auto listener = io.getNetwork().parseAddress("127.0.0.1").wait(ws)->listen(); + auto connectAddr = + io.getNetwork().parseAddress(kj::str("127.0.0.1:", listener->getPort())).wait(ws); + auto acceptPromise = listener->accept(); + auto client = connectAddr->connect().wait(ws); + auto server = acceptPromise.wait(ws); + return ConnectedPair{kj::mv(listener), kj::mv(client), kj::mv(server)}; +} + +#if !_WIN32 +// A connected AF_UNIX stream-socket pair, from the kj-rs-io network's `unix:` support. Used to +// prove take_kj_socket serves a non-TCP fd. Binds a short /tmp path (unlinked before bind to +// clear stale sockets, and +// again once connected) unique per process + call, so parallel/repeated runs never collide. +ConnectedPair makeUnixPair(TokioAsyncIoContext &io) { + auto &ws = io.getWaitScope(); + static uint counter = 0; + auto path = kj::str("/tmp/kj-rs-io-serve-", ::getpid(), "-", counter++, ".sock"); + ::unlink(path.cStr()); + auto addr = kj::str("unix:", path); + auto listener = io.getNetwork().parseAddress(addr).wait(ws)->listen(); + auto connectAddr = io.getNetwork().parseAddress(addr).wait(ws); + auto acceptPromise = listener->accept(); + auto client = connectAddr->connect().wait(ws); + auto server = acceptPromise.wait(ws); + // The bound path is no longer needed once both ends are connected. + ::unlink(path.cStr()); + return ConnectedPair{kj::mv(listener), kj::mv(client), kj::mv(server)}; +} +#endif // !_WIN32 + +kj::Array makePatternedData(size_t size, kj::byte seed) { + auto data = kj::heapArray(size); + for (size_t i = 0; i < size; i++) { + data[i] = static_cast((i * 31 + seed) & 0xff); + } + return data; +} + +// The client side of an echo round trip: write `data` (in chunks) and concurrently read the +// echo back and verify it (concurrent, so bounded transports -- the pump duplex, socket +// buffers -- never deadlock on payloads larger than their buffering); then half-close and +// expect EOF. +kj::Promise echoRoundTrip( + kj::AsyncIoStream &clientStream, kj::ArrayPtr data) { + static auto constexpr readBack = [](kj::AsyncIoStream &s, + kj::ArrayPtr expected) -> kj::Promise { + auto buffer = kj::heapArray(expected.size()); + size_t total = co_await s.tryRead(buffer.begin(), buffer.size(), buffer.size()); + KJ_ASSERT(total == expected.size(), total, expected.size()); + KJ_ASSERT(memcmp(buffer.begin(), expected.begin(), expected.size()) == 0); + }; + + static auto constexpr writeAll = [](kj::AsyncIoStream &s, + kj::ArrayPtr data) -> kj::Promise { + constexpr size_t CHUNK = 64 * 1024; + size_t offset = 0; + while (offset < data.size()) { + size_t n = kj::min(CHUNK, data.size() - offset); + co_await s.write(data.slice(offset, offset + n)); + offset += n; + } + s.shutdownWrite(); // half-close: the echo server sees EOF and finishes flushing + }; + + co_await kj::joinPromisesFailFast( + kj::arr(writeAll(clientStream, data), readBack(clientStream, data))); + kj::byte extra; + KJ_EXPECT(co_await clientStream.tryRead(&extra, 1, 1) == 0); // EOF after echo completes +} + +// ======================================================================================= +// Unwrap fast path + +KJ_TEST("serve_kj_stream takes the native path for kj-rs-io TCP streams and " + "echoes") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + // Native path: the connection now belongs to the Rust side (the hollow wrapper was + // destroyed by serve_kj_stream). + auto session = start_serve_echo(kj::mv(pair.server)); + KJ_EXPECT(session->is_native()); + + auto data = makePatternedData(256 * 1024, 7); + auto drive = session->drive(); + auto client = echoRoundTrip(*pair.client, data); + kj::joinPromisesFailFast(kj::arr(kj::mv(drive), kj::mv(client))).wait(ws); +} + +// ======================================================================================= +// Duplex pump fallback (foreign streams) + +KJ_TEST("serve_kj_stream pumps foreign streams: bidirectional echo + half-close") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + + // An in-memory kj pipe is the canonical foreign stream: not kj-rs-io-originated. + auto pipe = kj::newTwoWayPipe(); + + auto session = start_serve_echo(kj::mv(pipe.ends[0])); + KJ_EXPECT(!session->is_native()); + + auto data = makePatternedData(512 * 1024, 3); + auto drive = session->drive(); + auto client = echoRoundTrip(*pipe.ends[1], data); + // The pump owns `pipe.ends[0]` now; only the client end stays with the test. + kj::joinPromisesFailFast(kj::arr(kj::mv(drive), kj::mv(client))).wait(ws); +} + +KJ_TEST("serve_kj_stream pump: dropping the pump aborts the bridge (drop-abort)") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pipe = kj::newTwoWayPipe(); + + auto session = start_serve_echo(kj::mv(pipe.ends[0])); + KJ_EXPECT(!session->is_native()); + + { + // Prove the bridge is live: one small round trip, driving the pump only while the + // client operation runs, then *drop* the drive promise mid-connection. + auto drive = session->drive(); + auto oneRoundTrip = [](kj::AsyncIoStream &s) -> kj::Promise { + co_await s.write("ping"_kjb); + kj::byte buffer[4]; + size_t n = co_await s.tryRead(buffer, 4, 4); + KJ_ASSERT(n == 4); + KJ_ASSERT(memcmp(buffer, "ping", 4) == 0); + }(*pipe.ends[1]); + // exclusiveJoin: when the round trip finishes, `drive` is cancelled (dropped). + oneRoundTrip.exclusiveJoin(kj::mv(drive)).wait(ws); + } + + // Dropping the pump dropped the kj-side duplex end: the echo consumer reads EOF and + // finishes... + session->wait_echo_done().wait(ws); + + // ...and the pump destroyed the kj stream it owned: the peer observes teardown (a rejected + // write), not a zombie half-open pipe. + auto orphanWrite = kj::evalNow([&]() { return pipe.ends[1]->write("anyone there?"_kjb); }); + KJ_EXPECT(orphanWrite.poll(ws)); + orphanWrite + .then([]() { KJ_FAIL_EXPECT("write to a torn-down pipe unexpectedly succeeded"); }, + [](kj::Exception &&) { + }).wait(ws); +} + +// ======================================================================================= +// take_kj_socket (native-only: unwrap tier, else fd-dup tier) + +KJ_TEST("take_kj_socket unwraps kj-rs-io TCP streams (tier 1) and echoes") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + // Native socket taken; the (hollow) kj stream was destroyed inside take_kj_socket. + auto session = start_take_socket_echo(kj::mv(pair.server)); + KJ_EXPECT(session->is_native()); + + auto data = makePatternedData(256 * 1024, 5); + auto drive = session->drive(); + auto client = echoRoundTrip(*pair.client, data); + kj::joinPromisesFailFast(kj::arr(kj::mv(drive), kj::mv(client))).wait(ws); +} + +#if !_WIN32 +// A foreign kj::AsyncIoStream that exposes only its OS fd: unwrap must fail (it is not a +// kj-rs-io wrapper) and any per-read FFI would abort the test -- proving take_kj_socket's fd +// tier does all its I/O on the dup'd socket, never through the kj stream. +class FdOnlyStream final: public kj::AsyncIoStream { + public: + explicit FdOnlyStream(kj::Own inner): inner(kj::mv(inner)) {} + + kj::Maybe getFd() const override { + return inner->getFd(); + } + + kj::Promise tryRead(void *, size_t, size_t) override { + KJ_UNIMPLEMENTED("FdOnlyStream must not be read through the FFI"); + } + kj::Promise write(kj::ArrayPtr) override { + KJ_UNIMPLEMENTED("FdOnlyStream must not be written through the FFI"); + } + kj::Promise write(kj::ArrayPtr>) override { + KJ_UNIMPLEMENTED("FdOnlyStream must not be written through the FFI"); + } + kj::Promise whenWriteDisconnected() override { + KJ_UNIMPLEMENTED("FdOnlyStream must not be observed through the FFI"); + } + void shutdownWrite() override { + KJ_UNIMPLEMENTED("FdOnlyStream must not be shut down through the FFI"); + } + + private: + kj::Own inner; +}; + +KJ_TEST("take_kj_socket dups the fd of foreign fd-backed streams (tier 2); the " + "original stream may be dropped") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeTcpPair(io); + + // Hide the kj-rs-io origin behind a foreign wrapper: only getFd() is reachable. + auto foreign = kj::heap(kj::mv(pair.server)); + + // Fd tier: the dup is independent; take_kj_socket destroys the wrapper (and with it the + // original socket's owner) before returning, which must not tear the served connection down. + auto session = start_take_socket_echo(kj::mv(foreign)); + KJ_EXPECT(session->is_native()); + + auto data = makePatternedData(256 * 1024, 9); + auto drive = session->drive(); + auto client = echoRoundTrip(*pair.client, data); + kj::joinPromisesFailFast(kj::arr(kj::mv(drive), kj::mv(client))).wait(ws); +} + +// A unix-domain (AF_UNIX) socket must be served natively too: take_kj_socket's fd tier dups the +// fd and detects the family (getsockname), producing a tokio UnixStream (ServeIo::Unix). +KJ_TEST("take_kj_socket serves a unix-domain (AF_UNIX) socket via its fd tier and echoes") { + auto io = setupTokioAsyncIo(); + auto &ws = io.getWaitScope(); + auto pair = makeUnixPair(io); + + // Hide the kj-rs-io origin behind a foreign wrapper so take_kj_socket must use its fd tier + // (dup + family detection), not the unwrap fast path -- proving the AF_UNIX -> UnixStream + // branch of serve_io_from_owned_fd. + auto foreign = kj::heap(kj::mv(pair.server)); + + auto session = start_take_socket_echo(kj::mv(foreign)); + KJ_EXPECT(session->is_native()); // ServeIo::Unix is a native path (no pump) + + auto data = makePatternedData(256 * 1024, 11); + auto drive = session->drive(); + auto client = echoRoundTrip(*pair.client, data); + kj::joinPromisesFailFast(kj::arr(kj::mv(drive), kj::mv(client))).wait(ws); +} +#endif // !_WIN32 + +KJ_TEST("take_kj_socket refuses fd-less foreign streams (no pump tier)") { + auto io = setupTokioAsyncIo(); + auto pipe = kj::newTwoWayPipe(); + + KJ_EXPECT_THROW_MESSAGE( + "cannot take the stream's socket natively", start_take_socket_echo(kj::mv(pipe.ends[0]))); +} + +} // namespace +} // namespace kj_rs_io_test diff --git a/src/rust/cxx/kj-rs-io/tests/serve_helpers.rs b/src/rust/cxx/kj-rs-io/tests/serve_helpers.rs new file mode 100644 index 00000000000..de1b13431ee --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/serve_helpers.rs @@ -0,0 +1,109 @@ +//! Rust helpers for the `serve_kj_stream` `KJ_TEST`s (`serve-test.c++`): +//! echo sessions over each transport path, driven by the C++ tests. + +use std::cell::RefCell; + +use cxx::KjError; +use kj_rs::KjOwn; +use kj_rs_io::serve::ServeIo; +use kj_rs_io::serve::ServePath; +use kj_rs_io::serve::StreamPump; +use tokio::io::AsyncWriteExt; +use tokio::sync::watch; + +use crate::ffi::KjAsyncIoStream; + +type Result = std::result::Result; + +fn kj_err(message: impl std::fmt::Display) -> KjError { + KjError::new(cxx::KjExceptionType::Failed, message.to_string()) +} + +/// The echo task shared by every path: copy everything read back to the writer, then +/// propagate the half-close. Reports completion through `done_tx`. +async fn echo(io: ServeIo, done_tx: watch::Sender) -> std::io::Result { + let result = async { + let (mut rd, mut wr) = tokio::io::split(io); + let n = tokio::io::copy(&mut rd, &mut wr).await?; + wr.shutdown().await?; + Ok(n) + } + .await; + let _ = done_tx.send(true); + result +} + +/// One echo server over a served kj stream; see `start_serve_echo` in lib.rs. +pub struct ServeEchoSession { + native: bool, + /// Present for the pumped path; taken by `drive()`. + pump: RefCell>, + /// The echo task's completion signal (fires even if the task failed). + done_rx: watch::Receiver, + /// Taken by `drive()`. + echo: RefCell>>>, +} + +pub fn start_serve_echo(stream: KjOwn) -> Box { + let served = kj_rs_io::serve_kj_stream(stream); + Box::new(ServeEchoSession::new(served.io, served.pump)) +} + +/// Like `start_serve_echo`, but through the native-only entry point (`take_kj_socket`, +/// tiers 1 + 2): errors for streams that are neither kj-rs-io native nor fd-backed (the +/// handed-back stream is dropped with the error). +pub fn start_take_socket_echo(stream: KjOwn) -> Result> { + let io = kj_rs_io::take_kj_socket(stream).map_err(KjError::from)?; + Ok(Box::new(ServeEchoSession::new(io, None))) +} + +impl ServeEchoSession { + fn new(io: ServeIo, pump: Option) -> Self { + let native = io.path() == ServePath::Native; + let (done_tx, done_rx) = watch::channel(false); + // The echo consumer runs on this thread's KJ-loop runtime: the one-runtime shape + // (native sockets are registered with this runtime's I/O driver anyway). + let echo = kj_rs_tokio::spawn(echo(io, done_tx)); + Self { + native, + pump: RefCell::new(pump), + done_rx, + echo: RefCell::new(Some(echo)), + } + } + + pub fn is_native(&self) -> bool { + self.native + } + + /// Runs the connection to completion: drives the pump (if any) and waits for the echo + /// task to finish. Dropping the returned promise mid-connection drops the pump — + /// the abort-on-drop path. + pub async fn drive(&self) -> Result<()> { + let pump = self.pump.borrow_mut().take(); + let echo = self + .echo + .borrow_mut() + .take() + .ok_or_else(|| kj_err("drive() was already called"))?; + if let Some(pump) = pump { + pump.await?; + } + let echoed = echo + .await + .map_err(|e| kj_err(format!("echo task panicked: {e}")))? + .map_err(|e| kj_err(format!("echo failed: {e}")))?; + let _ = echoed; + Ok(()) + } + + /// Resolves once the echo task has finished (however `drive()` fared) — used by the + /// drop-abort test to observe that dropping the pump EOFs the consumer. + pub async fn wait_echo_done(&self) { + let mut rx = self.done_rx.clone(); + // Cannot fail: the sender is owned by the echo task, which always sends before exit; + // even if it panicked, the closed channel resolves wait_for with an error we ignore + // after checking the flag. + let _ = rx.wait_for(|done| *done).await; + } +} diff --git a/src/rust/cxx/kj-rs-io/tests/test_helpers.rs b/src/rust/cxx/kj-rs-io/tests/test_helpers.rs new file mode 100644 index 00000000000..b41b244dd86 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/tests/test_helpers.rs @@ -0,0 +1,70 @@ +use std::io; +use std::pin::Pin; + +use cxx::KjError; +use kj_rs_io::TokioStream; +use tokio::io::Interest; +use tokio::net::TcpStream; + +use crate::ffi::KjAsyncIoStream; +use crate::ffi::PreboundListener; + +type Result = std::result::Result; + +fn kj_err(message: impl std::fmt::Display) -> KjError { + KjError::new(cxx::KjExceptionType::Failed, message.to_string()) +} + +/// Write-all over the native tokio readiness API. +async fn native_write_all(stream: &TcpStream, data: &[u8]) -> io::Result<()> { + let mut written = 0; + while written < data.len() { + match stream.try_write(&data[written..]) { + Ok(n) => written += n, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => { + stream.ready(Interest::WRITABLE).await?; + } + Err(e) => return Err(e), + } + } + Ok(()) +} + +pub async fn native_write_via_unwrap(stream: Box, data: Vec) -> Result<()> { + let tcp = stream + .into_tcp_stream() + .ok_or_else(|| kj_err("expected a TCP stream"))?; + native_write_all(&tcp, &data).await.map_err(kj_err)?; + // Dropping `tcp` closes the connection; the C++ side observes data followed by EOF. + Ok(()) +} + +pub async fn native_write_via_kj_unwrap( + stream: Pin<&mut KjAsyncIoStream>, + data: &[u8], +) -> Result<()> { + // Safety: the C++ test guarantees no I/O promises are in flight on `stream`. + let native = unsafe { kj_rs_io::unwrap_kj_stream(stream) }.map_err(KjError::from)?; + let tcp = native + .into_tcp_stream() + .ok_or_else(|| kj_err("expected a TCP stream"))?; + native_write_all(&tcp, data).await.map_err(kj_err)?; + Ok(()) +} + +pub fn create_prebound_listener_fd() -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(kj_err)?; + let port = listener.local_addr().map_err(kj_err)?.port(); + #[cfg(unix)] + { + use std::os::fd::IntoRawFd; + Ok(PreboundListener { + fd: listener.into_raw_fd(), + port, + }) + } + #[cfg(not(unix))] + { + Err(kj_err("not supported on this platform")) + } +} diff --git a/src/rust/cxx/kj-rs-io/unwrap.h b/src/rust/cxx/kj-rs-io/unwrap.h new file mode 100644 index 00000000000..d9ce7109271 --- /dev/null +++ b/src/rust/cxx/kj-rs-io/unwrap.h @@ -0,0 +1,78 @@ +#pragma once +// Declarations needed by the kj-rs-io cxx bridge (lib.rs) itself. The full C++ API lives in +// kj-rs-io/async-io.h; this header only exposes what the generated bridge code references: +// kj::AsyncIoStream (as an opaque extern C++ type), the unwrap hook, and the bridged stream +// operations backing serve_kj_stream()'s pump fallback (serve.rs). + +#include + +#include + +namespace kj_rs_io { + +struct TokioStream; // Opaque Rust type, defined in the generated lib.rs.h. + +// Recovers the native Rust stream out of a kj::AsyncIoStream created by kj-rs-io (the "unwrap +// fast path"), leaving the wrapper hollow: any further I/O through the wrapper throws. Throws if +// `stream` is not a kj-rs-io stream or was already unwrapped, or if I/O promises are still in +// flight on it (caller contract; not detected). +// +// Implemented in async-io.c++. Rust code calls this through kj_rs_io::unwrap_kj_stream(). +::rust::Box unwrapTokioStream(kj::AsyncIoStream &stream); + +// --- Bridged operations on a *foreign* kj::AsyncIoStream (one that did not originate in +// kj-rs-io and therefore cannot be unwrapped). These back the duplex-pump fallback of +// serve_kj_stream() (serve.rs): the pump owns the stream and reads and writes it through +// concurrent Rust *shared* borrows — kj two-way streams support one read and one write in +// flight at once. + +// A Rust shared borrow (&KjAsyncIoStream) arrives in C++ as a const&: the constness is cxx's +// wire format for "shared", not a property of the object — the stream is uniquely owned by the +// pump and never actually const. NOTE the meaning mismatch with KJ convention: KJ const means +// *thread-safe* (Rust's Sync), but this shared-ness is the weaker property — reentrant use from +// one thread's async tasks (the bridge types are !Send/!Sync, so Rust can never move these +// borrows off the KJ event-loop thread that owns the stream). Recover the callable reference +// here, once. +inline kj::AsyncIoStream &pumpStream(const kj::AsyncIoStream &stream) { + return const_cast(stream); +} + +// Corresponds to kj::AsyncIoStream::tryRead(buffer, minBytes, buffer.size()). +inline kj::Promise kjStreamTryRead( + const kj::AsyncIoStream &stream, ::rust::Slice buffer, size_t minBytes) { + return pumpStream(stream).tryRead(buffer.data(), minBytes, buffer.size()); +} + +// Corresponds to kj::AsyncIoStream::write(buffer) (write-all semantics). +inline kj::Promise kjStreamWrite( + const kj::AsyncIoStream &stream, ::rust::Slice buffer) { + return pumpStream(stream).write(kj::arrayPtr(buffer.data(), buffer.size())); +} + +// Corresponds to kj::AsyncIoStream::shutdownWrite(). +inline void kjStreamShutdownWrite(const kj::AsyncIoStream &stream) { + pumpStream(stream).shutdownWrite(); +} + +// The stream's underlying raw OS socket handle -- a Unix fd (kj::AsyncIoStream::getFd()) or a +// win32 SOCKET (kj::AsyncIoStream::getWin32Handle()) -- widened to int64, or -1 if it exposes +// none. int64 fits both losslessly with one sentinel: a Unix fd is a non-negative int, and +// INVALID_SOCKET (~0 as UINT_PTR) is exactly -1 as int64 (live win64 SOCKET values fit in 32 +// bits per the Windows handle-interoperability guarantee, so they never collide with -1). +// Backs the handle tier of take_kj_socket() (ffi.rs); see that function's docs for the +// caller-asserted "the handle carries the stream's own bytes" contract (wrappers such as +// kj::TlsConnection forward getFd() to their *transport* socket, which this tier must never be +// used on). +inline int64_t kjStreamGetHandle(const kj::AsyncIoStream &stream) { +#if _WIN32 + // Validated by Windows CI; mirrors the unix arm. + KJ_IF_SOME(handle, stream.getWin32Handle()) { + return static_cast(reinterpret_cast(handle)); + } + return -1; +#else + return stream.getFd().orDefault(-1); +#endif +} + +} // namespace kj_rs_io