Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
434756e4f2 Odin test: pipelined puts with a lost datagram are both written
Adapted from Soul's S-6 probe. Two puts go out on one session through a
relay that drops the first reliable datagram. The transport resends it and
delivers both in order; Odin accepts both, so both are on disk and the file
matches the working set. The probe's other finding, that the transport
acknowledges the second before the first is resent, is the transport's
defect (tracked by Self) and is not pinned here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:39:32 +02:00
Meta Vens
30156f21d2 Odin: Odin's answer to a put is the promise, not the transport's ack
dcd36fc said an acknowledged put is always on disk. At the pinned
cultmesh-rs that is false: RUDP acknowledges a reliable datagram when it is
received, before ordered delivery hands the put to Odin, so a later put can
be acknowledged while an earlier one is still being resent (Soul probed it
with two pipelined puts and the first datagram dropped). What holds is
Odin's own answer: a put accepted by accept_raw_document is on disk, and one
it refuses is rejected by name. The comments now say that.

Soul S-5. The transport defect is tracked by Self.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:39:00 +02:00
Meta Vens
e2c0c8f626 Odin tests: a later failed attempt, and a removal on its own
Two rules had no test of their own (Soul's mutants survived):
- only_oldest_attempt_tried: every fixture's file held the oldest failed
  attempt. The new test leaves the file holding a later one.
- remove_not_dirty: the heartbeat always dirtied the store, so a removal
  that did not mark it survived. The new test removes a record with nothing
  else changed and checks the write drops it (and that removing what is not
  held writes nothing).

Soul S-8 and the attempted-list gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:38:30 +02:00
Meta Vens
f9b0926830 Odin: a target being replaced has one presence in the catalog, not a refusal
The catalog refused itself whole ("duplicate public document identities")
whenever two projected incarnations of one target both held an admitted
presence, which every provider has during a deploy window: presences are
served under (schema, target), and the incumbent and the candidate share it.

One is now served and the other skipped, logged once per interval: the
incarnation whose Odin correlation is Ready, and between two that are both
or neither Ready, the later observation. A duplicate identity between
anything but two presences still refuses the query.

Tests: the incumbent (Ready) is served over a newer Warming candidate; with
no correlations to decide, the later presence is served, from either side.

Soul S-4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:38:13 +02:00
Meta Vens
a129da90fe Odin: the activation limit is set where the server is built, from its options
Soul's mutant main_limit_halved survived: main passed the server's
max_snapshot_response_bytes into RuntimeState::open by hand, and the tests
built RuntimeState and the server separately, so nothing tied the two. One
function, serve_with, now builds the server from its options and gives the
state that same limit; main and the test fixture both go through it. The
state has no limit until then, and Odin does not activate without one (the
fixture checks it refuses before serve_with).

Soul S-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:37:09 +02:00
Meta Vens
726e0f31a1 Odin test: size the small document inside the calibrated range
The small document asked for a 200-byte response, less than the response's
own fixed overhead, so sizing it underflowed. It now asks for 2,000 bytes,
inside the range where the response grows one byte per payload byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:07:25 +02:00
Meta Vens
bdbecc3308 Odin Cut 3: size a stored document with a message id CultNet admits
CultNet refuses an empty message id, so sizing a document's snapshot
response with one failed for every record, and the failure was read as
"keep": nothing was ever dropped. The response is now sized with a one-byte
message id, the shortest CultNet admits, and a record that cannot be sized
is logged and kept rather than silently kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:58:28 +02:00
Meta Vens
723e74d5d1 Odin test: the unreadable-projection case is a projection that does not decode
The second fault in the test (removing the projection's lock file) did not
make the read fail, so it asserted a fallback that never ran. The test keeps
the fault that does: a projection file that does not decode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:58:28 +02:00
Meta Vens
6786faa430 Odin Cut 3: a stored document the server could never serve is dropped at activation
Odin's store carried two ~1.456 MB Eve records that no snapshot response
could ever return (the server sends at most max_snapshot_response_bytes,
1 MiB), and every write rewrote them. Under Q3 B every put writes the
store, so they cost a ~3 MB write per put.

At activation, each stored peer document is sized as the server would
answer a snapshot of it alone: the same encoder, the shortest message id.
One larger than the server's limit is dropped from the working set and
logged by type, key and size; the first write removes it from the file.
The limit is the server's own option, handed to RuntimeState where main
builds the server, so the two cannot disagree. Presences and records the
catalog does not serve are not checked.

Q1 A (refuse the unservable), Q2 (the two records are dead residue). The
CultLib half, refusing such a put at the server, is its own cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:50:09 +02:00
Meta Vens
dcd36fccb9 Odin: say what a refused put promises
An acknowledged put is always on disk. A put refused because its write
failed may still become durable and be served: it stays in the working set,
and the next write lands it, or the failed write had already replaced the
file. Puts are latest-value per type and key, so that is sound; the
promise is written down at accept_raw_document.

Soul finding 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:48:12 +02:00
Meta Vens
749c6b9bbe Odin test: with nothing to write, the next change is written on the next pass
Pins the rule that only an attempted write starts the flush interval: a due
write with nothing to write leaves the next change to land on the first pass
after it. Kills the mutant that starts the interval on every due pass.

Soul finding 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:48:12 +02:00
Meta Vens
03f01ddba0 Odin: an unreadable projection file skips presences, not the catalog
Reading Idunn's projection once per catalog query (620efb8) made a
projection file that cannot be read refuse the whole catalog, where before
it failed each presence lookup and left the peer documents served. The
query now answers as if Idunn projected nothing, logged once per interval:
peer documents are served and presences skipped. Odin's own projection
check (require_own_projection) is unchanged and still retries or ends Odin
as before.

The new test restores file-level coverage: a projection that does not
decode, and one whose lock cannot be opened.

Soul finding 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:47:33 +02:00
Meta Vens
08ae183444 Odin: every failed write since the last success is Odin's own
A write that fails does not say whether it replaced the file, so the file
may hold what any failed write since the last success tried to write. The
store kept only the last attempt: a failure after the replace followed by a
failure before it left the file holding an attempt Odin no longer
recognised, and the next write called Odin's own file foreign and ended
it. Every attempt since the last successful write is now an accepted
baseline, cleared when a write lands.

Soul finding 1, with Soul's probe as the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:47:00 +02:00
Meta Vens
f4a1f8900a Odin: drop doc lines that restate the code they sit on
One was also wrong: `records` is in key order, not the file's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:32:41 +02:00
Meta Vens
c94b5c4f85 Odin tests: the real binary; an acknowledged put survives SIGKILL, SIGTERM stops it cleanly
From Soul's SIGTERM probe (scratch 5c379dd), made a test. It runs the built
odin-daemon under an Idunn-shaped world with the warming probe and lease
grant made over RUDP. With puts written before they are acknowledged, the
SIGKILL round is the rule's test; the SIGTERM case pins `main`'s signal
wiring, which no in-process test reaches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:30:32 +02:00
Meta Vens
0a09df9b7c Odin: a peer's put is written before it is acknowledged (Q3 B)
Operator ruling Q3 B: `accept_raw_document` returns Ok, and so the server
acknowledges the put, only once the store holding it is on disk. The write
is the one `flush`, under the same lease check, so it also carries every
other unwritten change. A write that is not made refuses the put. Odin's own
bookkeeping stays on the one-second interval.

The interval tests now change Odin's own bookkeeping (its heartbeat) instead
of putting, since a put is no longer an interval change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:30:28 +02:00
Meta Vens
3afa3fb778 Odin: every way out of the serving loop makes the last write
The loop is `serve`, and it ends in one place: a stop request, a condition
`survive` returns, or a dead socket all reach `stop`, whose write is made
only while the lease is current and the file is still Odin's. Tested for
each exit kind: a stop request, a refused own authority and a dead socket
write what changed; a lost lease and a foreign write write nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:27:53 +02:00
Meta Vens
a4aa5fcf97 Odin: a failed write is retried as it stands, and its own replaced file is not a foreign write
The records stay dirty until a write lands, so the next write makes the
failed change without another mutation (tested with no heartbeat to
re-dirty them). A write that fails after it replaced the file (the
directory sync) leaves Odin's own records there; they are remembered as
the write's attempt and accepted as a second baseline until a write lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:25 +02:00
Meta Vens
620efb85d8 Odin: a catalog query reads Idunn's projection once
The file-backed projection source no longer answers lookups itself, so no
caller can read the file once per lookup: every lookup is made against one
snapshot. The catalog query takes one per query, as the refresh pass does;
startup, activation and the own-projection check take one each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:24:32 +02:00
Meta Vens
2ad60183a1 Odin: the self presence counts from its own launch; the stored-sequence continuation is gone
Every launch is issued a fresh activation (Idunn control_plane.rs, the
continuity demotion), presences are ordered only within one activation
(select_presence), and Idunn compares only the warming presence's digest.
Continuing from a stored sequence protected nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:14:25 +02:00
soul-probe
cda20870fd Odin tests: the write starts the next interval; unprojected correlations are withdrawn
cargo-mutants survivors, both real gaps:
- serving_pass with the timer condition inverted wrote every change on the
  next pass after a write; the interval test set the timer by hand on every
  pass and never saw the loop set it. A change right after a write now waits.
- correlated_incarnations returning nothing left a correlation whose
  incarnation Idunn stopped projecting in place forever; the lib test
  refreshed that incarnation by name. The refresh pass now has to find it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:07:44 +02:00
soul-probe
35ea0d45fc Odin test: with nothing to write, the write step reads nothing, not even the lease
The flush step is due on every pass until something changes. Hand mutant M5b
(the daemon skipping its own dirty check) left every write rule intact but read
the lease file on every 2 ms pass; no test saw it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:39:47 +02:00
soul-probe
8189623411 Odin: the correlation record type is imported only where it is used
Cut 1 left it imported at the top of main.rs, where only the tests use it;
the release build warned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:32:11 +02:00
soul-probe
b5f13308e9 Odin Cut 2: one write of the store file per interval, only when it changed
Every admission, put and refresh that changed something rewrote and fsynced
the whole store; a heartbeat did it twice (watermark, then correlation).
Now the serving loop writes the file at most once per FLUSH_INTERVAL (1 s,
operator default Q3), and only when the working set changed.

Deleted first: the per-mutation writes Cut 1 left in accept_raw_document,
publish_self_presence and refresh_all_correlations. The watermark's own write
went with the file store in Cut 1; its mark now travels in the same atomic
write as the correlations that use it. "Published" means written.

Added: FLUSH_INTERVAL, the flush step in serving_pass, and a final write on
SIGTERM/SIGINT. The dirty flag lives in MemoryOdinTopologyStore. RuntimeState::
flush is the only write of the file: it checks the lease immediately before
writing, and CultLib's snapshot compare-exchange against what Odin last loaded
or wrote refuses a file another process wrote (ForeignStoreWrite). survive
treats ForeignStoreWrite like WriteLeaseLost: both end Odin. An attempt, failed
or not, starts the interval; an interval with nothing to write does not.

Tests: 50 changes inside one interval are one write, not made at 0.9 s and
made at 1.1 s after the last; nothing changed is no write; a lease lost before
the write writes nothing and ends Odin; a foreign write ends Odin and is not
written over; stopping writes only under the lease; a crash and reload never
republishes a written sequence, withdrawal included; a flush writes only
changes and never over a foreign file. The failed-publication test becomes a
failed-write test: the store is no longer read by publication.

Map: docs/write-pattern-cut.md, Cut 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:00:53 +02:00
soul-probe
409d92f279 Odin Cut 1: the store file stops being working memory
Odin read and decoded its whole 3.24 MB store file about 30 times a second:
every refresh pass, every admission, every catalog query and every CAS retry
read it again. The working set now lives in memory under the write lease.

Deleted: CultCacheOdinTopologyStore (every file read and CAS against the
store, and its four CAS_ATTEMPTS retry loops, including the one in
reconcile), persist_generic_document's file CAS loop, the store half of
incarnation_keys, stored_snapshot's file read, and
prior_self_publisher_sequence's file read in RuntimeState::open. With one
writer under the lease an in-memory exchange cannot lose, so nothing retries.

Added: MemoryOdinTopologyStore, keyed (type, key), implementing the
OdinTopologyStore trait (still the mock point). It is loaded once, with the
locked pull_all, in try_activate after the lease is verified; legacy
retirement and the self publisher sequence are operations on the loaded set.
The trait's compare_exchange collapses into commit. Refresh reads Idunn's
projection once per pass (IdunnProjectionSnapshot) instead of once per
incarnation; admission reads it once per admission.

Not in this cut: the write policy. Each mutating operation still writes the
whole file immediately, now from memory (flush after an admission, a put or a
refresh pass that changed something).

Tests: the two tests that corrupted the store while Odin served now put the
bad record in the file at activation. New: rewriting the file behind Odin
changes nothing it serves; refresh passes and catalog reads succeed with the
file replaced by bytes that do not decode; a loaded store never reads its
file again.

Map: docs/write-pattern-cut.md, Cut 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:58:52 +02:00
3 changed files with 2398 additions and 761 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,350 @@
//! The real odin-daemon binary, run under an Idunn-shaped world: its bundle,
//! signer descriptors, projection and write lease on disk, and Idunn's warming
//! probe and lease grant made over RUDP as Idunn makes them. It reaches what
//! the in-process tests cannot: `main`'s signal wiring and a process that dies.
//! Unix only, like the daemon.
use std::fs::File;
use std::net::{SocketAddr, UdpSocket};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Result, bail, ensure};
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore};
use cultmesh_rs::{
CultMeshRudpDocumentPublishOptions, CultMeshRudpSnapshotOptions,
publish_cultnet_message_to_rudp_catalog, request_raw_snapshot_from_rudp_catalog,
};
use cultnet_rs::*;
use odin_daemon::IncarnationRef;
fn digest(byte: char) -> String {
format!("sha256-{}", byte.to_string().repeat(64))
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
}
fn rfc3339(millis: u64) -> String {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis as i64)
.unwrap()
.to_rfc3339()
}
/// Replace the whole store at `path` with `entries`.
fn write_store(path: &Path, entries: &[CultCacheEnvelope]) -> Result<()> {
let store = SingleFileMessagePackBackingStore::new(path);
let current = if path.is_file() {
store.pull_all_read_only_snapshot()?
} else {
Vec::new()
};
ensure!(store.compare_exchange_snapshot(&current, entries)?, "store write lost a race");
Ok(())
}
fn credential_bytes(store: &Path) -> Result<Vec<u8>> {
Ok(rmp_serde::to_vec(&SingleFileMessagePackBackingStore::new(store).pull_all()?)?)
}
struct Odin {
_temp: tempfile::TempDir,
root: PathBuf,
candidate: SocketAddr,
child: Child,
store: PathBuf,
}
impl Odin {
fn log(&self) -> String {
std::fs::read_to_string(self.root.join("odin.log")).unwrap_or_default()
}
/// Put one provider document and return once the server acknowledged it.
fn put(&self, key: &str) -> Result<()> {
let message = CultNetMessage::DocumentPutRaw {
message_id: format!("put-{key}"),
document: CultNetRawDocumentRecord {
schema_id: "ghostlight.doc.v1".into(),
record_key: key.into(),
stored_at: rfc3339(now()),
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
payload: vec![1],
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
},
};
let mut options = CultMeshRudpDocumentPublishOptions::odin(self.candidate, "test-publisher");
options.flush_timeout = Duration::from_secs(3);
publish_cultnet_message_to_rudp_catalog(&message, options)
}
fn holds(&self, key: &str) -> Result<bool> {
Ok(SingleFileMessagePackBackingStore::new(&self.store)
.pull_all_read_only_snapshot()?
.iter()
.any(|entry| entry.r#type == "ghostlight.doc" && entry.key == key))
}
fn signal(&mut self, signal: &str) -> Result<ExitStatus> {
let pid = self.child.id().to_string();
ensure!(Command::new("kill").args([signal, &pid]).status()?.success());
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(status) = self.child.try_wait()? {
return Ok(status);
}
ensure!(Instant::now() < deadline, "Odin did not exit on {signal}");
std::thread::sleep(Duration::from_millis(10));
}
}
}
impl Drop for Odin {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Launch Odin as Idunn does and grant it its lease; returns once Odin has
/// written its store, which it does on its first pass after activating.
fn start_odin() -> Result<Odin> {
let temp = tempfile::tempdir()?;
let root = temp.path().to_path_buf();
let candidate = UdpSocket::bind("127.0.0.1:0")?.local_addr()?;
let t0 = now();
let idunn = enroll_service_identity_at::<IdunnServiceIdentity>(&root.join("idunn.cc"))?;
enroll_service_identity_at::<OdinTopologyIdentity>(&root.join("odin-topology.cc"))?;
let provider =
enroll_service_identity_at::<GameCultProviderHealthIdentity>(&root.join("odin-provider.cc"))?;
std::fs::write(root.join("topology-cred"), credential_bytes(&root.join("odin-topology.cc"))?)?;
std::fs::write(root.join("provider-cred"), credential_bytes(&root.join("odin-provider.cc"))?)?;
export_service_identity_trust_anchor::<IdunnServiceIdentity>(&idunn, &root.join("idunn-anchor.cc"))?;
let expected = IdunnExpectedIncarnationRecord {
schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(),
target: "odin".into(),
plan_id: digest('1'),
incarnation_id: "odin/generation-1".into(),
sealed_release_id: digest('2'),
source_repository: "github.com/GameCult/Odin".into(),
source_revision: "3".repeat(40),
recipe_sha256: digest('4'),
runtime_id: "odin-runtime".into(),
expected_signer_identity_id: provider.entry().identity_id.clone(),
health_contract: "odin.runtime-health.v1".into(),
artifact_sha256: digest('5'),
state_schema_generation: Some("odin-v2".into()),
state_contract_sha256: Some(
"sha256-4f2f2dcd931d16f6b02bf295f41227b867aa982765661d55fa9f29fb2db7e449".into(),
),
write_lease_required: true,
route: Some(IdunnExpectedRoute {
route_id: "odin-route".into(),
transport: "rudp".into(),
stable_endpoint: "rudp://odin.internal:1000".into(),
candidate_endpoint: format!("rudp://{candidate}"),
}),
capabilities: vec![IdunnExpectedCapability {
capability: "odin.verse-rendezvous".into(),
schema: "odin.verse-topology.v1".into(),
compatibility: "v1".into(),
minimum_capacity: 1,
}],
dependencies: Vec::new(),
};
expected.validate()?;
let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('7'), t0 - 20, &idunn)?;
let activation = launch.activation().clone();
let mut activation_credential = Vec::new();
launch.write_credential(&mut activation_credential)?;
std::fs::write(root.join("activation-cred"), &activation_credential)?;
let expected_envelope = |key: &str| -> Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: key.into(),
r#type: IdunnExpectedIncarnationRecord::TYPE.into(),
payload: expected.canonical_bytes()?,
stored_at: rfc3339(t0 - 30),
schema_id: Some(IDUNN_EXPECTED_INCARNATION_SCHEMA.into()),
})
};
let activation_envelope = |key: &str| -> Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: key.into(),
r#type: IdunnRuntimeActivationRecord::TYPE.into(),
payload: activation.canonical_bytes()?,
stored_at: rfc3339(activation.issued_at_unix_millis),
schema_id: Some(IDUNN_RUNTIME_ACTIVATION_SCHEMA.into()),
})
};
let bundle = root.join("bundle");
std::fs::create_dir_all(&bundle)?;
write_store(&bundle.join("expected.cc"), &[expected_envelope("odin")?])?;
write_store(&bundle.join("activation.cc"), &[activation_envelope("odin")?])?;
let anchor = GameCultServiceTrustAnchorRecord {
schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(),
trust_anchor_id: "root/odin/runtime-presence".into(),
service_id: "odin".into(),
runtime_id: expected.runtime_id.clone(),
signer_identity_id: provider.entry().identity_id.clone(),
signer_public_key: provider.entry().public_key.clone(),
signature_algorithm: "ed25519".into(),
signing_purpose: GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE.into(),
signed_schema: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(),
binding_authority: "root".into(),
bound_at_unix_millis: t0 - 100,
expires_at_unix_millis: None,
private_state_exposed: false,
};
let incarnation_key = IncarnationRef::of(&expected)?.key();
let projection = root.join("projection.cc");
let mut projected = vec![
expected_envelope(&incarnation_key)?,
CultCacheEnvelope {
key: anchor.trust_anchor_id.clone(),
r#type: GameCultServiceTrustAnchorRecord::TYPE.into(),
payload: rmp_serde::to_vec(&anchor)?,
stored_at: rfc3339(anchor.bound_at_unix_millis),
schema_id: Some(GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into()),
},
activation_envelope(&incarnation_key)?,
];
write_store(&projection, &projected)?;
let lease_path = root.join("lease.cc");
File::create(root.join("lease.cc.lock"))?;
let state_root = root.join("state");
std::fs::create_dir_all(&state_root)?;
// Descriptors 3 and 4 carry the signer credentials, as systemd passes them.
let child = Command::new("bash")
.arg("-c")
.arg(format!(
"exec 3<{activation} 4<{provider}; exec {bin} --state-root {state} --idunn-projection {projection} --idunn-anchor {anchor}",
activation = root.join("activation-cred").display(),
provider = root.join("provider-cred").display(),
bin = env!("CARGO_BIN_EXE_odin-daemon"),
state = state_root.display(),
projection = projection.display(),
anchor = root.join("idunn-anchor.cc").display(),
))
.env("GAMECULT_IDUNN_RUNTIME_BUNDLE", &bundle)
.env("GAMECULT_IDUNN_CANDIDATE_BIND", candidate.to_string())
.env("GAMECULT_IDUNN_PROCESS_WRITE_LEASE", &lease_path)
.env("ODIN_TOPOLOGY_IDENTITY", root.join("topology-cred"))
.env("LISTEN_PID", "1")
.env("LISTEN_FDS", "2")
.env(
"LISTEN_FDNAMES",
format!("{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}:gamecult-runtime-presence-identity"),
)
.stdout(Stdio::null())
.stderr(Stdio::from(File::create(root.join("odin.log"))?))
.spawn()?;
let mut odin = Odin {
_temp: temp,
root,
candidate,
child,
store: state_root.join("topology.cc"),
};
// Idunn's warming probe, then the lease naming that exact warming presence.
let deadline = Instant::now() + Duration::from_secs(20);
let response = loop {
match request_raw_snapshot_from_rudp_catalog(CultMeshRudpSnapshotOptions {
target: candidate,
runtime_id: "test-idunn".into(),
message_id: format!("warming-{}", now()),
schema_ids: Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]),
record_keys: Some(vec!["odin".into()]),
..Default::default()
}) {
Ok(response) => break response,
Err(error) => {
if let Some(status) = odin.child.try_wait()? {
bail!("Odin exited while warming ({status}): {}", odin.log());
}
ensure!(Instant::now() < deadline, "Odin never answered warming: {error:#}");
std::thread::sleep(Duration::from_millis(100));
}
}
};
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
bail!("the warming probe was not answered with raw documents")
};
let presence: GameCultRuntimePresenceHealthRecord = rmp_serde::from_slice(&documents[0].payload)?;
ensure!(presence.state == "warming", "{}", presence.state);
let lease = IdunnProcessWriteLeaseRecord {
schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(),
target: "odin".into(),
expected_projection_sha256: expected.canonical_sha256()?,
plan_id: expected.plan_id.clone(),
incarnation_id: expected.incarnation_id.clone(),
sealed_release_id: expected.sealed_release_id.clone(),
activation_witness_sha256: activation.canonical_sha256()?,
state_schema_generation: "odin-v2".into(),
state_contract_sha256: expected.state_contract_sha256.clone().unwrap(),
runtime_id: expected.runtime_id.clone(),
runtime_instance_id: activation.runtime_instance_id.clone(),
warming_presence_sha256: presence.canonical_sha256()?,
lease_epoch: 1,
issued_at_unix_millis: now() - 5,
};
let lease_envelope = |key: &str| -> Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: key.into(),
r#type: IdunnProcessWriteLeaseRecord::TYPE.into(),
payload: lease.canonical_bytes()?,
stored_at: rfc3339(lease.issued_at_unix_millis),
schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()),
})
};
write_store(&lease_path, &[lease_envelope("odin")?])?;
projected.push(lease_envelope(&incarnation_key)?);
write_store(&projection, &projected)?;
while !odin.store.is_file() {
if let Some(status) = odin.child.try_wait()? {
bail!("Odin exited while activating ({status}): {}", odin.log());
}
ensure!(Instant::now() < deadline, "Odin never activated: {}", odin.log());
std::thread::sleep(Duration::from_millis(20));
}
Ok(odin)
}
/// A put Odin acknowledged is on disk: killed outright right after the
/// acknowledgement, inside the interval its own changes wait for, Odin loses
/// none of them.
#[test]
fn an_acknowledged_put_survives_sigkill() -> Result<()> {
for round in 0..3 {
let mut odin = start_odin()?;
// Inside the interval that Odin's activation write started.
std::thread::sleep(Duration::from_millis(300));
let key = format!("doc-{round}");
odin.put(&key)?;
odin.signal("-KILL")?;
ensure!(odin.holds(&key)?, "round {round}: the acknowledged put was lost: {}", odin.log());
}
Ok(())
}
/// SIGTERM reaches the serving loop as a stop request: Odin makes its last
/// write and exits cleanly, rather than waiting to be killed.
#[test]
fn a_stop_signal_ends_odin_cleanly() -> Result<()> {
let mut odin = start_odin()?;
let status = odin.signal("-TERM")?;
let log = odin.log();
ensure!(status.success(), "Odin exited {status}: {log}");
ensure!(log.contains("Odin stopping on request"), "{log}");
ensure!(!log.contains("final store write was not made"), "{log}");
Ok(())
}