跳转到内容

计量与速率限制

源码文件:13 个 · 核对版本 katana v3.0.1 · Etemenanki 596916d
  • katana/src/meter.rs
  • katana/src/traffic.rs
  • katana/src/connector.rs
  • katana/src/outbound/proxy.rs
  • katana/src/serve.rs
  • katana/src/manager/node.rs
  • katana/src/manager/mod.rs
  • katana/src/api/mod.rs
  • katana/tests/unit/meter.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/connector.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/concepts/src/wake.rs

katana 只在一个地方统计流量并控制速率:它交给内核 per-connection 运行时的出站流。src/meter.rs 用 Metered 包装这条流,把每个字节计入用户的 UserCounter,并在用户的 TokenBucket 处于欠额时暂停这条流。UDP fan-out 内部也放着同一个 Gate,所以数据报也由同一个令牌桶限速。

本页面向修改限速、字节计数器或出站路径的贡献者。内容包括涉及的三个类型、为什么在出站一侧计费、为什么长期速率与运行时如何切分读写无关,以及固定这些行为的测试。计数器如何上报给面板,见流量计费;从运维角度看用户的速率从何而来,见限速与连接限制。

组件 位置 负责 交给其他部分
TokenBucket src/traffic.rs 为每个用户维护一个字节余额:按 rate 补充,上限为一秒的 rate,扣费大于余额时变为负数,并回答“欠额何时还清”。 等待。除测试外它从不 sleep。
UserCounter src/traffic.rs 保存 up 和 down 两个字节总数以及用户的令牌桶。每个已注册用户一个,该用户的所有流通过 Arc 共享。 上报和核对总数(NodeTraffic)。
Gate src/meter.rs 回答“这条流现在能否传输字节”:用户未退役,且令牌桶没有欠额。传输完成后再计费。 判定谁已退役(Admission)。
Metered<S> src/meter.rs 包装一条出站字节流:写入算上行,读取算下行,每次都先过闸门、后计费。 实际搬运字节(内层流)和调度(运行时)。
KatanaConnector::connect src/connector.rs 用已准入用户的计数器和租约为每条流构建一个 Gate,并用它包装流或 UDP fan-out。 路由和审计判定。

katana 没有自己的中继循环。Etemenanki 运行时在一个 task 中搬运一条连接的全部字节,katana 能控制的是运行时通过 connector 打开的每个出站。因此出站是 katana 唯一能看到字节经过的地方,计量也就放在这里。

src/traffic.rs
pub struct TokenBucket {
rate: u64,
state: Mutex<BucketState>,
}
struct BucketState {
tokens: f64,
last: Instant,
}
impl TokenBucket {
pub fn new(rate: u64) -> Self;
pub fn charge(&self, n: usize) -> Option<Instant>;
pub fn ready_at(&self) -> Option<Instant>;
#[cfg(test)]
pub async fn consume(&self, n: usize);
fn refill(&self, st: &mut BucketState) -> Instant;
fn repaid_at(&self, st: &BucketState, now: Instant) -> Option<Instant>;
}

Mutex 是 parking_lot::Mutex,Instant 是 tokio::time::Instant,因此测试可以在暂停的时间上运行。

项 含义
rate 每秒字节数。0 表示不限速:charge 和 ready_at 立即返回 None,不获取锁。构造时确定,之后不变。
tokens 以字节计的余额,类型为 f64。令牌桶处于欠额时为负数。初始值为 rate,所以新建的令牌桶是满的。
last 上次把余额更新到当前的时间。
refill 按自 last 以来的 elapsed × rate 补充余额,并把余额截断到不超过 rate。这个上限就是突发量:无论令牌桶闲置多久,最多攒下一秒的速率。
charge(n) 先补充,再完整扣除 n,然后返回 repaid_at。即使 n 超过余额也照样扣除。
ready_at() 先补充,然后返回 repaid_at,不扣费。
repaid_at tokens >= 0 时为 None;否则为 now + (-tokens / rate) 秒,即补充使余额回到零的时刻。
consume(n) 仅用于测试:先 charge(n),再 sleep_until 返回的时刻。

每个读取余额的方法都会先在同一把锁下调用 refill,所以比较或修改余额时,余额总是最新的。

src/traffic.rs
pub struct UserCounter {
pub uid: i64,
up: AtomicU64,
down: AtomicU64,
pub rate: u64,
pub bucket: TokenBucket,
}
impl UserCounter {
pub(crate) fn new(uid: i64, rate: u64) -> Self;
pub fn add_up(&self, n: u64);
pub fn add_down(&self, n: u64);
pub fn up(&self) -> u64;
pub fn down(&self) -> u64;
pub fn commit_reported(&self, up: u64, down: u64);
}

