Skip to content

release: v0.3.0 — WASM runtime, command framework, crash diagnostics#22

Merged
Joshpolansky merged 76 commits into
mainfrom
agents/merge-develop-into-main-release-prep
Jul 2, 2026
Merged

release: v0.3.0 — WASM runtime, command framework, crash diagnostics#22
Joshpolansky merged 76 commits into
mainfrom
agents/merge-develop-into-main-release-prep

Conversation

@Joshpolansky

Copy link
Copy Markdown
Owner

Release v0.3.0

This PR merges develop into main and bumps to v0.3.0.


Summary of Changes

🌐 WebAssembly / Browser Runtime

The biggest feature in this release. The full Loom runtime now compiles to WebAssembly and runs in-browser:

  • Runtime-as-a-service: fetch('/api/*') calls are intercepted and served by the in-process WASM runtime — no server required for a self-contained demo
  • Real pthread threading: Worker-backed threads (requires COOP+COEP headers); module cyclic() callbacks run on genuine background threads in the browser
  • bootFromDataDir(): manifest-driven boot that fetches and loads .wasm side-modules automatically
  • @joshpolansky/loom-wasm npm package: WasmMachine, LoomRuntime, TypeScript types, and bootWasmMachine() entry point published to npm
  • OPC-UA reflected node r/w: loom_read_node/loom_write_node C exports + useVariable() React hook for live subscriptions
  • CMake + Conan WASM build: LOOM_WASM option; demo modules compile as Emscripten SIDE_MODULE via loom_add_module(WASM)

🔀 Transport-Agnostic API Dispatch

  • New loom::dispatch(Request, Response) layer shared by the native Crow server and the WASM in-browser path
  • All REST routes migrated; server.cpp is now a thin Crow adapter
  • Method::DELETEMethod::DELETE_ (MSVC fix)

⚙️ SDK — Command Framework (PLCopen-style)

  • loom::CommandFb<T>: PLCopen Execute/Busy/Done/Error output semantics with rising-edge detection and one-cycle terminal visibility
  • loom::CommandClient: async submission returning shared_ptr<CommandStatus> — no blocking in cyclic code
  • loom::CommandChannel: typed pending-command queue backed by RuntimeHeap
  • loom::RuntimeHeap: cross-module shared-object allocator using shared_ptr aliasing constructor

🔌 SDK — Port / Connection Points

  • loom::Port<T> / loom::PortRef<T>: typed connection points with generation-counter validation; stale refs self-correct

💥 Crash Diagnostics

  • POSIX signal handler captures raw stack addresses async-signal-safely to loom-crash-*.txt
  • cpptrace symbolizes to loom-crash-*.json with function/file/line
  • /api/faults REST endpoint + loom/faults bus topic for structured fault history

📊 System Metrics

  • SystemMetrics module: RSS memory (peak/current) + per-core CPU at configurable interval; visible as normal runtime fields

🏗️ Runtime Core Split

  • loom_runtimeloom_core (transport-free) + loom_host_native (Crow); downstream links loom::loom_core

🔧 Scheduler

  • Cooperative tickOnce() with per-class period deadlines for the WASM/thread-free host
  • LOOM_HAS_THREADS flag (distinguishes Emscripten+pthreads from no-threads)
  • longRunning() now optional (no background thread spawned if not overridden)
  • Closed last tickOnce/classLoop race window via mutex_ hold for entire cooperative call

Code Review Findings

Five review agents analyzed the 71 commits across all feature areas. The following issues were surfaced:

🔴 High

# Area Issue
1 Build conan-release preset name collision between native-release and WASM builds — second just setup-* overwrites CMakeUserPresets.json with wrong toolchain. Workaround: use separate checkouts.

🟡 Medium

# Area Issue
2 WASM boot.ts: module fetch doesn't check response.ok — 404 silently writes HTML bytes to MEMFS, module fails to load with no user-visible error
3 Build No install() rules in the Emscripten CMake branch — cmake --install build/Wasm is a no-op
4 API GET/POST /api/modules/:id/data/<badSection> returns 404 instead of 400 with a descriptive message (regression vs. old server.cpp)
5 SDK CommandChannel::submit OOM via vector::push_back throws std::bad_alloc through the FB instead of returning nullptr (inconsistent with heap-exhaustion path)
6 Diag POSIX signal handler calls cpptrace (allocates) — deadlocks on heap-corruption crashes; raw .txt report is unaffected

