Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ owns a UDP socket and may return traffic only from destinations previously
requested by that session. Closing the SOCKS control connection closes the
AutoCAR control stream and unblocks both receive loops.

Completed native requests use bounded per-requested-target FIFO lanes: a slow
domain resolution no longer serializes another target's work on the same
association. Every packet still invokes the policy resolver with its original
address; this is not a DNS cache or a destination-policy bypass. The configured
`UDPReceiveQueue` bounds total active plus waiting requests. A lazy pool has at
most eight workers/target lanes, with a 256 KiB payload-plus-address charge cap;
one target gets at most half the packet/byte budget (packet half rounded up).
Metadata is separately bounded by packet and target counts. Idle lanes retire.
Saturation drops new packets, and several slow targets can still exhaust the
shared budget; this is not a delivery guarantee or cross-target ordering.
Closing cancels resolution, discards queued work, closes the socket and joins
all workers before releasing the association's admission.

UDP has no TLS/TCP fallback. Explicit `tls` mode therefore does not advertise
SOCKS5 UDP ASSOCIATE. `auto` does advertise it, but an association always makes a
QUIC attempt and fails if QUIC is unavailable; the TCP fallback circuit applies
Expand All @@ -159,6 +172,17 @@ logical PacketConn, but the H3 implementation creates one authenticated request
stream lazily for each normalized target and reuses that stream for later
datagrams to the same target.

The SOCKS frontend also isolates lazy H3 target opening, but only for transports
that explicitly implement `transport.PacketConcurrentSender`. Canonical target
aliases share one FIFO lane; the WebClient and close-once wrappers preserve
this capability. Legacy/custom PacketConn implementations remain serial unless
they opt in to both concurrent Send safety and Close interrupting every Send.
The frontend uses a shared 32-packet active-plus-waiting budget, the same byte
and half-per-target caps, and at most eight workers (further capped by the
transport's reported limit). It joins sends after closing the endpoints. A
locally queued packet still does not prove remote delivery or H3 path health.
Fresh connection authentication remains serialized; no bootstrap is bypassed.

The HMAC ticket binds the H3 origin, Extended CONNECT protocol, and default
MASQUE target path. After authentication, the relay parses the target, applies
the same UDP resolver policy, freezes one successfully dialed numeric endpoint,
Expand Down
13 changes: 13 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,19 @@ both pacing fields as `not-applicable`, and `--pacing=fixed-rate` is rejected.
`h2` does not advertise UDP. The H3-to-H2 fallback carries only new TCP
streams: UDP never falls back to H2 and fails when H3 is unavailable.

For an established SOCKS association, opening a slow new H3 UDP target does
not serialize sends to another warm target while scheduler capacity remains.
Aliases are grouped using the same canonical target identity as CONNECT-UDP.
This keeps connection authentication and all server destination/admission
checks intact; it neither retries packets nor caches native DNS resolutions.
The frontend queue counts active and waiting packets together (32 total), with
at most eight workers and a 256 KiB payload-plus-address cap. One target can use
at most half the packet/byte budget. Multiple busy targets may still fill the
shared queue, in which case packets are dropped. Close interrupts and joins
pending sends before the SOCKS association is released. Custom packet
transports retain serial sending unless they explicitly opt in to this
concurrency/close contract; see [Architecture](ARCHITECTURE.md).

Only a newly authenticated CONNECT-UDP response is fresh evidence that the H3
path has recovered. Sending on a cached UDP target merely queues a datagram
locally; it does not clear the TCP fallback cooldown or change the last
Expand Down
43 changes: 42 additions & 1 deletion internal/proxy/socks5.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,20 @@ func (c *closeOncePacketConn) MaxPayloadSize() int {
return 0
}

func (c *closeOncePacketConn) ConcurrentSendLimit() int {
if sender, ok := c.PacketConn.(transport.PacketConcurrentSender); ok {
return sender.ConcurrentSendLimit()
}
return 0
}

func (c *closeOncePacketConn) SendTargetKey(address string) (string, error) {
if sender, ok := c.PacketConn.(transport.PacketConcurrentSender); ok {
return sender.SendTargetKey(address)
}
return address, nil
}

// accept records the first valid source port when the UDP ASSOCIATE request
// specified port zero. Every datagram must originate from the control TCP
// connection's peer IP, preventing the relay from becoming an open UDP proxy.
Expand Down Expand Up @@ -482,6 +496,16 @@ func runSOCKSUDPAssociation(
}
}
finish := func() { finished <- struct{}{} }
var sendQueue *transport.PacketSendQueue
var sendErrors <-chan error
var concurrent transport.PacketConcurrentSender
if sender, ok := upstream.(transport.PacketConcurrentSender); ok && sender.ConcurrentSendLimit() > 1 {
concurrent = sender
sendQueue = transport.NewPacketSendQueue(context.Background(), 32, sender.ConcurrentSendLimit(), func(_ context.Context, payload []byte, target string) error {
return upstream.Send(payload, target)
})
sendErrors = sendQueue.Errors()
}

go func() {
defer finish()
Expand All @@ -497,7 +521,16 @@ func runSOCKSUDPAssociation(
if err != nil || len(payload) > maxPayloadSize || !endpoint.accept(source) {
continue
}
if err := upstream.Send(payload, target); err != nil {
var sendErr error
if sendQueue != nil {
target, sendErr = concurrent.SendTargetKey(target)
if sendErr == nil {
sendErr = sendQueue.Enqueue(payload, target)
}
} else {
sendErr = upstream.Send(payload, target)
}
if err := sendErr; err != nil {
if errors.Is(err, transport.ErrPacketQueueFull) || errors.Is(err, transport.ErrPacketTargetUnavailable) {
// Datagram delivery is best effort. Queue pressure or a rejected
// target drops this packet, not other targets on the association.
Expand Down Expand Up @@ -550,6 +583,8 @@ wait:
break wait
case <-controlDone:
break wait
case <-sendErrors:
break wait
case <-activity:
if timer != nil {
if !timer.Stop() {
Expand All @@ -568,7 +603,13 @@ wait:
// Join packet workers before the caller joins its control reader and
// releases the tracked connection. Closing endpoints interrupts their I/O.
_ = local.Close()
if sendQueue != nil {
sendQueue.Stop()
}
_ = upstream.Close()
if sendQueue != nil {
sendQueue.Wait()
}
for completed < 2 {
<-finished
completed++
Expand Down
Loading
Loading