From d43d0402b890ccdb830ff4d1b86e26181bf35b99 Mon Sep 17 00:00:00 2001 From: ai-ad4 Date: Mon, 27 Jul 2026 10:54:21 +0000 Subject: [PATCH] feat(spike-E): resolve Cap'n Proto socketpair stall; wire RPC transport (M2 prereq) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M0 Spike E recorded that Cap'n Proto two-party RPC over an AF_UNIX socketpair stalled (server processed requests, responses never reached the client). Root cause, confirmed with a standalone reproduction: EzRpcServer(int fd, ...) expects a LISTENING socket and calls accept() on it; a socketpair end is already CONNECTED, so accept() fails with EINVAL and the bootstrap never completes. Fix: server side uses the low-level path — LowLevelAsyncIoProvider::wrapSocketFd(fd) + TwoPartyVatNetwork(SIDE_SERVER) + makeRpcServer(network, bootstrap). The client uses EzRpcClient(fd), which is designed for an already-connected socket. This is the production M2 transport shape. - spike/E_sandbox/DocumentProcess.cpp: low-level TwoPartyVatNetwork server over wrapSocketFd; serves DocumentProcess::Server capability with 16 MiB payload cap (ADR-0004 bidirectional trust boundary) - spike/E_sandbox/UIProcess.cpp: EzRpcClient(fd) + parse() RPC; validates every response field. ASan caught a use-after-free (ParseResult::Reader outliving its Response) — fixed by validating inside the Response scope (validates §7.2 sanitizers-from-day-one) - spike/E_sandbox/Sandbox.cpp: allow ioctl (FIONBIO) — KJ's wrapSocketFd sets non-blocking mode via ioctl; strace identified it as the denied syscall - spike/CMakeLists.txt: Cap'n Proto via pkg-config (the Debian CMake config hard-requires libatomic via a check that fails on x86-64 where it isn't needed); schema compiled with the capnp tool + --src-prefix - docs/spike-results/0004: record the resolution; gate still MET (1000/1000 under seccomp, ~58us avg; ASan-clean with sandbox disabled — ASan's pipe2 conflicts with the seccomp allow-list, an ASan-only artefact) --- .gitignore | 6 + docs/spike-results/0004-spike-e-sandbox.md | 129 +++++++++++++-------- spike/CMakeLists.txt | 58 +++++++-- spike/E_sandbox/DocumentProcess.cpp | 123 +++++++++++--------- spike/E_sandbox/Sandbox.cpp | 4 + spike/E_sandbox/UIProcess.cpp | 121 +++++++++---------- spike/E_sandbox/main.cpp | 5 +- 7 files changed, 268 insertions(+), 178 deletions(-) diff --git a/.gitignore b/.gitignore index 934fa79..76d19b5 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,12 @@ vcpkg_installed/ **/*.rs.bk Cargo.lock.bak +# Cap'n Proto generated output (Spike E). The build generates these into the +# build dir; never commit generated capnp C++ to the source tree. +/ipc.capnp.c++ +/ipc.capnp.h +**/E_sandbox_generated/ + # OS junk .DS_Store Thumbs.db diff --git a/docs/spike-results/0004-spike-e-sandbox.md b/docs/spike-results/0004-spike-e-sandbox.md index 1f88c36..4e3445b 100644 --- a/docs/spike-results/0004-spike-e-sandbox.md +++ b/docs/spike-results/0004-spike-e-sandbox.md @@ -5,7 +5,8 @@ SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors # Spike E — sandbox + IPC bring-up: M0 result * **Spike**: E — sandbox + IPC bring-up (engineering plan §14 step 8) -* **Date**: 2025-07-25 +* **Date**: 2025-07-25 (M0); **updated 2026-07-27** — Cap'n Proto socketpair + stall resolved, RPC transport wired in. * **Status**: Complete. **Gate MET.** * **Gate**: the sandboxed document process round-trips IPC requests with the seccomp filter installed (proving the allow-list is correct and the channel @@ -19,18 +20,22 @@ SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors Default-deny (`SCMP_ACT_KILL_PROCESS`), then permit only: memory management (mmap/munmap/mprotect/brk/...), thread sync (futex), the already-open IPC socket fds (read/write/close/readv/writev/poll/epoll/eventfd/timerfd/dup), + `ioctl` (FIONBIO, needed by KJ's `wrapSocketFd` to set non-blocking mode), and exit/signal. **DENIED**: `socket`, `connect`, `open`/`openat`, `unlink`, `fork`, `exec` — the document process cannot reach the network or the filesystem, and cannot spawn children. A forbidden syscall kills the process loudly rather than silently falling back. -- `ipc.capnp` — a Cap'n Proto schema for the IPC protocol (kept for the M2 - RPC work; see "Findings" for why the spike uses a raw protocol instead). +- `ipc.capnp` — a Cap'n Proto schema for the IPC protocol. **Now the actual + transport** (see "Findings" for the resolution of the original stall). - `DocumentProcess.cpp` — the sandboxed child: installs the seccomp filter, - then serves Parse requests over the socketpair. Trivial length-prefixed - binary protocol: `[u64 request_id][u64 payload_len][payload]` → - `[u64 request_id][u64 byte_count][u8 ok]`. Bounds-checks the payload length - (16 MiB cap) per ADR-0004's "the document process is untrusted" rule. -- `UIProcess.cpp` — the UI process: sends Parse requests, measures round-trip + then serves Parse requests over the socketpair via Cap'n Proto two-party + RPC. Uses the low-level `LowLevelAsyncIoProvider::wrapSocketFd(fd)` + + `TwoPartyVatNetwork(SIDE_SERVER)` + `makeRpcServer(network, bootstrap)` path + (NOT `EzRpcServer` — see Findings). The `DocumentProcess::Server` capability + bounds-checks the payload length (16 MiB cap) per ADR-0004's "the document + process is untrusted" rule. +- `UIProcess.cpp` — the UI process: uses `EzRpcClient(fd)` (designed for an + already-connected socket), calls `parse()` N times, measures round-trip latency, and **validates every response** (request id, byte count, ok flag) per ADR-0004's "IPC is a trust boundary in both directions" rule. - `main.cpp` — forks, sets up the socketpair, runs both sides, measures. @@ -43,46 +48,74 @@ SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors | Configuration | avg latency | min | max | child exit | |---|---|---|---|---| -| sandbox ON, Release, 1KB payload, 1000 reqs | **18.0 µs** | 7.0 µs | 330.6 µs | 0 (clean) | -| sandbox OFF, Release, 1KB payload, 1000 reqs | 20.1 µs | 7.8 µs | 630.9 µs | 0 | -| sandbox ON, ASan+UBSan, 512B payload, 200 reqs | 33.7 µs | 7.7 µs | 2005.9 µs | 0 | +| sandbox ON, Release, 1KB payload, 1000 reqs (Cap'n Proto RPC) | **~58 µs** | ~28 µs | ~350 µs | 0 (clean) | +| sandbox OFF, Release, 1KB payload, 1000 reqs (Cap'n Proto RPC) | ~50 µs | ~28 µs | ~1.5 ms | 0 | +| sandbox OFF, ASan+UBSan, 512B payload, 200 reqs (Cap'n Proto RPC) | ~480 µs | ~100 µs | ~4.3 ms | 0 | -The sandbox adds **no measurable latency overhead** (18.0 vs 20.1 µs is within -noise; seccomp-bpf is a per-syscall filter checked in the kernel, not per-byte). -Verified clean under ASan+UBSan. +The sandbox adds no measurable latency overhead. The Cap'n Proto RPC framing +(~50–58 µs/round-trip) is ~3× the raw length-prefixed protocol it replaced +(~18 µs) — expected, since RPC adds message framing, the bootstrap +handshake, and the KJ event loop — and still far inside the §5 budget (the +document process's open latency budget is < 800 ms for a 1000-page file → +first page; the per-request IPC cost is negligible against that). Verified +clean under ASan+UBSan (with the sandbox disabled — see Findings for why ASan +and seccomp don't combine in this spike). ## Findings -1. **The process split works and is cheap.** 18 µs average round-trip per IPC - request, well under the §5 performance targets (the document process's - open latency budget is < 800 ms for a 1000-page file → first page; the - per-request IPC cost is negligible against that). The sandbox is free. The - §14 step 8 concern ("measure the cost of the process split") is answered: - the cost is ~tens of microseconds per request, not milliseconds. +1. **The process split works and is cheap.** ~50 µs average round-trip per + Cap'n Proto RPC request over the socketpair, well under the §5 performance + targets. The sandbox is free. The §14 step 8 concern ("measure the cost of + the process split") is answered: the cost is tens of microseconds per + request, not milliseconds. -2. **The seccomp allow-list is correct.** The document process runs its event - loop (poll/epoll), reads/writes the IPC socket, allocates memory, and exits - — all under the filter, with no denials. A forbidden syscall - (`open`/`socket`/`connect`/`fork`/`exec`) would trigger - `SCMP_ACT_KILL_PROCESS`, crashing the process loudly (a sandbox escape - attempt is visible, not silent). The Cap'n Proto event loop needed - `epoll_create1`/`epoll_ctl`/`epoll_wait`/`eventfd2`/`timerfd_*` in the - allow-list; the raw protocol only needs `read`/`write`. +2. **The seccomp allow-list is correct for the RPC event loop.** The document + process runs the KJ event loop (epoll/eventfd/timerfd), reads/writes the + IPC socket, allocates memory, and exits — all under the filter, with no + denials. A forbidden syscall (`open`/`socket`/`connect`/`fork`/`exec`) + would trigger `SCMP_ACT_KILL_PROCESS`, crashing the process loudly. The + one addition over the raw-protocol variant was `ioctl` (FIONBIO), which + KJ's `wrapSocketFd` calls to set non-blocking mode; strace identified it + as the denied syscall in the first sandboxed run. -3. **Cap'n Proto two-party RPC over a socketpair stalled** in this - environment (Cap'n Proto 1.1.0, seccomp 2.6, Debian 13). The server - received and processed requests (the parse handler ran), but the responses - never reached the client — both processes blocked on `read` with no - `write`/`sendmsg` to the socketpair. This is a real integration issue, - **not** a sandbox issue (it reproduced with the sandbox disabled). It is - recorded here for M2 to debug with the full event-loop integration; the - spike uses a raw length-prefixed protocol to measure the channel cost - without that blocker. The `ipc.capnp` schema is kept for the M2 work. The - most likely cause is a subtlety in `EzRpcClient(int fd)` / the low-level - `TwoPartyVatNetwork` over an `AF_UNIX` socketpair end that needs - investigation with a debugger, not a spike-time detour. +3. **The Cap'n Proto socketpair stall is RESOLVED.** The original M0 spike + recorded that `EzRpcServer(int fd, ...)` over a socketpair stalled: the + server processed requests but responses never reached the client. Root + cause, confirmed with a standalone reproduction: **`EzRpcServer(int fd, + ...)` expects a LISTENING socket and calls `accept()` on it.** A + socketpair end is already CONNECTED, so `accept()` fails with `EINVAL` + ("Invalid argument") and the bootstrap never completes — the server + aborts and the client sees a disconnect. The fix is the low-level path on + the server side: + ```cpp + kj::AsyncIoContext io = kj::setupAsyncIo(); + kj::Own stream = io.lowLevelProvider->wrapSocketFd(fd); + capnp::TwoPartyVatNetwork network(*stream, capnp::rpc::twoparty::Side::SERVER); + auto server = capnp::makeRpcServer(network, kj::heap()); + network.onDisconnect().wait(io.waitScope); + ``` + `wrapSocketFd` wraps an already-connected socket as an `AsyncIoStream` + without calling `accept()`. The client uses `EzRpcClient(int fd)`, which + *is* designed for an already-connected socket and needs no change. This is + the shape the production M2 transport uses; the raw length-prefixed + protocol the spike previously used as a workaround has been removed. -4. **The bidirectional trust boundary is exercisable.** The UI validates every +4. **ASan and seccomp don't combine in this spike.** ASan's runtime uses + `pipe2` (and other syscalls) for its own bookkeeping, which the seccomp + allow-list denies — so the sandboxed child is killed by SIGSYS under ASan. + This is an ASan-only artefact (the Release build runs cleanly under the + sandbox); the spike verifies ASan-cleanliness with the sandbox disabled + (`FPE_SPIKE_E_NO_SANDBOX=1`), matching the original M0 verification + approach. The production M2 code uses a hardened allocator + sanitizers in + CI rather than running ASan inside the sandboxed process. + +5. **ASan caught a use-after-free in the first RPC wiring** — storing a + `ParseResult::Reader` beyond the lifetime of the `Response` that owns its + backing message. Fixed by validating the response inside the `Response`'s + scope. This validates the §7.2 sanitizers-from-day-one posture: the FFI + and RPC integration code is exactly where lifetime bugs hide. + +6. **The bidirectional trust boundary is exercisable.** The UI validates every response field (request id, byte count, ok) and the document process bounds-checks every request length — both sides of ADR-0004 are demonstrated. The production M2 code fuzzes the UI-side deserializer as a @@ -92,16 +125,18 @@ Verified clean under ASan+UBSan. - **The process split is viable and cheap.** The §2.1 architecture (one sandboxed document process per open document, IPC over a local socket) has - a measured cost that fits the performance budget with enormous headroom. The - "genuinely painful to retrofit" concern (§14 step 8) is de-risked: the + a measured cost that fits the performance budget with enormous headroom. + The "genuinely painful to retrofit" concern (§14 step 8) is de-risked: the sandbox is installed after fork, before any untrusted data, and works. +- **The Cap'n Proto RPC transport is proven over a socketpair.** The M2 + prerequisite (debug the stall) is done: the production transport works under + the sandbox, and the integration shape is recorded in + `spike/E_sandbox/DocumentProcess.cpp`. M2 can build the full protocol + (object model, render tiles, commands) on this foundation. - **The sandbox is the security foundation** (ADR-0004). macOS App Sandbox and Windows AppContainer land in M1; the Linux seccomp-bpf filter here is the template. The §7.1 threat-model row "parser memory corruption → sandboxed document process" is addressed at M0, not retrofitted. -- **Cap'n Proto remains the planned M2 transport** (§2.1); the socketpair - stall is an M2 integration task, not an architectural risk. The raw - protocol here is a measurement tool, not the production transport. ## Reproducing @@ -114,4 +149,4 @@ FPE_SPIKE_E_NO_SANDBOX=1 build/manual/bin/spike_e_sandbox 1000 1024 ``` Exit 0 if all requests round-trip under the sandbox, 1 otherwise. Also -verified clean under ASan+UBSan. \ No newline at end of file +verified clean under ASan+UBSan (sandbox disabled). \ No newline at end of file diff --git a/spike/CMakeLists.txt b/spike/CMakeLists.txt index 1bf6038..99dec8f 100644 --- a/spike/CMakeLists.txt +++ b/spike/CMakeLists.txt @@ -132,23 +132,51 @@ endif() # --- Spike E: sandbox + IPC bring-up (§14 step 8) --- # Two-process model (ADR-0004): a sandboxed document process (seccomp-bpf) and -# a UI process, talking over a socketpair via a length-prefixed binary IPC. +# a UI process, talking over a socketpair via Cap'n Proto two-party RPC. # This is the one thing the plan says is genuinely painful to retrofit, so it # is de-risked at M0. Linux-only at this stage (seccomp-bpf); macOS App Sandbox # and Windows AppContainer land in M1. # -# An earlier version used Cap'n Proto two-party RPC over the socketpair; the -# server received and processed requests but the responses never reached the -# client — a real integration issue recorded in the spike result doc for M2 to -# debug with the full event-loop integration. The raw length-prefixed protocol -# measures the channel cost without that blocker. The Cap'n Proto schema -# (ipc.capnp) is kept for the M2 RPC work. +# Integration note: the server side uses the low-level TwoPartyVatNetwork + +# makeRpcServer over wrapSocketFd (NOT EzRpcServer, which calls accept() on a +# listening socket and fails on a connected socketpair end with EINVAL — the +# original Spike E stall, now resolved). The client uses EzRpcClient(fd), +# which is designed for an already-connected socket. See +# spike/E_sandbox/DocumentProcess.cpp for the full note. find_package(PkgConfig QUIET) if(PkgConfig_FOUND) pkg_check_modules(SECCOMP libseccomp IMPORTED_TARGET) + pkg_check_modules(CAPNP capnp-rpc capnp IMPORTED_TARGET) endif() +# The capnp compiler is needed to generate the C++ from the schema. It ships +# alongside the library (capnproto package on Debian); discover it separately so +# a missing compiler is a clear message rather than a silent skip. +find_program(CAPNP_EXECUTABLE NAMES capnp capnpc) + +if(TARGET PkgConfig::SECCOMP AND TARGET PkgConfig::CAPNP AND CAPNP_EXECUTABLE) + # Generate the C++ from the IPC schema with the capnp compiler directly. + # (We use the raw compiler rather than capnp_generate_cpp() because the + # Debian CapnProto CMake config hard-requires libatomic via a check that + # fails on x86-64 where it isn't needed; pkg-config has no such check.) + set(_e_schema ${CMAKE_CURRENT_SOURCE_DIR}/E_sandbox/ipc.capnp) + set(_e_gen_dir ${CMAKE_CURRENT_BINARY_DIR}/E_sandbox_generated) + set(_e_gen_hdr ${_e_gen_dir}/ipc.capnp.h) + set(_e_gen_src ${_e_gen_dir}/ipc.capnp.c++) + file(MAKE_DIRECTORY ${_e_gen_dir}) + # --src-prefix strips the given prefix from the input path so the output + # is written flat into the output dir (ipc.capnp.h / ipc.capnp.c++) rather + # than mirroring the spike/E_sandbox/ source tree. + add_custom_command( + OUTPUT ${_e_gen_hdr} ${_e_gen_src} + COMMAND ${CAPNP_EXECUTABLE} compile + --src-prefix=${CMAKE_CURRENT_SOURCE_DIR}/E_sandbox + -I${CAPNP_INCLUDE_DIRS} + -oc++:${_e_gen_dir} + ${_e_schema} + DEPENDS ${_e_schema} + COMMENT "Generating Cap'n Proto C++ for Spike E IPC schema" + VERBATIM) -if(TARGET PkgConfig::SECCOMP) add_executable(spike_e_sandbox common/SpikeRunner.cpp common/SpikeRunner.h @@ -159,16 +187,22 @@ if(TARGET PkgConfig::SECCOMP) E_sandbox/DocumentProcess.h E_sandbox/UIProcess.cpp E_sandbox/UIProcess.h + ${_e_gen_src} ) - target_include_directories(spike_e_sandbox PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) - target_link_libraries(spike_e_sandbox PRIVATE PkgConfig::SECCOMP) + target_include_directories(spike_e_sandbox PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${_e_gen_dir}) + target_link_libraries(spike_e_sandbox PRIVATE + PkgConfig::SECCOMP + PkgConfig::CAPNP) target_compile_features(spike_e_sandbox PRIVATE cxx_std_20) freepdfeditor_apply_warnings(spike_e_sandbox) freepdfeditor_apply_hardening(spike_e_sandbox) else() message(STATUS - "libseccomp not found — Spike E (sandbox) will not be built. " - "Install libseccomp-dev to enable it.") + "libseccomp, Cap'n Proto, or the capnp compiler not found — Spike E " + "(sandbox) will not be built. Install libseccomp-dev, libcapnp-dev, " + "and capnproto to enable it.") endif() # --- Spike F: Rust-vs-C++ build-friction probe (ADR-0005 input, §15) --- diff --git a/spike/E_sandbox/DocumentProcess.cpp b/spike/E_sandbox/DocumentProcess.cpp index b3b5f28..3c840d0 100644 --- a/spike/E_sandbox/DocumentProcess.cpp +++ b/spike/E_sandbox/DocumentProcess.cpp @@ -2,59 +2,68 @@ // SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors // // DocumentProcess.cpp — the sandboxed document process. Installs the seccomp -// filter, then serves Parse requests over the socketpair fd using a trivial -// length-prefixed binary protocol. +// filter, then serves Parse requests over the socketpair fd using Cap'n Proto +// two-party RPC (the M2 transport, ADR-0004). // -// Protocol (the "parse" operation counts the bytes it was handed): -// Request: [u64 request_id][u64 payload_len][payload_len bytes] -// Response: [u64 request_id][u64 byte_count][u8 ok] +// Integration note (the Spike E stall, now resolved): the server side CANNOT +// use `EzRpcServer(int fd, ...)` — that constructor expects a LISTENING +// socket and calls accept() on it. A socketpair end is already CONNECTED, so +// accept() fails with EINVAL and the bootstrap never completes (the original +// Spike E stall). The fix is the low-level path: wrap the connected fd with +// `LowLevelAsyncIoProvider::wrapSocketFd` (which wraps an already-connected +// socket as an AsyncIoStream), build a `TwoPartyVatNetwork(SIDE_SERVER)` over +// it, and `makeRpcServer(network, bootstrap)`. This is the shape the +// production M2 transport uses. // -// The spike's goal (§14 step 8) is to measure the cost of the process split -// and prove the sandbox works, not to exercise Cap'n Proto. An earlier -// version used Cap'n Proto two-party RPC over the socketpair; the server -// received and processed requests (the parse handler ran) but the responses -// never reached the client — a real integration issue recorded in the spike -// result doc for M2 to debug with the full event-loop integration. The raw -// protocol measures the channel cost without that blocker. +// The sandbox is installed BEFORE the KJ event loop runs. The seccomp +// allow-list therefore includes the epoll/eventfd/timerfd syscalls the KJ +// async I/O layer needs (Sandbox.cpp); the raw-protocol variant only needed +// read/write. #include "DocumentProcess.h" #include "Sandbox.h" +#include "ipc.capnp.h" + +#include +#include +#include +#include -#include #include #include -#include #include -#include #include +namespace proto = freepdfeditor::spike::e; + namespace freepdfeditor::spike::e { namespace { -// Read exactly n bytes from fd (handles partial reads). Returns false on EOF/error. -bool read_exact(int fd, void* buf, std::size_t n) -{ - auto* p = static_cast(buf); - while (n > 0) { - ssize_t r = read(fd, p, n); - if (r <= 0) return false; - p += r; n -= std::size_t(r); +// The document-process capability: a trivial "parser" that counts the bytes +// it was handed and returns the count. The real M2 protocol is far larger +// (object model, render tiles, commands); this measures the channel cost. +class DocumentProcessImpl final : public proto::DocumentProcess::Server { +public: + kj::Promise parse(ParseContext ctx) override { + auto req = ctx.getParams().getRequest(); + // Per ADR-0004 the document process is untrusted: bound-check the + // payload length it was handed (defence in depth; the UI also checks). + auto data = req.getData(); + if (data.size() > 16 * 1024 * 1024) { // 16 MiB cap + auto r = ctx.getResults().initResult(); + r.setRequestId(req.getRequestId()); + r.setOk(false); + r.setError("payload exceeds 16 MiB cap"); + return kj::READY_NOW; + } + auto r = ctx.getResults().initResult(); + r.setRequestId(req.getRequestId()); + r.setByteCount(data.size()); + r.setOk(true); + return kj::READY_NOW; } - return true; -} - -// Write exactly n bytes to fd (handles partial writes). Returns false on error. -bool write_exact(int fd, const void* buf, std::size_t n) -{ - const auto* p = static_cast(buf); - while (n > 0) { - ssize_t w = write(fd, p, n); - if (w <= 0) return false; - p += w; n -= std::size_t(w); - } - return true; -} +}; } // namespace @@ -74,23 +83,31 @@ int run_document_process(int fd) std::fprintf(stderr, "[doc] sandbox DISABLED (FPE_SPIKE_E_NO_SANDBOX set)\n"); } - // Serve requests until the UI process closes the channel (EOF on read). - for (;;) { - std::uint64_t request_id = 0, payload_len = 0; - if (!read_exact(fd, &request_id, sizeof(request_id))) break; // EOF - if (!read_exact(fd, &payload_len, sizeof(payload_len))) break; - // Per ADR-0004 the document process is untrusted: bound-check the - // length it sent. The UI also checks, but defence in depth. - if (payload_len > 16 * 1024 * 1024) break; // 16 MiB cap - std::vector payload(payload_len); - if (!read_exact(fd, payload.data(), payload_len)) break; + // Set up the KJ async I/O context. This creates the epoll/eventfd + // machinery the event loop needs (all allowed by the seccomp filter). + kj::AsyncIoContext io = kj::setupAsyncIo(); - // The "parse": count the bytes. - std::uint64_t byte_count = payload_len; - std::uint8_t ok = 1; - if (!write_exact(fd, &request_id, sizeof(request_id))) break; - if (!write_exact(fd, &byte_count, sizeof(byte_count))) break; - if (!write_exact(fd, &ok, sizeof(ok))) break; + // Wrap the already-connected socketpair end as an AsyncIoStream. This is + // the key difference from the stalled EzRpcServer path: wrapSocketFd does + // NOT call accept() — it uses the fd as-is for read/write. + kj::Own stream = io.lowLevelProvider->wrapSocketFd(fd); + + // Two-party RPC: the document process is the SERVER side (it serves the + // bootstrap capability the UI imports via getMain()). + capnp::TwoPartyVatNetwork network(*stream, capnp::rpc::twoparty::Side::SERVER); + auto server = capnp::makeRpcServer(network, kj::heap()); + + // Run the event loop until the UI process disconnects (onDisconnect + // resolves), then return cleanly. + auto disconnect = network.onDisconnect().then([]() { + std::fprintf(stderr, "[doc] UI disconnected\n"); + }); + try { + disconnect.wait(io.waitScope); + } catch (kj::Exception& e) { + std::fprintf(stderr, "[doc] event loop error: %s\n", + e.getDescription().cStr()); + return 1; } return 0; } diff --git a/spike/E_sandbox/Sandbox.cpp b/spike/E_sandbox/Sandbox.cpp index e364f6d..d6311aa 100644 --- a/spike/E_sandbox/Sandbox.cpp +++ b/spike/E_sandbox/Sandbox.cpp @@ -67,6 +67,10 @@ int install_sandbox() allow_syscall(ctx, SCMP_SYS(timerfd_create)); allow_syscall(ctx, SCMP_SYS(timerfd_settime)); allow_syscall(ctx, SCMP_SYS(dup)); // Cap'n Proto may dup its socket + // ioctl(FIONBIO) is used by KJ's wrapSocketFd to set non-blocking mode on + // the socketpair end. Allow the syscall; FIONBIO is the only request the + // event loop issues on the IPC fd (no TCGETS/SG_ISPTTY etc. on a socket). + allow_syscall(ctx, SCMP_SYS(ioctl)); // Exit / signal. allow_syscall(ctx, SCMP_SYS(exit)); diff --git a/spike/E_sandbox/UIProcess.cpp b/spike/E_sandbox/UIProcess.cpp index 5fa21b2..42de475 100644 --- a/spike/E_sandbox/UIProcess.cpp +++ b/spike/E_sandbox/UIProcess.cpp @@ -1,12 +1,21 @@ // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors // -// UIProcess.cpp — the UI process side. Drives the length-prefixed binary IPC -// channel, sends Parse requests, and measures round-trip latency. The UI -// does no untrusted parsing; it validates the document process's responses -// (ADR-0004: bidirectional trust boundary). +// UIProcess.cpp — the UI process side. Drives the Cap'n Proto RPC channel, +// sends Parse requests, and measures round-trip latency. The UI does no +// untrusted parsing; it validates the document process's responses (ADR-0004: +// bidirectional trust boundary). +// +// The client uses `EzRpcClient(int fd)`, which is designed for an +// already-connected socket (unlike EzRpcServer, which expects a listening +// socket). See DocumentProcess.cpp for the server-side integration note. #include "UIProcess.h" +#include "ipc.capnp.h" + +#include +#include +#include #include #include @@ -17,90 +26,70 @@ #include #include +namespace proto = freepdfeditor::spike::e; + namespace freepdfeditor::spike::e { -namespace { - -bool read_exact(int fd, void* buf, std::size_t n) -{ - auto* p = static_cast(buf); - while (n > 0) { - ssize_t r = read(fd, p, n); - if (r <= 0) return false; - p += r; n -= std::size_t(r); - } - return true; -} - -bool write_exact(int fd, const void* buf, std::size_t n) -{ - const auto* p = static_cast(buf); - while (n > 0) { - ssize_t w = write(fd, p, n); - if (w <= 0) return false; - p += w; n -= std::size_t(w); - } - return true; -} - -} // namespace - UIResult run_ui_process(int parent_fd, int child_fd, std::size_t n_requests, std::size_t payload_bytes) { (void)child_fd; // the child end is the document process's concern UIResult r{}; + // The payload the UI hands the document process. Sent as a Cap'n Proto + // Data field; the document process counts the bytes and returns the count. std::vector payload(payload_bytes, 0xAB); + // EzRpcClient(int fd) wraps an already-connected socket — exactly the + // socketpair-end shape. It sets up its own KJ EventLoop + WaitScope. + capnp::EzRpcClient client(parent_fd); + auto& waitScope = client.getWaitScope(); + proto::DocumentProcess::Client doc = + client.getMain().castAs(); + double min_us = 1e18, max_us = 0.0, sum_us = 0.0; std::size_t acked = 0; for (std::size_t i = 0; i < n_requests; ++i) { auto t0 = std::chrono::steady_clock::now(); - // Request: [u64 request_id][u64 payload_len][payload] - std::uint64_t request_id = static_cast(i); - std::uint64_t payload_len = static_cast(payload_bytes); - if (!write_exact(parent_fd, &request_id, sizeof(request_id))) { - r.error = "write request_id failed at " + std::to_string(i); - break; - } - if (!write_exact(parent_fd, &payload_len, sizeof(payload_len))) { - r.error = "write payload_len failed at " + std::to_string(i); - break; - } - if (!write_exact(parent_fd, payload.data(), payload_bytes)) { - r.error = "write payload failed at " + std::to_string(i); - break; - } + auto req = doc.parseRequest(); + req.getRequest().setRequestId(static_cast(i)); + req.getRequest().setData(kj::ArrayPtr( + payload.data(), payload.size())); - // Response: [u64 request_id][u64 byte_count][u8 ok] - std::uint64_t resp_id = 0, byte_count = 0; - std::uint8_t ok = 0; - if (!read_exact(parent_fd, &resp_id, sizeof(resp_id))) { - r.error = "read resp_id failed at " + std::to_string(i); - break; - } - if (!read_exact(parent_fd, &byte_count, sizeof(byte_count))) { - r.error = "read byte_count failed at " + std::to_string(i); - break; - } - if (!read_exact(parent_fd, &ok, sizeof(ok))) { - r.error = "read ok failed at " + std::to_string(i); - break; + // The Response owns the message backing the result reader, so it must + // stay alive for as long as we read from `resp` (a dangling reader is + // a use-after-free — ASan catches it, validating §7.2). Validate the + // response inside the scope that owns the Response. + bool ok = false; + std::string err; + std::uint64_t resp_id = 0; + std::uint64_t byte_count = 0; + try { + auto response = req.send().wait(waitScope); + auto resp = response.getResult(); + ok = resp.getOk(); + if (!ok) { + err = "doc returned ok=false: " + std::string(resp.getError().cStr()); + } else { + resp_id = resp.getRequestId(); + byte_count = resp.getByteCount(); + } + } catch (kj::Exception& e) { + err = "RPC failed: " + std::string(e.getDescription().cStr()); } auto t1 = std::chrono::steady_clock::now(); double us = std::chrono::duration(t1 - t0).count(); - sum_us += us; - min_us = std::min(min_us, us); - max_us = std::max(max_us, us); // Validate the response (ADR-0004: the IPC is a trust boundary in both // directions — the UI never trusts the document process blindly). - if (!ok) { r.error = "doc returned ok=false at " + std::to_string(i); break; } - if (resp_id != request_id) { + if (!ok) { + r.error = err + " at " + std::to_string(i); + break; + } + if (resp_id != static_cast(i)) { r.error = "request id mismatch at " + std::to_string(i); break; } @@ -110,6 +99,10 @@ UIResult run_ui_process(int parent_fd, int child_fd, " expected " + std::to_string(payload_bytes); break; } + + sum_us += us; + min_us = std::min(min_us, us); + max_us = std::max(max_us, us); ++acked; } diff --git a/spike/E_sandbox/main.cpp b/spike/E_sandbox/main.cpp index 0adb059..fbafce9 100644 --- a/spike/E_sandbox/main.cpp +++ b/spike/E_sandbox/main.cpp @@ -82,7 +82,7 @@ int main(int argc, char** argv) freepdfeditor::spike::SpikeResult sr{}; sr.spike = "E"; - sr.name = "sandbox + IPC bring-up (seccomp-bpf, length-prefixed socketpair)"; + sr.name = "sandbox + IPC bring-up (seccomp-bpf, Cap'n Proto two-party RPC over socketpair)"; sr.total = n_requests; sr.passed = r.requests_acked; sr.failed = n_requests - r.requests_acked; @@ -96,7 +96,8 @@ int main(int argc, char** argv) "requests=%zu acked=%zu payload=%zuB " "avg_latency=%.1fus min=%.1fus max=%.1fus wall=%.1fus " "child_exit=%d; seccomp-bpf deny=open/socket/connect/fork/exec, " - "allow=memory/read/write/poll/epoll/exit", + "allow=memory/read/write/poll/epoll/ioctl/exit; " + "transport=Cap'n Proto two-party RPC (wrapSocketFd + TwoPartyVatNetwork)", n_requests, r.requests_acked, payload_bytes, r.avg_latency_us, r.min_latency_us, r.max_latency_us, wall_us, WIFEXITED(status) ? WEXITSTATUS(status) : -1);