🟢 Low

# Area Issue
7 WASM WasmMachine.dispose() doesn't call rt.stop() — tick timer keeps running in cooperative mode after dispose
8 API ClassStatsDto aggregate missing 6th field (lastTickStartMs always 0 on REST; correct over WebSocket)

All issues are documented in the Known Issues section of CHANGELOG.md. None are release blockers for v0.3.0.


Files Changed

  • 89 files, +6,057 / -1,404 lines
  • New: sdk/include/loom/command.h, command_client.h, port.h, runtime_heap.h, runtime/src/diag/ (6 files), runtime/src/run_wasm.cpp, packages/loom-wasm/
  • Modified: runtime/CMakeLists.txt, sdk/include/loom/module.h, tag_table.hpp, sdk/loom-config.cmake.in, runtime/src/api/router.cpp, frontend/src/wasm/

Checklist

  • CHANGELOG.md updated (v0.3.0 entry)
  • All commits reviewed by 5 parallel code-review agents
  • Known issues documented in CHANGELOG Known Issues section
  • Tag v0.3.0 after merge

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 23 commits June 30, 2026 15:52
…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
Summarises the 71 commits in this release across five areas:
- WebAssembly / browser runtime (pthread threading, bootFromDataDir,
  @joshpolansky/loom-wasm npm package, OPC-UA node r/w, fetch interception)
- Transport-agnostic API dispatch (loom::dispatch, all routes migrated)
- SDK command framework (CommandFb, CommandClient, CommandChannel, RuntimeHeap)
- SDK Port/PortRef typed connection points
- Crash diagnostics + system metrics (cpptrace symbolization, /api/faults,
  SystemMetrics CPU/memory)

Also documents 5 known non-blocking issues surfaced by code review.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 2, 2026 05:38

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

Release bump to v0.3.0 that lands a browser/WASM host for Loom, adds an SDK-level async command + port framework for PLC-style inter-module coordination, and introduces structured crash/fault diagnostics + system resource metrics across the runtime.

