Skip to content

feat: answer a UE-requested modification instead of dropping it - #669

Open
midwell wants to merge 22 commits into
omec-project:mainfrom
midwell:split/ue-requested-modification
Open

midwell wants to merge 22 commits into
omec-project:mainfrom
midwell:split/ue-requested-modification

Conversation

@midwell

@midwell midwell commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #668. This PR's own change is the last commit, 7e4e226; the commits below it belong to #668 and will drop out of this diff as it merges. Part of the four-PR split of #623.

A PDU SESSION MODIFICATION REQUEST from the UE was decoded, logged at debug and dropped. The UE was left retransmitting a request nothing would ever answer, and TS 24.501 subclause 6.4.2 expects an answer either way.

The SMF does not implement UE-requested modification, and saying so is an answer. The request is refused with a PDU SESSION MODIFICATION REJECT carrying the cause, attached to the UpdateSMContext response (TS 23.502 subclause 4.3.3.2, step 3a). The UE stops asking and knows why, instead of retrying into silence.

One case is not refused. Under TS 24.501 subclause 6.3.2.5 d), a UE request for a session the network is already modifying is ignored, and the network's procedure proceeds "as if no PDU SESSION MODIFICATION REQUEST message was received from the UE". So there is no reject, no state change, and T3591 keeps running. Refusing there would answer a request the network is required to ignore, and would apply the back-off that cause 32 asks for to a session that is being modified correctly.

A known limitation inside that case. Sub-item i) of the same clause has the network act on the URSP rule enforcement reports IE of the request it otherwise ignores. github.com/omec-project/nas/v2 does not decode that IE: PDUSessionModificationRequest has no field for it. So no report can reach the SMF, and only sub-item ii) is implemented here. Handling the reports needs the IE added to the NAS library first.

The shared activeSmContext test fixture now carries an empty tunnel. An active session always has one, and since #659 a policy update refuses a session without one as being released. Four of this change's tests go through that path, so without the tunnel they stopped at the refusal:

  • TestNetworkInitiatedModificationSendsTheCommandAndArmsT3591, TestNetworkProcedureMarksTheSessionSoAUeRequestCollides and TestUeRequestDuringThePfcpRoundTripIsAlreadyACollision failed.
  • TestFailedNetworkModificationDoesNotLeaveTheSessionLookingBusy passed for the wrong reason: its assertions hold under the refusal too, so its stubbed send was never reached. It is reached now; replacing that stub with a panic shows it.

Verification. Each fix in this PR has a test that fails without it; every fix was reverted in turn to confirm its test catches it. GOWORK=off pre-commit run --all-files on darwin/arm64 with Go 1.26.0 at this commit, all nine hooks passing as pinned in .pre-commit-config.yaml: go test -race ./... -count=20, staticcheck, gci, golangci-lint v2.13.2, gitleaks v8.30.1, reuse v6.2.0, yamlfmt v0.21.0 and the whitespace hooks.

🤖 Generated with Claude Code

midwell and others added 18 commits October 14, 2026 13:41
TS 23.502 subclause 4.3.3.2 and TS 24.501 subclause 6.3.2: when policy changes
a running session, the SMF programs the user plane, tells the UE what changed,
and waits for the UE to acknowledge it. Until now the SMF sent the command and
treated it as done.

The procedure is now driven end to end. ApplyModification programs the user
plane, moves the session into PfcpModify, sends the N1 command and the N2
transfer together, and arms T3591. The UE's Modification Complete is what
commits the update: committing on the acknowledgement rather than on the send
keeps the SMF's record in step with what the UE is actually running, so the
next modification computes its delta against the parameters in force. A
Modification Command Reject discards it with its own cause.

An unanswered command is retransmitted, and abandoned when the retransmissions
run out -- the pending update discarded, the session settled back to Active so
a later modification can be attempted, and the abandonment counted with the
path and cause that produced it. Abandonment is idempotent and ignores a
superseded timer, because a procedure can be answered while its last
retransmission is in flight.

