diff --git a/docs/project.md b/docs/project.md index 200597b..5a4071b 100644 --- a/docs/project.md +++ b/docs/project.md @@ -14,27 +14,33 @@ logos-liblogos/ │ └── project.md # This document ├── src/ │ ├── CMakeLists.txt # Source build configuration -│ ├── logos_core/ # Core library implementation +│ ├── logos_core/ # Core library implementation (Qt-free) │ │ ├── logos_core.h # C API header (public) │ │ ├── logos_core.cpp # C API implementation -│ │ ├── plugin_manager.h/cpp # Facade: orchestrates registry, launcher, resolver +│ │ ├── plugin_manager.h/cpp # Facade: orchestrates registry, runtime, resolver │ │ ├── plugin_registry.h/cpp # In-memory registry of discovered/loaded modules -│ │ ├── plugin_launcher.h/cpp # Spawns and manages logos_host subprocesses -│ │ ├── dependency_resolver.h/cpp # Topological sort with circular dependency detection -│ │ └── process_manager.h/cpp # Boost.Process-based subprocess management -│ └── logos_host/ # Module subprocess host -│ ├── logos_host.cpp # Host entry point -│ ├── command_line_parser.h/cpp # CLI argument parsing (--name, --path) -│ ├── plugin_initializer.h/cpp # Plugin loading and token setup -│ └── qt/ # Qt-specific host implementations -│ ├── qt_app.h/cpp # Qt application setup for host -│ └── qt_token_receiver.h/cpp # Auth token reception via local socket +│ │ ├── module_runtime.h # Abstract ModuleRuntime interface + ModuleDescriptor +│ │ ├── runtime_registry.h/cpp # Registry for ModuleRuntime implementations +│ │ └── dependency_resolver.h/cpp # Topological sort with circular dependency detection +│ └── runtimes/ # Runtime implementations (extensible) +│ └── qt_subprocess/ # Qt-based subprocess runtime (current default) +│ ├── qt_subprocess_runtime.h/cpp # ModuleRuntime impl: spawns logos_host processes +│ ├── subprocess_manager.h/cpp # Boost.Process-based subprocess management +│ └── host/ # logos_host executable sources +│ ├── main.cpp # Host entry point +│ ├── command_line_parser.h/cpp # CLI argument parsing (--name, --path) +│ ├── plugin_initializer.h/cpp # Plugin loading and token setup +│ └── qt/ # Qt-specific host implementations +│ ├── qt_app.h/cpp # Qt application setup for host +│ └── qt_token_receiver.h/cpp # Auth token reception via local socket ├── tests/ # Google Test suite │ ├── CMakeLists.txt # Test build configuration -│ ├── test_app_lifecycle.cpp # C API lifecycle tests (init, exec, cleanup, processEvents) +│ ├── test_app_lifecycle.cpp # C API lifecycle tests (init, exec, cleanup) │ ├── test_plugin_manager.cpp # PluginManager + PluginRegistry tests -│ ├── test_process_manager.cpp # ProcessManager lifecycle and subprocess tests -│ ├── test_dependency_resolver.cpp # DependencyResolver tests +│ ├── test_subprocess_manager.cpp # SubprocessManager lifecycle and subprocess tests +│ ├── test_runtime_registry.cpp # RuntimeRegistry: registration, selection, fan-out +│ ├── test_module_runtime_abstraction.cpp # PluginManager ↔ ModuleRuntime routing tests +│ ├── test_dependency_resolver.cpp # DependencyResolver tests │ ├── test_process_stats.cpp # ProcessStats tests (external process-stats lib) │ ├── test_token_exchange.cpp # Token exchange via Unix domain socket tests │ └── qt_test_adapter.h # Qt test utilities/adapter header @@ -83,7 +89,7 @@ logos-liblogos/ **Files:** `src/logos_core/plugin_manager.h`, `src/logos_core/plugin_manager.cpp` -**Purpose:** Thin facade that orchestrates `PluginRegistry`, `PluginLauncher`, and `DependencyResolver`. Provides the C++-level API for module lifecycle management. Each module runs in a separate `logos_host` process for isolation. +**Purpose:** Thin facade that orchestrates `PluginRegistry`, `RuntimeRegistry`, and `DependencyResolver`. Provides the C++-level API for module lifecycle management. How a module is loaded (subprocess, in-process, WASM, etc.) is determined by the registered `ModuleRuntime` implementation — `PluginManager` only decides *what* to load and *when*. **Thread safety:** `loadPlugin`, `loadPluginWithDependencies`, and `unloadPlugin` are serialised by a static `loadMutex()` (one load/unload at a time). `discoverInstalledModules` delegates to `PluginRegistry` which has its own reader-writer lock. @@ -92,28 +98,29 @@ logos-liblogos/ | Method | Description | |--------|-------------| | `registry() → PluginRegistry&` | Access the shared plugin registry | +| `runtimes() → RuntimeRegistry&` | Access the shared runtime registry (for installing custom runtimes) | | `setPluginsDir(path)` | Set the primary plugin directory (clears existing) | | `addPluginsDir(path)` | Add an additional plugin directory | | `setPersistenceBasePath(path)` | Set base directory for module instance persistence | | `discoverInstalledModules()` | Scan all plugin directories and register discovered modules | | `processPlugin(path) → std::string` | Extract metadata from a module file, register as known | | `processPluginCStr(path) → char*` | C-string variant of processPlugin | -| `loadPlugin(name) → bool` | Load a module (spawns `logos_host` process, sends auth token) | +| `loadPlugin(name) → bool` | Load a module via the selected `ModuleRuntime`, sends auth token | | `loadPluginWithDependencies(name) → bool` | Resolve dependency tree, load in topological order | | `initializeCapabilityModule() → bool` | Load the built-in capability module if available | -| `unloadPlugin(name) → bool` | Terminate module process and update registry | +| `unloadPlugin(name) → bool` | Terminate module via its runtime and update registry | | `unloadPluginWithDependents(name) → bool` | Cascade unload: terminate the named module together with every currently loaded module that transitively depends on it, leaves-first | -| `terminateAll()` | Terminate all running module processes | +| `terminateAll()` | Terminate all running modules across all runtimes | | `clear()` | Clear registry and reset all state | | `resolveDependencies(modules) → std::vector` | Topological sort with circular dependency detection | -| `getDependencies(name, recursive) → std::vector` | Declared dependencies of `name` among known modules; walks the forward graph transitively when `recursive=true`. Cycle- and diamond-safe BFS | -| `getDependents(name, recursive) → std::vector` | Declared dependents of `name` among known modules; walks the reverse graph transitively when `recursive=true`. Reads from the in-process registry, no disk query | +| `getDependencies(name, recursive) → std::vector` | Declared dependencies of `name` among known modules | +| `getDependents(name, recursive) → std::vector` | Declared dependents of `name` among known modules | | `getDependenciesCStr(name, recursive) → char**` | C-string variant backing `logos_core_get_module_dependencies` | | `getDependentsCStr(name, recursive) → char**` | C-string variant backing `logos_core_get_module_dependents` | | `getLoadedPluginsCStr() → char**` | Return loaded module names as null-terminated C string array | | `getKnownPluginsCStr() → char**` | Return known module names as null-terminated C string array | | `isPluginLoaded(name) → bool` | Check if a module is currently loaded | -| `getPluginProcessIds() → std::unordered_map` | Return module name → process ID mappings | +| `getPluginProcessIds() → std::unordered_map` | Return module name → process ID mappings (aggregated from all runtimes) | ### PluginRegistry @@ -122,7 +129,7 @@ logos-liblogos/ **Purpose:** In-memory registry of discovered and loaded modules. Single source of truth for the dependency graph: stores plugin paths, forward dependencies, and the derived reverse edges (dependents). All public methods are thread-safe: mutating methods acquire a `std::unique_lock` on an internal `std::shared_mutex`; read-only methods acquire a `std::shared_lock`, allowing concurrent reads. **Data:** -- `PluginInfo` struct — holds `path`, `dependencies` (`std::vector`), `dependents` (`std::vector`, reverse-edge cache), `loaded` flag +- `PluginInfo` struct — holds `path`, `dependencies`, `dependents` (reverse-edge cache), `loaded` flag, `std::shared_ptr runtime` (runtime that loaded this module, nullptr for externally-marked), `LogosCore::LoadedModuleHandle handle` (runtime-private state) - `std::unordered_map m_plugins` — plugin database keyed by name - `std::vector m_pluginsDirs` — configured plugin directories - `std::shared_mutex m_mutex` — reader-writer lock protecting all fields @@ -146,57 +153,82 @@ logos-liblogos/ | `pluginDependents(name, recursive) → std::vector` | Reverse-edge lookup. `recursive=false` returns direct dependents from `PluginInfo`; `recursive=true` walks the reverse graph breadth-first (cycle/diamond safe) | | `knownPluginNames() → std::vector` | All discovered module names | | `isLoaded(name) → bool` | Plugin is currently running | -| `markLoaded(name)` / `markUnloaded(name)` | Update load state | +| `markLoaded(name)` / `markUnloaded(name)` | Update load state (markLoaded without runtime is for test/external use) | +| `markLoaded(name, runtime, handle)` | Mark as loaded and record which runtime is responsible | +| `runtimeFor(name) → shared_ptr` | Return the runtime that loaded this module (or nullptr) | | `loadedPluginNames() → std::vector` | Currently running module names | | `clearLoaded()` | Clear all loaded state | | `clear()` | Reset entire registry | -### PluginLauncher +### ModuleRuntime (Abstract Interface) -**Files:** `src/logos_core/plugin_launcher.h`, `src/logos_core/plugin_launcher.cpp` +**File:** `src/logos_core/module_runtime.h` -**Purpose:** Spawn and manage module subprocesses. Delegates to the process manager (Boost.Process v2 / Boost.Asio) for subprocess operations. +**Purpose:** Qt-free abstract interface for module loading strategies. Decouples `PluginManager` from any specific module format, isolation mechanism, or transport layer. Each implementation decides *how* a module is loaded, isolated, and communicated with. -**API (namespace `PluginLauncher`):** +**Data structures:** + +- `ModuleDescriptor` — describes a module to load: `name`, `path`, `format` (`"qt-plugin"`, `"wasm"`, etc.), `dependencies`, `instancePersistencePath`, `pluginsDirs`, `rawMetadata` (JSON), `runtimeConfig` (JSON, optional, e.g. `{"id":"docker","image":"..."}`) +- `LoadedModuleHandle` — returned by `load()`: `name`, `pid` (-1 for non-process runtimes), `endpoint` (transport-specific URI), `opaque` (`std::any` for runtime-private state) + +**Interface (`LogosCore::ModuleRuntime`):** | Method | Description | |--------|-------------| -| `launch(name, path, dirs, instancePersistencePath, onTerminated) → bool` | Spawn `logos_host` process for a module | -| `sendToken(name, token) → bool` | Send auth token to module process via stdin | -| `terminate(name)` | Kill a specific module process | -| `terminateAll()` | Kill all module processes | -| `hasProcess(name) → bool` | Check if a process exists for this module | -| `getAllProcessIds() → std::unordered_map` | Map module names to process IDs | +| `id() → std::string` | Unique runtime identifier (e.g. `"qt-subprocess"`, `"inproc"`, `"extism"`) | +| `canHandle(desc) → bool` | Return true if this runtime can load the described module | +| `load(desc, onTerminated, out) → bool` | Load the module; populate `out`, call `onTerminated` when it exits | +| `sendToken(name, token) → bool` | Deliver the auth token to the loaded module | +| `terminate(name)` | Terminate a single module by name | +| `terminateAll()` | Terminate all modules managed by this runtime | +| `hasModule(name) → bool` | True if this runtime has an active entry for the module | +| `pid(name) → optional` | PID of the module (default: nullopt for non-process runtimes) | +| `getAllPids() → unordered_map` | All (name → pid) entries (default: empty) | -### DependencyResolver +### RuntimeRegistry -**Files:** `src/logos_core/dependency_resolver.h`, `src/logos_core/dependency_resolver.cpp` +**Files:** `src/logos_core/runtime_registry.h`, `src/logos_core/runtime_registry.cpp` -**Purpose:** Compute topological sort of module dependencies using Kahn's algorithm. Detects circular dependencies and missing modules. +**Purpose:** Holds all registered `ModuleRuntime` instances and selects the right one for a given `ModuleDescriptor`. Thread-safe (protected by an internal mutex). -**API (namespace `DependencyResolver`):** +**Selection order:** +1. If `desc.runtimeConfig["id"]` is set, return the matching runtime by id (no fallback). +2. Otherwise, return the first registered runtime whose `canHandle(desc)` returns true. +3. Returns `nullptr` if no runtime matches. + +**API (class `LogosCore::RuntimeRegistry`):** | Method | Description | |--------|-------------| -| `resolve(requested, isKnown, getDependencies) → std::vector` | Returns modules in load order (dependencies first) | +| `registerRuntime(runtime)` | Add a runtime; consulted in registration order for `canHandle` | +| `select(desc) → shared_ptr` | Pick the right runtime for a descriptor | +| `terminateAll()` | Fan-out `terminateAll()` to every registered runtime | +| `getAllPids() → unordered_map` | Aggregate `getAllPids()` across all runtimes | +| `clearForTests()` | Remove all runtimes (testing hook for installing fakes) | -Takes callback functions (`IsKnownFn`, `GetDependenciesFn`) so it has no coupling to the registry implementation. +### QtSubprocessRuntime -### Process Manager +**Files:** `src/runtimes/qt_subprocess/qt_subprocess_runtime.h`, `src/runtimes/qt_subprocess/qt_subprocess_runtime.cpp` -**Files:** `src/logos_core/process_manager.h`, `src/logos_core/process_manager.cpp` +**Purpose:** Concrete `ModuleRuntime` implementation for the existing Qt-subprocess strategy. Spawns one `logos_host` process per module, delivers the auth token via a Unix-domain socket, and communicates via Qt Remote Objects. Registered as the default runtime. -**Purpose:** Manages module subprocesses using Boost.Process v2 and Boost.Asio. Replaces the former Qt-based `QProcess` implementation. +**id:** `"qt-subprocess"` -- Uses `boost::process::v2::process` for subprocess spawning and `boost::asio::io_context` for async I/O -- Background `io_context` thread with work guard for non-blocking async read and wait callbacks +**canHandle:** Accepts `format == "qt-plugin"` or empty format. + +### SubprocessManager + +**Files:** `src/runtimes/qt_subprocess/subprocess_manager.h`, `src/runtimes/qt_subprocess/subprocess_manager.cpp` + +**Purpose:** Manages module subprocesses using Boost.Process v2 and Boost.Asio. Used internally by `QtSubprocessRuntime`. + +- Background `io_context` thread with work guard for non-blocking async I/O - Async read loop for stdout/stderr with line buffering - Synchronous kill with graceful SIGTERM → SIGKILL escalation (5s timeout) -- Unix domain socket for token delivery (matches previous `QLocalSocket` behavior) -- A `std::mutex` (`s_processesMutex`) protects the `s_processes` map against concurrent access -- Shared pointer-based lifetime management for safe async callback handling +- Unix domain socket for token delivery +- Thread-safe `s_processesMutex` protects the process map -**API (namespace `QtProcessManager`):** +**API (namespace `SubprocessManager`):** | Method | Description | |--------|-------------| @@ -210,6 +242,24 @@ Takes callback functions (`IsKnownFn`, `GetDependenciesFn`) so it has no couplin | `registerProcess(name)` | Register a placeholder process entry | | `clearAll()` | Clear all process entries | +### LogosHost + +**Files:** `src/runtimes/qt_subprocess/host/main.cpp`, `src/runtimes/qt_subprocess/host/command_line_parser.h/cpp`, `src/runtimes/qt_subprocess/host/plugin_initializer.h/cpp`, `src/runtimes/qt_subprocess/host/qt/qt_app.h/cpp`, `src/runtimes/qt_subprocess/host/qt/qt_token_receiver.h/cpp` + +**Purpose:** Lightweight subprocess that loads a single module. Part of the `qt_subprocess` runtime implementation. Parses `--name`, `--path`, and optional `--instance-persistence-path` arguments, loads the plugin, authenticates via token from the core, registers the module with the remote object registry, and runs the Qt event loop. + +**Files:** `src/logos_core/dependency_resolver.h`, `src/logos_core/dependency_resolver.cpp` + +**Purpose:** Compute topological sort of module dependencies using Kahn's algorithm. Detects circular dependencies and missing modules. + +**API (namespace `DependencyResolver`):** + +| Method | Description | +|--------|-------------| +| `resolve(requested, isKnown, getDependencies) → std::vector` | Returns modules in load order (dependencies first) | + +Takes callback functions (`IsKnownFn`, `GetDependenciesFn`) so it has no coupling to the registry implementation. + ### ProcessStats (external dependency) **Source:** [process-stats](https://github.com/logos-co/process-stats) library (linked as a static dependency) @@ -225,9 +275,9 @@ Takes callback functions (`IsKnownFn`, `GetDependenciesFn`) so it has no couplin ### LogosHost -**Files:** `src/logos_host/logos_host.cpp`, `src/logos_host/command_line_parser.h/cpp`, `src/logos_host/plugin_initializer.h/cpp`, `src/logos_host/qt/qt_app.h/cpp`, `src/logos_host/qt/qt_token_receiver.h/cpp` +**Files:** `src/runtimes/qt_subprocess/host/main.cpp` (and sibling files under `src/runtimes/qt_subprocess/host/`) -**Purpose:** Lightweight subprocess that loads a single module. Parses `--name`, `--path`, and optional `--instance-persistence-path` arguments, loads the plugin, authenticates via token from the core, registers the module with the remote object registry, and runs the Qt event loop. +**Purpose:** Lightweight subprocess that loads a single module. Part of the `qt_subprocess` runtime. Parses `--name`, `--path`, and optional `--instance-persistence-path` arguments, loads the plugin, authenticates via token from the core, registers the module with the remote object registry, and runs the Qt event loop. Lives under `src/runtimes/qt_subprocess/host/` so it is co-located with its runtime implementation. ## C API diff --git a/docs/spec.md b/docs/spec.md index 89722c7..26c5f1a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -43,7 +43,7 @@ At a high level, the Logos Core consists of: ### Process Architecture -Each module runs in its own process for isolation: +Each module runs in its own process for isolation (using the default `qt-subprocess` runtime): ``` ┌─────────────────────────────────────────────────┐ @@ -64,10 +64,10 @@ Each module runs in its own process for isolation: └─────────────────────────────────────────────────┘ ``` -- The core spawns a `logos_host` process per module +- The core spawns a `logos_host` process per module via the `QtSubprocessRuntime` - Communication happens via the Logos API (currently uses Qt Remote Objects over local sockets) - Faulty or untrusted modules cannot crash the core or other modules -- Modules can be written in different languages as long as they implement the RPC protocol +- The `ModuleRuntime` abstraction allows swapping the loading strategy per-module: in-process, WASM (Extism), Docker, gRPC, etc. ### Token-Based Authentication @@ -109,14 +109,12 @@ Every module ships a `metadata.json` referenced by Qt's `Q_PLUGIN_METADATA` macr 1. Core locates the plugin file for the requested module name 2. Core resolves dependencies and loads them first (topological sort with circular dependency detection) -3. If a persistence base path is configured, core resolves an instance ID and persistence directory for the module (reusing an existing instance or creating a new one) -4. Core spawns a `logos_host` process with the plugin path and instance persistence path -5. Core generates a UUID authentication token -6. Core sends the token to the host process via local socket -7. Host process loads the plugin and calls `initLogos(LogosAPI*)` -8. The `LogosAPI` instance exposes `modulePath`, `instanceId`, and `instancePersistencePath` as properties -9. Host process registers the plugin with the remote object registry -10. Core waits for registration and records the module as loaded +3. If a persistence base path is configured, core resolves an instance ID and persistence directory for the module +4. Core builds a `ModuleDescriptor` and selects the appropriate `ModuleRuntime` via `RuntimeRegistry` +5. The selected runtime's `load()` spawns (or otherwise starts) the module and returns a `LoadedModuleHandle` +6. Core generates a UUID authentication token and calls `runtime->sendToken()` +7. For the default `qt-subprocess` runtime: `logos_host` process loads the plugin, calls `initLogos(LogosAPI*)`, and registers with the remote object registry +8. Core records the module as loaded, storing the runtime and handle in `PluginRegistry` #### Unloading @@ -235,5 +233,6 @@ The SDK abstracts away registry lookup, token management, and async invocation. ## Future Work - **Signature support** — Signing and verifying module packages -- **Cross-language modules** — Modules in languages other than C++ -- **Move away from Qt** — Logos API will move away from Qt. Process management has been migrated from Qt (`QProcess`) to Boost.Process v2, and Qt container/utility types (`QString`, `QStringList`, `QHash`, `QDir`, `QFile`, `QUuid`) have been replaced with standard C++ and Boost equivalents (`std::string`, `std::vector`, `std::unordered_map`, `std::filesystem`, `boost::uuids`). Remaining Qt dependencies (event loop, plugin loading, remote objects) should be similarly abstracted from liblogos's perspective. +- **Cross-language modules** — Modules in languages other than C++ (enabled by `ModuleRuntime` abstraction) +- **Alternative runtimes** — In-process loading, Extism/WASM modules, Docker container isolation, gRPC transport (all implementable as new `ModuleRuntime` subclasses) +- **Move away from Qt** — Logos API will move away from Qt. `logos_core`'s internal code (`PluginManager`, `RuntimeRegistry`, `ModuleRuntime`) is already Qt-free. The `qt_subprocess` runtime and `logos_host` encapsulate the remaining Qt dependencies (event loop, plugin loading, Qt Remote Objects). New runtimes can avoid Qt entirely. diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..2d8d305 --- /dev/null +++ b/plan.md @@ -0,0 +1,190 @@ +# Plan: Pluggable Module Runtimes in `logos-liblogos` + +This document describes how to abstract module loading and transport so `logos_core` orchestrates **modules** without hard-coding Qt plugins, subprocess + `logos_host`, or Qt Remote Objects over Unix sockets. + +## 1. What is coupled today + +The core currently hard-wires three concerns together: + + +| Concern | Where | Coupling | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Discovery / metadata** | `PluginRegistry` (`plugin_registry.cpp`) | Uses `ModuleLib::LogosModule::getModuleName` / `getModuleDependencies`, which open the binary with `QPluginLoader` (Qt plugin format is baked in). | +| **Isolation strategy** | `PluginLauncher::launch` → `QtProcessManager::startProcess` | Always spawns a `logos_host` subprocess via Boost.Process. | +| **Transport** | `logos_host` (token receiver, `plugin_initializer` + `LogosAPIProvider`) + token delivery over Unix domain socket | Registry + Qt Remote Objects is implicit everywhere. | +| **Module ABI** | `logos_host` calls `module.as()` and expects a `QObject` with `Q_INVOKABLE` methods | Only Qt/C++ plugins work. | + + +`PluginManager::loadPluginInternal` performs a single linear recipe: generate token → `PluginLauncher::launch` → `sendToken` → `markLoaded` → notify capability module. That recipe should become polymorphic. + +## 2. Proposed architecture: `ModuleRuntime` abstraction + +Introduce a single extension point — a `**ModuleRuntime`** — and reduce `PluginManager` to a registry of runtimes plus orchestrator. Analogy: containerd → runc / kata / gvisor: the **core** decides **what** to load and **when**; the **runtime** decides **how**. + +### 2.1 The interface + +```cpp +// src/logos_core/module_runtime.h (conceptual) +namespace LogosCore { + +struct ModuleDescriptor { + std::string name; + std::string path; // file or directory; meaning depends on runtime + std::string format; // "qt-plugin" | "extism-wasm" | "native-lib" | ... + std::vector dependencies; + std::string instancePersistencePath; + std::vector pluginsDirs; + nlohmann::json rawMetadata; // from manifest.json / embedded metadata + nlohmann::json runtimeConfig; // free-form, e.g. docker image, grpc endpoint +}; + +struct LoadedModuleHandle { + std::string name; + int64_t pid = -1; // -1 when not a process (in-proc, wasm, remote) + std::string endpoint; // transport-specific: unix:///..., grpc://host:port, inproc:// + std::any opaque; // runtime-private state +}; + +class ModuleRuntime { +public: + virtual ~ModuleRuntime() = default; + + // Identity + capabilities + virtual std::string id() const = 0; // "qt-subprocess", "extism", "inproc", "docker" + virtual bool canHandle(const ModuleDescriptor&) const = 0; + + // Lifecycle (must be idempotent per name) + virtual bool load(const ModuleDescriptor&, + std::function onTerminated, + LoadedModuleHandle& out) = 0; + virtual bool sendToken(const std::string& name, const std::string& token) = 0; + virtual void terminate(const std::string& name) = 0; + virtual void terminateAll() = 0; + + // Optional introspection + virtual std::optional pid(const std::string& name) const { return std::nullopt; } + virtual std::string endpoint(const std::string& name) const { return {}; } +}; + +} // namespace LogosCore +``` + +### 2.2 The registry / selector + +```cpp +// src/logos_core/runtime_registry.h (conceptual) +class RuntimeRegistry { +public: + void registerRuntime(std::shared_ptr); + // Selection priority: explicit override in manifest > format match > first canHandle + std::shared_ptr select(const ModuleDescriptor&) const; + + // Load discovery of dynamic runtime plugins (e.g. liblogos-runtime-extism.so) + void loadRuntimePlugin(const std::string& soPath); +}; +``` + +Runtimes can be implemented as shared libraries with a single C entry point: + +```cpp +extern "C" LOGOS_RUNTIME_EXPORT +LogosCore::ModuleRuntime* logos_runtime_create(const LogosCore::RuntimeHostServices*); +``` + +`RuntimeHostServices` gives a runtime back-references it may need (e.g. `LogosAPI` for the `core` identity, `TokenManager`, logging, registry access — everything today’s `notifyCapabilityModule` uses). + +### 2.3 `PluginManager` becomes transport-agnostic + +`loadPluginInternal` collapses to: + +1. Build `ModuleDescriptor` from registry (manifest + embedded metadata merged). +2. `auto runtime = runtimes.select(desc);` — fail if none. +3. `LoadedModuleHandle h;` — `runtime->load(desc, onTerminated, h)`. +4. Generate token; `runtime->sendToken(name, token)` — on failure, `runtime->terminate(name)` and return false. +5. `registry.markLoaded(name, h)` — registry stores handle + owning runtime. +6. `TokenManager::instance().saveToken(name, token);` +7. `notifyCapabilityModule(name, token);` + +`PluginRegistry` keeps both the runtime reference and `LoadedModuleHandle` per loaded module so `unloadPlugin`, `getPluginProcessIds`, and stats routing know who owns what. + +### 2.4 Mapping requested variants to runtimes + + +| Variant | Runtime impl | Notes | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **Status quo** — `logos_host` subprocess + QtRO over Unix socket | `QtSubprocessRuntime` (wraps today’s `PluginLauncher` + `QtProcessManager`) | No behavioral change; default for `format: "qt-plugin"`. | +| **In-process** | `InProcRuntime` — `dlopen` + instantiate `PluginInterface` in the host, register with local `LogosAPIProvider` using **local transport** (SDK already has `implementations/qt_local/local_transport.`*). | `pid = getpid()`, `endpoint = "inproc://"`. Token delivery can short-circuit via `TokenManager` inside `sendToken`. | +| **WASM (Extism)** | `ExtismRuntime` — loads `.wasm`, drives via host functions; shim exposes exports so the rest of the core sees a consistent invocation surface (bridge implementing whatever abstract `LogosObject`-style API you standardize on). | No QObject. Metadata from sidecar `manifest.json`, not only `QPluginLoader`. | +| **Different transport (gRPC)** | `GrpcSubprocessRuntime` — spawns a host binary (could be `logos_host` built with gRPC transport, or language-specific host), reads `endpoint` from stdout or config, registers with SDK transport. | SDK already has `LogosTransport` (`qt_remote`, `qt_local`, `mock`); add `grpc` and wire from runtime. | +| **Docker** | `DockerRuntime` — `docker run` (or libdocker) with image from `runtimeConfig`, mount token socket or expose port; container ID as logical “pid” or map via `docker inspect` for stats. | Same `ModuleRuntime` interface; different `load` implementation. | + + +### 2.5 How a module picks its runtime + +Two mechanisms, layered: + +1. **Per-module override** in `manifest.json` (authoritative example): + ```json + { + "name": "my_module", + "runtime": { + "id": "docker", + "config": { "image": "ghcr.io/me/my_module:1.2.3", "transport": "grpc" } + } + } + ``` +2. **Format sniff** when `runtime` is absent: file extension (`.wasm` → extism, `.so`/`.dylib` with Qt plugin metadata → qt-subprocess), then first runtime whose `canHandle` returns true. + +A **core-wide policy** (env var or `logos_core_set_default_runtime`) can force e.g. “everything in-process” for tests or “everything in docker” in production — expressed as an ordered list of runtime IDs consulted before `canHandle`. + +### 2.6 Where today’s code moves + + +| Today | Tomorrow | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plugin_launcher.{h,cpp}` | Logic moves into `runtimes/qt_subprocess/qt_subprocess_runtime.{h,cpp}` (or equivalent path). | +| `process_manager.{h,cpp}` (`QtProcessManager` namespace) | Rename to something like `SubprocessManager`; keep as shared utility for any runtime that spawns OS processes (qt-subprocess, docker, grpc subprocess). | +| `logos_host/*` | Stays; owned conceptually by the qt-subprocess runtime. Other runtimes ship their own host binary (or none). | +| `plugin_manager.cpp` — `notifyCapabilityModule`, orchestration | Stays in `PluginManager`; no runtime-specific branching beyond `select` + virtual calls. | +| `PluginRegistry` + `ModuleLib::LogosModule::getModuleName` | Pluggable manifest reader and/or Qt metadata as **fallback**; primary source can become `manifest.json` fields including `format` / `runtime`. | + + +### 2.7 Capability module / token flow + +`notifyCapabilityModule` can stay largely as-is: it already uses `LogosAPI` / `LogosAPIClient`, which sit on the SDK’s transport abstraction. As long as each runtime registers the module with the same provider model the capability module expects, inter-module auth keeps working. **The SDK already abstracted transport; `logos-liblogos` is the main place still assuming “subprocess + QtRO”.** + +## 3. Suggested rollout (green at every step) + +1. **Introduce `ModuleRuntime` + `RuntimeRegistry`**, ship `QtSubprocessRuntime` as a thin wrapper around today’s `PluginLauncher` / process manager. Wire `PluginManager` through it only. **No behavior change.** +2. **Teach `PluginRegistry` to read `manifest.json` as primary** (if applicable to your package layout) and keep Qt-plugin metadata as fallback; add `runtime` / `format` fields (ignored until non-default runtimes exist). +3. **Add `InProcRuntime`** behind manifest opt-in or env override — validates second runtime + local transport; good for tests and trusted built-ins. +4. **Rename `QtProcessManager` → `SubprocessManager`** (or similar) and document as shared subprocess utility. +5. **Add `ExtismRuntime`** as optional shared library (`liblogos-runtime-extism.so`) loaded via `RuntimeRegistry::loadRuntimePlugin` — keeps wasm/extism deps out of core. +6. **Add `GrpcSubprocessRuntime` + gRPC `LogosTransport` in logos-cpp-sdk** — proves transport swap end-to-end. +7. **Add `DockerRuntime`** once subprocess + optional SO loading paths are stable. + +## 4. Risks and design notes + +- **Token exchange is runtime-specific** (Unix socket today in `qt_token_receiver` / `QtProcessManager::sendToken`). Each runtime owns end-to-end token delivery; avoid forcing one mechanism on all. +- **Stats** (`ProcessStats::getModuleStats`) assumes PIDs. Widen to module handles: `(pid-or-0, endpoint, runtime_id)` so UI/ops can show in-process, container ID, etc. +- **Event loop.** Qt subprocess runtime relies on Qt in `logos_host`. In-proc needs a Qt event loop in the host process where applicable. The runtime contract should state which runtimes require a loop and whether the host must pump it. +- `**logos_core_exec()` is currently a no-op** in this repo — acceptable; runtimes that need a loop either use the embedding app’s loop or an internal thread. Avoid leaking “must pump events” into the C API unless necessary. +- **Keep `ModuleRuntime` headers Qt-free** so optional runtimes do not pull Qt into the abstraction layer. +- **Discovery namespace:** multiple formats can collide on `name`. Treat manifest `name` as authoritative; on-disk artifact is opaque to the selected runtime. + +## 5. Minimal first commit (concrete starting point) + +Zero behavior change, three conceptual additions: + +1. `src/logos_core/module_runtime.h` — interface + `ModuleDescriptor` / `LoadedModuleHandle`. +2. `src/logos_core/runtime_registry.{h,cpp}` — register runtimes, `select(descriptor)`. +3. `src/logos_core/runtimes/qt_subprocess_runtime.{h,cpp}` — move current `PluginLauncher` logic here; `id()` returns `"qt-subprocess"`; `canHandle` true for `format == "qt-plugin"` or empty format (default). + +Then change `plugin_manager.cpp` to call `runtime->load` / `sendToken` / `terminate` instead of `PluginLauncher::`* directly. Downstream (`logos_host`, `PluginRegistry`, C API, tests) unchanged. Every further runtime is additive. + +## 6. Goals recap + +- `**logos_core` abstracts** discovery, load order, tokens, capability notification, unload — not **how** a module is hosted. +- **Extensions** (WASM, in-proc, gRPC, Docker) ship as runtimes (in-tree or `.so`), selected by manifest and/or policy. +- **Reuse** existing SDK transport split (`qt_remote`, `qt_local`, mock) when adding gRPC or other transports instead of forking the whole stack. + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ec5c21b..61b2df4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -96,23 +96,26 @@ set(LOGOS_CORE_SOURCES logos_core/dependency_resolver.h logos_core/plugin_manager.cpp logos_core/plugin_manager.h - logos_core/plugin_launcher.cpp - logos_core/plugin_launcher.h - logos_core/process_manager.cpp - logos_core/process_manager.h + logos_core/module_runtime.h + logos_core/runtime_registry.cpp + logos_core/runtime_registry.h + runtimes/qt_subprocess/qt_subprocess_runtime.cpp + runtimes/qt_subprocess/qt_subprocess_runtime.h + runtimes/qt_subprocess/subprocess_manager.cpp + runtimes/qt_subprocess/subprocess_manager.h ) # Define the logos host sources set(LOGOS_HOST_SOURCES - logos_host/logos_host.cpp - logos_host/command_line_parser.cpp - logos_host/command_line_parser.h - logos_host/plugin_initializer.cpp - logos_host/plugin_initializer.h - logos_host/qt/qt_app.cpp - logos_host/qt/qt_app.h - logos_host/qt/qt_token_receiver.cpp - logos_host/qt/qt_token_receiver.h + runtimes/qt_subprocess/host/main.cpp + runtimes/qt_subprocess/host/command_line_parser.cpp + runtimes/qt_subprocess/host/command_line_parser.h + runtimes/qt_subprocess/host/plugin_initializer.cpp + runtimes/qt_subprocess/host/plugin_initializer.h + runtimes/qt_subprocess/host/qt/qt_app.cpp + runtimes/qt_subprocess/host/qt/qt_app.h + runtimes/qt_subprocess/host/qt/qt_token_receiver.cpp + runtimes/qt_subprocess/host/qt/qt_token_receiver.h ) # Create the logos core library @@ -170,6 +173,7 @@ target_link_libraries(logos_core PUBLIC target_include_directories(logos_core PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/logos_core + ${CMAKE_CURRENT_SOURCE_DIR}/runtimes ${Qt${QT_VERSION_MAJOR}_INCLUDE_DIRS} ) @@ -234,7 +238,7 @@ target_link_libraries(logos_host PRIVATE # Include directories for the logos host application target_include_directories(logos_host PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_CURRENT_SOURCE_DIR}/logos_host + ${CMAKE_CURRENT_SOURCE_DIR}/runtimes/qt_subprocess/host ${Qt${QT_VERSION_MAJOR}_INCLUDE_DIRS} ) diff --git a/src/logos_core/logos_core.cpp b/src/logos_core/logos_core.cpp index 4cfbda4..dd198c8 100644 --- a/src/logos_core/logos_core.cpp +++ b/src/logos_core/logos_core.cpp @@ -84,7 +84,7 @@ char* logos_core_process_plugin(const char* plugin_path) { char* logos_core_get_token(const char* key) { if (!key) { fprintf(stderr, "logos_core_get_token: key must not be null\n"); std::abort(); } - std::string token = TokenManager::instance().getToken(std::string(key)); + std::string token = TokenManager::instance().getToken(key).toStdString(); if (token.empty()) return nullptr; char* result = new char[token.size() + 1]; diff --git a/src/logos_core/module_runtime.h b/src/logos_core/module_runtime.h new file mode 100644 index 0000000..8864054 --- /dev/null +++ b/src/logos_core/module_runtime.h @@ -0,0 +1,78 @@ +#ifndef MODULE_RUNTIME_H +#define MODULE_RUNTIME_H + +#include +#include +#include +#include +#include +#include +#include +#include + +// Qt-free abstract interface for module loading strategies. +// An implementation decides *how* a module is loaded, isolated, and communicated with. +// The core (PluginManager) decides *what* to load and *when*. + +namespace LogosCore { + +// Describes a module that the core wants to load. Passed to ModuleRuntime::load(). +struct ModuleDescriptor { + std::string name; + std::string path; // path to the module binary/bundle/wasm/etc. + std::string format; // "qt-plugin", "wasm", "" (empty = default) + std::vector dependencies; + std::string instancePersistencePath; // empty if not configured + std::vector pluginsDirs; // directories siblings are looked up in + nlohmann::json rawMetadata; // metadata parsed from manifest.json + nlohmann::json runtimeConfig; // optional: {"id":"docker","image":"..."}, etc. +}; + +// A handle to a successfully loaded module. Stored in PluginRegistry (PluginInfo). +struct LoadedModuleHandle { + std::string name; + int64_t pid = -1; // -1 when not process-based (in-proc, wasm, remote, etc.) + std::string endpoint; // transport-specific URI, e.g. "qtro+unix://my_module" + std::any opaque; // runtime-private state (optional) +}; + +// Abstract base: one instance per runtime kind, shared across all modules it manages. +// All implementations must be Qt-free at the interface level. +class ModuleRuntime { +public: + virtual ~ModuleRuntime() = default; + + // Unique identifier for this runtime (e.g. "qt-subprocess", "inproc", "extism"). + virtual std::string id() const = 0; + + // Return true if this runtime knows how to load the described module. + virtual bool canHandle(const ModuleDescriptor& desc) const = 0; + + // Load the module. On success, populate `out` and return true. + // `onTerminated` may be called from a background thread when the module exits. + virtual bool load(const ModuleDescriptor& desc, + std::function onTerminated, + LoadedModuleHandle& out) = 0; + + // Deliver the auth token to the named module. Called immediately after a successful load(). + virtual bool sendToken(const std::string& name, const std::string& token) = 0; + + // Terminate a single module by name. + virtual void terminate(const std::string& name) = 0; + + // Terminate all modules managed by this runtime. + virtual void terminateAll() = 0; + + // Return true if this runtime currently has an active entry for the named module. + virtual bool hasModule(const std::string& name) const = 0; + + // Return the PID of the named module, or nullopt if not process-based. + virtual std::optional pid(const std::string& /*name*/) const { return std::nullopt; } + + // Return all (name -> pid) mappings. PIDs are -1 for non-process runtimes. + virtual std::unordered_map getAllPids() const { return {}; } +}; + +} // namespace LogosCore + +#endif // MODULE_RUNTIME_H diff --git a/src/logos_core/plugin_launcher.cpp b/src/logos_core/plugin_launcher.cpp deleted file mode 100644 index f7332f9..0000000 --- a/src/logos_core/plugin_launcher.cpp +++ /dev/null @@ -1,123 +0,0 @@ -#include "plugin_launcher.h" -#include "process_manager.h" -#include -#include -#include -#include - -namespace fs = std::filesystem; - -namespace { - - std::string resolveLogosHostPath(const std::vector& pluginsDirs) { - std::string logosHostPath; - - const char* envPath = std::getenv("LOGOS_HOST_PATH"); - if (envPath) { - logosHostPath = envPath; - } - - if (logosHostPath.empty()) { - auto appDir = boost::dll::program_location().parent_path(); - auto normalized = (appDir / "logos_host").lexically_normal(); - logosHostPath = normalized.string(); - } - - if (!fs::exists(logosHostPath)) { - if (!pluginsDirs.empty()) { - auto candidatePath = fs::absolute( - fs::path(pluginsDirs.front()) / ".." / "bin" / "logos_host" - ).lexically_normal(); - if (fs::exists(candidatePath)) { - logosHostPath = candidatePath.string(); - } - } - } - - if (!fs::exists(logosHostPath)) { - spdlog::critical("logos_host not found at: {} - set LOGOS_HOST_PATH or place it next to the executable", - logosHostPath); - return {}; - } - - return logosHostPath; - } - -} - -namespace PluginLauncher { - - bool launch(const std::string& name, const std::string& pluginPath, - const std::vector& pluginsDirs, - const std::string& instancePersistencePath, - OnTerminatedFn onTerminated) { - std::string logosHostPath = resolveLogosHostPath(pluginsDirs); - if (logosHostPath.empty()) - return false; - - std::vector arguments = { - "--name", name, - "--path", pluginPath - }; - - if (!instancePersistencePath.empty()) { - arguments.push_back("--instance-persistence-path"); - arguments.push_back(instancePersistencePath); - } - - QtProcessManager::ProcessCallbacks callbacks; - - callbacks.onFinished = [onTerminated](const std::string& pName, int exitCode, bool crashed) { - (void)exitCode; - if (crashed) { - spdlog::critical("Plugin process crashed: {}", pName); - exit(1); - } - if (onTerminated) - onTerminated(pName); - }; - - callbacks.onError = [](const std::string& pName, bool crashed) { - if (crashed) { - spdlog::critical("Plugin process crashed: {}", pName); - exit(1); - } - }; - - callbacks.onOutput = [](const std::string& pName, const std::string& line, bool isStderr) { - if (isStderr) { - spdlog::critical("[{}] {}", pName, line); - } else if (line.find("Warning:") != std::string::npos || - line.find("WARNING:") != std::string::npos) { - spdlog::warn("[{}] {}", pName, line); - } else if (line.find("Critical:") != std::string::npos || - line.find("FAILED:") != std::string::npos || - line.find("ERROR:") != std::string::npos) { - spdlog::critical("[{}] {}", pName, line); - } - }; - - return QtProcessManager::startProcess(name, logosHostPath, arguments, callbacks); - } - - bool sendToken(const std::string& name, const std::string& token) { - return QtProcessManager::sendToken(name, token); - } - - void terminate(const std::string& name) { - QtProcessManager::terminateProcess(name); - } - - void terminateAll() { - QtProcessManager::terminateAll(); - } - - bool hasProcess(const std::string& name) { - return QtProcessManager::hasProcess(name); - } - - std::unordered_map getAllProcessIds() { - return QtProcessManager::getAllProcessIds(); - } - -} diff --git a/src/logos_core/plugin_launcher.h b/src/logos_core/plugin_launcher.h deleted file mode 100644 index fc46eb0..0000000 --- a/src/logos_core/plugin_launcher.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef PLUGIN_LAUNCHER_H -#define PLUGIN_LAUNCHER_H - -#include -#include -#include -#include -#include - -namespace PluginLauncher { - using OnTerminatedFn = std::function; - - bool launch(const std::string& name, const std::string& pluginPath, - const std::vector& pluginsDirs, - const std::string& instancePersistencePath, - OnTerminatedFn onTerminated); - bool sendToken(const std::string& name, const std::string& token); - void terminate(const std::string& name); - void terminateAll(); - bool hasProcess(const std::string& name); - std::unordered_map getAllProcessIds(); -} - -#endif // PLUGIN_LAUNCHER_H diff --git a/src/logos_core/plugin_manager.cpp b/src/logos_core/plugin_manager.cpp index f9e6ad6..99aff8a 100644 --- a/src/logos_core/plugin_manager.cpp +++ b/src/logos_core/plugin_manager.cpp @@ -1,8 +1,11 @@ #include "plugin_manager.h" #include "plugin_registry.h" #include "dependency_resolver.h" -#include "plugin_launcher.h" +#include "runtime_registry.h" +#include "runtimes/qt_subprocess/qt_subprocess_runtime.h" +#include "runtimes/qt_subprocess/subprocess_manager.h" #include +#include #include #include #include @@ -31,6 +34,17 @@ namespace { return path; } + // Lazily initialised RuntimeRegistry. On first call, registers the default + // QtSubprocessRuntime. clearForTests() can replace it for unit tests. + LogosCore::RuntimeRegistry& runtimeRegistry() { + static LogosCore::RuntimeRegistry reg; + static std::once_flag initFlag; + std::call_once(initFlag, []() { + reg.registerRuntime(std::make_shared()); + }); + return reg; + } + char** toNullTerminatedArray(const std::vector& list) { int count = static_cast(list.size()); if (count == 0) { @@ -53,14 +67,14 @@ namespace { return; TokenManager& tokenManager = TokenManager::instance(); - std::string capabilityModuleToken = tokenManager.getToken(std::string("capability_module")); + std::string capabilityModuleToken = tokenManager.getToken("capability_module").toStdString(); static LogosAPI* s_coreApi = nullptr; if (!s_coreApi) - s_coreApi = new LogosAPI(std::string("core")); + s_coreApi = new LogosAPI("core"); - LogosAPIClient* client = s_coreApi->getClient(std::string("capability_module")); - if (!client->informModuleToken(capabilityModuleToken, name, token)) { + LogosAPIClient* client = s_coreApi->getClient("capability_module"); + if (!client->informModuleToken(capabilityModuleToken.c_str(), name.c_str(), token.c_str())) { spdlog::warn("Failed to register token with capability module for: {}", name); } } @@ -80,31 +94,45 @@ namespace { std::string pluginPath = registryInstance().pluginPath(name); - // Resolve instance persistence path if a base path has been configured - std::string instancePersistencePath; + // Build a descriptor for the runtime to inspect. + LogosCore::ModuleDescriptor desc; + desc.name = name; + desc.path = pluginPath; + desc.format = "qt-plugin"; + desc.dependencies = registryInstance().pluginDependencies(name); + desc.pluginsDirs = registryInstance().pluginsDirs(); + if (!persistenceBasePath().empty()) { auto info = ModuleLib::InstancePersistence::resolveInstance( - persistenceBasePath(), - name); - instancePersistencePath = info.persistencePath; + QString::fromStdString(persistenceBasePath()), + QString::fromStdString(name)); + desc.instancePersistencePath = info.persistencePath.toStdString(); + } + + auto rt = runtimeRegistry().select(desc); + if (!rt) { + spdlog::warn("No runtime available to load plugin: {}", name); + return false; } auto onTerminated = [](const std::string& n) { registryInstance().markUnloaded(n); }; - if (!PluginLauncher::launch(name, pluginPath, registryInstance().pluginsDirs(), - instancePersistencePath, onTerminated)) + LogosCore::LoadedModuleHandle handle; + if (!rt->load(desc, onTerminated, handle)) return false; std::string authToken = boost::uuids::to_string(boost::uuids::random_generator()()); - if (!PluginLauncher::sendToken(name, authToken)) + if (!rt->sendToken(name, authToken)) { + rt->terminate(name); return false; + } - registryInstance().markLoaded(name); + registryInstance().markLoaded(name, rt, std::move(handle)); - TokenManager::instance().saveToken(name, authToken); + TokenManager::instance().saveToken(name.c_str(), authToken.c_str()); notifyCapabilityModule(name, authToken); @@ -122,12 +150,23 @@ namespace { return false; } - if (!PluginLauncher::hasProcess(name)) { - spdlog::warn("No process found for plugin: {}", name); - return false; + auto rt = registryInstance().runtimeFor(name); + if (rt) { + if (!rt->hasModule(name)) { + spdlog::warn("No module entry found for plugin: {}", name); + return false; + } + rt->terminate(name); + } else { + // Fallback: module was loaded via markLoaded(name) directly + // (test scenarios or external setup). Use SubprocessManager directly. + if (!SubprocessManager::hasProcess(name)) { + spdlog::warn("No process found for plugin: {}", name); + return false; + } + SubprocessManager::terminateProcess(name); } - PluginLauncher::terminate(name); registryInstance().markUnloaded(name); spdlog::info("Plugin unloaded: {}", name); @@ -141,6 +180,10 @@ namespace PluginManager { return registryInstance(); } + LogosCore::RuntimeRegistry& runtimes() { + return runtimeRegistry(); + } + void setPluginsDir(const char* plugins_dir) { assert(plugins_dir != nullptr); registryInstance().setPluginsDir(std::string(plugins_dir)); @@ -282,6 +325,7 @@ namespace PluginManager { if (teardownSetMembers.count(*it) && teardownOrderMembers.insert(*it).second) teardownOrder.push_back(*it); } + // Safety net: any members not seen by the resolver (shouldn't happen, // but don't silently skip them) go to the end. for (const std::string& n : teardownSet) { @@ -303,13 +347,13 @@ namespace PluginManager { void terminateAll() { std::lock_guard lock(loadMutex()); - PluginLauncher::terminateAll(); + runtimeRegistry().terminateAll(); registryInstance().clearLoaded(); } void clear() { std::lock_guard lock(loadMutex()); - PluginLauncher::terminateAll(); + runtimeRegistry().terminateAll(); registryInstance().clear(); } @@ -330,7 +374,7 @@ namespace PluginManager { } std::unordered_map getPluginProcessIds() { - return PluginLauncher::getAllProcessIds(); + return runtimeRegistry().getAllPids(); } std::vector resolveDependencies(const std::vector& requestedModules) { @@ -343,10 +387,6 @@ namespace PluginManager { std::vector getDependencies(const std::string& name, bool recursive) { // Filter to known modules to honour the documented contract. - // PluginInfo::dependencies holds whatever the manifest declares, - // including names that aren't installed. The reverse-edge accessor - // doesn't need this treatment because recomputeDependentsLocked only - // writes known names into PluginInfo::dependents by construction. std::vector deps = registryInstance().pluginDependencies(name, recursive); std::vector knownDeps; knownDeps.reserve(deps.size()); diff --git a/src/logos_core/plugin_manager.h b/src/logos_core/plugin_manager.h index 185bbfb..1d617d7 100644 --- a/src/logos_core/plugin_manager.h +++ b/src/logos_core/plugin_manager.h @@ -5,12 +5,16 @@ #include #include #include +#include "runtime_registry.h" class PluginRegistry; namespace PluginManager { PluginRegistry& registry(); + // Access the runtime registry (e.g. for tests that need to install a FakeRuntime). + LogosCore::RuntimeRegistry& runtimes(); + void setPluginsDir(const char* plugins_dir); void addPluginsDir(const char* plugins_dir); void setPersistenceBasePath(const char* path); diff --git a/src/logos_core/plugin_registry.cpp b/src/logos_core/plugin_registry.cpp index bf22752..c1537bb 100644 --- a/src/logos_core/plugin_registry.cpp +++ b/src/logos_core/plugin_registry.cpp @@ -2,12 +2,87 @@ #include #include #include +#include #include #include #include #include #include #include +#if __has_include() +#include +#endif + +// --------------------------------------------------------------------------- +// Compatibility shim for two API generations of PackageManagerLib: +// +// Old (logos-package-manager ≤ some version): +// std::string getInstalledModules() — returns a JSON array string +// +// New (logos-package-manager with InstalledPackage struct): +// std::vector getInstalledModules() +// +// We use a template + overload strategy so neither branch needs to name the +// type that the *other* API version doesn't define. The compiler instantiates +// only the overload that matches the actual return type; the other overload is +// parsed but never instantiated, so undefined types in its body don't matter. +// --------------------------------------------------------------------------- + +// Overload for the old JSON-string API. +static std::unordered_set +extractScannedNames(const std::string& jsonModules, + std::function processPlugin) +{ + std::unordered_set scannedNames; + // Avoid pulling in nlohmann/json here — do a minimal manual parse that + // is robust enough for the well-structured JSON produced by the library. + // Each module object has at least {"name":..., "mainFilePath":...}. + try { + // Use nlohmann/json if available (it is — logos_core links it). +#if __has_include() + auto arr = nlohmann::json::parse(jsonModules, nullptr, /*exceptions=*/false); + if (arr.is_array()) { + for (const auto& obj : arr) { + std::string mainPath; + if (obj.contains("mainFilePath") && obj["mainFilePath"].is_string()) + mainPath = obj["mainFilePath"].get(); + if (mainPath.empty()) continue; + std::string pluginName = processPlugin(mainPath); + if (pluginName.empty()) { + spdlog::warn("Failed to process plugin: {}", mainPath); + continue; + } + scannedNames.insert(pluginName); + } + } +#endif + } catch (...) { + spdlog::warn("Failed to parse installed modules JSON"); + } + return scannedNames; +} + +// Overload for the new InstalledPackage vector API. +// `T` is deduced as `InstalledPackage`; if that type is not defined, +// this template is simply never instantiated. +template +static std::unordered_set +extractScannedNames(const std::vector& modules, + std::function processPlugin) +{ + std::unordered_set scannedNames; + for (const auto& mod : modules) { + if (mod.name.empty() || mod.mainFilePath.empty()) + continue; + std::string pluginName = processPlugin(mod.mainFilePath); + if (pluginName.empty()) { + spdlog::warn("Failed to process plugin: {}", mod.mainFilePath); + continue; + } + scannedNames.insert(pluginName); + } + return scannedNames; +} static PackageManagerLib& packageManagerInstance() { static PackageManagerLib instance; @@ -43,7 +118,10 @@ void PluginRegistry::discoverInstalledModules() { } } - std::vector modules = pm.getInstalledModules(); + auto modules = pm.getInstalledModules(); + + std::function processPlugin = + [this](const std::string& path) { return processPluginInternal(path); }; // Collect names seen in this scan. Used after the upsert loop to prune // entries for plugins whose files disappeared (typical path: the user @@ -51,22 +129,8 @@ void PluginRegistry::discoverInstalledModules() { // the stale PluginInfo would stay in m_plugins forever and // knownPluginNames()/`logos_core_get_known_plugins` would keep returning // it, so the UI would never see the uninstall land. - std::unordered_set scannedNames; - - for (const InstalledPackage& mod : modules) { - if (mod.name.empty() || mod.mainFilePath.empty()) - continue; - - std::string pluginName = processPluginInternal(mod.mainFilePath); - if (pluginName.empty()) { - // Skip entries with no extractable metadata — a bare - // `scannedNames.insert` would record an empty string and the - // prune loop would mis-identify still-present plugins as gone. - spdlog::warn("Failed to process plugin: {}", mod.mainFilePath); - continue; - } - scannedNames.insert(pluginName); - } + std::unordered_set scannedNames = + extractScannedNames(modules, processPlugin); // Prune entries that aren't on disk anymore. Preserve currently-loaded // plugins even if their backing files are gone — the module is still @@ -271,6 +335,24 @@ void PluginRegistry::markLoaded(const std::string& name) { m_plugins[name].loaded = true; } +void PluginRegistry::markLoaded(const std::string& name, + std::shared_ptr runtime, + LogosCore::LoadedModuleHandle handle) { + std::unique_lock lock(m_mutex); + auto& info = m_plugins[name]; + info.loaded = true; + info.runtime = std::move(runtime); + info.handle = std::move(handle); +} + +std::shared_ptr +PluginRegistry::runtimeFor(const std::string& name) const { + std::shared_lock lock(m_mutex); + auto it = m_plugins.find(name); + if (it == m_plugins.end()) return nullptr; + return it->second.runtime; +} + void PluginRegistry::markUnloaded(const std::string& name) { std::unique_lock lock(m_mutex); auto it = m_plugins.find(name); diff --git a/src/logos_core/plugin_registry.h b/src/logos_core/plugin_registry.h index 87ccfc5..4cfd63c 100644 --- a/src/logos_core/plugin_registry.h +++ b/src/logos_core/plugin_registry.h @@ -1,10 +1,13 @@ #ifndef PLUGIN_REGISTRY_H #define PLUGIN_REGISTRY_H +#include #include +#include #include #include #include +#include "module_runtime.h" struct PluginInfo { std::string path; @@ -15,6 +18,10 @@ struct PluginInfo { // directly. Use PluginRegistry::pluginDependents() for transitive walks. std::vector dependents; bool loaded = false; + // Owning runtime, set when a module is loaded via loadPluginInternal(). + // Null when loaded directly via markLoaded(name) (test/external scenarios). + std::shared_ptr runtime; + LogosCore::LoadedModuleHandle handle; }; class PluginRegistry { @@ -46,11 +53,25 @@ public: void registerDependencies(const std::string& name, const std::vector& dependencies); bool isLoaded(const std::string& name) const; + + // Simple mark-as-loaded (no runtime association). Used by tests and + // external callers that set up state directly without going through loadPlugin. void markLoaded(const std::string& name); + + // Full mark-as-loaded that stores the owning runtime and handle for later + // use by unloadPlugin(). Should be called from loadPluginInternal(). + void markLoaded(const std::string& name, + std::shared_ptr runtime, + LogosCore::LoadedModuleHandle handle); + void markUnloaded(const std::string& name); std::vector loadedPluginNames() const; void clearLoaded(); + // Returns the runtime that owns the named module, or nullptr if it was + // loaded without a runtime association (e.g. via markLoaded(name) only). + std::shared_ptr runtimeFor(const std::string& name) const; + void clear(); private: diff --git a/src/logos_core/runtime_registry.cpp b/src/logos_core/runtime_registry.cpp new file mode 100644 index 0000000..b302c99 --- /dev/null +++ b/src/logos_core/runtime_registry.cpp @@ -0,0 +1,58 @@ +#include "runtime_registry.h" + +namespace LogosCore { + +void RuntimeRegistry::registerRuntime(std::shared_ptr runtime) +{ + std::lock_guard lock(m_mutex); + m_runtimes.push_back(std::move(runtime)); +} + +std::shared_ptr RuntimeRegistry::select(const ModuleDescriptor& desc) const +{ + std::lock_guard lock(m_mutex); + + // Explicit id override: caller pinned a specific runtime id. + if (desc.runtimeConfig.contains("id")) { + std::string requestedId = desc.runtimeConfig.at("id").get(); + for (const auto& rt : m_runtimes) { + if (rt->id() == requestedId) + return rt; + } + // Unknown explicit id — don't fall through to canHandle. + return nullptr; + } + + // Format/capability-based: first runtime that accepts this descriptor. + for (const auto& rt : m_runtimes) { + if (rt->canHandle(desc)) + return rt; + } + return nullptr; +} + +void RuntimeRegistry::terminateAll() +{ + std::lock_guard lock(m_mutex); + for (const auto& rt : m_runtimes) + rt->terminateAll(); +} + +std::unordered_map RuntimeRegistry::getAllPids() const +{ + std::lock_guard lock(m_mutex); + std::unordered_map result; + for (const auto& rt : m_runtimes) { + auto pids = rt->getAllPids(); + result.insert(pids.begin(), pids.end()); + } + return result; +} + +void RuntimeRegistry::clearForTests() +{ + std::lock_guard lock(m_mutex); + m_runtimes.clear(); +} + +} // namespace LogosCore diff --git a/src/logos_core/runtime_registry.h b/src/logos_core/runtime_registry.h new file mode 100644 index 0000000..8294e88 --- /dev/null +++ b/src/logos_core/runtime_registry.h @@ -0,0 +1,46 @@ +#ifndef RUNTIME_REGISTRY_H +#define RUNTIME_REGISTRY_H + +#include "module_runtime.h" +#include +#include +#include +#include + +namespace LogosCore { + +// Holds all registered ModuleRuntime instances and selects one for a given descriptor. +// Thread-safe (all operations protected by an internal mutex). +class RuntimeRegistry { +public: + // Register a runtime. Runtimes are consulted in registration order when + // no explicit runtimeConfig["id"] is present. + void registerRuntime(std::shared_ptr runtime); + + // Select a runtime for the descriptor. + // Selection order: + // 1. If desc.runtimeConfig["id"] is set, return the matching runtime by id. + // Returns nullptr if the id is unknown (does not fall through to canHandle). + // 2. Otherwise, return the first registered runtime whose canHandle(desc) is true. + // 3. Returns nullptr if no runtime matches. + std::shared_ptr select(const ModuleDescriptor& desc) const; + + // Fan-out terminateAll() to every registered runtime. + void terminateAll(); + + // Aggregate getAllPids() across all runtimes. Later-registered runtimes win on + // name collision (should not happen in practice). + std::unordered_map getAllPids() const; + + // Testing hook: remove all runtimes so a test can install a FakeRuntime + // without triggering any real Qt subprocess side effects. + void clearForTests(); + +private: + mutable std::mutex m_mutex; + std::vector> m_runtimes; +}; + +} // namespace LogosCore + +#endif // RUNTIME_REGISTRY_H diff --git a/src/logos_host/command_line_parser.cpp b/src/runtimes/qt_subprocess/host/command_line_parser.cpp similarity index 100% rename from src/logos_host/command_line_parser.cpp rename to src/runtimes/qt_subprocess/host/command_line_parser.cpp diff --git a/src/logos_host/command_line_parser.h b/src/runtimes/qt_subprocess/host/command_line_parser.h similarity index 100% rename from src/logos_host/command_line_parser.h rename to src/runtimes/qt_subprocess/host/command_line_parser.h diff --git a/src/logos_host/logos_host.cpp b/src/runtimes/qt_subprocess/host/main.cpp similarity index 100% rename from src/logos_host/logos_host.cpp rename to src/runtimes/qt_subprocess/host/main.cpp diff --git a/src/logos_host/plugin_initializer.cpp b/src/runtimes/qt_subprocess/host/plugin_initializer.cpp similarity index 77% rename from src/logos_host/plugin_initializer.cpp rename to src/runtimes/qt_subprocess/host/plugin_initializer.cpp index a221e41..7d4b5fe 100644 --- a/src/logos_host/plugin_initializer.cpp +++ b/src/runtimes/qt_subprocess/host/plugin_initializer.cpp @@ -1,6 +1,8 @@ #include "plugin_initializer.h" #include "qt/qt_token_receiver.h" #include +#include +#include #include #include #include "interface.h" @@ -15,12 +17,11 @@ using namespace ModuleLib; LogosModule loadPlugin(const std::string& pluginPath, const std::string& expectedName) { - // Load the plugin using module_lib for abstraction - std::string errorString; - LogosModule module = LogosModule::loadFromPath(pluginPath, &errorString); + QString errorStringQ; + LogosModule module = LogosModule::loadFromPath(QString::fromStdString(pluginPath), &errorStringQ); if (!module.isValid()) { - spdlog::critical("Failed to load plugin: {}", errorString); + spdlog::critical("Failed to load plugin: {}", errorStringQ.toStdString()); return LogosModule(); } @@ -43,20 +44,21 @@ LogosAPI* initializeLogosAPI(const std::string& pluginName, QObject* plugin, const std::string& pluginPath, const std::string& instancePersistencePath) { - LogosAPI* logos_api = new LogosAPI(pluginName, plugin); + LogosAPI* logos_api = new LogosAPI(QString::fromStdString(pluginName), plugin); logos_api->setProperty("modulePath", - fs::absolute(fs::path(pluginPath)).parent_path().string()); + QVariant(QString::fromStdString(fs::absolute(fs::path(pluginPath)).parent_path().string()))); if (!instancePersistencePath.empty()) { - logos_api->setProperty("instancePersistencePath", instancePersistencePath); + logos_api->setProperty("instancePersistencePath", + QVariant(QString::fromStdString(instancePersistencePath))); logos_api->setProperty("instanceId", - fs::path(instancePersistencePath).filename().string()); + QVariant(QString::fromStdString(fs::path(instancePersistencePath).filename().string()))); } bool success = logos_api->getProvider()->registerObject(basePlugin->name(), plugin); if (success) { - logos_api->getTokenManager()->saveToken(std::string("core"), authToken); - logos_api->getTokenManager()->saveToken(std::string("capability_module"), authToken); + logos_api->getTokenManager()->saveToken("core", authToken.c_str()); + logos_api->getTokenManager()->saveToken("capability_module", authToken.c_str()); } else { spdlog::critical("Failed to register plugin for remote access: {}", basePlugin->name().toStdString()); delete plugin; diff --git a/src/logos_host/plugin_initializer.h b/src/runtimes/qt_subprocess/host/plugin_initializer.h similarity index 100% rename from src/logos_host/plugin_initializer.h rename to src/runtimes/qt_subprocess/host/plugin_initializer.h diff --git a/src/logos_host/qt/qt_app.cpp b/src/runtimes/qt_subprocess/host/qt/qt_app.cpp similarity index 100% rename from src/logos_host/qt/qt_app.cpp rename to src/runtimes/qt_subprocess/host/qt/qt_app.cpp diff --git a/src/logos_host/qt/qt_app.h b/src/runtimes/qt_subprocess/host/qt/qt_app.h similarity index 100% rename from src/logos_host/qt/qt_app.h rename to src/runtimes/qt_subprocess/host/qt/qt_app.h diff --git a/src/logos_host/qt/qt_token_receiver.cpp b/src/runtimes/qt_subprocess/host/qt/qt_token_receiver.cpp similarity index 100% rename from src/logos_host/qt/qt_token_receiver.cpp rename to src/runtimes/qt_subprocess/host/qt/qt_token_receiver.cpp diff --git a/src/logos_host/qt/qt_token_receiver.h b/src/runtimes/qt_subprocess/host/qt/qt_token_receiver.h similarity index 100% rename from src/logos_host/qt/qt_token_receiver.h rename to src/runtimes/qt_subprocess/host/qt/qt_token_receiver.h diff --git a/src/runtimes/qt_subprocess/qt_subprocess_runtime.cpp b/src/runtimes/qt_subprocess/qt_subprocess_runtime.cpp new file mode 100644 index 0000000..aff2ca8 --- /dev/null +++ b/src/runtimes/qt_subprocess/qt_subprocess_runtime.cpp @@ -0,0 +1,145 @@ +#include "qt_subprocess_runtime.h" +#include "subprocess_manager.h" +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::string resolveLogosHostPath(const std::vector& pluginsDirs) { + std::string logosHostPath; + + const char* envPath = std::getenv("LOGOS_HOST_PATH"); + if (envPath) + logosHostPath = envPath; + + if (logosHostPath.empty()) { + auto appDir = boost::dll::program_location().parent_path(); + auto normalized = (appDir / "logos_host").lexically_normal(); + logosHostPath = normalized.string(); + } + + if (!fs::exists(logosHostPath)) { + if (!pluginsDirs.empty()) { + auto candidatePath = fs::absolute( + fs::path(pluginsDirs.front()) / ".." / "bin" / "logos_host" + ).lexically_normal(); + if (fs::exists(candidatePath)) + logosHostPath = candidatePath.string(); + } + } + + if (!fs::exists(logosHostPath)) { + spdlog::critical( + "logos_host not found at: {} - set LOGOS_HOST_PATH or place it next to the executable", + logosHostPath); + return {}; + } + + return logosHostPath; +} + +} // anonymous namespace + +namespace LogosCore { + +bool QtSubprocessRuntime::canHandle(const ModuleDescriptor& desc) const +{ + return desc.format == "qt-plugin" || desc.format.empty(); +} + +bool QtSubprocessRuntime::load(const ModuleDescriptor& desc, + std::function onTerminated, + LoadedModuleHandle& out) +{ + std::string logosHostPath = resolveLogosHostPath(desc.pluginsDirs); + if (logosHostPath.empty()) + return false; + + std::vector arguments = { + "--name", desc.name, + "--path", desc.path + }; + + if (!desc.instancePersistencePath.empty()) { + arguments.push_back("--instance-persistence-path"); + arguments.push_back(desc.instancePersistencePath); + } + + SubprocessManager::ProcessCallbacks callbacks; + + callbacks.onFinished = [onTerminated](const std::string& pName, int exitCode, bool crashed) { + (void)exitCode; + if (crashed) { + spdlog::critical("Plugin process crashed: {}", pName); + exit(1); + } + if (onTerminated) + onTerminated(pName); + }; + + callbacks.onError = [](const std::string& pName, bool crashed) { + if (crashed) { + spdlog::critical("Plugin process crashed: {}", pName); + exit(1); + } + }; + + callbacks.onOutput = [](const std::string& pName, const std::string& line, bool isStderr) { + if (isStderr) { + spdlog::critical("[{}] {}", pName, line); + } else if (line.find("Warning:") != std::string::npos || + line.find("WARNING:") != std::string::npos) { + spdlog::warn("[{}] {}", pName, line); + } else if (line.find("Critical:") != std::string::npos || + line.find("FAILED:") != std::string::npos || + line.find("ERROR:") != std::string::npos) { + spdlog::critical("[{}] {}", pName, line); + } + }; + + if (!SubprocessManager::startProcess(desc.name, logosHostPath, arguments, callbacks)) + return false; + + out.name = desc.name; + out.pid = SubprocessManager::getProcessId(desc.name); + out.endpoint = "qtro+unix://" + desc.name; + return true; +} + +bool QtSubprocessRuntime::sendToken(const std::string& name, const std::string& token) +{ + return SubprocessManager::sendToken(name, token); +} + +void QtSubprocessRuntime::terminate(const std::string& name) +{ + SubprocessManager::terminateProcess(name); +} + +void QtSubprocessRuntime::terminateAll() +{ + SubprocessManager::terminateAll(); +} + +bool QtSubprocessRuntime::hasModule(const std::string& name) const +{ + return SubprocessManager::hasProcess(name); +} + +std::optional QtSubprocessRuntime::pid(const std::string& name) const +{ + int64_t p = SubprocessManager::getProcessId(name); + if (p < 0) return std::nullopt; + return p; +} + +std::unordered_map QtSubprocessRuntime::getAllPids() const +{ + return SubprocessManager::getAllProcessIds(); +} + +} // namespace LogosCore diff --git a/src/runtimes/qt_subprocess/qt_subprocess_runtime.h b/src/runtimes/qt_subprocess/qt_subprocess_runtime.h new file mode 100644 index 0000000..30edcbc --- /dev/null +++ b/src/runtimes/qt_subprocess/qt_subprocess_runtime.h @@ -0,0 +1,33 @@ +#ifndef QT_SUBPROCESS_RUNTIME_H +#define QT_SUBPROCESS_RUNTIME_H + +#include "../../logos_core/module_runtime.h" + +namespace LogosCore { + +// ModuleRuntime implementation for the "qt-subprocess" strategy: +// - spawns a logos_host child process per module +// - delivers an auth token via a Unix-domain socket (logos_token_) +// - module communicates back via Qt Remote Objects over a local socket +class QtSubprocessRuntime : public ModuleRuntime { +public: + std::string id() const override { return "qt-subprocess"; } + + // Handles qt-plugin format (or unspecified format, which defaults to qt-plugin). + bool canHandle(const ModuleDescriptor& desc) const override; + + bool load(const ModuleDescriptor& desc, + std::function onTerminated, + LoadedModuleHandle& out) override; + + bool sendToken(const std::string& name, const std::string& token) override; + void terminate(const std::string& name) override; + void terminateAll() override; + bool hasModule(const std::string& name) const override; + std::optional pid(const std::string& name) const override; + std::unordered_map getAllPids() const override; +}; + +} // namespace LogosCore + +#endif // QT_SUBPROCESS_RUNTIME_H diff --git a/src/logos_core/process_manager.cpp b/src/runtimes/qt_subprocess/subprocess_manager.cpp similarity index 90% rename from src/logos_core/process_manager.cpp rename to src/runtimes/qt_subprocess/subprocess_manager.cpp index 7dbac2a..107acdd 100644 --- a/src/logos_core/process_manager.cpp +++ b/src/runtimes/qt_subprocess/subprocess_manager.cpp @@ -1,4 +1,4 @@ -#include "process_manager.h" +#include "subprocess_manager.h" #include #include @@ -63,19 +63,19 @@ IoRuntime& ioRuntime() { // --------------------------------------------------------------------------- struct ProcessEntry { - bp2::process process; - asio::readable_pipe pipe; - QtProcessManager::ProcessCallbacks callbacks; - std::string name; - std::array read_buf{}; - std::string line_buf; + bp2::process process; + asio::readable_pipe pipe; + SubprocessManager::ProcessCallbacks callbacks; + std::string name; + std::array read_buf{}; + std::string line_buf; // Set by async_wait callback; used by syncKill to avoid double-waitpid. - std::atomic exited{false}; + std::atomic exited{false}; // Set by terminateProcess/terminateAll to suppress onFinished callback. - std::atomic cancelled{false}; + std::atomic cancelled{false}; ProcessEntry(bp2::process proc, asio::readable_pipe rp, - const std::string& n, const QtProcessManager::ProcessCallbacks& cb) + const std::string& n, const SubprocessManager::ProcessCallbacks& cb) : process(std::move(proc)) , pipe(std::move(rp)) , name(n) @@ -210,11 +210,11 @@ void syncKill(std::shared_ptr entry) { }; if (!wait(std::chrono::seconds(5))) { - fprintf(stderr, "[QtProcessManager] Process did not terminate gracefully, killing: %s\n", + fprintf(stderr, "[SubprocessManager] Process did not terminate gracefully, killing: %s\n", entry->name.c_str()); entry->process.terminate(ec); // SIGKILL if (!wait(std::chrono::seconds(2))) { - fprintf(stderr, "[QtProcessManager] Process did not respond to SIGKILL: %s\n", + fprintf(stderr, "[SubprocessManager] Process did not respond to SIGKILL: %s\n", entry->name.c_str()); } } @@ -223,10 +223,10 @@ void syncKill(std::shared_ptr entry) { } // anonymous namespace // =========================================================================== -// QtProcessManager public API +// SubprocessManager public API // =========================================================================== -namespace QtProcessManager { +namespace SubprocessManager { bool startProcess(const std::string& name, const std::string& executable, const std::vector& arguments, @@ -241,7 +241,7 @@ bool startProcess(const std::string& name, const std::string& executable, asio::writable_pipe wpipe(rt.ctx); asio::connect_pipe(rpipe, wpipe, ec); if (ec) { - fprintf(stderr, "[QtProcessManager] Failed to create pipe for %s: %s\n", + fprintf(stderr, "[SubprocessManager] Failed to create pipe for %s: %s\n", name.c_str(), ec.message().c_str()); return false; } @@ -251,14 +251,13 @@ bool startProcess(const std::string& name, const std::string& executable, pstdio.out = wpipe; pstdio.err = wpipe; - // Use the launcher directly with ec as second arg (non-throwing variant) bp2::process proc = bp2::default_process_launcher()(rt.ctx, ec, executable, arguments, pstdio); // Close write end in parent once child has inherited it wpipe.close(); if (ec) { - fprintf(stderr, "[QtProcessManager] Failed to start process for %s: %s\n", + fprintf(stderr, "[SubprocessManager] Failed to start process for %s: %s\n", name.c_str(), ec.message().c_str()); return false; } @@ -289,7 +288,7 @@ bool sendToken(const std::string& name, const std::string& token) for (int attempt = 0; attempt < 10; ++attempt) { sock = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sock < 0) { - fprintf(stderr, "[QtProcessManager] socket() failed: %s\n", strerror(errno)); + fprintf(stderr, "[SubprocessManager] socket() failed: %s\n", strerror(errno)); break; } @@ -306,10 +305,10 @@ bool sendToken(const std::string& name, const std::string& token) } if (sock < 0) { - fprintf(stderr, "[QtProcessManager] Failed to connect to token socket for: %s\n", + fprintf(stderr, "[SubprocessManager] Failed to connect to token socket for: %s\n", name.c_str()); - // Remove and kill associated process (matching Qt behaviour) + // Remove and kill associated process std::shared_ptr entry; { std::lock_guard lock(s_processesMutex); @@ -404,4 +403,4 @@ void registerProcess(const std::string& name) s_processes[name] = nullptr; } -} // namespace QtProcessManager +} // namespace SubprocessManager diff --git a/src/logos_core/process_manager.h b/src/runtimes/qt_subprocess/subprocess_manager.h similarity index 79% rename from src/logos_core/process_manager.h rename to src/runtimes/qt_subprocess/subprocess_manager.h index b80f3ee..bfe10fe 100644 --- a/src/logos_core/process_manager.h +++ b/src/runtimes/qt_subprocess/subprocess_manager.h @@ -1,5 +1,5 @@ -#ifndef PROCESS_MANAGER_H -#define PROCESS_MANAGER_H +#ifndef SUBPROCESS_MANAGER_H +#define SUBPROCESS_MANAGER_H #include #include @@ -7,7 +7,9 @@ #include #include -namespace QtProcessManager { +// Subprocess management for the qt_subprocess runtime. +// Renamed from QtProcessManager — uses Boost.Process + Boost.Asio (no Qt). +namespace SubprocessManager { struct ProcessCallbacks { std::function onFinished; @@ -27,4 +29,4 @@ namespace QtProcessManager { void registerProcess(const std::string& name); } -#endif // PROCESS_MANAGER_H +#endif // SUBPROCESS_MANAGER_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ba243f1..7d1d1f7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,12 +12,14 @@ add_executable(logos_core_tests test_plugin_manager.cpp test_process_stats.cpp test_dependency_resolver.cpp - test_process_manager.cpp + test_subprocess_manager.cpp test_token_exchange.cpp + test_runtime_registry.cpp + test_module_runtime_abstraction.cpp # qt_test_adapter.h calls QtTokenReceiver::receiveAuthToken; compile the - # implementation unit directly into the test binary (it lives in logos_host, - # which is an executable, not a linkable library). - ${CMAKE_SOURCE_DIR}/src/logos_host/qt/qt_token_receiver.cpp + # implementation unit directly into the test binary (it lives in the runtime + # host, which is an executable, not a linkable library). + ${CMAKE_SOURCE_DIR}/src/runtimes/qt_subprocess/host/qt/qt_token_receiver.cpp ) target_link_libraries(logos_core_tests PRIVATE @@ -31,7 +33,8 @@ target_link_libraries(logos_core_tests PRIVATE target_include_directories(logos_core_tests PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src/logos_core - ${CMAKE_SOURCE_DIR}/src/logos_host + ${CMAKE_SOURCE_DIR}/src/runtimes + ${CMAKE_SOURCE_DIR}/src/runtimes/qt_subprocess/host ) # Propagate portable build flag to tests so manifest variant keys match diff --git a/tests/qt_test_adapter.h b/tests/qt_test_adapter.h index 7b9574e..7c9e4f5 100644 --- a/tests/qt_test_adapter.h +++ b/tests/qt_test_adapter.h @@ -12,7 +12,7 @@ #include "plugin_manager.h" #include "plugin_registry.h" -#include "process_manager.h" +#include "qt_subprocess/subprocess_manager.h" #include "qt/qt_token_receiver.h" #include @@ -135,14 +135,14 @@ inline void logos_core_clear() } // --------------------------------------------------------------------------- -// Process management — QtProcessManager already uses std::string in its API; +// Process management — SubprocessManager uses std::string throughout; // these pass-throughs keep the test call sites Qt-free. // --------------------------------------------------------------------------- inline void logos_core_register_process(const char* name) { if (!name) return; - QtProcessManager::registerProcess(std::string(name)); + SubprocessManager::registerProcess(std::string(name)); } inline int logos_core_start_process(const char* name, @@ -154,40 +154,40 @@ inline int logos_core_start_process(const char* name, if (args) for (int i = 0; args[i] != nullptr; ++i) arguments.push_back(args[i]); - QtProcessManager::ProcessCallbacks noopCallbacks; - return QtProcessManager::startProcess(std::string(name), - std::string(executable), - arguments, - noopCallbacks) ? 1 : 0; + SubprocessManager::ProcessCallbacks noopCallbacks; + return SubprocessManager::startProcess(std::string(name), + std::string(executable), + arguments, + noopCallbacks) ? 1 : 0; } inline int logos_core_send_token(const char* name, const char* token) { if (!name || !token) return 0; - return QtProcessManager::sendToken(std::string(name), std::string(token)) ? 1 : 0; + return SubprocessManager::sendToken(std::string(name), std::string(token)) ? 1 : 0; } inline int logos_core_has_process(const char* name) { if (!name) return 0; - return QtProcessManager::hasProcess(std::string(name)) ? 1 : 0; + return SubprocessManager::hasProcess(std::string(name)) ? 1 : 0; } inline int64_t logos_core_get_process_id(const char* name) { if (!name) return -1; - return QtProcessManager::getProcessId(std::string(name)); + return SubprocessManager::getProcessId(std::string(name)); } inline void logos_core_terminate_process(const char* name) { if (!name) return; - QtProcessManager::terminateProcess(std::string(name)); + SubprocessManager::terminateProcess(std::string(name)); } inline void logos_core_clear_processes() { - QtProcessManager::clearAll(); + SubprocessManager::clearAll(); } // --------------------------------------------------------------------------- diff --git a/tests/test_module_runtime_abstraction.cpp b/tests/test_module_runtime_abstraction.cpp new file mode 100644 index 0000000..9f87155 --- /dev/null +++ b/tests/test_module_runtime_abstraction.cpp @@ -0,0 +1,266 @@ +// ============================================================================= +// Tests for the ModuleRuntime abstraction seam. +// +// Installs a FakeRuntime into PluginManager's RuntimeRegistry and drives the +// full PluginManager load/unload/terminateAll path. Proves that: +// - load(), sendToken(), terminate(), terminateAll() are routed through the +// runtime abstraction (not directly to a subprocess or Qt mechanism). +// - Dependency-ordered loads call load() in the correct (topo) order. +// - Error paths (load returns false) prevent sendToken from being called. +// No child processes are spawned; no Qt Remote Objects are used. +// ============================================================================= +#include +#include "logos_core.h" +#include "qt_test_adapter.h" +#include "plugin_manager.h" +#include "plugin_registry.h" +#include "runtime_registry.h" +#include "module_runtime.h" +#include "qt_subprocess/qt_subprocess_runtime.h" +#include +#include +#include +#include +#include + +using namespace LogosCore; + +// --------------------------------------------------------------------------- +// FakeRuntime: records all calls; configurable per-module load result. +// Placed in an anonymous namespace to avoid ODR conflicts with the FakeRuntime +// stub in test_runtime_registry.cpp (same binary, different definition). +// --------------------------------------------------------------------------- +namespace { + +struct FakeRuntime : public ModuleRuntime { + std::string id() const override { return "fake"; } + + bool canHandle(const ModuleDescriptor&) const override { return true; } + + bool load(const ModuleDescriptor& desc, + std::function, + LoadedModuleHandle& out) override { + loadCalls.push_back(desc.name); + if (failOn.count(desc.name)) return false; + out.name = desc.name; + out.pid = 1234; + out.endpoint = "fake://" + desc.name; + activeModules.insert(desc.name); + return true; + } + + bool sendToken(const std::string& name, const std::string& token) override { + sendTokenCalls.push_back({name, token}); + return true; + } + + void terminate(const std::string& name) override { + terminateCalls.push_back(name); + activeModules.erase(name); + } + + void terminateAll() override { + terminateAllCount++; + activeModules.clear(); + } + + bool hasModule(const std::string& name) const override { + return activeModules.count(name) > 0; + } + + // Call records + std::vector loadCalls; + std::vector> sendTokenCalls; + std::vector terminateCalls; + int terminateAllCount = 0; + + // Modules to fail on load + std::unordered_set failOn; + // Modules currently "running" + std::unordered_set activeModules; +}; + +} // anonymous namespace + +// --------------------------------------------------------------------------- +// Test fixture: installs FakeRuntime, cleans up registry after each test. +// --------------------------------------------------------------------------- + +class ModuleRuntimeAbstractionTest : public ::testing::Test { +protected: + std::shared_ptr fake; + + void SetUp() override { + logos_core_terminate_all(); + logos_core_clear(); + SubprocessManager::clearAll(); + + fake = std::make_shared(); + PluginManager::runtimes().clearForTests(); + PluginManager::runtimes().registerRuntime(fake); + } + + void TearDown() override { + logos_core_terminate_all(); + logos_core_clear(); + SubprocessManager::clearAll(); + // Restore default runtime so other test suites aren't affected. + PluginManager::runtimes().clearForTests(); + PluginManager::runtimes().registerRuntime( + std::make_shared()); + } + + void registerPlugin(const std::string& name, + const std::vector& deps = {}) { + std::string path = "/fake/" + name + "_plugin.so"; + logos_core_register_plugin(name.c_str(), path.c_str()); + std::vector depPtrs; + for (const auto& d : deps) depPtrs.push_back(d.c_str()); + logos_core_register_plugin_dependencies( + name.c_str(), + depPtrs.empty() ? nullptr : depPtrs.data(), + static_cast(depPtrs.size())); + } +}; + +// ============================================================================= +// Basic load/unload routing +// ============================================================================= + +TEST_F(ModuleRuntimeAbstractionTest, LoadPlugin_CallsFakeRuntimeLoad) { + registerPlugin("foo"); + + int result = logos_core_load_plugin("foo"); + ASSERT_EQ(result, 1); + + ASSERT_EQ(fake->loadCalls.size(), 1u); + EXPECT_EQ(fake->loadCalls[0], "foo"); +} + +TEST_F(ModuleRuntimeAbstractionTest, LoadPlugin_CallsSendTokenAfterLoad) { + registerPlugin("foo"); + + logos_core_load_plugin("foo"); + + ASSERT_EQ(fake->sendTokenCalls.size(), 1u); + EXPECT_EQ(fake->sendTokenCalls[0].first, "foo"); + EXPECT_FALSE(fake->sendTokenCalls[0].second.empty()); +} + +TEST_F(ModuleRuntimeAbstractionTest, LoadPlugin_MarksModuleAsLoaded) { + registerPlugin("foo"); + + logos_core_load_plugin("foo"); + + EXPECT_EQ(logos_core_is_plugin_loaded("foo"), 1); +} + +TEST_F(ModuleRuntimeAbstractionTest, LoadPlugin_StoresRuntimeInRegistry) { + registerPlugin("foo"); + logos_core_load_plugin("foo"); + + auto rt = PluginManager::registry().runtimeFor("foo"); + EXPECT_EQ(rt.get(), fake.get()); +} + +TEST_F(ModuleRuntimeAbstractionTest, UnloadPlugin_CallsFakeRuntimeTerminate) { + registerPlugin("foo"); + logos_core_load_plugin("foo"); + + int result = logos_core_unload_plugin("foo"); + ASSERT_EQ(result, 1); + + ASSERT_EQ(fake->terminateCalls.size(), 1u); + EXPECT_EQ(fake->terminateCalls[0], "foo"); +} + +TEST_F(ModuleRuntimeAbstractionTest, UnloadPlugin_MarksModuleAsUnloaded) { + registerPlugin("foo"); + logos_core_load_plugin("foo"); + logos_core_unload_plugin("foo"); + + EXPECT_EQ(logos_core_is_plugin_loaded("foo"), 0); +} + +// ============================================================================= +// Dependency-ordered loads +// ============================================================================= + +TEST_F(ModuleRuntimeAbstractionTest, LoadWithDeps_LoadsInTopologicalOrder) { + // Chain: c depends on b, b depends on a. + // Expected load order: a, b, c. + registerPlugin("a"); + registerPlugin("b", {"a"}); + registerPlugin("c", {"b"}); + + int result = logos_core_load_plugin_with_dependencies("c"); + ASSERT_EQ(result, 1); + + ASSERT_EQ(fake->loadCalls.size(), 3u); + EXPECT_EQ(fake->loadCalls[0], "a"); + EXPECT_EQ(fake->loadCalls[1], "b"); + EXPECT_EQ(fake->loadCalls[2], "c"); +} + +TEST_F(ModuleRuntimeAbstractionTest, LoadWithDeps_SkipsAlreadyLoadedModules) { + registerPlugin("a"); + registerPlugin("b", {"a"}); + + logos_core_load_plugin("a"); + fake->loadCalls.clear(); + + logos_core_load_plugin_with_dependencies("b"); + + ASSERT_EQ(fake->loadCalls.size(), 1u); + EXPECT_EQ(fake->loadCalls[0], "b"); +} + +// ============================================================================= +// terminateAll routing +// ============================================================================= + +TEST_F(ModuleRuntimeAbstractionTest, TerminateAll_CallsFakeTerminateAll) { + registerPlugin("foo"); + logos_core_load_plugin("foo"); + + logos_core_terminate_all(); + + EXPECT_EQ(fake->terminateAllCount, 1); + EXPECT_EQ(logos_core_is_plugin_loaded("foo"), 0); +} + +// ============================================================================= +// Error paths +// ============================================================================= + +TEST_F(ModuleRuntimeAbstractionTest, LoadPlugin_ReturnsFalseWhenRuntimeLoadFails) { + registerPlugin("bad"); + fake->failOn.insert("bad"); + + int result = logos_core_load_plugin("bad"); + EXPECT_EQ(result, 0); +} + +TEST_F(ModuleRuntimeAbstractionTest, LoadPlugin_DoesNotCallSendTokenOnLoadFailure) { + registerPlugin("bad"); + fake->failOn.insert("bad"); + + logos_core_load_plugin("bad"); + + EXPECT_TRUE(fake->sendTokenCalls.empty()); +} + +TEST_F(ModuleRuntimeAbstractionTest, LoadPlugin_DoesNotMarkAsLoadedOnFailure) { + registerPlugin("bad"); + fake->failOn.insert("bad"); + + logos_core_load_plugin("bad"); + + EXPECT_EQ(logos_core_is_plugin_loaded("bad"), 0); +} + +TEST_F(ModuleRuntimeAbstractionTest, LoadPlugin_ReturnsFalseForUnknownPlugin) { + int result = logos_core_load_plugin("not_registered"); + EXPECT_EQ(result, 0); + EXPECT_TRUE(fake->loadCalls.empty()); +} diff --git a/tests/test_runtime_registry.cpp b/tests/test_runtime_registry.cpp new file mode 100644 index 0000000..4a8a594 --- /dev/null +++ b/tests/test_runtime_registry.cpp @@ -0,0 +1,238 @@ +// ============================================================================= +// Tests for RuntimeRegistry: registration, selection, fan-out operations. +// +// These tests run entirely in-process with FakeRuntime stubs — no Qt event +// loop, no subprocess, no file I/O. +// ============================================================================= +#include +#include "runtime_registry.h" +#include +#include +#include + +using namespace LogosCore; + +// --------------------------------------------------------------------------- +// A minimal in-process stub runtime used by every test below. +// Anonymous namespace prevents ODR conflicts with other FakeRuntime definitions +// in the same binary (e.g. test_module_runtime_abstraction.cpp). +// --------------------------------------------------------------------------- +namespace { + +struct FakeRuntime : public ModuleRuntime { + explicit FakeRuntime(std::string myId, std::string handledFormat = "") + : m_id(std::move(myId)), m_handledFormat(std::move(handledFormat)) {} + + std::string id() const override { return m_id; } + + bool canHandle(const ModuleDescriptor& desc) const override { + if (m_handledFormat.empty()) return true; // accepts anything + return desc.format == m_handledFormat; + } + + bool load(const ModuleDescriptor& desc, + std::function, + LoadedModuleHandle& out) override { + out.name = desc.name; + out.pid = 42; + loadCalls.push_back(desc.name); + return loadShouldSucceed; + } + + bool sendToken(const std::string& name, const std::string& token) override { + sendTokenCalls.push_back({name, token}); + return true; + } + + void terminate(const std::string& name) override { + terminateCalls.push_back(name); + } + + void terminateAll() override { terminateAllCount++; } + + bool hasModule(const std::string& name) const override { + (void)name; return false; + } + + std::unordered_map getAllPids() const override { + return fakePids; + } + + // Controllable behaviour + bool loadShouldSucceed = true; + + // Call records + std::vector loadCalls; + std::vector> sendTokenCalls; + std::vector terminateCalls; + int terminateAllCount = 0; + + // Configurable pids for getAllPids() + std::unordered_map fakePids; + +private: + std::string m_id; + std::string m_handledFormat; +}; + +} // anonymous namespace + +// ============================================================================= +// Empty registry +// ============================================================================= + +TEST(RuntimeRegistryTest, SelectReturnsNullWhenEmpty) { + RuntimeRegistry reg; + ModuleDescriptor desc; + desc.name = "foo"; + desc.format = "qt-plugin"; + EXPECT_EQ(reg.select(desc), nullptr); +} + +TEST(RuntimeRegistryTest, TerminateAllOnEmptyRegistryDoesNotCrash) { + RuntimeRegistry reg; + EXPECT_NO_THROW(reg.terminateAll()); +} + +TEST(RuntimeRegistryTest, GetAllPidsOnEmptyRegistryReturnsEmptyMap) { + RuntimeRegistry reg; + EXPECT_TRUE(reg.getAllPids().empty()); +} + +// ============================================================================= +// canHandle dispatch +// ============================================================================= + +TEST(RuntimeRegistryTest, SelectPicksFirstCanHandleRuntime) { + RuntimeRegistry reg; + auto rtA = std::make_shared("a", "qt-plugin"); + auto rtB = std::make_shared("b", "qt-plugin"); + reg.registerRuntime(rtA); + reg.registerRuntime(rtB); + + ModuleDescriptor desc; + desc.format = "qt-plugin"; + auto selected = reg.select(desc); + ASSERT_NE(selected, nullptr); + EXPECT_EQ(selected->id(), "a"); +} + +TEST(RuntimeRegistryTest, SelectSkipsRuntimeThatCannotHandle) { + RuntimeRegistry reg; + auto rtA = std::make_shared("a", "wasm"); + auto rtB = std::make_shared("b", "qt-plugin"); + reg.registerRuntime(rtA); + reg.registerRuntime(rtB); + + ModuleDescriptor desc; + desc.format = "qt-plugin"; + auto selected = reg.select(desc); + ASSERT_NE(selected, nullptr); + EXPECT_EQ(selected->id(), "b"); +} + +TEST(RuntimeRegistryTest, SelectReturnsNullIfNoRuntimeHandlesFormat) { + RuntimeRegistry reg; + auto rtA = std::make_shared("a", "wasm"); + reg.registerRuntime(rtA); + + ModuleDescriptor desc; + desc.format = "qt-plugin"; + EXPECT_EQ(reg.select(desc), nullptr); +} + +// ============================================================================= +// Explicit runtimeConfig["id"] override +// ============================================================================= + +TEST(RuntimeRegistryTest, SelectUsesExplicitIdOverride) { + RuntimeRegistry reg; + auto rtA = std::make_shared("a", "qt-plugin"); + auto rtB = std::make_shared("b", "qt-plugin"); + reg.registerRuntime(rtA); + reg.registerRuntime(rtB); + + ModuleDescriptor desc; + desc.format = "qt-plugin"; + desc.runtimeConfig["id"] = "b"; // explicitly request the second one + + auto selected = reg.select(desc); + ASSERT_NE(selected, nullptr); + EXPECT_EQ(selected->id(), "b"); +} + +TEST(RuntimeRegistryTest, SelectReturnsNullForUnknownExplicitId) { + RuntimeRegistry reg; + auto rtA = std::make_shared("a", "qt-plugin"); + reg.registerRuntime(rtA); + + ModuleDescriptor desc; + desc.runtimeConfig["id"] = "nonexistent-id"; + EXPECT_EQ(reg.select(desc), nullptr); +} + +TEST(RuntimeRegistryTest, ExplicitIdDoesNotFallThroughToCanHandle) { + // Even if "other" is the only runtime that canHandle, requesting "missing" + // explicitly must return nullptr rather than silently routing to "other". + RuntimeRegistry reg; + auto rtA = std::make_shared("other"); // accepts anything + reg.registerRuntime(rtA); + + ModuleDescriptor desc; + desc.runtimeConfig["id"] = "missing"; + EXPECT_EQ(reg.select(desc), nullptr); +} + +// ============================================================================= +// terminateAll fan-out +// ============================================================================= + +TEST(RuntimeRegistryTest, TerminateAllCallsEveryRuntime) { + RuntimeRegistry reg; + auto rtA = std::make_shared("a"); + auto rtB = std::make_shared("b"); + reg.registerRuntime(rtA); + reg.registerRuntime(rtB); + + reg.terminateAll(); + + EXPECT_EQ(rtA->terminateAllCount, 1); + EXPECT_EQ(rtB->terminateAllCount, 1); +} + +// ============================================================================= +// getAllPids aggregation +// ============================================================================= + +TEST(RuntimeRegistryTest, GetAllPidsAggregatesAcrossRuntimes) { + RuntimeRegistry reg; + auto rtA = std::make_shared("a"); + auto rtB = std::make_shared("b"); + rtA->fakePids["mod1"] = 100; + rtA->fakePids["mod2"] = 200; + rtB->fakePids["mod3"] = 300; + reg.registerRuntime(rtA); + reg.registerRuntime(rtB); + + auto pids = reg.getAllPids(); + EXPECT_EQ(pids.size(), 3u); + EXPECT_EQ(pids.at("mod1"), 100); + EXPECT_EQ(pids.at("mod2"), 200); + EXPECT_EQ(pids.at("mod3"), 300); +} + +// ============================================================================= +// clearForTests +// ============================================================================= + +TEST(RuntimeRegistryTest, ClearForTests_RemovesAllRuntimes) { + RuntimeRegistry reg; + reg.registerRuntime(std::make_shared("a")); + reg.registerRuntime(std::make_shared("b")); + + reg.clearForTests(); + + ModuleDescriptor desc; + EXPECT_EQ(reg.select(desc), nullptr); + EXPECT_NO_THROW(reg.terminateAll()); +} diff --git a/tests/test_process_manager.cpp b/tests/test_subprocess_manager.cpp similarity index 82% rename from tests/test_process_manager.cpp rename to tests/test_subprocess_manager.cpp index c616ad9..aaa8024 100644 --- a/tests/test_process_manager.cpp +++ b/tests/test_subprocess_manager.cpp @@ -1,7 +1,7 @@ // ============================================================================= -// Tests for the process manager lifecycle, exposed via logos_core.h. +// Tests for SubprocessManager lifecycle, exposed via qt_test_adapter.h. // -// The process manager maintains a registry of named child processes and +// The subprocess manager maintains a registry of named child processes and // provides token-IPC (sendToken / receive) between the core and plugin // host processes. These tests verify: // - register / hasProcess / clearAll lifecycle @@ -26,7 +26,7 @@ static void clearProcessState() { logos_core_clear_processes(); } -class ProcessManagerTest : public ::testing::Test { +class SubprocessManagerTest : public ::testing::Test { protected: void SetUp() override { clearProcessState(); } void TearDown() override { clearProcessState(); } @@ -36,16 +36,16 @@ protected: // register / hasProcess / clear lifecycle // --------------------------------------------------------------------------- -TEST_F(ProcessManagerTest, RegisterProcess_HasProcessReturnsTrue) { +TEST_F(SubprocessManagerTest, RegisterProcess_HasProcessReturnsTrue) { logos_core_register_process("my_plugin"); EXPECT_EQ(logos_core_has_process("my_plugin"), 1); } -TEST_F(ProcessManagerTest, HasProcess_ReturnsFalseForUnregistered) { +TEST_F(SubprocessManagerTest, HasProcess_ReturnsFalseForUnregistered) { EXPECT_EQ(logos_core_has_process("nope"), 0); } -TEST_F(ProcessManagerTest, ClearAll_RemovesAllEntries) { +TEST_F(SubprocessManagerTest, ClearAll_RemovesAllEntries) { logos_core_register_process("p1"); logos_core_register_process("p2"); logos_core_register_process("p3"); @@ -57,7 +57,7 @@ TEST_F(ProcessManagerTest, ClearAll_RemovesAllEntries) { EXPECT_EQ(logos_core_has_process("p3"), 0); } -TEST_F(ProcessManagerTest, RegisterProcess_IsIdempotent) { +TEST_F(SubprocessManagerTest, RegisterProcess_IsIdempotent) { logos_core_register_process("dup"); logos_core_register_process("dup"); logos_core_register_process("dup"); @@ -68,7 +68,7 @@ TEST_F(ProcessManagerTest, RegisterProcess_IsIdempotent) { EXPECT_EQ(logos_core_has_process("dup"), 0); } -TEST_F(ProcessManagerTest, NullName_DoesNotCrash) { +TEST_F(SubprocessManagerTest, NullName_DoesNotCrash) { logos_core_register_process(nullptr); EXPECT_EQ(logos_core_has_process(nullptr), 0); } @@ -77,14 +77,14 @@ TEST_F(ProcessManagerTest, NullName_DoesNotCrash) { // get_process_id: placeholder entry returns -1 // --------------------------------------------------------------------------- -TEST_F(ProcessManagerTest, GetProcessId_ReturnsNegativeOneForPlaceholder) { +TEST_F(SubprocessManagerTest, GetProcessId_ReturnsNegativeOneForPlaceholder) { logos_core_register_process("placeholder"); // Placeholder has no real process — should return -1. int64_t pid = logos_core_get_process_id("placeholder"); EXPECT_EQ(pid, -1); } -TEST_F(ProcessManagerTest, GetProcessId_ReturnsNegativeOneForUnknown) { +TEST_F(SubprocessManagerTest, GetProcessId_ReturnsNegativeOneForUnknown) { int64_t pid = logos_core_get_process_id("unknown"); EXPECT_EQ(pid, -1); } @@ -100,7 +100,7 @@ static const char* sleepBinary() { return nullptr; } -TEST_F(ProcessManagerTest, StartProcess_ReturnsOneOnSuccess) { +TEST_F(SubprocessManagerTest, StartProcess_ReturnsOneOnSuccess) { const char* sleep = sleepBinary(); if (!sleep) GTEST_SKIP() << "sleep binary not found"; @@ -109,7 +109,7 @@ TEST_F(ProcessManagerTest, StartProcess_ReturnsOneOnSuccess) { EXPECT_EQ(ok, 1); } -TEST_F(ProcessManagerTest, StartProcess_HasProcessReturnsTrueAfterStart) { +TEST_F(SubprocessManagerTest, StartProcess_HasProcessReturnsTrueAfterStart) { const char* sleep = sleepBinary(); if (!sleep) GTEST_SKIP() << "sleep binary not found"; @@ -118,7 +118,7 @@ TEST_F(ProcessManagerTest, StartProcess_HasProcessReturnsTrueAfterStart) { EXPECT_EQ(logos_core_has_process("sleep_has"), 1); } -TEST_F(ProcessManagerTest, StartProcess_GetProcessIdReturnsValidPid) { +TEST_F(SubprocessManagerTest, StartProcess_GetProcessIdReturnsValidPid) { const char* sleep = sleepBinary(); if (!sleep) GTEST_SKIP() << "sleep binary not found"; @@ -129,7 +129,7 @@ TEST_F(ProcessManagerTest, StartProcess_GetProcessIdReturnsValidPid) { EXPECT_GT(pid, 0) << "started process must have a positive PID"; } -TEST_F(ProcessManagerTest, StartProcess_ReturnsFalseForNonexistentExecutable) { +TEST_F(SubprocessManagerTest, StartProcess_ReturnsFalseForNonexistentExecutable) { const char* args[] = {nullptr}; int ok = logos_core_start_process("bad_exec", "/nonexistent/binary_that_does_not_exist", @@ -137,7 +137,7 @@ TEST_F(ProcessManagerTest, StartProcess_ReturnsFalseForNonexistentExecutable) { EXPECT_EQ(ok, 0); } -TEST_F(ProcessManagerTest, TerminateProcess_RemovesEntry) { +TEST_F(SubprocessManagerTest, TerminateProcess_RemovesEntry) { const char* sleep = sleepBinary(); if (!sleep) GTEST_SKIP() << "sleep binary not found"; @@ -150,13 +150,13 @@ TEST_F(ProcessManagerTest, TerminateProcess_RemovesEntry) { EXPECT_EQ(logos_core_has_process("sleep_term"), 0); } -TEST_F(ProcessManagerTest, TerminateProcess_NoopForUnknownName) { +TEST_F(SubprocessManagerTest, TerminateProcess_NoopForUnknownName) { // Should not crash when terminating a name that was never registered. logos_core_terminate_process("i_do_not_exist"); SUCCEED(); } -TEST_F(ProcessManagerTest, TerminateProcess_NoopForNullName) { +TEST_F(SubprocessManagerTest, TerminateProcess_NoopForNullName) { logos_core_terminate_process(nullptr); SUCCEED(); } @@ -165,7 +165,7 @@ TEST_F(ProcessManagerTest, TerminateProcess_NoopForNullName) { // Multiple distinct processes coexist without colliding // --------------------------------------------------------------------------- -TEST_F(ProcessManagerTest, MultipleProcesses_DistinctPids) { +TEST_F(SubprocessManagerTest, MultipleProcesses_DistinctPids) { const char* sleep = sleepBinary(); if (!sleep) GTEST_SKIP() << "sleep binary not found"; @@ -181,7 +181,7 @@ TEST_F(ProcessManagerTest, MultipleProcesses_DistinctPids) { EXPECT_NE(pidA, pidB) << "two separate processes must have different PIDs"; } -TEST_F(ProcessManagerTest, MultipleProcesses_TerminateOneKeepsOther) { +TEST_F(SubprocessManagerTest, MultipleProcesses_TerminateOneKeepsOther) { const char* sleep = sleepBinary(); if (!sleep) GTEST_SKIP() << "sleep binary not found"; @@ -199,7 +199,7 @@ TEST_F(ProcessManagerTest, MultipleProcesses_TerminateOneKeepsOther) { // terminateAll removes all running processes // --------------------------------------------------------------------------- -TEST_F(ProcessManagerTest, TerminateAll_RemovesAllRunningProcesses) { +TEST_F(SubprocessManagerTest, TerminateAll_RemovesAllRunningProcesses) { const char* sleep = sleepBinary(); if (!sleep) GTEST_SKIP() << "sleep binary not found";