Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds end-to-end handling for UPF-originated PFCP Session Reports (including Downlink Data Reports) by relaying Session Report Requests to the SMF and returning the SMF’s response back to the originating UPF, enabling mobile-terminated reachability (paging) when the adapter sits on N4.
Changes:
- Extend the PFCP UDP receive path to surface the remote peer address and update the dispatcher/handlers to route Session Report Request/Response.
- Record the SMF address (from SMF-initiated messages) and introduce a relay sequence renumbering table to avoid sequence collisions between UPF-originated reports and the adapter/SMF transaction space.
- Add unit tests covering SMF address bookkeeping and relay-sequence mapping behavior (including multi-UPF collisions and wrap/aging behavior).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| upfadapter.go | Records the SMF address from incoming SMF-initiated HTTP messages to support relaying UPF-originated PFCP messages. |
| pfcp/udp/udp.go | Returns remote peer address from reads; introduces a sentinel resend error and updates dispatch signature accordingly. |
| pfcp/handler/handler.go | Implements Session Report Request relay to SMF, response return to UPF, and improved handling for response resend-window timeout logging. |
| pfcp/dispatcher.go | Routes Session Report Request/Response message types to the new handler logic and passes through remote peer address. |
| config/config.go | Adds SMF address storage and relay sequence/origin bookkeeping with an adapter-owned sequence range. |
| config/relay_test.go | Adds unit tests validating SMF address persistence and relay origin/sequence bookkeeping. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
f01635f to
1a3e91d
Compare
|
Thanks — both addressed in The relay address. The concern is real, but I don't think a refusal is the right shape here. The SMF fills that field with its own first non-loopback address, which is the peer address in an ordinary deployment — but not in one where the SMF is multi-homed or reached through a proxy, and rejecting there would break the relay for a difference that is legitimate. So a claim that doesn't match the peer is now reported instead, once per change rather than per message, since every PFCP message the SMF sends arrives on this path. Worth saying plainly, though: that doesn't make the endpoint safe, and I'd rather not imply it does. The same body already names The stale-relay log. Correct, and worse than ambiguous: the map is keyed by the adapter's own sequence while the line printed the user plane's, and several user planes can be waiting on the same number — so it named an entry nobody could look up. It now carries both numbers and the peer address. One unrelated fix in the same push: |
5ee9e98 to
4648a50
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new SMF address recording and session-report relay paths introduce failure/duplication risks (invalid SMF address overwriting, nil-safety, and potential duplicate relays on UPF retransmits) that should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect sequence allocation, address tracking, and retransmission/error handling.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
pfcp/handler/handler.go:307
- The relay entry is already removed before this response transaction starts. If its first
WriteToUDPfails, this callback only logs the error; the UPF receives neither the SMF response nor a rejection, and its next retry is treated as a new report because the deduplication entry is gone. Preserve the origin for this failure path and reject or otherwise complete the UPF transaction instead of silently losing it.
return udp.PfcpEventData{LSEID: 0, ErrHandler: func(msg message.Message, err error) {
logger.PfcpLog.Debugf("resend window closed for session report response seq[%d]: %v",
msg.Sequence(), err)
}}
- Files reviewed: 6/6 changed files
- Comments generated: 4
- Review effort level: Lite
|
@gab-arrobo Four comments on
Each is pinned by a test that fails without it: I removed each fix in turn and watched its own Gates on the pushed tree: build, vet, Two limits worth naming rather than leaving to be discovered:
|
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved sequence-safety, address-expiry, and transaction-state issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
config/config.go:382
- This loop scans every outstanding relay for every incoming report, while holding the global relay mutex. With a slow SMF or a burst of reports, N in-flight entries makes each report O(N), producing O(N²) comparisons and serializing all report handling; that can delay the paging notification the relay is meant to deliver. Keep a keyed
(peer address/port, UPF sequence)index for deduplication and use a separate expiry mechanism rather than a full-map scan on every allocation.
for held, relay := range reportRelays {
if now.Sub(relay.recorded) > reportRelayLifetime {
delete(reportRelays, held)
// Both numbers, and the peer: the map is keyed by the adapter's own sequence while the
// user plane is waiting on its own, and several user planes can be waiting on the same
pfcp/handler/handler.go:254
- The four-attempt bound can reject a valid paging-triggering report even though the relay range still has millions of free sequence numbers. Four legitimate SMF-initiated requests can occupy the consecutive high-half values that this allocator tries, causing all four SendPfcp calls to return ErrDuplicateSequence and the report to be rejected for a transient collision. Continue searching until a free sequence is found (with an exhaustion bound over the whole relay range) instead of treating four collisions as exhaustion.
for attempt := 1; attempt <= relaySendAttempts; attempt++ {
relaySeq, fresh := config.RelayReportSequence(upfAddr, upfSeq, time.Now())
if !fresh {
// A retransmission of a report still being relayed. Relaying it again would raise a
pfcp/handler/handler.go:325
- This new response path creates a
ConsumerTableentry keyed by the UPF's address/port, butremoveTransactiononly deletes the sequence entry and never removes an empty peer table. Consequently, every distinct source address (or source port, which the gate allows) that receives a report response is retained for the process lifetime; moving UPFs or changing NAT ports can make this map grow without bound. Clean up empty peer tables with concurrency-safe ownership, or otherwise bound this peer-keyed state.
if err := udp.SendPfcp(response, upfAddr, reportResponseEventData()); err != nil {
pfcp/handler/handler.go:343
- This new response path relies on the response transaction to absorb retransmissions, but
Transaction.Startcreates one 15-second timer before its resend loop (pfcp/udp/transaction.go:120) and never resets it afterReceiveResendRequest. A report retransmitted near that deadline is answered once more and then the transaction is removed immediately; a later copy is re-relayed as a new report because this handler has already forgotten the relay entry, which can produce duplicate paging. Reset or extend the response timer when a retransmission is received.
// reportResponseEventData reports the end of a response's resend window for what it is.
// A response transaction holds the message so a retransmitted report is answered again,
// and closes with a timeout once the peer stops asking -- the ordinary outcome of a
// delivered response. HandlePfcpSendError would announce that as a message it was unable
// to send, and a false negative on this path is what made the original defect so hard to
// find.
func reportResponseEventData() udp.PfcpEventData {
return udp.PfcpEventData{LSEID: 0, ErrHandler: func(msg message.Message, err error) {
logger.PfcpLog.Debugf("resend window closed for session report response seq[%d]: %v",
msg.Sequence(), err)
}}
- Files reviewed: 7/7 changed files
- Comments generated: 3
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved issues remain in expiry enforcement, sequence allocation, and transaction namespace coordination.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
config/config.go:472
- This check only considers
reportRelays, whileudp.PutTransactioninserts ordinary SMF-originated requests into the same local-address transaction table. If a relay occupies a sequence first and the SMF later sends an ordinary request with that sequence,PutTransactionrejects the ordinary request asErrDuplicateSequence; unlike this path, that request cannot be renumbered, so the HTTP transaction fails. The relay IDs need a namespace unavailable to ordinary requests, or the two transaction namespaces must be coordinated in both directions.
if _, taken := reportRelays[reportRelaySeq]; taken {
continue
}
reportRelays[reportRelaySeq] = reportRelay{upfAddr: upfAddr, upfSeq: upfSeq, recorded: now}
upfadapter.go:63
- This warning claims the adapter will relay to
SmfIp, butconfig.SetSmfAddrnow rejects malformed and unspecified claims on the following lines. A bad claim therefore produces a misleading warning (and may leave the adapter with no destination at all); validate the claim before emitting this message or describe that it is only a claim being validated.
if current := config.SmfAddr(); current == nil || current.IP.String() != udpPodMsg.SmfIp {
logger.AppLog.Warnf("message claims SMF address [%s] but arrived from [%s]; relaying to the claimed address",
udpPodMsg.SmfIp, host)
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved peer-state cleanup, relay-scan performance, and test-isolation issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
config/config.go:464
- This loop scans every outstanding report while holding the global relay mutex for every incoming report, even when the peer/sequence is not present. Under a burst with many reports in flight, handlers serialize and the work per report grows linearly with outstanding state, which can delay relays and paging. Keep a keyed retransmission index (with separate expiry/sequence occupancy) or otherwise avoid the full-map scan.
config/relay_test.go:520
- This test does not isolate
reportRelaysand leaves therepeatedentry created at line 538 in the package-global map. Neighboring tests usewithReportRelays; without isolation, a later test using the same peer/sequence can become order-dependent and falsely classify its first report as a retransmission. Add the test isolation helper at the start.
func TestTakeReportRelayKeepsTheEntryUntilItIsForgotten(t *testing.T) {
upfAddr := &net.UDPAddr{IP: net.ParseIP("10.42.0.220"), Port: PfcpPort}
now := time.Now()
upfadapter.go:61
- The guard compares the stored, canonical
net.IPback to the original text. Equivalent IPv6 spellings (for example, compressed versus expanded form) therefore look changed on every request, so a persistent peer/claim mismatch emits a warning for every PFCP message instead of once per change. Compare parsed addresses withnet.IP.Equalhere.
if current := config.SmfAddr(); current == nil || current.IP.String() != udpPodMsg.SmfIp {
upfadapter.go:63
- This warning is emitted before
SetSmfAddrvalidates the claim. For a malformed or unspecifiedSmfIp, the log says the adapter is relaying to that address even thoughSetSmfAddrrejects it and preserves the previous destination, which makes the diagnostic misleading during exactly the failure this validation is meant to handle. Validate the claim before logging this message, or have the setter report whether it accepted the address.
if current := config.SmfAddr(); current == nil || current.IP.String() != udpPodMsg.SmfIp {
logger.AppLog.Warnf("message claims SMF address [%s] but arrived from [%s]; relaying to the claimed address",
udpPodMsg.SmfIp, host)
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved transaction-lifecycle, authorization, cleanup, and error-reporting issues remain, including one critical finding.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (6)
Previously missed (1) — in code that hasn't changed since the last review.
config/config.go:472
- This sweep holds the global relay mutex while scanning every outstanding entry on each report to expire entries and find a
(peer, UPF sequence)match. With the 30-second retention window, admission is O(number of in-flight reports) and all UPFs serialize behind the scan, so a burst can turn relay handling into a CPU/latency bottleneck. Keep a secondary dedup index keyed by peer plus UPF sequence and use an expiry queue/heap (or another bounded cleanup structure) instead of a full-map scan.
config/config.go:434
- When the SMF goes quiet, this branch only returns
false; it does not remove the stale address/node records or report the released host toudp.ForgetConsumer. SinceRecordUpfAddris the only sweep that performs that release, a peer that was answered before aging out leaves an emptyConsumerTableentry (and the authorization indexes) indefinitely, so the new 30-minute bound does not bound per-peer state in the quiet-SMF case. Add a cleanup path for age-based expiry (or a periodic sweep/callback) that can release the corresponding UDP state without holding this read lock.
for node := range upfAddrs[ip.String()] {
if now.Sub(upfNodeAddrs[node].seen) <= upfAddrLifetime {
return true
}
}
pfcp/handler/handler.go:213
- Authorization is enforced only after
readPfcpMessagehas already runfindTransactionfor requests. If a UPF is deauthorized while its response transaction is still present (or the lookup racesForgetConsumer), a retransmitted Session Report is consumed there, causing that transaction to resend without reaching thisIsKnownUpfAddrcheck. That violates the drop-on-unknown behavior; perform the source check before duplicate lookup for Session Report requests or make lookup authorization-aware.
if !config.IsKnownUpfAddr(upfAddr.IP) {
pfcp/handler/handler.go:335
- Deauthorization can remove the UPF's
ConsumerTablewhile this SMF response is in flight. BecauseSendPfcpis allowed to run for the stored origin without rechecking authorization, it can recreate a table for an address that has already been released; after the 15-second response transaction is removed, the empty table is never reclaimed. Repeated moves with in-flight reports can therefore reintroduce the per-peer leak this cleanup is meant to prevent. Coordinate this send with deauthorization or add cleanup for recreated tables.
if err := udp.SendPfcp(response, upfAddr, reportResponseEventData()); err != nil {
pfcp/handler/handler.go:353
- This callback receives both the normal response-window timeout and an actual
WriteToUDPfailure, but logs both only at Debug level. A closed socket or other send error can therefore leave the UPF without a response and be invisible at normal log levels; preserve the quiet timeout handling while reporting transport failures as errors (and, if appropriate, retrying them).
func reportResponseEventData() udp.PfcpEventData {
return udp.PfcpEventData{LSEID: 0, ErrHandler: func(msg message.Message, err error) {
logger.PfcpLog.Debugf("resend window closed for session report response seq[%d]: %v",
msg.Sequence(), err)
}}
pfcp/udp/udp.go:130
- This makes duplicate-sequence handling asymmetric: the relay retries when it loses the
LoadOrStore, but ordinary SMF-initiated requests still return this error directly fromForwardPfcpMsgToUpf. If a report reserves a relay ID first and an SMF request happens to use that same sequence concurrently, the legitimate SMF request is rejected even though the relay could have moved to another ID. Coordinate allocation so a relay cannot claim an ID needed by an SMF request, or add an equivalent retry/reservation path for ordinary requests.
if _, loaded := txTable.LoadOrStore(tx.SequenceNumber, tx); loaded {
return fmt.Errorf("insert tx error: %w %d", ErrDuplicateSequence, tx.SequenceNumber)
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate relay correctness, cleanup, expiry, and scalability issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
config/config.go:499
- This allocator only avoids sequence numbers already present in
reportRelays; it does not reserve the sharedudp.ConsumerTableslot atomically with ordinary SMF requests. If an SMF-originated request chooses this number after the relay entry is created, its normalSendPfcppath reachesPutTransaction, getsErrDuplicateSequence, andForwardPfcpMsgToUpfhas no retry path, so an unrelated control-plane request fails because a report is in flight. Coordinate relay allocation with the common transaction table (or otherwise isolate/reserve the sequence space) so the collision cannot reject the ordinary request.
candidate, free := nextFreeRelaySequence(reportRelays, reportRelaySeq, relaySequenceFloor, relaySequenceCeil)
config/config.go:476
- This is a lazy expiry sweep: an old
reportRelayis deleted only when another report callsRelayReportSequence. If an unanswered burst is followed by silence, every origin remains inreportRelaysindefinitely, so the claimed 30-second bound does not hold and a burst can permanently retain a large map. Add a timer/expiry queue or otherwise clean entries independently of new reports.
for held, relay := range reportRelays {
if now.Sub(relay.recorded) > reportRelayLifetime {
delete(reportRelays, held)
pfcp/handler/handler.go:23
- The fixed limit can reject a report even when a usable relay sequence exists: four consecutive candidates may be occupied by legitimate SMF-originated transactions while the fifth is free, but the loop stops and sends
RequestRejected. Since the allocator already skips occupied numbers and only reports true exhaustion for a full range, retry until a bounded full-range scan (or otherwise choose a free candidate) instead of treating four collisions as exhaustion.
// relaySendAttempts bounds how many sequence numbers a single report may try before it is
// rejected. Each retry costs one map insertion, and a peer numbering requests fast enough to
// take several of the adapter's numbers in a row is not a peer more attempts would help.
const relaySendAttempts = 4
pfcp/handler/handler.go:335
- This creates the
SendingResponsetransaction used to absorb UPF retransmissions, butTransaction.Startstops afterNumOfResend(three) resend events rather than keeping the entry for the full 15-second response window. A fourth retransmission within that window is then dispatched as a new Session Report afterForgetReportRelayhas run, causing a second SMF notification/page; retain the response transaction for the whole window or keep the relay deduplication entry until it expires.
if err := udp.SendPfcp(response, upfAddr, reportResponseEventData()); err != nil {
- Files reviewed: 10/10 changed files
- Comments generated: 3
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
A critical stale-claim error path and two moderate bookkeeping/test-isolation issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
config/relay_test.go:526
- This test mutates the package-global relay map and sequence counter but does not call
withReportRelays, and its finalrepeatedclaim is never forgotten. Later tests inherit that live claim and counter value, making outcomes depend on test order and leaving state behind for repeated runs. Isolate the test withwithReportRelays(t)(or explicitly clean the final claim).
pfcp/handler/handler.go:384
- This
deferdrops the relay claim even whenudp.SendPfcpreturns an error before registering a response transaction (for example, when the server is unavailable or the sequence is duplicated). A retransmitted report can then find neither the claim nor a response transaction and be relayed to the SMF again, producing a duplicate downlink notification/page. Keep the claim on the error path and forget it only afterSendPfcpsucceeds.
defer config.ForgetReportRelay(relaySeq)
- Files reviewed: 12/12 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical relay-lifecycle and sequence-collision issues can duplicate reports or fail normal N4 traffic.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
pfcp/handler/handler.go:448
rejectSessionReporthas the same asynchronous contract: a nil error only means the response was registered, not delivered. If its initial UDP write fails,startTxLifeCycleremoves the response transaction after this function has forgotten the claim; a UPF retransmission then creates a second relay and page. Coordinate claim cleanup with the actual send result rather than treatingSendPfcpreturning nil as delivery.
config.ForgetReportRelay(relaySeq)
pfcp/handler/handler.go:420
- This callback runs for both a normal response-window timeout and an actual initial
WriteToUDPfailure, but it always logs “resend window closed” at Debug level. A response that never left the socket is therefore indistinguishable from a delivered response whose peer stopped retransmitting and may be invisible at the normal log level; distinguish send failures from timeout and log the former as an operational error.
return udp.PfcpEventData{LSEID: 0, ErrHandler: func(msg message.Message, err error) {
logger.PfcpLog.Debugf("resend window closed for session report response seq[%d]: %v",
msg.Sequence(), err)
}}
- Files reviewed: 12/12 changed files
- Comments generated: 4
- Review effort level: Lite
| // Not a blocking send, for the same reason: a second response to a request already answered | ||
| // would otherwise hold the read loop until its transaction came back for the first. | ||
| select { | ||
| case tx.EventChannel <- ReceiveValidResponse: | ||
| default: | ||
| logger.PfcpLog.Debugf("seq[%d] is already answered; dropping a repeat response", msg.Sequence()) | ||
| } |
There was a problem hiding this comment.
Real, and fixed at 6be9a83. The deadline was started once outside the loop, so it measured from the first answer rather than from the last copy: a peer still retransmitting when it expired outlived the transaction that was absorbing those copies, and the next one arrived at the handler as a new request — a second downlink data notification for traffic already reported. Each resend now stops the timer, drains it if it had already fired, and resets it.
One limit does not change, and the commit says so: the loop is still capped at NumOfResend, so a fourth copy meets no transaction whatever the timing. This is about the window each of the three is given, which they previously shared.
A Session Report is the only message on N4 that the user-plane function originates, and a downlink data report is the only way the SMF learns that an idle UE has traffic waiting. This adapter handled neither: type 56 and 57 were commented out of the dispatcher, so a report fell through to the default branch and was logged as an unknown message type. There is no UPF-to-SMF direction here at all. The effect is that any deployment putting this adapter on N4 has no mobile-terminated reachability. It fails silently, twice over: the SMF never learns there is downlink traffic, and the user-plane function's request is never answered, so it retransmits into nothing while holding traffic it should either deliver or release. The relay: - the dispatcher routes type 56 and 57, and the read path returns the peer address, without which a UPF-originated message can be neither answered nor attributed; - the SMF's address is recorded from the SMF-initiated messages that already carry it, since a relayed report has no request of ours to answer; - the report is renumbered into the adapter's own sequence space and the user plane's own number is restored on the response. Outstanding requests are held in one table keyed by this socket's address, so every request the adapter sends shares a single sequence space: forwarding a UPF's number into it is refused as a duplicate whenever an SMF-originated request is in flight under the same one, and the report is then rejected for no reason but coincidence. With several user planes, whose counters are independent, two reports collide directly; - a report that cannot be relayed is rejected rather than dropped, including when the SMF never answers, so the user plane always learns the outcome; and the origin is forgotten after 30 s so an unanswered report costs nothing lasting. Two smaller things this made necessary. A response sent to a user plane keeps its own error handler: the resend transaction ends in a timeout once the peer stops retransmitting, which is the ordinary outcome of a delivered response, and the generic handler announced that as a message it had been unable to send. And the retransmission check compared an error against a spelling that error never had, so every retransmission was logged as a read failure -- harmless while no user-plane-originated request was handled, reachable now, and replaced with a sentinel. Verified on a live deployment: a downlink packet to an idle UE now produces a Downlink Data Report from the user plane, a relayed report to the SMF, Paging on N2, and the UE's user plane restored, with the report answered under the sequence number the user plane used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…ences Two review findings. The relay address comes from the request body, on a handler with no authentication. The concern is real but the check cannot be a refusal: the SMF fills the field with its own first non-loopback address, which is the peer address in an ordinary deployment but not in one where the SMF is multi-homed or reached through a proxy, and refusing there would break the relay for a legitimate difference. A mismatch is now reported instead, once per change rather than per message, since every PFCP message the SMF sends arrives on this path. Worth stating plainly: this does not make the endpoint safe. It already forwards to whatever UpNodeID the same body names, so a caller that can reach it already directs traffic. Authenticating the endpoint is the fix for that, and it is not this PR. The stale-relay warning named only the user plane's sequence while the map is keyed by the adapter's own, and several user planes can wait on the same number - so it named an entry nobody could look up. It now carries both and the peer. reportRelay's fields are reordered for govet's fieldalignment, which CI's golangci-lint v2.13.2 flags. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A session report was relayed for whatever could reach the UDP port. N4 carries no transport authentication, so a forged report with a live SEID reached the SMF and paged a UE; and because a report that cannot be relayed is rejected rather than dropped, any source was answered too. Each answer costs a resend transaction with a goroutine and a 15 s timer, keyed in ConsumerTable by the peer's address -- a key that PutTransaction creates and nothing ever deletes. Until this branch the adapter only ever sent requests, so that key was the socket's own address and there was one of them; relaying made it peer-chosen. The gate is the set of user-plane addresses the SMF itself names, recorded from UpNodeID on every message the SMF sends through us. Not UpfCfg.UPFs: that table is filled only on the association-setup path, and an adapter restart empties it while the sessions stay established. Nothing then makes the SMF associate again -- the heartbeat responses it gets carry the user plane's own recovery timestamp, not ours, and a heartbeat that fails to send while the adapter is down is not counted as a miss, because that increment sits in the success branch -- so the table would stay empty for the life of the association. Learning from ordinary traffic comes back within one heartbeat, the same way the SMF's own address does. An unrecognised source is dropped, not rejected: it holds no session here, so it is owed no answer, and answering it is what costs the state. The check runs before the no-SMF-address rejection so the only peers ever answered are named ones. The drop is logged with the peer, deliberately not rate-limited -- the realistic case is a user plane whose source address differs from its node ID, and that has to be diagnosable. Responses stay ungated. One is matched to its outstanding request by sequence number, as every other response type already is, so checking the sender belongs in the transaction layer rather than in the relay. Also spells ErrResendRequest as fmt.Errorf, the idiom of every other error in that file, and compares it directly -- readPfcpMessage is its only source and returns it unwrapped -- which is what actually drops the errors import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
Two things the third review round found, both in paths this branch adds. The SMF's address was stored as the unparsed string the request body claims and parsed at every use, so a claim that is not an IP erased an address that worked: SmfAddr returned nil from then on and every session report was rejected until the next SMF message happened to carry a good one. That body is unauthenticated, so nothing but the SMF's own behaviour kept it from happening. It is parsed where it arrives now and refused there, which leaves the getter with nothing to fail at. The getter hands out a copy, which it did implicitly while it parsed on every call; that is a property kept rather than a fault fixed. A user-plane function retransmits a request that has not been answered, and until the answer exists nothing here recognises the copy -- a report has no transaction of its own until it is answered, and the response transaction that absorbs retransmissions is created only then. Each copy was therefore renumbered into the adapter's sequence space and forwarded as a report in its own right, raising a downlink data notification each time for traffic the SMF was already being told about. A report from the same address and port under the same sequence number -- the identity this adapter keys every other transaction by -- is now recognised as the exchange already in flight and left for that relay to answer. The check rides along with the expiry sweep that already walks the map, and an entry past its lifetime is swept rather than matched. Delivery is not weakened, but the claim is narrower than unaffected: a relay lost on the way to the SMF used to be recovered by whichever came first, the adapter's own resend or the peer's copy, and now only the adapter's schedule recovers it -- the same notification, possibly a little later on that path. The relayed report is sent three times across nine seconds and the peer is rejected if the SMF answers none of them. Once an exchange is answered, or rejected, the same number is a new report again: what is recognised is the exchange, and the check does not extend its lifetime. What this does not do is tell a genuine report from a forged one. Two reports from a known user plane's address under one sequence number inside that window are one exchange here, and the second is dropped -- the same limit as the source gate, which a spoofed source matching a real user plane already passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
Four findings on f523002, all in the relay this branch adds. A claimed SMF address that is unspecified is refused. net.ParseIP accepts 0.0.0.0 and ::, and neither is a destination, so a claim carrying one erased a working address and every report afterwards was relayed into nothing until a later SMF message happened to carry a usable one. RecordUpfAddr already applied that test to a user plane's address; SetSmfAddr applies it too. The set of user planes the SMF has named forgets the address a node has left. It was keyed by address alone and so could only grow: a user plane reached by name that comes back on another address left its old one authorised for the life of the process, and whatever took that address over was relayed for as though the SMF had named it. Addresses are now held per node identity, and one is dropped once no identity is left at it -- two can share an address, because the SMF may name the same user plane by FQDN in one message and by IP in another. A relayed report is tried under the next sequence number when the transaction table refuses the first. Every request the adapter sends shares one table keyed by its own socket, the ones it relays to the SMF and the ones it forwards to a user plane under the SMF's own numbering alike, so starting this numbering at the halfway point separated them by convention only -- and the convention ends when the SMF's counter reaches that half, which this code cannot see. udp exports ErrDuplicateSequence so a caller that owns its numbering can tell that case from a send that failed. The origin of a relayed report is kept until its answer has been sent rather than dropped when the answer is claimed. The response transaction that absorbs retransmissions exists only once the answer goes out, and udp.Run dispatches each message on its own goroutine, so dropping it at the moment of claiming left a window in which a retransmission matched nothing and was relayed again -- a second downlink data notification and a second page, which is what this branch exists to prevent. TakeReportRelay claims, ForgetReportRelay releases, and only the path that claimed releases: claiming can fail because the other path got there first, and forgetting from the losing path would reopen the window. Each is pinned by a test that fails without it -- the guard removed, the forgetting removed, an address dropped while another identity is still there, the retry turned into a rejection, and the entry deleted on claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
Round five of the review, all three real and all three about the same shape: a check and the write that depends on it, separated by enough space for someone else to get in between. PutTransaction read the table to see whether a sequence was free and stored afterwards. Two goroutines numbering requests at once could both find it free and both store, and the second replaced a transaction whose response was still to come while its caller was told the message had gone out. Both insertions are insert-if-absent now, and TxTable.Store and ConsumerTable.Store are gone with their only caller, so the next insertion cannot reach past the discipline by picking the obvious method name. Measured rather than asserted, because the first version of this message got it wrong: against the previous shape, TestPutTransactionAdmitsOneSenderPerSequence fails 13 runs in 200 by default and 63 in 200 under -race, admitting two to six senders where one may pass. It is a race test and catches this intermittently. TestTxTableRefusesASecondSenderUnderOneSequence pins the contract deterministically -- a second insert is refused and the first transaction survives -- but it does not distinguish the two shapes on its own, since both refuse a sequential second insert. Between them: the contract cannot be lost silently, and the race is caught often enough to matter in CI. RelayReportSequence dropped expired entries and then assigned the next number unconditionally. Once the counter came round, an entry still outstanding was replaced: the SMF's answer to the first report would be returned to the user plane that raised the second, and the first exchange would never be answered at all. It now advances to a number nothing is waiting on, and reports ErrRelaySequenceExhausted when the whole range is outstanding, which the handler names explicitly and turns into a rejection -- the user plane is told rather than left waiting. RecordUpfAddr returned early for a node whose name does not resolve, leaving the address it had recorded authorised with nothing able to remove it. Addresses now age out: a node carries the time the SMF last named it, and one that has not been named for upfAddrLifetime is dropped along with the address it held. An identity whose name fails to resolve is still noted as named, because a name that does not resolve for a moment is not a user plane that has gone, and revoking there would stop relaying reports for a node that is merely waiting on DNS. The sweep runs from RecordUpfAddr, which every message the SMF forwards reaches, so it happens as often as there is anything to sweep -- and after this message has recorded its own node, not before. The other order expires a node on the very message that shows it is still in use, which is a mistake this made once and a test now prevents. Thirty minutes is 180 of the SMF's heartbeat periods, so a node is swept only once the SMF has stopped talking to it altogether. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…the range right Two more from the review, both on what the previous commit added. The lifetime was enforced only by the sweep, which runs on SMF traffic. That is the wrong side: an SMF that has gone quiet is the case the lifetime exists for and the case in which nothing sweeps, while reports keep arriving from user planes and the gate keeps answering from a map nothing has aged. IsKnownUpfAddr now tests the age of the identities at an address rather than only their presence, so a report is declined once the SMF has not named its user plane for upfAddrLifetime. Removing the entry still waits for the next SMF message; this only stops answering on its behalf in the meantime, and it stays a read-lock path. An address carrying several identities is refused only when all of them are stale. The search for a free sequence number tried ceil-floor candidates for a range that is inclusive at both ends -- the counter resets only once it is past the ceiling, so the ceiling itself is handed out. One trip short skips exactly one number: the one the search started from, which the wrap reaches last, and which is the one still free when every other is taken. It is now a function of its arguments, nextFreeRelaySequence, so that arithmetic can be tested over a range small enough to fill -- four numbers rather than 8 388 608. One difference the extraction makes, inert but real: the old loop advanced the package counter in place on every trip, so an exhausted search left it wherever the last trip reached. The search is now a function of its arguments and the counter is assigned only on success, so an exhausted search leaves it where it was. Reaching that state needs the whole range outstanding at once, which the 30-second lifetime puts out of reach. Two notes on the tests, because the first versions of both did not test what they claimed. The staleness test used 10.42.0.260, which is not an address: net.ParseIP returned nil, the gate refused on the nil alone, and it passed against the old gate too. With a real address it fails against it, which is what makes it worth having. The range test asserts that the ceiling is handed out and the counter then wraps to the floor. That is the premise the trip count rests on, but it does not exercise the count itself: it starts from an empty map, so the first candidate is always free. TestNextFreeRelaySequenceFindsTheOneTheWrapReachesLast is the one that fails when the count is one short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
Answering a user plane leaves a transaction table keyed by its own address -- ConsumerAddr is the destination for a response, where for a request it is this socket -- and nothing in the transaction machinery removes an empty one. So every user plane ever answered costs an entry for the life of the process, and a node that comes back on another address leaves the old one behind. This branch is what makes that reachable. Every SendPfcp on main sends a request, so main holds exactly one entry, for the adapter's own socket; the two response sends this branch adds are the first keyed by a peer. Reclaiming a table by emptiness would race the next insertion for the same peer, and losing that race relays a retransmission a second time -- the defect this relay exists to remove. Reclaiming when the peer stops being authorised cannot do that: the source gate runs before the relay, so a report from a released address is dropped rather than renumbered and sent again. It is not free, though. A response is held for 15 seconds so a retransmission can be answered from it, and an address released inside that window loses the held answer: the resend is dropped by the gate instead, and the user plane sees its report as unanswered although the SMF did answer it. That needs the SMF to move a node to another address while one of its reports is still retrying. Ageing cannot reach it -- that path needs 30 minutes of silence about a node whose resend window is 15 seconds. RecordUpfAddr already knows when that happens, both when a node moves and when one ages out, and it now returns those addresses. upfadapter.go passes each to udp.ForgetConsumer, because config cannot call udp -- udp imports config. The release is by address rather than by address and port. The peer chooses the port it sends from, and the table is keyed by what arrived, so releasing one guessed port would leave the entry that exists. Three tests, each checked against its own absence: the two halves of RecordUpfAddr reporting what it released, and DeleteByHost releasing every port seen for one peer while leaving another peer's table alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
Releasing a peer's table made a second thing reachable, and the reviewer of that release found it. A transaction is removed by the goroutine that ran it, and that goroutine finds its table by the peer's address. Releasing a peer drops its table; a peer named again gets a new one. The address is the same, so the old goroutine's removal reaches the successor's table -- and it deleted by sequence number alone, which takes the successor's response out of its resend window. The next retransmission then finds nothing to absorb it and is relayed to the SMF as a report of its own, which is the defect this branch exists to remove. Removal is by identity now: CompareAndDelete takes the entry only while it is still this transaction, and the error says which case it was. TxTable.Delete is gone with its only caller, so the discipline cannot be reached past. One half of the comment does not hold, and the reason is the same mechanism: a stale response cannot resend into a reused endpoint. A response resends only when told to, and it is told through findTransaction, which reaches whatever the table holds now. Once released, nothing points at the old transaction at all -- it waits out its window and exits. Deleting a peer's table cannot send anything. TestRemovingATransactionLeavesItsSuccessorAlone holds a response for a peer, releases it, holds another under the same address and sequence number, and removes the first. Deleting by sequence alone fails it twice: the removal reports success, and the successor is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…s held The release added two commits ago ran after RecordUpfAddr returned, which leaves a gap the review of the commit after it found one layer up: between the address being dropped and the table being released, the SMF can name that address again and a report from it can be answered. The release then throws away what that answer is holding -- a response transaction whose retransmissions would no longer be absorbed, so the next one is relayed as a report of its own. Closing it turned out to remove code rather than add it. config calls a hook while it still holds the lock, udp registers ForgetConsumer at initialisation, and the slice of released addresses that RecordUpfAddr returned -- and the loop in upfadapter.go that consumed it -- are both gone. What that closes is the successor case, and only that: a table can no longer be dropped out from under a peer named at the same address while the release was on its way. It is not atomicity over every ordering. A report passes the gate, waits for the SMF, and is answered afterwards, and the address can be released in between -- so a departed peer's own late answer can still create a table for an address nothing authorises. That table holds one entry, which its own lifecycle removes, and by the previous commit's reasoning it is inert: a table sends nothing, and the entry can only be taken by the transaction that owns it. A hook rather than a return value is the shape the import direction leaves: udp imports config, so config cannot call it. TestAnAddressThatIsLeftIsReleasedUnderTheLock and TestAnAddressThatAgesOutIsReleased pin that both paths release. Note what the first one actually checks: it asserts the hook fires with the address, and its TryLock inside the hook catches a release moved out of the critical section on the same goroutine. A release moved to another goroutine is caught by the first assertion rather than the second, because the hook may not have run yet when the test looks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…livered A report is claimed while it is relayed, and the claim is what tells a retransmission of it from a new report. Three things could part the claim from the report it belongs to. A relay number the SMF is already using is refused, and the report has to go out under another. That was done by forgetting the claim and making a new one, with the lock released in between: a copy of the report arriving in the gap found nothing claimed, was taken for a fresh report, and was relayed on its own account -- one report from one user plane becoming two downlink data notifications, and two pages. Messages are dispatched on their own goroutines, so the gap is reachable. It is now one operation under one hold, and the claim is made once outside the send loop rather than remade on every attempt. An address that stops being a user plane the SMF names kept its outstanding claims for up to the relay lifetime. Addresses are reused -- a pod that leaves gives its address to the next one -- and the next occupant's first report can carry a sequence number the previous occupant had outstanding: it was matched as that report's retransmission, so the new peer's report was never relayed, and the answer to the old one would have been delivered to a user plane that never sent it. The claims now go when the address does, from inside the lock that de-authorises it, next to the consumer state that was already released there. And recognising a retransmission walked every claim the adapter held, under the one lock every user plane shares, on every report. The table holds the reports genuinely in flight -- a claim is dropped when its answer is sent -- so it is short while the SMF is answering, which is also when this costs least. It grows when the SMF stops, which is the worst moment to spend a whole-table walk per packet. Claims are now indexed by the peer that raised them, so the match costs the reports outstanding from one user plane, and the expiry walk is throttled to once a second. Nothing depends on the walk having run: the two places a stale claim would be wrong -- the retransmission match and answering a report -- test the age of the entry they found. The index is written only by the pair of functions that write the table it indexes, the peer-release path included, so there is one place to change if either grows a field and one place for a reader to check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A report is absorbed by its claim while the adapter is relaying it, and by the answer once one has been sent -- sending the refusal registers the response transaction that does the absorbing. Refusing a report moves it from the first to the second, and the two have to overlap. They did not. The claim was ended first, and for the instant before the refusal went out a retransmission was recognised by neither: it was taken for a new report and relayed on its own account, which is the second downlink data notification and the second page this path exists to prevent. Three exits had the order that way round -- the send that failed for some other reason, the attempts-exhausted tail, and the renumber that could find no free number, where the allocator was releasing the claim itself before the caller had answered. All four exits go through one refuseAndRelease, which answers and then ends the claim, so the rule lives in one place rather than being restated correctly at each of them; the allocator no longer ends a claim its caller still has to answer for. The error handler was already in this order and now says so by using the same helper. Held by a test that asks the two questions the receive path asks, in the order it asks them, while a report is being refused: it fails on the first round when the two lines are swapped. The refusal reports whether it went out, which is what makes the ordering mean anything: the transaction exists only if the send succeeded. When it did not, the claim is kept rather than released -- retransmissions go on being recognised as a report already in hand instead of relayed afresh, and the claim falls away with its lifetime. The user plane is left without an answer either way; this is the failure that does not page the UE twice. That path is not covered by a test: forcing the adapter's own send to fail means replacing the shared server the package's other tests are using, and racing them is a worse trade than the coverage is worth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…en away Registering a transaction is a lookup and an insertion: the peer's table, then the sequence within it. A release lands between them and detaches the table the insertion writes into -- and a transaction in a detached table is invisible. The next lookup for that peer builds a fresh one, so no response would be matched to it and no retransmission absorbed, while the sender was told its message had gone out. Peers are released while the adapter is answering them, which is exactly when a response is being registered. The pair is checked afterwards instead: if the table is still the peer's, the insertion stands; if it is not, the transaction goes into whatever replaced it. A handful of rounds, because a peer is released once, not repeatedly -- and if it keeps being taken away, the honest answer is that the message cannot be registered rather than another round. It also corrects the test that came with the previous commit, which was not sound. It sampled the two questions the receive path asks while a report was being refused, and it has its own gap between them: it can read "no answer" before the refusal is sent and "no claim" after it is released, and call that a window. It fails about once in a hundred rounds against the code it was meant to hold, which I found by pointing it at a peer that accepts the refusal -- until then its answers were being removed by a write to a port nothing listens on, which masked the false positives with a real one. What replaces it pins the outcome and says what it cannot pin: after a refusal the answer is registered and the claim is gone, so a retransmission is met by the answer. The order of those two is held by refuseAndRelease being the one place that ends a claim, not by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A forwarded request is registered before it is sent, because the UPF's answer can arrive before the send call returns. When the send fails there is nothing left to wait for that answer, and the registration stayed: the next response carrying that sequence number was handed to a requester that had already gone, while the requester it belonged to waited for an answer given to someone else. The registration now goes with the send, on all five request types -- its own registration and only its own. Sequence numbers are the SMF's, and a second request can carry one already in flight here, a retransmission by definition; deleting by number alone would take that request's registration instead and do to it exactly what this repairs. One thing it does not repair, named rather than implied: two requests under one sequence number still overwrite each other at registration, and the first then waits for an answer the second will be given. That is how it behaved before this change too, and refusing the second is a different decision -- it would turn a retransmission of a request that is genuinely stuck into a failure rather than a recovery, and there is nothing that ages these entries out. Happy to take it separately. Answering could also block for good. The channel the answer is sent on was unbuffered, so the receive goroutine blocked until someone read it -- and after a failed send nobody would. It holds one answer now. A missing registration was worse: an absent entry yields a nil channel, and the answer was sent into it, which blocks for the life of the process. That is reported instead, on both paths that answer. The test for the ordering fix below drives the stale state directly rather than racing two goroutines for the mutex: it pins the comparison and the branch it guards, not the interleaving. And a user plane's address could be un-moved by a message that predates the move. The address is resolved before the lock is taken, and this is called from concurrent HTTP handlers, so the message that resolved first can reach the lock second -- authorising the address the node has left and forgetting the one it moved to. The resolution is dated before it is taken, and one older than what the node's record already holds now changes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…ved what they checked A retransmitting user plane could stop the adapter reading PFCP at all. The receive loop hands a resend notification to the transaction on a channel that holds one event, and it did so with a blocking send -- so a peer retransmitting faster than its transaction reads filled the channel and the loop stopped there, for every session rather than that one. One pending notification says everything a second would, so both sends are non-blocking now: a resend is already queued, or the request is already answered. Registering a transaction is serialised against releasing a peer's table. The check after the insertion narrowed that window rather than closing it -- a release landing between the check and the return leaves the transaction in a detached table just the same -- so the pair is held under one lock instead. Worth saying plainly: no test here fails without that lock. The window is two adjacent statements wide and a test cannot stand between them; what the tests pin is the predicate the lock protects. And a report could be claimed for a peer that had stopped being one. The source check releases its lock before the claim is made, and an SMF message can release the address in that gap -- so the claim outlived the authorisation that allowed it, and an address in a cluster is reused: the next occupant's first report matches that claim as a retransmission and is answered with the previous occupant's response. Releases are counted per address now; the handler reads the count before it tests the source, and a claim made under a count that has moved is refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…pt for ever A report whose claim is released while it is being relayed was answered rather than dropped. The renumber that follows a sequence collision reports that the claim is gone with its own error, and the handler treated every error from it the same way: it refused the report, which creates a response transaction for a peer the source gate has already revoked -- and holds it for whoever occupies that address next. A claim that was released is now dropped, as a report from an unknown source is, and only an allocator with nothing free still refuses. Release counts are no longer kept for the life of the process. They exist so a claim made after a release can be told from one made before it, and the gap between reading a count and claiming under it is one handler's work -- so a count older than the relay lifetime cannot be quoted by anyone, and the sweep that already walks this state drops it. Without that the map held one key for every address the deployment has ever retired, which in a cluster is one for every user-plane pod that has come and gone. Two pieces of test hygiene ride along, both found by running the suite eight times over rather than once: the release counts are saved and restored per test like the tables beside them, and the test that checks a taken relay outlives its answer now isolates itself -- it leaves an entry behind on purpose, which met the next run of itself as a retransmission. That one was failing seven runs in eight before this change, and once in one. The dropped-claim branch is not covered by a test: reaching it takes a sequence collision with the SMF and a release landing between that collision and the renumber. What is covered is the contract it rests on -- that renumbering something no longer held says so with its own error, rather than arriving as the failure that means something else entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
… last A response transaction absorbs retransmissions of the request it answers by resending the answer. Its deadline was started once and never reset, so it ran from the first answer rather than from the last copy: a peer still retransmitting when the window expired outlived the transaction absorbing those copies, and the next copy reached the handler as a new request. For a relayed session report that is a second downlink data notification for traffic the SMF has already been told about. Each resend asks for the window again -- stop, drain if it had already fired, reset. What that does not change is how many copies a transaction absorbs: the loop is still capped at NumOfResend, so a peer retransmitting a fourth time meets a handler with no transaction holding its request, exactly as before. This is about the window each of those three copies is given, which was previously shared between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A report's claim is what recognises a retransmission while the report is being relayed; the transaction created by answering it takes over once it has been answered. When the refusal could not be sent there is no transaction -- and the claim was kept for the rest of its lifetime rather than released. That is the wrong way round. Nothing is in flight for the report then and nothing has answered it, so every copy the user plane sends is absorbed by a claim that will never answer it: the user plane retransmits its full budget, gives up, and the downlink data is not delivered. Relaying the next copy afresh is the only route left to an answer. The claim now ends whether or not the refusal went out, as it already did on the answer path, where the same reasoning applies to a registration that never reached the wire. Three of the four ways here refuse a relay that never went out -- a local sequence collision, a send that failed before the write, a range with nothing free -- and for those there is nothing at the SMF for a second notification to duplicate. The fourth is the give-up after the SMF answered none of three copies over nine seconds, and those copies may well have arrived; a report relayed afresh there can raise a second notification. That needs the path to the SMF and the path to the user plane to fail at once, and it is still the better outcome: the alternative holds the report for its full lifetime and answers it never. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
b015a00 to
507fc9c
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved critical authorization-expiry race and moderate stale-transaction bypass block approval.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
Resolved since last review (3)
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Resolve the critical sequence-reservation race and the IPv6 address comparison issue.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
Resolved since last review (1)
| for held := range relaysByHost[addr] { | ||
| logger.CfgLog.Infof("user plane at [%s] is no longer named by the SMF; forgetting its session report relay: adapter seq[%d], user plane seq[%d]", | ||
| addr, held, reportRelays[held].upfSeq) | ||
|
|
||
| dropRelayLocked(held) |
There was a problem hiding this comment.
Checked the arithmetic and it does not reach. The relay range is the upper half of the 24-bit space, 0x800000–0xFFFFFF, and the allocator advances one number per relayed report — so coming back to a given number takes 8 388 608 relays. The outbound transaction it would have to still be alive for lives at most NumOfResend × ResendRequestTimeOutPeriod, which is 3 × 3 s. That is roughly 930 000 relayed session reports per second sustained for nine seconds, on one adapter.
The claim table's own reuse guard is what makes the ordinary case safe: nextFreeRelaySequence skips any number still claimed, so a number is only reusable once its claim has been dropped or aged out. What your case adds is that a released claim frees the number while the outbound transaction may still be in flight — true, and the window is the transaction's remaining lifetime against 8.4 million allocations. I would rather leave that than make response matching carry a generation, which is a change to how every N4 response is matched for a race that needs six orders of magnitude more traffic than this adapter sees.
There was a problem hiding this comment.
@midwell,
Should I understand that Copilot's comment is incorrect/inaccurate?
There was a problem hiding this comment.
@gab-arrobo
Copilot is in theory not wrong, but the issue will never happen as it takes 8.4 million requests in a 9 second period to reach the limit. That is 8.4 million concurrent sessions all notifying at once.
The code change is only about 15-20 lines, so that's no problem. What hurts is the test. A race test up to 8M would take around 2 seconds on my Mac. With pre-commit's count=20 it would realistically be around 30s-120s added test time. It could be solved by making relaySequenceFloor/relaySequenceCeil variables so the test range could be made smaller.
What do you recommend, shall I add the guard and fixes to make it testable or leave it with a comment?
There was a problem hiding this comment.
@midwell, I think we can leave it as-is because the pre-commit hooks do not run in the GHA. The pre-commit hooks are currently used only in local environments and the idea is to make things easier for developers to test their changes.
Does it mean this PR is ready to merge?

What this fixes
A Session Report is the only message on N4 that the user-plane function originates, and a
downlink data report is the only way the SMF learns that an idle UE has traffic waiting. This
adapter handles neither — types 56 and 57 are commented out of the dispatcher, so a report falls
through to the default branch and is logged as an unknown message type:
There is no UPF→SMF direction here at all. The consequence is that any deployment putting this
adapter on N4 has no mobile-terminated reachability: a downlink packet for an idle UE never
becomes a page. It fails silently, twice over — the SMF never learns there is traffic waiting,
and the user plane's request is never answered, so it retransmits into nothing while holding
traffic it should either deliver or release.
What the relay does
which a UPF-originated message can be neither answered nor attributed.
SmfIp),since a relayed report has no request of ours to answer. The claim is parsed where it arrives
and refused there if it is not an IP, so a malformed one cannot erase an address that works.
number is restored on the response. This is the part worth a second look: outstanding requests
are held in one table keyed by this socket's own address, so every request the adapter sends
shares a single sequence space. Forwarding a UPF's number into it is refused as a duplicate
whenever an SMF-originated request happens to be in flight under the same number — and the
report is then rejected for no reason but coincidence. With several user planes, whose counters
are independent, two reports collide with each other directly and one origin is lost.
been answered, and until the answer exists nothing here recognises the copy — a report has no
transaction of its own until it is answered, and the response transaction that absorbs
retransmissions is created only then. Each copy was therefore renumbered and forwarded as a
report in its own right: a second downlink data notification, and a second page, for traffic the
SMF was already being told about. A report from the same address and port under the same
sequence number — the identity this adapter keys every other transaction by — is recognised as
the exchange already in flight, and that relay answers it. Delivery is not weakened, though the
claim is narrower than unaffected: a relay lost on the way to the SMF used to be recovered by
whichever came first, the adapter's own resend or the peer's copy, and now only the adapter's
schedule recovers it — the same notification, possibly a little later on that path. Once the
exchange is answered, or rejected, the same number is a new report again.
— see below.
never answers, so the user plane always learns the outcome instead of retransmitting blind.
The recorded origin is forgotten after 30 s, so an unanswered report costs nothing lasting.
Relaying only for known user planes
Added in the third commit, answering the review. The handler previously checked that the message
parsed, that the peer address was non-nil and that an SMF address was known — nothing about who
sent it. Anything that could reach the UDP port had a report relayed toward the SMF, and, because
a report that cannot be relayed is rejected rather than dropped, was answered too. Each answer
costs a resend transaction — a goroutine and a 15 s timer — keyed in
ConsumerTableby thepeer's address, a key
PutTransactioncreates and nothing ever deletes. Until this branch theadapter only ever sent requests, so that key was the socket's own address and there was one of
them; relaying is what made it peer-chosen.
The set of user planes is learned from
UpNodeID, which the SMF names on every message it sendsthrough us, and a report's source address is matched against it. Deliberately not taken from
UpfCfg.UPFs: that table is filled only on the association-setup path, and an adapter restartempties it while the sessions stay established. Nothing then refills it — the heartbeat responses
the SMF gets carry the user plane's own recovery timestamp rather than ours, and a heartbeat that
fails to send while the adapter is down is not counted as a miss, because that increment sits in
the success branch. An association-gated relay would therefore drop every report for the life of
the association while everything else kept working. Learning from ordinary traffic comes back
within one heartbeat, the same way the SMF's own address does.
An unrecognised source is dropped, not rejected: it holds no session here, so it is owed no
answer, and answering it is what costs the state. The check runs before the no-SMF-address
rejection, so the only peers the adapter ever sends to unprompted are ones the SMF named. The
drop is logged with the peer and deliberately not rate-limited — the realistic case is a user
plane whose source address differs from its node ID, and that has to stay diagnosable; one Warn
is cheaper than the goroutine and 15 s timer an answer would cost.
What this is not: the set is learned from the same unauthenticated HTTP body that already names
the UPF every PFCP message is forwarded to, so it adds no new trust and removes none, and a
spoofed source that matches a real user plane still passes. N4 has no transport authentication;
this is recognition, not authentication, and it does not make the endpoint safe. Responses are
also not gated — one is matched to its outstanding request by sequence number, as every other
response type already is, so checking the sender there is a change to the transaction layer
rather than to the relay. Both belong with authenticating the endpoint, in their own change.
One behaviour this does change: a user plane whose configured node ID resolves to a different
address than it sends from will now lose MT paging. It fails visibly, with a log line naming the
peer, rather than silently.
The same limit applies to recognising a retransmission, which is by exchange identity and not by
content: two reports from a known user plane's address under one sequence number, inside the few
seconds an exchange is outstanding, are one exchange here and the second is dropped. A spoofed
source matching a real user plane already passes the gate above, and this is the same property
rather than a new one. Parsing the claimed SMF address is input validation on a field the adapter
already trusted — it keeps a malformed claim from erasing a working address, and it authenticates
nothing.
Two smaller things this made necessary
timeout once the peer stops retransmitting — the ordinary outcome of a delivered response —
and
HandlePfcpSendErrorannounced that as a message it had been unable to send.(
"Receive resend PFCP request"vs"receive resend PFCP request"), so every retransmissionwas logged as a read failure. Harmless while no user-plane-originated request was handled at
all; reachable now, so it is replaced with a sentinel that
readPfcpMessagereturns unwrappedand the read loop compares directly.
Two behaviours added in review, worth knowing about
An address stops being authorised once the SMF stops naming its user plane. The set of user
planes reports may be relayed for is learned from SMF traffic, so nothing used to remove an entry:
a node that moved, or one that went away, left its old address authorised for the life of the
process, and whatever took that address over was relayed for as though the SMF had named it.
Addresses now carry the time the SMF last named the node and are dropped after 30 minutes of
silence. That is 180 of the SMF's heartbeat periods, and every message the SMF forwards names its
user plane, so a live node is renamed constantly and only one the SMF has stopped talking to
altogether ages out. A name that fails to resolve does not revoke the address — the node is
still being named, and a DNS blip is not a user plane that has gone. The age is tested where
reports arrive as well as on the sweep: the sweep runs on SMF traffic, so an SMF that has gone
quiet is both the case the lifetime exists for and the case in which nothing would sweep.
A report that cannot be numbered is rejected rather than misdelivered. The adapter's own
sequence range is finite, and taking the next number regardless would, once the counter came
round, replace an exchange still outstanding: the SMF's answer to the first report would go back
to the user plane that raised the second. The allocator now skips numbers still in use and
reports
ErrRelaySequenceExhaustedwhen the range is full, which the handler turns into arejection toward the originating user plane.
Testing
One test is a race test, and its numbers are worth having.
TestPutTransactionAdmitsOneSenderPerSequenceputs eight senders under one sequence number.Against the previous check-then-store shape it fails 13 runs in 200 by default and 63 in 200
under
-race, admitting two to six senders where one may pass — so it catches the defectintermittently rather than every time.
TestTxTableRefusesASecondSenderUnderOneSequencepins thecontract deterministically, but does not distinguish the two shapes on its own, since both refuse
a sequential second insert.
linux/amd64, in the images CI pins:go test -race ./...green ingolang:1.27.1-bookworm, the Dockerfile's pingolangci-lint run— 0 issues ingolangci/golangci-lint:v2.13.2, the workflow's pinpre-commit run --all-filesat the repo's own pins — gitleaks, gci, staticcheck, yamlfmt,reuse and the Go hooks — every hook passing
Unit tests cover the bookkeeping: the address is remembered, an empty one does not erase it and
neither does one that is not an IP, what the getter hands out is a copy, the origin is taken
exactly once, two user planes using the same number stay distinguishable, a retransmission
resolves to the relay in flight and consumes no sequence number of its own, one whose entry has
expired is relayed afresh, the counter wraps inside its own range, and entries the SMF never
answered age out, and two ports at one address stay two peers.
Five mutations were checked, each failing exactly one test: storing an unparsed claim, handing out
the stored slice, never matching a retransmission, letting an expired entry be matched instead of
swept, and dropping the port from the peer's identity. One of those pins a property rather than a
fix — the getter handed out a copy before this round too, since it parsed on every call, and the
test is there so that is not quietly lost.
The source gate is covered at the
configlevel — recorded and matched in both 4- and 16-octetform, a source that was never named refused, an empty set refusing everything, an FQDN node ID
matched by the address it resolves to, and an unspecified address recorded as nothing. Making
IsKnownUpfAddralways return true fails three of them; makingRecordUpfAddrrecord nothingfails two. There is no test harness for
pfcp/handler, so the gate's placement insideHandlePfcpSessionReportRequestis not pinned by a test.Verified end to end on a live deployment with
enableUPFAdapter: true. A downlink packet to anidle UE produced:
and the UE's user plane restored 18 ms after the page, where before the report was dropped. That
run predates the source gate and was taken in a topology where the user plane sends from the
address the SMF names it by; a paging run with the gate in place has not been done, so it is
verified by unit test and by reading, not on the wire.
The retransmission case is not on the wire either, and cannot be with this project's own user
plane:
handleDigestReportsends the report withSendPFCPMsgrather thansendPFCPRequestMessage, so the UPF transmits it once and never retries — an unanswered downlinkdata report is simply lost there. Retransmitting an unanswered request is ordinary PFCP behaviour
for a user plane that does it, and the adapter sits on N4 for whatever is deployed behind it, so I
would rather it behave than rely on that. Stated here so the handling is not read as something a
run demonstrated.
Note for reviewers
The
PfcpPortconstant is duplicated inconfigrather than imported fromudp, becauseudpimports
config. If you would rather have it moved somewhere both can import, say so and I willrestructure.