add_up 和 add_down 是使用 Ordering::Relaxed 的 fetch_add。计数器的 rate 是用户以每秒字节数表示的实际速率。build_user_entries(src/manager/mod.rs)为它注册的每个用户计算这个值(UUID 无法解析的用户会被跳过),使用的是

src/traffic.rs
pub fn determine_rate(node_bps: u64, user_bps: u64) -> u64;

它返回两个非零限制中较小的一个;两者都为 0 时返回 0(不限速)。

src/meter.rs
pub struct Gate {
counter: Arc<UserCounter>,
wait: Option<Pin<Box<Sleep>>>,
retired: Pin<Box<WaitForCancellationFutureOwned>>,
}
impl Gate {
pub fn new(counter: Arc<UserCounter>, retired: CancellationToken) -> Self;
pub fn poll_open(&mut self, cx: &mut Context<'_>) -> Poll<io::Result<()>>;
pub fn sent(&self, n: usize);
pub fn received(&self, n: usize);
}
字段或方法 含义
counter 用户的计数器,与该用户的其他所有流共享。
wait 令牌桶处于欠额时,这个闸门等待的那一个 tokio::time::Sleep。流的两个方向共用它。
retired 用户租约上的 CancellationToken::cancelled_owned(),在 new 中一次性装箱并 pin 住。
poll_open(cx) 流可以传输字节时返回 Ready(Ok(()));用户退役后返回 Ready(Err(_));令牌桶处于欠额时返回 Pending,并把 cx 同时注册到退役和计时器上。无论返回什么,包括 Ready(Ok(())),cx 都会保持注册在退役上。
sent(n) 先 counter.add_up(n),再 counter.bucket.charge(n)。
received(n) 先 counter.add_down(n),再 counter.bucket.charge(n)。

由于 poll_open 即使放行传输也会 poll 退役 future,一条随后在内层流上等待的流(例如等待一个不发数据的目标的读取)在用户退役时仍会被唤醒。下一次 poll 返回 ConnectionAborted 错误。

sent 和 received 丢弃 charge 返回的时刻。一次传输留下的欠额,由接下来调用 poll_open 的一方偿还,可能是这条流,也可能是同一用户的其他任何流。

Gate 不实现 Clone,也不持有锁。它的两个被 pin 的 future 都装了箱,因此 Gate,以及对任意 S: Unpin 的 Metered<S>,都保持 Unpin;Metered 调用 self.get_mut() 时依赖这一点。

src/meter.rs
pub struct Metered<S> {
inner: S,
gate: Gate,
}
impl<S> Metered<S> {
pub fn new(inner: S, gate: Gate) -> Self;
}
impl<S: AsyncRead + Unpin> AsyncRead for Metered<S> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>>;
}
impl<S: AsyncWrite + Unpin> AsyncWrite for Metered<S> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
data: &[u8],
) -> Poll<io::Result<usize>>;
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>>;
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>>;
}

connector 把它实例化为 Metered<OutboundStream>,即 impl Connector<Flow<UserTag>> for KatanaConnector 的关联类型 Stream。OutboundStream(src/outbound/proxy.rs)可以是普通 TCP 流、装箱的代理客户端流,或 WireGuard 流。

方法 是否过闸门 计费
poll_read 是,在内层读取之前 对内层读取加入 buf 的 n 字节调用 received(n),仅当 n > 0
poll_write 是,在内层写入之前 对内层写入接受的 n 字节调用 sent(n),仅当 n > 0
poll_flush 否 否
poll_shutdown 否 否

Metered 只实现这四个方法。tokio 默认的 poll_write_vectored 会把第一个非空切片转给 poll_write,所以 vectored 写入和其他写入一样会过闸门并计费。

运行时解码入站协议,然后把载荷转发给出站,再把出站返回的载荷转回客户端。Metered 包装的是出站,所以它看到的字节已经被入站协议去掉了分帧和加密,而出站协议还没有加上自己的那一层。

flowchart LR
  client["客户端"] --> transport["入站传输层:TCP、TLS、WS、gRPC"]
  transport --> core["协议核心:VMess、VLESS、Trojan、SS"]
  core --> runtime["ProxyServerRuntime"]
  runtime --> metered["Metered:Gate、UserCounter"]
  metered --> outbound["OutboundStream:TCP、代理客户端、WireGuard"]
  outbound --> target["目标"]