A command that cannot be delivered is reverted rather than released. The
delivery-failure indication is shared with establishment, where dropping the
data path is right because the session was never usable; for a modification it
is wrong, and turned a routine delivery failure into an outage. Where putting
the user plane back itself fails, the session is marked for release instead,
because it is then running parameters the UE was never told about -- and the
FSM is told which of the two happened.

A rule the update deletes is withdrawn from the user plane as well: its PDR and
FAR are marked for removal, in both directions. The QERs are deliberately kept,
being per session and shared with the rules that remain.

The radio's answer to the command is not handled here; that follows.

Rebased onto main after omec-project#664 merged, keeping its guarantee through this
branch's rewrite of the policy-update path. A modification that fails now returns the session
to active through abandonPendingModify, so the pending user-plane entry
is cleared too, where ApplyModification only reset the state. omec-project#664's
test now records the entry the way a real send does, and fails without
this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
An acknowledgement that arrived just as T3591 fired could be followed by
one more PDU session modification command, for a procedure that had
already ended. Stopping T3591 does not wait for an expiry that is
already due: the timer's goroutine selects between the stop signal and
its ticker, and Go picks at random when both are ready. Abandonment was
already guarded against this, because abandonIfCurrent ignores an
expiry for a timer that is no longer the session's. Retransmission was
not.

The retransmission now sends only while its own timer is still the
session's T3591, and it checks that under SMLock at each of the send's
two holds of the lock: before building the command, and again in the
hold that starts the transfer. The second check is what makes it exact.
The acknowledgement stops T3591 under SMLock, so it either lands before
that check, which then fails, or waits until the command is on its way,
in which case the retransmission really did precede it.

A check-then-send in the timer callback would not have closed the gap,
because the send takes SMLock itself. Neither would making Stop wait,
because callers stop the timer under the lock the callback takes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
When a modification cannot be delivered and putting the user plane
back fails too, the session runs parameters the UE was never told
about, and revertModification marks it SmStatePfcpRelease. The FSM
handler for the delivery-failure indication then returned SmStateInit
for every case that was not a successful revert, and HandleEvent
applies whatever the handler returns, so the mark was overwritten one
frame after it was made.

HandlePduSessN1N2TransFailInd now reports whether the failure was a
modification's as well as whether it was reverted. A modification
whose revert failed keeps SmStatePfcpRelease; the AN-release path,
which is not a modification, still ends in Init as it always has.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
abandonModificationLocked dropped the session's handle on T3591
without stopping the timer. Most abandonments stop it themselves
before they get there, but a delivery-failure indication reverts a
modification whose timer is still armed, and that timer went on firing
for its whole retransmission sequence: each retransmission was refused
by the currency check, and the final abandonment found a different
timer and did nothing. Nothing reached the UE, but a goroutine per
reverted modification ran on for up to five T3591 intervals, and it
read the retransmission seam while tests were replacing it.

The handle is now dropped by StopT3591, which also clears
NwModificationPending as this did.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A second policy notification for a session whose network-requested
modification was still waiting for the UE started a second procedure
over the first. It replaced the pending update and stopped the first
T3591, and the UE's answer to the first Command then committed the
second update, which the UE had never been sent; the user plane by then
carried the changes of both. TS 24.501 subclause 6.3.2.5 gives the
network no way to run two at once: a network-requested Command carries
no procedure transaction identity, so the UE's answer cannot say which
Command it answers.

Such a decision is now held, and the PCF answered as for an applied
one. Each held decision is applied as a procedure of its own once the
one before it ends: the UE completing or rejecting it, T3591 abandoning
it, or a failure to program it. A modification whose delivery failed
starts the next one only after its revert has its answer, since the
revert is a PFCP exchange on the session's one response channel. The
update is computed when the decision is applied, against what the
session has committed by then, because a decision is a change to the
policy in force (TS 29.512 subclause 4.2.6.1).

