Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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/
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ find_package(glaze REQUIRED)
find_package(spdlog REQUIRED)
find_package(GTest REQUIRED)
find_package(Crow REQUIRED)
find_package(cpptrace REQUIRED) # runtime-only: crash-report symbolization

add_subdirectory(sdk)
add_subdirectory(runtime)
Expand Down
1 change: 1 addition & 0 deletions conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ def requirements(self):
# Runtime deps
self.requires("spdlog/1.17.0")
self.requires("crowcpp-crow/1.3.0")
self.requires("cpptrace/0.8.3") # runtime-only: crash-report stack symbolization
if self.options.with_tests:
self.requires("gtest/1.15.0")
1 change: 1 addition & 0 deletions modules/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ set(LOOM_MODULES_OUTPUT_DIR "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/modules")
# Build all example modules
add_subdirectory(class_based)
add_subdirectory(command_probe)
add_subdirectory(crasher)
add_subdirectory(ethercat)
add_subdirectory(example_motor)
add_subdirectory(oscilloscope)
Expand Down
17 changes: 17 additions & 0 deletions modules/crasher/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
add_library(crasher MODULE
crasher.cpp
)

target_link_libraries(crasher PRIVATE
loom::sdk
)
target_include_directories(crasher PUBLIC
${LOOM_MODULES_DIR}
)

set_target_properties(crasher PROPERTIES
PREFIX ""
SUFFIX "${LOOM_MODULE_SUFFIX}"
LIBRARY_OUTPUT_DIRECTORY "${LOOM_MODULES_OUTPUT_DIR}"
CXX_VISIBILITY_PRESET hidden
)
53 changes: 53 additions & 0 deletions modules/crasher/crasher.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#include <loom/module.h>
#include <loom/export.h>

#include <cstdint>
#include <cstdlib>
#include <stdexcept>
#include <string>

// ============================================================================
// Crasher — a deliberately-faulting module for exercising crash diagnostics.
//
// Config picks the fault and when it fires:
// fault: none | throw | segfault | fpe | abort | loop
// phase: init | cyclic
// after_ticks: for phase=cyclic, fault on the Nth cyclic tick (lets the
// module load + run first, so the breadcrumb shows phase=cyclic).
// ============================================================================

struct CrasherConfig {
std::string fault = "none";
std::string phase = "cyclic";
uint64_t after_ticks = 50;
};
struct CrasherRecipe { int _unused = 0; };
struct CrasherRuntime { uint64_t cycle = 0; };

namespace {
[[maybe_unused]] void doFault(const std::string& f) {
if (f == "throw") throw std::runtime_error("crasher: intentional std::runtime_error");
if (f == "segfault") { volatile int* p = nullptr; *p = 1; } // SIGSEGV / AV
if (f == "fpe") { volatile int a = 1, b = 0; volatile int c = a / b; (void)c; } // SIGFPE
if (f == "abort") std::abort(); // SIGABRT
if (f == "loop") { volatile bool spin = true; while (spin) {} } // hang (watchdog)
}
} // namespace

class Crasher : public loom::Module<CrasherConfig, CrasherRecipe, CrasherRuntime> {
public:
LOOM_MODULE_HEADER("Crasher", "1.0.0")

void init(const loom::InitContext&) override {
if (config_.phase == "init") doFault(config_.fault);
}
void cyclic() override {
runtime_.cycle++;
if (config_.phase == "cyclic" && runtime_.cycle >= config_.after_ticks)
doFault(config_.fault);
}
void exit() override {}
void longRunning() override {}
};

LOOM_REGISTER_MODULE(Crasher)
13 changes: 13 additions & 0 deletions runtime/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,33 @@ add_library(loom_runtime STATIC
src/opcua_rest_server.cpp
src/oscilloscope.cpp
src/module_watcher.cpp
src/diag/breadcrumb.cpp
src/diag/crash_handler.cpp
src/diag/symbolizer.cpp
src/diag/fault_report.cpp
src/diag/fault_store.cpp
src/diag/runtime_fault_sink.cpp
)

