## Breaking Change
IIO API redesigned from manual pull+if-forest to callback dispatch.
All modules must update their subscribe() calls to pass handlers.
### Before (OLD API)
```cpp
io->subscribe("input:mouse");
void process(...) {
while (io->hasMessages()) {
auto msg = io->pullMessage();
if (msg.topic == "input:mouse") {
handleMouse(msg);
} else if (msg.topic == "input:keyboard") {
handleKeyboard(msg);
}
}
}
```
### After (NEW API)
```cpp
io->subscribe("input:mouse", [this](const Message& msg) {
handleMouse(msg);
});
void process(...) {
while (io->hasMessages()) {
io->pullAndDispatch(); // Callbacks invoked automatically
}
}
```
## Changes
**Core API (include/grove/IIO.h)**
- Added: `using MessageHandler = std::function<void(const Message&)>`
- Changed: `subscribe()` now requires `MessageHandler` callback parameter
- Changed: `subscribeLowFreq()` now requires `MessageHandler` callback
- Removed: `pullMessage()`
- Added: `pullAndDispatch()` - pulls and auto-dispatches to handlers
**Implementation (src/IntraIO.cpp)**
- Store callbacks in `Subscription.handler`
- `pullAndDispatch()` matches topic against ALL subscriptions (not just first)
- Fixed: Regex pattern compilation supports both wildcards (*) and regex (.*)
- Performance: ~1000 msg/s throughput (unchanged from before)
**Files Updated**
- 31 test/module files migrated to callback API (via parallel agents)
- 8 documentation files updated (DEVELOPER_GUIDE, USER_GUIDE, module READMEs)
## Bugs Fixed During Migration
1. **pullAndDispatch() early return bug**: Was only calling FIRST matching handler
- Fix: Loop through ALL subscriptions, invoke all matching handlers
2. **Regex pattern compilation bug**: Pattern "player:.*" failed to match
- Fix: Detect ".*" in pattern → use as regex, otherwise escape and convert wildcards
## Testing
✅ test_11_io_system: PASSED (IIO pub/sub, pattern matching, batching)
✅ test_threaded_module_system: 6/6 PASSED
✅ test_threaded_stress: 5/5 PASSED (50 modules, 100x reload, concurrent ops)
✅ test_12_datanode: PASSED
✅ 10 TopicTree scenarios: 10/10 PASSED
✅ benchmark_e2e: ~1000 msg/s throughput
Total: 23+ tests passing
## Performance Impact
No performance regression from callback dispatch:
- IIO throughput: ~1000 msg/s (same as before)
- ThreadedModuleSystem: Speedup ~1.0x (barrier pattern expected)
## Migration Guide
For all modules using IIO:
1. Update subscribe() calls to include handler lambda
2. Replace pullMessage() loops with pullAndDispatch()
3. Move topic-specific logic from if-forest into callbacks
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
23 KiB
GroveEngine User Guide
GroveEngine is a C++17 hot-reload module system designed for building modular applications with runtime code replacement capabilities.
⚠️ IMPORTANT: GroveEngine is experimental and development-ready, NOT production-ready. It is non-deterministic and optimized for rapid prototyping. See main README.md for full limitations.
Table of Contents
- Overview
- Core Concepts
- Project Setup
- Creating Modules
- Module Lifecycle
- Inter-Module Communication
- Hot-Reload
- Configuration Management
- Task Scheduling
- API Reference
Overview
GroveEngine provides:
- Hot-Reload: Replace module code at runtime without losing state
- Modular Architecture: Self-contained modules with clear interfaces
- Pub/Sub Communication: Decoupled inter-module messaging via topics
- State Preservation: Automatic state serialization across reloads
- Configuration Hot-Reload: Update module configuration without code changes
Design Philosophy
- Modules contain pure business logic (200-300 lines recommended)
- No infrastructure code in modules (threading, networking, persistence)
- All data via
IDataNodeabstraction (backend agnostic) - Pull-based message processing with callback dispatch (modules control WHEN to process, callbacks handle HOW)
Core Concepts
IModule
The base interface all modules implement. Defines the contract for:
- Processing logic (
process()) - Configuration (
setConfiguration(),getConfiguration()) - State management (
getState(),setState()) - Lifecycle (
shutdown())
IDataNode
Hierarchical data structure for configuration, state, and messages. Supports:
- Typed accessors (
getString(),getInt(),getDouble(),getBool()) - Tree navigation (
getChild(),getChildNames()) - Pattern matching (
getChildrenByNameMatch())
IIO
Pull-based pub/sub communication with callback dispatch:
publish(): Send messages to topicssubscribe(): Register callback handler for topic patternpullAndDispatch(): Pull and auto-dispatch message to handler
ModuleLoader
Handles dynamic loading of .so files:
load(): Load a module from shared libraryreload(): Hot-reload with state preservationunload(): Clean unload
Project Setup
Directory Structure
MyProject/
├── CMakeLists.txt
├── external/
│ └── GroveEngine/ # GroveEngine (submodule or symlink)
├── src/
│ ├── main.cpp
│ └── modules/
│ ├── MyModule.h
│ └── MyModule.cpp
└── config/
└── mymodule.json
CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(MyProject VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# ============================================================================
# GroveEngine Integration
# ============================================================================
set(GROVE_BUILD_TESTS OFF CACHE BOOL "Disable GroveEngine tests" FORCE)
add_subdirectory(external/GroveEngine)
# ============================================================================
# Main Executable
# ============================================================================
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE
GroveEngine::impl
spdlog::spdlog
)
# ============================================================================
# Hot-Reloadable Modules (.so)
# ============================================================================
add_library(MyModule SHARED
src/modules/MyModule.cpp
)
target_link_libraries(MyModule PRIVATE
GroveEngine::impl
spdlog::spdlog
)
set_target_properties(MyModule PROPERTIES
PREFIX "lib"
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/modules
)
# ============================================================================
# Copy config files to build directory
# ============================================================================
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/config/
DESTINATION ${CMAKE_BINARY_DIR}/config)
# ============================================================================
# Convenience targets
# ============================================================================
add_custom_target(modules
DEPENDS MyModule
COMMENT "Building hot-reloadable modules only"
)
Linking GroveEngine
Option 1: Git submodule
git submodule add <grove-engine-repo> external/GroveEngine
Option 2: Symlink (local development)
ln -s /path/to/GroveEngine external/GroveEngine
Creating Modules
Module Header
// src/modules/MyModule.h
#pragma once
#include <grove/IModule.h>
#include <grove/IDataNode.h>
#include <grove/IIO.h>
#include <spdlog/spdlog.h>
#include <memory>
namespace myapp {
class MyModule : public grove::IModule {
public:
MyModule();
// Required IModule interface
void process(const grove::IDataNode& input) override;
void setConfiguration(const grove::IDataNode& configNode,
grove::IIO* io,
grove::ITaskScheduler* scheduler) override;
const grove::IDataNode& getConfiguration() override;
std::unique_ptr<grove::IDataNode> getHealthStatus() override;
void shutdown() override;
std::unique_ptr<grove::IDataNode> getState() override;
void setState(const grove::IDataNode& state) override;
std::string getType() const override { return "mymodule"; }
bool isIdle() const override { return true; }
private:
std::shared_ptr<spdlog::logger> m_logger;
std::unique_ptr<grove::IDataNode> m_config;
grove::IIO* m_io = nullptr;
// Module state (will be preserved across hot-reloads)
int m_counter = 0;
std::string m_status;
};
} // namespace myapp
// Required C exports for dynamic loading
extern "C" {
grove::IModule* createModule();
void destroyModule(grove::IModule* module);
}
Module Implementation
// src/modules/MyModule.cpp
#include "MyModule.h"
#include <grove/JsonDataNode.h>
#include <spdlog/sinks/stdout_color_sinks.h>
namespace myapp {
MyModule::MyModule() {
m_logger = spdlog::get("MyModule");
if (!m_logger) {
m_logger = spdlog::stdout_color_mt("MyModule");
}
m_config = std::make_unique<grove::JsonDataNode>("config");
}
void MyModule::setConfiguration(const grove::IDataNode& configNode,
grove::IIO* io,
grove::ITaskScheduler* scheduler) {
m_io = io;
m_config = std::make_unique<grove::JsonDataNode>("config");
// Read configuration values with defaults
m_status = configNode.getString("initialStatus", "ready");
int startCount = configNode.getInt("startCount", 0);
m_logger->info("MyModule configured: status={}, startCount={}",
m_status, startCount);
}
const grove::IDataNode& MyModule::getConfiguration() {
return *m_config;
}
void MyModule::process(const grove::IDataNode& input) {
// Get frame timing from input
double deltaTime = input.getDouble("deltaTime", 0.016);
int frameCount = input.getInt("frameCount", 0);
// Your processing logic here
m_counter++;
// Process incoming messages (dispatch to registered callbacks)
while (m_io && m_io->hasMessages() > 0) {
m_io->pullAndDispatch(); // Callbacks invoked automatically
}
// Publish events if needed
if (m_counter % 100 == 0) {
auto event = std::make_unique<grove::JsonDataNode>("event");
event->setInt("counter", m_counter);
m_io->publish("mymodule:milestone", std::move(event));
}
}
std::unique_ptr<grove::IDataNode> MyModule::getHealthStatus() {
auto status = std::make_unique<grove::JsonDataNode>("health");
status->setString("status", "running");
status->setInt("counter", m_counter);
return status;
}
void MyModule::shutdown() {
m_logger->info("MyModule shutting down, counter={}", m_counter);
}
// ============================================================================
// State Serialization (Critical for Hot-Reload)
// ============================================================================
std::unique_ptr<grove::IDataNode> MyModule::getState() {
auto state = std::make_unique<grove::JsonDataNode>("state");
// Serialize all state that must survive hot-reload
state->setInt("counter", m_counter);
state->setString("status", m_status);
m_logger->debug("State saved: counter={}", m_counter);
return state;
}
void MyModule::setState(const grove::IDataNode& state) {
// Restore state after hot-reload
m_counter = state.getInt("counter", 0);
m_status = state.getString("status", "ready");
m_logger->info("State restored: counter={}", m_counter);
}
} // namespace myapp
// ============================================================================
// C Export Functions (Required for dlopen)
// ============================================================================
extern "C" {
grove::IModule* createModule() {
return new myapp::MyModule();
}
void destroyModule(grove::IModule* module) {
delete module;
}
}
Module Lifecycle
┌─────────────────────────────────────────────────────────────────┐
│ Module Lifecycle │
└─────────────────────────────────────────────────────────────────┘
1. LOAD
ModuleLoader::load(path, name)
│
▼
dlopen() → createModule()
│
▼
setConfiguration(config, io, scheduler)
│
▼
Module Ready
2. PROCESS (Main Loop)
┌──────────────────────┐
│ process(input) │◄────┐
│ - Read deltaTime │ │
│ - Update state │ │
│ - Pull messages │ │
│ - Publish events │ │
└──────────────────────┘ │
│ │
└────────────────────┘
3. HOT-RELOAD
File change detected
│
▼
getState() → Save state
│
▼
unload() → dlclose()
│
▼
load(path, name, isReload=true)
│
▼
setConfiguration(config, io, scheduler)
│
▼
setState(savedState)
│
▼
Module continues with preserved state
4. SHUTDOWN
shutdown()
│
▼
destroyModule()
│
▼
dlclose()
Inter-Module Communication
IIO Pub/Sub System
Modules communicate via topics using publish/subscribe pattern with callback dispatch.
Key Design: Pull-Based with Callback Dispatch
Unlike traditional push-based systems, IIO gives modules control over WHEN to process messages while callbacks handle HOW to process them:
- Subscribe with Callback (in
setConfiguration): Register handlers for topic patterns - Pull and Dispatch (in
process): Module controls when to process - callbacks invoked automatically
Benefits:
- No if-forest dispatch: Logic registered at subscription, not scattered in process()
- Module controls timing: Pull-based means deterministic frame ordering
- Thread-safe: Callbacks invoked in module's own thread context
- Clean separation: Subscription setup vs. message processing
Publishing Messages
void MyModule::process(const grove::IDataNode& input) {
// Create message data
auto data = std::make_unique<grove::JsonDataNode>("data");
data->setString("event", "player_moved");
data->setDouble("x", 100.5);
data->setDouble("y", 200.3);
// Publish to topic
m_io->publish("game:player:position", std::move(data));
}
Subscribing to Topics with Callbacks
void MyModule::setConfiguration(const grove::IDataNode& configNode,
grove::IIO* io,
grove::ITaskScheduler* scheduler) {
m_io = io;
// Subscribe to specific topic with callback handler
m_io->subscribe("game:player:position", [this](const grove::Message& msg) {
double x = msg.data->getDouble("x", 0.0);
double y = msg.data->getDouble("y", 0.0);
// Handle position update...
});
// Subscribe with wildcard pattern
m_io->subscribe("game:player:*", [this](const grove::Message& msg) {
handlePlayerEvent(msg);
});
// Subscribe with low-frequency batching (for non-critical updates)
grove::SubscriptionConfig config;
config.batchInterval = 1000; // 1 second batches
m_io->subscribeLowFreq("analytics:*", [this](const grove::Message& msg) {
processBatchedAnalytics(msg);
}, config);
}
Processing Messages with Callback Dispatch
void MyModule::process(const grove::IDataNode& input) {
// Pull-based: module controls WHEN to process messages
// Callbacks registered at subscribe() handle HOW to process
while (m_io->hasMessages() > 0) {
m_io->pullAndDispatch(); // Automatically invokes registered callback
}
}
// No more if-forest dispatch - callbacks were registered at subscription:
// subscribe("game:player:position", [this](const Message& msg) { ... });
Topic Patterns
Topics support wildcard matching:
| Pattern | Matches |
|---|---|
game:player:* |
game:player:position, game:player:health |
economy:* |
economy:prices, economy:trade |
*:error |
network:error, database:error |
Hot-Reload
How It Works
- File Watcher detects
.somodification - State Extraction:
getState()serializes module state - Unload: Old library closed with
dlclose() - Load: New library loaded with
dlopen()(cache bypass via temp copy) - Configure:
setConfiguration()called with same config - Restore:
setState()restores serialized state
Implementing Hot-Reload Support
// Critical: Serialize ALL state that must survive reload
std::unique_ptr<grove::IDataNode> MyModule::getState() {
auto state = std::make_unique<grove::JsonDataNode>("state");
// Primitives
state->setInt("counter", m_counter);
state->setDouble("health", m_health);
state->setString("name", m_name);
state->setBool("active", m_active);
// Complex state: serialize to JSON child nodes
auto entitiesNode = std::make_unique<grove::JsonDataNode>("entities");
for (const auto& entity : m_entities) {
auto entityNode = std::make_unique<grove::JsonDataNode>(entity.id);
entityNode->setDouble("x", entity.x);
entityNode->setDouble("y", entity.y);
entitiesNode->setChild(entity.id, std::move(entityNode));
}
state->setChild("entities", std::move(entitiesNode));
return state;
}
void MyModule::setState(const grove::IDataNode& state) {
// Restore primitives
m_counter = state.getInt("counter", 0);
m_health = state.getDouble("health", 100.0);
m_name = state.getString("name", "default");
m_active = state.getBool("active", true);
// Restore complex state
m_entities.clear();
auto* entitiesNode = state.getChildReadOnly("entities");
if (entitiesNode) {
for (const auto& name : entitiesNode->getChildNames()) {
auto* entityNode = entitiesNode->getChildReadOnly(name);
if (entityNode) {
Entity e;
e.id = name;
e.x = entityNode->getDouble("x", 0.0);
e.y = entityNode->getDouble("y", 0.0);
m_entities.push_back(e);
}
}
}
}
File Watcher Example
class FileWatcher {
public:
void watch(const std::string& path) {
if (fs::exists(path)) {
m_lastModified[path] = fs::last_write_time(path);
}
}
bool hasChanged(const std::string& path) {
if (!fs::exists(path)) return false;
auto currentTime = fs::last_write_time(path);
auto it = m_lastModified.find(path);
if (it == m_lastModified.end()) {
m_lastModified[path] = currentTime;
return false;
}
if (currentTime != it->second) {
it->second = currentTime;
return true;
}
return false;
}
private:
std::unordered_map<std::string, fs::file_time_type> m_lastModified;
};
Configuration Management
JSON Configuration Files
// config/mymodule.json
{
"initialStatus": "ready",
"startCount": 0,
"maxItems": 100,
"debugMode": false,
"updateRate": 0.016
}
Loading Configuration
std::unique_ptr<grove::JsonDataNode> loadConfig(const std::string& path) {
if (fs::exists(path)) {
std::ifstream file(path);
nlohmann::json j;
file >> j;
return std::make_unique<grove::JsonDataNode>("config", j);
}
// Return empty config with defaults
return std::make_unique<grove::JsonDataNode>("config");
}
Runtime Config Updates
Modules can optionally support runtime configuration changes:
bool MyModule::updateConfig(const grove::IDataNode& newConfig) {
// Validate new config
int maxItems = newConfig.getInt("maxItems", 100);
if (maxItems < 1 || maxItems > 10000) {
m_logger->warn("Invalid maxItems: {}", maxItems);
return false;
}
// Apply changes
m_maxItems = maxItems;
m_debugMode = newConfig.getBool("debugMode", false);
m_logger->info("Config updated: maxItems={}", m_maxItems);
return true;
}
Task Scheduling
For computationally expensive operations, delegate to ITaskScheduler:
void MyModule::setConfiguration(const grove::IDataNode& configNode,
grove::IIO* io,
grove::ITaskScheduler* scheduler) {
m_scheduler = scheduler; // Store reference
}
void MyModule::process(const grove::IDataNode& input) {
// Delegate expensive pathfinding calculation
if (needsPathfinding) {
auto taskData = std::make_unique<grove::JsonDataNode>("task");
taskData->setDouble("startX", unit.x);
taskData->setDouble("startY", unit.y);
taskData->setDouble("targetX", target.x);
taskData->setDouble("targetY", target.y);
taskData->setString("unitId", unit.id);
m_scheduler->scheduleTask("pathfinding", std::move(taskData));
}
// Check for completed tasks
while (m_scheduler->hasCompletedTasks() > 0) {
auto result = m_scheduler->getCompletedTask();
std::string unitId = result->getString("unitId", "");
// Apply pathfinding result...
}
}
API Reference
IDataNode
| Method | Description |
|---|---|
getString(name, default) |
Get string property |
getInt(name, default) |
Get integer property |
getDouble(name, default) |
Get double property |
getBool(name, default) |
Get boolean property |
setString(name, value) |
Set string property |
setInt(name, value) |
Set integer property |
setDouble(name, value) |
Set double property |
setBool(name, value) |
Set boolean property |
hasProperty(name) |
Check if property exists |
getChild(name) |
Get child node (transfers ownership) |
getChildReadOnly(name) |
Get child node (no ownership transfer) |
setChild(name, node) |
Add/replace child node |
getChildNames() |
Get names of all children |
IIO
| Method | Description |
|---|---|
publish(topic, data) |
Publish message to topic |
subscribe(pattern, handler, config) |
Subscribe with callback handler |
subscribeLowFreq(pattern, handler, config) |
Subscribe with batching and callback |
hasMessages() |
Count of pending messages |
pullAndDispatch() |
Pull and auto-dispatch message to handler |
getHealth() |
Get IO health metrics |
IModule
| Method | Description |
|---|---|
process(input) |
Main processing method |
setConfiguration(config, io, scheduler) |
Initialize module |
getConfiguration() |
Get current config |
getState() |
Serialize state for hot-reload |
setState(state) |
Restore state after hot-reload |
getHealthStatus() |
Get module health report |
shutdown() |
Clean shutdown |
isIdle() |
Check if safe to hot-reload |
getType() |
Get module type identifier |
ModuleLoader
| Method | Description |
|---|---|
load(path, name, isReload) |
Load module from .so |
reload(module) |
Hot-reload with state preservation |
unload() |
Unload current module |
isLoaded() |
Check if module is loaded |
getLoadedPath() |
Get path of loaded module |
IOFactory
| Method | Description |
|---|---|
create(type, instanceId) |
Create IO by type string |
create(IOType, instanceId) |
Create IO by enum |
createFromConfig(config, instanceId) |
Create IO from config |
IO Types:
"intra"/IOType::INTRA- Same-process (development)"local"/IOType::LOCAL- Same-machine (production single-server)"network"/IOType::NETWORK- Distributed (MMO scale)
Building and Running
# Configure
cmake -B build
# Build everything
cmake --build build -j4
# Build modules only (for hot-reload workflow)
cmake --build build --target modules
# Run
./build/myapp
Hot-Reload Workflow
- Start application:
./build/myapp - Edit module source:
src/modules/MyModule.cpp - Rebuild module:
cmake --build build --target modules - Application detects change and hot-reloads automatically
Best Practices
- Keep modules small: 200-300 lines of pure business logic
- No infrastructure code: Let GroveEngine handle threading, persistence
- Serialize all state: Everything in
getState()survives hot-reload - Use typed accessors:
getInt(),getString()with sensible defaults - Pull-based messaging: Process messages in
process(), not callbacks - Validate config: Check configuration values in
setConfiguration() - Log appropriately: Debug for development, Info for production events