Admission and user tables
Source files: 18 · checked against katana v3.0.1 · Etemenanki 596916d
katana/src/connector.rskatana/src/traffic.rskatana/src/meter.rskatana/src/serve.rskatana/src/inbound.rskatana/src/api/mod.rskatana/src/manager/mod.rskatana/src/manager/proxy.rskatana/src/manager/transport.rskatana/src/manager/node.rskatana/tests/unit/connector.rskatana/tests/unit/traffic.rskatana/tests/unit/meter.rskatana/tests/unit/serve.rskatana/tests/unit/e2e.rsEtemenanki/concepts/src/net.rsEtemenanki/protocols/src/flow.rsEtemenanki/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.
Responsibilities
Section titled “Responsibilities”| 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. |
Key types
Section titled “Key types”AuthKey and UserTag
Section titled “AuthKey and UserTag”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.
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:
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.
What a user table carries
Section titled “What a user table carries”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:
pub struct NetworkUser<T> { pub authorization: UserAuthorization, pub user_data: std::sync::Arc<T>,}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>:
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.
The registry
Section titled “The registry”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.
-
preparetakes theusersread 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 nextOld counter goes to Key in cancel_keysYes Yes Yes the same Arcstays No Yes Yes No a fresh UserCountercarriedNo Yes No either a fresh UserCounterorphanedYes No n/a n/a a fresh UserCountern/a No After the entries, every registry key missing from
nextgoes toorphanedand intocancel_keys. A rate change forces a new counter becauseTokenBucketfixes its rate at construction. -
committakes theuserswrite lock and then thedraininglock, in the ordersnapshottakes them. It moves the old counters fromcarriedandorphanedintodrainingand replaces the map withnext. 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. -
lookupreturns the counter only whilekeyis registered to that sameuid: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.
Admission
Section titled “Admission”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:
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.
The lease slot and the connector
Section titled “The lease slot and the connector”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:
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.
The gate on every outbound
Section titled “The gate on every outbound”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.
The proxy layer
Section titled “The proxy layer”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:
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.
Data flow
Section titled “Data flow”Admitting a flow
Section titled “Admitting a flow”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.
The lease of one key
Section titled “The lease of one key”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.
The refresh
Section titled “The refresh”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:
-
Stage.
build_user_entries(node, users)returns the registry entries and the users whose key parsed.NodeTraffic::prepare(entries)returns aPreparedUsers. Nothing has changed yet. -
Build the replacement. For
Tables::Stream,build_protocolbuilds a newStreamProtocol. ForTables::Hysteria,build_hysteria_authenticatorbuilds a new authenticator from the storedHysteriaConfig. Both use the sametag_forthe staged entries were keyed with. On error,refreshlogsproxy refresh build failed, keeping current: <error>and returns. ThePreparedUsersis dropped without being committed, so the registry, the leases and the tables are all untouched. -
Commit under the leases lock.
self.dispatcher.admission.commit(prepared)takesleases, andNodeTraffic::commitswaps in the new registry and moves outgoing counters to the draining set. Thencommitremoves and cancels the lease of every key incancel_keys: departed users and credentials rebound to another uid. It does not wait for their flows to end, and their counters drain. -
Publish the tables. For a stream node,
ArcSwap::storeinstalls the newStreamProtocol, and connections accepted from then on use it. For a Hysteria node,Hy2Inbound::set_authenticatorinstalls the new authenticator. The listener, its socket and every live connection are untouched. ATables/Replacementmismatch isunreachable!("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
Why the registry goes first
Section titled “Why the registry goes first”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.
What a refresh does to each user
Section titled “What a refresh does to each user”| 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.
Cold rebuilds
Section titled “Cold rebuilds”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.
Invariants
Section titled “Invariants”Race analysis
Section titled “Race analysis”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 table
Section titled “Invariant table”| 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 |
Lock order
Section titled “Lock order”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.
Failure paths and cancellation
Section titled “Failure paths and cancellation”| 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.
Limits
Section titled “Limits”| 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.