Skip to content

Admission and user tables

Source files: 18 · checked against katana v3.0.1 · Etemenanki 596916d
  • katana/src/connector.rs
  • katana/src/traffic.rs
  • katana/src/meter.rs
  • katana/src/serve.rs
  • katana/src/inbound.rs
  • katana/src/api/mod.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/proxy.rs
  • katana/src/manager/transport.rs
  • katana/src/manager/node.rs
  • katana/tests/unit/connector.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/meter.rs
  • katana/tests/unit/serve.rs
  • katana/tests/unit/e2e.rs
  • Etemenanki/concepts/src/net.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/hysteria/server/inbound.rs

A protocol core decides whether a credential is valid. katana decides which panel user a flow belongs to, whether that user may still open flows, and how to end the flows of a user the panel has retired. It does this in two parts. The first is the payload the protocol user tables carry, a UserTag. The second is Admission, a lookup against the node’s traffic registry that every flow passes before it is dialed. When the panel’s user list changes, katana rebuilds the tables and the registry and publishes both, in an order that lets unchanged users keep their connections while departed users lose theirs.

This page is for contributors who change src/connector.rs, src/traffic.rs or src/manager/. It covers the per-flow admission path, the user-set refresh exactly as ProxyManager::refresh runs it, and the race analysis behind its ordering. Byte counting and reporting are described in Traffic accounting, and the metered outbound in Metering and speed limits.

Component Does Leaves to others
UserTag in the protocol user tables Names the registry key and uid a credential belongs to. The counter: it is looked up per flow, never stored in a table.
NodeTraffic (src/traffic.rs) Holds the registry AuthKey → Arc<UserCounter>. Stages the next user set (prepare) and swaps it in (commit). Retiring connections. It only computes which keys must go (cancel_keys).
Admission (src/connector.rs) Admits a flow against the registry and hands out the user’s lease. Commits a staged user set and cancels the leases of departed or rebound users. Building tables and deciding when to refresh.
KatanaConnector::connect Calls admit for every flow, publishes the lease to its connection, wraps the outbound in a Gate. Routing and audit details, see Connector and UDP fan-out.
ProxyManager::refresh (src/manager/proxy.rs) Builds the replacement table or authenticator, commits, then publishes. Classifying a change as a refresh or a rebuild, see Node manager.
TransportManager::start (src/manager/transport.rs) The cold path: stages, builds, binds, commits directly, and creates a fresh Admission. Tearing down the previous generation.

AuthKey is the registry key. The kernel’s UserAuthorization derives Eq but not Hash, so katana keeps its own projection, which derives Clone, Eq and Hash. UserTag derives Clone and Eq but not Hash: it is a payload, never a key.

src/traffic.rs
pub enum AuthKey {
Uuid(Uuid),
Name(CompactString),
}
pub struct UserTag {
pub key: AuthKey,
pub uid: i64,
}
impl UserTag {
pub fn unattributed() -> Self
}

The node type decides which variant a panel user gets. NodeType::keys_by_email in src/api/mod.rs is the single place that decision is made, and src/manager/mod.rs maps a UserInfo to its key and tag:

src/manager/mod.rs
pub(crate) fn user_key(by_email: bool, u: &UserInfo) -> Option<AuthKey>
pub(crate) fn user_tag(by_email: bool, u: &UserInfo) -> Option<Arc<UserTag>>
pub(crate) fn build_user_entries(
node: &NodeInfo,
users: &[UserInfo],
) -> (Vec<UserEntry>, Vec<UserInfo>)
Node type keys_by_email() AuthKey
V2ray (VMess or VLESS) false AuthKey::Uuid, parsed from UserInfo.uuid
Trojan, Shadowsocks, Hysteria2 true AuthKey::Name(traffic_email(u)): the panel email, or the uid as a string when the email is empty

