Develop#21
Open
Joshpolansky wants to merge 75 commits into
Open
Conversation
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <dataDir>/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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 + <dataDir>/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/<id> (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) <noreply@anthropic.com>
…t review) Fixes the macOS/Linux CI build failures on PR #13 and the Copilot review. Build breaks: - crash_handler.cpp: include <fcntl.h> 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 <string_view> / <map> (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) <noreply@anthropic.com>
… traversal) 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 (→ <crashDir>/<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) <noreply@anthropic.com>
…ust raw On mac/linux a segfault previously produced only a raw loom-crash-<pid>.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-<id>.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) <noreply@anthropic.com>
…lization 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) <noreply@anthropic.com>
… rule) 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) <noreply@anthropic.com>
…rame) 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) <noreply@anthropic.com>
macOS's <ucontext.h> 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 <sys/ucontext.h> provides
without those routines. Keep <ucontext.h> 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) <noreply@anthropic.com>
feat(diag): symbolized JSON crash reports on POSIX (mac/linux)
…dd EnableFb class
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 <noreply@anthropic.com>
…very build 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 <all 8 targets>) instead, which has no such restriction and copies every $<TARGET_FILE:...> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
refactor(runtime): split loom_runtime into loom_core (portable engine) + native host
…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/<id> DELETE,
modules/<id>/data/<section> POST+PUT+PATCH (write/patch a section),
modules/<id>/config/{save,load}, modules/<id>/recipe/{save,load}/<name>,
modules/<id>/reload, scope/probes POST, scope/probes/<id> DELETE,
io-mappings POST, io-mappings/<index> DELETE+PATCH, io-mappings/resolve,
scheduler/classes POST, scheduler/classes/<name> PATCH, scheduler/schema,
scheduler/modules/<id>/history and scheduler/classes/<name>/history (both
?since=&bin=), scheduler/reassign, bus/call/<service>, faults/<id>.
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 "/<path>"
SPA-fallback (registered before "/api/<path>") 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/<id>/data/<section>
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 "/<path>"'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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…e + 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 <noreply@anthropic.com>
…ent 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 <noreply@anthropic.com>
Mirrors native loom's "point at a dataDir and moduleDir and go" workflow
in the browser: reads <dataDir>/instances.json (the same [{id, so,
className}] manifest RuntimeCore::loadModules() reads off disk natively),
fetches each module's .so from <moduleDir>/ and its persisted config.json
from <dataDir>/<id>/, seeds it all into MEMFS, then boots through the
existing native code path unchanged. A module named in the manifest but
not found at <moduleDir>/ (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.
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 <MachineProvider>. 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.
…ct-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.
…hape, 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/<id> 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/<path> 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).
…ape, 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/<id> (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.
- 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.
PR #18 was retargeted to base on wasm-spike, so the two branches' parallel copies of shared fixes now merge here. Resolutions: - runtime/src/api/router.cpp: take wasm-spike's version outright -- it is a strict superset (this branch's cherry-picked round-1 fixes plus round 2: remaining jsonEscapeString coverage and the moduleInfoJson extraFields refactor that removes the ModuleDetail pop_back splice). - frontend/src/wasm/loomRuntime.js: deleted -- superseded by packages/loom-wasm/src/loomRuntime.js arriving in the merge (which already carries this branch's loom_init failure check); boot.ts imports the package path now. - runtime/src/run_wasm.cpp auto-merged keeping this branch's threaded variants (cooperative=false boot, warn-once loom_tick) with the stableString leak fix; also updates loom_load_module's stale doc comment that still described cooperative-only starts (review finding). Verified post-merge: native + wasm builds clean, frontend tsc clean, Node harness confirms ModuleDetail data sections, escaped 400 bodies, and no memory growth over 200k readNode calls.
Run the Loom runtime in-browser via WebAssembly
Real pthread-based threading for the wasm host
develop's wasm work landed a transport-agnostic api::dispatch, a loom_core/loom_runtime target split, and thread-free (cooperative) builds -- all of which this branch predates. Beyond textual conflict resolution, three structural adaptations keep the feature coherent in the new architecture: - GET /api/system moves out of server.cpp's Crow routes into api::dispatch (router.cpp), same migration pattern as the other routes -- the native server serves it via the catch-all and the wasm host gets it for free. The WS live frame's `system` block stays in server.cpp (WebSockets are native-only). - system_metrics.cpp lands in loom_core (RuntimeCore owns the sampler member, and loom_core is what the wasm host links); the Windows psapi link moves with it. - SystemMetrics::start() is a guarded no-op on a thread-free build (LOOM_HAS_THREADS==0): spawning the sampler std::thread would abort there. /api/system then serves zeros and an empty history rather than 404ing, so the endpoint shape stays stable across hosts.
- 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.
System metrics: process memory + CPU sampling, served on /api/system and the WS live frame
…edBytes 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).
…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.
Cap FaultStore, reap abandoned OPC-UA sessions, add heapUsedBytes to /api/system
Contributor
There was a problem hiding this comment.
Pull request overview
This PR advances Loom to v0.3.0 and substantially expands the runtime/SDK with hot-reload-safe cross-module primitives (async commands + ports), improved reflection coverage, and a new diagnostics + crash-reporting stack. It also splits the runtime into a portable loom_core and host layers, adds an Emscripten/WASM host path, and introduces an npm package (@joshpolansky/loom-wasm) to run Loom in-browser and integrate with lux-react.
Changes:
- Add a runtime-owned heap (
IRuntimeHeap+makeShared) to safely share trivially-destructible objects across unloaded modules (hot-reload safety). - Add async commands (CommandChannel/CommandClient + PLCopen-style
CommandFbbase) and typed ports (Busport registry +PortRef<T>). - Add diagnostics/fault handling (breadcrumbs, guarded module calls, fault store/reporting, crash handler, system metrics) and introduce a WASM host + browser integration package.
Reviewed changes
Copilot reviewed 87 out of 90 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| VERSION | Bumps project version to 0.3.0. |
| tests/test_tag_table.cpp | Adds regression coverage for nested glz::meta/glaze_object_t field indexing. |
| tests/test_runtime_heap.cpp | Adds unit coverage for RuntimeHeap + makeShared semantics and weak/shared behavior. |
| tests/test_port.cpp | Adds tests for port register/resolve/unregister and PortRef generation behavior. |
| tests/test_module_loader.cpp | Refactors module lookup + adds reload test and uses initGuarded. |
| tests/test_diag.cpp | Adds tests for breadcrumbs, guard(), fault report JSON, and fault store behavior. |
| tests/test_command_integration.cpp | Adds integration test that loads a real module and exercises commands across the module boundary. |
| tests/test_command_fb.cpp | Adds PLCopen-like semantics tests for CommandFb + channel write-through. |
| tests/test_bus.cpp | Extends Bus tests for command channel registry + submit/drain behavior. |
| tests/module_test_util.h | Adds shared helper to locate built modules using suffix + module dir definitions. |
| tests/CMakeLists.txt | Adds new tests and links in required runtime sources; ensures required plugins are built; defines module dir/suffix. |
| sdk/loom-config.cmake.in | Installs/exports LoomModule helpers for module authors via the SDK config. |
| sdk/include/loom/version.h.in | Adds kGitSha to embed git identity for crash reports. |
| sdk/include/loom/tag_table.hpp | Fixes recursion into glaze_object_t types (explicit glz::meta classes). |
| sdk/include/loom/runtime_heap.h | Introduces IRuntimeHeap and makeShared<T> aliasing-control-block pattern. |
| sdk/include/loom/port.h | Adds PortRef<T> cached typed port resolution handle. |
| sdk/include/loom/module.h | Adds runtime heap injection, command channel/port provisioning, and makes longRunning optional with interval. |
| sdk/include/loom/command.h | Introduces async command types + channel + IFunctionBlock/CommandFb/EnableFb bases. |
| sdk/include/loom/command_glaze.h | Opt-in glaze metadata for FB bases + field-list macros. |
| sdk/include/loom/command_client.h | Adds consumer-side CommandClient for safe submit + heap status allocation. |
| sdk/include/loom/bus.h | Adds command channel registry, typed ports registry, and exception-safe service calls. |
| sdk/CMakeLists.txt | Stamps git SHA, marks SDK package arch-independent, installs LoomModule.cmake. |
| runtime/src/scheduler.cpp | Adds guarded module calls, fault quarantine/reporting, cooperative tickOnce path, and safer stop/join semantics. |
| runtime/src/runtime_heap.cpp | Implements RuntimeHeap::allocate (resident control block + deleter). |
| runtime/src/runtime_core.cpp | Wires runtime heap injection, cooperative-mode guards, fault sink/store, and system metrics startup. |
| runtime/src/run.cpp | Installs crash handler and adds --log-system-metrics CLI flag. |
| runtime/src/run_wasm.cpp | Adds Emscripten host entrypoints to boot runtime and route /api/* + tag IO in-browser. |
| runtime/src/opcua_rest_server.cpp | Adds abandonment reaping logic for pushchannel sessions to prevent unbounded retention. |
| runtime/src/diag/system_metrics.cpp | Implements cross-platform RSS/CPU/heap sampling with optional periodic logging. |
| runtime/src/diag/symbolizer.cpp | Implements symbolization via cpptrace (impl-only TU). |
| runtime/src/diag/runtime_fault_sink.cpp | Builds fault reports on guarded exceptions, persists + publishes loom/faults. |
| runtime/src/diag/fault_store.cpp | Adds fault persistence/loading, raw-txt supersedence, and retention cap enforcement. |
| runtime/src/diag/fault_report.cpp | Adds structured JSON serialization with raw-section embedding. |
| runtime/src/diag/crash_handler.cpp | Adds process-global crash capture (signals/SEH/terminate) with raw + best-effort structured reporting. |
| runtime/src/diag/breadcrumb.cpp | Defines thread-local breadcrumb storage. |
| runtime/include/loom/thread_support.h | Adds LOOM_HAS_THREADS compile-time detection for cooperative vs threaded mode. |
| runtime/include/loom/scheduler.h | Adds cooperative tickOnce API, last-fault fields, and fault sink injection APIs. |
| runtime/include/loom/runtime_heap_impl.h | Adds concrete RuntimeHeap implementation header. |
| runtime/include/loom/runtime_core.h | Adds cooperative + metrics config, fault store/sink/system metrics members, and runtime heap member. |
| runtime/include/loom/diag/system_metrics.h | Declares SystemMetrics sampler API and sample model. |
| runtime/include/loom/diag/symbolizer.h | Declares symbolization API and SymFrame. |
| runtime/include/loom/diag/runtime_fault_sink.h | Declares runtime-layer fault sink implementation. |
| runtime/include/loom/diag/guard.h | Adds exception guard wrapper that stamps breadcrumbs and reports faults. |
| runtime/include/loom/diag/fault_store.h | Declares persistent fault store + retention cap. |
| runtime/include/loom/diag/fault_sink.h | Declares sink interface (IFaultSink) for scheduler fault reporting. |
| runtime/include/loom/diag/fault_report.h | Declares fault report model + JSON serialization. |
| runtime/include/loom/diag/crash_handler.h | Declares crash handler install API. |
| runtime/include/loom/diag/breadcrumb.h | Declares per-thread breadcrumb model + RAII scope. |
| runtime/include/loom/api/router.h | Declares transport-agnostic API dispatcher for native + wasm hosts. |
| runtime/include/loom/api/json_build.h | Declares shared JSON builders for API responses. |
| runtime/conanfile.py | Adds cpptrace dependency and correct static link order for runtime libs. |
| runtime/CMakeLists.txt | Splits into loom_core + loom_runtime, adds wasm host target, installs both libs, wires cpptrace privately. |
| README.md | Updates module authoring example to reflect optional longRunning. |
| packages/loom-wasm/tsconfig.json | Adds TS build config for published wasm helper package. |
| packages/loom-wasm/src/WasmMachine.ts | Adds lux-react-compatible machine that polls reflected values through wasm runtime. |
| packages/loom-wasm/src/loomRuntime.js | Adds runtime boot/tick/loadModule/api routing service for browser use. |
| packages/loom-wasm/src/loomRuntime.d.ts | Adds hand-written typings for loomRuntime.js. |
| packages/loom-wasm/src/index.ts | Exports package public API. |
| packages/loom-wasm/src/bootWasmMachine.ts | Adds high-level entrypoint to boot wasm runtime + return WasmMachine. |
| packages/loom-wasm/scripts/build-wasm.sh | Adds local build/stage script with version lockstep check. |
| packages/loom-wasm/package.json | Adds new publishable package manifest. |
| packages/loom-wasm/package-lock.json | Adds lockfile for package dev deps. |
| packages/loom-wasm/.npmrc | Configures GitHub Packages registry for publishing. |
| packages/loom-wasm/.gitignore | Ignores build outputs/staged wasm artifacts. |
| modules/stack_light/stack_light.cpp | Removes empty longRunning override (now optional). |
| modules/sequencer/sequencer.cpp | Removes empty longRunning override (now optional). |
| modules/pneumatic_actuator/pneumatic_actuator.cpp | Removes empty longRunning override (now optional). |
| modules/oscilloscope/oscilloscope.cpp | Removes empty longRunning override (now optional). |
| modules/example_motor/motor_module.cpp | Removes no-op longRunning override (now optional). |
| modules/example_motor/CMakeLists.txt | Switches to loom_add_module() helper. |
| modules/crasher/crasher.cpp | Adds intentionally faulting module for diagnostics/crash-report testing. |
| modules/crasher/CMakeLists.txt | Adds build for crasher module plugin. |
| modules/command_probe/command_probe.cpp | Adds minimal command provider module for integration testing. |
| modules/command_probe/CMakeLists.txt | Adds build for command_probe module plugin. |
| modules/CMakeLists.txt | Adjusts module output dirs for wasm vs native; adds wasm staging/copy and SIDE_MODULE flags; gates EtherCAT under native. |
| justfile | Adds wasm setup/build recipes. |
| frontend/src/wasm/wasmMode.ts | Adds wasm-mode detection (build flag + query/sessionStorage). |
| frontend/src/wasm/boot.ts | Boots in-browser runtime, loads wasm modules, installs fetch routing, returns WasmMachine. |
| frontend/src/main.tsx | Boots machine before first render and shows fallback UI on boot failure. |
| frontend/src/App.tsx | Accepts machine as prop from main boot step (native or wasm). |
| frontend/src/api/machine.ts | Avoids creating a real OpcuaMachine in wasm mode. |
| conanfile.py | Gates native-only deps (Crow/cpptrace/gtest) off wasm; clarifies shared deps. |
| conan/profiles/emscripten | Adds emscripten cross profile and header-only spdlog config. |
| CMakeLists.txt | Integrates LoomModule helper, adds debug-info option behavior, separates wasm vs native deps, enables wasm shared libs + pthread flags, gates tests under native. |
| cmake/LoomModule.cmake | Adds loom_add_module + debug-info helpers and wasm side-module support for module authors. |
| .gitignore | Ignores local crash test output directory. |
| .github/workflows/build.yml | Stages debug symbols (PDB/dSYM) for runtime + modules into artifacts. |
| .github/skills/cruntime-module-authoring/SKILL.md | Updates guidance: top-level aggregates may contain non-aggregate reflectable members; adds reflection/debuggability gotchas. |
Files not reviewed (1)
- packages/loom-wasm/package-lock.json: Generated file
Comment on lines
+7
to
+15
| std::shared_ptr<void> 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<void>(p, [a](void* q) noexcept { | ||
| ::operator delete(q, a); | ||
| }); | ||
| } |
| ASSERT_NE(mod, nullptr); | ||
| mod->instance->setBus(&bus, id); | ||
| mod->instance->setRuntimeHeap(&heap); | ||
| mod->instance->init(loom::InitContext{}); |
Comment on lines
3
to
8
| #include <algorithm> | ||
| #include <cstdint> | ||
| #include <exception> | ||
| #include <functional> | ||
| #include <future> | ||
| #include <mutex> |
Comment on lines
+306
to
+308
| mutable std::mutex portMutex_; | ||
| std::unordered_map<std::string, PortEntry> ports_; | ||
| uint64_t portGeneration_ = 1; // nonzero so a freshly-default PortRef re-resolves |
Comment on lines
+212
to
+234
| 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_; | ||
| } |
Comment on lines
+74
to
+81
| 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() |
Comment on lines
+77
to
+79
| std::string json = toJson(r); | ||
| store_.record(r); | ||
| bus_.publish("loom/faults", json); |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Joshpolansky
added a commit
that referenced
this pull request
Jul 2, 2026
- runtime_heap: catch bad_alloc in allocate() and return empty shared_ptr as documented by IRuntimeHeap::allocate's contract; previously threw across module boundaries instead - bus.h: make portGeneration_ atomic<uint64_t> so PortRef::get()'s steady-state generation check is lock-free; registerPort/unregisterPort use fetch_add(release) and portGeneration() uses load(acquire), keeping ports_ itself protected by portMutex_ - LoomModule.cmake: add -pthread to loom_target_wasm_module_support() compile+link options so out-of-tree SIDE_MODULEs match the threaded MAIN_MODULE host's object format (dlopen fails on format mismatch) - fault_store: add record(report, json) overload so RuntimeFaultSink can pass its already-serialized JSON, avoiding a second toJson() call on the fault path - test_command_integration: use initGuarded() instead of bare init() to match the runtime entry point and stay compatible with any future module that calls registerExtension() during init() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.