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 {