For UUID-keyed nodes, build_user_entries skips a user whose UUID does not parse and logs skipping user <uid>: uuid is not a valid UUID. It names the uid and never the value. The skipped user gets neither a registry entry nor a table entry.

UserTag::unattributed() returns AuthKey::Name("") with uid -1. It has two uses in src/inbound.rs. The Shadowsocks server configs (Ss2022ServerConfig::from_password and the legacy ShadowsocksServerConfig) require a single-password payload that katana never serves from, and the unattributed tag fills that slot. validate_hysteria, the configuration dry run, builds a real authenticator over one placeholder user and tags it the same way. No registered user can match the tag, because lookup only ever finds registered uids.

Every protocol core in the kernel is generic over a user payload T, and hands its connector a Flow<T>. The payload sits behind an Arc in NetworkUser:

concepts/src/net.rs
pub struct NetworkUser<T> {
pub authorization: UserAuthorization,
pub user_data: std::sync::Arc<T>,
}
protocols/src/flow.rs
pub struct Flow<T> {
pub destination: Destination,
pub user: NetworkUser<T>,
pub sniffed: Option<SniffedBehavior>,
pub source: Option<IpAddr>,
}

katana instantiates every table with T = UserTag. The stream-node tables are StreamProtocol variants, and the Hysteria 2 table is an Authenticator<UserTag>:

src/inbound.rs
pub enum StreamProtocol {
Vmess(Arc<AccountValidator<UserTag>>),
Vless(Arc<vless::Validator<UserTag>>),
Trojan(Arc<trojan::Validator<UserTag>>),
ShadowsocksLegacy(Arc<Resolved<UserTag>>),
Shadowsocks2022 {
config: Arc<Ss2022ServerConfig<UserTag>>,
validator: Option<Arc<ss_2022::Validator<UserTag>>>,
},
}
pub fn build_protocol(
node: &NodeInfo,
users: &[UserInfo],
enable_vless: bool,
tag_for: impl Fn(&UserInfo) -> Option<Arc<UserTag>>,
) -> io::Result<StreamProtocol>
pub fn build_hysteria_authenticator(
cfg: &HysteriaConfig,
users: &[UserInfo],
tag_for: impl Fn(&UserInfo) -> Option<Arc<UserTag>>,
) -> io::Result<Authenticator<UserTag>>

Both builders take the tag from tag_for, which is user_tag with the node’s keys_by_email(). The table and the registry therefore always agree on the key. A user that tag_for cannot tag is a hard build error, user <uid> has no key, because every user passed in was already staged.

src/traffic.rs
pub struct NodeTraffic {
users: RwLock<HashMap<AuthKey, Arc<UserCounter>>>,
residuals: Mutex<HashMap<i64, (u64, u64)>>,
draining: Mutex<Vec<Arc<UserCounter>>>,
}
pub struct UserEntry {
pub key: AuthKey,
pub uid: i64,
pub rate: u64,
}
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>
}
impl NodeTraffic {
pub fn prepare(&self, entries: Vec<UserEntry>) -> PreparedUsers
pub fn commit(&self, prepared: PreparedUsers)
pub fn lookup(&self, key: &AuthKey, uid: i64) -> Option<Arc<UserCounter>>
}

The locks are parking_lot locks. One NodeTraffic belongs to each NodeManager and outlives every listener generation, so counters of unchanged users carry across a rebuild.

  • prepare takes the users read lock and compares each entry with the current registry. It mutates nothing and reads no byte totals, so the tables can be built, and can fail, before anything changes. Each entry falls into one of four cases:

    Registry has the key? Same uid? Same rate? Counter in next Old counter goes to Key in cancel_keys
    Yes Yes Yes the same Arc stays No
    Yes Yes No a fresh UserCounter carried No
    Yes No either a fresh UserCounter orphaned Yes
    No n/a n/a a fresh UserCounter n/a No

    After the entries, every registry key missing from next goes to orphaned and into cancel_keys. A rate change forces a new counter because TokenBucket fixes its rate at construction.

  • commit takes the users write lock and then the draining lock, in the order snapshot takes them. It moves the old counters from carried and orphaned into draining and replaces the map with next. Holding both locks means a snapshot sees each outgoing counter in exactly one place. It does not read or wait on any counter, so connections still writing to an outgoing counter do not block it.

  • lookup returns the counter only while key is registered to that same uid:

    src/traffic.rs
    self.users.read().get(key).filter(|c| c.uid == uid).cloned()

    The uid filter matters for a rebound credential. A table built before the change still carries the old uid in its tag, and the lookup refuses it instead of billing the account the credential now belongs to.

