use std::time::Duration;
use serde::{Deserialize, Serialize};
use tocat_api::{BuildCtx, Ctx, Interval, Plugin, PluginError, PluginFactory, Result, Stage};
pub const NAME: &str = "timeout";
const GRANULARITY: u32 = 4;
const MIN_TICK: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct TimeoutConfig {
#[serde(alias = "wait", alias = "inactivity", alias = "idle")]
pub timeout: Interval,
}
pub struct Timeout {
limit: u32,
idle: u32,
tick: Duration,
timeout: Interval,
}
impl Plugin for Timeout {
fn name(&self) -> &str {
NAME
}
fn tick_interval(&self) -> Option<Duration> {
Some(self.tick)
}
fn on_tick(&mut self, ctx: &mut Ctx<'_>) -> Result<()> {
self.idle += 1;
if self.idle >= self.limit {
ctx.halt(&format!("no data for {}", self.timeout));
}
Ok(())
}
fn on_bytes(&mut self, ctx: &mut Ctx<'_>, _input: &[u8]) -> Result<()> {
ctx.pass_through();
self.idle = 0;
ctx.rearm();
Ok(())
}
fn datagram_safe(&self) -> bool {
true
}
}
pub struct TimeoutFactory;
impl PluginFactory for TimeoutFactory {
fn name(&self) -> &str {
NAME
}
fn description(&self) -> &str {
"end this direction when it has carried nothing for a while"
}
fn build(&self, ctx: &mut BuildCtx<'_>) -> Result<Stage> {
let config: TimeoutConfig = ctx.config()?;
let timeout = config.timeout.duration();
if timeout.is_zero() {
return Err(PluginError::config(
NAME,
"timeout must be greater than zero",
));
}
let tick = (timeout / GRANULARITY).max(MIN_TICK).min(timeout);
let limit = timeout.as_nanos().div_ceil(tick.as_nanos());
let limit = u32::try_from(limit).unwrap_or(u32::MAX).max(1);
Ok(Stage::filter(Timeout {
limit,
idle: 0,
tick,
timeout: config.timeout,
}))
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use tocat_api::{
ChannelId, ChannelTarget, Direction, EffectSink, Emission, Emit, HostBuilder, LogLevel,
PipelineMeta, StageInfo,
};
use super::*;
#[derive(Default)]
struct Recorder {
halts: Vec<String>,
}
impl EffectSink for Recorder {
fn write(&mut self, _channel: ChannelId, _bytes: &[u8]) {}
fn log(&mut self, _level: LogLevel, _stage: &str, _message: &str) {}
fn halt(&mut self, stage: &str, reason: &str) {
self.halts.push(format!("{stage}: {reason}"));
}
}
struct NullHost;
impl HostBuilder for NullHost {
fn open_channel(&mut self, _target: ChannelTarget) -> Result<ChannelId> {
Ok(ChannelId(0))
}
}
fn meta() -> PipelineMeta {
PipelineMeta::new(Direction::SourceToSink, "tcp://a", "STDIO")
}
fn build(config: Value) -> Result<Box<dyn Plugin>> {
let map = config.as_object().expect("object").clone();
let meta = meta();
let mut host = NullHost;
let stage = StageInfo {
index: 0,
total: 1,
name: NAME,
upstream: "tcp://a",
downstream: "STDIO",
};
let mut ctx = BuildCtx::new(NAME, &map, &meta, stage, &mut host);
match TimeoutFactory.build(&mut ctx)? {
Stage::Filter(plugin) => Ok(plugin),
Stage::External(_) => unreachable!("timeout is a filter"),
}
}
fn feed(plugin: &mut dyn Plugin, sink: &mut Recorder, input: &[u8]) -> Emission {
let meta = meta();
let mut emission = Emission::new();
{
let mut ctx = Ctx::new(&meta, NAME, input, &mut emission, sink);
plugin.on_bytes(&mut ctx, input).expect("on_bytes");
}
emission
}
fn tick(plugin: &mut dyn Plugin, sink: &mut Recorder) -> Emission {
let meta = meta();
let mut emission = Emission::new();
{
let mut ctx = Ctx::new(&meta, NAME, &[], &mut emission, sink);
plugin.on_tick(&mut ctx).expect("on_tick");
}
emission
}
#[test]
fn asks_for_a_fraction_of_the_timeout() {
let plugin = build(json!({ "timeout": "40s" })).expect("build");
assert_eq!(plugin.tick_interval(), Some(Duration::from_secs(10)));
}
#[test]
fn a_short_timeout_is_floored_rather_than_spinning() {
let plugin = build(json!({ "timeout": "20ms" })).expect("build");
assert_eq!(plugin.tick_interval(), Some(Duration::from_millis(20)));
}
#[test]
fn halts_after_a_whole_window_of_silence() {
let mut plugin = build(json!({ "timeout": "40s" })).expect("build");
let mut sink = Recorder::default();
for _ in 0..GRANULARITY - 1 {
tick(plugin.as_mut(), &mut sink);
assert!(sink.halts.is_empty(), "halted early");
}
tick(plugin.as_mut(), &mut sink);
assert_eq!(sink.halts.len(), 1);
assert!(sink.halts[0].contains("no data for 40s"));
}
#[test]
fn traffic_resets_the_window_and_costs_nothing() {
let mut plugin = build(json!({ "timeout": "40s" })).expect("build");
let mut sink = Recorder::default();
for _ in 0..GRANULARITY * 3 {
tick(plugin.as_mut(), &mut sink);
let emission = feed(plugin.as_mut(), &mut sink, b"ping");
assert_eq!(emission.emit(), Emit::Passthrough);
assert!(emission.bytes().is_empty());
assert!(emission.rearm_requested(), "the window must be restarted");
}
assert!(sink.halts.is_empty(), "a busy path timed out");
}
#[test]
fn rejects_a_zero_timeout() {
assert!(build(json!({ "timeout": 0 })).is_err());
}
#[test]
fn takes_its_aliases_and_rejects_anything_else() {
assert!(build(json!({ "wait": "10s" })).is_ok());
assert!(build(json!({ "idle": "10s" })).is_ok());
assert!(build(json!({ "timeuot": "10s" })).is_err());
assert!(build(json!({})).is_err());
}
}