// Copyright 2024 RustFS Team // // Licensed 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. //! Per-session liveness watchdog. //! //! Detects sessions that are silent at the SFTP handler layer while //! the underlying TCP connection is in CLOSE_WAIT, and cancels them //! so the server does not accumulate orphaned per-session resources //! (handle table entries, in-flight multipart uploads, read caches). //! //! The watchdog runs one tokio task per session. Every //! WEDGE_WATCHDOG_TICK_SECS it inspects the session's last-activity //! stamp and the kernel TCP state for the connection. A wedged //! session shows two coincident signals: silence past //! WEDGE_FAST_KILL_SILENCE_SECS, and a TCP state of CLOSE_WAIT //! (peer FIN'd, application has not closed). A healthy idle session //! shows ESTABLISHED. Two consecutive positive ticks are required //! before the watchdog cancels. //! //! The TCP-state probe lives in lifecycle::probe_tcp_state and reads //! /proc/net/tcp[6] to look up the row matching the session's local //! and peer addresses. CLOSE_WAIT is unambiguous, so a slow S3 //! backend operation that pipelines into a still-ESTABLISHED socket //! cannot be misdiagnosed as a wedge. //! //! Compiled on Linux only. Non-Linux targets use fallback_watchdog, a //! silence-only backstop at WEDGE_FALLBACK_KILL_SILENCE_SECS without the //! procfs CLOSE_WAIT probe. //! //! Worst-case fast-kill latency on Linux is WEDGE_FAST_KILL_SILENCE_SECS //! plus one to two ticks (approximately 45 to 60 s) from the moment a //! session is both silent past WEDGE_FAST_KILL_SILENCE_SECS and the //! kernel reports CLOSE_WAIT. The WEDGE_FALLBACK_KILL_SILENCE_SECS //! ceiling is the backstop for the procfs probe when /proc/net/tcp is //! unreadable (filesystem permissions, namespace tricks) or when the //! wedge is in a TCP state other than CLOSE_WAIT. //! //! On cancel the watchdog calls shutdown(Both) on the duplicated //! socket so russh's inner select unwedges via EOF propagation, //! then signals the shared CancellationToken so the outer session //! task drops the RunningSession. use super::constants::limits::{WEDGE_FALLBACK_KILL_SILENCE_SECS, WEDGE_FAST_KILL_SILENCE_SECS, WEDGE_WATCHDOG_TICK_SECS}; use super::lifecycle::{SessionDiag, TcpState, probe_tcp_state}; use socket2::Socket; use std::net::Shutdown; use std::os::fd::AsFd; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::net::TcpStream; use tokio_util::sync::CancellationToken; const LOG_COMPONENT_PROTOCOLS: &str = "protocols"; const LOG_SUBSYSTEM_SFTP_WATCHDOG: &str = "sftp_watchdog"; const EVENT_SFTP_WATCHDOG_STATE: &str = "sftp_watchdog_state"; /// Reason a watchdog cancelled its session. Included in the warn log /// the watchdog writes at cancel time so operators can correlate the /// cancel with the upstream client behaviour. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WedgeReason { /// Two consecutive ticks observed silence past the fast threshold /// AND a TCP state of CLOSE_WAIT (peer FIN'd, application has not /// drained the SSH stream) on the second tick. The CLOSE_WAIT /// observation on the cancelling tick is the load-bearing claim /// in the operator log line. TcpStateCloseWaitConfirmed, /// Two consecutive ticks observed silence past the fast threshold /// AND the TCP-state probe failed to return a known state on the /// second tick (closed dup, missing /proc, kernel without procfs /// entries). Session is not coming back and the kernel state was /// not decisively observable when the cancel fired. ProbeFailedConfirmed, /// Silence past WEDGE_FALLBACK_KILL_SILENCE_SECS regardless of /// the TCP_STATE probe result. Backstop for the case where the /// wedge is in a state other than CLOSE_WAIT and probes /// kept returning healthy or non-decisive. FallbackSilence, } impl WedgeReason { fn as_str(self) -> &'static str { match self { Self::TcpStateCloseWaitConfirmed => "tcp_state_close_wait_confirmed", Self::ProbeFailedConfirmed => "probe_failed_confirmed", Self::FallbackSilence => "fallback_silence", } } } /// Duplicate the TcpStream's underlying socket via the safe AsFd path /// and wrap the result in a socket2::Socket. The dup exists solely so /// the watchdog can call shutdown(Both) on the wedged session without /// racing russh for the original fd. Returns None when the dup fails. /// Callers should treat None as "no watchdog this session, accept-loop /// continues". pub(super) fn dup_socket(stream: &TcpStream) -> Option { let cloned = stream.as_fd().try_clone_to_owned().ok()?; Some(Socket::from(cloned)) } /// Spawn a per-session watchdog tick task. /// /// The task owns the duplicated socket (closed on task end via /// Socket::Drop) and a clone of the session's CancellationToken. /// The task exits when it cancels the session itself or when the /// outer session task cancels the token after a clean session end. pub(super) fn spawn_for_session(session_diag: Arc, socket: Socket, cancel_token: CancellationToken) { tokio::spawn(async move { let session_id = session_diag.session_id; let local = session_diag.local; let peer = session_diag.peer; let mut tick = tokio::time::interval(Duration::from_secs(WEDGE_WATCHDOG_TICK_SECS)); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // First tick fires immediately. Skip it so the watchdog never // makes a decision before one full silence window has elapsed. tick.tick().await; let mut wedge_suspected = false; loop { tokio::select! { _ = cancel_token.cancelled() => break, _ = tick.tick() => { let silence_secs = silence_secs(&session_diag); let probe = probe_tcp_state(local, peer); let outcome = evaluate(silence_secs, probe, wedge_suspected); match outcome { Decision::Quiet => { wedge_suspected = false; } Decision::SuspectedFirstTick => { wedge_suspected = true; } Decision::Cancel(reason) => { tracing::warn!( event = EVENT_SFTP_WATCHDOG_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SFTP_WATCHDOG, session_id, peer = %peer, silence_secs, reason = reason.as_str(), "sftp watchdog state changed", ); cancel_token.cancel(); break; } } } } } // Shut down the duplicated socket on every exit path. The // cancellation could come from this watchdog's own kill // decision, from the session task after a clean session end, // or from the listener-wide shutdown cascade. In the wedge // and shutdown-cascade cases the russh inner task is parked // at chan.send(...).await on a backpressured mpsc and only // unblocks when its read or write socket fails. shutdown // here makes the next I/O on the original fd return EOF, // which propagates through russh-sftp and drops the mpsc // receiver. In the clean-end case russh has already returned // and dropped its half of the fd. This call sends a final // FIN on the still-open dup, which the peer's stack // tolerates. let _ = socket.shutdown(Shutdown::Both); }); } #[derive(Debug, PartialEq, Eq)] enum Decision { /// No wedge signal this tick. Reset any suspected state. Quiet, /// First tick to observe silence past the fast threshold AND a /// non-healthy probe result. Hold suspected state for one more /// tick before deciding. SuspectedFirstTick, /// Cancel the session for the given reason. Cancel(WedgeReason), } fn silence_secs(session_diag: &SessionDiag) -> u64 { let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let last_ms = session_diag.last_activity_ms.load(Ordering::Relaxed); now_ms.saturating_sub(last_ms) / 1000 } /// Pure decision function. Takes the silence count, the TCP-state /// probe outcome (Some(state) for a known kernel TCP state, None for /// probe failure), and the previous tick's suspected flag. Returns /// the action the watchdog should take. /// /// CLOSE_WAIT is the unambiguous wedge signature: peer FIN'd and the /// application has not closed. Other states (ESTABLISHED, FIN_WAIT_*, /// transient close-handshake states) are treated as not-wedge. /// /// Probe failures (None) are treated as wedge-suspect rather than /// healthy: a session whose probe has failed and which has been silent /// past the fast threshold is at minimum not coming back, and the /// fallback silence threshold is the absolute backstop. fn evaluate(silence_secs: u64, probe: Option, wedge_suspected: bool) -> Decision { if silence_secs >= WEDGE_FALLBACK_KILL_SILENCE_SECS { return Decision::Cancel(WedgeReason::FallbackSilence); } if silence_secs < WEDGE_FAST_KILL_SILENCE_SECS { return Decision::Quiet; } let wedge_signal = match probe { Some(TcpState::CloseWait) => true, Some(TcpState::Established) | Some(TcpState::Other(_)) => false, None => true, }; if !wedge_signal { return Decision::Quiet; } if wedge_suspected { let reason = if probe.is_none() { WedgeReason::ProbeFailedConfirmed } else { WedgeReason::TcpStateCloseWaitConfirmed }; Decision::Cancel(reason) } else { Decision::SuspectedFirstTick } } #[cfg(test)] mod tests { use super::*; #[test] fn silence_below_fast_threshold_is_quiet() { let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS - 1, Some(TcpState::Established), false); assert_eq!(decision, Decision::Quiet); } #[test] fn silence_above_fast_with_established_is_quiet() { let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::Established), false); assert_eq!(decision, Decision::Quiet); } #[test] fn silence_above_fast_with_transient_close_state_is_quiet() { // FIN_WAIT_2 (0x05): the connection is in a clean close // handshake initiated by the local side. Not a wedge. let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::Other(0x05)), false); assert_eq!(decision, Decision::Quiet); } #[test] fn silence_above_fast_with_close_wait_first_tick_is_suspected() { let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), false); assert_eq!(decision, Decision::SuspectedFirstTick); } #[test] fn silence_above_fast_with_close_wait_second_tick_cancels() { let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), true); assert_eq!(decision, Decision::Cancel(WedgeReason::TcpStateCloseWaitConfirmed)); } #[test] fn probe_failed_silence_above_fast_first_tick_is_suspected() { let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, false); assert_eq!(decision, Decision::SuspectedFirstTick); } #[test] fn probe_failed_silence_above_fast_second_tick_cancels_with_probe_failed_reason() { let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, true); assert_eq!(decision, Decision::Cancel(WedgeReason::ProbeFailedConfirmed)); } #[test] fn close_wait_first_tick_then_probe_fail_second_tick_cancels_with_probe_failed_reason() { // The cancel reason names the second tick's probe outcome // because that is the kernel state at the moment the cancel // fires. CLOSE_WAIT was no longer observable when the kill // happened, so the operator log should not claim it was. let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, true); assert_eq!(decision, Decision::Cancel(WedgeReason::ProbeFailedConfirmed)); } #[test] fn probe_fail_first_tick_then_close_wait_second_tick_cancels_with_close_wait_reason() { let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), true); assert_eq!(decision, Decision::Cancel(WedgeReason::TcpStateCloseWaitConfirmed)); } #[test] fn silence_above_fallback_cancels_regardless_of_probe() { let decision = evaluate(WEDGE_FALLBACK_KILL_SILENCE_SECS, Some(TcpState::Established), false); assert_eq!(decision, Decision::Cancel(WedgeReason::FallbackSilence)); } #[test] fn wedge_reason_as_str_covers_all_variants() { assert_eq!(WedgeReason::TcpStateCloseWaitConfirmed.as_str(), "tcp_state_close_wait_confirmed"); assert_eq!(WedgeReason::ProbeFailedConfirmed.as_str(), "probe_failed_confirmed"); assert_eq!(WedgeReason::FallbackSilence.as_str(), "fallback_silence"); } }