aion_server/run.rs
1//! Run loop for the Aion workflow server: tracing initialization,
2//! configuration load, transport startup, and signal-driven graceful
3//! shutdown.
4//!
5//! This is the library entry point behind the `aion server` command. It
6//! preserves the operational contract of the former standalone
7//! `aion-server` binary: exit code 2 for configuration errors, the drain
8//! outcome's exit code on shutdown, and 130 when a second termination
9//! signal forces immediate exit.
10
11use std::process::ExitCode;
12
13use tracing::{error, info, warn};
14
15use crate::{
16 ServerConfig, ServerError, ServerState,
17 config::{CliOverrides, NamespaceMode, StoreBackend},
18 observability,
19 shutdown::ShutdownOutcome,
20};
21
22mod doors;
23mod outbox_commission;
24mod transports;
25
26use doors::{bind_doors, serve_until_shutdown};
27use outbox_commission::{
28 BackpressureSettings, maybe_spawn_cluster_supervisor, maybe_spawn_outbox_dispatcher,
29 rebuild_outbox_boot_state,
30};
31use transports::{serve_grpc, serve_http};
32
33/// Run the Aion workflow server until it shuts down, returning the process
34/// exit code.
35///
36/// Initializes the JSON tracing subscriber, loads and validates the merged
37/// configuration (file, environment, then `overrides`), serves the gRPC and
38/// HTTP transports, and drains gracefully after the first termination
39/// signal. Every failure is logged through tracing and mapped to the exit
40/// code contract above; the caller only has to exit with the returned code.
41pub async fn run(overrides: CliOverrides) -> ExitCode {
42 // `Box::pin`ned because the boot future is large — it owns the whole
43 // pre-serve composition — and an oversized future on the stack of every
44 // caller of this entry point is a cost nothing else here pays for.
45 match Box::pin(run_server(overrides)).await {
46 Ok(code) => code,
47 Err(error) => {
48 error!(%error, "aion-server failed");
49 if error.is_config() {
50 ExitCode::from(2)
51 } else {
52 ExitCode::FAILURE
53 }
54 }
55 }
56}
57
58/// The where-to-edit half of the missing `outbox.liminal_listen_address`
59/// refusal: a liminal outbox refusal must name the FILE to edit, not just the
60/// key — the operator reading it is exactly the operator who did not write
61/// the config (a scaffolded or setup-script home).
62fn liminal_address_hint(source: &crate::config::ConfigSource) -> String {
63 match source {
64 crate::config::ConfigSource::BuiltInDefaults => {
65 "set AION_OUTBOX_LIMINAL_LISTEN_ADDRESS, or add `liminal_listen_address = \
66 \"127.0.0.1:50061\"` to `[outbox]` in a config file"
67 .to_owned()
68 }
69 source => format!(
70 "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the {source}"
71 ),
72 }
73}
74
75/// Everything `run_server` must capture from the merged config BEFORE
76/// `ServerState::build` consumes it — the wiring below the build reads these,
77/// not the (moved) config.
78struct PreBuildCaptures {
79 /// The selected backend, surfaced so the boot banner records it.
80 store_backend: StoreBackend,
81 /// Static shard assignment (SS-1): the operator's pinned shard set from
82 /// `[store] owned_shards`. Empty means own ALL shards (single-node
83 /// default). The set is carried into `RuntimeConfig` by `into_parts` and
84 /// applied to the `EngineBuilder` during state construction; surfaced
85 /// here so the boot banner records which shards this node serves. No
86 /// election is performed.
87 owned_shards: Vec<usize>,
88 /// The outbox settings, so the (default-off) outbox dispatcher can be
89 /// wired after state is up. The dispatcher shares the engine's
90 /// already-opened haematite store via `state.outbox_store()`, so no
91 /// store settings are needed.
92 outbox_config: crate::config::OutboxConfig,
93 /// Control-Plane Phase 2 (P2-Q2): the keyed-backpressure inputs — the
94 /// generous platform-default ceiling and this node's owned-shard
95 /// fraction. On a single-node / own-all boot the fraction is 1, so
96 /// per-node ceilings equal the cluster-wide quota and, with the generous
97 /// default and no tenant override, the ceiling never engages
98 /// (byte-identical claim).
99 backpressure_settings: BackpressureSettings,
100 /// The SS-5b failover supervisor knobs. Only a distributed haematite
101 /// boot carries a `[store.cluster]` section; this is `None` for every
102 /// single-node boot, so no supervisor is ever spawned.
103 cluster_config: Option<crate::config::ClusterConfig>,
104 /// The managed-worker supervision policy. Resolution already happened
105 /// during config validation, so this cannot surprise an operator at
106 /// boot; it is re-resolved here because the policy is COMMISSIONED onto
107 /// the supervisor built into state below, and a server without the
108 /// section supervises nothing.
109 supervision_policy: Option<crate::worker::SupervisionPolicy>,
110}
111
112impl PreBuildCaptures {
113 fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
114 Ok(Self {
115 store_backend: config.store.backend,
116 owned_shards: config.store.owned_shards.clone(),
117 outbox_config: config.outbox.clone(),
118 backpressure_settings: BackpressureSettings::from_config(config),
119 cluster_config: config.store.cluster.clone(),
120 supervision_policy: config.worker_supervision.resolve()?,
121 })
122 }
123}
124
125async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
126 observability::tracing::init()?;
127
128 // #180: a boot that discovers no config anywhere first scaffolds
129 // `<AION_HOME>/config.toml` from the embedded template (claim-only-when-
130 // empty), then loads it — config LOAD itself stays pure and read-only.
131 let loaded = crate::config::load_or_scaffold(&cli)?;
132 loaded.resolution.ensure_private_home()?;
133 // Arm the death note as early as the home exists, so every later failure
134 // path — including config validation and state build — runs inside the
135 // ARMED/DISARMED bracket. Two anonymous server deaths on 2026-08-16 are
136 // why this exists; see the module docs for the exact coverage.
137 let death_note = crate::death_note::DeathNote::arm(&loaded.resolution.home)?;
138 let home = loaded.resolution.home.clone();
139 loaded.resolution.log_startup();
140 let liminal_address_hint = liminal_address_hint(&loaded.resolution.source);
141 // The boot-side config heal already logged each inserted field by name;
142 // the banner below carries the count so one line summarizes the boot.
143 let config_healed_field_count = loaded.healed.inserted.len();
144 let config = loaded.config;
145 reject_auth_without_feature(&config)?;
146 // The revision, not just the version. A crate version cannot distinguish
147 // two builds from different commits of the same version, and that is the
148 // distinction an operator needs when deciding whether a restart restores
149 // what was running or substitutes something else (#123). Read here, at
150 // the top of the boot, because the BIRTH CLAIM records it: a `status`
151 // against a booting server must be able to say which build is booting.
152 let build = crate::build_identity::BuildIdentity::current();
153 // 🔴 The home is claimed HERE — before the store is opened, before
154 // anything that can take minutes. A record written at bind left the whole
155 // recovery window invisible: `status` said the home was unclaimed, `stop`
156 // said there was nothing to stop, and the launcher's port probe read the
157 // home as empty and started ANOTHER server, which blocked without a word
158 // on the store's writer lock. Four stacked that way on 2026-08-26.
159 //
160 // The claim needs nothing but this process's own identity and the
161 // addresses the (already-loaded) configuration says this boot will bind,
162 // so it can run at stage zero and refuse a colliding sibling outright.
163 let pid_file_guard = claim_home_at_birth(&home, &config, build.commit)?;
164 let boot_stage = pid_file_guard.stage_reporter();
165 boot_stage.report(
166 crate::control::stage::STAGE_CONFIG,
167 format!(
168 "configuration resolved from {} for home {}",
169 loaded.resolution.source,
170 home.display()
171 ),
172 );
173 let captures = PreBuildCaptures::from_config(&config)?;
174 let state = ServerState::build(config, &boot_stage).await?;
175 reject_tls_until_supported(&state)?;
176
177 let runtime = state.runtime_config();
178 let grpc_address = runtime.listen.grpc;
179 let http_address = runtime.listen.http;
180 log_startup_banner(
181 &state,
182 &captures,
183 &build,
184 &death_note,
185 config_healed_field_count,
186 );
187 // #189 slice one: the built-in update check ships the same way, under the
188 // same only-the-empty-case install rule. Installing makes it STARTABLE
189 // and nothing else — no check runs without an explicit operator act.
190 install_embedded_surfaces(&state).await;
191 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
192 // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
193 // The single outbox dispatcher task is spawned in BOTH modes; the difference
194 // is only how ownership is enforced. Single-node (`None`) owns all shards by
195 // construction (`owned_shard_scope() == None`), so its claim sweeps see every
196 // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
197 // filter — already seeded by `set_owned_shards` during `ServerState::build`,
198 // which runs before this point — so each node only ever claims rows on the
199 // shards it owns. Compute the flag here where the cluster section is in
200 // scope; pass it to the gate so the boot banner records the mode.
201 let outbox_clustered = captures.cluster_config.is_some();
202 // Dormant by default: only when `outbox.enabled` is set does the
203 // non-replayed outbox dispatcher task start. With the flag off (the
204 // default) nothing here runs and server behaviour is unchanged.
205 // Hold the liminal worker listener (if any) for the server's lifetime: it is
206 // dropped at the end of `run_server`, after the serve `select!` completes, so
207 // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
208 // #204/#253: rebuild the pause dispatch-hold and settle terminal
209 // workflows' stranded outbox rows BEFORE the dispatcher's first claim.
210 rebuild_outbox_boot_state(&state, &captures.outbox_config).await;
211 let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
212 &state,
213 &captures.outbox_config,
214 outbox_clustered,
215 captures.backpressure_settings,
216 &shutdown_rx,
217 &liminal_address_hint,
218 )?;
219 // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
220 // supervisor — automatic failover detection. A single-node boot spawns
221 // nothing here (the method returns `false`), so default behaviour is
222 // unchanged.
223 maybe_spawn_cluster_supervisor(&state, captures.cluster_config.as_ref(), &shutdown_rx)?;
224 // #176: the worker heartbeat expiry sweeper is ALWAYS commissioned —
225 // dead-worker detection is a liveness correctness property, not an opt-in
226 // feature. It is the production caller of `fail_expired_workers`: a worker
227 // whose stream stays open while its process wedges (stops heartbeating
228 // without disconnecting) is expired, deregistered with the provable Timeout
229 // reason, and its in-flight tasks surface as TRANSPORT losses, re-dispatched
230 // attempt-neutrally rather than charged to the action's retry budget.
231 // Cadence derives from `worker.heartbeat_window` (quarter-window, clamped to
232 // [1s, window]; the default 30s window sweeps every 7.5s) — deliberately no
233 // separate config knob. It drains on the same shutdown watch as the
234 // transports; dropping the JoinHandle only detaches the task.
235 drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
236 commission_worker_supervision(&state, captures.supervision_policy).await;
237 withdraw_orphaned_auto_workers(&state).await;
238 // A record freezes the address it was minted with and the supervisor
239 // replays that argv verbatim, so an operator who moves
240 // `[outbox] liminal_listen_address` would otherwise restart into a fleet of
241 // built-in agent workers all dialling a port nothing binds. Correct the
242 // connection — and only the connection — before anything else is decided.
243 drop(crate::worker::auto_provision::refresh_dial_addresses(&state).await);
244 // Bind both listeners, then FILL the record this incarnation already
245 // holds: the bound addresses, the resolved drain window, and the move out
246 // of BOOTING into SERVING. The claim itself happened at birth; there is
247 // no second claim, and nothing here can take another server's record.
248 boot_stage.report(
249 crate::control::stage::STAGE_BINDING,
250 format!("binding http {http_address} and grpc {grpc_address}"),
251 );
252 let doors = bind_doors(
253 &pid_file_guard,
254 grpc_address,
255 http_address,
256 state.runtime_config().drain_timeout,
257 )
258 .await?;
259 let identity_pid = doors.identity_pid;
260 // Instant doors: the startup catch-up legs (owed timer fires, schedule
261 // catch-up) run as a background task CONCURRENT with the transports —
262 // the backlog has no upper bound, and a boot that blocks on it keeps the
263 // doors shut for the whole sweep (the 2026-08-24 estate outage shape:
264 // 37+ minutes of healthy catch-up with every listener refusing).
265 // Workflow-residency recovery already ran inside `ServerState::build`,
266 // so every surface the transports serve answers correctly while the
267 // catch-up drains behind them.
268 drop(state.spawn_startup_catchup(shutdown_rx.clone())?);
269 let mut grpc = tokio::spawn(serve_grpc(
270 state.clone(),
271 doors.grpc_listener,
272 doors.bound_grpc,
273 shutdown_rx.clone(),
274 ));
275 let mut http = tokio::spawn(serve_http(
276 state.clone(),
277 doors.http_listener,
278 doors.bound_http,
279 shutdown_rx,
280 ));
281
282 // From here the graceful drain is watching for a termination signal, so
283 // the death note's watcher goes back to merely OBSERVING one. Announced
284 // as late as possible and no later: everything before this line is boot,
285 // and a termination signal during the boot must ABANDON it rather than be
286 // caught and answered by nobody (the 2026-08-26 finding — see
287 // `DeathNote::drain_owns_termination`).
288 death_note.drain_owns_termination();
289 let report =
290 serve_until_shutdown(&state, &pid_file_guard, &shutdown_tx, &mut grpc, &mut http).await?;
291
292 // Every assistant harness this server was holding is shut down with its
293 // configured grace, and its session settled dormant with the reason. A
294 // process that outlived the server that owned it would be an orphan nothing
295 // could reach, cancel, or account for.
296 state.assistant_sessions().shutdown().await;
297
298 let outcome = report.outcome;
299 let exit_code = outcome.exit_code();
300 // The rich outcome crosses the process boundary through the death note
301 // (one file, one writer); the exit code keeps the #207 contract.
302 death_note.record_outcome(&crate::control::outcome::OutcomeRecord::from_report(
303 identity_pid,
304 &report,
305 ));
306 death_note.disarm(&format!(
307 "clean run-loop exit: shutdown outcome {outcome:?}"
308 ));
309 drop(pid_file_guard);
310 Ok(exit_code)
311}
312
313/// The surfaces this binary carries and installs at boot, after the engine has
314/// reloaded every persisted package and before the transports accept traffic.
315///
316/// #189 slice one: the built-in update check is installed only into a catalog
317/// holding no version of it — a fresh home. Installing makes it STARTABLE and
318/// nothing else: no check runs without an explicit operator act.
319///
320/// Assistant sessions whose harness process is gone are settled here, before any
321/// caller can read one: a session left unsettled would project the fallback
322/// state, and the settlement is WRITTEN BACK as an appended record with its
323/// cause, so the next reader projects it rather than recomputing the same
324/// decision. A sweep that cannot run is logged and the boot continues — a server
325/// whose past sessions could not be settled is a server with stale session
326/// states, not a server that must refuse to start.
327async fn install_embedded_surfaces(state: &ServerState) {
328 crate::update_check::install_embedded_update_check_for_server(state).await;
329 if let Err(error) = state.assistant_sessions().sweep_orphans().await {
330 tracing::warn!(
331 %error,
332 "assistant sessions whose harness process is gone could not be settled at boot; \
333 their states will read as unsettled until the next successful sweep"
334 );
335 // The sweep has already recorded WHY on the registry, so the descriptor
336 // answers `sessions_enabled: false` with this store's own error rather
337 // than offering a surface that cannot record a conversation. The boot
338 // continues: a server whose assistant is unavailable is still a server.
339 }
340}
341
342/// The one line an operator greps for when they want to know what this server
343/// actually is: build identity, addresses, backend, which surfaces are on,
344/// which shards it owns, and where its death note lives.
345fn log_startup_banner(
346 state: &ServerState,
347 captures: &PreBuildCaptures,
348 build: &crate::build_identity::BuildIdentity,
349 death_note: &crate::death_note::DeathNote,
350 config_healed_field_count: usize,
351) {
352 let runtime = state.runtime_config();
353 let grpc_address = runtime.listen.grpc;
354 let http_address = runtime.listen.http;
355 let workflow_packages: Vec<String> = runtime
356 .workflow_packages
357 .iter()
358 .map(|path| path.display().to_string())
359 .collect();
360 // #139: the server-resolved workspace root (the aion home's `clones/`
361 // directory) that declared bodies expand `{workspace_root}` with. Reported
362 // here so composition points (setup.sh today, the workspace verb later)
363 // READ the value from the server that will use it instead of re-deriving
364 // it. An unresolvable root is reported as exactly that — never fabricated;
365 // a placeholder-bearing dispatch will refuse terminally with this reason.
366 // The rendering itself is `WorkspaceRoot::banner_value`, pinned by its own
367 // two-case test, so the banner and the tests cannot drift apart.
368 let workspace_root = state.workspace_root().banner_value();
369 info!(
370 version = env!("CARGO_PKG_VERSION"),
371 build = %build.line(),
372 commit = build.commit,
373 grpc_address = %grpc_address,
374 http_address = %http_address,
375 default_namespace = %runtime.default_namespace,
376 namespace_mode = namespace_mode_label(&runtime.namespace.mode),
377 store_backend = store_backend_label(captures.store_backend),
378 auth_enabled = runtime.auth.enabled,
379 deploy_enabled = runtime.deploy.enabled,
380 metrics_enabled = runtime.metrics.enabled,
381 workspace_root = %workspace_root,
382 death_note = %death_note.path().display(),
383 workflow_package_count = workflow_packages.len(),
384 workflow_packages = ?workflow_packages,
385 owned_shards = ?captures.owned_shards,
386 owns_all_shards = captures.owned_shards.is_empty(),
387 config_healed_field_count,
388 "aion-server startup banner"
389 );
390}
391
392/// Claim this home before anything slow happens.
393///
394/// Everything the claim needs is available at stage zero: this process's own
395/// identity, the build's commit, and the addresses the already-loaded
396/// configuration says this boot will bind. Nothing here touches the store,
397/// which is the entire point — the record has to exist BEFORE the minutes.
398fn claim_home_at_birth(
399 home: &std::path::Path,
400 config: &ServerConfig,
401 commit: &str,
402) -> Result<crate::control::PidFileGuard, ServerError> {
403 let intended = crate::control::IntendedAddresses {
404 http: config.server.listen_address,
405 grpc: config.server.grpc_address,
406 };
407 crate::control::claim_at_birth(home, &birth_record(commit, intended)?, intended)
408}
409
410/// This incarnation's record as it exists at BIRTH.
411///
412/// Identity, build, and the addresses the already-loaded configuration says
413/// this boot WILL bind. The BOUND addresses stay `None` — nothing is bound,
414/// and that absence is a FACT rather than a placeholder: it is how a reader
415/// knows the doors are not open yet, and how `aion server status` says
416/// "still booting" instead of "unreachable". The INTENDED pair is recorded
417/// beside them because it is equally a fact at birth, and it is what lets a
418/// concurrent boot on this home decide collision against this server's
419/// configuration instead of presuming it. No drain window either — the
420/// runtime configuration has not been resolved into one yet.
421///
422/// [`Booting`]: crate::control::IncarnationState::Booting
423fn birth_record(
424 commit: &str,
425 intended: crate::control::IntendedAddresses,
426) -> Result<crate::control::PidRecord, ServerError> {
427 let identity = crate::control::incarnation::self_identity()?;
428 Ok(crate::control::PidRecord {
429 pid: identity.pid,
430 started_at_unix_secs: identity.started_at_unix_secs,
431 binary_sha256: identity.binary_sha256,
432 version: env!("CARGO_PKG_VERSION").to_owned(),
433 commit: commit.to_owned(),
434 state: crate::control::IncarnationState::Booting,
435 http_address: None,
436 grpc_address: None,
437 intended_http_address: Some(intended.http),
438 intended_grpc_address: Some(intended.grpc),
439 stage: None,
440 stage_detail: None,
441 stage_seq: 0,
442 stage_updated_at_unix_secs: 0,
443 drain_timeout_seconds: 0,
444 })
445}
446
447/// Install the operator's supervision policy and converge the fleet.
448///
449/// Uncommissioned is a first-class but never SILENT state: a server with no
450/// `[worker_supervision]` section supervises nothing, and every deployment that
451/// wanted to be running is named in the warning, so the gap between "the
452/// operator deployed a worker" and "nothing is running it" is never quiet.
453/// Withdraw every auto-provisioned worker record whose workflow no deployed
454/// package carries.
455///
456/// A record can outlive its workflow — the package it was staged for
457/// withdrawn, or gone with an upgrade — and would then be restarted forever
458/// against a document nothing deploys. The catalogue is read once; one that
459/// cannot be read withdraws nothing and says so.
460async fn withdraw_orphaned_auto_workers(state: &ServerState) {
461 match crate::worker::auto_provision::deployed_workflow_types(state).await {
462 Ok(deployed) => {
463 drop(crate::worker::auto_provision::withdraw_orphaned(state, &deployed).await);
464 }
465 Err(reason) => warn!(
466 %reason,
467 "the package catalogue could not be read at boot, so no auto-provisioned worker \
468 record was judged against it"
469 ),
470 }
471}
472
473async fn commission_worker_supervision(
474 state: &ServerState,
475 policy: Option<crate::worker::SupervisionPolicy>,
476) {
477 let supervisor = state.worker_supervisor();
478 let Some(policy) = policy else {
479 match supervisor.report().await {
480 Ok(report) => {
481 let wanted: Vec<&str> = report
482 .workers
483 .iter()
484 .filter(|worker| worker.desired == aion_store::DesiredState::Running)
485 .map(|worker| worker.name.as_str())
486 .collect();
487 if wanted.is_empty() {
488 info!("managed-worker supervision is not configured; no deployment wants it");
489 } else {
490 warn!(
491 deployments = wanted.join(", "),
492 remedy = crate::worker::supervisor::UNCOMMISSIONED_REMEDY,
493 "worker deployments want to be running but supervision is not configured"
494 );
495 }
496 }
497 Err(error) => error!(
498 %error,
499 "managed-worker supervision is not configured and the deployment records \
500 could not be read to say what that costs"
501 ),
502 }
503 return;
504 };
505 if !supervisor.commission(policy, crate::worker::ManagedExecutable::CurrentServer) {
506 error!("managed-worker supervision was already commissioned before boot completed");
507 return;
508 }
509 match supervisor.reconcile().await {
510 Ok(0) => info!("managed-worker supervision commissioned; no deployment wants to run"),
511 Ok(supervised) => info!(supervised, "managed-worker supervision commissioned"),
512 Err(error) => error!(%error, "managed-worker fleet could not be converged at boot"),
513 }
514}
515
516fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
517 if cfg!(not(feature = "auth")) && config.auth.enabled {
518 return Err(ServerError::Config {
519 message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
520 });
521 }
522 Ok(())
523}
524
525fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
526 if state.runtime_config().tls.is_some() {
527 return Err(ServerError::Config {
528 message: "configured TLS material cannot be served until transport TLS is wired"
529 .to_owned(),
530 });
531 }
532 Ok(())
533}
534
535fn store_backend_label(backend: StoreBackend) -> &'static str {
536 match backend {
537 StoreBackend::Memory => "memory",
538 StoreBackend::Haematite => "haematite",
539 }
540}
541
542fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
543 match mode {
544 NamespaceMode::SharedEngine => "SharedEngine",
545 NamespaceMode::SingleTenant { .. } => "SingleTenant",
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 #![allow(clippy::expect_used)]
552
553 use super::outbox_commission::{
554 BackpressureSettings, maybe_spawn_outbox_dispatcher, resolve_outbox_reconciler_config,
555 };
556 use crate::ServerState;
557 use crate::config::RuntimeConfig;
558 use crate::config::{OutboxConfig, OutboxTransport};
559 use crate::test_support::StateUnderTest;
560 use aion_store::InMemoryStore;
561 use std::net::SocketAddr;
562 use std::time::Duration;
563
564 /// Own-all, generous-default backpressure settings for the gate tests (the
565 /// single-node default: fraction 1, so the ceiling never engages).
566 fn test_backpressure_settings() -> BackpressureSettings {
567 BackpressureSettings {
568 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
569 fraction: crate::worker::OwnedShardFraction::own_all(),
570 }
571 }
572
573 /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
574 /// tests (mirrors `state.rs`'s test `runtime_config`).
575 fn runtime_config() -> RuntimeConfig {
576 use crate::config::{
577 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
578 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
579 WebSocketConfig, WorkerConfig,
580 };
581 RuntimeConfig {
582 listen: ListenConfig {
583 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
584 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
585 },
586 tls: None,
587 auth: AuthConfig {
588 enabled: false,
589 jwks_url: None,
590 jwks_refresh_seconds: 300,
591 },
592 ops_console: OpsConsoleConfig {
593 source: OpsConsoleAssetSource::Embedded,
594 },
595 namespace: NamespaceConfig {
596 mode: NamespaceMode::SharedEngine,
597 },
598 worker: WorkerConfig {
599 heartbeat_window: Duration::from_secs(30),
600 ..WorkerConfig::default()
601 },
602 websocket: WebSocketConfig {
603 outbound_buffer_bound: 32,
604 event_broadcast_capacity: Some(64),
605 cluster_broadcast_capacity: Some(64),
606 },
607 workflow_packages: Vec::new(),
608 deploy: DeployConfig::default(),
609 authoring: AuthoringConfig::default(),
610 dev: DevConfig::default(),
611 outbox: OutboxConfig::default(),
612 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
613 mcp: crate::config::ResolvedMcpConfig::default(),
614 assistant: crate::config::ResolvedAssistantConfig::default(),
615 scheduler_threads: 1,
616 stop_drain_timeout: Some(std::time::Duration::from_secs(5)),
617 jit_threshold: None,
618 query_timeout: Some(Duration::from_secs(10)),
619 workloop_sweep_interval: Some(Duration::from_millis(50)),
620 default_namespace: "default".to_owned(),
621 auto_create: crate::config::AutoCreate::Open,
622 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
623 drain_timeout: Duration::from_secs(30),
624 metrics: MetricsConfig { enabled: true },
625 owned_shards: Vec::new(),
626 cors_allowed_origins: Vec::new(),
627 }
628 }
629
630 /// An `OutboxConfig` with `enabled = true` and every required knob present, so
631 /// the only remaining gate is the store-backend / outbox-table availability.
632 fn enabled_outbox_config() -> OutboxConfig {
633 OutboxConfig {
634 enabled: true,
635 poll_interval_ms: Some(250),
636 batch_size: Some(64),
637 max_attempts: Some(5),
638 backoff_base_ms: Some(100),
639 backoff_multiplier: Some(2),
640 backoff_max_ms: Some(30_000),
641 reconcile_interval_ms: None,
642 reconcile_stale_after_ms: None,
643 transport: OutboxTransport::Grpc,
644 liminal_listen_address: None,
645 liminal_max_connection_outbound_bytes: None,
646 }
647 }
648
649 /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
650 /// dispatcher against the in-memory backend (which has no outbox table, so
651 /// `outbox_store()` is `None`) is a configuration error, and the message names
652 /// haematite as the required durable backend.
653 #[tokio::test]
654 async fn outbox_enabled_on_memory_backend_is_a_config_error() {
655 let state = StateUnderTest::new(
656 ServerState::build_with_store(InMemoryStore::default(), runtime_config())
657 .await
658 .expect("build in-memory state"),
659 );
660 let (_tx, rx) = tokio::sync::watch::channel(false);
661 let error = maybe_spawn_outbox_dispatcher(
662 &state,
663 &enabled_outbox_config(),
664 false,
665 test_backpressure_settings(),
666 &rx,
667 "set outbox.liminal_listen_address in the test config",
668 )
669 .expect_err("outbox.enabled on the memory backend must be a config error");
670 assert!(
671 error.is_config(),
672 "memory-backend outbox error must be Config"
673 );
674 let message = error.to_string();
675 assert!(
676 message.contains("store.backend=haematite"),
677 "message must name the durable backend, got: {message}"
678 );
679 }
680
681 /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
682 /// gate is a no-op even on a memory backend — nothing is spawned and no error
683 /// is produced, so a default single-node boot is unchanged.
684 #[tokio::test]
685 async fn disabled_outbox_is_a_noop_on_any_backend() {
686 let state = StateUnderTest::new(
687 ServerState::build_with_store(InMemoryStore::default(), runtime_config())
688 .await
689 .expect("build in-memory state"),
690 );
691 let (_tx, rx) = tokio::sync::watch::channel(false);
692 maybe_spawn_outbox_dispatcher(
693 &state,
694 &OutboxConfig::default(),
695 false,
696 test_backpressure_settings(),
697 &rx,
698 "set outbox.liminal_listen_address in the test config",
699 )
700 .expect("disabled outbox gate must be an infallible no-op");
701 }
702
703 /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
704 /// set — the condition under which the clustered-boot WARN fires.
705 #[test]
706 fn reconciler_config_absent_unless_both_knobs_set() {
707 let mut config = enabled_outbox_config();
708 // Neither knob: absent.
709 assert!(
710 resolve_outbox_reconciler_config(&config)
711 .expect("resolve")
712 .is_none()
713 );
714 // Only interval: still absent (the silent-backstop-absent default).
715 config.reconcile_interval_ms = Some(1_000);
716 assert!(
717 resolve_outbox_reconciler_config(&config)
718 .expect("resolve")
719 .is_none()
720 );
721 // Both set: present.
722 config.reconcile_stale_after_ms = Some(60_000);
723 assert!(
724 resolve_outbox_reconciler_config(&config)
725 .expect("resolve")
726 .is_some()
727 );
728 }
729
730 /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
731 /// Commissioning the dispatcher with `transport = liminal` but no listen
732 /// address is a configuration error naming the missing knob, rather than a
733 /// panic or a silent fall-through to gRPC. Built over haematite (so
734 /// the outbox-store gate passes and the missing-address check is actually
735 /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
736 /// only exists with `liminal-transport` on; in a feature-off build the same
737 /// selection is the missing-feature error instead, covered by the type system
738 /// rather than this test.)
739 #[cfg(feature = "liminal-transport")]
740 #[tokio::test]
741 async fn liminal_transport_requires_listen_address() {
742 use crate::config::{
743 RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
744 };
745
746 let data_dir = std::env::temp_dir().join(format!(
747 "aion-lsub-prod-listen-guard-{}-{}",
748 std::process::id(),
749 std::time::SystemTime::now()
750 .duration_since(std::time::UNIX_EPOCH)
751 .map(|elapsed| elapsed.as_nanos())
752 .unwrap_or_default()
753 ));
754 let mut outbox = enabled_outbox_config();
755 outbox.transport = OutboxTransport::Liminal;
756 outbox.liminal_listen_address = None;
757 let config = ServerConfig {
758 store: StoreConfig {
759 backend: StoreBackend::Haematite,
760 data_dir: Some(data_dir.to_string_lossy().into_owned()),
761 // Required, no default: the haematite boot path refuses a config
762 // that does not rule on the node cache's byte ceiling.
763 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
764 ..StoreConfig::default()
765 },
766 runtime: RuntimeSection {
767 scheduler_threads: 1,
768 stop_drain_timeout_ms: Some(5_000),
769 jit_threshold: None,
770 workloop_sweep_interval_ms: Some(50),
771 query_timeout_ms: Some(10_000),
772 },
773 websocket: WebSocketConfig {
774 outbound_buffer_bound: 32,
775 event_broadcast_capacity: Some(64),
776 cluster_broadcast_capacity: Some(64),
777 },
778 outbox: outbox.clone(),
779 // Required, no default: the transcript drain's flush policy.
780 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
781 ..ServerConfig::default()
782 };
783 let state = StateUnderTest::new(
784 ServerState::build(config, &crate::control::StageReporter::detached())
785 .await
786 .expect("build haematite state"),
787 );
788 let (_tx, rx) = tokio::sync::watch::channel(false);
789
790 let error = maybe_spawn_outbox_dispatcher(
791 &state,
792 &outbox,
793 false,
794 test_backpressure_settings(),
795 &rx,
796 "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the test config",
797 )
798 .expect_err("liminal transport without a listen address must be a config error");
799 assert!(
800 error.is_config(),
801 "missing-listen-address error must be Config"
802 );
803 assert!(
804 error.to_string().contains("liminal_listen_address"),
805 "error must name the missing knob, got: {error}"
806 );
807 // #180 review MAJ-4: the refusal must carry the caller's threaded
808 // where-to-edit hint, so the production message names the resolved
809 // config FILE, not just the key.
810 assert!(
811 error.to_string().contains("in the test config"),
812 "error must carry the threaded config-location hint, got: {error}"
813 );
814 }
815}
816
817/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
818///
819/// This is the proof that the production boot now does the full round-trip the
820/// retired stub could not. It drives the EXACT production commissioning function
821/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
822/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
823/// `liminal_listen_address`. That function lifts the full push wiring
824/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
825/// the SAME [`WorkerOutboxDispatch`](crate::worker::WorkerOutboxDispatch) the
826/// gRPC arm builds — with the liminal delivery attached, so each selected
827/// worker is served over the transport IT registered on (#52 R4) — over the
828/// SAME registry the gRPC path uses and the SAME
829/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
830/// (over the live engine), and spawns the real [`OutboxDispatcher`].
831///
832/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
833/// connects IN to the listener and self-registers in-band. A `collect_four`
834/// fan-out is started over the REAL HTTP transport, which stages four pending
835/// outbox rows; the production-wired dispatcher claims and pushes each to the
836/// worker, the worker executes it, and its completion re-enters aion through the
837/// production engine callback — `record_fan_out_completion` — driving the
838/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
839/// executed the activities, AND the terminals were recorded in history (four
840/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
841/// publish-and-mark-done path never achieved.
842#[cfg(all(test, feature = "liminal-transport"))]
843mod lsub_prod_xnode_e2e {
844 #![allow(clippy::expect_used)]
845
846 use std::net::SocketAddr;
847 use std::path::PathBuf;
848 use std::sync::Arc;
849 use std::sync::atomic::{AtomicUsize, Ordering};
850 use std::time::{Duration, Instant};
851
852 use aion_core::Event;
853 use aion_package::{
854 ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
855 ManifestVersion, PackageBuilder, PackageContract, WorkerContract,
856 };
857 use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
858 use axum::body;
859 use axum::http::{Request, StatusCode};
860 use serde_json::json;
861 use tower::ServiceExt;
862
863 use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
864 use crate::ServerState;
865 use crate::api::http::http_router;
866 use crate::config::{
867 OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
868 WebSocketConfig,
869 };
870 use crate::error::ServerError;
871 use crate::test_support::StateUnderTest;
872
873 type TestError = Box<dyn std::error::Error + Send + Sync>;
874
875 /// The `collect_four` fixture passes each member the JSON string `"in"` as
876 /// activity input, so the worker handler decodes a [`String`], not a struct.
877 type FanInput = String;
878
879 const NAMESPACE: &str = "default";
880 const TASK_QUEUE: &str = "default";
881 const OUTBOX_MODULE: &str = "aion_outbox_fixture";
882 const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
883 const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
884 const FAN_OUT: usize = 4;
885 const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
886 const POLL_DEADLINE: Duration = Duration::from_secs(20);
887 /// The one fan-out member the reconnect pin holds. Any of the four would do —
888 /// they are dispatched independently and served by identical handlers.
889 const HELD_ACTIVITY_TYPE: &str = FAN_ACTIVITY_TYPES[0];
890
891 fn test_error(message: impl std::fmt::Display) -> TestError {
892 message.to_string().into()
893 }
894
895 /// Reserve a loopback port and return it: the liminal listener binds this exact
896 /// address (the production path binds the configured `liminal_listen_address`,
897 /// so the test must commit to a concrete port the worker can also dial).
898 fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
899 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
900 let address = listener.local_addr().map_err(test_error)?;
901 drop(listener);
902 Ok(address)
903 }
904
905 /// The fixture's queue-scoped `.v4` contract: the four `fan:N` activities
906 /// `collect_four` schedules, declared on the queue its worker actually polls.
907 ///
908 /// Why the archive cannot just carry the manifest-derived record: by design
909 /// `PackageContract::from_manifest` "never invents a queue", so a manifest's
910 /// bare activity names land in `unscoped_activities` — and this server boots
911 /// queue-routed, where an unscoped catalog is a terminal
912 /// `NO_QUEUE_DECLARATION` at start admission
913 /// (`aion::lifecycle::start_admission`). That refusal is EARNED: an unserved
914 /// queue would otherwise wait silently forever. So the derived record is
915 /// amended rather than bypassed — the same four names move out of
916 /// `unscoped_activities` and onto the queue that serves them — and the
917 /// package still loads through the production boot path with the `.v4`
918 /// identity `PackageBuilder` stamps over this exact contract.
919 ///
920 /// The action schemas come from the SAME generator the worker's typed
921 /// registry uses, for the SAME Rust types: `collect_four` passes each member
922 /// the JSON string `"in"` and the handler returns a [`String`]. Deriving both
923 /// sides from `activity_descriptor::<FanInput, String>` means the package's
924 /// declaration and the worker's advertisement cannot drift apart, so
925 /// registration admission (`WORKER_CONTRACT_MISMATCH`) compares two schemas
926 /// with one source.
927 fn fixture_contract(manifest: &Manifest) -> Result<PackageContract, TestError> {
928 let mut actions = Vec::with_capacity(FAN_ACTIVITY_TYPES.len());
929 for activity_type in FAN_ACTIVITY_TYPES {
930 let descriptor = aion_worker::activity_descriptor::<FanInput, String>(activity_type)
931 .map_err(test_error)?;
932 actions.push(ActionContract {
933 name: descriptor.name,
934 input_schema: descriptor.input_schema,
935 output_schema: descriptor.output_schema,
936 node: None,
937 timeout: None,
938 retry: None,
939 advisory: false,
940 // A typed `String -> String` handler serves these, not an agent
941 // harness — the fan fixture's shape merely coincides with an
942 // agent seam's, and marking it would route it somewhere no
943 // handler is.
944 agent: false,
945 // A connected worker serves this fixture's queue, so the
946 // declaration carries no body of its own.
947 body: None,
948 });
949 }
950 let mut contract = PackageContract::from_manifest(manifest);
951 contract.workers = vec![WorkerContract {
952 task_queue: TASK_QUEUE.to_owned(),
953 actions,
954 }];
955 contract.unscoped_activities.clear();
956 Ok(contract)
957 }
958
959 /// Build the `collect_four` package on disk so the production state-build path
960 /// loads it exactly as it loads operator-supplied `workflow_packages`.
961 fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
962 let beams =
963 BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
964 let manifest = Manifest {
965 entry_module: OUTBOX_MODULE.to_owned(),
966 entry_function: "collect_four".to_owned(),
967 input_schema: json!({ "type": "object" }),
968 output_schema: json!({}),
969 timeout: Some(Duration::from_secs(30)),
970 // The four ordinals `collect_four` actually fans out. This manifest
971 // used to name one invented activity, `fixture_activity`, that the
972 // fixture never schedules and no worker ever served.
973 activities: FAN_ACTIVITY_TYPES
974 .iter()
975 .map(|activity_type| DeclaredActivity {
976 activity_type: (*activity_type).to_owned(),
977 })
978 .collect(),
979 version: ManifestVersion::new("stamped-by-builder"),
980 format_version: CURRENT_FORMAT_VERSION,
981 additional_workflows: Vec::new(),
982 };
983 let contract = fixture_contract(&manifest)?;
984 let archive =
985 PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
986 .with_contract(contract)
987 .write_to_bytes()
988 .map_err(test_error)?;
989 let path = dir.join("collect_four.aion");
990 std::fs::write(&path, archive).map_err(test_error)?;
991 Ok(path)
992 }
993
994 /// A production-shaped `ServerConfig`: the haematite backend (so the boot store
995 /// path shares the leaf as the dispatcher's outbox store, exactly as
996 /// `ServerState::build` does in production), `outbox.enabled`,
997 /// `transport = liminal`, the reserved `liminal_listen_address`, and the
998 /// `collect_four` package. Built through `ServerState::build` (not
999 /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
1000 fn server_config(
1001 data_dir: &std::path::Path,
1002 package_path: PathBuf,
1003 listen_address: SocketAddr,
1004 outbound_bound: Option<u64>,
1005 ) -> ServerConfig {
1006 ServerConfig {
1007 store: StoreConfig {
1008 backend: StoreBackend::Haematite,
1009 data_dir: Some(data_dir.to_string_lossy().into_owned()),
1010 // Required, no default: the haematite boot path refuses a config
1011 // that does not rule on the node cache's byte ceiling.
1012 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
1013 ..StoreConfig::default()
1014 },
1015 runtime: RuntimeSection {
1016 scheduler_threads: 1,
1017 stop_drain_timeout_ms: Some(5_000),
1018 jit_threshold: None,
1019 workloop_sweep_interval_ms: Some(50),
1020 query_timeout_ms: Some(10_000),
1021 },
1022 websocket: WebSocketConfig {
1023 outbound_buffer_bound: 32,
1024 event_broadcast_capacity: Some(64),
1025 cluster_broadcast_capacity: Some(64),
1026 },
1027 workflow_packages: vec![package_path],
1028 outbox: OutboxConfig {
1029 enabled: true,
1030 poll_interval_ms: Some(20),
1031 batch_size: Some(16),
1032 max_attempts: Some(5),
1033 backoff_base_ms: Some(50),
1034 backoff_multiplier: Some(2),
1035 backoff_max_ms: Some(1_000),
1036 reconcile_interval_ms: None,
1037 reconcile_stale_after_ms: None,
1038 transport: OutboxTransport::Liminal,
1039 liminal_listen_address: Some(listen_address.to_string()),
1040 liminal_max_connection_outbound_bytes: outbound_bound,
1041 },
1042 // Required, no default: the transcript drain's flush policy.
1043 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
1044 ..ServerConfig::default()
1045 }
1046 }
1047
1048 /// The remote worker self-describes for the fixture's pool `(default, default)`
1049 /// and registers a handler for every `fan:N` activity type, counting executions
1050 /// so the test proves it genuinely ran the pushed dispatches.
1051 fn worker_config() -> Result<WorkerConfig, TestError> {
1052 WorkerConfig::builder()
1053 .endpoint("unused-direct-address")
1054 .namespace(NAMESPACE)
1055 .task_queue(TASK_QUEUE)
1056 .identity("lsub-prod-worker")
1057 // FOUR, and the liminal transport now enforces it: one shared
1058 // execution budget across the plain executor and the agent path,
1059 // announced to the server on the capabilities channel. It used to be
1060 // a number nothing read.
1061 .max_concurrency(4)
1062 .reconnect_initial_backoff(Duration::from_millis(5))
1063 .reconnect_max_backoff(Duration::from_millis(20))
1064 .reconnect_max_attempts(3)
1065 .build()
1066 .map_err(test_error)
1067 }
1068
1069 fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1070 let mut registry = ActivityRegistry::new();
1071 for activity_type in FAN_ACTIVITY_TYPES {
1072 let executions = Arc::clone(executions);
1073 // `register_activity_with_contract`, not `register_activity`: the
1074 // bare form registers a handler with NO descriptor, so the worker
1075 // advertises four names and zero typed contracts, and admission —
1076 // which compares CONTRACTS — refuses the registration outright
1077 // (`WORKER_CONTRACT_MISMATCH`). Deriving the advertisement from
1078 // `<FanInput, String>` is what makes it the same source the
1079 // package's `fixture_contract` declares from, so the two sides
1080 // cannot drift.
1081 registry = registry
1082 .register_activity_with_contract(
1083 activity_type,
1084 move |_input: FanInput, _context| {
1085 let executions = Arc::clone(&executions);
1086 Box::pin(async move {
1087 executions.fetch_add(1, Ordering::SeqCst);
1088 Ok(activity_type.to_owned())
1089 })
1090 },
1091 )
1092 .map_err(test_error)?;
1093 }
1094 Ok(Arc::new(registry))
1095 }
1096
1097 /// Spawns the remote worker on its own OS thread with a current-thread runtime
1098 /// (the push receive is blocking), connecting IN to the production listener.
1099 struct WorkerThread {
1100 stop: Arc<std::sync::atomic::AtomicBool>,
1101 handle: Option<std::thread::JoinHandle<()>>,
1102 }
1103
1104 impl WorkerThread {
1105 fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1106 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1107 let thread_stop = Arc::clone(&stop);
1108 let handle = std::thread::spawn(move || {
1109 let runtime = match tokio::runtime::Builder::new_current_thread()
1110 .enable_all()
1111 .build()
1112 {
1113 Ok(runtime) => runtime,
1114 Err(error) => {
1115 eprintln!("worker runtime build failed: {error}");
1116 return;
1117 }
1118 };
1119 runtime.block_on(async move {
1120 let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1121 Ok(worker) => worker,
1122 Err(error) => {
1123 eprintln!("worker connect failed: {error}");
1124 return;
1125 }
1126 };
1127 if let Err(error) = worker
1128 .serve_until(|| thread_stop.load(Ordering::SeqCst))
1129 .await
1130 {
1131 eprintln!("worker serve loop ended with error: {error}");
1132 }
1133 });
1134 });
1135 Self {
1136 stop,
1137 handle: Some(handle),
1138 }
1139 }
1140
1141 /// Spawn the worker through [`aion_worker::serve_with_redial`] — the entry
1142 /// point every REAL worker uses — so a broken link is survivable.
1143 ///
1144 /// [`Self::spawn`] uses `LiminalActivityWorker::serve_until`, which returns
1145 /// the first transport error by design: a single-connection serve has no
1146 /// survivor to migrate to. That is the right shape for a test whose link
1147 /// never breaks, and the wrong instrument entirely for one whose link is
1148 /// broken on purpose — a worker that dies at the break can only ever show
1149 /// that outstanding work fails, whoever is at fault.
1150 ///
1151 /// The redial driver is SYNCHRONOUS and builds its own current-thread
1152 /// runtime, so it runs on the bare thread rather than inside one.
1153 fn spawn_redialing(
1154 address: String,
1155 config: WorkerConfig,
1156 registry: Arc<ActivityRegistry>,
1157 timing: aion_worker::RedialTiming,
1158 ) -> Self {
1159 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1160 let thread_stop = Arc::clone(&stop);
1161 let handle = std::thread::spawn(move || {
1162 if let Err(error) = aion_worker::serve_with_redial(
1163 vec![address],
1164 &config,
1165 ®istry,
1166 timing,
1167 &thread_stop,
1168 None,
1169 || {},
1170 ) {
1171 eprintln!("redialing worker ended with error: {error}");
1172 }
1173 });
1174 Self {
1175 stop,
1176 handle: Some(handle),
1177 }
1178 }
1179
1180 fn stop(mut self) {
1181 self.stop.store(true, Ordering::SeqCst);
1182 if let Some(handle) = self.handle.take() {
1183 handle.join().ok();
1184 }
1185 }
1186 }
1187
1188 fn count_completed(history: &[Event]) -> usize {
1189 history
1190 .iter()
1191 .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1192 .count()
1193 }
1194
1195 fn count_workflow_completed(history: &[Event]) -> usize {
1196 history
1197 .iter()
1198 .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1199 .count()
1200 }
1201
1202 async fn wait_for_history<F>(
1203 store: &dyn aion_store::ReadableEventStore,
1204 workflow_id: &aion_core::WorkflowId,
1205 description: &str,
1206 predicate: F,
1207 ) -> Result<Vec<Event>, TestError>
1208 where
1209 F: Fn(&[Event]) -> bool,
1210 {
1211 let deadline = Instant::now() + POLL_DEADLINE;
1212 loop {
1213 let history = store.read_history(workflow_id).await.map_err(test_error)?;
1214 if predicate(&history) {
1215 return Ok(history);
1216 }
1217 if Instant::now() > deadline {
1218 return Err(test_error(format!(
1219 "timed out waiting for {description}: {history:#?}"
1220 )));
1221 }
1222 tokio::time::sleep(Duration::from_millis(25)).await;
1223 }
1224 }
1225
1226 /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1227 async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1228 let build_request = || -> Result<Request<body::Body>, TestError> {
1229 Request::builder()
1230 .uri("/workflows/start")
1231 .method("POST")
1232 .header("content-type", "application/json")
1233 .header("x-aion-subject", "ci")
1234 .header("x-aion-namespaces", NAMESPACE)
1235 .body(body::Body::from(
1236 serde_json::to_vec(&json!({
1237 "namespace": NAMESPACE,
1238 "workflow_type": OUTBOX_MODULE,
1239 "input": { "fixture": "input" },
1240 }))
1241 .map_err(test_error)?,
1242 ))
1243 .map_err(test_error)
1244 };
1245 let response = router
1246 .clone()
1247 .oneshot(build_request()?)
1248 .await
1249 .map_err(test_error)?;
1250 let status = response.status();
1251 let bytes = body::to_bytes(response.into_body(), usize::MAX)
1252 .await
1253 .map_err(test_error)?
1254 .to_vec();
1255 if status != StatusCode::OK {
1256 return Err(test_error(format!(
1257 "workflow start over HTTP must succeed, got {status}: {}",
1258 String::from_utf8_lossy(&bytes)
1259 )));
1260 }
1261 let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1262 // The HTTP wire contract (`clean_dtos::StartWorkflowResponse`) serializes
1263 // `workflow_id` as a plain UUID string, not a nested `{ uuid }` object.
1264 let workflow_id = body["workflow_id"]
1265 .as_str()
1266 .ok_or_else(|| test_error("start response missing workflow id"))?
1267 .parse::<uuid::Uuid>()
1268 .map_err(test_error)?;
1269 Ok(aion_core::WorkflowId::new(workflow_id))
1270 }
1271
1272 /// How long a freshly connected worker needs before the dispatch path may
1273 /// select it, DERIVED from the same two facts the server derives it from.
1274 ///
1275 /// A worker is dispatch-ineligible until it serves an OPENING PROBATION:
1276 /// [`Reachability::is_proved`] requires `DISPATCH_PROBATION_PINGS` consecutive
1277 /// answered liveness pings, at the probe's cadence of
1278 /// [`sweep_interval`](crate::worker::sweep_interval)`(heartbeat_window)`. The
1279 /// constant's own documentation states the cost — *"at the probe's cadence a
1280 /// fresh worker is undispatchable for K cadences while its first dispatches
1281 /// park"* — so this is designed behaviour a test must wait out, not a delay to
1282 /// be shortened.
1283 ///
1284 /// One extra cadence is allowed because the first round lands at an arbitrary
1285 /// offset inside the first interval: the worker connects between rounds, so it
1286 /// can miss up to one whole cadence before its first answer is even counted.
1287 ///
1288 /// # Why this is not a raised timeout
1289 ///
1290 /// It was 5 seconds, fixed, and that is how this test became one of four
1291 /// documented carriers of a load-sensitive flake
1292 /// (`gate-logs/lock-race-attribution/VERDICT.md`). The mechanism, measured:
1293 /// `dispatch_ineligible` starts EMPTY and `select_and_reserve` filters only against
1294 /// what the probe has published, so a run in which **no probe round lands
1295 /// inside the window** selects the worker immediately and passes, while a run
1296 /// in which one does correctly withholds it for ~2 cadences and fails. On the
1297 /// default 30s window that is 7.5s per cadence against a 5s wait.
1298 ///
1299 /// 🔴 The passing runs were the WRONG ones. They dispatched to a worker that
1300 /// had not served its probation — a path production does not permit, because
1301 /// production parks those dispatches. Waiting for genuine eligibility makes
1302 /// this test MORE production-shaped, not more lenient, and that is the reason
1303 /// to do it. Raising a bound until a flake stops is how a liveness bug gets
1304 /// buried; deriving the bound from the mechanism that sets it is not the same
1305 /// act, and the register warns about the first for good reason.
1306 fn eligibility_patience(config: &ServerConfig) -> Duration {
1307 let cadence = crate::worker::sweep_interval(config.worker.heartbeat_window);
1308 cadence * (crate::worker::heartbeat::DISPATCH_PROBATION_PINGS + 1)
1309 }
1310
1311 /// Wait until the worker's in-band registration lands in the SAME registry the
1312 /// dispatch path selects from, with every fan-out activity type eligible.
1313 ///
1314 /// On the deadline this reports the state that DISCRIMINATES the worlds a
1315 /// missed registration can be in, because the bare sentence it replaced —
1316 /// "worker never registered in-band for the pool" — is equally true in at
1317 /// least three of them, and they want different fixes:
1318 ///
1319 /// 1. the liminal listener never bound, so nothing could dial in;
1320 /// 2. the worker never connected, or died dialling;
1321 /// 3. it connected and registration was merely slow;
1322 /// 4. it connected, registered correctly, and the SELECTOR refused it anyway —
1323 /// because the liveness probe published it as unreachable, or because it is
1324 /// not indexed for the activity type it advertises.
1325 ///
1326 /// The fourth was not in the first version of this report, and it is the world
1327 /// a real occurrence turned out to be in: the listener was bound, a worker was
1328 /// registered under the right namespace and queue advertising all four activity
1329 /// types, and every `select_and_reserve` still returned nothing. A report that
1330 /// cannot separate "not registered" from "registered and refused" names the
1331 /// wrong half of the system.
1332 ///
1333 /// That is not a hypothetical distinction here. This module's e2e is one of
1334 /// four documented carriers of a load-sensitive flake
1335 /// (`gate-logs/lock-race-attribution/VERDICT.md`), it fails through THIS wait,
1336 /// and the reason the carrier has never been explained is that the failure
1337 /// named the fact and withheld the cause.
1338 async fn wait_for_registration(
1339 registry: &crate::worker::ConnectedWorkerRegistry,
1340 heartbeat: &crate::worker::HeartbeatTracker,
1341 listen_address: SocketAddr,
1342 patience: Duration,
1343 ) -> Result<(), TestError> {
1344 let deadline = Instant::now() + patience;
1345 loop {
1346 let now = Instant::now();
1347 let mut ready = true;
1348 for activity_type in FAN_ACTIVITY_TYPES {
1349 let Some(worker) = registry
1350 .select_and_reserve(NAMESPACE, TASK_QUEUE, activity_type, None)
1351 .map(|selected| selected.map(|(worker, _reservation)| worker))
1352 .map_err(test_error)?
1353 else {
1354 ready = false;
1355 break;
1356 };
1357 if !heartbeat
1358 .is_dispatch_reachable(worker.id(), now)
1359 .map_err(test_error)?
1360 {
1361 ready = false;
1362 break;
1363 }
1364 }
1365 if ready {
1366 return Ok(());
1367 }
1368 if Instant::now() > deadline {
1369 return Err(test_error(format!(
1370 "worker never registered in-band for the pool within {patience:?}{}",
1371 registration_diagnosis(registry, listen_address)
1372 )));
1373 }
1374 tokio::time::sleep(Duration::from_millis(10)).await;
1375 }
1376 }
1377
1378 /// The discriminator behind [`wait_for_registration`]'s failure: enough of the
1379 /// world to tell those three apart, gathered at the moment of the failure.
1380 fn registration_diagnosis(
1381 registry: &crate::worker::ConnectedWorkerRegistry,
1382 listen_address: SocketAddr,
1383 ) -> String {
1384 let mut lines = vec![String::from("--- registration diagnosis ---")];
1385 // World 1, PROBED rather than assumed. The port was reserved by binding a
1386 // listener and dropping it, so losing the race for it is a real
1387 // possibility rather than a theoretical one, and it is indistinguishable
1388 // from every other failure unless something asks.
1389 lines.push(
1390 match std::net::TcpStream::connect_timeout(&listen_address, Duration::from_millis(500))
1391 {
1392 Ok(stream) => {
1393 drop(stream);
1394 format!("listener {listen_address}: ACCEPTS — the port is bound and dialable")
1395 }
1396 Err(error) => format!(
1397 "listener {listen_address}: NOT connectable ({error}) — nothing could have \
1398 registered, so this is not a timing problem"
1399 ),
1400 },
1401 );
1402 // Worlds 2 and 3: did any worker arrive at all, and if one did, what does
1403 // the registry hold for it against what the dispatch path asks of it? A
1404 // worker present under a different pool or advertising different activity
1405 // types is a contract mismatch wearing a timeout's clothes.
1406 match registry.all_workers() {
1407 Err(error) => lines.push(format!("registry: UNREADABLE ({error})")),
1408 Ok(workers) if workers.is_empty() => lines.push(String::from(
1409 "registry: EMPTY — no worker of any pool registered, so no connection ever \
1410 completed an in-band registration",
1411 )),
1412 Ok(workers) => {
1413 lines.push(format!("registry: {} worker(s) registered", workers.len()));
1414 for worker in &workers {
1415 lines.push(format!(
1416 " id={:?} namespaces={:?} task_queue={:?} node={:?} types={:?}",
1417 worker.id(),
1418 worker.namespaces(),
1419 worker.task_queue(),
1420 worker.node(),
1421 worker.activity_types()
1422 ));
1423 }
1424 }
1425 }
1426 lines.push(format!(
1427 "asked of it: namespace={NAMESPACE:?} task_queue={TASK_QUEUE:?}"
1428 ));
1429 // The liveness probe's reachability verdict. `select_and_reserve` skips every
1430 // worker in this set, so a registered, correctly-advertised worker that is
1431 // listed here is refused for a reason nothing else in this report shows.
1432 lines.push(match registry.dispatch_ineligible() {
1433 Ok(ineligible) if ineligible.is_empty() => {
1434 String::from("dispatch-ineligible: none — reachability is not refusing anyone")
1435 }
1436 Ok(ineligible) => format!(
1437 "dispatch-ineligible: {ineligible:?} — the liveness probe has published these \
1438 as ineligible and select_and_reserve skips them. The value beside each id is WHY: \
1439 an OpeningProbation clears itself within seconds, a ReachabilityLost does not"
1440 ),
1441 Err(error) => format!("dispatch-ineligible: UNREADABLE ({error})"),
1442 });
1443 // Which of the four the selector could not satisfy, and — the part that
1444 // discriminates — the pool census beside each refusal.
1445 //
1446 // `select_and_reserve` filters on THREE things: the activity index for
1447 // `(namespace, task_queue) + activity_type`, the node pin, and the
1448 // dispatch-ineligible set. The census counts the first two and does NOT
1449 // apply the third, so the pair of answers separates the remaining worlds
1450 // that a registry dump alone leaves fused:
1451 //
1452 // - census serves it, selector refuses ⇒ REACHABILITY, not registration;
1453 // - census serves 0 for the activity ⇒ the worker is in the pool but not
1454 // indexed for this activity type;
1455 // - census serves 0 for the pool ⇒ it is not in this pool at all,
1456 // whatever `all_workers` shows.
1457 //
1458 // Written after the bare registry dump above failed to close a real case:
1459 // it proved the listener was bound and a worker with all four activity
1460 // types was registered, and still could not say why every selection
1461 // returned nothing.
1462 for activity_type in FAN_ACTIVITY_TYPES {
1463 let outcome = match registry
1464 .select_and_reserve(NAMESPACE, TASK_QUEUE, activity_type, None)
1465 .map(|selected| selected.map(|(worker, _reservation)| worker))
1466 {
1467 Ok(Some(handle)) => format!("worker {:?}", handle.id()),
1468 Ok(None) => String::from("NO worker"),
1469 Err(error) => format!("error: {error}"),
1470 };
1471 let census = match registry.pool_census(NAMESPACE, TASK_QUEUE, activity_type, None) {
1472 Ok(census) => format!(
1473 "in_pool={} serving_activity={} compatible={} last_compatible_age={:?}",
1474 census.workers_in_pool,
1475 census.workers_serving_activity,
1476 census.compatible_workers,
1477 census.last_compatible_poller_age
1478 ),
1479 Err(error) => format!("census UNREADABLE ({error})"),
1480 };
1481 lines.push(format!(
1482 "select_and_reserve({activity_type}) -> {outcome} [census: {census}]"
1483 ));
1484 }
1485 format!("\n {}", lines.join("\n "))
1486 }
1487
1488 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1489 async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1490 {
1491 let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1492 let db_path = dir.path().join("aion.db");
1493 let package_path = write_package_archive(dir.path())?;
1494 // The production path binds the CONFIGURED listen address, so commit to a
1495 // concrete reserved loopback port the worker can also dial.
1496 let listen_address = reserve_loopback_port()?;
1497
1498 // (A) Build a real ServerState through the production boot path
1499 // (ServerState::build over a haematite ServerConfig): outbox enabled,
1500 // transport = liminal, the listen address set, collect_four loaded. This
1501 // shares the haematite leaf as the dispatcher's outbox store (the real boot
1502 // store seam) and installs the production ServerOutboxDeliveryCallback over
1503 // the live engine (gated on outbox.enabled).
1504 let config = server_config(&db_path, package_path, listen_address, None);
1505 let outbox_config = config.outbox.clone();
1506 // Captured before `build` consumes the config: the wait below is derived
1507 // from the very window this server is about to run its liveness probe on.
1508 let patience = eligibility_patience(&config);
1509 let state = StateUnderTest::new(
1510 ServerState::build(config, &crate::control::StageReporter::detached())
1511 .await
1512 .map_err(test_error)?,
1513 );
1514
1515 // (B) Drive the EXACT production commissioning function run_server calls:
1516 // it hosts the liminal listener, builds the shared WorkerOutboxDispatch
1517 // with the liminal delivery attached over the shared registry + engine
1518 // callback, and spawns the real OutboxDispatcher.
1519 // Hold the returned listener guard for the test's lifetime, exactly as
1520 // run_server holds it.
1521 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1522 // Own-all, generous-default backpressure (single-node e2e): fraction 1 and
1523 // the platform default, so the ceiling never engages — the claim behaves
1524 // exactly as before, proving the production path is byte-identical on default.
1525 let backpressure_settings = BackpressureSettings {
1526 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1527 fraction: crate::worker::OwnedShardFraction::own_all(),
1528 };
1529 let listener_guard = maybe_spawn_outbox_dispatcher(
1530 &state,
1531 &outbox_config,
1532 false,
1533 backpressure_settings,
1534 &shutdown_rx,
1535 "set outbox.liminal_listen_address in the test config",
1536 )
1537 .map_err(test_error)?;
1538
1539 // (C) A REAL remote worker connects IN to the production listener and
1540 // self-registers in-band for the fixture's pool.
1541 let executions = Arc::new(AtomicUsize::new(0));
1542 let worker = WorkerThread::spawn(
1543 listen_address.to_string(),
1544 worker_config()?,
1545 worker_registry(&executions)?,
1546 );
1547
1548 // Wait until the in-band registration landed in the SAME registry the
1549 // dispatch path selects from (every fan-out activity type is eligible).
1550 let registry = state.worker_registry().clone();
1551 if let Err(error) = wait_for_registration(
1552 ®istry,
1553 state.heartbeat_tracker(),
1554 listen_address,
1555 patience,
1556 )
1557 .await
1558 {
1559 worker.stop();
1560 return Err(error);
1561 }
1562
1563 // (D) Start collect_four over the REAL HTTP transport: the engine stages
1564 // four pending outbox rows; the production-wired dispatcher claims and
1565 // pushes each to the worker.
1566 let router = http_router(state.clone()).map_err(test_error)?;
1567 let workflow_id = start_over_http(&router).await?;
1568
1569 // (E) THE PROOF: the worker executed all four activities AND every terminal
1570 // was recorded through the production engine callback (record_fan_out_completion)
1571 // — four ActivityCompleted + one WorkflowCompleted in durable history. This
1572 // is the full round-trip the retired stub never achieved.
1573 let reader = state.engine().map_err(test_error)?.store();
1574 let settled =
1575 wait_for_history(reader.as_ref(), &workflow_id, "fan-out settled", |events| {
1576 count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1577 })
1578 .await?;
1579 assert_eq!(
1580 count_completed(&settled),
1581 FAN_OUT,
1582 "every fan-out member must record a terminal through the production callback"
1583 );
1584 assert_eq!(
1585 count_workflow_completed(&settled),
1586 1,
1587 "the workflow must complete exactly once"
1588 );
1589 assert_eq!(
1590 executions.load(Ordering::SeqCst),
1591 FAN_OUT,
1592 "the remote worker must have executed every pushed dispatch exactly once"
1593 );
1594
1595 // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
1596 // stops the accept worker), shut the engine down so durable appends finish.
1597 shutdown_tx.send(true).ok();
1598 worker.stop();
1599 drop(listener_guard);
1600 state.shutdown().map_err(test_error)?;
1601 Ok(())
1602 }
1603
1604 /// The door this test sets with `outbox.liminal_max_connection_outbound_bytes`:
1605 /// 1 MiB, deliberately SMALLER than liminal's own 4 MiB default, so a refusal
1606 /// naming this number can only have come from the operator's key reaching the
1607 /// connection supervisor. The number the refusal must name.
1608 const DOOR_BYTES: u64 = 1_048_576;
1609 /// The push sent through it: 2 MiB of payload bytes, above the door in every
1610 /// wire shape the payload codec can emit (base64 is 2.67 MiB; the integer
1611 /// array is about 8 MiB), so the outcome does not depend on the encoder.
1612 const OVERSIZE_PAYLOAD_BYTES: usize = 2 * 1_048_576;
1613 /// Liminal's default outbound bound. Its ABSENCE from the refusal is the
1614 /// other half of the proof: a supervisor still on default limits refuses the
1615 /// same push naming this number instead.
1616 const LIMINAL_DEFAULT_DOOR: &str = "4194304";
1617
1618 /// A valid JSON document of [`OVERSIZE_PAYLOAD_BYTES`] bytes: one string
1619 /// value, so anything that decodes it decodes a real payload.
1620 fn oversize_json_payload() -> Result<Vec<u8>, TestError> {
1621 let filler = "x".repeat(OVERSIZE_PAYLOAD_BYTES - 2);
1622 let bytes = serde_json::to_vec(&serde_json::Value::String(filler)).map_err(test_error)?;
1623 if bytes.len() != OVERSIZE_PAYLOAD_BYTES {
1624 return Err(test_error(format!(
1625 "oversize payload must be exactly {OVERSIZE_PAYLOAD_BYTES} bytes, got {}",
1626 bytes.len()
1627 )));
1628 }
1629 Ok(bytes)
1630 }
1631
1632 /// The `frame is N bytes` number the unservable refusal names.
1633 fn refused_frame_bytes(detail: &str) -> Result<u64, TestError> {
1634 let after = detail
1635 .split_once("frame is ")
1636 .map(|(_, rest)| rest)
1637 .ok_or_else(|| test_error(format!("refusal names no frame size: {detail}")))?;
1638 let digits: String = after.chars().take_while(char::is_ascii_digit).collect();
1639 digits
1640 .parse::<u64>()
1641 .map_err(|error| test_error(format!("frame size unreadable ({error}): {detail}")))
1642 }
1643
1644 /// Push one oversize dispatch to the selected worker over the connection the
1645 /// COMMISSIONED supervisor owns, and return the refusal it came back with.
1646 ///
1647 /// The delivery handle is the registry's own — the exact `WorkerDelivery` the
1648 /// outbox dispatch path selects — so the push crosses the same connection
1649 /// process, under the same limits, as a production dispatch. The wait runs on
1650 /// a blocking thread because the reply awaiter blocks by contract.
1651 async fn push_oversize_through_the_door(
1652 registry: &crate::worker::ConnectedWorkerRegistry,
1653 ) -> Result<ServerError, TestError> {
1654 let (worker, _reservation) = registry
1655 .select_and_reserve(NAMESPACE, TASK_QUEUE, FAN_ACTIVITY_TYPES[0], None)
1656 .map_err(test_error)?
1657 .ok_or_else(|| test_error("no eligible worker to push the oversize frame to"))?;
1658 let crate::worker::registry::WorkerDelivery::Liminal(delivery) = worker.delivery().clone()
1659 else {
1660 return Err(test_error(
1661 "the registered worker must be on the liminal transport for this door to apply",
1662 ));
1663 };
1664 let request = crate::worker::liminal_transport::DispatchRequest {
1665 activity_type: FAN_ACTIVITY_TYPES[0].to_owned(),
1666 workflow_id: aion_core::WorkflowId::new_v4(),
1667 ordinal: 0,
1668 run_id: None,
1669 completion_token: "door-probe".to_owned(),
1670 idempotency_key: "door-probe".to_owned(),
1671 input: oversize_json_payload()?,
1672 attempt: 1,
1673 labels: std::collections::BTreeMap::new(),
1674 heartbeat_window_ms: 0,
1675 };
1676 let pushed = tokio::task::spawn_blocking(move || delivery.dispatch_held(&request, || true))
1677 .await
1678 .map_err(test_error)?;
1679 match pushed {
1680 Err(refusal) => Ok(refusal),
1681 Ok(reply) => Err(test_error(format!(
1682 "a {OVERSIZE_PAYLOAD_BYTES}-byte push must be refused at a {DOOR_BYTES}-byte door, \
1683 but it was carried and answered: {reply:?}"
1684 ))),
1685 }
1686 }
1687
1688 /// Stage one outbox row carrying the oversize payload for a Running workflow
1689 /// the store knows, so the PRODUCTION dispatcher claims it, pushes it through
1690 /// the same door, and has a live row to dead-letter. The history goes through
1691 /// the engine's own event store and the row through the SAME outbox store the
1692 /// dispatcher claims from. Returns the workflow id and the row's dispatch key.
1693 async fn stage_oversize_row(
1694 state: &ServerState,
1695 ) -> Result<(aion_core::WorkflowId, String), TestError> {
1696 use aion_core::{ActivityId, ContentType, EventEnvelope, Payload, RunId, WorkflowId};
1697 use aion_store::{OutboxRow, WriteToken};
1698
1699 let events_store = state.engine().map_err(test_error)?.store();
1700 let outbox = state
1701 .outbox_store()
1702 .ok_or_else(|| test_error("the haematite boot store must carry the outbox"))?;
1703
1704 let workflow_id = WorkflowId::new_v4();
1705 let run_id = RunId::new_v4();
1706 let envelope = |seq: u64| EventEnvelope {
1707 seq,
1708 recorded_at: chrono::Utc::now(),
1709 workflow_id: workflow_id.clone(),
1710 };
1711 let input = Payload::new(ContentType::Json, oversize_json_payload()?);
1712 let events = vec![
1713 Event::WorkflowStarted {
1714 envelope: envelope(1),
1715 workflow_type: OUTBOX_MODULE.to_owned(),
1716 input: Payload::from_json(&json!({})).map_err(test_error)?,
1717 run_id: run_id.clone(),
1718 parent_run_id: None,
1719 parent_workflow_id: None,
1720 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1721 },
1722 Event::ActivityScheduled {
1723 envelope: envelope(2),
1724 activity_id: ActivityId::from_sequence_position(0),
1725 activity_type: FAN_ACTIVITY_TYPES[0].to_owned(),
1726 input: input.clone(),
1727 task_queue: TASK_QUEUE.to_owned(),
1728 node: None,
1729 },
1730 Event::ActivityStarted {
1731 envelope: envelope(3),
1732 activity_id: ActivityId::from_sequence_position(0),
1733 attempt: 1,
1734 },
1735 ];
1736 events_store
1737 .append(WriteToken::recorder(), &workflow_id, &events, 0)
1738 .await
1739 .map_err(test_error)?;
1740 let row = OutboxRow::pending(
1741 workflow_id.clone(),
1742 0,
1743 FAN_ACTIVITY_TYPES[0].to_owned(),
1744 input,
1745 chrono::Utc::now(),
1746 )
1747 .with_run_id(Some(run_id));
1748 let dispatch_key = row.dispatch_key.clone();
1749 outbox
1750 .append_outbox_batch(std::slice::from_ref(&row))
1751 .await
1752 .map_err(test_error)?;
1753 Ok((workflow_id, dispatch_key))
1754 }
1755
1756 /// Wait for the production dispatcher to dead-letter the staged row, and
1757 /// return the row as the store holds it. Read through the SAME store handle
1758 /// the dispatcher writes, by the workflow's dead-letter enumeration.
1759 async fn wait_for_dead_letter(
1760 state: &ServerState,
1761 workflow_id: &aion_core::WorkflowId,
1762 dispatch_key: &str,
1763 ) -> Result<aion_store::OutboxRow, TestError> {
1764 let outbox = state
1765 .outbox_store()
1766 .ok_or_else(|| test_error("the haematite boot store must carry the outbox"))?;
1767 let deadline = Instant::now() + POLL_DEADLINE;
1768 loop {
1769 let dead = outbox
1770 .list_dead_lettered_outbox_rows(workflow_id)
1771 .await
1772 .map_err(test_error)?;
1773 if let Some(row) = dead
1774 .into_iter()
1775 .find(|row| row.dispatch_key == dispatch_key)
1776 {
1777 return Ok(row);
1778 }
1779 if Instant::now() > deadline {
1780 let in_flight = outbox
1781 .count_inflight_outbox_rows(NAMESPACE)
1782 .await
1783 .map_err(test_error)?;
1784 return Err(test_error(format!(
1785 "the oversize row {dispatch_key} was never dead-lettered within \
1786 {POLL_DEADLINE:?}; {in_flight} row(s) still in flight in `{NAMESPACE}`"
1787 )));
1788 }
1789 tokio::time::sleep(Duration::from_millis(25)).await;
1790 }
1791 }
1792
1793 /// The door-capacity acceptance, END TO END on the commission site.
1794 ///
1795 /// `outbox.liminal_max_connection_outbound_bytes` is set to 1 MiB, the EXACT
1796 /// production commissioning function builds the listener AND the connection
1797 /// supervisor from it, a REAL worker connects in, and a 2 MiB dispatch is
1798 /// pushed through that real connection twice over:
1799 ///
1800 /// 1. directly, on the registry's own delivery handle — the refusal must be
1801 /// the unservable class and must name 1048576, not liminal's 4194304
1802 /// default. This is the fact a config-field assertion cannot give: the
1803 /// number the CONNECTION PROCESS enforces, which comes from the supervisor
1804 /// the commission site built, not from the listener config it also built.
1805 /// (The evening this was written, a `LimitsConfig` assertion passed while
1806 /// every worker connection stayed at 4 MiB, because the supervisor was
1807 /// still being built with default limits.) The refusal reaching the caller
1808 /// at all, as unservable, is the reply-awaiter classification: liminal
1809 /// settles this slot from the connection process after the push call has
1810 /// returned, so it arrives on the awaiter, where it used to be wrapped as
1811 /// the retryable dispatch class;
1812 /// 2. through the PRODUCTION outbox path — a staged row carrying the same
1813 /// payload is claimed by the real dispatcher, pushed through the same door,
1814 /// and dead-lettered on its FIRST observation: `failed` with the attempt
1815 /// still at its seeded zero. A retryable reading would have re-armed it
1816 /// (attempt bumped, backoff) and spent the whole budget on a certainty.
1817 ///
1818 /// The fixture's `collect_four` package and worker are the same as the boot
1819 /// round-trip's; only the door and the payload differ.
1820 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1821 async fn the_commissioned_door_reads_the_operator_key_and_refuses_an_oversize_push_by_name()
1822 -> Result<(), TestError> {
1823 let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1824 let db_path = dir.path().join("aion.db");
1825 let package_path = write_package_archive(dir.path())?;
1826 let listen_address = reserve_loopback_port()?;
1827
1828 let config = server_config(&db_path, package_path, listen_address, Some(DOOR_BYTES));
1829 let outbox_config = config.outbox.clone();
1830 let patience = eligibility_patience(&config);
1831 let state = StateUnderTest::new(
1832 ServerState::build(config, &crate::control::StageReporter::detached())
1833 .await
1834 .map_err(test_error)?,
1835 );
1836 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1837 let backpressure_settings = BackpressureSettings {
1838 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1839 fraction: crate::worker::OwnedShardFraction::own_all(),
1840 };
1841 let listener_guard = maybe_spawn_outbox_dispatcher(
1842 &state,
1843 &outbox_config,
1844 false,
1845 backpressure_settings,
1846 &shutdown_rx,
1847 "set outbox.liminal_listen_address in the test config",
1848 )
1849 .map_err(test_error)?;
1850
1851 let executions = Arc::new(AtomicUsize::new(0));
1852 let worker = WorkerThread::spawn(
1853 listen_address.to_string(),
1854 worker_config()?,
1855 worker_registry(&executions)?,
1856 );
1857
1858 let outcome = observe_the_door(&state, listen_address, patience).await;
1859
1860 // Teardown on every path, as the other pins in this venue do.
1861 shutdown_tx.send(true).ok();
1862 worker.stop();
1863 drop(listener_guard);
1864 state.shutdown().map_err(test_error)?;
1865
1866 assert_eq!(
1867 executions.load(Ordering::SeqCst),
1868 0,
1869 "no oversize dispatch may ever reach the worker's handler"
1870 );
1871 outcome
1872 }
1873
1874 /// The measurement behind
1875 /// [`the_commissioned_door_reads_the_operator_key_and_refuses_an_oversize_push_by_name`],
1876 /// split out so its early returns cannot skip the harness teardown.
1877 async fn observe_the_door(
1878 state: &ServerState,
1879 listen_address: SocketAddr,
1880 patience: Duration,
1881 ) -> Result<(), TestError> {
1882 let registry = state.worker_registry().clone();
1883 wait_for_registration(
1884 ®istry,
1885 state.heartbeat_tracker(),
1886 listen_address,
1887 patience,
1888 )
1889 .await?;
1890
1891 // (1) The door itself, on the registry's own delivery handle.
1892 let refusal = push_oversize_through_the_door(®istry).await?;
1893 let ServerError::WorkerDispatchUnservable { detail, .. } = &refusal else {
1894 return Err(test_error(format!(
1895 "an oversize push must be refused as UNSERVABLE (never lost-connection or a \
1896 retryable dispatch fault), got: {refusal}"
1897 )));
1898 };
1899 let door = DOOR_BYTES.to_string();
1900 if !detail.contains(&format!("buffer is {door} bytes")) {
1901 return Err(test_error(format!(
1902 "the refusal must name the operator's door ({door} bytes): {detail}"
1903 )));
1904 }
1905 if detail.contains(LIMINAL_DEFAULT_DOOR) {
1906 return Err(test_error(format!(
1907 "the refusal names liminal's default door ({LIMINAL_DEFAULT_DOOR}); the supervisor \
1908 was built without the operator's limits: {detail}"
1909 )));
1910 }
1911 if !detail.contains(crate::worker::liminal_transport::OUTBOUND_BOUND_KEY) {
1912 return Err(test_error(format!(
1913 "the refusal must name the key an operator would change: {detail}"
1914 )));
1915 }
1916 let frame = refused_frame_bytes(detail)?;
1917 let payload = u64::try_from(OVERSIZE_PAYLOAD_BYTES).map_err(test_error)?;
1918 if frame < payload || frame <= DOOR_BYTES {
1919 return Err(test_error(format!(
1920 "the refused frame must carry the {payload}-byte payload and exceed the \
1921 {DOOR_BYTES}-byte door, but the refusal names {frame} bytes: {detail}"
1922 )));
1923 }
1924
1925 // (2) The production outbox path through the same door.
1926 let (workflow_id, dispatch_key) = stage_oversize_row(state).await?;
1927 let dead = wait_for_dead_letter(state, &workflow_id, &dispatch_key).await?;
1928 if dead.status != aion_store::OutboxStatus::Failed {
1929 return Err(test_error(format!(
1930 "the production dispatcher must dead-letter an unservable row, got {:?}",
1931 dead.status
1932 )));
1933 }
1934 if dead.attempt != 0 {
1935 return Err(test_error(format!(
1936 "an unservable row is dead-lettered on its FIRST observation, never re-armed for \
1937 a retry; the attempt was bumped to {}",
1938 dead.attempt
1939 )));
1940 }
1941 Ok(())
1942 }
1943
1944 /// One dispatch as the WORKER saw it: the identity the server sent it under,
1945 /// and when it arrived.
1946 #[derive(Clone, Debug)]
1947 struct SeenDispatch {
1948 activity_type: String,
1949 activity_id: String,
1950 attempt: u32,
1951 at: Instant,
1952 }
1953
1954 /// A loopback TCP relay the test can BREAK, sitting between the worker and the
1955 /// production liminal listener.
1956 ///
1957 /// The worker dials this instead of the listener, so the test owns a socket it
1958 /// can shut from the outside. That is the only way to make a REAL
1959 /// [`LiminalActivityWorker`] lose its connection mid-flight without reaching
1960 /// inside either the worker or the server — and a link broken from the inside
1961 /// would be a different experiment, because the code under test would be the
1962 /// code doing the breaking.
1963 ///
1964 /// # Why this is not the relay in `tests/dead_man_switch_e2e.rs`
1965 ///
1966 /// That file has `WedgeableRelay`, which can both wedge and sever, and this is
1967 /// deliberately not it. The two cannot be one, for a structural reason rather
1968 /// than a matter of taste: an integration test links this crate as an ordinary
1969 /// dependency, so it can see neither `#[cfg(test)] pub(crate) mod test_support`
1970 /// nor the private `maybe_spawn_outbox_dispatcher` this harness is built on,
1971 /// and `src/` cannot see `tests/`. Sharing one instrument would mean exporting
1972 /// a public, feature-gated test surface from a production crate.
1973 ///
1974 /// So the split is stated rather than hidden, and this half is a strict subset:
1975 /// it only severs. Wedging — which leaves both sockets open and merely discards
1976 /// bytes, so writes keep succeeding into the kernel buffer — is a DIFFERENT
1977 /// instrument answering a different question. #69 is about a broken link, not
1978 /// a silent one.
1979 struct SeverableRelay {
1980 address: SocketAddr,
1981 /// Every relayed socket, held so [`Self::sever`] can break them.
1982 sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
1983 stop: Arc<std::sync::atomic::AtomicBool>,
1984 handle: Option<std::thread::JoinHandle<()>>,
1985 }
1986
1987 impl SeverableRelay {
1988 /// Bind a loopback port and relay every accepted connection to `upstream`.
1989 fn spawn(upstream: SocketAddr) -> Result<Self, TestError> {
1990 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
1991 let address = listener.local_addr().map_err(test_error)?;
1992 // Non-blocking accept so the relay can be shut down deterministically
1993 // rather than by parking a thread in `accept` until something happens
1994 // to connect. Accepted sockets are put back into blocking mode
1995 // explicitly: on this platform they would otherwise inherit the flag
1996 // and every pump would spin on `WouldBlock`.
1997 listener.set_nonblocking(true).map_err(test_error)?;
1998 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1999 let sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>> =
2000 Arc::new(std::sync::Mutex::new(Vec::new()));
2001 let accept_stop = Arc::clone(&stop);
2002 let accept_sockets = Arc::clone(&sockets);
2003 let handle = std::thread::spawn(move || {
2004 while !accept_stop.load(Ordering::SeqCst) {
2005 match listener.accept() {
2006 Ok((downstream, _)) => {
2007 if let Err(error) =
2008 Self::relay_one(&downstream, upstream, &accept_sockets)
2009 {
2010 // The worker redials, so a connection this relay
2011 // fails to carry surfaces as a slower recovery
2012 // rather than as a wrong answer — but silence here
2013 // would make that indistinguishable from the
2014 // server never pushing, which is exactly the
2015 // confusion this pin exists to resolve.
2016 eprintln!("relay could not carry a connection: {error}");
2017 }
2018 }
2019 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2020 std::thread::sleep(Duration::from_millis(2));
2021 }
2022 Err(error) => {
2023 eprintln!("relay accept failed: {error}");
2024 return;
2025 }
2026 }
2027 }
2028 });
2029 Ok(Self {
2030 address,
2031 sockets,
2032 stop,
2033 handle: Some(handle),
2034 })
2035 }
2036
2037 /// Dial upstream for one accepted connection and pump both directions.
2038 fn relay_one(
2039 downstream: &std::net::TcpStream,
2040 upstream: SocketAddr,
2041 sockets: &Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
2042 ) -> Result<(), TestError> {
2043 downstream.set_nonblocking(false).map_err(test_error)?;
2044 let up = std::net::TcpStream::connect(upstream).map_err(test_error)?;
2045 let down_read = downstream.try_clone().map_err(test_error)?;
2046 let down_write = downstream.try_clone().map_err(test_error)?;
2047 let up_read = up.try_clone().map_err(test_error)?;
2048 let up_write = up.try_clone().map_err(test_error)?;
2049 let held = downstream.try_clone().map_err(test_error)?;
2050 let mut parked = sockets
2051 .lock()
2052 .map_err(|_| test_error("relay socket register poisoned"))?;
2053 parked.push(held);
2054 parked.push(up);
2055 drop(parked);
2056 for (from, to) in [(down_read, up_write), (up_read, down_write)] {
2057 std::thread::spawn(move || Self::pump(from, to));
2058 }
2059 Ok(())
2060 }
2061
2062 /// Copy one direction until the connection ends.
2063 ///
2064 /// A read or write error here IS the severed link in the expected case, and
2065 /// in every case it means the peer this pump exists to serve is gone: there
2066 /// is no party left to propagate to, so ending the pump is the handling,
2067 /// not an omission of it.
2068 fn pump(mut from: std::net::TcpStream, mut to: std::net::TcpStream) {
2069 use std::io::{Read, Write};
2070 let mut buffer = [0_u8; 8192];
2071 loop {
2072 match from.read(&mut buffer) {
2073 Ok(0) | Err(_) => return,
2074 Ok(read) => {
2075 if to.write_all(&buffer[..read]).is_err() {
2076 return;
2077 }
2078 }
2079 }
2080 }
2081 }
2082
2083 const fn address(&self) -> SocketAddr {
2084 self.address
2085 }
2086
2087 /// BREAK every relayed socket, and report how many were broken.
2088 ///
2089 /// The count is returned, and asserted non-zero by the caller, so that a
2090 /// sever which severed nothing can never masquerade as a measurement — the
2091 /// pin would otherwise pass by never having run its own experiment.
2092 fn sever(&self) -> Result<usize, TestError> {
2093 let mut parked = self
2094 .sockets
2095 .lock()
2096 .map_err(|_| test_error("relay socket register poisoned"))?;
2097 let mut severed = 0;
2098 for socket in parked.iter() {
2099 if socket.shutdown(std::net::Shutdown::Both).is_ok() {
2100 severed += 1;
2101 }
2102 }
2103 parked.clear();
2104 Ok(severed)
2105 }
2106
2107 fn shutdown(mut self) {
2108 self.stop.store(true, Ordering::SeqCst);
2109 if let Some(handle) = self.handle.take() {
2110 handle.join().ok();
2111 }
2112 }
2113 }
2114
2115 /// Registry for the reconnect pin: every dispatch is RECORDED with the identity
2116 /// the server sent it under, and [`HELD_ACTIVITY_TYPE`]'s FIRST dispatch holds
2117 /// — the work is finished, its reply is not yet on the wire — until released.
2118 ///
2119 /// Only the first is held. A blanket hold would stall the re-delivery this pin
2120 /// exists to observe, and the pin would then measure its own instrument.
2121 fn recording_registry(
2122 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
2123 release: &Arc<std::sync::atomic::AtomicBool>,
2124 ) -> Result<Arc<ActivityRegistry>, TestError> {
2125 let mut registry = ActivityRegistry::new();
2126 for activity_type in FAN_ACTIVITY_TYPES {
2127 let seen = Arc::clone(seen);
2128 let release = Arc::clone(release);
2129 let arrivals = Arc::new(AtomicUsize::new(0));
2130 registry = registry
2131 .register_activity_with_contract(
2132 activity_type,
2133 move |_input: FanInput, context: &aion_worker::ActivityContext| {
2134 let seen = Arc::clone(&seen);
2135 let release = Arc::clone(&release);
2136 let arrivals = Arc::clone(&arrivals);
2137 let record = SeenDispatch {
2138 activity_type: activity_type.to_owned(),
2139 activity_id: context.activity_id().to_string(),
2140 attempt: context.attempt(),
2141 at: Instant::now(),
2142 };
2143 Box::pin(async move {
2144 // Recorded BEFORE the hold: a dispatch that arrives and
2145 // is never answered must still be visible, or the pin
2146 // cannot tell "never re-delivered" from "re-delivered
2147 // and lost again".
2148 match seen.lock() {
2149 Ok(mut log) => log.push(record),
2150 Err(_) => {
2151 return Err(aion_worker::ActivityFailure::terminal(
2152 "the pin's dispatch log is poisoned, so this run can \
2153 observe nothing — failing loudly rather than \
2154 returning a result no assertion could trust",
2155 ));
2156 }
2157 }
2158 let first = arrivals.fetch_add(1, Ordering::SeqCst) == 0;
2159 if activity_type == HELD_ACTIVITY_TYPE && first {
2160 while !release.load(Ordering::SeqCst) {
2161 tokio::time::sleep(Duration::from_millis(5)).await;
2162 }
2163 }
2164 Ok(activity_type.to_owned())
2165 })
2166 },
2167 )
2168 .map_err(test_error)?;
2169 }
2170 Ok(Arc::new(registry))
2171 }
2172
2173 fn dispatches_of(
2174 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
2175 activity_type: &str,
2176 ) -> Result<Vec<SeenDispatch>, TestError> {
2177 let log = seen
2178 .lock()
2179 .map_err(|_| test_error("the pin's dispatch log is poisoned"))?;
2180 Ok(log
2181 .iter()
2182 .filter(|record| record.activity_type == activity_type)
2183 .cloned()
2184 .collect())
2185 }
2186
2187 fn dispatch_log(seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>) -> String {
2188 match seen.lock() {
2189 Ok(log) => format!("{:#?}", *log),
2190 Err(_) => String::from("<poisoned>"),
2191 }
2192 }
2193
2194 /// aion #69 at the ENGINE level: what the system DOES after an activity's
2195 /// completion is lost to a broken link.
2196 ///
2197 /// # What this measures, and why the transport-level pin cannot
2198 ///
2199 /// #69's existing red-first pin lives on its fix branch rather than here (it
2200 /// is red on purpose and lands with the fix), and it establishes that the
2201 /// completion is DISCARDED: the server abandons the correlated reply-wait the
2202 /// moment the delivering connection closes. It drives `WorkerDelivery`
2203 /// directly, with no engine, no store and no workflow behind it, so it can say
2204 /// nothing at all about what happens NEXT. That gap is the whole severity of
2205 /// #69: "the work is repeated once" and "the work is lost" are priced very
2206 /// differently, and nothing in-tree could tell them apart.
2207 ///
2208 /// So this pin observes four things, and asserts only what must hold in EVERY
2209 /// world — including the one a #69 fix creates:
2210 ///
2211 /// - **O4, ASSERTED** — the workflow still reaches a recorded terminal. This is
2212 /// the invariant: a broken link must not cost the workflow. It is not a weak
2213 /// assertion, because `collect_four` consumes all four members, so the
2214 /// workflow cannot complete while any member's work is missing;
2215 /// - **O1, REPORTED** — whether the held activity is dispatched a SECOND time.
2216 /// This is the MECHANISM, and the mechanism is what a fix changes: a fix that
2217 /// carries the completion across the reconnect would produce NO re-delivery,
2218 /// and a pin asserting one would read that fix as a regression.
2219 /// regression;
2220 /// - **O2, asserted CONDITIONALLY** — if a re-delivery happened it must carry
2221 /// the activity's OWN identity. That is what makes the finished work
2222 /// discarded rather than recovered; a re-delivery under a different identity
2223 /// is a different defect and must not pass quietly;
2224 /// - **O3, REPORTED** — the elapsed time from the break to the re-delivery, as
2225 /// a NUMBER asserted against nothing. No threshold is invented here: the
2226 /// right bound is a conversation to have with the measurement in hand.
2227 ///
2228 /// ⚠️ **O3 is recovery LATENCY, and latency is not COST.** The number is
2229 /// measured on a fixture activity that is a pure `String -> String`, so its
2230 /// repeat costs microseconds. The real cost of a repeat is the repeated
2231 /// activity's own runtime plus its repeated SIDE EFFECTS, which this pin does
2232 /// not measure and structurally cannot: #69's own exhibit was an *agent*
2233 /// activity, whose repeat is minutes of compute and files written twice.
2234 /// Quote the finding — *repeated work, not lost work, one repeat per in-flight
2235 /// activity* — rather than the milliseconds, which carry their premise (a
2236 /// trivial activity) only for as long as someone remembers to attach it.
2237 ///
2238 /// The settle-wait below is bounded by [`POLL_DEADLINE`], so this pin cannot
2239 /// hang; but that bound is ~100x the observed recovery, so it is a liveness
2240 /// guard and NOT a latency guard. A large latency regression would still pass
2241 /// here, reported in O3 and asserted by nothing — deliberately, because the
2242 /// correct bound is not derivable from the samples taken so far.
2243 ///
2244 /// Executions are REPORTED, never asserted equal to the fan-out. A transport
2245 /// that can lose a reply gives at-least-once delivery, so the sibling test's
2246 /// `executions == FAN_OUT` is the wrong shape here and must not be copied
2247 /// across.
2248 ///
2249 /// # The world this models
2250 ///
2251 /// One server process with its transport-loss ledger live in memory, a worker
2252 /// that redials the SAME address, and a SINGLE loss — well inside
2253 /// `TRANSPORT_LOSS_BUDGET_WINDOWS`. It is NOT a server restart and NOT budget
2254 /// exhaustion, both of which are different worlds with different recoveries.
2255 /// The re-delivery this venue can produce is the outbox dispatcher's re-claim
2256 /// under the `max_attempts`/backoff this test's config sets, not the #266
2257 /// recovery replay — which is what gives O3's number a slot to mean anything in.
2258 ///
2259 /// The relay's own accept poll (2ms) sits inside the measured elapsed.
2260 ///
2261 /// ⚠️ This pin shares a venue with
2262 /// `production_boot_dispatches_executes_and_records_over_liminal`, one of four
2263 /// documented carriers of a load-sensitive flake — 2/24 on a base that
2264 /// predates it (`gate-logs/lock-race-attribution/VERDICT.md`). It inherits that
2265 /// sensitivity, and a red here should be read against that register first.
2266 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2267 async fn a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles()
2268 -> Result<(), TestError> {
2269 let dir = crate::test_support::private_tempdir().map_err(test_error)?;
2270 let db_path = dir.path().join("aion.db");
2271 let package_path = write_package_archive(dir.path())?;
2272 let listen_address = reserve_loopback_port()?;
2273
2274 let config = server_config(&db_path, package_path, listen_address, None);
2275 let outbox_config = config.outbox.clone();
2276 // Captured before `build` consumes the config: the wait below is derived
2277 // from the very window this server is about to run its liveness probe on.
2278 let patience = eligibility_patience(&config);
2279 let state = StateUnderTest::new(
2280 ServerState::build(config, &crate::control::StageReporter::detached())
2281 .await
2282 .map_err(test_error)?,
2283 );
2284 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
2285 let backpressure_settings = BackpressureSettings {
2286 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2287 fraction: crate::worker::OwnedShardFraction::own_all(),
2288 };
2289 let listener_guard = maybe_spawn_outbox_dispatcher(
2290 &state,
2291 &outbox_config,
2292 false,
2293 backpressure_settings,
2294 &shutdown_rx,
2295 "set outbox.liminal_listen_address in the test config",
2296 )
2297 .map_err(test_error)?;
2298
2299 // The worker dials the RELAY, which carries it to the production listener.
2300 let relay = SeverableRelay::spawn(listen_address)?;
2301 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2302 let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
2303 // The redial timings are the ones this module's `worker_config` already
2304 // declares, read off it rather than re-chosen here: a reconnect pin that
2305 // picked its own recovery timings would be measuring a world of its own.
2306 let config = worker_config()?;
2307 let timing = aion_worker::RedialTiming::new(
2308 config.reconnect.initial_backoff,
2309 config.reconnect.max_backoff,
2310 );
2311 let worker = WorkerThread::spawn_redialing(
2312 relay.address().to_string(),
2313 config,
2314 recording_registry(&seen, &release)?,
2315 timing,
2316 );
2317
2318 let outcome =
2319 observe_reconnect(&state, &relay, &seen, &release, listen_address, patience).await;
2320
2321 // Teardown runs on EVERY path, including a failing one: a leaked worker
2322 // thread or listener poisons whatever runs next, and this venue is already
2323 // load-sensitive enough without the pin adding to it.
2324 shutdown_tx.send(true).ok();
2325 release.store(true, Ordering::SeqCst);
2326 worker.stop();
2327 relay.shutdown();
2328 drop(listener_guard);
2329 state.shutdown().map_err(test_error)?;
2330 outcome
2331 }
2332
2333 /// The measurement behind
2334 /// [`a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles`],
2335 /// split out so its many early returns cannot skip the harness teardown.
2336 /// Wait until the held member is dispatched and holding — the moment the link
2337 /// can be broken — and report how many of its siblings had already settled.
2338 ///
2339 /// The split at the break is REPORTED, never required. An earlier draft
2340 /// demanded that the other three settle first, for a single-variable
2341 /// experiment. Measured across runs it simply varies: the four pushes land
2342 /// within microseconds of each other and which records a terminal first is a
2343 /// race, so requiring a particular split would fail the pin for a reason that
2344 /// has nothing to do with what it measures.
2345 async fn await_held_dispatch(
2346 reader: &dyn aion_store::ReadableEventStore,
2347 workflow_id: &aion_core::WorkflowId,
2348 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
2349 ) -> Result<(SeenDispatch, usize), TestError> {
2350 let deadline = Instant::now() + POLL_DEADLINE;
2351 loop {
2352 if let Some(first) = dispatches_of(seen, HELD_ACTIVITY_TYPE)?.first() {
2353 let at_the_break = reader.read_history(workflow_id).await.map_err(test_error)?;
2354 return Ok((first.clone(), count_completed(&at_the_break)));
2355 }
2356 if Instant::now() > deadline {
2357 let history = reader.read_history(workflow_id).await.map_err(test_error)?;
2358 return Err(test_error(format!(
2359 "{HELD_ACTIVITY_TYPE} was never dispatched at all within {POLL_DEADLINE:?}, \
2360 so there was no held completion to lose and this run measured nothing.\n\
2361 dispatch log: {}\nhistory: {history:#?}",
2362 dispatch_log(seen),
2363 )));
2364 }
2365 tokio::time::sleep(Duration::from_millis(25)).await;
2366 }
2367 }
2368
2369 async fn observe_reconnect(
2370 state: &ServerState,
2371 relay: &SeverableRelay,
2372 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
2373 release: &Arc<std::sync::atomic::AtomicBool>,
2374 listen_address: SocketAddr,
2375 patience: Duration,
2376 ) -> Result<(), TestError> {
2377 wait_for_registration(
2378 state.worker_registry(),
2379 state.heartbeat_tracker(),
2380 listen_address,
2381 patience,
2382 )
2383 .await?;
2384
2385 let router = http_router(state.clone()).map_err(test_error)?;
2386 let workflow_id = start_over_http(&router).await?;
2387 let reader = state.engine().map_err(test_error)?.store();
2388
2389 let (first, settled_before) =
2390 await_held_dispatch(reader.as_ref(), &workflow_id, seen).await?;
2391
2392 // BREAK the link while the finished work is still holding its reply.
2393 let severed = relay.sever()?;
2394 let severed_at = Instant::now();
2395 if severed == 0 {
2396 return Err(test_error(
2397 "the relay severed NOTHING, so no link was ever broken and this run measured \
2398 nothing — a pass here would have been an artefact of the instrument",
2399 ));
2400 }
2401 // Release the hold: the worker now writes its reply into a dead socket.
2402 release.store(true, Ordering::SeqCst);
2403
2404 // O4 FIRST, because it is the INVARIANT: a broken link must not cost the
2405 // workflow. Every other observable here describes the MECHANISM by which
2406 // that holds, and the mechanism is exactly what a #69 fix is expected to
2407 // change — so asserting today's mechanism would make the fix read as a
2408 // regression, and would be asserting the enumeration rather than the
2409 // invariant.
2410 //
2411 // O4 is load-bearing rather than weak because `collect_four` CONSUMES all
2412 // four members: the workflow cannot reach a completed terminal while any
2413 // member's work is missing, so "the workflow settled" is not a state that
2414 // silently lost work can also produce.
2415 let settled = wait_for_history(
2416 reader.as_ref(),
2417 &workflow_id,
2418 "the workflow to settle after the severed link",
2419 |events| count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1,
2420 )
2421 .await
2422 .map_err(|error| {
2423 test_error(format!(
2424 "O4 FAILED — the workflow did not settle after the link broke ({severed} \
2425 socket(s) severed), so the lost completion cost the workflow rather than \
2426 costing a repeat of the work.\n{error}\ndispatch log: {}",
2427 dispatch_log(seen),
2428 ))
2429 })?;
2430 assert_eq!(
2431 count_completed(&settled),
2432 FAN_OUT,
2433 "every fan-out member must still record a terminal after the link broke"
2434 );
2435 assert_eq!(
2436 count_workflow_completed(&settled),
2437 1,
2438 "the workflow must complete exactly once even though a completion was lost"
2439 );
2440
2441 // O1/O2/O3 — the MECHANISM, reported. O2 is asserted only CONDITIONALLY:
2442 // if a re-delivery happened it must have carried the activity's own
2443 // identity, because a re-delivery under a different identity would be a
2444 // different defect entirely and must not pass quietly. If no re-delivery
2445 // happened, the completion survived the reconnect — which is what a fixed
2446 // #69 looks like, and this pin should report it, not fail on it.
2447 let held = dispatches_of(seen, HELD_ACTIVITY_TYPE)?;
2448 match held.get(1) {
2449 None => println!(
2450 "aion#69 — {HELD_ACTIVITY_TYPE} ({}) was NOT re-dispatched and the workflow \
2451 still settled, so the held completion survived the break; {settled_before} of \
2452 {FAN_OUT} members had settled when it broke, {severed} socket(s) severed",
2453 first.activity_id,
2454 ),
2455 Some(second) => {
2456 if second.activity_id != first.activity_id {
2457 return Err(test_error(format!(
2458 "O2 FAILED — the re-delivery carried a DIFFERENT activity identity. The \
2459 first dispatch was {} (attempt {}) and the second was {} (attempt {}), \
2460 so the work was not re-run under its own identity and #69's framing \
2461 does not describe what happened here.",
2462 first.activity_id, first.attempt, second.activity_id, second.attempt,
2463 )));
2464 }
2465 if second.attempt != first.attempt {
2466 return Err(test_error(format!(
2467 "O2 FAILED — the re-delivery of {} carried attempt {} where the first \
2468 delivery carried attempt {}. A transport redelivery is the SAME attempt \
2469 (NOI-0: only a new ActivityStarted mints a new one); a different number \
2470 means the wire was stamped with the outbox delivery count, so the lease \
2471 and completion would name an attempt no start recorded.",
2472 first.activity_id, second.attempt, first.attempt,
2473 )));
2474 }
2475 let recovery = second.at.saturating_duration_since(severed_at);
2476 println!(
2477 "aion#69 O3 — re-delivery of {} ({}) took {}ms from the link breaking; \
2478 first attempt {}, second attempt {}; {settled_before} of {FAN_OUT} members \
2479 had already recorded a terminal when the link broke; {severed} socket(s) \
2480 severed",
2481 HELD_ACTIVITY_TYPE,
2482 first.activity_id,
2483 recovery.as_millis(),
2484 first.attempt,
2485 second.attempt,
2486 );
2487 }
2488 }
2489
2490 let all = seen
2491 .lock()
2492 .map_err(|_| test_error("the pin's dispatch log is poisoned"))?
2493 .len();
2494 println!(
2495 "aion#69 — {all} dispatch(es) served for {FAN_OUT} activities; the transport is \
2496 at-least-once, so the excess is the repeated work a broken link costs"
2497 );
2498 Ok(())
2499 }
2500}