Skip to content

Traffic accounting

Source files: 19 · checked against katana v3.0.1
  • katana/src/traffic.rs
  • katana/src/manager/node.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/proxy.rs
  • katana/src/manager/transport.rs
  • katana/src/runtime.rs
  • katana/src/connector.rs
  • katana/src/meter.rs
  • katana/src/serve.rs
  • katana/src/api/mod.rs
  • katana/src/api/newv2board.rs
  • katana/src/api/sspanel.rs
  • katana/src/config.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/connector.rs
  • katana/tests/unit/meter.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/unit/api/newv2board.rs
  • katana/tests/unit/runtime.rs

Every node in katana owns one NodeTraffic registry. It maps each authorized credential to a UserCounter, which holds the user’s upload and download totals and the token bucket that enforces the user’s speed limit. Flows write into those counters; once per poll cycle the node manager snapshots them, sends the totals to the panel, and, when the panel accepts the report, subtracts exactly what the report carried.

This page covers src/traffic.rs in depth and the reporting code around it in src/manager/node.rs and the two panel clients. It is for contributors who change how bytes are counted, how user-set changes move counters around, or how a report is built and reconciled. The operator’s view of the same behaviour is on Traffic reporting.

src/traffic.rs owns:

  • The counters. One UserCounter per credential and uid, with two AtomicU64 totals and one TokenBucket.
  • The registry. Which credential bills to which counter right now, and which counter a new flow gets (NodeTraffic::lookup).
  • User-set transitions. A two-phase prepare and commit that keeps unchanged users’ counters, gives new and rate-changed users fresh ones, and parks every outgoing counter so its bytes are still reported.
  • Report bookkeeping. snapshot reads every counter that still has unreported bytes, commit_reported subtracts what a successful report carried, and restore_residuals hands back the counter-less rows of a failed one.
  • The rate function. determine_rate combines the node and user limits into one bucket rate.

It deliberately leaves out:

  • Moving bytes and waiting on the bucket. src/meter.rs does that (see Metering).
  • Deciding who may open a flow and cancelling departed users’ connections. Admission in src/connector.rs does that on top of lookup and PreparedUsers::cancel_keys (see Admission).
  • Talking to the panel. NodeManager::report_traffic in src/manager/node.rs drives the report through PanelClient (see Panel clients).

Nothing in NodeTraffic is persisted. The registry lives as long as its NodeManager, which creates it in NodeManager::new and keeps it across listener rebuilds and across every swap of the node’s PanelClient.

A byte bucket that refills at rate bytes per second and holds at most one second of rate. A rate of 0 means unlimited: every method returns None before it touches the lock.

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>;
fn refill(&self, st: &mut BucketState) -> Instant;
fn repaid_at(&self, st: &BucketState, now: Instant) -> Option<Instant>;
}

Mutex is parking_lot::Mutex and Instant is tokio::time::Instant, so tests can drive the bucket with paused time.

Method What it does
new Starts full: tokens = rate, last = Instant::now().
refill Credits elapsed * rate tokens since last, capped at rate (the one-second burst), and moves last to now.
charge Refills, then subtracts n in full, even if that drives tokens negative. Returns the instant the debt is paid back, or None while the balance is non-negative.
ready_at Refills, then answers the same question without charging anything.
repaid_at now + (-tokens / rate) seconds when tokens < 0.0, else None.

The balance is allowed to go negative. That debt is what makes the limit a property of bytes rather than of chunk sizes: a 30 000-byte write against a 10 000 B/s bucket costs 10 000 bytes of burst plus two seconds of debt, and the next transfer from any of the user’s flows waits out those two seconds. A bucket that waited for “enough tokens” before each piece would have to let an oversized piece through after at most one refill period.

The bucket never blocks. charge and ready_at only report a deadline. In src/meter.rs, Gate::poll_open waits until ready_at returns None before each transfer, and Gate::sent and Gate::received call charge after it. Both directions of every flow a user has share the one bucket in that user’s counter.