选择这个位置有三个原因:

  • 这里的载荷是明文。 用户按自己要求传输的内容计费,而不是按 TLS 记录、WebSocket 帧、gRPC 分帧或 VMess chunk 头计费。流量相同的两个用户,无论通过哪种入站连接,费用都一样。
  • 这是唯一的挂钩点。 入站一侧归运行时所有;katana 只提供 connector 及其返回值。
  • 每条流都经过这里。 每个 TCP 请求、每个 mux 子流、每个 Hysteria 流和每个 UDP 关联都会到达 KatanaConnector::connect,闸门就在那里构建。

图中展示的是流式监听器(src/serve.rs 中的 serve_stream)。Hysteria 2 监听器走的是另一条入站路径:Etemenanki 的 Hysteria 入站为每个代理流运行一个运行时,为每个连接的 UDP 再运行一个,它们各自通过 run_hysteria 构建的 KatanaConnector 打开出站。出站一侧,以及计量,都是一样的。

Metered 对交给它的明文计费。如果出站本身是代理客户端(VMess、VLESS、Shadowsocks 等),客户端自己的分帧是在 inner 内部加上的,不计费。

闸门在 connector 中构建,这是每个入站的每条流都会经过的唯一位置:

src/connector.rs
impl Connector<Flow<UserTag>> for KatanaConnector {
type Stream = Metered<OutboundStream>;
type Datagram = FanOut;
type Future = ConnectFuture;
fn connect(&mut self, flow: Flow<UserTag>) -> ConnectFuture;
}
impl Admission {
pub fn admit(&self, tag: &UserTag) -> Option<(Arc<UserCounter>, CancellationToken)>;
}

KatanaConnector::connect 用 Admission::admit 准入流所属的用户,得到用户的 Arc<UserCounter> 和一个 CancellationToken 租约,然后为每条流构建一个闸门:

src/connector.rs (excerpt)
let gate = Gate::new(counter, lease);
  • UDP 流得到 FanOut::new(self.disp.clone(), gate, tag.uid, source)。
  • TCP 流先经过路由和审计。被路由到 Outbound::Block 或被审计规则禁止的流,在拨号之前就被拒绝,因此永远不会计费。其余情况下,拨出的流会被包装:Metered::new(dial.await?, gate)。

每条流有自己的 Gate 和自己的 wait 计时器。同一用户的所有闸门持有同一个 Arc<UserCounter>,因为 NodeTraffic::lookup 返回的是为该凭据和 uid 注册的唯一计数器。正是这一点让令牌桶按用户而不是按流划分。这个注册表属于单个节点(每个 NodeManager 拥有自己的 NodeTraffic),所以同一个 katana 进程中由两个节点服务的用户,在每个节点上各有一个令牌桶。

运行时用 poll_write 把一段转发数据写入出站,写入接受了字节时,在同一步中调用 poll_flush。每个出站都用自己的 KeyWaker(concepts/src/wake.rs)来 poll,所以闸门计时器触发的唤醒只会重新调度对应的那个出站。

sequenceDiagram
  participant R as 运行时 task
  participant M as Metered
  participant G as Gate
  participant C as UserCounter
  participant B as TokenBucket
  participant S as 内层流
  R->>M: poll_write(cx, data)
  M->>G: poll_open(cx)
  G->>G: poll retired(注册 cx)
  G->>B: ready_at()
  B-->>G: Some(at),令牌桶处于欠额
  G->>G: wait = sleep_until(at),poll 它
  G-->>M: Pending
  M-->>R: Pending,没有传输任何字节
  Note over R,G: 计时器触发,唤醒该出站的 KeyWaker
  R->>M: poll_write(cx, data)
  M->>G: poll_open(cx)
  G->>G: 再次 poll retired
  G->>G: 计时器已触发,清空 wait
  G->>B: ready_at()
  B-->>G: None,已无欠额
  G-->>M: Ready(Ok)
  M->>S: poll_write(cx, data)
  S-->>M: Ready(Ok(n))
  M->>G: sent(n)
  G->>C: add_up(n)
  G->>B: charge(n),忽略返回值
  M-->>R: Ready(Ok(n))
  R->>M: poll_flush(cx),不过闸门

读取与此对称:先 poll_open,再内层 poll_read,然后对落入 ReadBuf 的字节调用 received(n)。返回流结束(n == 0)的读取不计费。

poll_open 每次调用都先检查退役,然后围绕令牌桶循环:

