From df8d0b3e3b603f6e527b7cabc8249e431907abbe Mon Sep 17 00:00:00 2001 From: tobwen Date: Mon, 2 Mar 2026 18:37:23 +0000 Subject: [PATCH 1/8] fix: reduce detach latency and stabilize detach path --- libshpool/src/consts.rs | 2 +- libshpool/src/daemon/shell.rs | 27 +++++++++++---- libshpool/src/protocol.rs | 64 +++++++++++++++++++++++++++-------- 3 files changed, 72 insertions(+), 21 deletions(-) diff --git a/libshpool/src/consts.rs b/libshpool/src/consts.rs index 3defc60a..a882cbc2 100644 --- a/libshpool/src/consts.rs +++ b/libshpool/src/consts.rs @@ -15,7 +15,7 @@ use std::time; pub const SOCK_STREAM_TIMEOUT: time::Duration = time::Duration::from_millis(200); -pub const JOIN_POLL_DURATION: time::Duration = time::Duration::from_millis(100); +pub const JOIN_POLL_DURATION: time::Duration = time::Duration::from_millis(10); pub const BUF_SIZE: usize = 1024 * 16; diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs index 94399535..2a00bc39 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -51,10 +51,9 @@ const SUPERVISOR_POLL_DUR: time::Duration = time::Duration::from_millis(300); // size. const REATTACH_RESIZE_DELAY: time::Duration = time::Duration::from_millis(50); -// The shell->client thread should wake up relatively frequently so it can -// detect reattach, but we don't need to go crazy since reattach is not part of -// the inner loop. -const SHELL_TO_CLIENT_POLL_MS: u16 = 100; +// The shell->client thread should poll frequently so detach/reattach control +// messages are noticed quickly without spinning the CPU. +const SHELL_TO_CLIENT_POLL_MS: u16 = 10; // How long to wait before giving up while trying to talk to the // shell->client thread. @@ -835,7 +834,11 @@ impl SessionInner { use keybindings::Action::*; match action { - Detach => self.action_detach()?, + Detach => { + self.action_detach()?; + debug!("exiting client->shell thread after detach"); + return Ok(()); + } NoOp => {} } } @@ -888,7 +891,19 @@ impl SessionInner { return Ok(()); } - thread::sleep(consts::HEARTBEAT_DURATION); + let mut slept = time::Duration::ZERO; + let sleep_step = consts::JOIN_POLL_DURATION; + while slept < consts::HEARTBEAT_DURATION { + if stop.load(Ordering::Relaxed) { + info!("recvd stop msg"); + return Ok(()); + } + + let remaining = consts::HEARTBEAT_DURATION - slept; + let step = if remaining < sleep_step { remaining } else { sleep_step }; + thread::sleep(step); + slept += step; + } { let shell_to_client_ctl = self.shell_to_client_ctl.lock().unwrap(); match shell_to_client_ctl diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs index f96a444f..13f9314d 100644 --- a/libshpool/src/protocol.rs +++ b/libshpool/src/protocol.rs @@ -23,14 +23,18 @@ use std::{ use anyhow::{anyhow, Context}; use byteorder::{LittleEndian, ReadBytesExt as _, WriteBytesExt as _}; +use nix::unistd::isatty; use serde::{Deserialize, Serialize}; use shpool_protocol::{Chunk, ChunkKind, ConnectHeader, VersionHeader}; use tracing::{debug, error, info, instrument, span, trace, warn, Level}; use super::{consts, tty}; -const JOIN_POLL_DUR: time::Duration = time::Duration::from_millis(100); -const JOIN_HANGUP_DUR: time::Duration = time::Duration::from_millis(300); +const DETACH_TTY_FAST_WAIT_DUR: time::Duration = time::Duration::from_millis(10); +const MAX_DETACH_WAIT_DUR: time::Duration = time::Duration::from_millis(300); +const DETACH_BACKOFF_INITIAL_DUR: time::Duration = time::Duration::from_millis(1); +// Cap backoff steps so slow-path stays responsive while still avoiding busy waits. +const DETACH_BACKOFF_MAX_STEP_DUR: time::Duration = time::Duration::from_millis(25); /// The centralized encoding function that should be used for all protocol /// serialization. @@ -333,18 +337,49 @@ impl Client { if sock_to_stdout_h.is_finished() { nfinished_threads += 1; } + if nfinished_threads > 0 { if nfinished_threads < 2 { - thread::sleep(JOIN_HANGUP_DUR); - nfinished_threads = 0; - if stdin_to_sock_h.is_finished() { - info!("recheck: stdin->sock thread done"); - nfinished_threads += 1; - } - if sock_to_stdout_h.is_finished() { - info!("recheck: sock->stdout thread done"); - nfinished_threads += 1; + // Fast-path: when server->client already ended (detach/disconnect), + // stdin->sock can stay blocked on stdin. In that case, do a very + // short grace wait and then exit quickly. + // Slow-path: for other shutdown orders, keep compatibility by + // waiting up to 300ms with backoff. + let mut total_wait = time::Duration::ZERO; + let mut next_sleep = DETACH_BACKOFF_INITIAL_DUR; + let mut stdin_done = stdin_to_sock_h.is_finished(); + let mut stdout_done = sock_to_stdout_h.is_finished(); + + let stdin_is_tty = isatty(io::stdin()).unwrap_or(false); + // Keep max_wait fixed for this detach sequence. Recomputing it inside + // the loop could accidentally switch paths mid-cleanup. + let max_wait = if stdin_is_tty && stdout_done && !stdin_done { + DETACH_TTY_FAST_WAIT_DUR + } else { + MAX_DETACH_WAIT_DUR + }; + + loop { + stdin_done = stdin_to_sock_h.is_finished(); + stdout_done = sock_to_stdout_h.is_finished(); + nfinished_threads = (stdin_done as usize) + (stdout_done as usize); + + if nfinished_threads >= 2 { + break; + } + if total_wait >= max_wait { + break; + } + + let remaining = max_wait - total_wait; + let sleep_for = cmp::min(next_sleep, remaining); + thread::sleep(sleep_for); + total_wait += sleep_for; + // Exponential backoff with a capped step to avoid busy waits. + next_sleep = + cmp::min(next_sleep + next_sleep, DETACH_BACKOFF_MAX_STEP_DUR); } + if nfinished_threads < 2 { // If one of the worker threads is done and the // other is not exiting, we are likely blocked on @@ -355,8 +390,8 @@ impl Client { // us to use simple blocking IO. warn!( "exiting due to a stuck IO thread stdin_to_sock_finished={} sock_to_stdout_finished={}", - stdin_to_sock_h.is_finished(), - sock_to_stdout_h.is_finished() + stdin_done, + stdout_done ); // make sure that we restore the tty flags on the input // tty before exiting the process. @@ -367,7 +402,8 @@ impl Client { } break; } - thread::sleep(JOIN_POLL_DUR); + + thread::sleep(consts::JOIN_POLL_DURATION); } match stdin_to_sock_h.join() { From b92fb5bd3c418fc37a1cd3f0acb45e347e80008d Mon Sep 17 00:00:00 2001 From: tobwen Date: Mon, 2 Mar 2026 18:45:44 +0000 Subject: [PATCH 2/8] fix: reduce detach latency and stabilize detach path --- libshpool/src/consts.rs | 2 +- libshpool/src/daemon/shell.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libshpool/src/consts.rs b/libshpool/src/consts.rs index a882cbc2..d8fd925d 100644 --- a/libshpool/src/consts.rs +++ b/libshpool/src/consts.rs @@ -15,7 +15,7 @@ use std::time; pub const SOCK_STREAM_TIMEOUT: time::Duration = time::Duration::from_millis(200); -pub const JOIN_POLL_DURATION: time::Duration = time::Duration::from_millis(10); +pub const JOIN_POLL_DURATION: time::Duration = time::Duration::from_millis(50); pub const BUF_SIZE: usize = 1024 * 16; diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs index 2a00bc39..9e45c43a 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -53,7 +53,7 @@ const REATTACH_RESIZE_DELAY: time::Duration = time::Duration::from_millis(50); // The shell->client thread should poll frequently so detach/reattach control // messages are noticed quickly without spinning the CPU. -const SHELL_TO_CLIENT_POLL_MS: u16 = 10; +const SHELL_TO_CLIENT_POLL_MS: u16 = 50; // How long to wait before giving up while trying to talk to the // shell->client thread. From af4a3cd6d7076d0a4ccd1431fe138587769d58c8 Mon Sep 17 00:00:00 2001 From: tobwen Date: Tue, 3 Mar 2026 19:52:05 +0000 Subject: [PATCH 3/8] chore: fix rustfmt nightly comment wrapping --- libshpool/src/protocol.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs index 13f9314d..c152a0eb 100644 --- a/libshpool/src/protocol.rs +++ b/libshpool/src/protocol.rs @@ -33,7 +33,8 @@ use super::{consts, tty}; const DETACH_TTY_FAST_WAIT_DUR: time::Duration = time::Duration::from_millis(10); const MAX_DETACH_WAIT_DUR: time::Duration = time::Duration::from_millis(300); const DETACH_BACKOFF_INITIAL_DUR: time::Duration = time::Duration::from_millis(1); -// Cap backoff steps so slow-path stays responsive while still avoiding busy waits. +// Cap backoff steps so slow-path stays responsive while still avoiding busy +// waits. const DETACH_BACKOFF_MAX_STEP_DUR: time::Duration = time::Duration::from_millis(25); /// The centralized encoding function that should be used for all protocol @@ -54,7 +55,7 @@ where Ok(()) } -/// The centralized decoding focuntion that should be used for all protocol +/// The centralized decoding function that should be used for all protocol /// deserialization. pub fn decode_from(r: R) -> anyhow::Result where From 621c94ddc4919a31bad8338a19bb706195436c3f Mon Sep 17 00:00:00 2001 From: tobwen <1864057+tobwen@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:37:08 +0000 Subject: [PATCH 4/8] refactor: add shared sleep helper for stop-aware polling --- libshpool/src/common.rs | 106 ++++++++++++++++++++++++++++++++++ libshpool/src/daemon/shell.rs | 23 +++----- 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/libshpool/src/common.rs b/libshpool/src/common.rs index 02321e8f..28116bbd 100644 --- a/libshpool/src/common.rs +++ b/libshpool/src/common.rs @@ -15,9 +15,64 @@ //! The common module is a grab bag of shared utility functions. use std::env; +use std::{thread, time}; use anyhow::anyhow; +/// Controls how often `sleep_unless` re-checks its stop predicate. +#[derive(Clone, Copy, Debug)] +pub enum PollStrategy { + /// Poll at a fixed interval. + Uniform { interval: time::Duration }, + /// Poll with exponential backoff up to a maximum interval. + Backoff { initial_interval: time::Duration, factor: u32, max_interval: time::Duration }, +} + +/// Sleeps for up to `total_sleep`, but returns early if `stop` becomes true. +/// +/// Returns `true` when `stop` triggered before timeout and `false` when the +/// full sleep window elapsed. +pub fn sleep_unless( + total_sleep: time::Duration, + mut stop: F, + poll_strategy: PollStrategy, +) -> bool +where + F: FnMut() -> bool, +{ + let deadline = time::Instant::now() + total_sleep; + let mut next_interval = match poll_strategy { + PollStrategy::Uniform { interval } => interval, + PollStrategy::Backoff { initial_interval, .. } => initial_interval, + }; + + if next_interval.is_zero() { + // Avoid a tight spin-loop if a zero interval is accidentally configured. + next_interval = time::Duration::from_millis(1); + } + + loop { + if stop() { + return true; + } + + let now = time::Instant::now(); + if now >= deadline { + return false; + } + + let remaining = deadline.saturating_duration_since(now); + thread::sleep(remaining.min(next_interval)); + + if let PollStrategy::Backoff { factor, max_interval, .. } = poll_strategy { + if factor > 1 { + let grown = next_interval.checked_mul(factor).unwrap_or(max_interval); + next_interval = grown.min(max_interval); + } + } + } +} + pub fn resolve_sessions(sessions: &mut Vec, action: &str) -> anyhow::Result<()> { if sessions.is_empty() { if let Ok(current_session) = env::var("SHPOOL_SESSION_NAME") { @@ -32,3 +87,54 @@ pub fn resolve_sessions(sessions: &mut Vec, action: &str) -> anyhow::Res Ok(()) } + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::time::Duration; + + use super::{sleep_unless, PollStrategy}; + + #[test] + fn sleep_unless_returns_immediately_when_stop_is_true() { + let stopped = sleep_unless( + Duration::from_millis(10), + || true, + PollStrategy::Uniform { interval: Duration::from_millis(1) }, + ); + + assert!(stopped); + } + + #[test] + fn sleep_unless_times_out_when_stop_is_false() { + let stopped = sleep_unless( + Duration::from_millis(3), + || false, + PollStrategy::Uniform { interval: Duration::from_millis(1) }, + ); + + assert!(!stopped); + } + + #[test] + fn sleep_unless_rechecks_stop_with_backoff() { + let checks = Cell::new(0usize); + let stopped = sleep_unless( + Duration::from_millis(20), + || { + let n = checks.get() + 1; + checks.set(n); + n >= 3 + }, + PollStrategy::Backoff { + initial_interval: Duration::from_millis(1), + factor: 2, + max_interval: Duration::from_millis(4), + }, + ); + + assert!(stopped); + assert!(checks.get() >= 3); + } +} diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs index 9e45c43a..8ebb8f84 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -32,7 +32,7 @@ use shpool_protocol::{Chunk, ChunkKind, TtySize}; use tracing::{debug, error, info, instrument, span, trace, warn, Level}; use crate::{ - consts, + common, consts, daemon::{config, exit_notify::ExitNotifier, keybindings, pager::PagerCtl, prompt, show_motd}, protocol::ChunkExt as _, session_restore, test_hooks, @@ -886,24 +886,15 @@ impl SessionInner { loop { trace!("checking stop_rx"); - if stop.load(Ordering::Relaxed) { + let stop_early = common::sleep_unless( + consts::HEARTBEAT_DURATION, + || stop.load(Ordering::Relaxed), + common::PollStrategy::Uniform { interval: consts::JOIN_POLL_DURATION }, + ); + if stop_early { info!("recvd stop msg"); return Ok(()); } - - let mut slept = time::Duration::ZERO; - let sleep_step = consts::JOIN_POLL_DURATION; - while slept < consts::HEARTBEAT_DURATION { - if stop.load(Ordering::Relaxed) { - info!("recvd stop msg"); - return Ok(()); - } - - let remaining = consts::HEARTBEAT_DURATION - slept; - let step = if remaining < sleep_step { remaining } else { sleep_step }; - thread::sleep(step); - slept += step; - } { let shell_to_client_ctl = self.shell_to_client_ctl.lock().unwrap(); match shell_to_client_ctl From af727f4f666c69a312b67a4c7fb6a015416846f9 Mon Sep 17 00:00:00 2001 From: tobwen <1864057+tobwen@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:37:20 +0000 Subject: [PATCH 5/8] refactor: simplify detach wait logic with shared polling --- libshpool/src/protocol.rs | 44 ++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs index c152a0eb..90897079 100644 --- a/libshpool/src/protocol.rs +++ b/libshpool/src/protocol.rs @@ -23,14 +23,13 @@ use std::{ use anyhow::{anyhow, Context}; use byteorder::{LittleEndian, ReadBytesExt as _, WriteBytesExt as _}; -use nix::unistd::isatty; use serde::{Deserialize, Serialize}; use shpool_protocol::{Chunk, ChunkKind, ConnectHeader, VersionHeader}; use tracing::{debug, error, info, instrument, span, trace, warn, Level}; -use super::{consts, tty}; +use super::{common, consts, tty}; -const DETACH_TTY_FAST_WAIT_DUR: time::Duration = time::Duration::from_millis(10); +const DETACH_DISCONNECT_FAST_WAIT_DUR: time::Duration = time::Duration::from_millis(10); const MAX_DETACH_WAIT_DUR: time::Duration = time::Duration::from_millis(300); const DETACH_BACKOFF_INITIAL_DUR: time::Duration = time::Duration::from_millis(1); // Cap backoff steps so slow-path stays responsive while still avoiding busy @@ -346,39 +345,36 @@ impl Client { // short grace wait and then exit quickly. // Slow-path: for other shutdown orders, keep compatibility by // waiting up to 300ms with backoff. - let mut total_wait = time::Duration::ZERO; - let mut next_sleep = DETACH_BACKOFF_INITIAL_DUR; let mut stdin_done = stdin_to_sock_h.is_finished(); let mut stdout_done = sock_to_stdout_h.is_finished(); - let stdin_is_tty = isatty(io::stdin()).unwrap_or(false); // Keep max_wait fixed for this detach sequence. Recomputing it inside // the loop could accidentally switch paths mid-cleanup. - let max_wait = if stdin_is_tty && stdout_done && !stdin_done { - DETACH_TTY_FAST_WAIT_DUR + let max_wait = if stdout_done && !stdin_done { + DETACH_DISCONNECT_FAST_WAIT_DUR } else { MAX_DETACH_WAIT_DUR }; - loop { + let finished_waiting = common::sleep_unless( + max_wait, + || { + stdin_done = stdin_to_sock_h.is_finished(); + stdout_done = sock_to_stdout_h.is_finished(); + nfinished_threads = (stdin_done as usize) + (stdout_done as usize); + nfinished_threads >= 2 + }, + common::PollStrategy::Backoff { + initial_interval: DETACH_BACKOFF_INITIAL_DUR, + factor: 2, + max_interval: DETACH_BACKOFF_MAX_STEP_DUR, + }, + ); + + if !finished_waiting { stdin_done = stdin_to_sock_h.is_finished(); stdout_done = sock_to_stdout_h.is_finished(); nfinished_threads = (stdin_done as usize) + (stdout_done as usize); - - if nfinished_threads >= 2 { - break; - } - if total_wait >= max_wait { - break; - } - - let remaining = max_wait - total_wait; - let sleep_for = cmp::min(next_sleep, remaining); - thread::sleep(sleep_for); - total_wait += sleep_for; - // Exponential backoff with a capped step to avoid busy waits. - next_sleep = - cmp::min(next_sleep + next_sleep, DETACH_BACKOFF_MAX_STEP_DUR); } if nfinished_threads < 2 { From 7a7b77e4514830253024e45ab793f4dd5b05a9d1 Mon Sep 17 00:00:00 2001 From: tobwen <1864057+tobwen@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:43:10 +0000 Subject: [PATCH 6/8] docs: clarify generalized detach fast-path behavior --- libshpool/src/common.rs | 5 +++-- libshpool/src/protocol.rs | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/libshpool/src/common.rs b/libshpool/src/common.rs index 28116bbd..403d8353 100644 --- a/libshpool/src/common.rs +++ b/libshpool/src/common.rs @@ -14,8 +14,7 @@ //! The common module is a grab bag of shared utility functions. -use std::env; -use std::{thread, time}; +use std::{env, thread, time}; use anyhow::anyhow; @@ -25,6 +24,8 @@ pub enum PollStrategy { /// Poll at a fixed interval. Uniform { interval: time::Duration }, /// Poll with exponential backoff up to a maximum interval. + /// + /// Values <= 1 disable growth and behave like uniform polling. Backoff { initial_interval: time::Duration, factor: u32, max_interval: time::Duration }, } diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs index 90897079..62f22f81 100644 --- a/libshpool/src/protocol.rs +++ b/libshpool/src/protocol.rs @@ -342,7 +342,8 @@ impl Client { if nfinished_threads < 2 { // Fast-path: when server->client already ended (detach/disconnect), // stdin->sock can stay blocked on stdin. In that case, do a very - // short grace wait and then exit quickly. + // short grace wait and then exit quickly. This is independent + // of stdin being a TTY or a pipe. // Slow-path: for other shutdown orders, keep compatibility by // waiting up to 300ms with backoff. let mut stdin_done = stdin_to_sock_h.is_finished(); @@ -372,6 +373,8 @@ impl Client { ); if !finished_waiting { + // Re-probe after timeout because thread state can change + // during the final sleep inside sleep_unless. stdin_done = stdin_to_sock_h.is_finished(); stdout_done = sock_to_stdout_h.is_finished(); nfinished_threads = (stdin_done as usize) + (stdout_done as usize); From 5daea973940a5b7da90689fc9f229407d1e07770 Mon Sep 17 00:00:00 2001 From: tobwen <1864057+tobwen@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:56:49 +0000 Subject: [PATCH 7/8] docs: keep sock->stdout naming consistent --- libshpool/src/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs index 62f22f81..6b58fa2d 100644 --- a/libshpool/src/protocol.rs +++ b/libshpool/src/protocol.rs @@ -340,7 +340,7 @@ impl Client { if nfinished_threads > 0 { if nfinished_threads < 2 { - // Fast-path: when server->client already ended (detach/disconnect), + // Fast-path: when sock->stdout already ended (detach/disconnect), // stdin->sock can stay blocked on stdin. In that case, do a very // short grace wait and then exit quickly. This is independent // of stdin being a TTY or a pipe. From 26ecd152f99618349ebd4eb8e2444b659f067682 Mon Sep 17 00:00:00 2001 From: Ethan Pailes Date: Tue, 10 Mar 2026 14:46:14 +0000 Subject: [PATCH 8/8] switch factor to float --- libshpool/src/common.rs | 9 ++++----- libshpool/src/protocol.rs | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/libshpool/src/common.rs b/libshpool/src/common.rs index 403d8353..5ef027da 100644 --- a/libshpool/src/common.rs +++ b/libshpool/src/common.rs @@ -26,7 +26,7 @@ pub enum PollStrategy { /// Poll with exponential backoff up to a maximum interval. /// /// Values <= 1 disable growth and behave like uniform polling. - Backoff { initial_interval: time::Duration, factor: u32, max_interval: time::Duration }, + Backoff { initial_interval: time::Duration, factor: f32, max_interval: time::Duration }, } /// Sleeps for up to `total_sleep`, but returns early if `stop` becomes true. @@ -66,9 +66,8 @@ where thread::sleep(remaining.min(next_interval)); if let PollStrategy::Backoff { factor, max_interval, .. } = poll_strategy { - if factor > 1 { - let grown = next_interval.checked_mul(factor).unwrap_or(max_interval); - next_interval = grown.min(max_interval); + if factor > 1.0 { + next_interval = std::cmp::min(next_interval.mul_f32(factor), max_interval); } } } @@ -130,7 +129,7 @@ mod tests { }, PollStrategy::Backoff { initial_interval: Duration::from_millis(1), - factor: 2, + factor: 2.0, max_interval: Duration::from_millis(4), }, ); diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs index 6b58fa2d..6051db8e 100644 --- a/libshpool/src/protocol.rs +++ b/libshpool/src/protocol.rs @@ -367,7 +367,7 @@ impl Client { }, common::PollStrategy::Backoff { initial_interval: DETACH_BACKOFF_INITIAL_DUR, - factor: 2, + factor: 2.0, max_interval: DETACH_BACKOFF_MAX_STEP_DUR, }, );