target_include_directories(loom_runtime PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)

# Build type stamped into crash reports (works for single- and multi-config).
target_compile_definitions(loom_runtime PRIVATE LOOM_BUILD_TYPE="$<CONFIG>")

target_link_libraries(loom_runtime PUBLIC
loom::sdk
spdlog::spdlog
Crow::Crow
${CMAKE_DL_LIBS}
)

# Symbolization for crash reports. PRIVATE: confined to the diag symbolizer TU,
# never exposed in loom_runtime's public headers (SDK/consumers stay clean of it).
target_link_libraries(loom_runtime PRIVATE cpptrace::cpptrace)

# ---------------------------------------------------------------------------
# Thin executable — just calls loom::run(argc, argv).
# ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions runtime/conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def requirements(self):
self.requires(f"loom/{self.version}@local/stable", transitive_headers=True)
self.requires("spdlog/1.17.0", transitive_headers=True)
self.requires("crowcpp-crow/1.3.0", transitive_headers=True)
self.requires("cpptrace/0.8.3") # impl-only: crash-report symbolization (not in public headers)

def layout(self):
cmake_layout(self)
Expand Down
70 changes: 70 additions & 0 deletions runtime/include/loom/diag/breadcrumb.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#pragma once

#include <cstdint>

// ============================================================================
// loom::diag — execution breadcrumb
//
// A per-thread record of what the runtime is currently executing (which module,
// class, and lifecycle phase). Set cheaply via RAII around every module entry
// call; read by the crash handler (on the faulting thread) to attribute a fault
// to a specific module/phase.
//
// Stores *stable pointers* into the module's id/class strings (which outlive the
// call) plus a phase byte — no allocation, no copy. Reading the raw pointers/
// bytes from a signal handler is allocator-free and async-signal-safe.
// ============================================================================

namespace loom::diag {

enum class Phase : uint8_t {
None = 0, Init, PreCyclic, Cyclic, PostCyclic, LongRunning, Exit, Service,
};

/// Human-readable phase name (no allocation — safe in a signal handler).
inline const char* phaseName(Phase p) noexcept {
switch (p) {
case Phase::Init: return "init";
case Phase::PreCyclic: return "preCyclic";
case Phase::Cyclic: return "cyclic";
case Phase::PostCyclic: return "postCyclic";
case Phase::LongRunning: return "longRunning";
case Phase::Exit: return "exit";
case Phase::Service: return "service";
case Phase::None: return "none";
}
return "?";
}

struct Breadcrumb {
const char* moduleId = nullptr; // stable pointer into the module's id
const char* className = nullptr; // stable pointer into the module's class name
Phase phase = Phase::None;
uint64_t cycle = 0;
};

/// The current thread's breadcrumb. The crash handler runs on the faulting
/// thread, so reading this names the exact module/phase that was executing.
extern thread_local Breadcrumb tlsBreadcrumb;

/// RAII: stamp the breadcrumb on construction, restore the previous value on
/// destruction (so nested calls — e.g. a service invoked from cyclic — unwind
/// correctly).
class BreadcrumbScope {
public:
BreadcrumbScope(Phase p, const char* moduleId, const char* className) noexcept
: prev_(tlsBreadcrumb) {
tlsBreadcrumb.moduleId = moduleId;
tlsBreadcrumb.className = className;
tlsBreadcrumb.phase = p;
}
~BreadcrumbScope() noexcept { tlsBreadcrumb = prev_; }

BreadcrumbScope(const BreadcrumbScope&) = delete;
BreadcrumbScope& operator=(const BreadcrumbScope&) = delete;

private:
Breadcrumb prev_;
};

} // namespace loom::diag
28 changes: 28 additions & 0 deletions runtime/include/loom/diag/crash_handler.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#pragma once

#include <filesystem>