Changes:

  • Added a WASM host (loom_host_wasm) plus a small TypeScript package (@joshpolansky/loom-wasm) to run Loom fully in-browser and serve /api/* in-process.
  • Introduced SDK primitives: RuntimeHeap (resident shared_ptr control blocks), async CommandChannel/CommandClient/CommandFb, and typed ports (PortRef) integrated into Bus and IModule.
  • Added diagnostics: breadcrumbed exception guards, persistent fault store + bus topic, crash handler + symbolizer, and background system metrics sampling; refactored runtime into loom_core + native host layer.

Reviewed changes

Copilot reviewed 87 out of 90 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
VERSION Bump release version to 0.3.0.
tests/test_tag_table.cpp Regression test for nested glz::meta indexing in TagTable.
tests/test_runtime_heap.cpp Unit tests for RuntimeHeap/makeShared and weak_ptr behavior.
tests/test_port.cpp Tests for bus port registration and PortRef generation re-resolve.
tests/test_module_loader.cpp Uses shared module-finder helper; adds reload test; uses initGuarded.
tests/test_diag.cpp Tests for breadcrumbs, guard(), FaultReport JSON, and FaultStore behavior.
tests/test_command_integration.cpp End-to-end async command test across a real loaded module boundary.
tests/test_command_fb.cpp PLCopen-style CommandFb semantics + channel write-through tests.
tests/test_bus.cpp Adds command channel registry/submit-drain tests; catches service exceptions.
tests/module_test_util.h Central helper to locate built module plugins in tests.
tests/CMakeLists.txt Adds new test files and wires module build outputs/suffix defines.
sdk/loom-config.cmake.in Exports LoomModule helper to SDK consumers.
sdk/include/loom/version.h.in Adds kGitSha build identity string.
sdk/include/loom/tag_table.hpp Fix recursion to include glz::glaze_object_t meta types.
sdk/include/loom/runtime_heap.h Defines IRuntimeHeap and aliasing makeShared() helper.
sdk/include/loom/port.h Adds PortRef consumer-side caching handle.
sdk/include/loom/module.h Makes longRunning optional; adds runtime heap + command/port helpers.
sdk/include/loom/command.h Adds async command types, CommandChannel, and FB base classes.
sdk/include/loom/command_glaze.h Opt-in glaze metadata for command/FB surfaces.
sdk/include/loom/command_client.h Adds consumer-side command submission helper (heap + bus lookup).
sdk/include/loom/bus.h Adds command-channel registry, ports registry, and service exception safety.
sdk/CMakeLists.txt Stamps git SHA into version header; marks SDK as ARCH_INDEPENDENT; installs LoomModule.cmake.
runtime/src/scheduler.cpp Adds guarded module calls + fault quarantine; cooperative tickOnce; thread-availability gating.
runtime/src/runtime_heap.cpp Implements resident aligned allocation for RuntimeHeap.
runtime/src/runtime_core.cpp Wires runtime heap + fault sink/store + system metrics; avoids threads/watcher in cooperative mode.
runtime/src/run.cpp Installs crash handler; adds --log-system-metrics flag.
runtime/src/run_wasm.cpp Emscripten host exposing init/tick/request/load/read/write C exports.
runtime/src/diag/system_metrics.cpp Background sampler for RSS/CPU across platforms with thread gating.
runtime/src/diag/symbolizer.cpp cpptrace-backed symbolization TU.
runtime/src/diag/runtime_fault_sink.cpp Builds/persists/publishes structured fault reports from guarded exceptions.
runtime/src/diag/fault_store.cpp Loads and serves fault/crash report history from disk + memory.
runtime/src/diag/fault_report.cpp Hand-rolled JSON serialization for fault reports with raw embedded sections.
runtime/src/diag/crash_handler.cpp Process-global crash capture (signals/SEH/terminate) with raw fallback + structured attempt.
runtime/src/diag/breadcrumb.cpp Defines thread-local breadcrumb storage.
runtime/include/loom/thread_support.h Introduces LOOM_HAS_THREADS compile-time detection.
runtime/include/loom/scheduler.h Adds cooperative tickOnce API + fault sink integration + fault fields in TaskState.
runtime/include/loom/runtime_heap_impl.h Declares concrete RuntimeHeap owned by RuntimeCore.
runtime/include/loom/runtime_core.h Adds fault store/sink/system metrics and cooperative-mode config fields.
runtime/include/loom/diag/system_metrics.h Declares SystemMetrics sampler API and sample DTO.
runtime/include/loom/diag/symbolizer.h Declares symbolize() + SymFrame DTO.
runtime/include/loom/diag/runtime_fault_sink.h Declares runtime-layer IFaultSink implementation.
runtime/include/loom/diag/guard.h Declares templated exception guard wrapper with breadcrumb stamping.
runtime/include/loom/diag/fault_store.h Declares persisted fault/crash report registry and REST-facing methods.
runtime/include/loom/diag/fault_sink.h Declares IFaultSink interface + FaultEvent DTO.
runtime/include/loom/diag/fault_report.h Declares FaultReport model + toJson().
runtime/include/loom/diag/crash_handler.h Declares crash handler install API.
runtime/include/loom/diag/breadcrumb.h Declares breadcrumb model + RAII scope.
runtime/include/loom/api/router.h Declares transport-agnostic REST dispatcher types.
runtime/include/loom/api/json_build.h Declares shared JSON builders for REST shapes.
runtime/conanfile.py Adds cpptrace runtime dependency and adjusts static lib order.
runtime/CMakeLists.txt Splits loom_core vs native host; adds wasm host target and installs both libs.
README.md Updates module authoring snippet to reflect optional longRunning.
packages/loom-wasm/tsconfig.json TS build settings for published wasm helper package.
packages/loom-wasm/src/WasmMachine.ts lux-react-compatible polling machine implementation over NodeAccess.
packages/loom-wasm/src/loomRuntime.js Framework-agnostic wasm runtime bootstrap + fetch interception + bootFromDataDir.
packages/loom-wasm/src/loomRuntime.d.ts Handwritten types for loomRuntime.js.
packages/loom-wasm/src/index.ts Package public exports.
packages/loom-wasm/src/bootWasmMachine.ts Browser entrypoint that loads loom_wasm.js and boots a WasmMachine.
packages/loom-wasm/scripts/build-wasm.sh Local build + staging script with version lockstep check.
packages/loom-wasm/package.json New publishable package metadata/scripts.
packages/loom-wasm/package-lock.json Lockfile for TypeScript dev dependency.
packages/loom-wasm/.npmrc Scoped registry config for GitHub Packages.
packages/loom-wasm/.gitignore Ignores build/staged artifacts for wasm package.
modules/stack_light/stack_light.cpp Removes unused longRunning override (now optional).
modules/sequencer/sequencer.cpp Removes unused longRunning override (now optional).
modules/pneumatic_actuator/pneumatic_actuator.cpp Removes unused longRunning override (now optional).
modules/oscilloscope/oscilloscope.cpp Removes unused longRunning override (now optional).
modules/example_motor/motor_module.cpp Removes unused longRunning override (now optional).
modules/example_motor/CMakeLists.txt Switches to loom_add_module helper (smoke test).
modules/crasher/crasher.cpp Adds diagnostic test module that can intentionally fault.
modules/crasher/CMakeLists.txt Builds new crasher module plugin.
modules/command_probe/command_probe.cpp Adds command provider module for integration testing.
modules/command_probe/CMakeLists.txt Builds new command_probe module plugin.
modules/CMakeLists.txt Adjusts output dirs for wasm vs native; stages wasm modules; gates EtherCAT for wasm.
justfile Adds setup/build tasks for Emscripten/wasm builds and documents preset collision caveat.
frontend/src/wasm/wasmMode.ts Centralizes wasm-mode detection/persistence.
frontend/src/wasm/boot.ts Boots in-browser runtime and loads wasm modules; installs fetch shim.
frontend/src/main.tsx Boots machine before rendering; adds error fallback UI.
frontend/src/App.tsx Receives machine via props instead of importing a singleton.
frontend/src/api/machine.ts Avoids creating an OpcuaMachine in wasm mode; keeps node helpers shared.
conanfile.py Gates native-only deps (Crow/cpptrace/gtest) out of Emscripten builds.
conan/profiles/emscripten Adds Emscripten cross profile with header-only spdlog and chained toolchain.
CMakeLists.txt Adds LoomModule helpers; configures wasm threading/link behavior; gates deps/tests by EMSCRIPTEN.
cmake/LoomModule.cmake Adds loom_add_module + debug-symbol and wasm side-module helpers.
CHANGELOG.md Documents v0.3.0 feature set and known issues.
.gitignore Adds _crashtest/ ignore.
.github/workflows/build.yml Stages debug symbols (PDB/dSYM) alongside runtime and modules in artifacts.
.github/skills/cruntime-module-authoring/SKILL.md Updates module authoring guidance re: nested non-aggregate reflectable members.
Files not reviewed (1)
  • packages/loom-wasm/package-lock.json: Generated file

Comment on lines +31 to +36
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof buf, "\\u%04x", c);
out += buf;
} else {
out += c;
Comment thread frontend/src/wasm/boot.ts
Comment on lines +58 to +61
for (const name of WASM_MODULES) {
const bytes = new Uint8Array(await (await fetch(base + name)).arrayBuffer());
rt.loadModule(name, bytes);
}
Comment on lines +90 to +93
void submit(CommandSubmission s) {
std::lock_guard<std::mutex> lk(mutex_);
pending_.push_back(std::move(s));
}
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);
});
}
Joshpolansky and others added 3 commits July 1, 2026 22:42
…ion() window

Direct init() calls bypass the registrationOpen_ guard introduced in the
loom-core-split refactor, causing registerExtension() to throw. The runtime
scheduler always calls initGuarded(); tests loading modules must do the same.

Fixes TraceCacheTest.ExampleMotorRuntimeFieldsCached CI failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- 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>
CTest runs each gtest filter as a separate process, so gShadowLoadCounter
resets to 0 in each process. Two concurrently-running tests that both load
the same .dll (e.g. LoadExampleMotor + LoadDuplicate, or
CommandRoundTrip + ChannelUnavailableAfterUnload) both compute the same
shadow dir path (e.g. .shadow/example_motor/1/) and race to copy_file
into it. On Windows this produces 'file is being used by another process'.

Fix: embed the current PID in the shadow dir segment so each process gets
its own namespace: .shadow/example_motor/<pid>_1/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Joshpolansky Joshpolansky merged commit 0c6c7ac into main Jul 2, 2026
3 checks passed
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