Skip to content

Develop#21

Open
Joshpolansky wants to merge 75 commits into
mainfrom
develop
Open

Develop#21
Joshpolansky wants to merge 75 commits into
mainfrom
develop

Conversation

@Joshpolansky

Copy link
Copy Markdown
Owner

No description provided.

Joshpolansky and others added 30 commits June 16, 2026 13:32
- 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)
Joshpolansky and others added 25 commits June 30, 2026 15:02
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
Copilot AI review requested due to automatic review settings July 2, 2026 05:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CommandFb base) and typed ports (Bus port 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);
});
}
Comment thread tests/test_command_integration.cpp Outdated
ASSERT_NE(mod, nullptr);
mod->instance->setBus(&bus, id);
mod->instance->setRuntimeHeap(&heap);
mod->instance->init(loom::InitContext{});
Comment thread sdk/include/loom/bus.h
Comment on lines 3 to 8
#include <algorithm>
#include <cstdint>
#include <exception>
#include <functional>
#include <future>
#include <mutex>
Comment thread sdk/include/loom/bus.h
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 thread sdk/include/loom/bus.h
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 thread cmake/LoomModule.cmake
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants