Repository navigation
SparkDaemon
Long-lived background process that owns shared mutable state (asset cache,
shader cache, and the optional orchestration control plane) and serves multiple
engine / editor / tool instances over local IPC. Collaboration is implemented
by the separately isolated SparkCollabServer; build-watcher events remain a
future service.
Inspired by Wine's wineserver: one process serialises shared state, every client is a thin handle. The daemon is always optional — every engine subsystem that uses it retains an in-process fallback, and removing the daemon is never more than turning a CVar off.
- Why a daemon?
- Architecture
- Wire protocol
- Services shipped today
- Running the daemon
- Enabling it from the engine
- Adding a new service
- Platform support
- Ops and debugging
- Future phases
Four problems the daemon is designed to solve:
- Two editors can't share a warm shader cache. Every engine instance recompiles DXC / SPIRV-Cross variants from scratch on first boot, even though the bytecode is identical.
-
Cooked asset blobs aren't shareable across tools. Editor, headless
cook tool, and game preview each carry their own in-process
AssetCache. - Collaborative editing is P2P with no persistence. Host crash loses all session state.
- File watching is per-process. Every running engine polls timestamps on its own schedule.
A single long-lived process that owns the shared state and multiplexes multiple clients solves all four with the same pattern.
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Editor #1 │ │ Editor #2 │ │ CLI cook tool│
│ (client) │ │ (client) │ │ (client) │
└──────┬──────┘ └──────┬──────┘ └──────┬───────┘
│ │ │
└────────┬───────┴────────┬───────┘
│ AF_UNIX │
┌─────┴────────────────┴─────┐
│ SparkDaemon │
│ ┌──────────┐ ┌─────────┐ │
│ │ Control │ │ Shader │ │
│ │ (ping, │ │ (cache) │ │
│ │ stats, │ ├─────────┤ │
│ │ shut- │ │ Asset │ │
│ │ down) │ │ (cache) │ │
│ └──────────┘ └─────────┘ │
└────────────────────────────┘
SparkCollabServer (separate process/trust boundary)
Every client holds one DaemonClient (a single AF_UNIX socket). Each
subsystem wraps that client with a typed facade: ShaderServiceClient,
AssetServiceClient. The daemon binds one socket, accepts connections,
and dispatches incoming framed messages to the registered service that
matches their ServiceId.
Key source files:
| Layer | File |
|---|---|
| Wire protocol | SparkEngine/Source/Utils/DaemonProtocol.h |
| Framing helpers | SparkEngine/Source/Utils/DaemonFraming.h |
| Engine-side client | SparkEngine/Source/Utils/DaemonClient.{h,cpp} |
| Process-wide client lifecycle | SparkEngine/Source/Utils/DaemonConnection.{h,cpp} |
| Engine lifecycle glue | SparkEngine/Source/Utils/DaemonLifecycle.{h,cpp} |
| Daemon executable | SparkDaemon/src/main.cpp |
| Daemon server | SparkDaemon/src/DaemonServer.{h,cpp} |
| Service base | SparkDaemon/src/ServiceBase.h |
| Shader service (daemon) | SparkDaemon/src/ShaderService.{h,cpp} |
| Asset service (daemon) | SparkDaemon/src/AssetService.{h,cpp} |
| Control service (daemon) | SparkDaemon/src/ControlService.{h,cpp} |
| Shader client wrapper (engine) | SparkEngine/Source/Utils/ShaderServiceClient.{h,cpp} |
| Asset client wrapper (engine) | SparkEngine/Source/Utils/AssetServiceClient.{h,cpp} |
| CompiledShaderBlob bridge | SparkEngine/Source/Graphics/ShaderDaemonBridge.{h,cpp} |
Frames are length-prefixed little-endian binary:
[4 bytes: payload length][2 bytes: service ID][2 bytes: message type][N bytes: payload]
Fixed-size header = kFrameHeaderSize = 8 bytes. Max payload =
kMaxPayloadSize = 16 MiB — frames over this are rejected as malformed.
Service IDs (DaemonProtocol.h::ServiceId):
| ID | Service | Status |
|---|---|---|
| 0x0000 | Control | shipped (ping, version, stats, shutdown) |
| 0x0001 | Asset | shipped (cache get/put/invalidate/stats) |
| 0x0002 | Shader | shipped (cache get/put/clear/stats) |
| 0x0003 | Collab | shipped in standalone SparkCollabServer
|
| 0x0004 | Build | reserved — future phase |
| 0x0005 | Orchestration | shipped, explicit opt-in in SparkDaemon
|
Message type 0x00FF is reserved across every service as an error
reply. Any service that can't handle a request returns a response with
messageType = 0x00FF and a UTF-8 error string in the payload.
DaemonClient::Request surfaces those uniformly as std::unexpected().
Protocol version string = Spark::Daemon::kProtocolVersion =
"1.0.0". Returned by Control::VersionRequest and
Control::StatsRequest. Bump when the frame header layout changes.
Always registered. Uses empty payloads for all requests except
ErrorResponse and StatsResponse.
| Request | Response | Payload |
|---|---|---|
PingRequest (0x0001) |
PingResponse |
string "pong"
|
VersionRequest (0x0003) |
VersionResponse |
protocol version string |
ShutdownRequest (0x0005) |
ShutdownAck |
empty |
StatsRequest (0x0007) |
StatsResponse |
DaemonStats (uptime, version, registered IDs) |
Domain-agnostic blob cache keyed by (sourceHash: uint64, target: uint8, stage: uint8).
| Request | Response | Notes |
|---|---|---|
GetCacheEntry (0x0001) |
GetCacheEntryResponse |
miss returns found=false
|
PutCacheEntry (0x0003) |
PutCacheEntryResponse |
overwrites existing entry |
ClearCache (0x0005) |
ClearCacheResponse |
drops all entries, resets stats |
GetCacheStats (0x0007) |
GetCacheStatsResponse |
entry count, total bytes, hits, misses |
Optional persistence via --cache-dir <path>. On disk each entry is a
file <hash16>_<target3>_<stage3>.blob containing raw bytecode.
The engine-side bridge (ShaderDaemonBridge) serialises the full
CompiledShaderBlob (bytecode + target + stage + entry point + errors
- reflection counters) into a single byte buffer so the daemon sees only opaque bytes.
LRU eviction (Phase 5): pass --shader-cache-max-mb <N> to cap
the in-memory working set. When a PutCacheEntry pushes total payload
bytes over the limit, the service evicts oldest entries (tracked as a
linked list in insertion order) until the budget is satisfied, deleting
the corresponding .blob files in lockstep. The eviction counter is
exposed on the wire (ShaderCacheStats.evictionCount, appended after
the original four fields; older daemons that don't emit it are
tolerated by the decoder).
Domain-agnostic blob cache keyed by (path: std::string, platform: uint8).
| Request | Response | Notes |
|---|---|---|
GetAsset (0x0001) |
GetAssetResponse |
miss returns found=false
|
PutAsset (0x0003) |
PutAssetResponse |
overwrites existing entry |
InvalidateAsset (0x0005) |
InvalidateAssetResponse |
drops every platform variant for a path; returns removed count |
ClearCache (0x0007) |
ClearCacheResponse |
drops all entries, resets stats |
GetCacheStats (0x0009) |
GetCacheStatsResponse |
entry count, total bytes, hits, misses, evictions |
Optional persistence via --asset-cache-dir <path>. On disk each entry
is a file <pathHash16>_<platform3>.asset containing a [u32 pathLen] [pathBytes][blob] layout so arbitrary-length paths round-trip through
the 255-byte NAME_MAX limit (tested with 500+ char paths).
LRU eviction is enabled with --asset-cache-max-mb <N> (same semantics
as the shader service) and AssetCacheStats.evictionCount is tracked
on the wire.
SparkCollabServer deliberately runs outside SparkDaemon. Its collaboration
service uses capability tokens for session administration and peer operations,
maintains authoritative presence and node locks, keeps bounded edit history,
expires inactive peers, and releases their locks. The standalone binary uses
the same local IPC framing and Control service as the daemon.
# In-memory only (entries lost on restart):
./build/linux-gcc-release/bin/SparkDaemon
# Persistent shader + asset caches (typical dev setup):
./build/linux-gcc-release/bin/SparkDaemon \
--socket /tmp/spark-daemon.sock \
--cache-dir ~/.cache/spark/shaders \
--asset-cache-dir ~/.cache/spark/assetsOptions:
| Flag | Default | Purpose |
|---|---|---|
--socket <path> |
./.spark-daemon.sock |
Local endpoint: owner-only AF_UNIX socket on POSIX, same-user named pipe on Windows |
--cache-dir <path> |
disabled | Shader cache directory — enables persistence |
--asset-cache-dir <path> |
disabled | Asset cache directory — enables persistence |
--shader-cache-max-mb <N> |
0 (unbounded) |
LRU eviction threshold for the shader service |
--asset-cache-max-mb <N> |
0 (unbounded) |
LRU eviction threshold for the asset service |
--help, -h
|
— | Print usage |
The daemon writes a single-line startup banner on stdout:
SparkDaemon: listening on /tmp/spark-daemon.sock
SparkDaemon: shader cache /home/alice/.cache/spark/shaders (142 entries loaded)
SparkDaemon: asset cache /home/alice/.cache/spark/assets (891 entries loaded)
On SIGINT or SIGTERM (or a client's Control::ShutdownRequest) it
exits cleanly, unlinking the POSIX socket file or closing the Windows pipe.
Five CVars control daemon use from the engine side:
| CVar | Default | Purpose |
|---|---|---|
spark.daemon.enabled |
false | Master switch — when false the engine never talks to a daemon |
spark.daemon.socket_path |
empty | Override socket path (empty = ./.spark-daemon.sock) |
spark.daemon.auto_spawn |
false | If no daemon is running, launch one as a detached subprocess |
spark.daemon.binary_path |
empty | Override path to the executable (empty = ./SparkDaemon.exe on Windows, ./SparkDaemon elsewhere) |
spark.daemon.clear_on_startup |
false | After a successful connect, run daemon.clear_cache all — useful when reattaching a daemon that has stale cooked blobs from a previous source tree |
Typical power-user run:
# Terminal 1: start the daemon
./build/linux-gcc-release/bin/SparkDaemon \
--cache-dir ~/.cache/spark/shaders
# Terminal 2: run the engine pointed at it
./build/linux-gcc-release/bin/SparkEngine \
+spark.daemon.enabled 1The engine calls Spark::Daemon::InitializeDaemonLifecycle() from
InitConsole() (see SparkEngine.cpp). That helper:
- If
spark.daemon.enabledis false, returns immediately. - Calls
DaemonConnection::TryConnect(socketPath). If successful, proceeds to step 4. - If
spark.daemon.auto_spawnis true, launchesSparkDaemonas a detached subprocess, waits up to 2 seconds for its named pipe (Windows) or Unix-domain socket (Linux/macOS) to become ready, then retries connect. Readiness failures include the endpoint and native transport error in the log. - On success, builds a process-wide
ShaderServiceClientand wires it intoGetShaderDiskCache()viaSetDaemonClient(&client). - On failure, logs a warning and returns — engine continues with in-process caches only.
Spark::Daemon::ShutdownDaemonLifecycle() reverses all of that at
shutdown.
The pattern is established by ShaderService (Phase 2) and
AssetService (Phase 3a). To add a service:
- Pick a new
ServiceIdvalue (extend the enum inDaemonProtocol.h). - Write
<Svc>ServiceProtocol.hinSparkEngine/Source/Utils/:- Message enum (one value per request/response pair)
- Request / response structs
-
Encode*/Decode*helpers built onBinaryWriter/BinaryReader
- Write
<Svc>ServiceClient.{h,cpp}inSparkEngine/Source/Utils/:- Constructor takes
DaemonClient&(not owning) - One method per RPC, each returning
std::expected<Response, std::string> - Each method encodes the request, calls
m_client.Request(), decodes the response
- Constructor takes
- Write
<Svc>Service.{h,cpp}inSparkDaemon/src/:- Inherit from
ServiceBase -
GetServiceId()returns your ID -
HandleMessage(type, payload)dispatches on message type, decodes payload, returns aServiceResponse - On error, return a
ServiceResponsewithmessageType = ControlMessage::ErrorResponseand a string payload
- Inherit from
- Register in
SparkDaemon/src/main.cppalongsideControlService. - Add a service-client test file under
Tests/, following the existing fixture pattern (spin up aDaemonServerin a background thread, connect aDaemonClient, exercise the RPCs). - If the service persists to disk, add a
--<svc>-cache-dirCLI flag toSparkDaemonand anInitialize(path)method that scans the directory on startup. - Wire into the engine via
DaemonLifecyclewhen engine-side integration is needed (follow theShaderDiskCachepattern for the typed subsystem → daemon wiring).
| Platform | Transport | Daemon binary | Client |
|---|---|---|---|
| Linux | AF_UNIX sockets | ✅ | ✅ |
| macOS | AF_UNIX sockets | ✅ (experimental, untested in CI) | ✅ |
| Windows | Named pipes | ✅ | ✅ |
Windows targets are built by SparkDaemon/CMakeLists.txt. The server uses
same-user local named pipes and the engine client connects through
DaemonClient; Unix platforms use owner-restricted domain sockets. An
auto-spawned daemon is deliberately detached from the launching engine's
kill-on-close process group, so closing the editor does not terminate the
shared daemon.
When the daemon is connected, three commands are registered on the
engine-side CommandRegistry and usable from the in-game console:
| Command | Purpose |
|---|---|
daemon.stats |
Formats the Control StatsResponse plus per-service GetCacheStats (shader + asset) into a single human-readable report — uptime, protocol version, registered service IDs, entry count / bytes / hit-rate / evictions for each cache. The formatter lives in DaemonDiagnostics::FormatDaemonStats and is unit-tested directly against a DaemonStatsSnapshot so the command shell has no test surface of its own. |
daemon.clear_cache <shader|asset|all> |
Wipes one or both caches via RPC. The bitmask parser (DaemonCacheScope) accepts the three literals and rejects everything else with a usage string. |
daemon.invalidate <path> |
Calls AssetService::InvalidateAsset for the supplied source path, dropping every platform variant in one RPC and reporting the removed count. |
All three degrade gracefully if the daemon is not connected — they log a warning and do nothing, so scripts can fire them unconditionally at startup.
ls -l /tmp/spark-daemon.sock
# srw------- 1 alice alice 0 Apr 16 15:42 /tmp/spark-daemon.sockLive socket = daemon is up. Missing socket = either it crashed or it was never started.
On Windows the endpoint is a named pipe rather than a file. Use
daemon.stats from an engine/editor console for a transport-independent
health check; auto-spawn diagnostics report the normalized endpoint and
Win32 error when the pipe does not become ready.
From an engine console:
> spark.daemon.enabled
spark.daemon.enabled = true
> spark.daemon.socket_path
spark.daemon.socket_path =
Or inspect the log — InitializeDaemonLifecycle logs the outcome:
[Core] Daemon wired: shader cache sharing via ./.spark-daemon.sock
or
[Core] Daemon requested but unreachable — engine will run with in-process caches only
Send a StatsRequest from any connected client:
auto response = client.Request(ServiceId::Control,
static_cast<uint16_t>(ControlMessage::StatsRequest), {});
DaemonStats stats;
DecodeDaemonStats(response->payload, stats);
// stats.uptimeSeconds
// stats.protocolVersion
// stats.registeredIdsPer-service stats come from each service's own GetCacheStats RPC —
see ShaderServiceClient::GetCacheStats and
AssetServiceClient::GetCacheStats.
From a client:
client.Request(ServiceId::Control,
static_cast<uint16_t>(ControlMessage::ShutdownRequest), {});From the shell: kill -TERM $(pgrep -f SparkDaemon).
Both release the local IPC endpoint on exit.
-
Engine logs "Daemon requested but unreachable": endpoint is wrong
or the daemon isn't running. Check
spark.daemon.socket_path; on POSIX, inspect the socket file, and on Windows inspect the preceding named-pipe readiness diagnostic. -
Daemon launches but engine can't connect: check the socket path
matches on both sides. The daemon's CWD determines where
.spark-daemon.socklives if no--socketis passed. -
Auto-spawn logs "binary not found": set
spark.daemon.binary_pathto an absolute path, or run the engine withSparkDaemonin the CWD. -
Stale cache entries after a source change: the Asset service's
InvalidateAsset(path)drops all platform variants in one RPC. The Shader service'sClearCachedrops everything. File-watching auto-invalidation is a future phase.
Phase history is summarised here; the former .claude/knowledge store that
held the per-phase notes was retired on 2026-06-08 and folded into the wiki:
| Phase | Scope | Status |
|---|---|---|
| 1 | Protocol + framing + DaemonClient + DaemonServer + ControlService + SparkDaemon executable |
✅ |
| 2a |
ShaderService (get / put / clear / stats) |
✅ |
| 2b |
ShaderService disk persistence (--cache-dir, atomic rename writes, reload-on-restart) |
✅ |
| 3a |
AssetService with InvalidateAsset
|
✅ |
| 3b | Engine-side wiring: ShaderDiskCache consults daemon, falls through to local disk |
✅ |
| 3c | Engine lifecycle: spark.daemon.enabled CVar + DaemonLifecycle::Initialize called from InitConsole
|
✅ |
| 4 |
spark.daemon.auto_spawn, Control::StatsRequest, concurrent-clients test |
✅ |
| 5 | LRU eviction in both services + --shader-cache-max-mb / --asset-cache-max-mb CLI flags + evictionCount on the wire |
✅ |
| 6 |
daemon.stats / daemon.clear_cache / daemon.invalidate console commands + spark.daemon.clear_on_startup
|
✅ |
Planned and not yet implemented:
- Build monitor. Watches CMake + source files, reports incremental rebuild events to editors.
-
File watching + push notifications. inotify/FSEvents/kqueue
abstraction so the daemon can push
ShaderReloaded/AssetChangedevents to connected clients instead of every client polling. -
AssetPipeline engine wiring. Bridge between the engine's typed
shared_ptr<Asset>and the daemon's raw-blob Asset service. - Actual shader compilation inside the daemon. Warm DXC / glslang / SPIRV-Cross workers, parallel variant compile, push notifications for completed batches.
Published from 2b03dc797148. Edit the canonical source in wiki/.
- Documentation
- Docs route
- Wiki index
- Guides
- Tutorials
- Samples
- Examples
- API Reference
- API route
- Reference
- Build Guide
- Dependencies
- FAQ
- Changelog
- Roadmap
- Contributing
- Code of Conduct
- Home
- FAQ
- Getting Started
- Quick-Start Tutorial
- Making Your First Game
- Making Your First Multiplayer Game
- Artist Workflow Guide
- Editor Walkthrough
- Migration Guide
- How SparkEngine Works
- Architecture Overview
- Engine Architecture Flowchart
- Creating a Game Module
- Game Modules (catalog)
- Entity Component System
- Rendering and Graphics
- Physics
- Cloth Simulation
- Audio
- Input System
- Camera System
- Scripting with AngelScript
- Visual Scripting
- AI and Navigation
- Animation
- 2D Systems
- Networking
- Dedicated Server
- Multiplayer Quick Start
- Area Server Architecture
- Scene Management
- Large World Support
- Collaborative Editing
- Coroutine System
- Event System
- Event Response System
- Job System
- UI System
- UI Layout Extensions
- Localization
- Dialogue System
- Destruction System
- Replay System
- Achievement System
- Loading System
- Mod System
- Content Delivery
- Tween System
- Memory Integrity
- Gameplay Systems
- Terrain and Procedural Generation
- Save System
- Persistence System
- Day Night Cycle and Weather
- Cinematic Sequencer
- Runtime Prefabs
- SparkEditor
- Editor Tutorials
- SparkConsole
- SparkDaemon
- Shader Pipeline
- Asset Pipeline
- Asset Validation
- Asset Migration
- Game Packaging
- Online Services
- DataTable System
- Loot and Crafting System
- CSG System
- Font System
- Timer Manager
- Movie Render Pipeline
- HLOD and World Partition
- Remote Debug System
- Selection Manager
- Asset Dependency Graph
- Editor Automation
- File Watcher
- Project Templates
- System Requirements
- VR Support
- Mobile Platform
- Accessibility
- Platform Input
- Platform Certification
- Cross-Compilation: Wine Testing
- RHI Abstraction Layer
- D3D11 Backend
- D3D12 Backend
- Vulkan Backend
- OpenGL Backend
- Metal Backend
- DXR Raytracing
- Hybrid Ray Tracing
- Upscaling (DLSS/FSR)
- Render Graph
- Shader Graph
- GPU Particles
- GPU-Driven Rendering
- Volumetric Fog
- Volumetric Clouds
- Global Illumination
- Virtual Texturing
- Water Rendering
- Clustered Lighting
- Material System
- Post-Processing
- Shadow System
- Particle System
- Decal System
- Sky and Atmosphere
- Foliage System
- Mesh Shaders
- Neural Rendering
- Configuration Reference
- Performance Tips
- Benchmark Framework
- Threading Model
- Fuzz Policy and Parser Security
- Memory Safety
- Memory Management Patterns
- Build System and CMake Modules
- Profiler and Debugging
- Performance Profiling Guide
- Telemetry System
- Crash Reporting
- Golden Image Testing
- Utilities
- Testing
- Fuzz Policy and Parser Security
- Codebase Statistics
- Codebase Health
- Error Handling Patterns
- Hot Reload Overview
- Troubleshooting
- Contributing
- Workflow Patterns
- Build Optimizations
- CI Reproducible Builds
- GitHub API and PR Checks
- Git Rebase Conflicts
- Clang-Format
- Code Quality Violations
- AI Bloat Pattern
- MinGW + Wine Cross-Compilation
- Live Editor Testing
- Engine & Renderer Landscape
- DuetOS Portability Catalog
- Five-Engine Analysis
- Eleven-Engine Analysis
- ThorVG / Unity Graphics Analysis
- Advanced Techniques Catalog
- Third-Party Library Evaluation
- Engine Viability Evaluation
- Engine Feature Recommendations
- Project Recommendations
- Mac Compatibility Analysis
- Codebase Observations
- Codebase Bloat Audit
- Test Suite Audit
- Documentation Coverage Audit
- ThirdParty Dependencies Audit
- Load Test Baseline
- Gameplay Systems Status
- SparkGame Module Status
- Stub and Abandoned Features
- Memory Integrity System
- Memory Safety Evaluation
- Hardware Acceleration Systems
- Jolt Physics Integration
- GPU/CPU Separation Plan
- Daemon Services Architecture
- Reflection & Polymorphism Refactoring Plan
- SparkBuild In-Tree
- Wine No-JobSystem Breakthrough
- Wine Role and Fallback Tiers