consume(&self, n: usize) (charge, then sleep until the returned instant) exists only under #[cfg(test)].

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 and add_down are fetch_add with Ordering::Relaxed. Gate::sent calls add_up for bytes the user sent, Gate::received calls add_down for bytes the user received.
  • up and down are relaxed loads. They are two independent reads, not a consistent pair; nothing depends on them being one, because each is later committed on its own.
  • commit_reported is fetch_sub of exactly the amounts passed in, one per direction. It never stores zero.
  • uid and rate are immutable. A rate change cannot be applied to an existing counter because the bucket’s rate is fixed at construction; it replaces the counter instead (see prepare and commit).

A flow holds its counter as an Arc<UserCounter> inside its Gate for as long as the flow lives. That strong reference is what the registry later uses to tell whether a retired counter can still receive bytes.

pub enum AuthKey {
Uuid(Uuid),
Name(CompactString),
}
pub struct UserTag {
pub key: AuthKey,
pub uid: i64,
}
pub struct UserEntry {
pub key: AuthKey,
pub uid: i64,
pub rate: u64,
}
  • AuthKey is the registry key. The kernel’s UserAuthorization derives Eq but not Hash, so katana uses its own hashable key. user_key in src/manager/mod.rs picks it from the node type: a V2ray (VMess or VLESS) user is keyed by AuthKey::Uuid, and a Trojan, Shadowsocks or Hysteria 2 user by AuthKey::Name of their email label (traffic_email, which falls back to the uid as a string when the email is empty).
  • UserTag is what the inbound’s user tables carry for each credential, instead of a counter. A flow resolves its tag to a counter when it opens. A user table can outlive the refresh that replaced it (a long-lived connection keeps the table it authenticated against), so a counter baked into the table would keep billing a departed user’s new flows to a counter nobody reports.
  • UserTag::unattributed() is key = AuthKey::Name(""), uid = -1: the placeholder for the single-password slot a Shadowsocks table requires and katana never serves from. It is meant to match no registered user, so it resolves to no counter.
  • UserEntry is one row of the next user set. build_user_entries in src/manager/mod.rs builds them from the panel’s UserInfo list, with rate: determine_rate(node.speed_limit, u.speed_limit). On a V2ray node, a user whose uuid does not parse has no key and is skipped with the warning skipping user {uid}: uuid is not a valid UUID, which names the uid and never the credential. Email-keyed node types always produce a key.
pub fn determine_rate(node_bps: u64, user_bps: u64) -> u64;

The smaller of the two non-zero limits, where 0 means unlimited on that side:

node_bps user_bps Result
0 0 0 (unlimited)
0 u u
n 0 n
n u min(n, u)

Both inputs are bytes per second. The panel clients convert Mbps with mbps_to_bps in src/api/mod.rs, which multiplies by MBPS_TO_BPS = 1_000_000.0 / 8.0 and returns 0 for any value at or below zero. How each panel fills the two limits is on Speed limits.

#[derive(Default)]
pub struct NodeTraffic {
users: RwLock<HashMap<AuthKey, Arc<UserCounter>>>,
residuals: Mutex<HashMap<i64, (u64, u64)>>,
draining: Mutex<Vec<Arc<UserCounter>>>,
}

A counter with unreported bytes lives in exactly one of three places:

Field Holds Who can still write to it
users The current registry: one counter per credential. New flows get their counter from here. Every live flow of that credential, plus any new flow.
draining Counters that left the registry (rate change, departure, rebound) and may still have writers: connections opened before the change that are still winding down. Only flows that already hold the Arc. No new flow can reach them.
residuals Plain (up, down) totals per uid that a failed report handed back. No counter, only numbers. Nobody.

The methods, grouped by who calls them:

impl NodeTraffic {
pub fn new() -> Self;
// user-set changes
pub fn prepare(&self, entries: Vec<UserEntry>) -> PreparedUsers;
pub fn commit(&self, prepared: PreparedUsers);
// flows
pub fn lookup(&self, key: &AuthKey, uid: i64) -> Option<Arc<UserCounter>>;
// reporting
pub fn snapshot(&self) -> Vec<TrafficSnapshot>;
pub fn restore_residuals(&self, rows: Vec<(i64, u64, u64)>);
pub fn clear_residuals(&self);
pub fn prune_draining(&self);
fn add_residuals(&self, rows: Vec<(i64, u64, u64)>);
}

