Skip to content

fix(transaction): Timer F ends a non-INVITE client transaction in Proceeding - #189

Merged
shenjinti merged 1 commit into
restsend:mainfrom
tgeorge06:fix/timer-f-in-proceeding
Oct 8, 2026
Merged

shenjinti merged 1 commit into
restsend:mainfrom
tgeorge06:fix/timer-f-in-proceeding

Conversation

@tgeorge06

Copy link
Copy Markdown
Contributor

Fixes #188.

Problem

A non-INVITE client transaction runs Timer F as TransactionTimer::TimerB (armed in Calling, transaction.rs:1284). on_timer handles Timer B only in the Calling | Trying arm (line 1153). After a provisional other than 100 the transaction is in Proceeding, whose arm (line 1163) handles only Timer C, so Timer F is ignored: no 408 reaches the TU, the transaction never terminates, and a BYE answered with one 1xx and then nothing hangs.

RFC 3261 §17.1.2.2:

If Timer F fires while in the "Proceeding" state, the TU MUST be informed of a timeout, and the client transaction MUST transition to the terminated state.

Fix

In the Proceeding arm, handle Timer B for a non-INVITE client the same way as Timer C: a local 408 to the TU through inform_tu_response, which terminates the transaction like any final response. +6 / -1 lines in src/transaction/transaction.rs.

Unchanged on purpose:

  • Client INVITE: Timer B is cancelled when it leaves Calling (line 1294), and a Timer B still in flight is ignored in Proceeding, as before (RFC 3261 §17.1.1.2: "If the client transaction is still in the "Calling" state when timer B fires, the client transaction SHOULD inform the TU that a timeout has occurred."). Timer C is handled as before.
  • Server transactions and the Calling | Trying arm are untouched; a non-INVITE that got a 100 already timed out there.
  • A final response that races Timer F: whichever reaches the transaction first terminates it, and the other is dropped, so the TU sees one final response.
  • Timer E. RFC 3261 §17.1.2.2 also says "If Timer E fires while in the "Proceeding" state, the request MUST be passed to the transport layer for retransmission, and Timer E MUST be reset with a value of T2 seconds." Today Timer A (Timer E) is cancelled on entering Trying or Proceeding (line 1291), so a non-INVITE over UDP is not retransmitted after any 1xx, and before one its interval doubles up to 64*T1 rather than T2 (line 1147). That is a separate change and is left for a follow-up.

Contract / coverage

What the TU of a non-INVITE client transaction gets when the only response is a provisional:

provisional transport before after
100 any the 100, then a 408 at 64*T1 same
101-199 UDP the 1xx, then nothing, never terminates the 1xx, then a 408 at 64*T1, terminated
101-199 TCP / TLS / WS same as UDP same as UDP

The 408 is the same locally generated response the Trying arm already sends. Callers that already handle that 408 now get it in this case too: send_bye ends the dialog on it (dialog.rs:1335), other in-dialog requests and REGISTER return it as a final response.

Tests

src/transaction/tests/test_client.rs: test_non_invite_timer_f_after_provisional. T1 = 20 ms; a raw peer answers a BYE with one provisional and then stays silent (over TCP it keeps the connection open). For each row, the TU must see [provisional, 408] and receive() must return None (terminated) within 3 * 64*T1:

  • 100 over UDP (already worked, the Trying arm);
  • 180 over UDP;
  • 183 over TCP.

On main (92cec74) it fails:

---- transaction::tests::test_client::test_non_invite_timer_f_after_provisional stdout ----
panicked at src/transaction/tests/test_client.rs:552:9:
assertion `left == right` failed: 180 over tcp=false
  left: [180]
 right: [180, 408]

Row by row (assertion turned into a print), main then this PR:

100 over UDP: [100, 408]        100 over UDP: [100, 408]
180 over UDP: [180]             180 over UDP: [180, 408]
183 over TCP: [183]             183 over TCP: [183, 408]

Checks

…ceeding

A non-INVITE client transaction runs Timer F as Timer B, and on_timer
handled Timer B only in Calling and Trying. After a provisional other
than 100 (e.g. 180 to a BYE) the transaction is in Proceeding, Timer F
was ignored there, and a request answered with one 1xx and then nothing
never ended. RFC 3261 section 17.1.2.2: if Timer F fires in Proceeding,
the TU must be informed of a timeout and the transaction terminated.

Handle Timer B for a non-INVITE client in Proceeding like Timer C: a
local 408 to the TU, which terminates the transaction.
@shenjinti
shenjinti merged commit 8189ef4 into restsend:main Oct 8, 2026
3 checks passed
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.

Non-INVITE client transaction never times out after a provisional response other than 100

2 participants