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_and_claim, 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 match run_server(overrides).await {
43 Ok(code) => code,
44 Err(error) => {
45 error!(%error, "aion-server failed");
46 if error.is_config() {
47 ExitCode::from(2)
48 } else {
49 ExitCode::FAILURE
50 }
51 }
52 }
53}
54
55/// The where-to-edit half of the missing `outbox.liminal_listen_address`
56/// refusal: a liminal outbox refusal must name the FILE to edit, not just the
57/// key — the operator reading it is exactly the operator who did not write
58/// the config (a scaffolded or setup-script home).
59fn liminal_address_hint(source: &crate::config::ConfigSource) -> String {
60 match source {
61 crate::config::ConfigSource::BuiltInDefaults => {
62 "set AION_OUTBOX_LIMINAL_LISTEN_ADDRESS, or add `liminal_listen_address = \
63 \"127.0.0.1:50061\"` to `[outbox]` in a config file"
64 .to_owned()
65 }
66 source => format!(
67 "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the {source}"
68 ),
69 }
70}
71
72/// Everything `run_server` must capture from the merged config BEFORE
73/// `ServerState::build` consumes it — the wiring below the build reads these,
74/// not the (moved) config.
75struct PreBuildCaptures {
76 /// The selected backend, surfaced so the boot banner records it.
77 store_backend: StoreBackend,
78 /// Static shard assignment (SS-1): the operator's pinned shard set from
79 /// `[store] owned_shards`. Empty means own ALL shards (single-node
80 /// default). The set is carried into `RuntimeConfig` by `into_parts` and
81 /// applied to the `EngineBuilder` during state construction; surfaced
82 /// here so the boot banner records which shards this node serves. No
83 /// election is performed.
84 owned_shards: Vec<usize>,
85 /// The outbox settings, so the (default-off) outbox dispatcher can be
86 /// wired after state is up. The dispatcher shares the engine's
87 /// already-opened haematite store via `state.outbox_store()`, so no
88 /// store settings are needed.
89 outbox_config: crate::config::OutboxConfig,
90 /// Control-Plane Phase 2 (P2-Q2): the keyed-backpressure inputs — the
91 /// generous platform-default ceiling and this node's owned-shard
92 /// fraction. On a single-node / own-all boot the fraction is 1, so
93 /// per-node ceilings equal the cluster-wide quota and, with the generous
94 /// default and no tenant override, the ceiling never engages
95 /// (byte-identical claim).
96 backpressure_settings: BackpressureSettings,
97 /// The SS-5b failover supervisor knobs. Only a distributed haematite
98 /// boot carries a `[store.cluster]` section; this is `None` for every
99 /// single-node boot, so no supervisor is ever spawned.
100 cluster_config: Option<crate::config::ClusterConfig>,
101 /// The managed-worker supervision policy. Resolution already happened
102 /// during config validation, so this cannot surprise an operator at
103 /// boot; it is re-resolved here because the policy is COMMISSIONED onto
104 /// the supervisor built into state below, and a server without the
105 /// section supervises nothing.
106 supervision_policy: Option<crate::worker::SupervisionPolicy>,
107}
108
109impl PreBuildCaptures {
110 fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
111 Ok(Self {
112 store_backend: config.store.backend,
113 owned_shards: config.store.owned_shards.clone(),
114 outbox_config: config.outbox.clone(),
115 backpressure_settings: BackpressureSettings::from_config(config),
116 cluster_config: config.store.cluster.clone(),
117 supervision_policy: config.worker_supervision.resolve()?,
118 })
119 }
120}
121
122async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
123 observability::tracing::init()?;
124
125 // #180: a boot that discovers no config anywhere first scaffolds
126 // `<AION_HOME>/config.toml` from the embedded template (claim-only-when-
127 // empty), then loads it — config LOAD itself stays pure and read-only.
128 let loaded = crate::config::load_or_scaffold(&cli)?;
129 loaded.resolution.ensure_private_home()?;
130 // Arm the death note as early as the home exists, so every later failure
131 // path — including config validation and state build — runs inside the
132 // ARMED/DISARMED bracket. Two anonymous server deaths on 2026-08-16 are
133 // why this exists; see the module docs for the exact coverage.
134 let death_note = crate::death_note::DeathNote::arm(&loaded.resolution.home)?;
135 let home = loaded.resolution.home.clone();
136 loaded.resolution.log_startup();
137 let liminal_address_hint = liminal_address_hint(&loaded.resolution.source);
138 // The boot-side config heal already logged each inserted field by name;
139 // the banner below carries the count so one line summarizes the boot.
140 let config_healed_field_count = loaded.healed.inserted.len();
141 let config = loaded.config;
142 reject_auth_without_feature(&config)?;
143 let captures = PreBuildCaptures::from_config(&config)?;
144 let state = ServerState::build(config).await?;
145 reject_tls_until_supported(&state)?;
146
147 let runtime = state.runtime_config();
148 let grpc_address = runtime.listen.grpc;
149 let http_address = runtime.listen.http;
150 let workflow_packages: Vec<String> = runtime
151 .workflow_packages
152 .iter()
153 .map(|path| path.display().to_string())
154 .collect();
155 // The revision, not just the version. A crate version cannot distinguish
156 // two builds from different commits of the same version, and that is the
157 // distinction an operator needs when deciding whether a restart restores
158 // what was running or substitutes something else (#123). The endpoint
159 // answers this too, but a crashed server leaves only its log.
160 let build = crate::build_identity::BuildIdentity::current();
161 // #139: the server-resolved workspace root (the aion home's `clones/`
162 // directory) that declared bodies expand `{workspace_root}` with. Reported
163 // here so composition points (setup.sh today, the workspace verb later)
164 // READ the value from the server that will use it instead of re-deriving
165 // it. An unresolvable root is reported as exactly that — never fabricated;
166 // a placeholder-bearing dispatch will refuse terminally with this reason.
167 // The rendering itself is `WorkspaceRoot::banner_value`, pinned by its own
168 // two-case test, so the banner and the tests cannot drift apart.
169 let workspace_root = state.workspace_root().banner_value();
170 info!(
171 version = env!("CARGO_PKG_VERSION"),
172 build = %build.line(),
173 commit = build.commit,
174 grpc_address = %grpc_address,
175 http_address = %http_address,
176 default_namespace = %runtime.default_namespace,
177 namespace_mode = namespace_mode_label(&runtime.namespace.mode),
178 store_backend = store_backend_label(captures.store_backend),
179 auth_enabled = runtime.auth.enabled,
180 deploy_enabled = runtime.deploy.enabled,
181 metrics_enabled = runtime.metrics.enabled,
182 workspace_root = %workspace_root,
183 death_note = %death_note.path().display(),
184 workflow_package_count = workflow_packages.len(),
185 workflow_packages = ?workflow_packages,
186 owned_shards = ?captures.owned_shards,
187 owns_all_shards = captures.owned_shards.is_empty(),
188 config_healed_field_count,
189 "aion-server startup banner"
190 );
191 // #139 leg C: the assistant ships IN aion. The embedded document is
192 // installed here — after the engine has reloaded every persisted package,
193 // so the install can see what is already resident, and before the
194 // transports accept traffic, so the first caller finds it. It claims only a
195 // catalog holding no version of the assistant type; anything else is the
196 // operator's cut to make, and the outcome says so in the log either way.
197 crate::assistant::install_embedded_assistant_for_server(&state, &liminal_address_hint).await;
198 // #189 slice one: the built-in update check ships the same way, under the
199 // same only-the-empty-case install rule. Installing makes it STARTABLE
200 // and nothing else — no check runs without an explicit operator act.
201 crate::update_check::install_embedded_update_check_for_server(&state).await;
202 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
203 // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
204 // The single outbox dispatcher task is spawned in BOTH modes; the difference
205 // is only how ownership is enforced. Single-node (`None`) owns all shards by
206 // construction (`owned_shard_scope() == None`), so its claim sweeps see every
207 // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
208 // filter — already seeded by `set_owned_shards` during `ServerState::build`,
209 // which runs before this point — so each node only ever claims rows on the
210 // shards it owns. Compute the flag here where the cluster section is in
211 // scope; pass it to the gate so the boot banner records the mode.
212 let outbox_clustered = captures.cluster_config.is_some();
213 // Dormant by default: only when `outbox.enabled` is set does the
214 // non-replayed outbox dispatcher task start. With the flag off (the
215 // default) nothing here runs and server behaviour is unchanged.
216 // Hold the liminal worker listener (if any) for the server's lifetime: it is
217 // dropped at the end of `run_server`, after the serve `select!` completes, so
218 // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
219 // #204/#253: rebuild the pause dispatch-hold and settle terminal
220 // workflows' stranded outbox rows BEFORE the dispatcher's first claim.
221 rebuild_outbox_boot_state(&state, &captures.outbox_config).await;
222 let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
223 &state,
224 &captures.outbox_config,
225 outbox_clustered,
226 captures.backpressure_settings,
227 &shutdown_rx,
228 &liminal_address_hint,
229 )?;
230 // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
231 // supervisor — automatic failover detection. A single-node boot spawns
232 // nothing here (the method returns `false`), so default behaviour is
233 // unchanged.
234 maybe_spawn_cluster_supervisor(&state, captures.cluster_config.as_ref(), &shutdown_rx)?;
235 // #176: the worker heartbeat expiry sweeper is ALWAYS commissioned —
236 // dead-worker detection is a liveness correctness property, not an opt-in
237 // feature. It is the production caller of `fail_expired_workers`: a worker
238 // whose stream stays open while its process wedges (stops heartbeating
239 // without disconnecting) is expired, deregistered with the provable Timeout
240 // reason, and its in-flight tasks surface as TRANSPORT losses, re-dispatched
241 // attempt-neutrally rather than charged to the action's retry budget.
242 // Cadence derives from `worker.heartbeat_window` (quarter-window, clamped to
243 // [1s, window]; the default 30s window sweeps every 7.5s) — deliberately no
244 // separate config knob. It drains on the same shutdown watch as the
245 // transports; dropping the JoinHandle only detaches the task.
246 drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
247 commission_worker_supervision(&state, captures.supervision_policy).await;
248 // Bind both listeners and claim the home FIRST: the pid record the stop
249 // verb trusts is written only once both doors are provably ours.
250 let doors = bind_and_claim(
251 &home,
252 grpc_address,
253 http_address,
254 build.commit,
255 state.runtime_config().drain_timeout,
256 )
257 .await?;
258 let identity_pid = doors.identity_pid;
259 let pid_file_guard = doors.pid_file_guard;
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 let report = serve_until_shutdown(&state, &shutdown_tx, &mut grpc, &mut http).await?;
283
284 let outcome = report.outcome;
285 let exit_code = outcome.exit_code();
286 // The rich outcome crosses the process boundary through the death note
287 // (one file, one writer); the exit code keeps the #207 contract.
288 death_note.record_outcome(&crate::control::outcome::OutcomeRecord::from_report(
289 identity_pid,
290 &report,
291 ));
292 death_note.disarm(&format!(
293 "clean run-loop exit: shutdown outcome {outcome:?}"
294 ));
295 drop(pid_file_guard);
296 Ok(exit_code)
297}
298
299/// Install the operator's supervision policy and converge the fleet.
300///
301/// Uncommissioned is a first-class but never SILENT state: a server with no
302/// `[worker_supervision]` section supervises nothing, and every deployment that
303/// wanted to be running is named in the warning, so the gap between "the
304/// operator deployed a worker" and "nothing is running it" is never quiet.
305async fn commission_worker_supervision(
306 state: &ServerState,
307 policy: Option<crate::worker::SupervisionPolicy>,
308) {
309 let supervisor = state.worker_supervisor();
310 let Some(policy) = policy else {
311 match supervisor.report().await {
312 Ok(report) => {
313 let wanted: Vec<&str> = report
314 .workers
315 .iter()
316 .filter(|worker| worker.desired == aion_store::DesiredState::Running)
317 .map(|worker| worker.name.as_str())
318 .collect();
319 if wanted.is_empty() {
320 info!("managed-worker supervision is not configured; no deployment wants it");
321 } else {
322 warn!(
323 deployments = wanted.join(", "),
324 remedy = crate::worker::supervisor::UNCOMMISSIONED_REMEDY,
325 "worker deployments want to be running but supervision is not configured"
326 );
327 }
328 }
329 Err(error) => error!(
330 %error,
331 "managed-worker supervision is not configured and the deployment records \
332 could not be read to say what that costs"
333 ),
334 }
335 return;
336 };
337 if !supervisor.commission(policy, crate::worker::ManagedExecutable::CurrentServer) {
338 error!("managed-worker supervision was already commissioned before boot completed");
339 return;
340 }
341 match supervisor.reconcile().await {
342 Ok(0) => info!("managed-worker supervision commissioned; no deployment wants to run"),
343 Ok(supervised) => info!(supervised, "managed-worker supervision commissioned"),
344 Err(error) => error!(%error, "managed-worker fleet could not be converged at boot"),
345 }
346}
347
348fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
349 if cfg!(not(feature = "auth")) && config.auth.enabled {
350 return Err(ServerError::Config {
351 message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
352 });
353 }
354 Ok(())
355}
356
357fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
358 if state.runtime_config().tls.is_some() {
359 return Err(ServerError::Config {
360 message: "configured TLS material cannot be served until transport TLS is wired"
361 .to_owned(),
362 });
363 }
364 Ok(())
365}
366
367fn store_backend_label(backend: StoreBackend) -> &'static str {
368 match backend {
369 StoreBackend::Memory => "memory",
370 StoreBackend::Haematite => "haematite",
371 }
372}
373
374fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
375 match mode {
376 NamespaceMode::SharedEngine => "SharedEngine",
377 NamespaceMode::SingleTenant { .. } => "SingleTenant",
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 #![allow(clippy::expect_used)]
384
385 use super::outbox_commission::{
386 BackpressureSettings, maybe_spawn_outbox_dispatcher, resolve_outbox_reconciler_config,
387 };
388 use crate::ServerState;
389 use crate::config::RuntimeConfig;
390 use crate::config::{OutboxConfig, OutboxTransport};
391 use aion_store::InMemoryStore;
392 use std::net::SocketAddr;
393 use std::time::Duration;
394
395 /// Own-all, generous-default backpressure settings for the gate tests (the
396 /// single-node default: fraction 1, so the ceiling never engages).
397 fn test_backpressure_settings() -> BackpressureSettings {
398 BackpressureSettings {
399 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
400 fraction: crate::worker::OwnedShardFraction::own_all(),
401 }
402 }
403
404 /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
405 /// tests (mirrors `state.rs`'s test `runtime_config`).
406 fn runtime_config() -> RuntimeConfig {
407 use crate::config::{
408 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
409 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
410 WebSocketConfig, WorkerConfig,
411 };
412 RuntimeConfig {
413 listen: ListenConfig {
414 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
415 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
416 },
417 tls: None,
418 auth: AuthConfig {
419 enabled: false,
420 jwks_url: None,
421 jwks_refresh_seconds: 300,
422 },
423 ops_console: OpsConsoleConfig {
424 source: OpsConsoleAssetSource::Embedded,
425 },
426 namespace: NamespaceConfig {
427 mode: NamespaceMode::SharedEngine,
428 },
429 worker: WorkerConfig {
430 heartbeat_window: Duration::from_secs(30),
431 ..WorkerConfig::default()
432 },
433 websocket: WebSocketConfig {
434 outbound_buffer_bound: 32,
435 event_broadcast_capacity: Some(64),
436 cluster_broadcast_capacity: Some(64),
437 },
438 workflow_packages: Vec::new(),
439 deploy: DeployConfig::default(),
440 authoring: AuthoringConfig::default(),
441 dev: DevConfig::default(),
442 outbox: OutboxConfig::default(),
443 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
444 mcp: crate::config::ResolvedMcpConfig::default(),
445 scheduler_threads: 1,
446 jit_threshold: None,
447 query_timeout: Some(Duration::from_secs(10)),
448 workloop_sweep_interval: Some(Duration::from_millis(50)),
449 default_namespace: "default".to_owned(),
450 auto_create: crate::config::AutoCreate::Open,
451 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
452 drain_timeout: Duration::from_secs(30),
453 metrics: MetricsConfig { enabled: true },
454 owned_shards: Vec::new(),
455 cors_allowed_origins: Vec::new(),
456 }
457 }
458
459 /// An `OutboxConfig` with `enabled = true` and every required knob present, so
460 /// the only remaining gate is the store-backend / outbox-table availability.
461 fn enabled_outbox_config() -> OutboxConfig {
462 OutboxConfig {
463 enabled: true,
464 poll_interval_ms: Some(250),
465 batch_size: Some(64),
466 max_attempts: Some(5),
467 backoff_base_ms: Some(100),
468 backoff_multiplier: Some(2),
469 backoff_max_ms: Some(30_000),
470 reconcile_interval_ms: None,
471 reconcile_stale_after_ms: None,
472 transport: OutboxTransport::Grpc,
473 liminal_listen_address: None,
474 }
475 }
476
477 /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
478 /// dispatcher against the in-memory backend (which has no outbox table, so
479 /// `outbox_store()` is `None`) is a configuration error, and the message names
480 /// haematite as the required durable backend.
481 #[tokio::test]
482 async fn outbox_enabled_on_memory_backend_is_a_config_error() {
483 let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
484 .await
485 .expect("build in-memory state");
486 let (_tx, rx) = tokio::sync::watch::channel(false);
487 let error = maybe_spawn_outbox_dispatcher(
488 &state,
489 &enabled_outbox_config(),
490 false,
491 test_backpressure_settings(),
492 &rx,
493 "set outbox.liminal_listen_address in the test config",
494 )
495 .expect_err("outbox.enabled on the memory backend must be a config error");
496 assert!(
497 error.is_config(),
498 "memory-backend outbox error must be Config"
499 );
500 let message = error.to_string();
501 assert!(
502 message.contains("store.backend=haematite"),
503 "message must name the durable backend, got: {message}"
504 );
505 }
506
507 /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
508 /// gate is a no-op even on a memory backend — nothing is spawned and no error
509 /// is produced, so a default single-node boot is unchanged.
510 #[tokio::test]
511 async fn disabled_outbox_is_a_noop_on_any_backend() {
512 let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
513 .await
514 .expect("build in-memory state");
515 let (_tx, rx) = tokio::sync::watch::channel(false);
516 maybe_spawn_outbox_dispatcher(
517 &state,
518 &OutboxConfig::default(),
519 false,
520 test_backpressure_settings(),
521 &rx,
522 "set outbox.liminal_listen_address in the test config",
523 )
524 .expect("disabled outbox gate must be an infallible no-op");
525 }
526
527 /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
528 /// set — the condition under which the clustered-boot WARN fires.
529 #[test]
530 fn reconciler_config_absent_unless_both_knobs_set() {
531 let mut config = enabled_outbox_config();
532 // Neither knob: absent.
533 assert!(
534 resolve_outbox_reconciler_config(&config)
535 .expect("resolve")
536 .is_none()
537 );
538 // Only interval: still absent (the silent-backstop-absent default).
539 config.reconcile_interval_ms = Some(1_000);
540 assert!(
541 resolve_outbox_reconciler_config(&config)
542 .expect("resolve")
543 .is_none()
544 );
545 // Both set: present.
546 config.reconcile_stale_after_ms = Some(60_000);
547 assert!(
548 resolve_outbox_reconciler_config(&config)
549 .expect("resolve")
550 .is_some()
551 );
552 }
553
554 /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
555 /// Commissioning the dispatcher with `transport = liminal` but no listen
556 /// address is a configuration error naming the missing knob, rather than a
557 /// panic or a silent fall-through to gRPC. Built over haematite (so
558 /// the outbox-store gate passes and the missing-address check is actually
559 /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
560 /// only exists with `liminal-transport` on; in a feature-off build the same
561 /// selection is the missing-feature error instead, covered by the type system
562 /// rather than this test.)
563 #[cfg(feature = "liminal-transport")]
564 #[tokio::test]
565 async fn liminal_transport_requires_listen_address() {
566 use crate::config::{
567 RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
568 };
569
570 let data_dir = std::env::temp_dir().join(format!(
571 "aion-lsub-prod-listen-guard-{}-{}",
572 std::process::id(),
573 std::time::SystemTime::now()
574 .duration_since(std::time::UNIX_EPOCH)
575 .map(|elapsed| elapsed.as_nanos())
576 .unwrap_or_default()
577 ));
578 let mut outbox = enabled_outbox_config();
579 outbox.transport = OutboxTransport::Liminal;
580 outbox.liminal_listen_address = None;
581 let config = ServerConfig {
582 store: StoreConfig {
583 backend: StoreBackend::Haematite,
584 data_dir: Some(data_dir.to_string_lossy().into_owned()),
585 // Required, no default: the haematite boot path refuses a config
586 // that does not rule on the node cache's byte ceiling.
587 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
588 ..StoreConfig::default()
589 },
590 runtime: RuntimeSection {
591 scheduler_threads: 1,
592 jit_threshold: None,
593 workloop_sweep_interval_ms: Some(50),
594 query_timeout_ms: Some(10_000),
595 },
596 websocket: WebSocketConfig {
597 outbound_buffer_bound: 32,
598 event_broadcast_capacity: Some(64),
599 cluster_broadcast_capacity: Some(64),
600 },
601 outbox: outbox.clone(),
602 // Required, no default: the transcript drain's flush policy.
603 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
604 ..ServerConfig::default()
605 };
606 let state = ServerState::build(config)
607 .await
608 .expect("build haematite state");
609 let (_tx, rx) = tokio::sync::watch::channel(false);
610
611 let error = maybe_spawn_outbox_dispatcher(
612 &state,
613 &outbox,
614 false,
615 test_backpressure_settings(),
616 &rx,
617 "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the test config",
618 )
619 .expect_err("liminal transport without a listen address must be a config error");
620 assert!(
621 error.is_config(),
622 "missing-listen-address error must be Config"
623 );
624 assert!(
625 error.to_string().contains("liminal_listen_address"),
626 "error must name the missing knob, got: {error}"
627 );
628 // #180 review MAJ-4: the refusal must carry the caller's threaded
629 // where-to-edit hint, so the production message names the resolved
630 // config FILE, not just the key.
631 assert!(
632 error.to_string().contains("in the test config"),
633 "error must carry the threaded config-location hint, got: {error}"
634 );
635 }
636}
637
638/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
639///
640/// This is the proof that the production boot now does the full round-trip the
641/// retired stub could not. It drives the EXACT production commissioning function
642/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
643/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
644/// `liminal_listen_address`. That function lifts the full push wiring
645/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
646/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) over the
647/// SAME registry the gRPC path uses and the SAME
648/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
649/// (over the live engine), and spawns the real [`OutboxDispatcher`].
650///
651/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
652/// connects IN to the listener and self-registers in-band. A `collect_four`
653/// fan-out is started over the REAL HTTP transport, which stages four pending
654/// outbox rows; the production-wired dispatcher claims and pushes each to the
655/// worker, the worker executes it, and its completion re-enters aion through the
656/// production engine callback — `record_fan_out_completion` — driving the
657/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
658/// executed the activities, AND the terminals were recorded in history (four
659/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
660/// publish-and-mark-done path never achieved.
661#[cfg(all(test, feature = "liminal-transport"))]
662mod lsub_prod_xnode_e2e {
663 #![allow(clippy::expect_used)]
664
665 use std::net::SocketAddr;
666 use std::path::PathBuf;
667 use std::sync::Arc;
668 use std::sync::atomic::{AtomicUsize, Ordering};
669 use std::time::{Duration, Instant};
670
671 use aion_core::Event;
672 use aion_package::{
673 ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
674 ManifestVersion, PackageBuilder, PackageContract, WorkerContract,
675 };
676 use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
677 use axum::body;
678 use axum::http::{Request, StatusCode};
679 use serde_json::json;
680 use tower::ServiceExt;
681
682 use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
683 use crate::ServerState;
684 use crate::api::http::http_router;
685 use crate::config::{
686 OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
687 WebSocketConfig,
688 };
689
690 type TestError = Box<dyn std::error::Error + Send + Sync>;
691
692 /// The `collect_four` fixture passes each member the JSON string `"in"` as
693 /// activity input, so the worker handler decodes a [`String`], not a struct.
694 type FanInput = String;
695
696 const NAMESPACE: &str = "default";
697 const TASK_QUEUE: &str = "default";
698 const OUTBOX_MODULE: &str = "aion_outbox_fixture";
699 const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
700 const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
701 const FAN_OUT: usize = 4;
702 const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
703 const POLL_DEADLINE: Duration = Duration::from_secs(20);
704 /// The one fan-out member the reconnect pin holds. Any of the four would do —
705 /// they are dispatched independently and served by identical handlers.
706 const HELD_ACTIVITY_TYPE: &str = FAN_ACTIVITY_TYPES[0];
707
708 fn test_error(message: impl std::fmt::Display) -> TestError {
709 message.to_string().into()
710 }
711
712 /// Reserve a loopback port and return it: the liminal listener binds this exact
713 /// address (the production path binds the configured `liminal_listen_address`,
714 /// so the test must commit to a concrete port the worker can also dial).
715 fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
716 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
717 let address = listener.local_addr().map_err(test_error)?;
718 drop(listener);
719 Ok(address)
720 }
721
722 /// The fixture's queue-scoped `.v4` contract: the four `fan:N` activities
723 /// `collect_four` schedules, declared on the queue its worker actually polls.
724 ///
725 /// Why the archive cannot just carry the manifest-derived record: by design
726 /// `PackageContract::from_manifest` "never invents a queue", so a manifest's
727 /// bare activity names land in `unscoped_activities` — and this server boots
728 /// queue-routed, where an unscoped catalog is a terminal
729 /// `NO_QUEUE_DECLARATION` at start admission
730 /// (`aion::lifecycle::start_admission`). That refusal is EARNED: an unserved
731 /// queue would otherwise wait silently forever. So the derived record is
732 /// amended rather than bypassed — the same four names move out of
733 /// `unscoped_activities` and onto the queue that serves them — and the
734 /// package still loads through the production boot path with the `.v4`
735 /// identity `PackageBuilder` stamps over this exact contract.
736 ///
737 /// The action schemas come from the SAME generator the worker's typed
738 /// registry uses, for the SAME Rust types: `collect_four` passes each member
739 /// the JSON string `"in"` and the handler returns a [`String`]. Deriving both
740 /// sides from `activity_descriptor::<FanInput, String>` means the package's
741 /// declaration and the worker's advertisement cannot drift apart, so
742 /// registration admission (`WORKER_CONTRACT_MISMATCH`) compares two schemas
743 /// with one source.
744 fn fixture_contract(manifest: &Manifest) -> Result<PackageContract, TestError> {
745 let mut actions = Vec::with_capacity(FAN_ACTIVITY_TYPES.len());
746 for activity_type in FAN_ACTIVITY_TYPES {
747 let descriptor = aion_worker::activity_descriptor::<FanInput, String>(activity_type)
748 .map_err(test_error)?;
749 actions.push(ActionContract {
750 name: descriptor.name,
751 input_schema: descriptor.input_schema,
752 output_schema: descriptor.output_schema,
753 node: None,
754 timeout: None,
755 retry: None,
756 advisory: false,
757 // A typed `String -> String` handler serves these, not an agent
758 // harness — the fan fixture's shape merely coincides with an
759 // agent seam's, and marking it would route it somewhere no
760 // handler is.
761 agent: false,
762 // A connected worker serves this fixture's queue, so the
763 // declaration carries no body of its own.
764 body: None,
765 });
766 }
767 let mut contract = PackageContract::from_manifest(manifest);
768 contract.workers = vec![WorkerContract {
769 task_queue: TASK_QUEUE.to_owned(),
770 actions,
771 }];
772 contract.unscoped_activities.clear();
773 Ok(contract)
774 }
775
776 /// Build the `collect_four` package on disk so the production state-build path
777 /// loads it exactly as it loads operator-supplied `workflow_packages`.
778 fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
779 let beams =
780 BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
781 let manifest = Manifest {
782 entry_module: OUTBOX_MODULE.to_owned(),
783 entry_function: "collect_four".to_owned(),
784 input_schema: json!({ "type": "object" }),
785 output_schema: json!({}),
786 timeout: Some(Duration::from_secs(30)),
787 // The four ordinals `collect_four` actually fans out. This manifest
788 // used to name one invented activity, `fixture_activity`, that the
789 // fixture never schedules and no worker ever served.
790 activities: FAN_ACTIVITY_TYPES
791 .iter()
792 .map(|activity_type| DeclaredActivity {
793 activity_type: (*activity_type).to_owned(),
794 })
795 .collect(),
796 version: ManifestVersion::new("stamped-by-builder"),
797 format_version: CURRENT_FORMAT_VERSION,
798 additional_workflows: Vec::new(),
799 };
800 let contract = fixture_contract(&manifest)?;
801 let archive =
802 PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
803 .with_contract(contract)
804 .write_to_bytes()
805 .map_err(test_error)?;
806 let path = dir.join("collect_four.aion");
807 std::fs::write(&path, archive).map_err(test_error)?;
808 Ok(path)
809 }
810
811 /// A production-shaped `ServerConfig`: the haematite backend (so the boot store
812 /// path shares the leaf as the dispatcher's outbox store, exactly as
813 /// `ServerState::build` does in production), `outbox.enabled`,
814 /// `transport = liminal`, the reserved `liminal_listen_address`, and the
815 /// `collect_four` package. Built through `ServerState::build` (not
816 /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
817 fn server_config(
818 data_dir: &std::path::Path,
819 package_path: PathBuf,
820 listen_address: SocketAddr,
821 ) -> ServerConfig {
822 ServerConfig {
823 store: StoreConfig {
824 backend: StoreBackend::Haematite,
825 data_dir: Some(data_dir.to_string_lossy().into_owned()),
826 // Required, no default: the haematite boot path refuses a config
827 // that does not rule on the node cache's byte ceiling.
828 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
829 ..StoreConfig::default()
830 },
831 runtime: RuntimeSection {
832 scheduler_threads: 1,
833 jit_threshold: None,
834 workloop_sweep_interval_ms: Some(50),
835 query_timeout_ms: Some(10_000),
836 },
837 websocket: WebSocketConfig {
838 outbound_buffer_bound: 32,
839 event_broadcast_capacity: Some(64),
840 cluster_broadcast_capacity: Some(64),
841 },
842 workflow_packages: vec![package_path],
843 outbox: OutboxConfig {
844 enabled: true,
845 poll_interval_ms: Some(20),
846 batch_size: Some(16),
847 max_attempts: Some(5),
848 backoff_base_ms: Some(50),
849 backoff_multiplier: Some(2),
850 backoff_max_ms: Some(1_000),
851 reconcile_interval_ms: None,
852 reconcile_stale_after_ms: None,
853 transport: OutboxTransport::Liminal,
854 liminal_listen_address: Some(listen_address.to_string()),
855 },
856 // Required, no default: the transcript drain's flush policy.
857 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
858 ..ServerConfig::default()
859 }
860 }
861
862 /// The remote worker self-describes for the fixture's pool `(default, default)`
863 /// and registers a handler for every `fan:N` activity type, counting executions
864 /// so the test proves it genuinely ran the pushed dispatches.
865 fn worker_config() -> Result<WorkerConfig, TestError> {
866 WorkerConfig::builder()
867 .endpoint("unused-direct-address")
868 .namespace(NAMESPACE)
869 .task_queue(TASK_QUEUE)
870 .identity("lsub-prod-worker")
871 .max_concurrency(4)
872 .reconnect_initial_backoff(Duration::from_millis(5))
873 .reconnect_max_backoff(Duration::from_millis(20))
874 .reconnect_max_attempts(3)
875 .build()
876 .map_err(test_error)
877 }
878
879 fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
880 let mut registry = ActivityRegistry::new();
881 for activity_type in FAN_ACTIVITY_TYPES {
882 let executions = Arc::clone(executions);
883 // `register_activity_with_contract`, not `register_activity`: the
884 // bare form registers a handler with NO descriptor, so the worker
885 // advertises four names and zero typed contracts, and admission —
886 // which compares CONTRACTS — refuses the registration outright
887 // (`WORKER_CONTRACT_MISMATCH`). Deriving the advertisement from
888 // `<FanInput, String>` is what makes it the same source the
889 // package's `fixture_contract` declares from, so the two sides
890 // cannot drift.
891 registry = registry
892 .register_activity_with_contract(
893 activity_type,
894 move |_input: FanInput, _context| {
895 let executions = Arc::clone(&executions);
896 Box::pin(async move {
897 executions.fetch_add(1, Ordering::SeqCst);
898 Ok(activity_type.to_owned())
899 })
900 },
901 )
902 .map_err(test_error)?;
903 }
904 Ok(Arc::new(registry))
905 }
906
907 /// Spawns the remote worker on its own OS thread with a current-thread runtime
908 /// (the push receive is blocking), connecting IN to the production listener.
909 struct WorkerThread {
910 stop: Arc<std::sync::atomic::AtomicBool>,
911 handle: Option<std::thread::JoinHandle<()>>,
912 }
913
914 impl WorkerThread {
915 fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
916 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
917 let thread_stop = Arc::clone(&stop);
918 let handle = std::thread::spawn(move || {
919 let runtime = match tokio::runtime::Builder::new_current_thread()
920 .enable_all()
921 .build()
922 {
923 Ok(runtime) => runtime,
924 Err(error) => {
925 eprintln!("worker runtime build failed: {error}");
926 return;
927 }
928 };
929 runtime.block_on(async move {
930 let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
931 Ok(worker) => worker,
932 Err(error) => {
933 eprintln!("worker connect failed: {error}");
934 return;
935 }
936 };
937 if let Err(error) = worker
938 .serve_until(|| thread_stop.load(Ordering::SeqCst))
939 .await
940 {
941 eprintln!("worker serve loop ended with error: {error}");
942 }
943 });
944 });
945 Self {
946 stop,
947 handle: Some(handle),
948 }
949 }
950
951 /// Spawn the worker through [`aion_worker::serve_with_redial`] — the entry
952 /// point every REAL worker uses — so a broken link is survivable.
953 ///
954 /// [`Self::spawn`] uses `LiminalActivityWorker::serve_until`, which returns
955 /// the first transport error by design: a single-connection serve has no
956 /// survivor to migrate to. That is the right shape for a test whose link
957 /// never breaks, and the wrong instrument entirely for one whose link is
958 /// broken on purpose — a worker that dies at the break can only ever show
959 /// that outstanding work fails, whoever is at fault.
960 ///
961 /// The redial driver is SYNCHRONOUS and builds its own current-thread
962 /// runtime, so it runs on the bare thread rather than inside one.
963 fn spawn_redialing(
964 address: String,
965 config: WorkerConfig,
966 registry: Arc<ActivityRegistry>,
967 timing: aion_worker::RedialTiming,
968 ) -> Self {
969 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
970 let thread_stop = Arc::clone(&stop);
971 let handle = std::thread::spawn(move || {
972 if let Err(error) = aion_worker::serve_with_redial(
973 vec![address],
974 &config,
975 ®istry,
976 timing,
977 &thread_stop,
978 None,
979 || {},
980 ) {
981 eprintln!("redialing worker ended with error: {error}");
982 }
983 });
984 Self {
985 stop,
986 handle: Some(handle),
987 }
988 }
989
990 fn stop(mut self) {
991 self.stop.store(true, Ordering::SeqCst);
992 if let Some(handle) = self.handle.take() {
993 handle.join().ok();
994 }
995 }
996 }
997
998 fn count_completed(history: &[Event]) -> usize {
999 history
1000 .iter()
1001 .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1002 .count()
1003 }
1004
1005 fn count_workflow_completed(history: &[Event]) -> usize {
1006 history
1007 .iter()
1008 .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1009 .count()
1010 }
1011
1012 async fn wait_for_history<F>(
1013 store: &dyn aion_store::ReadableEventStore,
1014 workflow_id: &aion_core::WorkflowId,
1015 description: &str,
1016 predicate: F,
1017 ) -> Result<Vec<Event>, TestError>
1018 where
1019 F: Fn(&[Event]) -> bool,
1020 {
1021 let deadline = Instant::now() + POLL_DEADLINE;
1022 loop {
1023 let history = store.read_history(workflow_id).await.map_err(test_error)?;
1024 if predicate(&history) {
1025 return Ok(history);
1026 }
1027 if Instant::now() > deadline {
1028 return Err(test_error(format!(
1029 "timed out waiting for {description}: {history:#?}"
1030 )));
1031 }
1032 tokio::time::sleep(Duration::from_millis(25)).await;
1033 }
1034 }
1035
1036 /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1037 async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1038 let build_request = || -> Result<Request<body::Body>, TestError> {
1039 Request::builder()
1040 .uri("/workflows/start")
1041 .method("POST")
1042 .header("content-type", "application/json")
1043 .header("x-aion-subject", "ci")
1044 .header("x-aion-namespaces", NAMESPACE)
1045 .body(body::Body::from(
1046 serde_json::to_vec(&json!({
1047 "namespace": NAMESPACE,
1048 "workflow_type": OUTBOX_MODULE,
1049 "input": { "fixture": "input" },
1050 }))
1051 .map_err(test_error)?,
1052 ))
1053 .map_err(test_error)
1054 };
1055 let response = router
1056 .clone()
1057 .oneshot(build_request()?)
1058 .await
1059 .map_err(test_error)?;
1060 let status = response.status();
1061 let bytes = body::to_bytes(response.into_body(), usize::MAX)
1062 .await
1063 .map_err(test_error)?
1064 .to_vec();
1065 if status != StatusCode::OK {
1066 return Err(test_error(format!(
1067 "workflow start over HTTP must succeed, got {status}: {}",
1068 String::from_utf8_lossy(&bytes)
1069 )));
1070 }
1071 let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1072 // The HTTP wire contract (`clean_dtos::StartWorkflowResponse`) serializes
1073 // `workflow_id` as a plain UUID string, not a nested `{ uuid }` object.
1074 let workflow_id = body["workflow_id"]
1075 .as_str()
1076 .ok_or_else(|| test_error("start response missing workflow id"))?
1077 .parse::<uuid::Uuid>()
1078 .map_err(test_error)?;
1079 Ok(aion_core::WorkflowId::new(workflow_id))
1080 }
1081
1082 /// How long a freshly connected worker needs before the dispatch path may
1083 /// select it, DERIVED from the same two facts the server derives it from.
1084 ///
1085 /// A worker is dispatch-ineligible until it serves an OPENING PROBATION:
1086 /// [`Reachability::is_proved`] requires `DISPATCH_PROBATION_PINGS` consecutive
1087 /// answered liveness pings, at the probe's cadence of
1088 /// [`sweep_interval`](crate::worker::sweep_interval)`(heartbeat_window)`. The
1089 /// constant's own documentation states the cost — *"at the probe's cadence a
1090 /// fresh worker is undispatchable for K cadences while its first dispatches
1091 /// park"* — so this is designed behaviour a test must wait out, not a delay to
1092 /// be shortened.
1093 ///
1094 /// One extra cadence is allowed because the first round lands at an arbitrary
1095 /// offset inside the first interval: the worker connects between rounds, so it
1096 /// can miss up to one whole cadence before its first answer is even counted.
1097 ///
1098 /// # Why this is not a raised timeout
1099 ///
1100 /// It was 5 seconds, fixed, and that is how this test became one of four
1101 /// documented carriers of a load-sensitive flake
1102 /// (`gate-logs/lock-race-attribution/VERDICT.md`). The mechanism, measured:
1103 /// `dispatch_ineligible` starts EMPTY and `select_worker` filters only against
1104 /// what the probe has published, so a run in which **no probe round lands
1105 /// inside the window** selects the worker immediately and passes, while a run
1106 /// in which one does correctly withholds it for ~2 cadences and fails. On the
1107 /// default 30s window that is 7.5s per cadence against a 5s wait.
1108 ///
1109 /// 🔴 The passing runs were the WRONG ones. They dispatched to a worker that
1110 /// had not served its probation — a path production does not permit, because
1111 /// production parks those dispatches. Waiting for genuine eligibility makes
1112 /// this test MORE production-shaped, not more lenient, and that is the reason
1113 /// to do it. Raising a bound until a flake stops is how a liveness bug gets
1114 /// buried; deriving the bound from the mechanism that sets it is not the same
1115 /// act, and the register warns about the first for good reason.
1116 fn eligibility_patience(config: &ServerConfig) -> Duration {
1117 let cadence = crate::worker::sweep_interval(config.worker.heartbeat_window);
1118 cadence * (crate::worker::heartbeat::DISPATCH_PROBATION_PINGS + 1)
1119 }
1120
1121 /// Wait until the worker's in-band registration lands in the SAME registry the
1122 /// dispatch path selects from, with every fan-out activity type eligible.
1123 ///
1124 /// On the deadline this reports the state that DISCRIMINATES the worlds a
1125 /// missed registration can be in, because the bare sentence it replaced —
1126 /// "worker never registered in-band for the pool" — is equally true in at
1127 /// least three of them, and they want different fixes:
1128 ///
1129 /// 1. the liminal listener never bound, so nothing could dial in;
1130 /// 2. the worker never connected, or died dialling;
1131 /// 3. it connected and registration was merely slow;
1132 /// 4. it connected, registered correctly, and the SELECTOR refused it anyway —
1133 /// because the liveness probe published it as unreachable, or because it is
1134 /// not indexed for the activity type it advertises.
1135 ///
1136 /// The fourth was not in the first version of this report, and it is the world
1137 /// a real occurrence turned out to be in: the listener was bound, a worker was
1138 /// registered under the right namespace and queue advertising all four activity
1139 /// types, and every `select_worker` still returned nothing. A report that
1140 /// cannot separate "not registered" from "registered and refused" names the
1141 /// wrong half of the system.
1142 ///
1143 /// That is not a hypothetical distinction here. This module's e2e is one of
1144 /// four documented carriers of a load-sensitive flake
1145 /// (`gate-logs/lock-race-attribution/VERDICT.md`), it fails through THIS wait,
1146 /// and the reason the carrier has never been explained is that the failure
1147 /// named the fact and withheld the cause.
1148 async fn wait_for_registration(
1149 registry: &crate::worker::ConnectedWorkerRegistry,
1150 heartbeat: &crate::worker::HeartbeatTracker,
1151 listen_address: SocketAddr,
1152 patience: Duration,
1153 ) -> Result<(), TestError> {
1154 let deadline = Instant::now() + patience;
1155 loop {
1156 let now = Instant::now();
1157 let mut ready = true;
1158 for activity_type in FAN_ACTIVITY_TYPES {
1159 let Some(worker) = registry
1160 .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1161 .map_err(test_error)?
1162 else {
1163 ready = false;
1164 break;
1165 };
1166 if !heartbeat
1167 .is_dispatch_reachable(worker.id(), now)
1168 .map_err(test_error)?
1169 {
1170 ready = false;
1171 break;
1172 }
1173 }
1174 if ready {
1175 return Ok(());
1176 }
1177 if Instant::now() > deadline {
1178 return Err(test_error(format!(
1179 "worker never registered in-band for the pool within {patience:?}{}",
1180 registration_diagnosis(registry, listen_address)
1181 )));
1182 }
1183 tokio::time::sleep(Duration::from_millis(10)).await;
1184 }
1185 }
1186
1187 /// The discriminator behind [`wait_for_registration`]'s failure: enough of the
1188 /// world to tell those three apart, gathered at the moment of the failure.
1189 fn registration_diagnosis(
1190 registry: &crate::worker::ConnectedWorkerRegistry,
1191 listen_address: SocketAddr,
1192 ) -> String {
1193 let mut lines = vec![String::from("--- registration diagnosis ---")];
1194 // World 1, PROBED rather than assumed. The port was reserved by binding a
1195 // listener and dropping it, so losing the race for it is a real
1196 // possibility rather than a theoretical one, and it is indistinguishable
1197 // from every other failure unless something asks.
1198 lines.push(
1199 match std::net::TcpStream::connect_timeout(&listen_address, Duration::from_millis(500))
1200 {
1201 Ok(stream) => {
1202 drop(stream);
1203 format!("listener {listen_address}: ACCEPTS — the port is bound and dialable")
1204 }
1205 Err(error) => format!(
1206 "listener {listen_address}: NOT connectable ({error}) — nothing could have \
1207 registered, so this is not a timing problem"
1208 ),
1209 },
1210 );
1211 // Worlds 2 and 3: did any worker arrive at all, and if one did, what does
1212 // the registry hold for it against what the dispatch path asks of it? A
1213 // worker present under a different pool or advertising different activity
1214 // types is a contract mismatch wearing a timeout's clothes.
1215 match registry.all_workers() {
1216 Err(error) => lines.push(format!("registry: UNREADABLE ({error})")),
1217 Ok(workers) if workers.is_empty() => lines.push(String::from(
1218 "registry: EMPTY — no worker of any pool registered, so no connection ever \
1219 completed an in-band registration",
1220 )),
1221 Ok(workers) => {
1222 lines.push(format!("registry: {} worker(s) registered", workers.len()));
1223 for worker in &workers {
1224 lines.push(format!(
1225 " id={:?} namespaces={:?} task_queue={:?} node={:?} types={:?}",
1226 worker.id(),
1227 worker.namespaces(),
1228 worker.task_queue(),
1229 worker.node(),
1230 worker.activity_types()
1231 ));
1232 }
1233 }
1234 }
1235 lines.push(format!(
1236 "asked of it: namespace={NAMESPACE:?} task_queue={TASK_QUEUE:?}"
1237 ));
1238 // The liveness probe's reachability verdict. `select_worker` skips every
1239 // worker in this set, so a registered, correctly-advertised worker that is
1240 // listed here is refused for a reason nothing else in this report shows.
1241 lines.push(match registry.dispatch_ineligible() {
1242 Ok(ineligible) if ineligible.is_empty() => {
1243 String::from("dispatch-ineligible: none — reachability is not refusing anyone")
1244 }
1245 Ok(ineligible) => format!(
1246 "dispatch-ineligible: {ineligible:?} — the liveness probe has published these \
1247 as unreachable and select_worker skips them"
1248 ),
1249 Err(error) => format!("dispatch-ineligible: UNREADABLE ({error})"),
1250 });
1251 // Which of the four the selector could not satisfy, and — the part that
1252 // discriminates — the pool census beside each refusal.
1253 //
1254 // `select_worker` filters on THREE things: the activity index for
1255 // `(namespace, task_queue) + activity_type`, the node pin, and the
1256 // dispatch-ineligible set. The census counts the first two and does NOT
1257 // apply the third, so the pair of answers separates the remaining worlds
1258 // that a registry dump alone leaves fused:
1259 //
1260 // - census serves it, selector refuses ⇒ REACHABILITY, not registration;
1261 // - census serves 0 for the activity ⇒ the worker is in the pool but not
1262 // indexed for this activity type;
1263 // - census serves 0 for the pool ⇒ it is not in this pool at all,
1264 // whatever `all_workers` shows.
1265 //
1266 // Written after the bare registry dump above failed to close a real case:
1267 // it proved the listener was bound and a worker with all four activity
1268 // types was registered, and still could not say why every selection
1269 // returned nothing.
1270 for activity_type in FAN_ACTIVITY_TYPES {
1271 let outcome = match registry.select_worker(NAMESPACE, TASK_QUEUE, activity_type, None) {
1272 Ok(Some(handle)) => format!("worker {:?}", handle.id()),
1273 Ok(None) => String::from("NO worker"),
1274 Err(error) => format!("error: {error}"),
1275 };
1276 let census = match registry.pool_census(NAMESPACE, TASK_QUEUE, activity_type, None) {
1277 Ok(census) => format!(
1278 "in_pool={} serving_activity={} compatible={} last_compatible_age={:?}",
1279 census.workers_in_pool,
1280 census.workers_serving_activity,
1281 census.compatible_workers,
1282 census.last_compatible_poller_age
1283 ),
1284 Err(error) => format!("census UNREADABLE ({error})"),
1285 };
1286 lines.push(format!(
1287 "select_worker({activity_type}) -> {outcome} [census: {census}]"
1288 ));
1289 }
1290 format!("\n {}", lines.join("\n "))
1291 }
1292
1293 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1294 async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1295 {
1296 let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1297 let db_path = dir.path().join("aion.db");
1298 let package_path = write_package_archive(dir.path())?;
1299 // The production path binds the CONFIGURED listen address, so commit to a
1300 // concrete reserved loopback port the worker can also dial.
1301 let listen_address = reserve_loopback_port()?;
1302
1303 // (A) Build a real ServerState through the production boot path
1304 // (ServerState::build over a haematite ServerConfig): outbox enabled,
1305 // transport = liminal, the listen address set, collect_four loaded. This
1306 // shares the haematite leaf as the dispatcher's outbox store (the real boot
1307 // store seam) and installs the production ServerOutboxDeliveryCallback over
1308 // the live engine (gated on outbox.enabled).
1309 let config = server_config(&db_path, package_path, listen_address);
1310 let outbox_config = config.outbox.clone();
1311 // Captured before `build` consumes the config: the wait below is derived
1312 // from the very window this server is about to run its liveness probe on.
1313 let patience = eligibility_patience(&config);
1314 let state = ServerState::build(config).await.map_err(test_error)?;
1315
1316 // (B) Drive the EXACT production commissioning function run_server calls:
1317 // it hosts the liminal listener, builds RegistryLiminalDispatch over the
1318 // shared registry + engine callback, and spawns the real OutboxDispatcher.
1319 // Hold the returned listener guard for the test's lifetime, exactly as
1320 // run_server holds it.
1321 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1322 // Own-all, generous-default backpressure (single-node e2e): fraction 1 and
1323 // the platform default, so the ceiling never engages — the claim behaves
1324 // exactly as before, proving the production path is byte-identical on default.
1325 let backpressure_settings = BackpressureSettings {
1326 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1327 fraction: crate::worker::OwnedShardFraction::own_all(),
1328 };
1329 let listener_guard = maybe_spawn_outbox_dispatcher(
1330 &state,
1331 &outbox_config,
1332 false,
1333 backpressure_settings,
1334 &shutdown_rx,
1335 "set outbox.liminal_listen_address in the test config",
1336 )
1337 .map_err(test_error)?;
1338
1339 // (C) A REAL remote worker connects IN to the production listener and
1340 // self-registers in-band for the fixture's pool.
1341 let executions = Arc::new(AtomicUsize::new(0));
1342 let worker = WorkerThread::spawn(
1343 listen_address.to_string(),
1344 worker_config()?,
1345 worker_registry(&executions)?,
1346 );
1347
1348 // Wait until the in-band registration landed in the SAME registry the
1349 // dispatch path selects from (every fan-out activity type is eligible).
1350 let registry = state.worker_registry().clone();
1351 if let Err(error) = wait_for_registration(
1352 ®istry,
1353 state.heartbeat_tracker(),
1354 listen_address,
1355 patience,
1356 )
1357 .await
1358 {
1359 worker.stop();
1360 return Err(error);
1361 }
1362
1363 // (D) Start collect_four over the REAL HTTP transport: the engine stages
1364 // four pending outbox rows; the production-wired dispatcher claims and
1365 // pushes each to the worker.
1366 let router = http_router(state.clone()).map_err(test_error)?;
1367 let workflow_id = start_over_http(&router).await?;
1368
1369 // (E) THE PROOF: the worker executed all four activities AND every terminal
1370 // was recorded through the production engine callback (record_fan_out_completion)
1371 // — four ActivityCompleted + one WorkflowCompleted in durable history. This
1372 // is the full round-trip the retired stub never achieved.
1373 let reader = state.engine().map_err(test_error)?.store();
1374 let settled =
1375 wait_for_history(reader.as_ref(), &workflow_id, "fan-out settled", |events| {
1376 count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1377 })
1378 .await?;
1379 assert_eq!(
1380 count_completed(&settled),
1381 FAN_OUT,
1382 "every fan-out member must record a terminal through the production callback"
1383 );
1384 assert_eq!(
1385 count_workflow_completed(&settled),
1386 1,
1387 "the workflow must complete exactly once"
1388 );
1389 assert_eq!(
1390 executions.load(Ordering::SeqCst),
1391 FAN_OUT,
1392 "the remote worker must have executed every pushed dispatch exactly once"
1393 );
1394
1395 // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
1396 // stops the accept worker), shut the engine down so durable appends finish.
1397 shutdown_tx.send(true).ok();
1398 worker.stop();
1399 drop(listener_guard);
1400 state.shutdown().map_err(test_error)?;
1401 Ok(())
1402 }
1403
1404 /// One dispatch as the WORKER saw it: the identity the server sent it under,
1405 /// and when it arrived.
1406 #[derive(Clone, Debug)]
1407 struct SeenDispatch {
1408 activity_type: String,
1409 activity_id: String,
1410 attempt: u32,
1411 at: Instant,
1412 }
1413
1414 /// A loopback TCP relay the test can BREAK, sitting between the worker and the
1415 /// production liminal listener.
1416 ///
1417 /// The worker dials this instead of the listener, so the test owns a socket it
1418 /// can shut from the outside. That is the only way to make a REAL
1419 /// [`LiminalActivityWorker`] lose its connection mid-flight without reaching
1420 /// inside either the worker or the server — and a link broken from the inside
1421 /// would be a different experiment, because the code under test would be the
1422 /// code doing the breaking.
1423 ///
1424 /// # Why this is not the relay in `tests/dead_man_switch_e2e.rs`
1425 ///
1426 /// That file has `WedgeableRelay`, which can both wedge and sever, and this is
1427 /// deliberately not it. The two cannot be one, for a structural reason rather
1428 /// than a matter of taste: an integration test links this crate as an ordinary
1429 /// dependency, so it can see neither `#[cfg(test)] pub(crate) mod test_support`
1430 /// nor the private `maybe_spawn_outbox_dispatcher` this harness is built on,
1431 /// and `src/` cannot see `tests/`. Sharing one instrument would mean exporting
1432 /// a public, feature-gated test surface from a production crate.
1433 ///
1434 /// So the split is stated rather than hidden, and this half is a strict subset:
1435 /// it only severs. Wedging — which leaves both sockets open and merely discards
1436 /// bytes, so writes keep succeeding into the kernel buffer — is a DIFFERENT
1437 /// instrument answering a different question. #69 is about a broken link, not
1438 /// a silent one.
1439 struct SeverableRelay {
1440 address: SocketAddr,
1441 /// Every relayed socket, held so [`Self::sever`] can break them.
1442 sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
1443 stop: Arc<std::sync::atomic::AtomicBool>,
1444 handle: Option<std::thread::JoinHandle<()>>,
1445 }
1446
1447 impl SeverableRelay {
1448 /// Bind a loopback port and relay every accepted connection to `upstream`.
1449 fn spawn(upstream: SocketAddr) -> Result<Self, TestError> {
1450 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
1451 let address = listener.local_addr().map_err(test_error)?;
1452 // Non-blocking accept so the relay can be shut down deterministically
1453 // rather than by parking a thread in `accept` until something happens
1454 // to connect. Accepted sockets are put back into blocking mode
1455 // explicitly: on this platform they would otherwise inherit the flag
1456 // and every pump would spin on `WouldBlock`.
1457 listener.set_nonblocking(true).map_err(test_error)?;
1458 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1459 let sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>> =
1460 Arc::new(std::sync::Mutex::new(Vec::new()));
1461 let accept_stop = Arc::clone(&stop);
1462 let accept_sockets = Arc::clone(&sockets);
1463 let handle = std::thread::spawn(move || {
1464 while !accept_stop.load(Ordering::SeqCst) {
1465 match listener.accept() {
1466 Ok((downstream, _)) => {
1467 if let Err(error) =
1468 Self::relay_one(&downstream, upstream, &accept_sockets)
1469 {
1470 // The worker redials, so a connection this relay
1471 // fails to carry surfaces as a slower recovery
1472 // rather than as a wrong answer — but silence here
1473 // would make that indistinguishable from the
1474 // server never pushing, which is exactly the
1475 // confusion this pin exists to resolve.
1476 eprintln!("relay could not carry a connection: {error}");
1477 }
1478 }
1479 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1480 std::thread::sleep(Duration::from_millis(2));
1481 }
1482 Err(error) => {
1483 eprintln!("relay accept failed: {error}");
1484 return;
1485 }
1486 }
1487 }
1488 });
1489 Ok(Self {
1490 address,
1491 sockets,
1492 stop,
1493 handle: Some(handle),
1494 })
1495 }
1496
1497 /// Dial upstream for one accepted connection and pump both directions.
1498 fn relay_one(
1499 downstream: &std::net::TcpStream,
1500 upstream: SocketAddr,
1501 sockets: &Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
1502 ) -> Result<(), TestError> {
1503 downstream.set_nonblocking(false).map_err(test_error)?;
1504 let up = std::net::TcpStream::connect(upstream).map_err(test_error)?;
1505 let down_read = downstream.try_clone().map_err(test_error)?;
1506 let down_write = downstream.try_clone().map_err(test_error)?;
1507 let up_read = up.try_clone().map_err(test_error)?;
1508 let up_write = up.try_clone().map_err(test_error)?;
1509 let held = downstream.try_clone().map_err(test_error)?;
1510 let mut parked = sockets
1511 .lock()
1512 .map_err(|_| test_error("relay socket register poisoned"))?;
1513 parked.push(held);
1514 parked.push(up);
1515 drop(parked);
1516 for (from, to) in [(down_read, up_write), (up_read, down_write)] {
1517 std::thread::spawn(move || Self::pump(from, to));
1518 }
1519 Ok(())
1520 }
1521
1522 /// Copy one direction until the connection ends.
1523 ///
1524 /// A read or write error here IS the severed link in the expected case, and
1525 /// in every case it means the peer this pump exists to serve is gone: there
1526 /// is no party left to propagate to, so ending the pump is the handling,
1527 /// not an omission of it.
1528 fn pump(mut from: std::net::TcpStream, mut to: std::net::TcpStream) {
1529 use std::io::{Read, Write};
1530 let mut buffer = [0_u8; 8192];
1531 loop {
1532 match from.read(&mut buffer) {
1533 Ok(0) | Err(_) => return,
1534 Ok(read) => {
1535 if to.write_all(&buffer[..read]).is_err() {
1536 return;
1537 }
1538 }
1539 }
1540 }
1541 }
1542
1543 const fn address(&self) -> SocketAddr {
1544 self.address
1545 }
1546
1547 /// BREAK every relayed socket, and report how many were broken.
1548 ///
1549 /// The count is returned, and asserted non-zero by the caller, so that a
1550 /// sever which severed nothing can never masquerade as a measurement — the
1551 /// pin would otherwise pass by never having run its own experiment.
1552 fn sever(&self) -> Result<usize, TestError> {
1553 let mut parked = self
1554 .sockets
1555 .lock()
1556 .map_err(|_| test_error("relay socket register poisoned"))?;
1557 let mut severed = 0;
1558 for socket in parked.iter() {
1559 if socket.shutdown(std::net::Shutdown::Both).is_ok() {
1560 severed += 1;
1561 }
1562 }
1563 parked.clear();
1564 Ok(severed)
1565 }
1566
1567 fn shutdown(mut self) {
1568 self.stop.store(true, Ordering::SeqCst);
1569 if let Some(handle) = self.handle.take() {
1570 handle.join().ok();
1571 }
1572 }
1573 }
1574
1575 /// Registry for the reconnect pin: every dispatch is RECORDED with the identity
1576 /// the server sent it under, and [`HELD_ACTIVITY_TYPE`]'s FIRST dispatch holds
1577 /// — the work is finished, its reply is not yet on the wire — until released.
1578 ///
1579 /// Only the first is held. A blanket hold would stall the re-delivery this pin
1580 /// exists to observe, and the pin would then measure its own instrument.
1581 fn recording_registry(
1582 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
1583 release: &Arc<std::sync::atomic::AtomicBool>,
1584 ) -> Result<Arc<ActivityRegistry>, TestError> {
1585 let mut registry = ActivityRegistry::new();
1586 for activity_type in FAN_ACTIVITY_TYPES {
1587 let seen = Arc::clone(seen);
1588 let release = Arc::clone(release);
1589 let arrivals = Arc::new(AtomicUsize::new(0));
1590 registry = registry
1591 .register_activity_with_contract(
1592 activity_type,
1593 move |_input: FanInput, context: &aion_worker::ActivityContext| {
1594 let seen = Arc::clone(&seen);
1595 let release = Arc::clone(&release);
1596 let arrivals = Arc::clone(&arrivals);
1597 let record = SeenDispatch {
1598 activity_type: activity_type.to_owned(),
1599 activity_id: context.activity_id().to_string(),
1600 attempt: context.attempt(),
1601 at: Instant::now(),
1602 };
1603 Box::pin(async move {
1604 // Recorded BEFORE the hold: a dispatch that arrives and
1605 // is never answered must still be visible, or the pin
1606 // cannot tell "never re-delivered" from "re-delivered
1607 // and lost again".
1608 match seen.lock() {
1609 Ok(mut log) => log.push(record),
1610 Err(_) => {
1611 return Err(aion_worker::ActivityFailure::terminal(
1612 "the pin's dispatch log is poisoned, so this run can \
1613 observe nothing — failing loudly rather than \
1614 returning a result no assertion could trust",
1615 ));
1616 }
1617 }
1618 let first = arrivals.fetch_add(1, Ordering::SeqCst) == 0;
1619 if activity_type == HELD_ACTIVITY_TYPE && first {
1620 while !release.load(Ordering::SeqCst) {
1621 tokio::time::sleep(Duration::from_millis(5)).await;
1622 }
1623 }
1624 Ok(activity_type.to_owned())
1625 })
1626 },
1627 )
1628 .map_err(test_error)?;
1629 }
1630 Ok(Arc::new(registry))
1631 }
1632
1633 fn dispatches_of(
1634 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
1635 activity_type: &str,
1636 ) -> Result<Vec<SeenDispatch>, TestError> {
1637 let log = seen
1638 .lock()
1639 .map_err(|_| test_error("the pin's dispatch log is poisoned"))?;
1640 Ok(log
1641 .iter()
1642 .filter(|record| record.activity_type == activity_type)
1643 .cloned()
1644 .collect())
1645 }
1646
1647 fn dispatch_log(seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>) -> String {
1648 match seen.lock() {
1649 Ok(log) => format!("{:#?}", *log),
1650 Err(_) => String::from("<poisoned>"),
1651 }
1652 }
1653
1654 /// aion #69 at the ENGINE level: what the system DOES after an activity's
1655 /// completion is lost to a broken link.
1656 ///
1657 /// # What this measures, and why the transport-level pin cannot
1658 ///
1659 /// #69's existing red-first pin lives on its fix branch rather than here (it
1660 /// is red on purpose and lands with the fix), and it establishes that the
1661 /// completion is DISCARDED: the server abandons the correlated reply-wait the
1662 /// moment the delivering connection closes. It drives `WorkerDelivery`
1663 /// directly, with no engine, no store and no workflow behind it, so it can say
1664 /// nothing at all about what happens NEXT. That gap is the whole severity of
1665 /// #69: "the work is repeated once" and "the work is lost" are priced very
1666 /// differently, and nothing in-tree could tell them apart.
1667 ///
1668 /// So this pin observes four things, and asserts only what must hold in EVERY
1669 /// world — including the one a #69 fix creates:
1670 ///
1671 /// - **O4, ASSERTED** — the workflow still reaches a recorded terminal. This is
1672 /// the invariant: a broken link must not cost the workflow. It is not a weak
1673 /// assertion, because `collect_four` consumes all four members, so the
1674 /// workflow cannot complete while any member's work is missing;
1675 /// - **O1, REPORTED** — whether the held activity is dispatched a SECOND time.
1676 /// This is the MECHANISM, and the mechanism is what a fix changes: a fix that
1677 /// carries the completion across the reconnect would produce NO re-delivery,
1678 /// and a pin asserting one would read that fix as a regression.
1679 /// regression;
1680 /// - **O2, asserted CONDITIONALLY** — if a re-delivery happened it must carry
1681 /// the activity's OWN identity. That is what makes the finished work
1682 /// discarded rather than recovered; a re-delivery under a different identity
1683 /// is a different defect and must not pass quietly;
1684 /// - **O3, REPORTED** — the elapsed time from the break to the re-delivery, as
1685 /// a NUMBER asserted against nothing. No threshold is invented here: the
1686 /// right bound is a conversation to have with the measurement in hand.
1687 ///
1688 /// ⚠️ **O3 is recovery LATENCY, and latency is not COST.** The number is
1689 /// measured on a fixture activity that is a pure `String -> String`, so its
1690 /// repeat costs microseconds. The real cost of a repeat is the repeated
1691 /// activity's own runtime plus its repeated SIDE EFFECTS, which this pin does
1692 /// not measure and structurally cannot: #69's own exhibit was an *agent*
1693 /// activity, whose repeat is minutes of compute and files written twice.
1694 /// Quote the finding — *repeated work, not lost work, one repeat per in-flight
1695 /// activity* — rather than the milliseconds, which carry their premise (a
1696 /// trivial activity) only for as long as someone remembers to attach it.
1697 ///
1698 /// The settle-wait below is bounded by [`POLL_DEADLINE`], so this pin cannot
1699 /// hang; but that bound is ~100x the observed recovery, so it is a liveness
1700 /// guard and NOT a latency guard. A large latency regression would still pass
1701 /// here, reported in O3 and asserted by nothing — deliberately, because the
1702 /// correct bound is not derivable from the samples taken so far.
1703 ///
1704 /// Executions are REPORTED, never asserted equal to the fan-out. A transport
1705 /// that can lose a reply gives at-least-once delivery, so the sibling test's
1706 /// `executions == FAN_OUT` is the wrong shape here and must not be copied
1707 /// across.
1708 ///
1709 /// # The world this models
1710 ///
1711 /// One server process with its transport-loss ledger live in memory, a worker
1712 /// that redials the SAME address, and a SINGLE loss — well inside
1713 /// `TRANSPORT_LOSS_BUDGET_WINDOWS`. It is NOT a server restart and NOT budget
1714 /// exhaustion, both of which are different worlds with different recoveries.
1715 /// The re-delivery this venue can produce is the outbox dispatcher's re-claim
1716 /// under the `max_attempts`/backoff this test's config sets, not the #266
1717 /// recovery replay — which is what gives O3's number a slot to mean anything in.
1718 ///
1719 /// The relay's own accept poll (2ms) sits inside the measured elapsed.
1720 ///
1721 /// ⚠️ This pin shares a venue with
1722 /// `production_boot_dispatches_executes_and_records_over_liminal`, one of four
1723 /// documented carriers of a load-sensitive flake — 2/24 on a base that
1724 /// predates it (`gate-logs/lock-race-attribution/VERDICT.md`). It inherits that
1725 /// sensitivity, and a red here should be read against that register first.
1726 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1727 async fn a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles()
1728 -> Result<(), TestError> {
1729 let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1730 let db_path = dir.path().join("aion.db");
1731 let package_path = write_package_archive(dir.path())?;
1732 let listen_address = reserve_loopback_port()?;
1733
1734 let config = server_config(&db_path, package_path, listen_address);
1735 let outbox_config = config.outbox.clone();
1736 // Captured before `build` consumes the config: the wait below is derived
1737 // from the very window this server is about to run its liveness probe on.
1738 let patience = eligibility_patience(&config);
1739 let state = ServerState::build(config).await.map_err(test_error)?;
1740 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1741 let backpressure_settings = BackpressureSettings {
1742 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1743 fraction: crate::worker::OwnedShardFraction::own_all(),
1744 };
1745 let listener_guard = maybe_spawn_outbox_dispatcher(
1746 &state,
1747 &outbox_config,
1748 false,
1749 backpressure_settings,
1750 &shutdown_rx,
1751 "set outbox.liminal_listen_address in the test config",
1752 )
1753 .map_err(test_error)?;
1754
1755 // The worker dials the RELAY, which carries it to the production listener.
1756 let relay = SeverableRelay::spawn(listen_address)?;
1757 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1758 let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
1759 // The redial timings are the ones this module's `worker_config` already
1760 // declares, read off it rather than re-chosen here: a reconnect pin that
1761 // picked its own recovery timings would be measuring a world of its own.
1762 let config = worker_config()?;
1763 let timing = aion_worker::RedialTiming::new(
1764 config.reconnect.initial_backoff,
1765 config.reconnect.max_backoff,
1766 );
1767 let worker = WorkerThread::spawn_redialing(
1768 relay.address().to_string(),
1769 config,
1770 recording_registry(&seen, &release)?,
1771 timing,
1772 );
1773
1774 let outcome =
1775 observe_reconnect(&state, &relay, &seen, &release, listen_address, patience).await;
1776
1777 // Teardown runs on EVERY path, including a failing one: a leaked worker
1778 // thread or listener poisons whatever runs next, and this venue is already
1779 // load-sensitive enough without the pin adding to it.
1780 shutdown_tx.send(true).ok();
1781 release.store(true, Ordering::SeqCst);
1782 worker.stop();
1783 relay.shutdown();
1784 drop(listener_guard);
1785 state.shutdown().map_err(test_error)?;
1786 outcome
1787 }
1788
1789 /// The measurement behind
1790 /// [`a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles`],
1791 /// split out so its many early returns cannot skip the harness teardown.
1792 /// Wait until the held member is dispatched and holding — the moment the link
1793 /// can be broken — and report how many of its siblings had already settled.
1794 ///
1795 /// The split at the break is REPORTED, never required. An earlier draft
1796 /// demanded that the other three settle first, for a single-variable
1797 /// experiment. Measured across runs it simply varies: the four pushes land
1798 /// within microseconds of each other and which records a terminal first is a
1799 /// race, so requiring a particular split would fail the pin for a reason that
1800 /// has nothing to do with what it measures.
1801 async fn await_held_dispatch(
1802 reader: &dyn aion_store::ReadableEventStore,
1803 workflow_id: &aion_core::WorkflowId,
1804 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
1805 ) -> Result<(SeenDispatch, usize), TestError> {
1806 let deadline = Instant::now() + POLL_DEADLINE;
1807 loop {
1808 if let Some(first) = dispatches_of(seen, HELD_ACTIVITY_TYPE)?.first() {
1809 let at_the_break = reader.read_history(workflow_id).await.map_err(test_error)?;
1810 return Ok((first.clone(), count_completed(&at_the_break)));
1811 }
1812 if Instant::now() > deadline {
1813 let history = reader.read_history(workflow_id).await.map_err(test_error)?;
1814 return Err(test_error(format!(
1815 "{HELD_ACTIVITY_TYPE} was never dispatched at all within {POLL_DEADLINE:?}, \
1816 so there was no held completion to lose and this run measured nothing.\n\
1817 dispatch log: {}\nhistory: {history:#?}",
1818 dispatch_log(seen),
1819 )));
1820 }
1821 tokio::time::sleep(Duration::from_millis(25)).await;
1822 }
1823 }
1824
1825 async fn observe_reconnect(
1826 state: &ServerState,
1827 relay: &SeverableRelay,
1828 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
1829 release: &Arc<std::sync::atomic::AtomicBool>,
1830 listen_address: SocketAddr,
1831 patience: Duration,
1832 ) -> Result<(), TestError> {
1833 wait_for_registration(
1834 state.worker_registry(),
1835 state.heartbeat_tracker(),
1836 listen_address,
1837 patience,
1838 )
1839 .await?;
1840
1841 let router = http_router(state.clone()).map_err(test_error)?;
1842 let workflow_id = start_over_http(&router).await?;
1843 let reader = state.engine().map_err(test_error)?.store();
1844
1845 let (first, settled_before) =
1846 await_held_dispatch(reader.as_ref(), &workflow_id, seen).await?;
1847
1848 // BREAK the link while the finished work is still holding its reply.
1849 let severed = relay.sever()?;
1850 let severed_at = Instant::now();
1851 if severed == 0 {
1852 return Err(test_error(
1853 "the relay severed NOTHING, so no link was ever broken and this run measured \
1854 nothing — a pass here would have been an artefact of the instrument",
1855 ));
1856 }
1857 // Release the hold: the worker now writes its reply into a dead socket.
1858 release.store(true, Ordering::SeqCst);
1859
1860 // O4 FIRST, because it is the INVARIANT: a broken link must not cost the
1861 // workflow. Every other observable here describes the MECHANISM by which
1862 // that holds, and the mechanism is exactly what a #69 fix is expected to
1863 // change — so asserting today's mechanism would make the fix read as a
1864 // regression, and would be asserting the enumeration rather than the
1865 // invariant.
1866 //
1867 // O4 is load-bearing rather than weak because `collect_four` CONSUMES all
1868 // four members: the workflow cannot reach a completed terminal while any
1869 // member's work is missing, so "the workflow settled" is not a state that
1870 // silently lost work can also produce.
1871 let settled = wait_for_history(
1872 reader.as_ref(),
1873 &workflow_id,
1874 "the workflow to settle after the severed link",
1875 |events| count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1,
1876 )
1877 .await
1878 .map_err(|error| {
1879 test_error(format!(
1880 "O4 FAILED — the workflow did not settle after the link broke ({severed} \
1881 socket(s) severed), so the lost completion cost the workflow rather than \
1882 costing a repeat of the work.\n{error}\ndispatch log: {}",
1883 dispatch_log(seen),
1884 ))
1885 })?;
1886 assert_eq!(
1887 count_completed(&settled),
1888 FAN_OUT,
1889 "every fan-out member must still record a terminal after the link broke"
1890 );
1891 assert_eq!(
1892 count_workflow_completed(&settled),
1893 1,
1894 "the workflow must complete exactly once even though a completion was lost"
1895 );
1896
1897 // O1/O2/O3 — the MECHANISM, reported. O2 is asserted only CONDITIONALLY:
1898 // if a re-delivery happened it must have carried the activity's own
1899 // identity, because a re-delivery under a different identity would be a
1900 // different defect entirely and must not pass quietly. If no re-delivery
1901 // happened, the completion survived the reconnect — which is what a fixed
1902 // #69 looks like, and this pin should report it, not fail on it.
1903 let held = dispatches_of(seen, HELD_ACTIVITY_TYPE)?;
1904 match held.get(1) {
1905 None => println!(
1906 "aion#69 — {HELD_ACTIVITY_TYPE} ({}) was NOT re-dispatched and the workflow \
1907 still settled, so the held completion survived the break; {settled_before} of \
1908 {FAN_OUT} members had settled when it broke, {severed} socket(s) severed",
1909 first.activity_id,
1910 ),
1911 Some(second) => {
1912 if second.activity_id != first.activity_id {
1913 return Err(test_error(format!(
1914 "O2 FAILED — the re-delivery carried a DIFFERENT activity identity. The \
1915 first dispatch was {} (attempt {}) and the second was {} (attempt {}), \
1916 so the work was not re-run under its own identity and #69's framing \
1917 does not describe what happened here.",
1918 first.activity_id, first.attempt, second.activity_id, second.attempt,
1919 )));
1920 }
1921 let recovery = second.at.saturating_duration_since(severed_at);
1922 println!(
1923 "aion#69 O3 — re-delivery of {} ({}) took {}ms from the link breaking; \
1924 first attempt {}, second attempt {}; {settled_before} of {FAN_OUT} members \
1925 had already recorded a terminal when the link broke; {severed} socket(s) \
1926 severed",
1927 HELD_ACTIVITY_TYPE,
1928 first.activity_id,
1929 recovery.as_millis(),
1930 first.attempt,
1931 second.attempt,
1932 );
1933 }
1934 }
1935
1936 let all = seen
1937 .lock()
1938 .map_err(|_| test_error("the pin's dispatch log is poisoned"))?
1939 .len();
1940 println!(
1941 "aion#69 — {all} dispatch(es) served for {FAN_OUT} activities; the transport is \
1942 at-least-once, so the excess is the repeated work a broken link costs"
1943 );
1944 Ok(())
1945 }
1946}