src/connector.rs
pub struct Admission {
traffic: Arc<NodeTraffic>,
leases: Mutex<HashMap<AuthKey, CancellationToken>>,
}
impl Admission {
pub fn new(traffic: Arc<NodeTraffic>) -> Self
pub fn admit(&self, tag: &UserTag) -> Option<(Arc<UserCounter>, CancellationToken)>
pub fn commit(&self, prepared: PreparedUsers)
pub fn retire_all(&self)
}

A lease is a tokio_util CancellationToken, one per AuthKey that has had a flow admitted on this listener generation. Every flow and every connection of that user holds a clone. Cancelling it retires all of them at once. The whole mechanism is two short functions, and the lock scope is what makes it correct:

src/connector.rs
pub fn admit(&self, tag: &UserTag) -> Option<(Arc<UserCounter>, CancellationToken)> {
let mut leases = self.leases.lock();
let counter = self.traffic.lookup(&tag.key, tag.uid)?;
let lease = leases.entry(tag.key.clone()).or_default().clone();
Some((counter, lease))
}
pub fn commit(&self, prepared: PreparedUsers) {
let mut leases = self.leases.lock();
let leaving: Vec<AuthKey> = prepared.cancel_keys().iter().cloned().collect();
self.traffic.commit(prepared);
for key in leaving {
if let Some(lease) = leases.remove(&key) {
lease.cancel();
}
}
}

admit does its lookup and inserts the lease while it holds leases. commit swaps the registry and cancels leases while it holds the same lock. retire_all drains the map and cancels every lease. TransportManager::shutdown and TransportManager’s Drop call it through ProxyManager::retire_all.

Admission lives in the Dispatcher, which belongs to one listener generation. A rebuild creates a new Admission with an empty lease map over the same NodeTraffic.

src/connector.rs
pub type LeaseSlot = watch::Sender<Option<CancellationToken>>;
impl KatanaConnector {
pub fn new(
disp: Arc<Dispatcher>,
source: Option<IpAddr>,
lease: Option<Arc<LeaseSlot>>,
) -> Self
}
impl Connector<Flow<UserTag>> for KatanaConnector {
type Stream = Metered<OutboundStream>;
type Datagram = FanOut;
type Future = ConnectFuture;
fn connect(&mut self, flow: Flow<UserTag>) -> ConnectFuture;
}

serve_stream creates one watch channel for each stream the transport yields: one per TCP, TLS or WebSocket connection, and one per HTTP/2 stream on gRPC. It gives the sender to the connector as Some(Arc<LeaseSlot>) and keeps the receiver for the connection’s driver. All flows on one connection belong to one user, because the core authenticates once per connection and mux sub-flows inherit the carrier’s user (Flow::toward). The first admitted flow publishes its lease with send_if_modified, and later flows leave the published value alone. The driver waits on it:

src/serve.rs
async fn until_retired(mut lease: watch::Receiver<Option<CancellationToken>>)

until_retired resolves once a published lease is cancelled. Until a flow has been admitted, the connection has no user and the future never resolves. If the sender is dropped before any lease was published, it stays pending for good. Once the handshake has completed, drive selects on it next to the runtime and the progress watchdog, and returns Ok(()) when it fires. A lease cancelled before that point is still honoured, because a cancelled token stays cancelled and until_retired then resolves on its first poll. Dropping the runtime then closes the client stream, every outbound, and any mux sub-flow not opened yet.

