Skip to content

Add deterministic UDP peer preparation - #585

Open
the-sarge wants to merge 3 commits into
pion:mainfrom
the-sarge:gridswarm/prepare-udp-peer-joined-close
Open

Add deterministic UDP peer preparation#585
the-sarge wants to merge 3 commits into
pion:mainfrom
the-sarge:gridswarm/prepare-udp-peer-joined-close

Conversation

@the-sarge

@the-sarge the-sarge commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Add Client.PrepareUDPPeer(ctx, peer): an opt-in way for applications to wait until a UDP peer has a confirmed permission and ChannelBind before sending application packets.

This is a rework of the original diff. It now reuses and extends the existing binding manager, with the existing mutexes covering the bind, instead of introducing a new transaction/allocation lifecycle. The non-test diff is 447 insertions / 23 deletions (~320 code lines excluding comments and blanks), plus 340 lines of tests.

Motivation

  • Today the relayed conn sends via Send Indications and asynchronously upgrades a peer to ChannelData once the background ChannelBind completes. Applications cannot observe or control when that switch happens.
  • The switch changes per-packet TURN overhead (~36 bytes → 4 bytes) mid-stream, so the maximum usable payload changes at a nondeterministic point in the connection's lifetime.
  • This breaks packet-size-sensitive transports layered over the relayed conn — QUIC in particular, which pads Initials to 1200 bytes and validates path MTU once, then assumes it holds. An unobservable overhead change mid-handshake produces timing-dependent failures that are very hard to reproduce.

Rework

Rebuilt per JoTurk's suggestion on Discord (make the existing mutex cover the bind; reuse/extend the current binds; no new transaction and allocation lifecycle):

  • The previous +2668/−138 approach (new operation/lifecycle types, context-aware transaction plumbing) is dropped entirely.
  • An in-flight ChannelBind attempt is now tracked by a per-binding channel under the existing muBind. PrepareUDPPeer waiters select on that channel, so same-peer callers coalesce onto one shared attempt and a caller's context cancellation wakes only that waiter with its cause, leaving the shared attempt running. The binding state machine itself is unchanged.
  • Two small seams were added where the existing code had none: a terminalize transition folded into the binding's existing mutex, so a completing bind attempt cannot resurrect a permanently failed binding, and an onPermRefreshFailure callback on the allocation, because permission refresh failures were previously log-only but readiness must observe them.
  • Deterministic close reuses the same primitives: PeriodicTimer gains StopAndWait, and bind/permission workers are tracked by a WaitGroup gated by the existing closeMutex.

Proposed behavior

  • PrepareUDPPeer(ctx, peer) resolves once the permission and ChannelBind for that peer are confirmed. After it succeeds, writes to that peer use ChannelData or return an error for the lifetime of the allocation — never a silent fallback to Send Indications — so the packet profile is deterministic before the first application byte.
  • Concurrent calls for one peer coalesce on the existing binding state; different peers prepare independently. Caller cancellation wakes only that waiter with its exact cause and does not cancel shared allocation-owned permission/ChannelBind work.
  • Peer addresses are reduced to a canonical form (IPv4-mapped IPv6 unmapped, zones handled), so an alias of a prepared peer shares its permission and channel binding rather than bypassing them via Send Indication.
  • UDP allocation shutdown now joins refresh timers, binding checks, and in-flight ChannelBind/permission work before Close returns. Socket interruption remains the caller's responsibility: the client never closes the caller-owned ClientConfig.Conn or mutates its deadlines, so Close completes once the socket owner permits or actively unblocks in-flight I/O.
  • Known bound: if the server stops responding, Close can take up to the STUN retransmission budget (~63.5 s with defaults) while an in-flight transaction runs out, because transaction waits are not interruptible in this minimal version. Prompt interruption (context-aware transaction waits) is deliberately left out to keep this PR small.

Compatibility

  • Purely additive for applications that never call PrepareUDPPeer, with one intentional refinement: WriteTo now canonicalizes *net.UDPAddr aliases of the same peer so they share one permission and one channel binding.
  • Transaction and refresh plumbing is untouched; TCP allocations are untouched.

Testing

  • go test -race -count=1 ./internal/client . passes.
  • golangci-lint run with the repo config reports no new findings versus main.
  • New tests (all under -race): readiness success then ChannelData-only writes; invalid-peer rejection (unspecified, multicast, zoned, bad port, non-UDP); peer aliases sharing the prepared binding; same-peer coalescing onto one bind and one permission; waiter-local cancellation with its cause while the shared bind survives; permission refresh failure failing writes with no Send-indication fallback; bind failure surfacing to the preparing caller; terminal failure surviving an in-flight bind success; Close joining in-flight bind workers.

@the-sarge
the-sarge marked this pull request as draft August 10, 2026 00:52
@the-sarge
the-sarge marked this pull request as ready for review August 10, 2026 03:53
@the-sarge
the-sarge force-pushed the gridswarm/prepare-udp-peer-joined-close branch 2 times, most recently from eca4dde to 674a4cf Compare August 10, 2026 05:30
Client.PrepareUDPPeer(ctx, peer) creates a permission and waits until
the TURN server confirms a ChannelBind for the peer. After it returns
nil, writes to that peer use ChannelData or fail for the lifetime of
the allocation; they never silently fall back to Send indications.
Failed or expired bindings and permission refresh failures terminalize
readiness and fail subsequent writes.