// ============================================================================
// loom::diag — process-global crash handler (hardware-fault / unhandled path)
//
// Installs fatal-signal handlers (POSIX) / an unhandled-exception filter
// (Windows) / std::set_terminate, so a segfault/FPE/abort or an escaped C++
// exception — in a module OR in the runtime itself — produces a crash report
// (faulting thread's breadcrumb + signal/exception + build identity + raw stack
// addresses) before the process exits. Symbolization is layered on later
// (Phase 2). Install once, early in startup.
// ============================================================================

namespace loom::diag {

struct CrashConfig {
std::filesystem::path crashDir; // where crash reports are written (e.g. <dataDir>/crash)
};

class CrashHandler {
public:
/// Install the handlers. Idempotent; call once after logging is set up.
static void install(const CrashConfig& cfg);
};

} // namespace loom::diag
66 changes: 66 additions & 0 deletions runtime/include/loom/diag/fault_report.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#pragma once

#include "loom/diag/breadcrumb.h"
#include "loom/diag/symbolizer.h"

#include <cstdint>
#include <optional>
#include <string>
#include <vector>

// ============================================================================
// loom::diag — structured fault report
//
// The machine-readable record of a single fault, written to
// <dataDir>/crash/<id>.json and served over /api/faults for the LoomUI crash
// viewer. Built and serialized OFF the signal path only (it allocates): the
// exception path (scheduler guard) and the Windows unhandled-exception filter.
// The POSIX fatal-signal handler writes a raw text report instead (async-
// signal-safe) which is symbolized offline.
// ============================================================================

namespace loom::diag {

enum class FaultKind : uint8_t {
Exception, ///< C++ exception caught by a module-call guard
Signal, ///< Fatal signal / SEH exception caught by the crash handler
};

const char* faultKindName(FaultKind);

/// Module data sections captured at fault time (exception path only — reading a
/// module's state is safe off the signal path). Each holds raw JSON or "".
struct FaultSections {
std::string config;
std::string recipe;
std::string runtime;
std::string summary;
};

struct FaultReport {
std::string id; ///< Unique within a run, also the filename stem
int64_t tsMs = 0; ///< system_clock milliseconds
FaultKind kind = FaultKind::Exception;
int signalOrCode = 0; ///< signal number / SEH exception code (0 for exceptions)
std::string reason; ///< what() or signal/exception description

// Build identity (so a report maps back to a commit + matching symbols).
std::string sdkVersion;
std::string gitSha;
std::string buildType;

// Execution breadcrumb (which module/phase was running on the faulting thread).
std::string moduleId; ///< "" → runtime code (no module on the thread)
std::string className;
Phase phase = Phase::None;
uint64_t cycle = 0;

std::vector<SymFrame> frames; ///< symbolized stack (empty if unavailable)
std::optional<FaultSections> sections; ///< captured live values (exception path)
};

/// Serialize to clean, nested JSON (sections embed as real JSON objects, not
/// escaped strings). Allocates — off-signal use only.
std::string toJson(const FaultReport&);

} // namespace loom::diag
37 changes: 37 additions & 0 deletions runtime/include/loom/diag/fault_sink.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#pragma once

#include "loom/diag/breadcrumb.h"

#include <cstdint>
#include <string>

// ============================================================================
// loom::diag — fault sink interface
//
// The scheduler depends only on this interface (injected, not owned) so it
// stays thin: when a guarded module call throws, it reports a FaultEvent and
// the concrete sink (in the runtime layer) does the heavy lifting — capture
// live sections, build + persist a FaultReport, publish the `loom/faults`
// topic. Keeping the interface here lets diag stay free of DataEngine/Bus deps.
// ============================================================================

namespace loom::diag {

struct FaultEvent {
std::string moduleId;
std::string className;
Phase phase = Phase::None;
uint64_t cycle = 0;
std::string message; ///< exception what()
};

class IFaultSink {
public:
virtual ~IFaultSink() = default;

/// Called from the faulting worker thread, off the signal path. Implementations
/// must be thread-safe and must not throw.
virtual void onModuleFault(const FaultEvent&) = 0;
};

} // namespace loom::diag
Loading