Skip to content

pfcp: relay UPF-initiated session reports, so a paged UE can be reached - #116

Open
midwell wants to merge 18 commits into
omec-project:mainfrom
midwell:feat/relay-upf-initiated-session-report
Open

midwell wants to merge 18 commits into
omec-project:mainfrom
midwell:feat/relay-upf-initiated-session-report

Conversation

@midwell

@midwell midwell commented Aug 25, 2026

Copy link
Copy Markdown

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:

        /*
                case pfcp.PFCP_SESSION_REPORT_REQUEST:
                        handler.HandlePfcpSessionReportRequest(msg)
                case pfcp.PFCP_SESSION_REPORT_RESPONSE:
                        handler.HandlePfcpSessionReportResponse(msg)
        */

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

  • The dispatcher routes types 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 (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.
  • The report is renumbered into the adapter's own sequence space, and the user plane's own
    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.
  • A retransmitted report is relayed once. A user plane 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 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.
  • A report is relayed, and answered, only for the user planes the SMF has addressed through us
    — see below.
  • 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 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 ConsumerTable by the
peer's address, a key 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 is what made it peer-chosen.

The set of user planes is learned from UpNodeID, which the SMF names on every message it sends
through 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 restart
empties 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

  • A response sent to a user plane keeps its own error handler. The resend transaction ends in a
    timeout once the peer stops retransmitting — the ordinary outcome of a delivered response —
    and HandlePfcpSendError announced that as a message it had been unable to send.
  • The retransmission check compared an error against a spelling that error never had
    ("Receive resend PFCP request" vs "receive resend PFCP request"), so every retransmission
    was 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 readPfcpMessage returns unwrapped
    and 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 ErrRelaySequenceExhausted when the range is full, which the handler turns into a
rejection toward the originating user plane.

Testing

One test is a race test, and its numbers are worth having.
TestPutTransactionAdmitsOneSenderPerSequence puts 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 defect
intermittently rather than every time. TestTxTableRefusesASecondSenderUnderOneSequence pins the
contract 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 in golang:1.27.1-bookworm, the Dockerfile's pin
  • golangci-lint run0 issues in golangci/golangci-lint:v2.13.2, the workflow's pin
  • pre-commit run --all-files at 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 config level — recorded and matched in both 4- and 16-octet
form, 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
IsKnownUpfAddr always return true fails three of them; making RecordUpfAddr record nothing
fails two. There is no test harness for pfcp/handler, so the gate's placement inside
HandlePfcpSessionReportRequest is not pinned by a test.

Verified end to end on a live deployment with enableUPFAdapter: true. A downlink packet to an
idle UE produced:

20:03:02.446  UPF -> adapter   Session Report Request  seq[1]
20:03:02.447  adapter -> SMF   Session Report Request  seq[8388608]   <- adapter's own range
20:03:02.456  SMF -> adapter   Session Report Response seq[8388608]   Cause: Request accepted
20:03:02.4560 Paging on N2
20:03:02.4572 adapter -> UPF   Session Report Response seq[1]         <- the peer's own number
20:03:02.4636 Service request

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: handleDigestReport sends the report with SendPFCPMsg rather than
sendPFCPRequestMessage, so the UPF transmits it once and never retries — an unanswered downlink
data 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 PfcpPort constant is duplicated in config rather than imported from udp, because udp
imports config. If you would rather have it moved somewhere both can import, say so and I will
restructure.

@midwell
midwell requested a review from a team August 25, 2026 07:37
@gab-arrobo
gab-arrobo requested a lite review from Copilot August 25, 2026 08:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread upfadapter.go
Comment thread config/config.go Outdated
@gab-arrobo
gab-arrobo force-pushed the feat/relay-upf-initiated-session-report branch from f01635f to 1a3e91d Compare September 1, 2026 17:25
@midwell

midwell commented Sep 1, 2026

Copy link
Copy Markdown
Author

Thanks — both addressed in 5ee9e98.

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 UpNodeID, and the handler forwards PFCP to whatever it says — so anything that can reach this endpoint already directs traffic. Authenticating the endpoint is the fix for that, and it belongs in its own change rather than here.

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: govet's fieldalignment flags reportRelay under the v2.13.2 golangci-lint this repo now pins, so its fields are reordered.

@gab-arrobo
gab-arrobo force-pushed the feat/relay-upf-initiated-session-report branch from 5ee9e98 to 4648a50 Compare September 8, 2026 16:07
Comment thread pfcp/udp/udp.go Outdated
Comment thread pfcp/handler/handler.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread config/config.go
Comment thread config/config.go
Comment thread pfcp/handler/handler.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 WriteToUDP fails, 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

Comment thread config/config.go Outdated
Comment thread config/config.go Outdated
Comment thread pfcp/handler/handler.go
Comment thread config/config.go
@midwell

midwell commented Sep 14, 2026

Copy link
Copy Markdown
Author

@gab-arrobo Four comments on f523002, all four real and answered in 3e3d455. Reasoning is on
the individual threads; the short form:

  • A claimed SMF address that is unspecified (0.0.0.0, ::) is refused, so one claim can no
    longer erase a working address. RecordUpfAddr already applied that test to a user plane's
    address; SetSmfAddr applies it too.
  • The set of user planes the SMF has named is held per node identity now, so one that moves stops
    authorising the address it left. An address is dropped only once no identity remains at it,
    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 already
    holds one. NewTransaction keys a request by the adapter's own socket, so the SMF's numbering
    and the adapter's share one table; starting at the halfway point separated them by convention,
    and the convention ends once the SMF's counter reaches that half — which this code cannot see.
    udp exports ErrDuplicateSequence so the caller 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 after
    the send, and udp.Run dispatches each message on its own goroutine, so the gap was reachable —
    and a report relayed twice is a second downlink data notification and a second page, which is
    the defect this branch exists to remove. Claiming stays take-once, and only the path that
    claimed releases.

Each is pinned by a test that fails without it: I removed each fix in turn and watched its own
test die, rather than trusting one run of the suite.

Gates on the pushed tree: build, vet, go test -race ./..., golangci-lint v2.13.2 at 0 issues,
gofmt, and make check-reuse compliant — linux/amd64, in CI's own images.

Two limits worth naming rather than leaving to be discovered:

  • The retry backstop is one-directional. It covers the report relayed toward the SMF, which is
    where the shared table is: a request is keyed by the adapter's own socket. The two sends back
    toward a user plane are responses, keyed by destination, under that peer's own sequence
    number — a much narrower collision domain — so they are left without a retry rather than given
    one nothing is known to need.
  • An FQDN identity is compared byte for byte, so an SMF that varied the case of one hostname
    would look like two identities at one address rather than one that moved. That is the existing
    convention in this package — GetUpfFromNodeId compares FQDNs the same way — and this change
    neither introduces nor widens it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 ConsumerTable entry keyed by the UPF's address/port, but removeTransaction only 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.Start creates one 15-second timer before its resend loop (pfcp/udp/transaction.go:120) and never resets it after ReceiveResendRequest. 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

Comment thread config/config.go
Comment thread config/config.go Outdated
Comment thread pfcp/udp/udp.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, while udp.PutTransaction inserts 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, PutTransaction rejects the ordinary request as ErrDuplicateSequence; 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, but config.SetSmfAddr now 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

Comment thread config/config.go Outdated
Comment thread config/config.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 reportRelays and leaves the repeated entry created at line 538 in the package-global map. Neighboring tests use withReportRelays; 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.IP back 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 with net.IP.Equal here.
		if current := config.SmfAddr(); current == nil || current.IP.String() != udpPodMsg.SmfIp {

upfadapter.go:63

  • This warning is emitted before SetSmfAddr validates the claim. For a malformed or unspecified SmfIp, the log says the adapter is relaying to that address even though SetSmfAddr rejects 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

Comment thread pfcp/handler/handler.go
Comment thread pfcp/udp/udp.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 to udp.ForgetConsumer. Since RecordUpfAddr is the only sweep that performs that release, a peer that was answered before aging out leaves an empty ConsumerTable entry (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 readPfcpMessage has already run findTransaction for requests. If a UPF is deauthorized while its response transaction is still present (or the lookup races ForgetConsumer), a retransmitted Session Report is consumed there, causing that transaction to resend without reaching this IsKnownUpfAddr check. 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 ConsumerTable while this SMF response is in flight. Because SendPfcp is 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 WriteToUDP failure, 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 from ForwardPfcpMsgToUpf. 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

Comment thread upfadapter.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 shared udp.ConsumerTable slot atomically with ordinary SMF requests. If an SMF-originated request chooses this number after the relay entry is created, its normal SendPfcp path reaches PutTransaction, gets ErrDuplicateSequence, and ForwardPfcpMsgToUpf has 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 reportRelay is deleted only when another report calls RelayReportSequence. If an unanswered burst is followed by silence, every origin remains in reportRelays indefinitely, 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 SendingResponse transaction used to absorb UPF retransmissions, but Transaction.Start stops after NumOfResend (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 after ForgetReportRelay has 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

Comment thread config/config.go Outdated
Comment thread pfcp/handler/handler.go Outdated
Comment thread pfcp/udp/udp.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 final repeated claim 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 with withReportRelays(t) (or explicitly clean the final claim).

pfcp/handler/handler.go:384

  • This defer drops the relay claim even when udp.SendPfcp returns 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 after SendPfcp succeeds.
	defer config.ForgetReportRelay(relaySeq)
  • Files reviewed: 12/12 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread pfcp/handler/handler.go
Comment thread config/config.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • rejectSessionReport has the same asynchronous contract: a nil error only means the response was registered, not delivered. If its initial UDP write fails, startTxLifeCycle removes 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 treating SendPfcp returning 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 WriteToUDP failure, 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

Comment thread pfcp/handler/handler.go
Comment thread pfcp/handler/handler.go
Comment thread pfcp/udp/udp.go
Comment thread pfcp/udp/udp.go
Comment on lines +290 to 296
// 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())
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

midwell and others added 18 commits September 21, 2026 09:19
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>
@gab-arrobo
gab-arrobo force-pushed the feat/relay-upf-initiated-session-report branch from b015a00 to 507fc9c Compare September 21, 2026 16:19
@gab-arrobo
gab-arrobo requested a lite review from Copilot September 21, 2026 16:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity

Open (2)
Resolved since last review (3)

Comment thread config/config.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity

Open (2)
Resolved since last review (1)

Comment thread config/config.go
Comment on lines +706 to +710
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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@midwell,
Should I understand that Copilot's comment is incorrect/inaccurate?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

@gab-arrobo gab-arrobo Sep 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants