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();
+ }
+}