stateDiagram-v2
  [*] --> CheckRetired
  CheckRetired --> Aborted: 租约已取消
  CheckRetired --> AskBucket: 未取消
  AskBucket --> Open: ready_at 为 None
  AskBucket --> Waiting: ready_at 为 Some(at)
  Waiting --> Parked: 计时器未到期
  Waiting --> AskBucket: 计时器已触发,清空 wait
  Parked --> CheckRetired: 被计时器或取消唤醒
  Open --> [*]
  Aborted --> [*]
  • Aborted 返回 Err(io::Error::new(io::ErrorKind::ConnectionAborted, "the user was retired"))。
  • Parked 即 Pending,cx 同时注册在取消 future 和 Sleep 上,哪个先发生就由哪个唤醒这条流。
  • 计时器触发后,闸门会再次询问令牌桶,而不是假定欠额已经还清。同一用户的另一条流可能在此期间又扣出了更多欠额;如果是这样,闸门会为新的时刻设置新的计时器。

FanOut(见 Connector 与 UDP fan-out)使用同一个 Gate,顺序也相同:先过闸门,再传输,最后计费。

步骤 poll_send_to poll_recv_from
过闸门之前 数据包先经过路由和审计。被拦截或被禁止的数据包返回 Ready(Ok(buf.len())),直接丢弃且不计费。 无。
过闸门 self.gate.poll_open(cx) self.gate.poll_open(cx)
传输 由所选出站对应的子链路发送数据包;子链路仍在打开时返回 Pending。如果打开子链路失败,数据包被丢弃、报告为已发送,且不计费。 从 next 开始轮询各个子链路。
计费 在 Ready(Ok(n)) 时调用 self.gate.sent(n) 对落入缓冲区的载荷字节调用 self.gate.received(len)

因此,一个用户的 TCP 流、mux 子流和 UDP 数据包都从同一个令牌桶取用,两个方向都是如此。

不变量 机制 由谁固定
每个用户一个令牌桶,由其所有流、两个方向、TCP 和 UDP 共享。 用户的每个 Gate 都持有来自 NodeTraffic::lookup 的 Arc<UserCounter>;sent 和 received 都调用 counter.bucket.charge;FanOut 和 Metered 都通过 Gate 计费。 the_limit_is_shared_by_both_directions(tests/unit/meter.rs)、debt_holds_back_the_next_charge_too(tests/unit/traffic.rs)、udp_is_billed_after_routing_and_blocked_packets_are_free(tests/unit/connector.rs)
写入计上行,读取计下行,计入的恰好是实际传输的字节。 poll_write 按内层写入返回的 n 计费;poll_read 按 buf.filled() 的增量计费。 each_direction_is_billed_to_the_user(tests/unit/meter.rs)、an_admitted_stream_is_billed_to_its_user(tests/unit/connector.rs)
长期速率与 chunk 大小无关。 charge 扣除整次传输,即使越过零;tokens < 0 时,poll_open 拒绝开始任何传输。 the_limit_holds_however_the_writes_are_sized(tests/unit/meter.rs)、a_chunk_larger_than_the_burst_is_still_limited(tests/unit/traffic.rs)
闲置用户最多攒下一秒的速率。 refill 把 tokens 截断到 rate。 an_idle_bucket_banks_one_second_and_no_more(tests/unit/traffic.rs)
不限速的用户从不被延迟。 rate == 0 让 charge 和 ready_at 直接返回 None。 token_bucket_unlimited_is_instant(tests/unit/traffic.rs)
字节传输之后不会再返回 Pending。 闸门在内层调用之前检查;内层调用返回 Ready 之后,Metered 只计费并返回 Ready。读取不会丢失已放入 buf 的数据,写入也不会对内层流已接受的字节报告“什么都没写”。 由构造保证;each_direction_is_billed_to_the_user(tests/unit/meter.rs)和 an_admitted_stream_is_billed_to_its_user(tests/unit/connector.rs)检查字节完整到达且只计费一次。
闸门返回 Pending 意味着没有传输任何字节。 ready!(this.gate.poll_open(cx))? 在触及内层流之前就返回。 由构造保证。
即使流正在等待,退役也会结束它。 poll_open 最先 poll retired,把 cx 注册到取消上;由于 owned 取消 future 会先检查 is_cancelled,之后每次 poll 都能观察到退役。 retiring_the_user_wakes_a_parked_read、a_retired_users_stream_refuses_to_move(tests/unit/meter.rs)
被拦截和被禁止的流量不计费。 TCP:connect 在构建 Metered 之前就拒绝。UDP:poll_send_to 在过闸门之前丢弃。 udp_is_billed_after_routing_and_blocked_packets_are_free、a_forbidden_udp_destination_is_dropped_and_recorded(tests/unit/connector.rs)
已退役用户不会因无法打开的流而被计费。 对于已离开或被重新绑定的凭据,Admission::admit 返回 None,connect 在闸门存在之前就返回拒绝。 a_user_the_registry_does_not_know_is_refused、a_credential_rebound_to_another_uid_is_refused(tests/unit/connector.rs)

