diff --git a/.github/skills/cruntime-module-authoring/SKILL.md b/.github/skills/cruntime-module-authoring/SKILL.md index 9f082f1..b1c5001 100644 --- a/.github/skills/cruntime-module-authoring/SKILL.md +++ b/.github/skills/cruntime-module-authoring/SKILL.md @@ -176,15 +176,31 @@ add_subdirectory() - Fault flags and status ## Key Rules -1. **Aggregate structs only** — No constructors, no private members, no virtual methods in data structs +1. **Aggregate data structs** — Config/Recipe/Runtime themselves must be aggregates (no constructors, private members, or virtual methods *at the top level*). They MAY contain non-aggregate members — function blocks, `loom::Sequence`, any class with glaze metadata — as long as each such member is reflectable (see [Reflection & Debuggability](#reflection--debuggability-expose-everything-by-design)). Default *member* initializers are fine, and are required for members with no default constructor. 2. **Default initializers** — Always provide sensible defaults -3. **No raw pointers** in data structs — use `std::string`, `std::vector`, `std::optional` +3. **No raw pointers** in data structs — use `std::string`, `std::vector`, `std::optional`. A reflectable member may *internally* hold handles/pointers (a `CommandClient`, a status `shared_ptr`); that's fine as long as its metadata omits them. 4. **Keep cyclic() fast** — No blocking I/O, no allocations, no exceptions 5. **Use longRunning() for async work** — File I/O, network, heavy computation 6. **Unique module names** — The `name` field in `moduleHeader()` must be unique across all modules 7. **Prefer typed bus helpers** — `publishLocal()`, `subscribeTo()`, `registerLocalService()`, and typed `callService()` automatically serialize and deserialize aggregate payloads via glaze 8. **Namespace Bus addresses** — Use `publishLocal()`, `registerLocalService()` etc. which auto-prefix with the module instance ID. Use `bus_->publish()`/`bus_->subscribe()` for global topics. +## Reflection & Debuggability (expose everything by design) + +Every Config/Recipe/Runtime field is reflected, and the IDE lets a developer **read and write** all of them. This is intentional — reflection is for **debuggability**, not just the HMI (the HMI comes for free on top). A developer can set a function block's `execute`, flip an `enable`, or nudge a setpoint live to drive/step a module while developing. **Expose everything; don't hide internal state to "protect" it** — the open surface is the point. + +So **put framework objects directly in the Runtime** instead of hand-mirroring their state — e.g. a `loom::Sequence` and the PLCopen `MC_*` function blocks dropped straight into the Runtime struct reflect their own live I/O (step/elapsed, execute/busy/done/error), with no mirror fields. + +**Make a custom type reflectable** — it needs glaze metadata, one of: +- **External** `glz::meta` in a separate `*_glaze.h` (keeps the core control header glaze-free — `command.h` itself stays glaze-free; the opt-in metadata lives in ``). Function blocks reuse the base field-list macros — `LOOM_COMMAND_FB_FIELDS(T)` / `LOOM_ENABLE_FB_FIELDS(T)` / `LOOM_IFUNCTION_BLOCK_FIELDS(T)` — then add their own fields. +- **In-class** `struct glaze { static constexpr auto value = glz::object(...); };` for types you own (it can reach **private** members). Declare it **after** the data members — a static-member initializer isn't a complete-class context, so earlier-declared members aren't visible yet. + +**Gotchas (these bit us — do not repeat):** +- **Reflect only real, assignable data members.** Do *not* use value-returning getter lambdas or member-function pointers in meta: member-function pointers **silently serialize nothing**, and a value-returning getter makes `glz::read` **fail to compile** — and the SDK deserializes the *whole* Runtime, so one bad field breaks the entire module's build. To expose a private/computed value, use an in-class `struct glaze` with **data-member pointers**. +- **Enums serialize as integers** unless you provide `glz::meta` with `glz::enumerate("name", E::Value, ...)` for readable names. +- **`glz::merge` is not for base-class reuse** — it tries to serialize the member pointers themselves. Use the field-list macros instead. +- **Internal handles are simply omitted** from the metadata — list only the fields worth seeing. + ## Inter-Module Communication The `bus_` pointer is injected before `init()`. Three patterns: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c98fed..66167b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -177,6 +177,18 @@ jobs: exit 1 fi + # Debug symbols next to the loom binary, so crash reports symbolize + # in-process on the downloaded runtime. Windows: loom.pdb (installed to + # install/bin). macOS: produce loom.dSYM via dsymutil (the .o debug map + # is still present in this job). Linux: DWARF is embedded in the binary. + cp install/bin/loom.pdb "${STAGE}/" 2>/dev/null || true + if [ "${{ runner.os }}" = "macOS" ]; then + shopt -s nullglob + for b in "${STAGE}"/loom "${STAGE}"/loom.exe; do + [ -f "$b" ] && dsymutil "$b" -o "${b}.dSYM" || true + done + fi + # Module plugins. On Windows they are under output/modules/ # (Debug or Release). On other platforms they are flat in output/modules. BUILD_TYPE="${{ matrix.build_type }}" @@ -191,6 +203,15 @@ jobs: shopt -s nullglob for mod in "${MOD_DIR}"/*.so "${MOD_DIR}"/*.dylib "${MOD_DIR}"/*.dll; do cp "$mod" "${STAGE}/modules/" + # Ship each module's debug symbols alongside it so a crash in + # module code resolves to file:line. Windows: .pdb lives in + # output/ (CMAKE_PDB_OUTPUT_DIRECTORY). macOS: dsymutil → .dSYM. + # Linux: DWARF is embedded in the .so. + base="$(basename "$mod")"; stem="${base%.*}" + [ -f "output/${stem}.pdb" ] && cp "output/${stem}.pdb" "${STAGE}/modules/" || true + if [ "${{ runner.os }}" = "macOS" ]; then + dsymutil "${STAGE}/modules/${base}" -o "${STAGE}/modules/${base}.dSYM" || true + fi done fi diff --git a/.gitignore b/.gitignore index 368b752..ef7cfce 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ Thumbs.db # Frontend frontend/node_modules/ frontend/dist/ +_crashtest/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7356bb3..77cbba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,70 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.3.0] - 2026-07-02 + +### Added + +#### WebAssembly / Browser Runtime +- **Runtime-as-a-service in the browser**: the full Loom runtime now compiles to WebAssembly via Emscripten and runs directly inside the browser tab; `fetch('/api/*')` calls are intercepted and served by the in-process runtime with no network round-trip +- **Real pthread threading for the WASM host**: replaced cooperative `tickOnce()` polling with real Worker-backed threads (requires `SharedArrayBuffer`/COOP+COEP headers); the scheduler and module `cyclic()` callbacks run on genuine background threads in the browser +- **`bootFromDataDir()`**: manifest-driven browser boot that fetches a `manifest.json`, loads listed `.wasm` side-modules, and instantiates all declared modules — enabling self-contained browser demos +- **`@joshpolansky/loom-wasm` npm package**: the WASM host, TypeScript bindings (`WasmMachine`, `LoomRuntime`), and type declarations are packaged for npm distribution; `bootWasmMachine()` is the public entry point +- **OPC-UA reflected node read/write**: `loom_read_node`/`loom_write_node` exported C entry points allow JavaScript to read and write any reflected tag by dotted path; `useVariable()` React hook provides live subscription with glaze round-trip +- **Emscripten CMake + Conan build**: `LOOM_WASM` CMake option builds the WASM host; demo modules compile as `SIDE_MODULE` via `loom_add_module()`; build auto-stages artifacts into `frontend/public/` for Vite dev server +- **WASM-mode frontend flag**: `VITE_WASM` build-time env var (sticky, no fragile query-param) switches the frontend to route all API calls through the in-browser runtime + +#### Transport-Agnostic API Dispatch +- **`loom::dispatch()`**: a transport-free REST handler shared by the native Crow server and the WASM in-browser path; accepts `Request{method, path, body}` and returns `Response{status, body}` with no HTTP library dependency +- All REST routes migrated: scheduler/classes, scope, IO mappings, faults, bus topics/services, module detail/data/reload, system metrics, and all write (POST/PATCH/DELETE) routes now live exclusively in dispatch; `server.cpp` is a thin Crow adapter + +#### SDK — Command Framework (PLCopen-style) +- **`loom::CommandFb`**: PLCopen-compliant function-block base with full Execute/Busy/Done/Error output semantics, rising-edge execute detection, and one-cycle terminal visibility +- **`loom::EnableFb`**: companion always-on function block base with `IFunctionBlock` interface for uniform reflection +- **`loom::CommandClient`**: async command submission returning a `shared_ptr` handle; callers poll or await `phase` transitions without blocking cyclic code +- **`loom::CommandChannel`**: per-module typed pending-command queue backed by `RuntimeHeap`; thread-safe submit/consume with mutex-protected `std::vector` +- **`loom::RuntimeHeap`**: cross-module shared-object allocator using `std::shared_ptr` aliasing constructor; objects live in heap-allocated memory visible across `.so` boundaries without a shared allocator ABI + +#### SDK — Port / Connection Points +- **`loom::Port` / `loom::PortRef`**: typed connection points with generation-counter validation; `PortRef` detects stale connections and self-corrects on next use; `connect()`/`disconnect()` are thread-safe via mutex + generation increment + +#### SDK — Glaze / Reflection +- **`IFunctionBlock` Glaze metadata**: `glz::meta` specialisation for the `IFunctionBlock` base enables automatic reflection of `execute`, `busy`, `done`, `error`, `errorId` without macros +- **TagTable nested `glz::meta` indexing**: `TagTable` now correctly traverses types that expose a `glz::meta` specialisation (non-aggregate classes) in addition to plain aggregates; the `i++` counter aligns with `glz::reflect::keys` for both + +#### Diagnostics +- **Crash diagnostics**: `SIGSEGV`/`SIGABRT`/`SIGFPE`/`SIGILL` are caught by a POSIX signal handler; raw stack addresses are written async-signal-safely to `loom-crash-.txt` before symbolization +- **cpptrace symbolization**: after the raw capture, `cpptrace` symbolizes addresses against `.dSYM` / DWARF debug info and writes a structured JSON report (`loom-crash-.json`) with function name, file, line, and column +- **`/api/faults` + `loom/faults` bus topic**: structured `FaultReport` objects are published to the bus and exposed over REST; the frontend can display crash history without file system access +- **System metrics** (`SystemMetrics` module): tracks RSS memory (peak and current) and per-core CPU utilization at a configurable sample interval; data is reflected as normal runtime fields and visible on the dashboard + +#### Scheduler +- **Cooperative `tickOnce()`**: single-call sweep across all classes for the WASM / thread-free host; `sweepClassOnce()` honours each class's individual period using `steady_clock` deadlines; missed-deadline re-anchoring prevents accumulated lag +- **`LOOM_HAS_THREADS` flag**: `thread_support.h` distinguishes Emscripten+pthreads (real Workers) from Emscripten without pthreads (cooperative-only) — finer-grained than a raw `#ifdef EMSCRIPTEN` guard +- **`applyAffinityPolicy` stub**: no-op stub provided for all non-Linux/non-Darwin targets (WASM, Windows); closes a missing-symbol link error on those platforms +- **`longRunning()` now optional**: modules that don't override `longRunning()` are detected at `init()` time; the scheduler skips spawning a background thread for them + +### Changed +- `loom_runtime` CMake target split into `loom_core` (transport-free logic, `dispatch()`, data engine, scheduler, bus, module loader) and `loom_host_native` (Crow HTTP server, main entry point); downstream modules and tests link `loom_core` +- `loom_add_module()` CMake helper accepts `WASM` keyword to compile a module as an Emscripten `SIDE_MODULE` with the correct link flags; the same `CMakeLists.txt` builds both native `.so` and WASM `.wasm` targets +- `sdk/loom-config.cmake.in` updated to export `loom_core` instead of `loom_runtime`; downstream `find_package(loom)` consumers must link `loom::loom_core` + +### Fixed +- **Scheduler tickOnce/classLoop race**: `tickOnce()` now holds `mutex_` for its entire duration, fully serializing cooperative calls against all structural `Scheduler` mutations; the last window for a data race between the cooperative and threaded modes is closed +- **`Method::DELETE_` rename**: `Method::DELETE` renamed to `Method::DELETE_` to avoid MSVC C2589 (`delete` is a reserved keyword in some MS headers); all switch cases and if-guards updated +- **macOS in-context unwind**: crash handler includes `` (macOS) instead of `` (Linux) for the correct `mcontext_t` definition; the unwind now starts from the faulting instruction frame rather than the signal-handler frame +- **`lastFault` data race**: fault recording uses a release-store of `faulted` after all non-atomic writes; readers use acquire-load, ensuring full visibility of the fault record before observing `faulted == true` +- **`SystemMetrics` GCC/Clang ctor**: removed defaulted `Config` argument from the `SystemMetrics` constructor that triggered a compiler error under GCC/Clang strict-aggregate rules +- **POSIX affinity on all platforms**: `applyAffinityPolicy` now has a definition on every platform that doesn't support `pthread_setaffinity_np`, eliminating a link error on WASM and Windows +- **`.dSYM` CI build rule**: macOS debug-symbol generation moved to a CMake `add_custom_command(TARGET ... POST_BUILD)` dir-level rule so CI doesn't race on parallel directory creation + +### Known Issues (non-blocking) +- **`build/Wasm` and `build/Release` share the `conan-release` CMake preset name**: running `just setup-wasm` and `just setup-release` in the same checkout overwrites `CMakeUserPresets.json`; the second `cmake --preset conan-release` uses whichever toolchain ran last. Workaround: run them in separate checkouts or clean between runs. Fix: add `tools.cmake.cmaketoolchain:presets_prefix=wasm-` to `conan/profiles/emscripten`. +- **POSIX signal handler may deadlock on heap-corruption crashes**: the crash handler calls `cpptrace` (which allocates) from the signal path. If the crash was triggered by a corrupted heap, the handler may hang rather than exit. The raw `.txt` report is always written first (async-signal-safe), so no crash data is lost; the process must be `SIGKILL`ed by a watchdog. Fix: defer symbolization to a post-mortem pass. +- **`WasmMachine.dispose()` does not stop the runtime tick timer**: the `setInterval` driving `loom_tick` in cooperative mode keeps running after `dispose()`; call `rt.stop()` manually if tearing down a cooperative-mode instance. +- **`GET /api/modules/:id/data/` returns 404 instead of 400**: an unknown section name falls through to the catch-all 404 handler rather than returning a descriptive 400. Acceptable for v0.3.0; fix pending. +- **`lastTickStartMs` always 0 in `GET /api/scheduler/classes`**: the `ClassStatsDto` aggregate initializer is missing its 6th field; the value is correct over WebSocket. Fix pending. + ## [0.1.10] - 2026-04-14 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 3434530..b4fa1db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,38 @@ if(MSVC) string(APPEND CMAKE_MODULE_LINKER_FLAGS_DEBUG " /DEBUG") endif() +# Module-build helpers (loom_add_module, debug-info + dSYM) — shared with module +# authors via the SDK install. Defines the LOOM_WITH_DEBUG_INFO option. +list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") +include(LoomModule) + +# Emit debug info for crash symbolization in OPTIMIZED configs too (Debug always +# has it), so release/downloaded runtimes still produce readable crash traces. +# Symbols are shipped next to the binaries (install rules + CI staging). This is +# metadata, not code — no effect on runtime speed, only binary/file size. +# Skipped for WASM: there's no in-process symbolization there (no cpptrace), and +# -g would bloat the .wasm by ~10x for no benefit. +if(LOOM_WITH_DEBUG_INFO AND NOT EMSCRIPTEN) + if(MSVC) + foreach(cfg RELEASE RELWITHDEBINFO MINSIZEREL) + string(APPEND CMAKE_C_FLAGS_${cfg} " /Zi") + string(APPEND CMAKE_CXX_FLAGS_${cfg} " /Zi") + # /OPT:REF,ICF keeps the optimized image lean (the linker turns them + # off once /DEBUG is present). + string(APPEND CMAKE_EXE_LINKER_FLAGS_${cfg} " /DEBUG /OPT:REF /OPT:ICF") + string(APPEND CMAKE_SHARED_LINKER_FLAGS_${cfg} " /DEBUG /OPT:REF /OPT:ICF") + string(APPEND CMAKE_MODULE_LINKER_FLAGS_${cfg} " /DEBUG /OPT:REF /OPT:ICF") + endforeach() + else() + # Linux: DWARF embedded in the (unstripped) binary. macOS: -g leaves DWARF + # in the .o files; a .dSYM is produced per target by loom_target_dsym(). + foreach(cfg RELEASE RELWITHDEBINFO) + string(APPEND CMAKE_C_FLAGS_${cfg} " -g") + string(APPEND CMAKE_CXX_FLAGS_${cfg} " -g") + endforeach() + endif() +endif() + # Platform-appropriate suffix for Loom module plugins (.dll on Windows, .so elsewhere) if(WIN32) set(LOOM_MODULE_SUFFIX ".dll") @@ -50,15 +82,55 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE) endif() -# Conan-provided dependencies +# Dependencies — all from Conan, native and wasm alike (same pinned versions, no +# drift). glaze + spdlog are shared. The native host also needs Crow (HTTP/WS), +# cpptrace (crash symbolization) and GTest; the WASM build is server-less and +# skips them (gated in conanfile.py via settings.os). find_package(glaze REQUIRED) find_package(spdlog REQUIRED) -find_package(GTest REQUIRED) -find_package(Crow REQUIRED) +if(EMSCRIPTEN) + # spdlog is header-only here; alias it to the plain name the tree links + # against. Neutralize fmt's consteval (rejected by emcc's clang). + if(NOT TARGET spdlog::spdlog AND TARGET spdlog::spdlog_header_only) + add_library(spdlog::spdlog INTERFACE IMPORTED) + target_link_libraries(spdlog::spdlog INTERFACE spdlog::spdlog_header_only) + endif() + add_compile_definitions(FMT_CONSTEVAL=) + add_compile_options(-fexceptions) + add_link_options(-fexceptions) + + # Real threading (SharedArrayBuffer + Worker-backed std::thread), applied + # globally so loom_core, loom_host_wasm, AND every bundled module (dlopen'd + # SIDE_MODULEs) agree on the threaded object format -- a SIDE_MODULE built + # without -pthread loaded into a -pthread MAIN_MODULE is not a supported + # combination. De-risked standalone in spike/phaseC-pthread-dlopen/ (real + # worker thread driving dlopen'd module code, incl. a pauseClass()-style + # mutex+condvar handshake and cross-thread C++ exceptions -- all passed). + # Emscripten itself flags dynamic-linking+pthreads as experimental + # (-Wexperimental); this is the reason to keep an eye on it going forward, + # not a reason it's broken today. + add_compile_options(-pthread) + add_link_options(-pthread) + + # CMake's Emscripten platform defaults TARGET_SUPPORTS_SHARED_LIBS to FALSE, + # so add_library(MODULE) (used by every bundled module — modules/*/CMakeLists.txt) + # silently downgrades to a STATIC archive instead of a dlopen-able wasm side + # module. Override it before modules/ is added. + set_property(GLOBAL PROPERTY TARGET_SUPPORTS_SHARED_LIBS TRUE) +else() + find_package(GTest REQUIRED) + find_package(Crow REQUIRED) + find_package(cpptrace REQUIRED) # runtime-only: crash-report symbolization +endif() add_subdirectory(sdk) add_subdirectory(runtime) -add_subdirectory(modules) -enable_testing() -add_subdirectory(tests) +# Bundled modules build under both native and WASM (as SIDE_MODULEs — see +# modules/CMakeLists.txt); EtherCAT is the one exception, gated out there. +# Tests stay native-only (need GTest, which conanfile.py doesn't pull for wasm). +add_subdirectory(modules) +if(NOT EMSCRIPTEN) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/README.md b/README.md index 4e27bc4..728df86 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,11 @@ public: } void exit() override {} - void longRunning() override {} // required; leave empty if unused + + // longRunning() is optional — override it only for background work + // (e.g. polling a slow device). Omit it and the runtime starts no + // background thread for this module. + // void longRunning() override { ... } }; LOOM_REGISTER_MODULE(MyModule) diff --git a/VERSION b/VERSION index 7179039..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.3 +0.3.0 diff --git a/cmake/LoomModule.cmake b/cmake/LoomModule.cmake new file mode 100644 index 0000000..7a64c02 --- /dev/null +++ b/cmake/LoomModule.cmake @@ -0,0 +1,133 @@ +# LoomModule.cmake — helpers for building Loom module plugins with the debug +# info needed for in-process crash symbolization. +# +# Installed with the SDK and pulled in by loomConfig.cmake, so module authors who +# `find_package(loom CONFIG REQUIRED)` get loom_add_module() with symbols handled +# the same way the bundled modules are. Also included by the in-repo build. +# +# Crash reports are symbolized in-process via cpptrace, which needs debug info: +# • Windows : a .pdb next to the binary (/Zi + /DEBUG, kept lean with /OPT) +# • Linux : DWARF embedded in the unstripped binary (-g) +# • macOS : a .dSYM bundle next to the binary (dsymutil, since the linker +# leaves DWARF in the .o files which don't ship) + +if(NOT DEFINED LOOM_WITH_DEBUG_INFO) + option(LOOM_WITH_DEBUG_INFO "Emit debug info (PDB/DWARF/dSYM) for crash symbolization" ON) +endif() + +# Platform-appropriate module suffix (.dll on Windows, .so elsewhere — matching +# the runtime's dlopen/LoadLibrary lookup). Honors a value already set by the +# in-repo build. +if(NOT DEFINED LOOM_MODULE_SUFFIX) + if(WIN32) + set(LOOM_MODULE_SUFFIX ".dll") + else() + set(LOOM_MODULE_SUFFIX ".so") + endif() +endif() + +# Add debug-info flags to a target for optimized (non-Debug) configs. Debug +# already carries full debug info. No-op when LOOM_WITH_DEBUG_INFO is OFF. +function(loom_target_debug_info target) + if(NOT LOOM_WITH_DEBUG_INFO) + return() + endif() + if(MSVC) + target_compile_options(${target} PRIVATE $<$>:/Zi>) + # /DEBUG so a PDB is emitted; /OPT:REF,ICF to keep the optimized image + # lean (the linker disables those by default once /DEBUG is present). + target_link_options(${target} PRIVATE + $<$>:/DEBUG> + $<$>:/OPT:REF> + $<$>:/OPT:ICF>) + else() + target_compile_options(${target} PRIVATE $<$>:-g>) + endif() +endfunction() + +# On macOS, produce a .dSYM bundle next to the target after build so +# cpptrace can resolve file:line from a distributed (away-from-build-tree) binary. +function(loom_target_dsym target) + if(APPLE AND LOOM_WITH_DEBUG_INFO) + add_custom_command(TARGET ${target} POST_BUILD + COMMAND dsymutil $ -o $.dSYM + COMMENT "dsymutil ${target} → .dSYM (crash symbols)" + VERBATIM) + endif() +endfunction() + +# Target-scoped half of Emscripten wasm SIDE_MODULE support (see +# loom_add_module below for the GLOBAL-property half, which — unlike these — +# MUST be set before add_library() runs to take effect for that target): +# 1. -sSIDE_MODULE=1 (compile + link) is what actually produces a dlopen-able +# wasm binary (a `dylink.0` section) instead of a normal static archive. +# 2. -fexceptions is required so real C++ exceptions work — the module ABI +# boundary (dlopen'd module <-> the loom_wasm MAIN_MODULE host) needs +# matching exception support on both sides, and every Loom module's SDK +# surface (guard(), command dispatch) relies on real exception unwinding. +# De-risked standalone (cross-module throw/catch across a real worker +# thread) in the loom repo's spike/phaseC-pthread-dlopen/. +# 3. -pthread must match the MAIN_MODULE's threading mode: the host is built +# with -pthread (shared memory + atomics), so every SIDE_MODULE must also +# use -pthread or dlopen will fail with an object-format mismatch. +# 4. FMT_CONSTEVAL= neutralizes fmt's consteval format-string checks, which +# emcc's clang rejects; harmless if a module never includes spdlog/fmt. +# All apply per-module (not globally), so a consumer's own CMakeLists.txt needs +# zero Emscripten-specific configuration for modules built via loom_add_module. +function(loom_target_wasm_module_support target) + if(NOT EMSCRIPTEN) + return() + endif() + target_compile_options(${target} PRIVATE -sSIDE_MODULE=1 -fexceptions -pthread) + target_link_options(${target} PRIVATE -sSIDE_MODULE=1 -fexceptions -pthread) + target_compile_definitions(${target} PRIVATE FMT_CONSTEVAL=) +endfunction() + +# loom_add_module( +# SOURCES +# [LINK ] +# [INCLUDE ] +# [OUTPUT_DIRECTORY ]) +# +# Builds a Loom module plugin: a MODULE library with no "lib" prefix, the +# platform module suffix, hidden default visibility, linked against loom::sdk, +# and with crash-symbolization debug info (PDB/DWARF/dSYM) emitted next to it. +# On Emscripten, produces a dlopen-able wasm SIDE_MODULE instead (see +# loom_target_wasm_module_support above) — no extra configuration needed by +# the caller; the SAME loom_add_module() call works for native and wasm. +function(loom_add_module name) + cmake_parse_arguments(ARG "" "OUTPUT_DIRECTORY" "SOURCES;LINK;INCLUDE" ${ARGN}) + if(NOT ARG_SOURCES) + message(FATAL_ERROR "loom_add_module(${name}): SOURCES is required") + endif() + + # Must run BEFORE add_library(MODULE): TARGET_SUPPORTS_SHARED_LIBS is read + # at add_library() time, so setting it any later has no effect on this call. + if(EMSCRIPTEN) + set_property(GLOBAL PROPERTY TARGET_SUPPORTS_SHARED_LIBS TRUE) + endif() + + add_library(${name} MODULE ${ARG_SOURCES}) + target_link_libraries(${name} PRIVATE loom::sdk ${ARG_LINK}) + if(ARG_INCLUDE) + target_include_directories(${name} PRIVATE ${ARG_INCLUDE}) + endif() + + set_target_properties(${name} PROPERTIES + PREFIX "" + SUFFIX "${LOOM_MODULE_SUFFIX}" + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON) + + if(ARG_OUTPUT_DIRECTORY) + set_target_properties(${name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${ARG_OUTPUT_DIRECTORY}") + elseif(DEFINED LOOM_MODULES_OUTPUT_DIR) + set_target_properties(${name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${LOOM_MODULES_OUTPUT_DIR}") + endif() + + loom_target_debug_info(${name}) + loom_target_dsym(${name}) + loom_target_wasm_module_support(${name}) +endfunction() diff --git a/conan/profiles/emscripten b/conan/profiles/emscripten new file mode 100644 index 0000000..cb6b36c --- /dev/null +++ b/conan/profiles/emscripten @@ -0,0 +1,27 @@ +# Cross-build profile for the WebAssembly (Emscripten) runtime host. +# Uses the Emscripten SDK pointed at by $EMSDK (e.g. deps/wasm/emsdk) for the +# toolchain, while Conan resolves the (header-only) deps — same versions as the +# native build, so there's no drift. Build/host deps that don't apply to wasm +# (Crow, cpptrace, gtest) are dropped in conanfile.py via settings.os. +# +# EMSDK=/path/to/emsdk conan install . -pr:h conan/profiles/emscripten \ +# -pr:b default -of build/Wasm --build=missing + +[settings] +os=Emscripten +arch=wasm +compiler=clang +compiler.version=20 +compiler.cppstd=23 +compiler.libcxx=libc++ +build_type=Release + +[options] +# Header-only spdlog: nothing to cross-compile, and matches how loom_core uses it. +spdlog/*:header_only=True + +[conf] +tools.cmake.cmaketoolchain:generator=Ninja +# Chain Emscripten's CMake toolchain under Conan's generated one, so find_package +# resolves the Conan deps AND the compiler is emcc. +tools.cmake.cmaketoolchain:user_toolchain=["{{ os.getenv('EMSDK') }}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake"] diff --git a/conanfile.py b/conanfile.py index 90a7705..755dcb5 100644 --- a/conanfile.py +++ b/conanfile.py @@ -15,10 +15,17 @@ class LoomDevConan(ConanFile): default_options = {"with_tests": True} def requirements(self): - # SDK deps — glaze listed explicitly since the SDK is consumed as source here + # Shared by every target. glaze is listed explicitly since the SDK is + # consumed as source here; spdlog is header-only on the WASM build (set + # in conan/profiles/emscripten) so no library is cross-compiled. self.requires("glaze/7.2.0") - # Runtime deps self.requires("spdlog/1.17.0") - self.requires("crowcpp-crow/1.3.0") - if self.options.with_tests: - self.requires("gtest/1.15.0") + + # Native-host-only deps. The WASM runtime is thread-free and server-less: + # no HTTP/WebSocket (Crow), no in-process stack symbolization (cpptrace), + # and tests don't run there. + if self.settings.os != "Emscripten": + self.requires("crowcpp-crow/1.3.0") + self.requires("cpptrace/0.8.3") + if self.options.with_tests: + self.requires("gtest/1.15.0") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04dc145..bee9748 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,6 @@ import OscilloscopeView from './pages/OscilloscopeView'; import WatchView from './pages/WatchView'; import { MappingView } from './pages/MappingView'; import { MachineProvider, useMachine, ConnectionState } from '@loupeteam/lux-react'; -import { machine } from './api/machine'; import { DataServiceContext, useDataServiceProvider } from './api/dataService'; import './App.css'; @@ -102,7 +101,11 @@ function AppProviders() { ); } -function App() { +// `machine` is supplied by main.tsx after boot: native passes a lux OpcuaMachine, +// ?wasm=1 passes a duck-typed WasmMachine. (A shared machine interface would be +// the right follow-up to drop the `any`.) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function App({ machine }: { machine: any }) { return ( diff --git a/frontend/src/api/machine.ts b/frontend/src/api/machine.ts index cab9313..887fff8 100644 --- a/frontend/src/api/machine.ts +++ b/frontend/src/api/machine.ts @@ -1,4 +1,5 @@ import { OpcuaMachine } from '@loupeteam/lux-connect'; +import { isWasmMode } from '../wasm/wasmMode'; // One OpcuaMachine for the whole app, pointed at the same origin that served the // UI (the Loom runtime's mapp Connect-compatible facade). Same origin means no @@ -6,14 +7,21 @@ import { OpcuaMachine } from '@loupeteam/lux-connect'; const loc = window.location; const isHttps = loc.protocol === 'https:'; const port = loc.port ? Number(loc.port) : isHttps ? 443 : 80; +const useWasm = isWasmMode(); -export const machine = new OpcuaMachine({ - host: loc.hostname, - port, - protocol: isHttps ? 'https' : 'http', - wsProtocol: isHttps ? 'wss' : 'ws', - enableWebSocket: true, -}); +// In wasm mode the app drives an in-browser WasmMachine (see wasm/boot.ts), so +// DON'T create a real OPC-UA connection here — components import node()/classNode() +// from this module, and an OpcuaMachine constructed as a side effect would +// fruitlessly try to reach a server. boot.ts uses this export only in native mode. +export const machine = useWasm + ? (undefined as unknown as OpcuaMachine) + : new OpcuaMachine({ + host: loc.hostname, + port, + protocol: isHttps ? 'https' : 'http', + wsProtocol: isHttps ? 'wss' : 'ws', + enableWebSocket: true, + }); export function node(moduleId: string, section: string, path?: string): string { const base = `ns=1;s=/module/${moduleId}/${section}`; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..73a52bd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,25 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { bootMachine } from './wasm/boot' -createRoot(document.getElementById('root')!).render( - - - , -) +// Boot the machine first: a WasmMachine (in-browser runtime, ?wasm=1) or the real +// OPC-UA machine. Render once it's ready so the providers get a live machine. +// A boot failure must render SOMETHING — without the catch it's an unhandled +// rejection and a permanently blank page. +bootMachine().then((machine) => { + createRoot(document.getElementById('root')!).render( + + + , + ) +}).catch((err: unknown) => { + console.error('bootMachine failed:', err) + const root = document.getElementById('root') + if (root) { + root.innerHTML = + '
' + + '

Loom failed to start

' + root.querySelector('pre')!.textContent = String(err) + } +}) diff --git a/frontend/src/wasm/boot.ts b/frontend/src/wasm/boot.ts new file mode 100644 index 0000000..e256340 --- /dev/null +++ b/frontend/src/wasm/boot.ts @@ -0,0 +1,64 @@ +// Boot the machine the app talks to. +// +// ?wasm=1 → the Loom runtime runs in-browser: boot it, load the app's +// modules, route fetch('/api/*') to it, and back useVariable() with +// a WasmMachine. No server — this is the "ship the app as a website" +// path. +// (default) → the real OPC-UA machine talking to a native runtime server. +// +// main.tsx awaits this before first render and hands the result to MachineProvider. +// +// createLoomRuntime/WasmMachine now live in packages/loom-wasm (the +// npm-publishable package other consumers install) -- imported here by +// relative path rather than via node_modules/workspaces, since this repo +// intentionally has no workspace tooling (one new package doesn't justify a +// monorepo migration). This IS loom's own frontend dogfooding that package. +// loomRuntime.js is plain JS but has a sibling loomRuntime.d.ts alongside it +// providing real types, so no @ts-expect-error is needed here. +import { createLoomRuntime } from '../../../packages/loom-wasm/src/loomRuntime.js'; +import { WasmMachine } from '../../../packages/loom-wasm/src/WasmMachine'; +import { isWasmMode } from './wasmMode'; + +function loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = src; + s.onload = () => resolve(); + s.onerror = () => reject(new Error('failed to load ' + src)); + document.head.appendChild(s); + }); +} + +/** Modules to auto-load in wasm mode — the bundled example modules, same set + * native boots from a clean instances.json (modules/CMakeLists.txt's EMSCRIPTEN + * list, kept in sync with this one). EtherCAT is the one exception: it needs a + * real EtherCAT master (SOEM) + raw-Ethernet access, neither available in a + * browser, so it's native-only and not built for wasm at all. */ +const WASM_MODULES = [ + 'class_based.so', + 'command_probe.so', + 'crasher.so', + 'example_motor.so', + 'oscilloscope.so', + 'pneumatic_actuator.so', + 'sequencer.so', + 'stack_light.so', +]; + +export async function bootMachine(): Promise { + if (!isWasmMode()) { + return (await import('../api/machine')).machine; // native: real OpcuaMachine + } + + const base = import.meta.env.BASE_URL; // '/_loom/' + await loadScript(base + 'loom_wasm.js'); // defines global createLoomModule + const createModule = (window as unknown as { createLoomModule: () => Promise }).createLoomModule; + + const rt = await createLoomRuntime({ createModule: () => createModule() }); + for (const name of WASM_MODULES) { + const bytes = new Uint8Array(await (await fetch(base + name)).arrayBuffer()); + rt.loadModule(name, bytes); + } + rt.installFetch(); // from here, fetch('/api/*') is served by the in-browser runtime + return new WasmMachine(rt); +} diff --git a/frontend/src/wasm/wasmMode.ts b/frontend/src/wasm/wasmMode.ts new file mode 100644 index 0000000..d4dfdc2 --- /dev/null +++ b/frontend/src/wasm/wasmMode.ts @@ -0,0 +1,21 @@ +// Single source of truth for "is the app driving an in-browser wasm runtime?" +// +// - Production: the website is BUILT in wasm mode (VITE_WASM=1 at build time), +// so there is no server and this is always true. This is the "ship the app as +// a website" path. +// - Dev: pass ?wasm=1 once. It's persisted in sessionStorage so SPA navigation +// (which drops the query string) doesn't silently fall back to native mode. +// Pass ?no-wasm to clear it. +export function isWasmMode(): boolean { + const built = (import.meta.env as Record).VITE_WASM; + if (built === '1' || built === 'true') return true; + + try { + const params = new URLSearchParams(window.location.search); + if (params.has('no-wasm')) sessionStorage.removeItem('loom.wasm'); + else if (params.has('wasm')) sessionStorage.setItem('loom.wasm', '1'); + return sessionStorage.getItem('loom.wasm') === '1'; + } catch { + return false; + } +} diff --git a/justfile b/justfile index b1cbe8a..566b8cc 100644 --- a/justfile +++ b/justfile @@ -25,6 +25,36 @@ build-release: setup-release cmake --preset conan-release cmake --build --preset conan-release +# --- WebAssembly (Emscripten) ----------------------------------------------- + +# Install the Emscripten SDK (deps/wasm/emsdk) as the toolchain, then have Conan +# resolve the library deps for the Emscripten profile — the SAME glaze/spdlog +# versions as the native build, so there's no drift. Generates the wasm CMake +# toolchain under build/Wasm. +setup-wasm: + #!/usr/bin/env bash + set -euo pipefail + if [ ! -d deps/wasm/emsdk ]; then + git clone https://github.com/emscripten-core/emsdk.git deps/wasm/emsdk + ( cd deps/wasm/emsdk && ./emsdk install latest && ./emsdk activate latest ) + fi + EMSDK="$PWD/deps/wasm/emsdk" conan install . \ + -pr:h conan/profiles/emscripten -pr:b default \ + -of build/Wasm --build=missing + +# Build the WASM runtime host → output/loom_wasm.{js,wasm}. Uses the Conan- +# generated preset (build/Wasm), just as native uses conan-debug. +# +# CAVEAT: the Emscripten Release install generates a preset NAMED +# "conan-release" (Conan names presets by build_type, not output folder), the +# same name `just build-release` (build/Release) would generate — whichever +# ran last wins in CMakeUserPresets.json. Native dev builds use conan-debug so +# the two coexist fine; just don't mix `build-release` and `build-wasm` in one +# checkout without re-running the matching setup first. +build-wasm: setup-wasm + cmake --preset conan-release + cmake --build --preset conan-release + # Run tests test: build ctest --preset conan-debug --output-on-failure diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index bc9b363..60c2a56 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -1,11 +1,64 @@ set(LOOM_MODULES_DIR "${CMAKE_CURRENT_SOURCE_DIR}") -set(LOOM_MODULES_OUTPUT_DIR "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/modules") +if(EMSCRIPTEN) + # Separate output dir: CMAKE_RUNTIME_OUTPUT_DIRECTORY is an absolute path + # outside the build tree (shared across build presets), so a wasm SIDE_MODULE + # named e.g. class_based.so would otherwise overwrite the native .so of the + # same name. + set(LOOM_MODULES_OUTPUT_DIR "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/wasm-modules") +else() + set(LOOM_MODULES_OUTPUT_DIR "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/modules") +endif() -# Build all example modules +# Build all example modules. EtherCAT needs SOEM + raw-Ethernet access +# (CAP_NET_RAW / libpcap) that doesn't exist under Emscripten, so it's native-only. add_subdirectory(class_based) -add_subdirectory(ethercat) +add_subdirectory(command_probe) +add_subdirectory(crasher) +if(NOT EMSCRIPTEN) + add_subdirectory(ethercat) +endif() add_subdirectory(example_motor) add_subdirectory(oscilloscope) add_subdirectory(pneumatic_actuator) add_subdirectory(sequencer) add_subdirectory(stack_light) + +if(EMSCRIPTEN) + set(LOOM_WASM_MODULE_TARGETS class_based command_probe crasher example_motor + oscilloscope pneumatic_actuator sequencer stack_light) + + # Each module above is `add_library( MODULE ...)`; under Emscripten a + # MODULE library needs -sSIDE_MODULE=1 (compile and link) to produce a + # dlopen-able wasm side module instead of a native-style shared object. + foreach(_mod IN LISTS LOOM_WASM_MODULE_TARGETS) + target_compile_options(${_mod} PRIVATE -sSIDE_MODULE=1) + target_link_options(${_mod} PRIVATE -sSIDE_MODULE=1) + endforeach() + + # Stage straight into the frontend's static assets on every build — `cmake + # --build` alone is then enough, no manual cp. A plain add_custom_target (not + # add_custom_command(TARGET ... POST_BUILD)) because POST_BUILD must be added + # in the same directory that created the target; each module target above was + # created in its own add_subdirectory(), not here. + if(EXISTS "${CMAKE_SOURCE_DIR}/frontend") + set(_wasm_module_files "") + foreach(_mod IN LISTS LOOM_WASM_MODULE_TARGETS) + list(APPEND _wasm_module_files "$") + endforeach() + add_custom_target(stage_wasm_modules ALL + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_SOURCE_DIR}/frontend/public" + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${_wasm_module_files} + "${CMAKE_SOURCE_DIR}/frontend/public/" + DEPENDS ${LOOM_WASM_MODULE_TARGETS} + COMMENT "Staging wasm modules into frontend/public/" + VERBATIM + ) + endif() +endif() + +# Debug info for these modules comes from the global flags in the root +# CMakeLists (LOOM_WITH_DEBUG_INFO): a .pdb on Windows, embedded DWARF on Linux. +# macOS .dSYM bundles are produced for the distributed binaries in the CI staging +# step (dsymutil) — not here, because add_custom_command(TARGET) can't attach to +# a target created in a module subdirectory. Local macOS dev resolves via the +# debug map in the build tree, so no .dSYM is needed for development. diff --git a/modules/command_probe/CMakeLists.txt b/modules/command_probe/CMakeLists.txt new file mode 100644 index 0000000..7382923 --- /dev/null +++ b/modules/command_probe/CMakeLists.txt @@ -0,0 +1,17 @@ +add_library(command_probe MODULE + command_probe.cpp +) + +target_link_libraries(command_probe PRIVATE + loom::sdk +) +target_include_directories(command_probe PUBLIC + ${LOOM_MODULES_DIR} +) + +set_target_properties(command_probe PROPERTIES + PREFIX "" + SUFFIX "${LOOM_MODULE_SUFFIX}" + LIBRARY_OUTPUT_DIRECTORY "${LOOM_MODULES_OUTPUT_DIR}" + CXX_VISIBILITY_PRESET hidden +) diff --git a/modules/command_probe/command_probe.cpp b/modules/command_probe/command_probe.cpp new file mode 100644 index 0000000..83014f7 --- /dev/null +++ b/modules/command_probe/command_probe.cpp @@ -0,0 +1,54 @@ +#include +#include +#include + +#include +#include + +// ============================================================================ +// CommandProbe — a minimal async-command *provider* used by integration tests. +// +// In init() it registers a CommandChannel on the bus (provideCommands). Each +// cyclic() it drains the channel and marks every submitted command Done by +// writing through the command's weak_ptr. Exercises the generic +// command primitive end to end across a real loaded/unloaded module boundary. +// ============================================================================ + +struct CPConfig { int _unused = 0; }; +struct CPRecipe { int _unused = 0; }; +struct CPRuntime { + uint64_t processed = 0; + uint64_t last_command = 0; + uint32_t last_target = 0; +}; + +class CommandProbe : public loom::Module { +public: + LOOM_MODULE_HEADER("CommandProbe", "1.0.0") + + void init(const loom::InitContext&) override { + provideCommands(channel_); + } + + void cyclic() override { + channel_.drain(drained_); + for (auto& s : drained_) { + runtime_.processed++; + runtime_.last_command = s.command; + runtime_.last_target = s.target; + if (auto st = s.status.lock()) { // write status THROUGH the weak ref + st->phase.store(loom::CmdPhase::Done); + st->done.store(true); + st->progress.store(1.0); + } + } + } + + void exit() override {} + +private: + loom::CommandChannel channel_; + std::vector drained_; +}; + +LOOM_REGISTER_MODULE(CommandProbe) diff --git a/modules/crasher/CMakeLists.txt b/modules/crasher/CMakeLists.txt new file mode 100644 index 0000000..c921d1e --- /dev/null +++ b/modules/crasher/CMakeLists.txt @@ -0,0 +1,17 @@ +add_library(crasher MODULE + crasher.cpp +) + +target_link_libraries(crasher PRIVATE + loom::sdk +) +target_include_directories(crasher PUBLIC + ${LOOM_MODULES_DIR} +) + +set_target_properties(crasher PROPERTIES + PREFIX "" + SUFFIX "${LOOM_MODULE_SUFFIX}" + LIBRARY_OUTPUT_DIRECTORY "${LOOM_MODULES_OUTPUT_DIR}" + CXX_VISIBILITY_PRESET hidden +) diff --git a/modules/crasher/crasher.cpp b/modules/crasher/crasher.cpp new file mode 100644 index 0000000..13e7a43 --- /dev/null +++ b/modules/crasher/crasher.cpp @@ -0,0 +1,52 @@ +#include +#include + +#include +#include +#include +#include + +// ============================================================================ +// Crasher — a deliberately-faulting module for exercising crash diagnostics. +// +// Config picks the fault and when it fires: +// fault: none | throw | segfault | fpe | abort | loop +// phase: init | cyclic +// after_ticks: for phase=cyclic, fault on the Nth cyclic tick (lets the +// module load + run first, so the breadcrumb shows phase=cyclic). +// ============================================================================ + +struct CrasherConfig { + std::string fault = "none"; + std::string phase = "cyclic"; + uint64_t after_ticks = 50; +}; +struct CrasherRecipe { int _unused = 0; }; +struct CrasherRuntime { uint64_t cycle = 0; }; + +namespace { +[[maybe_unused]] void doFault(const std::string& f) { + if (f == "throw") throw std::runtime_error("crasher: intentional std::runtime_error"); + if (f == "segfault") { volatile int* p = nullptr; *p = 1; } // SIGSEGV / AV + if (f == "fpe") { volatile int a = 1, b = 0; volatile int c = a / b; (void)c; } // SIGFPE + if (f == "abort") std::abort(); // SIGABRT + if (f == "loop") { volatile bool spin = true; while (spin) {} } // hang (watchdog) +} +} // namespace + +class Crasher : public loom::Module { +public: + LOOM_MODULE_HEADER("Crasher", "1.0.0") + + void init(const loom::InitContext&) override { + if (config_.phase == "init") doFault(config_.fault); + } + void cyclic() override { + runtime_.cycle++; + if (config_.phase == "cyclic" && runtime_.cycle >= config_.after_ticks) + doFault(config_.fault); + } + void exit() override {} +}; + +LOOM_REGISTER_MODULE(Crasher) diff --git a/modules/example_motor/CMakeLists.txt b/modules/example_motor/CMakeLists.txt index 1bade7c..0c1c4b4 100644 --- a/modules/example_motor/CMakeLists.txt +++ b/modules/example_motor/CMakeLists.txt @@ -1,19 +1,9 @@ -add_library(example_motor MODULE - motor_module.cpp -) - -target_link_libraries(example_motor PRIVATE - loom::sdk -) -target_include_directories(example_motor PUBLIC - ${LOOM_MODULES_DIR} -) - -# Module output settings -set_target_properties(example_motor PROPERTIES - PREFIX "" # No "lib" prefix - SUFFIX "${LOOM_MODULE_SUFFIX}" # .dll on Windows, .so elsewhere - LIBRARY_OUTPUT_DIRECTORY "${LOOM_MODULES_OUTPUT_DIR}" - CXX_VISIBILITY_PRESET hidden # Hide symbols by default +# Smoke test for loom_add_module() itself (native + wasm): every other bundled +# module still hand-rolls add_library(MODULE) + the same boilerplate this +# helper now replaces. If this diverges from the others in any observable way, +# something is wrong with the helper, not with this module. +loom_add_module(example_motor + SOURCES motor_module.cpp + INCLUDE ${LOOM_MODULES_DIR} ) diff --git a/modules/example_motor/motor_module.cpp b/modules/example_motor/motor_module.cpp index 1c23554..4a49eed 100644 --- a/modules/example_motor/motor_module.cpp +++ b/modules/example_motor/motor_module.cpp @@ -91,10 +91,6 @@ class ExampleMotor : public loom::Module` line +# in ~/.npmrc (needs write:packages scope to publish) +@joshpolansky:registry=https://npm.pkg.github.com/ diff --git a/packages/loom-wasm/package-lock.json b/packages/loom-wasm/package-lock.json new file mode 100644 index 0000000..7584db1 --- /dev/null +++ b/packages/loom-wasm/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "@joshpolansky/loom-wasm", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@joshpolansky/loom-wasm", + "version": "0.3.0", + "devDependencies": { + "typescript": "~5.9.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/loom-wasm/package.json b/packages/loom-wasm/package.json new file mode 100644 index 0000000..e5817e4 --- /dev/null +++ b/packages/loom-wasm/package.json @@ -0,0 +1,22 @@ +{ + "name": "@joshpolansky/loom-wasm", + "version": "0.3.0", + "description": "Boot the Loom runtime in-browser via WebAssembly and get a lux-react-compatible machine connection.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "wasm" + ], + "scripts": { + "build": "tsc -b && node -e \"for (const f of ['loomRuntime.js','loomRuntime.d.ts']) require('fs').copyFileSync('src/'+f, 'dist/'+f)\"", + "build:wasm": "./scripts/build-wasm.sh" + }, + "devDependencies": { + "typescript": "~5.9.3" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/" + } +} diff --git a/packages/loom-wasm/scripts/build-wasm.sh b/packages/loom-wasm/scripts/build-wasm.sh new file mode 100755 index 0000000..c9af095 --- /dev/null +++ b/packages/loom-wasm/scripts/build-wasm.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Build the wasm host + demo modules and stage them into this package's +# wasm/ dir, ready for `npm pack`/`npm publish`. +# +# Local, scripted, non-CI for v1 -- no workflow in this repo builds +# Emscripten yet (that's separate follow-on work), so this is a developer's +# manual publish step, run from a machine with the Emscripten toolchain set +# up (see `just setup-wasm`). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$PKG_DIR/../.." && pwd)" + +# --- version lockstep check -------------------------------------------------- +# The package's version tracks LOOM_SDK_VERSION (see the root VERSION file, +# consumed by sdk/conanfile.py) in lockstep -- npm install @joshpolansky/loom-wasm@X +# unambiguously means "wasm host built against SDK version X". No separate +# compat-check layer: runtime/src/module_loader.cpp already refuses to load a +# module built against a different SDK version, so a drift here would just +# make the resulting package silently useless for whatever it's paired with. +sdk_version="$(cat "$REPO_ROOT/VERSION" | tr -d '[:space:]')" +pkg_version="$(node -p "require('$PKG_DIR/package.json').version")" +if [ "$sdk_version" != "$pkg_version" ]; then + echo "error: package.json version ($pkg_version) != root VERSION ($sdk_version)." >&2 + echo " Bump packages/loom-wasm/package.json to match before building." >&2 + exit 1 +fi + +# --- build -------------------------------------------------------------- +echo "==> just build-wasm (root: $REPO_ROOT)" +( cd "$REPO_ROOT" && just build-wasm ) + +# --- stage into wasm/ ----------------------------------------------------- +STAGE="$PKG_DIR/wasm" +mkdir -p "$STAGE" "$STAGE/demo-modules" + +echo "==> staging loom_wasm.js/.wasm" +cp "$REPO_ROOT/frontend/public/loom_wasm.js" "$STAGE/loom_wasm.js" +cp "$REPO_ROOT/frontend/public/loom_wasm.wasm" "$STAGE/loom_wasm.wasm" + +# Same list as frontend/src/wasm/boot.ts's WASM_MODULES -- the bundled demo +# module set. Optional quick-start assets for a consumer with no modules of +# their own yet; a real consumer (e.g. Actuate) points moduleUrl at their own +# build output instead and never touches these. +DEMO_MODULES=( + class_based.so + command_probe.so + crasher.so + example_motor.so + oscilloscope.so + pneumatic_actuator.so + sequencer.so + stack_light.so +) +echo "==> staging ${#DEMO_MODULES[@]} demo modules" +for m in "${DEMO_MODULES[@]}"; do + cp "$REPO_ROOT/frontend/public/$m" "$STAGE/demo-modules/$m" +done + +echo "==> done: $STAGE" diff --git a/packages/loom-wasm/src/WasmMachine.ts b/packages/loom-wasm/src/WasmMachine.ts new file mode 100644 index 0000000..2383891 --- /dev/null +++ b/packages/loom-wasm/src/WasmMachine.ts @@ -0,0 +1,76 @@ +// WasmMachine — a drop-in for @loupeteam/lux-connect's OpcuaMachine that serves +// useVariable()/writeVariable() from the in-browser Loom runtime instead of an +// OPC-UA server. +// +// lux-react only ever calls the public machine surface (connect, connectionState, +// onConnectionStateChanged, subscribe, unsubscribe, readVariable, writeVariable), +// so this duck-types it. A variable name is an OPC-UA NodeId, e.g. +// "ns=1;s=/module//runtime/" (see api/machine.ts node()), which the +// host maps to the module's reflected readField/writeField. Subscriptions are +// satisfied by polling the reflected value each tick — no push channel needed. +// +// No dependency on @loupeteam/lux-connect: lux-react's ICommLayer contract +// types connectionState as `ConnectionState | string`, so the plain string +// 'connected' (lux-connect's own ConnectionState.CONNECTED enum member, at +// runtime, is exactly this string) satisfies it without pulling in the +// package just for one enum value. +const CONNECTED = 'connected'; + +/** The runtime service this machine reads/writes through (see loomRuntime.js). */ +export interface NodeAccess { + readNode(nodeId: string): string; // raw reflected JSON ('null' if unknown) + writeNode(nodeId: string, value: unknown): boolean; +} + +type StateHandler = (state: string) => void; +interface Sub { nodeId: string; cb: (v: unknown) => void; lastRaw: string; } + +export class WasmMachine { + private readonly rt: NodeAccess; + private readonly handlers = new Set(); + private readonly subs = new Map(); + private nextHandle = 1; + private readonly poll: ReturnType; + + constructor(rt: NodeAccess, pollMs = 100) { + this.rt = rt; + this.poll = setInterval(() => this.tick(), pollMs); + } + + // --- connection (always "connected": the runtime is in-process) --- + get connectionState(): string { return CONNECTED; } + isConnected(): boolean { return true; } + async connect(): Promise { this.handlers.forEach((h) => h(CONNECTED)); } + async disconnect(): Promise {} + async reconnect(): Promise {} + async testConnection(): Promise {} + onConnectionStateChanged(handler: StateHandler): void { + this.handlers.add(handler); + handler(CONNECTED); + } + + // --- reads / writes --- + async readVariable(nodeId: string): Promise { return this.parse(this.rt.readNode(nodeId)); } + async writeVariable(nodeId: string, value: unknown): Promise { this.rt.writeNode(nodeId, value); } + + // --- subscriptions (poll-based; lux-react calls subscribe()/unsubscribe()) --- + async subscribe(nodeId: string, cb: (v: unknown) => void, _opts?: unknown): Promise { + const handle = String(this.nextHandle++); + const lastRaw = this.rt.readNode(nodeId); + this.subs.set(handle, { nodeId, cb, lastRaw }); + cb(this.parse(lastRaw)); // deliver the initial value immediately + return handle; + } + async unsubscribe(handle: string): Promise { this.subs.delete(handle); } + + dispose(): void { clearInterval(this.poll); this.subs.clear(); this.handlers.clear(); } + + private parse(raw: string): unknown { try { return JSON.parse(raw); } catch { return raw; } } + + private tick(): void { + for (const s of this.subs.values()) { + const raw = this.rt.readNode(s.nodeId); + if (raw !== s.lastRaw) { s.lastRaw = raw; s.cb(this.parse(raw)); } + } + } +} diff --git a/packages/loom-wasm/src/bootWasmMachine.ts b/packages/loom-wasm/src/bootWasmMachine.ts new file mode 100644 index 0000000..11a0d23 --- /dev/null +++ b/packages/loom-wasm/src/bootWasmMachine.ts @@ -0,0 +1,59 @@ +// The package's main entry point: boot the Loom runtime in-browser from a +// real dataDir/moduleDir (the same manifest-driven boot native's +// `loom --module-dir --data-dir` uses -- see bootFromDataDir in +// loomRuntime.js) and return a WasmMachine, a drop-in ICommLayer for +// @loupeteam/lux-react's . An app that +// already uses lux-react's useVariable()/useMachine() hooks needs zero +// changes anywhere else to consume it. +import { bootFromDataDir } from './loomRuntime.js'; +import { WasmMachine } from './WasmMachine.js'; + +export interface BootWasmMachineOptions { + /** Base URL serving the dataDir's contents (instances.json, scheduler.json, + * /config.json, ...) read-only -- e.g. '/data/loom'. */ + dataUrl: string; + /** Base URL serving the moduleDir's .so files -- e.g. '/output/modules'. */ + moduleUrl: string; + /** Base URL serving loom_wasm.js/.wasm (this package ships them under its + * own wasm/ dir -- copy that into your app's static output and point + * this at wherever it ends up served, e.g. '/loom-wasm'). Required and + * not defaulted: resolving it relative to the package's own install + * location depends on the consumer's bundler, which this package + * deliberately doesn't assume. */ + wasmUrl: string; + /** Cooperative tick cadence, ms. Default 25. */ + tickMs?: number; + /** Poll interval WasmMachine uses for subscribe()-backed variables, ms. Default 100. */ + pollMs?: number; +} + +function loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = src; + s.onload = () => resolve(); + s.onerror = () => reject(new Error('failed to load ' + src)); + document.head.appendChild(s); + }); +} + +export async function bootWasmMachine(opts: BootWasmMachineOptions): Promise { + const { dataUrl, moduleUrl, wasmUrl, tickMs, pollMs } = opts; + const base = wasmUrl.endsWith('/') ? wasmUrl : wasmUrl + '/'; + + await loadScript(base + 'loom_wasm.js'); + const createModule = (window as unknown as { createLoomModule?: () => Promise }).createLoomModule; + if (!createModule) { + throw new Error(`bootWasmMachine: ${base}loom_wasm.js did not define window.createLoomModule`); + } + + const rt = await bootFromDataDir({ createModule: () => createModule(), dataUrl, moduleUrl, tickMs }); + if (rt.skipped.length) { + // Expected for any instance not built for wasm (e.g. real-hardware-only + // modules) -- the C++ loader also refuses a version/ABI-mismatched .so + // (see runtime/src/module_loader.cpp) and bootFromDataDir folds that + // case into `skipped` too, distinct from a raw 404. + console.warn(`bootWasmMachine: ${rt.skipped.length} instance(s) not loaded, skipped:`, rt.skipped); + } + return new WasmMachine(rt, pollMs); +} diff --git a/packages/loom-wasm/src/index.ts b/packages/loom-wasm/src/index.ts new file mode 100644 index 0000000..b5d2cdb --- /dev/null +++ b/packages/loom-wasm/src/index.ts @@ -0,0 +1,6 @@ +export { bootWasmMachine } from './bootWasmMachine.js'; +export type { BootWasmMachineOptions } from './bootWasmMachine.js'; +export { WasmMachine } from './WasmMachine.js'; +export type { NodeAccess } from './WasmMachine.js'; +export { createLoomRuntime, bootFromDataDir } from './loomRuntime.js'; +export type { LoomRuntime, CreateLoomRuntimeOptions, BootFromDataDirOptions } from './loomRuntime.js'; diff --git a/packages/loom-wasm/src/loomRuntime.d.ts b/packages/loom-wasm/src/loomRuntime.d.ts new file mode 100644 index 0000000..7ec6e61 --- /dev/null +++ b/packages/loom-wasm/src/loomRuntime.d.ts @@ -0,0 +1,35 @@ +// Hand-written declarations for loomRuntime.js (kept as plain JS -- it's a +// framework-agnostic runtime service, not TS-specific). See loomRuntime.js +// for behavior/comments; this file only describes the public shape so +// consumers of the published package get real types instead of `any`. + +export interface LoomRuntime { + Module: unknown; + request(method: string, path: string, body?: string): { status: number; body: unknown }; + loadModule(name: string, bytes: Uint8Array | ArrayBuffer): string; + moduleIds(): string[]; + readNode(nodeId: string): string; + writeNode(nodeId: string, value: unknown): boolean; + makeFetch(passthrough?: typeof fetch): typeof fetch; + installFetch(): () => void; + stop(): void; +} + +export interface CreateLoomRuntimeOptions { + createModule: () => Promise; + tickMs?: number; + beforeInit?: (module: unknown) => Promise; +} + +export function createLoomRuntime(opts: CreateLoomRuntimeOptions): Promise; + +export interface BootFromDataDirOptions { + createModule: () => Promise; + dataUrl: string; + moduleUrl: string; + tickMs?: number; +} + +export function bootFromDataDir( + opts: BootFromDataDirOptions +): Promise; diff --git a/packages/loom-wasm/src/loomRuntime.js b/packages/loom-wasm/src/loomRuntime.js new file mode 100644 index 0000000..a4ad5e9 --- /dev/null +++ b/packages/loom-wasm/src/loomRuntime.js @@ -0,0 +1,200 @@ +// Framework-agnostic "Loom runtime in the browser" service. +// +// Boots the wasm runtime, drives the cooperative tick loop, loads user modules, +// and serves /api/* through the SAME dispatch the native HTTP server uses — so a +// UI built for the native runtime works against the in-browser runtime with no +// changes. The React wrapper is LoomRuntimeProvider.tsx. +// +// Live OPC-UA values (useVariable) are a separate transport, not handled here yet. + +/** + * @param {object} opts + * @param {() => Promise} opts.createModule Emscripten factory (createLoomModule). + * @param {number} [opts.tickMs=25] Cooperative tick cadence. + * @param {(M: any) => Promise} [opts.beforeInit] Called after the wasm + * Module is ready and /modules, /data, /uploads exist in MEMFS, but BEFORE + * loom_init() runs -- the hook point for seeding instances.json/scheduler.json/ + * module .so files/config.json into MEMFS ahead of boot (see bootFromDataDir). + */ +export async function createLoomRuntime({ createModule, tickMs = 25, beforeInit } = {}) { + if (!createModule) throw new Error('createLoomRuntime: createModule (the Emscripten factory) is required'); + const M = await createModule(); + for (const d of ['/modules', '/data', '/uploads']) { try { M.FS.mkdir(d); } catch (e) {} } + + const c = { + init: M.cwrap('loom_init', 'number', ['string', 'string']), + tick: M.cwrap('loom_tick', null, []), + request: M.cwrap('loom_request', 'string', ['string', 'string', 'string']), + loadModule: M.cwrap('loom_load_module', 'string', ['string']), + moduleIds: M.cwrap('loom_module_ids', 'string', []), + readNode: M.cwrap('loom_read_node', 'string', ['string']), + writeNode: M.cwrap('loom_write_node', 'number', ['string', 'string']), + }; + + if (beforeInit) await beforeInit(M); + // loom_init returns the module count, or -1 on a boot failure (in which case + // the C++ side has torn the runtime down and every later call would 503). + // Fail fast rather than hand back a runtime that quietly serves errors. + const initCount = c.init('/modules', '/data'); + if (initCount < 0) { + throw new Error('createLoomRuntime: loom_init failed -- see the console for the runtime error'); + } + let timer = setInterval(() => c.tick(), tickMs); + + /** Route a request through the wasm runtime. @returns {{status:number, body:any}} */ + function request(method, path, body = '') { + const raw = c.request(String(method).toUpperCase(), path, body || ''); + try { return JSON.parse(raw); } catch (e) { return { status: 502, body: null }; } + } + + /** Load a user module from raw bytes. @returns {string} module id ('' on failure). */ + function loadModule(name, bytes) { + const p = '/uploads/' + name; + M.FS.writeFile(p, bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)); + return c.loadModule(p); + } + + /** @returns {string[]} ids of currently loaded modules. */ + function moduleIds() { + const s = c.moduleIds(); + return s ? s.split(',').filter(Boolean) : []; + } + + /** A fetch() that serves /api/* from the wasm runtime and delegates the rest. */ + function makeFetch(passthrough = (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : null)) { + return async (input, init) => { + const url = typeof input === 'string' ? input : input.url; + const u = new URL(url, 'http://loom.wasm'); + if (u.pathname.startsWith('/api/')) { + const method = init && init.method ? init.method : 'GET'; + const body = init && init.body != null ? String(init.body) : ''; + const env = request(method, u.pathname + u.search, body); + return new Response(env.body == null ? '' : JSON.stringify(env.body), { + status: env.status, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (!passthrough) throw new Error('LoomRuntime: no passthrough fetch for ' + url); + return passthrough(input, init); + }; + } + + /** Replace globalThis.fetch so existing fetch('/api/*') hits wasm. @returns uninstaller */ + function installFetch() { + const orig = globalThis.fetch ? globalThis.fetch.bind(globalThis) : null; + globalThis.fetch = makeFetch(orig); + return () => { if (orig) globalThis.fetch = orig; }; + } + + /** Raw reflected-value JSON for an OPC-UA-style nodeId ('null' if unknown). */ + function readNode(nodeId) { return c.readNode(nodeId); } + + /** Write a reflected node value (any JSON-serializable). @returns {boolean} */ + function writeNode(nodeId, value) { return c.writeNode(nodeId, JSON.stringify(value)) === 1; } + + function stop() { if (timer) { clearInterval(timer); timer = null; } } + + return { Module: M, request, loadModule, moduleIds, readNode, writeNode, makeFetch, installFetch, stop }; +} + +/** + * Boot the runtime the way native `loom --module-dir --data-dir ` + * does: read /instances.json (the SAME manifest format native reads + * from disk -- [{id, so, className}, ...]) to learn which modules to load, + * fetch each one's .so from /, and fetch each instance's persisted + * config.json + the shared scheduler.json from /, seeding all of it + * into MEMFS before loom_init() runs. RuntimeCore::loadModules()'s existing + * native "boot from instances.json" code path does everything else -- this + * function's only job is turning "files on a real dataDir/moduleDir" into + * "the same files fetched over HTTP and written into MEMFS first". + * + * A module named in instances.json but not found at /.so + * (e.g. it was never built for wasm) is skipped with a warning, matching + * native's resolveModulePath() behavior -- the rest of the boot proceeds. + * + * @param {object} opts + * @param {() => Promise} opts.createModule Emscripten factory (createLoomModule). + * @param {string} opts.dataUrl Base URL serving the real dataDir's contents + * (instances.json, scheduler.json, /config.json, ...) read-only. + * @param {string} opts.moduleUrl Base URL serving the real moduleDir's .so files. + * @param {number} [opts.tickMs=25] + * @returns {Promise & {skipped: string[]}>} + */ +export async function bootFromDataDir({ createModule, dataUrl, moduleUrl, tickMs = 25 } = {}) { + if (!dataUrl || !moduleUrl) throw new Error('bootFromDataDir: dataUrl and moduleUrl are required'); + const skipped = []; + let manifestIds = []; + + async function fetchOk(url) { + try { + // no-store: native boot reads whatever's on disk right now, with no + // caching layer -- a browser HTTP cache serving a stale build after a + // module gets rebuilt (or removed) would silently defeat that. + const r = await fetch(url, { cache: 'no-store' }); + return r.ok ? r : null; + } catch (e) { + return null; + } + } + + const beforeInit = async (M) => { + const manifestResp = await fetchOk(`${dataUrl}/instances.json`); + if (!manifestResp) { + console.warn(`bootFromDataDir: no instances.json at ${dataUrl} -- booting with zero modules`); + return; + } + const manifestText = await manifestResp.text(); + M.FS.writeFile('/data/instances.json', manifestText); + + let entries = []; + try { entries = JSON.parse(manifestText); } catch (e) { + console.warn('bootFromDataDir: instances.json did not parse as JSON', e); + } + manifestIds = entries.map((e) => e && e.id).filter(Boolean); + + const schedResp = await fetchOk(`${dataUrl}/scheduler.json`); + if (schedResp) M.FS.writeFile('/data/scheduler.json', await schedResp.text()); + + for (const entry of entries) { + const { id, so } = entry; + if (!id || !so) continue; + + const soResp = await fetchOk(`${moduleUrl}/${so}.so`); + if (!soResp) { + console.warn(`bootFromDataDir: '${id}' (${so}.so) not found at ${moduleUrl} -- skipping ` + + `(likely not built for wasm; native-only modules are expected to be absent here)`); + skipped.push(id); + continue; + } + const bytes = new Uint8Array(await soResp.arrayBuffer()); + M.FS.writeFile(`/modules/${so}.so`, bytes); + + const cfgResp = await fetchOk(`${dataUrl}/${id}/config.json`); + if (cfgResp) { + try { M.FS.mkdir(`/data/${id}`); } catch (e) {} + M.FS.writeFile(`/data/${id}/config.json`, await cfgResp.text()); + } + } + }; + + const rt = await createLoomRuntime({ createModule, tickMs, beforeInit }); + + // A manifest id whose .so fetched fine (so it's not already in `skipped`) + // but never ended up registered was rejected inside loom_init() itself -- + // most likely an SDK/ABI version mismatch (see runtime/src/module_loader.cpp, + // which refuses to load a module built against a different SDK version and + // logs a clear error, but has no per-module JS-visible return value at this + // boot stage). Fold it into `skipped` too, distinct from a 404, so a + // consumer doesn't silently end up with fewer live modules than + // instances.json listed with no visible signal. + const loadedIds = new Set(rt.moduleIds()); + for (const id of manifestIds) { + if (!skipped.includes(id) && !loadedIds.has(id)) { + console.warn(`bootFromDataDir: '${id}' fetched but did not load (likely SDK/ABI version ` + + `mismatch -- check the console above for a "SDK version mismatch" error)`); + skipped.push(id); + } + } + + return { ...rt, skipped }; +} diff --git a/packages/loom-wasm/tsconfig.json b/packages/loom-wasm/tsconfig.json new file mode 100644 index 0000000..136982d --- /dev/null +++ b/packages/loom-wasm/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index b6e08d8..4af9030 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -19,36 +19,129 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) endif() # --------------------------------------------------------------------------- -# Static library — all runtime logic, exported for consumers to link against. -# Consumers add a thin main.cpp that calls loom::run(argc, argv). +# loom_core — portable engine. No networking, signals, or process lifecycle: +# the scheduler, data engine/store, module loader, IO mapper, oscilloscope and +# the portable diagnostics. Depends only on the SDK, spdlog, glaze (via the SDK) +# and dlopen. This is the layer every host links against — the native host +# below, and (out of tree, on branch wasm-spike) an Emscripten/WASM host. +# +# NB: keep this list free of any TU that needs sockets (Crow), signals, or +# in-process stack symbolization (cpptrace) — those belong in loom_runtime. # --------------------------------------------------------------------------- -add_library(loom_runtime STATIC - src/run.cpp +add_library(loom_core STATIC src/runtime_core.cpp + src/runtime_heap.cpp src/module_loader.cpp src/scheduler.cpp src/scheduler_config.cpp src/data_engine.cpp src/data_store.cpp src/io_mapper.cpp - src/server.cpp - src/opcua_rest_server.cpp src/oscilloscope.cpp src/module_watcher.cpp + src/api/router.cpp + src/diag/breadcrumb.cpp + src/diag/fault_report.cpp + src/diag/fault_store.cpp + src/diag/runtime_fault_sink.cpp + src/diag/system_metrics.cpp ) -target_include_directories(loom_runtime PUBLIC +# Process memory metrics need psapi (GetProcessMemoryInfo) on Windows; macOS uses +# the Mach task API (in the system libs) and Linux reads /proc — no extra links. +# system_metrics.cpp lives in loom_core (RuntimeCore owns the sampler), so the +# link goes here rather than loom_runtime. +if(WIN32) + target_link_libraries(loom_core PRIVATE psapi) +endif() + +target_include_directories(loom_core PUBLIC $ $ ) -target_link_libraries(loom_runtime PUBLIC +# Build type stamped into crash reports (works for single- and multi-config). +target_compile_definitions(loom_core PRIVATE LOOM_BUILD_TYPE="$") + +target_link_libraries(loom_core PUBLIC loom::sdk spdlog::spdlog - Crow::Crow ${CMAKE_DL_LIBS} ) +if(EMSCRIPTEN) + +# --------------------------------------------------------------------------- +# loom_host_wasm — WASM host: run_wasm.cpp + loom_core built as a MAIN_MODULE so +# module plugins can be dlopen'd at runtime. No Crow / cpptrace / signals. Real +# pthreads: startClasses() spawns actual Worker-backed class threads (same code +# path as native), so cyclic() timing/pacing/pause-handshake work unmodified -- +# no Scheduler::tickOnce() involved (see run_wasm.cpp's loom_init/loom_tick). +# Emits loom_wasm.js + loom_wasm.wasm. +# --------------------------------------------------------------------------- +add_executable(loom_host_wasm src/run_wasm.cpp) +target_compile_definitions(loom_host_wasm PRIVATE LOOM_BUILD_TYPE="$") +target_link_libraries(loom_host_wasm PRIVATE loom_core) +set_target_properties(loom_host_wasm PROPERTIES OUTPUT_NAME loom_wasm SUFFIX ".js") +target_link_options(loom_host_wasm PRIVATE + "-sMAIN_MODULE=1" + "-sALLOW_MEMORY_GROWTH=1" + "-sMODULARIZE=1" + "-sEXPORT_NAME=createLoomModule" + "-sENVIRONMENT=web,worker,node" + "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','FS']" + # Pre-warm a worker pool: the 3 built-in classes (fast/normal/slow) plus a + # spare for an isolated-thread or long-running module. Emscripten can grow + # beyond this on demand, but that path is less exercised -- size for the + # common case rather than relying on it. + "-sPTHREAD_POOL_SIZE=4") + +# Stage the host straight into the frontend's static assets on every build, so +# `cmake --build` alone is enough — no manual cp. Mirrors link_frontend_ui below +# (native build symlinks data/UI -> frontend/dist for the same reason). +if(EXISTS "${CMAKE_SOURCE_DIR}/frontend") + add_custom_command(TARGET loom_host_wasm POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_SOURCE_DIR}/frontend/public" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "${CMAKE_SOURCE_DIR}/frontend/public/" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/loom_wasm.wasm" + "${CMAKE_SOURCE_DIR}/frontend/public/" + COMMENT "Staging loom_wasm.js/.wasm into frontend/public/" + VERBATIM + ) +endif() + +else() # ---- native host ---------------------------------------------------- + +# --------------------------------------------------------------------------- +# loom_runtime — native host layer over loom_core: the HTTP/WebSocket and +# OPC-UA servers, process lifecycle / signal handling, and in-process crash +# capture. It aggregates loom_core (PUBLIC), so the installed and conan-consumed +# `loom_runtime` target keeps providing the full runtime exactly as before. +# Consumers still add a thin main.cpp that calls loom::run(argc, argv). +# --------------------------------------------------------------------------- +add_library(loom_runtime STATIC + src/run.cpp + src/server.cpp + src/opcua_rest_server.cpp + src/diag/crash_handler.cpp + src/diag/symbolizer.cpp +) + +# Build type stamped into crash reports (works for single- and multi-config). +target_compile_definitions(loom_runtime PRIVATE LOOM_BUILD_TYPE="$") + +target_link_libraries(loom_runtime PUBLIC + loom_core + Crow::Crow +) + +# Symbolization for crash reports. PRIVATE: confined to the diag symbolizer TU, +# never exposed in loom_runtime's public headers (SDK/consumers stay clean of it). +target_link_libraries(loom_runtime PRIVATE cpptrace::cpptrace) + # --------------------------------------------------------------------------- # Thin executable — just calls loom::run(argc, argv). # --------------------------------------------------------------------------- @@ -91,7 +184,7 @@ endif() # --------------------------------------------------------------------------- include(GNUInstallDirs) -install(TARGETS loom_runtime +install(TARGETS loom_runtime loom_core ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ) @@ -103,6 +196,15 @@ install(TARGETS loom RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) +# Ship the Windows PDB next to the binary so crash reports symbolize in-process +# on the downloaded runtime. OPTIONAL: absent when LOOM_WITH_DEBUG_INFO is OFF. +# Linux DWARF is embedded in the unstripped binary; the macOS .dSYM is produced +# from the binary in the CI staging step (dsymutil). +if(MSVC) + install(FILES $ + DESTINATION ${CMAKE_INSTALL_BINDIR} OPTIONAL) +endif() + install(DIRECTORY "${CMAKE_SOURCE_DIR}/frontend/dist/" DESTINATION data/UI OPTIONAL @@ -117,3 +219,5 @@ install(DIRECTORY "${CMAKE_SOURCE_DIR}/frontend/dist/" OPTIONAL ) +endif() # EMSCRIPTEN / native host + diff --git a/runtime/conanfile.py b/runtime/conanfile.py index 78096f5..145b9f5 100644 --- a/runtime/conanfile.py +++ b/runtime/conanfile.py @@ -22,6 +22,7 @@ def requirements(self): self.requires(f"loom/{self.version}@local/stable", transitive_headers=True) self.requires("spdlog/1.17.0", transitive_headers=True) self.requires("crowcpp-crow/1.3.0", transitive_headers=True) + self.requires("cpptrace/0.8.3") # impl-only: crash-report symbolization (not in public headers) def layout(self): cmake_layout(self) @@ -38,7 +39,9 @@ def package(self): def package_info(self): self.cpp_info.set_property("cmake_file_name", "loom-runtime") self.cpp_info.set_property("cmake_target_name", "loom::runtime") - self.cpp_info.libs = ["loom_runtime"] + # loom_runtime (native host) aggregates loom_core (portable engine). + # Order matters for static linking: dependent before dependency. + self.cpp_info.libs = ["loom_runtime", "loom_core"] self.cpp_info.includedirs = ["include"] self.cpp_info.libdirs = ["lib"] diff --git a/runtime/include/loom/api/json_build.h b/runtime/include/loom/api/json_build.h new file mode 100644 index 0000000..9b7c555 --- /dev/null +++ b/runtime/include/loom/api/json_build.h @@ -0,0 +1,40 @@ +#pragma once + +// Shared, transport-agnostic JSON builders for the runtime's REST surface. +// Crow-free, so both the native HTTP server (server.cpp) and the WASM api +// router (api/router.cpp) build identical response bodies — one source of truth. + +#include "loom/module_loader.h" +#include "loom/scheduler.h" + +#include +#include +#include +#include +#include +#include + +namespace loom { + +/// JSON-escape a string value. +std::string jsonEscapeString(std::string s); + +/// Serialize metric history to a JSON array. `maxSamples` caps the trailing +/// samples emitted (0 = unlimited). +std::string serializeCycleHistory(const std::vector& samples, std::size_t maxSamples = 0); +std::string serializeCycleHistory(const std::deque& samples, std::size_t maxSamples = 0); + +/// Per-module info object (id, name, class, state, path, stats + cycle history). +/// `extraFields`, if non-empty, is spliced into the object as additional +/// members (raw JSON, without enclosing braces or a leading comma) — e.g. +/// `"data":{...}` to build the ModuleDetail shape. Kept inside the builder so +/// callers never have to do brace surgery on its output. +std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler, + std::string_view extraFields = {}); + +/// Cycle/jitter history as `{"samples":[...], "latest":}`. `since` filters to +/// samples newer than the given timestamp; `binMs` > 0 aggregates into fixed-width +/// bins (max cycle, max |jitter| per bin) instead of emitting raw samples. +std::string buildHistoryBody(const std::vector& samples, int64_t since, int64_t binMs); + +} // namespace loom diff --git a/runtime/include/loom/api/router.h b/runtime/include/loom/api/router.h new file mode 100644 index 0000000..60b9470 --- /dev/null +++ b/runtime/include/loom/api/router.h @@ -0,0 +1,39 @@ +#pragma once + +// Transport-agnostic dispatch over the Loom runtime's REST surface. The native +// Crow server (server.cpp) and the WASM host (run_wasm.cpp) both route /api/* +// requests through dispatch(), so there is a single source of truth for the API +// regardless of transport (HTTP socket vs. in-process JS call). + +#include +#include + +namespace loom { +class RuntimeCore; + +namespace api { + +// DELETE_ (not DELETE) because /winnt.h #defines DELETE as +// (0x00010000L) — MSVC then macro-expands Method::DELETE into a syntax error +// (C2589) regardless of namespacing, since the preprocessor runs first. +enum class Method { GET, POST, PUT, PATCH, DELETE_, UNKNOWN }; + +Method methodFromString(std::string_view s); + +struct Request { + Method method = Method::GET; + std::string path; ///< full path, e.g. "/api/modules" or "/api/modules/foo" + std::string body; ///< raw request body (JSON), empty for GET +}; + +struct Response { + int status = 200; + std::string contentType = "application/json"; + std::string body; +}; + +/// Route a request to the matching handler. Returns 404 for unmatched paths. +Response dispatch(RuntimeCore& core, const Request& req); + +} // namespace api +} // namespace loom diff --git a/runtime/include/loom/diag/breadcrumb.h b/runtime/include/loom/diag/breadcrumb.h new file mode 100644 index 0000000..3cb6a0d --- /dev/null +++ b/runtime/include/loom/diag/breadcrumb.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +// ============================================================================ +// loom::diag — execution breadcrumb +// +// A per-thread record of what the runtime is currently executing (which module, +// class, and lifecycle phase). Set cheaply via RAII around every module entry +// call; read by the crash handler (on the faulting thread) to attribute a fault +// to a specific module/phase. +// +// Stores *stable pointers* into the module's id/class strings (which outlive the +// call) plus a phase byte — no allocation, no copy. Reading the raw pointers/ +// bytes from a signal handler is allocator-free and async-signal-safe. +// ============================================================================ + +namespace loom::diag { + +enum class Phase : uint8_t { + None = 0, Init, PreCyclic, Cyclic, PostCyclic, LongRunning, Exit, Service, +}; + +/// Human-readable phase name (no allocation — safe in a signal handler). +inline const char* phaseName(Phase p) noexcept { + switch (p) { + case Phase::Init: return "init"; + case Phase::PreCyclic: return "preCyclic"; + case Phase::Cyclic: return "cyclic"; + case Phase::PostCyclic: return "postCyclic"; + case Phase::LongRunning: return "longRunning"; + case Phase::Exit: return "exit"; + case Phase::Service: return "service"; + case Phase::None: return "none"; + } + return "?"; +} + +struct Breadcrumb { + const char* moduleId = nullptr; // stable pointer into the module's id + const char* className = nullptr; // stable pointer into the module's class name + Phase phase = Phase::None; + uint64_t cycle = 0; +}; + +/// The current thread's breadcrumb. The crash handler runs on the faulting +/// thread, so reading this names the exact module/phase that was executing. +extern thread_local Breadcrumb tlsBreadcrumb; + +/// RAII: stamp the breadcrumb on construction, restore the previous value on +/// destruction (so nested calls — e.g. a service invoked from cyclic — unwind +/// correctly). +class BreadcrumbScope { +public: + BreadcrumbScope(Phase p, const char* moduleId, const char* className) noexcept + : prev_(tlsBreadcrumb) { + tlsBreadcrumb.moduleId = moduleId; + tlsBreadcrumb.className = className; + tlsBreadcrumb.phase = p; + } + ~BreadcrumbScope() noexcept { tlsBreadcrumb = prev_; } + + BreadcrumbScope(const BreadcrumbScope&) = delete; + BreadcrumbScope& operator=(const BreadcrumbScope&) = delete; + +private: + Breadcrumb prev_; +}; + +} // namespace loom::diag diff --git a/runtime/include/loom/diag/crash_handler.h b/runtime/include/loom/diag/crash_handler.h new file mode 100644 index 0000000..193414c --- /dev/null +++ b/runtime/include/loom/diag/crash_handler.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +// ============================================================================ +// loom::diag — process-global crash handler (hardware-fault / unhandled path) +// +// Installs fatal-signal handlers (POSIX) / an unhandled-exception filter +// (Windows) / std::set_terminate, so a segfault/FPE/abort or an escaped C++ +// exception — in a module OR in the runtime itself — produces a crash report +// (faulting thread's breadcrumb + signal/exception + build identity + raw stack +// addresses) before the process exits. Symbolization is layered on later +// (Phase 2). Install once, early in startup. +// ============================================================================ + +namespace loom::diag { + +struct CrashConfig { + std::filesystem::path crashDir; // where crash reports are written (e.g. /crash) +}; + +class CrashHandler { +public: + /// Install the handlers. Idempotent; call once after logging is set up. + static void install(const CrashConfig& cfg); +}; + +} // namespace loom::diag diff --git a/runtime/include/loom/diag/fault_report.h b/runtime/include/loom/diag/fault_report.h new file mode 100644 index 0000000..d0e4f39 --- /dev/null +++ b/runtime/include/loom/diag/fault_report.h @@ -0,0 +1,66 @@ +#pragma once + +#include "loom/diag/breadcrumb.h" +#include "loom/diag/symbolizer.h" + +#include +#include +#include +#include + +// ============================================================================ +// loom::diag — structured fault report +// +// The machine-readable record of a single fault, written to +// /crash/.json and served over /api/faults for the LoomUI crash +// viewer. Built and serialized OFF the signal path only (it allocates): the +// exception path (scheduler guard) and the Windows unhandled-exception filter. +// The POSIX fatal-signal handler writes a raw text report instead (async- +// signal-safe) which is symbolized offline. +// ============================================================================ + +namespace loom::diag { + +enum class FaultKind : uint8_t { + Exception, ///< C++ exception caught by a module-call guard + Signal, ///< Fatal signal / SEH exception caught by the crash handler +}; + +const char* faultKindName(FaultKind); + +/// Module data sections captured at fault time (exception path only — reading a +/// module's state is safe off the signal path). Each holds raw JSON or "". +struct FaultSections { + std::string config; + std::string recipe; + std::string runtime; + std::string summary; +}; + +struct FaultReport { + std::string id; ///< Unique within a run, also the filename stem + int64_t tsMs = 0; ///< system_clock milliseconds + FaultKind kind = FaultKind::Exception; + int signalOrCode = 0; ///< signal number / SEH exception code (0 for exceptions) + std::string reason; ///< what() or signal/exception description + + // Build identity (so a report maps back to a commit + matching symbols). + std::string sdkVersion; + std::string gitSha; + std::string buildType; + + // Execution breadcrumb (which module/phase was running on the faulting thread). + std::string moduleId; ///< "" → runtime code (no module on the thread) + std::string className; + Phase phase = Phase::None; + uint64_t cycle = 0; + + std::vector frames; ///< symbolized stack (empty if unavailable) + std::optional sections; ///< captured live values (exception path) +}; + +/// Serialize to clean, nested JSON (sections embed as real JSON objects, not +/// escaped strings). Allocates — off-signal use only. +std::string toJson(const FaultReport&); + +} // namespace loom::diag diff --git a/runtime/include/loom/diag/fault_sink.h b/runtime/include/loom/diag/fault_sink.h new file mode 100644 index 0000000..b9928e4 --- /dev/null +++ b/runtime/include/loom/diag/fault_sink.h @@ -0,0 +1,37 @@ +#pragma once + +#include "loom/diag/breadcrumb.h" + +#include +#include + +// ============================================================================ +// loom::diag — fault sink interface +// +// The scheduler depends only on this interface (injected, not owned) so it +// stays thin: when a guarded module call throws, it reports a FaultEvent and +// the concrete sink (in the runtime layer) does the heavy lifting — capture +// live sections, build + persist a FaultReport, publish the `loom/faults` +// topic. Keeping the interface here lets diag stay free of DataEngine/Bus deps. +// ============================================================================ + +namespace loom::diag { + +struct FaultEvent { + std::string moduleId; + std::string className; + Phase phase = Phase::None; + uint64_t cycle = 0; + std::string message; ///< exception what() +}; + +class IFaultSink { +public: + virtual ~IFaultSink() = default; + + /// Called from the faulting worker thread, off the signal path. Implementations + /// must be thread-safe and must not throw. + virtual void onModuleFault(const FaultEvent&) = 0; +}; + +} // namespace loom::diag diff --git a/runtime/include/loom/diag/fault_store.h b/runtime/include/loom/diag/fault_store.h new file mode 100644 index 0000000..b88000e --- /dev/null +++ b/runtime/include/loom/diag/fault_store.h @@ -0,0 +1,67 @@ +#pragma once + +#include "loom/diag/fault_report.h" + +#include +#include +#include +#include +#include + +// ============================================================================ +// loom::diag — fault store +// +// Thread-safe registry of fault reports for this run, backed by JSON files in +// . On construction it scans the directory so crashes from PRIOR runs +// (including the signal-path text reports the process couldn't keep in memory) +// are visible too. The server's /api/faults[/:id] routes delegate here. +// ============================================================================ + +namespace loom::diag { + +class FaultStore { +public: + /// One row in the fault list — enough to render the LoomUI tree without + /// fetching every full report. + struct Summary { + std::string id; + int64_t tsMs = 0; + std::string kind; ///< "exception" | "signal" | "raw" + std::string moduleId; ///< "" → runtime code + std::string className; + std::string phase; + std::string reason; + }; + + explicit FaultStore(std::filesystem::path crashDir); + + /// Persist a live fault (JSON file + in-memory summary). Returns its id. + /// Safe to call from any worker thread; never throws. + std::string record(const FaultReport& report) noexcept; + + /// Same as record() but accepts pre-serialized JSON to avoid double toJson() work. + std::string record(const FaultReport& report, std::string json) noexcept; + + /// All known faults, newest first. + std::vector list() const; + + /// Full report JSON for one id ("" stem), or nullopt if unknown. + std::optional detailJson(const std::string& id) const; + + const std::filesystem::path& crashDir() const { return crashDir_; } + +private: + struct Entry { + Summary summary; + std::string rawJson; ///< full report JSON served by detailJson() + }; + + /// Load existing reports from disk (called once at construction). + void scanDir(); + + std::filesystem::path crashDir_; + mutable std::mutex mx_; + std::vector entries_; ///< append-only; newest at the back +}; + +} // namespace loom::diag diff --git a/runtime/include/loom/diag/guard.h b/runtime/include/loom/diag/guard.h new file mode 100644 index 0000000..1c96808 --- /dev/null +++ b/runtime/include/loom/diag/guard.h @@ -0,0 +1,47 @@ +#pragma once + +#include "loom/diag/breadcrumb.h" + +#include +#include + +// ============================================================================ +// loom::diag — module-call guard (C++ exception path) +// +// Wraps a module entry-point call in a breadcrumb + try/catch. Catches a thrown +// std::exception (or anything), reports it via the caller-supplied onFault, and +// returns false — turning an exception that would otherwise terminate the worker +// thread / process into a contained, attributed fault. Hardware faults +// (segfault/FPE) are NOT exceptions; those are captured by the crash handler. +// +// `onFault` is a template parameter (not std::function) so the happy path is +// zero-cost and allocation-free. The scheduler passes a small lambda that sets +// the module's faulted/Error state. +// ============================================================================ + +namespace loom::diag { + +struct FaultInfo { + const char* moduleId; + const char* className; + Phase phase; + std::string_view message; // exception what() (valid for the onFault call) +}; + +template +bool guard(Phase phase, const char* moduleId, const char* className, + Fn&& fn, OnFault&& onFault) { + BreadcrumbScope crumb(phase, moduleId, className); + try { + fn(); + return true; + } catch (const std::exception& e) { + onFault(FaultInfo{moduleId, className, phase, e.what()}); + return false; + } catch (...) { + onFault(FaultInfo{moduleId, className, phase, "unknown (non-std exception)"}); + return false; + } +} + +} // namespace loom::diag diff --git a/runtime/include/loom/diag/runtime_fault_sink.h b/runtime/include/loom/diag/runtime_fault_sink.h new file mode 100644 index 0000000..a0f1ad5 --- /dev/null +++ b/runtime/include/loom/diag/runtime_fault_sink.h @@ -0,0 +1,40 @@ +#pragma once + +#include "loom/diag/fault_sink.h" +#include "loom/diag/fault_store.h" + +#include +#include + +// ============================================================================ +// loom::diag — concrete fault sink (runtime layer) +// +// Bridges the scheduler's exception path to the rest of the runtime: builds a +// structured FaultReport from a module-call exception, captures the module's +// live data sections (safe off the signal path), persists it via the +// FaultStore, and publishes it on the `loom/faults` bus topic for the UI and +// any subscribing module. Keeping it here (not in the scheduler) is what lets +// diag stay free of DataEngine/Bus dependencies. +// ============================================================================ + +namespace loom { +class DataEngine; +class Bus; +} + +namespace loom::diag { + +class RuntimeFaultSink : public IFaultSink { +public: + RuntimeFaultSink(FaultStore& store, DataEngine& engine, Bus& bus); + + void onModuleFault(const FaultEvent& ev) override; + +private: + FaultStore& store_; + DataEngine& engine_; + Bus& bus_; + std::atomic seq_{0}; +}; + +} // namespace loom::diag diff --git a/runtime/include/loom/diag/symbolizer.h b/runtime/include/loom/diag/symbolizer.h new file mode 100644 index 0000000..e4d0236 --- /dev/null +++ b/runtime/include/loom/diag/symbolizer.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +// ============================================================================ +// loom::diag — stack symbolizer +// +// Resolves raw instruction addresses to function + file:line. Backed by cpptrace +// in symbolizer.cpp (the ONLY TU that includes cpptrace — it stays out of every +// header so consumers/SDK never see it). NOT async-signal-safe (it allocates and +// reads debug info): call only off the signal path — the Windows unhandled- +// exception filter, the std::set_terminate handler, or the offline --symbolize +// tool. The POSIX fatal-signal handler captures raw addresses and symbolizes +// later / offline. +// ============================================================================ + +namespace loom::diag { + +struct SymFrame { + uintptr_t address = 0; + std::string symbol; // function name ("" if unresolved) + std::string filename; // source file ("" if unavailable) + uint32_t line = 0; // 0 if unknown +}; + +std::vector symbolize(const void* const* addrs, std::size_t n); + +} // namespace loom::diag diff --git a/runtime/include/loom/diag/system_metrics.h b/runtime/include/loom/diag/system_metrics.h new file mode 100644 index 0000000..ce09e29 --- /dev/null +++ b/runtime/include/loom/diag/system_metrics.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// loom::diag — process resource metrics (memory + CPU) +// +// A lightweight background sampler that reads the process's resident memory and +// CPU usage from the OS (no dependencies) on a fixed interval, keeps a small +// history ring for charting, and — when enabled — logs a periodic line. Exposed +// over the runtime server (GET /api/system + the WS live frame), alongside the +// existing per-class/per-module cycle metrics. +// +// CPU is sampled as a delta between ticks, so the first sample reports 0. +// cpuPercent is normalized to 0–100% of the whole machine (total process CPU +// time / wall time / core count). +// ============================================================================ + +namespace loom::diag { + +struct SystemSample { + int64_t tsMs = 0; ///< system_clock milliseconds + uint64_t rssBytes = 0; ///< current resident set size + uint64_t peakRssBytes = 0; ///< peak resident set size since start + double cpuPercent = 0.0; ///< process CPU over the last interval, 0–100% of the machine + int64_t uptimeSec = 0; ///< seconds since the sampler started +}; + +class SystemMetrics { +public: + struct Config { + bool logEnabled = false; ///< emit a periodic spdlog line (the "setting") + int intervalMs = 1000; ///< sample period + int historyMax = 600; ///< samples retained for charting (~10 min @ 1s) + }; + + // NB: no `= {}` default — a defaulted Config argument would force the + // nested struct's default member initializers within the enclosing class + // definition, which GCC/Clang reject (MSVC allows it). Callers pass a Config. + explicit SystemMetrics(Config cfg); + ~SystemMetrics(); + + SystemMetrics(const SystemMetrics&) = delete; + SystemMetrics& operator=(const SystemMetrics&) = delete; + + /// Start the background sampler (idempotent). + void start(); + /// Stop and join the sampler (idempotent; also called by the destructor). + void stop(); + + /// Latest sample (thread-safe snapshot). + SystemSample current() const; + /// Recent samples, oldest first (thread-safe copy). + std::vector history() const; + + // --- raw OS readers (static; also usable for one-off reads) --- + static uint64_t readRssBytes(); + static uint64_t readPeakRssBytes(); + static uint64_t readCpuTimeNs(); ///< total process CPU time (user+system) + static unsigned cpuCount(); + +private: + void run(); + + Config cfg_; + std::thread thread_; + std::atomic running_{false}; + + mutable std::mutex mx_; + SystemSample latest_; + std::deque history_; + + std::chrono::steady_clock::time_point startTime_{}; +}; + +} // namespace loom::diag diff --git a/runtime/include/loom/runtime_core.h b/runtime/include/loom/runtime_core.h index 6c10468..a73a102 100644 --- a/runtime/include/loom/runtime_core.h +++ b/runtime/include/loom/runtime_core.h @@ -3,18 +3,23 @@ #include "loom/bus.h" #include "loom/data_engine.h" #include "loom/data_store.h" +#include "loom/diag/fault_store.h" +#include "loom/diag/runtime_fault_sink.h" +#include "loom/diag/system_metrics.h" #include "loom/instance_manifest.h" #include "loom/io_mapper.h" #include "loom/module.h" #include "loom/module_loader.h" #include "loom/module_watcher.h" #include "loom/oscilloscope.h" +#include "loom/runtime_heap_impl.h" #include "loom/scheduler.h" #include "loom/scheduler_config.h" #include "loom/types.h" #include #include +#include #include #include #include @@ -34,6 +39,19 @@ struct RuntimeConfig { std::vector additionalModuleDirs; std::filesystem::path dataDir = "./data"; std::chrono::milliseconds defaultCyclePeriod{100}; + /// Emit a periodic process memory/CPU log line (the --log-system-metrics + /// flag). Metrics are always sampled + served on /api/system regardless + /// (on thread-capable builds; a thread-free build serves zeros — see + /// SystemMetrics::start()). + bool logSystemMetrics = false; + + /// Cooperative (thread-free) mode: skip starting class threads and the file + /// watcher in loadModules(). The host drives execution by calling + /// scheduler().tickOnce() itself. Optional on a build with real threads + /// (LOOM_HAS_THREADS, see thread_support.h — native, or wasm built with + /// -pthread); FORCED to true by the RuntimeCore constructor on a build + /// without them (wasm without -pthread), regardless of this value. + bool cooperative = false; }; /// Central runtime controller. Owns all subsystems and module lifecycle operations. @@ -91,6 +109,8 @@ class RuntimeCore : public IModuleRegistry { Bus& bus() { return bus_; } Oscilloscope& oscilloscope() { return oscilloscope_; } IOMapper& ioMapper() { return ioMapper_; } + diag::FaultStore& faultStore() { return faultStore_; } + diag::SystemMetrics& systemMetrics() { return systemMetrics_; } const RuntimeConfig& config() const { return config_; } const SchedulerConfig& schedulerConfig() const { return schedCfg_; } @@ -130,6 +150,14 @@ class RuntimeCore : public IModuleRegistry { Oscilloscope oscilloscope_; IOMapper ioMapper_; ModuleWatcher watcher_; + RuntimeHeap runtimeHeap_; + + // Fault diagnostics: persistent store of fault reports + the sink that the + // scheduler notifies on a guarded exception. Declared after the subsystems + // it references so it is destroyed first. + diag::FaultStore faultStore_; + std::unique_ptr faultSink_; + diag::SystemMetrics systemMetrics_; std::shared_mutex moduleMutex_; }; diff --git a/runtime/include/loom/runtime_heap_impl.h b/runtime/include/loom/runtime_heap_impl.h new file mode 100644 index 0000000..af09c7c --- /dev/null +++ b/runtime/include/loom/runtime_heap_impl.h @@ -0,0 +1,23 @@ +#pragma once + +#include "loom/runtime_heap.h" + +#include +#include + +namespace loom { + +/// Concrete IRuntimeHeap owned by RuntimeCore. Backs allocate() with aligned +/// operator new; the returned shared_ptr's control block and deleter are +/// instantiated in the runtime TU (runtime_heap.cpp), hence resident and safe +/// to release after a consuming module unloads. +/// +/// Note: this is a plain heap (one object + one control-block allocation per +/// call). A fixed/pooled strategy can replace the body later for hard real-time +/// determinism without changing the IRuntimeHeap interface or any caller. +class RuntimeHeap : public IRuntimeHeap { +public: + std::shared_ptr allocate(std::size_t size, std::size_t align) override; +}; + +} // namespace loom diff --git a/runtime/include/loom/scheduler.h b/runtime/include/loom/scheduler.h index 6a0b86c..6f82499 100644 --- a/runtime/include/loom/scheduler.h +++ b/runtime/include/loom/scheduler.h @@ -4,6 +4,7 @@ #include "loom/scheduler_config.h" #include "loom/oscilloscope.h" #include "loom/data_engine.h" +#include "loom/diag/breadcrumb.h" #include #include @@ -19,6 +20,8 @@ #include #include +namespace loom::diag { class IFaultSink; } + namespace loom { // Forward declarations @@ -74,6 +77,17 @@ struct TaskState { std::atomic lastJitterUs{0}; ///< |actualStart − prevStart| − period (µs) std::atomic lastCyclicStartNs{0}; ///< Used internally to compute per-module jitter + // Last-fault diagnostics, written by the faulting worker thread in + // Scheduler::recordModuleFault() and read by the server thread. + // Synchronization: recordModuleFault writes these fields, THEN stores + // `faulted` with memory_order_release. Readers MUST load `faulted` with + // acquire and read these only when it is true. A module faults at most once + // (it is skipped afterward), so lastFaultMsg is effectively write-once — + // hence safe to read as a plain buffer after observing faulted==true. + std::atomic lastFaultMs{0}; ///< system_clock ms of last fault (0 = none) + std::atomic lastFaultPhase{0}; ///< Phase value at fault + char lastFaultMsg[256] = {}; + // Historical cycle/jitter data for charting (fixed-size ring buffer) MetricRingBuffer cycleHistory; mutable std::mutex cycleHistoryMx; @@ -135,6 +149,23 @@ class Scheduler { /// Idempotent: already-running classes are skipped. void startClasses(); + /// Run one cooperative scheduling pass: every class whose period has elapsed + /// (tracked against a steady clock, independently per class — see + /// ClassRunnerState::coopNextDue) gets one sweep of its members (preCyclic → + /// cyclic → I/O map → postCyclic), in place on the calling thread. No class + /// threads spawned, no sleeping, no RT thread policy. For hosts running in + /// cooperative mode (RuntimeConfig::cooperative; forced true when + /// !LOOM_HAS_THREADS, optional otherwise — see thread_support.h) that drive + /// the runtime from an external loop instead of startClasses(). Periods ARE + /// enforced (a "fast" class still runs more often than a "slow" one, capped + /// by call frequency); priority/affinity are NOT — they require an OS + /// thread, which cooperative mode doesn't have. Do NOT mix with + /// startClasses() on the same instance — RuntimeCore enforces this + /// (every startClasses() call site is gated on !cooperative), so as long + /// as callers go through RuntimeCore rather than a bare Scheduler this + /// can't happen. Takes the scheduler mutex for the duration of the call. + void tickOnce(); + /// Configure optional targets for cycle-aligned sampling. /// Pass pointers to the `Oscilloscope`, `DataEngine`, `ModuleLoader`, and /// the runtime `moduleMutex` so the scheduler can enqueue snapshots after @@ -146,6 +177,11 @@ class Scheduler { /// The scheduler will call executeForClass/executeForModule at appropriate times. void setIOMapper(IOMapper* mapper); + /// Inject the fault sink notified when a guarded module call throws. Not + /// owned; must outlive the scheduler. Optional — nullptr disables reporting + /// (the module is still quarantined). Set before startClasses(). + void setFaultSink(diag::IFaultSink* sink) { faultSink_ = sink; } + /// Stop a module. Removes from class (or stops isolated thread). Joins long-running thread. /// Does NOT call exit() — caller's responsibility. bool stop(const std::string& moduleId); @@ -227,6 +263,13 @@ class Scheduler { std::atomic liveCpuAffinity{-1}; std::atomic cfgEpoch{0}; + // Cooperative (tickOnce) scheduling only: the next steady-clock deadline + // at which this class is due to run. Default (epoch 0) = "not yet + // anchored"; tickOnce anchors it on first sight and advances it by the + // live period each run, so classes keep independent rates without + // threads. Unused by the threaded classLoop path. + std::chrono::steady_clock::time_point coopNextDue{}; + /// Publish def's tunables to the live atomics and bump the epoch. Call /// after mutating def (under the scheduler mutex). void publishTunables() { @@ -270,6 +313,17 @@ class Scheduler { void classLoop(ClassRunnerState& runner); void isolatedLoop(LoadedModule& mod, TaskConfig config, TaskState& state); + /// One sweep over a class's members (preCyclic → cyclic → I/O → postCyclic) + /// with per-member/class metrics. Shared by classLoop (native thread) and + /// tickOnce (cooperative driver); the caller owns timing/pacing. + void sweepClassOnce(ClassRunnerState& runner); + + /// Quarantine a module that threw from a guarded call and report the fault: + /// set faulted + ModuleState::Error, stamp last-fault fields, log, and notify + /// the fault sink (if any). Called from the worker thread, off the signal path. + void recordModuleFault(TaskState& state, LoadedModule& mod, + diag::Phase phase, std::string_view message); + // ---- State ------------------------------------------------------------------ SchedulerConfig schedCfg_; @@ -285,6 +339,7 @@ class Scheduler { ModuleLoader* loader_ = nullptr; std::shared_mutex* moduleMutex_ = nullptr; IOMapper* ioMapper_ = nullptr; + diag::IFaultSink* faultSink_ = nullptr; }; } // namespace loom diff --git a/runtime/include/loom/thread_support.h b/runtime/include/loom/thread_support.h new file mode 100644 index 0000000..94018f0 --- /dev/null +++ b/runtime/include/loom/thread_support.h @@ -0,0 +1,26 @@ +#pragma once + +/// thread_support.h — compile-time detection of real OS-thread availability. +/// +/// LOOM_HAS_THREADS is 1 for native builds (std::thread always backed by a real +/// OS thread) and for Emscripten builds compiled with -pthread (real +/// Worker-backed pthreads over SharedArrayBuffer — see runtime/CMakeLists.txt +/// and the de-risking spike in spike/phaseC-pthread-dlopen/). It is 0 only for +/// an Emscripten build WITHOUT -pthread: there, spawning a std::thread that +/// does real concurrent work isn't available, so the scheduler must run +/// cooperatively via Scheduler::tickOnce() instead of class threads. +/// +/// Check __EMSCRIPTEN_PTHREADS__ (defined only when -pthread was passed), NOT +/// __EMSCRIPTEN__ (defined for every Emscripten build regardless of threading) +/// — conflating the two was a bug in an earlier iteration of this codebase +/// (every wasm build, threaded or not, was treated as cooperative-only). +/// +/// CMake currently always builds the wasm host with -pthread (no non-threaded +/// wasm target is wired up), so LOOM_HAS_THREADS is 1 in every build produced +/// today. This header exists so the source itself supports both variants — +/// wiring a second, non-pthread CMake target is a separable, deferred step. +#if !defined(__EMSCRIPTEN__) || defined(__EMSCRIPTEN_PTHREADS__) +# define LOOM_HAS_THREADS 1 +#else +# define LOOM_HAS_THREADS 0 +#endif diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp new file mode 100644 index 0000000..d7be1a6 --- /dev/null +++ b/runtime/src/api/router.cpp @@ -0,0 +1,729 @@ +#include "loom/api/router.h" +#include "loom/api/json_build.h" + +#include "loom/runtime_core.h" +#include "loom/opcua_rest_nodeid.h" // opcrest::sectionFromName +#include "loom/diag/breadcrumb.h" // diag::phaseName + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace loom { + +// --------------------------------------------------------------------------- +// Shared JSON builders (moved verbatim from server.cpp so the native server and +// the WASM api router emit identical bodies). Crow-free. +// --------------------------------------------------------------------------- + +std::string jsonEscapeString(std::string s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (c == '\\') out += "\\\\"; + else if (c == '"') out += "\\\""; + else if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else if (c == '\t') out += "\\t"; + else out += c; + } + return out; +} + +std::string serializeCycleHistory(const std::vector& samples, std::size_t maxSamples) { + std::string json = "["; + bool first = true; + std::size_t startIdx = (maxSamples > 0 && samples.size() > maxSamples) ? samples.size() - maxSamples : 0; + for (std::size_t i = startIdx; i < samples.size(); ++i) { + const auto& s = samples[i]; + if (!first) json += ","; + json += "{\"t\":" + std::to_string(s.timestampMs) + + ",\"cycle\":" + std::to_string(s.cycleTimeUs) + + ",\"jitter\":" + std::to_string(s.jitterUs) + "}"; + first = false; + } + json += "]"; + return json; +} + +std::string serializeCycleHistory(const std::deque& samples, std::size_t maxSamples) { + std::string json = "["; + bool first = true; + std::size_t startIdx = (maxSamples > 0 && samples.size() > maxSamples) ? samples.size() - maxSamples : 0; + std::size_t i = 0; + for (const auto& s : samples) { + if (i++ < startIdx) continue; + if (!first) json += ","; + json += "{\"t\":" + std::to_string(s.timestampMs) + + ",\"cycle\":" + std::to_string(s.cycleTimeUs) + + ",\"jitter\":" + std::to_string(s.jitterUs) + "}"; + first = false; + } + json += "]"; + return json; +} + +std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler, + std::string_view extraFields) { + auto* ts = scheduler.taskState(mod.id); + + std::string json = "{"; + json += "\"id\":\"" + mod.id + "\""; + json += ",\"name\":\"" + mod.nameStr + "\""; + json += ",\"className\":\"" + mod.className + "\""; + json += ",\"version\":\"" + mod.versionStr + "\""; + json += ",\"state\":" + std::to_string(static_cast(mod.state)); + json += ",\"path\":\"" + jsonEscapeString(mod.path.string()) + "\""; + if (!mod.sourceFileStr.empty()) { + json += ",\"sourceFile\":\"" + jsonEscapeString(mod.sourceFileStr) + "\""; + } + json += ",\"cyclicClass\":\"" + scheduler.moduleClass(mod.id) + "\""; + if (ts) { + json += ",\"stats\":{"; + json += "\"cycleCount\":" + std::to_string(ts->cycleCount.load()); + json += ",\"overrunCount\":" + std::to_string(ts->overrunCount.load()); + json += ",\"lastCycleTimeUs\":" + std::to_string(ts->lastCycleTimeUs.load()); + json += ",\"maxCycleTimeUs\":" + std::to_string(ts->maxCycleTimeUs.load()); + json += ",\"lastJitterUs\":" + std::to_string(ts->lastJitterUs.load()); + + const bool faulted = ts->faulted.load(std::memory_order_acquire); + json += ",\"faulted\":" + std::string(faulted ? "true" : "false"); + if (faulted) { + json += ",\"lastFaultMs\":" + std::to_string(ts->lastFaultMs.load()); + json += ",\"lastFaultPhase\":\"" + + std::string(diag::phaseName(static_cast(ts->lastFaultPhase.load()))) + "\""; + json += ",\"lastFaultMsg\":\"" + jsonEscapeString(ts->lastFaultMsg) + "\""; + } + + { + std::lock_guard lk(ts->cycleHistoryMx); + auto histVec = ts->cycleHistory.getAll(); + json += ",\"cycleHistory\":" + serializeCycleHistory(histVec); + } + + json += "}"; + } + if (!extraFields.empty()) { + json += ","; + json += extraFields; + } + json += "}"; + return json; +} + +std::string buildHistoryBody(const std::vector& samples, int64_t since, int64_t binMs) { + std::string body = "{\"samples\":["; + bool first = true; + int64_t latest = since; + + if (binMs <= 0) { + for (const auto& s : samples) { + if (s.timestampMs <= since) continue; + if (!first) body += ","; + body += "{\"t\":" + std::to_string(s.timestampMs) + + ",\"cycle\":" + std::to_string(s.cycleTimeUs) + + ",\"jitter\":" + std::to_string(s.jitterUs) + "}"; + if (s.timestampMs > latest) latest = s.timestampMs; + first = false; + } + } else { + // Aggregate into bins: bin start = floor(t / binMs) * binMs. + // Emit max cycle and max |jitter| per bin (jitter is signed; magnitude + // is what's interesting on the chart). + struct Agg { int64_t maxCycle = 0; int64_t maxJitter = 0; }; + std::map bins; + int64_t lastBin = since; + for (const auto& s : samples) { + int64_t bin = (s.timestampMs / binMs) * binMs; + if (bin <= since) continue; + auto& a = bins[bin]; + if (s.cycleTimeUs > a.maxCycle) a.maxCycle = s.cycleTimeUs; + int64_t aj = s.jitterUs < 0 ? -s.jitterUs : s.jitterUs; + if (aj > a.maxJitter) a.maxJitter = aj; + } + // Don't emit the in-progress bin: it'll be re-aggregated on the next + // poll, keeping each emitted bin a stable, finalized data point. + int64_t now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + int64_t curBin = (now / binMs) * binMs; + for (auto& [bin, a] : bins) { + if (bin >= curBin) continue; + if (!first) body += ","; + body += "{\"t\":" + std::to_string(bin) + + ",\"cycle\":" + std::to_string(a.maxCycle) + + ",\"jitter\":" + std::to_string(a.maxJitter) + "}"; + if (bin > lastBin) lastBin = bin; + first = false; + } + latest = lastBin; + } + + body += "],\"latest\":" + std::to_string(latest) + "}"; + return body; +} + +// --------------------------------------------------------------------------- +// Routing +// --------------------------------------------------------------------------- + +namespace api { + +Method methodFromString(std::string_view s) { + if (s == "GET") return Method::GET; + if (s == "POST") return Method::POST; + if (s == "PUT") return Method::PUT; + if (s == "PATCH") return Method::PATCH; + if (s == "DELETE") return Method::DELETE_; + return Method::UNKNOWN; +} + +// Scheduler DTOs (glaze auto-reflects these aggregates). In a NAMED namespace +// because glaze reflection requires the reflected type to have linkage. This is +// the single copy — server.cpp's /api/scheduler/classes route and its DTOs were +// removed; native reaches this handler via the /api/ catch-all. +struct ClassStatsDto { + int64_t lastJitterUs = 0; + int64_t lastCycleTimeUs = 0; + int64_t maxCycleTimeUs = 0; + uint64_t tickCount = 0; + int memberCount = 0; + int64_t lastTickStartMs = 0; +}; +struct ClassInfoDto { + std::string name; + int period_us = 10000; + int cpu_affinity = -1; + int priority = 50; + int spin_us = 0; + ClassStatsDto stats; + std::vector modules; +}; +ClassInfoDto makeClassInfoDto(const ClassDef& def, const std::vector& allStats) { + ClassInfoDto dto; + dto.name = def.name; + dto.period_us = def.period_us; + dto.cpu_affinity = def.cpu_affinity; + dto.priority = def.priority; + dto.spin_us = def.spin_us; + for (const auto& s : allStats) { + if (s.name != def.name) continue; + dto.stats = { s.lastJitterUs, s.lastCycleTimeUs, s.maxCycleTimeUs, s.tickCount, s.memberCount }; + dto.modules = s.moduleIds; + break; + } + return dto; +} + +// Response item for GET /api/modules/available (glaze-reflected; named namespace). +struct AvailableModuleDto { + std::string filename; + std::string className; + std::string version; +}; + +// Request-body shapes (glaze-reflected; named namespace, same reason as above). +struct PatchBody { std::string ptr; glz::raw_json value; }; +struct AddProbeRequest { std::string moduleId; std::string path; }; +struct InstantiateRequest { std::string id; std::string so; }; + +namespace { +Response json(int status, std::string body) { return {status, "application/json", std::move(body)}; } +Response notFound() { return json(404, "{\"error\":\"no matching route\"}"); } +// msg must be escaped: some callers pass messages containing quotes (e.g. the +// PATCH body-shape hint), which would otherwise make the response invalid JSON. +Response badRequest(std::string msg) { return json(400, "{\"error\":\"" + jsonEscapeString(std::move(msg)) + "\"}"); } + +// Permissive JSON read: unknown/missing keys are not errors (PATCH-style partial +// bodies, forward-compatible clients). +template +glz::error_ctx readJsonPermissive(T& value, std::string_view jsonStr) { + constexpr glz::opts kPermissiveReadOpts{ + .error_on_unknown_keys = false, + .error_on_missing_keys = false, + }; + glz::context ctx{}; + return glz::read(value, jsonStr, ctx); +} + +// Split "path?query" into (path, query); query is empty if there's no '?'. Both +// transports hand dispatch() a path that may carry a query suffix: the wasm host +// passes JS's `pathname + search` verbatim, and the native catch-all passes +// Crow's raw_url (which already includes it) — so the split happens once, here, +// rather than at each call site. +std::pair splitPathQuery(const std::string& raw) { + auto pos = raw.find('?'); + if (pos == std::string::npos) return {raw, {}}; + return {raw.substr(0, pos), raw.substr(pos + 1)}; +} + +// Extract one value from a raw "a=1&b=2" query string ("" if key is absent). +std::string queryParam(const std::string& query, std::string_view key) { + std::size_t pos = 0; + while (pos < query.size()) { + auto amp = query.find('&', pos); + auto piece = query.substr(pos, amp == std::string::npos ? std::string::npos : amp - pos); + auto eq = piece.find('='); + auto k = eq == std::string::npos ? piece : piece.substr(0, eq); + if (k == key) return eq == std::string::npos ? std::string{} : piece.substr(eq + 1); + if (amp == std::string::npos) break; + pos = amp + 1; + } + return {}; +} +} + +Response dispatch(RuntimeCore& core, const Request& req) { + auto [path, query] = splitPathQuery(req.path); + const auto method = req.method; + const auto& body = req.body; + + // GET /api/modules — list all loaded modules + if (method == Method::GET && path == "/api/modules") { + std::shared_lock lock(core.moduleMutex()); + std::string out = "["; + bool first = true; + for (auto& [id, mod] : core.loader().modules()) { + if (!first) out += ","; + out += moduleInfoJson(mod, core.scheduler()); + first = false; + } + out += "]"; + return json(200, std::move(out)); + } + + // GET /api/modules/available — installable module .so files. MUST precede the + // /api/modules/ prefix check below (else "available" is read as a module id). + if (method == Method::GET && path == "/api/modules/available") { + const auto& cfg = core.config(); + auto available = ModuleLoader::queryAvailable(cfg.moduleDir); + std::unordered_set seen; + seen.reserve(available.size() * 2); + for (const auto& a : available) seen.insert(a.filename); + for (const auto& extra : cfg.additionalModuleDirs) { + for (auto& a : ModuleLoader::queryAvailable(extra)) + if (seen.insert(a.filename).second) available.push_back(std::move(a)); + } + std::vector dtos; + dtos.reserve(available.size()); + for (auto& a : available) dtos.push_back({ a.filename, a.className, a.version }); + return json(200, glz::write_json(dtos).value_or("[]")); + } + + // POST /api/modules/instantiate — create a new instance from a .so + // Body: {"id":"left_motor","so":"example_motor.so"}. MUST precede the + // /api/modules/ prefix check below for the same reason as "available". + if (method == Method::POST && path == "/api/modules/instantiate") { + InstantiateRequest reqBody{}; + auto err = readJsonPermissive(reqBody, body); + if (err || reqBody.id.empty() || reqBody.so.empty()) + return badRequest("body must have non-empty 'id' and 'so'"); + auto resultId = core.instantiateModule(reqBody.so, reqBody.id); + if (resultId.empty()) return json(500, "{\"error\":\"instantiate failed\"}"); + return json(200, "{\"ok\":true,\"id\":\"" + resultId + "\"}"); + } + + // /api/modules/[/...] — single-module GET/DELETE, and the /data, /config, + // /recipe, /reload sub-routes (all keyed off the same modId/tail split). + if (path.rfind("/api/modules/", 0) == 0) { + std::string rest = path.substr(std::string_view("/api/modules/").size()); + auto slash = rest.find('/'); + std::string modId = (slash == std::string::npos) ? rest : rest.substr(0, slash); + std::string tail = (slash == std::string::npos) ? std::string{} : rest.substr(slash + 1); + + if (!modId.empty() && tail.empty()) { + if (method == Method::GET) { + std::shared_lock lock(core.moduleMutex()); + auto* mod = core.loader().get(modId); + if (!mod) return json(404, "{\"error\":\"module not found\"}"); + // ModuleDetail = ModuleInfo + live data-section snapshots. The + // frontend's ModuleDetail page requires the `data` object + // (getModule() in rest.ts) — info alone breaks it, which is + // what the native pre-migration route always included. + std::string data = "\"data\":{"; + data += "\"config\":" + core.dataEngine().readSection(modId, DataSection::Config); + data += ",\"recipe\":" + core.dataEngine().readSection(modId, DataSection::Recipe); + data += ",\"runtime\":" + core.dataEngine().readSection(modId, DataSection::Runtime); + data += ",\"summary\":" + core.dataEngine().readSection(modId, DataSection::Summary); + data += "}"; + return json(200, moduleInfoJson(*mod, core.scheduler(), data)); + } + if (method == Method::DELETE_) { + if (!core.removeInstance(modId)) return json(404, "{\"error\":\"instance not found\"}"); + return json(200, "{\"ok\":true}"); + } + } else if (!modId.empty() && tail.rfind("data/", 0) == 0) { + // GET/POST/PUT/PATCH /api/modules//data/
+ auto sec = opcrest::sectionFromName(tail.substr(5)); + if (sec) { + if (method == Method::GET) { + std::shared_lock lock(core.moduleMutex()); + return json(200, core.dataEngine().readSection(modId, *sec)); + } + if (method == Method::POST || method == Method::PUT) { + std::shared_lock lock(core.moduleMutex()); + if (!core.dataEngine().writeSection(modId, *sec, body)) return badRequest("write failed"); + if (*sec == DataSection::Config) core.dataStore().saveConfig(modId, core.dataEngine()); + else if (*sec == DataSection::Recipe) core.dataStore().saveRecipe(modId, "default", core.dataEngine()); + return json(200, "{\"ok\":true}"); + } + if (method == Method::PATCH) { + PatchBody patch; + auto err = readJsonPermissive(patch, body); + if (err || patch.ptr.empty() || patch.value.str.empty()) + return badRequest(R"(body must be {"ptr":"...","value":...})"); + std::shared_lock lock(core.moduleMutex()); + if (!core.dataEngine().patchSection(modId, *sec, patch.ptr, patch.value.str)) + return badRequest("patch failed"); + return json(200, "{\"ok\":true}"); + } + } + } else if (!modId.empty() && tail == "config/save" && method == Method::POST) { + std::shared_lock lock(core.moduleMutex()); + bool ok = core.dataStore().saveConfig(modId, core.dataEngine()); + return json(ok ? 200 : 500, ok ? "{\"ok\":true}" : "{\"error\":\"save failed\"}"); + } else if (!modId.empty() && tail == "config/load" && method == Method::POST) { + std::shared_lock lock(core.moduleMutex()); + core.dataStore().loadConfig(modId, core.dataEngine()); + return json(200, core.dataEngine().readSection(modId, DataSection::Config)); + } else if (!modId.empty() && tail.rfind("recipe/save/", 0) == 0 && method == Method::POST) { + auto name = tail.substr(std::string_view("recipe/save/").size()); + std::shared_lock lock(core.moduleMutex()); + bool ok = core.dataStore().saveRecipe(modId, name, core.dataEngine()); + return json(ok ? 200 : 500, ok ? "{\"ok\":true}" : "{\"error\":\"save failed\"}"); + } else if (!modId.empty() && tail.rfind("recipe/load/", 0) == 0 && method == Method::POST) { + auto name = tail.substr(std::string_view("recipe/load/").size()); + std::shared_lock lock(core.moduleMutex()); + core.dataStore().loadRecipe(modId, name, core.dataEngine()); + return json(200, core.dataEngine().readSection(modId, DataSection::Recipe)); + } else if (!modId.empty() && tail == "reload" && method == Method::POST) { + if (!core.reloadModule(modId)) return json(500, "{\"error\":\"reload failed\"}"); + return json(200, R"({"ok":true,"message":"module reloaded"})"); + } + } + + // GET /api/scheduler/classes — class definitions + live stats + if (method == Method::GET && path == "/api/scheduler/classes") { + auto allStats = core.scheduler().allClassStats(); + auto defs = core.scheduler().classConfigs(); + std::vector resp; + resp.reserve(defs.size()); + for (const auto& def : defs) resp.push_back(makeClassInfoDto(def, allStats)); + return json(200, glz::write_json(resp).value_or("[]")); + } + + // POST /api/scheduler/classes — create a new class definition + if (method == Method::POST && path == "/api/scheduler/classes") { + ClassDef def; + if (auto err = readJsonPermissive(def, body); err) return badRequest("invalid JSON body"); + if (def.name.empty()) return badRequest("name is required"); + if (!core.scheduler().addClassDef(def)) return json(409, "{\"error\":\"class already exists\"}"); + core.saveSchedulerConfig(); + return json(201, "{\"ok\":true}"); + } + + // /api/scheduler/classes/[/history] — update a class, or its history + if (path.rfind("/api/scheduler/classes/", 0) == 0) { + std::string rest = path.substr(std::string_view("/api/scheduler/classes/").size()); + auto slash = rest.find('/'); + std::string className = (slash == std::string::npos) ? rest : rest.substr(0, slash); + std::string tail = (slash == std::string::npos) ? std::string{} : rest.substr(slash + 1); + + if (!className.empty() && tail.empty() && method == Method::PATCH) { + auto defs = core.scheduler().classConfigs(); + ClassDef updated; + bool found = false; + for (auto& d : defs) { if (d.name == className) { updated = d; found = true; break; } } + if (!found) return json(404, "{\"error\":\"class not found\"}"); + if (auto err = readJsonPermissive(updated, body); err) return badRequest("invalid JSON body"); + updated.name = className; // never let the client rename a class via PATCH + core.scheduler().updateClassDef(updated); + core.saveSchedulerConfig(); + return json(200, "{\"ok\":true}"); + } + if (!className.empty() && tail == "history" && method == Method::GET) { + auto deque = core.scheduler().classHistory(className); + std::vector samples(deque.begin(), deque.end()); + int64_t since = 0, binMs = 0; + if (auto p = queryParam(query, "since"); !p.empty()) try { since = std::stoll(p); } catch (...) {} + if (auto p = queryParam(query, "bin"); !p.empty()) try { binMs = std::stoll(p); } catch (...) {} + return json(200, buildHistoryBody(samples, since, binMs)); + } + } + + // GET /api/scheduler/schema — JSON Schema for SchedulerConfig + if (method == Method::GET && path == "/api/scheduler/schema") { + return json(200, glz::write_json_schema().value_or("{}")); + } + + // GET /api/scheduler/modules//history?since=&bin= + if (method == Method::GET && path.rfind("/api/scheduler/modules/", 0) == 0 + && path.size() > std::string_view("/api/scheduler/modules/").size()) { + std::string rest = path.substr(std::string_view("/api/scheduler/modules/").size()); + static constexpr std::string_view kSuffix = "/history"; + if (rest.size() > kSuffix.size() && rest.compare(rest.size() - kSuffix.size(), kSuffix.size(), kSuffix) == 0) { + std::string id = rest.substr(0, rest.size() - kSuffix.size()); + auto* ts = core.scheduler().taskState(id); + if (!ts) return json(404, "{\"error\":\"module not found\"}"); + int64_t since = 0, binMs = 0; + if (auto p = queryParam(query, "since"); !p.empty()) try { since = std::stoll(p); } catch (...) {} + if (auto p = queryParam(query, "bin"); !p.empty()) try { binMs = std::stoll(p); } catch (...) {} + std::vector samples; + { std::lock_guard lk(ts->cycleHistoryMx); samples = ts->cycleHistory.getAll(); } + return json(200, buildHistoryBody(samples, since, binMs)); + } + } + + // POST /api/scheduler/reassign — move a module to a different class + // Body: {"moduleId":"...","class":"...","order":0 (optional)} + if (method == Method::POST && path == "/api/scheduler/reassign") { + // glaze can't bind to the C++ keyword "class" via the default member name, + // so pull moduleId/class/order with the same dependency-light manual + // extraction server.cpp used (this body is tiny and fixed-shape). + auto extract = [&](const std::string& key) -> std::string { + auto pos = body.find("\"" + key + "\""); + if (pos == std::string::npos) return {}; + pos = body.find(':', pos); + if (pos == std::string::npos) return {}; + ++pos; + while (pos < body.size() && body[pos] == ' ') ++pos; + if (pos >= body.size()) return {}; + if (body[pos] == '"') { + ++pos; + auto end = body.find('"', pos); + return (end != std::string::npos) ? body.substr(pos, end - pos) : ""; + } + auto end = body.find_first_of(",}", pos); + return (end != std::string::npos) ? body.substr(pos, end - pos) : ""; + }; + std::string moduleId = extract("moduleId"); + std::string newClass = extract("class"); + auto orderStr = extract("order"); + if (moduleId.empty() || newClass.empty()) return badRequest("moduleId and class are required"); + std::optional newOrder; + if (!orderStr.empty()) { try { newOrder = std::stoi(orderStr); } catch (...) {} } + auto ec = core.scheduler().reassignClass(moduleId, newClass, newOrder); + if (ec) return json(400, "{\"error\":\"" + ec.message() + "\"}"); + core.saveSchedulerConfig(); + return json(200, "{\"ok\":true}"); + } + + // GET /api/system — process resource metrics (memory + CPU) + history. + // On a thread-free build the sampler never starts (see + // SystemMetrics::start()), so this serves zeros and an empty history + // rather than 404ing — the shape stays stable across hosts. + // History points carry only the chartable series (ts/rssBytes/cpuPercent), + // deliberately leaner than the top-level sample: peakRssBytes is monotonic + // (the current value subsumes the history) and uptimeSec is derivable from + // ts — repeating them 600x per poll is pure payload bloat. + if (method == Method::GET && path == "/api/system") { + const auto cur = core.systemMetrics().current(); + const auto hist = core.systemMetrics().history(); + std::string out = "{"; + out += "\"ts\":" + std::to_string(cur.tsMs); + out += ",\"rssBytes\":" + std::to_string(cur.rssBytes); + out += ",\"peakRssBytes\":" + std::to_string(cur.peakRssBytes); + out += ",\"cpuPercent\":" + std::to_string(cur.cpuPercent); + out += ",\"uptimeSec\":" + std::to_string(cur.uptimeSec); + out += ",\"history\":["; + bool first = true; + for (const auto& s : hist) { + if (!first) out += ","; + first = false; + out += "{\"ts\":" + std::to_string(s.tsMs) + + ",\"rssBytes\":" + std::to_string(s.rssBytes) + + ",\"cpuPercent\":" + std::to_string(s.cpuPercent) + "}"; + } + out += "]}"; + return json(200, std::move(out)); + } + + // GET /api/faults — fault reports (newest first) + if (method == Method::GET && path == "/api/faults") { + std::string out = "["; + bool first = true; + for (const auto& s : core.faultStore().list()) { + if (!first) out += ","; + first = false; + out += "{\"id\":\"" + jsonEscapeString(s.id) + "\"" + + ",\"ts\":" + std::to_string(s.tsMs) + + ",\"kind\":\"" + jsonEscapeString(s.kind) + "\"" + + ",\"module\":\"" + jsonEscapeString(s.moduleId) + "\"" + + ",\"class\":\"" + jsonEscapeString(s.className) + "\"" + + ",\"phase\":\"" + jsonEscapeString(s.phase) + "\"" + + ",\"reason\":\"" + jsonEscapeString(s.reason) + "\"}"; + } + out += "]"; + return json(200, std::move(out)); + } + + // GET /api/faults/ — single fault detail + if (method == Method::GET && path.rfind("/api/faults/", 0) == 0) { + std::string id = path.substr(std::string_view("/api/faults/").size()); + auto detail = core.faultStore().detailJson(id); + return detail ? json(200, *detail) : json(404, "{\"error\":\"fault not found\"}"); + } + + // GET /api/bus/topics + if (method == Method::GET && path == "/api/bus/topics") { + auto topics = core.bus().topics(); + std::string out = "["; + for (std::size_t i = 0; i < topics.size(); ++i) { if (i) out += ","; out += "\"" + jsonEscapeString(topics[i]) + "\""; } + out += "]"; + return json(200, std::move(out)); + } + + // GET /api/bus/services + if (method == Method::GET && path == "/api/bus/services") { + auto infos = core.bus().serviceInfos(); + std::string out = "["; + for (std::size_t i = 0; i < infos.size(); ++i) { + if (i) out += ","; + out += "{\"name\":\"" + jsonEscapeString(infos[i].name) + "\",\"schema\":"; + out += infos[i].schema.empty() ? "null" : infos[i].schema; + out += "}"; + } + out += "]"; + return json(200, std::move(out)); + } + + // POST /api/bus/call/ — invoke a bus service (name may contain '/') + if (method == Method::POST && path.rfind("/api/bus/call/", 0) == 0) { + std::string serviceName = path.substr(std::string_view("/api/bus/call/").size()); + auto result = core.bus().call(serviceName, body); + std::string out = "{\"ok\":" + std::string(result.ok ? "true" : "false"); + if (!result.response.empty()) out += ",\"response\":" + result.response; + if (!result.error.empty()) out += ",\"error\":\"" + jsonEscapeString(result.error) + "\""; + out += "}"; + return json(200, std::move(out)); + } + + // GET /api/scope/schema — each module's runtime section snapshot + if (method == Method::GET && path == "/api/scope/schema") { + std::shared_lock lock(core.moduleMutex()); + std::string out = "{"; + bool first = true; + for (const auto& entry : core.loader().modules()) { + const auto& id = entry.first; + auto runtime = core.dataEngine().readSection(id, DataSection::Runtime); + if (runtime.empty()) runtime = "{}"; + if (!first) out += ","; + out += "\"" + id + "\":" + runtime; + first = false; + } + out += "}"; + return json(200, std::move(out)); + } + + // GET /api/scope/probes — configured probes + if (method == Method::GET && path == "/api/scope/probes") { + auto probes = core.oscilloscope().listProbes(); + std::string out = "["; + bool first = true; + for (auto& p : probes) { + if (!first) out += ","; + out += "{\"id\":" + std::to_string(p.id) + + ",\"moduleId\":\"" + p.moduleId + "\"" + + ",\"path\":\"" + p.path + "\"" + + ",\"label\":\"" + p.label + "\"}"; + first = false; + } + out += "]"; + return json(200, std::move(out)); + } + + // POST /api/scope/probes — add a probe. Body: {"moduleId":"...","path":"/field"} + if (method == Method::POST && path == "/api/scope/probes") { + AddProbeRequest reqBody; + if (auto err = readJsonPermissive(reqBody, body); err) return badRequest("invalid JSON body"); + if (reqBody.moduleId.empty() || reqBody.path.empty()) return badRequest("moduleId and path required"); + auto id = core.oscilloscope().addProbe(reqBody.moduleId, reqBody.path); + return json(200, "{\"ok\":true,\"id\":" + std::to_string(id) + "}"); + } + + // DELETE /api/scope/probes/ + if (method == Method::DELETE_ && path.rfind("/api/scope/probes/", 0) == 0) { + std::string idStr = path.substr(std::string_view("/api/scope/probes/").size()); + uint64_t probeId = 0; + try { probeId = std::stoull(idStr); } catch (...) { return badRequest("invalid probe id"); } + if (!core.oscilloscope().removeProbe(probeId)) return json(404, "{\"error\":\"probe not found\"}"); + return json(200, "{\"ok\":true}"); + } + + // GET /api/scope/data — all captured probe samples + if (method == Method::GET && path == "/api/scope/data") { + return json(200, core.oscilloscope().allDataToJson()); + } + + // GET /api/io-mappings — I/O mappings with resolution status + if (method == Method::GET && path == "/api/io-mappings") { + auto& mapper = core.ioMapper(); + std::string out = "["; + bool first = true; + for (std::size_t i = 0; i < mapper.entryCount(); ++i) { + auto* entry = mapper.getMapping(i); + if (!entry) continue; + if (!first) out += ","; + out += "{\"index\":" + std::to_string(i); + out += ",\"source\":\"" + jsonEscapeString(i < mapper.getMappings().size() ? mapper.getMappings()[i].src_module_id : "") + "\""; + out += ",\"target\":\"" + jsonEscapeString(i < mapper.getMappings().size() ? mapper.getMappings()[i].dst_module_id : "") + "\""; + out += ",\"enabled\":" + std::string(entry->valid ? "true" : "false"); + out += ",\"resolved\":" + std::string(entry->valid ? "true" : "false"); + out += ",\"stable\":" + std::string(entry->stable ? "true" : "false"); + out += ",\"error\":\"" + jsonEscapeString(entry->error) + "\"}"; + first = false; + } + out += "]"; + return json(200, std::move(out)); + } + + // POST /api/io-mappings — add a mapping. Body: {"source":"...","target":"...","enabled":true} + if (method == Method::POST && path == "/api/io-mappings") { + IOMapEntry entry; + if (auto err = readJsonPermissive(entry, body); err) return badRequest("invalid JSON body"); + if (entry.source.empty() || entry.target.empty()) return badRequest("source and target are required"); + auto index = core.ioMapper().addMapping(entry.source, entry.target, entry.enabled); + core.ioMapper().resolveAll(core); + return json(201, "{\"ok\":true,\"index\":" + std::to_string(index) + "}"); + } + + // POST /api/io-mappings/resolve — force re-resolve all mappings. MUST precede + // the /api/io-mappings/ prefix check below (else "resolve" parses as an index). + if (method == Method::POST && path == "/api/io-mappings/resolve") { + core.ioMapper().resolveAll(core); + return json(200, "{\"ok\":true}"); + } + + // DELETE/PATCH /api/io-mappings/ — remove or update a mapping + if (path.rfind("/api/io-mappings/", 0) == 0 + && (method == Method::DELETE_ || method == Method::PATCH)) { + std::string idxStr = path.substr(std::string_view("/api/io-mappings/").size()); + std::size_t index = 0; + try { index = std::stoull(idxStr); } catch (...) { return badRequest("invalid index"); } + + if (method == Method::DELETE_) { + if (!core.ioMapper().removeMapping(index)) return json(404, "{\"error\":\"mapping not found\"}"); + return json(200, "{\"ok\":true}"); + } + // PATCH + if (index >= core.ioMapper().entryCount()) return json(404, "{\"error\":\"mapping not found\"}"); + IOMapEntry entry; + if (auto err = readJsonPermissive(entry, body); err) return badRequest("invalid JSON body"); + if (entry.source.empty() || entry.target.empty()) return badRequest("source and target are required"); + if (!core.ioMapper().updateMapping(index, entry.source, entry.target, entry.enabled)) + return json(500, "{\"error\":\"update failed\"}"); + core.ioMapper().resolveAll(core); + return json(200, "{\"ok\":true}"); + } + + return notFound(); +} + +} // namespace api +} // namespace loom diff --git a/runtime/src/diag/breadcrumb.cpp b/runtime/src/diag/breadcrumb.cpp new file mode 100644 index 0000000..400e6a9 --- /dev/null +++ b/runtime/src/diag/breadcrumb.cpp @@ -0,0 +1,7 @@ +#include "loom/diag/breadcrumb.h" + +namespace loom::diag { + +thread_local Breadcrumb tlsBreadcrumb; + +} // namespace loom::diag diff --git a/runtime/src/diag/crash_handler.cpp b/runtime/src/diag/crash_handler.cpp new file mode 100644 index 0000000..508e38a --- /dev/null +++ b/runtime/src/diag/crash_handler.cpp @@ -0,0 +1,328 @@ +// Must precede any libc header: exposes the glibc REG_RIP/REG_RBP mcontext enum +// used by the in-context stack unwind on Linux/x86_64. +#if defined(__linux__) && !defined(_GNU_SOURCE) +# define _GNU_SOURCE +#endif + +#include "loom/diag/crash_handler.h" +#include "loom/diag/breadcrumb.h" +#include "loom/diag/fault_report.h" +#include "loom/diag/symbolizer.h" +#include "loom/version.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef LOOM_BUILD_TYPE +#define LOOM_BUILD_TYPE "unknown" +#endif + +// ============================================================================ +// Crash handler. Captures the faulting thread's breadcrumb + a stack trace and +// writes a crash report, then lets the process die. +// +// Both platforms produce the SAME structured, symbolized JSON report (built from +// the FaultReport model, resolved via cpptrace) so mac/linux crashes are as +// readable as Windows ones. cpptrace allocates and is therefore NOT async- +// signal-safe, so on the POSIX signal path we ALSO write a minimal async-signal- +// safe raw text report FIRST as a guaranteed fallback; the structured JSON is +// then attempted best-effort. A g_reporting flag stops a fault during reporting +// from looping. (The Windows unhandled-exception filter is not signal- +// constrained, so it writes the JSON directly.) +// ============================================================================ + +namespace loom::diag { +namespace { + +std::atomic_flag g_reporting = ATOMIC_FLAG_INIT; // first faulting thread wins +char g_reportPath[1024] = {}; // structured JSON report path +char g_reportId[256] = {}; // report id / filename stem + +// Build + symbolize + write the structured JSON crash report. NOT async-signal- +// safe (cpptrace allocates). The FaultReport/JSON is fully built in memory and +// then written in one shot, so the file is either complete or absent — never +// half-written garbage. +void writeStructuredReport(FaultKind kind, const char* reason, int code, + void* const* frames, unsigned nframes) { + const Breadcrumb& b = tlsBreadcrumb; // faulting thread + + FaultReport r; + r.id = g_reportId; + r.tsMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + r.kind = kind; + r.signalOrCode = code; + r.reason = reason ? reason : ""; + r.sdkVersion = loom::kSdkVersion; + r.gitSha = loom::kGitSha; + r.buildType = LOOM_BUILD_TYPE; + r.moduleId = b.moduleId ? b.moduleId : ""; + r.className = b.className ? b.className : ""; + r.phase = b.phase; + r.cycle = b.cycle; + r.frames = symbolize(reinterpret_cast(frames), nframes); + + std::string json = toJson(r); + std::ofstream f(g_reportPath, std::ios::binary | std::ios::trunc); + if (f) f << json; +} + +} // namespace +} // namespace loom::diag + +// --------------------------------------------------------------------------- +#if defined(_WIN32) +// --------------------------------------------------------------------------- +#include // CaptureStackBackTrace (RtlCaptureStackBackTrace, in kernel32) + +namespace loom::diag { +namespace { + +LONG WINAPI unhandledFilter(EXCEPTION_POINTERS* ep) { + if (g_reporting.test_and_set()) return EXCEPTION_EXECUTE_HANDLER; + void* frames[64]; + unsigned n = CaptureStackBackTrace(0, 64, frames, nullptr); + const DWORD code = ep ? ep->ExceptionRecord->ExceptionCode : 0UL; + char reason[64]; + std::snprintf(reason, sizeof reason, "SEH exception 0x%08lx", code); + writeStructuredReport(FaultKind::Signal, reason, static_cast(code), frames, n); + return EXCEPTION_EXECUTE_HANDLER; // run default handler → terminate +} + +void terminateHandler() { + if (!g_reporting.test_and_set()) { + void* frames[64]; + unsigned n = CaptureStackBackTrace(0, 64, frames, nullptr); + writeStructuredReport(FaultKind::Signal, "std::terminate (unhandled C++ exception)", + 0, frames, n); + } + std::abort(); +} + +} // namespace + +void CrashHandler::install(const CrashConfig& cfg) { + std::error_code ec; + std::filesystem::create_directories(cfg.crashDir, ec); + std::snprintf(g_reportId, sizeof g_reportId, "loom-crash-%lu", GetCurrentProcessId()); + auto path = (cfg.crashDir / (std::string(g_reportId) + ".json")).string(); + std::snprintf(g_reportPath, sizeof g_reportPath, "%s", path.c_str()); + SetUnhandledExceptionFilter(unhandledFilter); + std::set_terminate(terminateHandler); +} + +} // namespace loom::diag + +// --------------------------------------------------------------------------- +#else // POSIX +// --------------------------------------------------------------------------- +#include +#include +#include +#include // open(), O_WRONLY/O_CREAT/O_TRUNC (not transitively included on macOS) +#include + +// Faulting register state (PC/FP) for the in-context unwind. macOS's +// hard-errors unless _XOPEN_SOURCE is defined (it guards the deprecated +// get/setcontext routines), so pull just the mcontext TYPES from +// there; glibc's is fine (REG_RIP/REG_RBP need _GNU_SOURCE, set above). +#if defined(__APPLE__) +# include +#else +# include +#endif + +#if defined(__APPLE__) && defined(__aarch64__) && __has_include() +# include // strip pointer-auth bits from return addresses (arm64e) +# define LOOM_HAVE_PTRAUTH 1 +#endif + +namespace loom::diag { +namespace { + +char g_reportPathRaw[1024] = {}; // async-signal-safe raw text fallback (.txt) +char g_buildId[256] = {}; // precomputed at install (no formatting in handler) +void* g_primeFrames[4]; // backtrace priming target + +// async-signal-safe helpers -------------------------------------------------- +// NB: std::strlen is NOT in the POSIX async-signal-safe list, so compute the +// length with a plain loop (which is) before the single write(). +void sWrite(int fd, const char* s) { + if (!s) return; + size_t n = 0; + while (s[n]) ++n; + ssize_t r = ::write(fd, s, n); (void)r; +} +void sWriteHex(int fd, uintptr_t v) { + char buf[2 + sizeof(uintptr_t) * 2]; buf[0] = '0'; buf[1] = 'x'; + const char* hex = "0123456789abcdef"; + int i = 2 + (int)sizeof(uintptr_t) * 2; + char* p = buf + i; + if (v == 0) { *--p = '0'; } else { while (v) { *--p = hex[v & 0xf]; v >>= 4; } } + sWrite(fd, "0x"); ssize_t r = ::write(fd, p, (buf + i) - p); (void)r; +} + +const char* signalName(int sig) { + switch (sig) { + case SIGSEGV: return "SIGSEGV (segmentation fault)"; + case SIGABRT: return "SIGABRT (abort)"; + case SIGFPE: return "SIGFPE (floating-point exception)"; + case SIGILL: return "SIGILL (illegal instruction)"; + case SIGBUS: return "SIGBUS (bus error)"; + default: return "fatal signal"; + } +} + +// Guaranteed async-signal-safe report: breadcrumb + raw return addresses. Always +// written first so there is a report even if the structured pass fails. Skipped +// by the FaultStore when a sibling .json exists. +void writeRawReport(int sig, void* const* frames, int n) { + int fd = ::open(g_reportPathRaw, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) return; + const Breadcrumb& b = tlsBreadcrumb; + sWrite(fd, "=== Loom crash report (raw) ===\nsignal: "); + sWriteHex(fd, (uintptr_t)sig); + sWrite(fd, "\nmodule: "); sWrite(fd, b.moduleId ? b.moduleId : "(none/runtime)"); + sWrite(fd, " class: "); sWrite(fd, b.className ? b.className : "(none)"); + sWrite(fd, " phase: "); sWrite(fd, phaseName(b.phase)); + sWrite(fd, "\n"); sWrite(fd, g_buildId); sWrite(fd, "\n"); + sWrite(fd, "frames (raw addresses — symbolize offline):\n"); + for (int i = 0; i < n; ++i) { sWrite(fd, " "); sWriteHex(fd, (uintptr_t)frames[i]); sWrite(fd, "\n"); } + ::close(fd); +} + +// Strip ARM pointer-authentication bits from a return address so cpptrace can +// resolve it (no-op off Apple arm64e). +inline uintptr_t stripPac(uintptr_t p) { +#if defined(LOOM_HAVE_PTRAUTH) + return reinterpret_cast( + ptrauth_strip(reinterpret_cast(p), ptrauth_key_return_address)); +#else + return p; +#endif +} + +// Capture a stack trace starting from the FAULTING context rather than the +// handler's own stack. backtrace() walks the handler stack: crossing the signal +// trampoline it recovers only the caller's return address (losing the real fault +// site), and the alternate signal stack (SA_ONSTACK) is discontinuous from the +// faulting thread's stack so its frame-pointer walk degrades into a 0x0 tail. +// Seeding from ucontext's saved PC + FP and walking the frame-record chain +// (`[fp] = caller fp`, `[fp + 8] = return address`) sidesteps both. Async-signal- +// safe: only aligned reads, no allocation. Returns 0 on an unknown platform so +// the caller can fall back to backtrace(). +unsigned captureFromContext(void* ucv, void** frames, unsigned max) { + if (!ucv || max == 0) return 0; + auto* uc = static_cast(ucv); + uintptr_t pc = 0, fp = 0; + +#if defined(__APPLE__) && defined(__aarch64__) + const auto& ss = uc->uc_mcontext->__ss; + pc = ss.__pc; fp = ss.__fp; // x29 +#elif defined(__APPLE__) && defined(__x86_64__) + const auto& ss = uc->uc_mcontext->__ss; + pc = ss.__rip; fp = ss.__rbp; +#elif defined(__linux__) && defined(__aarch64__) + pc = uc->uc_mcontext.pc; fp = uc->uc_mcontext.regs[29]; +#elif defined(__linux__) && defined(__x86_64__) + pc = uc->uc_mcontext.gregs[REG_RIP]; fp = uc->uc_mcontext.gregs[REG_RBP]; +#else + (void)uc; // unknown → fall back +#endif + + if (pc == 0 && fp == 0) return 0; + + unsigned n = 0; + if (pc) frames[n++] = reinterpret_cast(stripPac(pc)); // real fault site (leaf) + while (fp && (fp & (sizeof(void*) - 1)) == 0 && n < max) { + const uintptr_t next = *reinterpret_cast(fp); + const uintptr_t ret = *reinterpret_cast(fp + sizeof(void*)); + if (ret == 0) break; + frames[n++] = reinterpret_cast(stripPac(ret)); + if (next <= fp) break; // must move up-stack (stops runaway) + fp = next; + } + return n; +} + +void handler(int sig, siginfo_t*, void* ucv) { + // Re-raise with kill() (async-signal-safe); the handler was installed with + // SA_RESETHAND, so the disposition is already SIG_DFL — no signal() needed + // (signal() is NOT async-signal-safe). + if (g_reporting.test_and_set()) { kill(getpid(), sig); _exit(128 + sig); } + + // Unwind from the faulting context (keeps the leaf frame); fall back to + // backtrace() on platforms we don't have register layouts for. + void* frames[64]; + unsigned n = captureFromContext(ucv, frames, 64); + if (n == 0) n = static_cast(backtrace(frames, 64)); + + // 1. Guaranteed: async-signal-safe raw report. + writeRawReport(sig, frames, static_cast(n)); + // 2. Best-effort: structured + symbolized JSON (mirrors Windows). cpptrace + // allocates — not strictly async-signal-safe, but works for the common + // (non-heap-corruption) crash; the raw report above is the fallback. + writeStructuredReport(FaultKind::Signal, signalName(sig), sig, frames, n); + + // Re-raise for a core dump with the default disposition (async-signal-safe). + kill(getpid(), sig); + _exit(128 + sig); +} + +void terminateHandler() { + // Not in a signal context, but mirror the signal path for a consistent report. + if (!g_reporting.test_and_set()) { + void* frames[64]; + int n = backtrace(frames, 64); + writeRawReport(SIGABRT, frames, n); + writeStructuredReport(FaultKind::Signal, "std::terminate (unhandled C++ exception)", + 0, frames, static_cast(n)); + } + std::abort(); +} + +} // namespace + +void CrashHandler::install(const CrashConfig& cfg) { + std::error_code ec; + std::filesystem::create_directories(cfg.crashDir, ec); + + std::snprintf(g_reportId, sizeof g_reportId, "loom-crash-%ld", (long)getpid()); + auto jsonPath = (cfg.crashDir / (std::string(g_reportId) + ".json")).string(); + auto rawPath = (cfg.crashDir / (std::string(g_reportId) + ".txt")).string(); + std::snprintf(g_reportPath, sizeof g_reportPath, "%s", jsonPath.c_str()); + std::snprintf(g_reportPathRaw, sizeof g_reportPathRaw, "%s", rawPath.c_str()); + std::snprintf(g_buildId, sizeof g_buildId, "build: sdk=%s git=%s type=%s", + loom::kSdkVersion, loom::kGitSha, LOOM_BUILD_TYPE); + + // Prime backtrace() so its first (lazy libgcc) call already happened — the + // handler's backtrace() is then effectively async-signal-safe. + backtrace(g_primeFrames, 4); + + // Alternate signal stack so a stack-overflow SIGSEGV still has stack to run. + // Use a fixed compile-time size: since glibc 2.34, SIGSTKSZ expands to a + // sysconf() call (not a constant), so it can't size a static array. 64 KiB + // comfortably exceeds SIGSTKSZ on every supported platform. + static constexpr size_t kAltStackSize = 64 * 1024; + static char altStack[kAltStackSize]; + stack_t ss{}; ss.ss_sp = altStack; ss.ss_size = sizeof altStack; ss.ss_flags = 0; + sigaltstack(&ss, nullptr); + + struct sigaction sa{}; + sa.sa_sigaction = handler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESETHAND; + for (int sig : {SIGSEGV, SIGABRT, SIGFPE, SIGILL, SIGBUS}) sigaction(sig, &sa, nullptr); + + std::set_terminate(terminateHandler); +} + +} // namespace loom::diag +#endif diff --git a/runtime/src/diag/fault_report.cpp b/runtime/src/diag/fault_report.cpp new file mode 100644 index 0000000..caab24a --- /dev/null +++ b/runtime/src/diag/fault_report.cpp @@ -0,0 +1,133 @@ +#include "loom/diag/fault_report.h" + +#include +#include +#include + +namespace loom::diag { + +const char* faultKindName(FaultKind k) { + switch (k) { + case FaultKind::Exception: return "exception"; + case FaultKind::Signal: return "signal"; + } + return "unknown"; +} + +namespace { + +// Minimal JSON string escaper (RFC 8259). We hand-roll the report JSON so the +// captured `sections` can embed as real nested JSON rather than escaped strings. +void appendEscaped(std::string& out, std::string_view s) { + out += '"'; + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof buf, "\\u%04x", c); + out += buf; + } else { + out += c; + } + } + } + out += '"'; +} + +void appendKV(std::string& out, const char* key, std::string_view val, bool& first) { + if (!first) out += ','; + first = false; + out += '"'; out += key; out += "\":"; + appendEscaped(out, val); +} + +void appendKVRaw(std::string& out, const char* key, std::string_view rawJson, bool& first) { + if (!first) out += ','; + first = false; + out += '"'; out += key; out += "\":"; + out += (rawJson.empty() ? std::string_view{"null"} : rawJson); +} + +void appendKVNum(std::string& out, const char* key, long long val, bool& first) { + if (!first) out += ','; + first = false; + out += '"'; out += key; out += "\":"; + out += std::to_string(val); +} + +} // namespace + +std::string toJson(const FaultReport& r) { + std::string out; + out.reserve(1024 + r.frames.size() * 128); + out += '{'; + bool first = true; + + appendKV (out, "id", r.id, first); + appendKVNum(out, "ts", r.tsMs, first); + appendKV (out, "kind", faultKindName(r.kind), first); + appendKVNum(out, "signalOrCode", r.signalOrCode, first); + appendKV (out, "reason", r.reason, first); + + // build identity + out += ",\"build\":{"; + { + bool bfirst = true; + appendKV(out, "sdkVersion", r.sdkVersion, bfirst); + appendKV(out, "gitSha", r.gitSha, bfirst); + appendKV(out, "buildType", r.buildType, bfirst); + } + out += '}'; + + // breadcrumb + out += ",\"breadcrumb\":{"; + { + bool cfirst = true; + appendKV (out, "module", r.moduleId.empty() ? "" : r.moduleId, cfirst); + appendKV (out, "class", r.className, cfirst); + appendKV (out, "phase", phaseName(r.phase), cfirst); + appendKVNum(out, "cycle", static_cast(r.cycle), cfirst); + } + out += '}'; + + // frames + out += ",\"frames\":["; + for (std::size_t i = 0; i < r.frames.size(); ++i) { + const SymFrame& f = r.frames[i]; + if (i) out += ','; + out += '{'; + bool ffirst = true; + char addr[24]; + std::snprintf(addr, sizeof addr, "0x%016llx", + static_cast(f.address)); + appendKVNum(out, "idx", static_cast(i), ffirst); + appendKV (out, "address", addr, ffirst); + appendKV (out, "function", f.symbol, ffirst); + appendKV (out, "file", f.filename, ffirst); + appendKVNum(out, "line", f.line, ffirst); + out += '}'; + } + out += ']'; + + // sections (exception path only) + if (r.sections) { + out += ",\"sections\":{"; + bool sfirst = true; + appendKVRaw(out, "config", r.sections->config, sfirst); + appendKVRaw(out, "recipe", r.sections->recipe, sfirst); + appendKVRaw(out, "runtime", r.sections->runtime, sfirst); + appendKVRaw(out, "summary", r.sections->summary, sfirst); + out += '}'; + } + + out += '}'; + return out; +} + +} // namespace loom::diag diff --git a/runtime/src/diag/fault_store.cpp b/runtime/src/diag/fault_store.cpp new file mode 100644 index 0000000..cf8efe7 --- /dev/null +++ b/runtime/src/diag/fault_store.cpp @@ -0,0 +1,156 @@ +#include "loom/diag/fault_store.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace loom::diag { + +namespace { + +std::string readFile(const std::filesystem::path& p) { + std::ifstream f(p, std::ios::binary); + if (!f) return {}; + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +std::string jstr(glz::json_t& j, const char* key) { + if (!j.is_object() || !j.contains(key)) return {}; + auto& v = j[key]; + return v.is_string() ? v.get() : std::string{}; +} + +int64_t jnum(glz::json_t& j, const char* key) { + if (!j.is_object() || !j.contains(key)) return 0; + auto& v = j[key]; + return v.is_number() ? static_cast(v.get()) : 0; +} + +// Build a Summary by parsing a report JSON blob. Falls back to a minimal +// summary (id only) if the blob isn't our JSON shape. +FaultStore::Summary summarize(const std::string& id, const std::string& json) { + FaultStore::Summary s; + s.id = id; + glz::json_t doc; + if (json.empty() || glz::read_json(doc, json) || !doc.is_object()) { + s.kind = "raw"; + return s; + } + s.tsMs = jnum(doc, "ts"); + s.kind = jstr(doc, "kind"); + s.reason = jstr(doc, "reason"); + if (doc.contains("breadcrumb") && doc["breadcrumb"].is_object()) { + auto& bc = doc["breadcrumb"]; + s.moduleId = jstr(bc, "module"); + s.className = jstr(bc, "class"); + s.phase = jstr(bc, "phase"); + } + return s; +} + +} // namespace + +FaultStore::FaultStore(std::filesystem::path crashDir) + : crashDir_(std::move(crashDir)) { + std::error_code ec; + std::filesystem::create_directories(crashDir_, ec); + scanDir(); +} + +void FaultStore::scanDir() { + std::error_code ec; + if (!std::filesystem::exists(crashDir_, ec)) return; + + // First pass: note which stems have a structured .json so a sibling .txt + // (the POSIX async-signal-safe fallback) can be suppressed in its favor. + std::set jsonStems; + for (const auto& de : std::filesystem::directory_iterator(crashDir_, ec)) { + if (ec || !de.is_regular_file()) continue; + if (de.path().extension() == ".json") jsonStems.insert(de.path().stem().string()); + } + + for (const auto& de : std::filesystem::directory_iterator(crashDir_, ec)) { + if (ec || !de.is_regular_file()) continue; + const auto& path = de.path(); + const std::string ext = path.extension().string(); + const std::string stem = path.stem().string(); + + if (ext == ".json") { + std::string json = readFile(path); + entries_.push_back({summarize(stem, json), std::move(json)}); + } else if (ext == ".txt" && stem.rfind("loom-crash-", 0) == 0) { + // POSIX signal-path raw fallback. Skip it if the structured .json for + // the same crash exists (it supersedes the raw addresses). + if (jsonStems.count(stem)) continue; + std::string raw = readFile(path); + std::string wrapped = glz::write_json( + std::map{{"id", stem}, + {"kind", "raw"}, + {"raw", raw}}).value_or("{}"); + Summary s; + s.id = stem; s.kind = "raw"; s.reason = "raw report — symbolize offline"; + entries_.push_back({std::move(s), std::move(wrapped)}); + } + } + // Keep the invariant "newest at the back" (matches record()'s push_back), so + // list()'s reverse walk yields newest-first. Raw .txt reports have ts 0. + std::sort(entries_.begin(), entries_.end(), + [](const Entry& a, const Entry& b) { return a.summary.tsMs < b.summary.tsMs; }); +} + +std::string FaultStore::record(const FaultReport& report) noexcept { + return record(report, toJson(report)); +} + +std::string FaultStore::record(const FaultReport& report, std::string json) noexcept { + try { + { + std::lock_guard lock(mx_); + const auto path = crashDir_ / (report.id + ".json"); + bool persisted = false; + { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (f) { f << json; persisted = static_cast(f); } + } + // Keep the in-memory entry regardless (a fault that happened must stay + // visible in /api/faults for this run), but surface a persistence + // failure rather than silently implying the report was saved to disk. + if (!persisted) + spdlog::warn("FaultStore: fault '{}' kept in memory only — failed to write {}", + report.id, path.string()); + entries_.push_back({summarize(report.id, json), std::move(json)}); + } + return report.id; + } catch (const std::exception& e) { + spdlog::error("FaultStore::record failed: {}", e.what()); + return {}; + } catch (...) { + return {}; + } +} + +std::vector FaultStore::list() const { + std::lock_guard lock(mx_); + std::vector out; + out.reserve(entries_.size()); + for (auto it = entries_.rbegin(); it != entries_.rend(); ++it) + out.push_back(it->summary); + return out; +} + +std::optional FaultStore::detailJson(const std::string& id) const { + std::lock_guard lock(mx_); + for (auto it = entries_.rbegin(); it != entries_.rend(); ++it) + if (it->summary.id == id) return it->rawJson; + return std::nullopt; +} + +} // namespace loom::diag diff --git a/runtime/src/diag/runtime_fault_sink.cpp b/runtime/src/diag/runtime_fault_sink.cpp new file mode 100644 index 0000000..9e615f1 --- /dev/null +++ b/runtime/src/diag/runtime_fault_sink.cpp @@ -0,0 +1,87 @@ +#include "loom/diag/runtime_fault_sink.h" + +#include "loom/diag/fault_report.h" +#include "loom/bus.h" +#include "loom/data_engine.h" +#include "loom/types.h" +#include "loom/version.h" + +#include + +#include +#include +#include + +#ifndef LOOM_BUILD_TYPE +#define LOOM_BUILD_TYPE "unknown" +#endif + +namespace loom::diag { + +RuntimeFaultSink::RuntimeFaultSink(FaultStore& store, DataEngine& engine, Bus& bus) + : store_(store), engine_(engine), bus_(bus) {} + +namespace { +std::string safeRead(DataEngine& engine, const std::string& id, DataSection sec) { + try { + return engine.readSection(id, sec); + } catch (...) { + return {}; + } +} + +// The report id becomes a filename (/.json), and the module +// portion ultimately comes from the HTTP instantiate request body — so a '/' or +// '..' could escape the crash directory. Map anything outside a safe set to '_'. +std::string sanitizeForFilename(std::string_view s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + out += ok ? c : '_'; + } + return out.empty() ? std::string{"module"} : out; +} +} // namespace + +void RuntimeFaultSink::onModuleFault(const FaultEvent& ev) { + try { + const int64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + + FaultReport r; + r.id = sanitizeForFilename(ev.moduleId) + "-" + std::to_string(nowMs) + "-" + + std::to_string(seq_.fetch_add(1, std::memory_order_relaxed)); + r.tsMs = nowMs; + r.kind = FaultKind::Exception; + r.signalOrCode = 0; + r.reason = ev.message; + r.sdkVersion = loom::kSdkVersion; + r.gitSha = loom::kGitSha; + r.buildType = LOOM_BUILD_TYPE; + r.moduleId = ev.moduleId; + r.className = ev.className; + r.phase = ev.phase; + r.cycle = ev.cycle; + + // Capture the module's live data sections — safe off the signal path. + FaultSections sections; + sections.config = safeRead(engine_, ev.moduleId, DataSection::Config); + sections.recipe = safeRead(engine_, ev.moduleId, DataSection::Recipe); + sections.runtime = safeRead(engine_, ev.moduleId, DataSection::Runtime); + sections.summary = safeRead(engine_, ev.moduleId, DataSection::Summary); + r.sections = std::move(sections); + + std::string json = toJson(r); + store_.record(r, json); + bus_.publish("loom/faults", json); + } catch (const std::exception& e) { + spdlog::error("RuntimeFaultSink failed to record fault: {}", e.what()); + } catch (...) { + // never propagate out of the worker thread + } +} + +} // namespace loom::diag diff --git a/runtime/src/diag/symbolizer.cpp b/runtime/src/diag/symbolizer.cpp new file mode 100644 index 0000000..4b650ce --- /dev/null +++ b/runtime/src/diag/symbolizer.cpp @@ -0,0 +1,31 @@ +#include "loom/diag/symbolizer.h" + +#include + +#include + +namespace loom::diag { + +std::vector symbolize(const void* const* addrs, std::size_t n) { + cpptrace::raw_trace raw; + raw.frames.reserve(n); + for (std::size_t i = 0; i < n; ++i) + raw.frames.push_back( + static_cast(reinterpret_cast(addrs[i]))); + + cpptrace::stacktrace st = raw.resolve(); + + std::vector out; + out.reserve(st.frames.size()); + for (const auto& f : st.frames) { + SymFrame sf; + sf.address = static_cast(f.raw_address); + sf.symbol = f.symbol; + sf.filename = f.filename; + sf.line = f.line.has_value() ? static_cast(f.line.value()) : 0u; + out.push_back(std::move(sf)); + } + return out; +} + +} // namespace loom::diag diff --git a/runtime/src/diag/system_metrics.cpp b/runtime/src/diag/system_metrics.cpp new file mode 100644 index 0000000..3cfb2ff --- /dev/null +++ b/runtime/src/diag/system_metrics.cpp @@ -0,0 +1,214 @@ +#include "loom/diag/system_metrics.h" + +#include "loom/thread_support.h" + +#include + +#include + +// --------------------------------------------------------------------------- +// Platform readers +// --------------------------------------------------------------------------- +#if defined(_WIN32) +# include +# include +#elif defined(__APPLE__) +# include +# include +# include +#else // Linux & other POSIX +# include +# include +# include +#endif + +namespace loom::diag { + +unsigned SystemMetrics::cpuCount() { + unsigned n = std::thread::hardware_concurrency(); + return n ? n : 1; +} + +#if defined(_WIN32) + +uint64_t SystemMetrics::readRssBytes() { + PROCESS_MEMORY_COUNTERS pmc{}; + if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) + return static_cast(pmc.WorkingSetSize); + return 0; +} + +uint64_t SystemMetrics::readPeakRssBytes() { + PROCESS_MEMORY_COUNTERS pmc{}; + if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) + return static_cast(pmc.PeakWorkingSetSize); + return 0; +} + +uint64_t SystemMetrics::readCpuTimeNs() { + FILETIME creation, exit, kernel, user; + if (!GetProcessTimes(GetCurrentProcess(), &creation, &exit, &kernel, &user)) + return 0; + auto to100ns = [](const FILETIME& ft) -> uint64_t { + return (static_cast(ft.dwHighDateTime) << 32) | ft.dwLowDateTime; + }; + // FILETIME units are 100 ns. + return (to100ns(kernel) + to100ns(user)) * 100ull; +} + +#elif defined(__APPLE__) + +uint64_t SystemMetrics::readRssBytes() { + mach_task_basic_info info{}; + mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; + if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, + reinterpret_cast(&info), &count) == KERN_SUCCESS) + return info.resident_size; + return 0; +} + +uint64_t SystemMetrics::readPeakRssBytes() { + mach_task_basic_info info{}; + mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; + if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, + reinterpret_cast(&info), &count) == KERN_SUCCESS) + return info.resident_size_max; + return 0; +} + +uint64_t SystemMetrics::readCpuTimeNs() { + rusage ru{}; + if (getrusage(RUSAGE_SELF, &ru) != 0) return 0; + auto tvNs = [](const timeval& tv) -> uint64_t { + return static_cast(tv.tv_sec) * 1'000'000'000ull + + static_cast(tv.tv_usec) * 1'000ull; + }; + return tvNs(ru.ru_utime) + tvNs(ru.ru_stime); +} + +#else // Linux + +uint64_t SystemMetrics::readRssBytes() { + // /proc/self/statm: size resident shared ... (in pages) + FILE* f = std::fopen("/proc/self/statm", "r"); + if (!f) return 0; + unsigned long sizePages = 0, residentPages = 0; + int got = std::fscanf(f, "%lu %lu", &sizePages, &residentPages); + std::fclose(f); + if (got < 2) return 0; + long pageSize = sysconf(_SC_PAGESIZE); + if (pageSize <= 0) return 0; // sysconf can return -1; casting that would explode the value + return static_cast(residentPages) * static_cast(pageSize); +} + +uint64_t SystemMetrics::readPeakRssBytes() { + // ru_maxrss is in kilobytes on Linux. + rusage ru{}; + if (getrusage(RUSAGE_SELF, &ru) != 0) return 0; + return static_cast(ru.ru_maxrss) * 1024ull; +} + +uint64_t SystemMetrics::readCpuTimeNs() { + rusage ru{}; + if (getrusage(RUSAGE_SELF, &ru) != 0) return 0; + auto tvNs = [](const timeval& tv) -> uint64_t { + return static_cast(tv.tv_sec) * 1'000'000'000ull + + static_cast(tv.tv_usec) * 1'000ull; + }; + return tvNs(ru.ru_utime) + tvNs(ru.ru_stime); +} + +#endif + +// --------------------------------------------------------------------------- +// Sampler +// --------------------------------------------------------------------------- + +SystemMetrics::SystemMetrics(Config cfg) : cfg_(cfg) { + if (cfg_.intervalMs < 100) cfg_.intervalMs = 100; // floor + if (cfg_.historyMax < 1) cfg_.historyMax = 1; +} + +SystemMetrics::~SystemMetrics() { stop(); } + +void SystemMetrics::start() { +#if !LOOM_HAS_THREADS + // No real OS threads on this build (wasm without -pthread; see + // thread_support.h) -- spawning the sampler would abort. Stay inert: + // current()/history() then serve zeros/empty, which /api/system reports + // as-is so the endpoint shape stays stable across hosts. + spdlog::debug("SystemMetrics: no thread support on this build; sampler not started"); + return; +#else + if (running_.exchange(true)) return; + startTime_ = std::chrono::steady_clock::now(); + thread_ = std::thread(&SystemMetrics::run, this); +#endif +} + +void SystemMetrics::stop() { + if (!running_.exchange(false)) return; + if (thread_.joinable()) thread_.join(); +} + +void SystemMetrics::run() { + uint64_t lastCpuNs = readCpuTimeNs(); + auto lastWall = std::chrono::steady_clock::now(); + const double cores = static_cast(cpuCount()); + + while (running_.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(cfg_.intervalMs)); + if (!running_.load()) break; + + const auto now = std::chrono::steady_clock::now(); + const uint64_t cpuNs = readCpuTimeNs(); + + const double wallNs = static_cast( + std::chrono::duration_cast(now - lastWall).count()); + double cpuPct = 0.0; + if (wallNs > 0.0 && cpuNs >= lastCpuNs) { + // (CPU time used / wall time) gives 0–N_cores; normalize to 0–100%. + cpuPct = (static_cast(cpuNs - lastCpuNs) / wallNs) * 100.0 / cores; + // Clamp both ends: timer quantization can push the ratio slightly + // past 100, and the published contract is 0–100 of the machine. + if (cpuPct < 0.0) cpuPct = 0.0; + if (cpuPct > 100.0) cpuPct = 100.0; + } + lastCpuNs = cpuNs; + lastWall = now; + + SystemSample s; + s.tsMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + s.rssBytes = readRssBytes(); + s.peakRssBytes = readPeakRssBytes(); + s.cpuPercent = cpuPct; + s.uptimeSec = std::chrono::duration_cast(now - startTime_).count(); + + { + std::lock_guard lock(mx_); + latest_ = s; + history_.push_back(s); + while (history_.size() > static_cast(cfg_.historyMax)) history_.pop_front(); + } + + if (cfg_.logEnabled) { + spdlog::info("system: rss={} MB peak={} MB cpu={:.1f}% uptime={}s", + s.rssBytes / (1024 * 1024), s.peakRssBytes / (1024 * 1024), + s.cpuPercent, s.uptimeSec); + } + } +} + +SystemSample SystemMetrics::current() const { + std::lock_guard lock(mx_); + return latest_; +} + +std::vector SystemMetrics::history() const { + std::lock_guard lock(mx_); + return {history_.begin(), history_.end()}; +} + +} // namespace loom::diag diff --git a/runtime/src/module_loader.cpp b/runtime/src/module_loader.cpp index 33ff420..5a20ecb 100644 --- a/runtime/src/module_loader.cpp +++ b/runtime/src/module_loader.cpp @@ -5,6 +5,10 @@ #include #include +#if defined(_WIN32) +#include // _getpid() +#endif + namespace loom { #if defined(_WIN32) @@ -82,10 +86,16 @@ std::string ModuleLoader::load(const std::filesystem::path& soPath, #if defined(_WIN32) // On Windows, never load the original DLL directly. Loading it even once can // cause the debugger to bind and lock the build-side PDB, breaking hot-rebuild. + // + // The shadow dir embeds the current PID so that concurrent test processes + // (each with their own gShadowLoadCounter starting at 0) never collide on + // the same path, which would cause copy_file to race and fail with + // "file is being used by another process". std::string shadowKey = instanceId.empty() ? soPath.stem().string() : instanceId; auto shadowSeq = ++gShadowLoadCounter; std::filesystem::path shadowDir = - soPath.parent_path() / ".shadow" / shadowKey / std::to_string(shadowSeq); + soPath.parent_path() / ".shadow" / shadowKey / + (std::to_string(_getpid()) + "_" + std::to_string(shadowSeq)); std::filesystem::path shadowPath = shadowDir / soPath.filename(); auto cleanupShadowDir = [&]() { diff --git a/runtime/src/run.cpp b/runtime/src/run.cpp index 50cb37e..5f76a57 100644 --- a/runtime/src/run.cpp +++ b/runtime/src/run.cpp @@ -1,6 +1,7 @@ #include "loom/runtime_core.h" #include "loom/server.h" #include "loom/version.h" +#include "loom/diag/crash_handler.h" #include @@ -87,6 +88,7 @@ namespace { << " --cycle-ms Default cycle period in milliseconds (default: 100)\n" << " --port HTTP/WebSocket server port (default: 8080)\n" << " --bind Bind address (default: 127.0.0.1, use 0.0.0.0 for all interfaces)\n" + << " --log-system-metrics Log a periodic process memory/CPU line (always served on /api/system)\n" << " --version Print version and exit\n" << " --help, -h Show this help and exit\n"; } @@ -102,6 +104,7 @@ int run(int argc, char* argv[]) { std::string bindAddress = "127.0.0.1"; int cycleMs = 100; int port = 8080; + bool logSystemMetrics = false; for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; @@ -117,6 +120,8 @@ int run(int argc, char* argv[]) { port = std::stoi(argv[++i]); } else if (arg == "--bind" && i + 1 < argc) { bindAddress = argv[++i]; + } else if (arg == "--log-system-metrics") { + logSystemMetrics = true; } else if (arg == "--version") { std::cout << "loom " << kSdkVersion << "\n"; return 0; @@ -149,6 +154,11 @@ int run(int argc, char* argv[]) { std::signal(SIGINT, signalHandler); std::signal(SIGTERM, signalHandler); + // Process-global crash capture: any fatal signal / SEH / unhandled C++ + // exception writes a crash report (faulting module/phase + build id + stack) + // to /crash before the process dies. Covers module and runtime faults. + loom::diag::CrashHandler::install({std::filesystem::path(dataDir) / "crash"}); + RuntimeConfig runtimeCfg; runtimeCfg.moduleDir = moduleDirs.front(); for (size_t i = 1; i < moduleDirs.size(); ++i) { @@ -156,6 +166,7 @@ int run(int argc, char* argv[]) { } runtimeCfg.dataDir = dataDir; runtimeCfg.defaultCyclePeriod = std::chrono::milliseconds(cycleMs); + runtimeCfg.logSystemMetrics = logSystemMetrics; RuntimeCore core(runtimeCfg); core.loadModules(); diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp new file mode 100644 index 0000000..a101baa --- /dev/null +++ b/runtime/src/run_wasm.cpp @@ -0,0 +1,201 @@ +// run_wasm.cpp — Emscripten host for the Loom runtime. +// +// The native host (run.cpp + server.cpp) drives the runtime with class threads +// and a Crow HTTP/WebSocket server. A browser can't run a socket server, but +// CAN run real threads (SharedArrayBuffer + Worker-backed std::thread, opted +// into via -pthread in CMakeLists.txt — see spike/phaseC-pthread-dlopen/ for +// the de-risking spike that proved dlopen + real worker threads + the +// pauseClass()/unpauseClass() mutex+condvar handshake all work together). +// So this host loads modules NON-cooperatively: RuntimeConfig::cooperative is +// false, so RuntimeCore::loadModules() calls Scheduler::startClasses() (spawns +// real class threads running classLoop() — the SAME code path as native, +// unmodified) AND setupWatcher() (a real file-watcher thread over MEMFS; harmless +// if nothing ever writes into moduleDir post-boot, but no longer special-cased +// away). No Crow, no signals — but the scheduler and module watcher are the +// real thing, not a JS-driven cooperative stand-in. State is read back as JSON +// through the same DataEngine the server exposes. +// +// Built only for Emscripten and linked against loom_core (NOT loom_runtime). +#ifdef __EMSCRIPTEN__ + +#include "loom/runtime_core.h" +#include "loom/api/router.h" +#include "loom/opcua_rest_nodeid.h" // opcrest::parseNodeId (OPC-UA node addressing) +#include "loom/types.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { +std::unique_ptr g_core; +std::vector g_ids; // ids of modules loaded by loom_init + +// Hand a string to JS via a static buffer that stays valid until the next +// exported call. The JS side binds these with cwrap(..., 'string', ...), which +// copies the bytes out synchronously and never free()s the pointer — so a +// malloc'd return (as an earlier revision used) leaked per call, unbounded +// under readNode() polling. A single static buffer is safe here: every +// exported entry point runs on the JS main thread, and cwrap's copy completes +// before any other exported call can observe the buffer. +const char* stableString(std::string s) { + static std::string buf; + buf = std::move(s); + return buf.c_str(); +} +} // namespace + +extern "C" { + +// Boot the runtime. `moduleDir`/`dataDir` are paths in the Emscripten FS. +// Modules already present in moduleDir are discovered and loaded onto real +// class threads (startClasses()) — same as native. Returns the number of +// modules loaded, or -1 on error. +EMSCRIPTEN_KEEPALIVE +int loom_init(const char* moduleDir, const char* dataDir) { + spdlog::set_level(spdlog::level::info); + try { + loom::RuntimeConfig cfg; + cfg.moduleDir = moduleDir ? moduleDir : "/modules"; + cfg.dataDir = dataDir ? dataDir : "/data"; + cfg.cooperative = false; // real class threads + file watcher (-pthread) + g_core = std::make_unique(cfg); + g_ids = g_core->loadModules(); + spdlog::info("loom_init: {} module(s) loaded", g_ids.size()); + return static_cast(g_ids.size()); + } catch (const std::exception& e) { + spdlog::error("loom_init failed: {}", e.what()); + g_core.reset(); + return -1; + } +} + +// Cooperative-mode-only: advance every due class one pass. Real class threads +// (the current, non-cooperative default — see loom_init) self-drive via +// classLoop() and must NEVER be swept concurrently by tickOnce() too — both +// would call sweepClassOnce() on the same runner.members with no mutual +// exclusion between them. Kept exported (a lighter-weight cooperative mode may +// still be useful, e.g. where -pthread's COOP/COEP hosting requirement can't be +// met) but is a safe no-op unless the runtime was actually booted cooperative. +EMSCRIPTEN_KEEPALIVE +void loom_tick() { + if (!g_core) return; + if (!g_core->config().cooperative) { + // Warn once, not per call: the JS service ticks on a 25ms interval + // regardless of mode, so a per-call warning is 40 lines/sec of spam. + static bool warned = false; + if (!warned) { + warned = true; + spdlog::warn("loom_tick() called on a non-cooperative runtime (real class " + "threads are already driving it) -- ignored, and further " + "ticks are ignored silently."); + } + return; + } + g_core->scheduler().tickOnce(); +} + +// Comma-separated ids of the modules loaded by loom_init. Valid until the next +// exported call (JS copies immediately — see stableString). +EMSCRIPTEN_KEEPALIVE +const char* loom_module_ids() { + std::string s; + for (std::size_t i = 0; i < g_ids.size(); ++i) { if (i) s += ','; s += g_ids[i]; } + return stableString(std::move(s)); +} + +// Route a REST request (method, path, body) through the same transport-agnostic +// dispatcher the native HTTP server uses. Returns a JSON envelope +// {"status":,"body":} (valid until the next exported call) so +// the JS fetch shim can build a Response with the right status. Body is +// embedded raw (every handler returns valid JSON). +EMSCRIPTEN_KEEPALIVE +const char* loom_request(const char* method, const char* path, const char* body) { + if (!g_core) return stableString("{\"status\":503,\"body\":null}"); + loom::api::Request req; + req.method = loom::api::methodFromString(method ? method : "GET"); + req.path = path ? path : ""; + req.body = body ? body : ""; + loom::api::Response resp = loom::api::dispatch(*g_core, req); + std::string env = "{\"status\":" + std::to_string(resp.status) + + ",\"body\":" + (resp.body.empty() ? "null" : resp.body) + "}"; + return stableString(std::move(env)); +} + +// Load an arbitrary user module from a file already written into the Emscripten +// FS (the JS service FS.writeFile()s the bytes, then calls this). Copies it into +// the module dir, loads + starts it in whichever mode the runtime booted +// (threaded here -- loom_init sets cooperative=false, so uploadModule() starts +// real class threads). Returns the loaded module id (empty string on failure), +// valid until the next exported call. +EMSCRIPTEN_KEEPALIVE +const char* loom_load_module(const char* path) { + if (!g_core || !path) return stableString(""); + try { + std::string id = g_core->uploadModule(path); + if (!id.empty()) g_ids.push_back(id); + return stableString(std::move(id)); + } catch (const std::exception& e) { + spdlog::error("loom_load_module('{}') failed: {}", path, e.what()); + return stableString(""); + } +} + +// Reflected runtime state of one module instance, as a JSON string valid until +// the next exported call (null on error). +EMSCRIPTEN_KEEPALIVE +const char* loom_state_json(const char* moduleId) { + if (!g_core || !moduleId) return nullptr; + return stableString(g_core->dataEngine().readSection(moduleId, loom::DataSection::Runtime)); +} + +// --- OPC-UA-style reflected tag access (backs the WasmMachine) --------------- +// A node is "ns=1;s=/module//
/" (same address space the +// native OPC-UA facade serves). These map to IModule readField/writeField, so a +// WasmMachine can satisfy lux-react's useVariable()/writeVariable() in-browser. + +// Read a node's reflected value as JSON. Returns "null" for unknown/non-value +// nodes. Valid until the next exported call (JS copies immediately) — this one +// gets polled continuously by WasmMachine subscriptions, which is exactly why +// the returns must not allocate per call. +EMSCRIPTEN_KEEPALIVE +const char* loom_read_node(const char* nodeId) { + if (!g_core || !nodeId) return stableString("null"); + auto p = loom::opcrest::parseNodeId(loom::opcrest::urlDecode(nodeId)); + using K = loom::opcrest::ParsedNode::Kind; + if (p.kind != K::Field && p.kind != K::Section) return stableString("null"); + std::shared_lock lock(g_core->moduleMutex()); + auto* mod = g_core->loader().get(p.moduleId); + if (!mod || !mod->instance) return stableString("null"); + if (p.kind == K::Section) return stableString(mod->instance->readSection(p.section)); + auto v = mod->instance->readField(p.section, p.fieldPointer); + return stableString(v ? std::move(*v) : std::string("null")); +} + +// Write a node's reflected value from JSON. Returns 1 on success, 0 on failure. +EMSCRIPTEN_KEEPALIVE +int loom_write_node(const char* nodeId, const char* valueJson) { + if (!g_core || !nodeId || !valueJson) return 0; + auto p = loom::opcrest::parseNodeId(loom::opcrest::urlDecode(nodeId)); + using K = loom::opcrest::ParsedNode::Kind; + std::shared_lock lock(g_core->moduleMutex()); + auto* mod = g_core->loader().get(p.moduleId); + if (!mod || !mod->instance) return 0; + if (p.kind == K::Section) return mod->instance->writeSection(p.section, valueJson) ? 1 : 0; + if (p.kind == K::Field) return mod->instance->writeField(p.section, p.fieldPointer, valueJson) ? 1 : 0; + return 0; +} + +EMSCRIPTEN_KEEPALIVE +void loom_shutdown() { + if (g_core) { g_core->shutdown(); g_core.reset(); } +} + +} // extern "C" + +#endif // __EMSCRIPTEN__ diff --git a/runtime/src/runtime_core.cpp b/runtime/src/runtime_core.cpp index 8804b20..e561bcd 100644 --- a/runtime/src/runtime_core.cpp +++ b/runtime/src/runtime_core.cpp @@ -1,5 +1,6 @@ #include "loom/runtime_core.h" #include "loom/scheduler_config.h" +#include "loom/thread_support.h" #include #include @@ -43,7 +44,25 @@ std::filesystem::path resolveModulePath(const RuntimeConfig& config, RuntimeCore::RuntimeCore(const RuntimeConfig& config) : config_(config), dataStore_(config.dataDir), - watcher_(config.moduleDir) { + watcher_(config.moduleDir), + faultStore_(config.dataDir / "crash"), + systemMetrics_(diag::SystemMetrics::Config{ .logEnabled = config.logSystemMetrics }) { +#if !LOOM_HAS_THREADS + // This build has no real OS threads (see thread_support.h) -- cooperative + // mode isn't optional here, it's the only mode that works. Force it + // regardless of what the caller asked for, rather than let a mismatched + // request silently misbehave (startClasses() spawning threads that can't + // exist). + if (!config_.cooperative) { + spdlog::warn("RuntimeCore: this build has no real threads; forcing " + "cooperative mode (RuntimeConfig::cooperative=false was requested)"); + config_.cooperative = true; + } +#endif + // Begin sampling process memory/CPU; served on /api/system + the WS live + // frame. On a thread-free build start() is a guarded no-op (it would spawn + // a std::thread that can't exist there) and /api/system serves zeros. + systemMetrics_.start(); // Load scheduler.json from the data directory. auto schedPath = config.dataDir / "scheduler.json"; bool schedExisted = std::filesystem::exists(schedPath); @@ -52,6 +71,11 @@ RuntimeCore::RuntimeCore(const RuntimeConfig& config) // Provide scheduler with pointers needed for cycle-aligned oscilloscope sampling. scheduler_.setSamplingTargets(&oscilloscope_, &dataEngine_, &loader_, &moduleMutex_); scheduler_.setIOMapper(&ioMapper_); + + // Wire fault reporting: a module-call exception is captured, persisted, and + // published on `loom/faults` by the sink (see runtime_fault_sink.cpp). + faultSink_ = std::make_unique(faultStore_, dataEngine_, bus_); + scheduler_.setFaultSink(faultSink_.get()); if (!schedExisted) { loom::saveSchedulerConfig(schedCfg_, schedPath); spdlog::info("Wrote default scheduler config to '{}'", schedPath.string()); @@ -154,9 +178,9 @@ std::vector RuntimeCore::loadModules() { spdlog::warn("No modules loaded from instances.json"); return ids; } - scheduler_.startClasses(); + if (!config_.cooperative) scheduler_.startClasses(); ioMapper_.resolveAll(*this); - setupWatcher(); + if (!config_.cooperative) setupWatcher(); return ids; } @@ -194,9 +218,9 @@ std::vector RuntimeCore::loadModules() { spdlog::info("Wrote instances.json with {} entry(ies)", discovered.size()); } - scheduler_.startClasses(); + if (!config_.cooperative) scheduler_.startClasses(); ioMapper_.resolveAll(*this); - setupWatcher(); + if (!config_.cooperative) setupWatcher(); return ids; } @@ -308,6 +332,7 @@ bool RuntimeCore::startModule(const std::string& id, const InitContext& ctx) { dataEngine_.registerModule(id, mod->instance.get()); mod->instance->setBus(&bus_, id); mod->instance->setRegistry(this); + mod->instance->setRuntimeHeap(&runtimeHeap_); dataStore_.loadConfig(id, dataEngine_); TaskConfig taskCfg = resolveTaskConfig(id, mod->instance.get()); @@ -422,7 +447,11 @@ std::string RuntimeCore::instantiateModule(const std::string& soFilename, return {}; } - scheduler_.startClasses(); + // Only in threaded mode -- mirrors loadModules()'s guard. A cooperative + // instance must never spawn a class thread, even for a module added after + // boot (this hot-load path), or Scheduler::tickOnce() and a classLoop() + // thread could end up touching the same class concurrently. + if (!config_.cooperative) scheduler_.startClasses(); ioMapper_.resolveAll(*this); saveInstanceManifest(); return id; @@ -502,7 +531,8 @@ std::string RuntimeCore::uploadModule(const std::filesystem::path& srcPath) { if (!startModule(loadedId, ctx)) { return {}; } - scheduler_.startClasses(); + // See the identical guard + comment in instantiateModule(). + if (!config_.cooperative) scheduler_.startClasses(); ioMapper_.resolveAll(*this); saveInstanceManifest(); } diff --git a/runtime/src/runtime_heap.cpp b/runtime/src/runtime_heap.cpp new file mode 100644 index 0000000..4539722 --- /dev/null +++ b/runtime/src/runtime_heap.cpp @@ -0,0 +1,22 @@ +#include "loom/runtime_heap_impl.h" + +#include + +namespace loom { + +std::shared_ptr RuntimeHeap::allocate(std::size_t size, std::size_t align) { + const std::align_val_t a{align}; + void* p = nullptr; + try { + p = ::operator new(size, a); + } catch (const std::bad_alloc&) { + return {}; // documented: empty shared_ptr on failure + } + // Deleter + control block are constructed here in the resident runtime TU, + // so releasing the last (weak_)ptr from any module is safe after unload. + return std::shared_ptr(p, [a](void* q) noexcept { + ::operator delete(q, a); + }); +} + +} // namespace loom diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 734fd19..aa7b251 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -1,6 +1,9 @@ #include "loom/scheduler.h" #include "loom/scheduler_config.h" #include "loom/io_mapper.h" +#include "loom/diag/guard.h" +#include "loom/diag/fault_sink.h" +#include "loom/thread_support.h" #include @@ -8,6 +11,7 @@ #include #include #include +#include #include #include @@ -136,6 +140,14 @@ static void applyAffinityPolicy(int cpuCore) { spdlog::debug("Linux affinity set to core {}", cpuCore); } } + +#else // platforms without thread RT/affinity control (e.g. Emscripten/WASM) + +// applyAffinityPolicy is called unconditionally below (the realtime-policy calls +// are #if-guarded, but the affinity call is not), so it needs a definition on +// every platform. No-op where OS thread affinity isn't available. +static void applyAffinityPolicy(int /*cpuCore*/) {} + #endif // platform // ---- Scheduler implementation --------------------------------------------------- @@ -165,6 +177,35 @@ void Scheduler::setIOMapper(IOMapper* mapper) { ioMapper_ = mapper; } +void Scheduler::recordModuleFault(TaskState& state, LoadedModule& mod, + diag::Phase phase, std::string_view message) { + // Write the fault details FIRST, then publish `faulted` with release. A + // reader (the server) that observes faulted==true with acquire is then + // guaranteed to see a fully-written lastFaultMsg. A module faults at most + // once (it's skipped thereafter), so these fields are effectively write-once. + const int64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + state.lastFaultMs.store(nowMs); + state.lastFaultPhase.store(static_cast(phase)); + { + const std::size_t n = std::min(message.size(), sizeof(state.lastFaultMsg) - 1); + std::memcpy(state.lastFaultMsg, message.data(), n); + state.lastFaultMsg[n] = '\0'; + } + mod.state = ModuleState::Error; + state.faulted.store(true, std::memory_order_release); // publish last + + spdlog::error("Module '{}' faulted in {}: {}", + mod.id, diag::phaseName(phase), message); + + if (faultSink_) { + faultSink_->onModuleFault(diag::FaultEvent{ + mod.id, mod.className, phase, + state.cycleCount.load(), std::string(message)}); + } +} + Scheduler::~Scheduler() { stopAll(); } @@ -224,6 +265,7 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon // Init module before any thread touches it. initGuarded() opens the // extension-registration window around the user's init(). spdlog::info("Initializing module '{}' (reason: {})", mod.id, static_cast(ctx.reason)); + diag::BreadcrumbScope initCrumb(diag::Phase::Init, mod.id.c_str(), mod.className.c_str()); try { mod.instance->initGuarded(ctx); } catch (const std::exception& e) { @@ -231,6 +273,9 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon // of the scheduler and terminate the runtime — fail this module cleanly. spdlog::error("Module '{}' init() failed: {}", mod.id, e.what()); mod.state = ModuleState::Error; + if (faultSink_) + faultSink_->onModuleFault(diag::FaultEvent{ + mod.id, mod.className, diag::Phase::Init, 0, e.what()}); return false; } mod.state = ModuleState::Initialized; @@ -244,21 +289,57 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon // Start long-running thread immediately (independent of class membership). if (config.enableLongRunning) { - statePtr->longRunningThread = std::thread([&mod, statePtr]() { +#if LOOM_HAS_THREADS + statePtr->longRunningThread = std::thread([this, &mod, statePtr]() { spdlog::info("Long-running task started for '{}'", mod.id); while (statePtr->running.load()) { - mod.instance->longRunning(); + bool ok = diag::guard(diag::Phase::LongRunning, mod.id.c_str(), mod.className.c_str(), + [&]{ mod.instance->longRunning(); }, + [&](const diag::FaultInfo& f) { + recordModuleFault(*statePtr, mod, f.phase, f.message); + }); + if (!ok) break; // stop the loop rather than spin-faulting + + // If longRunning() wasn't overridden, the base implementation + // flags opt-out: there's no background work, so retire the + // thread instead of spinning a core on a no-op. + if (mod.instance->longRunningOptedOut()) { + spdlog::debug("Module '{}' has no long-running work; thread exiting", mod.id); + break; + } + + // Sleep between iterations so background work can't peg a core. + // Re-read the interval every pass so the module can change its + // cadence at runtime (e.g. back off when idle, speed up when + // busy). Chunk the wait so stop() stays responsive even when the + // interval is long. + auto remaining = mod.instance->longRunningInterval(); + constexpr auto kSlice = std::chrono::milliseconds{100}; + while (statePtr->running.load() && remaining > std::chrono::milliseconds::zero()) { + const auto chunk = std::min(remaining, kSlice); + std::this_thread::sleep_for(chunk); + remaining -= chunk; + } } spdlog::info("Long-running task ended for '{}'", mod.id); }); +#endif // LOOM_HAS_THREADS } const bool isIsolated = config.isolateThread || config.cyclicClass.empty(); if (isIsolated) { +#if LOOM_HAS_THREADS statePtr->cyclicThread = std::thread([this, &mod, config, statePtr]() { isolatedLoop(mod, config, *statePtr); }); +#else + // No real threads in this build: drive an "isolated" module + // cooperatively by adding it to a class so tickOnce() sweeps it (its + // dedicated period collapses to that class's period). + auto& corunner = getOrCreateClass(config.cyclicClass.empty() ? "normal" : config.cyclicClass); + insertMember(corunner, { mod.id, config.order, &mod, statePtr }); +#endif } else { // Add to class (thread started later by startClasses, or live-inserted if running). auto& runner = getOrCreateClass(config.cyclicClass); @@ -279,9 +360,11 @@ void Scheduler::startClasses() { if (runner->members.empty() || runner->running.load()) continue; runner->running.store(true); +#if LOOM_HAS_THREADS runner->thread = std::thread([this, r = runner.get()]() { classLoop(*r); }); +#endif spdlog::info("Class '{}' started (period: {}µs, members: {})", runner->def.name, runner->def.period_us, static_cast(runner->members.size())); @@ -292,6 +375,11 @@ void Scheduler::startClasses() { bool Scheduler::stop(const std::string& moduleId) { std::thread cyclicToJoin, longRunToJoin; + // Keep the TaskState alive until AFTER the threads are joined: the cyclic / + // long-running threads hold a raw TaskState* (running flag, fault fields), so + // destroying it before the join is a use-after-free. We extract the owning + // unique_ptr into this local and let it drop at end of function, post-join. + std::unique_ptr stateToFree; { std::lock_guard lock(mutex_); @@ -328,11 +416,16 @@ bool Scheduler::stop(const std::string& moduleId) { spdlog::info("Module '{}' stopped ({} cycles, {} overruns)", moduleId, state.cycleCount.load(), state.overrunCount.load()); - tasks_.erase(moduleId); + // Transfer ownership out of the map (keeps the TaskState object alive via + // stateToFree) before erasing the now-empty slot. + stateToFree = std::move(stateIt->second); + tasks_.erase(stateIt); configs_.erase(moduleId); } - // Join outside the lock so we don't block the mutex. + // Join outside the lock so we don't block the mutex. stateToFree keeps the + // TaskState valid for the threads until they have fully exited here, after + // which it is destroyed. if (cyclicToJoin.joinable()) cyclicToJoin.join(); if (longRunToJoin.joinable()) longRunToJoin.join(); @@ -630,6 +723,7 @@ void Scheduler::sortMembers(ClassRunnerState& runner) { } void Scheduler::pauseClass(ClassRunnerState& runner) { +#if LOOM_HAS_THREADS if (!runner.running.load()) return; { @@ -639,18 +733,37 @@ void Scheduler::pauseClass(ClassRunnerState& runner) { } // Wait for the class thread to finish its current tick and ack the pause. + // Real threads under Emscripten too when built with -pthread (see + // spike/phaseC-pthread-dlopen/, which proved this exact mutex+condvar + // handshake against a worker thread calling into a dlopen'd module). std::unique_lock lk(runner.pauseMx); runner.pauseCv.wait(lk, [&] { return runner.pauseAcked || !runner.running.load(); }); +#else + // No-op: this build has no class thread (classLoop() is never spawned — + // see startClasses()/insertMember's #if LOOM_HAS_THREADS guards), so there + // is nothing to pause and nothing that will ever ack. Waiting here would + // deadlock forever: insertMember() calls this for every module after the + // first one joining an already-"running" (per the flag, not actually + // threaded) class, and removeMember() calls it on reload/reassign. The + // cooperative tick loop can't run concurrently with this synchronous call + // anyway (single JS thread), so the caller's subsequent runner.members + // mutation is already safe. + (void)runner; +#endif } void Scheduler::unpauseClass(ClassRunnerState& runner) { +#if LOOM_HAS_THREADS { std::lock_guard lk(runner.pauseMx); runner.pauseRequested = false; } runner.pauseCv.notify_all(); +#else + (void)runner; // no-op to match pauseClass(); see its comment. +#endif } // ---- Metrics buffering ---------------------------------------------------------- @@ -671,6 +784,163 @@ static void storeSample(MetricRingBuffer& buffer, std::mutex& bufferMx, buffer.push({ nowMs, cycleTimeUs, jitterUs }); } +// ---- sweepClassOnce / tickOnce -------------------------------------------------- + +void Scheduler::sweepClassOnce(ClassRunnerState& runner) { + const auto& def = runner.def; + const int64_t periodUsInt = runner.livePeriodUs.load(); + const int64_t periodNs = periodUsInt * 1000LL; + + auto execStart = std::chrono::steady_clock::now(); + + // Mark a member faulted (skipped on subsequent sweeps via the faulted checks + // below) and report — used by the guarded calls. + auto faultMember = [&](auto& m, const diag::FaultInfo& f) { + recordModuleFault(*m.state, *m.mod, f.phase, f.message); + }; + + // --- Sweep 1: preCyclic (e.g. read hardware inputs) --- + for (auto& member : runner.members) { + if (member.state->faulted.load()) continue; + diag::guard(diag::Phase::PreCyclic, member.moduleId.c_str(), member.mod->className.c_str(), + [&]{ member.mod->instance->preCyclicGuarded(); }, + [&](const diag::FaultInfo& f){ faultMember(member, f); }); + } + + // --- Sweep 2: cyclic (do work) — timed, sampled --- + for (auto& member : runner.members) { + if (member.state->faulted.load()) continue; + + // Per-module jitter: |Δstart − period| + auto startNow = std::chrono::steady_clock::now(); + int64_t startNs = startNow.time_since_epoch().count(); + int64_t prevNs = member.state->lastCyclicStartNs.load(); + if (prevNs != 0) { + int64_t deltaNs = startNs - prevNs; + member.state->lastJitterUs.store(std::abs(deltaNs - periodNs) / 1000LL); + } + member.state->lastCyclicStartNs.store(startNs); + + // Execute (cyclicGuarded acquires module's runtimeMutex_ so + // server/watch threads can't race on runtime_ reads) + diag::guard(diag::Phase::Cyclic, member.moduleId.c_str(), member.mod->className.c_str(), + [&]{ member.mod->instance->cyclicGuarded(); }, + [&](const diag::FaultInfo& f){ faultMember(member, f); }); + + // Lightweight sampling: oscilloscope fast-path (alive member; no lock). + if (oscilloscope_ && dataEngine_) { + int64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + oscilloscope_->sampleModule(member.moduleId, *dataEngine_, + *member.mod->instance, nowMs); + } + + auto endNow = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast(endNow - startNow); + member.state->lastCycleTimeUs.store(elapsed.count()); + member.state->cycleCount.fetch_add(1); + + int64_t curMax = member.state->maxCycleTimeUs.load(); + if (elapsed.count() > curMax) + member.state->maxCycleTimeUs.store(elapsed.count()); + + if (elapsed.count() > periodUsInt) { + member.state->overrunCount.fetch_add(1); + if (member.state->overrunCount.load() % 100 == 1) { + spdlog::warn("Class '{}' module '{}' cyclic overrun: {}µs > {}µs", + def.name, member.moduleId, elapsed.count(), periodUsInt); + } + } + } + + // --- Sweep 2.5: I/O mappings (copy field values between modules) --- + if (ioMapper_) { + ioMapper_->executeForClass(def.name); + } + + // --- Sweep 3: postCyclic (e.g. flush outputs to hardware) --- + for (auto& member : runner.members) { + if (member.state->faulted.load()) continue; + diag::guard(diag::Phase::PostCyclic, member.moduleId.c_str(), member.mod->className.c_str(), + [&]{ member.mod->instance->postCyclicGuarded(); }, + [&](const diag::FaultInfo& f){ faultMember(member, f); }); + } + + // --- Record total class cycle time --- + auto execEnd = std::chrono::steady_clock::now(); + auto cycleTime = std::chrono::duration_cast(execEnd - execStart).count(); + runner.lastCycleTimeUs.store(cycleTime); + int64_t curMax = runner.maxCycleTimeUs.load(); + if (cycleTime > curMax) runner.maxCycleTimeUs.store(cycleTime); + storeSample(runner.cycleHistory, runner.cycleHistoryMx, cycleTime, runner.lastJitterUs.load()); +} + +void Scheduler::tickOnce() { + // Cooperative single-threaded drive: sweep each class that is DUE, once, in + // place. No spin/sleep, no RT policy, no pause handshake (there are no class + // threads to coordinate with). Per-class periods are honored against a + // steady clock, so a "fast" class runs more often than a "slow" one — capped + // by how often the host calls tickOnce() (it can't run faster than that). + // + // NOTE: only class-based modules are driven here. Isolated-thread and + // long-running modules remain threaded-only; a thread-free host (WASM) must + // route those into a class or drive them itself. + // + // `loopStart` is a single consistent snapshot used for the due-check below, + // so every class in this call is judged against the same instant regardless + // of how long earlier classes' sweeps took. The missed-deadline reanchor + // after sweepClassOnce(), however, samples the clock FRESH (see `afterSweep`) + // — using the stale `loopStart` there would under-detect a sweep that ran + // long, letting the class come due again immediately on the next tickOnce() + // call instead of re-anchoring to the period grid. Mirrors classLoop, which + // also samples `now` fresh after each tick's work for the same check. + // + // mutex_ held for the whole call: classLoop()'s hot path deliberately does + // NOT hold it (relies on the pause handshake instead, to stay real-time + // tight), but tickOnce() isn't a real-time hot path -- it's the cooperative + // driver -- so the coarser lock is an acceptable trade for correctness. + // RuntimeCore guarantees no classLoop() thread coexists with a cooperative + // instance (every startClasses() call site is gated on !cooperative), so + // this is defense-in-depth against a caller invoking tickOnce() and a + // structural Scheduler method (start/stop/updateClassDef/...) from two + // different OS threads concurrently -- newly plausible now that the wasm + // host can be built with real pthreads (see thread_support.h), even though + // nothing in the current host actually does this. + std::lock_guard lock(mutex_); + + const auto loopStart = std::chrono::steady_clock::now(); + + for (auto& [name, runnerPtr] : classes_) { + ClassRunnerState& runner = *runnerPtr; + if (runner.members.empty()) continue; + + // First sight of this class: anchor its schedule to now (run immediately). + if (runner.coopNextDue.time_since_epoch().count() == 0) + runner.coopNextDue = loopStart; + if (loopStart < runner.coopNextDue) continue; // not due yet + + runner.tickCount.fetch_add(1); + runner.lastTickStartMs.store(static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count())); + sweepClassOnce(runner); + + // Advance the deadline by one period. If we've fallen behind (host ticks + // slower than this class's period, or the sweep ran long), re-anchor to + // the next grid point rather than bursting catch-up cycles — mirrors the + // missed-deadline guard in classLoop. + const auto afterSweep = std::chrono::steady_clock::now(); + const auto periodUs = std::chrono::microseconds(runner.livePeriodUs.load()); + runner.coopNextDue += periodUs; + if (runner.coopNextDue <= afterSweep) { + const auto behind = std::chrono::duration_cast( + afterSweep - runner.coopNextDue); + runner.coopNextDue += periodUs * (behind / periodUs + 1); + } + } +} + // ---- classLoop ------------------------------------------------------------------ void Scheduler::classLoop(ClassRunnerState& runner) { @@ -707,7 +977,6 @@ void Scheduler::classLoop(ClassRunnerState& runner) { applyPolicy(); auto periodUs = std::chrono::microseconds(runner.livePeriodUs.load()); - auto periodNs = static_cast(runner.livePeriodUs.load()) * 1000LL; auto kSpinThreshold = std::chrono::microseconds(runner.liveSpinUs.load()); spdlog::info("Class '{}' thread started (period: {}µs, priority: {})", @@ -718,7 +987,6 @@ void Scheduler::classLoop(ClassRunnerState& runner) { while (runner.running.load()) { // --- Pick up live tunable changes (no restart needed) --- periodUs = std::chrono::microseconds(runner.livePeriodUs.load()); - periodNs = static_cast(runner.livePeriodUs.load()) * 1000LL; kSpinThreshold = std::chrono::microseconds(runner.liveSpinUs.load()); if (uint64_t ep = runner.cfgEpoch.load(std::memory_order_acquire); ep != seenEpoch) { seenEpoch = ep; @@ -747,93 +1015,10 @@ void Scheduler::classLoop(ClassRunnerState& runner) { std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count())); - // --- Execute each member sequentially --- - // NOTE: runner.members is only mutated when the class is paused (below). - // Reading it here without a lock is safe. - auto execStart = std::chrono::steady_clock::now(); - - // --- Sweep 1: preCyclic (e.g. read hardware inputs) --- - for (auto& member : runner.members) { - if (member.state->faulted.load()) continue; - member.mod->instance->preCyclicGuarded(); - } - - // --- Sweep 2: cyclic (do work) — timed, sampled --- - for (auto& member : runner.members) { - if (member.state->faulted.load()) continue; - - // Per-module jitter: |Δstart − period| - auto startNow = std::chrono::steady_clock::now(); - int64_t startNs = startNow.time_since_epoch().count(); - int64_t prevNs = member.state->lastCyclicStartNs.load(); - if (prevNs != 0) { - int64_t deltaNs = startNs - prevNs; - member.state->lastJitterUs.store( - std::abs(deltaNs - periodNs) / 1000LL); - } - member.state->lastCyclicStartNs.store(startNs); - - // Execute (cyclicGuarded acquires module's runtimeMutex_ so - // server/watch threads can't race on runtime_ reads) - member.mod->instance->cyclicGuarded(); - - // Lightweight sampling: use oscilloscope fast-path. - // A member in runner.members is guaranteed alive — removeMember() pauses - // the class and waits for ack BEFORE erasing, so no moduleMutex_ needed. - // We try_lock the module's runtimeMutex_ so we never block the caller; - // missing one sample tick is acceptable. - if (oscilloscope_ && dataEngine_) { - int64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count()); - oscilloscope_->sampleModule(member.moduleId, *dataEngine_, - *member.mod->instance, nowMs); - } - - auto endNow = std::chrono::steady_clock::now(); - auto elapsed = std::chrono::duration_cast( - endNow - startNow); - member.state->lastCycleTimeUs.store(elapsed.count()); - member.state->cycleCount.fetch_add(1); - - int64_t curMax = member.state->maxCycleTimeUs.load(); - if (elapsed.count() > curMax) - member.state->maxCycleTimeUs.store(elapsed.count()); - - int64_t periodUsInt = periodUs.count(); // live period (see top of loop) - if (elapsed.count() > periodUsInt) { - member.state->overrunCount.fetch_add(1); - if (member.state->overrunCount.load() % 100 == 1) { - spdlog::warn("Class '{}' module '{}' cyclic overrun: {}µs > {}µs", - def.name, member.moduleId, elapsed.count(), periodUsInt); - } - } - } - - // --- Sweep 2.5: I/O mappings (copy field values between modules) --- - if (ioMapper_) { - ioMapper_->executeForClass(def.name); - } - - // --- Sweep 3: postCyclic (e.g. flush outputs to hardware) --- - for (auto& member : runner.members) { - if (member.state->faulted.load()) continue; - member.mod->instance->postCyclicGuarded(); - } - - // --- Record total class cycle time --- - { - auto execEnd = std::chrono::steady_clock::now(); - auto cycleTime = std::chrono::duration_cast( - execEnd - execStart).count(); - runner.lastCycleTimeUs.store(cycleTime); - int64_t curMax = runner.maxCycleTimeUs.load(); - if (cycleTime > curMax) runner.maxCycleTimeUs.store(cycleTime); - - // Store historical sample for charting - int64_t jitterUs = runner.lastJitterUs.load(); - storeSample(runner.cycleHistory, runner.cycleHistoryMx, cycleTime, jitterUs); - } + // Run one sweep over the class members (preCyclic → cyclic → I/O → + // postCyclic + per-member/class metrics). Extracted so the native thread + // loop and the cooperative tickOnce() driver execute identical logic. + sweepClassOnce(runner); // --- Pause check (for hot-reassignment and stop) --- { @@ -921,21 +1106,31 @@ void Scheduler::isolatedLoop(LoadedModule& mod, TaskConfig config, TaskState& st state.lastCyclicStartNs.store(startNs); auto t0 = std::chrono::steady_clock::now(); - mod.instance->cyclicGuarded(); + if (!state.faulted.load()) { + diag::guard(diag::Phase::Cyclic, mod.id.c_str(), mod.className.c_str(), + [&]{ mod.instance->cyclicGuarded(); }, + [&](const diag::FaultInfo& f){ + recordModuleFault(state, mod, f.phase, f.message); + }); + } auto t1 = std::chrono::steady_clock::now(); - // Execute I/O mappings for this isolated module - if (ioMapper_) { - ioMapper_->executeForModule(mod.id); - } + // Once quarantined, skip I/O mappings + sampling too (the class loop skips + // faulted members entirely) — don't keep touching a module in an error state. + if (!state.faulted.load()) { + // Execute I/O mappings for this isolated module + if (ioMapper_) { + ioMapper_->executeForModule(mod.id); + } - // Sample this isolated module using oscilloscope fast-path. - // The isolated thread owns this module exclusively; state.running guards lifetime. - if (oscilloscope_ && dataEngine_) { - int64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count()); - oscilloscope_->sampleModule(mod.id, *dataEngine_, *mod.instance, nowMs); + // Sample this isolated module using oscilloscope fast-path. + // The isolated thread owns this module exclusively; state.running guards lifetime. + if (oscilloscope_ && dataEngine_) { + int64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + oscilloscope_->sampleModule(mod.id, *dataEngine_, *mod.instance, nowMs); + } } auto elapsed = std::chrono::duration_cast(t1 - t0); diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index 72a9c43..e2abcc3 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -1,4 +1,8 @@ #include "loom/server.h" +#include "loom/api/json_build.h" // shared jsonEscapeString / serializeCycleHistory / moduleInfoJson +#include "loom/api/router.h" // api::dispatch — shared /api/* routing +#include "loom/diag/breadcrumb.h" +#include "loom/diag/fault_store.h" // Crow static serving: define CROW_STATIC_DIRECTORY as a C++ variable expression // so the path is resolved at runtime (when app.run() calls add_static_dir()). @@ -41,32 +45,8 @@ namespace loom { // Plain aggregate structs — glaze auto-reflects these with no macros. // --------------------------------------------------------------------------- -struct PatchBody { std::string ptr; glz::raw_json value; }; - -struct ClassStatsDto { - int64_t lastJitterUs = 0; - int64_t lastCycleTimeUs = 0; - int64_t maxCycleTimeUs = 0; - uint64_t tickCount = 0; - int memberCount = 0; - int64_t lastTickStartMs = 0; -}; - -struct ClassInfoDto { - std::string name; - int period_us = 10000; - int cpu_affinity = -1; - int priority = 50; - int spin_us = 0; - ClassStatsDto stats; - std::vector modules; -}; - -/// Request body for POST /api/scope/probes. -struct AddProbeRequest { - std::string moduleId; - std::string path; -}; +// PatchBody / AddProbeRequest / InstantiateRequest moved to api/router.cpp with +// the routes that used them (data/
PATCH, scope/probes POST, modules/instantiate). // Request payload for /ws/watch WebSocket messages. struct WatchRequest { @@ -86,61 +66,22 @@ struct LiveSubscribeRequest { std::vector topics; }; -/// Request body for POST /api/modules/instantiate. -struct InstantiateRequest { - std::string id; - std::string so; -}; - -/// Response item for GET /api/modules/available. +/// Response item for GET /api/modules/available. NOTE: kept here (not migrated to +/// the catch-all) because the specific GET /api/modules/ route would +/// otherwise grab "/api/modules/available" as a module id. dispatch() has its own +/// copy (ordered before ) that serves WASM. struct AvailableModuleDto { std::string filename; std::string className; std::string version; }; -// Build a ClassInfoDto from a ClassDef + ClassStats snapshot. -static ClassInfoDto makeClassInfoDto(const ClassDef& def, - const std::vector& allStats) { - ClassInfoDto dto; - dto.name = def.name; - dto.period_us = def.period_us; - dto.cpu_affinity = def.cpu_affinity; - dto.priority = def.priority; - dto.spin_us = def.spin_us; - for (const auto& s : allStats) { - if (s.name != def.name) continue; - dto.stats = { s.lastJitterUs, s.lastCycleTimeUs, - s.maxCycleTimeUs, s.tickCount, s.memberCount }; - dto.modules = s.moduleIds; - break; - } - return dto; -} - // Escape a raw string for embedding inside a JSON string literal. -static std::string jsonEscapeString(std::string s) { - std::string out; - out.reserve(s.size()); - for (char c : s) { - if (c == '\\') out += "\\\\"; - else if (c == '"') out += "\\\""; - else if (c == '\n') out += "\\n"; - else if (c == '\r') out += "\\r"; - else if (c == '\t') out += "\\t"; - else out += c; - } - return out; -} +// jsonEscapeString, serializeCycleHistory and moduleInfoJson now live in +// loom/api/json_build.h (shared verbatim with the WASM api router). -// Convert DataSection string to enum -static std::optional parseSectionName(const std::string& name) { - if (name == "config") return DataSection::Config; - if (name == "recipe") return DataSection::Recipe; - if (name == "runtime") return DataSection::Runtime; - if (name == "summary") return DataSection::Summary; - return std::nullopt; -} +// parseSectionName moved to opcrest::sectionFromName (opcua_rest_nodeid.h), used +// directly by api/router.cpp now that the data/
routes live there. template static glz::error_ctx readJsonPermissive(T& value, std::string_view json) { @@ -152,1209 +93,253 @@ static glz::error_ctx readJsonPermissive(T& value, std::string_view json) { return glz::read(value, json, ctx); } -// Serialize metric history to JSON array. -// `maxSamples` caps the number of trailing samples emitted (0 = unlimited). -// The live WS broadcast uses a small cap so we don't ship the entire 2000-sample -// ring buffer for every module on every tick. -static std::string serializeCycleHistory(const std::vector& samples, size_t maxSamples = 0) { - std::string json = "["; - bool first = true; - size_t startIdx = (maxSamples > 0 && samples.size() > maxSamples) ? samples.size() - maxSamples : 0; - for (size_t i = startIdx; i < samples.size(); ++i) { - const auto& s = samples[i]; - if (!first) json += ","; - json += "{\"t\":" + std::to_string(s.timestampMs) - + ",\"cycle\":" + std::to_string(s.cycleTimeUs) - + ",\"jitter\":" + std::to_string(s.jitterUs) + "}"; - first = false; - } - json += "]"; - return json; -} - -// Overload for deque (from classHistory accessor) -static std::string serializeCycleHistory(const std::deque& samples, size_t maxSamples = 0) { - std::string json = "["; - bool first = true; - size_t startIdx = (maxSamples > 0 && samples.size() > maxSamples) ? samples.size() - maxSamples : 0; - size_t i = 0; - for (const auto& s : samples) { - if (i++ < startIdx) continue; - if (!first) json += ","; - json += "{\"t\":" + std::to_string(s.timestampMs) - + ",\"cycle\":" + std::to_string(s.cycleTimeUs) - + ",\"jitter\":" + std::to_string(s.jitterUs) + "}"; - first = false; - } - json += "]"; - return json; -} - -// Build a {"samples":[...],"latest":N} JSON body from raw samples. -// `since` : drop samples with t <= since (unbinned) or whose bin start <= since (binned). -// `binMs` : if > 0, group samples by floor(t/binMs)*binMs and emit max(cycle), max(jitter) per bin. -// `latest` : returned to the client so the next poll only requests new data. -// - unbinned: latest = max sample t emitted -// - binned: latest = (last fully-elapsed bin start) so the in-progress -// bin is re-fetched (and re-aggregated) on the next poll. -static std::string buildHistoryBody(const std::vector& samples, - int64_t since, - int64_t binMs) { - std::string body = "{\"samples\":["; - bool first = true; - int64_t latest = since; - - if (binMs <= 0) { - for (const auto& s : samples) { - if (s.timestampMs <= since) continue; - if (!first) body += ","; - body += "{\"t\":" + std::to_string(s.timestampMs) - + ",\"cycle\":" + std::to_string(s.cycleTimeUs) - + ",\"jitter\":" + std::to_string(s.jitterUs) + "}"; - if (s.timestampMs > latest) latest = s.timestampMs; - first = false; - } - } else { - // Aggregate into bins: bin start = floor(t / binMs) * binMs. - // Emit max cycle and max |jitter| per bin (jitter is signed; magnitude - // is what's interesting on the chart). - struct Agg { int64_t maxCycle = 0; int64_t maxJitter = 0; }; - std::map bins; - int64_t lastBin = since; - for (const auto& s : samples) { - int64_t bin = (s.timestampMs / binMs) * binMs; - if (bin <= since) continue; - auto& a = bins[bin]; - if (s.cycleTimeUs > a.maxCycle) a.maxCycle = s.cycleTimeUs; - int64_t aj = s.jitterUs < 0 ? -s.jitterUs : s.jitterUs; - if (aj > a.maxJitter) a.maxJitter = aj; - } - // Don't emit the in-progress bin: it'll be re-aggregated on the next - // poll, keeping each emitted bin a stable, finalized data point. - int64_t now = std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count(); - int64_t curBin = (now / binMs) * binMs; - for (auto& [bin, a] : bins) { - if (bin >= curBin) continue; - if (!first) body += ","; - body += "{\"t\":" + std::to_string(bin) - + ",\"cycle\":" + std::to_string(a.maxCycle) - + ",\"jitter\":" + std::to_string(a.maxJitter) + "}"; - if (bin > lastBin) lastBin = bin; - first = false; - } - latest = lastBin; - } - - body += "],\"latest\":" + std::to_string(latest) + "}"; - return body; -} - -// Build JSON module info for a single module -static std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler) { - auto* ts = scheduler.taskState(mod.id); - - std::string json = "{"; - json += "\"id\":\"" + mod.id + "\""; - json += ",\"name\":\"" + mod.nameStr + "\""; - json += ",\"className\":\"" + mod.className + "\""; - json += ",\"version\":\"" + mod.versionStr + "\""; - json += ",\"state\":" + std::to_string(static_cast(mod.state)); - json += ",\"path\":\"" + jsonEscapeString(mod.path.string()) + "\""; - if (!mod.sourceFileStr.empty()) { - json += ",\"sourceFile\":\"" + jsonEscapeString(mod.sourceFileStr) + "\""; - } - json += ",\"cyclicClass\":\"" + scheduler.moduleClass(mod.id) + "\""; - if (ts) { - json += ",\"stats\":{"; - json += "\"cycleCount\":" + std::to_string(ts->cycleCount.load()); - json += ",\"overrunCount\":" + std::to_string(ts->overrunCount.load()); - json += ",\"lastCycleTimeUs\":" + std::to_string(ts->lastCycleTimeUs.load()); - json += ",\"maxCycleTimeUs\":" + std::to_string(ts->maxCycleTimeUs.load()); - json += ",\"lastJitterUs\":" + std::to_string(ts->lastJitterUs.load()); - - // Add cycle history - { - std::lock_guard lk(ts->cycleHistoryMx); - auto histVec = ts->cycleHistory.getAll(); - json += ",\"cycleHistory\":" + serializeCycleHistory(histVec); - } - - json += "}"; - } - json += "}"; - return json; -} - -Server::Server(RuntimeCore& core, const ServerConfig& config) - : core_(core), config_(config) {} - -Server::~Server() { - stop(); -} - -void Server::start() { - if (running_.load()) return; - running_.store(true); - - serverThread_ = std::thread([this]() { - crow::SimpleApp app; - app.loglevel(crow::LogLevel::Warning); - // ===================================================================== - // GET /api/modules — List all modules - // ===================================================================== - CROW_ROUTE(app, "/api/modules") - ([this]() { - std::shared_lock lock(core_.moduleMutex()); - std::string json = "["; - bool first = true; - for (auto& [id, mod] : core_.loader().modules()) { - if (!first) json += ","; - json += moduleInfoJson(mod, core_.scheduler()); - first = false; - } - json += "]"; - - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // GET /api/modules/available — List .so files with metadata across - // all configured module directories. De-duplicated by filename; - // primary moduleDir is enumerated first so a user's local build - // shadows any system example sharing the same .so name. - // ===================================================================== - CROW_ROUTE(app, "/api/modules/available") - ([this]() { - const auto& cfg = core_.config(); - auto available = ModuleLoader::queryAvailable(cfg.moduleDir); - - std::unordered_set seen; - seen.reserve(available.size() * 2); - for (const auto& a : available) seen.insert(a.filename); - - for (const auto& extra : cfg.additionalModuleDirs) { - auto more = ModuleLoader::queryAvailable(extra); - for (auto& a : more) { - if (seen.insert(a.filename).second) { - available.push_back(std::move(a)); - } - } - } - - std::vector dtos; - dtos.reserve(available.size()); - for (auto& a : available) { - dtos.push_back({ a.filename, a.className, a.version }); - } - auto json = glz::write_json(dtos).value_or("[]"); - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/modules/instantiate — Create a new instance from a .so - // Body: { "id": "left_motor", "so": "libexample_motor.so" } - // ===================================================================== - CROW_ROUTE(app, "/api/modules/instantiate") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - - InstantiateRequest body{}; - auto err = readJsonPermissive(body, req.body); - if (err || body.id.empty() || body.so.empty()) { - auto resp = crow::response(400, R"({"error":"body must have non-empty 'id' and 'so'"})" ); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - auto resultId = core_.instantiateModule(body.so, body.id); - if (resultId.empty()) { - auto resp = crow::response(500, R"({"error":"instantiate failed"})" ); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - auto resp = crow::response(200, R"({"ok":true,"id":")" - + resultId + R"("})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // GET /api/modules/:id — Module detail - // ===================================================================== - CROW_ROUTE(app, "/api/modules/") - ([this](const std::string& id) { - std::shared_lock lock(core_.moduleMutex()); - auto* mod = core_.loader().get(id); - if (!mod) { - auto resp = crow::response(404, R"({"error":"module not found"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - // Build detail with data sections included - std::string json = "{"; - json += "\"id\":\"" + mod->id + "\""; - json += ",\"name\":\"" + mod->nameStr + "\""; - json += ",\"version\":\"" + mod->versionStr + "\""; - json += ",\"state\":" + std::to_string(static_cast(mod->state)); - json += ",\"path\":\"" + jsonEscapeString(mod->path.string()) + "\""; - if (!mod->sourceFileStr.empty()) { - json += ",\"sourceFile\":\"" + jsonEscapeString(mod->sourceFileStr) + "\""; - } - json += ",\"cyclicClass\":\"" + core_.scheduler().moduleClass(mod->id) + "\""; - - auto* ts = core_.scheduler().taskState(mod->id); - if (ts) { - json += ",\"stats\":{"; - json += "\"cycleCount\":" + std::to_string(ts->cycleCount.load()); - json += ",\"overrunCount\":" + std::to_string(ts->overrunCount.load()); - json += ",\"lastCycleTimeUs\":" + std::to_string(ts->lastCycleTimeUs.load()); - json += ",\"maxCycleTimeUs\":" + std::to_string(ts->maxCycleTimeUs.load()); - json += ",\"lastJitterUs\":" + std::to_string(ts->lastJitterUs.load()); - - // Add cycle history - { - std::lock_guard lk(ts->cycleHistoryMx); - auto histVec = ts->cycleHistory.getAll(); - json += ",\"cycleHistory\":" + serializeCycleHistory(histVec); - } - - json += "}"; - } - - // Include current data - json += ",\"data\":{"; - json += "\"config\":" + core_.dataEngine().readSection(id, DataSection::Config); - json += ",\"recipe\":" + core_.dataEngine().readSection(id, DataSection::Recipe); - json += ",\"runtime\":" + core_.dataEngine().readSection(id, DataSection::Runtime); - json += ",\"summary\":" + core_.dataEngine().readSection(id, DataSection::Summary); - json += "}"; - - json += "}"; - - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // GET/POST /api/modules/:id/data/:section — Read or Write data section - // ===================================================================== - CROW_ROUTE(app, "/api/modules//data/") - .methods("GET"_method, "POST"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& id, const std::string& sectionName) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - - auto section = parseSectionName(sectionName); - if (!section) { - auto resp = crow::response(400, R"({"error":"invalid section. Use: config, recipe, runtime"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - if (req.method == "GET"_method) { - std::shared_lock lock(core_.moduleMutex()); - auto json = core_.dataEngine().readSection(id, *section); - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - // POST — write section - std::shared_lock lock(core_.moduleMutex()); - bool ok = core_.dataEngine().writeSection(id, *section, req.body); - if (ok) { - if (*section == DataSection::Config) { - core_.dataStore().saveConfig(id, core_.dataEngine()); - } else if (*section == DataSection::Recipe) { - core_.dataStore().saveRecipe(id, "default", core_.dataEngine()); - } - auto resp = crow::response(200, R"({"ok":true})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } else { - auto resp = crow::response(400, R"({"error":"write failed"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - }); - - // ===================================================================== - // PATCH /api/modules/:id/data/:section — Update a single field - // Body: {"ptr":"/field/0/sub","value":} - // ===================================================================== - CROW_ROUTE(app, "/api/modules//data/") - .methods("PATCH"_method) - ([this](const crow::request& req, const std::string& id, const std::string& sectionName) { - auto section = parseSectionName(sectionName); - if (!section) { - auto resp = crow::response(400, R"({"error":"invalid section"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - // Parse body: extract "ptr" string and keep "value" as raw JSON. - PatchBody body; - if (auto ec = readJsonPermissive(body, req.body); ec || body.ptr.empty() || body.value.str.empty()) { - auto resp = crow::response(400, R"({"error":"body must be {\"ptr\":\"...\",\"value\":...}"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - const auto& ptr = body.ptr; - const auto& valueJson = body.value.str; - - std::shared_lock lock(core_.moduleMutex()); - bool ok = core_.dataEngine().patchSection(id, *section, ptr, valueJson); - if (!ok) { - auto resp = crow::response(400, R"({"error":"patch failed"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - auto resp = crow::response(200, R"({"ok":true})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/modules/:id/config/save — Persist config to disk - // ===================================================================== - CROW_ROUTE(app, "/api/modules//config/save") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& id) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - std::shared_lock lock(core_.moduleMutex()); - bool ok = core_.dataStore().saveConfig(id, core_.dataEngine()); - auto resp = crow::response(ok ? 200 : 500, ok ? R"({"ok":true})" : R"({"error":"save failed"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/modules/:id/config/load — Reload config from disk - // ===================================================================== - CROW_ROUTE(app, "/api/modules//config/load") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& id) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - std::shared_lock lock(core_.moduleMutex()); - core_.dataStore().loadConfig(id, core_.dataEngine()); - auto json = core_.dataEngine().readSection(id, DataSection::Config); - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/modules/:id/recipe/save/:name — Persist recipe to disk - // ===================================================================== - CROW_ROUTE(app, "/api/modules//recipe/save/") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& id, const std::string& name) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - std::shared_lock lock(core_.moduleMutex()); - bool ok = core_.dataStore().saveRecipe(id, name, core_.dataEngine()); - auto resp = crow::response(ok ? 200 : 500, ok ? R"({"ok":true})" : R"({"error":"save failed"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/modules/:id/recipe/load/:name — Load recipe from disk - // ===================================================================== - CROW_ROUTE(app, "/api/modules//recipe/load/") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& id, const std::string& name) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - std::shared_lock lock(core_.moduleMutex()); - core_.dataStore().loadRecipe(id, name, core_.dataEngine()); - auto json = core_.dataEngine().readSection(id, DataSection::Recipe); - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // DELETE /api/modules/:id — Remove a module instance - // ===================================================================== - CROW_ROUTE(app, "/api/modules/") - .methods("DELETE"_method) - ([this](const std::string& id) { - bool ok = core_.removeInstance(id); - if (!ok) { - auto resp = crow::response(404, R"({"error":"instance not found"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - auto resp = crow::response(200, R"({"ok":true})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/modules/:id/reload — Warm-restart a module - // ===================================================================== - CROW_ROUTE(app, "/api/modules//reload") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& id) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - - bool ok = core_.reloadModule(id); - if (!ok) { - auto resp = crow::response(500, R"({"error":"reload failed"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - auto resp = crow::response(200, R"({"ok":true,"message":"module reloaded"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/modules/upload — Upload a new or replacement .so module - // Body: raw binary content of the .so file - // Header X-Filename: required — the filename (e.g. "libmy_module.so") - // ===================================================================== - CROW_ROUTE(app, "/api/modules/upload") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type, X-Filename"); - return resp; - } - - auto filename = req.get_header_value("X-Filename"); - if (filename.empty()) { - auto resp = crow::response(400, R"({"error":"X-Filename header required"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - // Sanitize filename: extract basename only, reject path traversal attempts. - auto sanitized = std::filesystem::path(filename).filename().string(); - if (sanitized.empty() || sanitized != filename || sanitized[0] == '.') { - auto resp = crow::response(400, R"({"error":"invalid filename — must be a plain filename with no path separators"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - // Whitelist valid module extensions. - auto ext = std::filesystem::path(sanitized).extension().string(); - if (ext != ".so" && ext != ".dylib" && ext != ".dll") { - auto resp = crow::response(400, R"({"error":"invalid file extension — must be .so, .dylib, or .dll"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - if (req.body.empty()) { - auto resp = crow::response(400, R"({"error":"empty body"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - // Write to a temp file, then call uploadModule. - auto tmpPath = std::filesystem::temp_directory_path() / sanitized; - { - std::ofstream ofs(tmpPath, std::ios::binary | std::ios::trunc); - if (!ofs) { - auto resp = crow::response(500, R"({"error":"failed to write temp file"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - ofs.write(req.body.data(), static_cast(req.body.size())); - } - - auto moduleId = core_.uploadModule(tmpPath); - std::filesystem::remove(tmpPath); - - if (moduleId.empty()) { - auto resp = crow::response(500, R"({"error":"upload failed"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - auto resp = crow::response(200, "{\"ok\":true,\"id\":\"" + moduleId + "\"}"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // GET /api/scope/schema — Nested runtime JSON for every module, keyed by - // module ID. Returns `{ "moduleId": {...runtime...}, ... }`. The - // frontend walks this tree directly to present probeable fields, so we - // never ship a flat `[{moduleId, path}, ...]` representation. - // ===================================================================== - CROW_ROUTE(app, "/api/scope/schema") - ([this]() { - std::shared_lock lock(core_.moduleMutex()); - std::string json = "{"; - bool first = true; - for (const auto& [id, _] : core_.loader().modules()) { - auto runtime = core_.dataEngine().readSection(id, DataSection::Runtime); - if (runtime.empty()) runtime = "{}"; - if (!first) json += ","; - json += "\"" + id + "\":" + runtime; - first = false; - } - json += "}"; - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // GET /api/scope/probes — List active probes - // ===================================================================== - CROW_ROUTE(app, "/api/scope/probes") - ([this]() { - auto probes = core_.oscilloscope().listProbes(); - std::string json = "["; - bool first = true; - for (auto& p : probes) { - if (!first) json += ","; - json += "{\"id\":" + std::to_string(p.id) - + ",\"moduleId\":\"" + p.moduleId + "\"" - + ",\"path\":\"" + p.path + "\"" - + ",\"label\":\"" + p.label + "\"}"; - first = false; - } - json += "]"; - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/scope/probes — Add a probe - // Body: {"moduleId": "...", "path": "/fieldname"} - // ===================================================================== - CROW_ROUTE(app, "/api/scope/probes") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - - AddProbeRequest req_body; - if (auto err = readJsonPermissive(req_body, req.body); err) { - auto resp = crow::response(400, R"({"error":"invalid JSON body"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - if (req_body.moduleId.empty() || req_body.path.empty()) { - auto resp = crow::response(400, R"({"error":"moduleId and path required"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - auto id = core_.oscilloscope().addProbe(req_body.moduleId, req_body.path); - auto resp = crow::response(200, "{\"ok\":true,\"id\":" + std::to_string(id) + "}"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // DELETE /api/scope/probes/:id — Remove a probe - // ===================================================================== - CROW_ROUTE(app, "/api/scope/probes/") - .methods("DELETE"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& probeIdStr) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "DELETE, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - uint64_t probeId = 0; - try { probeId = std::stoull(probeIdStr); } catch (...) { - auto resp = crow::response(400, R"({"error":"invalid probe id"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - bool ok = core_.oscilloscope().removeProbe(probeId); - if (!ok) { - auto resp = crow::response(404, R"({"error":"probe not found"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - auto resp = crow::response(200, R"({"ok":true})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // GET /api/scope/data — Get all ring buffer data - // ===================================================================== - CROW_ROUTE(app, "/api/scope/data") - ([this]() { - auto json = core_.oscilloscope().allDataToJson(); - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // GET /api/scheduler/classes — List class configs + live stats - // ===================================================================== - CROW_ROUTE(app, "/api/scheduler/classes") - ([this]() { - auto allStats = core_.scheduler().allClassStats(); - auto defs = core_.scheduler().classConfigs(); - - std::vector response; - response.reserve(defs.size()); - for (const auto& def : defs) - response.push_back(makeClassInfoDto(def, allStats)); - - auto json = glz::write_json(response).value_or("[]"); - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/scheduler/classes — Create a new class definition - // Body: {"name": "myclass", "period_us": 20000, "priority": 50, "cpu_affinity": -1, "spin_us": 0} - // ===================================================================== - CROW_ROUTE(app, "/api/scheduler/classes") - .methods("POST"_method) - ([this](const crow::request& req) { - ClassDef def; - if (auto err = readJsonPermissive(def, req.body); err) { - auto resp = crow::response(400, R"({"error":"invalid JSON body"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - if (def.name.empty()) { - auto resp = crow::response(400, R"({"error":"name is required"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - if (!core_.scheduler().addClassDef(def)) { - auto resp = crow::response(409, R"({"error":"class already exists"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - core_.saveSchedulerConfig(); - auto resp = crow::response(201, R"({"ok":true})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // PATCH /api/scheduler/classes/:name — Update class definition - // Body: {"period_us": 10000, "priority": 50, "cpu_affinity": -1} - // ===================================================================== - CROW_ROUTE(app, "/api/scheduler/classes/") - .methods("PATCH"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& className) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "PATCH, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - - // Find current definition - auto defs = core_.scheduler().classConfigs(); - ClassDef updated; - bool found = false; - for (auto& d : defs) { - if (d.name == className) { updated = d; found = true; break; } - } - if (!found) { - auto resp = crow::response(404, R"({"error":"class not found"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } +// serializeCycleHistory (vector + deque overloads) and buildHistoryBody → +// loom/api/json_build.h (shared with the WASM api router; the two history routes +// that used buildHistoryBody now live in api/router.cpp). - // Deserialize patch: glaze only overwrites fields present in the JSON body, - // leaving all other fields at their current values (PATCH semantics for free). - if (auto err = readJsonPermissive(updated, req.body); err) { - auto resp = crow::response(400, R"({"error":"invalid JSON body"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - // Never allow the client to rename a class via PATCH - updated.name = className; +// Build JSON module info for a single module +// moduleInfoJson → loom/api/json_build.h (shared with the WASM api router) - core_.scheduler().updateClassDef(updated); - core_.saveSchedulerConfig(); +Server::Server(RuntimeCore& core, const ServerConfig& config) + : core_(core), config_(config) {} - auto resp = crow::response(200, R"({"ok":true})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); +Server::~Server() { + stop(); +} + +static api::Method crowMethodToApi(crow::HTTPMethod m) { + switch (m) { + case crow::HTTPMethod::Get: return api::Method::GET; + case crow::HTTPMethod::Post: return api::Method::POST; + case crow::HTTPMethod::Put: return api::Method::PUT; + case crow::HTTPMethod::Patch: return api::Method::PATCH; + case crow::HTTPMethod::Delete: return api::Method::DELETE_; + default: return api::Method::UNKNOWN; + } +} + +void Server::start() { + if (running_.load()) return; + running_.store(true); + serverThread_ = std::thread([this]() { + crow::SimpleApp app; + app.loglevel(crow::LogLevel::Warning); // ===================================================================== - // GET /api/scheduler/schema — JSON Schema for SchedulerConfig + // GET /api/modules — List all modules // ===================================================================== - CROW_ROUTE(app, "/api/scheduler/schema") + CROW_ROUTE(app, "/api/modules") ([this]() { - auto schema = glz::write_json_schema().value_or("{}"); - auto resp = crow::response(200, schema); + std::shared_lock lock(core_.moduleMutex()); + std::string json = "["; + bool first = true; + for (auto& [id, mod] : core_.loader().modules()) { + if (!first) json += ","; + json += moduleInfoJson(mod, core_.scheduler()); + first = false; + } + json += "]"; + + auto resp = crow::response(200, json); resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); return resp; }); // ===================================================================== - // GET /api/scheduler/modules/:id/history?since=&bin= - // Returns the per-module cycle/jitter ring buffer as a JSON array. - // `since` (optional) filters to samples newer than the given timestamp. - // `bin` (optional) groups samples into fixed-width bins; each bin - // reports max cycle and max |jitter| within the window. + // GET /api/modules/available — List .so files with metadata across + // all configured module directories. De-duplicated by filename; + // primary moduleDir is enumerated first so a user's local build + // shadows any system example sharing the same .so name. // ===================================================================== - CROW_ROUTE(app, "/api/scheduler/modules//history") - ([this](const crow::request& req, const std::string& id) { - auto* ts = core_.scheduler().taskState(id); - if (!ts) { - auto resp = crow::response(404, "{\"error\":\"module not found\"}"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; + CROW_ROUTE(app, "/api/modules/available") + ([this]() { + const auto& cfg = core_.config(); + auto available = ModuleLoader::queryAvailable(cfg.moduleDir); + + std::unordered_set seen; + seen.reserve(available.size() * 2); + for (const auto& a : available) seen.insert(a.filename); + + for (const auto& extra : cfg.additionalModuleDirs) { + auto more = ModuleLoader::queryAvailable(extra); + for (auto& a : more) { + if (seen.insert(a.filename).second) { + available.push_back(std::move(a)); + } + } } - int64_t since = 0; - int64_t binMs = 0; - if (auto p = req.url_params.get("since")) { try { since = std::stoll(p); } catch (...) {} } - if (auto p = req.url_params.get("bin")) { try { binMs = std::stoll(p); } catch (...) {} } - std::vector samples; - { - std::lock_guard lk(ts->cycleHistoryMx); - samples = ts->cycleHistory.getAll(); + + std::vector dtos; + dtos.reserve(available.size()); + for (auto& a : available) { + dtos.push_back({ a.filename, a.className, a.version }); } - auto body = buildHistoryBody(samples, since, binMs); - auto resp = crow::response(200, body); + auto json = glz::write_json(dtos).value_or("[]"); + auto resp = crow::response(200, json); resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); return resp; }); // ===================================================================== - // GET /api/scheduler/classes/:name/history?since=&bin= - // Returns the per-class cycle/jitter ring buffer (binned if bin > 0). + // GET /api/faults — List fault reports (newest first). Includes this + // run's exception faults and any persisted reports from prior runs + // (signal-path crashes the process couldn't keep in memory). // ===================================================================== - CROW_ROUTE(app, "/api/scheduler/classes//history") - ([this](const crow::request& req, const std::string& name) { - auto deque = core_.scheduler().classHistory(name); - std::vector samples(deque.begin(), deque.end()); - int64_t since = 0; - int64_t binMs = 0; - if (auto p = req.url_params.get("since")) { try { since = std::stoll(p); } catch (...) {} } - if (auto p = req.url_params.get("bin")) { try { binMs = std::stoll(p); } catch (...) {} } - auto body = buildHistoryBody(samples, since, binMs); - auto resp = crow::response(200, body); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); + // GET /api/faults — migrated to api::dispatch (router.cpp); served by the catch-all above. - // ===================================================================== - // GET /api/io-mappings — List all I/O mappings with resolution status - // ===================================================================== - CROW_ROUTE(app, "/api/io-mappings") - ([this]() { - auto& mapper = core_.ioMapper(); - std::string json = "["; - bool first = true; - for (size_t i = 0; i < mapper.entryCount(); ++i) { - auto* entry = mapper.getMapping(i); - if (!entry) continue; - if (!first) json += ","; - json += "{\"index\":" + std::to_string(i); - json += ",\"source\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].src_module_id : "") + "\""; - json += ",\"target\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].dst_module_id : "") + "\""; - json += ",\"enabled\":" + std::string(entry->valid ? "true" : "false"); - json += ",\"resolved\":" + std::string(entry->valid ? "true" : "false"); - json += ",\"stable\":" + std::string(entry->stable ? "true" : "false"); - json += ",\"error\":\"" + entry->error + "\"}"; - first = false; - } - json += "]"; - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); + // GET /api/faults/ and POST /api/modules/instantiate — migrated to + // api::dispatch (router.cpp). + + // GET /api/system (process memory/CPU metrics + history) — migrated to + // api::dispatch (router.cpp) so the wasm host serves it too; served by + // the catch-all below. The WS live frame's `system` block stays here + // (WebSockets are native-only). + + // GET /api/modules/ (module detail: info + data sections) — migrated + // to api::dispatch (router.cpp), which now serves the same ModuleDetail + // shape this route used to build; served by the catch-all below. + + // GET/POST/PUT/PATCH /api/modules//data/
, POST + // /api/modules//config/{save,load}, POST + // /api/modules//recipe/{save,load}/, DELETE /api/modules/, + // and POST /api/modules//reload — migrated to api::dispatch + // (router.cpp). reload is safe under wasm now too — see the reassign note + // above; reload's reloadModule() goes through the same scheduler + // stop()/start() path. // ===================================================================== - // POST /api/io-mappings — Add a new mapping - // Body: {"source": "left_motor.runtime.speed", "target": "controller.runtime.input", "enabled": true} + // POST /api/modules/upload — Upload a new or replacement .so module + // Body: raw binary content of the .so file + // Header X-Filename: required — the filename (e.g. "libmy_module.so") // ===================================================================== - CROW_ROUTE(app, "/api/io-mappings") + CROW_ROUTE(app, "/api/modules/upload") .methods("POST"_method, "OPTIONS"_method) ([this](const crow::request& req) { if (req.method == "OPTIONS"_method) { auto resp = crow::response(204); resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); + resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); + resp.add_header("Access-Control-Allow-Headers", "Content-Type, X-Filename"); return resp; } - IOMapEntry entry; - if (auto err = readJsonPermissive(entry, req.body); err) { - auto resp = crow::response(400, R"({"error":"invalid JSON body"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - if (entry.source.empty() || entry.target.empty()) { - auto resp = crow::response(400, R"({"error":"source and target are required"})"); + auto filename = req.get_header_value("X-Filename"); + if (filename.empty()) { + auto resp = crow::response(400, R"({"error":"X-Filename header required"})"); resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); return resp; } - auto index = core_.ioMapper().addMapping(entry.source, entry.target, entry.enabled); - core_.ioMapper().resolveAll(core_); - - auto resp = crow::response(201, "{\"ok\":true,\"index\":" + std::to_string(index) + "}"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // DELETE/PATCH /api/io-mappings/:index — Remove or update a mapping - // ===================================================================== - CROW_ROUTE(app, "/api/io-mappings/") - .methods("DELETE"_method, "PATCH"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& indexStr) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); + // Sanitize filename: extract basename only, reject path traversal attempts. + auto sanitized = std::filesystem::path(filename).filename().string(); + if (sanitized.empty() || sanitized != filename || sanitized[0] == '.') { + auto resp = crow::response(400, R"({"error":"invalid filename — must be a plain filename with no path separators"})"); + resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "DELETE, PATCH, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); return resp; } - size_t index = 0; - try { index = std::stoull(indexStr); } catch (...) { - auto resp = crow::response(400, R"({"error":"invalid index"})"); + // Whitelist valid module extensions. + auto ext = std::filesystem::path(sanitized).extension().string(); + if (ext != ".so" && ext != ".dylib" && ext != ".dll") { + auto resp = crow::response(400, R"({"error":"invalid file extension — must be .so, .dylib, or .dll"})"); resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); return resp; } - if (req.method == "DELETE"_method) { - // DELETE: remove mapping - if (!core_.ioMapper().removeMapping(index)) { - auto resp = crow::response(404, R"({"error":"mapping not found"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - auto resp = crow::response(200, R"({"ok":true})"); + if (req.body.empty()) { + auto resp = crow::response(400, R"({"error":"empty body"})"); resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); return resp; - } else if (req.method == "PATCH"_method) { - // PATCH: update mapping - if (index >= core_.ioMapper().entryCount()) { - auto resp = crow::response(404, R"({"error":"mapping not found"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - - IOMapEntry entry; - if (auto err = readJsonPermissive(entry, req.body); err) { - auto resp = crow::response(400, R"({"error":"invalid JSON body"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - if (entry.source.empty() || entry.target.empty()) { - auto resp = crow::response(400, R"({"error":"source and target are required"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } + } - if (!core_.ioMapper().updateMapping(index, entry.source, entry.target, entry.enabled)) { - auto resp = crow::response(500, R"({"error":"update failed"})"); + // Write to a temp file, then call uploadModule. + auto tmpPath = std::filesystem::temp_directory_path() / sanitized; + { + std::ofstream ofs(tmpPath, std::ios::binary | std::ios::trunc); + if (!ofs) { + auto resp = crow::response(500, R"({"error":"failed to write temp file"})"); resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); return resp; } + ofs.write(req.body.data(), static_cast(req.body.size())); + } - core_.ioMapper().resolveAll(core_); + auto moduleId = core_.uploadModule(tmpPath); + std::filesystem::remove(tmpPath); - auto resp = crow::response(200, R"({"ok":true})"); + if (moduleId.empty()) { + auto resp = crow::response(500, R"({"error":"upload failed"})"); resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); return resp; } - auto resp = crow::response(405, R"({"error":"method not allowed"})"); + auto resp = crow::response(200, "{\"ok\":true,\"id\":\"" + moduleId + "\"}"); resp.add_header("Content-Type", "application/json"); resp.add_header("Access-Control-Allow-Origin", "*"); return resp; }); // ===================================================================== - // POST /api/io-mappings/resolve — Force re-resolve all mappings + // GET /api/scope/schema — Nested runtime JSON for every module, keyed by + // module ID. Returns `{ "moduleId": {...runtime...}, ... }`. The + // frontend walks this tree directly to present probeable fields, so we + // never ship a flat `[{moduleId, path}, ...]` representation. // ===================================================================== - CROW_ROUTE(app, "/api/io-mappings/resolve") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - - core_.ioMapper().resolveAll(core_); - auto resp = crow::response(200, R"({"ok":true})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); + // GET /api/scope/schema — migrated to api::dispatch (router.cpp). // ===================================================================== - // POST /api/scheduler/reassign — Move a module to a different class - // Body: {"moduleId": "...", "class": "...", "order": 0 (optional)} + // GET /api/scope/probes — List active probes // ===================================================================== - CROW_ROUTE(app, "/api/scheduler/reassign") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } + // GET /api/scope/probes, POST /api/scope/probes, DELETE + // /api/scope/probes/ — migrated to api::dispatch (router.cpp). - // Parse body manually (keep the dep-light pattern of the rest of server.cpp). - std::string moduleId, newClass; - std::optional newOrder; - - // Simple key extraction — body is expected to be compact JSON. - auto extract = [&](const std::string& key) -> std::string { - auto pos = req.body.find("\"" + key + "\""); - if (pos == std::string::npos) return {}; - pos = req.body.find(':', pos); - if (pos == std::string::npos) return {}; - ++pos; - while (pos < req.body.size() && req.body[pos] == ' ') ++pos; - if (pos >= req.body.size()) return {}; - if (req.body[pos] == '"') { - ++pos; - auto end = req.body.find('"', pos); - return (end != std::string::npos) ? req.body.substr(pos, end - pos) : ""; - } - auto end = req.body.find_first_of(",}", pos); - return (end != std::string::npos) ? req.body.substr(pos, end - pos) : ""; - }; + // ===================================================================== + // GET /api/scope/data — Get all ring buffer data + // ===================================================================== + // GET /api/scope/data — migrated to api::dispatch (router.cpp). - moduleId = extract("moduleId"); - newClass = extract("class"); - auto orderStr = extract("order"); + // ===================================================================== + // GET /api/scheduler/classes — List class configs + live stats + // ===================================================================== + // GET /api/scheduler/classes — migrated to api::dispatch (router.cpp). - if (moduleId.empty() || newClass.empty()) { - auto resp = crow::response(400, R"({"error":"moduleId and class are required"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } + // POST /api/scheduler/classes (add), PATCH /api/scheduler/classes/ + // (update), GET /api/scheduler/schema, GET /api/scheduler/modules//history + // and GET /api/scheduler/classes//history (both ?since=&bin=) — + // migrated to api::dispatch (router.cpp). - if (!orderStr.empty()) { - try { newOrder = std::stoi(orderStr); } catch (...) {} - } + // ===================================================================== + // GET /api/io-mappings — List all I/O mappings with resolution status + // ===================================================================== + // GET /api/io-mappings — migrated to api::dispatch (router.cpp). POST below stays. - auto ec = core_.scheduler().reassignClass(moduleId, newClass, newOrder); - if (ec) { - auto resp = crow::response(400, "{\"error\":\"" + ec.message() + "\"}"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - } - core_.saveSchedulerConfig(); + // POST /api/io-mappings, DELETE/PATCH /api/io-mappings/, and + // POST /api/io-mappings/resolve — migrated to api::dispatch (router.cpp). - auto resp = crow::response(200, R"({"ok":true})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); + // POST /api/scheduler/reassign — migrated to api::dispatch (router.cpp). + // Safe under wasm now: Scheduler::pauseClass()/unpauseClass() are no-ops + // under EMSCRIPTEN (see scheduler.cpp), so the insertMember()/removeMember() + // pause-handshake this goes through no longer deadlocks the cooperative host. // ===================================================================== // GET /api/bus/topics — List bus topics // ===================================================================== - CROW_ROUTE(app, "/api/bus/topics") - ([this]() { - auto topics = core_.bus().topics(); - std::string json = "["; - for (size_t i = 0; i < topics.size(); ++i) { - if (i > 0) json += ","; - json += "\"" + topics[i] + "\""; - } - json += "]"; - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); + // GET /api/bus/topics — migrated to api::dispatch (router.cpp). // ===================================================================== // GET /api/bus/services — List bus services with request schemas // ===================================================================== - CROW_ROUTE(app, "/api/bus/services") - ([this]() { - auto infos = core_.bus().serviceInfos(); - std::string json = "["; - for (size_t i = 0; i < infos.size(); ++i) { - if (i > 0) json += ","; - json += "{\"name\":\"" + infos[i].name + "\",\"schema\":"; - json += infos[i].schema.empty() ? "null" : infos[i].schema; - json += "}"; - } - json += "]"; - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); - - // ===================================================================== - // POST /api/bus/services/:name — Call a bus service - // ===================================================================== - CROW_ROUTE(app, "/api/bus/call/") - .methods("POST"_method, "OPTIONS"_method) - ([this](const crow::request& req, const std::string& serviceName) { - if (req.method == "OPTIONS"_method) { - auto resp = crow::response(204); - resp.add_header("Access-Control-Allow-Origin", "*"); - resp.add_header("Access-Control-Allow-Methods", "POST, OPTIONS"); - resp.add_header("Access-Control-Allow-Headers", "Content-Type"); - return resp; - } - - auto result = core_.bus().call(serviceName, req.body); - std::string json = "{"; - json += "\"ok\":" + std::string(result.ok ? "true" : "false"); - if (!result.response.empty()) { - json += ",\"response\":" + result.response; - } - if (!result.error.empty()) { - json += ",\"error\":\"" + result.error + "\""; - } - json += "}"; + // GET /api/bus/services — migrated to api::dispatch (router.cpp). - auto resp = crow::response(200, json); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - return resp; - }); + // POST /api/bus/call/ — migrated to api::dispatch (router.cpp). // ===================================================================== // WebSocket /ws — Live data streaming with topic subscriptions. @@ -1500,6 +485,7 @@ void Server::start() { std::unordered_map moduleFragments; std::unordered_map runtimeFragments; std::string classesTail; + std::string systemTail; { std::shared_lock lock(core_.moduleMutex()); @@ -1544,7 +530,17 @@ void Server::start() { classesTail += "}"; firstClass = false; } - classesTail += "}}"; + classesTail += "}"; // close "classes" only; root closed after the system block + + // Process resource metrics (memory + CPU). Same for every + // connection, so build once here; closes the root object. + const auto sys = core_.systemMetrics().current(); + systemTail = ",\"system\":{"; + systemTail += "\"rssBytes\":" + std::to_string(sys.rssBytes); + systemTail += ",\"peakRssBytes\":" + std::to_string(sys.peakRssBytes); + systemTail += ",\"cpuPercent\":" + std::to_string(sys.cpuPercent); + systemTail += ",\"uptimeSec\":" + std::to_string(sys.uptimeSec); + systemTail += "}}"; } // One send_binary per connection — runtime data embedded inline for @@ -1573,6 +569,7 @@ void Server::start() { first = false; } msg += classesTail; + msg += systemTail; conn->send_binary(msg); } } @@ -1750,8 +747,37 @@ void Server::start() { // after every real route (REST + WebSocket) gives it the highest index, // so it only wins when no literal route matches — otherwise it would // shadow the facade's GET endpoints and the pushchannel WS upgrade. + // + // GOTCHA: this only protects against routes Crow can match as a single + // node (a literal segment, or a route with exactly the registered shape). + // For a path under /api/ whose exact segment-shape was never registered + // as its own CROW_ROUTE (e.g. /api/modules//data/
, now only + // known to api::dispatch, not to Crow's trie), Crow's GET-only "/" + // here apparently still wins the match over the GET+POST+PATCH+DELETE + // "/api/" catch-all below, despite being registered first and + // /api/ being the more specific literal-prefixed route — verified + // empirically (curl): GET returned an empty 200 text/html (this handler's + // serveIndex() fallback) for such paths, while POST/PATCH/DELETE to the + // exact same paths worked, because "/" never registers those + // methods so there's no ambiguity for them. Rather than depend further on + // Crow's wildcard-tie-breaking, this handler explicitly hands off any + // "api/..." path to the SAME dispatch() the /api/ catch-all below + // uses, so GET correctness doesn't depend on which wildcard Crow resolves to. CROW_ROUTE(app, "/") - ([serveIndex](crow::response& res, const std::string& filePathPartial) { + ([this, serveIndex](const crow::request& creq, crow::response& res, const std::string& filePathPartial) { + if (filePathPartial.rfind("api/", 0) == 0) { + api::Request areq; + areq.method = crowMethodToApi(creq.method); + areq.path = creq.raw_url; + areq.body = creq.body; + api::Response ares = api::dispatch(core_, areq); + res.code = ares.status; + res.body = ares.body; + res.add_header("Content-Type", ares.contentType); + res.add_header("Access-Control-Allow-Origin", "*"); + res.end(); + return; + } std::error_code ec; const std::string full = g_crowStaticDir + filePathPartial; if (std::filesystem::is_regular_file(full, ec)) { @@ -1763,6 +789,40 @@ void Server::start() { } }); + // ---- /api/* catch-all → shared dispatch (registered LAST so the static + // routes above take priority; this only handles paths none of them match, + // i.e. routes already migrated into api::dispatch and shared with WASM). + // GET is handled by "/" above (see its comment) — kept here too + // since it's harmless dead weight if ever reached, and POST/PUT/PATCH/ + // DELETE reliably reach this one (no GET-only sibling to contend with). + // OPTIONS answers CORS preflight: the pre-migration per-route handlers + // did this explicitly, and without it cross-origin non-GET requests to + // migrated routes die at the preflight step before dispatch is reached. + CROW_ROUTE(app, "/api/").methods( + crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Put, + crow::HTTPMethod::Patch, crow::HTTPMethod::Delete, crow::HTTPMethod::Options) + ([this](const crow::request& creq, const std::string&) { + if (creq.method == crow::HTTPMethod::Options) { + crow::response resp(204); + resp.add_header("Access-Control-Allow-Origin", "*"); + resp.add_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"); + resp.add_header("Access-Control-Allow-Headers", "Content-Type"); + return resp; + } + api::Request areq; + areq.method = crowMethodToApi(creq.method); + // raw_url includes the "?query" suffix (creq.url is path-only) — dispatch() + // splits it back apart itself, matching what the wasm host already passes + // (JS's pathname + search), so history-style ?since=&bin= routes work natively too. + areq.path = creq.raw_url; + areq.body = creq.body; + api::Response ares = api::dispatch(core_, areq); + crow::response resp(ares.status, ares.body); + resp.add_header("Content-Type", ares.contentType); + resp.add_header("Access-Control-Allow-Origin", "*"); + return resp; + }); + spdlog::info("Starting HTTP server on {}:{}", config_.bindAddress, config_.port); app.bindaddr(config_.bindAddress) .port(config_.port) diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index e6a34a2..031cdf6 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -14,6 +14,29 @@ else() set(LOOM_SDK_VERSION ${PROJECT_VERSION}) endif() +# Optional git identity, stamped into version.h. Degrades gracefully: if git or +# the .git dir is absent (source-tarball / conan-cache / no-git builds), kGitSha +# becomes "unknown" and the build NEVER fails. kSdkVersion + BuildInfo remain the +# always-present identifiers. +set(LOOM_GIT_SHA "unknown") +find_package(Git QUIET) +if(Git_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git") + execute_process( + COMMAND ${GIT_EXECUTABLE} -C "${CMAKE_SOURCE_DIR}" rev-parse --short=12 HEAD + OUTPUT_VARIABLE LOOM_GIT_SHA OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(NOT LOOM_GIT_SHA) + set(LOOM_GIT_SHA "unknown") + else() + execute_process( + COMMAND ${GIT_EXECUTABLE} -C "${CMAKE_SOURCE_DIR}" status --porcelain --untracked-files=no + OUTPUT_VARIABLE _loom_git_dirty OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(_loom_git_dirty) + set(LOOM_GIT_SHA "${LOOM_GIT_SHA}+dirty") + endif() + endif() +endif() +message(STATUS "Loom git sha: ${LOOM_GIT_SHA}") + # Generate version.h from the template so every module built against this SDK # has the exact version string baked in at compile time. configure_file( @@ -60,15 +83,23 @@ configure_package_config_file( INSTALL_DESTINATION ${CONFIG_INSTALL_DIR} ) +# ARCH_INDEPENDENT: the SDK is a header-only INTERFACE library (no compiled +# binaries), so it's genuinely usable from any target architecture/pointer +# width. Without this, find_package(loom CONFIG)'s default compatibility +# check compares CMAKE_SIZEOF_VOID_P against the arch this was installed +# from, silently rejecting the package for any cross-compile (e.g. wasm32) +# consumer even though nothing here actually depends on pointer size. write_basic_package_version_file( ${CMAKE_CURRENT_BINARY_DIR}/loomConfigVersion.cmake VERSION ${LOOM_SDK_VERSION} COMPATIBILITY AnyNewerVersion + ARCH_INDEPENDENT ) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/loomConfig.cmake ${CMAKE_CURRENT_BINARY_DIR}/loomConfigVersion.cmake + ${CMAKE_SOURCE_DIR}/cmake/LoomModule.cmake DESTINATION ${CONFIG_INSTALL_DIR} ) diff --git a/sdk/include/loom/bus.h b/sdk/include/loom/bus.h index b8fb2ca..7972a03 100644 --- a/sdk/include/loom/bus.h +++ b/sdk/include/loom/bus.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include #include @@ -12,6 +14,8 @@ namespace loom { +class CommandChannel; // loom/command.h — registered here, looked up by consumers + /// Result of a service call. struct CallResult { bool ok = false; @@ -145,7 +149,16 @@ class Bus { } handler = it->second; } - return handler(request); + // A throwing service handler must not unwind into the caller (which may + // be a cyclic thread or the HTTP thread) — return an error instead. + // Dependency-free (std only): keeps the SDK clean of diagnostics deps. + try { + return handler(request); + } catch (const std::exception& e) { + return {false, {}, std::string("service threw: ") + e.what()}; + } catch (...) { + return {false, {}, "service threw: unknown exception"}; + } } // ========================================================================= @@ -158,6 +171,82 @@ class Bus { }); } + // ========================================================================= + // Command channels (async-command providers) + // ========================================================================= + + /// A provider registers its CommandChannel under its module id; consumers + /// look it up via CommandClient. Lifetime is the provider's — it must + /// unregister on unload (Module does this in cleanupSubscriptions). + inline void registerCommandChannel(const std::string& provider, CommandChannel* channel) { + std::lock_guard lock(commandMutex_); + commandChannels_[provider] = channel; + } + + inline void unregisterCommandChannel(const std::string& provider) { + std::lock_guard lock(commandMutex_); + commandChannels_.erase(provider); + } + + inline CommandChannel* commandChannel(const std::string& provider) const { + std::lock_guard lock(commandMutex_); + auto it = commandChannels_.find(provider); + return it == commandChannels_.end() ? nullptr : it->second; + } + + // ========================================================================= + // Ports (typed connection points for continuous data binding) + // + // A provider exposes a typed pointer (e.g. an axis's per-axis AxisInterface) + // under a name; consumers resolve it with a type-id check. Unlike a command + // channel this is continuous shared data, not async one-shot work; unlike + // getRuntimeAs it exposes only the named cell, not the whole runtime. The + // generation counter lets consumers cache the pointer and re-resolve only + // when the registry changes (see loom/port.h PortRef). + // ========================================================================= + + struct PortEntry { + void* ptr = nullptr; + std::string type_id; // matched against T::kTypeId on typed resolve + }; + + inline void registerPort(const std::string& name, void* ptr, std::string_view typeId) { + std::lock_guard lock(portMutex_); + ports_[name] = PortEntry{ptr, std::string(typeId)}; + portGeneration_.fetch_add(1, std::memory_order_release); + } + + inline void unregisterPort(const std::string& name) { + std::lock_guard lock(portMutex_); + if (ports_.erase(name) != 0) + portGeneration_.fetch_add(1, std::memory_order_release); + } + + inline PortEntry lookupPort(const std::string& name) const { + std::lock_guard lock(portMutex_); + auto it = ports_.find(name); + return it == ports_.end() ? PortEntry{} : it->second; + } + + /// Bumped on every (un)register, so consumers can detect a changed registry + /// without re-doing a string lookup every cycle. Lock-free fast path for + /// PortRef::get() — no mutex needed to read the generation counter. + inline uint64_t portGeneration() const { + return portGeneration_.load(std::memory_order_acquire); + } + + /// Typed resolve: nullptr if the port is absent or its type id doesn't match + /// T::kTypeId. + template + T* port(const std::string& name) const { + auto e = lookupPort(name); + if (!e.ptr) return nullptr; + if constexpr (requires { T::kTypeId; }) { + if (e.type_id != T::kTypeId) return nullptr; + } + return static_cast(e.ptr); + } + // ========================================================================= // Introspection // ========================================================================= @@ -212,6 +301,13 @@ class Bus { mutable std::mutex serviceMutex_; std::unordered_map serviceHandlers_; std::unordered_map serviceSchemas_; + + mutable std::mutex commandMutex_; + std::unordered_map commandChannels_; + + mutable std::mutex portMutex_; + std::unordered_map ports_; + std::atomic portGeneration_{1}; // nonzero so a freshly-default PortRef re-resolves; lock-free fast path }; } // namespace loom diff --git a/sdk/include/loom/command.h b/sdk/include/loom/command.h new file mode 100644 index 0000000..a608ed4 --- /dev/null +++ b/sdk/include/loom/command.h @@ -0,0 +1,246 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// Async commands — the third inter-module primitive (alongside topics & RPC) +// +// Topics are fire-and-forget; services are synchronous request/response. Neither +// fits a long-running, stateful operation that needs progress + Done/Aborted/ +// Error feedback (a motion, a heater ramp, a pump sequence). Commands fill that +// gap, with PLCopen-style status delivered by write-through — no polling. +// +// Flow: +// - A consumer holds a std::shared_ptr (from the runtime heap) +// and submits {command, target, params, weak_ptr} to a provider's +// CommandChannel (looked up on the Bus by provider id). +// - The provider drains its channel in cyclic(), runs the work over as many +// cycles as needed, and writes status THROUGH the weak_ptr. If the consumer +// has gone, weak_ptr::lock() fails and the provider drops the report. +// +// This header is standalone (no ); CommandClient / CommandFb, +// which need a module handle, live in command_client.h. +// ============================================================================ + +namespace loom { + +/// Lifecycle phase of a single async command. Mirrors PLCopen FB outputs and +/// the ATN IDLE / EXECUTE / WAITING / DONE / ABORT / ERROR / BYPASSED set. +enum class CmdPhase : uint8_t { + None = 0, + Queued = 1, ///< accepted, waiting behind another command (Buffered) + Active = 2, ///< currently controlling the provider + Done = 3, ///< terminal: success + Aborted = 4, ///< terminal: superseded / cancelled + Error = 5, ///< terminal: rejected / faulted (see error_id) + Bypassed = 6, ///< provider bypassed (e.g. simulation) +}; + +/// Status of one async command, written through by the provider and read by the +/// issuing function block. The provider (its cyclic thread) and the consumer FB +/// may run on different scheduler threads, so the fields are atomic to avoid a +/// data race; write with .store(), read with .load(). Still trivially +/// destructible (atomics of trivial types are), so it lives in runtime-heap +/// storage (see runtime_heap.h). Note: per-field atomics give no cross-field +/// snapshot — a reader re-reads each cycle and converges; map correlated state +/// onto `phase` (one load) where consistency matters. +struct CommandStatus { + std::atomic phase {CmdPhase::None}; + std::atomic busy {false}; + std::atomic active {false}; + std::atomic done {false}; + std::atomic aborted {false}; + std::atomic error {false}; + std::atomic error_id {0}; + std::atomic progress {0.0}; ///< optional 0..1 progress for long commands +}; + +/// PLCopen buffer behaviour at the provider when a command arrives. +enum class BufferMode : uint8_t { + Aborting = 0, ///< immediately supersede the active command + Buffered = 1, ///< queue behind the active command +}; + +/// FB-side error id used when a submission can't be delivered. +inline constexpr uint32_t kErrSubmitFailed = 0xA001; + +/// One submitted command. Fixed layout, so its ABI does not grow as a provider's +/// command set grows. `command` is a *provider-defined* id — each provider +/// declares its own `enum class : uint32_t` of commands and casts it here; the +/// generic layer treats it as an opaque integer (no string parsing on dispatch). +/// `params` carries the per-command payload (JSON by convention) and is opaque. +struct CommandSubmission { + uint32_t command = 0; ///< provider-defined command id (its enum, as int) + uint32_t target = 0; ///< sub-entity index (e.g. axis); 0 if N/A + std::string params; ///< opaque payload (JSON by convention) + BufferMode buffer_mode = BufferMode::Aborting; + std::weak_ptr status; ///< issuer-owned cell (runtime heap) +}; + +/// Provider-owned submission inbox. Registered on the Bus under the provider's +/// id; the provider drains it once per cycle. Thread-safe (submit from consumer +/// threads, drain from the provider's cyclic thread). +class CommandChannel { +public: + void submit(CommandSubmission s) { + std::lock_guard lk(mutex_); + pending_.push_back(std::move(s)); + } + void drain(std::vector& out) { + out.clear(); + std::lock_guard lk(mutex_); + out.swap(pending_); + } + +private: + std::mutex mutex_; + std::vector pending_; +}; + +/// Polymorphic root for every function block, so a heterogeneous set can be +/// ticked uniformly: std::vector and call update() on each. The +/// provider handle + inputs are bound by the concrete FB (typically at +/// construction), so update() is arg-less. Common outputs (busy/error/error_id) +/// live here so they're readable through the base without a downcast. +/// +/// ABI note: keep this vtable minimal. It is intended for INTRA-module use — do +/// not pass these pointers across .so boundaries (hidden visibility + header-only +/// vtables make cross-boundary RTTI/dynamic_cast unsafe). +class IFunctionBlock { +public: + bool busy = false; + bool error = false; + uint32_t error_id = 0; + + virtual ~IFunctionBlock() = default; + + /// Evaluate one cycle. + virtual void update() = 0; + + /// Full reset: clear outputs + any internal edge/latch state so the FB can be + /// reused from scratch. Base clears the common outputs; subclasses extend. + virtual void clear() { busy = false; error = false; error_id = 0; } + + /// Re-arm verbs (no-ops on level FBs): + /// rearm() — drop edge memory so a still-asserted `execute` re-fires. + /// resetEdge() — drop the `execute` pulse so re-firing needs a fresh edge. + virtual void rearm() {} + virtual void resetEdge() {} +}; + +/// Reusable edge-triggered function-block base for PLCopen-style commands. A +/// domain FB sets `execute`, on a rising edge builds its params + submits via a +/// CommandClient (see command_client.h), and passes the result to commit(); +/// outputs then mirror the status cell. Depends only on CommandStatus — no +/// module handle, no serialization — so it stays in this glaze-free header. +class CommandFb : public IFunctionBlock { +public: + bool execute = false; + BufferMode buffer_mode = BufferMode::Aborting; + + // busy / error / error_id are inherited from IFunctionBlock. + bool active = false; + bool done = false; + bool command_aborted = false; + + /// clear() — full reset (outputs + edge memory + execute) → reuse. + /// rearm() — drop edge memory only; a held `execute` re-fires next update. + /// resetEdge() — drop the `execute` pulse; re-fire needs a fresh rising edge + /// (a cycle with execute low). The "set true / auto-clear" idiom. + void clear() override { + clearOutputs(); status_.reset(); state_ = State::Idle; + prev_execute_ = false; execute = false; + } + void rearm() override { prev_execute_ = false; } + void resetEdge() override { execute = false; } + bool finished() const { return done || command_aborted || error; } + +protected: + /// True on the Execute rising edge (valid before commit() updates state). + bool rising() const { return execute && !prev_execute_; } + + /// Apply one cycle's outcome. On a rising edge `submitted` is the submit + /// result (possibly null on failure); otherwise pass nullptr. + /// + /// PLCopen output rules enforced here: + /// - exactly one of {busy, done, command_aborted, error} is set at a time + /// (active may accompany busy); + /// - dropping Execute does NOT cancel — the block stays Busy until the + /// command finishes, then surfaces the terminal result; + /// - a terminal result (Done/Aborted/Error) is held while Execute is high, + /// and is visible for >= 1 cycle even if Execute is already low; + /// - a rising Execute starts a new command, superseding any held result. + void commit(bool rising_edge, std::shared_ptr submitted) { + // A held terminal result clears only on a LATER call with Execute low, so + // it was visible for at least the cycle it was set in. + if (state_ == State::Held && !execute) { + clearOutputs(); + status_.reset(); + state_ = State::Idle; + } + + if (rising_edge) { // new command supersedes anything currently shown + clearOutputs(); + status_ = std::move(submitted); + if (!status_) { error = true; error_id = kErrSubmitFailed; state_ = State::Held; } + else { busy = true; state_ = State::Running; } + } + + if (state_ == State::Running && status_) { + const CmdPhase ph = status_->phase.load(); + if (ph == CmdPhase::Done || ph == CmdPhase::Aborted || ph == CmdPhase::Error) { + clearOutputs(); + done = (ph == CmdPhase::Done); + command_aborted = (ph == CmdPhase::Aborted); + error = (ph == CmdPhase::Error); + error_id = status_->error_id.load(); + state_ = State::Held; + } else { // still in flight (Queued / Active / None) + busy = true; + active = (ph == CmdPhase::Active); + } + } + + prev_execute_ = execute; + } + +private: + enum class State { Idle, Running, Held }; + void clearOutputs() { + busy = active = done = command_aborted = error = false; + error_id = 0; + } + std::shared_ptr status_; + bool prev_execute_ = false; + State state_ = State::Idle; +}; + +/// Level-triggered ("Enable") function-block base — the PLCopen counterpart to +/// CommandFb for administrative / read blocks (MC_Power, MC_ReadParameter, +/// MC_ReadActualPosition, ...). `enable` is a level input; while it is high the +/// FB refreshes its outputs each update(). busy/error/error_id are shared with +/// the base; subclasses add their own outputs (e.g. `status`, or `valid`+`value`). +/// The re-arm verbs are no-ops here — a level input has no edge to consume. +class EnableFb : public IFunctionBlock { +public: + bool enable = false; + + void clear() override { + busy = false; error = false; error_id = 0; prev_enable_ = false; + } + +protected: + bool enableRising() const { return enable && !prev_enable_; } + bool enableFalling() const { return !enable && prev_enable_; } + void noteEnable() { prev_enable_ = enable; } + +private: + bool prev_enable_ = false; +}; + +} // namespace loom diff --git a/sdk/include/loom/command_client.h b/sdk/include/loom/command_client.h new file mode 100644 index 0000000..e3fdc89 --- /dev/null +++ b/sdk/include/loom/command_client.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include + +#include "command.h" +#include "module.h" + +// ============================================================================ +// Consumer side of the async-command primitive: CommandClient (submit) and +// CommandFb (the reusable Execute-edge / status-mirror base for PLCopen-style +// function blocks). Domain layers build thin FBs on top of these. +// +// These need a module handle (for the Bus lookup + runtime heap), so they live +// here rather than in command.h. +// ============================================================================ + +namespace loom { + +/// Handle a consumer module uses to submit commands to a named provider. +/// Resolves the provider's channel on the Bus fresh on each submit (hot-reload +/// safe), allocates the status cell from the runtime heap, and enqueues it. +class CommandClient { +public: + CommandClient(IModule* self, std::string provider) + : self_(self), provider_(std::move(provider)) {} + + /// Submit a provider-defined command id to sub-entity `target` (0 for + /// single-entity providers). Returns the status cell to hold, or nullptr if + /// the provider is unreachable or the heap is exhausted. + std::shared_ptr submit(uint32_t command, uint32_t target, + std::string params, BufferMode bm) { + if (!self_ || !self_->bus()) return nullptr; + auto* ch = self_->bus()->commandChannel(provider_); + if (!ch) return nullptr; + auto status = self_->makeShared(); + if (!status) return nullptr; + ch->submit(CommandSubmission{command, target, std::move(params), bm, status}); + return status; + } + + std::shared_ptr submit(uint32_t command, std::string params, BufferMode bm) { + return submit(command, 0, std::move(params), bm); + } + + /// Typed convenience: pass the provider's own `enum class : uint32_t` + /// directly — it's cast to the integer command id. + template >> + std::shared_ptr submit(E command, uint32_t target, std::string params, BufferMode bm) { + return submit(static_cast(command), target, std::move(params), bm); + } + template >> + std::shared_ptr submit(E command, std::string params, BufferMode bm) { + return submit(static_cast(command), 0, std::move(params), bm); + } + + const std::string& provider() const { return provider_; } + +private: + IModule* self_; + std::string provider_; +}; + +} // namespace loom diff --git a/sdk/include/loom/command_glaze.h b/sdk/include/loom/command_glaze.h new file mode 100644 index 0000000..153c5a1 --- /dev/null +++ b/sdk/include/loom/command_glaze.h @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include "loom/command.h" + +// ============================================================================ +// Glaze metadata for the function-block bases — OPT-IN (keeps command.h itself +// glaze-free, so the core control header has no serialization dependency). +// +// Include this header where you want function blocks to be reflectable, e.g. to +// place them directly in a module's Runtime struct so the watch tree / HMI sees +// their live PLCopen I/O. The bases are abstract, so these specializations are +// never serialized on their own — the value here is the field-list macros, which +// a concrete FB's meta expands to reuse the base I/O surface without repeating it: +// +// #include +// template <> struct glz::meta { +// using T = MC_MoveAbsolute; +// static constexpr auto value = glz::object( +// LOOM_COMMAND_FB_FIELDS(T), // execute/done/busy/... +// "position", &T::position, "velocity", &T::velocity); +// }; +// +// Only the public I/O is exposed; private edge/latch state and any bound handles +// (a CommandClient, a status cell) are intentionally omitted. +// ============================================================================ + +/// Common IFunctionBlock outputs. +#define LOOM_IFUNCTION_BLOCK_FIELDS(T) \ + "busy", &T::busy, "error", &T::error, "error_id", &T::error_id + +/// CommandFb surface: the Execute input + the PLCopen command outputs. +#define LOOM_COMMAND_FB_FIELDS(T) \ + "execute", &T::execute, "buffer_mode", &T::buffer_mode, \ + "busy", &T::busy, "active", &T::active, "done", &T::done, \ + "command_aborted", &T::command_aborted, "error", &T::error, \ + "error_id", &T::error_id + +/// EnableFb surface: the Enable level input + common outputs. +#define LOOM_ENABLE_FB_FIELDS(T) \ + "enable", &T::enable, LOOM_IFUNCTION_BLOCK_FIELDS(T) + +template <> +struct glz::meta { + using enum loom::BufferMode; + static constexpr auto value = glz::enumerate("aborting", Aborting, "buffered", Buffered); +}; + +template <> +struct glz::meta { + using T = loom::IFunctionBlock; + static constexpr auto value = glz::object(LOOM_IFUNCTION_BLOCK_FIELDS(T)); +}; + +template <> +struct glz::meta { + using T = loom::CommandFb; + static constexpr auto value = glz::object(LOOM_COMMAND_FB_FIELDS(T)); +}; + +template <> +struct glz::meta { + using T = loom::EnableFb; + static constexpr auto value = glz::object(LOOM_ENABLE_FB_FIELDS(T)); +}; diff --git a/sdk/include/loom/module.h b/sdk/include/loom/module.h index 788c0f0..76d4d13 100644 --- a/sdk/include/loom/module.h +++ b/sdk/include/loom/module.h @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include #include "loom/types.h" +#include "loom/runtime_heap.h" #include "loom/tag_table.hpp" #include #include @@ -60,7 +62,31 @@ class IModule { virtual void postCyclic() {} virtual void exit() = 0; - virtual void longRunning() = 0; + + /// Optional background work, run repeatedly on a dedicated per-module thread + /// (independent of the cyclic schedule) — e.g. polling a slow device or + /// flushing logs. The runtime sleeps longRunningInterval() between calls. + /// + /// If you do NOT override this, the base implementation flags the module as + /// having no background work and the scheduler retires the thread after the + /// first call — so an unused longRunning() never spins a core. + virtual void longRunning() { longRunningOptedOut_ = true; } + + /// Cadence the runtime waits between longRunning() calls (default 100 ms). + /// Polled after every longRunning() call, so a module can change it at + /// runtime — e.g. return a member that longRunning() updates to back off + /// when idle or speed up when busy. If that member is also written from + /// another thread (cyclic(), a service handler), make it atomic. Also bounds + /// how long stop() waits for the thread. Ignored when longRunning() isn't + /// overridden. + virtual std::chrono::milliseconds longRunningInterval() const { + return std::chrono::milliseconds{100}; + } + + /// True once the default (non-overridden) longRunning() has run — i.e. the + /// module has no background work. Read by the scheduler to retire the + /// long-running thread; not intended for module-author use. + bool longRunningOptedOut() const { return longRunningOptedOut_; } /// Called by the scheduler instead of init() directly. The Module<> base /// opens the extension-registration window around the user's init() so that @@ -122,9 +148,65 @@ class IModule { registry_ = registry; } + // Runtime heap injection (called by runtime before init). The heap is owned + // by the resident runtime; see runtime_heap.h. + void setRuntimeHeap(IRuntimeHeap* heap) { + runtimeHeap_ = heap; + } + + /// Register a CommandChannel so consumers can submit async commands to this + /// module (looked up on the Bus by this module's id). Call once in init(); + /// it is unregistered automatically on unload. See command.h / command_client.h. + void provideCommands(CommandChannel& channel) { + if (!bus_) return; // not registered → leave providesCommands_ false + bus_->registerCommandChannel(moduleId_, &channel); + providesCommands_ = true; + } + + /// Expose a typed connection point under `name` for consumers to bind via + /// loom::PortRef / Bus::port. The object must outlive the binding; + /// unregistered automatically on unload. See port.h. + template + void providePort(const std::string& name, T& obj) { + if (!bus_) return; + if constexpr (requires { T::kTypeId; }) + bus_->registerPort(name, &obj, T::kTypeId); + else + bus_->registerPort(name, &obj, {}); + providedPorts_.push_back(name); + } + Bus* bus() const { return bus_; } const std::string& moduleId() const { return moduleId_; } + /// Resident runtime heap, used with loom::makeShared() to allocate + /// trivially-destructible cross-module shared objects that stay valid across + /// hot-reloads. May be null if the runtime predates this API. + IRuntimeHeap* runtimeHeap() const { return runtimeHeap_; } + + /// Allocate a trivially-destructible T from this module's injected runtime + /// heap — the no-argument form of loom::makeShared(*runtimeHeap(), ...). + /// Returns an empty shared_ptr if no heap was injected (call after init). + template + std::shared_ptr makeShared(Args&&... args) { + if (!runtimeHeap_) return {}; + return loom::makeShared(*runtimeHeap_, std::forward(args)...); + } + + /// Type-erased resolution of a sibling module's Runtime pointer with a + /// type-id check. Returns nullptr if the module is absent or the runtime + /// type id doesn't match. This is the non-template form of + /// Module::getRuntimeAs(), usable by header-only helpers (e.g. function + /// blocks) that hold only an IModule*. Same lifetime caveat applies: do not + /// cache the returned pointer across cyclic() calls. + void* findRuntime(std::string_view id, std::string_view typeId) { + if (!registry_) return nullptr; + auto* mod = registry_->findModule(id); + if (!mod) return nullptr; + if (!typeId.empty() && mod->runtimeTypeId() != typeId) return nullptr; + return mod->runtimePtr(); + } + // Called by runtime before unload to tear down all subscriptions made by this module. void cleanupSubscriptions() { if (!bus_) return; @@ -132,13 +214,28 @@ class IModule { bus_->unsubscribe(id); } subscriptionIds_.clear(); + if (providesCommands_) { + bus_->unregisterCommandChannel(moduleId_); + providesCommands_ = false; + } + for (const auto& name : providedPorts_) { + bus_->unregisterPort(name); + } + providedPorts_.clear(); } protected: Bus* bus_ = nullptr; IModuleRegistry* registry_ = nullptr; + IRuntimeHeap* runtimeHeap_ = nullptr; + bool providesCommands_ = false; + std::vector providedPorts_; std::vector subscriptionIds_; std::string moduleId_; + + /// Set by the base longRunning() when it isn't overridden — signals the + /// scheduler that this module has no background work. See longRunning(). + bool longRunningOptedOut_ = false; }; /// Base class for user modules. Users inherit from this with their own diff --git a/sdk/include/loom/port.h b/sdk/include/loom/port.h new file mode 100644 index 0000000..f20c437 --- /dev/null +++ b/sdk/include/loom/port.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#include "bus.h" + +// ============================================================================ +// PortRef — consumer-side handle to a typed connection point. +// +// Caches the resolved pointer and re-resolves only when the Bus port registry +// changes (provider (un)registered / hot-reloaded), so steady-state access is a +// plain pointer load. get() returns nullptr if the port is absent or its type +// id doesn't match T::kTypeId. +// ============================================================================ + +namespace loom { + +template +class PortRef { +public: + PortRef() = default; + PortRef(Bus* bus, std::string name) : bus_(bus), name_(std::move(name)) {} + + T* get() { + if (!bus_) return nullptr; + const uint64_t g = bus_->portGeneration(); + if (g != gen_) { // registry changed (or first call) — re-resolve + cached_ = bus_->port(name_); + gen_ = g; + } + return cached_; + } + + explicit operator bool() { return get() != nullptr; } + const std::string& name() const { return name_; } + +private: + Bus* bus_ = nullptr; + std::string name_; + T* cached_ = nullptr; + uint64_t gen_ = 0; // != Bus initial generation, forces first resolve +}; + +} // namespace loom diff --git a/sdk/include/loom/runtime_heap.h b/sdk/include/loom/runtime_heap.h new file mode 100644 index 0000000..61b46e5 --- /dev/null +++ b/sdk/include/loom/runtime_heap.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include +#include + +// ============================================================================ +// Runtime heap — resident allocator for cross-module shared objects +// +// Hot-reload safety problem: a std::shared_ptr's control block (its vtable) and +// deleter are instantiated wherever the shared_ptr is *constructed*. If a module +// constructs one and is later unloaded while another module / the runtime still +// holds a (weak_)ptr, the final release dispatches through a vtable in unmapped +// code → crash. For an arbitrary module-defined T the destructor is also module +// code, so it can never be safely run after the module unloads. +// +// Solution: the resident runtime hands out storage already owned by a +// shared_ptr whose control block + deleter live in the runtime binary. +// A module turns that into a typed shared_ptr via the *aliasing constructor*, +// which shares the runtime's control block rather than creating a new one. The +// resident deleter only frees bytes, so T must be trivially destructible. +// +// This is the general mechanism for any trivially-destructible type a module +// needs to share across the boundary (e.g. an async-command status struct). +// ============================================================================ + +namespace loom { + +/// Allocator owned by the resident runtime. Injected into every module before +/// init() (see Module::runtimeHeap()). +class IRuntimeHeap { +public: + virtual ~IRuntimeHeap() = default; + + /// Allocate `size` bytes aligned to `align`, owned by a shared_ptr + /// whose control block + deleter are runtime-resident. Empty on failure. + /// Prefer the makeShared() helper over calling this directly. + virtual std::shared_ptr allocate(std::size_t size, std::size_t align) = 0; +}; + +/// Construct a T in runtime-owned storage and return a typed shared_ptr that +/// shares the runtime's resident control block (via the aliasing constructor). +/// Returns an empty shared_ptr if the heap allocation fails. +/// +/// T must be trivially destructible so the resident deleter — which only frees +/// bytes — never has to run module-defined destructor code. +template +std::shared_ptr makeShared(IRuntimeHeap& heap, Args&&... args) { + static_assert(std::is_trivially_destructible_v, + "loom::makeShared requires a trivially destructible T so the resident " + "deleter needs no module code (hot-reload safety)."); + std::shared_ptr block = heap.allocate(sizeof(T), alignof(T)); + if (!block) return {}; + T* obj = ::new (block.get()) T(std::forward(args)...); + // Aliasing constructor: shares `block`'s (resident) control block; no new + // control block is created here in the module TU. + return std::shared_ptr(std::move(block), obj); +} + +} // namespace loom diff --git a/sdk/include/loom/tag_table.hpp b/sdk/include/loom/tag_table.hpp index 9fb35a8..0158e91 100644 --- a/sdk/include/loom/tag_table.hpp +++ b/sdk/include/loom/tag_table.hpp @@ -183,7 +183,14 @@ void tag_visit_field(FieldType& field, if (field.has_value()) { tag_visit_field(*field, path, cache, stale_checkers, true); // mark descendants as dynamic } - } else if constexpr (glz::reflectable && !is_string_like::value) { + } else if constexpr ((glz::reflectable || glz::glaze_object_t) + && !is_string_like::value) { + // Recurse into nested structs. The guard mirrors glz::for_each_field's + // two overloads exactly — reflectable (plain aggregates) OR glaze_object_t + // (types with a glz::meta specialization, e.g. function blocks and other + // class-based data types). Without the glaze_object_t arm, meta types fall + // through to the leaf branch below and their sub-fields are never indexed, + // so the OPC UA facade / watch tree can't see them. Tag struct_tag; struct_tag.path = path; struct_tag.type_name = glz::name_v; diff --git a/sdk/include/loom/version.h.in b/sdk/include/loom/version.h.in index 26b8690..eb6f847 100644 --- a/sdk/include/loom/version.h.in +++ b/sdk/include/loom/version.h.in @@ -7,4 +7,8 @@ namespace loom { /// to detect version mismatches at load time. inline constexpr const char* kSdkVersion = "@LOOM_SDK_VERSION@"; +/// Git commit the SDK was built from ("unknown" if git/.git was unavailable at +/// build time). Used in crash reports to map a fault back to exact source. +inline constexpr const char* kGitSha = "@LOOM_GIT_SHA@"; + } // namespace loom diff --git a/sdk/loom-config.cmake.in b/sdk/loom-config.cmake.in index 2209d70..ec04c3b 100644 --- a/sdk/loom-config.cmake.in +++ b/sdk/loom-config.cmake.in @@ -33,3 +33,8 @@ include("${CMAKE_CURRENT_LIST_DIR}/LoomSdkTargets.cmake") if(NOT TARGET loom::sdk) message(FATAL_ERROR "The imported target loom::sdk was not found") endif() + +# loom_add_module() + crash-symbolization debug-info helpers. Lets module authors +# build plugins the same way the bundled modules are, with PDB/DWARF/dSYM emitted +# next to the binary so a crashing module resolves to file:line in-process. +include("${CMAKE_CURRENT_LIST_DIR}/LoomModule.cmake") diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 66e7a58..6d48af4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,6 +5,11 @@ add_executable(loom_tests test_bus.cpp test_trace_cache.cpp test_tag_table.cpp + test_runtime_heap.cpp + test_command_integration.cpp + test_command_fb.cpp + test_port.cpp + test_diag.cpp ) target_include_directories(loom_tests PRIVATE @@ -27,6 +32,10 @@ target_sources(loom_tests PRIVATE ${CMAKE_SOURCE_DIR}/runtime/src/io_mapper.cpp ${CMAKE_SOURCE_DIR}/runtime/src/scheduler.cpp ${CMAKE_SOURCE_DIR}/runtime/src/scheduler_config.cpp + ${CMAKE_SOURCE_DIR}/runtime/src/runtime_heap.cpp + ${CMAKE_SOURCE_DIR}/runtime/src/diag/breadcrumb.cpp # scheduler.cpp references diag::tlsBreadcrumb + ${CMAKE_SOURCE_DIR}/runtime/src/diag/fault_report.cpp + ${CMAKE_SOURCE_DIR}/runtime/src/diag/fault_store.cpp # no cpptrace dep (symbolizer is header-only here) ) include(GoogleTest) @@ -34,10 +43,11 @@ gtest_discover_tests(loom_tests WORKING_DIRECTORY ${CMAKE_BINARY_DIR} ) -# Ensure example_motor is built before tests (tests load it as a plugin) -add_dependencies(loom_tests example_motor) +# Ensure the plugin modules the tests load are built first. +add_dependencies(loom_tests example_motor command_probe) -# Tell tests where modules are built +# Tell tests where modules are built and the platform plugin suffix. target_compile_definitions(loom_tests PRIVATE - LOOM_MODULE_DIR="${CMAKE_BINARY_DIR}/modules" + LOOM_MODULE_DIR="${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/modules" + LOOM_MODULE_SUFFIX="${LOOM_MODULE_SUFFIX}" ) diff --git a/tests/module_test_util.h b/tests/module_test_util.h new file mode 100644 index 0000000..3bdc705 --- /dev/null +++ b/tests/module_test_util.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace loomtest { + +/// Locate a built module plugin by class name, using the platform suffix and +/// the module output directory provided by CMake. Returns {} if not found +/// (caller should GTEST_SKIP — modules must be built first). +inline std::filesystem::path findModule(const std::string& name) { +#ifdef LOOM_MODULE_SUFFIX + const std::string suffix = LOOM_MODULE_SUFFIX; +#else + const std::string suffix = ".so"; +#endif +#ifdef LOOM_MODULE_DIR + for (const std::string& fname : {name + suffix, "lib" + name + suffix}) { + std::filesystem::path p = std::filesystem::path(LOOM_MODULE_DIR) / fname; + if (std::filesystem::exists(p)) return p; + } +#endif + return {}; +} + +} // namespace loomtest diff --git a/tests/test_bus.cpp b/tests/test_bus.cpp index 4dfdf1a..b9f1fc1 100644 --- a/tests/test_bus.cpp +++ b/tests/test_bus.cpp @@ -1,9 +1,11 @@ #include "loom/bus.h" +#include "loom/command.h" #include #include #include +#include #include #include #include @@ -255,3 +257,38 @@ TEST(BusTest, ConcurrentPublishSubscribe) { // 4 threads * 100 messages * 4 subscribers = 1600 EXPECT_EQ(totalReceived.load(), 1600); } + +// ============================================================================= +// Command Channel Registry Tests +// ============================================================================= + +TEST(BusTest, CommandChannelRegisterLookupUnregister) { + loom::Bus bus; + loom::CommandChannel ch; + + EXPECT_EQ(bus.commandChannel("axis_1"), nullptr); // not registered yet + bus.registerCommandChannel("axis_1", &ch); + EXPECT_EQ(bus.commandChannel("axis_1"), &ch); // looked up by provider id + bus.unregisterCommandChannel("axis_1"); + EXPECT_EQ(bus.commandChannel("axis_1"), nullptr); // gone after unregister +} + +TEST(BusTest, CommandChannelSubmitDrain) { + loom::CommandChannel ch; + auto status = std::make_shared(); + + ch.submit(loom::CommandSubmission{/*command*/ 42, /*target*/ 3, "{}", + loom::BufferMode::Aborting, status}); + + std::vector out; + ch.drain(out); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].command, 42u); + EXPECT_EQ(out[0].target, 3u); + + if (auto s = out[0].status.lock()) s->done.store(true); // write-through + EXPECT_TRUE(status->done.load()); + + ch.drain(out); // inbox was emptied by the previous drain + EXPECT_TRUE(out.empty()); +} diff --git a/tests/test_command_fb.cpp b/tests/test_command_fb.cpp new file mode 100644 index 0000000..7a4d8ca --- /dev/null +++ b/tests/test_command_fb.cpp @@ -0,0 +1,183 @@ +#include + +#include "loom/command.h" + +#include +#include + +// ============================================================================ +// loom::CommandFb PLCopen output semantics: +// - exactly one of {busy, done, command_aborted, error} at a time; +// - dropping Execute does not cancel — stays Busy until the command finishes; +// - a terminal result is held while Execute is high and is visible for >= 1 +// cycle even if Execute is already low; resets when Execute is low. +// Plus a write-through round trip through a CommandChannel. +// ============================================================================ + +namespace { + +using namespace loom; + +/// Test FB: submits the injected `next` status on the Execute rising edge, +/// mimicking a domain FB's update(). Exposes the protected CommandFb plumbing. +struct PokeFb : CommandFb { + std::shared_ptr next; // "allocated" status to submit on rising edge + void tick() { + const bool r = rising(); + commit(r, r ? next : nullptr); + } + void update() override { tick(); } // satisfy IFunctionBlock +}; + +/// Count of the mutually-exclusive busy/terminal outputs (active is separate). +int hot(const CommandFb& f) { + return int(f.busy) + int(f.done) + int(f.command_aborted) + int(f.error); +} + +// --- 1.1: exactly one of busy/done/aborted/error at each phase --------------- +TEST(CommandFb, ExactlyOneOutputPerPhase) { + PokeFb fb; + fb.next = std::make_shared(); + + fb.execute = true; fb.tick(); // accepted + EXPECT_TRUE(fb.busy); + EXPECT_EQ(hot(fb), 1); + + fb.next->phase.store(CmdPhase::Active); fb.tick(); // active (busy + active) + EXPECT_TRUE(fb.busy); + EXPECT_TRUE(fb.active); + EXPECT_EQ(hot(fb), 1); + + fb.next->phase.store(CmdPhase::Done); fb.tick(); // done only + EXPECT_TRUE(fb.done); + EXPECT_FALSE(fb.busy); + EXPECT_EQ(hot(fb), 1); +} + +// --- 1.2: dropping Execute keeps the block Busy until completion ------------- +TEST(CommandFb, StaysBusyAfterExecuteDropped) { + PokeFb fb; + fb.next = std::make_shared(); + + fb.execute = true; fb.tick(); + fb.next->phase.store(CmdPhase::Active); fb.tick(); + ASSERT_TRUE(fb.busy); + + fb.execute = false; fb.tick(); // Execute dropped mid-flight + EXPECT_TRUE(fb.busy); // still busy (command not cancelled) + EXPECT_FALSE(fb.done); +} + +// --- 1.3: terminal visible >= 1 cycle even if Execute already low ------------ +TEST(CommandFb, TerminalHeldOneCycleThenResetWhenExecuteLow) { + PokeFb fb; + fb.next = std::make_shared(); + + fb.execute = true; fb.tick(); + fb.execute = false; fb.tick(); // dropped while running + fb.next->phase.store(CmdPhase::Done); fb.tick(); // completes with Execute low + EXPECT_TRUE(fb.done); // surfaced for this cycle + EXPECT_EQ(hot(fb), 1); + + fb.tick(); // next cycle, Execute still low + EXPECT_EQ(hot(fb), 0); // now reset +} + +// --- 1.3: terminal held as long as Execute stays high ------------------------ +TEST(CommandFb, DoneHeldWhileExecuteHigh) { + PokeFb fb; + fb.next = std::make_shared(); + + fb.execute = true; fb.tick(); + fb.next->phase.store(CmdPhase::Done); fb.tick(); + EXPECT_TRUE(fb.done); + fb.tick(); fb.tick(); + EXPECT_TRUE(fb.done); // still held (Execute high) + + fb.execute = false; fb.tick(); + EXPECT_FALSE(fb.done); // resets only when Execute low +} + +// --- aborted / error surface as the single terminal output ------------------- +TEST(CommandFb, AbortSurfaces) { + PokeFb fb; + fb.next = std::make_shared(); + fb.execute = true; fb.tick(); + fb.next->phase.store(CmdPhase::Aborted); fb.tick(); + EXPECT_TRUE(fb.command_aborted); + EXPECT_EQ(hot(fb), 1); +} + +TEST(CommandFb, SubmitFailureErrors) { + PokeFb fb; + fb.next = nullptr; // submit returned no status cell + fb.execute = true; fb.tick(); + EXPECT_TRUE(fb.error); + EXPECT_EQ(fb.error_id, kErrSubmitFailed); + EXPECT_EQ(hot(fb), 1); +} + +// --- a rising Execute starts a new command, superseding a held result -------- +TEST(CommandFb, RisingExecuteSupersedesHeldResult) { + PokeFb fb; + fb.next = std::make_shared(); + fb.execute = true; fb.tick(); + fb.next->phase.store(CmdPhase::Done); fb.tick(); + ASSERT_TRUE(fb.done); + + fb.execute = false; fb.tick(); // back to idle + fb.next = std::make_shared(); + fb.execute = true; fb.tick(); // new command + EXPECT_TRUE(fb.busy); + EXPECT_FALSE(fb.done); +} + +// --- write-through round trip through a CommandChannel ----------------------- +TEST(CommandRoundTrip, ProviderCompletesThroughChannel) { + CommandChannel ch; + + struct ChFb : CommandFb { + CommandChannel* ch = nullptr; + void tick() { + const bool r = rising(); + std::shared_ptr s; + if (r) { + s = std::make_shared(); + ch->submit(CommandSubmission{1, 0, "", buffer_mode, s}); + } + commit(r, s); + } + void update() override { tick(); } // satisfy IFunctionBlock + }; + ChFb fb; fb.ch = &ch; + + std::vector drained; + std::weak_ptr active; + + // A minimal provider: drain, mark Active, optionally complete (write-through). + auto provider = [&](bool complete) { + ch.drain(drained); + for (auto& s : drained) { + if (auto p = s.status.lock()) p->phase.store(CmdPhase::Active); + active = s.status; + } + if (complete) { + if (auto p = active.lock()) p->phase.store(CmdPhase::Done); + } + }; + + fb.execute = true; fb.tick(); // FB submits → Busy + EXPECT_TRUE(fb.busy); + + provider(false); // provider receives + marks Active + fb.tick(); + EXPECT_TRUE(fb.busy); + EXPECT_TRUE(fb.active); + + provider(true); // provider completes + fb.tick(); + EXPECT_TRUE(fb.done); // FB observes completion via write-through + EXPECT_FALSE(fb.busy); +} + +} // namespace diff --git a/tests/test_command_integration.cpp b/tests/test_command_integration.cpp new file mode 100644 index 0000000..c3eee75 --- /dev/null +++ b/tests/test_command_integration.cpp @@ -0,0 +1,90 @@ +#include + +#include "loom/bus.h" +#include "loom/command.h" +#include "loom/module_loader.h" +#include "loom/runtime_heap_impl.h" +#include "module_test_util.h" + +#include + +// ============================================================================ +// End-to-end integration of the async-command primitive across a real loaded +// module boundary: load CommandProbe, inject bus + runtime heap (as the runtime +// does), submit a command, verify write-through, then verify channel cleanup on +// unload and that a heap-backed status cell survives the module's unload. +// ============================================================================ + +namespace { + +class CommandIntegrationTest : public ::testing::Test { +protected: + loom::ModuleLoader loader; + loom::Bus bus; + loom::RuntimeHeap heap; +}; + +TEST_F(CommandIntegrationTest, CommandRoundTripAcrossModuleBoundary) { + auto path = loomtest::findModule("command_probe"); + if (path.empty()) GTEST_SKIP() << "command_probe not built"; + + const auto id = loader.load(path); + ASSERT_FALSE(id.empty()); + auto* mod = loader.get(id); + ASSERT_NE(mod, nullptr); + + // Wire dependencies exactly as RuntimeCore::startModule does. + mod->instance->setBus(&bus, id); + mod->instance->setRuntimeHeap(&heap); + mod->instance->initGuarded(loom::InitContext{}); + auto* channel = bus.commandChannel(id); + ASSERT_NE(channel, nullptr); + + // Act as a consumer: allocate a status cell from the (resident) heap and + // submit a command to the provider's channel. + auto status = loom::makeShared(heap); + ASSERT_NE(status, nullptr); + channel->submit(loom::CommandSubmission{/*command*/ 7, /*target*/ 2, "{}", + loom::BufferMode::Aborting, status}); + + EXPECT_FALSE(status->done.load()); + mod->instance->cyclic(); // provider drains + writes status through weak_ptr + EXPECT_TRUE(status->done.load()); + EXPECT_EQ(status->phase.load(), loom::CmdPhase::Done); + EXPECT_DOUBLE_EQ(status->progress.load(), 1.0); + + // Teardown order from RuntimeCore: cleanup (unregisters the channel) then unload. + mod->instance->cleanupSubscriptions(); + EXPECT_EQ(bus.commandChannel(id), nullptr); // channel gone after cleanup + + ASSERT_TRUE(loader.unload(id)); + EXPECT_EQ(loader.get(id), nullptr); + + // The status cell was allocated by the resident heap, so it remains valid + // after the module is unloaded, and frees through the resident deleter. + EXPECT_TRUE(status->done.load()); + status.reset(); // must not crash (deleter is resident) + SUCCEED(); +} + +TEST_F(CommandIntegrationTest, ChannelUnavailableAfterUnload) { + auto path = loomtest::findModule("command_probe"); + if (path.empty()) GTEST_SKIP() << "command_probe not built"; + + const auto id = loader.load(path); + ASSERT_FALSE(id.empty()); + auto* mod = loader.get(id); + ASSERT_NE(mod, nullptr); + mod->instance->setBus(&bus, id); + mod->instance->setRuntimeHeap(&heap); + mod->instance->initGuarded(loom::InitContext{}); + ASSERT_NE(bus.commandChannel(id), nullptr); + + mod->instance->cleanupSubscriptions(); + loader.unload(id); + + // A late consumer lookup fails cleanly rather than returning a dangling ptr. + EXPECT_EQ(bus.commandChannel(id), nullptr); +} + +} // namespace diff --git a/tests/test_diag.cpp b/tests/test_diag.cpp new file mode 100644 index 0000000..b81f5da --- /dev/null +++ b/tests/test_diag.cpp @@ -0,0 +1,192 @@ +#include "loom/diag/breadcrumb.h" +#include "loom/diag/guard.h" +#include "loom/diag/fault_report.h" +#include "loom/diag/fault_store.h" + +#include +#include + +#include +#include +#include +#include +#include + +using namespace loom::diag; + +// --- BreadcrumbScope ------------------------------------------------------- + +TEST(DiagBreadcrumb, SetsAndRestores) { + EXPECT_EQ(tlsBreadcrumb.phase, Phase::None); + { + BreadcrumbScope s(Phase::Cyclic, "mod_a", "ClassA"); + EXPECT_EQ(tlsBreadcrumb.phase, Phase::Cyclic); + EXPECT_STREQ(tlsBreadcrumb.moduleId, "mod_a"); + EXPECT_STREQ(tlsBreadcrumb.className, "ClassA"); + } + EXPECT_EQ(tlsBreadcrumb.phase, Phase::None); // restored +} + +TEST(DiagBreadcrumb, NestedRestores) { + BreadcrumbScope outer(Phase::Cyclic, "mod_a", "A"); + { + BreadcrumbScope inner(Phase::Service, "mod_b", "B"); + EXPECT_EQ(tlsBreadcrumb.phase, Phase::Service); + EXPECT_STREQ(tlsBreadcrumb.moduleId, "mod_b"); + } + EXPECT_EQ(tlsBreadcrumb.phase, Phase::Cyclic); // back to outer + EXPECT_STREQ(tlsBreadcrumb.moduleId, "mod_a"); +} + +// --- guard ----------------------------------------------------------------- + +TEST(DiagGuard, NoThrowReturnsTrueNoFault) { + bool faulted = false; + bool ran = false; + bool ok = guard(Phase::Cyclic, "m", "C", + [&]{ ran = true; }, + [&](const FaultInfo&){ faulted = true; }); + EXPECT_TRUE(ok); + EXPECT_TRUE(ran); + EXPECT_FALSE(faulted); + EXPECT_EQ(tlsBreadcrumb.phase, Phase::None); // breadcrumb restored +} + +TEST(DiagGuard, StdExceptionCaughtReported) { + std::string msg; + Phase capturedPhase = Phase::None; + bool ok = guard(Phase::PreCyclic, "m", "C", + [&]{ throw std::runtime_error("boom"); }, + [&](const FaultInfo& f){ msg = std::string(f.message); capturedPhase = f.phase; }); + EXPECT_FALSE(ok); + EXPECT_EQ(msg, "boom"); + EXPECT_EQ(capturedPhase, Phase::PreCyclic); + EXPECT_EQ(tlsBreadcrumb.phase, Phase::None); // restored even on throw +} + +TEST(DiagGuard, NonStdThrowCaught) { + bool faulted = false; + bool ok = guard(Phase::Cyclic, "m", "C", + [&]{ throw 42; }, // non-std exception + [&](const FaultInfo&){ faulted = true; }); + EXPECT_FALSE(ok); + EXPECT_TRUE(faulted); +} + +// --- FaultReport JSON ------------------------------------------------------ + +static FaultReport sampleReport() { + FaultReport r; + r.id = "mod_a-123-0"; + r.tsMs = 123; + r.kind = FaultKind::Exception; + r.reason = "boom \"quoted\"\nnewline"; // exercise escaping + r.sdkVersion = "0.3.0"; + r.gitSha = "abc123"; + r.buildType = "Debug"; + r.moduleId = "mod_a"; + r.className = "ClassA"; + r.phase = Phase::Cyclic; + r.cycle = 7; + r.frames.push_back(SymFrame{0xdead, "foo()", "foo.cpp", 42}); + FaultSections s; + s.runtime = R"({"pos":1.5})"; + r.sections = s; + return r; +} + +TEST(DiagFaultReport, ToJsonIsParseableAndPreservesFields) { + std::string json = toJson(sampleReport()); + + glz::json_t doc; + ASSERT_FALSE(glz::read_json(doc, json)) << "report JSON must parse"; + ASSERT_TRUE(doc.is_object()); + + EXPECT_EQ(doc["id"].get(), "mod_a-123-0"); + EXPECT_EQ(doc["kind"].get(), "exception"); + EXPECT_EQ(doc["reason"].get(), "boom \"quoted\"\nnewline"); // round-trips escaping + EXPECT_EQ(doc["breadcrumb"]["module"].get(), "mod_a"); + EXPECT_EQ(doc["breadcrumb"]["phase"].get(), "cyclic"); + EXPECT_EQ(static_cast(doc["breadcrumb"]["cycle"].get()), 7); + ASSERT_TRUE(doc["frames"].is_array()); + EXPECT_EQ(doc["frames"][0]["function"].get(), "foo()"); + // sections embed as real nested JSON, not an escaped string + ASSERT_TRUE(doc["sections"]["runtime"].is_object()); + EXPECT_EQ(static_cast(doc["sections"]["runtime"]["pos"].get()), 1.5); +} + +// --- FaultStore ------------------------------------------------------------ + +TEST(DiagFaultStore, RecordListDetailRoundTrip) { + auto dir = std::filesystem::temp_directory_path() / + ("loom_faults_" + std::to_string(::testing::UnitTest::GetInstance()->random_seed())); + std::filesystem::remove_all(dir); + + FaultStore store(dir); + EXPECT_TRUE(store.list().empty()); + + std::string id = store.record(sampleReport()); + EXPECT_EQ(id, "mod_a-123-0"); + + auto list = store.list(); + ASSERT_EQ(list.size(), 1u); + EXPECT_EQ(list[0].id, "mod_a-123-0"); + EXPECT_EQ(list[0].kind, "exception"); + EXPECT_EQ(list[0].moduleId, "mod_a"); + EXPECT_EQ(list[0].phase, "cyclic"); + + auto detail = store.detailJson(id); + ASSERT_TRUE(detail.has_value()); + EXPECT_NE(detail->find("\"boom"), std::string::npos); + + EXPECT_FALSE(store.detailJson("nope").has_value()); + + // A fresh store over the same dir re-loads the persisted report. + FaultStore reopened(dir); + ASSERT_EQ(reopened.list().size(), 1u); + EXPECT_EQ(reopened.list()[0].id, "mod_a-123-0"); + + std::filesystem::remove_all(dir); +} + +// The POSIX crash handler writes a raw loom-crash-.txt fallback AND a +// best-effort structured loom-crash-.json. When both exist for the same +// crash, the store must surface only the structured one. +TEST(DiagFaultStore, JsonSupersedesRawTxtSibling) { + auto dir = std::filesystem::temp_directory_path() / + ("loom_faults_super_" + + std::to_string(::testing::UnitTest::GetInstance()->random_seed())); + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + + auto write = [&](const std::string& name, const std::string& content) { + std::ofstream f(dir / name, std::ios::binary | std::ios::trunc); + f << content; + }; + + // Crash A: both .txt and .json present -> json wins. + FaultReport a = sampleReport(); + a.id = "loom-crash-111"; + a.kind = FaultKind::Signal; + write("loom-crash-111.txt", "=== raw ===\n"); + write("loom-crash-111.json", toJson(a)); + // Crash B: only the raw .txt survived (structured pass failed) -> raw shown. + write("loom-crash-222.txt", "=== raw ===\n"); + + FaultStore store(dir); + auto list = store.list(); + ASSERT_EQ(list.size(), 2u); + + auto byId = [&](const std::string& id) { + return std::find_if(list.begin(), list.end(), + [&](const auto& s) { return s.id == id; }); + }; + auto a_it = byId("loom-crash-111"); + auto b_it = byId("loom-crash-222"); + ASSERT_NE(a_it, list.end()); + ASSERT_NE(b_it, list.end()); + EXPECT_EQ(a_it->kind, "signal"); // structured json, not the raw txt + EXPECT_EQ(b_it->kind, "raw"); // only the txt existed + + std::filesystem::remove_all(dir); +} diff --git a/tests/test_module_loader.cpp b/tests/test_module_loader.cpp index 3530411..6a632db 100644 --- a/tests/test_module_loader.cpp +++ b/tests/test_module_loader.cpp @@ -1,38 +1,15 @@ #include #include "loom/module_loader.h" +#include "module_test_util.h" #include namespace { -// Helper to find the example_motor.so in the build directory +// Locate example_motor using the platform suffix + CMake-provided module dir. std::filesystem::path findExampleMotor() { - // Use compile-time path from CMake if available -#ifdef LOOM_MODULE_DIR - auto p = std::filesystem::path(LOOM_MODULE_DIR) / "example_motor.so"; - if (std::filesystem::exists(p)) return p; -#endif - - // Fallback: look relative to working directory - std::filesystem::path candidates[] = { - "modules/example_motor.so", - "../modules/example_motor.so", - "../../modules/example_motor.so", - }; - - // Also try via environment variable or CMake-provided path - for (auto& p : candidates) { - if (std::filesystem::exists(p)) return p; - } - - // Try from LOOM_MODULE_DIR env - if (auto* dir = std::getenv("LOOM_MODULE_DIR")) { - auto p = std::filesystem::path(dir) / "example_motor.so"; - if (std::filesystem::exists(p)) return p; - } - - return {}; + return loomtest::findModule("example_motor"); } class ModuleLoaderTest : public ::testing::Test { @@ -93,6 +70,34 @@ TEST_F(ModuleLoaderTest, UnloadNonExistent) { EXPECT_FALSE(loader.unload("DoesNotExist")); } +TEST_F(ModuleLoaderTest, ReloadModule) { + auto path = findExampleMotor(); + if (path.empty()) { + GTEST_SKIP() << "example_motor not found — build modules first"; + } + + auto id = loader.load(path); + ASSERT_FALSE(id.empty()); + auto* before = loader.get(id); + ASSERT_NE(before, nullptr); + ASSERT_NE(before->instance, nullptr); + + // Warm-restart: unload + reload from the same path. + EXPECT_TRUE(loader.reload(id)); + + auto* after = loader.get(id); + ASSERT_NE(after, nullptr); + EXPECT_EQ(after->id, id); + EXPECT_EQ(after->state, loom::ModuleState::Loaded); + ASSERT_NE(after->instance, nullptr); // a fresh instance is in place + + // The reloaded module is still usable. Use initGuarded() (the entry the + // runtime calls) so registerExtension() is valid during init(). + after->instance->initGuarded(loom::InitContext{}); + after->instance->cyclic(); + after->instance->exit(); +} + TEST_F(ModuleLoaderTest, ModuleLifecycle) { auto path = findExampleMotor(); if (path.empty()) { @@ -105,8 +110,10 @@ TEST_F(ModuleLoaderTest, ModuleLifecycle) { auto* mod = loader.get(id); ASSERT_NE(mod, nullptr); - // Test init - mod->instance->init(loom::InitContext{}); + // Test init — initGuarded() is the runtime's entry (opens the + // registerExtension window); calling bare init() would throw for modules + // that register extensions. + mod->instance->initGuarded(loom::InitContext{}); // Test cyclic mod->instance->cyclic(); diff --git a/tests/test_port.cpp b/tests/test_port.cpp new file mode 100644 index 0000000..93dc9bd --- /dev/null +++ b/tests/test_port.cpp @@ -0,0 +1,58 @@ +#include + +#include "loom/bus.h" +#include "loom/port.h" + +#include + +namespace { + +using namespace loom; + +struct Widget { static constexpr std::string_view kTypeId = "Widget/1"; int x = 0; }; +struct Other { static constexpr std::string_view kTypeId = "Other/1"; int y = 0; }; + +TEST(Port, RegisterResolveTyped) { + Bus bus; Widget w; w.x = 42; + bus.registerPort("a/0", &w, Widget::kTypeId); + Widget* p = bus.port("a/0"); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->x, 42); +} + +TEST(Port, TypeMismatchReturnsNull) { + Bus bus; Widget w; + bus.registerPort("a/0", &w, Widget::kTypeId); + EXPECT_EQ(bus.port("a/0"), nullptr); // wrong type id → null +} + +TEST(Port, MissingReturnsNull) { + Bus bus; + EXPECT_EQ(bus.port("none"), nullptr); +} + +TEST(Port, UnregisterRemoves) { + Bus bus; Widget w; + bus.registerPort("a/0", &w, Widget::kTypeId); + bus.unregisterPort("a/0"); + EXPECT_EQ(bus.port("a/0"), nullptr); +} + +TEST(Port, RefCachesAndReresolvesOnGeneration) { + Bus bus; Widget w1; w1.x = 1; Widget w2; w2.x = 2; + PortRef ref(&bus, "a/0"); + EXPECT_EQ(ref.get(), nullptr); // not registered yet + + bus.registerPort("a/0", &w1, Widget::kTypeId); + ASSERT_NE(ref.get(), nullptr); + EXPECT_EQ(ref.get()->x, 1); + + bus.registerPort("a/0", &w2, Widget::kTypeId); // provider re-registered (e.g. reload) + ASSERT_NE(ref.get(), nullptr); + EXPECT_EQ(ref.get()->x, 2); // ref re-resolved on generation bump + + bus.unregisterPort("a/0"); + EXPECT_EQ(ref.get(), nullptr); +} + +} // namespace diff --git a/tests/test_runtime_heap.cpp b/tests/test_runtime_heap.cpp new file mode 100644 index 0000000..e49cc90 --- /dev/null +++ b/tests/test_runtime_heap.cpp @@ -0,0 +1,180 @@ +#include "loom/runtime_heap.h" // IRuntimeHeap, loom::makeShared +#include "loom/runtime_heap_impl.h" // loom::RuntimeHeap (concrete, runtime-owned) +#include "loom/command.h" // loom::CommandStatus (a real makeShared user) + +#include + +#include +#include +#include +#include +#include +#include +#include + +// The whole design depends on this: makeShared targets are trivially +// destructible, so the resident deleter never runs module-defined code. +static_assert(std::is_trivially_destructible_v); + +namespace { + +struct Pod { int a = 1; double b = 2.0; }; + +struct WithCtor { + int v; + explicit WithCtor(int x) : v(x) {} // trivial dtor → still ok for makeShared +}; + +/// Test double that counts allocate/free and records the requested size/align. +struct CountingHeap : loom::IRuntimeHeap { + int allocs = 0; + int frees = 0; + std::size_t last_size = 0; + std::size_t last_align = 0; + + std::shared_ptr allocate(std::size_t size, std::size_t align) override { + ++allocs; + last_size = size; + last_align = align; + const std::align_val_t a{align}; + void* p = ::operator new(size, a); + return std::shared_ptr(p, [this, a](void* q) { + ++frees; + ::operator delete(q, a); + }); + } +}; + +/// Always fails to allocate (simulates an exhausted/failed heap). +struct NullHeap : loom::IRuntimeHeap { + std::shared_ptr allocate(std::size_t, std::size_t) override { return {}; } +}; + +} // namespace + +// ============================================================================= +// Concrete RuntimeHeap +// ============================================================================= + +TEST(RuntimeHeapTest, AllocateReturnsAlignedStorage) { + loom::RuntimeHeap heap; + auto block = heap.allocate(128, 64); + ASSERT_NE(block, nullptr); + EXPECT_EQ(reinterpret_cast(block.get()) % 64u, 0u); + EXPECT_EQ(block.use_count(), 1); +} + +TEST(RuntimeHeapTest, MakeSharedDefaultConstructs) { + loom::RuntimeHeap heap; + auto p = loom::makeShared(heap); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->a, 1); + EXPECT_EQ(p->b, 2.0); +} + +TEST(RuntimeHeapTest, MakeSharedForwardsArgs) { + loom::RuntimeHeap heap; + auto i = loom::makeShared(heap, 42); + ASSERT_NE(i, nullptr); + EXPECT_EQ(*i, 42); + + auto c = loom::makeShared(heap, 9); + ASSERT_NE(c, nullptr); + EXPECT_EQ(c->v, 9); +} + +// ============================================================================= +// weak_ptr as the generational handle (the hot-reload-safety property) +// ============================================================================= + +TEST(RuntimeHeapTest, WeakPtrExpiresWhenSharedDropped) { + loom::RuntimeHeap heap; + auto sp = loom::makeShared(heap, 5); + std::weak_ptr wp = sp; + + EXPECT_FALSE(wp.expired()); + ASSERT_NE(wp.lock(), nullptr); + EXPECT_EQ(*wp.lock(), 5); + + sp.reset(); // issuer drops its ref → observer's handle goes stale + EXPECT_TRUE(wp.expired()); + EXPECT_EQ(wp.lock(), nullptr); +} + +TEST(RuntimeHeapTest, WriteThroughWeakPtr) { + // The CommandStatus pattern: issuer holds shared_ptr, executor holds weak. + loom::RuntimeHeap heap; + auto issuer = loom::makeShared(heap); + std::weak_ptr executor = issuer; + + if (auto s = executor.lock()) { // executor writes status THROUGH the weak ref + s->phase.store(loom::CmdPhase::Done); + s->done.store(true); + } + EXPECT_TRUE(issuer->done.load()); + EXPECT_EQ(issuer->phase.load(), loom::CmdPhase::Done); + + issuer.reset(); // issuer gone → executor write becomes a no-op + EXPECT_TRUE(executor.expired()); + EXPECT_EQ(executor.lock(), nullptr); +} + +// ============================================================================= +// makeShared contract against a test double +// ============================================================================= + +TEST(RuntimeHeapTest, MakeSharedUsesHeapWithCorrectSizeAndFrees) { + CountingHeap heap; + { + auto p = loom::makeShared(heap); + ASSERT_NE(p, nullptr); + EXPECT_EQ(heap.allocs, 1); + EXPECT_EQ(heap.last_size, sizeof(Pod)); + EXPECT_EQ(heap.last_align, alignof(Pod)); + EXPECT_EQ(heap.frees, 0); // still held + } + EXPECT_EQ(heap.frees, 1); // released via the heap's deleter on last drop +} + +TEST(RuntimeHeapTest, StorageOutlivesIssuerWhileObserverHoldsWeak) { + // The object's storage is reclaimed when the last *shared* ref drops, even + // if a weak_ptr is still outstanding (the control block lingers, the bytes + // are freed). Verifies the free happens at strong-count zero. + CountingHeap heap; + std::weak_ptr w; + { + auto sp = loom::makeShared(heap); + w = sp; + EXPECT_EQ(heap.frees, 0); + } + EXPECT_EQ(heap.frees, 1); + EXPECT_TRUE(w.expired()); +} + +TEST(RuntimeHeapTest, MakeSharedReturnsNullWhenHeapFails) { + NullHeap heap; + auto p = loom::makeShared(heap, 1); + EXPECT_EQ(p, nullptr); +} + +// ============================================================================= +// Thread-safety smoke test (operator new/delete; no shared state in the heap) +// ============================================================================= + +TEST(RuntimeHeapTest, ConcurrentAllocateAndRelease) { + loom::RuntimeHeap heap; + std::atomic ok{0}; + + std::vector threads; + for (int t = 0; t < 8; ++t) { + threads.emplace_back([&] { + for (int i = 0; i < 1000; ++i) { + auto p = loom::makeShared(heap); + if (p && p->a == 1) ++ok; // construct + read, then drop (frees) + } + }); + } + for (auto& th : threads) th.join(); + + EXPECT_EQ(ok.load(), 8 * 1000); +} diff --git a/tests/test_tag_table.cpp b/tests/test_tag_table.cpp index 8497b8e..9319ecc 100644 --- a/tests/test_tag_table.cpp +++ b/tests/test_tag_table.cpp @@ -36,6 +36,28 @@ class TagTableTest : public ::testing::Test { loom::TagTable table{root}; }; +// --------------------------------------------------------------------------- +// Class-based "meta data type": a non-aggregate class (has a constructor) with +// an explicit glz::meta — exactly how the function-block / class_based modules +// expose their fields. glz::reflectable is false here; only +// glz::glaze_object_t is true, which is precisely the case the tag table used +// to miss. +// --------------------------------------------------------------------------- +struct MetaLeaf { + int a; + double b; + MetaLeaf() : a(5), b(6.5) {} +}; +template <> struct glz::meta { + using T = MetaLeaf; + static constexpr auto value = glz::object("a", &T::a, "b", &T::b); +}; + +struct MetaRoot { + int top = 1; + MetaLeaf fb = {}; // nested meta type +}; + // --------------------------------------------------------------------------- // Scalar & nested struct // --------------------------------------------------------------------------- @@ -63,6 +85,30 @@ TEST_F(TagTableTest, NestedStructFields) { EXPECT_EQ(*table.read_json("nested/x"), "9.9"); } +// Regression: a nested glz::meta (glaze_object_t) type must have its sub-fields +// indexed, not be treated as an opaque leaf. Before the recursion-guard fix the +// "fb/a" / "fb/b" paths were missing, so the OPC UA facade / watch tree couldn't +// see fields of class-based / function-block data types. +TEST(TagTableMetaTest, NestedMetaTypeFieldsIndexed) { + MetaRoot root{}; + loom::TagTable table{root}; + + EXPECT_TRUE(table.contains("top")); // plain top-level field + EXPECT_TRUE(table.contains("fb")); // the meta object itself + + // The bug: these sub-paths used to be absent. + ASSERT_TRUE(table.contains("fb/a")); + ASSERT_TRUE(table.contains("fb/b")); + EXPECT_EQ(*table.read_json("fb/a"), "5"); + + // ptr resolves into the live nested object and tracks mutations. + auto* pa = static_cast(table.ptr("fb/a")); + ASSERT_NE(pa, nullptr); + EXPECT_EQ(pa, &root.fb.a); + root.fb.a = 77; + EXPECT_EQ(*table.read_json("fb/a"), "77"); +} + // --------------------------------------------------------------------------- // std::vector // --------------------------------------------------------------------------- diff --git a/tests/test_trace_cache.cpp b/tests/test_trace_cache.cpp index c59002b..2ed54d1 100644 --- a/tests/test_trace_cache.cpp +++ b/tests/test_trace_cache.cpp @@ -37,8 +37,9 @@ TEST_F(TraceCacheTest, ExampleMotorRuntimeFieldsCached) { auto* mod = loader.get(id); ASSERT_NE(mod, nullptr); - // initialize to populate runtime defaults - mod->instance->init(loom::InitContext{}); + // initialize to populate runtime defaults (initGuarded opens the + // registerExtension() window; direct init() would throw) + mod->instance->initGuarded(loom::InitContext{}); std::vector fields = {"current_speed","position","at_speed","cycle_count", "history", "history/0/current_speed", "history/0/position", "history/0/at_speed", "history/1/current_speed", "history/1/position", "history/1/at_speed"}; for (auto& f : fields) {