From 05c04fdd7976bf5568c65946724a170d6ba8b8fc Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Fri, 12 Jun 2026 03:12:06 -0700
Subject: [PATCH 1/4] feat: add SessionStateListener callback when a PossDup
message with too-low MsgSeqNum is discarded
Fixes #1254
---
.../src/main/java/quickfix/Session.java | 5 +-
.../java/quickfix/SessionStateListener.java | 14 +++
.../src/test/java/quickfix/SessionTest.java | 99 +++++++++++++++++++
3 files changed, 117 insertions(+), 1 deletion(-)
diff --git a/quickfixj-core/src/main/java/quickfix/Session.java b/quickfixj-core/src/main/java/quickfix/Session.java
index f9151daf88..542d13d060 100644
--- a/quickfixj-core/src/main/java/quickfix/Session.java
+++ b/quickfixj-core/src/main/java/quickfix/Session.java
@@ -1841,6 +1841,7 @@ private boolean verify(Message msg, boolean checkTooHigh, boolean checkTooLow)
// Handle poss dup where msgSeq is as expected
// FIX 4.4 Vol 2, test case 2f&g
if (isPossibleDuplicate(msg) && !validatePossDup(msg)) {
+ stateListener.onPossDupMessageDiscarded(sessionID, msg);
return false;
}
@@ -1881,7 +1882,9 @@ private boolean doTargetTooLow(Message msg) throws FieldNotFound, IOException {
generateLogout(text);
throw new SessionException(text);
}
- return validatePossDup(msg);
+ final boolean validPossDup = validatePossDup(msg);
+ stateListener.onPossDupMessageDiscarded(sessionID, msg);
+ return validPossDup;
}
private void doBadCompID(Message msg) throws IOException, FieldNotFound {
diff --git a/quickfixj-core/src/main/java/quickfix/SessionStateListener.java b/quickfixj-core/src/main/java/quickfix/SessionStateListener.java
index 4c1f0ea594..e5aa0c2712 100644
--- a/quickfixj-core/src/main/java/quickfix/SessionStateListener.java
+++ b/quickfixj-core/src/main/java/quickfix/SessionStateListener.java
@@ -105,4 +105,18 @@ default void onSequenceResetReceived(SessionID sessionID, int newSeqNo, boolean
*/
default void onResendRequestSatisfied(SessionID sessionID, int beginSeqNo, int endSeqNo) {
}
+
+ /**
+ * Called when a received PossDupFlag=Y message is discarded before
+ * application processing because it failed sequence number or
+ * OrigSendingTime validation.
+ *
+ * The message is the full inbound message that was discarded. Listener
+ * implementations must treat it as read-only and must not mutate it.
+ *
+ * @param sessionID affected SessionID
+ * @param message discarded message
+ */
+ default void onPossDupMessageDiscarded(SessionID sessionID, Message message) {
+ }
}
diff --git a/quickfixj-core/src/test/java/quickfix/SessionTest.java b/quickfixj-core/src/test/java/quickfix/SessionTest.java
index dc70f958c0..deecaea7de 100644
--- a/quickfixj-core/src/test/java/quickfix/SessionTest.java
+++ b/quickfixj-core/src/test/java/quickfix/SessionTest.java
@@ -294,6 +294,105 @@ public void testPossDupMessageWithoutOrigSendingTime() throws Exception {
session.close();
}
+ @Test
+ public void testTooLowPossDupMessageDiscardNotifiesStateListener() throws Exception {
+ final UnitTestApplication application = new UnitTestApplication();
+ try (Session session = setUpSession(application, false,
+ new UnitTestResponder())) {
+ logonTo(session);
+ session.next(createAppMessage(2));
+
+ assertEquals(3, session.getExpectedTargetNum());
+ assertEquals(1, application.fromAppMessages.size());
+
+ session.addStateListener(new SessionStateListener() {
+ @Override
+ public void onMissedHeartBeat(SessionID sessionID) {
+ }
+ });
+ final SessionStateListener mockStateListener = mock(SessionStateListener.class);
+ session.addStateListener(mockStateListener);
+
+ final Message possDupMessage = createPossDupAppMessage(2);
+ session.next(possDupMessage);
+
+ assertEquals(3, session.getExpectedTargetNum());
+ assertEquals(1, application.fromAppMessages.size());
+
+ final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class);
+ verify(mockStateListener).onPossDupMessageDiscarded(session.getSessionID(),
+ messageCaptor.capture());
+ assertTrue(possDupMessage == messageCaptor.getValue());
+ verifyNoMoreInteractions(mockStateListener);
+ }
+ }
+
+ @Test
+ public void testExpectedSequencePossDupMessageDiscardNotifiesStateListener()
+ throws Exception {
+ final UnitTestApplication application = new UnitTestApplication();
+ try (Session session = setUpSession(application, false,
+ new UnitTestResponder())) {
+ logonTo(session);
+
+ final SessionStateListener mockStateListener = mock(SessionStateListener.class);
+ session.addStateListener(mockStateListener);
+
+ final Message possDupMessage = createAppMessage(2);
+ possDupMessage.getHeader().setBoolean(PossDupFlag.FIELD, true);
+ session.next(possDupMessage);
+
+ assertEquals(2, session.getExpectedTargetNum());
+ assertNull(application.lastFromAppMessage());
+ assertEquals(Reject.MSGTYPE, application.lastToAdminMessage()
+ .getHeader().getString(MsgType.FIELD));
+
+ final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class);
+ verify(mockStateListener).onPossDupMessageDiscarded(session.getSessionID(),
+ messageCaptor.capture());
+ assertTrue(possDupMessage == messageCaptor.getValue());
+ verifyNoMoreInteractions(mockStateListener);
+ }
+ }
+
+ @Test
+ public void testTooLowNonPossDupMessageDoesNotNotifyStateListener() throws Exception {
+ final UnitTestApplication application = new UnitTestApplication();
+ try (Session session = setUpSession(application, false,
+ new UnitTestResponder())) {
+ logonTo(session);
+ session.next(createAppMessage(2));
+
+ final SessionStateListener mockStateListener = mock(SessionStateListener.class);
+ session.addStateListener(mockStateListener);
+
+ processMessage(session, createAppMessage(1));
+
+ verify(mockStateListener, times(0)).onPossDupMessageDiscarded(
+ any(SessionID.class), any(Message.class));
+ }
+ }
+
+ @Test
+ public void testInSequenceMessageDoesNotNotifyPossDupDiscarded() throws Exception {
+ final UnitTestApplication application = new UnitTestApplication();
+ try (Session session = setUpSession(application, false,
+ new UnitTestResponder())) {
+ logonTo(session);
+
+ final SessionStateListener mockStateListener = mock(SessionStateListener.class);
+ session.addStateListener(mockStateListener);
+
+ session.next(createAppMessage(2));
+
+ assertEquals(3, session.getExpectedTargetNum());
+ assertEquals(1, application.fromAppMessages.size());
+ verify(mockStateListener, times(0)).onPossDupMessageDiscarded(
+ any(SessionID.class), any(Message.class));
+ verifyNoMoreInteractions(mockStateListener);
+ }
+ }
+
@Test
public void testInferResetSeqNumAcceptedWithNonInitialSequenceNumber()
throws Exception {
From b1ade67aa83c0fc09fc2f242595c96225c0ae622 Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Fri, 12 Jun 2026 03:24:37 -0700
Subject: [PATCH 2/4] fix: address self-review findings
---
quickfixj-core/src/test/java/quickfix/SessionTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/quickfixj-core/src/test/java/quickfix/SessionTest.java b/quickfixj-core/src/test/java/quickfix/SessionTest.java
index deecaea7de..7b16843bda 100644
--- a/quickfixj-core/src/test/java/quickfix/SessionTest.java
+++ b/quickfixj-core/src/test/java/quickfix/SessionTest.java
@@ -342,7 +342,7 @@ public void testExpectedSequencePossDupMessageDiscardNotifiesStateListener()
possDupMessage.getHeader().setBoolean(PossDupFlag.FIELD, true);
session.next(possDupMessage);
- assertEquals(2, session.getExpectedTargetNum());
+ assertEquals(3, session.getExpectedTargetNum());
assertNull(application.lastFromAppMessage());
assertEquals(Reject.MSGTYPE, application.lastToAdminMessage()
.getHeader().getString(MsgType.FIELD));
From edd22ad60a2f6a82655ceff0e414aa1ba06cc2aa Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Sat, 13 Jun 2026 06:53:33 -0700
Subject: [PATCH 3/4] test: fix invalid Mockito matcher use in PossDup discard
tests
Wrap the raw session.getSessionID() argument in eq() so it is not mixed
with the messageCaptor.capture() matcher in the same verify() call, which
raised InvalidUseOfMatchersException. Add the ArgumentMatchers.eq import.
---
quickfixj-core/src/test/java/quickfix/SessionTest.java | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/quickfixj-core/src/test/java/quickfix/SessionTest.java b/quickfixj-core/src/test/java/quickfix/SessionTest.java
index 7b16843bda..88368fa456 100644
--- a/quickfixj-core/src/test/java/quickfix/SessionTest.java
+++ b/quickfixj-core/src/test/java/quickfix/SessionTest.java
@@ -67,6 +67,7 @@
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import org.mockito.Mockito;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
@@ -320,7 +321,7 @@ public void onMissedHeartBeat(SessionID sessionID) {
assertEquals(1, application.fromAppMessages.size());
final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class);
- verify(mockStateListener).onPossDupMessageDiscarded(session.getSessionID(),
+ verify(mockStateListener).onPossDupMessageDiscarded(eq(session.getSessionID()),
messageCaptor.capture());
assertTrue(possDupMessage == messageCaptor.getValue());
verifyNoMoreInteractions(mockStateListener);
@@ -348,7 +349,7 @@ public void testExpectedSequencePossDupMessageDiscardNotifiesStateListener()
.getHeader().getString(MsgType.FIELD));
final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class);
- verify(mockStateListener).onPossDupMessageDiscarded(session.getSessionID(),
+ verify(mockStateListener).onPossDupMessageDiscarded(eq(session.getSessionID()),
messageCaptor.capture());
assertTrue(possDupMessage == messageCaptor.getValue());
verifyNoMoreInteractions(mockStateListener);
From edde0ed3e37415296bb276de25594fa7fce79cb5 Mon Sep 17 00:00:00 2001
From: Christoph John
Date: Fri, 3 Jul 2026 11:42:45 +0200
Subject: [PATCH 4/4] Refactor doTargetTooLow method and add state listener
call
Removed the return value of `doTargetTooLow()` since it is not used anywhere.
---
quickfixj-core/src/main/java/quickfix/Session.java | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/quickfixj-core/src/main/java/quickfix/Session.java b/quickfixj-core/src/main/java/quickfix/Session.java
index 542d13d060..9037913c5f 100644
--- a/quickfixj-core/src/main/java/quickfix/Session.java
+++ b/quickfixj-core/src/main/java/quickfix/Session.java
@@ -1834,6 +1834,7 @@ private boolean verify(Message msg, boolean checkTooHigh, boolean checkTooLow)
return false;
} else if (checkTooLow && isTargetTooLow(msgSeqNum)) {
doTargetTooLow(msg);
+ stateListener.onPossDupMessageDiscarded(sessionID, msg);
return false;
}
}
@@ -1874,7 +1875,7 @@ private boolean verify(Message msg, boolean checkTooHigh, boolean checkTooLow)
return true;
}
- private boolean doTargetTooLow(Message msg) throws FieldNotFound, IOException {
+ private void doTargetTooLow(Message msg) throws FieldNotFound, IOException {
if (!isPossibleDuplicate(msg)) {
final int msgSeqNum = msg.getHeader().getInt(MsgSeqNum.FIELD);
final String text = "MsgSeqNum too low, expecting " + getExpectedTargetNum()
@@ -1882,9 +1883,7 @@ private boolean doTargetTooLow(Message msg) throws FieldNotFound, IOException {
generateLogout(text);
throw new SessionException(text);
}
- final boolean validPossDup = validatePossDup(msg);
- stateListener.onPossDupMessageDiscarded(sessionID, msg);
- return validPossDup;
+ validatePossDup(msg);
}
private void doBadCompID(Message msg) throws IOException, FieldNotFound {