为什么欠额让速率与 chunk 大小无关

Section titled “为什么欠额让速率与 chunk 大小无关”

运行时不按固定大小读写。它从出站读入 scratch[..max],其中 max = (staging.room() - Core::STAGING_RESERVE).min(BUF_SIZE);写入的则是协议核心产生的任意一段转发数据。BUF_SIZE 因协议核心而异。如果限速器的行为取决于这些大小,用户在不同协议上就会得到不同的速度。

考虑另一种做法:“等令牌桶攒够 n 个令牌,再传输 n 字节”。由于突发上限是一秒,大于 rate 的传输永远攒不够令牌,因此这种限速器最多等一个补充周期就必须放行,结果按大 chunk 传输的流会跑得比 rate 快。

TokenBucket 通过允许余额变为负数来避免这个问题:

  1. 只要 tokens >= 0,传输就可以开始。
  2. 传输完成后,完整扣除其大小,即使越过零。
  3. 在补充以每秒 rate 字节的速度把 tokens 拉回零之前,该用户的任何传输都不会再开始。

因此每个字节都按 rate 付费,无论它是一次传完还是分多次传完。唯一的余量是突发量(最多攒下 rate 字节),加上余额越过零时已经通过闸门的那些传输;它们的费用由之后的传输偿还。

以 the_limit_holds_however_the_writes_are_sized 为例,rate = 10_000,令牌桶初始为满:

步骤 之前余额 传输 之后余额 下一次传输可开始
写入 30,000 字节 10,000 立即传输 −20,000 2 秒后
写入 1 字节 0(2 秒后) 传输 −1 0.0001 秒后

为什么两个方向共用一个计时器

Section titled “为什么两个方向共用一个计时器”

Gate 只保留一个 wait,因为同一个 Metered 的两个方向由同一个运行时 task、用同一个 per-outbound waker(concepts/src/runtime.rs 中的 slot.waker)来 poll。一个 Sleep 只保存一个 waker;由于读和写注册的是同一个,任何一个方向都不会失去唤醒。计时器触发时,KeyWaker 把该出站加入队列并唤醒运行时 task。在这次 poll 中,运行时会重试仍排在其 effect 队列最前面的写入,并因为该出站的 key 已入队而读取它,所以两个方向看到的是同一个令牌桶状态。

情形 Metered 或 FanOut 返回什么 接下来会发生什么
用户的租约被取消(用户离开了用户列表、其凭据转到另一个 uid,或因监听器即将关闭而执行了 Admission::retire_all) 之后的每次读、写、发送或接收都返回 Err,其 io::ErrorKind 为 ConnectionAborted,消息为 the user was retired 运行时让该出站失败。在流式监听器上,连接本身也会通过租约槽结束;见准入与用户表。Hysteria 2 的 connector 没有租约槽,所以那里的流只能靠其出站拒绝传输而结束。
内层流出错 原样返回内层错误 这次调用不计费。
内层读取返回流结束 Ready(Ok(())),没有新增数据 不计费。
令牌桶处于欠额 Pending 计时器触发或租约被取消时(以先发生者为准),流恢复。
处于欠额或已退役时调用 poll_flush 或 poll_shutdown 转发给内层流 冲刷已接受的字节或关闭流,从不等待令牌桶,退役后也不会被拒绝。

丢弃 Metered 会丢弃它的 Gate,进而丢弃挂起的 Sleep 和取消 future。二者都不持有 task 或锁,所以取消无需清理。令牌桶的 Mutex 只在 charge 和 ready_at 内部的算术运算期间持有,从不跨越 .await 或 poll。

新速率通过一次面板轮询到达节点:可以是常规轮询,也可以是配置修改改动了 [node.api](例如 speed_limit 覆盖值)时,NodeManager::apply_static(src/manager/node.rs)立即发起的那次轮询。面板客户端自己保存了一份该覆盖值,因此这类修改会在轮询前先构建一个新的 PanelClient。新客户端不持有任何 ETag,所以面板对这次轮询返回完整的用户列表,而不是 304 Not Modified,每个变化了的速率都会出现在其中。