Per maintainer suggestion, this extends the existing binding manager
instead of adding a new transaction/allocation lifecycle: a bind
attempt is tracked by a per-binding channel under the existing muBind
mutex, so same-peer callers coalesce onto one shared attempt and a
caller's context cancellation wakes only that waiter, leaving the
shared work running. Peer addresses are reduced to a canonical form so
aliases (IPv4-mapped IPv6, zoned addresses) share one permission and
one channel binding.

UDPConn.Close now returns only after allocation-owned goroutines have
finished: the three refresh timers are stopped and joined, and bind or
permission workers are tracked by a WaitGroup gated against close. The
internal ChannelBind 400 close path uses a join-free startClose so a
worker never joins itself. The client still never closes the
caller-owned socket or touches its deadlines. With an unresponsive
server, Close latency is bounded by the STUN retransmission budget,
since in-flight transaction waits are not interruptible; prompt
interruption is deliberately left out to keep this change minimal.
@the-sarge
the-sarge force-pushed the gridswarm/prepare-udp-peer-joined-close branch from 674a4cf to ff1ce8f Compare August 10, 2026 19:18
@the-sarge

Copy link
Copy Markdown
Contributor Author

@JoTurk reworked per the Discord discussion — thanks for the pointer. The original approach is dropped entirely; the branch now reuses and extends the current binds with the existing mutex covering the bind, and no new transaction or allocation lifecycle.

Where it landed: ~447 non-test lines including comments (~320 code lines). That covers both readiness (PrepareUDPPeer: permission + server-confirmed ChannelBind, same-peer callers coalesce, cancellation wakes only the canceled waiter, no silent Send-indication fallback afterwards) and deterministic close (timers and bind/permission workers joined before Close returns).

Tests, all under -race: readiness → ChannelData-only writes, peer-alias canonicalization, same-peer coalescing, waiter-local cancellation, permission-refresh failure fails writes (never Send indication), sticky terminal failure against an in-flight bind success, and Close joining in-flight workers. Happy to adjust further.

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.94737% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.97%. Comparing base (b374908) to head (c7dcea7).

Files with missing lines Patch % Lines
internal/client/udp_conn.go 78.28% 25 Missing and 13 partials ⚠️
client.go 0.00% 5 Missing ⚠️
internal/client/periodic_timer.go 75.00% 2 Missing and 1 partial ⚠️
internal/client/binding.go 92.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #585      +/-   ##
==========================================
- Coverage   82.32%   81.97%   -0.36%     
==========================================
  Files          46       46              
  Lines        3304     3517     +213     
==========================================
+ Hits         2720     2883     +163     
- Misses        381      416      +35     
- Partials      203      218      +15     
Flag Coverage Δ
go 81.97% <78.94%> (-0.36%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A second PrepareUDPPeer caller for the same peer could block on the
permission mutex, held across the whole CreatePermission transaction,
instead of the cancellation-aware attempt channel. Move attempt
bookkeeping to its own mutex so waiters always block in the select
and caller cancellation works during an in-flight permission
exchange. Add a regression test using a delayed permission
transaction.
@the-sarge

Copy link
Copy Markdown
Contributor Author

Heads up — while poking at the close wait-time gap I mentioned on Discord, I found a bug in this branch: a second PrepareUDPPeer call for the same peer could block uncancellably while the first caller's CreatePermission transaction was in flight (it waited on the permission mutex instead of the attempt channel). Fixed by moving attempt bookkeeping onto its own mutex, with a regression test using a delayed permission transaction. +12 lines, one test.

While I was in there I started putting together that wait-time follow-up for after this lands. Shape so far:

  • ~85 non-test lines (plus 216 of tests) on top of this PR
  • zero public API change — the primitive already exists (Transaction.Close(), same thing Client.Close() uses); the follow-up just wires an allocation's close to abort its own in-flight exchanges, so Close() simply gets fast
  • measured, not estimated: against a server that goes silent, close latency drops from the full retransmission budget (~63.5 s at defaults) to about a tenth of a millisecond

TestPeriodicTimer/stop_inside_handler asserted IsRunning 30ms after
starting a 20ms timer, so on a slow runner the check could run before
the handler had fired, failing with "should not be running" (seen on
the macOS CI runner). A fired handler that calls Stop can never leave
the timer running: Stop clears stopFunc under the mutex before it
returns, and IsRunning reports stopFunc.

Signal from the handler after it calls Stop and assert once the
signal arrives, instead of calibrating a sleep against the timer
interval. The timer behavior under test is unchanged.
@the-sarge

Copy link
Copy Markdown
Contributor Author

The macOS 1.25 failure is a flake in the pre-existing TestPeriodicTimer/stop_inside_handler subtest — it sleeps 30ms against a 20ms timer, so a slow runner can run the assert before the handler has fired (reproduced that exact failure signature locally by tightening the margin; a fired handler that calls Stop provably can't leave the timer running). Pushed a small commit making the subtest event-driven instead of sleep-calibrated — timer behavior unchanged.

@the-sarge

Copy link
Copy Markdown
Contributor Author

@JoTurk let me know if you'd rather I squash/force these.

@JoTurk

JoTurk commented Aug 13, 2026

Copy link
Copy Markdown
Member

@the-sarge It's fine to keep the commits we can always squash merge or do it before merge.
Sorry i got more busy with the DTLS 1.3 project, I'll try to review this tomorrow. Thank you for the review and the fixes.

@the-sarge

Copy link
Copy Markdown
Contributor Author

No worries - I had no idea how many different projects you guys had going on! Impressive. Thanks for all of it.

@the-sarge

Copy link
Copy Markdown
Contributor Author

I decided to go a different way so I could make progress...feel free to close this if you don't want it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants