Part 2: Lines, Callbacks & Lifetime Safety
This part covers the contracts that cause the most crashes when they are broken: the per-connection callback lifecycle, who owns a line, and how to survive the re-entrant callbacks that can destroy a line out from under you.
If you read only one part of this guide before touching connection flow, read this one. Prerequisite: Part 1 (objects, directions, lifecycle).
The Six Flow Callbacks
Every connection is driven by six events, each in two directions (upstream toward
next, downstream toward prev):
| Event | Meaning | Direction notes |
|---|---|---|
Init | A line is starting; initialize this tunnel's per-line state. | Flows upstream from the creating adapter. |
Est | Repeatable notification that an adapter's transport is established (e.g. the remote socket connected); protocol negotiation is separate. | Normally relayed downstream, back toward the originating adapter. |
Payload | A chunk of data (sbuf_t) to transform and forward. | Either direction. |
Pause | Repeatable advisory request to stop production; ordinary middle nodes relay it transparently. | Either direction; adapters, exact-line endpoints, SpeedLimit and bandwidth-enabled UserController handle producer control. |
Resume | Repeatable advisory request to resume production; it does not prove all pressure has cleared. | Either direction; ordinary middle nodes relay it transparently. |
Finish | This direction is closing. | Directional and destructive. See below. |
A tunnel implements only the handlers whose behavior differs from its constructor
defaults. tunnelCreate() terminates on upstream Est or downstream Init unless
the node explicitly overrides that callback; its other flow defaults forward to
the neighbor. Packet constructors preserve both-direction lifecycle forwarding.
For example, an encryptor overrides Payload in both directions but can leave
Pause/Resume at default.
Dispatch Through Helpers
All inter-tunnel flow events must use tunnelNextUpStream*,
tunnelPrevDownStream*, tunnelUpStream*, or tunnelDownStream*. This applies to
all six events in both directions, including delivery to a selected branch.
The helpers enforce the line's active-tunnel mask at delivery time.
Treat the fnInit*, fnEst*, fnPayload*, fnFin*, fnPause*, and fnResume*
fields as handler-registration slots. Tunnel constructors may assign their
handlers. Outside handler assignments, production tunnel code must not access
these fields, including invocation, caching, copying or passing callbacks
elsewhere; only the framework's dispatch implementation
may invoke them directly. Keep tunnel_t visible in the header so the dispatch
helpers can remain inline. Struct visibility does not authorize direct dispatch.
Pause/Resume defaults forward every notification, including duplicates. Dispatch does not store a paused bit, count holds, gate Payload, or infer a node's role from its chain position. Registered handlers still run, including handlers that map different lines or implement the explicit limiter exception. Ordinary middle nodes use the forwarding defaults. This transparency is separate from permanent per-line bypass: a node that still transforms payload or owns state must remain active.
For a known target, use tunnelUpStream* or tunnelDownStream*; these include
that target in bypass resolution. Pass the helper itself to reference guards
and line-task schedulers, so the mask is checked when delivery actually occurs:
lineCallWithRef(line, tunnelUpStreamInit, target);
lineCallWithRefWithBuf(line, tunnelUpStreamPayload, target, payload);
Do not pass target->fnInitU or target->fnPayloadU instead. The existing
reference, buffer-transfer and scheduling contracts still apply. This rule
covers flow delivery, not the separate framework lifecycle hooks such as
onChain, onIndex and onStop.
tests/tunnel_callback_dispatch_policy_test.py checks tunnel C sources and
headers for callback-field reads, including direct calls and captures, while
allowing handler assignments and ignoring comments and literals. Run it through
the production source-policy selection; it does not require a compiled binary.
Permanent Per-Line Tunnel Bypass
Every new or recycled line starts with active_tunnels_mask == UINT64_MAX.
Bit t->chain_index enables that tunnel's flow callbacks. On the line's owner
worker, tunnelBypassLine(t, line) permanently clears that bit; there is no
re-enable operation. tunnelIsActiveForLine(t, line) queries it.
Bypass covers all six events in both directions, including Init and Finish.
The direct tunnelUpStream* / tunnelDownStream* helpers include their supplied
target in the search. The tunnelNextUpStream* / tunnelPrevDownStream* helpers
start at the neighbour. Both follow actual next/prev links until an active
tunnel is found; chain-index order does not determine the path. Forwarding
defaults, context delivery, pipe delivery and packet-anchor publication use
these helpers. Raw callback-pointer calls do not perform bypass resolution.
A tunnel may bypass itself only when it is fully transparent along those links
for that exact line. It must call its LinestateDestroy routine before
tunnelBypassLine(). Destruction must settle retained buffers, timers, queued
work, references and other cleanup obligations, then zero the entire aligned
t->lstate_size reservation, including padding. Merely zeroing pointers does
not replace releasing the resources they own. tunnelBypassLine() asserts every
byte of that region is zero in Debug before clearing the bit; it performs no
cleanup itself. The tunnel will not receive a later Finish.
A node with zero per-line state and no cleanup obligations needs no destroy
routine; it can bypass directly once its one-time work is complete.
Line owners, endpoints, branch selectors and any tunnel
still needed for routing, transformation or flow control must remain active.
Every valid dispatch path must reach an active endpoint. Skipping a tunnel does
not authorize callbacks into an uninitialized target or change buffer ownership.
Publish bypass before calling outward if a reentrant callback should skip the tunnel. Bypass does not cancel an already-running callback, and ordinary line reference/aliveness rules still apply. A newly created companion line starts with every bit enabled independently of its source line. For persistent worker packet lines, clearing a bit affects every packet using that worker's line. An immediate certified packet enqueue sink must remain active for inline delivery to be eligible; bypass must not redirect that privileged delivery to an uncertified neighbour.
Router and SniffRouter retirement
Both routers borrow the source owner's normal line. Router can choose a route during Init when all rules depend only on existing metadata; payload-dependent rules and SniffRouter defer branch Init until classification. Publish the selected route and the startup replay guard before calling branch Init. Reentrant upstream input must wait until that Init returns and follow the original classified bytes. Keep the guard through every replay handoff so newer input cannot overtake the retained suffix. Finish clears local state; an outer replay must stop even when the borrowed line remains logically alive.
Nested startup stream input coalesces in the existing ordinary pending buffer, with an inclusive 2 MiB logical limit. This limit applies to newly retained reentrant input, not to the original admitted classification batch. Both routers latch the incoming UDP transport during Init, before branch context rewrites. Each keeps datagrams in a separate FIFO and only copies bytes into the sniffing view. The FIFO, including startup reentry, admits at most 2 MiB of payload, 4 MiB of buffer capacity charge, and 1,024 entries; empty datagrams retain their boundaries and consume entry and capacity allowance. Refusal destroys local state and finishes only initialized directions, with the still-live previous side last. Complete initial input retains the classifier's existing size policy. Once startup replay settles, opaque forwarding needs no additional queue.
Router destroys its state and bypasses after selecting the actual next path,
including a rule whose callable target equals next. SniffRouter bypasses only
its default-next route. Configured SniffRouter targets retain their distinct
upstream-Est suppression, even when a target equals next. Alternate branches
remain active in both nodes because generic bypass cannot preserve their routing.
Pause/Resume already relay every repeat to the selected branch and do not gate
replay; before branch selection there is no initialized upstream recipient.
How a Line Starts
A normal connection begins with Init, originated by an adapter:
- An adapter (e.g.
TcpListener) accepts a socket and creates aline_t. - It calls
tunnelNextUpStreamInit(). - That invokes the next tunnel's
fnInitU, which initializes its own line state and forwardsInitfurther upstream. - Each middle tunnel initializes its per-line state in turn.
- The final adapter (e.g.
TcpConnector) starts the real network operation.
A tunnel's Init can synchronously trigger more callbacks before it returns —
Payload, Est, Pause, Resume, or even Finish — because the downstream
tunnel it called into may immediately push data back or close. This is the same
re-entrancy hazard described below; it simply starts at line birth.
Rules for Init:
- Initialize this tunnel's line state in
Init. Later callbacks are allowed to assume that state already exists. - Do not add an
initializedboolean to guard later callbacks. In the normal WaterWall lifecycleInitalways runs first for a tunnel; aninitializedflag is almost always a band-aid over unsafe control flow. Add one only when an explicitly documented line-role/callback contract truly requires state before ordinaryInit, and cover that exception directly. - If your
Initcalls a re-entrant forwarding helper and then still needs to touch the line afterward, protect it (see Line References). If yourInitjust forwards and returns, you do not need to hold a reference.
Resolve Context Before Propagating Init
For a normal line, a node that selects, changes, or validates source or
destination context must complete its own context decision, including any
admission or branch decision that determines that handoff, before propagating
Init to the next stage. Publish the resulting address/domain, port, protocol,
source identity, selected branch, and required association state before calling
out. The next stage may immediately consume or snapshot that context in Init;
changing it afterward cannot repair an earlier routing or connection decision.
Initialize this node's local per-line state as soon as Init arrives. Holding
propagation does not defer local initialization. If the necessary information is
already available, complete the decision and propagate during Init. If it
requires incoming bytes or asynchronous work, process that work locally and
withhold outward Init until the decision is complete. Retained input must stay
ordered and bounded. Do not forward Payload into a stage that has not received
Init; preserve the startup FIFO and re-entrancy rules when releasing input.
If the decision rejects the line, close only the stages already initialized,
following the ordinary Finish and line-ownership rules.
This requirement concerns this node's own decision for the exact line and
branch being initialized. A later node may legitimately perform its own
context rewrite. A separately created child or carrier line has its own context
handoff and ownership; initializing that transport does not require completing
protocol negotiation that needs the transport to exist. Est remains transport
readiness and follows its separate forwarding rules.
Examples include DomainResolver waiting for destination DNS, HeaderServer waiting
for its port or PROXY header, and protocol servers publishing their selected
target and association before backend Init.
Line Lifetime: refcount and alive
A line_t carries an exactly 32-bit atomic refc and a boolean alive (see ww/net/line.h).
These are two different ideas, and conflating them causes bugs:
lineRef(line)incrementsrefc. It guarantees the allocation memory will not be freed while you hold the reference. A line reference is not a mutex or lock: it provides no mutual exclusion and does not transfer worker affinity.lineUnref(line)releases one reference. A final release physically reclaims only a logically dead line; releasing a live line to zero is a fatal contract violation.lineDestroy(line)setsalive = falseand drops the creator's reference.lineIsAlive(line)reports whether the line is still logically open.
The crucial distinction:
Holding a line reference keeps the allocation memory valid. It does not lock line contents, grant cross-thread access, or mean the line is still logically alive. After a re-entrant callback, a referenced line may be
alive == false. Always re-checklineIsAlive()before continuing.
Logical death is immediate; physical reclamation is not. lineDestroy()
clears alive and drops one reference. If anyone else still holds a reference
via lineRef() the allocation remains present until they unref — which is
exactly what makes the owner postcondition below both checkable and cheap:
lineRef(l); /* keep the allocation present for the assertion */
owner->fnFinD(owner, l);
assert(! lineIsAlive(l)); /* logical death is immediate */
lineUnref(l); /* physical free may occur right here */
A logically dead line must never receive another flow callback, and by the time it is reclaimed every tunnel's line state must already be zeroed.
The thread which drops the final reference is not necessarily the line's event
worker. A device thread or unregistered scheduler can hold
a legitimate reference while the owner destroys the line and closes message
admission. The final lineUnref() therefore returns the allocation through a
thread-safe shared-pool path when no registered event worker is current; only a
registered event worker may use the local-pool fast path. Chain teardown treats
checked-out line-pool items as a hard ordering violation, so the shared master
pool remains alive until every outstanding reference is gone.
The reference count is also the publication edge for final reclamation. Every
acquire-release decrement publishes the releasing thread's preceding teardown
writes; the RMW which observes 1 -> 0 acquires that release sequence before reading alive, zeroed
tunnel state, routing/domain storage, or credentials. Code that adds another
direct reference-count decrement must preserve this release/acquire protocol.
Do not rely on worker-message locks or an unrelated readiness flag to publish
line state to a foreign final releaser.
Line reference counters use atomic_u32_t from
ww/libc/watomic.h. Access published counters through its atomic*U32*
helpers, including in tests. The generic atomic API uses pointer-sized storage
on the Windows fallback and cannot operate on these 32-bit fields. Incrementing
a zero or maximum reference count is a fatal invariant violation.
Authenticated identity publication
AuthenticationClient separates cached admission from control-session readiness. Every credential family and new
connection admission checks started && !stopping, a valid installed table/clock basis, no terminal continuity error,
and no sticky explicit Authenticate rejection under control_mutex -> users_lock -> collection/user locks.
A public availability precheck is only a snapshot; the actual lookup/reservation must recheck it under those locks.
After initial synchronization ordinary outages/re-authentication and malformed refreshes keep the last usable RAM cache
without an age timeout. Only a correlated authentication-failed for the current Authenticate sets rejection; only a
validated successful Session clears it. After a valid producer credential match, session capacity/token/epoch refusal
returns session-unavailable, preserving the old session/checkpoint and usable client cache/pending batch while the
existing generic RemoteError path reconnects with bounded backoff. accounting-continuity-lost remains distinct.
Session expiry/timeout does not fabricate credential rejection. Existing
connections retain accounting, cached enforcement and reservation release during rejection. Reused HTTP requests
perform new admission; in-flight exchanges and CONNECT streams continue.
A correlated stats permission refusal keeps this cached admission policy. Nonterminal push failures preserve the exact batch and first-use/debt state, reconnect and reauthenticate after a monotonic not-before deadline of at least 30 seconds (or a larger configured reconnect interval). Required retry scheduling remains active with optional intervals zero; explicit work, first use and Resume cannot bypass it. Only a matching batch ACK clears refusal recovery. No pull may replace the baseline while that batch is unresolved, so cached policy may remain stale. Terminal epoch continuity, sequence and replay inconsistency still block new admission. This recovery needs the same live server checkpoint; startup permission changes or process restart do not provide checkpoint recovery.
A required/in-flight pull defers first-use reporting while preserving its request latch and per-user flags. After a non-accounting refresh failure, an installed usable cache may report against its unchanged baseline between traversal attempts. Pull and reporting retries retain separate deadlines; never send a new batch during an active traversal. Pull/ACK completion continues remembered reporting. Paused control writes do not rearm the short progress timer; Resume restarts eligible work explicitly. Worker quiescence cancels both progress and reconnect timer roots.
Credential eligibility and handle liveness check cached enabled state, expiry and cumulative traffic quota. Persisted IP/connection/device/speed gauges must not reject credentials. Live capacity is the separate atomic local reservation operation. Local allowances can overshoot global quotas across disconnected clients; administrative changes arrive with a valid refresh, and already-produced usage remains acceptable after policy changes.
Consumers that must try several UUID-derived credentials use AuthenticationClient's typed UUID source metadata and
generation-checked copy pages. A page visits at most 256 source rows, including rows without UUID credentials, and
returns only owned UUID/durable-ID values. Callers must bound their retained copies and crypto work, wipe copied
credentials on release, and never retain private user pointers or perform crypto under database locks. Every page in
one candidate snapshot uses the same installed users_generation; a changed generation invalidates unfinished
construction. Include currently ineligible UUID accounts in the snapshot and recheck eligibility after protocol proof.
authenticationclientResolveUUIDCandidateWithProfile() checks the current UUID owner, durable ID and continuous
membership generation floor with availability, eligibility and profile copying under the normal lock order. Ordinary
refresh preserves continuous identities; removal/recreation or UUID reassignment invalidates older candidates. The
returned handle uses the current generation. Snapshot limits report configured sync bounds, not a process-wide RAM cap.
Shared credential copying in lineAddUser(),
lineAddAuthenticatedCredentials() (including lineSetAuthenticatedCredentials()),
lineCopyUsers() and AuthenticationClient profile lookups intentionally fails
fast under the project heap-allocation policy.
The memory manager terminates on OOM before a failed copy can return. These APIs
must never return or continue with partially copied identity. Existing signatures
and ordinary cleanup remain unchanged; this contract does not require secure wiping
of these copies.
Absent fields retain their existing semantics. A null profile output
still requests only the handle and requires no profile-string copies. The global
stringDuplicate() function returns NULL for a null source; copying a non-null
source follows the fatal allocation policy, including node-local copies.
Recoverable shared-copy results, transactional staging and caller rollback are an explicit policy redesign requiring a maintainer request. Do not introduce them as incidental hardening of these shared APIs.
Paged client table publication
Producer synchronization uses shared request 9 (GetUsersPage) and Users response 3. A traversal owns a private
incremental table and page clock ranges. It replays an unresolved usage batch before starting and keeps pull_required
through every page, inter-page gap and terminal validation. A pending correlation represents one request; removing it
must not release the traversal barrier. First-use/push work remains latched and local traffic continues accumulating.
Under control_mutex, pending capacity gates request admission and short progress continuations. Full capacity
retains desired pulls/pushes/catch-up work without batch reconstruction, warnings or a one-millisecond retry loop.
Include ordinary pull_requested in runnable work. A recognized response completes semantic validation and
enrollment/ACK/error processing before waking queued work; raw pending removal never sends while locked.
Local send rollback defers continuation after actually releasing capacity; stale correlations release nothing.
Optional jobs and request timeout may all be zero: waiting indefinitely for an unanswered request must not busy retry.
Multiple Pings retain their own absolute deadlines and the timer tracks the earliest one.
Periodic ticks join an active traversal. An explicit authenticationclientRequestPull() during traversal coalesces
into one later traversal, and installation preserves that latch. Producer pages retain an immutable server snapshot,
so configuration edits do not invalidate its continuation or final validation. A failed refresh releases staging and
backs off that pull without preventing new reports from an already installed baseline. Due pull retries still get a
turn under continuing traffic. Initial synchronization and unresolved-batch replay remain barriers.
Every request binds the current epoch and last acknowledged sequence/digest. Only matching ACK/enrollment updates
those local facts. Usage ACKs require strict bounded JSON validation before settling counters or releasing retry
storage; reject trailing input, duplicate decoded keys, decoded NUL and malformed UTF-8 without changing accounting state.
Check exact decimal-string metadata, instance/config revision, total, contiguous positions, bounded
count, canonical advancing cursor, completion flags and checkpoint before admitting rows. Count exact received user
object spans before construction. Positive host-representable sync-max-users/sync-max-bytes default to 100000 and
268435456. They bound one traversal, including an early declared-total refusal, without whole-total reservation.
Resource or protocol failure releases staging only and preserves active cache, debt, clocks and generation.
Each producer session retains a reference to an immutable encoded snapshot. Sessions may share a snapshot acquired
at the same configuration and statistics revision; a new acquisition cannot reuse pre-ACK counters. Snapshot offsets,
rows and construction scratch share the server's finite snapshot-max-bytes allowance (256 MiB by default).
Capture and page reads hold server state ownership and release it before transport callbacks. Terminal validation,
replacement, session expiry/destruction and a renewable idle lease release references; the default lease is 60 seconds.
Resource refusal never publishes a partial snapshot. Administrative list-users retains its separate live revision
cursor contract. Each page carries current wall/accounting timestamps, while snapshot revisions and records remain
fixed. Envelope revisions may be newer; installed revisions come from the validated snapshot, with latest observed
remote revisions stored separately. The client requires terminal validation before publishing.
Before runtime movement, finish structural/ID/index validation, baseline copying, page-clock coverage and current debt
completeness. Under the table lock, merge current local deltas/observations, then migrate reservations, shaping and carry.
Migration/publication is a nonfailing commit. Reapply each user's OWN page wall/accounting/receipt samples after migration,
because runtime migration replaces clock state. Publish active and baseline together and increment generation once.
Keep relative catch-up debt even if saturation hides it or an account is absent; never prune debt to finish a traversal.
An absent account's owed traffic and first-use evidence move into retained orphan obligations before publication.
They do not block installing other accounts and are not sent while the ID is absent. The same durable ID's later
return reattaches its obligation exactly once. The retained ledger and replacement overlaps have a finite allowance
derived from sync-max-bytes; capacity refusal preserves the old table and every obligation. Status includes
orphan_debt_count, and detailed accounting includes these obligations. Authoritatively absent IDs cannot admit new
connections; live-handle accounting/close and shaping-ticket settlement retain their existing ownership rules.
Retain the original local first-use origin, report timestamp and strongest elapsed-age sample alongside orphan debt,
including observations already acknowledged before later bytes became owed. Restore that evidence after runtime
migration and before installing the new clock projection; absence must never restart duration expiry. Each newly
appearing account receives a handle-generation floor. Continuous membership migrates that floor, while removal and
reappearance establish a new one: an older handle must neither become active again nor release a new connection's
reservation. Independently owned shaping tickets still settle by their own tokens.
Only an explicit usage-accounts-missing response may resolve a rejected batch without an ACK. Validate its current
epoch, exact attempted sequence and payload digest, unchanged prior checkpoint sequence/digest, and unique missing
IDs belonging to that batch before releasing immutable attempt storage. Preserve debt, baseline and checkpoint;
the uncommitted sequence remains reusable for the surviving accounts. A lost reply or unproved/malformed refusal
never permits rewriting an uncertain batch. Exact accepted-batch replay remains acknowledgeable after account removal.
Account configuration and accounting ownership
usersPatchConfigByIdentifier() pins an existing user with the collection write
lock through validation and commit. Its typed user_config_patch_t has a presence
bit per editable leaf and carries no credentials, counters, runtime state or
creation/first-use timestamps. It reuses the existing string/CIDR preparation and
index transaction. Finish every expected duplicate/overlap/capacity precondition
before publishing fields, then apply only selected leaves under user->lock.
Never merge a previously copied whole timeinfo or limits structure into a patch:
that can overwrite concurrent first-use accounting or omitted policy fields.
Collection-to-user lock order remains unchanged; first-use updates share that
user lock. Configuration edits preserve live reservations and client refreshes
migrate them by durable ID while rebuilding the expiry projection.
Collection lookups return borrowed user/string pointers whose lifetime needs external collection ownership when removal
or replacement can run concurrently. Successful non-self usersCopy(dest, src) replaces the destination and invalidates
all of its old user/string pointers; failed copy preserves them, and self-copy is a no-op. Standalone indexed-field
setters such as userSetId and userSetWireGuardAllowedIps must not mutate accounts already owned by a collection;
use collection transactions so every lookup index changes together. Runtime self-move is also a no-op.
Each owned name, password, email, notes and normalized Allowed-IP string carries its derived JSON-escaped content charge and encoding/representability result. Prepare replacements before shared locks and transfer their metadata with their text; imports, name-map hints, setters, snapshots, copies and moves preserve this ownership invariant. Generic object callers may hold unexportable text, recorded by that result. Never repair missing metadata by rescanning stored strings under API transaction locks. Direct fixture replacements must update the same metadata.
A successful prepared configuration patch transfers its owned values, clears its aliases/presence masks and consumes the preparation; only destruction is valid afterward. Busy, timeout and other precommit refusals leave it reusable. Prepared account creation remains reusable, including a later duplicate-ID attempt after successful creation.
Fresh JSON imports derive each credential once and use structural/index validation. Trusted baseline copies preserve
those derived values without recomputation. Public usersValidate and userPasswordDataValid still perform the full
credential consistency diagnostic in every build, and arbitrary caller-supplied checked-add objects retain that check.
An incremental required-ID import rolls back only its own appended rows on failure; successful pages never copy or
rederive the accumulated table. The collection keeps full diagnostic, standalone and legacy update APIs available.
AuthenticationServer validates at most 64 request frames before executing an envelope. The session at entry governs all siblings, and successful Authenticate does not authorize adjacent requests. Without a valid token, frame payload is at most 16 KiB. Authenticate data is at most 16,375 bytes and uses strict quiet JSON parsing with depth two and eight lexical values; configured credentials must fit the exact escaped handshake including a resume epoch. Failed Authenticate closes further input admission before response handoff while preserving bounded paused response delivery. Only authenticated administrative refusals and commits enter the audit queue; anonymous refusals consume no slots.
Producer module descriptors are the single source for operation names, permissions, store mutation, state-lock class and handlers. Parse each envelope once into at most 64 borrowed frame views before any operation; run mutation-first and read-second over those views under one state lock. Authenticate mutates session state without becoming a store mutation. Page requests take exclusive ownership for snapshot bookkeeping; point reads take shared ownership. An unauthenticated envelope without Authenticate returns bounded refusals under shared ownership. Session resolution must be repeated after reacquiring exclusive ownership. Unknown wire operations remain ordinary bounded refusals. Digest lookup uses canonical base64 with exact decoded lengths; password payloads stay length-bounded and are copied before C-string use.
Request 9 is paged synchronization (GetUsersPage, with the retained GetAllUsers C alias). Each request independently
checks token, pull permission and the producer's epoch/acknowledged sequence/digest. Instance/config/position
cursors bind the session's retained snapshot, not authority. At most 128 users and a 256 KiB response target form ordinary pages; one
exceptional first row may use the hard response cap after exact metadata overhead. Admit encoded row size before
copying its strings. A terminal cursor requires one final validation under the same state lock before client publication.
Configuration and traffic edits leave an acquired snapshot unchanged. Paging never acknowledges usage.
See the producer page schema.
AuthenticationServer UpdateUser selects a nonzero exact durable ID and accepts
only explicit configuration leaves. It rejects protected/unknown/duplicate keys,
empty containers, null, invalid UTF-8/NUL, excessive nesting and trailing input.
The checked lexical parser consumes the full authentication payload with depth
limit four and at most 64 lexical values before JSON-tree allocation, without applying host-config file-size limits. Successful nonempty
patches bump config_revision once, even for a same-value assignment, inside the
server state write lock; stats_revision and producer checkpoint are unchanged.
Competing patches serialize: omitted fields survive and the latest accepted edit
to the same field wins. There is no expected-revision precondition or remote reset.
For example, a name-only patch after a 100-byte account receives 10 more bytes
leaves 110 bytes and its first-use timestamp intact.
Password replacement is a separate prepared collection transaction, exposed by
AuthenticationServer's explicitly permitted change-user-password API. It checks
the server-wide edit token and changes the password plus all four credential
indexes together. Configuration-only patch types and producer UpdateUser still
cannot change credentials. Preserve the stable user address/ID, configuration,
traffic, first-use evidence, creation/expiry, statistics revision, producer
checkpoints and live reservations; a credentials-only commit takes no statistics
lock. Exact password bytes matter. A UUID-form password adds or replaces its UUID
identity; changing to plain text removes it. Case-equivalent UUID spellings keep
that identity for the same account, while changed derived keys stop resolving.
Clients receive the change on their ordinary refresh schedules. Before a valid refresh, an offline or connected cache may still accept its old credential. Refresh migrates reservations, accounting clocks, first-use/ACK evidence and expiry projection by durable ID, including pending exact-batch replay and traffic produced during a pull. Existing connections retain accounting and reservation release; rotation does not forcibly terminate them. Public reads omit credentials, so an unknown password-change outcome requires a fresh read and an explicit decision to resubmit the desired password with its current token, without automatic retries.
AuthenticationClient's traffic-multiplier converts measured upload/download bytes at its two public traffic
entry points, before counters and quota enforcement, using fixed-point integer arithmetic with scale 1000.
The startup-fixed JSON number defaults to 1, accepts 0.001 through 1000 and at most three effective decimal places.
Callers must supply raw measured bytes without pre-scaling. Store a separate fractional charge remainder per user
and direction; rounding each payload independently changes the charge with fragmentation. Update remainders,
charged counters and first-use evidence in the same user/stats transaction. Migrate the remainders by durable ID
on refresh, including same-ID credential changes; reconnect/ACK must not clear them. Generic copies, database JSON
and sync batches exclude these process-local remainders. Removal/destruction discards them, with no crash recovery.
Apply checked saturation to charged counters without unsigned wraparound.
Existing and pulled counters, batch deltas, baselines, immutable retries and catch-up debt already use charged quota bytes; never scale them again. AuthenticationServer and other producer/import paths accept charged bytes as supplied. Traffic limits and API usage/accounting totals use the same units. Bandwidth, speed, reservations and timestamps keep their existing meanings. Start first-use expiry from a positive raw measurement even when its charged increment is zero.
Remote usage reporting uses only deduplicated PushUserStats batches with root
epoch, sequence, users and entry id, up, down, first-usage-at-ms, all required and no other
fields. The same strict lexical rules apply. Recognize an exact replay using its
original payload bytes before consulting current account existence; a subsequent
account edit/removal must not prevent that ACK. New batches validate all entries
before any mutation. Server aggregate bytes and the earliest accepted first-use observation are
accounting state; creation is import metadata, while limits/enabled/expiry are
configuration. Connection/IP reservations remain local runtime state. Stored or
imported IP/device/connection/speed gauges are not live global occupancy or an
admission source. See the statistics ownership table
for the current boundaries and requirements for future telemetry. No live gauge
reporting, reset, or crash-recovery behavior is implied.
Producer write JSON is bounded before tree allocation: PushUserStats allows
depth three and at most 7 + 9 * 4096 lexical values (including keys),
UpdateUser allows depth four and 64 values, and full AddNewUser allows depth
four and 256 values. Keep these independent of the 16 MiB wire cap and preserve
credential-string cleanup on post-parse refusal. Envelope response assembly
reserves space for a correlated error for every remaining frame, so an oversized
read response cannot discard the acknowledgement of an earlier committed write.
First nonzero measured client traffic, including a discounted increment below one charged byte, captures a valid local monotonic origin (zero is allowed) and immutable positive accounting-clock estimate inside the user write lock, then updates counters/expiry under stats write lock. Generic server/import helpers without a client clock never invent observations. Both client traffic APIs use this transaction; zero-byte calls, login, reservation and refresh rebasing do not start use. Scheduling latches are separate from evidence.
GetAllUsers requires exact canonical server-time-ms and positive accounting-time-ms, with no local-wall fallback.
The server initializes one wall/monotonic accounting anchor per instance, then advances it only with fresh monotonic
elapsed time and saturation. Clients sample receipt before parsing and retain maximum elapsed first-use age across
local observations, prior age and canonical estimates. Checked differences never rewind an anchor. Future imported
origins remain future until reached or superseded by real earlier use. Duration and absolute-date policy project
separately; unchanged pulls keep the earlier absolute deadline and never extend relative lifetime. Explicit edits
recompute only their component. Already expired at monotonic zero must remain expired, not become unlimited.
Generic user/user-collection helpers accepting expiry now_ms use local
monotonic milliseconds when a client expiry projection is valid. Without that
projection they use the wall-time domain of persisted absolute/first-use expiry.
Do not pass pacing time to an unclocked account's expiry check. Client traffic
observation/accounting helpers with an installed accounting clock sample fresh
monotonic time while locked instead of using the caller's fallback now_ms.
userSetClientViewExpiry() changes only the local expiry projection: valid zero
means no projected expiry, and clearing validity restores persisted wall-time
expiry. It clears the projection's expired marker, while retaining accounting
anchors, first-use/ACK evidence and age. A later clock installation or observed
traffic can rebuild the projection from that retained state.
Runtime clock/evidence/ACK state and fractional charge remainders migrate by durable ID with reservations, including same-ID credential changes, under ordered collection locks, both user locks, then both stats locks. Keep new authoritative policy/timeinfo and rebuild projections before table publication. Generic user/users copies and JSON exclude these process-local fields. Explicit accounting snapshots capture counters and observations together and retain only owned values across replacement.
The runtime-migration destination must be an unpublished staging table without live reservations or waiters of its own: moving state retires the destination's previous runtime storage. Migration keeps its newly imported policy/timeinfo, transfers matching source accounting clocks and reservations, and requires fresh per-user clock projection before publication. It is not a merge of two active runtime tables.
Every batch entry includes the producer's same immutable observation, even after its ACK. Zero-delta metadata-only entries report unacknowledged observations at saturation; matching ACK settles only included totals/observation markers and keeps origins/age. New batches validate all entries before mutation, sample accounting time once, clamp future estimates and merge the smallest nonzero canonical observation, then publish checkpoint and one stats revision. Configuration patches cannot write these fields; unrelated missing-only first-use APIs retain their contract. Cross-host estimates include network/serialization delay and rate drift; local monotonic suspend semantics remain unchanged. This introduces no crash recovery, remote reset, live telemetry or global reservation coordinator.
WIO Descriptor Ownership
A wio_t stores its owned primary descriptor directly as int fd, defined in
ww/event/wevent.h. wioGetFD() returns that descriptor, or -1 after it has
been closed or released. Descriptor zero is valid. The descriptor has no separate
allocation, reference count, or pool; only the WIO allocation has deferred
lifetime protection.
WIO close removes watcher interest and settles its queued writes, invokes the
close callback while the descriptor is still available, then invalidates and
closes the descriptor exactly once. Deferred graceful close keeps it open until
draining ends. Reentrant freeing in the close callback preserves the WIO
allocation until that close frame returns. On Windows, the native close kind is
captured at descriptor adoption, so sockets use closesocket() and CRT file
descriptors use _close() even if the logical I/O type changes later.
The separate io_slot field preserves descriptor-table bookkeeping after fd
becomes -1. It is never used for I/O. Pending and native IOCP operation roots
protect the WIO allocation after close; they do not keep its socket open.
NIO splice reads move socket bytes into a wrapper's private pipe before invoking the callback. The wrapper contains neither a source pointer nor a source reference; its length reflects the actual transfer. After delivery the read path does not inspect the source or buffer, so callbacks may close/free the source immediately. No socket reservation or post-callback source guard is needed for this read path.
Each splice allocation exclusively owns its private pipe. Buffers can be retained or partially consumed and deferred immediately, and source WIO reclamation cannot affect them. Queued writes preserve their private pairs until completion or discard. Graceful close drains them; cancellation drains or closes each private pair before recycling. See the private-body rules and AAA/BBB example. This source-independent lifetime does not make pipe bytes ordinary pointer data: parsers and retaining transforms follow the BufferStream representation contract in Part 3.
Freeing a pending NIO detaches its descriptor-table slot immediately but defers recycling the WIO allocation until the pending dispatcher releases it. Shutdown also settles these detached pending allocations before destroying the WIO pool.
pending also prevents reuse of the allocation for a new descriptor. If the OS
reuses an fd whose closed NIO is still pending, wioGet() retires that old object
on its original loop and creates a fresh WIO for the new connection. The old
pending entry never follows an accepted socket to another worker. A closed NIO
whose pending dispatch has finished can still be reused in place.
wioDetach() runs on the original owner thread and wioAttach() on the
destination owner thread. Both require stopped read/write interest and no
pending dispatch; a pending WIO triggers LOGF() and abortProgramNow(1).
Stopping interest alone does not release pending-list ownership. Defer a
handoff until the original loop has finished dispatch. SocketManager hands off
newly accepted, non-pending WIOs, including when an fd number was reused.
The dispatcher retains its cur->loop == loop ownership check as well.
wioReleaseNoClose() preserves descriptors owned by another subsystem, such
as c-ares. It removes watcher interest, detaches the WIO's array slot, and sets
fd to -1 without closing the external descriptor. The external owner remains
responsible for closing it. Pending/native operations and callback frames still
settle before the WIO allocation can return to its existing pool.
SocketManager endpoints and selection
SocketManager owns one separately allocated endpoint per physical listening
socket. Its persistent pointer collection is the sole listener/UDP side-data
inventory. An endpoint owns its interface-scope string and authoritative WIO slot;
its lifetime must not depend on the logical filter that first created it. WIO
userdata points to the endpoint, including on newly accepted TCP sockets. Static
UDP callbacks resolve the endpoint's existing udpsock_t; posted datagrams retain
that public side-data pointer. Dynamic UdpListener endpoints use their separate
provider-owned lifecycle.
Physical sharing requires equal protocol, bind family/address/wildcard class, bound port and effective scope. Dispatch uses the receiving endpoint's scope as eligibility: a scoped arrival admits that interface's filters and unrestricted filters; unknown scope admits only unrestricted filters. On platforms without device binding, the resolved interface bind address supplies address eligibility. The shared TCP/UDP selector must apply the same protocol, logical port, scope, address-tier and ACL predicate to fresh candidates and cached targets.
Address tiers remain outermost: exact, same-family wildcard, then dual-stack IPv6 wildcard for IPv4. Priorities scan high to low with registration ties kept. An eligible unbalanced filter returns immediately, including after balanced candidates accumulate. Balanced selection retains the first group's candidate set and cached-hit short circuit. Sticky keys include normalized client/local IPs, logical destination port, address tier, effective scope and protocol; client source port is not part of this key. Redirected arrivals retain their logical original port separately from the physical endpoint's bound port.
Selection owns no WIO, buffer or line and performs no transport/tunnel callback.
TCP applies the selected filter's socket options before committing a new sticky
entry and detaching/posting the accepted WIO. Failed option setup closes once
without trying another filter. UDP commits before posting its buffer/side-data.
Cached hits refresh during selection; failed cache insertion still allows delivery.
Balance-table pointers live privately in filters; socket_filter_option_t
contains listener configuration only.
TcpListener still creates/owns accepted normal lines; UdpListener still
creates/owns the peer lines in each physical socket's worker-local idle tables.
Scheduled Line Tasks
lineScheduleTask(), lineScheduleTaskWithBuf(), and the two delayed variants
transfer one task to the immutable event worker recorded by the line. Immediate
work and a delayed call with delay_ms == 0 are always queued for a later loop
iteration; neither executes inline. Timer creation and task execution remain
owner-worker-affine even when submission originates on another worker, the
device thread or an unregistered thread.
Submission precondition
The caller must hold an independently synchronized live handle until the scheduler takes its own line reference. That means both:
- a physical reference keeps the allocation present; and
- a gate, registry lock, or equivalent publication protocol prevents
lineDestroy()from racing the call.
An unprotected raw pointer is invalid. A physical reference alone is also
insufficient because it does not keep alive true. The scheduler retains the
line after the call, but it does not retain the supplied tunnel_t; the runtime
or component must keep that tunnel valid until task-or-cancellation settlement.
What the result proves
Every scheduling result is line_task_submit_result_e and must be inspected:
| Result | Synchronous proof |
|---|---|
kLineTaskSubmitRejectedSettled | The task will not run. Optional cancellation notification and all scheduler-owned record, line-reference, and buffer settlement have completed before return. |
kLineTaskSubmitAcceptedAsync | Ownership entered an asynchronous queue or setup path. It does not prove that a positive-delay timer is armed, that the task remains pending when observed, or that the task will run. |
kLineTaskSubmitTimerArmed | A non-zero-delay call made on the line's owner worker installed its timer before return. Later line death or quiescence can still cancel it. |
The immediate APIs never return TimerArmed. Delayed zero returns
AcceptedAsync. A positive delay submitted on the owner returns TimerArmed
after successful installation. The same positive delay submitted from any
other thread returns AcceptedAsync once owner-side setup is queued.
Scheduling records and timers use normal fail-fast allocation. Positive-delay
worker messages install their timer with wtimerAdd(). On the loop's owner
worker, timeout records use genericpoolGetItem() from a worker-local pool
backed by GSTATE.masterpool_timers. Local width is RAM_PROFILE; master width
is 2 * RAM_PROFILE. Pool constructors double those widths for cache capacity,
and local refills request RAM_PROFILE records. Allocation either supplies
storage or terminates the process; heap exhaustion is not a scheduling
cancellation reason and does not invoke cancellation cleanup.
Every pooled checkout resets the record. Expiry, explicit deletion, quiesced/deferred deletion, loop destruction, and pending-event reclamation return records only at final release; an executing callback's pending timer cannot be reused by a nested submission. Worker pools outlive loop cleanup and the shared master outlives all workers. Foreign startup callers and standalone loops without a pool use normal event allocation without borrowing an owner's local pool. Period timers also retain normal event allocation.
Shutdown admission can still refuse timer installation. On the owner worker,
that refusal reports AdmissionClosed and returns RejectedSettled. A foreign
submission returns AcceptedAsync once setup is queued, and may subsequently
be canceled before arming. Cancellation may be delivered before, concurrently
with, or after the submitter observes acceptance. The task never runs inline:
doing so would violate its minimum delay and can make a self-rescheduling task
recurse without bound.
Independent delayed tasks have no equal-deadline FIFO guarantee. Ordered work
belongs in one explicit queue drained by one scheduled task.
Exactly one terminal outcome
A non-null LineTaskCancelFn makes the public outcome exactly one of task or
cancellation. Cancellation may run synchronously and re-entrantly before the
scheduling call returns, later on the owner worker, on the submitting thread,
or during detached-queue teardown. The scheduler holds its physical line
reference during notification, but the line may already be logically dead and
its tunnel line state may already be destroyed. The callback is notification
only: it owns neither a transferred buffer nor scheduler cleanup.
Set a pending latch before submission so synchronous cancellation can settle
it. A cancellation handler may touch only state whose lifetime and concurrency
are independently proved, such as an atomic tunnel-state latch kept alive by the
component lifecycle. It must not restart normal work during quiescence or
teardown. Passing NULL deliberately declines notification; internal ownership
still settles exactly once.
Cancellation reasons are precise:
| Reason | Meaning |
|---|---|
kLineTaskCancelTargetUnavailable | The target WID has no ordinary event-worker queue. |
kLineTaskCancelAdmissionClosed | The target no longer admits normal work or timer setup. |
kLineTaskCancelEnqueueFailure | Queue-size geometry or wake publication refused submission. |
kLineTaskCancelQuiesced | Worker quiescence settled accepted work before execution. |
kLineTaskCancelTeardown | Detached queue teardown settled the task. |
kLineTaskCancelLineDead | Dispatch found the retained allocation logically dead. |
After foreign admission, submitter and owner progress independently:
submitter lane -> setup admission commits result -> return/observe AcceptedAsync
owner lane -> accepted setup may be canceled -> cancellation(Quiesced)
cancellation may precede, race with, or follow submitter observation
Memory and resource safety are unconditional; task progress is not. Required
work therefore needs a synchronous RejectedSettled fallback and an
authoritative owner/Stop path for cancellation after acceptance. Deliberately
lossy payload and observational-statistics work may pass NULL and explicitly
discard the typed result:
const line_task_submit_result_e result =
lineScheduleDelayedTask(parent, muxclientLogStatsTask, 5000, t, NULL);
discard result; /* statistics are intentionally best effort */
A required pending-state design can use an independently safe atomic latch in tunnel state. Both terminal callbacks clear the same latch; neither assumes that a timer was armed or reaches into possibly destroyed line state:
static void retryCancelled(tunnel_t *t, line_t *line, line_task_cancel_reason_e reason)
{
retry_tstate_t *ts = tunnelGetState(t);
atomicStoreExplicit(&ts->pending[lineGetWID(line)], false, memory_order_release);
discard reason; /* owner drain is authoritative after cancellation */
}
atomicStoreExplicit(&ts->pending[wid], true, memory_order_release);
const line_task_submit_result_e result =
lineScheduleDelayedTask(line, retryTask, delay_ms, t, retryCancelled);
if (result == kLineTaskSubmitRejectedSettled)
{
/* retryCancelled already cleared pending; perform only safe inline policy. */
}
Do not let both a cancellation callback and the RejectedSettled branch perform
the same fallback. Notification has already run when that result is returned.
The shared pipe_tunnel follows the same rule for ordered cross-worker streams.
One explicit pair owner holds the borrowed/owned roles and references; any lost
Init, data, control, or Finish atomically makes the pair terminal. Each side
detaches on its owner worker, only the created companion line is destroyed, and
the owner-worker drain is the no-message fallback. The pair retains
both physical line references until both sides and every queued message have
detached; releasing a target reference at the first-side detach would let a
concurrent sender race storage reuse.
Who May Destroy a Line
Only the tunnel that created a line may call lineDestroy() on it —
lineCreate() or lineCreateForWorker() for that exact line_t instance.
Ownership belongs to the line, not to the node type. Do not infer it from a name such as "Client", "Server", "Listener", or "Connector":
TcpListenercallslineCreate()for every accepted connection and is that line's owner.TcpConnectorowns a socket, not a line. It calls neitherlineCreate()norlineDestroy(); it consumes a line created toward theprevside. Owning an OS resource is not owning aline_t.- A tunnel routinely mixes roles:
MuxClientowns its parent/carrier line while borrowing every child line;HttpClientowns the upload/download transport lines it splits a borrowed main line into;Socks5Server,TrojanServer, andVlessServerown UDP remote lines while borrowing the client line. Decide per line, in the same handler, before cleaning anything up.
Trace each production creation site during review and keep its ownership and Finish behavior covered by the owning component's runtime tests.
Servers creating owned UDP backend lines must copy the observed client's source
address with addresscontextCopy() before backend Init so admission
can enforce source-IP limits while preserving the requested UDP destination.
Ownership and topology
For ordinary connection streams, the usual layout is:
creator/owner -> middle tunnels -> borrowing socket adapter
left upstream toward next -> right
Normal lines are almost always left-owned in this layout. The creator sends
upstream Init; middle tunnels and the right-side adapter borrow that same line.
The owner may itself be a middle node creating a separate parent, child, or
transport line. "Left-owned" describes the path back to that exact line's
creator, not the first node in the configuration file or its chain-array index.
TcpListener, UdpListener, and the head-only UdpStatelessSocket follow this
pattern for accepted connections or incoming peers. Closing their OS resource
at a borrowing node does not transfer ownership of the line.
An ordinary middle stream tunnel may use the upstream-then-downstream local Finish sequence below under this topology contract. The downstream Finish travels back to the owner and may destroy the line; it must be the last use of that line in the close path. This is distinct from handling a received Finish, which is forwarded only away from its sender.
Keep these special cases separate:
| Line/path | Ownership and lifetime |
|---|---|
| Worker packet line | Shared worker-local packet state, allocated and released by the chain. It stays logically alive throughout runtime; a node must never destroy it on Finish. Worker affinity is not per-connection ownership. |
| Reverse nodes and Bridge | Bridge forwards the same line while reversing callback direction; it does not become the owner. Follow the exact reverse-node ownership contract at the direction-changing boundary. |
| Internal companion transport line | A node that creates one owns that normal line, even inside a packet chain. For example, WireGuardDevice creates normal companion transport lines; when transport faces prev, their Init travels downstream. These are not persistent worker packet lines or ordinary left-owned stream ingress. |
The reverse topology has two mandatory direct connections:
Bridge -> ReverseClient
ReverseServer -> Bridge
No node may sit between either pair, including a transparent transform, logger, or limiter. Put extra processing outside these gaps. In particular, ReverseClient's downstream Init must reach Bridge directly; Bridge turns it into upstream Init on the paired branch. An intervening ordinary stream tunnel cannot rely on the usual left-owner model at that boundary. These rules define supported configurations even though startup does not currently enforce the adjacencies automatically; see Part 5.
Do not infer that every normal line in every topology is left-owned, or that an explicit downstream Init handler changes ownership. Trace special/internal creation paths before applying the ordinary stream close sequence. A physical reference keeps allocation memory available; it does not keep a destroyed line logically alive.
Reverse activation and pairing
ReverseServer borrows both normal halves. The local/user half enters its owner worker's waiting list in downstream Init, before establishment can reenter; pairing must not require a first application Payload. Reverse handshake validation still precedes admission of the reverse half. Either arrival order initiates local or cross-worker matching; notifications resolve live peer lists on their owner worker and never carry a foreign line pointer. PipeTunnel owns any normal companion it creates for migration.
After Est returns, hold an unpaired user source with Pause. After validating the complete reverse handshake and owning its accompanying application tail, hold that reverse source while matching. Never pause an incomplete handshake. Publish the combined source-notification latch before callbacks; waiting/startup FIFO holds and peer-consumer pressure are distinct reasons. Activation does not clear consumer pressure. Drain eligible older application buffers before releasing a source with Resume, and preserve both the waiting hold and consumer pressure through PipeTunnel migration. A source may pair, close or admit bounded input reentrantly from any notification.
A pair emits exactly one length-bearing activation prefix before user bytes. ReverseClient owns both of its normal lines and opens its destination only after consuming the complete prefix, independently of application input. The unused pair's existing idle deadline covers partial activation. Publish pair state and retained FIFO ownership before Init/Payload/Est/Pause/Resume callbacks. Hold and recheck both exact halves when continuing initial replay; a close releases all pending buffers before propagating Finish away from its sender. The server hands off activation exactly once even when its consumer is paused, separately from application backlog. This bounded startup exception does not bypass receiver hard limits or Finish, and a paused transport may defer the physical write. Retained application output waits for its consumer's Resume. After activation and initial replay, application forwarding remains direct. See the wire and storage contract in Part 3.
Neither reverse node can permanently bypass itself: every paired delivery maps between two different normal lines, and Finish still needs that association. ReverseServer retains its combined waiting/FIFO/consumer holds as an exact-line endpoint. ReverseClient forwards every Pause/Resume once destination Init has started. Before that Init, it remembers only an outstanding transport Pause; Resume cancels it. Publish destination initialization before calling out, then replay any remaining Pause after Init returns, before handing off the initial application body. Clear the pending flag before notification so nested signals supersede it. Keep the existing activation FIFO guard and bounds through this callback; Pause does not gate the admitted body or nested replay.
ReverseClient accepts activation after transport Init even if Est has not yet arrived. Completing activation moves the pair from its current connecting or unused count to active exactly once. A late or repeated Est marks transport readiness without returning an active pair to the spare pool or replenishing it again. Est during a partial activation may still move connecting to unused; it does not reset the prefix parser or clear pending Pause.
Shutdown-complete owners and adapters
Immediate Finish ownership is only half of the normal-line contract. Every
autonomous creator must also be able to enumerate all of its live lines during
worker shutdown. Publish each new line into exactly one owner-worker inventory
before any re-entrant Init or other callback can expose it, and remove the
entry exactly once on every close path. Clear the slot or idle-table entry
before invoking Finish: the callback can re-enter, and a repeated stop must
see an empty slot and do nothing.
A connection-admitting/source owner stops admission, enumerates its remaining
lines, destroys its state, sends Finish away from any sender as applicable,
and returns with every owned line logically dead. A connection-holding tail
adapter has a different obligation: enumerate every line whose Init reached
it, detach and close its OS resource, destroy its adapter state, and send exactly
one downstream Finish so the real creator closes the line. A tail owns no
responsibility before its Init; the autonomous or internal creator must cover
that interval itself.
Source-owner draining runs on the owning event worker while the event loop,
chain, tunnel states, tail adapters, and line pools are still alive. NodeManager
visits tunnels in chain storage order, which is not guaranteed to be
source-to-tail. A middle or tail worker hook therefore cannot require borrowed
line sets to be empty based on neighbour ordering, and it must not build a
duplicate inventory merely to compensate. Middle tunnels do inventory and drain
normal lines they create internally. Final onDestroy, after all worker drains,
may diagnose a residual borrowed registry. Persistent packet lines and
documented terminal absorbers with no per-line resource are explicit exceptions
rather than missing inventories.
Every other tunnel must propagate Finish and let the owner destroy the
line. Calling lineDestroy() on a line you did not create is a serious bug.
There is also a debug invariant to respect: when a line is finally freed,
lineUnRefInternal() asserts that every tunnel's line state is already zeroed
(debugAssertZeroBuf). That is why each tunnel must destroy its own line state
before the real line-closing Finish propagates — unless it is intentionally
holding a positive reference across the close.
Protecting a Line Across Re-entrant Callbacks
These inter-tunnel calls can indirectly close the line before they return. Treat every one of them as dangerous:
tunnelNextUpStreamInit tunnelNextUpStreamPayload
tunnelPrevDownStreamInit tunnelPrevDownStreamPayload
tunnelNextUpStreamEst tunnelNextUpStreamPause tunnelNextUpStreamResume
tunnelPrevDownStreamEst tunnelPrevDownStreamPause tunnelPrevDownStreamResume
After any of these returns:
- the line may already be dead,
- this tunnel's line state may already be destroyed and zeroed,
- any code that touches
lineorlsis unsafe unless you protected the line.
Never reason: "the call returned, so my line state is still valid."
Method A — lineCallWithRef() (preferred)
lineCallWithRef() and lineCallWithRefWithBuf() hold a temporary reference across
the callback and tell you whether the line survived. Their actual implementation
(see ww/net/line.h) is:
static inline bool lineCallWithRef(line_t *const line, LineTaskFnNoBuf task, tunnel_t *t)
{
lineRef(line);
task(t, line);
if (! lineIsAlive(line))
{
lineUnref(line);
return false; // line died during the callback
}
lineUnref(line);
return true; // line still alive; safe to continue
}
Use it whenever you must keep working with the line after a re-entrant call:
if (! lineCallWithRef(line, tunnelNextUpStreamInit, t))
{
return; // line died; do not touch line or ls
}
// Still alive here. Re-read state if you need it.
my_lstate_t *ls = lineGetState(line, t);
For payload forwarding, use the buffered variant:
if (! lineCallWithRefWithBuf(line, tunnelNextUpStreamPayload, t, buf))
{
return;
}
When a helper returns false:
- do not touch
line, - do not touch this tunnel's line state,
- do not call your
LinestateDestroy()"just to clean up" — the close path that killed the line already did that, - if you still own a buffer you did not hand off, recycle it without touching the dead line state.
Re-entrancy can also cross exact-line boundaries. A callback on line A may enter
a shared neighbouring tunnel that closes a different tracked line B. If a caller
keeps B in a pointer array or registry snapshot and will examine it after the
callback on A, it must retain B with lineRef() before the first callback and
release that reference on every path. Holding a reference only on A, or holding
a registry mutex while copying raw pointers and then releasing it, does not
preserve B's storage.
For example, a SOCKS UDP client can create a backend remote line whose Init
closes the control association and provider-owned client line. The caller must
capture the client's buffer pool and retain the client before invoking the
remote callback; retaining only the remote makes neither the dead client's
lstate nor lineReuseBuffer(client, ...) safe after return. If the client died,
recycle still-local bytes through the captured pool, unref the retained client,
and return without reading either line's destroyed state.
If you call a forwarding helper and then immediately return with no further work
on the line, wrapping it in lineCallWithRef() buys you nothing — the boolean
result would not change your behavior. This is common for plain Init and
Finish pass-throughs.
Method B — Manual lineRef() / lineUnref()
Use manual reference management when you need explicit control across several calls:
lineRef(line);
// one or more potentially re-entrant callbacks ...
if (! lineIsAlive(line))
{
lineUnref(line);
return;
}
// still alive: safe to continue
lineUnref(line);
Rules: always unref what you ref; never read tunnel state after destroying it;
if a helper both destroys your line state and propagates Finish, the caller must
return immediately afterward.
Finish Semantics
Finish is directional and destructive, and it has one property that
makes it special among all the callbacks:
If Tunnel A sends Finish to Tunnel B for a line, Tunnel B must not call any
callback (including Finish) back in that same direction for that line_t.
This guarantee is specific to that line and direction. Handling Finish on line A may synchronously send a payload on, or close, another still-live line B. This can happen entirely on one event worker; worker-local ownership does not prevent it.
For example, A and B may share a UDP socket. While its close handler notifies A, A's owner may cause a send on B before B receives Finish. B being alive, or holding a line reference, does not make the shared socket usable. Shared-resource teardown must close operation admission for all affected users before its first outward callback, and cached send/queue paths must honor that gate. Review callbacks on siblings as well as callbacks on the line being finished; see UDP send retirement in Part 7.
Concretely:
- A tunnel that receives upstream
Finishmust not sendtunnelPrevDownStream*(Init/Payload/Est/Pause/Resume/Finish) back toward the sender for that line. - A tunnel that receives downstream
Finishmust not sendtunnelNextUpStream*back toward the sender for that line.
This is called reflection, and it is a frequent crash source precisely because
tunnels destroy their line state before propagating Finish — reflecting a
callback calls into a tunnel whose state for that line is already gone.
Turn the guarantee around and it becomes a simple, load-bearing rule: because
Finish is non-re-entrant, you are promised you will not be called back on the
finished side — so you must extend the same promise and send nothing back. The
instant you receive Finish from a direction, that direction is closed for that
line. No Payload, no Est, no Pause/Resume, no second Finish, for any
reason, on any later event for that line_t.
Also remember the destruction direction: a downstream Finish that reaches the
adapter which created the line will lead to lineDestroy(). So after you
propagate a downstream Finish, assume the line can be gone.
The Simplest Clean Finish
A middle tunnel with nothing to flush just destroys its own line state and
forwards. This is EncryptionClient's upstream finish, verbatim:
void encryptionclientTunnelUpStreamFinish(tunnel_t *t, line_t *l)
{
encryptionclient_lstate_t *ls = lineGetState(l, t);
encryptionclientLinestateDestroy(ls); // destroy local state first
tunnelNextUpStreamFinish(t, l); // then propagate
}
Order matters: destroy local state before propagating the real close. Do not
read any field of ls after LinestateDestroy(ls) — assume it is zeroed.
Closing Both Directions From the Middle
When a middle tunnel itself decides to tear down an ordinary borrowed, left-owned connection (a protocol error, a limit hit), it is responsible for closing both sides. Under the ownership/topology contract above, the ordering is:
- Destroy this tunnel's own line state.
- Send the upstream
Finishfirst. The right side borrows this line and must neither destroy it nor reflect callbacks toward the sender. - Send the downstream
Finishsecond. This reaches the left-side owner and can destroy the line. - Return immediately.
my_lstate_t *ls = lineGetState(l, t);
myLinestateDestroy(ls); // 1. local state gone
tunnelNextUpStreamFinish(t, l); // 2. close forward (line still alive)
tunnelPrevDownStreamFinish(t, l); // 3. close backward (line may die now)
return; // 4. do not touch l or ls again
The line surviving the first Finish follows from its owner being on the other side, together with the no-reflection rule. Non-re-entrancy alone does not prove that an arbitrary recipient cannot own and destroy a line. Apply the specific ownership contract at reverse/Bridge boundaries and for internal companion lines; do not apply this normal-line teardown recipe to a worker packet line.
Adapters are simpler: sitting at one end of the chain, they only ever send
Finish once, toward the opposite direction.
Lines you own are different again, and they carry a stronger, checkable obligation — the next section.
The Owner Finish Postcondition
When a tunnel receives
Finishfor a normal line it created, that handler must not return while the line is still logically alive:/* on return from the owner's Finish handler for a normal line */
assert(! lineIsAlive(line));
The postcondition is about logical death only. Outstanding lineRef()
references may keep the allocation present until suspended or re-entrant frames
unwind; the memory does not have to be freed before the handler returns.
Before returning, an owner must:
- Detach every producer that can still target the line — io callbacks, timers, idle-table items, lookup maps, queues, scheduled tasks.
- Complete whatever cleanup is legal for the direction that sent
Finish. - Destroy its own line state exactly once.
- Propagate
Finishonly where the topology requires, and only away from the sender — the no-reflection rule is unchanged by ownership. - Call
lineDestroy(line)unless a nested path already did. - Return immediately, touching no destroyed state.
For keyed or timed ownership, detach the map/timer entry and clear the consumer's handle before invoking a re-entrant Finish or destroying the line. An indexed idle entry must be removed immediately on explicit close; retaining a long-deadline tombstone makes memory proportional to historical churn rather than live ownership.
A creator such as MuxClient does exactly that. The excerpt below is
muxclientTunnelDownStreamFinish() — the fnFinD slot, so next is the
sender. The parent's children hang off the prev side, and prev is therefore the
only direction anything may travel:
/* fnFinD on the parent line MuxClient created; next finished us. */
while (child_ls)
{
/* ... */
muxclientLinestateDestroy(child_ls);
tunnelPrevDownStreamFinish(t, child_l); // away from the sender
/* ... */
}
muxclientLinestateDestroy(parent_ls);
lineDestroy(parent_l); // only the owner may do this
The general shape, with the copy-to-stack step that keeps the code safe after
LinestateDestroy(). Read the direction off the callback slot the handler is
installed in — this one is fnFinU, so prev sent it and next is the only
side that may be told:
void ownerUpStreamFinish(tunnel_t *t, line_t *l)
{
owner_lstate_t *ls = lineGetState(l, t);
ownerDetachResources(t, l, ls); /* 1 */
bool propagate_away = ls->next_init_sent; /* copy before destroying */
ownerLinestateDestroy(ls); /* 3 */
if (propagate_away)
{
tunnelNextUpStreamFinish(t, l); /* 4 */
}
if (lineIsAlive(l))
{
lineDestroy(l); /* 5 */
}
return; /* 6 */
}
The fnFinD mirror is the same function with tunnelPrevDownStreamFinish() in
step 4. An endpoint owner — a chain head such as TcpListener or
UdpStatelessSocket — has no prev at all, so its fnFinD propagates nothing and
step 4 disappears. The postcondition is unchanged either way.
Why it matters. Without it, this sequence corrupts memory: a middle tunnel
forwards a re-entrant callback; a nested Finish destroys that middle tunnel's
per-line state; the line owner receives the propagated Finish but leaves the
line alive; the outer callback then reads lineIsAlive(line) == true and resumes
against state that no longer exists. The postcondition makes lineIsAlive() an
honest post-callback signal.
Borrowed lines are the mirror image. Destroy only your own per-line state,
propagate the same directional Finish away from the sender when the topology
requires it, and never call lineDestroy() — the real owner's destroy must
stay valid. Never reflect Finish, payload, Est, Pause, or Resume back
toward the side that finished you.
A middle tunnel that also owns the line still follows the both-directions ordering above for its local close, and then the owner postcondition applies on top: the line must be dead before that close frame returns.
requestProgramShutdown() does not satisfy the postcondition. It schedules
global teardown on worker 0; the current worker callback returns and unwinds
through every suspended frame first. An owner whose Finish reaches a terminal
failure must still detach, destroy its state, and mark the line dead — and then
request the shutdown.
Deferred OS cleanup is not an excuse to stay alive. If a resource needs asynchronous teardown, detach it from the line first and give the deferred cleanup object its own copy of whatever it needs. A live line is not a cleanup token.
Packet lines are excluded. They are never subject to the owned-normal-line
postcondition and must never be destroyed at runtime. A Finish on one is not
automatically a contract violation: a packet-lifecycle anchor may reject it, a
transparent middle transform forwards it in the same direction, and an
intentional terminal absorber may absorb it when there is nothing onward to
notify or release. The exact handler role and direction decide which category
applies — see Part 4.
Most Tunnels Need No Directional-Finish State
A direct consequence of non-re-entrancy: do not give a tunnel
prev_finished / next_finished (or can_upstream / can_downstream) flags by
default. A tunnel that simply forwards Finish is guaranteed never to be called
back on the finished side, so it has nothing to remember. A speculative
"did this side finish?" boolean is almost always a control-flow problem being
patched instead of fixed — the same anti-pattern as an initialized flag.
There is exactly one situation that justifies directional-close state: the
tunnel must send final bytes before it closes, including a bounded delayed
fallback batch (the next subsections). That send re-enters the adapter or branch,
which can emit Pause/Resume, and the flag is what lets handlers drop anything
that would reflect toward the already-finished side. The flag is bookkeeping for
that hazard and nothing else.
Real tunnels carry exactly this state and no more. The names differ; the role is identical:
| Tunnel | Directional-close state | Why it needs it |
|---|---|---|
TlsServer | upstream_finished, downstream_finishing, fallback_close_draining | Sends TLS alert / close and settles delayed fallback before closing |
TrojanServer, VlessServer | fallback_close_draining | Settles a delayed fallback batch without reflecting toward the finished previous side |
TcpOverUdpClient | can_downstream | Must finish its reliable-stream teardown before closing |
TcpOverUdpServer | can_upstream | The same, in the opposite direction |
HttpClient, HttpServer, HttpProxyClient, Socks5Server | prev_finished, next_finished | Send final protocol bytes before transport close |
If your tunnel does not send final bytes during Finish, it should not carry
any of these flags. Destroy local state and forward — that is the whole job.
Final Protocol Bytes Before Closing
Some protocols must emit closing bytes before the transport closes: TLS
close_notify, an HTTP final chunk, HTTP/2 END_STREAM, a WebSocket close frame,
a KCP close frame. Sending those bytes during Finish opens a dangerous
re-entrant window.
The hazard, step by step:
1. Tunnel receives Finish from side A.
2. It sends final protocol bytes toward side B.
3. Side B's adapter blocks while writing and emits Pause (or later Resume).
4. That Pause/Resume travels back through the tunnel...
5. ...and if the tunnel reflects it toward side A — whose state was already
destroyed by the original Finish — it calls into freed/zeroed state. Crash.
The required safe shape:
lineRef(line)— you are about to send bytes re-entrantly.- Mark the sender side finished first, using the tunnel's existing
directional state (
prev_finished,next_finished,can_downstream = false,can_upstream = false, etc.). YourPause/Resumehandlers must check this and refuse to reflect toward a finished side. - Send the final protocol bytes toward the other side.
- Destroy this tunnel's local line state.
- Propagate the real WaterWall
Finish. lineUnref(line).- Return immediately. Do not touch local line state afterward.
Direction examples:
- Handling upstream
Finish, sending final bytes upstream tonext: any downstreamPause/Resumecaused by that send must not be forwarded back toprev. - Handling downstream
Finish, sending final bytes downstream toprev: any upstreamPause/Resumecaused by that send must not be forwarded back tonext.
Do not invent a new initialized flag for this. Reuse the tunnel's existing
directional finish/close state, or — if the tunnel genuinely lacks one and this is
the final-bytes case — add an explicit directional close flag. As stressed above,
this is the only place such a flag belongs.
TlsServer SNI fallback admission
TlsServer borrows the normal line created by its upstream owner. With both sni
and fallback configured, its OpenSSL early ClientHello callback compares the visible
SNI before TLS output. A mismatch or absent SNI suspends OpenSSL; the payload driver
must replay the retained original wire bytes to fallback before later payloads.
Matching SNI continues to the protected branch. Without fallback, the SNI gate
still rejects mismatches. Malformed ClientHello/SNI input rejected before routing
stays on the TLS error path.
The original transcript is bounded to 1 MiB, including record headers and coalesced trailing bytes, and the existing handshake deadline covers incomplete input. Retained snapshots use compact byte copies and coalescing; never retain a full receive allocation for each tiny fragment merely because the logical byte count is below the limit. Copies carry the line pool's left padding for later replay. ServerHello, including HelloRetryRequest, commits the protected TLS branch: never switch to fallback afterward. Starting fallback releases TLS state and disarms its deadline before branch Init. Queue the entire initial input before invoking Init, including the triggering plaintext buffer when applicable. While Init is on the stack, later re-entrant input may enqueue behind it, but neither inline payload nor scheduled drains may run, including through Resume. After Init survives, release this admission barrier and drain ready input in FIFO order, including during advisory Pause. A close during Init discards queued input instead of sending payload into an unfinished Init callback. Preserve the borrowed line's reference/owner rules.
Before branch selection, retain only a pending client-side Pause for the branch that has not received Init. Resume cancels that pending notification. Once the selected branch has received Init, relay every Pause/Resume, including duplicates; clear any pending notification before an outward callback so reentry cannot replay stale pressure. Keep the line referenced when work continues after the notification and stop if it closes the line. This startup mapping does not gate TLS record output or delayed fallback payloads on Resume.
Delayed Fallback Batches Before Closing
A scheduled line task holds a physical line reference only. It does not keep
the line logically alive, and its runner intentionally skips the callback after
the owner destroys the line. A borrowed middle tunnel therefore must never park an
upstream Finish behind a line timer merely because its fallback payload queue is
delayed.
For an initialized fallback branch, use this close order:
- Mark the previous side finished and publish a fallback-drain gate before any
final branch
Payloadcallback. An empty queue can destroy local state and synchronously finish the initialized branch immediately. - For a non-empty bounded FIFO, capture the branch and line pool, detach the queue from line state, and establish that its total fits the destination buffer before coalescing it in FIFO order. An admission limit that already proves this bound needs only an assertion at the drain, provided no path can bypass the limit. One callback bounds the final handoff and its reentrant teardown window.
- Never flush while branch Init is unfinished. TrojanServer, VlessServer and TlsServer may deliver this final batch through advisory Pause; receiver limits still apply. Recycle any batch disallowed by the node's close gate before finishing the branch.
- While the final callback runs, absorb supported
Init,Est,Pause, andResumecallbacks, and recycle any returnedPayload. A callback explicitly forbidden by the receiving node remains fatal; for example,TlsServerrejects downstreamIniteven during this drain. A re-entrant downstreamFinishrecords that the branch already closed and is absorbed, so no reflected or duplicate branchFinishfollows. - Re-check
lineIsAlive()after the final payload while holding the temporary reference. If it died, unref and return without touching line state. Otherwise destroy local line state before the final branchFinish; after that finish, only unref and return.
The old timer has no cancellation handle. Emptying/detaching its queue logically
cancels the work; once the real owner destroys the line, the scheduled runner
becomes a harmless reference release. Preserving the exact remaining delay after
Finish instead requires a detached session object that owns copied buffers,
timer state, and branch resources without any line_t or line-state reference.
That is a different design, not an excuse to keep a borrowed line alive.
With delay zero, the safe referenced inline path requires completed branch Init and no older FIFO batch or scheduled drain. TrojanServer, VlessServer and TlsServer do not gate it on Pause. Retained bytes keep their ordered drain path, so later payload cannot overtake them.
Adapters Flush On Their Own
Adapters such as TcpConnector and TcpListener may flush queued bytes before
they actually close the socket after receiving Finish. So a middle tunnel
should: send its final protocol bytes, destroy local state, forward Finish —
and then trust the adapter to flush. Do not keep protocol state alive just to
"wait for the socket to drain."
The optional TCP peer write-shutdown diagnostic is implemented on Linux excluding
Android and Cygwin. It can identify a fallback peer only within the same network
namespace; unavailable diagnostics return false. Android builds use the
false-returning stub, and TcpConnector does not enable the diagnostic there.
Android therefore retains ordinary EOF closure without diagnostic-triggered
fallback socket lingering.
TCP adapter idle deadlines
TcpListener starts each owned normal line with a 60000 ms idle deadline.
Listener-side payload activity extends it using the 600000 ms active idle
timeout; the idle-table refresh only extends an existing deadline. Explicit
initial-idle-timeout-ms and active-idle-timeout-ms settings override these
defaults. TcpConnector uses a 600000 ms idle limit for its borrowed normal
lines, refreshed on adapter activity. Keep both active defaults aligned so
neither adapter introduces a shorter cleanup limit into a fallback path.
Target closure still propagates Finish without waiting for these idle deadlines;
socket output may drain under the existing adapter close contract.
TcpConnector explicitly sets a separate 60000 ms WIO connect timeout before
starting each outbound TCP attempt, after address resolution. Both NIO and IOCP
cancel that timer on successful connection. It bounds connection establishment,
not first-payload silence, and immediate socket errors can still finish the line
sooner. The node's deadline does not change the generic WIO connect default for
other callers.
The initial listener wait approximates NGINX's default, while ten minutes is a
transport cleanup policy, not an NGINX maximum. Neither adapter tracks HTTP
request stages. TlsServer owns its separate absolute handshake deadline and
disarms it when entering fallback. Protocol-specific header/body/write deadlines
and lingering-close behavior cannot be obtained by changing adapter idle limits
alone. Fallback targets allowing longer inactivity require every applicable idle
limit in the chain to accommodate it.
MuxClient fixed-parent selection
Fixed mode assigns a worker-local key to each new child and a stable identity to
each owned parent. Selection first prefers non-suspect eligible parents, then
uses rendezvous hashing, with the parent identity breaking equal scores.
max-children remains a hard eligibility cap; existing associations are never
remapped. The child is initialized as an unattached borrowed line before parent
selection invokes any neighbour, and a physical reference protects that Init.
Reentrant source Finish destroys the unattached state without reflection; a
finished child must not be attached or established afterward.
MuxClient's keepalive defaults true in all modes, with ping-interval 15000 ms
and tolerance-ms 45000 ms (integers in 1..INT_MAX). Each parent schedules its
first probe one ping-interval after downstream transport Est, sending on the
first eligible local-idle expiry at or after that deadline. Waiting for this
probe does not make the parent suspect or block child attachment; reply timing starts only
when a probe is sent. A parent-local transport notification latch sets this
schedule even if a downstream junction already marked the shared line established;
repeated Est does not reset it. MuxServer's boolean
keepalive defaults true and controls replies only. Disabled endpoints consume
controls without responding or acknowledging. Only a matching complete Pong on
the same parent clears an outstanding probe; user traffic and partial frames do
not. The first matching Pong confirms peer support. Unanswered discovery probes
are retried without suspicion or retirement, so disabled responders are safe but
parents that never confirm support have no health-based recovery.
One quarter of elapsed tolerance (at least 1 ms) makes a confirmed parent lower priority in fixed selection. Full tolerance permits soft retirement in every mode on reply-deadline expiry or new-child selection, including during Pause. Late matching replies restore unretired parents. Retirement preserves attached children and the authoritative owner inventory, but never returns a parent to selection. Each worker retains at most one keepalive-retired parent per fixed slot, or one in timer/counter modes. Pending final output still counts; Unregister settles the count exactly once. At the bound, occupied unresponsive parents remain selectable and the deadline item retries retirement after 1000 ms without changing the original probe time. Empty unresponsive parents close through normal parent-loss cleanup without waiting for Resume. A watchdog-retired parent closes when its last child leaves, discarding blocked final output. Ordinary timer/counter rotation still preserves output until drain. No active child is forcibly terminated and no expiry callback creates replacement parents.
Probe times use wloopNowMonotonicMS() on the owner worker. Parent transport
Pause and child FlowPause do not suspend scheduled probes or outstanding reply
deadlines.
New probes wait for unpaused parent output, an idle output pump and an empty FIFO;
only handoff starts the reply clock. Blocked probes retry after 1000 ms while
retaining their original due timestamp; missed wakeups coalesce into one due probe.
The local table's approximately one-second wakeup precision applies even to
shorter configured intervals and tolerances. A separate item tracks the reply
deadline, so matching Pong cancels that obligation without rescheduling the next
probe. Discovery expiry clears the unanswered obligation and permits another due
probe without penalizing a peer whose keepalive support remains unconfirmed.
Pong replies share the ordinary parent FIFO, charge limit and Pause gate.
Downstream buffering and transfer after handoff consume tolerance. Unconfirmed support still prevents
health-based retirement. Quiet connections
and one-way uploads need only the Mux peer's reply. Timer-mode parent age uses the
same monotonic clock; wall-clock changes do not rotate transport connections.
MuxServer's rejected-Open burst refill also uses its owner's monotonic clock.
See Part 7 for table and parent ownership.
Est, Pause, and Resume
Estis a repeatable notification of adapter transport establishment. Continuing middle tunnels must relay it promptly, including repeats; their own protocol readiness must not delay or replace that signal.Pause/Resumeare repeatable advisory backpressure signals. Ordinary middle nodes forward them transparently, including duplicates. They are not counted holds or proof that no more payload will arrive. Emit them in the intended direction and never toward a finished side. See Flow-Control Roles and Admission.
Est Is Transport Readiness
Adapters originate and consume transport establishment for their connection.
Est may arrive more than once for the same live, initialized line. It reports
transport readiness; it does not initialize or restart the connection. Every
supported recipient must tolerate repeated notifications. Est does not clear
Pause or release a producer controlled by an adapter, exact-line endpoint or
SpeedLimit.
On a continuing same-line path, an intermediate node must forward every received
Est, including repeats, in its supported callback direction without waiting for
its request header, first application payload, authentication, protocol reply,
handshake completion, timer or consumer Resume. Do not keep a notification latch
solely to suppress forwarding. In the ordinary upstream-created chain, next-side
Est continues toward prev with tunnelPrevDownStreamEst().
Transport establishment and protocol readiness are different facts. A middle
node may record transport state and perform required protocol work, but it must
keep the protocol's readiness and payload barriers separate. Est does not prove
authentication, destination acceptance by a remote proxy, or completion of TLS
or another negotiation. Use explicit protocol state and, where two cooperating
nodes need notification, a dedicated protocol-completion hook. Do not send a
second transport Est when that protocol work completes.
Middle nodes may still use Est for their own logic. Guard work that must happen
once separately from forwarding: repeated notifications must not resend a
greeting, recreate resources, restart a deadline or repeat an accounting
transition. Publish that local state before forwarding, because the previous
node can synchronously submit Payload, Est, Pause, Resume or Finish. Keep accepted
payloads ordered and bounded until their protocol can carry them. Preserve the
required wire order, including control messages before application bodies, even
when application data arrives from the Est callback. Pause is independent of the
establishment signal. Adapters, exact-line endpoints, SpeedLimit and bandwidth-enabled UserController retain
producer control; ordinary middle-node scheduled output need not wait for Resume.
Continuing after any callback still requires the exact line references and
logical-life checks described above. Never forward into a finished direction or
on a logically dead line.
On their owned normal lines, TesterClient and SpeedTestClient consume the first Est to start sending. Record that first notification before scheduling work; repeated Est, including reentry during a Payload callback, must not queue another send or retry timer or bypass a configured inter-burst delay.
Classify each exact line at topology boundaries. A node that owns a carrier, multiplexes children, pairs streams or bridges a packet line can terminate one line lifecycle and map readiness to another; it must use that role's explicit mapping and notify a live application association once. Do not blindly forward an owned helper line into a neighbour that was never initialized for it, broadcast one child's Est to unrelated children, or manufacture establishment before the relevant transport or local lifecycle endpoint exists. Boundary ownership is not permission to delay a continuing line's Est behind protocol negotiation.
Payload admission does not require a prior Est. After the adjacent Init has admitted the exact line, a live node may submit valid protocol bytes while the transport is connecting; the transport owner handles connection-time writes. This does not permit Payload before Init, unauthenticated forwarding, application bytes before required wire headers, or callbacks into a finished direction.
lineIsEstablished() / lineMarkEstablished() describe line establishment,
not protocol readiness. lineMarkEstablished() is idempotent: marking an already
established live line leaves it established and does not dispatch a callback.
A downstream endpoint may mark the shared line before forwarding Est, so the
shared marker does not prove that this transform has performed its own one-time
work and must not suppress forwarding. Retain a local guard when that work needs
one. This rule does not enable otherwise unsupported upstream Est callbacks on
ordinary tunnels, change disabled adapter edges, or replace the
packet lifecycle rules.
Flow-Control Roles and Admission
Pause/Resume are advisory and repeatable. Ordinary middle nodes must relay both signals transparently, including repeated Pause while already paused and repeated Resume while already resumed. Do not keep consumer-pause state solely to suppress duplicates or gate ordinary middle-node output. A timer becoming due or a protocol backlog becoming ready may produce output without waiting for Resume. FIFO order, protocol readiness, ownership, hard limits and line lifetime still apply; Pause transparency does not remove those obligations.
Classify the role for the exact line, rather than the node's position in the configured chain. A node may be a middle transform on one line and the head or tail of a carrier, child or paired line on another. Those boundaries must keep their explicit signal mapping; blindly following next/prev with the wrong line is never transparent forwarding.
| Role | Responsibility |
|---|---|
| Transport adapter or exact-line head/tail | Handle repeated notifications idempotently, control new reads/emissions and independent drains, serialize writes and enforce local queue limits. |
| Ordinary middle transform or parser | Relay every Pause/Resume. Transform admitted input and retain only necessary framing/protocol/ordering state under explicit bounds. |
| Ordinary middle node with scheduled output | Relay every Pause/Resume. Due timers and ready backlogs may send without Resume, in FIFO and protocol order. |
| Association or multiplexing boundary | Map exact lines and combine pressure reasons for the producers it controls. Being in the middle of a configured chain does not remove its endpoint responsibilities. |
| SpeedLimit and bandwidth-enabled UserController | Explicit middle-node exception: combine local throttling with external pressure; handle or absorb notifications so neither reason is released by the other. UserController datagram backlog alone does not hold its producer. |
An ordinary middle node may originate advisory Pause/Resume toward either live, initialized side. After originating Pause, it must arrange its own eventual Resume or close even if no further input arrives. Do not wait for another Payload to release the source that was just asked to stop. Repeated Pause is not a counted hold requiring an equal number of Resume calls.
These advisory requests do not carry the identity of every outstanding issuer. An advisory Resume may override another node's Pause; it does not promise that all receivers are writable or all queues empty. Endpoints and the explicit limiters retain the combination of pressure reasons that they explicitly own. Ordinary middle nodes do not recreate that machinery merely to relay notifications.
The accepted overload policy is bounded retention and closure on refusal, rather than a guarantee that all middle producers stop during congestion. A receiver can receive both in-flight data and later scheduled output after requesting Pause. Each buffering node must enforce its own limits; an adapter cannot bound data still held inside another node. Limits are node-specific, not a universal 2 MiB allowance, and several upstream queues may converge on one receiver.
Time deadlines are separate from storage limits. They continue according to the
node's timeout policy. If a protocol starts its reply deadline at Payload handoff,
that deadline can include time spent queued behind congestion; timeout closure
before actual wire transmission is an accepted consequence. Handoff is not proof
of transmission. No generic timer or payload gate belongs in ww dispatch.
Node-specific descriptions below also document scheduling gates implemented by individual nodes. Those gates do not establish a general requirement that an ordinary middle node wait for Resume. Endpoint and explicit-limiter flow control remains required independently of those local scheduling choices.
The reverse activation prefix has a narrow startup exception: ReverseServer hands off that single bounded control buffer through Pause to open the destination. It never attaches retained application bytes to the exception; their independent drain still waits for Resume.
Completing an admitted input batch
An input batch consists of one accepted Payload delivery and the bounded parser prefix/partial frame needed to interpret it. The node must establish its ownership and FIFO position before invoking another node. Completing this batch can emit multiple Payload callbacks; these are expected to reach a receiver even after Pause propagated synchronously through the chain.
Trojan and VLESS UDP decoders complete all complete datagrams in that accepted input after Pause. They do not leave ready datagrams queued solely for Resume or stop header parsing solely because Pause arrived during an earlier delivery. They retain only an incomplete suffix or input held for a genuine protocol or reentrancy barrier. Reentrant input has its own FIFO position and bound; serialize already-admitted nested batches after older input within the outer synchronous dispatch. They must not be relabeled as one ever-growing batch. Work deferred beyond that dispatch remains a bounded, ordered backlog. An ordinary middle node may release ready work without Resume; endpoint and explicit-limiter drains retain their flow control.
Finish, logical death, protocol rejection and hard-limit refusal are terminal: stop immediately and release the untransferred remainder. Batch completion never permits callbacks after Finish or requires a receiver to exceed its hard limit. A partial frame still needs later input; its existence must not itself suppress the Resume that allows its producer to complete it.
Receiver tolerance and FIFO ownership
A receiver requesting Pause must retain finite admission headroom and tolerate further delivery up to its hard limits. Pause does not guarantee that scheduled middle-node output stops. Specify the supported batch envelope, transformation expansion, queue high/low watermarks, hard limits and overflow policy. Count retained bytes and entries or allocation charge; tiny and empty datagrams must consume a finite budget. One coalesced stream input can decode into many datagrams, so a payload-byte limit alone is not a complete resource bound.
Tolerance is not unlimited acceptance or a guarantee that every protocol-valid burst fits during congestion. If a burst exhausts the hard limit, close the smallest supported association or apply the node's documented datagram-drop policy. Never drop arbitrary TCP bytes, silently lose an accepted suffix, or allocate without a bound to finish a batch. The sender rechecks exact-line life after each handoff and stops on closure. Stronger admission reservations may be used where an entire maximum batch must be guaranteed, but that guarantee must be explicit.
Publish ownership, queue accounting and notification state before emitting Pause, Resume or any callback that can reenter. A callback must not allow newer data to overtake an older buffer that is still only in a local variable. Ordinary pop releases budget usage before handing a buffer onward. If the same budget still owns active retention, transfer to a reservation without uncharging; publish consumption before callbacks and release when that responsibility ends. Use one authoritative FIFO per independent ordering obligation; a guarded admission/drain can serialize reentry without adding permanent queues to every middle node.
At an endpoint or an explicit limiter, Resume releases a producer according to the reasons that endpoint or limiter owns; it does not prove that every queue in the chain is empty. Ordinary middle nodes relay notifications without consumer-permission queues or a Resume prerequisite for due output. Locally originated advisory pressure still needs its own release path.
Bgp4Client and Bgp4Server retain a receive stream for incomplete frames and a bounded output FIFO for complete encoded batches and reentrant input. Their per-direction pump guards preserve FIFO while default Pause/Resume callbacks forward every notification transparently; admitted complete output does not wait for Resume. Such protocol and ordering obligations require explicit bounds and exact-line role handling. They do not require every ordinary encoder/decoder to own a transport queue.
MuxClient and MuxServer are examples of explicit local buffering: parent transport Pause forbids parent-bound Payload, including Open, Close, flow controls and Ping/Pong. Admitted encoded output uses one bounded per-parent FIFO and charge limit. New probes remain due until direct handoff is possible, without joining that backlog. Mux nodes pause child producers at its aggregate queue-capacity threshold, in addition to individual writer holds. Complete queued frames retain valid private bodies under local queue limits. Incoming assembly and attached child queues share the existing parent receive limit, with beneficial pressure compaction followed by largest-child shedding or parent close. There is no worker-wide pipe quota. Pause still gates Payload while allowing bounded retention; see the Part 3 contract. A blocked child writer acquires its own local producer hold immediately after admission, including empty Data and complete splice batches. A direct write that leaves the transport paused holds its writer before returning; a transient Pause then Resume uses the final permission. Controls alone are not attributed writers. Idle siblings below aggregate high water remain runnable. At high water the separate pause-all latch holds every attached open child.
The local hold FIFO borrows attached child state and is independent of that latch. Release requires a live parent state, no Finish/quiescence, writable transport and charge at/below low water, even when pause-all never activated. Recheck after every callback and before each release/pop. New output appends behind existing FIFO entries; renewed transport pressure or charge above low water stops release. A writer held again joins behind remaining waiters. Snapshot references protect every member before any callback, followed by logical-life, attachment and state checks. No persistent references or second cursor are used.
Init/Est defers permission callbacks without confusing a recorded reason with an emitted Pause. Completion reconciles the current reasons once safe. Peer pressure and terminal-close state remain independent; local unlink and terminal paths remove held-list membership without emitting Resume. A writable empty parent must release ready waiters rather than depend on a future transport event. TcpListener also preserves a Pause received during accepted-line Init: it enables initial reads only if Init returns alive and the source is still unpaused.
Mux's fixed header cache owns consumed header bytes until the declared body has been consumed. Data body bytes reach a writable child incrementally, with no Payload boundary guarantee and no callback for empty Data. Paused children keep available chunks in their bounded FIFO; Mux remains an endpoint for its separate parent and child lines, so this queue still waits for that child's Resume. The parser publishes the remaining body length before callbacks and serializes reentrant parent input behind existing bytes. A child closing during a partial Data frame makes its remaining body discard-only; only after consuming that suffix may the next header be parsed. Parent loss cannot retract an already delivered prefix, and accepted queued chunks keep their detached-drain policy. No callback observes detached stream ownership. Large splice encoding publishes the complete frame batch and Open state before any callback. A Pause during its first delivery leaves all later frames in the parent FIFO; nested Data or child Close follows that remainder. Parent death discards it, while child death alone does not release its cost. Ordinary large encoding keeps one aggregate.
A local child Pause submits FlowPause immediately, without a queued-byte threshold. MuxClient defers this control until Open has been admitted. After submitting the complete first payload, including a large splice batch, it must re-check both referenced lines and the child's attachment and pause state before submitting any deferred FlowPause. A resume or close during submission must not produce a stale control, and an already submitted FlowPause must not be repeated. FlowResume still requires a writable child and logical queued payload below its configured resume threshold.
Submitting a Mux control frame can close its child during queue-pressure callbacks while leaving the parent alive. The receive path distinguishes those outcomes: it retains the parent across callbacks, stops using the closed child, discards any remaining body for it, and continues parsing sibling traffic on the surviving parent. Remaining incoming child queues still undergo the normal parent memory-limit check.
UdpConnector rechecks both line life and read-pause state after its fallback receive-time Est callback. If Est pauses the line, the already-read datagram is recycled under the same drop policy as other paused receives; it is not delivered through Pause. Later datagrams may be delivered after Resume.
Transport queue admission must be transactional before threshold callbacks. Once a queue owns the incoming buffer, callback-driven close releases it through that queue; the caller must not recycle it again. If admission fails before ownership transfers, the caller recycles it through the captured line pool and settles the affected flow. Exact-line references protect callbacks, not logical life. Preserve this ordering in connecting, paused-write and write-completion paths.
TCP adapters attach their FIFO to a shared byte/charge budget and transfer popped entries to an active WIO reservation. The original admitted capacity charge remains until completion or terminal callback detachment. Partial write callbacks, live write returns and new admissions refresh logical bytes from WIO without reading the transferred buffer. Cleanup settles reservations before destroying line state; deferred WIO cleanup retains no budget pointer. UdpConnector initialization and all per-destination DNS queues attach to one line budget, including recreated DNS queues; socket datagram writes do not create an active reservation or retry queue. Empty retained buffers consume charge and entries.
TLS/Reality pending plaintext queues share their existing byte/entry limits with active backlog reservations. Consumed plaintext releases bytes before wire-output callbacks, while capacity and the entry remain charged until buffer release. Direct synchronous inputs retain their existing admitted-input behavior. TrojanServer attaches its populated FIFO only when selecting CONNECT or fallback output; initial and UDP parser fragments retain their separate byte and framing policies.
Numeric thresholds and hysteresis are implementation details. Read the target
adapter's payload.c, helpers.c, and structure.h; do not copy one adapter's
current tolerance into another node or into this guide as a universal value.
ConnectionFisher line mapping and retirement
ConnectionFisherClient terminates its borrowed main line and owns the candidate child lines. It must keep the winning-child mapping, combined backlog/transport pressure and Finish ownership for the association's lifetime. Its independent backlog drain obeys child Pause; its main-side reply Pause is held until a winner exists so it cannot block the probe exchange. Candidate Est notifications map to one main-line readiness notification. These are endpoint responsibilities, so the client cannot permanently bypass itself.
ConnectionFisherServer borrows one normal line along its actual next/prev path. It validates FISH?, sends FISH!, and delays next Init until the first application body. The current body and nested input retain FIFO position and their existing 2 MiB/1,024-buffer bound across reply, Init and pending-Pause callbacks. Keep only the latest upstream Pause before next Init; replay it after Init unless a newer Pause/Resume handled that state during reentry. Once Init starts, relay every Pause/Resume in both directions and every downstream Est, including repeats.
After Init and all admitted replay settle, destroy and zero the server's entire aligned line state, then permanently bypass it. Neither Est nor Resume is a prerequisite. No callbacks remain for the server after retirement, including Finish; the client retains its distinct-line mapping.
TcpOverUdp local KCP pressure
TcpOverUdpClient and TcpOverUdpServer borrow normal lines and continuously transform stream payloads into KCP datagrams and back, optionally through FEC. Their timers and protocol state prevent permanent bypass. Incoming Pause/Resume already relay transparently, including repeats, with directional Finish guards; they do not stop KCP updates or due output.
KCP/FEC output callbacks enqueue owned datagrams in contexts. Neighboring tunnel callbacks run only after the KCP/FEC call returns, because they may destroy the protocol state. Preserve this output staging and the subsequent line/handle checks.
Their separate source-pressure latch represents locally queued and unacknowledged
KCP segments. Reconcile it after KCP update and packet delivery, outside KCP's
output callback, with the exact line referenced. Publish the latch before sending
Pause above the configured threshold or Resume below it; equality retains the
existing state. ACK-only input must be able to release the local hold. Admit the
complete source payload before any pressure callback so reentrant input cannot
overtake it. After notification, recheck both line life and the KCP handle: Finish
may have destroyed this borrowed node's state without destroying the line.
The automatically derived threshold saturates at INT_MAX, matching KCP's signed
queue-count interface. The send-buffer setting remains an advisory threshold,
not a hard admission cap.
Raw QUIC transforms
QuicClient and QuicServer borrow the existing normal line and create neither
sockets nor companion/application lines. Client upstream plaintext becomes
next-side carrier data; server upstream carrier data becomes next-side
plaintext. Their outputs preserve these directions even though the transforms
have opposite roles. One QUIC v1 connection carries one client-initiated
bidirectional application stream per line. The client sends encrypted opening
byte 0x00; the server validates and consumes it before releasing application
data, including queued server-first output. It is never application data.
Both nodes initialize and publish local state before forwarding next Init once. Client handshake startup is independent of Est. Every supported downstream Est is relayed, including repeats; authenticated protocol readiness produces no extra Est. Application bytes wait under hard budgets until handshake/stream readiness, while input and library output are serialized against reentry. Neighbor callbacks run after the QUICHE driver call returns. After each callback, check both line life and local attachment: Finish may retire this transform on a still-live borrowed line.
Incoming Pause/Resume relay, including duplicates. Due loss/ACK alarms and ready FIFO output continue through advisory Pause. A separate local send-pressure latch asks only the application source to pause when any retained byte count, entry count or allocation charge exceeds three quarters of its hard limit. Resume requires all three below one half; equality preserves its state. The allocation limit is twice the configured byte budget. Accepted unacknowledged chunks remain charged, so ACK-only input must be able to release pressure. An external Pause reaching a UDP adapter can stop ACK ingress; finite pauses recover when deadlines permit, while prolonged pauses can close the association.
A received Finish closes its sender's direction before cleanup and never emits
back toward that side. Cancel alarms and detach protocol state before forwarding
away. A local failure settles local state and finishes next before prev under
the ordinary borrowed-line topology. Neither QUIC node calls lineDestroy().
Application-side Finish may make a bounded synchronous ready-wire drain only
toward the still-open carrier, honoring congestion/pacing and guarding every
reentrant signal against the finished side. No asynchronous reliable-drain
promise survives received Finish: the line owner
may destroy it immediately after the handler returns. Final unacknowledged data
may be lost; the peer has finite deadlines. Application stream FIN/reset closes
the association after preceding decoded bytes, without application half-close.
SpeedLimit independent drains
SpeedLimit combines its directional queue hold with external consumer pressure. It publishes each reason before notification and sends Resume only when both have cleared, without a temporary Resume followed by Pause. Consumer Pause stops that direction's timer/backlog output; a due timer leaves the bounded queue intact and does not poll while blocked. Consumer Resume rearms queued work, including an empty-queue local-release tick, before any outward notification. Timer admission refusal, including a refused reset of an existing timer, settles local buffers/timers and closes through the normal line owner. Keep the reset result; an existing timer pointer does not guarantee rearm admission. Each notification path returns without touching line state after the callback, which may synchronously close or reenter the connection. Token availability never overrides consumer pressure, and empty queued input cannot stall a drain.
UserController shared user shaping
Bandwidth-enabled UserController is an explicit producer-control limiter on its borrowed normal lines. For streams, its local queue hold and external consumer Pause are independent reasons: neither releases the other. Datagram backlog does not create a producer hold or send Pause/Resume solely for local shaping: UDP producers may otherwise discard arrivals before the controller's bounded queue fills. Datagram queue overflow drops the newly refused input; explicit external consumer Pause/Resume still applies and is propagated. The direction carrying queued upstream bytes controls the producer toward prev; downstream bytes control the producer toward next. Mapping those bytes to upload/download depends separately on the saved Init origin. A consumer Pause suspends that direction's drain and removes its eligible fair turn. Resume establishes progress before notifying the producer, because notification may reenter Payload or Finish. Anonymous and unlimited immediate forwarding remain transparent; a retained FIFO still has priority after a finite-to-unlimited transition. UserController still borrows the line. For an internal close, save the Init origin before clearing local state, finish the noncreator side first, then the creator side; reverse-origin lines therefore finish prev before next. Receiving Finish still propagates only away from its sender.
A user's independent directional bucket and fair-turn queue belong to one AuthenticationClient runtime and are shared
across controllers and workers. Waiter identity is a non-reused runtime ticket, not a raw user pointer or numeric ID alone.
No user, collection or budget lock covers a neighboring callback. Ready queues receive bounded FIFO turns; paused,
canceled, empty or removed tickets do not block eligible work. Refill uses fresh monotonic samples across workers; burst
capacity is max(1, ceil(R / 10)) bytes at actual logical rate R bytes/s, with zero unlimited and no unsigned-64 rate
clamp. The one-byte minimum permits integer-byte progress at very low rates. Whole datagrams can hold a turn while earning
credit, so FIFO fairness does not promise equal latency.
Bandwidth preview and delivery commit use separate samples for pacing and expiry. Buckets, grants and retries use
fresh monotonic milliseconds. Installed client expiry projections retain their local monotonic interpretation,
including an explicit projection without a client clock. A standalone user without that projection compares absolute
and first-use-duration expiry against fresh wall time under the same locks; wall-clock corrections never refill buckets.
Preview does not charge. Commit rechecks shared enabled/expiry/traffic eligibility and charges only delivered bytes
through the same directional fractional-remainder primitive as ordinary scaled traffic accounting. Live reservation
gauges remain separate from stored gauge predicates.
An oversized head datagram must earn complete credit before release. Credit earned before its activation is limited to ordinary capacity; abandoning its turn cannot donate an oversized burst to another ticket.
Managed positive arrival observes first use before retention, independently of charged traffic. Delivery first previews eligibility without consuming credit, prepares exact output and reserves all retained costs, then revalidates and commits bandwidth plus multiplied quota under shared locks. Successful commit is immediately followed by ownership handoff, without intervening fallible preparation or scheduling. Streams may split; datagrams stay whole. A quota-exhausting final chunk is handed off once before closure. Queued, canceled or discarded data consumes no quota or multiplier remainder. An already exhausted user cannot commit more. Public traffic-accounting APIs retain their ordinary accounting behavior.
The creator binds a pure owner-worker payload-mode resolver before preparation, or a manual instance configures
payload-mode as stream, datagram or dest_context->protocol; conflicting or missing sources reject startup.
The destination option reads protocol metadata once during Init: exactly TCP selects stream and exactly UDP selects
datagram; missing, ambiguous or unsupported protocol rejects the line, even when unmanaged. It is opt-in, not an implicit
fallback. Use it only when destination metadata is ready before Init and describes payload framing at this point in the
chain; transport conversion may leave routing metadata that no longer describes local payloads.
Either Init direction evaluates and saves the mode before admission or forwarding. Late promotion uses that saved mode,
independently of later routing metadata changes. SOCKS/Trojan/VLESS classify their initialized exact-line role, HTTP uses stream, and WireGuard's normal
transport uses datagram in either orientation. This metadata API adds no WireGuard authentication integration.
StreamFragmenter upstream scheduling
TlsClient's optional fragment object creates an owned private StreamFragmenter
after the TLS core and before its configured next node. Both borrow the same
source-owned normal line. The complete initial TLS flight reaches the helper as
one Payload; subsequent TLS output uses the same ordinary callback path. The
helper's default Est wait gates its own output without holding the transport Est
notification. Record shaping precedes stream fragmentation; their queues and
budgets remain independent. Raw ClientHello-generation calls do not enter this
per-line helper.
StreamFragmenter borrows ordinary upstream-created Layer 4 lines. In default
stream mode, its immutable settings select the first count Payload arrivals or
arrivals strictly before a duration-ms deadline established at the first
downstream Est by default. Every arrival consumes counter scope, including empty,
bypassed, and too-short input. Eligibility and selected absolute cut offsets are
fixed before admission or any reentrant handoff.
With tls-hello-fragment: true, the first non-empty arrival latches eligibility
and bypass for one initial ClientHello; later callbacks can complete it after
scope expiry. Earlier empty callbacks still count. Selected cuts are sampled
once when the four-byte handshake header reveals the complete message length.
Offsets count handshake bytes, including that header and excluding TLS record
headers. The node keeps the exact original wire prefix in a single budgeted
candidate FIFO job. It accepts supported handshake records up to a 64 KiB
message ending at an original record boundary, preserves record versions and
boundaries, and adds headers only for selected cuts inside an original record.
Failure to recognize that initial candidate, bypass, no selected cuts, or
assembly deadline expiry makes the original prefix ready unchanged and ends
detection for the line. No later ClientHello is probed. A suffix from the
completing input is admitted behind the candidate before any callback can
deliver newer input.
Optional wait-for-est defaults to true. When enabled, all pre-Est upstream
input joins the same bounded FIFO, including bypassed, empty and exhausted-scope
input. No upstream handoff or fragment-delay timer starts before the first
downstream Est has finished forwarding. Counter scope still counts arrivals;
timed scope admits pre-Est arrivals and starts its window on the first Est.
Publish receipt and the timed deadline before forwarding Est promptly, retaining
the startup gate across notification and checking exact-line life afterward.
Nested Payload, Resume or Est cannot bypass that gate. Duplicate Est continues
forwarding without restarting eligibility or scheduling. Pressure notifications
remain transparent before Est and do not gate fragment scheduling.
Disabled shaping does not disable the startup wait. Explicit false permits
pre-Est scheduling and starts timed eligibility in Init. Finish settles the same
FIFO without waiting for Est. This is a node-specific scheduling policy,
not a general prerequisite for middle-node payload admission or a promise of
transport packet boundaries.
One per-line FIFO owns original payload jobs and the active remainder. Each job's first delay starts at the active head; subsequent delays start after the previous fragment is forwarded. Bypassed and no-longer-eligible arrivals retain their FIFO position. New arrivals during an active drain append behind its remainder, including from Pause, Resume, or Payload callbacks.
Once shaping eligibility is exhausted, or TLS handling reaches its terminal opaque
phase, retire the node only after the startup wait is open, its FIFO and candidate
are empty, no timer or drain is active, and its own source pressure is released.
Destroy and zero the full line-state region before permanent bypass. Subsequent
callbacks skip the node, including Finish. A collecting TLS candidate remains
active after scope expiry. Generic timed expiry is observed on input or drain;
there is no extra timer solely to retire an idle line. Disabled shaping may
retire during Init when wait-for-est is false, or after the first Est and queued
work settle when it is true.
The incomplete TLS candidate also occupies that FIFO, but its parser can advance through bounded admitted input while the startup Est wait closes the output gate. The separate assembly deadline starts with the first selected non-empty input, includes time waiting for Est, and may arm the line's one timer before Est. It is canceled on successful commitment. After completion, the first fragment delay starts only when the ready job reaches the head and the startup gate is open. Expiry makes original bytes ready without bypassing the startup Est wait or older FIFO work; Finish discards them. An empty callback during collection needs no extra parser job.
Pause/Resume use transparent default forwarding in both directions, including duplicates. Due fragments, completed TLS candidates, and ready FIFO work do not wait for Resume. Monotonic deadline checks prevent the event loop's coarse timer rounding from sending early. Downstream bytes and Est retain ordinary immediate forwarding.
Retained jobs, including the active head, share fixed inclusive limits of 8 MiB logical bytes, 8 MiB canonical buffer-capacity charge, and 1,024 jobs. Local source pressure starts at 6 MiB charge or 768 jobs and clears only at/below both 3 MiB and 384 jobs. Release this local hold automatically with Resume when retention reaches those low watermarks. Forwarded Pause/Resume are independent advisory signals: they neither settle this hold nor prevent its release. Publish ownership and updated accounting before callbacks. Ordinary allocation charge remains held until its remainder transfers; splice charge can shrink with body consumption. One transient output fragment is additional extraction workspace, not another retained FIFO. Neither charge nor the entry limit promises physical kernel pipe memory usage.
The splice-capable node extracts generic selected ranges through sbufMoveRangeTo,
with ordinary fallback, and transfers the final remainder directly. TLS
inspection uses range helpers to materialize only the candidate prefix; later
opaque splice buffers can still pass intact. Rewriting temporarily reserves both
original and replacement buffers in the same budget before replacing the job.
Received Finish
cancels its timer and discards all queued bytes before forwarding away from the
sender. Local admission/setup refusal closes the borrowed line upstream then
downstream; it never destroys that line itself. Generic event-loop quiescence
detaches timer callbacks before the creator's owner-worker line drain reclaims
the retained timer handle and jobs. See the node reference
for configuration and concrete cut examples.
Client destination preparation before DNS
TcpConnector selects its destination once before DNS and retains that destination's socket options and outbound IP range in the resolver's prepare-hook storage. Its own Init initializes the ordinary connector line state and consumes the prepared settings directly during socket creation, before connect can trigger callbacks. Keep destination selection and its settings together across DNS.
TcpConnector and UdpConnector check constant domains against the address context's
1–255-byte length before narrowing, for both single and weighted destinations.
TcpConnector's constant CIDRs use the strict shared parser at construction:
IPv4 accepts prefixes 0–32 and IPv6 accepts 64–128. Randomization replaces only
host bits, including the complete IPv4 /0 and IPv6 /64 ranges.
TrojanClient, VlessClient and Socks5Client apply their configured address, port and TCP/UDP selection to the line's destination context before local DNS resolution. Their internal DomainResolver invokes a client-owned prepare hook; the resolver itself does not choose the client's protocol. These hooks need no custom resolver line-state storage.
DNS resolution replaces the destination address while preserving the selected protocol flags. When Init reaches the client, read those flags from the prepared destination context. Without local DNS, the client applies the target directly in Init and then reads the same flags. Target preparation leaves exactly one of TCP or UDP selected; this is a debug invariant at the client entry, not another runtime configuration check.
Do not apply the target settings a second time after DNS: a configured domain
could overwrite the resolved destination. Protocol-selection warnings and the
existing TCP fallback for an invalid incoming dest_context->protocol belong
to target preparation and occur there once.
HttpProxyClient also prepares its configured/dynamic origin in a client-owned DomainResolver hook when local resolution is enabled for CONNECT. It is TCP-only: validate and reject explicit UDP/ICMP/packet inputs before replacing context; an unspecified protocol becomes TCP. Copy dynamic address and port before any setter resets their storage. Do not apply the other clients' protocol fallback rules here. After DNS, format the numeric origin without reapplying settings, before the following connector replaces context with the proxy endpoint.
ConnectionToPackets also prepares its destination in its internal DomainResolver
hook before DNS. Its optional target-address and port independently replace
the corresponding incoming fields; omission or their exact dest_context->...
selectors preserve those fields. Preserve the destination protocol during address
replacement: an exact destination TCP/UDP protocol wins, otherwise infer it from
the source context. The resolver stores the selected protocol for the bridge's
Init and uses only-ipv4. A configured literal IPv6 address fails configuration;
an unusable dynamic destination fails only its line. Consume the resolved address
in Init without applying the configured target again. source-ipv4 selects the
virtual packet source, independently of this destination preparation.
UDP line role names
TrojanClient, VlessClient and Socks5Client name the borrowed UDP-facing line
UdpApplication and refer to it through application_line. Application is relative
to this protocol layer: the preceding chain may contain another protocol node.
TrojanClient and VlessClient name their internally owned TCP line UdpCarrier
and link to it through carrier_line; UDP describes the traffic carried, not the
carrier's transport protocol. Socks5Client instead distinguishes its internally
owned UdpControl TCP line and UdpRelay UDP line.
These names describe protocol roles. Server-side client, control and UDP remote
roles retain their own names; prev and next describe callback direction and
neighboring tunnels. Determine ownership from the exact line's creation site.
SOCKS transport readiness and protocol ordering
Socks5Client forwards every direct TCP transport Est before or after negotiation, including repeats, while starting its greeting only once. A UDP control carrier maps transport establishment to its application once. Authentication, command success and relay readiness retain their distinct application-data gates. Its pending application FIFO uses the default 2 MiB / 1,024-buffer backlog budget described in Part 3. Direct TCP releases this FIFO when protocol-ready, without waiting for Resume, and keeps nested input behind older bytes. It forwards every Pause/Resume in both directions, including during negotiation. The first retained application input originates an advisory source Pause; draining that local backlog releases its hold without waiting for transport Resume. UDP retains combined control/relay output pressure and its protocol backlog hold at the association boundary. Its control handshake remains separate from application receive pressure. Proxy reply parsing has one synchronous owner. Nested downstream Payload cannot advance the parser recursively or overtake the accepted reply tail. A retained TCP response backlog drains in FIFO order without a consumer-Pause gate. Ready direct TCP forwarding keeps its synchronous path. UDP application receive pressure maps to the owned relay, including replay when that relay is created; control tails are discarded after association acceptance.
Direct TCP permanently bypasses Socks5Client after the successful command reply has been consumed, all parser/reentrant input and application FIFOs have drained, and its local source hold has been released. Destroy and zero its complete line state before clearing the active bit. A source-pressure or Est callback may finish setup reentrantly; suspended continuations check the active bit before using that state again. UDP application, control and relay lines remain active for protocol framing, signal mapping and owned-line cleanup.
Socks5Server forwards every CONNECT transport Est, including repeats, and generates its success reply only once. That reply still precedes backend response bodies, including under Init/Est reentry and Pause. CONNECT success and ready ordering queues do not wait for Resume. Repeated TCP Pause/Resume forwards transparently; before outgoing Init, retain the latest receiver Pause for replay to the initialized branch, with an early Resume canceling that pending request. UDP control, client and remote line roles retain their explicit mapping and endpoint behavior. Its control-input scope serializes parser progression: nested Payload joins the upstream FIFO behind the active input. Method, authentication and UDP-association transitions and identity are committed before reply callbacks. Failure replies close input admission before calling prev; reentrant Finish still completes local cleanup before the borrowed-line owner destroys its line.
CONNECT TCP permanently bypasses Socks5Server after its success-reply handoff, outgoing Init and startup pressure replay, and both directional ordering queues have settled. Drain work added by a response callback in the opposite direction before retiring. Input and control-dispatch guards keep state alive for older parser/Init callbacks; a suspended Est continuation recognizes nested retirement. Destroy and zero local state first; credentials already published on the line remain available. UDP control/client/remote lines stay active for their endpoint and association responsibilities.
DomainResolver accepts upstream Init and delays next Init until destination DNS completes. Its prepare hook runs before resolution and has no direction argument. Downstream Init and upstream Est retain the normal constructor's rejection policy; this is a node-specific contract, not a rule for reverse/Bridge or companion lines. Payload, Pause/Resume and Finish still operate in both ordinary directions.
The resolver keeps one authoritative upstream backlog through next Init. The Open phase permits downstream Est and replies immediately; an Init guard serializes new upstream input behind older DNS input. DNS completion releases that FIFO independently and observes next-side Pause. Admission checks logical bytes and retained allocation charge separately against 2 MiB per line, including empty buffers. The local DNS/backlog hold combines with next-side pressure before notifying prev. Upstream receive pressure is remembered during DNS and forwarded to next once Init starts. Failures before next Init finish only prev; internal failures after it starts finish both initialized sides. Received Finish destroys local state first and propagates only away from its sender.
On a normal line, the resolver permanently bypasses itself once next Init has returned, its pending FIFO is empty, and its local hold and neighbour pressure notifications have settled. Neither direction needs to be unpaused when the permissions already match. Destroy the entire resolver state, including embedded prepare-hook storage, before clearing the active bit. Prepare-state consumers must finish using that storage during next Init and copy anything needed later; the destroy hook runs at bypass or earlier Finish, never again after bypass. Source-pressure callbacks can complete this transition reentrantly, so suspended continuations check the active bit before touching state. Actual worker packet lines remain active; normal lines in a packet-containing chain remain eligible.
TrojanClient establishment and association ownership
TrojanClient borrows direct TCP and UDP application normal lines. UDP creates one owned normal TCP carrier on the application's worker; only that carrier receives next Init. The application keeps its UDP routing context. Signal forwarding maps the application and carrier explicitly, retaining both exact lines across callbacks and checking logical life and association afterwards.
A valid first application payload can submit the request after next Init, without waiting for transport Est. The request and complete first payload share one ordinary buffer; later TCP forwarding is opaque. Direct TCP transport Est is forwarded promptly, including repeats; UDP carrier Est maps once to its application. The first transport Est starts an idle first-payload deadline only if the request has not already been sent; repeated Est does not restart that deadline. The header-only timer sends when due on direct TCP, including during advisory Pause. TCP Pause/Resume notifications are forwarded transparently, including duplicates. The owned UDP carrier retains its output-pressure gate: its due header waits for carrier Resume. An admitted application Payload can still complete its combined handoff under the batch contract.
Ordinary TCP forwarding and one-datagram upstream wrapping do not gain separate Pause queues. Downstream UDP retains framing input and completes the current accepted input batch. Keep only the guard/storage needed for parser reentry, initialization ordering or a genuine protocol wait. A node-wide pump is not a contract; use explicit directional handlers and focused helpers for actual work.
For direct TCP, assembling the complete first request ends this node's setup work. Destroy and zero its line state, including cancellation of the first-payload timer, then permanently bypass the node before handing off that buffer. Reentrant callbacks use the transparent path; suspended Est callbacks must check the active bit before accessing their retired state. No transport Est or protocol reply is required for this transition. UDP application and owned carrier lines remain active for framing, association mapping and cleanup.
Close cancels the deadline, detaches both association links and destroys locally owned buffers/state before outward Finish. Only the carrier is destroyed here. Source-owner shutdown drains the dependent carrier while loops, chains and pools remain alive. No duplicate borrowed-line inventory is needed.
VlessClient establishment and association ownership
VlessClient borrows direct TCP and UDP application lines and owns only each UDP application's dependent TCP carrier on the same worker. Both links are attached before next Init. The line role determines the VLESS command: direct lines use TCP, while UDP application lines route requests through their UDP carrier role. Keep the configured protocol selection in tunnel state; per-line state need not store a second copy of the role's protocol. Its first-payload batching, idle deadline, signal mapping and opaque TCP paths follow the same ownership/admission rules as TrojanClient. Every direct TCP Est is forwarded, including repeats; UDP carrier Est maps once to its application. Repeated Est does not restart the first-payload deadline.
VLESS protocol readiness remains separate. The request must precede upstream body, but neither transport Est nor a response is required before submitting a valid request/body batch after Init. Downstream body waits for complete validated response metadata. Consuming that metadata cannot lose or reorder a coalesced TCP body or the first UDP frame. UDP input retains incomplete framing bytes, completes all ready datagrams in its active accepted batch and preserves an incomplete suffix.
Record request admission before the first outward callback so nested data cannot send a second request or overtake it. Protocol/reentry storage is bounded; an otherwise-ready TCP buffer is not retained merely for Pause or Est. Direct TCP Pause/Resume is transparent, including duplicates, and its first-payload timer sends when due without waiting for Resume. The owned UDP carrier retains its output-pressure gate and exact-line signal mapping as described for TrojanClient. Close clears timer/parser/ordering state and both links before outward Finish, never reflects toward its sender, and leaves an owned carrier logically dead.
Direct TCP permanently bypasses VlessClient only after request admission and complete response metadata consumption, once the outer receive batch has drained all retained and reentrant body input. Either protocol direction may complete first; neither alone permits bypass. Destroy and zero local state before clearing the active bit, including canceling the first-payload timer. Suspended Est callbacks must recognize state retired by nested callbacks. UDP application and carrier lines remain active for framing, signal mapping and owned-line cleanup.
TrojanServer associations and fallback replay
TrojanServer borrows the client normal line and owns each UDP backend on that client's worker. Target, credentials, map membership and callback-ready state must be published before backend Init. AuthenticationClient is a lookup dependency; UserController runs only on the protected branch. Authentication, initial parsing and exact fallback replay remain required independently of transport Est.
TrojanServer and VlessServer acquire their locally owned credential snapshots before protected or fallback Init. Authentication rejection/unavailability retain their existing fallback policy. Heap allocation for local snapshots and configured local users follows the same fatal policy as shared line-identity and AuthenticationClient profile copies; see the fail-fast publication contract.
After selecting and initializing the TCP/fallback branch, ordinary synchronous body/reply forwarding needs no queue solely for Pause or Est. Preserve older body or replay bytes across reentrant branch Init; this is a protocol-ordering barrier, not permission to maintain permanent TCP transport queues. A local fallback may send Payload and Finish within Init without ever sending Est.
Authenticated TCP on the normal next branch permanently bypasses TrojanServer at the pump's quiescent exit: onward Init has returned and retained body and reentrant input have drained. TCP Pause/Resume is forwarded transparently, including duplicates. A receiver Pause before branch selection is remembered and replayed onto the initialized branch; it does not gate TCP output. Destroy and zero the server's state before clearing its active bit. Credentials already published on the line remain available to later nodes. This transition does not wait for Est or require either direction to be unpaused. UDP stays active for framing and owned-backend cleanup. Fallback also stays active, because its selected branch and possible delayed replay cannot be replaced by following next.
Trojan UDP backend identity is the original address kind, exact address/domain bytes and length, and port. Hashes select buckets; full equality selects a backend. The remote owns an immutable key borrowed by the client map, and detaches it before freeing it. Routing/DNS rewrites cannot change lookup/removal identity; wildcard IPv4 and IPv6 remain distinct, and domain spelling and case are preserved.
UDP is an association junction. Upstream callbacks operate on the borrowed client; backend upstream events use each owned backend, and replies/notifications return through downstream callbacks. Aggregate backend pressure by exact membership: one backend's Resume cannot release a carrier still paused by another backend. Client reply pressure applies to every initialized backend, including new backends. Publish each contribution before callbacks, clear it on removal, and restart traversal after callbacks rather than retaining raw sibling pointers or iterators.
Pause stops future carrier admission, not completion of the carrier input already being decoded. Each complete datagram in that active input is submitted to its selected backend even if an earlier datagram caused Pause; the backend's receiver owns bounded in-flight tolerance. Preserve framing/destination checks, initial input ordering and the incomplete suffix. Stop immediately if the affected line or association closes. Do not drain unrelated pending input as part of this batch.
Replies received on initialized live backends do not wait for backend Est. They may forward synchronously with the required datagram framing. If independent backend producers or reentry require an association FIFO, keep one bounded encoded FIFO, account entries before callbacks, and stop its independent drain at consumer Pause. Already-admitted immutable replies belong to the client FIFO and do not need a borrowed backend pointer kept alive merely as an Est waiter.
Intentional fallback delay owns a bounded FIFO and scheduled drain. Due replay does not wait for Resume; both directions relay advisory Pause/Resume, including duplicates, along the selected branch. Source Finish permits at most one synchronous final batch outside branch Init, including during advisory Pause, with the finished side closed before any callback. Returned callbacks cannot revive parsing or reopen the finished direction. Init, delay and reentry still preserve the same FIFO order.
Retain the client across association callbacks and each exact backend across its own Init/Payload/permission calls. Close detaches membership and selected-input links before callbacks, releases only locally owned bytes and returns with owned backends logically dead. Source shutdown settles dependent lines and delayed work before reclaiming their reachable resources.
VlessServer association and replay ordering
VlessServer borrows its client normal line and owns one dependent UDP backend for the initial request's fixed destination. Publish destination, credentials and association links before backend Init; serialize nested input behind older parser bytes. Hold the client across backend callbacks and each exact backend across its own callbacks. Backend death can leave the client alive and permit recreation; client Est is still sent only once. Settle any extracted, undelivered body if Init fails. Replay client receiver pressure onto new/recreated backends before delivery. Backend source pressure is separate from client receiver pressure. Publish the current backend's contribution and the emitted source hold before callbacks; duplicate signals do not add holds or releases. Detach clears the contribution before callbacks. After the old backend is logically dead, release a previously sent hold on a surviving client, retaining that client independently through notification. Resume may close the client or install a paused replacement; never write stale association state afterward. Client teardown and shutdown suppress source restart.
TCP and fallback forward every Pause/Resume transparently. Before branch selection, remember the latest receiver signal for replay onto the initialized branch; update that startup state during Init reentry so a newer Resume cancels stale Pause. UDP retains receiver pressure for backend recreation and response draining.
Every same-line TCP and fallback Est is forwarded promptly, including repeats; an owned UDP backend still maps
readiness to its application once. Est does not emit a standalone VLESS response header. The header accompanies the first
nonempty TCP reply or first framed UDP reply in one ordinary buffer, including replies arriving before Est. Empty UDP
datagrams are valid; an empty first UDP reply emits the response header and zero-length prefix together as 00 00 00 00.
Empty TCP input, Resume and Finish do not
trigger a header-only send, and there is no header-only timer. TCP reply draining proceeds through advisory Pause;
UDP retains its response-pressure gate. Publish header ownership before callbacks so Est and reply
reentry cannot duplicate the header or reorder replies.
TCP Init and unauthenticated fallback selection secure all older body/replay ownership before outward callbacks. Fallback restores inspected metadata before its untouched tail and gates every drain during branch Init. Delayed replay does not wait for Resume; the narrow source-Finish batch runs only outside Init, with the previous side already closed. Source-owner shutdown settles parser storage, delayed work and the owned backend while pools remain alive; VlessServer adds no inventory of borrowed lines. See Part 3 for representation and retention limits.
Authenticated TCP on the normal next branch permanently bypasses VlessServer after the first response header/body handoff, once branch initialization, retained input and response queues have settled. Destroy and zero local state before clearing the active bit. Est alone or an empty reply does not complete this transition. Transparent TCP Resume forwarding does not start a reply drain or require a later permission-reconciliation step. Credentials already published on the line remain available to later nodes. UDP and fallback remain active for framing, owned-backend cleanup, branch selection and delayed replay.
VmessClient bootstrap and association ownership
VmessClient borrows direct TCP and UDP application normal lines. For each UDP application, VmessClient creates one dependent TCP carrier on the same worker and is that carrier's exact-line head and owner. Initialize both states and publish both association links before next Init. Snapshot the final target before transport nodes replace destination context with the VMess server endpoint. Optional DomainResolver is an owned helper before the client and applies that target before local DNS. The outbound UUID is not an authenticated local-peer identity and must not create duplicate managed accounting on the carrier.
Init emits no request. The first eligible payload may precede transport Est, reenter Est or arrive after advisory Pause; it sends immediately without awaiting a response. Publish request ownership, expected response marker and both codec contexts before the first outward Payload. Direct TCP forwards every Est promptly, including repeats; UDP maps the carrier's first Est once. The first Est sets one idle deadline. Repeated Est never resets it. Direct TCP due output proceeds through advisory Pause; an owned UDP carrier defers only its idle header while that exact carrier is paused. Empty TCP and empty native/oversized local UDP do not trigger a request. Packetaddr preserves empty application datagrams through a nonempty address envelope. There is no separate response deadline because a backend can legitimately remain silent.
Independent TX/RX guards preserve each admitted FIFO across nested payloads and synchronous responses. Drain complete records incrementally through advisory Pause, stopping on closure. Keep the client active for the whole connection, including NONE and zero; no successful-TCP permanent bypass applies. UDP records preserve datagram boundaries, and authenticated empty records terminate only after tag verification.
Finish publishes the sender's closed side before any outward callback, detaches
associations, cancels timers, settles buffers and wipes session material. Protocol
termination may go only toward an open wire side after request submission when
send-termination is true (the default). False omits that record and uses the same
full-close path, including strict counter exhaustion. Incoming valid EOF remains
accepted independently. Finish never starts a request. Never reflect Finish or pressure toward its closed sender.
An owned carrier receiving Finish must be logically dead before returning, including
when Finish reenters final-record handoff. Temporary references preserve allocation;
recheck logical life and association membership after callbacks. Borrowed application
lines remain their source owner's responsibility during normal close and shutdown.
VmessServer authentication and association ownership
VmessServer borrows its carrier and owns its dependent UDP backend normal lines. Publish verified destination and identity before protected Init; managed mode inserts UserController on decoded traffic, while fallback bypasses that helper. TLS and WebSocket remain separate preceding nodes and require no VMess chain-introspection setting.
Native UDP keeps one fixed-destination backend. Authenticated packetaddr UDP uses a
bounded per-carrier associative table of immutable IP/family/port destinations:
packetaddr-max-destinations defaults to 64 and accepts 1..1024. Each live entry owns
one same-worker normal backend, with copied source/listener context and authenticated
identity published before protected Init. Managed UserController sees each decoded
application datagram once; envelope/carrier bytes are not separate application traffic.
At capacity, drop only a datagram requiring a new destination. An exact backend Finish
removes its entry, permitting later recreation while admission remains open. Replies
carry that backend's immutable target and queued reply bytes remain independently owned.
Publish table removal, inventory removal and association detachment before callbacks. Recheck logical life and exact membership after reentry; references preserve allocation only. Carrier closure closes backend admission and drains every owned entry; worker stop also drains the separate owner inventory before pools and chains are reclaimed. Pressure relays retain the existing exact-line head/tail roles and finite response FIFO; packetaddr does not grant ordinary transforms a new producer gate. All these lines are normal lines, and only their creators destroy them.
The server authenticates/parses the request before applying allowed-security, UDP
and packetaddr permissions, and must enforce them before protected Init or backend
creation. Denied authenticated requests close without fallback. The body allow-list
classifies chunked NONE as none and unframed NONE as zero; it introduces no new
wire mode or header-authentication mechanism. Mux.Cool/XUDP remain unsupported;
WaterWall's custom Mux is not an interoperable substitute.
A node shares one immutable, reference-counted vector of copied UUID identities and derived keys across workers. Build it from generation-checked AuthenticationClient pages outside broad credential locks. Pending searches pin the vector, use one worker-owned trial cipher context, and yield through a coalesced worker pump after 256 trials or one millisecond. Ordinary ready continuations have no deliberate timer delay or throughput rate limit. A stale-vector miss must reconsider an installed replacement before selecting fallback; successful header verification must revalidate UUID ownership, durable ID, identity incarnation and current eligibility before publishing the handle. Account refresh must not reset replay protection.
AuthID and session replay tables are shared by all workers of one VmessServer instance, with full-key equality and atomic admission. Keep AuthID marks through the entire inclusive 120-second past/future window, including the future edge. A nondecreasing wall-clock cutoff prevents a backward correction from reviving purged timestamps. Session marks retain UUID/key/IV identity for three monotonic minutes. Capacity refusal must never evict live protection. Separate node instances have independent replay domains.
Authentication admission counts pending carriers, retained input and all live credential-vector allocations, including construction and pinned old generations. The absolute handshake deadline starts at Init and input never extends it. Quiescence closes admission and detaches worker timers; queued work settles under normal worker-message ownership. Worker stop closes pending carriers through their owners and releases construction state. Ordinary source-owner drain settles established carriers and dependent UDP lines before pools, helpers or shared state are reclaimed.
HttpProxyServer fallback association
HttpProxyServer borrows the client normal line and owns a separate child. Every child callback uses that child's selected branch entry, including pressure and Finish; helper insertion must not be bypassed. Detach the child, branch entry and worker inventory before closing it, and destroy that exact owned line before returning from its Finish handler. Retain the session and exact lines across reentrant calls; physical references do not keep associations logically alive.
Publish original replay, working input and deferred tails in FIFO order before fallback Init. An active Payload suffix must be admitted before Init or outward pressure can reenter. No drain or pressure notification enters unfinished Init. Record receiver Pause/Resume before forwarding, even before branch selection; reconcile the latest state after Init, without waiting for Est. Nested Resume cancels earlier Pause, and independent backlog pressure still applies. Child Est maps promptly and once to the borrowed client, independently of replay or Pause. Only the first Est for each child clears its connect deadline and records initial progress. Repeated notifications are not data progress and must not extend the session's idle deadline. A child may send raw Payload and Finish inside Init without Est: retain its reply, discard upstream work, destroy the child, and drain accepted client output before Finish. Never reflect callbacks into that finished child.
In both directions, publish the active Payload remainder before a streaming slice can call out. Nested input appends after that remainder and shares the existing delivery allowance; it must not enter a FIFO that drains before the older suffix. Close and early final-response handling settle active input as well as queued input. Raw fallback and CONNECT relay drain each direction's retained buffers directly, without needing spare working capacity from the opposite direction. Child Finish preserves accepted response bytes, including an active remainder, until the client can receive them; client Finish discards them immediately.
Worked Example: Tracing a Payload
Consider the chain:
TcpListener -> ObfuscatorClient -> TlsClient -> TcpConnector
(head) (middle) (middle) (tail)
Upstream (client to remote):
client bytes arrive at TcpListener's socket
TcpListener: tunnelNextUpStreamPayload(t, line, buf)
-> ObfuscatorClient.fnPayloadU: scramble bytes, forward upstream
-> TlsClient.fnPayloadU: encrypt via BoringSSL, forward upstream
-> TcpConnector.fnPayloadU: write ciphertext to the remote socket
Downstream (remote to client):
remote bytes arrive at TcpConnector's socket
TcpConnector: tunnelPrevDownStreamPayload(t, line, buf)
-> TlsClient.fnPayloadD: decrypt, forward downstream
-> ObfuscatorClient.fnPayloadD: unscramble, forward downstream
-> TcpListener.fnPayloadD: write plaintext to the accepted client
Notice that the same TlsClient instance handles encrypt on fnPayloadU and
decrypt on fnPayloadD. The direction, not the tunnel, decides the
transformation. If TlsClient's upstream payload write blocks at TcpConnector,
backpressure flows back as a Pause in the downstream direction — and must not be
reflected past a side that has already finished.
Checklist for This Part
Before you call a flow change done:
- Does
Initinitialize this tunnel's line state before any callback can use it? - Does a normal-line context selector or rewriter complete and publish its own
context/admission decision before propagating
Initto the selected stage? - Are upstream callbacks forwarded only with
tunnelNextUpStream*, and downstream only withtunnelPrevDownStream*? - Does every re-entrant call either return immediately or protect the line with
lineCallWithRef()/ manuallineRef()+lineIsAlive()? - On a
falsefromlineCallWithRef(), do you avoid touchingline,ls, andLinestateDestroy()? - Does
Finishdestroy local state before propagating the real close? - When closing from the middle, do you finish the noncreator side before the creator side, then return?
- If you send final protocol bytes during
Finish, do you mark the sender side finished before the send and block reflectedPause/Resume? - Are Pause/Resume repeatable and transparent in ordinary middle nodes, with due output independent of Resume? Do adapters, exact-line endpoints, SpeedLimit and bandwidth-enabled UserController retain producer control? Are local advisory holds released without relying on more input, and are FIFO, receiver limits and overflow/close behavior explicit?
- Does retained data have a parser, protocol, ordering or producer obligation, rather than an unnecessary output queue copied into a transparent transform?
- Does only the line owner ever call
lineDestroy()? - Did you classify each line as owned normal, borrowed normal, or
packet before editing its
Finishpath? - Does every owner
Finishpath for a normal line return with! lineIsAlive(line)— including the error, timeout, and terminal-verdict branches? - Are io callbacks, timers, idle items, and map entries detached before the owner's state and line are destroyed?
- Does a borrowed-line path avoid
lineDestroy()entirely? - Is
requestProgramShutdown()used in addition to closing an owned line, never instead of it? - Did you avoid adding an
initializedflag the source does not require?
HttpProxyClient single exchange
HttpProxyClient borrows one normal line from its source and creates no child line. An optional owned DomainResolver tunnel precedes the client core on that same line. Origin DNS completes before core Init, client timers, or next Init begin; early input stays in the resolver FIFO. DNS uses the service timeout/retry policy, not the client idle deadline. Failed DNS finishes only the initialized source. Core Init starts idle accounting, including subsequent proxy DNS/connect waiting. CONNECT response timing starts at header handoff; incomplete header timing starts at its first byte. Paused time counts after these deadlines start. Init snapshots the origin before next Init can replace destination context, then hands off its complete request header after next Init returns. An Init/header guard keeps reentrant application data behind that header. Est forwards promptly, including repeats, independently of HTTP readiness. CONNECT releases application input only after a 2xx reply; normal HTTP uploads need not wait for the response.
Pause/Resume relay transparently, including duplicates. Ready headers, upload and response backlogs, and due timer work do not wait for Resume; protocol and reentrancy barriers still preserve order. The client's own startup/FIFO Pause has an independent release path. CONNECT remains active after acceptance because the configured idle deadline still observes traffic and closes an idle stream; permanent bypass would discard that obligation. HTTP mode also retains framing and response-completion work.
Fixed/bodyless request completion leaves the response live. A complete framed response closes both directions and cancels upload, including a suspended chunk suffix; trailing bytes never start another exchange. Downstream Finish completes only close-delimited responses or an established CONNECT stream; other incomplete responses are truncated. The borrowed line is always destroyed by its source. Chunked source Finish can emit one terminal chunk only with no older backlog or active frame, a sent request header and normal loop admission, before response completion. Its final-byte window suppresses reflection and duplicate next Finish. It then destroys local state and propagates Finish: it cannot wait for a response after source Finish. There is no separate upload-completion callback, and an empty Payload does not finish the body. A terminal HTTP chunk would suffice to end a request without TCP half-close, but Finish cannot represent that send-only completion. Finite uploads that need a later response must use a known Content-Length; chunked exchanges require peers that respond while the upload remains open. See the HttpProxyClient completion limitation. Worker quiescence unlinks the timer registry; it is not another borrowed-line inventory.
Continue to Part 3: Buffers, Padding & Shift Buffers.
Packet/connection bridge ownership
PTC owns the normal lines it creates on the delivered packet worker. CTP borrows the preceding source's normal line and owns only its PCB/local state. Both use chain-owned persistent packet lines, which neither bridge nor engine destroys. The runtime owns each worker engine beyond individual line Finish.
lwIP callbacks execute on that engine's event worker. Normal-line callbacks and
arbitrary packet neighbours remain queued: a neighbour must not recursively inject
while stack input scratch is active. PTC alone may deliver directly on its owner
packet line to a reciprocal immediate L3 sink advertising
kNodeFlagPacketPayloadEnqueueOnly. The sink may validate, enqueue without waiting
for capacity, or drop; it must not enter an engine, pump a loop, invoke another
tunnel callback, write the native device, or destroy the packet line. TunDevice
advertises this contract on all supported platforms independently of GSO/checksum
mode. Its payload callback appends to the submitting event worker's bounded
output FIFO and schedules one deferred flush on that worker. Native writes and
buffer recycling happen in that later callback, outside the lwIP entry scope.
Topology and capability stay fixed, so queued and inline output cannot
alternate and overtake on a route. Node worker drain detaches CTP's PCB,
registration and deadline while preserving borrowed state, pending buffers and
references until its true source sends Finish, regardless of node storage order.
Trusted TUN/PTC transport checksums do not change line ownership, repair-request consumption, callback direction, or receive credit. Device assurance stays in private FIFO work until publication on the chain-owned packet line; generated normal lines remain owned and drained by PTC.