lookup returns the registered counter only if it belongs to the same uid as the tag: self.users.read().get(key).filter(|c| c.uid == uid).cloned(). A departed credential finds nothing, and so does a credential that now belongs to another uid. Admission::admit turns that None into a refused flow.

set_users (commit(prepare(entries)) in one call) and get (lookup by UserAuthorization regardless of uid) exist only under #[cfg(test)].

pub struct PreparedUsers {
next: HashMap<AuthKey, Arc<UserCounter>>,
carried: Vec<(Arc<UserCounter>, Arc<UserCounter>)>,
orphaned: Vec<Arc<UserCounter>>,
cancel_keys: HashSet<AuthKey>,
}
impl PreparedUsers {
pub fn cancel_keys(&self) -> &HashSet<AuthKey>;
}
Field Contents
next The complete next registry.
carried (fresh, old) pairs where a rate change forced a new counter for the same uid. The old one drains; its connections are kept.
orphaned Counters whose credential left the set or now belongs to another uid. They drain too.
cancel_keys The keys whose live connections must be cancelled: exactly the keys of orphaned. Rate-changed keys are not in it.
pub struct TrafficSnapshot {
pub uid: i64,
pub up: u64,
pub down: u64,
pub counter: Option<Arc<UserCounter>>,
}

counter decides how the row is reconciled after the report:

  • Some(counter): the bytes still sit in that counter. A successful report subtracts them from it; a failed one leaves them there.
  • None: the bytes have already been taken out of NodeTraffic (a drained residual, or a draining counter’s final row). A successful report lets them go; a failed one must hand them back with restore_residuals.
pub struct UserTraffic {
pub uid: i64,
pub upload: i64,
pub download: i64,
}
impl PanelClient {
pub async fn report_user_traffic(&self, t: &[UserTraffic]) -> Result<()>;
}

PanelClient dispatches to the client for the configured panel. Result is anyhow::Result.

flowchart LR
  P["user set from the panel"] --> PR["prepare"]
  PR --> CM["commit"]
  CM --> U["users (registry)"]
  CM --> D["draining"]
  F["new flow"] -->|lookup| U
  G["Gate of a live flow"] -->|"add_up / add_down"| C["Arc UserCounter"]
  U -. holds .-> C
  D -. holds .-> C
  S["snapshot"] --> U
  S --> D
  S --> R["residuals"]
  X["failed report"] -->|restore_residuals| R

A user-set change is two calls. prepare builds the next registry without mutating anything and without reading any byte total; commit publishes it. Between the two, the caller builds the next user tables, and a build error can abandon the PreparedUsers with nothing changed.

prepare holds users.read() for its whole run and decides, for each UserEntry:

flowchart TB
  E["UserEntry key, uid, rate"] --> Q{"key in registry?"}
  Q -->|no| N["fresh counter"]
  Q -->|yes| S{"same uid and same rate?"}
  S -->|yes| K["keep the existing Arc"]
  S -->|no| UID{"same uid?"}
  UID -->|"yes: rate change"| CA["fresh counter, old one to carried"]
  UID -->|"no: rebound"| OR["fresh counter, old one to orphaned, key to cancel_keys"]

Afterwards, every registry key that is not in next is a user who left: its counter goes to orphaned and its key to cancel_keys.

Change Counter Old counter Live connections
Unchanged key, uid and rate The same Arc None Kept, metering uninterrupted
New key Fresh None None yet
Same key and uid, new rate Fresh, at the new rate carried, drains Kept, still billing and limited by the old counter
Same key, different uid (rebound) Fresh, for the new uid orphaned, drains, still reported under the old uid Cancelled
Key no longer listed None orphaned, drains Cancelled

A rate change therefore takes effect for new flows only. Connections that were open before it keep the old counter, and with it the old bucket, until they close. Both counters report under the same uid, and report_traffic merges them into one row.

pub fn commit(&self, prepared: PreparedUsers) {
let PreparedUsers {
next,
carried,
orphaned,
cancel_keys: _,
} = prepared;
let mut users = self.users.write();
let mut draining = self.draining.lock();
draining.extend(carried.into_iter().map(|(_fresh, old)| old));
draining.extend(orphaned);
*users = next;
}

