From e2ac2ccf67a05095c5deadd670c001da1c544445 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 16 Jun 2026 13:32:22 -0700 Subject: [PATCH 01/62] feat: implement runtime heap for cross-module shared object allocation --- runtime/CMakeLists.txt | 1 + runtime/include/loom/runtime_core.h | 2 + runtime/include/loom/runtime_heap_impl.h | 23 +++++++++ runtime/src/runtime_core.cpp | 1 + runtime/src/runtime_heap.cpp | 17 +++++++ sdk/include/loom/module.h | 36 ++++++++++++++ sdk/include/loom/runtime_heap.h | 62 ++++++++++++++++++++++++ 7 files changed, 142 insertions(+) create mode 100644 runtime/include/loom/runtime_heap_impl.h create mode 100644 runtime/src/runtime_heap.cpp create mode 100644 sdk/include/loom/runtime_heap.h diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index b6e08d8..bd938cd 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -25,6 +25,7 @@ endif() add_library(loom_runtime STATIC src/run.cpp src/runtime_core.cpp + src/runtime_heap.cpp src/module_loader.cpp src/scheduler.cpp src/scheduler_config.cpp diff --git a/runtime/include/loom/runtime_core.h b/runtime/include/loom/runtime_core.h index 6c10468..356cf6a 100644 --- a/runtime/include/loom/runtime_core.h +++ b/runtime/include/loom/runtime_core.h @@ -9,6 +9,7 @@ #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" @@ -130,6 +131,7 @@ class RuntimeCore : public IModuleRegistry { Oscilloscope oscilloscope_; IOMapper ioMapper_; ModuleWatcher watcher_; + RuntimeHeap runtimeHeap_; 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/src/runtime_core.cpp b/runtime/src/runtime_core.cpp index 8804b20..07d3b78 100644 --- a/runtime/src/runtime_core.cpp +++ b/runtime/src/runtime_core.cpp @@ -308,6 +308,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()); diff --git a/runtime/src/runtime_heap.cpp b/runtime/src/runtime_heap.cpp new file mode 100644 index 0000000..0323e04 --- /dev/null +++ b/runtime/src/runtime_heap.cpp @@ -0,0 +1,17 @@ +#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 = ::operator new(size, a); + // 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/sdk/include/loom/module.h b/sdk/include/loom/module.h index dfddb51..0bd0bf4 100644 --- a/sdk/include/loom/module.h +++ b/sdk/include/loom/module.h @@ -4,6 +4,7 @@ #include #include #include "loom/types.h" +#include "loom/runtime_heap.h" #include "loom/tag_table.hpp" #include #include @@ -116,9 +117,43 @@ 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; + } + 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; @@ -131,6 +166,7 @@ class IModule { protected: Bus* bus_ = nullptr; IModuleRegistry* registry_ = nullptr; + IRuntimeHeap* runtimeHeap_ = nullptr; std::vector subscriptionIds_; std::string moduleId_; }; 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 From de1ff5e806f9d4711955f4ccd690a14eab1328d3 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 16 Jun 2026 14:23:02 -0700 Subject: [PATCH 02/62] feat: add async command handling with CommandChannel and CommandClient --- sdk/include/loom/bus.h | 28 ++++++ sdk/include/loom/command.h | 146 ++++++++++++++++++++++++++++++ sdk/include/loom/command_client.h | 54 +++++++++++ sdk/include/loom/module.h | 13 +++ 4 files changed, 241 insertions(+) create mode 100644 sdk/include/loom/command.h create mode 100644 sdk/include/loom/command_client.h diff --git a/sdk/include/loom/bus.h b/sdk/include/loom/bus.h index b8fb2ca..0314893 100644 --- a/sdk/include/loom/bus.h +++ b/sdk/include/loom/bus.h @@ -12,6 +12,8 @@ namespace loom { +class CommandChannel; // loom/command.h — registered here, looked up by consumers + /// Result of a service call. struct CallResult { bool ok = false; @@ -158,6 +160,29 @@ 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; + } + // ========================================================================= // Introspection // ========================================================================= @@ -212,6 +237,9 @@ class Bus { mutable std::mutex serviceMutex_; std::unordered_map serviceHandlers_; std::unordered_map serviceSchemas_; + + mutable std::mutex commandMutex_; + std::unordered_map commandChannels_; }; } // namespace loom diff --git a/sdk/include/loom/command.h b/sdk/include/loom/command.h new file mode 100644 index 0000000..ccb8c57 --- /dev/null +++ b/sdk/include/loom/command.h @@ -0,0 +1,146 @@ +#pragma once + +#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. Trivially destructible → lives in runtime-heap +/// storage (see runtime_heap.h). +struct CommandStatus { + CmdPhase phase = CmdPhase::None; + bool busy = false; + bool active = false; + bool done = false; + bool aborted = false; + bool error = false; + uint32_t error_id = 0; + double 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_; +}; + +/// 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: + bool execute = false; + BufferMode buffer_mode = BufferMode::Aborting; + + bool busy = false; + bool active = false; + bool done = false; + bool command_aborted = false; + bool error = false; + uint32_t error_id = 0; + +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. + void commit(bool rising_edge, std::shared_ptr submitted) { + if (rising_edge) { + status_ = std::move(submitted); + if (!status_) { reset(); error = true; error_id = kErrSubmitFailed; } + } + if (status_) { + const CommandStatus& s = *status_; + busy = s.busy; active = s.active; done = s.done; + command_aborted = s.aborted; error = s.error; error_id = s.error_id; + } + if (!execute && prev_execute_) reset(); // PLCopen: outputs reset on Execute drop + prev_execute_ = execute; + } + + void reset() { + status_.reset(); + busy = active = done = command_aborted = error = false; + error_id = 0; + } + + std::shared_ptr status_; + bool prev_execute_ = 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..a12470a --- /dev/null +++ b/sdk/include/loom/command_client.h @@ -0,0 +1,54 @@ +#pragma once + +#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 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(std::string 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{std::move(command), target, std::move(params), bm, status}); + return status; + } + + std::shared_ptr submit(std::string command, std::string params, BufferMode bm) { + return submit(std::move(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/module.h b/sdk/include/loom/module.h index 0bd0bf4..01c0f04 100644 --- a/sdk/include/loom/module.h +++ b/sdk/include/loom/module.h @@ -123,6 +123,14 @@ class IModule { 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_) bus_->registerCommandChannel(moduleId_, &channel); + providesCommands_ = true; + } + Bus* bus() const { return bus_; } const std::string& moduleId() const { return moduleId_; } @@ -161,12 +169,17 @@ class IModule { bus_->unsubscribe(id); } subscriptionIds_.clear(); + if (providesCommands_) { + bus_->unregisterCommandChannel(moduleId_); + providesCommands_ = false; + } } protected: Bus* bus_ = nullptr; IModuleRegistry* registry_ = nullptr; IRuntimeHeap* runtimeHeap_ = nullptr; + bool providesCommands_ = false; std::vector subscriptionIds_; std::string moduleId_; }; From c31aa84914b313b0969635453ae12c9c4d0186d2 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 16 Jun 2026 14:26:16 -0700 Subject: [PATCH 03/62] feat: enhance CommandClient submit methods for better command handling --- sdk/include/loom/command_client.h | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/sdk/include/loom/command_client.h b/sdk/include/loom/command_client.h index a12470a..e3fdc89 100644 --- a/sdk/include/loom/command_client.h +++ b/sdk/include/loom/command_client.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "command.h" @@ -26,22 +27,33 @@ class CommandClient { CommandClient(IModule* self, std::string provider) : self_(self), provider_(std::move(provider)) {} - /// Submit 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(std::string command, uint32_t target, + /// 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{std::move(command), target, std::move(params), bm, status}); + ch->submit(CommandSubmission{command, target, std::move(params), bm, status}); return status; } - std::shared_ptr submit(std::string command, std::string params, BufferMode bm) { - return submit(std::move(command), 0, std::move(params), bm); + 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_; } From 69c93138535550601581ad4960ac949d96f6d271 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 16 Jun 2026 15:02:07 -0700 Subject: [PATCH 04/62] feat: add CommandProbe module and integration tests for async command handling --- modules/CMakeLists.txt | 1 + modules/command_probe/CMakeLists.txt | 17 +++ modules/command_probe/command_probe.cpp | 55 ++++++++ tests/CMakeLists.txt | 12 +- tests/module_test_util.h | 26 ++++ tests/test_command_integration.cpp | 92 ++++++++++++ tests/test_module_loader.cpp | 56 ++++---- tests/test_runtime_heap.cpp | 180 ++++++++++++++++++++++++ 8 files changed, 409 insertions(+), 30 deletions(-) create mode 100644 modules/command_probe/CMakeLists.txt create mode 100644 modules/command_probe/command_probe.cpp create mode 100644 tests/module_test_util.h create mode 100644 tests/test_command_integration.cpp create mode 100644 tests/test_runtime_heap.cpp diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index bc9b363..982a18b 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -3,6 +3,7 @@ set(LOOM_MODULES_OUTPUT_DIR "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/modules") # Build all example modules add_subdirectory(class_based) +add_subdirectory(command_probe) add_subdirectory(ethercat) add_subdirectory(example_motor) add_subdirectory(oscilloscope) 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..c40ff8c --- /dev/null +++ b/modules/command_probe/command_probe.cpp @@ -0,0 +1,55 @@ +#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 = loom::CmdPhase::Done; + st->done = true; + st->progress = 1.0; + } + } + } + + void exit() override {} + void longRunning() override {} + +private: + loom::CommandChannel channel_; + std::vector drained_; +}; + +LOOM_REGISTER_MODULE(CommandProbe) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 66e7a58..f1c32a7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,6 +5,8 @@ add_executable(loom_tests test_bus.cpp test_trace_cache.cpp test_tag_table.cpp + test_runtime_heap.cpp + test_command_integration.cpp ) target_include_directories(loom_tests PRIVATE @@ -27,6 +29,7 @@ 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 ) include(GoogleTest) @@ -34,10 +37,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_command_integration.cpp b/tests/test_command_integration.cpp new file mode 100644 index 0000000..52f5bd1 --- /dev/null +++ b/tests/test_command_integration.cpp @@ -0,0 +1,92 @@ +#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->init(loom::InitContext{}); + + // provideCommands() registered the channel on the bus under the module id. + 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); + mod->instance->cyclic(); // provider drains + writes status through weak_ptr + EXPECT_TRUE(status->done); + EXPECT_EQ(status->phase, loom::CmdPhase::Done); + EXPECT_DOUBLE_EQ(status->progress, 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); + 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->init(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_module_loader.cpp b/tests/test_module_loader.cpp index 3530411..632ab48 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,33 @@ 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. + after->instance->init(loom::InitContext{}); + after->instance->cyclic(); + after->instance->exit(); +} + TEST_F(ModuleLoaderTest, ModuleLifecycle) { auto path = findExampleMotor(); if (path.empty()) { diff --git a/tests/test_runtime_heap.cpp b/tests/test_runtime_heap.cpp new file mode 100644 index 0000000..4c606fe --- /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 = loom::CmdPhase::Done; + s->done = true; + } + EXPECT_TRUE(issuer->done); + EXPECT_EQ(issuer->phase, 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); +} From 76e17b92e31f6f80da7ffc200afdf3efdf42cfe2 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:21:29 -0700 Subject: [PATCH 05/62] Address Copilot review: thread-safe CommandStatus + tests - CommandStatus fields are now std::atomic so a provider (its cyclic thread) and a consuming function block (a different scheduler class/thread) no longer race; write via store(), read via load(). Still trivially destructible, so runtime-heap allocation is unaffected. - provideCommands(): only set providesCommands_ when a bus is present, so the flag matches actual registration. - Add Bus command-channel unit tests (register/lookup/unregister, submit/drain). - Bump SDK version to 0.3.0 for the added async-command primitive. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- modules/command_probe/command_probe.cpp | 6 ++-- sdk/include/loom/command.h | 31 +++++++++++++-------- sdk/include/loom/module.h | 3 +- tests/test_bus.cpp | 37 +++++++++++++++++++++++++ tests/test_command_integration.cpp | 10 +++---- tests/test_runtime_heap.cpp | 8 +++--- 7 files changed, 71 insertions(+), 26 deletions(-) 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/modules/command_probe/command_probe.cpp b/modules/command_probe/command_probe.cpp index c40ff8c..439224d 100644 --- a/modules/command_probe/command_probe.cpp +++ b/modules/command_probe/command_probe.cpp @@ -37,9 +37,9 @@ class CommandProbe : public loom::Module { runtime_.last_command = s.command; runtime_.last_target = s.target; if (auto st = s.status.lock()) { // write status THROUGH the weak ref - st->phase = loom::CmdPhase::Done; - st->done = true; - st->progress = 1.0; + st->phase.store(loom::CmdPhase::Done); + st->done.store(true); + st->progress.store(1.0); } } } diff --git a/sdk/include/loom/command.h b/sdk/include/loom/command.h index ccb8c57..407062f 100644 --- a/sdk/include/loom/command.h +++ b/sdk/include/loom/command.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -41,17 +42,22 @@ enum class CmdPhase : uint8_t { }; /// Status of one async command, written through by the provider and read by the -/// issuing function block. Trivially destructible → lives in runtime-heap -/// storage (see runtime_heap.h). +/// 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 { - CmdPhase phase = CmdPhase::None; - bool busy = false; - bool active = false; - bool done = false; - bool aborted = false; - bool error = false; - uint32_t error_id = 0; - double progress = 0.0; ///< optional 0..1 progress for long commands + 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. @@ -126,8 +132,9 @@ class CommandFb { } if (status_) { const CommandStatus& s = *status_; - busy = s.busy; active = s.active; done = s.done; - command_aborted = s.aborted; error = s.error; error_id = s.error_id; + busy = s.busy.load(); active = s.active.load(); done = s.done.load(); + command_aborted = s.aborted.load(); error = s.error.load(); + error_id = s.error_id.load(); } if (!execute && prev_execute_) reset(); // PLCopen: outputs reset on Execute drop prev_execute_ = execute; diff --git a/sdk/include/loom/module.h b/sdk/include/loom/module.h index 01c0f04..7515dd8 100644 --- a/sdk/include/loom/module.h +++ b/sdk/include/loom/module.h @@ -127,7 +127,8 @@ class IModule { /// 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_) bus_->registerCommandChannel(moduleId_, &channel); + if (!bus_) return; // not registered → leave providesCommands_ false + bus_->registerCommandChannel(moduleId_, &channel); providesCommands_ = true; } 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_integration.cpp b/tests/test_command_integration.cpp index 52f5bd1..563d34e 100644 --- a/tests/test_command_integration.cpp +++ b/tests/test_command_integration.cpp @@ -49,11 +49,11 @@ TEST_F(CommandIntegrationTest, CommandRoundTripAcrossModuleBoundary) { channel->submit(loom::CommandSubmission{/*command*/ 7, /*target*/ 2, "{}", loom::BufferMode::Aborting, status}); - EXPECT_FALSE(status->done); + EXPECT_FALSE(status->done.load()); mod->instance->cyclic(); // provider drains + writes status through weak_ptr - EXPECT_TRUE(status->done); - EXPECT_EQ(status->phase, loom::CmdPhase::Done); - EXPECT_DOUBLE_EQ(status->progress, 1.0); + 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(); @@ -64,7 +64,7 @@ TEST_F(CommandIntegrationTest, CommandRoundTripAcrossModuleBoundary) { // 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); + EXPECT_TRUE(status->done.load()); status.reset(); // must not crash (deleter is resident) SUCCEED(); } diff --git a/tests/test_runtime_heap.cpp b/tests/test_runtime_heap.cpp index 4c606fe..e49cc90 100644 --- a/tests/test_runtime_heap.cpp +++ b/tests/test_runtime_heap.cpp @@ -108,11 +108,11 @@ TEST(RuntimeHeapTest, WriteThroughWeakPtr) { std::weak_ptr executor = issuer; if (auto s = executor.lock()) { // executor writes status THROUGH the weak ref - s->phase = loom::CmdPhase::Done; - s->done = true; + s->phase.store(loom::CmdPhase::Done); + s->done.store(true); } - EXPECT_TRUE(issuer->done); - EXPECT_EQ(issuer->phase, loom::CmdPhase::Done); + 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()); From 6f9f66972db7e06300fa0e19eab993f993317a22 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:47:37 -0700 Subject: [PATCH 06/62] feat: implement CommandFb with PLCopen output semantics and associated tests --- sdk/include/loom/command.h | 53 ++++++++--- tests/CMakeLists.txt | 1 + tests/test_command_fb.cpp | 181 +++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 tests/test_command_fb.cpp diff --git a/sdk/include/loom/command.h b/sdk/include/loom/command.h index 407062f..bafcc62 100644 --- a/sdk/include/loom/command.h +++ b/sdk/include/loom/command.h @@ -125,29 +125,58 @@ class CommandFb { /// 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) { - if (rising_edge) { + // 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_) { reset(); error = true; error_id = kErrSubmitFailed; } + if (!status_) { error = true; error_id = kErrSubmitFailed; state_ = State::Held; } + else { busy = true; state_ = State::Running; } } - if (status_) { - const CommandStatus& s = *status_; - busy = s.busy.load(); active = s.active.load(); done = s.done.load(); - command_aborted = s.aborted.load(); error = s.error.load(); - error_id = s.error_id.load(); + + 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); + } } - if (!execute && prev_execute_) reset(); // PLCopen: outputs reset on Execute drop + prev_execute_ = execute; } - void reset() { - status_.reset(); +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; + bool prev_execute_ = false; + State state_ = State::Idle; }; } // namespace loom diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f1c32a7..f2c2500 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(loom_tests test_tag_table.cpp test_runtime_heap.cpp test_command_integration.cpp + test_command_fb.cpp ) target_include_directories(loom_tests PRIVATE diff --git a/tests/test_command_fb.cpp b/tests/test_command_fb.cpp new file mode 100644 index 0000000..d237a35 --- /dev/null +++ b/tests/test_command_fb.cpp @@ -0,0 +1,181 @@ +#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); + } +}; + +/// 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); + } + }; + 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 From 702d2461943ba8e1ec8125fe559fe0e36961c391 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:41:36 -0700 Subject: [PATCH 07/62] feat: add Port and PortRef for typed connection points with tests --- sdk/include/loom/bus.h | 56 +++++++++++++++++++++++++++++++++++++ sdk/include/loom/module.h | 18 ++++++++++++ sdk/include/loom/port.h | 46 +++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_port.cpp | 58 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 sdk/include/loom/port.h create mode 100644 tests/test_port.cpp diff --git a/sdk/include/loom/bus.h b/sdk/include/loom/bus.h index 0314893..f79043c 100644 --- a/sdk/include/loom/bus.h +++ b/sdk/include/loom/bus.h @@ -183,6 +183,58 @@ class Bus { 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_; + } + + inline void unregisterPort(const std::string& name) { + std::lock_guard lock(portMutex_); + if (ports_.erase(name) != 0) ++portGeneration_; + } + + 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. + inline uint64_t portGeneration() const { + std::lock_guard lock(portMutex_); + return portGeneration_; + } + + /// 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 // ========================================================================= @@ -240,6 +292,10 @@ class Bus { mutable std::mutex commandMutex_; std::unordered_map commandChannels_; + + mutable std::mutex portMutex_; + std::unordered_map ports_; + uint64_t portGeneration_ = 1; // nonzero so a freshly-default PortRef re-resolves }; } // namespace loom diff --git a/sdk/include/loom/module.h b/sdk/include/loom/module.h index 7515dd8..7872abd 100644 --- a/sdk/include/loom/module.h +++ b/sdk/include/loom/module.h @@ -132,6 +132,19 @@ class IModule { 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_; } @@ -174,6 +187,10 @@ class IModule { bus_->unregisterCommandChannel(moduleId_); providesCommands_ = false; } + for (const auto& name : providedPorts_) { + bus_->unregisterPort(name); + } + providedPorts_.clear(); } protected: @@ -181,6 +198,7 @@ class IModule { IModuleRegistry* registry_ = nullptr; IRuntimeHeap* runtimeHeap_ = nullptr; bool providesCommands_ = false; + std::vector providedPorts_; std::vector subscriptionIds_; std::string moduleId_; }; 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/tests/CMakeLists.txt b/tests/CMakeLists.txt index f2c2500..6e92f7c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable(loom_tests test_runtime_heap.cpp test_command_integration.cpp test_command_fb.cpp + test_port.cpp ) target_include_directories(loom_tests PRIVATE 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 From 67bc115a222f293e301928f68f3ff1f461a892e1 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:22:00 -0700 Subject: [PATCH 08/62] test: use initGuarded() in module-loader lifecycle tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combining feat/command_interface (test discovery fix that un-skips these on Windows) with claude/priceless-moore (registerExtension requires the init window) surfaced a failure: the tests called bare init(), so example_motor's registerExtension() threw. Call initGuarded() — the same entry the runtime uses (scheduler.cpp:228). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_module_loader.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_module_loader.cpp b/tests/test_module_loader.cpp index 632ab48..6a632db 100644 --- a/tests/test_module_loader.cpp +++ b/tests/test_module_loader.cpp @@ -91,8 +91,9 @@ TEST_F(ModuleLoaderTest, ReloadModule) { EXPECT_EQ(after->state, loom::ModuleState::Loaded); ASSERT_NE(after->instance, nullptr); // a fresh instance is in place - // The reloaded module is still usable. - after->instance->init(loom::InitContext{}); + // 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(); } @@ -109,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(); From 37b18055eeff313e56051086100feef1e4bd365b Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:32:05 -0700 Subject: [PATCH 09/62] =?UTF-8?q?diag:=20Phase=201=20=E2=80=94=20make=20ev?= =?UTF-8?q?ery=20crash=20diagnosable=20(no=20new=20deps)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a modular loom::diag subsystem (runtime-only; SDK stays clean): - breadcrumb.h: per-thread {module,class,phase} set via BreadcrumbScope (stable pointers, signal-safe to read). - guard.h: diag::guard() wraps module entry points in a breadcrumb + try/catch; a thrown exception is reported and contained instead of terminating the worker thread. - crash_handler.{h,cpp}: process-global fatal-signal (POSIX sigaction+sigaltstack) / SetUnhandledExceptionFilter (Windows) / std::set_terminate, writing a text crash report (faulting module/phase + signal + build id + raw stack addresses) to /crash before exit. Covers module AND runtime crashes. - scheduler.cpp: guards preCyclic/cyclic/postCyclic/longRunning + isolated cyclic; on fault sets TaskState::faulted + ModuleState::Error (wiring the previously-dead infra) so the runtime keeps running and skips the faulted module. init() gets a breadcrumb. - bus.h: dependency-free try/catch around service dispatch (returns a CallResult error; no spdlog/diag dep). - version.h.in + sdk/CMakeLists.txt: optional git SHA stamp, degrades to "unknown" when git/.git absent (build never fails). - run.cpp installs the handler; runtime CMake stamps LOOM_BUILD_TYPE. Unit tests for breadcrumb scope + guard exception path. loom_tests: 112 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/CMakeLists.txt | 5 + runtime/include/loom/diag/breadcrumb.h | 70 ++++++++ runtime/include/loom/diag/crash_handler.h | 28 ++++ runtime/include/loom/diag/guard.h | 47 ++++++ runtime/src/diag/breadcrumb.cpp | 7 + runtime/src/diag/crash_handler.cpp | 194 ++++++++++++++++++++++ runtime/src/run.cpp | 6 + runtime/src/scheduler.cpp | 42 ++++- sdk/CMakeLists.txt | 23 +++ sdk/include/loom/bus.h | 12 +- sdk/include/loom/version.h.in | 4 + tests/CMakeLists.txt | 2 + tests/test_diag.cpp | 68 ++++++++ 13 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 runtime/include/loom/diag/breadcrumb.h create mode 100644 runtime/include/loom/diag/crash_handler.h create mode 100644 runtime/include/loom/diag/guard.h create mode 100644 runtime/src/diag/breadcrumb.cpp create mode 100644 runtime/src/diag/crash_handler.cpp create mode 100644 tests/test_diag.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index bd938cd..6d5c9cb 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -36,6 +36,8 @@ add_library(loom_runtime STATIC src/opcua_rest_server.cpp src/oscilloscope.cpp src/module_watcher.cpp + src/diag/breadcrumb.cpp + src/diag/crash_handler.cpp ) target_include_directories(loom_runtime PUBLIC @@ -43,6 +45,9 @@ target_include_directories(loom_runtime PUBLIC $ ) +# 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::sdk spdlog::spdlog 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/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/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..721fcd7 --- /dev/null +++ b/runtime/src/diag/crash_handler.cpp @@ -0,0 +1,194 @@ +#include "loom/diag/crash_handler.h" +#include "loom/diag/breadcrumb.h" +#include "loom/version.h" + +#include +#include +#include +#include +#include +#include + +#ifndef LOOM_BUILD_TYPE +#define LOOM_BUILD_TYPE "unknown" +#endif + +// ============================================================================ +// Crash handler. Phase 1: capture (breadcrumb + signal/exception + build id + +// raw stack addresses) and write a text crash report, then let the process die. +// Symbolization (cpptrace) and structured JSON come in later phases. +// +// POSIX handler bodies must be async-signal-safe: only open()/write()/_exit and +// raw reads — no malloc/printf/ofstream/locks. The Windows unhandled-exception +// filter is NOT signal-constrained, so it may use richer calls. +// ============================================================================ + +namespace loom::diag { +namespace { + +std::atomic_flag g_reporting = ATOMIC_FLAG_INIT; // first faulting thread wins +char g_reportPath[1024] = {}; // precomputed at install + +void buildIdentityLine(char* buf, size_t n) { + std::snprintf(buf, n, "build: sdk=%s git=%s type=%s", + loom::kSdkVersion, loom::kGitSha, LOOM_BUILD_TYPE); +} + +} // namespace +} // namespace loom::diag + +// --------------------------------------------------------------------------- +#if defined(_WIN32) +// --------------------------------------------------------------------------- +#include // CaptureStackBackTrace (RtlCaptureStackBackTrace, in kernel32) + +namespace loom::diag { +namespace { + +void writeReportWin(const char* reason, void* const* frames, unsigned nframes) { + HANDLE h = CreateFileA(g_reportPath, GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + char line[1200]; + auto put = [&](const char* s) { DWORD w; WriteFile(h, s, (DWORD)std::strlen(s), &w, nullptr); }; + + const Breadcrumb& b = tlsBreadcrumb; // faulting thread + put("=== Loom crash report ===\n"); + std::snprintf(line, sizeof line, "reason: %s\n", reason); put(line); + std::snprintf(line, sizeof line, "module: %s class: %s phase: %s cycle: %llu\n", + b.moduleId ? b.moduleId : "(none/runtime)", + b.className ? b.className : "(none)", phaseName(b.phase), + (unsigned long long)b.cycle); put(line); + buildIdentityLine(line, sizeof line); put(line); put("\n"); + put("frames (raw addresses — symbolize offline with matching PDBs):\n"); + for (unsigned i = 0; i < nframes; ++i) { + std::snprintf(line, sizeof line, " #%-2u 0x%p\n", i, frames[i]); + put(line); + } + FlushFileBuffers(h); + CloseHandle(h); +} + +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); + char reason[64]; + std::snprintf(reason, sizeof reason, "SEH exception 0x%08lx", + ep ? ep->ExceptionRecord->ExceptionCode : 0UL); + writeReportWin(reason, 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); + writeReportWin("std::terminate (unhandled C++ exception)", frames, n); + } + std::abort(); +} + +} // namespace + +void CrashHandler::install(const CrashConfig& cfg) { + std::error_code ec; + std::filesystem::create_directories(cfg.crashDir, ec); + auto path = (cfg.crashDir / ("loom-crash-" + std::to_string(GetCurrentProcessId()) + ".txt")).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 + +namespace loom::diag { +namespace { + +// async-signal-safe helpers -------------------------------------------------- +void sWrite(int fd, const char* s) { ssize_t r = ::write(fd, s, std::strlen(s)); (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; +} + +char g_buildId[256] = {}; // precomputed at install (no formatting in handler) +void* g_primeFrames[4]; // backtrace priming target + +void handler(int sig, siginfo_t*, void*) { + if (g_reporting.test_and_set()) { signal(sig, SIG_DFL); raise(sig); _exit(128 + sig); } + int fd = ::open(g_reportPath, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd >= 0) { + const Breadcrumb& b = tlsBreadcrumb; + sWrite(fd, "=== Loom crash report ===\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"); + void* frames[64]; + int n = backtrace(frames, 64); + for (int i = 0; i < n; ++i) { sWrite(fd, " "); sWriteHex(fd, (uintptr_t)frames[i]); sWrite(fd, "\n"); } + ::close(fd); + } + signal(sig, SIG_DFL); + raise(sig); // re-raise for a core dump with default disposition + _exit(128 + sig); +} + +void terminateHandler() { + if (!g_reporting.test_and_set()) { + int fd = ::open(g_reportPath, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd >= 0) { + sWrite(fd, "=== Loom crash report ===\nstd::terminate (unhandled C++ exception)\n"); + sWrite(fd, g_buildId); sWrite(fd, "\n"); + void* frames[64]; int n = backtrace(frames, 64); + for (int i = 0; i < n; ++i) { sWrite(fd, " "); sWriteHex(fd, (uintptr_t)frames[i]); sWrite(fd, "\n"); } + ::close(fd); + } + } + std::abort(); +} + +} // namespace + +void CrashHandler::install(const CrashConfig& cfg) { + std::error_code ec; + std::filesystem::create_directories(cfg.crashDir, ec); + auto path = (cfg.crashDir / ("loom-crash-" + std::to_string((long)getpid()) + ".txt")).string(); + std::snprintf(g_reportPath, sizeof g_reportPath, "%s", path.c_str()); + buildIdentityLine(g_buildId, sizeof g_buildId); + + // 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. + static char altStack[SIGSTKSZ < 65536 ? 65536 : SIGSTKSZ]; + 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/run.cpp b/runtime/src/run.cpp index 50cb37e..847a8ed 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 @@ -149,6 +150,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) { diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 734fd19..e07575a 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -1,6 +1,7 @@ #include "loom/scheduler.h" #include "loom/scheduler_config.h" #include "loom/io_mapper.h" +#include "loom/diag/guard.h" #include @@ -224,6 +225,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) { @@ -247,7 +249,14 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon statePtr->longRunningThread = std::thread([&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) { + statePtr->faulted.store(true); + mod.state = ModuleState::Error; + spdlog::error("Module '{}' faulted in longRunning: {}", mod.id, f.message); + }); + if (!ok) break; // stop the loop rather than spin-faulting } spdlog::info("Long-running task ended for '{}'", mod.id); }); @@ -752,10 +761,21 @@ void Scheduler::classLoop(ClassRunnerState& runner) { // Reading it here without a lock is safe. auto execStart = std::chrono::steady_clock::now(); + // Mark a member faulted (skipped on subsequent sweeps/ticks via the + // faulted checks below) and report — used by the guarded calls. + auto faultMember = [&](auto& m, const diag::FaultInfo& f) { + m.state->faulted.store(true); + m.mod->state = ModuleState::Error; + spdlog::error("Module '{}' faulted in {}: {}", + m.moduleId, diag::phaseName(f.phase), f.message); + }; + // --- Sweep 1: preCyclic (e.g. read hardware inputs) --- for (auto& member : runner.members) { if (member.state->faulted.load()) continue; - member.mod->instance->preCyclicGuarded(); + 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 --- @@ -775,7 +795,9 @@ void Scheduler::classLoop(ClassRunnerState& runner) { // Execute (cyclicGuarded acquires module's runtimeMutex_ so // server/watch threads can't race on runtime_ reads) - member.mod->instance->cyclicGuarded(); + 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: use oscilloscope fast-path. // A member in runner.members is guaranteed alive — removeMember() pauses @@ -818,7 +840,9 @@ void Scheduler::classLoop(ClassRunnerState& runner) { // --- Sweep 3: postCyclic (e.g. flush outputs to hardware) --- for (auto& member : runner.members) { if (member.state->faulted.load()) continue; - member.mod->instance->postCyclicGuarded(); + 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 --- @@ -921,7 +945,15 @@ 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){ + state.faulted.store(true); + mod.state = ModuleState::Error; + spdlog::error("Module '{}' faulted in cyclic: {}", mod.id, f.message); + }); + } auto t1 = std::chrono::steady_clock::now(); // Execute I/O mappings for this isolated module diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index e6a34a2..a9880e5 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( diff --git a/sdk/include/loom/bus.h b/sdk/include/loom/bus.h index f79043c..65c41a2 100644 --- a/sdk/include/loom/bus.h +++ b/sdk/include/loom/bus.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -147,7 +148,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"}; + } } // ========================================================================= 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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6e92f7c..523ae73 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable(loom_tests test_command_integration.cpp test_command_fb.cpp test_port.cpp + test_diag.cpp ) target_include_directories(loom_tests PRIVATE @@ -32,6 +33,7 @@ target_sources(loom_tests PRIVATE ${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 ) include(GoogleTest) diff --git a/tests/test_diag.cpp b/tests/test_diag.cpp new file mode 100644 index 0000000..ffc25a0 --- /dev/null +++ b/tests/test_diag.cpp @@ -0,0 +1,68 @@ +#include "loom/diag/breadcrumb.h" +#include "loom/diag/guard.h" + +#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); +} From dfa5b584ef3d41410cc445b3bff7d55caa792660 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:41:24 -0700 Subject: [PATCH 10/62] test: add crasher module for crash-diagnostics verification Config-selectable fault (throw|segfault|fpe|abort|loop) at init or on the Nth cyclic tick. Used to exercise the guard (exception isolation) and crash handler (signal/SEH report) end to end. Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/CMakeLists.txt | 1 + modules/crasher/CMakeLists.txt | 17 +++++++++++ modules/crasher/crasher.cpp | 53 ++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 modules/crasher/CMakeLists.txt create mode 100644 modules/crasher/crasher.cpp diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index 982a18b..ec95bdb 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -4,6 +4,7 @@ set(LOOM_MODULES_OUTPUT_DIR "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/modules") # Build all example modules add_subdirectory(class_based) add_subdirectory(command_probe) +add_subdirectory(crasher) add_subdirectory(ethercat) add_subdirectory(example_motor) add_subdirectory(oscilloscope) 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..0bb64f1 --- /dev/null +++ b/modules/crasher/crasher.cpp @@ -0,0 +1,53 @@ +#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 {} + void longRunning() override {} +}; + +LOOM_REGISTER_MODULE(Crasher) From c8a2273c12de4f37abd2d1f82f382b8419cb5188 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:41:24 -0700 Subject: [PATCH 11/62] chore: ignore _crashtest scratch dir Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 2fad66f2a8eabef4b9e55a2d5090ce41c54bcb03 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:47:39 -0700 Subject: [PATCH 12/62] feat(diag): in-process stack symbolization via cpptrace Phase 2 of crash diagnostics. The Windows unhandled-exception filter and terminate handler now resolve raw return addresses to function + file:line at crash time, so a dev build's crash report is readable with no offline step and no tools installed. - runtime/diag/symbolizer.{h,cpp}: ISymbolizer-style free function backed by cpptrace, isolated to one TU. cpptrace reads PDB/DWARF directly, so it works despite modules' hidden visibility and needs no -rdynamic. Never called from the POSIX signal path (it allocates) - only the Windows UEF, terminate handler, and (later) the offline --symbolize tool. - crash_handler.cpp (Windows): symbolize captured frames in the UEF and write "#i 0x.. symbol / at file:line" per frame. - cpptrace/0.8.3 wired runtime-only (root + runtime conanfile, find_package, PRIVATE link). SDK stays glaze-only. Verified: crasher segfault report pins the fault to doFault @ crasher.cpp:30 <- Crasher::cyclic() @ crasher.cpp:47 through the scheduler guard, fully symbolized in-process. Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 1 + conanfile.py | 1 + runtime/CMakeLists.txt | 5 +++++ runtime/conanfile.py | 1 + runtime/include/loom/diag/symbolizer.h | 31 ++++++++++++++++++++++++++ runtime/src/diag/crash_handler.cpp | 16 ++++++++++--- runtime/src/diag/symbolizer.cpp | 31 ++++++++++++++++++++++++++ 7 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 runtime/include/loom/diag/symbolizer.h create mode 100644 runtime/src/diag/symbolizer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3434530..03f0c6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,6 +55,7 @@ find_package(glaze REQUIRED) find_package(spdlog REQUIRED) find_package(GTest REQUIRED) find_package(Crow REQUIRED) +find_package(cpptrace REQUIRED) # runtime-only: crash-report symbolization add_subdirectory(sdk) add_subdirectory(runtime) diff --git a/conanfile.py b/conanfile.py index 90a7705..6ddce5a 100644 --- a/conanfile.py +++ b/conanfile.py @@ -20,5 +20,6 @@ def requirements(self): # Runtime deps self.requires("spdlog/1.17.0") self.requires("crowcpp-crow/1.3.0") + self.requires("cpptrace/0.8.3") # runtime-only: crash-report stack symbolization if self.options.with_tests: self.requires("gtest/1.15.0") diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 6d5c9cb..4edc428 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -38,6 +38,7 @@ add_library(loom_runtime STATIC src/module_watcher.cpp src/diag/breadcrumb.cpp src/diag/crash_handler.cpp + src/diag/symbolizer.cpp ) target_include_directories(loom_runtime PUBLIC @@ -55,6 +56,10 @@ target_link_libraries(loom_runtime PUBLIC ${CMAKE_DL_LIBS} ) +# 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). # --------------------------------------------------------------------------- diff --git a/runtime/conanfile.py b/runtime/conanfile.py index 78096f5..bb57980 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) 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/src/diag/crash_handler.cpp b/runtime/src/diag/crash_handler.cpp index 721fcd7..d95eabf 100644 --- a/runtime/src/diag/crash_handler.cpp +++ b/runtime/src/diag/crash_handler.cpp @@ -1,5 +1,6 @@ #include "loom/diag/crash_handler.h" #include "loom/diag/breadcrumb.h" +#include "loom/diag/symbolizer.h" #include "loom/version.h" #include @@ -60,10 +61,19 @@ void writeReportWin(const char* reason, void* const* frames, unsigned nframes) { b.className ? b.className : "(none)", phaseName(b.phase), (unsigned long long)b.cycle); put(line); buildIdentityLine(line, sizeof line); put(line); put("\n"); - put("frames (raw addresses — symbolize offline with matching PDBs):\n"); - for (unsigned i = 0; i < nframes; ++i) { - std::snprintf(line, sizeof line, " #%-2u 0x%p\n", i, frames[i]); + put("frames:\n"); + // Not signal-constrained here (Windows UEF) — symbolize in-process via cpptrace. + auto syms = symbolize(reinterpret_cast(frames), nframes); + for (size_t i = 0; i < syms.size(); ++i) { + const SymFrame& f = syms[i]; + std::snprintf(line, sizeof line, " #%-2zu 0x%016llx %s\n", + i, (unsigned long long)f.address, + f.symbol.empty() ? "" : f.symbol.c_str()); put(line); + if (!f.filename.empty()) { + std::snprintf(line, sizeof line, " at %s:%u\n", f.filename.c_str(), f.line); + put(line); + } } FlushFileBuffers(h); CloseHandle(h); 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 From e6ad9b4c44ef32c836acf82a05df8b8406430dc8 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:03:12 -0700 Subject: [PATCH 13/62] feat(diag): structured fault reports + /api/faults + loom/faults topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 of crash diagnostics. Faults are now first-class structured records, not just log lines, and are queryable over HTTP and the bus. New loom::diag components (modular; diag stays free of DataEngine/Bus deps): - fault_report.{h,cpp}: FaultReport model + hand-rolled JSON (sections embed as real nested JSON, build identity + breadcrumb + symbolized frames). - fault_sink.h: IFaultSink interface the scheduler depends on (injected). - fault_store.{h,cpp}: thread-safe in-memory ring + /crash/*.json persistence; scans the dir on construction so prior-run (signal-path) crashes surface too. - runtime_fault_sink.{h,cpp}: concrete sink (runtime layer) — on a module-call exception it captures the module's live data sections, persists a report, and publishes it on the loom/faults bus topic. Wiring: - scheduler: single recordModuleFault() helper routes all four guarded fault sites (init/cyclic/postCyclic/longRunning/isolated), stamps TaskState last-fault fields, and notifies the injected sink. setFaultSink() hook. - RuntimeCore owns the FaultStore + sink and attaches them to the scheduler. - server: GET /api/faults (list, newest-first) + GET /api/faults/ (full report); module info now exposes faulted + lastFault{Ms,Phase,Msg}. - crash_handler (Windows): the UEF/terminate path now writes a structured, symbolized JSON report (off-signal-constrained) so SEH/segfault crashes also appear in /api/faults on the next start. POSIX keeps the raw text report. Verified: throw in cyclic -> module Error + runtime survives + report with live sections (runtime.cycle captured) at /api/faults; segfault -> symbolized JSON report, picked up by the store on next start. Unit tests for toJson round-trip and FaultStore record/list/detail/persistence. 114 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/CMakeLists.txt | 3 + runtime/include/loom/diag/fault_report.h | 66 +++++++++ runtime/include/loom/diag/fault_sink.h | 37 +++++ runtime/include/loom/diag/fault_store.h | 64 +++++++++ .../include/loom/diag/runtime_fault_sink.h | 40 ++++++ runtime/include/loom/runtime_core.h | 10 ++ runtime/include/loom/scheduler.h | 22 +++ runtime/src/diag/crash_handler.cpp | 70 ++++----- runtime/src/diag/fault_report.cpp | 132 +++++++++++++++++ runtime/src/diag/fault_store.cpp | 133 ++++++++++++++++++ runtime/src/diag/runtime_fault_sink.cpp | 72 ++++++++++ runtime/src/runtime_core.cpp | 8 +- runtime/src/scheduler.cpp | 46 ++++-- runtime/src/server.cpp | 52 +++++++ tests/CMakeLists.txt | 2 + tests/test_diag.cpp | 80 +++++++++++ 16 files changed, 791 insertions(+), 46 deletions(-) create mode 100644 runtime/include/loom/diag/fault_report.h create mode 100644 runtime/include/loom/diag/fault_sink.h create mode 100644 runtime/include/loom/diag/fault_store.h create mode 100644 runtime/include/loom/diag/runtime_fault_sink.h create mode 100644 runtime/src/diag/fault_report.cpp create mode 100644 runtime/src/diag/fault_store.cpp create mode 100644 runtime/src/diag/runtime_fault_sink.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 4edc428..a314d77 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -39,6 +39,9 @@ add_library(loom_runtime STATIC src/diag/breadcrumb.cpp src/diag/crash_handler.cpp src/diag/symbolizer.cpp + src/diag/fault_report.cpp + src/diag/fault_store.cpp + src/diag/runtime_fault_sink.cpp ) target_include_directories(loom_runtime PUBLIC 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..944a743 --- /dev/null +++ b/runtime/include/loom/diag/fault_store.h @@ -0,0 +1,64 @@ +#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; + + /// 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/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/runtime_core.h b/runtime/include/loom/runtime_core.h index 356cf6a..4958517 100644 --- a/runtime/include/loom/runtime_core.h +++ b/runtime/include/loom/runtime_core.h @@ -3,6 +3,8 @@ #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/instance_manifest.h" #include "loom/io_mapper.h" #include "loom/module.h" @@ -16,6 +18,7 @@ #include #include +#include #include #include #include @@ -92,6 +95,7 @@ class RuntimeCore : public IModuleRegistry { Bus& bus() { return bus_; } Oscilloscope& oscilloscope() { return oscilloscope_; } IOMapper& ioMapper() { return ioMapper_; } + diag::FaultStore& faultStore() { return faultStore_; } const RuntimeConfig& config() const { return config_; } const SchedulerConfig& schedulerConfig() const { return schedCfg_; } @@ -133,6 +137,12 @@ class RuntimeCore : public IModuleRegistry { 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_; + std::shared_mutex moduleMutex_; }; diff --git a/runtime/include/loom/scheduler.h b/runtime/include/loom/scheduler.h index 6a0b86c..c68d873 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,13 @@ struct TaskState { std::atomic lastJitterUs{0}; ///< |actualStart − prevStart| − period (µs) std::atomic lastCyclicStartNs{0}; ///< Used internally to compute per-module jitter + // Last-fault diagnostics (set when a guarded call throws; surfaced by the + // server). lastFaultMsg is written under the class thread and read by the + // server thread — a benign race for a diagnostic string. + 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; @@ -146,6 +156,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); @@ -270,6 +285,12 @@ class Scheduler { void classLoop(ClassRunnerState& runner); void isolatedLoop(LoadedModule& mod, TaskConfig config, TaskState& state); + /// 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 +306,7 @@ class Scheduler { ModuleLoader* loader_ = nullptr; std::shared_mutex* moduleMutex_ = nullptr; IOMapper* ioMapper_ = nullptr; + diag::IFaultSink* faultSink_ = nullptr; }; } // namespace loom diff --git a/runtime/src/diag/crash_handler.cpp b/runtime/src/diag/crash_handler.cpp index d95eabf..dc6cfad 100644 --- a/runtime/src/diag/crash_handler.cpp +++ b/runtime/src/diag/crash_handler.cpp @@ -1,5 +1,6 @@ #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" @@ -9,6 +10,8 @@ #include #include #include +#include +#include #ifndef LOOM_BUILD_TYPE #define LOOM_BUILD_TYPE "unknown" @@ -46,47 +49,43 @@ void buildIdentityLine(char* buf, size_t n) { namespace loom::diag { namespace { -void writeReportWin(const char* reason, void* const* frames, unsigned nframes) { - HANDLE h = CreateFileA(g_reportPath, GENERIC_WRITE, 0, nullptr, - CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); - if (h == INVALID_HANDLE_VALUE) return; - char line[1200]; - auto put = [&](const char* s) { DWORD w; WriteFile(h, s, (DWORD)std::strlen(s), &w, nullptr); }; +char g_reportId[256] = {}; // crash-report id / filename stem (precomputed at install) +// Build a structured JSON crash report and write it to g_reportPath. The Windows +// unhandled-exception filter is NOT async-signal-constrained, so we may allocate: +// symbolize in-process via cpptrace and serialize the same FaultReport the +// exception path uses, so signal-path crashes surface in /api/faults too. +void writeReportWin(FaultKind kind, const char* reason, int code, + void* const* frames, unsigned nframes) { const Breadcrumb& b = tlsBreadcrumb; // faulting thread - put("=== Loom crash report ===\n"); - std::snprintf(line, sizeof line, "reason: %s\n", reason); put(line); - std::snprintf(line, sizeof line, "module: %s class: %s phase: %s cycle: %llu\n", - b.moduleId ? b.moduleId : "(none/runtime)", - b.className ? b.className : "(none)", phaseName(b.phase), - (unsigned long long)b.cycle); put(line); - buildIdentityLine(line, sizeof line); put(line); put("\n"); - put("frames:\n"); - // Not signal-constrained here (Windows UEF) — symbolize in-process via cpptrace. - auto syms = symbolize(reinterpret_cast(frames), nframes); - for (size_t i = 0; i < syms.size(); ++i) { - const SymFrame& f = syms[i]; - std::snprintf(line, sizeof line, " #%-2zu 0x%016llx %s\n", - i, (unsigned long long)f.address, - f.symbol.empty() ? "" : f.symbol.c_str()); - put(line); - if (!f.filename.empty()) { - std::snprintf(line, sizeof line, " at %s:%u\n", f.filename.c_str(), f.line); - put(line); - } - } - FlushFileBuffers(h); - CloseHandle(h); + + FaultReport r; + r.id = g_reportId; + 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; } 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", - ep ? ep->ExceptionRecord->ExceptionCode : 0UL); - writeReportWin(reason, frames, n); + std::snprintf(reason, sizeof reason, "SEH exception 0x%08lx", code); + writeReportWin(FaultKind::Signal, reason, static_cast(code), frames, n); return EXCEPTION_EXECUTE_HANDLER; // run default handler → terminate } @@ -94,7 +93,8 @@ void terminateHandler() { if (!g_reporting.test_and_set()) { void* frames[64]; unsigned n = CaptureStackBackTrace(0, 64, frames, nullptr); - writeReportWin("std::terminate (unhandled C++ exception)", frames, n); + writeReportWin(FaultKind::Signal, "std::terminate (unhandled C++ exception)", + 0, frames, n); } std::abort(); } @@ -104,7 +104,9 @@ void terminateHandler() { void CrashHandler::install(const CrashConfig& cfg) { std::error_code ec; std::filesystem::create_directories(cfg.crashDir, ec); - auto path = (cfg.crashDir / ("loom-crash-" + std::to_string(GetCurrentProcessId()) + ".txt")).string(); + 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); diff --git a/runtime/src/diag/fault_report.cpp b/runtime/src/diag/fault_report.cpp new file mode 100644 index 0000000..33f7b6f --- /dev/null +++ b/runtime/src/diag/fault_report.cpp @@ -0,0 +1,132 @@ +#include "loom/diag/fault_report.h" + +#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..a413434 --- /dev/null +++ b/runtime/src/diag/fault_store.cpp @@ -0,0 +1,133 @@ +#include "loom/diag/fault_store.h" + +#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; + + 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 report (raw addresses) — wrap the text so the + // viewer can show it; symbolize offline via `loom --symbolize`. + 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 { + try { + std::string json = toJson(report); + { + std::lock_guard lock(mx_); + auto path = crashDir_ / (report.id + ".json"); + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (f) f << json; + 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..8ca19f1 --- /dev/null +++ b/runtime/src/diag/runtime_fault_sink.cpp @@ -0,0 +1,72 @@ +#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 + +#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 {}; + } +} +} // 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 = 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); + 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/runtime_core.cpp b/runtime/src/runtime_core.cpp index 07d3b78..07bde8d 100644 --- a/runtime/src/runtime_core.cpp +++ b/runtime/src/runtime_core.cpp @@ -43,7 +43,8 @@ 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") { // Load scheduler.json from the data directory. auto schedPath = config.dataDir / "scheduler.json"; bool schedExisted = std::filesystem::exists(schedPath); @@ -52,6 +53,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()); diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index e07575a..45537b5 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -2,6 +2,7 @@ #include "loom/scheduler_config.h" #include "loom/io_mapper.h" #include "loom/diag/guard.h" +#include "loom/diag/fault_sink.h" #include @@ -9,6 +10,7 @@ #include #include #include +#include #include #include @@ -166,6 +168,32 @@ void Scheduler::setIOMapper(IOMapper* mapper) { ioMapper_ = mapper; } +void Scheduler::recordModuleFault(TaskState& state, LoadedModule& mod, + diag::Phase phase, std::string_view message) { + state.faulted.store(true); + mod.state = ModuleState::Error; + + 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'; + } + + 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(); } @@ -233,6 +261,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; @@ -246,15 +277,13 @@ 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]() { + statePtr->longRunningThread = std::thread([this, &mod, statePtr]() { spdlog::info("Long-running task started for '{}'", mod.id); while (statePtr->running.load()) { bool ok = diag::guard(diag::Phase::LongRunning, mod.id.c_str(), mod.className.c_str(), [&]{ mod.instance->longRunning(); }, [&](const diag::FaultInfo& f) { - statePtr->faulted.store(true); - mod.state = ModuleState::Error; - spdlog::error("Module '{}' faulted in longRunning: {}", mod.id, f.message); + recordModuleFault(*statePtr, mod, f.phase, f.message); }); if (!ok) break; // stop the loop rather than spin-faulting } @@ -764,10 +793,7 @@ void Scheduler::classLoop(ClassRunnerState& runner) { // Mark a member faulted (skipped on subsequent sweeps/ticks via the // faulted checks below) and report — used by the guarded calls. auto faultMember = [&](auto& m, const diag::FaultInfo& f) { - m.state->faulted.store(true); - m.mod->state = ModuleState::Error; - spdlog::error("Module '{}' faulted in {}: {}", - m.moduleId, diag::phaseName(f.phase), f.message); + recordModuleFault(*m.state, *m.mod, f.phase, f.message); }; // --- Sweep 1: preCyclic (e.g. read hardware inputs) --- @@ -949,9 +975,7 @@ void Scheduler::isolatedLoop(LoadedModule& mod, TaskConfig config, TaskState& st diag::guard(diag::Phase::Cyclic, mod.id.c_str(), mod.className.c_str(), [&]{ mod.instance->cyclicGuarded(); }, [&](const diag::FaultInfo& f){ - state.faulted.store(true); - mod.state = ModuleState::Error; - spdlog::error("Module '{}' faulted in cyclic: {}", mod.id, f.message); + recordModuleFault(state, mod, f.phase, f.message); }); } auto t1 = std::chrono::steady_clock::now(); diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index 72a9c43..2dfcb98 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -1,4 +1,6 @@ #include "loom/server.h" +#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()). @@ -273,6 +275,13 @@ static std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& sche json += ",\"maxCycleTimeUs\":" + std::to_string(ts->maxCycleTimeUs.load()); json += ",\"lastJitterUs\":" + std::to_string(ts->lastJitterUs.load()); + // Last-fault diagnostics (faulted modules are skipped by the scheduler). + json += ",\"faulted\":" + std::string(ts->faulted.load() ? "true" : "false"); + json += ",\"lastFaultMs\":" + std::to_string(ts->lastFaultMs.load()); + json += ",\"lastFaultPhase\":\"" + + std::string(diag::phaseName(static_cast(ts->lastFaultPhase.load()))) + "\""; + json += ",\"lastFaultMsg\":\"" + jsonEscapeString(ts->lastFaultMsg) + "\""; + // Add cycle history { std::lock_guard lk(ts->cycleHistoryMx); @@ -357,6 +366,49 @@ void Server::start() { return resp; }); + // ===================================================================== + // 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/faults") + ([this]() { + std::string json = "["; + bool first = true; + for (const auto& s : core_.faultStore().list()) { + if (!first) json += ","; + first = false; + json += "{"; + json += "\"id\":\"" + jsonEscapeString(s.id) + "\""; + json += ",\"ts\":" + std::to_string(s.tsMs); + json += ",\"kind\":\"" + jsonEscapeString(s.kind) + "\""; + json += ",\"module\":\"" + jsonEscapeString(s.moduleId) + "\""; + json += ",\"class\":\"" + jsonEscapeString(s.className) + "\""; + json += ",\"phase\":\"" + jsonEscapeString(s.phase) + "\""; + json += ",\"reason\":\"" + jsonEscapeString(s.reason) + "\""; + 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 /api/faults/ — Full structured report for one fault. + // ===================================================================== + CROW_ROUTE(app, "/api/faults/") + ([this](const std::string& id) { + auto detail = core_.faultStore().detailJson(id); + crow::response resp = detail + ? crow::response(200, *detail) + : crow::response(404, R"({"error":"fault not found"})"); + 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" } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 523ae73..6d48af4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,6 +34,8 @@ target_sources(loom_tests PRIVATE ${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) diff --git a/tests/test_diag.cpp b/tests/test_diag.cpp index ffc25a0..466fe59 100644 --- a/tests/test_diag.cpp +++ b/tests/test_diag.cpp @@ -1,8 +1,12 @@ #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 @@ -66,3 +70,79 @@ TEST(DiagGuard, NonStdThrowCaught) { 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); +} From f995360ed3386131b5908ca720d4fc1d4f72293b Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:50:41 -0700 Subject: [PATCH 14/62] fix(diag): POSIX build portability + lastFault data race (CI + Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the macOS/Linux CI build failures on PR #13 and the Copilot review. Build breaks: - crash_handler.cpp: include for open()/O_* — not transitively available on macOS (clang/libc++), which broke the darwin build. - crash_handler.cpp: size the signal alt-stack with a fixed 64 KiB constant instead of SIGSTKSZ. Since glibc 2.34, SIGSTKSZ expands to a sysconf() call, not a constant, so it can't size a static array (the linux-x64 build error). Correctness (Copilot review): - crash_handler.cpp: sWrite() computed length with std::strlen, which is not async-signal-safe; use a plain loop, restoring the handler's signal-safety. - fault_report.cpp / fault_store.cpp: add / (were relying on transitive includes). - lastFaultMsg data race: recordModuleFault() now writes the last-fault fields BEFORE storing `faulted` with memory_order_release; the server acquire-loads `faulted` and reads the fields only when set. A module faults at most once, so the buffer is effectively write-once and safe under this release/acquire pair. Updated the scheduler.h comment to document the protocol (not a "benign race"). Windows build + diag tests green; POSIX paths covered by CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/include/loom/scheduler.h | 10 +++++++--- runtime/src/diag/crash_handler.cpp | 16 ++++++++++++++-- runtime/src/diag/fault_report.cpp | 1 + runtime/src/diag/fault_store.cpp | 1 + runtime/src/scheduler.cpp | 9 ++++++--- runtime/src/server.cpp | 16 +++++++++++----- 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/runtime/include/loom/scheduler.h b/runtime/include/loom/scheduler.h index c68d873..f0f42ca 100644 --- a/runtime/include/loom/scheduler.h +++ b/runtime/include/loom/scheduler.h @@ -77,9 +77,13 @@ struct TaskState { std::atomic lastJitterUs{0}; ///< |actualStart − prevStart| − period (µs) std::atomic lastCyclicStartNs{0}; ///< Used internally to compute per-module jitter - // Last-fault diagnostics (set when a guarded call throws; surfaced by the - // server). lastFaultMsg is written under the class thread and read by the - // server thread — a benign race for a diagnostic string. + // 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] = {}; diff --git a/runtime/src/diag/crash_handler.cpp b/runtime/src/diag/crash_handler.cpp index dc6cfad..414c07d 100644 --- a/runtime/src/diag/crash_handler.cpp +++ b/runtime/src/diag/crash_handler.cpp @@ -120,13 +120,21 @@ void CrashHandler::install(const CrashConfig& cfg) { #include #include #include +#include // open(), O_WRONLY/O_CREAT/O_TRUNC (not transitively included on macOS) #include namespace loom::diag { namespace { // async-signal-safe helpers -------------------------------------------------- -void sWrite(int fd, const char* s) { ssize_t r = ::write(fd, s, std::strlen(s)); (void)r; } +// 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"; @@ -189,7 +197,11 @@ void CrashHandler::install(const CrashConfig& cfg) { backtrace(g_primeFrames, 4); // Alternate signal stack so a stack-overflow SIGSEGV still has stack to run. - static char altStack[SIGSTKSZ < 65536 ? 65536 : SIGSTKSZ]; + // 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); diff --git a/runtime/src/diag/fault_report.cpp b/runtime/src/diag/fault_report.cpp index 33f7b6f..caab24a 100644 --- a/runtime/src/diag/fault_report.cpp +++ b/runtime/src/diag/fault_report.cpp @@ -2,6 +2,7 @@ #include #include +#include namespace loom::diag { diff --git a/runtime/src/diag/fault_store.cpp b/runtime/src/diag/fault_store.cpp index a413434..1157331 100644 --- a/runtime/src/diag/fault_store.cpp +++ b/runtime/src/diag/fault_store.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 45537b5..ac8bf47 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -170,9 +170,10 @@ void Scheduler::setIOMapper(IOMapper* mapper) { void Scheduler::recordModuleFault(TaskState& state, LoadedModule& mod, diag::Phase phase, std::string_view message) { - state.faulted.store(true); - mod.state = ModuleState::Error; - + // 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()); @@ -183,6 +184,8 @@ void Scheduler::recordModuleFault(TaskState& state, LoadedModule& mod, 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); diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index 2dfcb98..694c319 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -276,11 +276,17 @@ static std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& sche json += ",\"lastJitterUs\":" + std::to_string(ts->lastJitterUs.load()); // Last-fault diagnostics (faulted modules are skipped by the scheduler). - json += ",\"faulted\":" + std::string(ts->faulted.load() ? "true" : "false"); - json += ",\"lastFaultMs\":" + std::to_string(ts->lastFaultMs.load()); - json += ",\"lastFaultPhase\":\"" + - std::string(diag::phaseName(static_cast(ts->lastFaultPhase.load()))) + "\""; - json += ",\"lastFaultMsg\":\"" + jsonEscapeString(ts->lastFaultMsg) + "\""; + // Acquire-load `faulted` and read the last-fault fields only when set: + // this pairs with the release store in Scheduler::recordModuleFault so we + // never read a half-written lastFaultMsg (see scheduler.h). + 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) + "\""; + } // Add cycle history { From a45748b264240920bc7630791451666d285d9148 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:09:00 -0700 Subject: [PATCH 15/62] fix(diag): address Copilot re-review (UAF, quarantine, signal-safety, traversal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five issues from Copilot's second pass: - scheduler stop(): the cyclic/long-running threads hold a raw TaskState*, but stop() erased the TaskState from tasks_ BEFORE joining the threads (join is outside the lock) → use-after-free, made more reachable now the long-running thread calls recordModuleFault on a throw. Extract the owning unique_ptr into a local that outlives the join so the TaskState stays valid until the threads exit. - isolatedLoop(): a quarantined (faulted) module still ran I/O mappings and oscilloscope sampling each tick. Gate those behind !faulted, matching classLoop which skips faulted members entirely. - crash_handler POSIX: the fatal-signal handler called signal()+raise(), and signal() is not async-signal-safe. SA_RESETHAND already resets the disposition to SIG_DFL on entry, so re-raise with kill(getpid(), sig) (async-signal-safe) and drop the signal() call (both the report path and the re-entrant path). - runtime_fault_sink: the report id (→ /.json) embedded the module id, which comes from the HTTP instantiate body — a '/' or '..' could escape the crash dir. Sanitize the module portion to a filename-safe set. - FaultStore::record(): reported success even when the JSON file failed to open. Detect the write failure and log a warning; keep the in-memory entry so a real fault still shows in /api/faults this run (just not persisted across restart). 114 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/src/diag/crash_handler.cpp | 11 +++++-- runtime/src/diag/fault_store.cpp | 15 ++++++++-- runtime/src/diag/runtime_fault_sink.cpp | 17 ++++++++++- runtime/src/scheduler.cpp | 40 +++++++++++++++++-------- 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/runtime/src/diag/crash_handler.cpp b/runtime/src/diag/crash_handler.cpp index 414c07d..60ba4e1 100644 --- a/runtime/src/diag/crash_handler.cpp +++ b/runtime/src/diag/crash_handler.cpp @@ -148,7 +148,10 @@ char g_buildId[256] = {}; // precomputed at install (no formatting in handler void* g_primeFrames[4]; // backtrace priming target void handler(int sig, siginfo_t*, void*) { - if (g_reporting.test_and_set()) { signal(sig, SIG_DFL); raise(sig); _exit(128 + sig); } + // 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); } int fd = ::open(g_reportPath, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd >= 0) { const Breadcrumb& b = tlsBreadcrumb; @@ -164,8 +167,10 @@ void handler(int sig, siginfo_t*, void*) { for (int i = 0; i < n; ++i) { sWrite(fd, " "); sWriteHex(fd, (uintptr_t)frames[i]); sWrite(fd, "\n"); } ::close(fd); } - signal(sig, SIG_DFL); - raise(sig); // re-raise for a core dump with default disposition + // Re-raise for a core dump with the default disposition. SA_RESETHAND already + // reset it to SIG_DFL on entry, so just kill(getpid(), sig) — async-signal-safe, + // unlike signal()/raise(). + kill(getpid(), sig); _exit(128 + sig); } diff --git a/runtime/src/diag/fault_store.cpp b/runtime/src/diag/fault_store.cpp index 1157331..fa62432 100644 --- a/runtime/src/diag/fault_store.cpp +++ b/runtime/src/diag/fault_store.cpp @@ -101,9 +101,18 @@ std::string FaultStore::record(const FaultReport& report) noexcept { std::string json = toJson(report); { std::lock_guard lock(mx_); - auto path = crashDir_ / (report.id + ".json"); - std::ofstream f(path, std::ios::binary | std::ios::trunc); - if (f) f << json; + 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; diff --git a/runtime/src/diag/runtime_fault_sink.cpp b/runtime/src/diag/runtime_fault_sink.cpp index 8ca19f1..8ac5df0 100644 --- a/runtime/src/diag/runtime_fault_sink.cpp +++ b/runtime/src/diag/runtime_fault_sink.cpp @@ -10,6 +10,7 @@ #include #include +#include #ifndef LOOM_BUILD_TYPE #define LOOM_BUILD_TYPE "unknown" @@ -28,6 +29,20 @@ std::string safeRead(DataEngine& engine, const std::string& id, DataSection sec) 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) { @@ -37,7 +52,7 @@ void RuntimeFaultSink::onModuleFault(const FaultEvent& ev) { std::chrono::system_clock::now().time_since_epoch()).count()); FaultReport r; - r.id = ev.moduleId + "-" + std::to_string(nowMs) + "-" + + 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; diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index ac8bf47..069a183 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -333,6 +333,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_); @@ -369,11 +374,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(); @@ -983,18 +993,22 @@ void Scheduler::isolatedLoop(LoadedModule& mod, TaskConfig config, TaskState& st } 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); From 4344ce24ad4b354850ca272f7a9446644803ee96 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:40:59 -0700 Subject: [PATCH 16/62] feat(diag): symbolized JSON crash reports on POSIX (mac/linux), not just raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mac/linux a segfault previously produced only a raw loom-crash-.txt (async-signal-safe path), so the viewer showed it as "raw" with no frames — while Windows already wrote a structured, symbolized JSON report. Make both platforms produce the same structured report. - crash_handler: extract a shared writeStructuredReport() (FaultReport + cpptrace symbolize + JSON) used by both the Windows UEF and the POSIX signal handler. The POSIX handler now writes the async-signal-safe raw .txt FIRST (guaranteed fallback), then attempts the structured .json best-effort. The FaultReport/JSON is fully built in memory then written in one shot, so the file is complete or absent — never partial. g_reporting still guards reentry. Also stamp a timestamp (system_clock/clock_gettime is async-signal-safe) so signal-path reports sort/display correctly. - fault_store scanDir: when both loom-crash-.json and .txt exist for one crash, surface only the .json (the .txt is the raw fallback it supersedes). - test: DiagFaultStore.JsonSupersedesRawTxtSibling covers both the json-wins-over-txt and txt-only (structured pass failed) cases. Note: symbol *quality* on mac still depends on debug info (a Debug build with a .dSYM gives file:line; otherwise function names / addresses) — that's the separate offline-symbolize/symbolsDir track. The report is structured either way. Windows build + diag tests green; POSIX path validated by CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/src/diag/crash_handler.cpp | 164 +++++++++++++++++------------ runtime/src/diag/fault_store.cpp | 14 ++- tests/test_diag.cpp | 44 ++++++++ 3 files changed, 152 insertions(+), 70 deletions(-) diff --git a/runtime/src/diag/crash_handler.cpp b/runtime/src/diag/crash_handler.cpp index 60ba4e1..9f5c781 100644 --- a/runtime/src/diag/crash_handler.cpp +++ b/runtime/src/diag/crash_handler.cpp @@ -5,6 +5,7 @@ #include "loom/version.h" #include +#include #include #include #include @@ -18,49 +19,38 @@ #endif // ============================================================================ -// Crash handler. Phase 1: capture (breadcrumb + signal/exception + build id + -// raw stack addresses) and write a text crash report, then let the process die. -// Symbolization (cpptrace) and structured JSON come in later phases. +// Crash handler. Captures the faulting thread's breadcrumb + a stack trace and +// writes a crash report, then lets the process die. // -// POSIX handler bodies must be async-signal-safe: only open()/write()/_exit and -// raw reads — no malloc/printf/ofstream/locks. The Windows unhandled-exception -// filter is NOT signal-constrained, so it may use richer calls. +// 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] = {}; // precomputed at install - -void buildIdentityLine(char* buf, size_t n) { - std::snprintf(buf, n, "build: sdk=%s git=%s type=%s", - loom::kSdkVersion, loom::kGitSha, LOOM_BUILD_TYPE); -} - -} // namespace -} // namespace loom::diag - -// --------------------------------------------------------------------------- -#if defined(_WIN32) -// --------------------------------------------------------------------------- -#include // CaptureStackBackTrace (RtlCaptureStackBackTrace, in kernel32) - -namespace loom::diag { -namespace { - -char g_reportId[256] = {}; // crash-report id / filename stem (precomputed at install) - -// Build a structured JSON crash report and write it to g_reportPath. The Windows -// unhandled-exception filter is NOT async-signal-constrained, so we may allocate: -// symbolize in-process via cpptrace and serialize the same FaultReport the -// exception path uses, so signal-path crashes surface in /api/faults too. -void writeReportWin(FaultKind kind, const char* reason, int code, - void* const* frames, unsigned nframes) { +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 : ""; @@ -78,6 +68,17 @@ void writeReportWin(FaultKind kind, const char* reason, int code, 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]; @@ -85,7 +86,7 @@ LONG WINAPI unhandledFilter(EXCEPTION_POINTERS* ep) { const DWORD code = ep ? ep->ExceptionRecord->ExceptionCode : 0UL; char reason[64]; std::snprintf(reason, sizeof reason, "SEH exception 0x%08lx", code); - writeReportWin(FaultKind::Signal, reason, static_cast(code), frames, n); + writeStructuredReport(FaultKind::Signal, reason, static_cast(code), frames, n); return EXCEPTION_EXECUTE_HANDLER; // run default handler → terminate } @@ -93,8 +94,8 @@ void terminateHandler() { if (!g_reporting.test_and_set()) { void* frames[64]; unsigned n = CaptureStackBackTrace(0, 64, frames, nullptr); - writeReportWin(FaultKind::Signal, "std::terminate (unhandled C++ exception)", - 0, frames, n); + writeStructuredReport(FaultKind::Signal, "std::terminate (unhandled C++ exception)", + 0, frames, n); } std::abort(); } @@ -104,8 +105,7 @@ void terminateHandler() { 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()); + 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); @@ -126,6 +126,10 @@ void CrashHandler::install(const CrashConfig& cfg) { 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(). @@ -144,46 +148,65 @@ void sWriteHex(int fd, uintptr_t v) { sWrite(fd, "0x"); ssize_t r = ::write(fd, p, (buf + i) - p); (void)r; } -char g_buildId[256] = {}; // precomputed at install (no formatting in handler) -void* g_primeFrames[4]; // backtrace priming target +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); +} void handler(int sig, siginfo_t*, void*) { // 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); } - int fd = ::open(g_reportPath, O_WRONLY | O_CREAT | O_TRUNC, 0644); - if (fd >= 0) { - const Breadcrumb& b = tlsBreadcrumb; - sWrite(fd, "=== Loom crash report ===\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"); - void* frames[64]; - int n = backtrace(frames, 64); - for (int i = 0; i < n; ++i) { sWrite(fd, " "); sWriteHex(fd, (uintptr_t)frames[i]); sWrite(fd, "\n"); } - ::close(fd); - } - // Re-raise for a core dump with the default disposition. SA_RESETHAND already - // reset it to SIG_DFL on entry, so just kill(getpid(), sig) — async-signal-safe, - // unlike signal()/raise(). + + void* frames[64]; + int n = backtrace(frames, 64); + + // 1. Guaranteed: async-signal-safe raw report. + writeRawReport(sig, frames, 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, static_cast(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()) { - int fd = ::open(g_reportPath, O_WRONLY | O_CREAT | O_TRUNC, 0644); - if (fd >= 0) { - sWrite(fd, "=== Loom crash report ===\nstd::terminate (unhandled C++ exception)\n"); - sWrite(fd, g_buildId); sWrite(fd, "\n"); - void* frames[64]; int n = backtrace(frames, 64); - for (int i = 0; i < n; ++i) { sWrite(fd, " "); sWriteHex(fd, (uintptr_t)frames[i]); sWrite(fd, "\n"); } - ::close(fd); - } + 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(); } @@ -193,9 +216,14 @@ void terminateHandler() { void CrashHandler::install(const CrashConfig& cfg) { std::error_code ec; std::filesystem::create_directories(cfg.crashDir, ec); - auto path = (cfg.crashDir / ("loom-crash-" + std::to_string((long)getpid()) + ".txt")).string(); - std::snprintf(g_reportPath, sizeof g_reportPath, "%s", path.c_str()); - buildIdentityLine(g_buildId, sizeof g_buildId); + + 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. diff --git a/runtime/src/diag/fault_store.cpp b/runtime/src/diag/fault_store.cpp index fa62432..af345c5 100644 --- a/runtime/src/diag/fault_store.cpp +++ b/runtime/src/diag/fault_store.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -68,6 +69,14 @@ 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(); @@ -78,8 +87,9 @@ void FaultStore::scanDir() { 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 report (raw addresses) — wrap the text so the - // viewer can show it; symbolize offline via `loom --symbolize`. + // 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}, diff --git a/tests/test_diag.cpp b/tests/test_diag.cpp index 466fe59..b81f5da 100644 --- a/tests/test_diag.cpp +++ b/tests/test_diag.cpp @@ -6,7 +6,9 @@ #include #include +#include #include +#include #include #include @@ -146,3 +148,45 @@ TEST(DiagFaultStore, RecordListDetailRoundTrip) { 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); +} From d4383080bef46d29da6fc8548f87217d3976a8e3 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:00:13 -0700 Subject: [PATCH 17/62] build(diag): generate + ship debug symbols for in-process crash symbolization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cpptrace symbolizes crash frames from BOTH the runtime and the crashing module, but the shipped (Release) builds carried no debug info and the release tarball shipped no symbol files — so a downloaded runtime resolved only raw addresses. Now optimized builds emit debug info and the symbols ride along next to the binaries, so crash reports resolve to function/file:line with zero setup. - cmake/LoomModule.cmake (new): loom_add_module() + loom_target_debug_info() + loom_target_dsym(). Installed with the SDK and included by loomConfig, so module authors who find_package(loom) build plugins with symbols handled the same way the bundled modules are. Defines the LOOM_WITH_DEBUG_INFO option (ON). - root CMakeLists: for optimized configs (Release/RelWithDebInfo), add MSVC /Zi + /DEBUG + /OPT:REF,ICF (PDB, kept lean) or GCC/Clang -g (embedded DWARF on Linux; .dSYM on macOS via the helper). Covers loom + loom_runtime + all bundled modules with no per-module edits. - modules: emit a .dSYM per module on macOS (explicit list so SOEM isn't dsym'd). - runtime: emit loom.dSYM on macOS; install loom.pdb (Windows) / loom.dSYM (macOS) next to the binary. Linux DWARF is embedded (unstripped), nothing extra. - sdk: install LoomModule.cmake into lib/cmake/loom and include it from loomConfig so user modules get loom_add_module(). - CI build.yml: stage loom's and each module's symbols into the tarball (PDBs / .dSYM bundles), per the "symbols in the runtime tarball" choice. Verified on Windows Release: loom.pdb + per-module PDBs generated, binaries stay lean (/OPT), loom.pdb installs to bin/, LoomModule.cmake installs to lib/cmake/loom. macOS .dSYM + Linux DWARF paths validated by CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 14 ++++++ CMakeLists.txt | 30 ++++++++++++ cmake/LoomModule.cmake | 96 +++++++++++++++++++++++++++++++++++++ modules/CMakeLists.txt | 14 ++++++ runtime/CMakeLists.txt | 18 +++++++ sdk/CMakeLists.txt | 1 + sdk/loom-config.cmake.in | 5 ++ 7 files changed, 178 insertions(+) create mode 100644 cmake/LoomModule.cmake diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c98fed..3070a5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -177,6 +177,13 @@ jobs: exit 1 fi + # Debug symbols next to the loom binary, so crash reports symbolize + # in-process on the downloaded runtime. Windows: loom.pdb; macOS: + # loom.dSYM bundle (both installed to install/bin by the runtime + # CMakeLists). Linux needs nothing — DWARF is embedded in the binary. + cp install/bin/loom.pdb "${STAGE}/" 2>/dev/null || true + cp -R install/bin/loom.dSYM "${STAGE}/" 2>/dev/null || true + # 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 +198,13 @@ 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: .dSYM sits + # next to the module. Linux: DWARF is embedded in the .so. + base="$(basename "$mod")"; stem="${base%.*}" + [ -f "output/${stem}.pdb" ] && cp "output/${stem}.pdb" "${STAGE}/modules/" || true + [ -d "${mod}.dSYM" ] && cp -R "${mod}.dSYM" "${STAGE}/modules/" || true done fi diff --git a/CMakeLists.txt b/CMakeLists.txt index 03f0c6d..ea7748b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,36 @@ 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. +if(LOOM_WITH_DEBUG_INFO) + 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") diff --git a/cmake/LoomModule.cmake b/cmake/LoomModule.cmake new file mode 100644 index 0000000..d4359f5 --- /dev/null +++ b/cmake/LoomModule.cmake @@ -0,0 +1,96 @@ +# 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() + +# 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. +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() + + 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}) +endfunction() diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index ec95bdb..cef2a69 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -11,3 +11,17 @@ add_subdirectory(oscilloscope) add_subdirectory(pneumatic_actuator) add_subdirectory(sequencer) add_subdirectory(stack_light) + +# Debug info for these modules comes from the global flags in the root +# CMakeLists (LOOM_WITH_DEBUG_INFO). On macOS, additionally emit a .dSYM next to +# each module binary so crash frames in module code resolve to file:line +# (no-op on Linux/Windows). Listed explicitly so we don't dsymutil third-party +# targets (e.g. SOEM under ethercat). +if(COMMAND loom_target_dsym) + foreach(_loom_mod class_based command_probe crasher ethercat example_motor + oscilloscope pneumatic_actuator sequencer stack_light) + if(TARGET ${_loom_mod}) + loom_target_dsym(${_loom_mod}) + endif() + endforeach() +endif() diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index a314d77..9815bea 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -69,6 +69,13 @@ target_link_libraries(loom_runtime PRIVATE cpptrace::cpptrace) add_executable(loom src/main.cpp) target_link_libraries(loom PRIVATE loom_runtime) +# macOS: emit loom.dSYM next to the binary for in-process crash symbolization +# (Windows PDB / Linux DWARF are produced by the global debug-info flags). Guarded +# so a standalone runtime build (without the root helper) still configures. +if(COMMAND loom_target_dsym) + loom_target_dsym(loom) +endif() + # --------------------------------------------------------------------------- # Frontend UI — symlink data/UI → frontend/dist for live development editing. # On install, the dist contents are copied instead (see install rule below). @@ -117,6 +124,17 @@ install(TARGETS loom RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) +# Ship debug symbols next to the binary so crash reports symbolize in-process on +# the downloaded runtime. OPTIONAL: absent when LOOM_WITH_DEBUG_INFO is OFF. +# (Linux needs nothing extra — DWARF is embedded in the unstripped binary.) +if(MSVC) + install(FILES $ + DESTINATION ${CMAKE_INSTALL_BINDIR} OPTIONAL) +elseif(APPLE) + install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/loom.dSYM + DESTINATION ${CMAKE_INSTALL_BINDIR} OPTIONAL) +endif() + install(DIRECTORY "${CMAKE_SOURCE_DIR}/frontend/dist/" DESTINATION data/UI OPTIONAL diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index a9880e5..fef09c3 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -92,6 +92,7 @@ write_basic_package_version_file( 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/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") From f55744d88f6ee0d5264c7b93ef4cc84213265f9a Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:09:03 -0700 Subject: [PATCH 18/62] fix(build): move macOS .dSYM generation to CI (add_custom_command dir rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS build failed at Configure: add_custom_command(TARGET) can only attach to a target created in the SAME directory, but modules/CMakeLists.txt called loom_target_dsym() on module targets created in their subdirectories. Windows passed only because the .dSYM step is APPLE-gated. .dSYM isn't a build byproduct (unlike the PDB/embedded-DWARF that compilation already emits) — it needs an explicit dsymutil pass, and only the *distributed* binary needs it (local macOS dev resolves via the build-tree debug map). So: - modules/CMakeLists.txt + runtime/CMakeLists.txt: drop the in-repo loom_target_dsym() calls and the macOS .dSYM install rule. - CI build.yml staging: run dsymutil on the staged loom binary and each staged module on macOS (the .o debug map is still present in the job), producing .dSYM bundles next to the binaries in the tarball. - LoomModule.cmake keeps loom_target_dsym for loom_add_module(), where it's same-directory (the user creates the target in that call) and valid. Windows Release reconfigure clean; macOS .dSYM path now in CI staging. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 23 +++++++++++++++-------- modules/CMakeLists.txt | 17 +++++------------ runtime/CMakeLists.txt | 17 ++++------------- 3 files changed, 24 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3070a5d..66167b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -178,11 +178,16 @@ jobs: fi # Debug symbols next to the loom binary, so crash reports symbolize - # in-process on the downloaded runtime. Windows: loom.pdb; macOS: - # loom.dSYM bundle (both installed to install/bin by the runtime - # CMakeLists). Linux needs nothing — DWARF is embedded in the binary. - cp install/bin/loom.pdb "${STAGE}/" 2>/dev/null || true - cp -R install/bin/loom.dSYM "${STAGE}/" 2>/dev/null || true + # 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. @@ -200,11 +205,13 @@ jobs: 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: .dSYM sits - # next to the module. Linux: DWARF is embedded in the .so. + # 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 - [ -d "${mod}.dSYM" ] && cp -R "${mod}.dSYM" "${STAGE}/modules/" || true + if [ "${{ runner.os }}" = "macOS" ]; then + dsymutil "${STAGE}/modules/${base}" -o "${STAGE}/modules/${base}.dSYM" || true + fi done fi diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index cef2a69..60e0b1f 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -13,15 +13,8 @@ add_subdirectory(sequencer) add_subdirectory(stack_light) # Debug info for these modules comes from the global flags in the root -# CMakeLists (LOOM_WITH_DEBUG_INFO). On macOS, additionally emit a .dSYM next to -# each module binary so crash frames in module code resolve to file:line -# (no-op on Linux/Windows). Listed explicitly so we don't dsymutil third-party -# targets (e.g. SOEM under ethercat). -if(COMMAND loom_target_dsym) - foreach(_loom_mod class_based command_probe crasher ethercat example_motor - oscilloscope pneumatic_actuator sequencer stack_light) - if(TARGET ${_loom_mod}) - loom_target_dsym(${_loom_mod}) - endif() - endforeach() -endif() +# 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/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 9815bea..96276fe 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -69,13 +69,6 @@ target_link_libraries(loom_runtime PRIVATE cpptrace::cpptrace) add_executable(loom src/main.cpp) target_link_libraries(loom PRIVATE loom_runtime) -# macOS: emit loom.dSYM next to the binary for in-process crash symbolization -# (Windows PDB / Linux DWARF are produced by the global debug-info flags). Guarded -# so a standalone runtime build (without the root helper) still configures. -if(COMMAND loom_target_dsym) - loom_target_dsym(loom) -endif() - # --------------------------------------------------------------------------- # Frontend UI — symlink data/UI → frontend/dist for live development editing. # On install, the dist contents are copied instead (see install rule below). @@ -124,15 +117,13 @@ install(TARGETS loom RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) -# Ship debug symbols next to the binary so crash reports symbolize in-process on -# the downloaded runtime. OPTIONAL: absent when LOOM_WITH_DEBUG_INFO is OFF. -# (Linux needs nothing extra — DWARF is embedded in the unstripped binary.) +# 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) -elseif(APPLE) - install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/loom.dSYM - DESTINATION ${CMAKE_INSTALL_BINDIR} OPTIONAL) endif() install(DIRECTORY "${CMAKE_SOURCE_DIR}/frontend/dist/" From 041e549f1e73801a7d1c151c73d422e9df5ef9e5 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:03:26 -0700 Subject: [PATCH 19/62] fix(diag): unwind from the faulting context on POSIX (keep the leaf frame) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POSIX crash reports dropped the actual fault site: the trace jumped from the signal trampoline straight to the *caller* (the return address of the call into the crashing function) and degraded into a 0x0 tail. Not a debug-info problem — the frame was lost at capture time. Cause: the signal handler captured with backtrace(), which walks the handler's own stack. Crossing the signal trampoline it can only recover the saved return address (losing the live faulting PC), and the alternate signal stack (SA_ONSTACK) is discontinuous from the faulting thread's stack so the frame-pointer walk can't cross back, yielding the 0x0 tail. Fix (signal handler only): seed the unwind from the ucontext_t the handler already receives (SA_SIGINFO). Take the faulting PC + FP, set frame 0 = PC (the real leaf), then walk the saved frame-record chain ([fp]=caller fp, [fp+8]=return address). This starts from the faulting thread's real stack, sidestepping both the trampoline and the alt-stack. Register layouts for macOS/Linux × arm64/x86_64; falls back to backtrace() elsewhere. Apple arm64e return addresses are PAC-stripped (ptrauth_strip) so cpptrace can resolve them. Async-signal-safe: only aligned reads, no allocation; alignment + up-stack-only checks stop a runaway walk. writeRawReport (.txt) is still written before the allocating symbolize pass. terminateHandler and the Windows path are unchanged (they run in a normal call context where backtrace()/CaptureStackBackTrace work). POSIX-only change; verify on macOS per the crasher repro (frame 0 should be the module's faulting function at its real source line, no 0x0 tail). Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/src/diag/crash_handler.cpp | 78 ++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/runtime/src/diag/crash_handler.cpp b/runtime/src/diag/crash_handler.cpp index 9f5c781..27ab7cd 100644 --- a/runtime/src/diag/crash_handler.cpp +++ b/runtime/src/diag/crash_handler.cpp @@ -1,3 +1,9 @@ +// 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" @@ -121,8 +127,14 @@ void CrashHandler::install(const CrashConfig& cfg) { #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 #include +#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 { @@ -177,22 +189,78 @@ void writeRawReport(int sig, void* const* frames, int n) { ::close(fd); } -void handler(int sig, siginfo_t*, void*) { +// 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]; - int n = backtrace(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, n); + 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, static_cast(n)); + writeStructuredReport(FaultKind::Signal, signalName(sig), sig, frames, n); // Re-raise for a core dump with the default disposition (async-signal-safe). kill(getpid(), sig); From ad14deef0ffa5d7836a4393d8977656062df82d0 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:10:16 -0700 Subject: [PATCH 20/62] fix(diag): use on macOS for the in-context unwind macOS's hard-errors ("deprecated ucontext routines require _XOPEN_SOURCE") because it declares the get/setcontext routines. We only need the mcontext_t TYPES (uc_mcontext->__ss), which provides without those routines. Keep on Linux (REG_RIP/REG_RBP via _GNU_SOURCE). Linux + Windows already compiled; this fixes the darwin build. Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/src/diag/crash_handler.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/runtime/src/diag/crash_handler.cpp b/runtime/src/diag/crash_handler.cpp index 27ab7cd..508e38a 100644 --- a/runtime/src/diag/crash_handler.cpp +++ b/runtime/src/diag/crash_handler.cpp @@ -127,9 +127,18 @@ void CrashHandler::install(const CrashConfig& cfg) { #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 #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 From bcf88d74f53dda8b58cffee9d8f47fe76fd33571 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Sat, 27 Jun 2026 14:46:15 -0700 Subject: [PATCH 21/62] feat(command): implement IFunctionBlock interface for CommandFb and add EnableFb class --- sdk/include/loom/command.h | 78 ++++++++++++++++++++++++++++++++++---- tests/test_command_fb.cpp | 2 + 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/sdk/include/loom/command.h b/sdk/include/loom/command.h index bafcc62..a608ed4 100644 --- a/sdk/include/loom/command.h +++ b/sdk/include/loom/command.h @@ -102,22 +102,63 @@ class CommandChannel { 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 { +class CommandFb : public IFunctionBlock { public: bool execute = false; BufferMode buffer_mode = BufferMode::Aborting; - bool busy = false; - bool active = false; - bool done = false; - bool command_aborted = false; - bool error = false; - uint32_t error_id = 0; + // 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). @@ -179,4 +220,27 @@ class CommandFb { 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/tests/test_command_fb.cpp b/tests/test_command_fb.cpp index d237a35..7a4d8ca 100644 --- a/tests/test_command_fb.cpp +++ b/tests/test_command_fb.cpp @@ -26,6 +26,7 @@ struct PokeFb : CommandFb { 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). @@ -146,6 +147,7 @@ TEST(CommandRoundTrip, ProviderCompletesThroughChannel) { } commit(r, s); } + void update() override { tick(); } // satisfy IFunctionBlock }; ChFb fb; fb.ch = &ch; From a37666b0e2968bcfec33b80d3587141eb0102cb2 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Sat, 27 Jun 2026 19:07:51 -0700 Subject: [PATCH 22/62] feat(glaze): add Glaze metadata for function-block bases to enable reflection --- .../skills/cruntime-module-authoring/SKILL.md | 20 +++++- sdk/include/loom/command_glaze.h | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 sdk/include/loom/command_glaze.h 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/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)); +}; From 2c4018da5480d158249cfee9bd21980a22433643 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Sat, 27 Jun 2026 21:21:22 -0700 Subject: [PATCH 23/62] feat(module): make longRunning() optional and improve scheduler handling --- README.md | 6 +++- modules/command_probe/command_probe.cpp | 1 - modules/crasher/crasher.cpp | 1 - modules/example_motor/motor_module.cpp | 4 --- modules/oscilloscope/oscilloscope.cpp | 1 - .../pneumatic_actuator/pneumatic_actuator.cpp | 1 - modules/sequencer/sequencer.cpp | 1 - modules/stack_light/stack_light.cpp | 1 - runtime/src/scheduler.cpp | 21 +++++++++++++ sdk/include/loom/module.h | 31 ++++++++++++++++++- 10 files changed, 56 insertions(+), 12 deletions(-) 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/modules/command_probe/command_probe.cpp b/modules/command_probe/command_probe.cpp index 439224d..83014f7 100644 --- a/modules/command_probe/command_probe.cpp +++ b/modules/command_probe/command_probe.cpp @@ -45,7 +45,6 @@ class CommandProbe : public loom::Module { } void exit() override {} - void longRunning() override {} private: loom::CommandChannel channel_; diff --git a/modules/crasher/crasher.cpp b/modules/crasher/crasher.cpp index 0bb64f1..13e7a43 100644 --- a/modules/crasher/crasher.cpp +++ b/modules/crasher/crasher.cpp @@ -47,7 +47,6 @@ class Crasher : public loom::ModulelongRunningOptedOut()) { + 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); }); diff --git a/sdk/include/loom/module.h b/sdk/include/loom/module.h index 691e0ad..76d4d13 100644 --- a/sdk/include/loom/module.h +++ b/sdk/include/loom/module.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -61,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 @@ -207,6 +232,10 @@ class IModule { 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 From 78d030c95d470b827a3ba4a06ac2bd512d792280 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Sun, 28 Jun 2026 20:17:26 -0700 Subject: [PATCH 24/62] feat(tag-table): enhance indexing for nested glz::meta types in TagTable --- sdk/include/loom/tag_table.hpp | 9 ++++++- tests/test_tag_table.cpp | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) 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/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 // --------------------------------------------------------------------------- From f196b669f097aa3fe8482bddf248404abc6239e9 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:16:05 -0700 Subject: [PATCH 25/62] feat(diag): add system metrics tracking for memory and CPU usage --- runtime/CMakeLists.txt | 7 + runtime/include/loom/diag/system_metrics.h | 79 ++++++++ runtime/include/loom/runtime_core.h | 6 + runtime/src/diag/system_metrics.cpp | 199 +++++++++++++++++++++ runtime/src/run.cpp | 5 + runtime/src/runtime_core.cpp | 5 +- runtime/src/server.cpp | 43 ++++- 7 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 runtime/include/loom/diag/system_metrics.h create mode 100644 runtime/src/diag/system_metrics.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 96276fe..da1faca 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -42,8 +42,15 @@ add_library(loom_runtime STATIC src/diag/fault_report.cpp src/diag/fault_store.cpp src/diag/runtime_fault_sink.cpp + src/diag/system_metrics.cpp ) +# 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. +if(WIN32) + target_link_libraries(loom_runtime PRIVATE psapi) +endif() + target_include_directories(loom_runtime PUBLIC $ $ diff --git a/runtime/include/loom/diag/system_metrics.h b/runtime/include/loom/diag/system_metrics.h new file mode 100644 index 0000000..9b8b52b --- /dev/null +++ b/runtime/include/loom/diag/system_metrics.h @@ -0,0 +1,79 @@ +#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) + }; + + 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 4958517..3391c49 100644 --- a/runtime/include/loom/runtime_core.h +++ b/runtime/include/loom/runtime_core.h @@ -5,6 +5,7 @@ #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" @@ -38,6 +39,9 @@ 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. + bool logSystemMetrics = false; }; /// Central runtime controller. Owns all subsystems and module lifecycle operations. @@ -96,6 +100,7 @@ class RuntimeCore : public IModuleRegistry { 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_; } @@ -142,6 +147,7 @@ class RuntimeCore : public IModuleRegistry { // 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/src/diag/system_metrics.cpp b/runtime/src/diag/system_metrics.cpp new file mode 100644 index 0000000..b4fa43e --- /dev/null +++ b/runtime/src/diag/system_metrics.cpp @@ -0,0 +1,199 @@ +#include "loom/diag/system_metrics.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); + 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 (running_.exchange(true)) return; + startTime_ = std::chrono::steady_clock::now(); + thread_ = std::thread(&SystemMetrics::run, this); +} + +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; + if (cpuPct < 0.0) cpuPct = 0.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/run.cpp b/runtime/src/run.cpp index 847a8ed..5f76a57 100644 --- a/runtime/src/run.cpp +++ b/runtime/src/run.cpp @@ -88,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"; } @@ -103,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]; @@ -118,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; @@ -162,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/runtime_core.cpp b/runtime/src/runtime_core.cpp index 07bde8d..0eb3b27 100644 --- a/runtime/src/runtime_core.cpp +++ b/runtime/src/runtime_core.cpp @@ -44,7 +44,10 @@ std::filesystem::path resolveModulePath(const RuntimeConfig& config, RuntimeCore::RuntimeCore(const RuntimeConfig& config) : config_(config), dataStore_(config.dataDir), watcher_(config.moduleDir), - faultStore_(config.dataDir / "crash") { + faultStore_(config.dataDir / "crash"), + systemMetrics_(diag::SystemMetrics::Config{ .logEnabled = config.logSystemMetrics }) { + // Begin sampling process memory/CPU; served on /api/system + the WS frame. + systemMetrics_.start(); // Load scheduler.json from the data directory. auto schedPath = config.dataDir / "scheduler.json"; bool schedExisted = std::filesystem::exists(schedPath); diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index 694c319..422c1ab 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -415,6 +415,35 @@ void Server::start() { return resp; }); + // ===================================================================== + // GET /api/system — Process resource metrics (memory + CPU) + history. + // ===================================================================== + CROW_ROUTE(app, "/api/system") + ([this]() { + const auto cur = core_.systemMetrics().current(); + const auto hist = core_.systemMetrics().history(); + std::string json = "{"; + json += "\"ts\":" + std::to_string(cur.tsMs); + json += ",\"rssBytes\":" + std::to_string(cur.rssBytes); + json += ",\"peakRssBytes\":" + std::to_string(cur.peakRssBytes); + json += ",\"cpuPercent\":" + std::to_string(cur.cpuPercent); + json += ",\"uptimeSec\":" + std::to_string(cur.uptimeSec); + json += ",\"history\":["; + bool first = true; + for (const auto& s : hist) { + if (!first) json += ","; + first = false; + json += "{\"ts\":" + std::to_string(s.tsMs) + + ",\"rssBytes\":" + std::to_string(s.rssBytes) + + ",\"cpuPercent\":" + std::to_string(s.cpuPercent) + "}"; + } + 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/modules/instantiate — Create a new instance from a .so // Body: { "id": "left_motor", "so": "libexample_motor.so" } @@ -1558,6 +1587,7 @@ void Server::start() { std::unordered_map moduleFragments; std::unordered_map runtimeFragments; std::string classesTail; + std::string systemTail; { std::shared_lock lock(core_.moduleMutex()); @@ -1602,7 +1632,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 @@ -1631,6 +1671,7 @@ void Server::start() { first = false; } msg += classesTail; + msg += systemTail; conn->send_binary(msg); } } From 3dc6a0f943210534f38bbd46b73f01f8d9024705 Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:56:25 -0700 Subject: [PATCH 26/62] fix(diag): drop defaulted Config arg on SystemMetrics ctor (GCC/Clang) dev build #30 failed to compile on linux + macOS: `SystemMetrics(Config cfg = {})` makes the `= {}` default value-initialize the nested Config via its default member initializers within the enclosing class definition, which GCC/Clang reject ("default member initializer ... needed within definition of enclosing class"). MSVC accepted it, so Windows passed. RuntimeCore always passes a Config explicitly, so just remove the default argument. Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/include/loom/diag/system_metrics.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/runtime/include/loom/diag/system_metrics.h b/runtime/include/loom/diag/system_metrics.h index 9b8b52b..ce09e29 100644 --- a/runtime/include/loom/diag/system_metrics.h +++ b/runtime/include/loom/diag/system_metrics.h @@ -40,7 +40,10 @@ class SystemMetrics { int historyMax = 600; ///< samples retained for charting (~10 min @ 1s) }; - explicit SystemMetrics(Config cfg = {}); + // 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; From 27920946fe9788de83bf30a365c0b3297db1274e Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Mon, 29 Jun 2026 22:26:22 -0700 Subject: [PATCH 27/62] fix(scheduler): define applyAffinityPolicy on all platforms The realtime-policy calls in classLoop/isolatedLoop are #if-guarded per platform, but the adjacent applyAffinityPolicy() call is not. On any platform that isn't Apple/Windows/Linux (e.g. an Emscripten/WASM build) the function has no definition and the unguarded call fails to compile. Add an #else no-op definition. No behavior change on supported platforms. Co-Authored-By: Claude Opus 4.8 --- runtime/src/scheduler.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 13f6536..019d6dd 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -139,6 +139,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 --------------------------------------------------- From 7781c44875d73d840c200113a27fc96bf3e9bd05 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Mon, 29 Jun 2026 22:26:22 -0700 Subject: [PATCH 28/62] refactor(runtime): split loom_runtime into loom_core + native host Separate the portable engine (scheduler, data engine/store, module loader, IO mapper, oscilloscope, portable diagnostics) into a new loom_core static lib with no networking/signal dependencies. Keep loom_runtime as the native host layer (Crow HTTP/WS + OPC-UA servers, process lifecycle/signals, in-process crash capture) which now PUBLIC-links loom_core and aggregates it. loom_runtime keeps its name, so the installed and conan-consumed target still provides the full runtime exactly as before; conanfile now lists both libs (dependent before dependency). This isolates Crow/cpptrace from the engine, makes the core unit-testable without a server, and is the basis for an out-of-tree Emscripten/WASM host that links loom_core only. Co-Authored-By: Claude Opus 4.8 --- runtime/CMakeLists.txt | 49 +++++++++++++++++++++++++++++++----------- runtime/conanfile.py | 4 +++- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 96276fe..3cfbbf3 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -19,11 +19,16 @@ 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 @@ -32,33 +37,51 @@ add_library(loom_runtime STATIC 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/diag/breadcrumb.cpp - src/diag/crash_handler.cpp - src/diag/symbolizer.cpp src/diag/fault_report.cpp src/diag/fault_store.cpp src/diag/runtime_fault_sink.cpp ) -target_include_directories(loom_runtime PUBLIC +target_include_directories(loom_core PUBLIC $ $ ) # Build type stamped into crash reports (works for single- and multi-config). -target_compile_definitions(loom_runtime PRIVATE LOOM_BUILD_TYPE="$") +target_compile_definitions(loom_core PRIVATE LOOM_BUILD_TYPE="$") -target_link_libraries(loom_runtime PUBLIC +target_link_libraries(loom_core PUBLIC loom::sdk spdlog::spdlog - Crow::Crow ${CMAKE_DL_LIBS} ) +# --------------------------------------------------------------------------- +# 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) @@ -105,7 +128,7 @@ endif() # --------------------------------------------------------------------------- include(GNUInstallDirs) -install(TARGETS loom_runtime +install(TARGETS loom_runtime loom_core ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ) diff --git a/runtime/conanfile.py b/runtime/conanfile.py index bb57980..145b9f5 100644 --- a/runtime/conanfile.py +++ b/runtime/conanfile.py @@ -39,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"] From bd79709317d68d473e90b334af02893908fae508 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Mon, 29 Jun 2026 22:33:12 -0700 Subject: [PATCH 29/62] refactor(scheduler): extract sweepClassOnce; add cooperative tickOnce() Pull the per-tick sweep (preCyclic -> cyclic -> I/O map -> postCyclic, plus per-member and per-class metrics) out of classLoop into a private sweepClassOnce(runner) -- a verbatim move, so the native class thread runs identical logic. Add a public tickOnce() that drives every class's members once, cooperatively, on the calling thread: no spin/sleep, no RT thread policy, no pause handshake. This lets a single-threaded host (e.g. the WASM build) own the outer loop and drive the scheduler without spawning class threads, and as a side effect makes the sweep unit-testable without threads. No behavior change for the native threaded path (verified: native build + link unchanged). Co-Authored-By: Claude Opus 4.8 --- runtime/include/loom/scheduler.h | 13 ++ runtime/src/scheduler.cpp | 211 ++++++++++++++++--------------- 2 files changed, 125 insertions(+), 99 deletions(-) diff --git a/runtime/include/loom/scheduler.h b/runtime/include/loom/scheduler.h index f0f42ca..3c42a75 100644 --- a/runtime/include/loom/scheduler.h +++ b/runtime/include/loom/scheduler.h @@ -149,6 +149,14 @@ class Scheduler { /// Idempotent: already-running classes are skipped. void startClasses(); + /// Run exactly one cooperative scheduling pass over every class's members + /// (preCyclic → cyclic → I/O map → postCyclic), in place on the calling + /// thread — no class threads spawned, no sleeping. For single-threaded hosts + /// (e.g. the WASM build) that drive the runtime from an external loop instead + /// of startClasses(). Class periods/priorities become advisory metadata here, + /// not enforced timing. Do NOT mix with startClasses() on the same instance. + 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 @@ -289,6 +297,11 @@ 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. diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 019d6dd..31f333e 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -751,6 +751,114 @@ 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 every class that has members, + // once, in place. No spin/sleep/RT policy and no pause handshake (there are + // no class threads to coordinate with). Used by hosts that own the loop. + for (auto& [name, runnerPtr] : classes_) { + ClassRunnerState& runner = *runnerPtr; + if (runner.members.empty()) continue; + + 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); + } +} + // ---- classLoop ------------------------------------------------------------------ void Scheduler::classLoop(ClassRunnerState& runner) { @@ -827,105 +935,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(); - - // Mark a member faulted (skipped on subsequent sweeps/ticks 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: 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; - 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); - - // 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) --- { From 550f43bee76adbd488e8d2caa2326647f1b97281 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Mon, 29 Jun 2026 22:45:05 -0700 Subject: [PATCH 30/62] feat(scheduler): honor per-class periods in cooperative tickOnce() tickOnce() now anchors each class to a steady-clock deadline (coopNextDue) and advances it by the class's live period each run, with the same missed-deadline re-anchoring guard as classLoop. Classes therefore keep independent rates under a single cooperative driver (fast vs slow), capped by how often the host calls tickOnce() -- restoring multi-rate scheduling for the thread-free path. Isolated-thread and long-running modules remain threaded-only (documented in tickOnce); a thread-free host must route them into a class. Co-Authored-By: Claude Opus 4.8 --- runtime/include/loom/scheduler.h | 7 +++++++ runtime/src/scheduler.cpp | 31 ++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/runtime/include/loom/scheduler.h b/runtime/include/loom/scheduler.h index 3c42a75..49691f6 100644 --- a/runtime/include/loom/scheduler.h +++ b/runtime/include/loom/scheduler.h @@ -254,6 +254,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() { diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 31f333e..4724409 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -844,18 +844,43 @@ void Scheduler::sweepClassOnce(ClassRunnerState& runner) { } void Scheduler::tickOnce() { - // Cooperative single-threaded drive: sweep every class that has members, - // once, in place. No spin/sleep/RT policy and no pause handshake (there are - // no class threads to coordinate with). Used by hosts that own the loop. + // 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. + const auto now = 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 = now; + if (now < 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 periodUs = std::chrono::microseconds(runner.livePeriodUs.load()); + runner.coopNextDue += periodUs; + if (runner.coopNextDue <= now) { + const auto behind = std::chrono::duration_cast( + now - runner.coopNextDue); + runner.coopNextDue += periodUs * (behind / periodUs + 1); + } } } From 67dc99dc0bf42b0bc9cbac344a403304204c7dc1 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Mon, 29 Jun 2026 22:52:11 -0700 Subject: [PATCH 31/62] feat(runtime): cooperative mode + Emscripten host (run_wasm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RuntimeConfig::cooperative — when set, loadModules() skips startClasses() and setupWatcher(), so no class threads or file-watcher thread are spawned and the host drives execution via Scheduler::tickOnce(). Default false; native is unchanged. Add runtime/src/run_wasm.cpp: a thread-free Emscripten host (no Crow, no signals) that builds a cooperative RuntimeCore and exposes a small C API to JS — loom_init/loom_tick/loom_state_json/loom_shutdown. Linked against loom_core (not loom_runtime) as a MAIN_MODULE, it boots the full engine in wasm: DataStore persists to MEMFS, the scheduler config loads, and tickOnce() runs. Verified via a node smoke test (boots + ticks with zero modules). Co-Authored-By: Claude Opus 4.8 --- runtime/include/loom/runtime_core.h | 6 +++ runtime/src/run_wasm.cpp | 82 +++++++++++++++++++++++++++++ runtime/src/runtime_core.cpp | 8 +-- 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 runtime/src/run_wasm.cpp diff --git a/runtime/include/loom/runtime_core.h b/runtime/include/loom/runtime_core.h index 4958517..ae1f0be 100644 --- a/runtime/include/loom/runtime_core.h +++ b/runtime/include/loom/runtime_core.h @@ -38,6 +38,12 @@ struct RuntimeConfig { std::vector additionalModuleDirs; std::filesystem::path dataDir = "./data"; std::chrono::milliseconds defaultCyclePeriod{100}; + + /// Cooperative (thread-free) mode: skip starting class threads and the file + /// watcher in loadModules(). The host drives execution by calling + /// scheduler().tickOnce() itself. Used by single-threaded hosts (the WASM + /// build); leave false for the normal threaded runtime. + bool cooperative = false; }; /// Central runtime controller. Owns all subsystems and module lifecycle operations. diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp new file mode 100644 index 0000000..01e9f02 --- /dev/null +++ b/runtime/src/run_wasm.cpp @@ -0,0 +1,82 @@ +// 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 do neither, so this host +// drives RuntimeCore *cooperatively*: it loads modules in cooperative mode (no +// class threads, no file watcher) and steps the scheduler from JavaScript via +// Scheduler::tickOnce(). State is read back as JSON through the same DataEngine +// the server would expose. No Crow, no signals, no threads. +// +// Built only for Emscripten and linked against loom_core (NOT loom_runtime). +#ifdef __EMSCRIPTEN__ + +#include "loom/runtime_core.h" +#include "loom/types.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { +std::unique_ptr g_core; + +// Copy a std::string into a malloc'd C buffer the JS side reads then free()s. +char* dupString(const std::string& s) { + char* out = static_cast(std::malloc(s.size() + 1)); + if (out) std::memcpy(out, s.c_str(), s.size() + 1); + return out; +} +} // namespace + +extern "C" { + +// Boot the runtime. `moduleDir`/`dataDir` are paths in the Emscripten FS. +// Modules already present in moduleDir are discovered and loaded cooperatively. +// 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 = true; // no class threads, no file watcher + g_core = std::make_unique(cfg); + auto ids = g_core->loadModules(); + spdlog::info("loom_init: {} module(s) loaded", ids.size()); + return static_cast(ids.size()); + } catch (const std::exception& e) { + spdlog::error("loom_init failed: {}", e.what()); + g_core.reset(); + return -1; + } +} + +// Advance every due class one cooperative pass. Call from JS on a timer / rAF. +EMSCRIPTEN_KEEPALIVE +void loom_tick() { + if (g_core) g_core->scheduler().tickOnce(); +} + +// Reflected runtime state of one module instance, as a JSON string. Returns a +// malloc'd C string the caller must free() (null on error). +EMSCRIPTEN_KEEPALIVE +char* loom_state_json(const char* moduleId) { + if (!g_core || !moduleId) return nullptr; + std::string js = g_core->dataEngine().readSection(moduleId, loom::DataSection::Runtime); + return dupString(js); +} + +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 07bde8d..1cc28d4 100644 --- a/runtime/src/runtime_core.cpp +++ b/runtime/src/runtime_core.cpp @@ -160,9 +160,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; } @@ -200,9 +200,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; } From b17f8af8c3659f9864121873df5f03c34eebd187 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Mon, 29 Jun 2026 23:01:52 -0700 Subject: [PATCH 32/62] feat(scheduler): guard thread spawns for thread-free (wasm) hosts scheduler.start() spawns std::thread for long-running and isolated modules, and startClasses() spawns a thread per class. wasm has no threads, so these three spawn sites are now #ifndef __EMSCRIPTEN__. On wasm an isolated module is routed into a class instead so tickOnce() still sweeps it; long-running background work is skipped. Native is unchanged (it takes the #ifndef branch). With this, a real Loom module (built as a SIDE_MODULE) loads via dlopen in the wasm host, registers, and its cyclic() runs under cooperative tickOnce() with reflected state advancing. run_wasm gains loom_module_ids() for the JS harness. Co-Authored-By: Claude Opus 4.8 --- runtime/src/run_wasm.cpp | 16 +++++++++++++--- runtime/src/scheduler.cpp | 12 ++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp index 01e9f02..1b5966a 100644 --- a/runtime/src/run_wasm.cpp +++ b/runtime/src/run_wasm.cpp @@ -21,9 +21,11 @@ #include #include #include +#include namespace { std::unique_ptr g_core; +std::vector g_ids; // ids of modules loaded by loom_init // Copy a std::string into a malloc'd C buffer the JS side reads then free()s. char* dupString(const std::string& s) { @@ -47,9 +49,9 @@ int loom_init(const char* moduleDir, const char* dataDir) { cfg.dataDir = dataDir ? dataDir : "/data"; cfg.cooperative = true; // no class threads, no file watcher g_core = std::make_unique(cfg); - auto ids = g_core->loadModules(); - spdlog::info("loom_init: {} module(s) loaded", ids.size()); - return static_cast(ids.size()); + 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(); @@ -63,6 +65,14 @@ void loom_tick() { if (g_core) g_core->scheduler().tickOnce(); } +// Comma-separated ids of the modules loaded by loom_init. malloc'd; caller free()s. +EMSCRIPTEN_KEEPALIVE +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 dupString(s); +} + // Reflected runtime state of one module instance, as a JSON string. Returns a // malloc'd C string the caller must free() (null on error). EMSCRIPTEN_KEEPALIVE diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 4724409..1a98789 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -288,6 +288,7 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon // Start long-running thread immediately (independent of class membership). if (config.enableLongRunning) { +#ifndef __EMSCRIPTEN__ // wasm has no threads; background long-running work is skipped statePtr->longRunningThread = std::thread([this, &mod, statePtr]() { spdlog::info("Long-running task started for '{}'", mod.id); while (statePtr->running.load()) { @@ -321,14 +322,23 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon } spdlog::info("Long-running task ended for '{}'", mod.id); }); +#endif // __EMSCRIPTEN__ } const bool isIsolated = config.isolateThread || config.cyclicClass.empty(); if (isIsolated) { +#ifndef __EMSCRIPTEN__ statePtr->cyclicThread = std::thread([this, &mod, config, statePtr]() { isolatedLoop(mod, config, *statePtr); }); +#else + // No threads in wasm: 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); @@ -349,9 +359,11 @@ void Scheduler::startClasses() { if (runner->members.empty() || runner->running.load()) continue; runner->running.store(true); +#ifndef __EMSCRIPTEN__ // wasm drives classes cooperatively via tickOnce(), no 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())); From d9a8e57c2de4971046ccbf9f4f71facdcd1c4828 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Mon, 29 Jun 2026 23:46:08 -0700 Subject: [PATCH 33/62] build(wasm): build the Emscripten runtime host via CMake + Conan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a first-class WebAssembly build alongside the native one. Build graph: - runtime/CMakeLists builds loom_core + a new loom_host_wasm executable (run_wasm.cpp; MAIN_MODULE so modules dlopen at runtime; MODULARIZE, ENVIRONMENT=web,worker,node) under EMSCRIPTEN, in place of the native loom_runtime + loom. Top-level CMakeLists skips modules/ and tests/ for wasm. Dependencies — one source of truth, no drift: - conanfile.py gates Crow/cpptrace/GTest behind settings.os != Emscripten; glaze + spdlog are shared. conan/profiles/emscripten resolves them for the Emscripten profile (spdlog header_only) and chains the emsdk toolchain (via $EMSDK) into Conan's generated CMake toolchain. - CMakeLists uses find_package(glaze)+find_package(spdlog) for both targets; for wasm it aliases spdlog::spdlog -> the header-only target Conan provides, defines FMT_CONSTEVAL= (emcc's clang rejects fmt's consteval) and builds with -fexceptions (matches the module ABI across dlopen). The crash-symbolization -g block is skipped for wasm (no cpptrace there; it would ~10x the .wasm). No bespoke CMake preset — the Conan-generated conan-release preset is used, the same way native uses conan-debug. 'just setup-wasm' installs emsdk + runs the Conan Emscripten install; 'just build-wasm' configures + builds. Verified: just setup-wasm + build-wasm -> lean ~2.3MB output/loom_wasm.{js,wasm} that boots + drives tickOnce() under node (and a real module dlopens + ticks via the spike harness). Native build + conan-debug preset unaffected. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 37 +++++++++++++++++++++++++++++-------- conan/profiles/emscripten | 27 +++++++++++++++++++++++++++ conanfile.py | 18 ++++++++++++------ justfile | 23 +++++++++++++++++++++++ runtime/CMakeLists.txt | 23 +++++++++++++++++++++++ 5 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 conan/profiles/emscripten diff --git a/CMakeLists.txt b/CMakeLists.txt index ea7748b..276b833 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,7 +47,9 @@ include(LoomModule) # 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. -if(LOOM_WITH_DEBUG_INFO) +# 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") @@ -80,16 +82,35 @@ 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 thread-free and +# 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) -find_package(cpptrace REQUIRED) # runtime-only: crash-report symbolization +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) +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 and tests are native-only for now (modules need a SIDE_MODULE +# build path; tests need GTest). The WASM build produces just the runtime host. +if(NOT EMSCRIPTEN) + add_subdirectory(modules) + enable_testing() + add_subdirectory(tests) +endif() 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 6ddce5a..755dcb5 100644 --- a/conanfile.py +++ b/conanfile.py @@ -15,11 +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") - self.requires("cpptrace/0.8.3") # runtime-only: crash-report stack symbolization - 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/justfile b/justfile index b1cbe8a..a7f3d64 100644 --- a/justfile +++ b/justfile @@ -25,6 +25,29 @@ 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. +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/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 3cfbbf3..5a748cc 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -59,6 +59,27 @@ target_link_libraries(loom_core PUBLIC ${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; driven +# from JavaScript via Scheduler::tickOnce(). 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']") + +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 @@ -163,3 +184,5 @@ install(DIRECTORY "${CMAKE_SOURCE_DIR}/frontend/dist/" OPTIONAL ) +endif() # EMSCRIPTEN / native host + From 6325cb8ba827369d8a74e740d9f778d41c30c010 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 09:14:10 -0700 Subject: [PATCH 34/62] feat(api): transport-agnostic REST dispatch shared by native server + wasm Introduce loom::api::dispatch(RuntimeCore&, Request) -> Response, a Crow-free router for the /api/* surface, plus loom/api/json_build.h holding the shared JSON builders (jsonEscapeString, serializeCycleHistory, moduleInfoJson) moved verbatim out of server.cpp. The native HTTP server and the wasm host now build identical response bodies from one source -- no drift. First routes: GET /api/modules and GET /api/modules/. run_wasm exposes loom_request(method, path, body); the React service will intercept fetch('/api/*') and call it, so UI built for the native runtime hits the in-browser runtime unchanged. Verified: native build green (server.cpp now uses the shared builders); wasm node test loads a module and GET /api/modules returns JSON identical to the server's. Remaining routes migrate the same way; loom_load_module + the React service next. Co-Authored-By: Claude Opus 4.8 --- runtime/CMakeLists.txt | 1 + runtime/include/loom/api/json_build.h | 28 +++++ runtime/include/loom/api/router.h | 36 ++++++ runtime/src/api/router.cpp | 159 ++++++++++++++++++++++++++ runtime/src/run_wasm.cpp | 16 +++ runtime/src/server.cpp | 102 +---------------- 6 files changed, 245 insertions(+), 97 deletions(-) create mode 100644 runtime/include/loom/api/json_build.h create mode 100644 runtime/include/loom/api/router.h create mode 100644 runtime/src/api/router.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5a748cc..7965ba6 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -39,6 +39,7 @@ add_library(loom_core STATIC src/io_mapper.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 diff --git a/runtime/include/loom/api/json_build.h b/runtime/include/loom/api/json_build.h new file mode 100644 index 0000000..082fbdb --- /dev/null +++ b/runtime/include/loom/api/json_build.h @@ -0,0 +1,28 @@ +#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 + +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). +std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler); + +} // namespace loom diff --git a/runtime/include/loom/api/router.h b/runtime/include/loom/api/router.h new file mode 100644 index 0000000..d373770 --- /dev/null +++ b/runtime/include/loom/api/router.h @@ -0,0 +1,36 @@ +#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 { + +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/src/api/router.cpp b/runtime/src/api/router.cpp new file mode 100644 index 0000000..053a6ca --- /dev/null +++ b/runtime/src/api/router.cpp @@ -0,0 +1,159 @@ +#include "loom/api/router.h" +#include "loom/api/json_build.h" + +#include "loom/runtime_core.h" +#include "loom/diag/breadcrumb.h" // diag::phaseName + +#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) { + 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 += "}"; + } + json += "}"; + return json; +} + +// --------------------------------------------------------------------------- +// 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; +} + +namespace { +Response json(int status, std::string body) { return {status, "application/json", std::move(body)}; } +Response notFound() { return json(404, "{\"error\":\"no matching route\"}"); } +} + +Response dispatch(RuntimeCore& core, const Request& req) { + // GET /api/modules — list all loaded modules + if (req.method == Method::GET && req.path == "/api/modules") { + std::shared_lock lock(core.moduleMutex()); + std::string body = "["; + bool first = true; + for (auto& [id, mod] : core.loader().modules()) { + if (!first) body += ","; + body += moduleInfoJson(mod, core.scheduler()); + first = false; + } + body += "]"; + return json(200, std::move(body)); + } + + // GET /api/modules/ — one module + if (req.method == Method::GET && req.path.rfind("/api/modules/", 0) == 0) { + std::string id = req.path.substr(std::string_view("/api/modules/").size()); + if (!id.empty() && id.find('/') == std::string::npos) { + std::shared_lock lock(core.moduleMutex()); + auto* mod = core.loader().get(id); + if (!mod) return json(404, "{\"error\":\"module not found\"}"); + return json(200, moduleInfoJson(*mod, core.scheduler())); + } + } + + return notFound(); +} + +} // namespace api +} // namespace loom diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp index 1b5966a..645f251 100644 --- a/runtime/src/run_wasm.cpp +++ b/runtime/src/run_wasm.cpp @@ -11,6 +11,7 @@ #ifdef __EMSCRIPTEN__ #include "loom/runtime_core.h" +#include "loom/api/router.h" #include "loom/types.h" #include @@ -73,6 +74,21 @@ char* loom_module_ids() { return dupString(s); } +// Route a REST request (method, path, body) through the same transport-agnostic +// dispatcher the native HTTP server uses. Returns the response body as a malloc'd +// C string (caller free()s); the JS service intercepts fetch('/api/*') and calls +// this. (Status code wiring comes with the React service; body is enough here.) +EMSCRIPTEN_KEEPALIVE +char* loom_request(const char* method, const char* path, const char* body) { + if (!g_core) return nullptr; + 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); + return dupString(resp.body); +} + // Reflected runtime state of one module instance, as a JSON string. Returns a // malloc'd C string the caller must free() (null on error). EMSCRIPTEN_KEEPALIVE diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index 694c319..ea308a2 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -1,4 +1,5 @@ #include "loom/server.h" +#include "loom/api/json_build.h" // shared jsonEscapeString / serializeCycleHistory / moduleInfoJson #include "loom/diag/breadcrumb.h" #include "loom/diag/fault_store.h" @@ -121,19 +122,8 @@ static ClassInfoDto makeClassInfoDto(const ClassDef& def, } // 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) { @@ -154,43 +144,7 @@ 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; -} +// serializeCycleHistory (vector + deque overloads) → loom/api/json_build.h // Build a {"samples":[...],"latest":N} JSON body from raw samples. // `since` : drop samples with t <= since (unbinned) or whose bin start <= since (binned). @@ -253,53 +207,7 @@ static std::string buildHistoryBody(const std::vector& samples, } // 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()); - - // Last-fault diagnostics (faulted modules are skipped by the scheduler). - // Acquire-load `faulted` and read the last-fault fields only when set: - // this pairs with the release store in Scheduler::recordModuleFault so we - // never read a half-written lastFaultMsg (see scheduler.h). - 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) + "\""; - } - - // Add cycle history - { - std::lock_guard lk(ts->cycleHistoryMx); - auto histVec = ts->cycleHistory.getAll(); - json += ",\"cycleHistory\":" + serializeCycleHistory(histVec); - } - - json += "}"; - } - json += "}"; - return json; -} +// moduleInfoJson → loom/api/json_build.h (shared with the WASM api router) Server::Server(RuntimeCore& core, const ServerConfig& config) : core_(core), config_(config) {} From e1262073b7946ef991e61c9ba5543fbecdac0de0 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 10:00:21 -0700 Subject: [PATCH 35/62] =?UTF-8?q?feat(wasm):=20runtime-as-a-service=20?= =?UTF-8?q?=E2=80=94=20fetch('/api/*')=20served=20by=20the=20in-browser=20?= =?UTF-8?q?runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status-aware loom_request (returns a {status,body} envelope so a JS fetch shim can build a Response) + loom_load_module (load arbitrary user-module bytes at runtime via RuntimeCore::uploadModule). Plus a framework-agnostic browser service (frontend/src/wasm/loomRuntime.js) that boots the wasm runtime, drives the tick loop, loads modules, and routes fetch('/api/*') through loom_request -- and a thin React wrapper. Result: an UNMODIFIED fetch('/api/modules') (exactly what rest.ts issues) is served by the in-browser runtime, with a dynamically-loaded module, HTTP 200, re-polling live. Verified in a browser (spike/phaseA-visible) and under node. Live OPC-UA values (useVariable) are a separate transport, deferred to Phase B. Note: RuntimeCore::uploadModule returns the filename stem as the instance id while the module registers under its header name -- /api/modules is correct; the return value is cosmetic for now. Co-Authored-By: Claude Opus 4.8 --- frontend/src/wasm/LoomRuntimeProvider.tsx | 51 +++++++++++++++ frontend/src/wasm/loomRuntime.js | 79 +++++++++++++++++++++++ runtime/src/run_wasm.cpp | 30 +++++++-- 3 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 frontend/src/wasm/LoomRuntimeProvider.tsx create mode 100644 frontend/src/wasm/loomRuntime.js diff --git a/frontend/src/wasm/LoomRuntimeProvider.tsx b/frontend/src/wasm/LoomRuntimeProvider.tsx new file mode 100644 index 0000000..55b4096 --- /dev/null +++ b/frontend/src/wasm/LoomRuntimeProvider.tsx @@ -0,0 +1,51 @@ +// React wrapper around the framework-agnostic Loom-in-wasm runtime service. +// +// Wrap (a subtree of) the app in and, while it's mounted, +// fetch('/api/*') is served by the in-browser wasm runtime instead of a real +// HTTP server — so the existing REST-driven UI (rest.ts, DataService) works with +// no changes. Live OPC-UA values are a separate transport (not yet faked). +import React, { createContext, useContext, useEffect, useState } from 'react'; +// @ts-expect-error — plain-JS service module (see loomRuntime.js) +import { createLoomRuntime } from './loomRuntime.js'; + +export type LoomRuntime = Awaited>; + +const Ctx = createContext(null); +export const useLoomRuntime = (): LoomRuntime | null => useContext(Ctx); + +export interface LoomRuntimeProviderProps { + /** Emscripten factory, e.g. () => (window as any).createLoomModule() or an import. */ + createModule: () => Promise; + /** Optional modules to load at boot. */ + modules?: { name: string; bytes: Uint8Array }[]; + /** Rendered while the runtime is booting. */ + fallback?: React.ReactNode; + children: React.ReactNode; +} + +export function LoomRuntimeProvider({ + createModule, + modules = [], + fallback =
Booting Loom runtime…
, + children, +}: LoomRuntimeProviderProps) { + const [rt, setRt] = useState(null); + + useEffect(() => { + let uninstall = () => {}; + let runtime: LoomRuntime | null = null; + let cancelled = false; + (async () => { + const r = await createLoomRuntime({ createModule }); + if (cancelled) { r.stop(); return; } + for (const m of modules) r.loadModule(m.name, m.bytes); + uninstall = r.installFetch(); // route /api/* to the wasm runtime + runtime = r; + setRt(r); + })(); + return () => { cancelled = true; uninstall(); runtime?.stop(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return {rt ? children : fallback}; +} diff --git a/frontend/src/wasm/loomRuntime.js b/frontend/src/wasm/loomRuntime.js new file mode 100644 index 0000000..9af6c17 --- /dev/null +++ b/frontend/src/wasm/loomRuntime.js @@ -0,0 +1,79 @@ +// 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. + */ +export async function createLoomRuntime({ createModule, tickMs = 25 } = {}) { + 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', []), + }; + + c.init('/modules', '/data'); + 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; }; + } + + function stop() { if (timer) { clearInterval(timer); timer = null; } } + + return { Module: M, request, loadModule, moduleIds, makeFetch, installFetch, stop }; +} diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp index 645f251..862480f 100644 --- a/runtime/src/run_wasm.cpp +++ b/runtime/src/run_wasm.cpp @@ -75,18 +75,38 @@ char* loom_module_ids() { } // Route a REST request (method, path, body) through the same transport-agnostic -// dispatcher the native HTTP server uses. Returns the response body as a malloc'd -// C string (caller free()s); the JS service intercepts fetch('/api/*') and calls -// this. (Status code wiring comes with the React service; body is enough here.) +// dispatcher the native HTTP server uses. Returns a malloc'd JSON envelope +// {"status":,"body":} (caller free()s) so the JS fetch shim can +// build a Response with the right status. Body is embedded raw (every handler +// returns valid JSON). EMSCRIPTEN_KEEPALIVE char* loom_request(const char* method, const char* path, const char* body) { - if (!g_core) return nullptr; + if (!g_core) return dupString("{\"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); - return dupString(resp.body); + std::string env = "{\"status\":" + std::to_string(resp.status) + + ",\"body\":" + (resp.body.empty() ? "null" : resp.body) + "}"; + return dupString(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 cooperatively. Returns the loaded module id +// (empty string on failure). malloc'd; caller free()s. +EMSCRIPTEN_KEEPALIVE +char* loom_load_module(const char* path) { + if (!g_core || !path) return dupString(""); + try { + std::string id = g_core->uploadModule(path); + if (!id.empty()) g_ids.push_back(id); + return dupString(id); + } catch (const std::exception& e) { + spdlog::error("loom_load_module('{}') failed: {}", path, e.what()); + return dupString(""); + } } // Reflected runtime state of one module instance, as a JSON string. Returns a From c3ddf5ce49820d696fdd18da4f3a35661c3448b9 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 11:09:47 -0700 Subject: [PATCH 36/62] feat(wasm): OPC-UA reflected node read/write + WasmMachine (live useVariable) Host loom_read_node/loom_write_node map OPC-UA node ids (ns=1;s=/module//
/) onto IModule readField/writeField via the shared opcua_rest_nodeid.h parser -- the same address space the native OPC-UA facade serves. loomRuntime gains readNode/writeNode, and frontend/src/wasm/ WasmMachine.ts is a drop-in for @loupeteam/lux-connect's OpcuaMachine (connect, connectionState, onConnectionStateChanged, readVariable, writeVariable, and poll-based subscribe/unsubscribe) so lux-react's useVariable() gets live in-browser values with no component changes. Verified: node (read whole section + a field; write recipe/speed=5 -> pos climbs by 5/tick) and in-browser (live runtime/pos|ticks updating; reflected write wired). Capstone next: swap the machine in api/machine.ts behind a wasm flag so the real lux-react app renders against it. Co-Authored-By: Claude Opus 4.8 --- frontend/src/wasm/WasmMachine.ts | 70 ++++++++++++++++++++++++++++++++ frontend/src/wasm/loomRuntime.js | 10 ++++- runtime/src/run_wasm.cpp | 36 ++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 frontend/src/wasm/WasmMachine.ts diff --git a/frontend/src/wasm/WasmMachine.ts b/frontend/src/wasm/WasmMachine.ts new file mode 100644 index 0000000..5dccdd2 --- /dev/null +++ b/frontend/src/wasm/WasmMachine.ts @@ -0,0 +1,70 @@ +// 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. +import { ConnectionState } from '@loupeteam/lux-connect'; + +/** 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: ConnectionState) => 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(): ConnectionState { return ConnectionState.CONNECTED; } + isConnected(): boolean { return true; } + async connect(): Promise { this.handlers.forEach((h) => h(ConnectionState.CONNECTED)); } + async disconnect(): Promise {} + async reconnect(): Promise {} + async testConnection(): Promise {} + onConnectionStateChanged(handler: StateHandler): void { + this.handlers.add(handler); + handler(ConnectionState.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/frontend/src/wasm/loomRuntime.js b/frontend/src/wasm/loomRuntime.js index 9af6c17..3488c50 100644 --- a/frontend/src/wasm/loomRuntime.js +++ b/frontend/src/wasm/loomRuntime.js @@ -23,6 +23,8 @@ export async function createLoomRuntime({ createModule, tickMs = 25 } = {}) { 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']), }; c.init('/modules', '/data'); @@ -73,7 +75,13 @@ export async function createLoomRuntime({ createModule, tickMs = 25 } = {}) { 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, makeFetch, installFetch, stop }; + return { Module: M, request, loadModule, moduleIds, readNode, writeNode, makeFetch, installFetch, stop }; } diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp index 862480f..6a95b03 100644 --- a/runtime/src/run_wasm.cpp +++ b/runtime/src/run_wasm.cpp @@ -12,6 +12,7 @@ #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 @@ -118,6 +119,41 @@ char* loom_state_json(const char* moduleId) { return dupString(js); } +// --- 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. malloc'd; caller free()s. +EMSCRIPTEN_KEEPALIVE +char* loom_read_node(const char* nodeId) { + if (!g_core || !nodeId) return dupString("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 dupString("null"); + std::shared_lock lock(g_core->moduleMutex()); + auto* mod = g_core->loader().get(p.moduleId); + if (!mod || !mod->instance) return dupString("null"); + if (p.kind == K::Section) return dupString(mod->instance->readSection(p.section)); + auto v = mod->instance->readField(p.section, p.fieldPointer); + return dupString(v ? *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(); } From 38d6a6dfb89009d5ad033971b85080f481a0da38 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 12:06:18 -0700 Subject: [PATCH 37/62] feat(wasm): run the real frontend against the in-browser runtime (?wasm=1) Wire the actual app to the wasm runtime: main.tsx awaits bootMachine() (loads loom_wasm.js, boots the runtime, loads modules, installs the fetch shim, returns a WasmMachine), App takes the machine as a prop, machine.ts skips creating the real OpcuaMachine in wasm mode (it has a module-level side effect imported via node()). Adds GET /api/modules//data/
to the shared dispatch (the ModuleDetail REST fallback). Verified in a browser: the real Loom UI renders against the in-browser runtime -- Module Dashboard lists DemoModule (RUNNING) from /api/modules via the fetch shim, ModuleDetail shows the live pos/ticks runtime tree via useVariable -> WasmMachine, connection indicator green. PUNCH-LIST surfaced by integration: - ?wasm=1 query flag is fragile (SPA navigation drops it -> stray OpcuaMachine + 502s). Right fix: a build-time flag (the shipped website is built wasm-mode) or a persisted one. - Remaining /api routes to migrate (scheduler, scope, bus, faults, config/recipe load+save, data write, instantiate, available...). Best done by making server.cpp delegate wholesale to dispatch. - boot.ts WASM_MODULES is hardcoded to the demo; should load the app's modules. Co-Authored-By: Claude Opus 4.8 --- frontend/src/App.tsx | 7 ++++-- frontend/src/api/machine.ts | 21 +++++++++++------ frontend/src/main.tsx | 15 ++++++++----- frontend/src/wasm/boot.ts | 45 +++++++++++++++++++++++++++++++++++++ runtime/src/api/router.cpp | 14 ++++++++++++ 5 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 frontend/src/wasm/boot.ts 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..ea5cdc3 100644 --- a/frontend/src/api/machine.ts +++ b/frontend/src/api/machine.ts @@ -6,14 +6,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 = new URLSearchParams(loc.search).has('wasm'); -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..c07e680 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,14 @@ 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. +bootMachine().then((machine) => { + createRoot(document.getElementById('root')!).render( + + + , + ) +}) diff --git a/frontend/src/wasm/boot.ts b/frontend/src/wasm/boot.ts new file mode 100644 index 0000000..89b15fb --- /dev/null +++ b/frontend/src/wasm/boot.ts @@ -0,0 +1,45 @@ +// 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. +// @ts-expect-error — plain-JS runtime service (see loomRuntime.js) +import { createLoomRuntime } from './loomRuntime.js'; +import { WasmMachine } from './WasmMachine'; + +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. For now the bundled demo; later, the + * application's own SIDE_MODULE .wasm files (built via loom_add_module). */ +const WASM_MODULES = ['demo_module.so']; + +export async function bootMachine(): Promise { + const useWasm = new URLSearchParams(location.search).has('wasm'); + if (!useWasm) { + 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/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 053a6ca..9a8e04d 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -2,6 +2,7 @@ #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 @@ -150,6 +151,19 @@ Response dispatch(RuntimeCore& core, const Request& req) { if (!mod) return json(404, "{\"error\":\"module not found\"}"); return json(200, moduleInfoJson(*mod, core.scheduler())); } + + // GET /api/modules//data/
— reflected section JSON + auto slash = id.find('/'); + if (slash != std::string::npos) { + std::string modId = id.substr(0, slash); + std::string tail = id.substr(slash + 1); // "data/
" + if (!modId.empty() && tail.rfind("data/", 0) == 0) { + if (auto sec = opcrest::sectionFromName(tail.substr(5))) { + std::shared_lock lock(core.moduleMutex()); + return json(200, core.dataEngine().readSection(modId, *sec)); + } + } + } } return notFound(); From 21794af0dbe88633ef89c1200dacb72588eee886 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 12:11:34 -0700 Subject: [PATCH 38/62] fix(wasm): build-time/sticky wasm-mode flag (replaces fragile ?wasm query) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isWasmMode() is the single source of truth: production is BUILT in wasm mode (VITE_WASM=1), the shipped-website path; in dev, ?wasm=1 is persisted in sessionStorage so SPA navigation (which drops the query) can't silently fall back to native and spin up a stray OpcuaMachine. boot.ts + machine.ts use it. Verified in-browser: navigating to ModuleDetail with NO query keeps wasm mode (sticky) — zero console errors, no more /api/1.0/auth 502 storm. Co-Authored-By: Claude Opus 4.8 --- frontend/src/api/machine.ts | 3 ++- frontend/src/wasm/boot.ts | 4 ++-- frontend/src/wasm/wasmMode.ts | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 frontend/src/wasm/wasmMode.ts diff --git a/frontend/src/api/machine.ts b/frontend/src/api/machine.ts index ea5cdc3..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,7 +7,7 @@ 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 = new URLSearchParams(loc.search).has('wasm'); +const useWasm = isWasmMode(); // 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() diff --git a/frontend/src/wasm/boot.ts b/frontend/src/wasm/boot.ts index 89b15fb..b656203 100644 --- a/frontend/src/wasm/boot.ts +++ b/frontend/src/wasm/boot.ts @@ -10,6 +10,7 @@ // @ts-expect-error — plain-JS runtime service (see loomRuntime.js) import { createLoomRuntime } from './loomRuntime.js'; import { WasmMachine } from './WasmMachine'; +import { isWasmMode } from './wasmMode'; function loadScript(src: string): Promise { return new Promise((resolve, reject) => { @@ -26,8 +27,7 @@ function loadScript(src: string): Promise { const WASM_MODULES = ['demo_module.so']; export async function bootMachine(): Promise { - const useWasm = new URLSearchParams(location.search).has('wasm'); - if (!useWasm) { + if (!isWasmMode()) { return (await import('../api/machine')).machine; // native: real OpcuaMachine } 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; + } +} From 1910c09a433691cb79e1436d10973c6eeebdf23b Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 12:17:00 -0700 Subject: [PATCH 39/62] feat(api): migrate GET /api/scheduler/classes to dispatch (Scheduler page on wasm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the scheduler classes route (ClassInfoDto/ClassStatsDto + makeClassInfoDto) to the shared dispatch so the Scheduler page renders against the in-browser runtime. DTOs live in a NAMED namespace — glaze reflection requires the reflected type to have linkage (anonymous-namespace types fail). Verified in-browser: Scheduler Timeline shows normal/slow/fast from the wasm runtime, DemoModule on 'normal', no errors. NOTE: these DTOs duplicate server.cpp's for now — the right consolidation is to make server.cpp delegate wholesale to dispatch() (a catch-all) and host one copy here. Remaining routes follow the same pattern: scope/*, bus/*, faults, modules/available, config+recipe load/save, data write, instantiate, reassign. Co-Authored-By: Claude Opus 4.8 --- runtime/src/api/router.cpp | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 9a8e04d..fc538bf 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -5,6 +5,8 @@ #include "loom/opcua_rest_nodeid.h" // opcrest::sectionFromName #include "loom/diag/breadcrumb.h" // diag::phaseName +#include + #include #include #include @@ -122,6 +124,43 @@ Method methodFromString(std::string_view s) { return Method::UNKNOWN; } +// Scheduler DTOs (glaze auto-reflects these aggregates). In a NAMED namespace +// because glaze reflection requires the reflected type to have linkage. NOTE: +// duplicated with server.cpp for now — the right consolidation is to make +// server.cpp delegate wholesale to dispatch() and host the one copy here. +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; +} + namespace { Response json(int status, std::string body) { return {status, "application/json", std::move(body)}; } Response notFound() { return json(404, "{\"error\":\"no matching route\"}"); } @@ -166,6 +205,16 @@ Response dispatch(RuntimeCore& core, const Request& req) { } } + // GET /api/scheduler/classes — class definitions + live stats + if (req.method == Method::GET && req.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("[]")); + } + return notFound(); } From a0d0f14880191631c4f4f31af011b083234f8d82 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 12:42:36 -0700 Subject: [PATCH 40/62] feat(api): server.cpp catch-all delegates to dispatch; migrate faults+bus routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish the wholesale-consolidation mechanism: server.cpp registers an /api/ catch-all (registered LAST so Crow's static routes take priority) that forwards to api::dispatch — the same router the WASM host uses. As routes migrate into dispatch their specific CROW_ROUTEs are deleted and fall through to the catch-all, converging the REST surface on one source of truth. Migrated GET /api/faults, /api/bus/topics, /api/bus/services into dispatch and deleted their server.cpp routes. Verified NATIVE at runtime (curl against a running loom): migrated routes 200 via catch-all->dispatch; unmigrated routes (scope/data, modules/available, ...) still 200 via their specific routes -- the last-registered wildcard does NOT shadow them (it does when registered first -- that's the gotcha). Verified WASM in-browser: the Bus page renders. Four pages now run on wasm (Dashboard, ModuleDetail, Scheduler, Bus). Remaining routes migrate the same way. Co-Authored-By: Claude Opus 4.8 --- runtime/src/api/router.cpp | 42 +++++++++++++++++++ runtime/src/server.cpp | 86 +++++++++++++++----------------------- 2 files changed, 75 insertions(+), 53 deletions(-) diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index fc538bf..7152b87 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -215,6 +215,48 @@ Response dispatch(RuntimeCore& core, const Request& req) { return json(200, glz::write_json(resp).value_or("[]")); } + // GET /api/faults — fault reports (newest first) + if (req.method == Method::GET && req.path == "/api/faults") { + std::string body = "["; + bool first = true; + for (const auto& s : core.faultStore().list()) { + if (!first) body += ","; + first = false; + body += "{\"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) + "\"}"; + } + body += "]"; + return json(200, std::move(body)); + } + + // GET /api/bus/topics + if (req.method == Method::GET && req.path == "/api/bus/topics") { + auto topics = core.bus().topics(); + std::string body = "["; + for (std::size_t i = 0; i < topics.size(); ++i) { if (i) body += ","; body += "\"" + topics[i] + "\""; } + body += "]"; + return json(200, std::move(body)); + } + + // GET /api/bus/services + if (req.method == Method::GET && req.path == "/api/bus/services") { + auto infos = core.bus().serviceInfos(); + std::string body = "["; + for (std::size_t i = 0; i < infos.size(); ++i) { + if (i) body += ","; + body += "{\"name\":\"" + infos[i].name + "\",\"schema\":"; + body += infos[i].schema.empty() ? "null" : infos[i].schema; + body += "}"; + } + body += "]"; + return json(200, std::move(body)); + } + return notFound(); } diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index ea308a2..e2a777a 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -1,5 +1,6 @@ #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" @@ -216,6 +217,17 @@ 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); @@ -285,29 +297,7 @@ void Server::start() { // 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/faults") - ([this]() { - std::string json = "["; - bool first = true; - for (const auto& s : core_.faultStore().list()) { - if (!first) json += ","; - first = false; - json += "{"; - json += "\"id\":\"" + jsonEscapeString(s.id) + "\""; - json += ",\"ts\":" + std::to_string(s.tsMs); - json += ",\"kind\":\"" + jsonEscapeString(s.kind) + "\""; - json += ",\"module\":\"" + jsonEscapeString(s.moduleId) + "\""; - json += ",\"class\":\"" + jsonEscapeString(s.className) + "\""; - json += ",\"phase\":\"" + jsonEscapeString(s.phase) + "\""; - json += ",\"reason\":\"" + jsonEscapeString(s.reason) + "\""; - 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 /api/faults — migrated to api::dispatch (router.cpp); served by the catch-all above. // ===================================================================== // GET /api/faults/ — Full structured report for one fault. @@ -1256,40 +1246,12 @@ void Server::start() { // ===================================================================== // 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; - }); + // GET /api/bus/services — migrated to api::dispatch (router.cpp). // ===================================================================== // POST /api/bus/services/:name — Call a bus service @@ -1729,6 +1691,24 @@ 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). + CROW_ROUTE(app, "/api/").methods( + crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Put, + crow::HTTPMethod::Patch, crow::HTTPMethod::Delete) + ([this](const crow::request& creq, const std::string&) { + api::Request areq; + areq.method = crowMethodToApi(creq.method); + areq.path = creq.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) From 04e919ee38b13bcf95ce00596197086cc7a0ec36 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 12:44:36 -0700 Subject: [PATCH 41/62] refactor(api): drop server.cpp's scheduler/classes route + DTOs (dispatch is sole copy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/scheduler/classes route plus ClassInfoDto/ClassStatsDto/makeClassInfoDto existed in both server.cpp and dispatch. With the catch-all in place, server.cpp's copies are dead code — removed them; router.cpp's dispatch is now the single source. Native reaches the handler via the /api/ catch-all. Verified native: scheduler/classes 200 (catch-all->dispatch), scope/data still 200 (specific route). Co-Authored-By: Claude Opus 4.8 --- runtime/src/api/router.cpp | 6 ++--- runtime/src/server.cpp | 55 +------------------------------------- 2 files changed, 4 insertions(+), 57 deletions(-) diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 7152b87..9784096 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -125,9 +125,9 @@ Method methodFromString(std::string_view s) { } // Scheduler DTOs (glaze auto-reflects these aggregates). In a NAMED namespace -// because glaze reflection requires the reflected type to have linkage. NOTE: -// duplicated with server.cpp for now — the right consolidation is to make -// server.cpp delegate wholesale to dispatch() and host the one copy here. +// 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; diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index e2a777a..a4adb82 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -47,25 +47,6 @@ namespace loom { 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; @@ -103,25 +84,6 @@ struct AvailableModuleDto { 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. // jsonEscapeString, serializeCycleHistory and moduleInfoJson now live in // loom/api/json_build.h (shared verbatim with the WASM api router). @@ -849,22 +811,7 @@ void Server::start() { // ===================================================================== // 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; - }); + // GET /api/scheduler/classes — migrated to api::dispatch (router.cpp). // ===================================================================== // POST /api/scheduler/classes — Create a new class definition From 4a124145caca0d6031f4d6c8101c6cc58413a1b0 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 13:00:15 -0700 Subject: [PATCH 42/62] feat(api): migrate scope routes to dispatch (Scope page on wasm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrated GET /api/scope/{schema,probes,data} into dispatch (verbatim logic) and deleted their server.cpp routes; native reaches them via the catch-all. Added GET /api/modules/available to dispatch too, ordered BEFORE the /api/modules/ prefix check so 'available' is not read as a module id — this serves WASM. Kept the NATIVE /api/modules/available specific route (and its DTO): Crow's /api/modules/ route would otherwise grab '/api/modules/available' as an id, and that route emits a richer single-module payload (inline data sections) than dispatch's moduleInfoJson — a pre-existing format divergence left for a later alignment pass. Verified native (curl): scope/{schema,probes,data} 200 via catch-all->dispatch; modules/available 200; modules/ 200. Verified WASM in-browser: the Scope page renders. Five pages now run on wasm (added Scope). Co-Authored-By: Claude Opus 4.8 --- runtime/src/api/router.cpp | 65 ++++++++++++++++++++++++++++++++++++++ runtime/src/server.cpp | 53 ++++--------------------------- 2 files changed, 72 insertions(+), 46 deletions(-) diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 9784096..565a9ea 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace loom { @@ -161,6 +162,13 @@ ClassInfoDto makeClassInfoDto(const ClassDef& def, const std::vector return dto; } +// Response item for GET /api/modules/available (glaze-reflected; named namespace). +struct AvailableModuleDto { + std::string filename; + std::string className; + std::string version; +}; + namespace { Response json(int status, std::string body) { return {status, "application/json", std::move(body)}; } Response notFound() { return json(404, "{\"error\":\"no matching route\"}"); } @@ -181,6 +189,24 @@ Response dispatch(RuntimeCore& core, const Request& req) { return json(200, std::move(body)); } + // 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 (req.method == Method::GET && req.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("[]")); + } + // GET /api/modules/ — one module if (req.method == Method::GET && req.path.rfind("/api/modules/", 0) == 0) { std::string id = req.path.substr(std::string_view("/api/modules/").size()); @@ -257,6 +283,45 @@ Response dispatch(RuntimeCore& core, const Request& req) { return json(200, std::move(body)); } + // GET /api/scope/schema — each module's runtime section snapshot + if (req.method == Method::GET && req.path == "/api/scope/schema") { + std::shared_lock lock(core.moduleMutex()); + std::string body = "{"; + 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) body += ","; + body += "\"" + id + "\":" + runtime; + first = false; + } + body += "}"; + return json(200, std::move(body)); + } + + // GET /api/scope/probes — configured probes + if (req.method == Method::GET && req.path == "/api/scope/probes") { + auto probes = core.oscilloscope().listProbes(); + std::string body = "["; + bool first = true; + for (auto& p : probes) { + if (!first) body += ","; + body += "{\"id\":" + std::to_string(p.id) + + ",\"moduleId\":\"" + p.moduleId + "\"" + + ",\"path\":\"" + p.path + "\"" + + ",\"label\":\"" + p.label + "\"}"; + first = false; + } + body += "]"; + return json(200, std::move(body)); + } + + // GET /api/scope/data — all captured probe samples + if (req.method == Method::GET && req.path == "/api/scope/data") { + return json(200, core.oscilloscope().allDataToJson()); + } + return notFound(); } diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index a4adb82..04881f6 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -77,7 +77,10 @@ struct InstantiateRequest { 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; @@ -685,47 +688,12 @@ void Server::start() { // 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/schema — migrated to api::dispatch (router.cpp). // ===================================================================== // 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; - }); + // GET /api/scope/probes — migrated to api::dispatch (router.cpp). POST/DELETE stay below. // ===================================================================== // POST /api/scope/probes — Add a probe @@ -799,14 +767,7 @@ void Server::start() { // ===================================================================== // 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/scope/data — migrated to api::dispatch (router.cpp). // ===================================================================== // GET /api/scheduler/classes — List class configs + live stats From 6683cdae0cbd6ace78e2bfec43cace26c9a039de Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 13:04:23 -0700 Subject: [PATCH 43/62] feat(api): migrate GET /api/io-mappings to dispatch (Mappings page on wasm) Migrated the I/O-mappings list route into dispatch and deleted its server.cpp GET route (POST add / DELETE / resolve stay specific). Verified native (curl): GET 200 via catch-all->dispatch, POST 400 via its specific route (reachable, not shadowed). Verified WASM in-browser: Mappings page renders with zero console errors. Six pages now run on wasm (added Mappings). Co-Authored-By: Claude Opus 4.8 --- runtime/src/api/router.cpp | 22 ++++++++++++++++++++++ runtime/src/server.cpp | 25 +------------------------ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 565a9ea..bb2ab65 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -322,6 +322,28 @@ Response dispatch(RuntimeCore& core, const Request& req) { return json(200, core.oscilloscope().allDataToJson()); } + // GET /api/io-mappings — I/O mappings with resolution status + if (req.method == Method::GET && req.path == "/api/io-mappings") { + auto& mapper = core.ioMapper(); + std::string body = "["; + bool first = true; + for (std::size_t i = 0; i < mapper.entryCount(); ++i) { + auto* entry = mapper.getMapping(i); + if (!entry) continue; + if (!first) body += ","; + body += "{\"index\":" + std::to_string(i); + body += ",\"source\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].src_module_id : "") + "\""; + body += ",\"target\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].dst_module_id : "") + "\""; + body += ",\"enabled\":" + std::string(entry->valid ? "true" : "false"); + body += ",\"resolved\":" + std::string(entry->valid ? "true" : "false"); + body += ",\"stable\":" + std::string(entry->stable ? "true" : "false"); + body += ",\"error\":\"" + entry->error + "\"}"; + first = false; + } + body += "]"; + return json(200, std::move(body)); + } + return notFound(); } diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index 04881f6..839b64f 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -922,30 +922,7 @@ void Server::start() { // ===================================================================== // 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/io-mappings — migrated to api::dispatch (router.cpp). POST below stays. // ===================================================================== // POST /api/io-mappings — Add a new mapping From 342f747f54f16b0f6e9618184fbf406c64741c7d Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 14:02:19 -0700 Subject: [PATCH 44/62] fix(api): rename Method::DELETE -> DELETE_ (MSVC C2589 on Windows) /winnt.h #defines DELETE as (0x00010000L). MSVC's preprocessor expands Method::DELETE into Method::(0x00010000L) before the compiler ever sees the namespaced identifier, producing error C2589: '(': illegal token on right side of '::'. This broke the win32-x64 leg of dev-build run #33 (runtime/src/api/router.cpp:124, on Method::DELETE in methodFromString). Renamed the enumerator to DELETE_ (router.h) and updated its two call sites (router.cpp's methodFromString, server.cpp's crowMethodToApi). No other ALL-CAPS identifiers in these files collide with the other classic Windows macros (IN/OUT/ERROR/min/max). Native (macOS) and WASM both rebuild clean; could not verify MSVC locally (no Windows toolchain here) but the fix is mechanical and the failure line was pinpointed exactly from the CI log. Co-Authored-By: Claude Opus 4.8 --- runtime/include/loom/api/router.h | 5 ++++- runtime/src/api/router.cpp | 2 +- runtime/src/server.cpp | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/runtime/include/loom/api/router.h b/runtime/include/loom/api/router.h index d373770..60b9470 100644 --- a/runtime/include/loom/api/router.h +++ b/runtime/include/loom/api/router.h @@ -13,7 +13,10 @@ class RuntimeCore; namespace api { -enum class Method { GET, POST, PUT, PATCH, DELETE, UNKNOWN }; +// 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); diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index bb2ab65..404ce98 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -121,7 +121,7 @@ Method methodFromString(std::string_view s) { if (s == "POST") return Method::POST; if (s == "PUT") return Method::PUT; if (s == "PATCH") return Method::PATCH; - if (s == "DELETE") return Method::DELETE; + if (s == "DELETE") return Method::DELETE_; return Method::UNKNOWN; } diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index 839b64f..6a730c5 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -188,7 +188,7 @@ static api::Method crowMethodToApi(crow::HTTPMethod m) { 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; + case crow::HTTPMethod::Delete: return api::Method::DELETE_; default: return api::Method::UNKNOWN; } } From d9ab013e227ce3eb1e67d4640e954b36f3b4e2fe Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 15:02:31 -0700 Subject: [PATCH 45/62] feat(wasm): build + boot the bundled demo modules under Emscripten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build modules/ as Emscripten SIDE_MODULEs (one CMake gotcha each): - CMake's Emscripten platform defaults the TARGET_SUPPORTS_SHARED_LIBS global property to FALSE, so add_library(MODULE) (every module's CMakeLists.txt) silently downgraded to a STATIC archive instead of a dlopen-able wasm module. Override it before add_subdirectory(modules) (CMakeLists.txt). - modules/CMakeLists.txt: build under EMSCRIPTEN too, with -sSIDE_MODULE=1 on each MODULE target and a separate wasm-modules/ output dir (the native build writes same-named .so files to the same absolute CMAKE_RUNTIME_OUTPUT_DIRECTORY, which would otherwise collide). EtherCAT stays native-only — it needs a real EtherCAT master (SOEM) + raw-Ethernet access, neither available in a browser. Fixed a real scheduler bug this surfaced (runtime/src/scheduler.cpp): Scheduler::pauseClass() unconditionally waits on a condition variable for the class's cyclic thread to ack a pause. Under EMSCRIPTEN that thread is never spawned (scheduler.cpp's existing #ifndef __EMSCRIPTEN__ guards), so the wait blocks forever. insertMember() calls pauseClass() for every module joining a class that's already marked running -- i.e. every module after the first one loaded in a session -- so loading more than one module always deadlocked the single JS thread. removeMember() (reload/reassign) hits the same path. Made pauseClass()/unpauseClass() no-ops under EMSCRIPTEN: the cooperative tick loop can't run concurrently with the synchronous call that's mutating runner.members anyway (one JS thread, ticks only fire between synchronous calls), so there's nothing to actually pause. Verified zero native regression: 116/117 tests pass before and after (the 1 failure is pre-existing/unrelated, confirmed via git stash on baseline). frontend/src/wasm/boot.ts: WASM_MODULES lists all 8 wasm-built modules (replacing the single throwaway spike demo_module.so). Verified in-browser: all 8 modules load, register, and tick concurrently (GET /api/modules shows every one RUNNING; ExampleMotor's cycle_count advances tick over tick); Module Dashboard renders all 8 live, zero console errors. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 13 ++++++++++--- frontend/src/wasm/boot.ts | 18 +++++++++++++++--- modules/CMakeLists.txt | 28 +++++++++++++++++++++++++--- runtime/src/scheduler.cpp | 17 +++++++++++++++++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 276b833..4616d72 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,12 @@ if(EMSCRIPTEN) add_compile_definitions(FMT_CONSTEVAL=) add_compile_options(-fexceptions) add_link_options(-fexceptions) + + # 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) @@ -107,10 +113,11 @@ endif() add_subdirectory(sdk) add_subdirectory(runtime) -# Bundled modules and tests are native-only for now (modules need a SIDE_MODULE -# build path; tests need GTest). The WASM build produces just the runtime host. +# 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) - add_subdirectory(modules) enable_testing() add_subdirectory(tests) endif() diff --git a/frontend/src/wasm/boot.ts b/frontend/src/wasm/boot.ts index b656203..6a952c8 100644 --- a/frontend/src/wasm/boot.ts +++ b/frontend/src/wasm/boot.ts @@ -22,9 +22,21 @@ function loadScript(src: string): Promise { }); } -/** Modules to auto-load in wasm mode. For now the bundled demo; later, the - * application's own SIDE_MODULE .wasm files (built via loom_add_module). */ -const WASM_MODULES = ['demo_module.so']; +/** 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()) { diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index 60e0b1f..e52f811 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -1,17 +1,39 @@ 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(command_probe) add_subdirectory(crasher) -add_subdirectory(ethercat) +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) + # 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 ITEMS class_based command_probe crasher example_motor + oscilloscope pneumatic_actuator sequencer stack_light) + target_compile_options(${_mod} PRIVATE -sSIDE_MODULE=1) + target_link_options(${_mod} PRIVATE -sSIDE_MODULE=1) + endforeach() +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 diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 1a98789..024e9a4 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -722,6 +722,18 @@ void Scheduler::sortMembers(ClassRunnerState& runner) { } void Scheduler::pauseClass(ClassRunnerState& runner) { +#ifdef __EMSCRIPTEN__ + // No-op: wasm has no class thread (classLoop() is never spawned — see + // startClasses()/insertMember's #ifndef __EMSCRIPTEN__ guards), so there is + // nothing to pause and nothing that will ever ack. Waiting here deadlocks + // 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 — both went straight to + // pauseCv.wait() before this guard existed. 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; +#else if (!runner.running.load()) return; { @@ -735,14 +747,19 @@ void Scheduler::pauseClass(ClassRunnerState& runner) { runner.pauseCv.wait(lk, [&] { return runner.pauseAcked || !runner.running.load(); }); +#endif } void Scheduler::unpauseClass(ClassRunnerState& runner) { +#ifdef __EMSCRIPTEN__ + (void)runner; // no-op to match pauseClass(); see its comment. +#else { std::lock_guard lk(runner.pauseMx); runner.pauseRequested = false; } runner.pauseCv.notify_all(); +#endif } // ---- Metrics buffering ---------------------------------------------------------- From f67ebc937cbb95ca02d641c4cff1a26567dcb80a Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 15:52:39 -0700 Subject: [PATCH 46/62] build(wasm): auto-stage the host + modules into frontend/public/ on every build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmake --build alone now produces a runnable frontend/public/ — no manual cp. runtime/CMakeLists.txt: loom_host_wasm gets a POST_BUILD copy of loom_wasm.js + its .wasm sibling (same directory it links to, so a same-directory POST_BUILD command is fine here). modules/CMakeLists.txt: the 8 wasm module targets are each created inside their own add_subdirectory() — add_custom_command(TARGET ... POST_BUILD) requires the same directory that created the target, so that approach (tried first) failed configure with 'TARGET X was not created in this directory'. Used a separate add_custom_target(stage_wasm_modules ALL DEPENDS ) instead, which has no such restriction and copies every $ in one step. Verified: removed all staged assets, ran a clean cmake --build --preset conan-release, confirmed frontend/public/ was fully repopulated (8 .so + loom_wasm.js/wasm) with no manual step; re-verified in-browser (all 8 modules load, GET /api/modules lists all 8). Native rebuild unaffected (change is entirely inside EMSCRIPTEN branches). Co-Authored-By: Claude Opus 4.8 --- modules/CMakeLists.txt | 26 ++++++++++++++++++++++++-- runtime/CMakeLists.txt | 17 +++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index e52f811..60c2a56 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -24,14 +24,36 @@ 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 ITEMS class_based command_probe crasher example_motor - oscilloscope pneumatic_actuator sequencer stack_light) + 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 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7965ba6..540bdca 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -79,6 +79,23 @@ target_link_options(loom_host_wasm PRIVATE "-sENVIRONMENT=web,worker,node" "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','FS']") +# 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 ---------------------------------------------------- # --------------------------------------------------------------------------- From 918f3bb00fdcc8f4dd75049b607f88e188589513 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 16:01:02 -0700 Subject: [PATCH 47/62] fix(scheduler): address Copilot review findings on tickOnce()/classLoop Three issues flagged by Copilot's review of this PR, all confirmed by direct inspection: - tickOnce() reused a single pre-sweep `now` for the missed-deadline reanchor check after sweepClassOnce(). If a sweep ran long, the stale timestamp could under-detect the overrun and let the class burst-run on the next tickOnce() call instead of re-anchoring to the period grid. Fixed by sampling the clock fresh (afterSweep) right before that check, mirroring classLoop's own fresh-sample pattern. The due-check itself still uses one consistent snapshot (loopStart) across all classes in a call -- that part was correct. - classLoop() computed and updated a local periodNs left over from the sweepClassOnce() extraction; nothing reads it anymore (the per-module jitter calc that used it now lives in sweepClassOnce with its own copy). Removed the dead variable (-Wunused-but-set-variable). - tickOnce()'s docstring said class periods/priorities are "advisory metadata, not enforced timing" -- stale from before this PR's multi-rate commit added real per-class period enforcement via coopNextDue. Corrected: periods ARE enforced; priority/affinity are NOT (no OS thread to apply them to). Held off on the 4th finding (tickOnce() doesn't lock mutex_ while other public Scheduler methods do): it can't fire today -- tickOnce() has no caller yet (the WASM host that will call it isn't part of this PR) -- and threading is expected to be refactored soon, so fixing the locking now risks being redone. Revisit once a thread-free host lands and/or the threading refactor is settled. Verified: native build clean, 116/117 tests pass (the 1 failure is the pre-existing/unrelated TraceCacheTest.ExampleMotorRuntimeFieldsCached). Co-Authored-By: Claude Opus 4.8 --- runtime/include/loom/scheduler.h | 16 ++++++++++------ runtime/src/scheduler.cpp | 24 ++++++++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/runtime/include/loom/scheduler.h b/runtime/include/loom/scheduler.h index 49691f6..067223c 100644 --- a/runtime/include/loom/scheduler.h +++ b/runtime/include/loom/scheduler.h @@ -149,12 +149,16 @@ class Scheduler { /// Idempotent: already-running classes are skipped. void startClasses(); - /// Run exactly one cooperative scheduling pass over every class's members - /// (preCyclic → cyclic → I/O map → postCyclic), in place on the calling - /// thread — no class threads spawned, no sleeping. For single-threaded hosts - /// (e.g. the WASM build) that drive the runtime from an external loop instead - /// of startClasses(). Class periods/priorities become advisory metadata here, - /// not enforced timing. Do NOT mix with startClasses() on the same instance. + /// 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 single-threaded + /// hosts (e.g. the WASM build) 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. void tickOnce(); /// Configure optional targets for cycle-aligned sampling. diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 4724409..06bfb78 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -853,7 +853,16 @@ void Scheduler::tickOnce() { // 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. - const auto now = std::chrono::steady_clock::now(); + // + // `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. + const auto loopStart = std::chrono::steady_clock::now(); for (auto& [name, runnerPtr] : classes_) { ClassRunnerState& runner = *runnerPtr; @@ -861,8 +870,8 @@ void Scheduler::tickOnce() { // First sight of this class: anchor its schedule to now (run immediately). if (runner.coopNextDue.time_since_epoch().count() == 0) - runner.coopNextDue = now; - if (now < runner.coopNextDue) continue; // not due yet + runner.coopNextDue = loopStart; + if (loopStart < runner.coopNextDue) continue; // not due yet runner.tickCount.fetch_add(1); runner.lastTickStartMs.store(static_cast( @@ -874,11 +883,12 @@ void Scheduler::tickOnce() { // 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 periodUs = std::chrono::microseconds(runner.livePeriodUs.load()); + const auto afterSweep = std::chrono::steady_clock::now(); + const auto periodUs = std::chrono::microseconds(runner.livePeriodUs.load()); runner.coopNextDue += periodUs; - if (runner.coopNextDue <= now) { + if (runner.coopNextDue <= afterSweep) { const auto behind = std::chrono::duration_cast( - now - runner.coopNextDue); + afterSweep - runner.coopNextDue); runner.coopNextDue += periodUs * (behind / periodUs + 1); } } @@ -920,7 +930,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: {})", @@ -931,7 +940,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; From fcdf1d36a382b6d2172872ed60077663e8fec61f Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 16:26:14 -0700 Subject: [PATCH 48/62] feat(api): migrate the write routes into dispatch (full module/scope/io-mapping/scheduler interactivity on wasm) Completes the wholesale route-consolidation: dispatch() (router.cpp) now also handles GET/POST/PUT/PATCH/DELETE for the write surface, not just GETs. Migrated: modules/instantiate, modules/ DELETE, modules//data/
POST+PUT+PATCH (write/patch a section), modules//config/{save,load}, modules//recipe/{save,load}/, modules//reload, scope/probes POST, scope/probes/ DELETE, io-mappings POST, io-mappings/ DELETE+PATCH, io-mappings/resolve, scheduler/classes POST, scheduler/classes/ PATCH, scheduler/schema, scheduler/modules//history and scheduler/classes//history (both ?since=&bin=), scheduler/reassign, bus/call/, faults/. server.cpp's now-dead DTOs/helpers (PatchBody, AddProbeRequest, InstantiateRequest, parseSectionName, buildHistoryBody) moved out or removed; buildHistoryBody is now shared via json_build.h like the other JSON builders. reload and reassign are now genuinely safe under wasm -- both go through Scheduler::stop()/insertMember()-removeMember(), the exact pause-handshake path fixed in the previous commit. modules/upload (binary .so upload) stays native-only by choice -- different shape, lower value for the wasm demo. Two infra additions both transports needed: - Query-string support: dispatch() now splits "path?query" once at entry (splitPathQuery/queryParam helpers) since the history routes need ?since=&bin=. The native catch-all passes creq.raw_url (was creq.url, which Crow strips the query from); the wasm host already passed pathname+search this way. - Fixed a real native routing bug this surfaced: server.cpp's GET-only "/" SPA-fallback (registered before "/api/") was winning Crow's route resolution over the api catch-all for any api path whose exact segment-shape was never its own literal CROW_ROUTE (e.g. nested modules//data/
now living only in dispatch) -- confirmed via curl: GET returned an empty 200 text/html (the SPA fallback's index.html-or-blank response) while POST/PATCH/ DELETE to the identical path worked fine (the SPA route never registers those methods, so no ambiguity for them). Fixed by making "/"'s handler itself recognize "api/..." paths and call api::dispatch() directly, rather than depending on Crow's wildcard-vs-wildcard tie-breaking. Verified natively end-to-end via curl: every migrated route (including the previously-broken nested GETs) returns correct status/body; instantiate -> reload -> delete a second ExampleMotor instance round-trips cleanly; scope probe add/list/delete, io-mapping add/patch/resolve/delete, scheduler class add/patch/ reassign, and bus/call all work. 116/117 native tests pass (1 pre-existing/ unrelated failure, confirmed via git stash on baseline both before and after). Verified in-browser (wasm): same route battery via fetch() -- including instantiate/reload/delete completing without the pre-fix deadlock -- plus the real ModuleDetail page rendering live runtime vars, a command-call form, and working Reload/Remove/class-reassign controls; Reload visibly re-initialized ExampleMotor (speed/target reset to 0), zero console errors. Co-Authored-By: Claude Opus 4.8 --- runtime/include/loom/api/json_build.h | 6 + runtime/src/api/router.cpp | 485 +++++++++++--- runtime/src/server.cpp | 873 ++------------------------ 3 files changed, 480 insertions(+), 884 deletions(-) diff --git a/runtime/include/loom/api/json_build.h b/runtime/include/loom/api/json_build.h index 082fbdb..65d74e8 100644 --- a/runtime/include/loom/api/json_build.h +++ b/runtime/include/loom/api/json_build.h @@ -8,6 +8,7 @@ #include "loom/scheduler.h" #include +#include #include #include #include @@ -25,4 +26,9 @@ std::string serializeCycleHistory(const std::deque& samples, std:: /// Per-module info object (id, name, class, state, path, stats + cycle history). std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler); +/// 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/src/api/router.cpp b/runtime/src/api/router.cpp index 404ce98..84655ed 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -7,7 +7,10 @@ #include +#include #include +#include +#include #include #include #include @@ -110,6 +113,57 @@ std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler) 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 // --------------------------------------------------------------------------- @@ -169,29 +223,77 @@ struct AvailableModuleDto { 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\"}"); } +Response badRequest(std::string msg) { return json(400, "{\"error\":\"" + 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 (req.method == Method::GET && req.path == "/api/modules") { + if (method == Method::GET && path == "/api/modules") { std::shared_lock lock(core.moduleMutex()); - std::string body = "["; + std::string out = "["; bool first = true; for (auto& [id, mod] : core.loader().modules()) { - if (!first) body += ","; - body += moduleInfoJson(mod, core.scheduler()); + if (!first) out += ","; + out += moduleInfoJson(mod, core.scheduler()); first = false; } - body += "]"; - return json(200, std::move(body)); + 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 (req.method == Method::GET && req.path == "/api/modules/available") { + if (method == Method::GET && path == "/api/modules/available") { const auto& cfg = core.config(); auto available = ModuleLoader::queryAvailable(cfg.moduleDir); std::unordered_set seen; @@ -207,32 +309,90 @@ Response dispatch(RuntimeCore& core, const Request& req) { return json(200, glz::write_json(dtos).value_or("[]")); } - // GET /api/modules/ — one module - if (req.method == Method::GET && req.path.rfind("/api/modules/", 0) == 0) { - std::string id = req.path.substr(std::string_view("/api/modules/").size()); - if (!id.empty() && id.find('/') == std::string::npos) { - std::shared_lock lock(core.moduleMutex()); - auto* mod = core.loader().get(id); - if (!mod) return json(404, "{\"error\":\"module not found\"}"); - return json(200, moduleInfoJson(*mod, core.scheduler())); - } + // 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 + "\"}"); + } - // GET /api/modules//data/
— reflected section JSON - auto slash = id.find('/'); - if (slash != std::string::npos) { - std::string modId = id.substr(0, slash); - std::string tail = id.substr(slash + 1); // "data/
" - if (!modId.empty() && tail.rfind("data/", 0) == 0) { - if (auto sec = opcrest::sectionFromName(tail.substr(5))) { + // /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\"}"); + return json(200, moduleInfoJson(*mod, core.scheduler())); + } + 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 (req.method == Method::GET && req.path == "/api/scheduler/classes") { + if (method == Method::GET && path == "/api/scheduler/classes") { auto allStats = core.scheduler().allClassStats(); auto defs = core.scheduler().classConfigs(); std::vector resp; @@ -241,107 +401,278 @@ Response dispatch(RuntimeCore& core, const Request& req) { 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/faults — fault reports (newest first) - if (req.method == Method::GET && req.path == "/api/faults") { - std::string body = "["; + if (method == Method::GET && path == "/api/faults") { + std::string out = "["; bool first = true; for (const auto& s : core.faultStore().list()) { - if (!first) body += ","; + if (!first) out += ","; first = false; - body += "{\"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 += "{\"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) + "\"}"; } - body += "]"; - return json(200, std::move(body)); + 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 (req.method == Method::GET && req.path == "/api/bus/topics") { + if (method == Method::GET && path == "/api/bus/topics") { auto topics = core.bus().topics(); - std::string body = "["; - for (std::size_t i = 0; i < topics.size(); ++i) { if (i) body += ","; body += "\"" + topics[i] + "\""; } - body += "]"; - return json(200, std::move(body)); + std::string out = "["; + for (std::size_t i = 0; i < topics.size(); ++i) { if (i) out += ","; out += "\"" + topics[i] + "\""; } + out += "]"; + return json(200, std::move(out)); } // GET /api/bus/services - if (req.method == Method::GET && req.path == "/api/bus/services") { + if (method == Method::GET && path == "/api/bus/services") { auto infos = core.bus().serviceInfos(); - std::string body = "["; + std::string out = "["; for (std::size_t i = 0; i < infos.size(); ++i) { - if (i) body += ","; - body += "{\"name\":\"" + infos[i].name + "\",\"schema\":"; - body += infos[i].schema.empty() ? "null" : infos[i].schema; - body += "}"; + if (i) out += ","; + out += "{\"name\":\"" + infos[i].name + "\",\"schema\":"; + out += infos[i].schema.empty() ? "null" : infos[i].schema; + out += "}"; } - body += "]"; - return json(200, std::move(body)); + 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\":\"" + result.error + "\""; + out += "}"; + return json(200, std::move(out)); } // GET /api/scope/schema — each module's runtime section snapshot - if (req.method == Method::GET && req.path == "/api/scope/schema") { + if (method == Method::GET && path == "/api/scope/schema") { std::shared_lock lock(core.moduleMutex()); - std::string body = "{"; + 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) body += ","; - body += "\"" + id + "\":" + runtime; + if (!first) out += ","; + out += "\"" + id + "\":" + runtime; first = false; } - body += "}"; - return json(200, std::move(body)); + out += "}"; + return json(200, std::move(out)); } // GET /api/scope/probes — configured probes - if (req.method == Method::GET && req.path == "/api/scope/probes") { + if (method == Method::GET && path == "/api/scope/probes") { auto probes = core.oscilloscope().listProbes(); - std::string body = "["; + std::string out = "["; bool first = true; for (auto& p : probes) { - if (!first) body += ","; - body += "{\"id\":" + std::to_string(p.id) - + ",\"moduleId\":\"" + p.moduleId + "\"" - + ",\"path\":\"" + p.path + "\"" - + ",\"label\":\"" + p.label + "\"}"; + if (!first) out += ","; + out += "{\"id\":" + std::to_string(p.id) + + ",\"moduleId\":\"" + p.moduleId + "\"" + + ",\"path\":\"" + p.path + "\"" + + ",\"label\":\"" + p.label + "\"}"; first = false; } - body += "]"; - return json(200, std::move(body)); + 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 (req.method == Method::GET && req.path == "/api/scope/data") { + 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 (req.method == Method::GET && req.path == "/api/io-mappings") { + if (method == Method::GET && path == "/api/io-mappings") { auto& mapper = core.ioMapper(); - std::string body = "["; + 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) body += ","; - body += "{\"index\":" + std::to_string(i); - body += ",\"source\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].src_module_id : "") + "\""; - body += ",\"target\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].dst_module_id : "") + "\""; - body += ",\"enabled\":" + std::string(entry->valid ? "true" : "false"); - body += ",\"resolved\":" + std::string(entry->valid ? "true" : "false"); - body += ",\"stable\":" + std::string(entry->stable ? "true" : "false"); - body += ",\"error\":\"" + entry->error + "\"}"; + if (!first) out += ","; + out += "{\"index\":" + std::to_string(i); + out += ",\"source\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].src_module_id : "") + "\""; + out += ",\"target\":\"" + (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\":\"" + entry->error + "\"}"; first = false; } - body += "]"; - return json(200, std::move(body)); + 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(); diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index 6a730c5..d4d0b8d 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -45,13 +45,8 @@ namespace loom { // Plain aggregate structs — glaze auto-reflects these with no macros. // --------------------------------------------------------------------------- -struct PatchBody { std::string ptr; glz::raw_json value; }; - -/// 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 { @@ -71,12 +66,6 @@ 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. 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 @@ -91,14 +80,8 @@ struct AvailableModuleDto { // 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) { @@ -110,67 +93,9 @@ static glz::error_ctx readJsonPermissive(T& value, std::string_view json) { return glz::read(value, json, ctx); } -// serializeCycleHistory (vector + deque overloads) → loom/api/json_build.h - -// 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; -} +// 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). // Build JSON module info for a single module // moduleInfoJson → loom/api/json_build.h (shared with the WASM api router) @@ -264,58 +189,8 @@ void Server::start() { // ===================================================================== // GET /api/faults — migrated to api::dispatch (router.cpp); served by the catch-all above. - // ===================================================================== - // GET /api/faults/ — Full structured report for one fault. - // ===================================================================== - CROW_ROUTE(app, "/api/faults/") - ([this](const std::string& id) { - auto detail = core_.faultStore().detailJson(id); - crow::response resp = detail - ? crow::response(200, *detail) - : crow::response(404, R"({"error":"fault not found"})"); - 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/faults/ and POST /api/modules/instantiate — migrated to + // api::dispatch (router.cpp). // ===================================================================== // GET /api/modules/:id — Module detail @@ -378,231 +253,13 @@ void Server::start() { 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; - }); + // 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/modules/upload — Upload a new or replacement .so module @@ -693,76 +350,8 @@ void Server::start() { // ===================================================================== // GET /api/scope/probes — List active probes // ===================================================================== - // GET /api/scope/probes — migrated to api::dispatch (router.cpp). POST/DELETE stay below. - - // ===================================================================== - // 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/probes, POST /api/scope/probes, DELETE + // /api/scope/probes/ — migrated to api::dispatch (router.cpp). // ===================================================================== // GET /api/scope/data — Get all ring buffer data @@ -774,359 +363,23 @@ void Server::start() { // ===================================================================== // GET /api/scheduler/classes — migrated to api::dispatch (router.cpp). - // ===================================================================== - // 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; - } - - // 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; - - core_.scheduler().updateClassDef(updated); - core_.saveSchedulerConfig(); - - 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/scheduler/schema — JSON Schema for SchedulerConfig - // ===================================================================== - CROW_ROUTE(app, "/api/scheduler/schema") - ([this]() { - auto schema = glz::write_json_schema().value_or("{}"); - auto resp = crow::response(200, schema); - 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. - // ===================================================================== - 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; - } - 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(); - } - 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/scheduler/classes/:name/history?since=&bin= - // Returns the per-class cycle/jitter ring buffer (binned if bin > 0). - // ===================================================================== - 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; - }); + // 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). // ===================================================================== // 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. - // ===================================================================== - // POST /api/io-mappings — Add a new mapping - // Body: {"source": "left_motor.runtime.speed", "target": "controller.runtime.input", "enabled": true} - // ===================================================================== - CROW_ROUTE(app, "/api/io-mappings") - .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; - } - - 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; - } + // POST /api/io-mappings, DELETE/PATCH /api/io-mappings/, and + // POST /api/io-mappings/resolve — migrated to api::dispatch (router.cpp). - 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); - 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"})"); - 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})"); - 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"})"); - resp.add_header("Content-Type", "application/json"); - resp.add_header("Access-Control-Allow-Origin", "*"); - 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; - } - - auto resp = crow::response(405, R"({"error":"method not allowed"})"); - 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 - // ===================================================================== - 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; - }); - - // ===================================================================== - // POST /api/scheduler/reassign — Move a module to a different class - // Body: {"moduleId": "...", "class": "...", "order": 0 (optional)} - // ===================================================================== - 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; - } - - // 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) : ""; - }; - - moduleId = extract("moduleId"); - newClass = extract("class"); - auto orderStr = extract("order"); - - 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; - } - - if (!orderStr.empty()) { - try { newOrder = std::stoi(orderStr); } catch (...) {} - } - - 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(); - - 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 @@ -1138,36 +391,7 @@ void Server::start() { // ===================================================================== // GET /api/bus/services — migrated to api::dispatch (router.cpp). - // ===================================================================== - // 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 += "}"; - - 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. @@ -1563,8 +787,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)) { @@ -1579,13 +832,19 @@ 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). CROW_ROUTE(app, "/api/").methods( crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Put, crow::HTTPMethod::Patch, crow::HTTPMethod::Delete) ([this](const crow::request& creq, const std::string&) { api::Request areq; areq.method = crowMethodToApi(creq.method); - areq.path = creq.url; + // 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); From aa917624f0f89963c60b4d8e4ac0dd4aedea0dc5 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 17:27:46 -0700 Subject: [PATCH 49/62] feat(wasm): real pthread threading for the wasm host (drop cooperative tickOnce) Replaces the cooperative single-threaded scheduling model with real Emscripten pthreads (SharedArrayBuffer + Worker-backed std::thread), de-risked standalone in spike/phaseC-pthread-dlopen/ (a real worker thread driving dlopen'd module code, incl. the pauseClass()-style mutex+condvar handshake and cross-thread C++ exceptions -- all passed, with the caveat that Emscripten itself flags dynamic linking + pthreads as experimental). - CMakeLists.txt: -pthread applied globally under EMSCRIPTEN (compile + link), alongside the existing -fexceptions -- every wasm target (loom_core, loom_host_wasm, and all 8 bundled module SIDE_MODULEs) must agree on the threaded object format, so this can't be scoped to one target. - runtime/CMakeLists.txt: loom_host_wasm gains -sPTHREAD_POOL_SIZE=4 (prewarm for the 3 built-in classes + one spare). - runtime/src/scheduler.cpp: removed all __EMSCRIPTEN__ guards. The 3 std::thread spawns (long-running + isolated in start(), per-class in startClasses()) and the pauseClass()/unpauseClass() mutex+condvar handshake are unconditional again -- the exact same code path as native, unmodified. The wasm-only isolated-module-routed-into-a-class fallback is gone; isolated modules get their own real thread again. - runtime/src/run_wasm.cpp: loom_init() now boots RuntimeConfig::cooperative = false, so loadModules() calls Scheduler::startClasses() (real class threads) AND setupWatcher() (a real file-watcher thread over MEMFS) instead of the JS-driven tickOnce() path. loom_tick() is kept exported for a possible future lighter-weight cooperative mode (e.g. where -pthread's COOP/COEP hosting requirement can't be met), but is now a guarded no-op when the runtime was booted non-cooperative -- calling it while real class threads are already self-driving would race two callers of sweepClassOnce() on the same runner.members with no mutual exclusion between them. Verified: native build + 116/117 tests unchanged (same pre-existing TraceCacheTest.ExampleMotorRuntimeFieldsCached failure as baseline -- these reverts are true no-ops on native, __EMSCRIPTEN__ was never defined there). Wasm build (loom_host_wasm + all 8 bundled modules) links clean with -pthread. End-to-end: booted a real bundled module (example_motor, SIDE_MODULE) under node with ZERO JS-driven ticking -- the real classLoop() worker thread autonomously advanced cycle_count on schedule (10ms period -> exactly 30 cycles per 300ms, twice in a row), plus the long-running thread spawning and self-retiring and the module watcher starting, matching native behavior exactly (spike/phaseD-threaded-boot/test-real-threads.cjs). Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 17 ++++++++++++++-- runtime/CMakeLists.txt | 14 ++++++++++--- runtime/src/run_wasm.cpp | 41 +++++++++++++++++++++++++++++---------- runtime/src/scheduler.cpp | 32 +++--------------------------- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4616d72..b4fa1db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,8 +84,8 @@ endif() # 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 thread-free and -# server-less and skips them (gated in conanfile.py via settings.os). +# 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) if(EMSCRIPTEN) @@ -99,6 +99,19 @@ if(EMSCRIPTEN) 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 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 540bdca..8a7bb0f 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -64,8 +64,11 @@ 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; driven -# from JavaScript via Scheduler::tickOnce(). Emits loom_wasm.js + loom_wasm.wasm. +# 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="$") @@ -77,7 +80,12 @@ target_link_options(loom_host_wasm PRIVATE "-sMODULARIZE=1" "-sEXPORT_NAME=createLoomModule" "-sENVIRONMENT=web,worker,node" - "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','FS']") + "-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 diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp index 6a95b03..5e54836 100644 --- a/runtime/src/run_wasm.cpp +++ b/runtime/src/run_wasm.cpp @@ -1,11 +1,19 @@ // 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 do neither, so this host -// drives RuntimeCore *cooperatively*: it loads modules in cooperative mode (no -// class threads, no file watcher) and steps the scheduler from JavaScript via -// Scheduler::tickOnce(). State is read back as JSON through the same DataEngine -// the server would expose. No Crow, no signals, no 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__ @@ -40,8 +48,9 @@ char* dupString(const std::string& s) { extern "C" { // Boot the runtime. `moduleDir`/`dataDir` are paths in the Emscripten FS. -// Modules already present in moduleDir are discovered and loaded cooperatively. -// Returns the number of modules loaded, or -1 on error. +// 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); @@ -49,7 +58,7 @@ int loom_init(const char* moduleDir, const char* dataDir) { loom::RuntimeConfig cfg; cfg.moduleDir = moduleDir ? moduleDir : "/modules"; cfg.dataDir = dataDir ? dataDir : "/data"; - cfg.cooperative = true; // no class threads, no file watcher + 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()); @@ -61,10 +70,22 @@ int loom_init(const char* moduleDir, const char* dataDir) { } } -// Advance every due class one cooperative pass. Call from JS on a timer / rAF. +// 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) g_core->scheduler().tickOnce(); + if (!g_core) return; + if (!g_core->config().cooperative) { + spdlog::warn("loom_tick() called on a non-cooperative runtime (real class " + "threads are already driving it) -- ignored."); + return; + } + g_core->scheduler().tickOnce(); } // Comma-separated ids of the modules loaded by loom_init. malloc'd; caller free()s. diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index f8a110e..958e245 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -288,7 +288,6 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon // Start long-running thread immediately (independent of class membership). if (config.enableLongRunning) { -#ifndef __EMSCRIPTEN__ // wasm has no threads; background long-running work is skipped statePtr->longRunningThread = std::thread([this, &mod, statePtr]() { spdlog::info("Long-running task started for '{}'", mod.id); while (statePtr->running.load()) { @@ -322,23 +321,14 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon } spdlog::info("Long-running task ended for '{}'", mod.id); }); -#endif // __EMSCRIPTEN__ } const bool isIsolated = config.isolateThread || config.cyclicClass.empty(); if (isIsolated) { -#ifndef __EMSCRIPTEN__ statePtr->cyclicThread = std::thread([this, &mod, config, statePtr]() { isolatedLoop(mod, config, *statePtr); }); -#else - // No threads in wasm: 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); @@ -359,11 +349,9 @@ void Scheduler::startClasses() { if (runner->members.empty() || runner->running.load()) continue; runner->running.store(true); -#ifndef __EMSCRIPTEN__ // wasm drives classes cooperatively via tickOnce(), no 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())); @@ -722,18 +710,6 @@ void Scheduler::sortMembers(ClassRunnerState& runner) { } void Scheduler::pauseClass(ClassRunnerState& runner) { -#ifdef __EMSCRIPTEN__ - // No-op: wasm has no class thread (classLoop() is never spawned — see - // startClasses()/insertMember's #ifndef __EMSCRIPTEN__ guards), so there is - // nothing to pause and nothing that will ever ack. Waiting here deadlocks - // 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 — both went straight to - // pauseCv.wait() before this guard existed. 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; -#else if (!runner.running.load()) return; { @@ -743,23 +719,21 @@ void Scheduler::pauseClass(ClassRunnerState& runner) { } // Wait for the class thread to finish its current tick and ack the pause. + // Real threads under Emscripten too now (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(); }); -#endif } void Scheduler::unpauseClass(ClassRunnerState& runner) { -#ifdef __EMSCRIPTEN__ - (void)runner; // no-op to match pauseClass(); see its comment. -#else { std::lock_guard lk(runner.pauseMx); runner.pauseRequested = false; } runner.pauseCv.notify_all(); -#endif } // ---- Metrics buffering ---------------------------------------------------------- From 3ada98f7081408637071df20154e5eb730be3d49 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 17:47:41 -0700 Subject: [PATCH 50/62] feat(scheduler): support both real-thread and cooperative builds from one source The previous commit (aa91762) deleted the cooperative fallback entirely, keying everything on __EMSCRIPTEN__ -- but that macro means "this is wasm at all", not "this wasm build lacks threads". A build without -pthread would have had no working code path left (RuntimeCore::loadModules() would call startClasses(), which would try to spawn std::thread on a build where that can't do real concurrent work). Add runtime/include/loom/thread_support.h: LOOM_HAS_THREADS, keyed on the correct macro (__EMSCRIPTEN_PTHREADS__, defined only when -pthread was passed -- verified empirically, not assumed). 1 for native and wasm+pthread, 0 only for wasm without pthread. Restore the cooperative fallback in scheduler.cpp (the 3 thread-spawn sites + pauseClass()/unpauseClass()'s no-op), now under #if LOOM_HAS_THREADS / #else instead of __EMSCRIPTEN__, so the SAME source produces the right behavior for whichever way it's compiled. RuntimeCore's constructor now enforces the policy requested: cooperative mode is OPTIONAL when LOOM_HAS_THREADS (the caller's RuntimeConfig::cooperative is honored as-is), but FORCED to true when !LOOM_HAS_THREADS regardless of what was requested (with a warning log) -- a build with no real threads has no other mode that works, so silently misbehaving on a mismatched request would be worse than overriding it. CMake is intentionally NOT changed -- it still always builds loom_host_wasm with -pthread (no non-threaded wasm target is wired up). This is a source-only change: the code supports the non-threaded variant, building it is a separate, deferred step. Verified all three configurations empirically: - Native (LOOM_HAS_THREADS=1): unaffected -- build + 116/117 tests, same pre-existing TraceCacheTest.ExampleMotorRuntimeFieldsCached failure. - Wasm with -pthread, via the existing CMake target (LOOM_HAS_THREADS=1): unaffected -- re-ran spike/phaseD-threaded-boot, identical result (real class thread, 10ms period -> exactly 30 cycles per 300ms). - Wasm WITHOUT -pthread, standalone (LOOM_HAS_THREADS=0, not wired into CMake): compiled loom_core + run_wasm.cpp + a real bundled module manually (spike/phaseE-nonpthread-check/). Confirmed the RuntimeCore constructor logs the forcing warning and overrides the (always-false) request from run_wasm.cpp; confirmed the module is INERT with zero tick() calls (proving no thread was spawned); confirmed 5 explicit tick() calls (spaced past the class period) advance cycle_count by exactly 5 -- the cooperative path genuinely works, not just compiles. Co-Authored-By: Claude Opus 4.8 --- runtime/include/loom/runtime_core.h | 6 +++-- runtime/include/loom/thread_support.h | 26 +++++++++++++++++++ runtime/src/runtime_core.cpp | 13 ++++++++++ runtime/src/scheduler.cpp | 36 ++++++++++++++++++++++++--- 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 runtime/include/loom/thread_support.h diff --git a/runtime/include/loom/runtime_core.h b/runtime/include/loom/runtime_core.h index ae1f0be..2912d50 100644 --- a/runtime/include/loom/runtime_core.h +++ b/runtime/include/loom/runtime_core.h @@ -41,8 +41,10 @@ struct RuntimeConfig { /// Cooperative (thread-free) mode: skip starting class threads and the file /// watcher in loadModules(). The host drives execution by calling - /// scheduler().tickOnce() itself. Used by single-threaded hosts (the WASM - /// build); leave false for the normal threaded runtime. + /// 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; }; 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/runtime_core.cpp b/runtime/src/runtime_core.cpp index 1cc28d4..9ab8b18 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 @@ -45,6 +46,18 @@ RuntimeCore::RuntimeCore(const RuntimeConfig& config) : config_(config), dataStore_(config.dataDir), watcher_(config.moduleDir), faultStore_(config.dataDir / "crash") { +#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 // Load scheduler.json from the data directory. auto schedPath = config.dataDir / "scheduler.json"; bool schedExisted = std::filesystem::exists(schedPath); diff --git a/runtime/src/scheduler.cpp b/runtime/src/scheduler.cpp index 958e245..e26ee8d 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -3,6 +3,7 @@ #include "loom/io_mapper.h" #include "loom/diag/guard.h" #include "loom/diag/fault_sink.h" +#include "loom/thread_support.h" #include @@ -288,6 +289,7 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon // Start long-running thread immediately (independent of class membership). if (config.enableLongRunning) { +#if LOOM_HAS_THREADS statePtr->longRunningThread = std::thread([this, &mod, statePtr]() { spdlog::info("Long-running task started for '{}'", mod.id); while (statePtr->running.load()) { @@ -321,14 +323,23 @@ bool Scheduler::start(LoadedModule& mod, const TaskConfig& config, const InitCon } 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); @@ -349,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())); @@ -710,6 +723,7 @@ void Scheduler::sortMembers(ClassRunnerState& runner) { } void Scheduler::pauseClass(ClassRunnerState& runner) { +#if LOOM_HAS_THREADS if (!runner.running.load()) return; { @@ -719,21 +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 now (see spike/phaseC-pthread-dlopen/, - // which proved this exact mutex+condvar handshake against a worker thread - // calling into a dlopen'd module). + // 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 ---------------------------------------------------------- From c6e09688f5b766566429af0c067cba8d875c8cbe Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 18:04:20 -0700 Subject: [PATCH 51/62] fix(scheduler): close the last path to the tickOnce()/classLoop() race + add mutex_ lock Revisits Copilot's original PR #16 finding (tickOnce() reads classes_ / runner.members without mutex_, unlike every other public Scheduler method) now that real threading exists. The specific race it described still can't happen through the boot-time path (loadModules()'s two startClasses() call sites are gated on !cooperative, and loom_tick() refuses to run on a non-cooperative instance) -- but re-checking surfaced a real gap in the HOT-LOAD path. - RuntimeCore::instantiateModule() and ::uploadModule() (used by loom_load_module(), i.e. adding a module after boot) called scheduler_.startClasses() UNCONDITIONALLY -- no !cooperative guard, unlike loadModules(). Harmless today only because LOOM_HAS_THREADS==0 compiles the actual thread spawn out of startClasses() entirely regardless of the call. But on a LOOM_HAS_THREADS==1 build (native, or wasm+pthread) running cooperative=true voluntarily -- not exercised anywhere today, but a real configuration the source now supports -- this would spawn a genuine classLoop() thread despite cooperative mode, which could then race a concurrent tickOnce() call on the same classes_/runner.members. Fixed by adding the same guard used in loadModules(). - With that fixed, startClasses() can no longer run while cooperative==true through ANY code path -- tickOnce() and classLoop() threads are now structurally exclusive, not just exclusive-in-practice. - Added the mutex_ lock to tickOnce() anyway, as defense-in-depth: the guard fix closes the only currently-plausible path, but a pthread-capable wasm host makes it newly possible for a future harness to call exported functions from multiple OS threads into the same module instance. sweepClassOnce() and everything it calls were checked and never touch mutex_ or a structural Scheduler method, so this can't self-deadlock. Verified: native build + 116/117 tests (same pre-existing failure). Rebuilt wasm-pthread (29/29) and re-ran spike/phaseD-threaded-boot -- identical result (real class thread, 10ms period -> 30/60 cycles). Rebuilt and re-ran spike/phaseE-nonpthread-check (the path that actually exercises the new tickOnce() lock) -- identical result (forced-cooperative warning, inert without tick(), advances exactly 5 for 5 spaced tick() calls). Co-Authored-By: Claude Opus 4.8 --- runtime/include/loom/scheduler.h | 17 +++++++++++------ runtime/src/runtime_core.cpp | 9 +++++++-- runtime/src/scheduler.cpp | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/runtime/include/loom/scheduler.h b/runtime/include/loom/scheduler.h index 067223c..6f82499 100644 --- a/runtime/include/loom/scheduler.h +++ b/runtime/include/loom/scheduler.h @@ -153,12 +153,17 @@ class Scheduler { /// (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 single-threaded - /// hosts (e.g. the WASM build) 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. + /// 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. diff --git a/runtime/src/runtime_core.cpp b/runtime/src/runtime_core.cpp index 9ab8b18..c5fb3a3 100644 --- a/runtime/src/runtime_core.cpp +++ b/runtime/src/runtime_core.cpp @@ -442,7 +442,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; @@ -522,7 +526,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/scheduler.cpp b/runtime/src/scheduler.cpp index e26ee8d..aa7b251 100644 --- a/runtime/src/scheduler.cpp +++ b/runtime/src/scheduler.cpp @@ -895,6 +895,20 @@ void Scheduler::tickOnce() { // 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_) { From c774e295c3ec36a201e88d7caf49be2df6811ea4 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 20:40:50 -0700 Subject: [PATCH 52/62] feat(sdk): loom_add_module() wasm SIDE_MODULE support + arch-independent package version loom_add_module() (cmake/LoomModule.cmake) now produces a real dlopen-able wasm SIDE_MODULE automatically under Emscripten, with zero extra configuration needed by the caller: - TARGET_SUPPORTS_SHARED_LIBS forced TRUE before add_library(MODULE) -- otherwise CMake's Emscripten platform silently downgrades MODULE libraries to plain static archives with a .so-shaped name. - -sSIDE_MODULE=1 (compile + link) -- what actually produces the dylink.0 wasm binary. - -fexceptions -- matches the loom_wasm MAIN_MODULE host's build, required for real C++ exceptions to work across the dlopen boundary. De-risked standalone in spike/phaseC-pthread-dlopen/ before this landed. - FMT_CONSTEVAL= -- neutralizes fmt's consteval format-string checking, which emcc's clang rejects (harmless if a module never includes spdlog). modules/example_motor/CMakeLists.txt converts to loom_add_module() as a smoke test -- every other bundled module still hand-rolls add_library(MODULE) + the boilerplate this helper now replaces. Verified: native build + 116/117 tests unchanged, and a node harness booting the wasm host confirmed the module dlopens, registers, and ticks identically to before (spike/phaseF-loom-add-module-check/). sdk/CMakeLists.txt: add ARCH_INDEPENDENT to write_basic_package_version_file. The loom SDK is a header-only INTERFACE library (no compiled binaries), so it's genuinely usable from any target architecture -- without this flag, find_package(loom CONFIG)'s default compatibility check compares CMAKE_SIZEOF_VOID_P against the architecture this was installed from, silently (QUIET) rejecting the package for any cross-compile consumer (e.g. a wasm32 configure against a package installed from a native arm64 build) even though nothing in the SDK actually depends on pointer size. Found while getting Actuate's own Loom modules (robot/axis) building for wasm via find_package(loom CONFIG) against a local dev install. Co-Authored-By: Claude Opus 4.8 --- cmake/LoomModule.cmake | 34 ++++++++++++++++++++++++++++ modules/example_motor/CMakeLists.txt | 24 ++++++-------------- sdk/CMakeLists.txt | 7 ++++++ 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/cmake/LoomModule.cmake b/cmake/LoomModule.cmake index d4359f5..d49e578 100644 --- a/cmake/LoomModule.cmake +++ b/cmake/LoomModule.cmake @@ -56,6 +56,30 @@ function(loom_target_dsym target) 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. 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) + target_link_options(${target} PRIVATE -sSIDE_MODULE=1 -fexceptions) + target_compile_definitions(${target} PRIVATE FMT_CONSTEVAL=) +endfunction() + # loom_add_module( # SOURCES # [LINK ] @@ -65,12 +89,21 @@ endfunction() # 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) @@ -93,4 +126,5 @@ function(loom_add_module name) loom_target_debug_info(${name}) loom_target_dsym(${name}) + loom_target_wasm_module_support(${name}) endfunction() 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/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index fef09c3..031cdf6 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -83,10 +83,17 @@ 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 From a3e68cd00033c4c689b36773ef652ea8dcddccaa Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 21:19:11 -0700 Subject: [PATCH 53/62] feat(wasm): add bootFromDataDir() manifest-driven browser boot Mirrors native loom's "point at a dataDir and moduleDir and go" workflow in the browser: reads /instances.json (the same [{id, so, className}] manifest RuntimeCore::loadModules() reads off disk natively), fetches each module's .so from / and its persisted config.json from //, seeds it all into MEMFS, then boots through the existing native code path unchanged. A module named in the manifest but not found at / (never built for wasm) is skipped with a warning rather than failing the whole boot, matching native's resolveModulePath() behavior. Fetches use cache: 'no-store' -- native boot always reads whatever's currently on disk with no caching layer, and a browser's default HTTP caching can otherwise serve a stale module after a rebuild or removal. createLoomRuntime() gains an optional beforeInit hook (called after MEMFS directories exist but before loom_init()) as the seam bootFromDataDir uses; read-only for now, no write-back persistence yet. --- frontend/src/wasm/loomRuntime.js | 89 +++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/frontend/src/wasm/loomRuntime.js b/frontend/src/wasm/loomRuntime.js index 3488c50..9cde874 100644 --- a/frontend/src/wasm/loomRuntime.js +++ b/frontend/src/wasm/loomRuntime.js @@ -11,8 +11,12 @@ * @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 } = {}) { +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) {} } @@ -27,6 +31,7 @@ export async function createLoomRuntime({ createModule, tickMs = 25 } = {}) { writeNode: M.cwrap('loom_write_node', 'number', ['string', 'string']), }; + if (beforeInit) await beforeInit(M); c.init('/modules', '/data'); let timer = setInterval(() => c.tick(), tickMs); @@ -85,3 +90,85 @@ export async function createLoomRuntime({ createModule, tickMs = 25 } = {}) { 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 = []; + + 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); + } + + 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 }); + return { ...rt, skipped }; +} From 5e212334a0393532ab9fbb619efabca1bb314951 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Tue, 30 Jun 2026 22:17:36 -0700 Subject: [PATCH 54/62] feat(wasm): package @joshpolansky/loom-wasm for npm distribution Extracts the browser-runtime pieces (loomRuntime.js, WasmMachine.ts) out of frontend/src/wasm/ into a standalone package, packages/loom-wasm, so any React app can `npm install @joshpolansky/loom-wasm` and get a working lux-react-compatible machine connection instead of hand-copying files. loom's own frontend now dogfoods the package via a relative import (no workspace tooling introduced for one package) rather than keeping its own duplicate. bootWasmMachine() is the package's whole public API: point it at a real dataDir/moduleUrl and it boots the wasm runtime, loads modules the same way native's `loom --module-dir --data-dir` does (via bootFromDataDir), and returns a WasmMachine ready to hand to lux-react's . Also closes a real visibility gap in bootFromDataDir: a module whose .so fetched fine but got silently rejected by the C++ loader (SDK/ABI version mismatch, runtime/src/module_loader.cpp) previously vanished from the loaded set with no signal. It's now reconciled into `skipped` with a clear warning after boot completes. Scoped to a local, scripted build+pack flow for v1 (scripts/build-wasm.sh) -- no CI builds Emscripten yet, and publishing needs a new write:packages-scoped GitHub PAT that doesn't exist yet either. Both are real follow-on work, not done here. --- frontend/src/wasm/boot.ts | 10 ++- packages/loom-wasm/.gitignore | 7 +++ packages/loom-wasm/.npmrc | 13 ++++ packages/loom-wasm/package.json | 25 ++++++++ packages/loom-wasm/scripts/build-wasm.sh | 61 +++++++++++++++++++ .../loom-wasm/src}/WasmMachine.ts | 0 packages/loom-wasm/src/bootWasmMachine.ts | 59 ++++++++++++++++++ packages/loom-wasm/src/index.ts | 6 ++ packages/loom-wasm/src/loomRuntime.d.ts | 35 +++++++++++ .../loom-wasm/src}/loomRuntime.js | 20 ++++++ packages/loom-wasm/tsconfig.json | 21 +++++++ 11 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 packages/loom-wasm/.gitignore create mode 100644 packages/loom-wasm/.npmrc create mode 100644 packages/loom-wasm/package.json create mode 100755 packages/loom-wasm/scripts/build-wasm.sh rename {frontend/src/wasm => packages/loom-wasm/src}/WasmMachine.ts (100%) create mode 100644 packages/loom-wasm/src/bootWasmMachine.ts create mode 100644 packages/loom-wasm/src/index.ts create mode 100644 packages/loom-wasm/src/loomRuntime.d.ts rename {frontend/src/wasm => packages/loom-wasm/src}/loomRuntime.js (88%) create mode 100644 packages/loom-wasm/tsconfig.json diff --git a/frontend/src/wasm/boot.ts b/frontend/src/wasm/boot.ts index 6a952c8..c391ef2 100644 --- a/frontend/src/wasm/boot.ts +++ b/frontend/src/wasm/boot.ts @@ -7,9 +7,15 @@ // (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. // @ts-expect-error — plain-JS runtime service (see loomRuntime.js) -import { createLoomRuntime } from './loomRuntime.js'; -import { WasmMachine } from './WasmMachine'; +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 { diff --git a/packages/loom-wasm/.gitignore b/packages/loom-wasm/.gitignore new file mode 100644 index 0000000..c522051 --- /dev/null +++ b/packages/loom-wasm/.gitignore @@ -0,0 +1,7 @@ +dist/ +wasm/*.js +wasm/*.wasm +wasm/demo-modules/*.so +node_modules/ +*.tgz +*.tsbuildinfo diff --git a/packages/loom-wasm/.npmrc b/packages/loom-wasm/.npmrc new file mode 100644 index 0000000..ea663a9 --- /dev/null +++ b/packages/loom-wasm/.npmrc @@ -0,0 +1,13 @@ +# The @loupeteam packages (lux-connect) used by WasmMachine's ConnectionState +# type are published to GitHub Packages, not npmjs. Map that scope to the +# GitHub registry so `npm install` resolves them. This package itself also +# publishes to the same registry under the @joshpolansky scope (see +# publishConfig in package.json) -- GitHub Packages requires a scoped +# package's scope to exactly match its GitHub owner. +# +# Authentication is supplied out-of-band and never committed here: +# - local dev / publish: a `//npm.pkg.github.com/:_authToken=` line +# in ~/.npmrc (needs write:packages scope to publish) +# - CI (consuming only): injected from a repo secret, see frontend/.npmrc +@loupeteam:registry=https://npm.pkg.github.com/ +@joshpolansky:registry=https://npm.pkg.github.com/ diff --git a/packages/loom-wasm/package.json b/packages/loom-wasm/package.json new file mode 100644 index 0000000..df17afc --- /dev/null +++ b/packages/loom-wasm/package.json @@ -0,0 +1,25 @@ +{ + "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 && cp src/loomRuntime.js src/loomRuntime.d.ts dist/", + "build:wasm": "./scripts/build-wasm.sh" + }, + "peerDependencies": { + "@loupeteam/lux-connect": "^0.0.3" + }, + "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/frontend/src/wasm/WasmMachine.ts b/packages/loom-wasm/src/WasmMachine.ts similarity index 100% rename from frontend/src/wasm/WasmMachine.ts rename to packages/loom-wasm/src/WasmMachine.ts 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/frontend/src/wasm/loomRuntime.js b/packages/loom-wasm/src/loomRuntime.js similarity index 88% rename from frontend/src/wasm/loomRuntime.js rename to packages/loom-wasm/src/loomRuntime.js index 9cde874..155007b 100644 --- a/frontend/src/wasm/loomRuntime.js +++ b/packages/loom-wasm/src/loomRuntime.js @@ -117,6 +117,7 @@ export async function createLoomRuntime({ createModule, tickMs = 25, beforeInit 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 { @@ -143,6 +144,7 @@ export async function bootFromDataDir({ createModule, dataUrl, moduleUrl, tickMs 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()); @@ -170,5 +172,23 @@ export async function bootFromDataDir({ createModule, dataUrl, moduleUrl, tickMs }; 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"] +} From ead4f6ec5f6db5532e25b45f6b27c99ea5df007a Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Wed, 1 Jul 2026 18:24:38 -0700 Subject: [PATCH 55/62] fix(wasm): drop unnecessary @loupeteam/lux-connect dep, stale ts-expect-error WasmMachine only used @loupeteam/lux-connect for ConnectionState.CONNECTED -- a TS string enum whose runtime value is literally "connected". lux-react's ICommLayer.connectionState is typed `ConnectionState | string`, so a plain string already satisfies it; no need to depend on the package for one enum value. Drops the peerDependency and the now-unused @loupeteam registry mapping in .npmrc. This also fixes the actual CI failure: packages/loom-wasm has no node_modules of its own in CI (only manually installed locally for testing), so the peer dependency never resolved there, even though frontend's own install made it work on a dev machine. Also removes a now-stale @ts-expect-error above boot.ts's loomRuntime.js import -- it was needed before loomRuntime.d.ts existed; once real types were available the directive itself became a TS2578 (unused directive) error. --- frontend/src/wasm/boot.ts | 3 ++- packages/loom-wasm/.npmrc | 11 +++------- packages/loom-wasm/package-lock.json | 29 +++++++++++++++++++++++++++ packages/loom-wasm/package.json | 3 --- packages/loom-wasm/src/WasmMachine.ts | 16 ++++++++++----- 5 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 packages/loom-wasm/package-lock.json diff --git a/frontend/src/wasm/boot.ts b/frontend/src/wasm/boot.ts index c391ef2..e256340 100644 --- a/frontend/src/wasm/boot.ts +++ b/frontend/src/wasm/boot.ts @@ -13,7 +13,8 @@ // 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. -// @ts-expect-error — plain-JS runtime service (see loomRuntime.js) +// 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'; diff --git a/packages/loom-wasm/.npmrc b/packages/loom-wasm/.npmrc index ea663a9..3da1548 100644 --- a/packages/loom-wasm/.npmrc +++ b/packages/loom-wasm/.npmrc @@ -1,13 +1,8 @@ -# The @loupeteam packages (lux-connect) used by WasmMachine's ConnectionState -# type are published to GitHub Packages, not npmjs. Map that scope to the -# GitHub registry so `npm install` resolves them. This package itself also -# publishes to the same registry under the @joshpolansky scope (see -# publishConfig in package.json) -- GitHub Packages requires a scoped -# package's scope to exactly match its GitHub owner. +# This package publishes to GitHub Packages (see publishConfig in +# package.json) -- GitHub Packages requires a scoped package's scope to +# exactly match its GitHub owner. # # Authentication is supplied out-of-band and never committed here: # - local dev / publish: a `//npm.pkg.github.com/:_authToken=` line # in ~/.npmrc (needs write:packages scope to publish) -# - CI (consuming only): injected from a repo secret, see frontend/.npmrc -@loupeteam:registry=https://npm.pkg.github.com/ @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 index df17afc..b1ac84f 100644 --- a/packages/loom-wasm/package.json +++ b/packages/loom-wasm/package.json @@ -13,9 +13,6 @@ "build": "tsc -b && cp src/loomRuntime.js src/loomRuntime.d.ts dist/", "build:wasm": "./scripts/build-wasm.sh" }, - "peerDependencies": { - "@loupeteam/lux-connect": "^0.0.3" - }, "devDependencies": { "typescript": "~5.9.3" }, diff --git a/packages/loom-wasm/src/WasmMachine.ts b/packages/loom-wasm/src/WasmMachine.ts index 5dccdd2..2383891 100644 --- a/packages/loom-wasm/src/WasmMachine.ts +++ b/packages/loom-wasm/src/WasmMachine.ts @@ -8,7 +8,13 @@ // "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. -import { ConnectionState } from '@loupeteam/lux-connect'; +// +// 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 { @@ -16,7 +22,7 @@ export interface NodeAccess { writeNode(nodeId: string, value: unknown): boolean; } -type StateHandler = (state: ConnectionState) => void; +type StateHandler = (state: string) => void; interface Sub { nodeId: string; cb: (v: unknown) => void; lastRaw: string; } export class WasmMachine { @@ -32,15 +38,15 @@ export class WasmMachine { } // --- connection (always "connected": the runtime is in-process) --- - get connectionState(): ConnectionState { return ConnectionState.CONNECTED; } + get connectionState(): string { return CONNECTED; } isConnected(): boolean { return true; } - async connect(): Promise { this.handlers.forEach((h) => h(ConnectionState.CONNECTED)); } + 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(ConnectionState.CONNECTED); + handler(CONNECTED); } // --- reads / writes --- From 919bf553a0501b4d5c43f10e8c4af6eb04bb1b44 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Wed, 1 Jul 2026 18:56:17 -0700 Subject: [PATCH 56/62] fix(wasm): address PR #17 review findings (leak, error JSON, detail shape, preflight) - run_wasm.cpp returned malloc'd strings, but the JS side binds every entry point with cwrap(..., 'string', ...), which copies and never frees -- an unbounded wasm-heap leak, worst under WasmMachine's continuous readNode() polling. All string returns now hand out a static buffer valid until the next exported call (safe: exported calls all run on the JS main thread and cwrap copies synchronously). Verified: 300k readNode calls, zero RSS growth. - createLoomRuntime() ignored loom_init()'s return; a failed boot (-1, core torn down) still started the tick loop and returned a runtime that quietly serves 503s. It now throws. - badRequest() interpolated the message into JSON unescaped. Not theoretical: the PATCH body-shape hint contains double quotes, so that 400 response was invalid JSON. Now escaped via the file's existing jsonEscapeString(). - GET /api/modules/ in api::dispatch returned ModuleInfo only, while the frontend's ModuleDetail page requires the data{config,recipe,runtime, summary} object -- broken on wasm, where dispatch is the only server. dispatch now serves the full ModuleDetail shape, and server.cpp's duplicate native route is dropped in favor of it (same pattern as the other migrated routes). - The /api/ catch-all didn't answer OPTIONS; the pre-migration per-route handlers answered CORS preflight explicitly, so cross-origin non-GET calls to migrated routes regressed to dying at preflight. The catch-all now answers OPTIONS with 204 + CORS headers. Also documents the conan-release preset-name collision between build-release and build-wasm in the justfile (a latent footgun, not a current breakage -- the review's claim that build-wasm builds native was incorrect; it resolves to build/Wasm's generated preset). --- justfile | 7 +++ packages/loom-wasm/src/loomRuntime.js | 8 ++- runtime/src/api/router.cpp | 18 ++++++- runtime/src/run_wasm.cpp | 75 +++++++++++++++------------ runtime/src/server.cpp | 75 +++++---------------------- 5 files changed, 85 insertions(+), 98 deletions(-) diff --git a/justfile b/justfile index a7f3d64..566b8cc 100644 --- a/justfile +++ b/justfile @@ -44,6 +44,13 @@ setup-wasm: # 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 diff --git a/packages/loom-wasm/src/loomRuntime.js b/packages/loom-wasm/src/loomRuntime.js index 155007b..a4ad5e9 100644 --- a/packages/loom-wasm/src/loomRuntime.js +++ b/packages/loom-wasm/src/loomRuntime.js @@ -32,7 +32,13 @@ export async function createLoomRuntime({ createModule, tickMs = 25, beforeInit }; if (beforeInit) await beforeInit(M); - c.init('/modules', '/data'); + // 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}} */ diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 84655ed..8021b22 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -231,7 +231,9 @@ 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\"}"); } -Response badRequest(std::string msg) { return json(400, "{\"error\":\"" + std::move(msg) + "\"}"); } +// 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). @@ -335,7 +337,19 @@ Response dispatch(RuntimeCore& core, const Request& req) { std::shared_lock lock(core.moduleMutex()); auto* mod = core.loader().get(modId); if (!mod) return json(404, "{\"error\":\"module not found\"}"); - return json(200, moduleInfoJson(*mod, core.scheduler())); + // 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 out = moduleInfoJson(*mod, core.scheduler()); + out.pop_back(); // strip closing '}' to append the data object + out += ",\"data\":{"; + out += "\"config\":" + core.dataEngine().readSection(modId, DataSection::Config); + out += ",\"recipe\":" + core.dataEngine().readSection(modId, DataSection::Recipe); + out += ",\"runtime\":" + core.dataEngine().readSection(modId, DataSection::Runtime); + out += ",\"summary\":" + core.dataEngine().readSection(modId, DataSection::Summary); + out += "}}"; + return json(200, std::move(out)); } if (method == Method::DELETE_) { if (!core.removeInstance(modId)) return json(404, "{\"error\":\"instance not found\"}"); diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp index 6a95b03..226eee5 100644 --- a/runtime/src/run_wasm.cpp +++ b/runtime/src/run_wasm.cpp @@ -18,22 +18,27 @@ #include #include -#include -#include #include #include #include +#include #include namespace { std::unique_ptr g_core; std::vector g_ids; // ids of modules loaded by loom_init -// Copy a std::string into a malloc'd C buffer the JS side reads then free()s. -char* dupString(const std::string& s) { - char* out = static_cast(std::malloc(s.size() + 1)); - if (out) std::memcpy(out, s.c_str(), s.size() + 1); - return out; +// 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 @@ -67,22 +72,23 @@ void loom_tick() { if (g_core) g_core->scheduler().tickOnce(); } -// Comma-separated ids of the modules loaded by loom_init. malloc'd; caller free()s. +// Comma-separated ids of the modules loaded by loom_init. Valid until the next +// exported call (JS copies immediately — see stableString). EMSCRIPTEN_KEEPALIVE -char* loom_module_ids() { +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 dupString(s); + 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 malloc'd JSON envelope -// {"status":,"body":} (caller free()s) so the JS fetch shim can -// build a Response with the right status. Body is embedded raw (every handler -// returns valid JSON). +// 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 -char* loom_request(const char* method, const char* path, const char* body) { - if (!g_core) return dupString("{\"status\":503,\"body\":null}"); +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 : ""; @@ -90,33 +96,32 @@ char* loom_request(const char* method, const char* path, const char* 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 dupString(env); + 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 cooperatively. Returns the loaded module id -// (empty string on failure). malloc'd; caller free()s. +// (empty string on failure), valid until the next exported call. EMSCRIPTEN_KEEPALIVE -char* loom_load_module(const char* path) { - if (!g_core || !path) return dupString(""); +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 dupString(id); + return stableString(std::move(id)); } catch (const std::exception& e) { spdlog::error("loom_load_module('{}') failed: {}", path, e.what()); - return dupString(""); + return stableString(""); } } -// Reflected runtime state of one module instance, as a JSON string. Returns a -// malloc'd C string the caller must free() (null on error). +// Reflected runtime state of one module instance, as a JSON string valid until +// the next exported call (null on error). EMSCRIPTEN_KEEPALIVE -char* loom_state_json(const char* moduleId) { +const char* loom_state_json(const char* moduleId) { if (!g_core || !moduleId) return nullptr; - std::string js = g_core->dataEngine().readSection(moduleId, loom::DataSection::Runtime); - return dupString(js); + return stableString(g_core->dataEngine().readSection(moduleId, loom::DataSection::Runtime)); } // --- OPC-UA-style reflected tag access (backs the WasmMachine) --------------- @@ -125,19 +130,21 @@ char* loom_state_json(const char* moduleId) { // 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. malloc'd; caller free()s. +// 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 -char* loom_read_node(const char* nodeId) { - if (!g_core || !nodeId) return dupString("null"); +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 dupString("null"); + 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 dupString("null"); - if (p.kind == K::Section) return dupString(mod->instance->readSection(p.section)); + 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 dupString(v ? *v : std::string("null")); + return stableString(v ? std::move(*v) : std::string("null")); } // Write a node's reflected value from JSON. Returns 1 on success, 0 on failure. diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index d4d0b8d..c2a1801 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -192,66 +192,9 @@ void Server::start() { // GET /api/faults/ and POST /api/modules/instantiate — migrated to // api::dispatch (router.cpp). - // ===================================================================== - // 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 /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 @@ -835,10 +778,20 @@ void Server::start() { // 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::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() From d3342ea97851b3a6d043a692a41f5062ee68e7ca Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Wed, 1 Jul 2026 18:59:12 -0700 Subject: [PATCH 57/62] fix(wasm): address PR #18 review findings (leak, tick spam, detail shape, preflight) Ports the shared fixes from wasm-spike (919bf55) -- static-buffer string returns in run_wasm.cpp (the cwrap 'string' binding copies and never frees, so malloc'd returns leaked unboundedly under readNode polling), escaped badRequest() messages (the PATCH shape hint contains quotes and produced invalid JSON), the full ModuleDetail shape from api::dispatch's GET /api/modules/ (dropping server.cpp's duplicate native route), OPTIONS preflight on the /api catch-all, and loom_init failure-checking in loomRuntime.js. Plus one finding specific to this branch: loom_tick() warned on EVERY call when the runtime is threaded (non-cooperative), and the JS service ticks on a 25ms interval regardless of mode -- 40 warnings/sec of log spam. It now warns once and ignores silently after that. Verified: native + wasm both build clean; Node harness confirms module detail carries data{...}, the 400 body parses as JSON, and 200k readNode calls show no memory growth. --- frontend/src/wasm/loomRuntime.js | 8 ++- justfile | 7 +++ runtime/src/api/router.cpp | 18 ++++++- runtime/src/run_wasm.cpp | 86 +++++++++++++++++++------------- runtime/src/server.cpp | 75 ++++++---------------------- 5 files changed, 94 insertions(+), 100 deletions(-) diff --git a/frontend/src/wasm/loomRuntime.js b/frontend/src/wasm/loomRuntime.js index 3488c50..4c7a639 100644 --- a/frontend/src/wasm/loomRuntime.js +++ b/frontend/src/wasm/loomRuntime.js @@ -27,7 +27,13 @@ export async function createLoomRuntime({ createModule, tickMs = 25 } = {}) { writeNode: M.cwrap('loom_write_node', 'number', ['string', 'string']), }; - c.init('/modules', '/data'); + // 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}} */ diff --git a/justfile b/justfile index a7f3d64..566b8cc 100644 --- a/justfile +++ b/justfile @@ -44,6 +44,13 @@ setup-wasm: # 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 diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 84655ed..8021b22 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -231,7 +231,9 @@ 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\"}"); } -Response badRequest(std::string msg) { return json(400, "{\"error\":\"" + std::move(msg) + "\"}"); } +// 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). @@ -335,7 +337,19 @@ Response dispatch(RuntimeCore& core, const Request& req) { std::shared_lock lock(core.moduleMutex()); auto* mod = core.loader().get(modId); if (!mod) return json(404, "{\"error\":\"module not found\"}"); - return json(200, moduleInfoJson(*mod, core.scheduler())); + // 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 out = moduleInfoJson(*mod, core.scheduler()); + out.pop_back(); // strip closing '}' to append the data object + out += ",\"data\":{"; + out += "\"config\":" + core.dataEngine().readSection(modId, DataSection::Config); + out += ",\"recipe\":" + core.dataEngine().readSection(modId, DataSection::Recipe); + out += ",\"runtime\":" + core.dataEngine().readSection(modId, DataSection::Runtime); + out += ",\"summary\":" + core.dataEngine().readSection(modId, DataSection::Summary); + out += "}}"; + return json(200, std::move(out)); } if (method == Method::DELETE_) { if (!core.removeInstance(modId)) return json(404, "{\"error\":\"instance not found\"}"); diff --git a/runtime/src/run_wasm.cpp b/runtime/src/run_wasm.cpp index 5e54836..ffec27a 100644 --- a/runtime/src/run_wasm.cpp +++ b/runtime/src/run_wasm.cpp @@ -26,22 +26,27 @@ #include #include -#include -#include #include #include #include +#include #include namespace { std::unique_ptr g_core; std::vector g_ids; // ids of modules loaded by loom_init -// Copy a std::string into a malloc'd C buffer the JS side reads then free()s. -char* dupString(const std::string& s) { - char* out = static_cast(std::malloc(s.size() + 1)); - if (out) std::memcpy(out, s.c_str(), s.size() + 1); - return out; +// 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 @@ -81,29 +86,37 @@ EMSCRIPTEN_KEEPALIVE void loom_tick() { if (!g_core) return; if (!g_core->config().cooperative) { - spdlog::warn("loom_tick() called on a non-cooperative runtime (real class " - "threads are already driving it) -- ignored."); + // 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. malloc'd; caller free()s. +// Comma-separated ids of the modules loaded by loom_init. Valid until the next +// exported call (JS copies immediately — see stableString). EMSCRIPTEN_KEEPALIVE -char* loom_module_ids() { +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 dupString(s); + 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 malloc'd JSON envelope -// {"status":,"body":} (caller free()s) so the JS fetch shim can -// build a Response with the right status. Body is embedded raw (every handler -// returns valid JSON). +// 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 -char* loom_request(const char* method, const char* path, const char* body) { - if (!g_core) return dupString("{\"status\":503,\"body\":null}"); +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 : ""; @@ -111,33 +124,32 @@ char* loom_request(const char* method, const char* path, const char* 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 dupString(env); + 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 cooperatively. Returns the loaded module id -// (empty string on failure). malloc'd; caller free()s. +// (empty string on failure), valid until the next exported call. EMSCRIPTEN_KEEPALIVE -char* loom_load_module(const char* path) { - if (!g_core || !path) return dupString(""); +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 dupString(id); + return stableString(std::move(id)); } catch (const std::exception& e) { spdlog::error("loom_load_module('{}') failed: {}", path, e.what()); - return dupString(""); + return stableString(""); } } -// Reflected runtime state of one module instance, as a JSON string. Returns a -// malloc'd C string the caller must free() (null on error). +// Reflected runtime state of one module instance, as a JSON string valid until +// the next exported call (null on error). EMSCRIPTEN_KEEPALIVE -char* loom_state_json(const char* moduleId) { +const char* loom_state_json(const char* moduleId) { if (!g_core || !moduleId) return nullptr; - std::string js = g_core->dataEngine().readSection(moduleId, loom::DataSection::Runtime); - return dupString(js); + return stableString(g_core->dataEngine().readSection(moduleId, loom::DataSection::Runtime)); } // --- OPC-UA-style reflected tag access (backs the WasmMachine) --------------- @@ -146,19 +158,21 @@ char* loom_state_json(const char* moduleId) { // 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. malloc'd; caller free()s. +// 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 -char* loom_read_node(const char* nodeId) { - if (!g_core || !nodeId) return dupString("null"); +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 dupString("null"); + 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 dupString("null"); - if (p.kind == K::Section) return dupString(mod->instance->readSection(p.section)); + 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 dupString(v ? *v : std::string("null")); + return stableString(v ? std::move(*v) : std::string("null")); } // Write a node's reflected value from JSON. Returns 1 on success, 0 on failure. diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index d4d0b8d..c2a1801 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -192,66 +192,9 @@ void Server::start() { // GET /api/faults/ and POST /api/modules/instantiate — migrated to // api::dispatch (router.cpp). - // ===================================================================== - // 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 /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 @@ -835,10 +778,20 @@ void Server::start() { // 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::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() From 6d7acdfd7c82c34977e38e696a3e258b5e9b7b28 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Wed, 1 Jul 2026 19:51:01 -0700 Subject: [PATCH 58/62] fix(wasm): address round-2 review findings on PR #17 - instantiateModule()/uploadModule() called scheduler_.startClasses() unconditionally, unlike loadModules()'s cooperative guard -- the same gap already fixed on the wasm-pthread branch (c6e0968), now ported so a cooperative host can't spawn a class thread through the hot-load path. - Escape the remaining raw string interpolations in router.cpp's JSON builders (bus topics, service names, bus-call error, io-mapping source/target/error) with jsonEscapeString(), same class of bug as the badRequest() fix. - moduleInfoJson() gains an extraFields parameter so the ModuleDetail route no longer does pop_back() brace surgery on the builder's output -- the splice lives inside the builder that owns the format. - Delete frontend/src/wasm/LoomRuntimeProvider.tsx: dead code (never imported), and its './loomRuntime.js' import broke when the file moved to packages/loom-wasm -- masked from tsc by its own @ts-expect-error. - main.tsx: a bootMachine() failure was an unhandled rejection and a permanently blank page; now renders a visible error fallback. - packages/loom-wasm build script: replace `cp` with a node one-liner so the package builds on Windows shells too. Verified: native build clean, frontend tsc clean, wasm rebuilt, Node harness re-confirms ModuleDetail shape / escaped 400 bodies / zero heap growth over 300k readNode calls. --- frontend/src/main.tsx | 11 +++++ frontend/src/wasm/LoomRuntimeProvider.tsx | 51 ----------------------- packages/loom-wasm/package.json | 2 +- runtime/include/loom/api/json_build.h | 8 +++- runtime/src/api/router.cpp | 35 +++++++++------- runtime/src/runtime_core.cpp | 9 +++- 6 files changed, 45 insertions(+), 71 deletions(-) delete mode 100644 frontend/src/wasm/LoomRuntimeProvider.tsx diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c07e680..73a52bd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,10 +6,21 @@ import { bootMachine } from './wasm/boot' // 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/LoomRuntimeProvider.tsx b/frontend/src/wasm/LoomRuntimeProvider.tsx deleted file mode 100644 index 55b4096..0000000 --- a/frontend/src/wasm/LoomRuntimeProvider.tsx +++ /dev/null @@ -1,51 +0,0 @@ -// React wrapper around the framework-agnostic Loom-in-wasm runtime service. -// -// Wrap (a subtree of) the app in and, while it's mounted, -// fetch('/api/*') is served by the in-browser wasm runtime instead of a real -// HTTP server — so the existing REST-driven UI (rest.ts, DataService) works with -// no changes. Live OPC-UA values are a separate transport (not yet faked). -import React, { createContext, useContext, useEffect, useState } from 'react'; -// @ts-expect-error — plain-JS service module (see loomRuntime.js) -import { createLoomRuntime } from './loomRuntime.js'; - -export type LoomRuntime = Awaited>; - -const Ctx = createContext(null); -export const useLoomRuntime = (): LoomRuntime | null => useContext(Ctx); - -export interface LoomRuntimeProviderProps { - /** Emscripten factory, e.g. () => (window as any).createLoomModule() or an import. */ - createModule: () => Promise; - /** Optional modules to load at boot. */ - modules?: { name: string; bytes: Uint8Array }[]; - /** Rendered while the runtime is booting. */ - fallback?: React.ReactNode; - children: React.ReactNode; -} - -export function LoomRuntimeProvider({ - createModule, - modules = [], - fallback =
Booting Loom runtime…
, - children, -}: LoomRuntimeProviderProps) { - const [rt, setRt] = useState(null); - - useEffect(() => { - let uninstall = () => {}; - let runtime: LoomRuntime | null = null; - let cancelled = false; - (async () => { - const r = await createLoomRuntime({ createModule }); - if (cancelled) { r.stop(); return; } - for (const m of modules) r.loadModule(m.name, m.bytes); - uninstall = r.installFetch(); // route /api/* to the wasm runtime - runtime = r; - setRt(r); - })(); - return () => { cancelled = true; uninstall(); runtime?.stop(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return {rt ? children : fallback}; -} diff --git a/packages/loom-wasm/package.json b/packages/loom-wasm/package.json index b1ac84f..e5817e4 100644 --- a/packages/loom-wasm/package.json +++ b/packages/loom-wasm/package.json @@ -10,7 +10,7 @@ "wasm" ], "scripts": { - "build": "tsc -b && cp src/loomRuntime.js src/loomRuntime.d.ts dist/", + "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": { diff --git a/runtime/include/loom/api/json_build.h b/runtime/include/loom/api/json_build.h index 65d74e8..9b7c555 100644 --- a/runtime/include/loom/api/json_build.h +++ b/runtime/include/loom/api/json_build.h @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace loom { @@ -24,7 +25,12 @@ std::string serializeCycleHistory(const std::vector& samples, std: std::string serializeCycleHistory(const std::deque& samples, std::size_t maxSamples = 0); /// Per-module info object (id, name, class, state, path, stats + cycle history). -std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler); +/// `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 diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 8021b22..92b4ef8 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -70,7 +70,8 @@ std::string serializeCycleHistory(const std::deque& samples, std:: return json; } -std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler) { +std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler, + std::string_view extraFields) { auto* ts = scheduler.taskState(mod.id); std::string json = "{"; @@ -109,6 +110,10 @@ std::string moduleInfoJson(const LoadedModule& mod, const Scheduler& scheduler) json += "}"; } + if (!extraFields.empty()) { + json += ","; + json += extraFields; + } json += "}"; return json; } @@ -341,15 +346,13 @@ Response dispatch(RuntimeCore& core, const Request& req) { // 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 out = moduleInfoJson(*mod, core.scheduler()); - out.pop_back(); // strip closing '}' to append the data object - out += ",\"data\":{"; - out += "\"config\":" + core.dataEngine().readSection(modId, DataSection::Config); - out += ",\"recipe\":" + core.dataEngine().readSection(modId, DataSection::Recipe); - out += ",\"runtime\":" + core.dataEngine().readSection(modId, DataSection::Runtime); - out += ",\"summary\":" + core.dataEngine().readSection(modId, DataSection::Summary); - out += "}}"; - return json(200, std::move(out)); + 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\"}"); @@ -541,7 +544,7 @@ Response dispatch(RuntimeCore& core, const Request& req) { 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 += "\"" + topics[i] + "\""; } + for (std::size_t i = 0; i < topics.size(); ++i) { if (i) out += ","; out += "\"" + jsonEscapeString(topics[i]) + "\""; } out += "]"; return json(200, std::move(out)); } @@ -552,7 +555,7 @@ Response dispatch(RuntimeCore& core, const Request& req) { std::string out = "["; for (std::size_t i = 0; i < infos.size(); ++i) { if (i) out += ","; - out += "{\"name\":\"" + infos[i].name + "\",\"schema\":"; + out += "{\"name\":\"" + jsonEscapeString(infos[i].name) + "\",\"schema\":"; out += infos[i].schema.empty() ? "null" : infos[i].schema; out += "}"; } @@ -566,7 +569,7 @@ Response dispatch(RuntimeCore& core, const Request& req) { 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\":\"" + result.error + "\""; + if (!result.error.empty()) out += ",\"error\":\"" + jsonEscapeString(result.error) + "\""; out += "}"; return json(200, std::move(out)); } @@ -638,12 +641,12 @@ Response dispatch(RuntimeCore& core, const Request& req) { if (!entry) continue; if (!first) out += ","; out += "{\"index\":" + std::to_string(i); - out += ",\"source\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].src_module_id : "") + "\""; - out += ",\"target\":\"" + (i < mapper.getMappings().size() ? mapper.getMappings()[i].dst_module_id : "") + "\""; + 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\":\"" + entry->error + "\"}"; + out += ",\"error\":\"" + jsonEscapeString(entry->error) + "\"}"; first = false; } out += "]"; diff --git a/runtime/src/runtime_core.cpp b/runtime/src/runtime_core.cpp index 1cc28d4..4dd644d 100644 --- a/runtime/src/runtime_core.cpp +++ b/runtime/src/runtime_core.cpp @@ -429,7 +429,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; @@ -509,7 +513,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(); } From f8348c29048b089fd3ec855cf48ccf6395e6a680 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Wed, 1 Jul 2026 20:40:09 -0700 Subject: [PATCH 59/62] fix(diag): address PR #19 review findings - Guard sysconf(_SC_PAGESIZE) returning -1 on Linux -- casting it to uint64_t would explode rssBytes into a near-UINT64_MAX value. - Clamp cpuPercent's upper bound to 100: timer quantization can push the ratio slightly past it, and the documented contract is 0-100 of the machine. - History-point shape (ts/rssBytes/cpuPercent only) is deliberate, now documented at the route: peakRssBytes is monotonic (current value subsumes history) and uptimeSec is derivable from ts -- repeating them 600x per poll is payload bloat with no consumer. --- runtime/src/api/router.cpp | 4 ++++ runtime/src/diag/system_metrics.cpp | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index 4545162..d7be1a6 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -518,6 +518,10 @@ Response dispatch(RuntimeCore& core, const Request& req) { // 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(); diff --git a/runtime/src/diag/system_metrics.cpp b/runtime/src/diag/system_metrics.cpp index 053061e..3cfb2ff 100644 --- a/runtime/src/diag/system_metrics.cpp +++ b/runtime/src/diag/system_metrics.cpp @@ -97,6 +97,7 @@ uint64_t SystemMetrics::readRssBytes() { 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); } @@ -168,7 +169,10 @@ void SystemMetrics::run() { 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; - if (cpuPct < 0.0) cpuPct = 0.0; + // 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; From 4ab924d2b7d68f735d16eb51a24eefba3be278e6 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Wed, 1 Jul 2026 21:45:16 -0700 Subject: [PATCH 60/62] fix(diag): cap FaultStore, reap abandoned OPC-UA sessions, add heapUsedBytes Closes the two real unbounded-growth paths found in the memory-leak investigation (the steady-state RSS creep itself was confirmed to be allocator page retention, not a leak -- heap identical under `leaks` across 10 minutes of load, RSS plateaued flat), and adds the allocator-level metric that makes that distinction visible without external tooling: - FaultStore: retention cap of 200 (newest kept), enforced in memory AND on disk -- record() evicts the oldest entry + its report files, and an over-full crash dir from prior runs is trimmed at boot. Previously every fault's full JSON (stack traces included) was retained forever, and scanDir() reloaded the ever-growing dir each boot; a fault-looping module leaked without bound. - OPC-UA sessions: a half-open pushchannel (client vanished without a FIN -- laptop sleep, cable pull) kept pushConns non-empty forever, pinning the session and every monitored item's lastJson. Sessions with connections but no keep-alive past 3x their timeout are now force-closed and reaped. All known clients keep-alive well inside 1x (LoomUI 10s, lux-connect 20s vs the 30s default). Verified: abandoned session reaped at exactly 3x with the socket closed as "session expired"; a keep-alive session is untouched. - SystemMetrics: new heapUsedBytes (malloc_zone_statistics on macOS, mallinfo2 on glibc >= 2.33, 0 = unavailable elsewhere) on /api/system (current + history) and the WS system block. This is the leak-vs-churn discriminator RSS can't provide: RSS climbing with flat heapUsed is page retention; both climbing together is a real leak. Tests: 116/117 (same pre-existing TraceCacheTest failure). Fault cap verified with a 250-file crash dir (200 survive, newest-first, disk pruned). --- runtime/include/loom/diag/fault_store.h | 13 +++++++- runtime/include/loom/diag/system_metrics.h | 18 ++++++++--- runtime/src/api/router.cpp | 6 ++++ runtime/src/diag/fault_store.cpp | 13 ++++++++ runtime/src/diag/system_metrics.cpp | 37 ++++++++++++++++++---- runtime/src/opcua_rest_server.cpp | 27 ++++++++++++++-- runtime/src/server.cpp | 1 + 7 files changed, 99 insertions(+), 16 deletions(-) diff --git a/runtime/include/loom/diag/fault_store.h b/runtime/include/loom/diag/fault_store.h index 944a743..130dc4c 100644 --- a/runtime/include/loom/diag/fault_store.h +++ b/runtime/include/loom/diag/fault_store.h @@ -33,6 +33,13 @@ class FaultStore { std::string reason; }; + /// Retention cap: newest kMaxEntries faults are kept, in memory AND on + /// disk (older report files are deleted as new ones arrive). Without a cap + /// a fault-looping module grows the store without bound — each entry holds + /// the full report JSON including stack traces — and the crash dir + /// compounds across runs (scanDir() reloads every file at boot). + static constexpr std::size_t kMaxEntries = 200; + explicit FaultStore(std::filesystem::path crashDir); /// Persist a live fault (JSON file + in-memory summary). Returns its id. @@ -56,9 +63,13 @@ class FaultStore { /// Load existing reports from disk (called once at construction). void scanDir(); + /// Drop oldest entries (and their disk files) beyond kMaxEntries. Caller + /// holds mx_ (or is the single-threaded constructor). + void enforceCap(); + std::filesystem::path crashDir_; mutable std::mutex mx_; - std::vector entries_; ///< append-only; newest at the back + std::vector entries_; ///< newest at the back; capped at kMaxEntries }; } // namespace loom::diag diff --git a/runtime/include/loom/diag/system_metrics.h b/runtime/include/loom/diag/system_metrics.h index ce09e29..fa90d9c 100644 --- a/runtime/include/loom/diag/system_metrics.h +++ b/runtime/include/loom/diag/system_metrics.h @@ -25,11 +25,12 @@ 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 + 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 + uint64_t heapUsedBytes = 0; ///< bytes in LIVE heap allocations (see readHeapUsedBytes) }; class SystemMetrics { @@ -64,6 +65,13 @@ class SystemMetrics { static uint64_t readPeakRssBytes(); static uint64_t readCpuTimeNs(); ///< total process CPU time (user+system) static unsigned cpuCount(); + /// Bytes currently held by LIVE heap allocations, from the allocator's own + /// bookkeeping (malloc_zone_statistics on macOS, mallinfo2 on Linux; 0 where + /// unavailable). This is the leak-vs-churn discriminator RSS alone can't + /// give: RSS ratchets up to the allocator's page high-water mark and stays + /// there, while heapUsed tracks what's actually alive. RSS climbing with + /// flat heapUsed = retention/fragmentation; both climbing together = leak. + static uint64_t readHeapUsedBytes(); private: void run(); diff --git a/runtime/src/api/router.cpp b/runtime/src/api/router.cpp index d7be1a6..870bd4b 100644 --- a/runtime/src/api/router.cpp +++ b/runtime/src/api/router.cpp @@ -529,6 +529,11 @@ Response dispatch(RuntimeCore& core, const Request& req) { out += "\"ts\":" + std::to_string(cur.tsMs); out += ",\"rssBytes\":" + std::to_string(cur.rssBytes); out += ",\"peakRssBytes\":" + std::to_string(cur.peakRssBytes); + // heapUsedBytes = live allocations per the allocator's own bookkeeping; + // 0 where the platform reader is unavailable (render as absent, not as + // an empty heap). RSS climbing while this stays flat is allocator page + // retention, not a leak; both climbing together is a leak. + out += ",\"heapUsedBytes\":" + std::to_string(cur.heapUsedBytes); out += ",\"cpuPercent\":" + std::to_string(cur.cpuPercent); out += ",\"uptimeSec\":" + std::to_string(cur.uptimeSec); out += ",\"history\":["; @@ -538,6 +543,7 @@ Response dispatch(RuntimeCore& core, const Request& req) { first = false; out += "{\"ts\":" + std::to_string(s.tsMs) + ",\"rssBytes\":" + std::to_string(s.rssBytes) + + ",\"heapUsedBytes\":" + std::to_string(s.heapUsedBytes) + ",\"cpuPercent\":" + std::to_string(s.cpuPercent) + "}"; } out += "]}"; diff --git a/runtime/src/diag/fault_store.cpp b/runtime/src/diag/fault_store.cpp index af345c5..acd0b8b 100644 --- a/runtime/src/diag/fault_store.cpp +++ b/runtime/src/diag/fault_store.cpp @@ -104,6 +104,18 @@ void FaultStore::scanDir() { // 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; }); + // An over-full crash dir from prior runs is trimmed at boot too. + enforceCap(); +} + +void FaultStore::enforceCap() { + while (entries_.size() > kMaxEntries) { + const auto& oldest = entries_.front(); + std::error_code ec; + std::filesystem::remove(crashDir_ / (oldest.summary.id + ".json"), ec); + std::filesystem::remove(crashDir_ / (oldest.summary.id + ".txt"), ec); + entries_.erase(entries_.begin()); + } } std::string FaultStore::record(const FaultReport& report) noexcept { @@ -124,6 +136,7 @@ std::string FaultStore::record(const FaultReport& report) noexcept { 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)}); + enforceCap(); } return report.id; } catch (const std::exception& e) { diff --git a/runtime/src/diag/system_metrics.cpp b/runtime/src/diag/system_metrics.cpp index 3cfb2ff..d05355e 100644 --- a/runtime/src/diag/system_metrics.cpp +++ b/runtime/src/diag/system_metrics.cpp @@ -14,12 +14,16 @@ # include #elif defined(__APPLE__) # include +# include # include # include #else // Linux & other POSIX # include # include # include +# if defined(__GLIBC__) +# include +# endif #endif namespace loom::diag { @@ -29,6 +33,24 @@ unsigned SystemMetrics::cpuCount() { return n ? n : 1; } +uint64_t SystemMetrics::readHeapUsedBytes() { +#if defined(__APPLE__) + // Sum across all malloc zones (nullptr = every zone in the process). + malloc_statistics_t st{}; + malloc_zone_statistics(nullptr, &st); + return static_cast(st.size_in_use); +#elif defined(__GLIBC__) && ((__GLIBC__ > 2) || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 33)) + // uordblks = bytes in allocated (live) chunks, main arena + mmapped. + struct mallinfo2 mi = mallinfo2(); + return static_cast(mi.uordblks) + static_cast(mi.hblkhd); +#else + // Windows / musl / older glibc: no cheap allocator introspection wired up. + // 0 means "unavailable", which consumers should render as absent, not as + // an empty heap. + return 0; +#endif +} + #if defined(_WIN32) uint64_t SystemMetrics::readRssBytes() { @@ -181,10 +203,11 @@ void SystemMetrics::run() { 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(); + s.rssBytes = readRssBytes(); + s.peakRssBytes = readPeakRssBytes(); + s.cpuPercent = cpuPct; + s.uptimeSec = std::chrono::duration_cast(now - startTime_).count(); + s.heapUsedBytes = readHeapUsedBytes(); { std::lock_guard lock(mx_); @@ -194,9 +217,9 @@ void SystemMetrics::run() { } 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); + spdlog::info("system: rss={} MB heap={} MB peak={} MB cpu={:.1f}% uptime={}s", + s.rssBytes / (1024 * 1024), s.heapUsedBytes / (1024 * 1024), + s.peakRssBytes / (1024 * 1024), s.cpuPercent, s.uptimeSec); } } } diff --git a/runtime/src/opcua_rest_server.cpp b/runtime/src/opcua_rest_server.cpp index bb2bca1..5ccf26c 100644 --- a/runtime/src/opcua_rest_server.cpp +++ b/runtime/src/opcua_rest_server.cpp @@ -273,12 +273,33 @@ void OpcUaRestServer::pumpLoop() { std::lock_guard smlk(sm_.mutex()); // Prune idle sessions that timed out without a DELETE. Sessions with a - // live pushchannel are kept (their keep-alive HEADs refresh lastSeen). + // live pushchannel are kept (their keep-alive HEADs refresh lastSeen) + // -- but only up to a grace multiple of the timeout: a half-open TCP + // connection (client vanished without a FIN: laptop sleep, cable pull) + // keeps pushConns non-empty forever with no traffic Crow would notice, + // pinning the session and every monitored item's lastJson for the life + // of the process. All known clients keep-alive well inside timeoutMs + // (LoomUI PATCHes every 10s, lux-connect every 20s vs the 30s default), + // so a connected-but-silent session past 3x timeout is abandoned: + // force-close its sockets and reap it. The late onclose is harmless + // (unbindPushConn scans by pointer; a missing session is a no-op). for (auto it = sm_.sessions().begin(); it != sm_.sessions().end();) { auto& s = it->second; const double ageMs = static_cast(duration_cast(now - s.lastSeen).count()); - if (s.pushConns.empty() && ageMs > s.timeoutMs) it = sm_.sessions().erase(it); - else ++it; + const bool idleExpired = s.pushConns.empty() && ageMs > s.timeoutMs; + const bool abandonedWs = !s.pushConns.empty() && ageMs > 3.0 * s.timeoutMs; + if (idleExpired || abandonedWs) { + if (abandonedWs) { + spdlog::info("opcua_rest: reaping session {} ({} pushchannel conn(s), " + "no keep-alive for {:.0f}s)", s.id, s.pushConns.size(), ageMs / 1000.0); + for (auto* c : s.pushConns) { + try { c->close("session expired"); } catch (...) { /* already dead */ } + } + } + it = sm_.sessions().erase(it); + } else { + ++it; + } } { diff --git a/runtime/src/server.cpp b/runtime/src/server.cpp index e2abcc3..74b2a25 100644 --- a/runtime/src/server.cpp +++ b/runtime/src/server.cpp @@ -538,6 +538,7 @@ void Server::start() { systemTail = ",\"system\":{"; systemTail += "\"rssBytes\":" + std::to_string(sys.rssBytes); systemTail += ",\"peakRssBytes\":" + std::to_string(sys.peakRssBytes); + systemTail += ",\"heapUsedBytes\":" + std::to_string(sys.heapUsedBytes); systemTail += ",\"cpuPercent\":" + std::to_string(sys.cpuPercent); systemTail += ",\"uptimeSec\":" + std::to_string(sys.uptimeSec); systemTail += "}}"; From 25371339350d31b10dda5dbde2164020257085e9 Mon Sep 17 00:00:00 2001 From: Josh Polansky Date: Wed, 1 Jul 2026 22:02:47 -0700 Subject: [PATCH 61/62] fix(diag): linear enforceCap + retention-cap regression test (PR #20 review) - enforceCap() erased entries_.begin() one at a time -- O(n^2) shifting when boot finds a crash dir left thousands of reports deep by a fault loop. Now deletes the excess files in one pass and erases the front range in a single call. - New DiagFaultStore.RetentionCapPrunesMemoryAndDisk test: seeds cap+50 on-disk reports, asserts construction keeps exactly the newest kMaxEntries (memory and disk, newest-first ordering), and that record() at the cap evicts precisely the oldest entry and its file. --- runtime/src/diag/fault_store.cpp | 14 +++++---- tests/test_diag.cpp | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/runtime/src/diag/fault_store.cpp b/runtime/src/diag/fault_store.cpp index acd0b8b..ed6bd99 100644 --- a/runtime/src/diag/fault_store.cpp +++ b/runtime/src/diag/fault_store.cpp @@ -109,13 +109,17 @@ void FaultStore::scanDir() { } void FaultStore::enforceCap() { - while (entries_.size() > kMaxEntries) { - const auto& oldest = entries_.front(); + if (entries_.size() <= kMaxEntries) return; + // Erase the excess as one front range (not one erase(begin()) per entry, + // which is O(n^2) shifting — noticeable at boot after a fault loop left + // thousands of reports behind). + const auto excess = static_cast(entries_.size() - kMaxEntries); + for (auto it = entries_.begin(); it != entries_.begin() + excess; ++it) { std::error_code ec; - std::filesystem::remove(crashDir_ / (oldest.summary.id + ".json"), ec); - std::filesystem::remove(crashDir_ / (oldest.summary.id + ".txt"), ec); - entries_.erase(entries_.begin()); + std::filesystem::remove(crashDir_ / (it->summary.id + ".json"), ec); + std::filesystem::remove(crashDir_ / (it->summary.id + ".txt"), ec); } + entries_.erase(entries_.begin(), entries_.begin() + excess); } std::string FaultStore::record(const FaultReport& report) noexcept { diff --git a/tests/test_diag.cpp b/tests/test_diag.cpp index b81f5da..e5b72f2 100644 --- a/tests/test_diag.cpp +++ b/tests/test_diag.cpp @@ -190,3 +190,52 @@ TEST(DiagFaultStore, JsonSupersedesRawTxtSibling) { std::filesystem::remove_all(dir); } + +// Retention cap: the store keeps only the newest kMaxEntries faults, in memory +// AND on disk — an over-full crash dir is trimmed at construction, and record() +// evicts the oldest entry (deleting its report file) once at the cap. +TEST(DiagFaultStore, RetentionCapPrunesMemoryAndDisk) { + auto dir = std::filesystem::temp_directory_path() / + ("loom_faults_cap_" + std::to_string(::testing::UnitTest::GetInstance()->random_seed())); + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + + // Seed cap+50 structured reports on disk, ids ordered by timestamp. + const std::size_t seeded = FaultStore::kMaxEntries + 50; + auto idFor = [](std::size_t i) { + char buf[16]; + std::snprintf(buf, sizeof buf, "fault-%04zu", i); + return std::string(buf); + }; + for (std::size_t i = 0; i < seeded; ++i) { + std::ofstream f(dir / (idFor(i) + ".json"), std::ios::binary); + f << R"({"ts":)" << (1000 + i) << R"(,"kind":"exception","reason":"synthetic"})"; + } + + // Construction trims to the cap: newest kept, oldest gone, disk pruned. + FaultStore store(dir); + auto list = store.list(); // newest first + ASSERT_EQ(list.size(), FaultStore::kMaxEntries); + EXPECT_EQ(list.front().id, idFor(seeded - 1)); + EXPECT_EQ(list.back().id, idFor(seeded - FaultStore::kMaxEntries)); + std::size_t onDisk = 0; + for (const auto& de : std::filesystem::directory_iterator(dir)) + if (de.is_regular_file()) ++onDisk; + EXPECT_EQ(onDisk, FaultStore::kMaxEntries); + EXPECT_FALSE(std::filesystem::exists(dir / (idFor(0) + ".json"))); + + // record() at the cap evicts exactly the oldest (memory + its file). + const std::string evicted = list.back().id; + FaultReport r = sampleReport(); + r.id = "newest-recorded"; + r.tsMs = 999999; + store.record(r); + list = store.list(); + ASSERT_EQ(list.size(), FaultStore::kMaxEntries); + EXPECT_EQ(list.front().id, "newest-recorded"); + EXPECT_NE(list.back().id, evicted); + EXPECT_FALSE(std::filesystem::exists(dir / (evicted + ".json"))); + EXPECT_TRUE(std::filesystem::exists(dir / "newest-recorded.json")); + + std::filesystem::remove_all(dir); +} From 41c62858ac8587f816b8464afab37202c23ba32d Mon Sep 17 00:00:00 2001 From: Josh Polansky <669274+Joshpolansky@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:33:02 -0700 Subject: [PATCH 62/62] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/test_command_integration.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_command_integration.cpp b/tests/test_command_integration.cpp index 563d34e..1d0e584 100644 --- a/tests/test_command_integration.cpp +++ b/tests/test_command_integration.cpp @@ -36,7 +36,7 @@ TEST_F(CommandIntegrationTest, CommandRoundTripAcrossModuleBoundary) { // Wire dependencies exactly as RuntimeCore::startModule does. mod->instance->setBus(&bus, id); mod->instance->setRuntimeHeap(&heap); - mod->instance->init(loom::InitContext{}); + mod->instance->initGuarded(loom::InitContext{}); // provideCommands() registered the channel on the bus under the module id. auto* channel = bus.commandChannel(id);