Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
e2ac2cc
feat: implement runtime heap for cross-module shared object allocation
Joshpolansky Jun 16, 2026
de1ff5e
feat: add async command handling with CommandChannel and CommandClient
Joshpolansky Jun 16, 2026
c31aa84
feat: enhance CommandClient submit methods for better command handling
Joshpolansky Jun 16, 2026
69c9313
feat: add CommandProbe module and integration tests for async command…
Joshpolansky Jun 16, 2026
76e17b9
Address Copilot review: thread-safe CommandStatus + tests
Joshpolansky Jun 16, 2026
6f9f669
feat: implement CommandFb with PLCopen output semantics and associate…
Joshpolansky Jun 16, 2026
702d246
feat: add Port and PortRef for typed connection points with tests
Joshpolansky Jun 17, 2026
34c9a41
Merge branch 'claude/priceless-moore-2c99b7' into dev
Joshpolansky Jun 19, 2026
67bc115
test: use initGuarded() in module-loader lifecycle tests
Joshpolansky Jun 19, 2026
37b1805
diag: Phase 1 — make every crash diagnosable (no new deps)
Joshpolansky Jun 19, 2026
dfa5b58
test: add crasher module for crash-diagnostics verification
Joshpolansky Jun 19, 2026
c8a2273
chore: ignore _crashtest scratch dir
Joshpolansky Jun 19, 2026
2fad66f
feat(diag): in-process stack symbolization via cpptrace
Joshpolansky Jun 19, 2026
e6ad9b4
feat(diag): structured fault reports + /api/faults + loom/faults topic
Joshpolansky Jun 19, 2026
f995360
fix(diag): POSIX build portability + lastFault data race (CI + Copilo…
Joshpolansky Jun 19, 2026
a45748b
fix(diag): address Copilot re-review (UAF, quarantine, signal-safety,…
Joshpolansky Jun 19, 2026
53c6f77
Merge pull request #13 from Joshpolansky/feat/crash-diagnostics
Joshpolansky Jun 19, 2026
4344ce2
feat(diag): symbolized JSON crash reports on POSIX (mac/linux), not j…
Joshpolansky Jun 20, 2026
d438308
build(diag): generate + ship debug symbols for in-process crash symbo…
Joshpolansky Jun 20, 2026
f55744d
fix(build): move macOS .dSYM generation to CI (add_custom_command dir…
Joshpolansky Jun 20, 2026
041e549
fix(diag): unwind from the faulting context on POSIX (keep the leaf f…
Joshpolansky Jun 20, 2026
ad14dee
fix(diag): use <sys/ucontext.h> on macOS for the in-context unwind
Joshpolansky Jun 20, 2026
18d84ff
Merge pull request #14 from Joshpolansky/feat/crash-diagnostics
Joshpolansky Jun 23, 2026
bcf88d7
feat(command): implement IFunctionBlock interface for CommandFb and a…
Joshpolansky Jun 27, 2026
a37666b
feat(glaze): add Glaze metadata for function-block bases to enable re…
Joshpolansky Jun 28, 2026
2c4018d
feat(module): make longRunning() optional and improve scheduler handling
Joshpolansky Jun 28, 2026
78d030c
feat(tag-table): enhance indexing for nested glz::meta types in TagTable
Joshpolansky Jun 29, 2026
8e98bd1
Merge branch 'claude/infallible-blackburn-eea2e6' into develop
Joshpolansky Jun 29, 2026
f196b66
feat(diag): add system metrics tracking for memory and CPU usage
Joshpolansky Jun 29, 2026
f0e0596
Merge commit '8e98bd1dbea9d21a16d579ed008817263a88018f' into feat/sys…
Joshpolansky Jun 29, 2026
3dc6a0f
fix(diag): drop defaulted Config arg on SystemMetrics ctor (GCC/Clang)
Joshpolansky Jun 29, 2026
2792094
fix(scheduler): define applyAffinityPolicy on all platforms
Joshpolansky Jun 30, 2026
7781c44
refactor(runtime): split loom_runtime into loom_core + native host
Joshpolansky Jun 30, 2026
bd79709
refactor(scheduler): extract sweepClassOnce; add cooperative tickOnce()
Joshpolansky Jun 30, 2026
550f43b
feat(scheduler): honor per-class periods in cooperative tickOnce()
Joshpolansky Jun 30, 2026
67dc99d
feat(runtime): cooperative mode + Emscripten host (run_wasm)
Joshpolansky Jun 30, 2026
b17f8af
feat(scheduler): guard thread spawns for thread-free (wasm) hosts
Joshpolansky Jun 30, 2026
d9a8e57
build(wasm): build the Emscripten runtime host via CMake + Conan
Joshpolansky Jun 30, 2026
6325cb8
feat(api): transport-agnostic REST dispatch shared by native server +…
Joshpolansky Jun 30, 2026
e126207
feat(wasm): runtime-as-a-service — fetch('/api/*') served by the in-b…
Joshpolansky Jun 30, 2026
c3ddf5c
feat(wasm): OPC-UA reflected node read/write + WasmMachine (live useV…
Joshpolansky Jun 30, 2026
38d6a6d
feat(wasm): run the real frontend against the in-browser runtime (?wa…
Joshpolansky Jun 30, 2026
21794af
fix(wasm): build-time/sticky wasm-mode flag (replaces fragile ?wasm q…
Joshpolansky Jun 30, 2026
1910c09
feat(api): migrate GET /api/scheduler/classes to dispatch (Scheduler …
Joshpolansky Jun 30, 2026
a0d0f14
feat(api): server.cpp catch-all delegates to dispatch; migrate faults…
Joshpolansky Jun 30, 2026
04e919e
refactor(api): drop server.cpp's scheduler/classes route + DTOs (disp…
Joshpolansky Jun 30, 2026
4a12414
feat(api): migrate scope routes to dispatch (Scope page on wasm)
Joshpolansky Jun 30, 2026
6683cda
feat(api): migrate GET /api/io-mappings to dispatch (Mappings page on…
Joshpolansky Jun 30, 2026
342f747
fix(api): rename Method::DELETE -> DELETE_ (MSVC C2589 on Windows)
Joshpolansky Jun 30, 2026
d9ab013
feat(wasm): build + boot the bundled demo modules under Emscripten
Joshpolansky Jun 30, 2026
f67ebc9
build(wasm): auto-stage the host + modules into frontend/public/ on e…
Joshpolansky Jun 30, 2026
918f3bb
fix(scheduler): address Copilot review findings on tickOnce()/classLoop
Joshpolansky Jun 30, 2026
33f2f9c
Merge pull request #16 from Joshpolansky/loom-core-split
Joshpolansky Jun 30, 2026
fcdf1d3
feat(api): migrate the write routes into dispatch (full module/scope/…
Joshpolansky Jun 30, 2026
0419aeb
Merge remote-tracking branch 'origin/develop' into wasm-spike
Joshpolansky Jul 1, 2026
aa91762
feat(wasm): real pthread threading for the wasm host (drop cooperativ…
Joshpolansky Jul 1, 2026
3ada98f
feat(scheduler): support both real-thread and cooperative builds from…
Joshpolansky Jul 1, 2026
c6e0968
fix(scheduler): close the last path to the tickOnce()/classLoop() rac…
Joshpolansky Jul 1, 2026
c774e29
feat(sdk): loom_add_module() wasm SIDE_MODULE support + arch-independ…
Joshpolansky Jul 1, 2026
a3e68cd
feat(wasm): add bootFromDataDir() manifest-driven browser boot
Joshpolansky Jul 1, 2026
5e21233
feat(wasm): package @joshpolansky/loom-wasm for npm distribution
Joshpolansky Jul 1, 2026
ead4f6e
fix(wasm): drop unnecessary @loupeteam/lux-connect dep, stale ts-expe…
Joshpolansky Jul 2, 2026
919bf55
fix(wasm): address PR #17 review findings (leak, error JSON, detail s…
Joshpolansky Jul 2, 2026
d3342ea
fix(wasm): address PR #18 review findings (leak, tick spam, detail sh…
Joshpolansky Jul 2, 2026
6d7acdf
fix(wasm): address round-2 review findings on PR #17
Joshpolansky Jul 2, 2026
98d28c8
Merge wasm-spike into wasm-pthread (resolve PR #18 base conflicts)
Joshpolansky Jul 2, 2026
0f5a7ee
Merge pull request #17 from Joshpolansky/wasm-spike
Joshpolansky Jul 2, 2026
93d87bf
Merge pull request #18 from Joshpolansky/wasm-pthread
Joshpolansky Jul 2, 2026
f46c0ad
Merge origin/develop into feat/system-metrics (post-wasm integration)
Joshpolansky Jul 2, 2026
f8348c2
fix(diag): address PR #19 review findings
Joshpolansky Jul 2, 2026
5369357
Merge pull request #19 from Joshpolansky/feat/system-metrics
Joshpolansky Jul 2, 2026
4ab924d
fix(diag): cap FaultStore, reap abandoned OPC-UA sessions, add heapUs…
Joshpolansky Jul 2, 2026
2537133
fix(diag): linear enforceCap + retention-cap regression test (PR #20 …
Joshpolansky Jul 2, 2026
f29f6c5
Merge pull request #20 from Joshpolansky/fix/memory-caps
Joshpolansky Jul 2, 2026
41c6285
Potential fix for pull request finding
Joshpolansky Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .github/skills/cruntime-module-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,31 @@ add_subdirectory(<module_name>)
- Fault flags and status

## Key Rules
1. **Aggregate structs only** — No constructors, no private members, no virtual methods in data structs
1. **Aggregate data structs** — Config/Recipe/Runtime themselves must be aggregates (no constructors, private members, or virtual methods *at the top level*). They MAY contain non-aggregate members — function blocks, `loom::Sequence`, any class with glaze metadata — as long as each such member is reflectable (see [Reflection & Debuggability](#reflection--debuggability-expose-everything-by-design)). Default *member* initializers are fine, and are required for members with no default constructor.
2. **Default initializers** — Always provide sensible defaults
3. **No raw pointers** in data structs — use `std::string`, `std::vector`, `std::optional`
3. **No raw pointers** in data structs — use `std::string`, `std::vector`, `std::optional`. A reflectable member may *internally* hold handles/pointers (a `CommandClient`, a status `shared_ptr`); that's fine as long as its metadata omits them.
4. **Keep cyclic() fast** — No blocking I/O, no allocations, no exceptions
5. **Use longRunning() for async work** — File I/O, network, heavy computation
6. **Unique module names** — The `name` field in `moduleHeader()` must be unique across all modules
7. **Prefer typed bus helpers** — `publishLocal()`, `subscribeTo()`, `registerLocalService()`, and typed `callService()` automatically serialize and deserialize aggregate payloads via glaze
8. **Namespace Bus addresses** — Use `publishLocal()`, `registerLocalService()` etc. which auto-prefix with the module instance ID. Use `bus_->publish()`/`bus_->subscribe()` for global topics.

## Reflection & Debuggability (expose everything by design)

Every Config/Recipe/Runtime field is reflected, and the IDE lets a developer **read and write** all of them. This is intentional — reflection is for **debuggability**, not just the HMI (the HMI comes for free on top). A developer can set a function block's `execute`, flip an `enable`, or nudge a setpoint live to drive/step a module while developing. **Expose everything; don't hide internal state to "protect" it** — the open surface is the point.

So **put framework objects directly in the Runtime** instead of hand-mirroring their state — e.g. a `loom::Sequence<Step>` and the PLCopen `MC_*` function blocks dropped straight into the Runtime struct reflect their own live I/O (step/elapsed, execute/busy/done/error), with no mirror fields.

**Make a custom type reflectable** — it needs glaze metadata, one of:
- **External** `glz::meta<T>` in a separate `*_glaze.h` (keeps the core control header glaze-free — `command.h` itself stays glaze-free; the opt-in metadata lives in `<loom/command_glaze.h>`). Function blocks reuse the base field-list macros — `LOOM_COMMAND_FB_FIELDS(T)` / `LOOM_ENABLE_FB_FIELDS(T)` / `LOOM_IFUNCTION_BLOCK_FIELDS(T)` — then add their own fields.
- **In-class** `struct glaze { static constexpr auto value = glz::object(...); };` for types you own (it can reach **private** members). Declare it **after** the data members — a static-member initializer isn't a complete-class context, so earlier-declared members aren't visible yet.

**Gotchas (these bit us — do not repeat):**
- **Reflect only real, assignable data members.** Do *not* use value-returning getter lambdas or member-function pointers in meta: member-function pointers **silently serialize nothing**, and a value-returning getter makes `glz::read<Runtime>` **fail to compile** — and the SDK deserializes the *whole* Runtime, so one bad field breaks the entire module's build. To expose a private/computed value, use an in-class `struct glaze` with **data-member pointers**.
- **Enums serialize as integers** unless you provide `glz::meta<E>` with `glz::enumerate("name", E::Value, ...)` for readable names.
- **`glz::merge` is not for base-class reuse** — it tries to serialize the member pointers themselves. Use the field-list macros instead.
- **Internal handles are simply omitted** from the metadata — list only the fields worth seeing.

## Inter-Module Communication

The `bus_` pointer is injected before `init()`. Three patterns:
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,18 @@ jobs:
exit 1
fi

# Debug symbols next to the loom binary, so crash reports symbolize
# in-process on the downloaded runtime. Windows: loom.pdb (installed to
# install/bin). macOS: produce loom.dSYM via dsymutil (the .o debug map
# is still present in this job). Linux: DWARF is embedded in the binary.
cp install/bin/loom.pdb "${STAGE}/" 2>/dev/null || true
if [ "${{ runner.os }}" = "macOS" ]; then
shopt -s nullglob
for b in "${STAGE}"/loom "${STAGE}"/loom.exe; do
[ -f "$b" ] && dsymutil "$b" -o "${b}.dSYM" || true
done
fi

# Module plugins. On Windows they are under output/modules/<Config>
# (Debug or Release). On other platforms they are flat in output/modules.
BUILD_TYPE="${{ matrix.build_type }}"
Expand All @@ -191,6 +203,15 @@ jobs:
shopt -s nullglob
for mod in "${MOD_DIR}"/*.so "${MOD_DIR}"/*.dylib "${MOD_DIR}"/*.dll; do
cp "$mod" "${STAGE}/modules/"
# Ship each module's debug symbols alongside it so a crash in
# module code resolves to file:line. Windows: <name>.pdb lives in
# output/ (CMAKE_PDB_OUTPUT_DIRECTORY). macOS: dsymutil → .dSYM.
# Linux: DWARF is embedded in the .so.
base="$(basename "$mod")"; stem="${base%.*}"
[ -f "output/${stem}.pdb" ] && cp "output/${stem}.pdb" "${STAGE}/modules/" || true
if [ "${{ runner.os }}" = "macOS" ]; then
dsymutil "${STAGE}/modules/${base}" -o "${STAGE}/modules/${base}.dSYM" || true
fi
done
fi

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ Thumbs.db
# Frontend
frontend/node_modules/
frontend/dist/
_crashtest/
84 changes: 78 additions & 6 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ if(MSVC)
string(APPEND CMAKE_MODULE_LINKER_FLAGS_DEBUG " /DEBUG")
endif()

# Module-build helpers (loom_add_module, debug-info + dSYM) — shared with module
# authors via the SDK install. Defines the LOOM_WITH_DEBUG_INFO option.
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(LoomModule)

# Emit debug info for crash symbolization in OPTIMIZED configs too (Debug always
# has it), so release/downloaded runtimes still produce readable crash traces.
# Symbols are shipped next to the binaries (install rules + CI staging). This is
# metadata, not code — no effect on runtime speed, only binary/file size.
# Skipped for WASM: there's no in-process symbolization there (no cpptrace), and
# -g would bloat the .wasm by ~10x for no benefit.
if(LOOM_WITH_DEBUG_INFO AND NOT EMSCRIPTEN)
if(MSVC)
foreach(cfg RELEASE RELWITHDEBINFO MINSIZEREL)
string(APPEND CMAKE_C_FLAGS_${cfg} " /Zi")
string(APPEND CMAKE_CXX_FLAGS_${cfg} " /Zi")
# /OPT:REF,ICF keeps the optimized image lean (the linker turns them
# off once /DEBUG is present).
string(APPEND CMAKE_EXE_LINKER_FLAGS_${cfg} " /DEBUG /OPT:REF /OPT:ICF")
string(APPEND CMAKE_SHARED_LINKER_FLAGS_${cfg} " /DEBUG /OPT:REF /OPT:ICF")
string(APPEND CMAKE_MODULE_LINKER_FLAGS_${cfg} " /DEBUG /OPT:REF /OPT:ICF")
endforeach()
else()
# Linux: DWARF embedded in the (unstripped) binary. macOS: -g leaves DWARF
# in the .o files; a .dSYM is produced per target by loom_target_dsym().
foreach(cfg RELEASE RELWITHDEBINFO)
string(APPEND CMAKE_C_FLAGS_${cfg} " -g")
string(APPEND CMAKE_CXX_FLAGS_${cfg} " -g")
endforeach()
endif()
endif()

# Platform-appropriate suffix for Loom module plugins (.dll on Windows, .so elsewhere)
if(WIN32)
set(LOOM_MODULE_SUFFIX ".dll")
Expand All @@ -50,15 +82,55 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE)
endif()

# Conan-provided dependencies
# Dependencies — all from Conan, native and wasm alike (same pinned versions, no
# drift). glaze + spdlog are shared. The native host also needs Crow (HTTP/WS),
# cpptrace (crash symbolization) and GTest; the WASM build is server-less and
# skips them (gated in conanfile.py via settings.os).
find_package(glaze REQUIRED)
find_package(spdlog REQUIRED)
find_package(GTest REQUIRED)
find_package(Crow REQUIRED)
if(EMSCRIPTEN)
# spdlog is header-only here; alias it to the plain name the tree links
# against. Neutralize fmt's consteval (rejected by emcc's clang).
if(NOT TARGET spdlog::spdlog AND TARGET spdlog::spdlog_header_only)
add_library(spdlog::spdlog INTERFACE IMPORTED)
target_link_libraries(spdlog::spdlog INTERFACE spdlog::spdlog_header_only)
endif()
add_compile_definitions(FMT_CONSTEVAL=)
add_compile_options(-fexceptions)
add_link_options(-fexceptions)

# Real threading (SharedArrayBuffer + Worker-backed std::thread), applied
# globally so loom_core, loom_host_wasm, AND every bundled module (dlopen'd
# SIDE_MODULEs) agree on the threaded object format -- a SIDE_MODULE built
# without -pthread loaded into a -pthread MAIN_MODULE is not a supported
# combination. De-risked standalone in spike/phaseC-pthread-dlopen/ (real
# worker thread driving dlopen'd module code, incl. a pauseClass()-style
# mutex+condvar handshake and cross-thread C++ exceptions -- all passed).
# Emscripten itself flags dynamic-linking+pthreads as experimental
# (-Wexperimental); this is the reason to keep an eye on it going forward,
# not a reason it's broken today.
add_compile_options(-pthread)
add_link_options(-pthread)

# CMake's Emscripten platform defaults TARGET_SUPPORTS_SHARED_LIBS to FALSE,
# so add_library(MODULE) (used by every bundled module — modules/*/CMakeLists.txt)
# silently downgrades to a STATIC archive instead of a dlopen-able wasm side
# module. Override it before modules/ is added.
set_property(GLOBAL PROPERTY TARGET_SUPPORTS_SHARED_LIBS TRUE)
else()
find_package(GTest REQUIRED)
find_package(Crow REQUIRED)
find_package(cpptrace REQUIRED) # runtime-only: crash-report symbolization
endif()

add_subdirectory(sdk)
add_subdirectory(runtime)
add_subdirectory(modules)

enable_testing()
add_subdirectory(tests)
# Bundled modules build under both native and WASM (as SIDE_MODULEs — see
# modules/CMakeLists.txt); EtherCAT is the one exception, gated out there.
# Tests stay native-only (need GTest, which conanfile.py doesn't pull for wasm).
add_subdirectory(modules)
if(NOT EMSCRIPTEN)
enable_testing()
add_subdirectory(tests)
endif()
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ public:
}

void exit() override {}
void longRunning() override {} // required; leave empty if unused

// longRunning() is optional — override it only for background work
// (e.g. polling a slow device). Omit it and the runtime starts no
// background thread for this module.
// void longRunning() override { ... }
};

LOOM_REGISTER_MODULE(MyModule)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.3
0.3.0
130 changes: 130 additions & 0 deletions cmake/LoomModule.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# LoomModule.cmake — helpers for building Loom module plugins with the debug
# info needed for in-process crash symbolization.
#
# Installed with the SDK and pulled in by loomConfig.cmake, so module authors who
# `find_package(loom CONFIG REQUIRED)` get loom_add_module() with symbols handled
# the same way the bundled modules are. Also included by the in-repo build.
#
# Crash reports are symbolized in-process via cpptrace, which needs debug info:
# • Windows : a .pdb next to the binary (/Zi + /DEBUG, kept lean with /OPT)
# • Linux : DWARF embedded in the unstripped binary (-g)
# • macOS : a .dSYM bundle next to the binary (dsymutil, since the linker
# leaves DWARF in the .o files which don't ship)

if(NOT DEFINED LOOM_WITH_DEBUG_INFO)
option(LOOM_WITH_DEBUG_INFO "Emit debug info (PDB/DWARF/dSYM) for crash symbolization" ON)
endif()

# Platform-appropriate module suffix (.dll on Windows, .so elsewhere — matching
# the runtime's dlopen/LoadLibrary lookup). Honors a value already set by the
# in-repo build.
if(NOT DEFINED LOOM_MODULE_SUFFIX)
if(WIN32)
set(LOOM_MODULE_SUFFIX ".dll")
else()
set(LOOM_MODULE_SUFFIX ".so")
endif()
endif()

# Add debug-info flags to a target for optimized (non-Debug) configs. Debug
# already carries full debug info. No-op when LOOM_WITH_DEBUG_INFO is OFF.
function(loom_target_debug_info target)
if(NOT LOOM_WITH_DEBUG_INFO)
return()
endif()
if(MSVC)
target_compile_options(${target} PRIVATE $<$<NOT:$<CONFIG:Debug>>:/Zi>)
# /DEBUG so a PDB is emitted; /OPT:REF,ICF to keep the optimized image
# lean (the linker disables those by default once /DEBUG is present).
target_link_options(${target} PRIVATE
$<$<NOT:$<CONFIG:Debug>>:/DEBUG>
$<$<NOT:$<CONFIG:Debug>>:/OPT:REF>
$<$<NOT:$<CONFIG:Debug>>:/OPT:ICF>)
else()
target_compile_options(${target} PRIVATE $<$<NOT:$<CONFIG:Debug>>:-g>)
endif()
endfunction()

# On macOS, produce a <binary>.dSYM bundle next to the target after build so
# cpptrace can resolve file:line from a distributed (away-from-build-tree) binary.
function(loom_target_dsym target)
if(APPLE AND LOOM_WITH_DEBUG_INFO)
add_custom_command(TARGET ${target} POST_BUILD
COMMAND dsymutil $<TARGET_FILE:${target}> -o $<TARGET_FILE:${target}>.dSYM
COMMENT "dsymutil ${target} → .dSYM (crash symbols)"
VERBATIM)
endif()
endfunction()

# Target-scoped half of Emscripten wasm SIDE_MODULE support (see
# loom_add_module below for the GLOBAL-property half, which — unlike these —
# MUST be set before add_library() runs to take effect for that target):
# 1. -sSIDE_MODULE=1 (compile + link) is what actually produces a dlopen-able
# wasm binary (a `dylink.0` section) instead of a normal static archive.
# 2. -fexceptions is required so real C++ exceptions work — the module ABI
# boundary (dlopen'd module <-> the loom_wasm MAIN_MODULE host) needs
# matching exception support on both sides, and every Loom module's SDK
# surface (guard(), command dispatch) relies on real exception unwinding.
# De-risked standalone (cross-module throw/catch across a real worker
# thread) in the loom repo's spike/phaseC-pthread-dlopen/.
# 3. FMT_CONSTEVAL= neutralizes fmt's consteval format-string checks, which
# emcc's clang rejects; harmless if a module never includes spdlog/fmt.
# All apply per-module (not globally), so a consumer's own CMakeLists.txt needs
# zero Emscripten-specific configuration for modules built via loom_add_module.
function(loom_target_wasm_module_support target)
if(NOT EMSCRIPTEN)
return()
endif()
target_compile_options(${target} PRIVATE -sSIDE_MODULE=1 -fexceptions)
target_link_options(${target} PRIVATE -sSIDE_MODULE=1 -fexceptions)
target_compile_definitions(${target} PRIVATE FMT_CONSTEVAL=)
endfunction()
Comment on lines +74 to +81

# loom_add_module(<name>
# SOURCES <src...>
# [LINK <libs...>]
# [INCLUDE <dirs...>]
# [OUTPUT_DIRECTORY <dir>])
#
# Builds a Loom module plugin: a MODULE library with no "lib" prefix, the
# platform module suffix, hidden default visibility, linked against loom::sdk,
# and with crash-symbolization debug info (PDB/DWARF/dSYM) emitted next to it.
# On Emscripten, produces a dlopen-able wasm SIDE_MODULE instead (see
# loom_target_wasm_module_support above) — no extra configuration needed by
# the caller; the SAME loom_add_module() call works for native and wasm.
function(loom_add_module name)
cmake_parse_arguments(ARG "" "OUTPUT_DIRECTORY" "SOURCES;LINK;INCLUDE" ${ARGN})
if(NOT ARG_SOURCES)
message(FATAL_ERROR "loom_add_module(${name}): SOURCES is required")
endif()

# Must run BEFORE add_library(MODULE): TARGET_SUPPORTS_SHARED_LIBS is read
# at add_library() time, so setting it any later has no effect on this call.
if(EMSCRIPTEN)
set_property(GLOBAL PROPERTY TARGET_SUPPORTS_SHARED_LIBS TRUE)
endif()

add_library(${name} MODULE ${ARG_SOURCES})
target_link_libraries(${name} PRIVATE loom::sdk ${ARG_LINK})
if(ARG_INCLUDE)
target_include_directories(${name} PRIVATE ${ARG_INCLUDE})
endif()

set_target_properties(${name} PROPERTIES
PREFIX ""
SUFFIX "${LOOM_MODULE_SUFFIX}"
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN ON)

if(ARG_OUTPUT_DIRECTORY)
set_target_properties(${name} PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${ARG_OUTPUT_DIRECTORY}")
elseif(DEFINED LOOM_MODULES_OUTPUT_DIR)
set_target_properties(${name} PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${LOOM_MODULES_OUTPUT_DIR}")
endif()

loom_target_debug_info(${name})
loom_target_dsym(${name})
loom_target_wasm_module_support(${name})
endfunction()
27 changes: 27 additions & 0 deletions conan/profiles/emscripten
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Cross-build profile for the WebAssembly (Emscripten) runtime host.
# Uses the Emscripten SDK pointed at by $EMSDK (e.g. deps/wasm/emsdk) for the
# toolchain, while Conan resolves the (header-only) deps — same versions as the
# native build, so there's no drift. Build/host deps that don't apply to wasm
# (Crow, cpptrace, gtest) are dropped in conanfile.py via settings.os.
#
# EMSDK=/path/to/emsdk conan install . -pr:h conan/profiles/emscripten \
# -pr:b default -of build/Wasm --build=missing

[settings]
os=Emscripten
arch=wasm
compiler=clang
compiler.version=20
compiler.cppstd=23
compiler.libcxx=libc++
build_type=Release

[options]
# Header-only spdlog: nothing to cross-compile, and matches how loom_core uses it.
spdlog/*:header_only=True

[conf]
tools.cmake.cmaketoolchain:generator=Ninja
# Chain Emscripten's CMake toolchain under Conan's generated one, so find_package
# resolves the Conan deps AND the compiler is emcc.
tools.cmake.cmaketoolchain:user_toolchain=["{{ os.getenv('EMSDK') }}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake"]
17 changes: 12 additions & 5 deletions conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ class LoomDevConan(ConanFile):
default_options = {"with_tests": True}

def requirements(self):
# SDK deps — glaze listed explicitly since the SDK is consumed as source here
# Shared by every target. glaze is listed explicitly since the SDK is
# consumed as source here; spdlog is header-only on the WASM build (set
# in conan/profiles/emscripten) so no library is cross-compiled.
self.requires("glaze/7.2.0")
# Runtime deps
self.requires("spdlog/1.17.0")
self.requires("crowcpp-crow/1.3.0")
if self.options.with_tests:
self.requires("gtest/1.15.0")

# Native-host-only deps. The WASM runtime is thread-free and server-less:
# no HTTP/WebSocket (Crow), no in-process stack symbolization (cpptrace),
# and tests don't run there.
if self.settings.os != "Emscripten":
self.requires("crowcpp-crow/1.3.0")
self.requires("cpptrace/0.8.3")
if self.options.with_tests:
self.requires("gtest/1.15.0")
Loading
Loading