A Hysteria 2 listener runs its own runtimes, one per proxy stream and one for each connection’s datagrams. run_hysteria therefore builds each client’s connector with lease: None. A retired user’s Hysteria flows end through their Gate instead.

src/meter.rs
impl Gate {
pub fn new(counter: Arc<UserCounter>, retired: CancellationToken) -> Self
pub fn poll_open(&mut self, cx: &mut Context<'_>) -> Poll<io::Result<()>>
}

connect builds a Gate from the admitted counter and lease. Metered calls poll_open before every read and write. FanOut calls it on every receive, and on every send that routing and audit let through: a datagram the router blocks or the audit forbids is dropped before the gate is asked. It checks the retirement first. A cancelled lease makes every transfer fail with io::ErrorKind::ConnectionAborted and the message the user was retired. Because the gate registers the waker with the cancellation future, a read parked on a silent peer also wakes up when the user is retired.

src/manager/proxy.rs
pub enum Tables {
Stream(ArcSwap<StreamProtocol>),
Hysteria {
server: Hy2Inbound<UserTag>,
cfg: HysteriaConfig,
},
}
enum Replacement {
Stream(StreamProtocol),
Hysteria(Arc<Authenticator<UserTag>>),
}
impl ProxyManager {
pub fn refresh(&self, node: &NodeInfo, users: &[UserInfo], enable_vless: bool)
pub fn retire_all(&self)
}

The two Tables shapes are swapped differently. A stream node builds a protocol core per connection over whichever table is current, so the whole table sits in an ArcSwap and is replaced in one store. A Hysteria 2 listener owns its UDP socket, and rebuilding it would drop every connected client. Only its authenticator is replaced:

protocols/src/hysteria/server/inbound.rs
impl<T> Hy2Inbound<T> {
pub fn set_authenticator(&self, authenticator: Arc<Authenticator<T>>)
}

The listener loads the current authenticator when a QUIC connection sends its /auth request. A client already past /auth keeps the identity it was admitted under. The per-flow admission check is what retires a departed Hysteria user.

connect admits a flow synchronously, before it returns the future. The decision therefore uses the registry as it is when the core emits the flow, before any dial.

sequenceDiagram
  participant Core as Protocol core
  participant Conn as KatanaConnector
  participant Adm as Admission
  participant Reg as NodeTraffic
  participant Slot as LeaseSlot
  Core->>Conn: connect(Flow with UserTag)
  Conn->>Adm: admit(tag)
  Adm->>Adm: lock leases
  Adm->>Reg: lookup(key, uid)
  alt key registered to this uid
    Reg-->>Adm: counter
    Adm->>Adm: lease = entry(key).or_default()
    Adm-->>Conn: counter and lease
    Conn->>Slot: publish lease if none yet
    Conn->>Conn: Gate::new(counter, lease)
    Conn-->>Core: UDP gives a FanOut, TCP is routed, audited, dialed and wrapped in Metered
  else not registered, or another uid
    Reg-->>Adm: None
    Adm-->>Conn: None
    Conn-->>Core: Err(refused), PermissionDenied
  end

A refused admission is not logged. A departed user’s client keeps retrying until it notices, and a log line per attempt would flood the log.

stateDiagram-v2
  [*] --> Absent
  Absent --> Live: first admit for the key
  Live --> Live: admit clones it
  Live --> Live: refresh, unchanged or rate changed
  Live --> Cancelled: commit, key in cancel_keys
  Live --> Cancelled: retire_all
  Cancelled --> [*]: removed from the map

commit and retire_all remove a cancelled lease from the map as they cancel it. If the user comes back in a later refresh, the next admit inserts a fresh token. Flows and connections that still hold the old token stay retired.

NodeManager::reconcile calls ProxyManager::refresh when the panel’s user list is not empty, a listener is running, the node’s transport and protocol are unchanged (NodeInfo::transport_eq, NodeInfo::protocol_eq), no local config edit forces a rebuild, and either the user list differs (user_set_differs, an order-independent set comparison of whole UserInfo values) or the node speed limit changed. It holds the node’s transport mutex during the call. ProxyManager::refresh then runs these steps in order:

  1. Stage. build_user_entries(node, users) returns the registry entries and the users whose key parsed. NodeTraffic::prepare(entries) returns a PreparedUsers. Nothing has changed yet.

  2. Build the replacement. For Tables::Stream, build_protocol builds a new StreamProtocol. For Tables::Hysteria, build_hysteria_authenticator builds a new authenticator from the stored HysteriaConfig. Both use the same tag_for the staged entries were keyed with. On error, refresh logs proxy refresh build failed, keeping current: <error> and returns. The PreparedUsers is dropped without being committed, so the registry, the leases and the tables are all untouched.

  3. Commit under the leases lock. self.dispatcher.admission.commit(prepared) takes leases, and NodeTraffic::commit swaps in the new registry and moves outgoing counters to the draining set. Then commit removes and cancels the lease of every key in cancel_keys: departed users and credentials rebound to another uid. It does not wait for their flows to end, and their counters drain.

  4. Publish the tables. For a stream node, ArcSwap::store installs the new StreamProtocol, and connections accepted from then on use it. For a Hysteria node, Hy2Inbound::set_authenticator installs the new authenticator. The listener, its socket and every live connection are untouched. A Tables/Replacement mismatch is unreachable!("table kind changed without a rebuild"), because the replacement is built from the same arm it is applied to.

sequenceDiagram
  participant NM as NodeManager
  participant PM as ProxyManager
  participant Reg as NodeTraffic
  participant Adm as Admission
  participant Tab as Tables
  NM->>PM: refresh(node, users, enable_vless)
  PM->>Reg: prepare(entries)
  Reg-->>PM: PreparedUsers
  PM->>PM: build_protocol or build_hysteria_authenticator
  alt build failed
    PM-->>NM: log error, keep current state
  else built
    PM->>Adm: commit(prepared)
    Adm->>Adm: lock leases
    Adm->>Reg: commit, swap registry, park outgoing counters
    Adm->>Adm: remove and cancel leases of cancel_keys
    PM->>Tab: ArcSwap store or set_authenticator
  end

Between steps 3 and 4 the new registry is live while the old table still authenticates. New connections authenticate against the old table in that window, and long-lived connections keep the table they loaded for as long as they last. A departed user can still pass authentication there, but every flow they open is refused at admit. A rebound credential’s old-uid tag is refused by the uid filter in lookup. A newly added user is already in the registry when the new table starts to accept them.

The opposite order would break new users. With the table first, a newly added user could authenticate against the new table, and the old registry would then refuse every flow they open.

User Registry after commit Lease Open flows and connections New flows
Unchanged: same key, uid and rate Same Arc<UserCounter> Kept Continue, metering uninterrupted Admitted to the same counter
Rate changed: same key and uid Fresh counter, the old one drains Kept Continue on the old counter and its bucket at the old rate until they end Admitted to the fresh counter at the new rate
Rebound: same key, another uid Fresh counter for the new uid, the old one drains under the old uid Cancelled Stream connections end, and every Gate refuses to move Old-uid tags refused. New-uid tags from the new table get a fresh lease
Departed: key not re-listed Removed, counter drains Cancelled Stream connections end, and every Gate refuses to move Refused
New Fresh counter Created on first admit n/a Admitted once the new table accepts them

Until a rate-changed user’s old flows end, their old and new flows draw on separate buckets.

A transport or protocol change, a route or outbound change, a static-config rebuild, or a return from an empty user list goes through NodeManager::rebuild. That path tears the old listener down first and then builds a new one. TransportManager::start does not use Admission::commit:

flowchart TB
  A["NodeManager::tear_down"] --> B["TransportManager::shutdown"]
  B --> C["Scope::shutdown, scoped tasks finished"]
  C --> D["ProxyManager::retire_all, all leases cancelled"]
  D --> E["release_listener, QUIC port freed"]
  E --> F["TransportManager::start: prepare"]
  F --> G["build tables and transport, bind"]
  G --> H["NodeTraffic::commit directly"]
  H --> I["new Dispatcher with Admission::new"]
  I --> J["spawn accept loop or run_hysteria"]

route_change_drops_connections in tests/unit/e2e.rs pins that this path ends every open connection. A direct commit is safe here. The previous generation’s scope has been shut down and its leases cancelled. The new generation’s listener has not started accepting, so no flow can reach an Admission between the commit and the first accept, and there is no lease to cancel. start keeps the same discipline as refresh: every step that can fail (building the Hysteria 2 authenticator and listener, or the stream transport and protocol table, and binding the socket) runs before the commit. A failure returns the error with the registry unchanged. Because the old listener is already gone, the node serves nothing until a later cycle rebuilds it successfully. On the node’s first start, the failure ends one bootstrap attempt, and NodeManager::bootstrap retries it with backoff until the node comes up or is shut down.

Two more paths commit an empty set directly while no listener runs: reconcile with an empty user list (after tear_down), and bring_up with no users. Both call traffic.commit(traffic.prepare(Vec::new())), so every counter drains and nothing listens.

Two tasks meet in Admission. A connection task calls admit from connect. The node’s task calls commit from refresh. Both run their critical sections under Admission.leases, so they are serialized, and only two interleavings exist for a user who departs in this refresh:

sequenceDiagram
  participant F as Connection task
  participant L as leases lock
  participant N as Node task
  alt admit takes the lock first
    F->>L: admit, lookup hits the old registry, lease inserted
    L-->>F: counter and lease
    N->>L: commit, registry swapped
    N->>L: lease removed and cancelled
    Note over F: Gate aborts, until_retired fires
  else commit takes the lock first
    N->>L: commit, registry swapped, lease cancelled if present
    F->>L: admit, lookup misses the new registry
    L-->>F: None, flow refused
  end

A flow is therefore either admitted before the commit and then retired by it, or checked against the new registry. Without the shared lock, a third interleaving would be possible. admit could look the user up in the old registry, commit could then swap the registry and find no lease to cancel, and admit could insert a fresh token that no one would ever cancel. Holding leases across both the lookup and the insert rules that out.

Publishing into the LeaseSlot happens after admit releases the lock, so a lease can already be cancelled when it is published. That is harmless. A CancellationToken stays cancelled, so until_retired resolves at once and the Gate refuses the first transfer.

Invariant Mechanism Pinned by
A table payload never carries a counter. Every table is built over UserTag, and the counter comes from NodeTraffic::lookup in admit. Structural, by the table types. an_admitted_stream_is_billed_to_its_user in tests/unit/connector.rs pins that the looked-up counter is billed
A key not in the registry opens no flow. lookup returns None, and connect returns refused() (PermissionDenied). a_user_the_registry_does_not_know_is_refused in tests/unit/connector.rs
A credential rebound to another uid is not billed to the new account from an old table. lookup filters on c.uid == uid. a_credential_rebound_to_another_uid_is_refused in tests/unit/connector.rs, rebound_credential_reports_the_old_uid_separately in tests/unit/traffic.rs
No admitted flow holds a lease that a later commit misses. admit and commit share Admission.leases. Structural. the_lease_reaches_the_connection_and_goes_with_the_user in tests/unit/connector.rs pins the sequential case
Retiring a user ends the connection, including sub-flows not opened yet. The first admitted flow publishes its lease to the LeaseSlot, and drive selects on until_retired. the_lease_reaches_the_connection_and_goes_with_the_user in tests/unit/connector.rs, a_retired_users_connection_ends in tests/unit/serve.rs
A retired user’s open flows move no more bytes. Gate::poll_open checks the lease first and returns ConnectionAborted. a_retired_users_stream_refuses_to_move and retiring_the_user_wakes_a_parked_read in tests/unit/meter.rs
Unchanged users keep their connections and their counter across a refresh. prepare reuses the Arc when uid and rate match, and their keys are not in cancel_keys. unchanged_user_survives_user_refresh in tests/unit/e2e.rs (VMess), repeated_user_refreshes_never_disturb_a_live_connection in tests/unit/e2e.rs (Hysteria 2)
A user-set change never rebuilds a Hysteria 2 listener, and a retired Hysteria user stops while others continue. Tables::Hysteria swaps only the authenticator, and admission retires the user. a_retired_user_stops_while_the_rest_keep_their_connections in tests/unit/e2e.rs
Rate-changed users keep their lease, and their new flows get the new rate. prepare puts a same-uid rate change in carried, not in cancel_keys. Counter side: rate_change_drains_old_counter_and_reports_once in tests/unit/traffic.rs. No test targets the lease side
A failed build changes nothing. refresh builds before commit, start builds and binds before NodeTraffic::commit, and prepare does not mutate. Not pinned by a test
Outgoing counters are neither lost nor reported twice. commit holds users and draining together, in snapshot’s order, and does not read byte totals. a_departed_users_late_bytes_are_still_reported, draining_counter_with_live_writer_is_retained, dropped_user_bytes_become_residuals in tests/unit/traffic.rs
Only one prepare/commit pair runs at a time for a node. NodeManager::run handles polls and static updates in one task, and refresh runs under the transport mutex. Structural

All three locks are parking_lot locks, held only for a few map operations and never across an .await.

Lock Type Taken by Order
Admission.leases Mutex<HashMap<AuthKey, CancellationToken>> admit, commit, retire_all 1st
NodeTraffic.users RwLock<HashMap<AuthKey, Arc<UserCounter>>> read: lookup, prepare, snapshot. write: commit 2nd
NodeTraffic.draining Mutex<Vec<Arc<UserCounter>>> commit, snapshot, prune_draining 3rd, after users

No path takes a lock that comes earlier in this order while it holds a later one. prepare and snapshot never touch leases.

Situation What happens Visible as
Flow from an unregistered key or a stale uid connect returns an already-ready future with refused() io::ErrorKind::PermissionDenied, refused. Not logged
Lease cancelled while a flow is open Gate::poll_open fails the next transfer, and a parked read is woken io::ErrorKind::ConnectionAborted, the user was retired
Lease cancelled on a stream connection until_retired resolves, drive returns Ok(()) and the runtime is dropped The client’s connection closes. Counted as a clean end, not a handshake failure
Lease cancelled on a Hysteria 2 connection katana does not close the QUIC connection. Each flow’s Gate refuses to move, and new flows are refused at admit The client’s proxy streams and UDP stop relaying
Replacement build fails in refresh Nothing is committed or published. reconcile still records the new user list as current, so the refresh is not retried until the user list or the node speed limit changes again proxy refresh build failed, keeping current: <error> at error level
Build or bind fails in TransportManager::start The error is returned before NodeTraffic::commit, and the node has no listener. After a rebuild, the next poll finds no listener and rebuilds again. On the initial start, the bootstrap attempt fails and is retried after 1 s, doubling up to 60 s or the poll period if that is shorter. Each attempt fetches the node and its users from the panel again node <id>: rebuild failed: <error> or node <id>: initial start failed: <error>; retrying in <n>s
Listener torn down TransportManager::shutdown: scope shutdown, retire_all, release_listener. Drop cancels the scope token and calls retire_all as a backstop All the node’s connections end

Admission::commit never waits for the flows it retires. It cancels tokens and returns. Retired flows finish on their own tasks, and whatever they still write lands in a draining counter that Traffic accounting keeps reporting.

Quantity Bound Enforced by
Entries in Admission.leases At most one per registered AuthKey that has had a flow admitted on this generation An entry is inserted only after lookup succeeds, removed when its key is in cancel_keys, and drained by retire_all
Leases per connection One, the first admitted flow’s send_if_modified publishes only into an empty slot
Draining counters Pruned once no writer remains (Arc::strong_count == 1) NodeTraffic::snapshot, or prune_draining when traffic upload is disabled
Residuals One entry per uid NodeTraffic::add_residuals merges by uid
Unattributed tag uid -1, empty AuthKey::Name UserTag::unattributed

The admission path defines no timeouts or sizes of its own. The handshake deadline, the pre-auth cap and the progress watchdog that surround it are described in Serving connections.

Test File Pins
a_user_the_registry_does_not_know_is_refused tests/unit/connector.rs An empty registry refuses a flow with PermissionDenied.
a_credential_rebound_to_another_uid_is_refused tests/unit/connector.rs A tag carrying the old uid is refused after the key moves to uid 2.
an_admitted_stream_is_billed_to_its_user tests/unit/connector.rs The counter found by lookup receives the stream’s bytes, 5 up and 5 down.
the_lease_reaches_the_connection_and_goes_with_the_user tests/unit/connector.rs The first flow publishes an uncancelled lease into the slot. Admission::commit of an empty set cancels it, and the next flow is refused.
a_retired_users_connection_ends tests/unit/serve.rs Cancelling the published lease ends drive with Ok(()) within one second.
a_retired_users_stream_refuses_to_move tests/unit/meter.rs A write through a retired Gate fails with ConnectionAborted.
retiring_the_user_wakes_a_parked_read tests/unit/meter.rs A read parked on a silent peer wakes on retirement.
rate_change_drains_old_counter_and_reports_once tests/unit/traffic.rs Same uid and rate keeps the counter. A rate change installs a fresh counter and reports the old bytes once.
draining_counter_with_live_writer_is_retained tests/unit/traffic.rs A draining counter with a live writer keeps being reported until the writer drops.
dropped_user_bytes_become_residuals tests/unit/traffic.rs A departed user’s bytes are reported after it leaves.
a_departed_users_late_bytes_are_still_reported tests/unit/traffic.rs Bytes written after the commit that removed the user are still reported, and only once.
rebound_credential_reports_the_old_uid_separately tests/unit/traffic.rs A rebound credential’s new counter starts empty, and the old bytes stay with the old uid.
set_users_drops_absent tests/unit/traffic.rs A key that is not re-listed leaves the registry.
unchanged_user_survives_user_refresh tests/unit/e2e.rs A VMess connection survives a refresh that adds a user, ends after a refresh that removes its user, and both payloads are reported.
a_retired_user_stops_while_the_rest_keep_their_connections tests/unit/e2e.rs On a Hysteria 2 node, the retired user stops relaying while the other user’s original QUIC connection stays alive and keeps relaying.
repeated_user_refreshes_never_disturb_a_live_connection tests/unit/e2e.rs Six refreshes that add and remove another user leave a Hysteria 2 connection alive and relaying.
a_hysteria_node_refuses_an_unknown_credential tests/unit/e2e.rs The authenticator built from the panel’s users rejects a credential the panel never issued.
route_change_drops_connections tests/unit/e2e.rs The cold path: a [node.route] edit rebuilds the listener and the open VMess connection is dropped.
a_node_whose_port_is_taken_comes_up_once_it_is_free tests/unit/e2e.rs An initial start that fails to bind is retried, asks the panel again, and serves once the port is free.

NodeTraffic::set_users, which runs prepare and commit in one step, is compiled only for tests. The runtime always calls the two phases separately.