commit takes both locks together, in the same order snapshot takes them, and moves every outgoing counter into draining in the same critical section that swaps the registry. A snapshot therefore sees each outgoing counter in exactly one place. If a counter could be seen both still registered and already draining, its bytes would be reported twice and then subtracted twice.

Neither phase reads a byte total, so neither waits for the old generation’s connections to stop. Whatever they write after the commit lands in a draining counter that snapshot keeps reading.

Every caller runs on the node’s own task (NodeManager::run), so user-set changes and reports are serialized.

Caller When Order
ProxyManager::refresh (src/manager/proxy.rs) The user set or the node speed limit changed, and the listener is kept. prepare, build the replacement tables (an error returns and changes nothing), Admission::commit, then swap the tables.
TransportManager::start (src/manager/transport.rs) A listener is built: bootstrap and every rebuild. prepare, build and bind everything that can fail, then traffic.commit. No one is serving yet, so no one needs cancelling.
NodeManager::reconcile and NodeManager::bring_up (src/manager/node.rs) The panel returned an empty user list. commit(prepare(Vec::new())): every counter drains.

Admission::commit holds the admission leases lock, calls NodeTraffic::commit, and only then cancels the lease of every key in cancel_keys. Because Admission::admit calls lookup under the same leases lock, a flow is either admitted before the commit (and then cancelled by it) or checked against the registry the commit published. The registry is published before the new tables, so a newly added user can never authenticate against the new table and then be refused by a registry that has not heard of them. Details are on Admission.

stateDiagram-v2
  [*] --> Registered: prepare creates it, commit publishes it
  Registered --> Registered: report succeeds, commit_reported
  Registered --> Draining: rate change, departure or rebound
  Draining --> Draining: snapshot while a writer holds it
  Draining --> FinalRow: snapshot sees strong_count 1
  FinalRow --> [*]: report succeeds
  FinalRow --> Residual: report fails, restore_residuals
  Residual --> Reported: next snapshot drains the map
  Reported --> [*]: report succeeds
  Reported --> Residual: report fails

A registered or draining counter is never reset: a report only ever subtracts what it carried. Once the last flow drops its Arc, only the draining vector holds the counter, nothing can reach it to write, and its next snapshot row is final. After that the bytes exist only as numbers, in a snapshot row or in residuals, until a report succeeds.

snapshot produces one row per counter that may have unreported bytes, plus one row per parked residual.

  1. Take users.read(), then draining.lock(), and hold both.
  2. For every registered counter, push a row with its current up, down and counter: Some(clone).
  3. For every draining counter:
    • Read Arc::strong_count before the bytes. If it is 1, only the vector holds the counter, so no writer is left, and the count can never rise again.
    • If it is 1, issue std::sync::atomic::fence(Ordering::Acquire). The fence pairs with the release decrement in the last writer’s Arc drop, so every byte that writer added is visible to the loads that follow.
    • Read uid, up and down.
    • If it was alone, push the row with counter: None and swap_remove the counter from draining. That row is final.
    • Otherwise push the row with counter: Some(clone) and keep the counter.
  4. Release both locks.
  5. Take residuals.lock() and drain() the whole map into rows with counter: None.

The result is not merged: a uid can appear more than once, for example a live counter, a draining one after a rate change, and a residual from an earlier failed report. Rows with zero bytes are included; report_traffic skips them.

Lock Kind Taken by
NodeTraffic::users parking_lot::RwLock prepare (read), commit (write), lookup (read), snapshot (read)
NodeTraffic::draining parking_lot::Mutex commit, snapshot, prune_draining
NodeTraffic::residuals parking_lot::Mutex add_residuals, restore_residuals, clear_residuals, snapshot
Admission::leases parking_lot::Mutex Admission::admit, Admission::commit, Admission::retire_all

The order is leases, then users, then draining. residuals is never held together with any of them. No NodeTraffic or Admission lock is held across an .await: the report request runs with none of them held, while flows keep writing into the atomics.

NodeManager::poll_cycle fetches node info and users, reconciles, refreshes rules, then calls report_traffic, then report_illegal. The timer runs it every update_periodic seconds (default_update_periodic returns 60; poll_period applies .max(1)); a config edit and shutdown add the runs listed under When a report runs.

