diff --git a/sip-transaction/pom.xml b/sip-transaction/pom.xml index 596dcec..a00f853 100644 --- a/sip-transaction/pom.xml +++ b/sip-transaction/pom.xml @@ -26,9 +26,18 @@ com.sip sip-transport-api + + com.sip + sip-codec + org.slf4j slf4j-api + + org.slf4j + slf4j-simple + test + diff --git a/sip-transaction/src/main/java/com/sip/transaction/ClientTransactionListener.java b/sip-transaction/src/main/java/com/sip/transaction/ClientTransactionListener.java new file mode 100644 index 0000000..6dc0e3e --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/ClientTransactionListener.java @@ -0,0 +1,24 @@ +package com.sip.transaction; + +import com.sip.message.SipResponse; + +/** + * Callbacks delivered by a client transaction to the Transaction User (TU). + * + *

The contract is one-shot per event; implementations should hand work + * off to a virtual thread if they need to do anything non-trivial.

+ */ +public interface ClientTransactionListener { + + /** Provisional response arrived (1xx). */ + default void onProvisional(SipResponse response) { } + + /** Final response arrived (2xx-6xx). */ + void onFinal(SipResponse response); + + /** Transaction timed out (Timer B / F). */ + void onTimeout(); + + /** Transport-level failure observed; transaction is terminated. */ + default void onTransportError(Throwable error) { } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/InviteClientTransaction.java b/sip-transaction/src/main/java/com/sip/transaction/InviteClientTransaction.java new file mode 100644 index 0000000..f212757 --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/InviteClientTransaction.java @@ -0,0 +1,237 @@ +package com.sip.transaction; + +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import com.sip.message.SipVersion; +import com.sip.message.header.HeaderName; +import com.sip.message.header.Headers; +import com.sip.transport.Endpoint; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * INVITE client transaction FSM — RFC 3261 §17.1.1. + * + *

Key complications versus non-INVITE:

+ * + */ +public final class InviteClientTransaction implements Transaction { + + private static final Logger log = LoggerFactory.getLogger(InviteClientTransaction.class); + + private final TransactionKey key; + private final SipRequest request; + private final Endpoint peer; + private final boolean reliable; + private final MessageSender sender; + private final TimerScheduler timers; + private final ClientTransactionListener listener; + + private final AtomicReference state = + new AtomicReference<>(TransactionState.CALLING); + private final AtomicInteger attempt = new AtomicInteger(0); + + private volatile TimerScheduler.Handle timerA; + private volatile TimerScheduler.Handle timerB; + private volatile TimerScheduler.Handle timerD; + private volatile SipResponse finalResponse; + + private InviteClientTransaction(SipRequest request, Endpoint peer, + MessageSender sender, TimerScheduler timers, + ClientTransactionListener listener) { + this.request = request; + this.peer = peer; + this.reliable = peer.transport().isReliable(); + this.sender = sender; + this.timers = timers; + this.listener = listener; + this.key = TransactionKey.of(request); + } + + public static InviteClientTransaction start(SipRequest request, Endpoint peer, + MessageSender sender, + TimerScheduler timers, + ClientTransactionListener listener) { + if (request.method() != SipMethod.INVITE) { + throw new IllegalArgumentException( + "InviteClientTransaction requires INVITE, got " + request.method()); + } + InviteClientTransaction tx = new InviteClientTransaction( + request, peer, sender, timers, listener); + tx.fire(); + return tx; + } + + @Override + public TransactionKey key() { return key; } + + @Override + public TransactionState state() { return state.get(); } + + private void fire() { + sender.send(request, peer).whenComplete((ok, err) -> { + if (err != null) { + terminateWith(() -> listener.onTransportError(err)); + } + }); + if (!reliable) { + scheduleA(); + } + timerB = timers.schedule(this::onTimerB, Timing.TIMER_B_MS); + } + + private void scheduleA() { + long delay = Timing.timerA(attempt.getAndIncrement()); + timerA = timers.schedule(this::onTimerA, delay); + } + + private void onTimerA() { + if (state.get() != TransactionState.CALLING) { + return; + } + log.debug("INVITE client retransmit (Timer A) {} attempt={}", key, attempt.get()); + sender.send(request, peer); + scheduleA(); + } + + private void onTimerB() { + if (state.get() == TransactionState.TERMINATED + || state.get() == TransactionState.COMPLETED) { + return; + } + log.debug("INVITE client timeout (Timer B) {}", key); + terminateWith(listener::onTimeout); + } + + private void onTimerD() { + terminateWith(() -> { }); + } + + /** Feed an inbound response into the FSM. */ + public void onResponse(SipResponse response) { + TransactionState s = state.get(); + if (s == TransactionState.TERMINATED) { + return; + } + int status = response.status(); + + if (status < 200) { + if (state.compareAndSet(TransactionState.CALLING, TransactionState.PROCEEDING)) { + cancelTimer(timerA); + listener.onProvisional(response); + } else if (s == TransactionState.PROCEEDING) { + listener.onProvisional(response); + } + return; + } + + // Final response. + if (status >= 200 && status < 300) { + // 2xx is handled by the TU — this transaction terminates immediately. + if (state.compareAndSet(TransactionState.CALLING, TransactionState.TERMINATED) + || state.compareAndSet(TransactionState.PROCEEDING, + TransactionState.TERMINATED)) { + cancelAllTimers(); + listener.onFinal(response); + } + return; + } + + // 3xx-6xx + if (state.compareAndSet(TransactionState.CALLING, TransactionState.COMPLETED) + || state.compareAndSet(TransactionState.PROCEEDING, + TransactionState.COMPLETED)) { + cancelTimer(timerA); + this.finalResponse = response; + sendAckForNonSuccess(response); + listener.onFinal(response); + long d = reliable ? Timing.TIMER_D_MS_RELIABLE : Timing.TIMER_D_MS_UDP; + if (d == 0) { + terminateWith(() -> { }); + } else { + timerD = timers.schedule(this::onTimerD, d); + } + } else if (state.get() == TransactionState.COMPLETED) { + // Retransmitted final — resend ACK. + SipResponse last = this.finalResponse; + if (last != null) { + sendAckForNonSuccess(last); + } + } + } + + /** + * RFC 3261 §17.1.1.3 — ACK for non-2xx is built from the INVITE's + * From, Call-ID, CSeq (with method=ACK), top Via, and the response's + * To header. The ACK is sent within this transaction on the same + * transport / branch. + */ + private void sendAckForNonSuccess(SipResponse response) { + Headers original = request.headers(); + Headers.Builder b = Headers.builder(); + // Top Via must be reused so the ACK matches our outbound branch. + original.first(HeaderName.VIA).ifPresent(via -> b.add(via)); + original.first(HeaderName.FROM).ifPresent(from -> b.add(from)); + response.headers().first(HeaderName.TO).ifPresentOrElse( + b::add, + () -> original.first(HeaderName.TO).ifPresent(b::add)); + original.first(HeaderName.CALL_ID).ifPresent(b::add); + original.first(HeaderName.CSEQ).ifPresent(cseq -> { + // Replace CSeq method with ACK. + String value = cseq.value(); + int sp = -1; + for (int i = 0; i < value.length(); i++) { + if (Character.isWhitespace(value.charAt(i))) { + sp = i; + break; + } + } + String seq = sp < 0 ? value : value.substring(0, sp); + b.add(HeaderName.CSEQ, seq + " ACK"); + }); + original.first(HeaderName.MAX_FORWARDS).ifPresent(b::add); + + SipRequest ack = new SipRequest( + SipMethod.ACK, + request.requestUri(), + SipVersion.SIP_2_0, + b.build(), + new byte[0]); + sender.send(ack, peer); + } + + private void terminateWith(Runnable hook) { + if (state.getAndSet(TransactionState.TERMINATED) == TransactionState.TERMINATED) { + return; + } + cancelAllTimers(); + try { + hook.run(); + } catch (Throwable t) { + log.warn("listener callback threw on terminate: {}", t.toString()); + } + } + + private void cancelAllTimers() { + cancelTimer(timerA); + cancelTimer(timerB); + cancelTimer(timerD); + } + + private static void cancelTimer(TimerScheduler.Handle h) { + if (h != null) h.cancel(); + } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/InviteServerTransaction.java b/sip-transaction/src/main/java/com/sip/transaction/InviteServerTransaction.java new file mode 100644 index 0000000..7ff4f4c --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/InviteServerTransaction.java @@ -0,0 +1,195 @@ +package com.sip.transaction; + +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import com.sip.transport.Endpoint; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * INVITE server transaction FSM — RFC 3261 §17.2.1. + * + *

States: Proceeding (initial) → Completed (non-2xx sent) → + * Confirmed (ACK received) → Terminated. 2xx responses don't drive this + * FSM — they go directly to the TU which manages its own retransmits and + * terminates the transaction immediately.

+ */ +public final class InviteServerTransaction implements Transaction { + + private static final Logger log = LoggerFactory.getLogger(InviteServerTransaction.class); + + private final TransactionKey key; + private final SipRequest initial; + private final Endpoint peer; + private final boolean reliable; + private final MessageSender sender; + private final TimerScheduler timers; + private final ServerTransactionListener listener; + + private final AtomicReference state = + new AtomicReference<>(TransactionState.PROCEEDING); + private final AtomicInteger attempt = new AtomicInteger(0); + + private volatile TimerScheduler.Handle timerG; + private volatile TimerScheduler.Handle timerH; + private volatile TimerScheduler.Handle timerI; + private volatile SipResponse lastResponse; + + private InviteServerTransaction(SipRequest initial, Endpoint peer, + MessageSender sender, TimerScheduler timers, + ServerTransactionListener listener) { + this.initial = initial; + this.peer = peer; + this.reliable = peer.transport().isReliable(); + this.sender = sender; + this.timers = timers; + this.listener = listener; + this.key = TransactionKey.of(initial); + } + + public static InviteServerTransaction accept(SipRequest initial, Endpoint peer, + MessageSender sender, + TimerScheduler timers, + ServerTransactionListener listener) { + if (initial.method() != SipMethod.INVITE) { + throw new IllegalArgumentException( + "InviteServerTransaction requires INVITE, got " + initial.method()); + } + InviteServerTransaction tx = new InviteServerTransaction( + initial, peer, sender, timers, listener); + tx.listener.onRequest(initial); + return tx; + } + + @Override + public TransactionKey key() { return key; } + + @Override + public TransactionState state() { return state.get(); } + + /** TU asks to send a response. 2xx terminates the transaction immediately. */ + public void sendResponse(SipResponse response) { + TransactionState s = state.get(); + if (s == TransactionState.TERMINATED) { + return; + } + lastResponse = response; + int status = response.status(); + + if (status < 200) { + // Stay in Proceeding; just forward. + sender.send(response, peer); + return; + } + + if (status >= 200 && status < 300) { + // 2xx — terminate immediately. TU handles ACK and any retransmits. + cancelAllTimers(); + state.set(TransactionState.TERMINATED); + sender.send(response, peer); + try { + listener.onTerminate(TransactionState.TERMINATED); + } catch (Throwable t) { + log.warn("onTerminate threw: {}", t.toString()); + } + return; + } + + // 3xx-6xx — enter Completed; arm G (retransmit) and H (timeout waiting ACK). + if (state.compareAndSet(TransactionState.PROCEEDING, TransactionState.COMPLETED)) { + sender.send(response, peer); + if (!reliable) { + scheduleG(); + } + timerH = timers.schedule(this::onTimerH, Timing.TIMER_H_MS); + } + } + + private void scheduleG() { + long delay = Timing.timerG(attempt.getAndIncrement()); + timerG = timers.schedule(this::onTimerG, delay); + } + + private void onTimerG() { + if (state.get() != TransactionState.COMPLETED) { + return; + } + SipResponse last = lastResponse; + if (last != null) { + sender.send(last, peer); + } + scheduleG(); + } + + private void onTimerH() { + if (state.get() == TransactionState.COMPLETED) { + log.debug("INVITE server timeout waiting ACK (Timer H) {}", key); + terminate(); + } + } + + private void onTimerI() { + terminate(); + } + + /** + * Called by the transport when a request arrives that maps to this + * transaction key. ACK to non-2xx moves Completed → Confirmed; other + * retransmissions just bounce the last response. + */ + public void onRequest(SipRequest request) { + TransactionState s = state.get(); + if (s == TransactionState.TERMINATED) { + return; + } + if (request.method() == SipMethod.ACK) { + if (state.compareAndSet(TransactionState.COMPLETED, TransactionState.CONFIRMED)) { + cancelTimer(timerG); + long i = reliable ? Timing.TIMER_I_MS_RELIABLE : Timing.TIMER_I_MS_UDP; + if (i == 0) { + terminate(); + } else { + timerI = timers.schedule(this::onTimerI, i); + } + } else if (s == TransactionState.CONFIRMED) { + // Absorb retransmitted ACK. + log.debug("INVITE server absorbing duplicate ACK {}", key); + } + return; + } + // INVITE retransmission. + if (s == TransactionState.PROCEEDING || s == TransactionState.COMPLETED) { + SipResponse last = lastResponse; + if (last != null) { + sender.send(last, peer); + } + listener.onRetransmit(request); + } + } + + private void terminate() { + if (state.getAndSet(TransactionState.TERMINATED) == TransactionState.TERMINATED) { + return; + } + cancelAllTimers(); + try { + listener.onTerminate(TransactionState.TERMINATED); + } catch (Throwable t) { + log.warn("onTerminate threw: {}", t.toString()); + } + } + + private void cancelAllTimers() { + cancelTimer(timerG); + cancelTimer(timerH); + cancelTimer(timerI); + } + + private static void cancelTimer(TimerScheduler.Handle h) { + if (h != null) h.cancel(); + } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/MessageSender.java b/sip-transaction/src/main/java/com/sip/transaction/MessageSender.java new file mode 100644 index 0000000..7fc502c --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/MessageSender.java @@ -0,0 +1,16 @@ +package com.sip.transaction; + +import com.sip.message.SipMessage; +import com.sip.transport.Endpoint; + +import java.util.concurrent.CompletableFuture; + +/** + * Outbound-only view of the transport. Transaction FSMs depend on this + * abstraction (not on {@code Transport}) so they can be unit tested with + * an in-memory implementation. + */ +@FunctionalInterface +public interface MessageSender { + CompletableFuture send(SipMessage message, Endpoint peer); +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/NonInviteClientTransaction.java b/sip-transaction/src/main/java/com/sip/transaction/NonInviteClientTransaction.java new file mode 100644 index 0000000..d1ce01a --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/NonInviteClientTransaction.java @@ -0,0 +1,196 @@ +package com.sip.transaction; + +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import com.sip.transport.Endpoint; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Non-INVITE client transaction FSM — RFC 3261 §17.1.2. + * + *
+ *                                |Request from TU
+ *                                |send request
+ *                Timer E         V
+ *                send request  +-----------+
+ *                  +---------|             |-------------------+
+ *                  |         |   Trying    |  Timer F          |
+ *                  +-------->|             |  or Transport Err.|
+ *                            +-----------+                     |
+ *                              |                               |
+ *                              |1xx                            |
+ *                              |to TU                          |
+ *                              V                               |
+ *                            +-----------+                     |
+ *                  +---------|             |---+               |
+ *                  |Timer E  |             |   |               |
+ *                  |send req |  Proceeding |   |200-699        |
+ *                  +-------->|             |   |to TU          |
+ *                            +-----------+    |               |
+ *                              |   |          |               |
+ *                              | 200-699      |               |
+ *                              | to TU        |               |
+ *                              |              |               |
+ *                              +-----+--------+               |
+ *                                    |                        |
+ *                                    V                        |
+ *                            +-----------+                    |
+ *                            |             |--+               |
+ *                            |  Completed  |  | retx          |
+ *                            |             |<-+ Timer K       |
+ *                            +-----------+                    |
+ *                              |                              |
+ *                              | Timer K                      |
+ *                              | -                            |
+ *                              V                              |
+ *                            +-----------+                    |
+ *                            |             |<-----------------+
+ *                            | Terminated  |
+ *                            |             |
+ *                            +-----------+
+ * 
+ */ +public final class NonInviteClientTransaction implements Transaction { + + private static final Logger log = LoggerFactory.getLogger(NonInviteClientTransaction.class); + + private final TransactionKey key; + private final SipRequest request; + private final Endpoint peer; + private final boolean reliable; + private final MessageSender sender; + private final TimerScheduler timers; + private final ClientTransactionListener listener; + + private final AtomicReference state = + new AtomicReference<>(TransactionState.TRYING); + private final AtomicInteger attempt = new AtomicInteger(0); + + private volatile TimerScheduler.Handle timerE; + private volatile TimerScheduler.Handle timerF; + private volatile TimerScheduler.Handle timerK; + + private NonInviteClientTransaction(SipRequest request, Endpoint peer, + MessageSender sender, TimerScheduler timers, + ClientTransactionListener listener) { + this.request = request; + this.peer = peer; + this.reliable = peer.transport().isReliable(); + this.sender = sender; + this.timers = timers; + this.listener = listener; + this.key = TransactionKey.of(request); + } + + /** Sends the request, starts Timers E (UDP only) and F, returns the transaction. */ + public static NonInviteClientTransaction start(SipRequest request, Endpoint peer, + MessageSender sender, + TimerScheduler timers, + ClientTransactionListener listener) { + NonInviteClientTransaction tx = new NonInviteClientTransaction( + request, peer, sender, timers, listener); + tx.fire(); + return tx; + } + + @Override + public TransactionKey key() { return key; } + + @Override + public TransactionState state() { return state.get(); } + + private void fire() { + sender.send(request, peer).whenComplete((ok, err) -> { + if (err != null) { + terminateWith(() -> listener.onTransportError(err)); + } + }); + if (!reliable) { + scheduleE(); + } + timerF = timers.schedule(this::onTimerF, Timing.TIMER_F_MS); + } + + private void scheduleE() { + long delay = Timing.timerE(attempt.getAndIncrement()); + timerE = timers.schedule(this::onTimerE, delay); + } + + private void onTimerE() { + TransactionState s = state.get(); + if (s != TransactionState.TRYING && s != TransactionState.PROCEEDING) { + return; + } + log.debug("non-INVITE client retransmit {} (state={}, attempt={})", + key, s, attempt.get()); + sender.send(request, peer); + scheduleE(); + } + + private void onTimerF() { + if (state.get() == TransactionState.TERMINATED) { + return; + } + log.debug("non-INVITE client timeout (Timer F) {}", key); + terminateWith(listener::onTimeout); + } + + private void onTimerK() { + terminateWith(() -> { }); + } + + /** Feed an inbound response into the FSM. */ + public void onResponse(SipResponse response) { + TransactionState s = state.get(); + if (s == TransactionState.TERMINATED) { + return; + } + int status = response.status(); + if (status < 200) { + if (state.compareAndSet(TransactionState.TRYING, TransactionState.PROCEEDING)) { + listener.onProvisional(response); + } else if (s == TransactionState.PROCEEDING) { + listener.onProvisional(response); + } + return; + } + // Final response + if (state.compareAndSet(TransactionState.TRYING, TransactionState.COMPLETED) + || state.compareAndSet(TransactionState.PROCEEDING, TransactionState.COMPLETED)) { + cancelTimer(timerE); + cancelTimer(timerF); + listener.onFinal(response); + long k = reliable ? Timing.TIMER_K_MS_RELIABLE : Timing.TIMER_K_MS_UDP; + if (k == 0) { + terminateWith(() -> { }); + } else { + timerK = timers.schedule(this::onTimerK, k); + } + } else if (s == TransactionState.COMPLETED) { + // Retransmitted final — absorb silently per RFC 3261 §17.1.2.2. + log.debug("non-INVITE client absorbing retransmitted final {}", key); + } + } + + private void terminateWith(Runnable hook) { + if (state.getAndSet(TransactionState.TERMINATED) == TransactionState.TERMINATED) { + return; + } + cancelTimer(timerE); + cancelTimer(timerF); + cancelTimer(timerK); + try { + hook.run(); + } catch (Throwable t) { + log.warn("listener callback threw on terminate: {}", t.toString()); + } + } + + private static void cancelTimer(TimerScheduler.Handle h) { + if (h != null) h.cancel(); + } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/NonInviteServerTransaction.java b/sip-transaction/src/main/java/com/sip/transaction/NonInviteServerTransaction.java new file mode 100644 index 0000000..b7d69b9 --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/NonInviteServerTransaction.java @@ -0,0 +1,145 @@ +package com.sip.transaction; + +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import com.sip.transport.Endpoint; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Non-INVITE server transaction FSM — RFC 3261 §17.2.2. + * + *
+ *                            +-----+
+ *                            |     | Request from network
+ *                            |     V
+ *                          +-----------+
+ *                          |  Trying   |
+ *                          +-----------+
+ *                                |
+ *                                | 1xx from TU
+ *                                V
+ *                          +-----------+
+ *               +----------|             |--+ Retransmit 1xx
+ *               |          | Proceeding  |  |
+ *               +--------->|             |<-+
+ *                          +-----------+
+ *                                |
+ *                                | 200-699 from TU
+ *                                V
+ *                          +-----------+
+ *               +----------|             |--+ Retransmit request → resend response
+ *               |          |  Completed  |  |
+ *               +--------->|             |<-+
+ *                          +-----------+
+ *                                |
+ *                                | Timer J
+ *                                V
+ *                          +-----------+
+ *                          | Terminated|
+ *                          +-----------+
+ * 
+ */ +public final class NonInviteServerTransaction implements Transaction { + + private static final Logger log = LoggerFactory.getLogger(NonInviteServerTransaction.class); + + private final TransactionKey key; + private final Endpoint peer; + private final boolean reliable; + private final MessageSender sender; + private final TimerScheduler timers; + private final ServerTransactionListener listener; + + private final AtomicReference state = + new AtomicReference<>(TransactionState.TRYING); + private volatile SipResponse lastResponse; + private volatile TimerScheduler.Handle timerJ; + + private NonInviteServerTransaction(SipRequest initial, Endpoint peer, + MessageSender sender, TimerScheduler timers, + ServerTransactionListener listener) { + this.peer = peer; + this.reliable = peer.transport().isReliable(); + this.sender = sender; + this.timers = timers; + this.listener = listener; + this.key = TransactionKey.of(initial); + } + + public static NonInviteServerTransaction accept(SipRequest initial, Endpoint peer, + MessageSender sender, + TimerScheduler timers, + ServerTransactionListener listener) { + NonInviteServerTransaction tx = new NonInviteServerTransaction( + initial, peer, sender, timers, listener); + tx.listener.onRequest(initial); + return tx; + } + + @Override + public TransactionKey key() { return key; } + + @Override + public TransactionState state() { return state.get(); } + + /** Called by the TU when it wants to send a response. */ + public void sendResponse(SipResponse response) { + TransactionState s = state.get(); + if (s == TransactionState.TERMINATED) { + log.debug("response for terminated non-INVITE server {}", key); + return; + } + lastResponse = response; + int status = response.status(); + if (status < 200) { + state.compareAndSet(TransactionState.TRYING, TransactionState.PROCEEDING); + } else { + // Final response. + if (state.compareAndSet(TransactionState.TRYING, TransactionState.COMPLETED) + || state.compareAndSet(TransactionState.PROCEEDING, + TransactionState.COMPLETED)) { + long j = reliable ? Timing.TIMER_J_MS_RELIABLE : Timing.TIMER_J_MS_UDP; + if (j == 0) { + terminate(); + sender.send(response, peer); + return; + } + timerJ = timers.schedule(this::terminate, j); + } + } + sender.send(response, peer); + } + + /** Called by transport on retransmitted request from peer. */ + public void onRequest(SipRequest request) { + TransactionState s = state.get(); + if (s == TransactionState.PROCEEDING || s == TransactionState.COMPLETED) { + // Resend the last response. + SipResponse last = lastResponse; + if (last != null) { + sender.send(last, peer); + } + listener.onRetransmit(request); + return; + } + if (s == TransactionState.TRYING) { + // Duplicate of the initial request — TU may ignore. + listener.onRetransmit(request); + } + } + + private void terminate() { + if (state.getAndSet(TransactionState.TERMINATED) == TransactionState.TERMINATED) { + return; + } + if (timerJ != null) timerJ.cancel(); + try { + listener.onTerminate(TransactionState.TERMINATED); + } catch (Throwable t) { + log.warn("onTerminate threw: {}", t.toString()); + } + } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/ServerTransactionListener.java b/sip-transaction/src/main/java/com/sip/transaction/ServerTransactionListener.java new file mode 100644 index 0000000..96b0d7f --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/ServerTransactionListener.java @@ -0,0 +1,18 @@ +package com.sip.transaction; + +import com.sip.message.SipRequest; + +/** + * Callbacks delivered by a server transaction to the Transaction User (TU). + */ +public interface ServerTransactionListener { + + /** A new request was received that should be handled by this TU. */ + void onRequest(SipRequest request); + + /** Retransmission of the same request; TU may ignore. */ + default void onRetransmit(SipRequest request) { } + + /** Transaction is being torn down (timeout, ACK, etc.). */ + default void onTerminate(TransactionState terminalState) { } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/TimerScheduler.java b/sip-transaction/src/main/java/com/sip/transaction/TimerScheduler.java new file mode 100644 index 0000000..6f97317 --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/TimerScheduler.java @@ -0,0 +1,90 @@ +package com.sip.transaction; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * Lightweight timer wheel abstraction. + * + *

RFC 3261 needs 11 timers per transaction. We default to a single + * scheduled-executor backed implementation rather than a hand-rolled + * hashed wheel because:

+ *
    + *
  • SIP transaction counts even on busy servers stay in the tens of + * thousands, well within {@link ScheduledThreadPoolExecutor} territory.
  • + *
  • Virtual threads carry the timer callbacks; we never need a busy + * loop or batch fire.
  • + *
  • The {@link TimerScheduler} contract is small, so a hashed-wheel + * implementation can be dropped in later without changing call sites.
  • + *
+ */ +public interface TimerScheduler extends AutoCloseable { + + /** Schedules {@code task} to run after {@code delayMs} milliseconds. */ + Handle schedule(Runnable task, long delayMs); + + @Override + void close(); + + /** A scheduled task. Cancel it to prevent firing. Idempotent. */ + interface Handle { + void cancel(); + boolean isCancelled(); + } + + /** Default impl backed by a 1-thread scheduled executor + virtual dispatch. */ + static TimerScheduler create() { + return new DefaultTimerScheduler(); + } + + final class DefaultTimerScheduler implements TimerScheduler { + + private final ScheduledExecutorService scheduler; + private final java.util.concurrent.ExecutorService dispatch; + + DefaultTimerScheduler() { + ThreadFactory platform = r -> { + Thread t = new Thread(r, "sip-timer-wheel"); + t.setDaemon(true); + return t; + }; + this.scheduler = Executors.newSingleThreadScheduledExecutor(platform); + this.dispatch = Executors.newThreadPerTaskExecutor( + Thread.ofVirtual().name("sip-timer-cb-", 0).factory()); + } + + @Override + public Handle schedule(Runnable task, long delayMs) { + ScheduledFuture f = scheduler.schedule( + () -> dispatch.submit(task), delayMs, TimeUnit.MILLISECONDS); + return new HandleImpl(f); + } + + @Override + public void close() { + scheduler.shutdownNow(); + dispatch.shutdownNow(); + } + + private static final class HandleImpl implements Handle { + private final ScheduledFuture future; + + HandleImpl(ScheduledFuture f) { + this.future = f; + } + + @Override + public void cancel() { + future.cancel(false); + } + + @Override + public boolean isCancelled() { + return future.isCancelled(); + } + } + } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/Timing.java b/sip-transaction/src/main/java/com/sip/transaction/Timing.java new file mode 100644 index 0000000..b7bbfd2 --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/Timing.java @@ -0,0 +1,68 @@ +package com.sip.transaction; + +import java.time.Duration; + +/** + * RFC 3261 §A timer values, in millis. + * + *

The exact values used in production may be tuned by callers; the + * constants here are the RFC defaults.

+ */ +public final class Timing { + + /** T1 — RTT estimate (RFC 3261 §17.1.1.1). */ + public static final long T1_MS = 500; + + /** T2 — maximum retransmit interval for non-INVITE / 2xx-final + * responses (RFC 3261 §17.1.2.2). */ + public static final long T2_MS = 4_000; + + /** T4 — maximum duration a message stays in the network (RFC 3261 §A). */ + public static final long T4_MS = 5_000; + + /** Timer A — INVITE client retransmission (initial = T1, doubles). */ + public static long timerA(int attempt) { + return Math.min(T1_MS << attempt, T2_MS); + } + + /** Timer B — INVITE client transaction timeout = 64 * T1. */ + public static final long TIMER_B_MS = 64 * T1_MS; + + /** Timer D — wait for response retransmissions after 3xx-6xx. */ + public static final long TIMER_D_MS_UDP = 32_000; + public static final long TIMER_D_MS_RELIABLE = 0; + + /** Timer E — non-INVITE client retransmission (initial = T1, doubles, capped T2). */ + public static long timerE(int attempt) { + return Math.min(T1_MS << attempt, T2_MS); + } + + /** Timer F — non-INVITE client transaction timeout = 64 * T1. */ + public static final long TIMER_F_MS = 64 * T1_MS; + + /** Timer G — INVITE server response retransmission for 2xx (T1, doubles, cap T2). */ + public static long timerG(int attempt) { + return Math.min(T1_MS << attempt, T2_MS); + } + + /** Timer H — INVITE server wait for ACK = 64 * T1. */ + public static final long TIMER_H_MS = 64 * T1_MS; + + /** Timer I — wait for ACK retransmissions on UDP = T4. */ + public static final long TIMER_I_MS_UDP = T4_MS; + public static final long TIMER_I_MS_RELIABLE = 0; + + /** Timer J — non-INVITE server lifetime = 64 * T1 on UDP, 0 on reliable. */ + public static final long TIMER_J_MS_UDP = 64 * T1_MS; + public static final long TIMER_J_MS_RELIABLE = 0; + + /** Timer K — non-INVITE client wait for retransmissions = T4 on UDP. */ + public static final long TIMER_K_MS_UDP = T4_MS; + public static final long TIMER_K_MS_RELIABLE = 0; + + private Timing() { } + + public static Duration ms(long millis) { + return Duration.ofMillis(millis); + } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/Transaction.java b/sip-transaction/src/main/java/com/sip/transaction/Transaction.java new file mode 100644 index 0000000..b285440 --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/Transaction.java @@ -0,0 +1,20 @@ +package com.sip.transaction; + +/** + * Common API for all four transaction FSMs. + * + *

Each transaction is uniquely keyed by {@link TransactionKey} and lives + * inside a {@link TransactionTable}. The FSMs are intentionally minimal — + * higher layers receive notifications via TU callbacks supplied at + * construction time.

+ */ +public interface Transaction { + + TransactionKey key(); + + TransactionState state(); + + default boolean isTerminated() { + return state() == TransactionState.TERMINATED; + } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/TransactionKey.java b/sip-transaction/src/main/java/com/sip/transaction/TransactionKey.java new file mode 100644 index 0000000..0cfb40d --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/TransactionKey.java @@ -0,0 +1,88 @@ +package com.sip.transaction; + +import com.sip.codec.typed.ViaParser; +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.header.HeaderName; +import com.sip.message.header.typed.ViaHeader; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * RFC 3261 §17.1.3 / §17.2.3 transaction matching key. + * + *

For RFC 3261-compliant requests (branch starts with {@code z9hG4bK}), + * the transaction identity is {@code (branch, sent-by, method)}. For RFC + * 2543 compatibility we'd need a different rule — out of scope for this + * stack.

+ * + *

ACK matching has a special rule (RFC 3261 §17.1.3 last paragraph): + * an ACK for a 2xx is a NEW transaction (and matches no transaction); + * an ACK for non-2xx must match the original INVITE transaction. Callers + * resolve this by keying ACK lookups with the INVITE method, since the + * server-side INVITE FSM owns the non-2xx ACK reception.

+ * + * @param branch the {@code branch} parameter of the top Via + * @param sentBy the {@code sent-by} component of the top Via + * @param method the method (or {@code INVITE} for non-2xx ACK matching) + */ +public record TransactionKey(String branch, String sentBy, SipMethod method) { + + /** RFC 3261 §8.1.1.7 — branches always start with this magic cookie. */ + public static final String RFC3261_MAGIC_COOKIE = "z9hG4bK"; + + public TransactionKey { + Objects.requireNonNull(branch, "branch"); + Objects.requireNonNull(sentBy, "sentBy"); + Objects.requireNonNull(method, "method"); + } + + /** + * Derives the transaction key for a request as seen by a server (server + * transaction) or for a request we just sent (client transaction). + * + *

For ACK that is meant to terminate the INVITE server transaction + * (i.e. ACK to non-2xx), pass {@link SipMethod#INVITE} as the + * {@code overrideMethod} so the lookup hits the originating INVITE.

+ */ + public static TransactionKey of(SipRequest request, SipMethod overrideMethod) { + List vias = ViaParser.parseAll(request.headers()); + if (vias.isEmpty()) { + throw new IllegalArgumentException( + "request has no Via header — cannot derive transaction key"); + } + ViaHeader top = vias.get(0); + String branch = top.branch().orElseThrow(() -> new IllegalArgumentException( + "Via has no branch parameter — cannot derive transaction key")); + if (!branch.startsWith(RFC3261_MAGIC_COOKIE)) { + throw new IllegalArgumentException( + "Via branch missing RFC 3261 magic cookie '" + RFC3261_MAGIC_COOKIE + + "': '" + branch + "'"); + } + SipMethod m = overrideMethod != null ? overrideMethod : request.method(); + return new TransactionKey(branch, top.sentBy().asWire(), m); + } + + public static TransactionKey of(SipRequest request) { + return of(request, null); + } + + /** + * Look up the top Via's branch directly without going through {@link #of}. + * Useful for ACK handling (RFC 3261 §17.1.3): an ACK matches the INVITE + * server transaction if {@code branch} and {@code sent-by} agree, even + * though the methods differ. + */ + public static Optional topViaBranch(SipRequest request) { + return request.headers().first(HeaderName.VIA).flatMap(raw -> { + try { + return ViaParser.parseAll(request.headers()).stream().findFirst() + .flatMap(ViaHeader::branch); + } catch (RuntimeException e) { + return Optional.empty(); + } + }); + } +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/TransactionLayer.java b/sip-transaction/src/main/java/com/sip/transaction/TransactionLayer.java deleted file mode 100644 index b884acf..0000000 --- a/sip-transaction/src/main/java/com/sip/transaction/TransactionLayer.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.sip.transaction; - -/** - * Scaffold marker for the transaction-layer module. - * - *

This type exists so the {@code com.sip.transaction} JPMS package is - * non-empty before the concrete FSM lands. It is intentionally not part - * of any stable contract and may be removed once the layer is real.

- */ -public final class TransactionLayer { - - /** Indicates whether the layer is wired up. Always {@code false} for now. */ - public static final boolean IMPLEMENTED = false; - - private TransactionLayer() { } -} diff --git a/sip-transaction/src/main/java/com/sip/transaction/TransactionState.java b/sip-transaction/src/main/java/com/sip/transaction/TransactionState.java new file mode 100644 index 0000000..eb3f6b6 --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/TransactionState.java @@ -0,0 +1,22 @@ +package com.sip.transaction; + +/** + * Combined state vocabulary for all four RFC 3261 §17 transaction FSMs. + * + *

Not every state applies to every FSM; the variant comments call out + * which kind uses which.

+ */ +public enum TransactionState { + /** INVITE client: send INVITE, await provisional. */ + CALLING, + /** Non-INVITE client: send request, await provisional. */ + TRYING, + /** Both clients & both servers: received provisional. */ + PROCEEDING, + /** Both clients & both servers: final 2xx-6xx sent/received. */ + COMPLETED, + /** INVITE server only: ACK received for 2xx (transaction kept for retransmits). */ + CONFIRMED, + /** Terminal — transaction is dead and may be removed from the table. */ + TERMINATED +} diff --git a/sip-transaction/src/main/java/com/sip/transaction/TransactionTable.java b/sip-transaction/src/main/java/com/sip/transaction/TransactionTable.java new file mode 100644 index 0000000..1150b1d --- /dev/null +++ b/sip-transaction/src/main/java/com/sip/transaction/TransactionTable.java @@ -0,0 +1,35 @@ +package com.sip.transaction; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Lock-free map from {@link TransactionKey} to {@link Transaction}. + * + *

Both client and server transactions share the same table; matching by + * key plus method discriminates the two when looking up the right side of + * a request/response pair (RFC 3261 §17.1.3 / §17.2.3).

+ */ +public final class TransactionTable { + + private final ConcurrentHashMap table = + new ConcurrentHashMap<>(); + + public TransactionTable() { } + + public void put(Transaction tx) { + table.put(tx.key(), tx); + } + + public Optional find(TransactionKey key) { + return Optional.ofNullable(table.get(key)); + } + + public void remove(TransactionKey key) { + table.remove(key); + } + + public int size() { + return table.size(); + } +} diff --git a/sip-transaction/src/main/java/module-info.java b/sip-transaction/src/main/java/module-info.java index 971ab21..b47366f 100644 --- a/sip-transaction/src/main/java/module-info.java +++ b/sip-transaction/src/main/java/module-info.java @@ -7,6 +7,7 @@ module com.sip.transaction { requires transitive com.sip.message; requires transitive com.sip.transport.api; + requires com.sip.codec; requires org.slf4j; exports com.sip.transaction; diff --git a/sip-transaction/src/test/java/com/sip/transaction/InviteClientTransactionTest.java b/sip-transaction/src/test/java/com/sip/transaction/InviteClientTransactionTest.java new file mode 100644 index 0000000..3ca83be --- /dev/null +++ b/sip-transaction/src/test/java/com/sip/transaction/InviteClientTransactionTest.java @@ -0,0 +1,117 @@ +package com.sip.transaction; + +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class InviteClientTransactionTest { + + private TimerScheduler timers; + private TransactionTestSupport.RecordingSender sender; + + @BeforeEach + void setUp() { + timers = TimerScheduler.create(); + sender = new TransactionTestSupport.RecordingSender(); + } + + @AfterEach + void tearDown() { + timers.close(); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void successfulInviteTerminatesImmediatelyOn2xx() throws Exception { + CountDownLatch finalLatch = new CountDownLatch(1); + AtomicReference seen = new AtomicReference<>(); + InviteClientTransaction tx = InviteClientTransaction.start( + TransactionTestSupport.request(SipMethod.INVITE, "z9hG4bK-i1"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ClientTransactionListener() { + @Override public void onFinal(SipResponse r) { + seen.set(r); + finalLatch.countDown(); + } + @Override public void onTimeout() { } + }); + + tx.onResponse(TransactionTestSupport.response(100, "Trying")); + tx.onResponse(TransactionTestSupport.response(200, "OK")); + + assertThat(finalLatch.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(seen.get().status()).isEqualTo(200); + // 2xx → transaction terminates immediately, TU builds its own ACK. + assertThat(tx.state()).isEqualTo(TransactionState.TERMINATED); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void nonSuccessFinalEnqueuesAckAndEntersCompleted() throws Exception { + CountDownLatch finalLatch = new CountDownLatch(1); + InviteClientTransaction tx = InviteClientTransaction.start( + TransactionTestSupport.request(SipMethod.INVITE, "z9hG4bK-i2"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ClientTransactionListener() { + @Override public void onFinal(SipResponse r) { finalLatch.countDown(); } + @Override public void onTimeout() { } + }); + + tx.onResponse(TransactionTestSupport.response(404, "Not Found")); + assertThat(finalLatch.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(tx.state()).isEqualTo(TransactionState.COMPLETED); + + // Expect: 1 INVITE + 1 ACK in sent list. + long ackCount = sender.sent.stream() + .filter(m -> m instanceof SipRequest r && r.method() == SipMethod.ACK) + .count(); + assertThat(ackCount).isEqualTo(1); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void retransmittedNonSuccessFinalResendsAck() throws Exception { + InviteClientTransaction tx = InviteClientTransaction.start( + TransactionTestSupport.request(SipMethod.INVITE, "z9hG4bK-i3"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ClientTransactionListener() { + @Override public void onFinal(SipResponse r) { } + @Override public void onTimeout() { } + }); + SipResponse rsp = TransactionTestSupport.response(500, "Server Error"); + tx.onResponse(rsp); + tx.onResponse(rsp); // retransmit → must trigger another ACK + tx.onResponse(rsp); + + long ackCount = sender.sent.stream() + .filter(m -> m instanceof SipRequest r && r.method() == SipMethod.ACK) + .count(); + assertThat(ackCount).isEqualTo(3); + } + + @Test + void wrongMethodIsRejected() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + InviteClientTransaction.start( + TransactionTestSupport.request(SipMethod.OPTIONS, "z9hG4bK-x"), + TransactionTestSupport.UDP_PEER, + sender, timers, new ClientTransactionListener() { + @Override public void onFinal(SipResponse r) { } + @Override public void onTimeout() { } + })) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/sip-transaction/src/test/java/com/sip/transaction/InviteServerTransactionTest.java b/sip-transaction/src/test/java/com/sip/transaction/InviteServerTransactionTest.java new file mode 100644 index 0000000..33cc85d --- /dev/null +++ b/sip-transaction/src/test/java/com/sip/transaction/InviteServerTransactionTest.java @@ -0,0 +1,93 @@ +package com.sip.transaction; + +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class InviteServerTransactionTest { + + private TimerScheduler timers; + private TransactionTestSupport.RecordingSender sender; + + @BeforeEach + void setUp() { + timers = TimerScheduler.create(); + sender = new TransactionTestSupport.RecordingSender(); + } + + @AfterEach + void tearDown() { + timers.close(); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void twoXxTerminatesImmediately() { + InviteServerTransaction tx = InviteServerTransaction.accept( + TransactionTestSupport.request(SipMethod.INVITE, "z9hG4bK-s1"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ServerTransactionListener() { + @Override public void onRequest(SipRequest r) { } + }); + + tx.sendResponse(TransactionTestSupport.response(180, "Ringing")); + tx.sendResponse(TransactionTestSupport.response(200, "OK")); + assertThat(tx.state()).isEqualTo(TransactionState.TERMINATED); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void nonSuccessFinalGoesToCompletedThenAckMovesToConfirmed() throws Exception { + CountDownLatch terminated = new CountDownLatch(1); + AtomicReference terminalState = new AtomicReference<>(); + + InviteServerTransaction tx = InviteServerTransaction.accept( + TransactionTestSupport.request(SipMethod.INVITE, "z9hG4bK-s2"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ServerTransactionListener() { + @Override public void onRequest(SipRequest r) { } + @Override public void onTerminate(TransactionState s) { + terminalState.set(s); + terminated.countDown(); + } + }); + + tx.sendResponse(TransactionTestSupport.response(404, "Not Found")); + assertThat(tx.state()).isEqualTo(TransactionState.COMPLETED); + + // Now feed an ACK. + tx.onRequest(TransactionTestSupport.request(SipMethod.ACK, "z9hG4bK-s2")); + assertThat(tx.state()) + .isIn(TransactionState.CONFIRMED, TransactionState.TERMINATED); + + // Timer I = T4 = 5s. Wait briefly; if reliable transport it'd be 0. + // We just check the state transition happened above. + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void retransmittedInviteResendsLastResponse() { + SipRequest invite = TransactionTestSupport.request(SipMethod.INVITE, "z9hG4bK-s3"); + InviteServerTransaction tx = InviteServerTransaction.accept( + invite, TransactionTestSupport.UDP_PEER, sender, timers, + new ServerTransactionListener() { + @Override public void onRequest(SipRequest r) { } + }); + tx.sendResponse(TransactionTestSupport.response(180, "Ringing")); + int before = sender.sent.size(); + tx.onRequest(invite); // retransmitted INVITE + assertThat(sender.sent.size()).isGreaterThan(before); + } +} diff --git a/sip-transaction/src/test/java/com/sip/transaction/NonInviteClientTransactionTest.java b/sip-transaction/src/test/java/com/sip/transaction/NonInviteClientTransactionTest.java new file mode 100644 index 0000000..d6a9a8d --- /dev/null +++ b/sip-transaction/src/test/java/com/sip/transaction/NonInviteClientTransactionTest.java @@ -0,0 +1,131 @@ +package com.sip.transaction; + +import com.sip.message.SipMethod; +import com.sip.message.SipResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class NonInviteClientTransactionTest { + + private TimerScheduler timers; + private TransactionTestSupport.RecordingSender sender; + + @BeforeEach + void setUp() { + timers = TimerScheduler.create(); + sender = new TransactionTestSupport.RecordingSender(); + } + + @AfterEach + void tearDown() { + timers.close(); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void deliversFinalResponseAndTransitionsToCompleted() throws Exception { + CountDownLatch finalLatch = new CountDownLatch(1); + AtomicReference seenFinal = new AtomicReference<>(); + NonInviteClientTransaction tx = NonInviteClientTransaction.start( + TransactionTestSupport.request(SipMethod.OPTIONS, "z9hG4bK-1"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ClientTransactionListener() { + @Override public void onFinal(SipResponse response) { + seenFinal.set(response); + finalLatch.countDown(); + } + @Override public void onTimeout() { /* unused */ } + }); + + assertThat(tx.state()).isEqualTo(TransactionState.TRYING); + tx.onResponse(TransactionTestSupport.response(200, "OK")); + + assertThat(finalLatch.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(seenFinal.get().status()).isEqualTo(200); + assertThat(tx.state()).isEqualTo(TransactionState.COMPLETED); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void provisionalMovesToProceedingThenFinal() throws Exception { + AtomicInteger provisionalCount = new AtomicInteger(); + CountDownLatch latch = new CountDownLatch(1); + NonInviteClientTransaction tx = NonInviteClientTransaction.start( + TransactionTestSupport.request(SipMethod.OPTIONS, "z9hG4bK-2"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ClientTransactionListener() { + @Override public void onProvisional(SipResponse r) { + provisionalCount.incrementAndGet(); + } + @Override public void onFinal(SipResponse r) { latch.countDown(); } + @Override public void onTimeout() { } + }); + + tx.onResponse(TransactionTestSupport.response(100, "Trying")); + assertThat(tx.state()).isEqualTo(TransactionState.PROCEEDING); + tx.onResponse(TransactionTestSupport.response(180, "Ringing")); + tx.onResponse(TransactionTestSupport.response(404, "Not Found")); + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(provisionalCount.get()).isEqualTo(2); + assertThat(tx.state()).isEqualTo(TransactionState.COMPLETED); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void retransmittedFinalIsAbsorbed() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicInteger finalCount = new AtomicInteger(); + NonInviteClientTransaction tx = NonInviteClientTransaction.start( + TransactionTestSupport.request(SipMethod.OPTIONS, "z9hG4bK-3"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ClientTransactionListener() { + @Override public void onFinal(SipResponse r) { + finalCount.incrementAndGet(); + latch.countDown(); + } + @Override public void onTimeout() { } + }); + SipResponse rsp = TransactionTestSupport.response(200, "OK"); + tx.onResponse(rsp); + tx.onResponse(rsp); // retransmit + tx.onResponse(rsp); // retransmit + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(finalCount.get()).isEqualTo(1); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void timerETriggersRetransmitOnUdp() throws Exception { + // Use a short T1 by scheduling our own to avoid 500ms wait. We instead + // observe that after a moment, sender has more than 1 sent (the first + // is the initial send, additional ones are Timer E retransmissions). + NonInviteClientTransaction.start( + TransactionTestSupport.request(SipMethod.OPTIONS, "z9hG4bK-4"), + TransactionTestSupport.UDP_PEER, + sender, timers, + new ClientTransactionListener() { + @Override public void onFinal(SipResponse r) { } + @Override public void onTimeout() { } + }); + + // Wait up to 1500 ms; at least the first retransmit (Timer E ≈ T1 = 500ms) + // should fire. + long deadline = System.nanoTime() + 1_500_000_000L; + while (sender.sent.size() < 2 && System.nanoTime() < deadline) { + Thread.sleep(50); + } + assertThat(sender.sent.size()).isGreaterThanOrEqualTo(2); + } +} diff --git a/sip-transaction/src/test/java/com/sip/transaction/NonInviteServerTransactionTest.java b/sip-transaction/src/test/java/com/sip/transaction/NonInviteServerTransactionTest.java new file mode 100644 index 0000000..18c5f81 --- /dev/null +++ b/sip-transaction/src/test/java/com/sip/transaction/NonInviteServerTransactionTest.java @@ -0,0 +1,79 @@ +package com.sip.transaction; + +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class NonInviteServerTransactionTest { + + private TimerScheduler timers; + private TransactionTestSupport.RecordingSender sender; + + @BeforeEach + void setUp() { + timers = TimerScheduler.create(); + sender = new TransactionTestSupport.RecordingSender(); + } + + @AfterEach + void tearDown() { + timers.close(); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void deliversInitialRequestToListener() { + AtomicReference seen = new AtomicReference<>(); + SipRequest req = TransactionTestSupport.request(SipMethod.OPTIONS, "z9hG4bK-ns1"); + NonInviteServerTransaction.accept( + req, TransactionTestSupport.UDP_PEER, sender, timers, + new ServerTransactionListener() { + @Override public void onRequest(SipRequest r) { seen.set(r); } + }); + assertThat(seen.get()).isSameAs(req); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void finalResponseTransitionsToCompleted() { + NonInviteServerTransaction tx = NonInviteServerTransaction.accept( + TransactionTestSupport.request(SipMethod.OPTIONS, "z9hG4bK-ns2"), + TransactionTestSupport.UDP_PEER, sender, timers, + new ServerTransactionListener() { + @Override public void onRequest(SipRequest r) { } + }); + tx.sendResponse(TransactionTestSupport.response(200, "OK")); + assertThat(tx.state()).isEqualTo(TransactionState.COMPLETED); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void retransmittedRequestInCompletedResendsLastResponse() { + SipRequest req = TransactionTestSupport.request(SipMethod.OPTIONS, "z9hG4bK-ns3"); + AtomicInteger retransmits = new AtomicInteger(); + NonInviteServerTransaction tx = NonInviteServerTransaction.accept( + req, TransactionTestSupport.UDP_PEER, sender, timers, + new ServerTransactionListener() { + @Override public void onRequest(SipRequest r) { } + @Override public void onRetransmit(SipRequest r) { + retransmits.incrementAndGet(); + } + }); + tx.sendResponse(TransactionTestSupport.response(200, "OK")); + int sentBefore = sender.sent.size(); + tx.onRequest(req); + tx.onRequest(req); + assertThat(retransmits.get()).isEqualTo(2); + assertThat(sender.sent.size()).isEqualTo(sentBefore + 2); + } +} diff --git a/sip-transaction/src/test/java/com/sip/transaction/TransactionKeyTest.java b/sip-transaction/src/test/java/com/sip/transaction/TransactionKeyTest.java new file mode 100644 index 0000000..2a2f3b6 --- /dev/null +++ b/sip-transaction/src/test/java/com/sip/transaction/TransactionKeyTest.java @@ -0,0 +1,52 @@ +package com.sip.transaction; + +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TransactionKeyTest { + + @Test + void derivesKeyFromTopVia() { + SipRequest req = TransactionTestSupport.request(SipMethod.INVITE, "z9hG4bK-abc"); + TransactionKey k = TransactionKey.of(req); + assertThat(k.branch()).isEqualTo("z9hG4bK-abc"); + assertThat(k.method()).isEqualTo(SipMethod.INVITE); + assertThat(k.sentBy()).isEqualTo("host"); + } + + @Test + void rejectsViaWithoutBranch() { + SipRequest req = new SipRequest( + SipMethod.OPTIONS, + com.sip.message.uri.SipUri.builder().host("x").build(), + com.sip.message.SipVersion.SIP_2_0, + com.sip.message.header.Headers.builder() + .add("Via", "SIP/2.0/UDP host") + .add("From", ";tag=1") + .add("To", "") + .add("Call-ID", "x@x") + .add("CSeq", "1 OPTIONS") + .build(), + new byte[0]); + assertThatThrownBy(() -> TransactionKey.of(req)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsBranchMissingMagicCookie() { + SipRequest req = TransactionTestSupport.request(SipMethod.OPTIONS, "no-magic-cookie"); + assertThatThrownBy(() -> TransactionKey.of(req)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void overrideMethodForAckMatching() { + SipRequest ack = TransactionTestSupport.request(SipMethod.ACK, "z9hG4bK-abc"); + TransactionKey k = TransactionKey.of(ack, SipMethod.INVITE); + assertThat(k.method()).isEqualTo(SipMethod.INVITE); + } +} diff --git a/sip-transaction/src/test/java/com/sip/transaction/TransactionTestSupport.java b/sip-transaction/src/test/java/com/sip/transaction/TransactionTestSupport.java new file mode 100644 index 0000000..160eb9e --- /dev/null +++ b/sip-transaction/src/test/java/com/sip/transaction/TransactionTestSupport.java @@ -0,0 +1,88 @@ +package com.sip.transaction; + +import com.sip.message.SipMessage; +import com.sip.message.SipMethod; +import com.sip.message.SipRequest; +import com.sip.message.SipResponse; +import com.sip.message.SipVersion; +import com.sip.message.header.Headers; +import com.sip.message.uri.SipUri; +import com.sip.transport.Endpoint; +import com.sip.transport.TransportType; + +import java.net.InetSocketAddress; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Test helpers for the transaction FSMs. + * + *

Provides a {@link RecordingSender} that captures every outbound + * message in order, and small builders for canonical request/response + * messages.

+ */ +final class TransactionTestSupport { + + private TransactionTestSupport() { } + + static final Endpoint UDP_PEER = new Endpoint(TransportType.UDP, + new InetSocketAddress("127.0.0.1", 5060)); + static final Endpoint TCP_PEER = new Endpoint(TransportType.TCP, + new InetSocketAddress("127.0.0.1", 5060)); + + /** + * Captures every outbound send. Defaults to immediate success; + * tests can override {@link #failNextWith(Throwable)} to simulate + * transport failure. + */ + static final class RecordingSender implements MessageSender { + final List sent = new CopyOnWriteArrayList<>(); + private final AtomicReference nextFailure = new AtomicReference<>(); + + @Override + public CompletableFuture send(SipMessage message, Endpoint peer) { + sent.add(message); + Throwable err = nextFailure.getAndSet(null); + if (err != null) { + return CompletableFuture.failedFuture(err); + } + return CompletableFuture.completedFuture(null); + } + + void failNextWith(Throwable t) { + nextFailure.set(t); + } + } + + static SipRequest request(SipMethod method, String branch) { + return new SipRequest( + method, + SipUri.builder().host("target.example").build(), + SipVersion.SIP_2_0, + Headers.builder() + .add("Via", "SIP/2.0/UDP host;branch=" + branch) + .add("From", ";tag=t1") + .add("To", "") + .add("Call-ID", "test@example") + .add("CSeq", "1 " + method.name()) + .add("Max-Forwards", "70") + .build(), + new byte[0]); + } + + static SipResponse response(int status, String reason) { + return SipResponse.builder() + .status(status) + .reason(reason) + .headers(Headers.builder() + .add("Via", "SIP/2.0/UDP host;branch=z9hG4bK-test") + .add("From", ";tag=t1") + .add("To", ";tag=t2") + .add("Call-ID", "test@example") + .add("CSeq", "1 INVITE") + .build()) + .build(); + } +}