From 698fe5182c954b86171aa9215e83606838121014 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 16 Oct 2026 23:04:33 +0800 Subject: [PATCH] Own the Linux runtime layout discovery and ship the GTK main-thread bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staged-vs-packaged runtime layout is Rivet's contract, but its discovery code was hand-copied per product (autarx AppModel.swift, taskly HostResources.swift, syncpilot discover_runtime_layout, and the Linux host template's main.cpp). rivet::linux_runtime now owns it (issue #246): discover_layout() locates runtime/*.boot + res/core.zo beside the running executable, an explicit-root overload serves tests and unusual staging, layout_error_hint() is the remediation message hosts display verbatim, and RuntimeLayout exposes stage_root, resources, and the four artifact paths. The template main.cpp consumes the library instead of carrying its own copy. GTK hosts also share one completion-marshaling shape: completions arrive on the backend reader thread and nothing there may touch GTK objects. The host template ships rpc_bridge.hpp (issue #247): unpack flattens the generated Result — including the Result specialization Void RPCs produce — into an Unpacked decided on the reader thread (no exception flow across the hop), and post_to_main runs the closure on the GTK main loop, heap-allocated on the reader thread and freed there. The template main.cpp drops its private IntResult/g_idle_add plumbing for the bridge, so a new GTK host starts with the correct pattern instead of rediscovering thread affinity the hard way. --- platform/linux/Integration/CMakeLists.txt | 8 ++ platform/linux/Integration/layout_test.cpp | 70 +++++++++++++++++ platform/linux/host/README.md | 44 ++++++++++- platform/linux/host/src/main.cpp | 88 +++++----------------- platform/linux/host/src/rpc_bridge.hpp | 71 +++++++++++++++++ platform/linux/rivet_linux_sources.cmake | 1 + platform/linux/runtime/layout.cpp | 39 ++++++++++ platform/linux/runtime/layout.hpp | 37 +++++++++ 8 files changed, 289 insertions(+), 69 deletions(-) create mode 100644 platform/linux/Integration/layout_test.cpp create mode 100644 platform/linux/host/src/rpc_bridge.hpp create mode 100644 platform/linux/runtime/layout.cpp create mode 100644 platform/linux/runtime/layout.hpp diff --git a/platform/linux/Integration/CMakeLists.txt b/platform/linux/Integration/CMakeLists.txt index e2b1e32..da060d1 100644 --- a/platform/linux/Integration/CMakeLists.txt +++ b/platform/linux/Integration/CMakeLists.txt @@ -57,6 +57,13 @@ add_executable(RivetUpdateHandoffTest target_compile_features(RivetUpdateHandoffTest PRIVATE cxx_std_17) target_link_libraries(RivetUpdateHandoffTest PRIVATE rivet::platform) target_compile_options(RivetUpdateHandoffTest PRIVATE -Wall -Wextra -Wpedantic) + +add_executable(RivetLayoutTest + layout_test.cpp +) +target_compile_features(RivetLayoutTest PRIVATE cxx_std_17) +target_link_libraries(RivetLayoutTest PRIVATE rivet::platform) +target_compile_options(RivetLayoutTest PRIVATE -Wall -Wextra -Wpedantic) target_compile_features(RivetGlobalShortcutsTest PRIVATE cxx_std_17) target_include_directories(RivetGlobalShortcutsTest PRIVATE ../system) target_compile_options(RivetGlobalShortcutsTest PRIVATE -Wall -Wextra -Wpedantic) @@ -86,3 +93,4 @@ endif() enable_testing() add_test(NAME RivetUpdateHandoffTest COMMAND RivetUpdateHandoffTest) +add_test(NAME RivetLayoutTest COMMAND RivetLayoutTest) diff --git a/platform/linux/Integration/layout_test.cpp b/platform/linux/Integration/layout_test.cpp new file mode 100644 index 0000000..c491820 --- /dev/null +++ b/platform/linux/Integration/layout_test.cpp @@ -0,0 +1,70 @@ +#include "layout.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +void stage_layout(fs::path const& root, bool with_core) { + fs::create_directories(root / "runtime"); + fs::create_directories(root / "res"); + for (char const* boot : + {"petite.boot", "scheme.boot", "racket.boot"}) { + std::ofstream out(root / "runtime" / boot); + out << boot; + } + if (with_core) { + std::ofstream out(root / "res" / "core.zo"); + out << "core"; + } +} + +} // namespace + +int main() { + auto scratch = fs::temp_directory_path() / + ("rivet-layout-test-" + std::to_string(::getpid())); + fs::create_directories(scratch); + + // A complete layout is discovered with every path anchored at the root. + auto complete = scratch / "complete"; + stage_layout(complete, true); + auto layout = rivet::linux_runtime::discover_layout(complete); + assert(layout.has_value()); + assert(layout->stage_root == complete); + assert(layout->resources == complete / "res"); + assert(layout->petite_boot == complete / "runtime" / "petite.boot"); + assert(layout->scheme_boot == complete / "runtime" / "scheme.boot"); + assert(layout->racket_boot == complete / "runtime" / "racket.boot"); + assert(layout->core == complete / "res" / "core.zo"); + + // A missing backend bundle is not a layout, with or without the boots. + auto truncated = scratch / "truncated"; + stage_layout(truncated, false); + assert(!rivet::linux_runtime::discover_layout(truncated).has_value()); + auto empty = scratch / "empty"; + fs::create_directories(empty); + assert(!rivet::linux_runtime::discover_layout(empty).has_value()); + + // The remediation hint stays displayable and names the expected files. + auto const& hint = rivet::linux_runtime::layout_error_hint(); + assert(hint.find("runtime/*.boot") != std::string::npos); + assert(hint.find("res/core.zo") != std::string::npos); + + // The executable's own path resolves on Linux (/proc/self/exe); other + // hosts throw, and this test still exercises the root-based discovery. + try { + assert(!rivet::linux_runtime::executable_path().empty()); + } catch (...) { + } + + std::error_code cleanup; + fs::remove_all(scratch, cleanup); + return 0; +} diff --git a/platform/linux/host/README.md b/platform/linux/host/README.md index 2d223e4..3d293a8 100644 --- a/platform/linux/host/README.md +++ b/platform/linux/host/README.md @@ -35,7 +35,9 @@ platform/linux/ └── host/ ├── GeneratedBackend.hpp # scaffold schema; raco rivet build replaces it ├── CMakeLists.txt - └── src/main.cpp # GTK4 counter demo over the generated client + └── src/ + ├── main.cpp # GTK4 counter demo over the generated client + └── rpc_bridge.hpp # completion → main-loop marshaling (below) ``` ## Building by hand @@ -61,6 +63,46 @@ and `res/core.zo` (produced by `raco ctool --mods`, the same artifacts every platform host consumes) next to `RivetHost`. `raco rivet build`, `dev`, and `package` create this layout automatically. +### Runtime layout discovery + +Do not re-derive the layout in application code. The platform library owns +the contract (`rivet::linux_runtime`, `layout.hpp`): + +```cpp +auto layout = rivet::linux_runtime::discover_layout(); +if (!layout.has_value()) { + show(rivet::linux_runtime::layout_error_hint()); // names the missing files +} +// layout->petite_boot / scheme_boot / racket_boot / core / stage_root +``` + +`discover_layout()` looks beside the running executable; an explicit-root +overload exists for tests and unusual staging. `raco rivet build`, `dev`, +and `package` produce exactly the tree it looks for. + +### Completions belong on the main loop + +Backend completions and events arrive on the runtime's reader thread, and +nothing in that context may touch GTK objects. Use the shipped bridge +(`src/rpc_bridge.hpp`) instead of hand-rolling `g_idle_add` plumbing: + +```cpp +#include "rpc_bridge.hpp" + +void deliver_count(rivet_app::Result result) { + rivet_app::post_to_main( + [flat = rivet_app::unpack(std::move(result))]() mutable { + if (flat.ok) { set_count(flat.value); } + else { set_status("State error: " + flat.error); } + }); +} +``` + +`unpack` flattens `Result` into `Unpacked` (value or error, decided on +the reader thread — no exception flow crosses the hop); `post_to_main` runs +the closure on the GTK main loop and frees it there. `main.cpp` shows the +pattern end to end. + ## Display policy Wayland is the primary Linux target. CI starts a headless Weston compositor, diff --git a/platform/linux/host/src/main.cpp b/platform/linux/host/src/main.cpp index dda82e3..8006d4d 100644 --- a/platform/linux/host/src/main.cpp +++ b/platform/linux/host/src/main.cpp @@ -7,15 +7,14 @@ #include #include #include -#include -#include #include #include -#include #include #include #include "GeneratedBackend.hpp" +#include "layout.hpp" +#include "rpc_bridge.hpp" #include "system_services.hpp" #include "theme.hpp" @@ -51,67 +50,19 @@ AppState g_state; void perform_orderly_shutdown(); -std::filesystem::path executable_path() { - return std::filesystem::read_symlink("/proc/self/exe"); -} - -// Rivet keeps runtime/res beside the executable in development and packages. -struct RuntimeLayout { - std::filesystem::path petite_boot; - std::filesystem::path scheme_boot; - std::filesystem::path racket_boot; - std::filesystem::path core; -}; - -std::optional discover_runtime_layout() { - std::filesystem::path const exe = executable_path(); - std::filesystem::path const roots[] = {exe.parent_path()}; - for (auto const& root : roots) { - RuntimeLayout layout{ - root / "runtime" / "petite.boot", - root / "runtime" / "scheme.boot", - root / "runtime" / "racket.boot", - root / "res" / "core.zo", - }; - if (std::filesystem::exists(layout.petite_boot) && - std::filesystem::exists(layout.scheme_boot) && - std::filesystem::exists(layout.racket_boot) && - std::filesystem::exists(layout.core)) { - return layout; - } - } - return std::nullopt; -} - -struct IntResult { - bool ok{false}; - std::int64_t value{0}; - std::string error; -}; - -int on_count_delivered(gpointer user_data) { - std::unique_ptr result(static_cast(user_data)); - if (result->ok) { - g_state.set_count(result->value); - gtk_widget_set_sensitive(GTK_WIDGET(g_state.increment), TRUE); - } else { - g_state.set_status("State error: " + result->error); - gtk_widget_set_sensitive(GTK_WIDGET(g_state.increment), TRUE); - } - return G_SOURCE_REMOVE; -} - +// One completion, flattened once, dispatched once: unpack() runs on the +// backend reader thread, post_to_main moves the flattened result to the +// GTK main loop, and the closure frees itself there (issue #247). void deliver_count_result(rivet_app::Result result) { - auto* delivered = new IntResult; - try { - delivered->value = result.get(); - delivered->ok = true; - } catch (std::exception const& error) { - delivered->error = error.what(); - } catch (...) { - delivered->error = "unknown backend failure"; - } - g_idle_add(on_count_delivered, delivered); + rivet_app::post_to_main( + [flat = rivet_app::unpack(std::move(result))]() mutable { + if (flat.ok) { + g_state.set_count(flat.value); + } else { + g_state.set_status("State error: " + flat.error); + } + gtk_widget_set_sensitive(GTK_WIDGET(g_state.increment), TRUE); + }); } int on_backend_finished(gpointer) { @@ -165,16 +116,17 @@ int on_backend_finished(gpointer) { } void start_backend() { - auto layout = discover_runtime_layout(); + // The runtime library owns the layout contract (issue #246); hosts locate + // stage roots through it instead of re-deriving the paths. + auto layout = rivet::linux_runtime::discover_layout(); if (!layout.has_value()) { - g_state.set_status( - "Missing Rivet runtime layout (runtime/*.boot, res/core.zo) next to " - "the executable. Build with raco rivet build/dev."); + g_state.set_status(rivet::linux_runtime::layout_error_hint()); return; } rivet::linux_runtime::RacketRuntimeConfig config; - config.executable_path = executable_path().string(); + config.executable_path = + rivet::linux_runtime::executable_path().string(); config.petite_boot = layout->petite_boot.string(); config.scheme_boot = layout->scheme_boot.string(); config.racket_boot = layout->racket_boot.string(); diff --git a/platform/linux/host/src/rpc_bridge.hpp b/platform/linux/host/src/rpc_bridge.hpp new file mode 100644 index 0000000..5e3f9b1 --- /dev/null +++ b/platform/linux/host/src/rpc_bridge.hpp @@ -0,0 +1,71 @@ +#pragma once + +// Main-thread bridge for GTK hosts (issue #247): backend completions and +// events arrive on the embedded runtime's reader thread, and nothing in +// that context may touch GTK objects. Hosts flatten one completion into an +// Unpacked (value or error, no exception flow across threads) and hand a +// closure to post_to_main, which runs it on the GTK main loop and frees it +// there. The template main.cpp shows the pattern end to end. + +#include + +#include +#include +#include +#include +#include +#include + +#include "GeneratedBackend.hpp" + +namespace rivet_app { + +// One completion, flattened for widget code: exactly one of value/error is +// meaningful, decided on the reader thread before the hop. +template +struct Unpacked { + bool ok{false}; + T value{}; + std::string error; +}; + +template <> +struct Unpacked { + bool ok{false}; + std::string error; +}; + +template +Unpacked unpack(Result result) { + Unpacked flat; + try { + if constexpr (!std::is_void_v) { + flat.value = result.get(); + } else { + result.get(); + } + flat.ok = true; + } catch (std::exception const& error) { + flat.error = error.what(); + } catch (...) { + flat.error = "unknown backend failure"; + } + return flat; +} + +// Runs work on the GTK main loop. The closure is heap-allocated on the +// calling (reader) thread and freed on the main thread no matter which +// GSource callback result the invocation produces. +inline void post_to_main(std::function work) { + auto* pending = new std::function(std::move(work)); + g_idle_add( + +[](gpointer user_data) -> gint { + std::unique_ptr> work( + static_cast*>(user_data)); + (*work)(); + return G_SOURCE_REMOVE; + }, + pending); +} + +} // namespace rivet_app diff --git a/platform/linux/rivet_linux_sources.cmake b/platform/linux/rivet_linux_sources.cmake index afbbcb8..757fbc8 100644 --- a/platform/linux/rivet_linux_sources.cmake +++ b/platform/linux/rivet_linux_sources.cmake @@ -10,6 +10,7 @@ include_guard(GLOBAL) set(RIVET_LINUX_PLATFORM_SOURCES "${RIVET_ROOT}/platform/linux/chart/chart_area.cpp" "${RIVET_ROOT}/platform/linux/runtime/backend.cpp" + "${RIVET_ROOT}/platform/linux/runtime/layout.cpp" "${RIVET_ROOT}/platform/linux/system/autostart.cpp" "${RIVET_ROOT}/platform/linux/system/clipboard.cpp" "${RIVET_ROOT}/platform/linux/system/crash_shutdown.cpp" diff --git a/platform/linux/runtime/layout.cpp b/platform/linux/runtime/layout.cpp new file mode 100644 index 0000000..43aa664 --- /dev/null +++ b/platform/linux/runtime/layout.cpp @@ -0,0 +1,39 @@ +#include "layout.hpp" + +namespace rivet::linux_runtime { + +std::filesystem::path executable_path() { + return std::filesystem::read_symlink("/proc/self/exe"); +} + +std::optional discover_layout( + std::filesystem::path const& stage_root) { + RuntimeLayout layout; + layout.stage_root = stage_root; + layout.resources = stage_root / "res"; + layout.petite_boot = stage_root / "runtime" / "petite.boot"; + layout.scheme_boot = stage_root / "runtime" / "scheme.boot"; + layout.racket_boot = stage_root / "runtime" / "racket.boot"; + layout.core = stage_root / "res" / "core.zo"; + std::error_code probe; + if (std::filesystem::exists(layout.petite_boot, probe) && + std::filesystem::exists(layout.scheme_boot, probe) && + std::filesystem::exists(layout.racket_boot, probe) && + std::filesystem::exists(layout.core, probe)) { + return layout; + } + return std::nullopt; +} + +std::optional discover_layout() { + return discover_layout(executable_path().parent_path()); +} + +std::string const& layout_error_hint() { + static std::string const hint = + "Missing Rivet runtime layout (runtime/*.boot, res/core.zo) next to " + "the executable. Build with raco rivet build/dev."; + return hint; +} + +} // namespace rivet::linux_runtime diff --git a/platform/linux/runtime/layout.hpp b/platform/linux/runtime/layout.hpp new file mode 100644 index 0000000..6bde453 --- /dev/null +++ b/platform/linux/runtime/layout.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +namespace rivet::linux_runtime { + +// The canonical Rivet runtime layout, in development trees and packaged +// applications alike (issue #246): runtime/*.boot plus res/core.zo under one +// stage root beside the executable. Products consume this discovery instead +// of re-deriving it — the layout is Rivet's contract, so the code that knows +// it lives here too. +struct RuntimeLayout { + std::filesystem::path stage_root; + std::filesystem::path resources; + std::filesystem::path petite_boot; + std::filesystem::path scheme_boot; + std::filesystem::path racket_boot; + std::filesystem::path core; +}; + +// Reads the running executable's own path (/proc/self/exe). +std::filesystem::path executable_path(); + +// Looks for the canonical layout beside the running executable. +std::optional discover_layout(); + +// The same check against an explicit stage root — the injectable core used +// by tests and by hosts that stage a layout somewhere else. +std::optional discover_layout( + std::filesystem::path const& stage_root); + +// The missing-layout remediation message hosts display verbatim. +std::string const& layout_error_hint(); + +} // namespace rivet::linux_runtime