The UE can answer before the transfer call returns, and a held decision
can then start before that call has finished. Each modification now
carries a generation, and the first one's tail leaves the session's
state and timer alone once a later one has started.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A held decision started on a goroutine of its own, from the handler of
the transaction that ended the modification before it. That
transaction's state machine had not finished: HandleEvent applies the
handler's returned state after the handler has released SMLock, and a
modification started in between moved the session to
SmStatePfcpModify only for HandleEvent to put it back to Active. The
user plane's answer is delivered only in SmStatePfcpModify, so the
held modification then waited for it for good.

Work the SMF starts for a session can now be queued in that session's
transaction queue as a SessionTask, which runs in the session's slot
after whatever the session is doing and without a state machine of its
own. A held decision is queued this way. The fsm package installs the
queue at start, since the producer cannot import it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…e's copy

The UE answers every copy of a PDU SESSION MODIFICATION COMMAND it
receives, and a network-requested Command carries the PTI value "No
procedure transaction identity assigned" (TS 24.501 subclause 6.2.1),
so the UE cannot recognise a retransmission and the SMF cannot tell
which Command an answer belongs to. After a retransmitted Command was
answered, a late answer to another copy of it could arrive once the
next held decision had started: it stopped that procedure's T3591 and
committed, or discarded, an update the UE had not answered.

For assigned PTIs, TS 24.501 subclause 6.3.2.3 NOTE 5 has the UE keep
the PTI for at least the default T3591 so that retransmissions stay
distinguishable. This is the network-side counterpart for the
unassigned one: once a procedure whose Command was retransmitted ends,
no network-requested modification starts for one T3591 interval of
that procedure. A decision arriving meanwhile is held, and the held
ones start when the interval is over. An answer arriving within it
finds nothing pending and changes nothing. A procedure whose Command
was sent once has no copy to wait for and is not delayed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
The failure indication reads whether a modification is pending in one
hold of SMLock and reverts it in another. T3591 can abandon the
modification in between, and a held decision can start, which the
revert then discarded as though it were the modification whose
delivery had failed.

The indication now reads the modification's generation with the
pending flag, and the revert checks both and abandons in a single hold
of the lock, doing nothing if a later modification owns the session or
none is pending. The transfer-failure path in ApplyModification passes
its own generation.

abandonModification had no caller left after that, outside tests, and
is removed; its tests abandon through abandonModificationLocked, which
every abandonment path uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
The Modification Complete and Command Reject handlers acted on the
current procedure without checking the PDU session identity the
message carries, so an answer naming another session stopped this
session's T3591 and committed or discarded its pending update. TS
24.501 subclause 7.3.2 d) has the network ignore a 5GSM message whose
PDU session identity does not match an existing PDU session; both now
compare it with this context's, as the release request already does,
and ignore a mismatch before touching any procedure state.

Raised in review of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A decision that deletes only rules carries no valid PCC rule, so the
builder infers release-only and releases its fallback rule, "default".
Since the deletion loop in this PR, a decision deleting the default
rule had it marked for removal twice, by the loop and by the
release-only branch, and each Remove PDR and Remove FAR went out twice
in one request. The release-only branch now skips a rule the loop has
already removed.

Release-only also tears down the default rule when the decision
deletes a different one. That predates this PR and is removed, with
the NGAP side's inference, in a change of its own.

Raised in review of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…tted

The Command's transfer re-discovers the AMF and retries against
another one when a transfer gets no HTTP answer, and the transfer that
got no answer may still have been delivered. The UE then held two
copies of a Command the SMF counted as sent once, and answers both; the
first answer could start the next held modification and the second
then commit it. The quiet interval after a retransmitted Command did
not apply, because T3591 had retransmitted nothing.

SendN1N2TransferCountingSends is SendN1N2TransferWithRediscovery
reporting how many transfers it sent, and the existing function, used
by the other callers, wraps it. When the Command's transfer sent more
than one, its procedure's end waits out one T3591 interval, as after a
retransmission. The count is read from the function's returns by
inspection; the test drives the Command through the real builder with
the transfer reporting two sends.