速率变化不等于退役。NodeTraffic::prepare 会给用户一个新的 UserCounter,带有一个初始为满的新令牌桶,因为令牌桶的 rate 在构造时就已确定。已有的闸门继续持有指向旧计数器的 Arc,因此:

  • 变化之前打开的流继续计入旧计数器,并保持旧速率,直到结束;
  • 变化之后打开的流使用新计数器和新速率;
  • 有一段时间,用户同时从两个令牌桶取用。

旧计数器会逐渐排空,并且仍会上报;见流量计费。

名称 值 位置 含义
突发量 rate 字节(一秒) TokenBucket::refill 令牌桶最多能攒下的量。新令牌桶的初始值正好是这个。
不限速 rate == 0 TokenBucket::charge、ready_at 不限速;计数器照常计数。
MBPS_TO_BPS 1_000_000.0 / 8.0 src/api/mod.rs 面板以 Mbps 给出限制;mbps_to_bps 乘以这个值,所以 1 Mbps 对应的 rate 是每秒 125,000 字节。0 或更小的值变为 0,即不限速。
MAX_SUBS 64 src/connector.rs 一个 UDP 关联保留的子链路数;它们共用该关联唯一的 Gate。

令牌桶以每秒字节数为单位。面板客户端先用 mbps_to_bps 转换每个限制,再由 determine_rate 合并节点和用户的限制。哪个面板字段对应哪个限制,以及节点级覆盖,见限速与连接限制。

tests/unit/meter.rs,在 tokio::io::duplex 对上运行:

测试 固定的行为
each_direction_is_billed_to_the_user 5 字节的写入计 up = 5,7 字节的读取计 down = 7。
the_limit_holds_however_the_writes_are_sized 在暂停的时间上、速率为 10,000 B/s 时,一次 30,000 字节的写入后跟一次 1 字节的写入,至少耗时 2 秒:超大的写入留下了 20,000 字节的欠额。
the_limit_is_shared_by_both_directions 速率为 10,000 B/s 时,20,000 字节的上传让接下来 5 字节的读取至少延迟 1 秒。
a_retired_users_stream_refuses_to_move 租约取消后的写入以 ConnectionAborted 失败。
retiring_the_user_wakes_a_parked_read 在不发数据的对端上等待的读取,在 1 秒内被取消唤醒,并以 ConnectionAborted 失败。

tests/unit/traffic.rs,只测试令牌桶本身:

测试 固定的行为
token_bucket_unlimited_is_instant rate = 0 从不等待,即使是 1,000,000 字节。
token_bucket_rate_limits 突发量耗尽后,速率为 10,000 B/s 时,接下来的 10,000 字节等待约 1 秒。
a_chunk_larger_than_the_burst_is_still_limited 在暂停的时间上、速率为 10,000 B/s 时,consume(30_000) 恰好耗时 2 秒。
debt_holds_back_the_next_charge_too charge(25_000) 返回 1.5 秒之后的时刻,接下来 1 字节的扣费要等到那时。
an_idle_bucket_banks_one_second_and_no_more 闲置 60 秒后,charge(20_000) 仍会留下 1 秒的欠额。
determine_rate_min_nonzero 实际速率取非零限制中较小的一个。

tests/unit/connector.rs,通过 KatanaConnector::connect 对本地 echo 服务器测试:

测试 固定的行为
an_admitted_stream_is_billed_to_its_user 一次 5 字节的 TCP 往返向已注册的计数器计入 (5, 5)。
udp_is_billed_after_routing_and_blocked_packets_are_free 一次 UDP echo 计入 (4, 4);发往被拦截地址的数据包被接受但不计费。
a_forbidden_udp_destination_is_dropped_and_recorded 被审计规则禁止的数据包不计费,并记录为一次命中。

测量限速的测试运行在 #[tokio::test(start_paused = true)] 上,所以它们断言的时长是精确的,不受机器负载影响。tests/unit/traffic.rs 中有两个测试改用真实时间:token_bucket_rate_limits 断言下限 0.8 秒,token_bucket_unlimited_is_instant 断言上限 50 毫秒。retiring_the_user_wakes_a_parked_read 也运行在真实时间上,因为它只断言唤醒在 1 秒超时之内到达。为令牌桶或闸门新增计时测试时,请使用暂停的时间。