sequenceDiagram
  participant N as NodeManager task
  participant T as NodeTraffic
  participant F as Flows (Gate)
  participant P as PanelClient
  participant S as Panel
  N->>T: snapshot()
  T-->>N: rows (counter Some or None)
  N->>N: skip zero rows, merge by uid, split commits and residuals
  F->>T: add_up / add_down (keeps going)
  N->>P: report_user_traffic(reports)
  P->>S: POST
  alt Ok
    S-->>P: accepted
    P-->>N: Ok(())
    N->>T: commit_reported(up, down) per counter row
  else Err (send error, timeout, HTTP 4xx or 5xx, SSPanel body or ret)
    P-->>N: Err
    N->>T: restore_residuals(counter-less rows)
    N->>N: warn "report traffic"
  end
Trigger What runs Where
Timer tick, every update_periodic seconds. The first tick comes one full interval after the node first comes up. poll_cycle(false) NodeManager::serve
A config edit, once the node is up, that swaps in a new PanelClient (any [node.api] change that keeps the identity tuple, or a panel_type edit that changes only its letter case) or forces a listener rebuild (route, listen_ip, cert, disable_sniffing, enable_vless, [node.hysteria]) poll_cycle(rebuild) at once, report included NodeManager::apply_static
Shutdown, removal or respawn of the node tear_down, then report_traffic and report_illegal, one attempt each NodeManager::run

A report run by a config edit is an ordinary report: the node keeps its NodeTraffic, and report_traffic reads the client through self.api() when it runs, so the report after an edit goes out through the new client. The edit does not move the timer, unless it also changes update_periodic: then serve starts a new interval and the next tick comes one full new period later. While the node is still coming up, its bootstrap retrying, no report runs, and a config edit polls nothing: the node stores it, or refuses it if it does not build, and starts the next attempt at once.

report_traffic(&self) in src/manager/node.rs, step by step:

  1. If [node.controller].disable_upload_traffic is set: clear_residuals(), prune_draining(), return. See Reporting disabled.
  2. snapshot().
  3. For each row with any non-zero direction:
    • add it into totals: HashMap<i64, (u64, u64)> with saturating_add, keyed by uid;
    • if counter is Some, push (counter, up, down) to commits;
    • if counter is None, push (uid, up, down) to residuals.
  4. If totals is empty, return without a request.
  5. Convert totals to Vec<UserTraffic>, casting each u64 to i64.
  6. self.api.report_user_traffic(&reports).await.
  7. On Ok(()), call counter.commit_reported(up, down) for every entry in commits, with the amounts that row carried.
  8. On Err(e), call self.traffic.restore_residuals(residuals) and log node {id}: report traffic: {e} at WARN. The counter rows need no action: their bytes were never taken out.

Two separate views are kept on purpose:

  • The wire view is merged by uid, because the newV2board payload is a JSON object keyed by uid, where a second row would silently overwrite the first.
  • The commit view is one entry per counter. A uid with a live and a draining counter gets two commit_reported calls, each subtracting what was read from that counter.

commit_reported acts on the Arc, not on a registry key, so the subtraction lands on the right counter wherever it sits by then.

One registered counter for uid 7, a flow that keeps writing, a first report that fails and a second that succeeds. The numbers are the counter’s up total.

sequenceDiagram
  participant F as Flow (Gate)
  participant C as UserCounter uid 7
  participant N as report_traffic
  participant P as Panel
  F->>C: add_up(1000), up = 1000
  N->>C: snapshot reads 1000
  F->>C: add_up(200), up = 1200
  N->>P: push uid 7 upload 1000
  P-->>N: Err
  Note over N,C: counter row, nothing to restore, up stays 1200
  N->>C: next snapshot reads 1200
  N->>P: push uid 7 upload 1200
  F->>C: add_up(50), up = 1250
  P-->>N: Ok
  N->>C: commit_reported(1200, 0), up = 50
  Note over C: the 50 bytes wait for the next cycle

