blob: 2dc1efd7295f267c63ffece4d87a80d2e869fef7 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Shard OS threads: thread entry, pin, join, and the shutdown plumbing.
use crate::boot::handoff::{BootstrapBarrier, MetadataHandoff};
use crate::boot::shard_main;
use crate::boot::topology::RosterCells;
use crate::server_error::{ServerError, ShardJoinFailure, ShardJoinFailureKind};
use crate::shard_allocator::{ShardAllocator, ShardInfo};
use compio::runtime::ResumeUnwind;
use configs::server::ServerConfig;
use configs::sharding::{
INBOX_CAPACITY_MAX, SHUTDOWN_DRAIN_TIMEOUT_MAX, SHUTDOWN_POLL_INTERVAL_MAX,
};
use message_bus::{IggyMessageBus, ReplicaOwnerTable};
use partitions::FatalCommit;
use server_common::executor::create_shard_executor;
use shard::metrics::ShardMetrics;
use shard::{Receiver as ShardReceiver, Sender, ShardFrame, TaggedSender};
use std::backtrace::Backtrace;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use std::{panic, thread};
use tracing::{error, info, warn};
/// Result of a multi-shard bootstrap.
///
/// Carries the cross-thread shutdown flag, one OS-thread `JoinHandle`
/// per shard, and the first panic `install_panic_hook` recorded. The
/// caller flips the flag via [`Self::install_ctrlc_handler`] and then
/// drains every shard via [`Self::join_all`], bounded by `join_timeout`
/// (`system.sharding.shutdown_join_timeout`).
pub struct ShardHandles {
pub(in crate::boot) shutdown_flag: Arc<AtomicBool>,
pub(in crate::boot) shard_threads: Vec<(u16, thread::JoinHandle<Result<(), ServerError>>)>,
pub(in crate::boot) join_timeout: Duration,
pub(in crate::boot) first_panic: Arc<OnceLock<String>>,
}
impl ShardHandles {
/// Install a SIGINT/Ctrl-C handler that flips the shutdown flag on
/// the first signal. A second signal is logged but otherwise
/// ignored so an in-flight WAL fsync or replica drain runs to
/// completion.
///
/// # Errors
///
/// Returns the underlying `ctrlc::Error` if the handler cannot be
/// installed (typically because another handler already owns the
/// signal).
pub fn install_ctrlc_handler(&self) -> Result<(), ctrlc::Error> {
let flag = Arc::clone(&self.shutdown_flag);
ctrlc::set_handler(move || {
if flag.swap(true, Ordering::Relaxed) {
// Second Ctrl-C: leave the shutdown machinery to drain.
// Refusing to abort here keeps the WAL fsync / replica
// drain from being interrupted mid-frame.
warn!("second Ctrl-C ignored; server is already shutting down");
} else {
info!("Ctrl-C received; signalling server shutdown");
}
})
}
/// Drain every shard thread. This is the main thread's park for the
/// server's whole lifetime, so shards are awaited WITHOUT any time
/// bound while the server runs; the `shutdown_join_timeout` clock
/// only starts once the cross-thread shutdown flag flips (Ctrl-C or
/// a shard failure). Each shard's outcome is logged (`info` on clean
/// exit, `error` on Err, panic, or wedge). If any shard failed,
/// returns every failure together as
/// [`ServerError::ShardJoinFailures`] so the operator sees the
/// full set rather than just the first.
///
/// A shard whose thread is still running when the post-shutdown
/// deadline passes is abandoned (its `JoinHandle` dropped, the OS
/// thread left to die with the process) and reported as
/// [`ShardJoinFailureKind::Wedged`]: a wedged pump or listener must
/// not block process exit forever.
///
/// # Errors
///
/// Returns [`ServerError::ShardJoinFailures`] if any shard
/// returned a `Result::Err`, panicked, or wedged past the deadline.
/// The variant carries every per-shard failure in shard-id order so
/// the caller does not need to read the trace log to discover
/// late-failing shards. Returns [`ServerError::Panicked`] when every
/// thread exited `Ok` but the panic hook recorded a panic: a task
/// compio's `spawn` caught, which no thread result can carry.
pub fn join_all(self) -> Result<(), ServerError> {
let mut failures: Vec<ShardJoinFailure> = Vec::new();
// Armed on the first poll that observes the shutdown flag, shared
// across all shards: one budget covers the whole drain, not one
// budget per shard.
let mut deadline: Option<Instant> = None;
// Shards run thread-per-core with compio's blocking fallback pool
// disabled, so an io_uring opcode the kernel lacks aborts every shard
// with the same panic. Surface the actionable diagnostic once.
let mut io_uring_diagnostic_shown = false;
for (shard_id, handle) in self.shard_threads {
let Some(joined) = join_until_shutdown_deadline(
handle,
&self.shutdown_flag,
self.join_timeout,
&mut deadline,
) else {
error!(
shard_id,
waited = ?self.join_timeout,
"shard thread still running at the shutdown join deadline; abandoning it"
);
failures.push(ShardJoinFailure {
shard_id,
kind: ShardJoinFailureKind::Wedged {
waited: self.join_timeout,
},
});
continue;
};
match joined {
Ok(Ok(())) => {
info!(shard_id, "shard thread exited cleanly");
}
Ok(Err(error)) => {
error!(shard_id, error = %error, "shard thread returned error");
failures.push(ShardJoinFailure {
shard_id,
kind: ShardJoinFailureKind::Error(Box::new(error)),
});
}
Err(panic_payload) => {
let message = panic_payload_to_string(&*panic_payload);
error!(shard_id, message = %message, "shard thread panicked");
if !io_uring_diagnostic_shown
&& message
.contains(server_common::diagnostics::ASYNCIFY_POOL_DISABLED_PANIC_MSG)
{
server_common::diagnostics::print_incomplete_io_uring_ops_info();
io_uring_diagnostic_shown = true;
}
failures.push(ShardJoinFailure {
shard_id,
kind: ShardJoinFailureKind::Panic { message },
});
}
}
}
if !failures.is_empty() {
return Err(ServerError::ShardJoinFailures { failures });
}
self.first_panic.get().map_or_else(
|| Ok(()),
|description| {
Err(ServerError::Panicked {
description: description.clone(),
})
},
)
}
}
/// Install the process-wide panic hook and return the slot it records
/// the first panic into.
///
/// The hook runs on the panicking thread before the unwind, so it sees
/// every panic on a shard: the thread body, whose unwind
/// [`run_shard_thread`] already turns into a join failure, and the tasks
/// compio's `spawn` catches, which nothing observes while the server
/// runs (a dead listener or connection task leaves every thread exiting
/// `Ok`). It logs the panic with its backtrace, records the first one for
/// [`ShardHandles::join_all`] to fail the exit on, and flips the shutdown
/// flag so every shard drains instead of the half-alive state one dead
/// task leaves behind. The previous hook still runs after it, so stderr
/// keeps the standard panic line.
pub(in crate::boot) fn install_panic_hook(shutdown_flag: Arc<AtomicBool>) -> Arc<OnceLock<String>> {
let first_panic = Arc::new(OnceLock::new());
let recorded = Arc::clone(&first_panic);
let previous_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let current = thread::current();
let thread_name = current.name().unwrap_or("<unnamed>");
let location = info
.location()
.map_or_else(|| "<unknown>".to_string(), ToString::to_string);
let message = panic_payload_to_string(info.payload());
let backtrace = Backtrace::force_capture();
error!(thread = thread_name, location = %location, backtrace = %backtrace, "{message}");
let _ = recorded.set(format!(
"thread '{thread_name}' panicked at {location}: {message}"
));
shutdown_flag.store(true, Ordering::Relaxed);
previous_hook(info);
}));
first_panic
}
/// Poll cadence for the bounded shard joins. Coarse enough to cost
/// nothing during a normal drain, fine enough that exit latency past
/// the last shard's return stays imperceptible.
const JOIN_POLL_INTERVAL: Duration = Duration::from_millis(25);
/// Join `handle`, waiting indefinitely while the server runs. The
/// `join_timeout` clock starts only when `shutdown_flag` is observed set
/// (arming the caller-shared `deadline` once, so all shards drain under
/// ONE budget); a running server parked here for hours must never be
/// mistaken for a wedged shard. `None` means the thread was still
/// running at the post-shutdown deadline and the handle was dropped
/// (the OS thread keeps running detached; process exit reaps it).
/// `JoinHandle` has no timed join, so this polls `is_finished` at
/// [`JOIN_POLL_INTERVAL`]; the closing `join()` on a finished thread
/// returns immediately.
fn join_until_shutdown_deadline(
handle: thread::JoinHandle<Result<(), ServerError>>,
shutdown_flag: &AtomicBool,
join_timeout: Duration,
deadline: &mut Option<Instant>,
) -> Option<thread::Result<Result<(), ServerError>>> {
while !handle.is_finished() {
if deadline.is_none() && shutdown_flag.load(Ordering::Relaxed) {
*deadline = Some(Instant::now() + join_timeout);
}
if let Some(deadline) = deadline
&& Instant::now() >= *deadline
{
return None;
}
thread::sleep(JOIN_POLL_INTERVAL);
}
Some(handle.join())
}
/// Best-effort extraction of the panic message from a
/// `Box<dyn Any + Send>` returned by `JoinHandle::join`. Tries the two
/// payload shapes the standard library guarantees (`&'static str` and
/// `String`) and falls back to a placeholder so the panic still surfaces
/// in the error chain.
fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
return (*s).to_string();
}
if let Some(s) = payload.downcast_ref::<String>() {
return s.clone();
}
"<panic payload not String/&str>".to_string()
}
/// Joins survivor shard threads after a partial-spawn failure, bounded
/// by the same `shutdown_join_timeout` budget as the normal exit path.
///
/// Polls every survivor's `is_finished` in one loop instead of spawning
/// per-survivor joiner threads: the likely OS state on this path is
/// `pthread_create` EAGAIN (the parent spawn just failed with it), so
/// nothing here may create threads, and polling drains all survivors in
/// parallel anyway. A survivor still running at the deadline is
/// abandoned with an error log so the failed bootstrap can surface its
/// spawn error instead of hanging on a wedged shard.
pub(in crate::boot) fn join_partial_shard_survivors(
shard_threads: Vec<(u16, thread::JoinHandle<Result<(), ServerError>>)>,
join_timeout: Duration,
) {
let deadline = Instant::now() + join_timeout;
let mut remaining = shard_threads;
loop {
let mut still_running = Vec::with_capacity(remaining.len());
for (shard_id, survivor) in remaining {
if survivor.is_finished() {
let _ = survivor.join();
info!(shard_id, "survivor shard thread drained");
} else {
still_running.push((shard_id, survivor));
}
}
remaining = still_running;
if remaining.is_empty() || Instant::now() >= deadline {
break;
}
thread::sleep(JOIN_POLL_INTERVAL);
}
for (shard_id, _survivor) in remaining {
error!(
shard_id,
waited = ?join_timeout,
"survivor shard thread still running at the shutdown join deadline; abandoning it"
);
}
}
/// Flips the cross-thread shutdown flag on `Drop` unless disarmed.
///
/// A shard thread that exits via an error `?` or a panic unwind would
/// otherwise leave sibling shards parked forever on `bus.token().wait()`:
/// their watchdogs never observe the flag and the bus has no
/// `Drop`-triggered shutdown. Arming this for the whole thread body makes
/// every non-clean exit drive sibling-shard teardown. Disarmed only on a
/// clean `Ok(())`.
struct ShutdownOnDrop {
flag: Arc<AtomicBool>,
armed: bool,
}
impl ShutdownOnDrop {
const fn new(flag: Arc<AtomicBool>) -> Self {
Self { flag, armed: true }
}
const fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for ShutdownOnDrop {
fn drop(&mut self) {
if self.armed {
self.flag.store(true, Ordering::Relaxed);
}
}
}
/// Resolve the operator's `cpu_allocation` into concrete shard
/// assignments plus the checked `u16` shard count.
///
/// Shard ids index `ReplicaOwnerTable` slots as `u16`. `OWNER_NONE`
/// (`u16::MAX`) is reserved as the empty-slot sentinel, so a server
/// configured with `u16::MAX` shards would mint a shard id that
/// collides with the sentinel and an owner-table lookup could never
/// tell that shard apart from an unowned slot. Reject at boot so the
/// invariant is held by the type system, not by hoping the operator
/// never configures 65535 cores worth of shards.
pub(in crate::boot) fn resolve_shard_assignments(
sharding: &configs::sharding::ShardingConfig,
) -> Result<(Vec<ShardInfo>, u16), ServerError> {
let allocator = ShardAllocator::new(&sharding.cpu_allocation, sharding.pin_cores)
.map_err(ServerError::ShardAllocator)?;
let assignments = allocator
.to_shard_assignments()
.map_err(ServerError::ShardAllocator)?;
if assignments.is_empty() {
return Err(ServerError::ShardsCountZero);
}
match u16::try_from(assignments.len()) {
Ok(count) if count < message_bus::OWNER_NONE => Ok((assignments, count)),
_ => Err(ServerError::ShardsCountOverflow {
count: assignments.len(),
}),
}
}
/// Re-validate the runtime sharding knobs that the per-shard runtime
/// consumes directly. Mirrors `ShardingConfig::validate` so a caller
/// that built the config without running it (e.g. tests, embedded
/// usage) cannot OOM at boot or wedge process exit with an out-of-range
/// value.
pub(in crate::boot) fn validate_sharding_runtime_knobs(
sharding: &configs::sharding::ShardingConfig,
) -> Result<(), ServerError> {
let inbox_capacity = sharding.inbox_capacity;
if inbox_capacity == 0 || inbox_capacity > INBOX_CAPACITY_MAX {
return Err(ServerError::InvalidInboxCapacity {
value: inbox_capacity,
max: INBOX_CAPACITY_MAX,
});
}
let reply_inbox_capacity = sharding.reply_inbox_capacity;
if reply_inbox_capacity == 0 || reply_inbox_capacity > INBOX_CAPACITY_MAX {
return Err(ServerError::InvalidReplyInboxCapacity {
value: reply_inbox_capacity,
max: INBOX_CAPACITY_MAX,
});
}
let drain_timeout = sharding.shutdown_drain_timeout.get_duration();
if drain_timeout.is_zero() || drain_timeout > SHUTDOWN_DRAIN_TIMEOUT_MAX {
return Err(ServerError::InvalidShutdownDrainTimeout {
value: drain_timeout,
max: SHUTDOWN_DRAIN_TIMEOUT_MAX,
});
}
let poll_interval = sharding.shutdown_poll_interval.get_duration();
if poll_interval.is_zero() || poll_interval > SHUTDOWN_POLL_INTERVAL_MAX {
return Err(ServerError::InvalidShutdownPollInterval {
value: poll_interval,
max: SHUTDOWN_POLL_INTERVAL_MAX,
});
}
// Ordering: a poll cadence coarser than the drain budget makes the
// cross-thread shutdown flag effectively unobservable during teardown.
if poll_interval > drain_timeout {
return Err(ServerError::ShutdownPollExceedsDrain {
poll: poll_interval,
drain: drain_timeout,
});
}
Ok(())
}
/// Per-shard OS thread entry. Pins CPU + memory, builds the compio
/// runtime, and `block_on`s `shard_main`.
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
pub(in crate::boot) fn run_shard_thread(
shard_id: u16,
total_shards: u16,
replica_id: Option<u8>,
assignment: ShardInfo,
senders: Vec<TaggedSender>,
inbox: ShardReceiver<ShardFrame>,
reply_inbox: ShardReceiver<ShardFrame>,
config: Arc<ServerConfig>,
shutdown_flag: Arc<AtomicBool>,
metadata_handoff: MetadataHandoff,
barrier: BootstrapBarrier,
owner_table: Arc<ReplicaOwnerTable>,
roster_cells: RosterCells,
shard_metrics_all: Vec<ShardMetrics>,
) -> Result<(), ServerError> {
// Armed for the whole thread body: a post-spawn error `?` or a panic
// unwind here must flip `shutdown_flag` so sibling watchdogs drive
// their bus shutdown instead of parking forever on `bus.token().wait()`.
let mut shutdown_guard = ShutdownOnDrop::new(Arc::clone(&shutdown_flag));
assignment
.bind_cpu()
.map_err(|source| ServerError::CpuAffinityFailed { shard_id, source })?;
assignment
.bind_memory()
.map_err(|source| ServerError::MemoryAffinityFailed { shard_id, source })?;
// `enrich_runtime_create_error` folds the io_uring remediation (raise
// `ulimit -l`, unblock seccomp, kernel-flag floor) into the error, so the
// guidance survives into the shard-join failure report instead of only
// stderr. Multi-shard boxes exhaust RLIMIT_MEMLOCK on per-shard rings
// before the bootstrap runtime does, so this path needs it most.
let runtime = create_shard_executor().map_err(|source| {
let source = server_common::diagnostics::enrich_runtime_create_error(source);
ServerError::ShardRuntimeCreateFailed { shard_id, source }
})?;
let result = runtime.block_on(async move {
// `shard_main`'s future grows past clippy's `large_futures` cap
// (it ferries the metadata handoff, bus, builders, and inflight
// I/O in one state machine). Heap-pin it so the top-level
// `block_on` future stays small; one allocation per startup buys
// the stack budget back.
Box::pin(shard_main(
shard_id,
total_shards,
replica_id,
senders,
inbox,
reply_inbox,
&config,
shutdown_flag,
metadata_handoff,
barrier,
owner_table,
roster_cells,
shard_metrics_all,
))
.await
});
if result.is_ok() {
shutdown_guard.disarm();
}
result
}
/// Await the message pump's completion before the shard returns: its
/// post-loop work includes the final flush of every committed journal to
/// segment storage, and returning first drops the compio runtime, which
/// cancels that flush at its next await point.
///
/// `Err` means the pump was already dead (a panic, or an exit outside the
/// stop protocol), so its final flush never ran and the shard must not
/// report a clean exit. The verdict is the inner `JoinError`; the timeout
/// wrapper alone cannot see it, and a shard that swallows it prints
/// "exited cleanly" over a corpse.
pub(in crate::boot) async fn await_pump_drain(
pump_handle: Option<compio::runtime::JoinHandle<Option<FatalCommit>>>,
config: &ServerConfig,
shard_id: u16,
) -> Result<(), ServerError> {
let Some(pump_handle) = pump_handle else {
return Ok(());
};
let drain_budget = config.system.sharding.shutdown_drain_timeout.get_duration();
let Ok(join_result) = compio::time::timeout(drain_budget, pump_handle).await else {
error!(
shard = shard_id,
timeout = ?drain_budget,
"message pump did not drain within the shutdown budget; \
committed journal tail may not have flushed"
);
return Err(ServerError::ShardPumpDrainTimedOut {
shard_id,
timeout: drain_budget,
});
};
// `JoinError` renders a panic as the bare "Task has panicked" and the
// type is not re-exported, so the payload -- the only part with
// diagnostic value -- is lifted by re-raising into an immediate catch.
// The panic hook already ran when the task died; `resume_unwind` does
// not run it again, so nothing is printed twice and the message finally
// reaches the tracing sink too.
let reason = match panic::catch_unwind(panic::AssertUnwindSafe(|| join_result.resume_unwind()))
{
Ok(Some(None)) => return Ok(()),
// The pump drained and flushed; it just has nothing left to serve.
// Fail the shard so the process exits non-zero: a node that stopped
// because it could not persist a cluster-committed op must not look
// to an orchestrator like a clean shutdown.
Ok(Some(Some(fault))) => {
error!(
shard = shard_id,
namespace_raw = fault.namespace_raw,
op = fault.op,
operation = ?fault.operation,
"message pump stopped on a partition commit fault; \
the server is shutting down"
);
return Err(ServerError::ShardFatal {
shard_id,
namespace_raw: fault.namespace_raw,
op: fault.op,
});
}
Ok(None) => "task was cancelled".to_string(),
Err(payload) => payload
.downcast_ref::<&str>()
.map(|message| (*message).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.map_or_else(
|| "task panicked".to_string(),
|message| format!("task panicked: {message}"),
),
};
error!(
shard = shard_id,
"message pump died instead of draining ({reason}); \
committed journal tail may not have flushed"
);
Err(ServerError::ShardPumpDied { shard_id, reason })
}
/// Spawn a per-shard polling task that watches the cross-thread shutdown
/// flag and triggers this shard's bus shutdown on transition. The flag
/// is the only Send signal we have; the bus' shutdown machinery is
/// `!Send` (`Rc<Cell<bool>>` + per-shard `async_channel`), so it must be
/// triggered from within the runtime that owns the bus.
///
/// The caller owns the returned handle and must await it on the exit paths
/// where shutdown is in progress (flag set or bus token triggered):
/// dropping it there cancels the watchdog mid-`bus.shutdown()`, truncating
/// in-flight `ClientForwardFailed` replies (terminal per `SendError` docs).
/// It cannot go through `bus.track_background` instead: the watchdog itself
/// drives `bus.shutdown()`, and the bg-drain loop in `shutdown()` would
/// re-enter awaiting the watchdog's own pending shutdown call
/// (self-deadlock). The await is bounded: once the token fires the loop
/// stands down within one poll interval, and the shutdown call itself is
/// capped by `drain_timeout`.
#[allow(clippy::needless_pass_by_value)]
pub(in crate::boot) fn spawn_shutdown_watchdog(
bus: Rc<IggyMessageBus>,
shutdown_flag: Arc<AtomicBool>,
drain_timeout: Duration,
poll_interval: Duration,
) -> compio::runtime::JoinHandle<()> {
let bus_for_task = Rc::clone(&bus);
let bus_token = bus.token();
compio::runtime::spawn(async move {
loop {
if shutdown_flag.load(Ordering::Relaxed) {
break;
}
if bus_token.is_triggered() {
// Bus shutdown was driven from elsewhere (e.g. internal
// failure path). Watchdog has nothing left to do.
return;
}
compio::time::sleep(poll_interval).await;
}
let _ = bus_for_task.shutdown(drain_timeout).await;
})
}
/// Stop senders of the background loops `shard_main` spawns, fired together
/// on `shard_main`'s bind-failure and normal-shutdown exits.
pub(in crate::boot) struct StopSignals {
pub(in crate::boot) pump: Sender<()>,
pub(in crate::boot) reconciler: Sender<()>,
pub(in crate::boot) heartbeat: Option<Sender<()>>,
pub(in crate::boot) pat_cleaner: Option<Sender<()>>,
pub(in crate::boot) segment_cleaner: Option<Sender<()>>,
}
impl StopSignals {
/// Best-effort: a loop that already exited has dropped its receiver.
pub(in crate::boot) fn fire(&self) {
let _ = self.pump.try_send(());
let _ = self.reconciler.try_send(());
for stop in [&self.heartbeat, &self.pat_cleaner, &self.segment_cleaner]
.into_iter()
.flatten()
{
let _ = stop.try_send(());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shutdown_on_drop_armed_flips_flag() {
let flag = Arc::new(AtomicBool::new(false));
drop(ShutdownOnDrop::new(Arc::clone(&flag)));
assert!(
flag.load(Ordering::Relaxed),
"an armed guard must flip the flag on drop (covers the error `?` \
and panic-unwind exit paths of run_shard_thread)"
);
}
#[test]
fn shutdown_on_drop_disarmed_leaves_flag() {
let flag = Arc::new(AtomicBool::new(false));
let mut guard = ShutdownOnDrop::new(Arc::clone(&flag));
guard.disarm();
drop(guard);
assert!(
!flag.load(Ordering::Relaxed),
"a disarmed guard must not flip the flag (clean `Ok(())` exit)"
);
}
#[compio::test]
async fn pump_drain_timeout_is_not_reported_as_clean() {
let mut config = ServerConfig::default();
let timeout = Duration::from_millis(1);
Arc::get_mut(&mut config.system)
.expect("a fresh ServerConfig owns its system config")
.sharding
.shutdown_drain_timeout = iggy_common::IggyDuration::new(timeout);
let pump = compio::runtime::spawn(std::future::pending::<Option<FatalCommit>>());
let error = await_pump_drain(Some(pump), &config, 7)
.await
.expect_err("a live pump past the drain budget is not a clean exit");
assert!(matches!(
error,
ServerError::ShardPumpDrainTimedOut {
shard_id: 7,
timeout: actual,
} if actual == timeout
));
}
#[compio::test]
async fn pump_stopped_by_a_commit_fault_is_not_reported_as_clean() {
// The pump drained and flushed, so the join succeeds. Reporting that
// as a clean exit would hand an orchestrator exit code 0 for a node
// that stopped because it could not persist a cluster-committed op.
let config = ServerConfig::default();
let fault = FatalCommit {
namespace_raw: 42,
op: 7,
operation: iggy_binary_protocol::Operation::SendMessages,
};
let pump = compio::runtime::spawn(async move { Some(fault) });
let error = await_pump_drain(Some(pump), &config, 3)
.await
.expect_err("a pump that stopped on a commit fault is not a clean exit");
assert!(matches!(
error,
ServerError::ShardFatal {
shard_id: 3,
namespace_raw: 42,
op: 7,
}
));
}
/// Regression: the shutdown-join deadline must arm at SHUTDOWN, not
/// at boot. The original bound measured from `join_all` entry, so any
/// healthy server outliving `shutdown_join_timeout` (30s default) was
/// abandoned as "wedged" and the process exited - every BDD run died
/// at t+30s while the test container was still compiling.
#[test]
fn join_waits_unbounded_while_the_server_runs() {
let shutdown_flag = AtomicBool::new(false);
// Thread outlives a deliberately tiny join budget; with the flag
// clear the budget must never even arm.
let handle = thread::spawn(|| -> Result<(), ServerError> {
thread::sleep(Duration::from_millis(300));
Ok(())
});
let mut deadline = None;
let joined = join_until_shutdown_deadline(
handle,
&shutdown_flag,
Duration::from_millis(20),
&mut deadline,
);
assert!(
matches!(joined, Some(Ok(Ok(())))),
"a running server must be awaited indefinitely, not abandoned as wedged"
);
assert!(
deadline.is_none(),
"the join deadline must not arm before the shutdown flag flips"
);
}
#[test]
fn join_all_fails_the_exit_on_a_panic_no_thread_surfaced() {
// A listener or connection task panic leaves every shard thread
// exiting Ok; only the hook's record can keep that from reading
// as a clean shutdown to an orchestrator.
let first_panic = Arc::new(OnceLock::new());
first_panic
.set("thread 'shard-0' panicked at listener.rs:1:1: boom".to_string())
.expect("a fresh slot accepts the first record");
let handle = thread::spawn(|| -> Result<(), ServerError> { Ok(()) });
let handles = ShardHandles {
shutdown_flag: Arc::new(AtomicBool::new(true)),
shard_threads: vec![(0, handle)],
join_timeout: Duration::from_secs(1),
first_panic,
};
let error = handles
.join_all()
.expect_err("a recorded panic must fail the exit even when every thread exited Ok");
assert!(
matches!(&error, ServerError::Panicked { description } if description.contains("boom")),
"unexpected error: {error}"
);
}
#[test]
fn panic_hook_records_the_panic_and_flips_the_shutdown_flag() {
let shutdown_flag = Arc::new(AtomicBool::new(false));
let first_panic = install_panic_hook(Arc::clone(&shutdown_flag));
let joined = thread::Builder::new()
.name("shard-7".to_string())
.spawn(|| panic!("injected task panic"))
.expect("spawn")
.join();
assert!(joined.is_err(), "the thread must have panicked");
assert!(
shutdown_flag.load(Ordering::Relaxed),
"a panic anywhere must drive the whole server down"
);
let description = first_panic.get().expect("the hook records the first panic");
assert!(
description.starts_with("thread 'shard-7' panicked at ")
&& description.ends_with(": injected task panic"),
"unexpected record: {description}"
);
}
#[test]
fn join_abandons_a_wedged_shard_after_the_shutdown_deadline() {
let shutdown_flag = AtomicBool::new(true);
// Never finishes: stands in for a wedged pump. The thread leaks
// into the test process, which exits right after.
let handle = thread::spawn(|| -> Result<(), ServerError> {
loop {
thread::sleep(Duration::from_secs(1));
}
});
let mut deadline = None;
let joined = join_until_shutdown_deadline(
handle,
&shutdown_flag,
Duration::from_millis(100),
&mut deadline,
);
assert!(
joined.is_none(),
"a shard still running past the post-shutdown budget must be abandoned"
);
assert!(deadline.is_some(), "the deadline arms once the flag is set");
}
}