Raised in review of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
The held-decision tests waited 2 seconds for work the session runs
asynchronously, as a queued task or after a quiet interval. Under the
race detector on a loaded machine that was not enough: a held decision
started after the test had given up and restored its stubs, failing the
test and racing its cleanup. They now wait up to 10 seconds; the bound
only decides how long a real failure takes to be reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
TS 29.512 subclause 4.2.3.2 NOTE: "When there is an ongoing procedure
that collisions with the update of SM policies ... the SMF, based on
operator policies, can delay the update of SM policies and return a
"204 No Content" response code. In this case the SMF will process the
request when the procedure is finished." A decision held behind a
modification is exactly that case, and it was answered 200 as if it had
been applied. A decision applied at once is answered as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A modification is pending from the moment it starts, but its Command
reaches the AMF only after the user plane has answered. A Modification
Complete or Command Reject arriving in that gap cannot be the new
procedure's -- the UE has nothing of it to answer -- so it is a late
answer to an earlier Command, a retransmitted copy or one AMF
re-discovery delivered twice. Taken as the new procedure's, it ended
that procedure and committed or discarded an update the UE was never
sent.

NwModificationUnsent is set when a modification starts and cleared in
the hold of SMLock that hands its Command to the AMF, so the UE's
answer, which needs the lock, is never judged in between. Both answers
are ignored while it is set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
Timer.Stop now waits for the timer's goroutine (omec-project#679), and documents
that a caller must not hold a lock the callbacks take, nor call it
from a callback. StopT3591 did both: every caller holds SMLock, which
both T3591 callbacks take, and T3591's own abandonment reaches it from
inside the cancellation callback. So every abandonment waited out
Stop's five-second timeout on itself, and an acknowledgement that met
a callback waiting for SMLock stalled the same way.

Timer.Cancel is Stop without the wait, for exactly those callers, and
Stop is now Cancel followed by the wait. StopT3591 cancels. A late
callback is harmless because both check, under SMLock, that their timer
is still the session's T3591, and StopT3591 drops the handle in the
same hold.

Raised in review of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A held decision is started as a session task when the modification
before it ends. A notification that was already in the session's
transaction queue runs before that task, and found nothing pending, so
it was applied at once; the older held decision then ran after it and
overwrote it with older policy.

A decision is now held whenever others are held, not only while a
modification is in progress or the quiet interval runs, and starting
the oldest is requested there too in case nothing in progress will. A
second request is harmless: the task that runs later finds a
modification pending and stops.

Raised in review of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A transfer that got no HTTP answer and could not be retried against
another AMF returns an error after one send. That Command may still
have reached the UE, whose answer can then arrive after the next
modification has started and be taken as that one's. The quiet
interval after a duplicated Command now applies here as well. A
failure that proves nothing was delivered is not told apart: waiting
needlessly only delays the next modification by one T3591 interval.

Raised in review of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A replacement establishment purges the old context through
RemoveSMContextLocked, as release does, but nothing there stopped
T3591. The timer went on retransmitting the old context's Command --
built from its retained policy, to the UE's new session with the same
PDU session ID -- and later abandoned it. Removal now cancels T3591 and
drops its handle, so both callbacks find their timer superseded, and
drops the decisions held for the session.

Raised in review of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
midwell and others added 4 commits October 14, 2026 13:41
The AMF sends a transfer failure notification only for a transfer that
asked for one, and the only transfer that asks is the downlink data
paging one: a modification's Command does not. So the notification is
never the Command's delivery failure. Taken as one whenever a
modification was pending, it reverted a modification on its way to the
UE and skipped the paging failure's own handling. The AMF can produce
that case itself: a Command that arrives while it is paging can end the
paging and fail the paging transfer.

The handler and its state machine step are back to what main has. A
Command that never reaches the UE is left to T3591, and a transfer that
fails outright is still reverted where it fails, now the revert's only
cause. Tests that drove the revert through the notification call it
directly.