The failed report cost nothing: its 1000 bytes were never taken out of the counter, so the second snapshot carries them together with the 200 that arrived meanwhile. The 50 bytes written while the second request was in flight survive the commit because it subtracts 1200 instead of storing zero. For a counter-less row (a residual or a draining counter’s final row) the failure branch is what keeps the bytes: restore_residuals parks them again, and the next snapshot drains them into its result.

newv2board::Client::report_user_traffic builds a HashMap<String, [i64; 2]> from uid to [upload, download] and sends it as JSON to PUSH_PATH = "/api/v1/server/UniProxy/push", with node_id, node_type and token in the query string.

{ "1001": [52428800, 1073741824] }

Success is any status that passes error_for_status (below 400). The body is not read.

Both clients use the node’s shared reqwest::Client, built by build_http_client(api.timeout_secs()) with a whole-request timeout. ApiConfig::timeout_secs returns [node.api].timeout, or 5 when it is 0. An edit to timeout swaps in a new client for the same node and NodeTraffic, so the report that the edit runs, and every later one, uses the new timeout. error_for_status strips the URL from the error, because the panel key travels in the query string.

Invariant Mechanism Pinned by
A failed report loses no bytes. Counter rows are only ever reduced by commit_reported, which runs only on Ok. Counter-less rows are handed back with restore_residuals on Err. The registry half: restored_residuals_are_retried (tests/unit/traffic.rs). The branch in report_traffic that chooses between the two outcomes has no test.
Bytes that arrive while a report is in flight are kept. commit_reported is a fetch_sub of the amounts in the report, never a store of zero. commit_reported_preserves_concurrent, a_departed_users_late_bytes_are_still_reported (tests/unit/traffic.rs)
No counter is reported or committed twice in one cycle. commit and snapshot hold users and draining together in the same order, so an outgoing counter is in exactly one of them. rate_change_drains_old_counter_and_reports_once (tests/unit/traffic.rs), sequentially. No test races commit against snapshot.
A draining counter’s bytes are reported until its last writer is gone, and its final row once. strong_count == 1 read before the bytes, the Acquire fence, and swap_remove in the same critical section. draining_counter_with_live_writer_is_retained, a_departed_users_late_bytes_are_still_reported (tests/unit/traffic.rs)
A departed user’s bytes are still reported, once. prepare moves the counter to orphaned, commit to draining, snapshot emits the final row and drops the counter; residuals is drained by snapshot. dropped_user_bytes_become_residuals (tests/unit/traffic.rs)
Bytes stay with the uid that earned them. A rebound credential’s old counter is orphaned rather than reused; lookup filters on uid, so a table pinned by an old connection cannot bill the new account. rebound_credential_reports_the_old_uid_separately (tests/unit/traffic.rs), a_credential_rebound_to_another_uid_is_refused (tests/unit/connector.rs)
An unchanged user keeps one counter across refreshes and rebuilds. prepare reuses the existing Arc when uid and rate match; NodeTraffic outlives every TransportManager and every PanelClient swap. rate_change_drains_old_counter_and_reports_once (first half), unchanged_user_survives_user_refresh (tests/unit/e2e.rs) for a user refresh. Byte continuity across a listener rebuild has no test.
A rate change reaches new flows. The fresh counter carries a fresh TokenBucket at the new rate; lookup returns it to every new flow. rate_change_drains_old_counter_and_reports_once
A departed user opens no new flows. lookup returns None; Admission::commit cancels the user’s lease after publishing the registry. a_user_the_registry_does_not_know_is_refused, the_lease_reaches_the_connection_and_goes_with_the_user (tests/unit/connector.rs)
Bookkeeping state is bounded. add_residuals merges by uid with saturating_add and skips all-zero rows, so residuals holds at most one entry per uid however often users churn; draining counters leave the vector at their final snapshot. Pruning: draining_counter_with_live_writer_is_retained, rate_change_drains_old_counter_and_reports_once. The merge by uid in add_residuals has no dedicated test.
A report is never abandoned between snapshot and outcome. In NodeManager::serve, poll_cycle runs inside a tokio::select! handler body, for a timer tick and for a config edit alike, and the shutdown branch is not polled while a handler runs, so a cancelled node finishes its cycle first. The bootstrap attempt that bootstrap races against shutdown never reports. Not pinned by a dedicated test.

Two derived properties follow from these:

  • A counter never goes below zero. The only subtraction is commit_reported, of an amount read from that same counter earlier, while every other operation only adds. Reporting the same counter twice would break this, which is one more reason the single-location invariant matters.
  • Idle users cost nothing on the wire. Zero rows are skipped, and a cycle with nothing to send makes no request.

report_user_traffic returns Err for a connection or send error, the request timeout, a status of 400 or above, and on SSPanel a response body that cannot be read or an envelope whose ret is not 1. katana does not retry within the cycle and has no backoff: the next attempt is the next poll_cycle, whose snapshot contains the same bytes plus whatever arrived since. A long outage ends in one larger report.

When the node’s CancellationToken fires, NodeManager::run leaves its poll loop, or stops retrying its bootstrap, calls tear_down (which shuts the TransportManager down: stops accepting, retires every user, releases the listener), then runs report_traffic and report_illegal once more. A node stopped before its listener was ever bound has counted no bytes, so this final report_traffic finds nothing and sends no request.

The same path runs when a reload removes a node or changes its identity tuple (panel_type, api.host, node_id, api.key, panel node type), since apply_reload cancels the old node’s token and awaits its task before a new NodeManager, with a new, empty NodeTraffic, starts. The panel node type comes from panel_node_type in src/api/mod.rs:

  • On newV2board it is the node_type the UniProxy requests carry: vless when enable_vless is set and node_type is V2ray, Vmess or Vless (in any letter case), otherwise the lowercased node_type. UniProxy finds a node by its id and this type, so an edit that changes the value names another panel node: a node_type change beyond letter case (except between V2ray-family names while enable_vless is set, since all of them ask for vless), or an enable_vless toggle on a V2ray or Vmess node. katana respawns the node with a fresh NodeTraffic instead of billing one panel node’s counters to another. The old node’s final report goes out through its old client, to the panel node its bytes were counted for.
  • On SSPanel it is empty, because mod_mu finds a node by its id alone.

Every other edit, api.timeout included, reaches the running node as a config update. The node keeps its NodeTraffic; an edit to [node.api] swaps in a new PanelClient and runs a report at once (see When a report runs). A reload in which any node does not build (an unknown node_type, for example) is refused whole, so no running node is stopped or replaced by it. Process runtime and reload covers the reload side.

That final report is a single attempt. If it fails, its bytes go down with the old NodeTraffic. A crash or SIGKILL loses everything not yet reported, because nothing is written to disk.

reconcile tears the listener down and calls commit(prepare(Vec::new())). Every registered counter moves to draining; the cycle’s own report_traffic then reports them, and they leave the vector once their connections are gone. bring_up does the same when it is asked to start a node with no users.

With disable_upload_traffic set, report_traffic never calls snapshot:

  • clear_residuals empties the residual map every cycle.
  • prune_draining keeps only draining counters with strong_count > 1, discarding the bytes of the rest.
  • Registered counters are neither read nor reduced. They keep accumulating, and their buckets keep limiting.

The flag is read at every cycle and can be changed by a same-identity reload. Setting it back to false makes the next snapshot report everything the registered counters gathered in the meantime.

Cancelling a user’s lease ends their connections in two places: every Gate::poll_open returns ConnectionAborted (“the user was retired”) once the lease is cancelled, and on a stream listener the connection task watches the lease its first admitted flow published (until_retired in src/serve.rs) and ends the whole connection. Accounting does not wait for that: bytes moved before the flow ends are already in the counter, and the counter’s last Arc drop is what lets the next snapshot emit its final row.

What Value Where
Bucket capacity (burst) One second of rate: min(tokens + elapsed * rate, rate) TokenBucket::refill
Initial balance Full: rate tokens TokenBucket::new
Unlimited rate == 0 TokenBucket::charge, TokenBucket::ready_at, determine_rate
Mbps conversion MBPS_TO_BPS = 1_000_000.0 / 8.0, decimal megabits src/api/mod.rs
Report interval update_periodic, default 60 s, at least 1 s default_update_periodic, NodeManager::poll_period
First report One full interval after the node first comes up; the first interval.tick() is consumed before the loop. A config edit that swaps the panel client or forces a listener rebuild reports at once. NodeManager::serve, NodeManager::apply_static
Report timeout [node.api].timeout, 5 s when 0, for the whole request; an edit applies from the next report ApiConfig::timeout_secs, build_http_client
Retries None within a cycle; the next cycle carries the bytes NodeManager::report_traffic
Residual map size At most one entry per uid NodeTraffic::add_residuals
Counter width u64 per direction; merged totals use saturating_add; sent as i64 UserCounter, report_traffic, UserTraffic
Persistence None NodeManager::new

