mirror of
https://github.com/logos-co/logos-modules-state-module.git
synced 2026-08-27 13:11:12 +00:00
Merge pull request #1 from logos-co/feat/structural-ingest-gate
feat(contract)!: gate ingest on WHO the caller is, not what it knows
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
push:
|
||||
branches: [master, main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Setup Cachix
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: logos-co
|
||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||
|
||||
- name: Build module
|
||||
run: nix build -L
|
||||
|
||||
# `.#unit-tests` is the SAME DERIVATION as checks.<system>.unit-tests
|
||||
# (verified: identical drvPath), and it RUNS the suite rather than only
|
||||
# compiling it. That distinction matters here: mkLogosModuleTests finds
|
||||
# suites with
|
||||
# find . -maxdepth 1 -executable \( -name "*_tests" -o -name "*_test" \)
|
||||
# so a binary named anything else is built, matched by nothing, never
|
||||
# executed -- and the check still goes GREEN. This suite was once named
|
||||
# `modules_state_invariants` and passed three mutation tests it should
|
||||
# have caught before that was spotted.
|
||||
- name: Run unit tests
|
||||
run: nix build .#unit-tests -L
|
||||
|
||||
# Packaging is a separate failure mode from compiling: the .lgx payload
|
||||
# layout and the install tree are built by different code than the plugin.
|
||||
- name: Build installable package
|
||||
run: nix build .#install -L
|
||||
@@ -10,10 +10,12 @@ first-class, queryable, subscribable fact.
|
||||
|
||||
It **reports**. It does not drive load/unload — that stays with liblogos' C API.
|
||||
|
||||
## Status: Stage 1
|
||||
## Status
|
||||
|
||||
Nothing feeds it yet. The module is complete and standalone; the liblogos
|
||||
registry observer and the core push that will feed it are Stages 2 and 3.
|
||||
The module is complete and standalone. The liblogos **registry observer** that
|
||||
turns load/unload/crash/discovery into sequenced transitions is merged
|
||||
(logos-liblogos#189); the **core push** that carries them across this wire is in
|
||||
progress. Until that lands, nothing feeds this module in a normal run.
|
||||
|
||||
## Contract
|
||||
|
||||
@@ -29,12 +31,11 @@ type ModuleListing { modules: [ModuleRecord], partial: bool, seq: uint }
|
||||
method list_modules() -> ModuleListing
|
||||
method module_record(module: tstr) -> ?ModuleRecord
|
||||
method is_ready(module: tstr) -> bool
|
||||
method rejected_ingest_count() -> uint
|
||||
|
||||
# ingest surface — authToken-gated, for liblogos core only
|
||||
method note_transition(authToken, module, instance: ?tstr, pid: ?int,
|
||||
# ingest surface — admitted only when currentCaller() is the HOST
|
||||
method note_transition(module, instance: ?tstr, pid: ?int,
|
||||
old_state, new_state, reason: ?tstr, seq: uint) -> bool
|
||||
method apply_snapshot(authToken: tstr, listing: ModuleListing) -> bool
|
||||
method apply_snapshot(listing: ModuleListing) -> bool
|
||||
|
||||
event module_state_changed(module, instance: ?tstr, pid: ?int,
|
||||
old_state, new_state, reason: ?tstr, seq: uint)
|
||||
@@ -124,22 +125,68 @@ Two traps if you write one yourself:
|
||||
|
||||
`note_transition` and `apply_snapshot` write the facts every other module is
|
||||
about to trust, so the ingest surface is gated while the read surface is open.
|
||||
The token is an **argument** because liblogos' `AccessPolicy` restricts by target
|
||||
module with no per-method granularity — restricting `modules_state` to caller
|
||||
`core` would lock out every reader.
|
||||
|
||||
| environment | behaviour |
|
||||
**The gate is structural: the caller must be the host.**
|
||||
|
||||
```cpp
|
||||
logos::currentCaller().isHost() // logos-cpp-sdk, cpp/logos_caller.h
|
||||
```
|
||||
|
||||
A push from core arrives as `{"kind":"host"}`; a call from any module arrives as
|
||||
`{"kind":"module","name":…}`. So authority is what the caller **is**, not what it
|
||||
knows — no secret to distribute, rotate or leak.
|
||||
|
||||
`host` carries **no name**, by rule 5 of the caller contract: `core` and
|
||||
`capability_module` hold the same token value under two keys, so a name there
|
||||
"would be a coin flip presented as a fact". The gate therefore admits core *or*
|
||||
capability_module — both host-side components of the runtime rather than peer
|
||||
modules, which is the distinction that matters here.
|
||||
|
||||
| caller | behaviour |
|
||||
|---|---|
|
||||
| `LOGOS_MODULES_STATE_INGEST_TOKEN=<nonce>` | ingest requires an exact match. The production shape: the host generates a per-run nonce and puts it in the module subprocess's environment. |
|
||||
| neither set | **all ingest refused.** Fail closed. |
|
||||
| `LOGOS_MODULES_STATE_TEST_INGEST=1` (and no token) | **test only.** Any token accepted, with a loud stderr banner on every accepted write. |
|
||||
| `kind=host` | accepted |
|
||||
| `kind=module` | **refused**, counted, and named on stderr |
|
||||
| `kind=unknown` | **refused**, counted. Fail closed. |
|
||||
| `LOGOS_MODULES_STATE_TEST_INGEST=1` | **test only.** Any caller accepted, with a loud stderr banner. |
|
||||
|
||||
The test escape is an environment variable and not a method on purpose: a method
|
||||
would itself be callable by any module, which would make the gate decorative.
|
||||
The test door exists because a unit test calls the impl directly — no dispatch,
|
||||
so no caller, so `Unknown` — and without it these invariants could only be
|
||||
exercised by driving a live daemon, which is not a thing CI does. It is an
|
||||
environment variable and not a method on purpose: a method would itself be
|
||||
callable by any module, which would make the gate decorative.
|
||||
|
||||
`rejected_ingest_count()` counts refusals **by the authority gate** and nothing
|
||||
else — not stale-seq drops, not malformed arguments — so a test asserting "the
|
||||
gate is closed" asserts exactly that.
|
||||
### This replaced a shared secret, and why
|
||||
|
||||
The ingest surface used to take an `authToken` argument compared against a
|
||||
per-run nonce in `LOGOS_MODULES_STATE_INGEST_TOKEN`. That design existed only
|
||||
because the accessor did not — `LogosModuleContext` exposes this module's own
|
||||
identity and nothing about the caller — and it would have required an `env`
|
||||
field on `ModuleDescriptor` plumbed through `logos-container` and
|
||||
`logos-container-subprocess` to deliver the nonce. The accessor landed, it
|
||||
reaches this module (measured: `kind=host`), and a structural check beats a
|
||||
secret, so the token and all of that plumbing are retired.
|
||||
|
||||
### The one pairing this depends on
|
||||
|
||||
This module requires a host that carries the caller machinery, and it ships
|
||||
alongside one. That pairing is load-bearing rather than incidental.
|
||||
|
||||
**A stale `logos-module-builder` pin degrades caller identity to `unknown`
|
||||
silently.** Measured: at `bc72ce39` the built plugin contained no caller
|
||||
machinery at all (`nm` finds no `CallerScope`, no `currentInboundCallerJson`)
|
||||
and every call answered `kind=unknown`; at master `464a75d` the same probe
|
||||
answers `kind=host`. It compiles, links and loads either way, and nothing warns.
|
||||
|
||||
A fail-closed gate on `unknown` then refuses **every** push — inert rather than
|
||||
secure. There is no counter on this surface to ask about it: the refusal is
|
||||
written to **stderr**, naming what the caller actually was, with `unknown`
|
||||
getting its own message pointing at the pin. That log line is the symptom.
|
||||
|
||||
One caveat on the measurement: it is macOS. `logos_caller.h` notes that on ELF
|
||||
at default visibility a function-local static in an inline function emits as
|
||||
`STB_GNU_UNIQUE` and the linker collapses every image's copy into one, and that
|
||||
`logos-module-builder` sets no visibility anywhere — so Linux deserves its own
|
||||
measurement before this is relied on there.
|
||||
|
||||
## The replay rule
|
||||
|
||||
|
||||
+159
-270
@@ -1,5 +1,7 @@
|
||||
#include "modules_state_impl.h"
|
||||
|
||||
#include <logos_caller.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -10,88 +12,50 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// THREAD SAFETY — read this before touching anything below.
|
||||
// THREAD SAFETY — read before touching anything below.
|
||||
//
|
||||
// Where calls come from
|
||||
// Inbound method calls arrive through the generated Qt glue. With
|
||||
// `"concurrency": "single"` in metadata.json the glue dispatches one handler
|
||||
// at a time, so in THIS build the handlers are already serialised and the
|
||||
// mutex below is never contended.
|
||||
// Handlers are serialised today by `"concurrency": "single"`, so the mutex is
|
||||
// never contended in this build. It exists anyway for two reasons, either
|
||||
// sufficient: flipping that one metadata key must not silently introduce a data
|
||||
// race in the module whose job is to be the trustworthy answer about system
|
||||
// state; and the feed originates on liblogos' background asio thread, so this
|
||||
// module must not stake its invariants on somebody else's marshalling.
|
||||
//
|
||||
// Why the mutex exists anyway — two independent reasons, either sufficient
|
||||
// 1. `concurrency` is one metadata key. Flipping it to "multi" must not
|
||||
// silently introduce a data race in the one module whose entire job is to
|
||||
// be the trustworthy answer about system state. The lock makes that flip
|
||||
// a performance decision instead of a correctness decision.
|
||||
// 2. The feed that is coming (liblogos ModuleManager) originates state
|
||||
// changes on the container's BACKGROUND asio thread — a module crash is
|
||||
// detected there, not on the main thread. Core is responsible for
|
||||
// marshalling that onto its Qt queue before it leaves the host, but this
|
||||
// module must not stake its own invariants on somebody else's marshalling
|
||||
// being correct.
|
||||
// THE ONE RULE: never hold m_mutex across an event emission. Emitting crosses
|
||||
// the C ABI into the host and fans out to subscribers, any of which can call
|
||||
// straight back in — is_ready() is the obvious one. std::mutex is not
|
||||
// recursive, so emitting under the lock is a self-deadlock waiting for a
|
||||
// consumer to be written naturally.
|
||||
//
|
||||
// THE ONE RULE THAT MATTERS
|
||||
// Never hold m_mutex across an event emission.
|
||||
//
|
||||
// module_state_changed() is a generated body that marshals into JSON and
|
||||
// crosses the C ABI into the host, which fans out to every subscriber. A
|
||||
// subscriber can call straight back in — is_ready() is the obvious one, and
|
||||
// it is exactly what a consumer reacting to an event would do. std::mutex is
|
||||
// not recursive, so emitting under the lock is a self-deadlock waiting for a
|
||||
// consumer to be written naturally.
|
||||
//
|
||||
// Every mutator therefore follows the same shape, and there are no
|
||||
// exceptions to it:
|
||||
//
|
||||
// compute under the lock -> collect pending events into a local vector
|
||||
// -> release the lock -> emit
|
||||
//
|
||||
// That is the same compute-under-lock/dispatch-after discipline the liblogos
|
||||
// registry observer will use on the other side of the wire.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Every mutator therefore follows one shape, with no exceptions:
|
||||
// compute under the lock -> collect pending events -> release -> emit.
|
||||
|
||||
namespace {
|
||||
|
||||
// ── The state vocabulary ─────────────────────────────────────────────────────
|
||||
// Free functions rather than an enum: the wire type is `tstr` (LIDL has no
|
||||
// enum, no union, no string-literal constraint), and an enum here would invite
|
||||
// treating an unrecognised state as an error — the single thing the contract
|
||||
// forbids.
|
||||
// Free constants rather than an enum: the wire type is `tstr`, and an enum here
|
||||
// would invite treating an unrecognised state as an error — the one thing the
|
||||
// contract forbids. They live in this TU because the generator parses the
|
||||
// header as text, so anything there that is not contract is noise at best.
|
||||
//
|
||||
// They live in this TU and not in the impl header on purpose: the generator
|
||||
// parses the header as text to derive the contract, so anything in it that is
|
||||
// not part of the contract is at best noise and at worst a dropped declaration.
|
||||
//
|
||||
// kAbsent is the EVENT-ONLY one, and the invariant it carries is checkable by
|
||||
// grep. It is READ where a module leaves the view (note_transition's
|
||||
// membership edge, and apply_snapshot's skip of a snapshot record that claims
|
||||
// it), and WRITTEN only into a PendingEvent — as the previousState of a module
|
||||
// a snapshot has just discovered, and as the new_state of one the snapshot
|
||||
// pruned. It is never assigned to a stored record's `state`, in any path. That
|
||||
// absence is the whole of list_modules' never-absent invariant.
|
||||
// kAbsent is the EVENT-ONLY one, and its invariant is checkable by grep: it is
|
||||
// READ where a module leaves the view, and WRITTEN only into a PendingEvent.
|
||||
// It is never assigned to a stored record's `state` on any path.
|
||||
constexpr const char* kAbsent = "absent";
|
||||
constexpr const char* kLoaded = "loaded";
|
||||
constexpr const char* kReady = "ready";
|
||||
|
||||
// "Up and usable, as far as the host is concerned."
|
||||
//
|
||||
// `loaded` is in this set BECAUSE `ready` is not reachable yet — nothing emits
|
||||
// it. If the set were {ready} alone, is_ready() would answer false forever and
|
||||
// the method would be useless on the day it ships.
|
||||
// `loaded` is NOT in this set: liblogos emits loaded->ready once the module
|
||||
// publishes its object, so is_ready() is false during that window — which is
|
||||
// precisely the window a caller is asking about.
|
||||
//
|
||||
// When a real loaded->ready transition exists, `loaded` leaves this set. That
|
||||
// is not a silent break: the only visible change is that is_ready() goes false
|
||||
// during the loaded-but-not-yet-ready window, which is precisely the window a
|
||||
// caller of is_ready() was always asking about. Consumers get more correct, not
|
||||
// broken.
|
||||
//
|
||||
// Any state string this build does not recognise answers false — the same
|
||||
// forward-compatibility fallback the contract imposes on consumers. A module
|
||||
// that demands others tolerate unknown states has to do it itself.
|
||||
// An unrecognised state answers false: the same forward-compatibility fallback
|
||||
// the contract imposes on consumers. A module that demands others tolerate
|
||||
// unknown states has to do it itself.
|
||||
bool stateIsReady(const std::string& state)
|
||||
{
|
||||
return state == kLoaded || state == kReady;
|
||||
return state == kReady;
|
||||
}
|
||||
|
||||
// The stored form of a ModuleRecord. Identical to the wire record; kept as its
|
||||
@@ -110,23 +74,19 @@ struct PendingEvent {
|
||||
uint64_t seq;
|
||||
};
|
||||
|
||||
// A fully zeroed record: the starting point for a module first learned from a
|
||||
// delta, whose static metadata (path/type/version/deps) only a snapshot can
|
||||
// fill in.
|
||||
// The starting point for a module first learned from a delta, whose static
|
||||
// metadata only a snapshot can fill in.
|
||||
//
|
||||
// It is NOT a value any surface returns, and it deliberately leaves `state`
|
||||
// EMPTY. The one caller assigns a real state before storing it, and an empty
|
||||
// state is not in the vocabulary — so if a future path ever stores this seed
|
||||
// untouched, the result is visibly wrong rather than plausibly "absent". That
|
||||
// is the whole reason this stopped being called absentRecord(): it used to be
|
||||
// module_record()'s miss answer, and the miss is std::nullopt now.
|
||||
// It deliberately leaves `state` EMPTY: the one caller assigns a real state
|
||||
// before storing, and an empty state is not in the vocabulary, so a future path
|
||||
// that stored this seed untouched would be visibly wrong rather than plausibly
|
||||
// "absent".
|
||||
//
|
||||
// Written out explicitly rather than leaning on std::map::operator[]'s
|
||||
// value-initialisation. That would in fact zero the members (ModuleRecord has
|
||||
// no user-provided constructor), but the guarantee is a language subtlety, and
|
||||
// the fields cannot carry default member initialisers: the impl header is
|
||||
// parsed as text to derive the contract, and a `uint64_t seq = 0;` field line
|
||||
// is a spelling the field scanner is not promised to read.
|
||||
// Written out rather than leaning on std::map::operator[]'s value-init: that
|
||||
// would in fact zero the members, but the guarantee is a language subtlety, and
|
||||
// the fields cannot carry default member initialisers because the header is
|
||||
// parsed as text and `uint64_t seq = 0;` is not a spelling the field scanner is
|
||||
// promised to read.
|
||||
ModuleRecord blankRecord(const std::string& name)
|
||||
{
|
||||
ModuleRecord rec;
|
||||
@@ -138,67 +98,43 @@ ModuleRecord blankRecord(const std::string& name)
|
||||
|
||||
// ── INGEST AUTHORITY ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The problem
|
||||
// note_transition() and apply_snapshot() write the facts every other module
|
||||
// is about to trust. If any module can call them, any module can forge a
|
||||
// lifecycle event — announce that a rival crashed, or that a module it wants
|
||||
// others to call is `ready`. The read surface is deliberately open; the
|
||||
// ingest surface must not be.
|
||||
// note_transition and apply_snapshot write the facts every other module is
|
||||
// about to trust. Unguarded, any module could forge a lifecycle event —
|
||||
// announce that a rival crashed, or that a module it wants others to call is
|
||||
// `ready`. The read surface is open; this one must not be.
|
||||
//
|
||||
// Why the token is an ARGUMENT and not a policy entry
|
||||
// liblogos' AccessPolicy restricts by TARGET MODULE, with no per-method
|
||||
// granularity. Restricting modules_state to caller "core" would lock out
|
||||
// every reader, which is the entire point of the module. So the authority for
|
||||
// the two ingest methods has to ride in the call itself.
|
||||
// THE GATE IS STRUCTURAL: the caller must be the HOST. A push from core arrives
|
||||
// as {"kind":"host"}; a call from any module arrives as {"kind":"module",...}.
|
||||
// So authority is what the caller IS, not what it knows, and there is no secret
|
||||
// to distribute, rotate or leak. This replaced an authToken compared against a
|
||||
// per-run nonce, which existed only because the accessor did not.
|
||||
//
|
||||
// Why this module cannot just check who called it
|
||||
// LogosModuleContext exposes this module's own identity (moduleName(),
|
||||
// instanceId(), modulePath()) and nothing about the CALLER. There is no
|
||||
// caller-identity accessor to check against, so a shared secret is what is
|
||||
// actually available today. If a caller-identity accessor lands later, this
|
||||
// gate should be rewritten to use it and the token retired — a structural
|
||||
// check beats a secret.
|
||||
// WHAT `host` MEANS: rule 5 of the caller contract gives the host arm no name,
|
||||
// because "core" and "capability_module" hold the same token value under two
|
||||
// keys and a name there "would be a coin flip presented as a fact". So this
|
||||
// admits core OR capability_module — both host-side runtime components rather
|
||||
// than peer modules, which is the distinction that matters.
|
||||
//
|
||||
// The gate, and it is FAIL-CLOSED
|
||||
// LOGOS_MODULES_STATE_INGEST_TOKEN set and non-empty
|
||||
// -> ingest requires an exact match. This is the production shape: the
|
||||
// host generates a per-run nonce, puts it in the module subprocess's
|
||||
// environment when it spawns it, and sends it with every push. The
|
||||
// module subprocess's environment is not readable by other modules'
|
||||
// subprocesses, so the secret does not leak sideways.
|
||||
// unset
|
||||
// -> ALL ingest is refused. A build with no configured authority reports
|
||||
// only what it was given by someone who proved authority, which is
|
||||
// nothing. That is the correct answer, not an inconvenience.
|
||||
// unset AND LOGOS_MODULES_STATE_TEST_INGEST=1
|
||||
// -> the TEST-ONLY escape. Any token is accepted, and the module says so
|
||||
// loudly on stderr once at first use and again on every accepted call,
|
||||
// so an accidental production run is visible in the logs rather than
|
||||
// silent.
|
||||
// FAIL CLOSED ON `unknown`. currentCaller() answers Unknown for anything that
|
||||
// is not an inbound dispatch, and for any identity this build cannot parse.
|
||||
// The case that bites is a MISPINNED BUILD: a stale logos-module-builder
|
||||
// produces a plugin with no caller machinery at all, so every push is refused
|
||||
// while everything still compiles, links and loads. This module ships alongside
|
||||
// a host that carries the machinery, and that pairing is the mitigation — there
|
||||
// is no counter on this surface to ask. The refusal goes to stderr, naming what
|
||||
// the caller actually was.
|
||||
//
|
||||
// The test escape is deliberately an ENVIRONMENT variable and not a method.
|
||||
// A method — `enable_test_ingest()` — would itself be callable by any module,
|
||||
// which would make the gate decorative. Environment is set by whoever launches
|
||||
// the process, which is the host or the developer, and never by a peer module.
|
||||
//
|
||||
// STAGE 1 STATUS: nothing sets LOGOS_MODULES_STATE_INGEST_TOKEN yet, because
|
||||
// liblogos does not push yet. Every Stage-1 verification run therefore uses
|
||||
// LOGOS_MODULES_STATE_TEST_INGEST=1, and a run WITHOUT it is the proof that the
|
||||
// gate is closed by default.
|
||||
// THE TEST DOOR: LOGOS_MODULES_STATE_TEST_INGEST=1 accepts any caller, loudly.
|
||||
// A unit test calls the impl directly, so there is no dispatch and no caller,
|
||||
// and without it these invariants could only be driven through a live daemon —
|
||||
// which CI does not do. It is an environment variable and not a method on
|
||||
// purpose: a method would itself be callable by any module.
|
||||
bool testIngestEnabled()
|
||||
{
|
||||
const char* v = std::getenv("LOGOS_MODULES_STATE_TEST_INGEST");
|
||||
return v != nullptr && std::strcmp(v, "1") == 0;
|
||||
}
|
||||
|
||||
const char* configuredIngestToken()
|
||||
{
|
||||
const char* v = std::getenv("LOGOS_MODULES_STATE_INGEST_TOKEN");
|
||||
if (v == nullptr || v[0] == '\0')
|
||||
return nullptr;
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -211,25 +147,16 @@ const char* configuredIngestToken()
|
||||
struct ModulesStateRegistry {
|
||||
std::mutex mutex;
|
||||
|
||||
// MEMBERSHIP IS THIS MAP. Every module the host's view contains, and
|
||||
// nothing else. No entry here ever has state "absent" — that state is an
|
||||
// event-only transition target, so leaving the view means leaving the map.
|
||||
// MEMBERSHIP IS THIS MAP. No entry ever has state "absent": that is an
|
||||
// event-only target, so leaving the view means leaving the map.
|
||||
std::map<std::string, StoredRecord> records;
|
||||
|
||||
// Modules that WERE in `records` and are not any more, with the seq at
|
||||
// which each left. Tombstones, and they exist for exactly one reason: the
|
||||
// REPLAY RULE has to stay total.
|
||||
//
|
||||
// Before `absent` was narrowed, a departed module stayed in `records` as an
|
||||
// absent record and its `seq` was what a late delta was compared against.
|
||||
// Now it is erased — so without this table a delta that lost a race would
|
||||
// find no stored seq, pass the rule, and resurrect a module the host has
|
||||
// already pruned. This is the same tombstone, minus the fake record: one
|
||||
// uint64 instead of a whole ModuleRecord, and unreachable from any read
|
||||
// method, so it cannot be mistaken for membership.
|
||||
//
|
||||
// It grows with the number of modules ever seen, not with events. That is
|
||||
// bounded by what is installed on the machine.
|
||||
// Tombstones: modules that left `records`, and the seq at which each left.
|
||||
// They exist so the REPLAY RULE stays total — without them a delta that
|
||||
// lost a race would find no stored seq, pass the rule, and resurrect a
|
||||
// module the host already pruned. Unreachable from any read method, so it
|
||||
// cannot be mistaken for membership. Grows with modules ever seen, not with
|
||||
// events, so it is bounded by what is installed.
|
||||
std::map<std::string, uint64_t> departedSeq;
|
||||
|
||||
// Highest seq applied from any source. Reported as ModuleListing::seq.
|
||||
@@ -238,11 +165,6 @@ struct ModulesStateRegistry {
|
||||
// See list_modules() for why this starts TRUE.
|
||||
bool partial = true;
|
||||
|
||||
// Refusals by the authority gate specifically — NOT stale-seq drops and NOT
|
||||
// malformed arguments. Kept narrow so a test asserting "the gate is closed"
|
||||
// asserts exactly that.
|
||||
uint64_t rejectedIngest = 0;
|
||||
|
||||
bool warnedAboutTestIngest = false;
|
||||
};
|
||||
|
||||
@@ -287,24 +209,18 @@ static bool storedSeqLocked(const std::string& module, uint64_t& out)
|
||||
ModulesStateImpl::ModulesStateImpl() = default;
|
||||
ModulesStateImpl::~ModulesStateImpl() = default;
|
||||
|
||||
// Shared by both ingest methods. Returns true when the caller proved authority;
|
||||
// increments the refusal counter and complains on stderr when it did not.
|
||||
static bool ingestAuthorised(const std::string& authToken)
|
||||
// Shared by both ingest methods. Returns true when the caller IS the host;
|
||||
// increments the refusal counter and complains on stderr when it is not.
|
||||
//
|
||||
// Order matters: the structural check comes first, so a machine that has the
|
||||
// test door open for some other module's suite still takes the real path here
|
||||
// whenever the real path can answer.
|
||||
static bool ingestAuthorised()
|
||||
{
|
||||
const char* expected = configuredIngestToken();
|
||||
const logos::LogosCaller caller = logos::currentCaller();
|
||||
|
||||
if (expected != nullptr) {
|
||||
// Length-independent comparison is not worth it here: the token is a
|
||||
// per-run nonce and a peer module gets one guess per RPC, not a timing
|
||||
// oracle. Simplicity beats a false sense of hardening.
|
||||
if (authToken == expected)
|
||||
return true;
|
||||
std::lock_guard<std::mutex> lock(reg().mutex);
|
||||
++reg().rejectedIngest;
|
||||
std::fprintf(stderr,
|
||||
"[modules_state] REFUSED ingest: bad authToken\n");
|
||||
return false;
|
||||
}
|
||||
if (caller.isHost())
|
||||
return true;
|
||||
|
||||
if (testIngestEnabled()) {
|
||||
bool warn = false;
|
||||
@@ -316,20 +232,35 @@ static bool ingestAuthorised(const std::string& authToken)
|
||||
if (warn) {
|
||||
std::fprintf(stderr,
|
||||
"[modules_state] *** TEST INGEST ENABLED ***\n"
|
||||
"[modules_state] LOGOS_MODULES_STATE_TEST_INGEST=1 and no\n"
|
||||
"[modules_state] LOGOS_MODULES_STATE_INGEST_TOKEN is set, so ANY\n"
|
||||
"[modules_state] caller can write lifecycle facts. This is for\n"
|
||||
"[modules_state] testing only. Never set this in production.\n");
|
||||
"[modules_state] LOGOS_MODULES_STATE_TEST_INGEST=1, so ANY caller\n"
|
||||
"[modules_state] can write lifecycle facts regardless of identity.\n"
|
||||
"[modules_state] This is for testing only. Never set it in production.\n");
|
||||
}
|
||||
std::fprintf(stderr, "[modules_state] TEST INGEST: accepting unauthenticated write\n");
|
||||
std::fprintf(stderr, "[modules_state] TEST INGEST: accepting write from a non-host caller\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(reg().mutex);
|
||||
++reg().rejectedIngest;
|
||||
std::fprintf(stderr,
|
||||
"[modules_state] REFUSED ingest: no LOGOS_MODULES_STATE_INGEST_TOKEN is\n"
|
||||
"[modules_state] configured, so this build accepts no writes at all.\n");
|
||||
// Refused. The message names what the caller ACTUALLY was, because the two
|
||||
// ways to land here need different fixes and are otherwise
|
||||
// indistinguishable from outside:
|
||||
//
|
||||
// kind=module a peer module tried to write lifecycle facts. Working as
|
||||
// intended; this is the case the gate exists for.
|
||||
// kind=unknown either a non-dispatch context, or A MISPINNED BUILD whose
|
||||
// plugin carries no caller machinery at all. The second is
|
||||
// silent everywhere else — it compiles, links and loads —
|
||||
// so naming it here is the only warning anyone gets.
|
||||
if (caller.isUnknown()) {
|
||||
std::fprintf(stderr,
|
||||
"[modules_state] REFUSED ingest: caller identity is UNKNOWN.\n"
|
||||
"[modules_state] Either this was not an inbound call, or this build\n"
|
||||
"[modules_state] carries no caller machinery -- check that\n"
|
||||
"[modules_state] logos-module-builder is not pinned stale.\n");
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"[modules_state] REFUSED ingest: caller is '%s', not the host.\n",
|
||||
caller.name.empty() ? "<unnamed non-host>" : caller.name.c_str());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -343,27 +274,20 @@ ModuleListing ModulesStateImpl::list_modules()
|
||||
std::lock_guard<std::mutex> lock(reg().mutex);
|
||||
|
||||
out.modules.reserve(reg().records.size());
|
||||
// std::map iterates in key order, so the listing is sorted by module name.
|
||||
// Deterministic output is not cosmetic: it is what lets a test diff two
|
||||
// listings instead of set-comparing them.
|
||||
// std::map iterates in key order, so the listing is sorted — deterministic
|
||||
// output lets a test diff two listings instead of set-comparing them.
|
||||
//
|
||||
// NOT FILTERED, on purpose. `records` is membership, so every entry belongs
|
||||
// in the listing and none of them can be "absent" — the two mutators are
|
||||
// the only writers and neither can store that state. A defensive filter
|
||||
// here would hide the bug it was written to catch, and would re-introduce
|
||||
// the second spelling of "not there" that narrowing `absent` removed.
|
||||
// NOT FILTERED, on purpose: `records` is membership and neither mutator can
|
||||
// store "absent", so a defensive filter would hide the bug it was written
|
||||
// to catch.
|
||||
for (const auto& kv : reg().records)
|
||||
out.modules.push_back(kv.second);
|
||||
|
||||
// `partial` starts TRUE and only a snapshot can clear it.
|
||||
//
|
||||
// Before a snapshot has arrived, everything here was learned from
|
||||
// individual deltas — which by construction only mention modules that
|
||||
// CHANGED since this module came up. A module that has been quietly loaded
|
||||
// the whole time is missing from that view. Reporting partial:false then
|
||||
// would be a confidently short list, which is the exact failure this flag
|
||||
// exists to prevent. After a snapshot, `partial` is whatever the host said:
|
||||
// true when the host's own scan skipped a module it could not read.
|
||||
// `partial` starts TRUE and only a snapshot can clear it. Deltas by
|
||||
// construction only mention modules that CHANGED, so a module quietly
|
||||
// loaded the whole time is missing from that view — reporting false would
|
||||
// be a confidently short list, the exact failure this flag prevents. After
|
||||
// a snapshot it is whatever the host said.
|
||||
out.partial = reg().partial;
|
||||
out.seq = reg().highWaterSeq;
|
||||
return out;
|
||||
@@ -375,9 +299,8 @@ std::optional<ModuleRecord> ModulesStateImpl::module_record(const std::string& m
|
||||
auto it = reg().records.find(module);
|
||||
if (it != reg().records.end())
|
||||
return it->second;
|
||||
// The miss. A tombstone is not a hit: a module that was pruned is exactly
|
||||
// as absent as one never discovered, and this method's job is to answer
|
||||
// membership, not history.
|
||||
// A tombstone is not a hit: a pruned module is exactly as absent as one
|
||||
// never discovered. This answers membership, not history.
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -390,31 +313,25 @@ bool ModulesStateImpl::is_ready(const std::string& module)
|
||||
return stateIsReady(it->second.state);
|
||||
}
|
||||
|
||||
uint64_t ModulesStateImpl::rejected_ingest_count()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(reg().mutex);
|
||||
return reg().rejectedIngest;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ingest surface
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
bool ModulesStateImpl::note_transition(const std::string& authToken,
|
||||
const std::string& module,
|
||||
const std::optional<std::string>& instance,
|
||||
const std::optional<int64_t>& pid,
|
||||
const std::string& old_state,
|
||||
const std::string& new_state,
|
||||
const std::optional<std::string>& reason,
|
||||
uint64_t seq)
|
||||
bool ModulesStateImpl::note_transition(const std::string& module,
|
||||
const std::optional<std::string>& instance,
|
||||
const std::optional<int64_t>& pid,
|
||||
const std::string& old_state,
|
||||
const std::string& new_state,
|
||||
const std::optional<std::string>& reason,
|
||||
uint64_t seq)
|
||||
{
|
||||
if (!ingestAuthorised(authToken))
|
||||
if (!ingestAuthorised())
|
||||
return false;
|
||||
|
||||
// Malformed arguments are refused but NOT counted as an authority refusal:
|
||||
// rejected_ingest_count() has to mean "the gate turned someone away" and
|
||||
// nothing else, or it stops being usable as a test assertion.
|
||||
// Malformed arguments are refused, and are a different thing from the
|
||||
// authority refusal above: the caller proved it was the host and then sent
|
||||
// something unusable.
|
||||
if (module.empty() || old_state.empty() || new_state.empty())
|
||||
return false;
|
||||
|
||||
@@ -428,28 +345,18 @@ bool ModulesStateImpl::note_transition(const std::string& authToken,
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(reg().mutex);
|
||||
|
||||
// THE REPLAY RULE. Applied if and only if seq is strictly newer than
|
||||
// what is stored for THIS module.
|
||||
//
|
||||
// Delivery is not ordered. The first push to an un-tokened target
|
||||
// coalesces behind a token handshake, so a later push can complete
|
||||
// first. Per-module seq makes that harmless with no in-flight buffer,
|
||||
// no timing assumption and no lock held across an RPC in either
|
||||
// direction: a stale delta is simply dropped.
|
||||
// THE REPLAY RULE: applied iff seq is strictly newer than what is
|
||||
// stored for THIS module. Delivery is not ordered, so a later push can
|
||||
// land first; per-module seq makes that harmless with no buffering, no
|
||||
// timing assumption and no lock held across an RPC.
|
||||
uint64_t stored = 0;
|
||||
if (storedSeqLocked(module, stored) && seq <= stored)
|
||||
return false;
|
||||
|
||||
// THE MEMBERSHIP EDGE. `absent` is event-only, so a transition INTO it
|
||||
// does not store an absent record — it removes the module from the
|
||||
// listing and leaves a seq tombstone behind. The event still fires
|
||||
// below, carrying the pair the caller computed, and the high-water seq
|
||||
// still advances, so a consumer re-reading list_modules can still tell
|
||||
// that something moved.
|
||||
//
|
||||
// This is the whole mechanism behind list_modules' never-absent
|
||||
// invariant: there is no spelling of this method that puts an absent
|
||||
// record into `records`.
|
||||
// THE MEMBERSHIP EDGE. A transition INTO `absent` stores no record: it
|
||||
// removes the module and leaves a seq tombstone. The event still fires
|
||||
// and the high-water seq still advances. This is the whole mechanism
|
||||
// behind list_modules' never-absent invariant.
|
||||
if (new_state == kAbsent) {
|
||||
// Leaving the view. Erase the record and leave a seq tombstone, so
|
||||
// a delta that lost a race cannot resurrect a module the host has
|
||||
@@ -470,29 +377,19 @@ bool ModulesStateImpl::note_transition(const std::string& authToken,
|
||||
rec.state = new_state;
|
||||
rec.reason = reason;
|
||||
rec.seq = seq;
|
||||
// Two classes of field, treated differently on purpose:
|
||||
//
|
||||
// instance / pid are OVERWRITTEN, including to empty. They
|
||||
// describe the incarnation this transition is ABOUT — an
|
||||
// unload's pid is the pid that just went away — so a delta is
|
||||
// authoritative for them and a delta that says "no pid" means
|
||||
// there is no pid.
|
||||
//
|
||||
// path / type / version / dependencies / dependents / loadedAt
|
||||
// are SNAPSHOT-ONLY. A transition carries the lifecycle change,
|
||||
// not the module's static metadata, so whatever a previous
|
||||
// snapshot put there is preserved rather than blanked. A record
|
||||
// first learned from a delta simply has them empty until a
|
||||
// snapshot fills them.
|
||||
// instance/pid are OVERWRITTEN, including to empty: they describe
|
||||
// the incarnation this transition is ABOUT, so a delta saying "no
|
||||
// pid" means there is no pid. path/type/version/deps/loadedAt are
|
||||
// SNAPSHOT-ONLY and preserved rather than blanked — a delta carries
|
||||
// the lifecycle change, not the static metadata.
|
||||
}
|
||||
|
||||
if (seq > reg().highWaterSeq)
|
||||
reg().highWaterSeq = seq;
|
||||
|
||||
// The pair forwarded is the one the CALLER computed, not
|
||||
// (ourStoredState -> new_state). Core computes old_state atomically
|
||||
// with its own write, so its pair is the authoritative description of
|
||||
// what happened; our stored value can only be older.
|
||||
// The pair forwarded is the CALLER's, not (ourStoredState -> new).
|
||||
// Core computes old_state atomically with its own write, so its pair is
|
||||
// authoritative; our stored value can only be older.
|
||||
pending = PendingEvent{module, instance, pid, old_state, new_state, reason, seq};
|
||||
}
|
||||
|
||||
@@ -504,10 +401,9 @@ bool ModulesStateImpl::note_transition(const std::string& authToken,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ModulesStateImpl::apply_snapshot(const std::string& authToken,
|
||||
const ModuleListing& listing)
|
||||
bool ModulesStateImpl::apply_snapshot(const ModuleListing& listing)
|
||||
{
|
||||
if (!ingestAuthorised(authToken))
|
||||
if (!ingestAuthorised())
|
||||
return false;
|
||||
|
||||
std::vector<PendingEvent> pending;
|
||||
@@ -520,16 +416,11 @@ bool ModulesStateImpl::apply_snapshot(const std::string& authToken,
|
||||
if (incoming.module.empty())
|
||||
continue;
|
||||
|
||||
// A snapshot record claiming "absent" is contract-malformed: the
|
||||
// state is event-only, and a snapshot spells non-membership by
|
||||
// OMISSION. Skipping it makes it mean exactly what omitting it
|
||||
// would have meant — the prune loop below then drops whatever we
|
||||
// hold for that name. One rule for leaving the listing, not two.
|
||||
// An EMPTY state is skipped by the same rule. `state` is tstr on
|
||||
// the wire with no enum to enforce it, so a record that simply
|
||||
// omitted the field would otherwise be admitted with state "" —
|
||||
// a seventh, undeclared state, reachable from outside and
|
||||
// indistinguishable from a real one to every consumer.
|
||||
// "absent" is contract-malformed here — a snapshot spells
|
||||
// non-membership by OMISSION — so skipping makes it mean exactly
|
||||
// what omitting it would have. An EMPTY state is skipped by the
|
||||
// same rule: `state` is tstr with no enum, so an omitted field
|
||||
// would otherwise be admitted as a seventh, undeclared state.
|
||||
if (incoming.state == kAbsent || incoming.state.empty())
|
||||
continue;
|
||||
|
||||
@@ -546,10 +437,8 @@ bool ModulesStateImpl::apply_snapshot(const std::string& authToken,
|
||||
if (storedSeqLocked(incoming.module, stored) && incoming.seq <= stored)
|
||||
continue;
|
||||
|
||||
// An unknown module is one the host has just DISCOVERED, whether it
|
||||
// is unknown because we never heard of it or because it departed
|
||||
// and came back. Either way the edge it just crossed is
|
||||
// absent -> incoming.state, which is the membership edge.
|
||||
// Unknown means just DISCOVERED — never heard of, or departed and
|
||||
// come back. Either way the edge crossed is absent -> state.
|
||||
const std::string previousState = known ? it->second.state : std::string(kAbsent);
|
||||
|
||||
reg().records[incoming.module] = incoming;
|
||||
|
||||
+103
-211
@@ -1,37 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// modules_state — the read-only registry of module lifecycle state.
|
||||
//
|
||||
// WHAT THIS IS
|
||||
// Today there are no module-lifecycle events at all: load, unload and crash
|
||||
// are spdlog lines inside logos-liblogos' ModuleManager, so every consumer
|
||||
// polls. This module is the place that state becomes a first-class, queryable,
|
||||
// subscribable fact.
|
||||
// Load, unload and crash are spdlog lines inside liblogos' ModuleManager, so
|
||||
// every consumer polls. This is where that state becomes queryable and
|
||||
// subscribable. It REPORTS ONLY; load/unload stays with liblogos' C API.
|
||||
//
|
||||
// WHAT THIS IS NOT
|
||||
// It does not drive load/unload. Nothing here starts, stops or restarts a
|
||||
// module — that stays with liblogos' C API. This module only *reports*.
|
||||
// read — list_modules / module_record / is_ready + module_state_changed.
|
||||
// Open to every module.
|
||||
// ingest — note_transition / apply_snapshot. Host only; see INGEST AUTHORITY
|
||||
// in the .cpp.
|
||||
//
|
||||
// THE TWO SURFACES
|
||||
// read — list_modules / module_record / is_ready, plus the
|
||||
// module_state_changed event. Open to every module.
|
||||
// ingest — note_transition / apply_snapshot. Gated by `authToken`, and
|
||||
// intended for exactly one caller: liblogos core. See INGEST
|
||||
// AUTHORITY in modules_state_impl.cpp.
|
||||
//
|
||||
// STAGE 1 (this repo, right now)
|
||||
// Nothing feeds it. The ingest surface is the only way state enters, which
|
||||
// makes it both the future core seam AND the Stage-1 test injection point.
|
||||
//
|
||||
// AUTHORING RULES (universal / Qt-free) — the generator parses this header as
|
||||
// text, so these are hard constraints, not style:
|
||||
// AUTHORING RULES (universal/Qt-free). The generator parses this header as
|
||||
// TEXT, so these are constraints, not style:
|
||||
// * no Qt types; std + declared structs only.
|
||||
// * no trailing `// comment` on a declaration line — the parser only accepts
|
||||
// a line ending in `;`, and silently DROPS anything else. Comments go above.
|
||||
// * event params that are non-scalar must be `const T&`.
|
||||
// * do not declare name()/version() — auto-injected as `derived`.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// * no trailing `// comment` on a declaration line — the parser wants a line
|
||||
// ending in `;` and silently DROPS anything else. Comments go above.
|
||||
// * non-scalar event params must be `const T&`.
|
||||
// * do not declare name()/version(); they are auto-injected as `derived`.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -40,110 +26,57 @@
|
||||
|
||||
#include <logos_module_context.h>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// THE STATE VOCABULARY (wire type: tstr — LIDL has no enum)
|
||||
// THE STATE VOCABULARY (wire type: tstr — LIDL has no enum)
|
||||
//
|
||||
// Six RECORD states — the ones a record returned by list_modules or
|
||||
// module_record may carry:
|
||||
// Six RECORD states, which a record may carry:
|
||||
// unloaded known and installed, not running
|
||||
// loading the host has selected a loader and is bringing it up
|
||||
// loaded the host owns the process; the object may not be up yet
|
||||
// ready loaded AND the module has published its object
|
||||
// stopping an orderly teardown is in progress
|
||||
// error it exited without being asked to
|
||||
//
|
||||
// "unloaded" known and installed, not running
|
||||
// "loading" the host has selected a loader and is bringing it up
|
||||
// "loaded" the plugin is up and its provider has published
|
||||
// "ready" loaded AND has completed its own readiness work
|
||||
// "stopping" an orderly teardown is in progress
|
||||
// "error" it exited without being asked to
|
||||
// One EVENT-ONLY state, which no record ever carries:
|
||||
// absent the host does not know this module
|
||||
//
|
||||
// and one EVENT-ONLY state, which no record ever carries:
|
||||
// WHY `absent` SURVIVES — do not delete it as redundant. It is the only way to
|
||||
// name the two MEMBERSHIP EDGES, and those are this module's headline claim:
|
||||
// `absent -> unloaded` on discovery, `unloaded -> absent` on prune (liblogos
|
||||
// module_registry.cpp). A transition needs a state on both sides; without it
|
||||
// those edges cannot be events at all, and a consumer is back to inferring
|
||||
// membership from package-install events plus a settle timer, which is what
|
||||
// basecamp's PackageCoordinator does today.
|
||||
//
|
||||
// "absent" the host does not know this module
|
||||
// AND WHY IT IS EVENT-ONLY. A module that is absent is simply NOT IN the
|
||||
// listing, and module_record answers the empty optional — one spelling for
|
||||
// "not there", not two. The invariant holds BY CONSTRUCTION: a transition into
|
||||
// `absent` removes the record, so no spelling of the ingest surface can store
|
||||
// one.
|
||||
//
|
||||
// ── WHY "absent" SURVIVES — DO NOT DELETE IT AS REDUNDANT ────────────────────
|
||||
// DIVERGENCE FROM THE DRAFT SPECS (logos-lips#317). spec-module-runtime §3.4
|
||||
// has no `absent`, so we diverge by one event-only transition target, not a
|
||||
// sixth record state — a consumer reading records sees only spec vocabulary.
|
||||
// Separately, `loading` is a record state we have and the drafts fold into
|
||||
// `loaded`; folding is a live option and is not settled here.
|
||||
//
|
||||
// It is the only way to name the two MEMBERSHIP EDGES of the graph, and those
|
||||
// edges are this module's headline claim:
|
||||
// `ready` is emitted by liblogos when the module publishes its object, which is
|
||||
// later than `loaded` — the host owning the process. That gap is the window
|
||||
// is_ready() exists to answer, and is why the read surface says is_ready() and
|
||||
// not isLoaded().
|
||||
//
|
||||
// absent -> unloaded the host has just DISCOVERED a module. In liblogos
|
||||
// that is the upsert loop of
|
||||
// discoverInstalledModules (module_registry.cpp:80-94).
|
||||
// unloaded -> absent the host has just PRUNED one, because its files went
|
||||
// away (module_registry.cpp:96-108).
|
||||
//
|
||||
// A transition needs a state on BOTH sides. Strike `absent` and those two
|
||||
// edges have no old_state and no new_state to ride on, so they cannot be
|
||||
// events at all — and a consumer is back to what logos-basecamp does today:
|
||||
// PackageCoordinator.cpp:117-157 infers module lifecycle from PACKAGE-INSTALL
|
||||
// events plus a 100ms QTimer settle. Guessing membership from package events is
|
||||
// exactly what this module exists to stop doing.
|
||||
//
|
||||
// ── AND WHY IT IS EVENT-ONLY ─────────────────────────────────────────────────
|
||||
//
|
||||
// `absent` may appear as old_state or new_state in module_state_changed. It may
|
||||
// NEVER be the `state` of a record. A module that is absent is simply NOT IN
|
||||
// list_modules, and module_record answers an empty optional.
|
||||
//
|
||||
// It used to carry a second load: it was also module_record's MISS ANSWER, a
|
||||
// workaround for a generator that refused `-> ?T`. That refusal is gone, so the
|
||||
// miss is an empty optional and the second load with it. Two spellings for "not
|
||||
// there" is the drift this narrowing removes — a consumer that filtered
|
||||
// `state == "absent"` out of a listing and a consumer that checked has_value()
|
||||
// were two code paths for one fact, and only one of them stayed correct.
|
||||
//
|
||||
// The invariant holds BY CONSTRUCTION, not by convention: a transition into
|
||||
// `absent` removes the record (see note_transition in the .cpp), so there is no
|
||||
// spelling of the ingest surface that can put an absent record into the store.
|
||||
//
|
||||
// ── DIVERGENCE FROM THE DRAFT CORE SPECS (logos-lips#317) ────────────────────
|
||||
//
|
||||
// spec-module-runtime.md §3.4 spells the runtime's vocabulary
|
||||
// `unloaded | loaded | ready | stopping | error` — five states, no `absent`,
|
||||
// and it is normative there as a CDDL enum (logos.runtime_control.state).
|
||||
//
|
||||
// After the narrowing above, `absent` diverges as exactly one EVENT-ONLY
|
||||
// TRANSITION TARGET rather than as a sixth record state. A consumer that reads
|
||||
// records already sees only spec vocabulary; only an event subscriber ever sees
|
||||
// `absent`, and only on the two membership edges above. The drafts have no way
|
||||
// to say "discovered" or "pruned" — registry membership is not in their state
|
||||
// machine at all — so this is a deliberate addition, not drift.
|
||||
//
|
||||
// One other divergence, named here so it is not mistaken for the same thing:
|
||||
// `loading` is a RECORD state we have and the drafts do not. Their `loaded`
|
||||
// covers our loading+loaded ("Runtime has acquired or attached the selected
|
||||
// realization and is performing the applicable initialization and readiness
|
||||
// checks"). Folding the two is a live option; it is a separate question from
|
||||
// `absent` and is not settled here.
|
||||
//
|
||||
// ── REACHABILITY, TODAY ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Against liblogos as it stands: absent, unloaded, loading, loaded, stopping
|
||||
// and error all have real emission points. "ready" does not — it becomes real
|
||||
// when a module can report its own readiness. It ships anyway so consumers
|
||||
// written now are not rewritten then; that is the same reason the read surface
|
||||
// says is_ready() and not isLoaded().
|
||||
//
|
||||
// NORMATIVE FORWARD-COMPATIBILITY RULE — this is the whole point of shipping
|
||||
// the full vocabulary, so it is a rule and not advice:
|
||||
// A consumer MUST treat an unrecognised state string as forward-compatible
|
||||
// and fall back to "not loaded". It MUST NOT treat it as an error and MUST
|
||||
// NOT crash. Without this, the day "ready" starts being emitted is the day
|
||||
// every existing consumer breaks — the exact failure the full vocabulary
|
||||
// exists to prevent.
|
||||
//
|
||||
// The canonical strings are exposed as functions rather than an enum because
|
||||
// the wire type is tstr; see modules_state_impl.cpp.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FORWARD-COMPATIBILITY RULE, normative: a consumer MUST treat an unrecognised
|
||||
// state as forward-compatible and fall back to "not loaded". It must not error
|
||||
// or crash. Otherwise the day `ready` starts being emitted is the day every
|
||||
// existing consumer breaks.
|
||||
|
||||
// One module's lifecycle facts, as reported by the host.
|
||||
//
|
||||
// `state` is one of the six RECORD states. It is never "absent": that one is an
|
||||
// event-only transition target, and a record for a module that is not there is
|
||||
// not a record — it is the empty optional module_record answers with.
|
||||
// `state` is never "absent" — that is event-only, and a record for a module
|
||||
// that is not there is the empty optional instead.
|
||||
//
|
||||
// `instance` and `pid` answer different questions and both are cheap:
|
||||
// instance — the host's persistence identity. STABLE across load/unload
|
||||
// cycles (ResolveMode::ReuseOrCreate), so it cannot tell you a
|
||||
// module died and came back.
|
||||
// pid — the process incarnation. A changed pid between two reads IS
|
||||
// that answer.
|
||||
// `instance` and `pid` answer different questions: instance is the host's
|
||||
// persistence identity and is STABLE across load/unload cycles, so only a
|
||||
// changed pid tells you a module died and came back.
|
||||
struct ModuleRecord {
|
||||
std::string module;
|
||||
std::optional<std::string> instance;
|
||||
@@ -161,13 +94,11 @@ struct ModuleRecord {
|
||||
|
||||
// The answer to list_modules().
|
||||
//
|
||||
// `partial` is an honest short answer, not a health flag: it is true when the
|
||||
// host's last scan SKIPPED at least one module (unreadable metadata, or a
|
||||
// failed trusted-name check). A silently short list is worse than a flagged
|
||||
// one.
|
||||
// `partial` is an honest short answer, not a health flag: true when the host's
|
||||
// last scan SKIPPED a module. A silently short list is worse than a flagged one.
|
||||
//
|
||||
// `seq` is the listing-level high-water mark. Paired with each record's own
|
||||
// `seq` it lets a consumer re-read and tell whether anything moved underneath.
|
||||
// `seq` is the listing-level high-water mark, so a consumer re-reading can tell
|
||||
// whether anything moved underneath.
|
||||
struct ModuleListing {
|
||||
std::vector<ModuleRecord> modules;
|
||||
bool partial;
|
||||
@@ -183,86 +114,55 @@ public:
|
||||
|
||||
// Every module the host knows about, with its current state.
|
||||
//
|
||||
// MEMBERSHIP IS THE LISTING. No record here ever carries state "absent" —
|
||||
// a module that is absent is not a member, so it is not in `modules` at
|
||||
// all. A consumer must not filter for it, and a consumer that finds it has
|
||||
// found a bug in this module, not a module that went away.
|
||||
// MEMBERSHIP IS THE LISTING: no record here carries "absent". A consumer
|
||||
// must not filter for it, and one that finds it has found a bug here.
|
||||
ModuleListing list_modules();
|
||||
|
||||
// One module's record — or nothing at all.
|
||||
//
|
||||
// EMPTY means "the host's view does not contain this module": never
|
||||
// discovered, or discovered and since pruned. That is not an error, and it
|
||||
// is not a record either. `absent` is an EVENT-ONLY state (see THE STATE
|
||||
// VOCABULARY above), so there is exactly ONE spelling for "not there" and
|
||||
// this is it.
|
||||
// EMPTY means the host's view does not contain it: never discovered, or
|
||||
// discovered and since pruned. Not an error.
|
||||
//
|
||||
// Why an optional and not a sentinel record: std::optional has a state no
|
||||
// ModuleRecord value occupies, so nullopt is distinct from
|
||||
// ModuleRecord{} — the distinction a bare record could never make. A caller
|
||||
// branches on has_value(), not on a sentinel field it has to be told to
|
||||
// look at. (It used to be told: the miss came back as `state:"absent"`,
|
||||
// `seq:0`. That was a workaround for a generator that refused `-> ?T`, the
|
||||
// refusal is gone, and the workaround went with it.)
|
||||
//
|
||||
// Over the wire the empty answer is JSON null, and on every surface this
|
||||
// module is reached through, null does NOT mean the call failed:
|
||||
// * the generated Qt consumer decides success from the C ABI return code
|
||||
// (rc == LP_OK) and reports failure on a SEPARATE channel,
|
||||
// logos::CallError — never from the value. See logos-qt-sdk,
|
||||
// qt-generator/lidl_gen_qt_consumer.cpp:524-558.
|
||||
// * logoscore's `logosctl module call` answers
|
||||
// {"status":"ok","result":null}. On a null return it asks the module
|
||||
// for its published method list and says METHOD_NOT_FOUND only when the
|
||||
// method genuinely is not there — logos-logoscore-cli,
|
||||
// src/core_service/call_envelope.cpp:81-103.
|
||||
// Over the wire that is JSON null, and null does NOT mean the call failed:
|
||||
// the generated Qt consumer decides success from the C ABI return code and
|
||||
// reports failure on logos::CallError, never from the value; logoscore
|
||||
// answers {"status":"ok","result":null} and says METHOD_NOT_FOUND only
|
||||
// after checking the module's published method list.
|
||||
std::optional<ModuleRecord> module_record(const std::string& module);
|
||||
|
||||
// True when this module is up and usable *from the host's point of view*.
|
||||
// True when this module is up and usable FROM THE HOST'S POINT OF VIEW.
|
||||
//
|
||||
// READ THE LIMIT: this answers "the host has it loaded and it has
|
||||
// published". It does NOT answer "a call from ME to it will succeed" —
|
||||
// that additionally needs the per-caller token handshake, which is
|
||||
// per-caller and therefore not a fact this module can hold. A caller that
|
||||
// needs "can I call it" wants whenObjectAvailable() on its own client, not
|
||||
// this. Using is_ready() for that trades a working poll for a predicate
|
||||
// that goes true a few hundred milliseconds early.
|
||||
// READ THE LIMIT: it does not answer "a call from ME will succeed" — that
|
||||
// additionally needs the per-caller token handshake, which is per-caller
|
||||
// and so not a fact this module can hold. For "can I call it", use
|
||||
// whenObjectAvailable() on your own client; this predicate goes true a few
|
||||
// hundred milliseconds early for that purpose.
|
||||
bool is_ready(const std::string& module);
|
||||
|
||||
// ── INGEST SURFACE (authToken-gated; core only) ───────────────────────────
|
||||
// ── INGEST SURFACE (host only) ───────────────────────────────────────────
|
||||
|
||||
// Record one state transition. Returns true when it was applied.
|
||||
// Record one state transition. True when applied.
|
||||
//
|
||||
// Returns FALSE for three different reasons, deliberately not
|
||||
// distinguished on the wire (a probe must not be able to tell a bad token
|
||||
// from a stale seq):
|
||||
// * the token was refused,
|
||||
// * `seq` was not newer than what is already stored for this module,
|
||||
// * old_state/new_state were empty.
|
||||
// FALSE covers three cases, deliberately not distinguished on the wire so a
|
||||
// probe cannot tell a refused caller from a stale seq: the caller is not the
|
||||
// host; `seq` was not newer; old_state/new_state were empty.
|
||||
//
|
||||
// REPLAY RULE: applied if and only if `seq` is strictly greater than the
|
||||
// seq already stored for that module. Deliveries are not ordered — the
|
||||
// first call to an un-tokened target coalesces behind a handshake, so a
|
||||
// later push can land first — and per-module seq is what makes that
|
||||
// harmless without any buffering or timing assumption. The rule is TOTAL:
|
||||
// it covers a module that has since gone absent too, which is why the .cpp
|
||||
// keeps a seq tombstone for one that left the listing.
|
||||
// REPLAY RULE: applied iff `seq` is strictly greater than what is stored for
|
||||
// that module. Delivery is not ordered, so a later push can land first, and
|
||||
// per-module seq makes that harmless with no buffering and no timing
|
||||
// assumption. The rule is TOTAL — it covers a module that has since gone
|
||||
// absent, which is why the .cpp keeps a seq tombstone for one that left.
|
||||
//
|
||||
// THE TOMBSTONE PUTS A REQUIREMENT ON CORE, and it is not obvious from
|
||||
// this side: a record pruned by apply_snapshot is tombstoned at the
|
||||
// LISTING's seq, so core must stamp snapshot record seqs from the SAME
|
||||
// global counter it stamps transitions from. Stamp them from anything
|
||||
// else — a per-snapshot counter, or zero — and the tombstone is either
|
||||
// unreachably high (a real later delta is dropped forever) or trivially
|
||||
// low (a stale delta resurrects a module that is gone). One counter, both
|
||||
// paths.
|
||||
// THE TOMBSTONE PUTS A REQUIREMENT ON CORE, and it is not visible from this
|
||||
// side: a record pruned by apply_snapshot is tombstoned at the LISTING's
|
||||
// seq, so core must stamp snapshot record seqs from the SAME counter it
|
||||
// stamps transitions from. Anything else makes the tombstone unreachably
|
||||
// high (a real later delta dropped forever) or trivially low (a stale delta
|
||||
// resurrects a pruned module).
|
||||
//
|
||||
// A transition whose `new_state` is "absent" is a MEMBERSHIP EDGE. The
|
||||
// event fires exactly as any other, but no absent record is stored: the
|
||||
// module leaves the listing instead. That is what makes list_modules'
|
||||
// never-absent invariant true by construction.
|
||||
bool note_transition(const std::string& authToken,
|
||||
const std::string& module,
|
||||
// `new_state == "absent"` is a MEMBERSHIP EDGE: the event fires as normal
|
||||
// but the module leaves the listing rather than being stored absent.
|
||||
bool note_transition(const std::string& module,
|
||||
const std::optional<std::string>& instance,
|
||||
const std::optional<int64_t>& pid,
|
||||
const std::string& old_state,
|
||||
@@ -272,32 +172,24 @@ public:
|
||||
|
||||
// Replace the whole picture with a host-supplied snapshot.
|
||||
//
|
||||
// This is what makes a late-loading modules_state correct: core pushes a
|
||||
// snapshot when this module comes up, so everything that loaded BEFORE it
|
||||
// (capability_module always does) is still reported.
|
||||
// This is what makes a late-loading modules_state correct: it loads after
|
||||
// other modules, so deltas alone give it a permanently short list.
|
||||
//
|
||||
// Merge is per-record and uses the same seq rule as note_transition, so a
|
||||
// delta that overtook the snapshot survives it. Records missing from the
|
||||
// listing whose stored seq is older than the listing's seq are dropped,
|
||||
// each with its own state -> "absent" event.
|
||||
// Merge is per-record under the same seq rule, so a delta that overtook the
|
||||
// snapshot survives it. Records missing from the listing whose stored seq is
|
||||
// older than the listing's are dropped, each with its own "absent" event.
|
||||
//
|
||||
// A snapshot record whose `state` is "absent" is contract-malformed —
|
||||
// `absent` is event-only, and a snapshot spells non-membership by OMISSION.
|
||||
// It is skipped, which makes it mean exactly what omitting it would have
|
||||
// meant. One rule, not two.
|
||||
bool apply_snapshot(const std::string& authToken, const ModuleListing& listing);
|
||||
|
||||
// Number of ingest calls refused. A counter, not an event: it exists so a
|
||||
// test can prove the gate is closed, and so an operator can see a module
|
||||
// trying to forge lifecycle facts.
|
||||
uint64_t rejected_ingest_count();
|
||||
// A snapshot record claiming "absent" is contract-malformed — a snapshot
|
||||
// spells non-membership by OMISSION — so it is skipped, which makes it mean
|
||||
// exactly what omitting it would have meant.
|
||||
bool apply_snapshot(const ModuleListing& listing);
|
||||
|
||||
logos_events:
|
||||
// Emitted on every APPLIED transition — including the ones apply_snapshot
|
||||
// Emitted on every APPLIED transition, including those apply_snapshot
|
||||
// applies.
|
||||
//
|
||||
// A transition PAIR, not a "kind" string: strictly more informative, and a
|
||||
// consumer that only cares that something went away just reads new_state.
|
||||
// A transition PAIR rather than a "kind" string: strictly more informative,
|
||||
// and a consumer that only cares something went away reads new_state.
|
||||
// old_state is never equal to new_state; a no-op is not an event.
|
||||
void module_state_changed(const std::string& module,
|
||||
const std::optional<std::string>& instance,
|
||||
|
||||
@@ -3,17 +3,13 @@ project(ModulesStateTests LANGUAGES CXX)
|
||||
|
||||
include(LogosTest)
|
||||
|
||||
# The NAME must end in _test or _tests. mkLogosModuleTests runs the suite with
|
||||
# NAME must end in _test or _tests. mkLogosModuleTests finds suites with
|
||||
# find . -maxdepth 1 -executable \( -name "*_tests" -o -name "*_test" \)
|
||||
# and pipes the result into a while-read loop, so a binary named anything else
|
||||
# is BUILT, MATCHED BY NOTHING, and never executed — and the check still goes
|
||||
# green, because compiling was all that happened. This suite was written as
|
||||
# `modules_state_invariants` and passed three mutation tests it should have
|
||||
# caught before that was spotted.
|
||||
# so a binary named anything else is BUILT, MATCHED BY NOTHING, never executed —
|
||||
# and the check still goes GREEN. This suite was `modules_state_invariants` and
|
||||
# passed three mutation tests it should have caught before that was spotted.
|
||||
|
||||
# The registry logic only — no daemon, no Qt, no plugin load. These are the
|
||||
# invariants that were previously checked only by driving logoscore by hand,
|
||||
# which CI does not do, so they were unguarded in practice.
|
||||
# Registry logic only: no daemon, no Qt, no plugin load.
|
||||
logos_test(
|
||||
NAME modules_state_invariants_tests
|
||||
MODULE_SOURCES
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
// The three invariants that are cheap to state and expensive to lose.
|
||||
//
|
||||
// Each is load-bearing for a claim the header makes, and each was previously
|
||||
// checked only by driving a live logoscore daemon by hand — which is not a
|
||||
// thing CI does, so in practice they were unguarded.
|
||||
//
|
||||
// The ingest gate is opened here with LOGOS_MODULES_STATE_TEST_INGEST=1, which
|
||||
// is the documented test-only door. A run WITHOUT it is the proof that the gate
|
||||
// is closed by default; that belongs in its own case, below.
|
||||
// The invariants that are cheap to state and expensive to lose. Each is
|
||||
// load-bearing for a claim the header makes, and each was previously checked
|
||||
// only by driving a live daemon by hand — which CI does not do.
|
||||
|
||||
#include <logos_test.h>
|
||||
#include "../src/modules_state_impl.h"
|
||||
@@ -15,10 +9,9 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// The event emitter is DECLARED on the impl and DEFINED by the generated
|
||||
// scaffold, which a unit test does not link. Stubbing it here is not a
|
||||
// workaround — it is what makes the emissions assertable at all, so the
|
||||
// membership edges can be checked rather than assumed.
|
||||
// The emitter is declared on the impl and defined by the generated scaffold,
|
||||
// which a unit test does not link. Stubbing it is what makes the emissions
|
||||
// assertable, so the membership edges are checked rather than assumed.
|
||||
std::vector<std::string> g_emitted;
|
||||
|
||||
void ModulesStateImpl::module_state_changed(const std::string& module,
|
||||
@@ -41,12 +34,13 @@ struct OpenIngest {
|
||||
~OpenIngest() { unsetenv("LOGOS_MODULES_STATE_TEST_INGEST"); }
|
||||
};
|
||||
|
||||
constexpr const char* kTok = "test-ingest";
|
||||
|
||||
// A unit test calls the impl DIRECTLY: no dispatch, so no caller, so
|
||||
// currentCaller() answers Unknown and the real gate refuses. The test door is
|
||||
// what makes these checkable in CI; the closed-gate case below opens none.
|
||||
bool note(ModulesStateImpl& m, const std::string& mod,
|
||||
const std::string& from, const std::string& to, uint64_t seq)
|
||||
{
|
||||
return m.note_transition(kTok, mod, std::nullopt, std::nullopt,
|
||||
return m.note_transition(mod, std::nullopt, std::nullopt,
|
||||
from, to, std::nullopt, seq);
|
||||
}
|
||||
|
||||
@@ -59,12 +53,9 @@ bool listingHas(const ModuleListing& l, const std::string& mod)
|
||||
|
||||
} // namespace
|
||||
|
||||
// INVARIANT 1 — no surface may ever hand back a record whose state is "absent".
|
||||
//
|
||||
// `absent` is an EVENT-ONLY transition target. If a record could carry it,
|
||||
// there would be two spellings for "not there" — a record saying absent, and
|
||||
// the empty optional — and every consumer would have to handle both or quietly
|
||||
// handle one.
|
||||
// INVARIANT 1 — no surface hands back a record whose state is "absent". If one
|
||||
// could, there would be two spellings for "not there" and every consumer would
|
||||
// have to handle both, or quietly handle one.
|
||||
LOGOS_TEST(absent_never_appears_on_a_record)
|
||||
{
|
||||
OpenIngest gate;
|
||||
@@ -95,12 +86,10 @@ LOGOS_TEST(absent_never_appears_on_a_record)
|
||||
LOGOS_ASSERT(sawOut);
|
||||
}
|
||||
|
||||
// INVARIANT 2 — a departed module stays departed under a stale delta.
|
||||
//
|
||||
// Erasing the record also erases the seq the replay rule compares against, so
|
||||
// without a tombstone an out-of-order push for a pruned module would resurrect
|
||||
// it. Deliveries are NOT ordered, so this is a real sequence, not a contrived
|
||||
// one.
|
||||
// INVARIANT 2 — a departed module stays departed under a stale delta. Erasing
|
||||
// the record also erases the seq the replay rule compares against, so without a
|
||||
// tombstone an out-of-order push would resurrect it. Delivery is not ordered,
|
||||
// so this is a real sequence, not a contrived one.
|
||||
LOGOS_TEST(a_stale_delta_does_not_resurrect_a_departed_module)
|
||||
{
|
||||
OpenIngest gate;
|
||||
@@ -121,11 +110,8 @@ LOGOS_TEST(a_stale_delta_does_not_resurrect_a_departed_module)
|
||||
}
|
||||
|
||||
// INVARIANT 3 — a snapshot record with no state is skipped, like an absent one.
|
||||
//
|
||||
// `state` is tstr on the wire with no enum behind it, so a record that simply
|
||||
// omitted the field would otherwise be admitted with state "" — a seventh,
|
||||
// undeclared state, reachable from outside and indistinguishable from a real
|
||||
// one to every consumer.
|
||||
// `state` is tstr with no enum, so an omitted field would otherwise be admitted
|
||||
// as a seventh, undeclared state indistinguishable from a real one.
|
||||
LOGOS_TEST(a_snapshot_record_with_no_state_is_not_admitted)
|
||||
{
|
||||
OpenIngest gate;
|
||||
@@ -145,20 +131,56 @@ LOGOS_TEST(a_snapshot_record_with_no_state_is_not_admitted)
|
||||
listing.modules = { stateless, real };
|
||||
listing.seq = 5;
|
||||
|
||||
LOGOS_ASSERT(m.apply_snapshot(kTok, listing));
|
||||
LOGOS_ASSERT(m.apply_snapshot(listing));
|
||||
LOGOS_ASSERT(!m.module_record("ghost_module").has_value());
|
||||
LOGOS_ASSERT(m.module_record("irc_module").has_value());
|
||||
}
|
||||
|
||||
// The gate is closed unless a door is opened, and this case deliberately opens
|
||||
// none. It is the reason the others may open one without weakening the claim.
|
||||
// none. With no dispatch the caller is Unknown, which is NOT the host, so the
|
||||
// structural gate refuses — the same answer a peer module gets.
|
||||
LOGOS_TEST(ingest_is_refused_when_no_door_is_open)
|
||||
{
|
||||
unsetenv("LOGOS_MODULES_STATE_TEST_INGEST");
|
||||
unsetenv("LOGOS_MODULES_STATE_INGEST_TOKEN");
|
||||
ModulesStateImpl m;
|
||||
|
||||
LOGOS_ASSERT(!note(m, "chat_module", "absent", "unloaded", 1));
|
||||
LOGOS_ASSERT(!m.module_record("chat_module").has_value());
|
||||
LOGOS_ASSERT(m.rejected_ingest_count() >= 1);
|
||||
// ...and it is absent from the listing too. NOT modules.empty(): the
|
||||
// registry is a process-wide static, so earlier cases in this binary have
|
||||
// left records behind.
|
||||
LOGOS_ASSERT(!listingHas(m.list_modules(), "chat_module"));
|
||||
}
|
||||
|
||||
// `loaded` is not `ready`. liblogos marks a module loaded when it owns the
|
||||
// process, and emits loaded->ready only once the module publishes its object.
|
||||
// is_ready() must answer false in that window — it is the window callers ask
|
||||
// about — and an unknown state must not be optimistic either.
|
||||
LOGOS_TEST(ready_is_publish_not_load)
|
||||
{
|
||||
OpenIngest gate;
|
||||
ModulesStateImpl m;
|
||||
|
||||
LOGOS_ASSERT(note(m, "eth_rpc_module", "absent", "unloaded", 1));
|
||||
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
|
||||
|
||||
LOGOS_ASSERT(note(m, "eth_rpc_module", "unloaded", "loading", 2));
|
||||
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
|
||||
|
||||
// The window: the host owns the process, the object is not up yet.
|
||||
LOGOS_ASSERT(note(m, "eth_rpc_module", "loading", "loaded", 3));
|
||||
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
|
||||
|
||||
LOGOS_ASSERT(note(m, "eth_rpc_module", "loaded", "ready", 4));
|
||||
LOGOS_ASSERT(m.is_ready("eth_rpc_module"));
|
||||
|
||||
// Going away closes it again.
|
||||
LOGOS_ASSERT(note(m, "eth_rpc_module", "ready", "stopping", 5));
|
||||
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
|
||||
|
||||
// A module nobody reported is not ready, and neither is an unrecognised
|
||||
// state — forward compatibility fails closed.
|
||||
LOGOS_ASSERT(!m.is_ready("never_seen_module"));
|
||||
LOGOS_ASSERT(note(m, "eth_rpc_module", "stopping", "quiescing", 6));
|
||||
LOGOS_ASSERT(!m.is_ready("eth_rpc_module"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user