Raised in review of this PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
From review. BuildPfcpParam asked for the rules the pending update
deletes inside the loop over data paths. The answer depends only on the
update, so it is now read once, before the loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A modification is answered twice: the UE acknowledges the command, and the
radio reports what it actually established. Only the UE's answer was read, so a
radio that refused the modification, or built part of it, changed nothing. The
session's record, the UE and the user plane then disagreed, and a downlink
packet classified onto a flow the radio never built went to a QoS flow with no
radio bearer and was dropped rather than falling back.

The response transfer is decoded into the flows the radio established and the
flows it refused, and the three shapes are answered differently. Everything
established: nothing to do. Nothing established: the modification is abandoned,
and the session settles back on the parameters it had. Some established: what
the radio built is what the session has, so the pending update is pruned to
those flows before it is committed -- committing the whole of it would record
flows that do not exist -- and the refused ones are withdrawn from the UE and
the user plane by an ordinary corrective modification. TS 23.502 subclause
4.3.3.2 calls for a further procedure rather than a retry of the one that was
partly accepted, which is what a deletion through ApplyModification is.

The two answers are unordered, and the order where the UE is first has to be
handled too: the completion then commits the update whole, so the radio's
answer keeps the committed update and builds the correction from that instead.
Both refusal paths are covered -- the response naming every flow as refused,
and the failure message refusing the modification in its entirety -- because
they arrive at different handlers.

A corrective modification asks for no release: the flows it withdraws are the
ones the radio refused, which it never built, so asking it to release them
names flows it does not have.

Policy decisions held behind a modification (see the previous PR)
wait for the correction too. A correction is committed to when the
UE's completion finds a partial rejection, so the session is marked
pending from there, before its goroutine starts anything: a
notification arriving meanwhile is held, and the corrected
modification's tail leaves the session to it. With nothing to correct,
the completion starts the next held decision, as does the radio
refusing the whole modification.

The correction runs as a task in the session's queue, as a held
decision does since the previous PR: started on a goroutine from the
UE's completion, it could move the session to SmStatePfcpModify before
that completion's state machine set Active again, after which the user
plane's answer to the correction was never delivered.

The radio's answer is part of the modification, so a held policy
decision waits for it as well as for the UE's: the answer can still
call for a correction after the UE has completed (TS 23.502 clause
4.3.3.2), and a modification started before it would discard what the
completion kept for it. The wait is bounded by one T3591 interval from
the UE's completion; a radio that never answers then stops holding
decisions, and its correction, if one was owed, is given up as it was
before.

A correction after a Command that was retransmitted, or sent twice by
AMF re-discovery, waits out the same interval a held decision does,
and its Command counts as not yet sent while it waits, so a late answer
to another copy of the corrected Command is ignored rather than taken
as the correction's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
A PDU SESSION MODIFICATION REQUEST was decoded, logged at debug and dropped.
The UE is left retransmitting a request nothing will ever answer, and TS 24.501
subclause 6.4.2 expects an answer either way -- so the session ends up in the
one state the specification does not describe.

The SMF does not implement UE-requested modification, and saying so is an
answer: the request is refused with a MODIFICATION REJECT carrying the cause,
attached to the UpdateSmContext response per TS 23.502 subclause 4.3.3.2 step
3a. The UE stops asking and knows why, instead of retrying into silence.

One case is not refused. TS 24.501 subclause 6.3.2.5 item d: a UE request for a
session the network is already modifying is disregarded, not rejected -- the
network's own procedure carries on as if the request had never arrived, so no
reject, no state change, and T3591 keeps running. Refusing there would answer a
procedure the UE is entitled to have ignored, and the back-off that cause 32
asks for would be applied to a session that is being modified perfectly well.

The shared activeSmContext fixture now carries an empty tunnel. An
active session always has one, and since omec-project#659 a policy update refuses a
session without one as being released, so this change's tests of the
network-initiated procedure would otherwise stop at that refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
@midwell
midwell force-pushed the split/ue-requested-modification branch from abb921a to 7e4e226 Compare October 14, 2026 12:00
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.

1 participant