tests/unit/traffic.rs (included into src/traffic.rs as its tests module):

Test Pins
determine_rate_min_nonzero The four cases of determine_rate.
rate_change_drains_old_counter_and_reports_once Same uid and rate keeps the counter and its bytes; a rate change gives a fresh empty counter at the new rate; the old bytes are reported once and the drained counter disappears.
draining_counter_with_live_writer_is_retained A draining counter with a writer reports with counter: Some, keeps accumulating, and becomes one final counter: None row after the writer drops.
dropped_user_bytes_become_residuals A departed user’s bytes appear once as a counter-less row, then never again.
a_departed_users_late_bytes_are_still_reported Bytes written after the removing commit are reported; after a commit, the final row carries only the remainder.
restored_residuals_are_retried A restored row reappears in the next snapshot; clear_residuals empties the map.
rebound_credential_reports_the_old_uid_separately The old uid keeps its bytes; the new uid starts at zero.
set_users_drops_absent An unlisted key no longer resolves.
commit_reported_preserves_concurrent An increment between the read and the commit survives.
token_bucket_unlimited_is_instant rate == 0 never waits.
token_bucket_rate_limits After the burst, 10 000 bytes at 10 000 B/s take at least 0.8 s of wall-clock time.
a_chunk_larger_than_the_burst_is_still_limited A 30 000-byte charge at 10 000 B/s takes exactly two seconds (paused time).
debt_holds_back_the_next_charge_too A 25 000-byte charge leaves 1.5 s of debt that the next one-byte charge waits out.
an_idle_bucket_banks_one_second_and_no_more After 60 idle seconds, a 20 000-byte charge still leaves one second of debt.

Around it:

File Tests Pins
tests/unit/meter.rs the_limit_holds_however_the_writes_are_sized, the_limit_is_shared_by_both_directions, each_direction_is_billed_to_the_user The bucket’s debt as seen through Metered, and the direction each byte is billed to.
tests/unit/connector.rs a_user_the_registry_does_not_know_is_refused, a_credential_rebound_to_another_uid_is_refused, an_admitted_stream_is_billed_to_its_user, the_lease_reaches_the_connection_and_goes_with_the_user, udp_is_billed_after_routing_and_blocked_packets_are_free lookup as the admission check, and that billed bytes land in the registered counter.
tests/unit/e2e.rs vmess_traffic_is_metered_and_reported, unchanged_user_survives_user_refresh, proxy_outbound_relays_and_meters, a_hysteria_node_relays_and_meters The whole path against a fake UniProxy panel that records every push body: at least the relayed bytes reach the panel under the right uid, across a user refresh and a user’s removal. The assertions are lower bounds, so they do not prove exactly-once reporting.
tests/unit/runtime.rs an_sspanel_node_is_its_panel_node_id, a_newv2board_node_is_also_the_type_it_asks_for, a_newv2board_type_edit_respawns_the_node Which edits change the identity tuple, and so respawn the node with a fresh NodeTraffic: on newV2board node_type V2ray to Trojan, or an enable_vless toggle on a V2ray node, does; a case-only node_type change, enable_vless on a Trojan node, and on both panels an api.timeout change do not.
tests/unit/api/newv2board.rs push_body_shape That a HashMap<String, [i64; 2]> serializes to the {"uid": [upload, download]} shape. It builds the map itself and does not call report_user_traffic.

report_traffic’s own branches (the merge by uid, the split into commits and residuals, and the failure branch) are covered only through the end-to-end tests, and those exercise the success path. A change to that function should come with a test that makes the fake panel fail a push and checks that the next push carries the same bytes. See Testing for how to run the suites.