From dba409a69956b20a81f9aaea61a7f90551eb6859 Mon Sep 17 00:00:00 2001 From: Egor Seredin <4819888+agmt@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:37:43 +0000 Subject: [PATCH 01/10] Test: body field after nested repeating group --- message_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/message_test.go b/message_test.go index a51c5231e..2037b69cd 100644 --- a/message_test.go +++ b/message_test.go @@ -113,6 +113,48 @@ func (s *MessageSuite) TestParseOutOfOrder() { s.Nil(ParseMessage(s.msg, rawMsg)) } +func (s *MessageSuite) TestParseGroup_BodyFieldAfterNestedGroup() { + dict, dictErr := datadictionary.Parse("spec/FIX44.xml") + s.Nil(dictErr) + + // Wire layout (FIX 4.4 MassQuoteAcknowledgement): + // 117=QID QuoteID (body) + // 296=1 NoQuoteSets count (body group) + // 302=SET1 QuoteSetID (inside NoQuoteSets) + // 295=1 NoQuoteEntries count (nested group) + // 299=E1 QuoteEntryID (inside NoQuoteEntries) + // 132=100 BidPx (inside NoQuoteEntries) + // 133=101 OfferPx (inside NoQuoteEntries) + // 297=0 QuoteStatus (BODY level, AFTER the group) + rawMsg := bytes.NewBufferString( + "8=FIX.4.49=6335=b117=QID" + + "296=1302=SET1" + + "295=1299=E1132=100133=101" + + "297=1" + + "10=002") + + err := ParseMessageWithDataDictionary(s.msg, rawMsg, dict, dict) + s.Nil(err) + + rebuildBytes := s.msg.build() + expectedBytes := rawMsg.Bytes() + s.True(bytes.Equal(expectedBytes, rebuildBytes), "Unexpected bytes,\n +%s\n -%s", rebuildBytes, expectedBytes) + + // NoQuoteSets count (group delimiter) lands in Body as expected. + s.True(s.msg.Body.Has(Tag(296))) + + // ToDo: should be correct — QuoteStatus (297) must be a top-level body + // field per FIX 4.4. Current parseGroup logic silently absorbs 297 into + // the NoQuoteSets group buffer because it only checks whether the + // parent tag is a NumInGroup, not whether 297 is a member of the + // parent group template. + s.False(s.msg.Body.Has(Tag(297)), + "BUG: QuoteStatus (297) is absent from Body.FieldMap — parser "+ + "mis-attributed it to the parent NoQuoteSets group. This "+ + "assertion documents the bug; flip to s.True(...) when "+ + "parseGroup is fixed.") +} + func (s *MessageSuite) TestBuild() { s.msg.Header.SetField(tagBeginString, FIXString(BeginStringFIX44)) s.msg.Header.SetField(tagMsgType, FIXString("A")) From e6591d95e02b97d9eafa2eff89f3ff087c7198f4 Mon Sep 17 00:00:00 2001 From: Egor Seredin <4819888+agmt@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:41:27 +0000 Subject: [PATCH 02/10] parseGroup: stop misattributing body fields after nested groups --- message.go | 20 ++++++++++---------- message_test.go | 17 +++++++---------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/message.go b/message.go index 69407cf5d..1f9604339 100644 --- a/message.go +++ b/message.go @@ -310,6 +310,7 @@ func parseGroup(mp *msgParser, tags []Tag) { dm := mp.msg.fields[mp.fieldIndex : mp.fieldIndex+1] fields := getGroupFields(mp.msg, tags, mp.appDataDictionary) +parseLoop: for { mp.fieldIndex++ mp.parsedFieldBytes = &mp.msg.fields[mp.fieldIndex] @@ -355,16 +356,15 @@ func parseGroup(mp *msgParser, tags []Tag) { fields = getGroupFields(mp.msg, searchTags, mp.appDataDictionary) continue } - if len(tags) > 1 { - searchTags = tags[:len(tags)-1] - } - // Did this tag occur after a nested group and belongs to the parent group. - if isNumInGroupField(mp.msg, searchTags, mp.appDataDictionary) { - // Add the field member to the group. - dm = append(dm, *mp.parsedFieldBytes) - // Continue parsing the parent group. - fields = getGroupFields(mp.msg, searchTags, mp.appDataDictionary) - continue + // The tag isn't a member of the current group. Walk up: if an + // ancestor group includes it, resume there; otherwise it's body-level. + for len(tags) > 1 { + tags = tags[:len(tags)-1] + fields = getGroupFields(mp.msg, tags, mp.appDataDictionary) + if isGroupMember(mp.parsedFieldBytes.tag, fields) { + dm = append(dm, *mp.parsedFieldBytes) + continue parseLoop + } } // Add the repeating group. mp.msg.Body.add(dm) diff --git a/message_test.go b/message_test.go index 2037b69cd..84bade7ed 100644 --- a/message_test.go +++ b/message_test.go @@ -143,16 +143,13 @@ func (s *MessageSuite) TestParseGroup_BodyFieldAfterNestedGroup() { // NoQuoteSets count (group delimiter) lands in Body as expected. s.True(s.msg.Body.Has(Tag(296))) - // ToDo: should be correct — QuoteStatus (297) must be a top-level body - // field per FIX 4.4. Current parseGroup logic silently absorbs 297 into - // the NoQuoteSets group buffer because it only checks whether the - // parent tag is a NumInGroup, not whether 297 is a member of the - // parent group template. - s.False(s.msg.Body.Has(Tag(297)), - "BUG: QuoteStatus (297) is absent from Body.FieldMap — parser "+ - "mis-attributed it to the parent NoQuoteSets group. This "+ - "assertion documents the bug; flip to s.True(...) when "+ - "parseGroup is fixed.") + // QuoteStatus (297) must be a top-level body field per FIX 4.4. + s.True(s.msg.Body.Has(Tag(297)), + "QuoteStatus (297) must land at the body level; it is a body field "+ + "in MassQuoteAcknowledgement, not a member of NoQuoteSets.") + val, verr := s.msg.Body.GetInt(Tag(297)) + s.Nil(verr) + s.Equal(1, val) } func (s *MessageSuite) TestBuild() { From 3b4f830ff13b6f1022e638a50d5abfb1fc35821d Mon Sep 17 00:00:00 2001 From: caiyi0616 <2056014003@qq.com> Date: Fri, 4 Sep 2026 16:38:13 +0800 Subject: [PATCH 03/10] fix(repeating_group): add Reset() method to support reusable instances Implement RepeatingGroup.Reset() to clear all groups without reallocating the template or struct. This addresses issue #758, where repeated message parsing required new RepeatingGroup instances for each parse, causing unnecessary allocations in high-throughput FIX message processing scenarios. Changes: - Add RepeatingGroup.Reset() that clears groups slice (keeps capacity) - Add test_repeating_group_reset.go covering Reset() behavior and multi-cycle reuse without reallocation --- repeating_group.go | 7 +++++ repeating_group_test.go | 67 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/repeating_group.go b/repeating_group.go index 853cc7d35..b2dc39809 100644 --- a/repeating_group.go +++ b/repeating_group.go @@ -121,6 +121,13 @@ func (f *RepeatingGroup) Add() *Group { return g } +// Reset clears all groups from this RepeatingGroup, allowing the instance to be +// reused for a new message parse without reallocating the template and struct. +// This avoids the allocation overhead of calling NewRepeatingGroup for each parse. +func (f *RepeatingGroup) Reset() { + f.groups = f.groups[:0] +} + // Write returns tagValues for all Items in the repeating group ordered by // Group sequence and Group template order. func (f RepeatingGroup) Write() []TagValue { diff --git a/repeating_group_test.go b/repeating_group_test.go index abd316d78..5248acb24 100644 --- a/repeating_group_test.go +++ b/repeating_group_test.go @@ -17,6 +17,7 @@ package quickfix import ( "bytes" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -291,3 +292,69 @@ func TestRepeatingGroup_ReadComplete(t *testing.T) { } } } + +func TestRepeatingGroup_Reset(t *testing.T) { + // Issue #758: RepeatingGroup should support Reset() to avoid reallocation + template := GroupTemplate{GroupElement(1), GroupElement(2)} + rg := NewRepeatingGroup(Tag(100), template) + + // Add two groups + g1 := rg.Add() + g1.SetField(Tag(1), FIXString("hello")) + g1.SetField(Tag(2), FIXString("world")) + + g2 := rg.Add() + g2.SetField(Tag(1), FIXString("foo")) + g2.SetField(Tag(2), FIXString("bar")) + + require.Equal(t, 2, rg.Len()) + + // Reset and reuse + rg.Reset() + require.Equal(t, 0, rg.Len()) + + // Add new groups after reset + g3 := rg.Add() + g3.SetField(Tag(1), FIXString("reused")) + g3.SetField(Tag(2), FIXString("instance")) + + require.Equal(t, 1, rg.Len()) + + // Verify the new group has correct data + var v1, v2 FIXString + require.Nil(t, rg.groups[0].GetField(Tag(1), &v1)) + require.Nil(t, rg.groups[0].GetField(Tag(2), &v2)) + require.Equal(t, "reused", string(v1)) + require.Equal(t, "instance", string(v2)) + + // Tag and template should be unchanged + require.Equal(t, Tag(100), rg.Tag()) + require.Equal(t, 2, len(rg.template)) +} + +func TestRepeatingGroup_ResetMultipleCycles(t *testing.T) { + template := GroupTemplate{GroupElement(1)} + rg := NewRepeatingGroup(Tag(50), template) + + // Simulate multiple message parses without reallocation + for cycle := 0; cycle < 3; cycle++ { + numGroups := cycle + 1 + for i := 0; i < numGroups; i++ { + g := rg.Add() + g.SetField(Tag(1), FIXString(fmt.Sprintf("cycle-%d-group-%d", cycle, i))) + } + + require.Equal(t, numGroups, rg.Len()) + + // Verify data + for i := 0; i < numGroups; i++ { + var v FIXString + require.Nil(t, rg.groups[i].GetField(Tag(1), &v)) + require.Equal(t, fmt.Sprintf("cycle-%d-group-%d", cycle, i), string(v)) + } + + // Reset for next cycle + rg.Reset() + require.Equal(t, 0, rg.Len()) + } +} From 35f546d4cbe53c94e2c688751aa51191c618c4ce Mon Sep 17 00:00:00 2001 From: Sam Mulube Date: Mon, 7 Sep 2026 00:24:28 +0100 Subject: [PATCH 04/10] Fix potential deadlock in FieldMap.GetTime In FieldMap.GetTime the code took an RLock(), then called GetBytes() which attempted to RLock the mutex again. This could lead to potential deadlock so here we swap out that call for the existing getBytesNoLock method which appears to be exactly for this purpose. --- field_map.go | 2 +- field_map_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/field_map.go b/field_map.go index 4aac64b1d..d982cdb3b 100644 --- a/field_map.go +++ b/field_map.go @@ -196,7 +196,7 @@ func (m FieldMap) GetTime(tag Tag) (t time.Time, err MessageRejectError) { m.rwLock.RLock() defer m.rwLock.RUnlock() - bytes, err := m.GetBytes(tag) + bytes, err := m.getBytesNoLock(tag) if err != nil { return } diff --git a/field_map_test.go b/field_map_test.go index 0e9078734..ac58342a4 100644 --- a/field_map_test.go +++ b/field_map_test.go @@ -17,7 +17,9 @@ package quickfix import ( "bytes" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -202,3 +204,46 @@ func TestFieldMap_Remove(t *testing.T) { assert.False(t, fMap.Has(1)) assert.True(t, fMap.Has(2)) } + +// TestFieldMap_GetTimeConcurrentWithSet verifies the fix guarding against +// recursive read-locking in GetTime. +func TestFieldMap_GetTimeConcurrentWithSet(t *testing.T) { + var fMap FieldMap + fMap.init() + fMap.SetField(Tag(1), FIXUTCTimestamp{Time: time.Now()}) + fMap.SetField(Tag(2), FIXString("blah")) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50000; i++ { + fMap.SetField(Tag(2), FIXString("blah")) + } + }() + + for r := 0; r < 8; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50000; i++ { + if _, err := fMap.GetTime(Tag(1)); err != nil { + t.Error(err) + return + } + } + }() + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(60 * time.Second): + t.Fatal("deadlock: GetTime did not complete concurrently with writers") + } +} From 6946c927d26c1885fea64ef52a480b51f4111f00 Mon Sep 17 00:00:00 2001 From: Sam Mulube Date: Mon, 7 Sep 2026 00:39:51 +0100 Subject: [PATCH 05/10] Remove redundant buffer Reset Remove a call to Reset a bytes.Buffer immediately after it is initialised. --- parser.go | 1 - 1 file changed, 1 deletion(-) diff --git a/parser.go b/parser.go index a3ec73993..38ac19ddc 100644 --- a/parser.go +++ b/parser.go @@ -157,7 +157,6 @@ func (p *parser) ReadMessage() (msgBytes *bytes.Buffer, err error) { } msgBytes = new(bytes.Buffer) - msgBytes.Reset() msgBytes.Write(p.buffer[:index]) p.buffer = p.buffer[index:] From 6790bb961c2cbb97995d7e271bfcfe9d8e85e8c0 Mon Sep 17 00:00:00 2001 From: Michael Wilner Date: Mon, 14 Sep 2026 10:16:05 -0500 Subject: [PATCH 06/10] Revert "Fix duplicate reject reason value for ConditionallyRequiredFieldMissing" --- errors.go | 2 +- errors_test.go | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/errors.go b/errors.go index 34f1224ff..e9d120991 100644 --- a/errors.go +++ b/errors.go @@ -31,7 +31,7 @@ const ( rejectReasonUnsupportedMessageType = 3 rejectReasonTagSpecifiedWithoutAValue = 4 rejectReasonValueIsIncorrect = 5 - rejectReasonConditionallyRequiredFieldMissing = 8 + rejectReasonConditionallyRequiredFieldMissing = 5 rejectReasonIncorrectDataFormatForValue = 6 rejectReasonCompIDProblem = 9 rejectReasonSendingTimeAccuracyProblem = 10 diff --git a/errors_test.go b/errors_test.go index ac0eaca4a..c5b77d9b6 100644 --- a/errors_test.go +++ b/errors_test.go @@ -141,9 +141,7 @@ func TestValueIsIncorrect(t *testing.T) { func TestConditionallyRequiredFieldMissing(t *testing.T) { var ( - // Per FIX spec (tag 373/380 enum), ConditionallyRequiredFieldMissing - // is value 8, distinct from ValueIsIncorrect (value 5). See issue #721. - expectedRejectReason = 8 + expectedRejectReason = 5 expectedRefTagID Tag = 44 expectedErrorString = fmt.Sprintf("Conditionally Required Field Missing (%d)", expectedRefTagID) expectedIsBusinessReject = true @@ -161,10 +159,6 @@ func TestConditionallyRequiredFieldMissing(t *testing.T) { if msgRej.IsBusinessReject() != expectedIsBusinessReject { t.Error("Expected IsBusinessReject to be true\n") } - // Regression guard: the two reject reasons must not collide. - if msgRej.RejectReason() == ValueIsIncorrect(expectedRefTagID).RejectReason() { - t.Errorf("ConditionallyRequiredFieldMissing and ValueIsIncorrect must not share a reject reason value") - } } func TestInvalidMessageType(t *testing.T) { From 2600e522adb02eeae5952e98929dce746a85c798 Mon Sep 17 00:00:00 2001 From: Michael Ackley Date: Mon, 14 Sep 2026 15:20:06 -0500 Subject: [PATCH 07/10] v0.9.11 release notes --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb801918..ec7211fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## 0.9.11 (September 14, 2026) + +### FEATURES +* Adds `SetNewListenerCallback` to the Acceptor to allow custom `net.Listener` creation [#729](https://github.com/quickfixgo/quickfix/pull/729) +* Adds config option for ValidateFieldsHaveValues [#734](https://github.com/quickfixgo/quickfix/pull/734) +* Adds `Reset()` method to RepeatingGroup [#775](https://github.com/quickfixgo/quickfix/pull/775) + +### ENHANCEMENTS +* Make both initiator and acceptor fixIn channels buffered [#736](https://github.com/quickfixgo/quickfix/pull/736) +* Update devcontainer Go image to 1.25 [#748](https://github.com/quickfixgo/quickfix/pull/748) +* Remove redundant buffer Reset when reading messages [#777](https://github.com/quickfixgo/quickfix/pull/777) + +### BUG FIXES +* Resent messages with repeating groups on sessions without a data dictionary no longer produce an incorrect length and checksum [#743](https://github.com/quickfixgo/quickfix/pull/743) +* Validate each token of multi-value enum fields [#766](https://github.com/quickfixgo/quickfix/pull/766) +* Fix duplicate 10= checksum when resending a message ending in a repeating group [#767](https://github.com/quickfixgo/quickfix/pull/767) +* Resend Requests should not interleave with live traffic [#768](https://github.com/quickfixgo/quickfix/pull/768) +* Fix potential deadlock in FieldMap.GetTime [#776](https://github.com/quickfixgo/quickfix/pull/776) + ## 0.9.10 (August 8, 2025) ### BUG FIXES From fbbb42eab5e69a739f0639df543841b61dc85876 Mon Sep 17 00:00:00 2001 From: Robin1987China <41602358+Robin1987China@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:22:22 +0800 Subject: [PATCH 08/10] fix(composite): forward OnEventf format arguments to wrapped logs compositeLog.OnEventf passed the variadic arguments as a single []interface{} argument, so every wrapped log rendered the format verbs against one slice value, producing output such as "session [FIX.4.4 %!s(int=7)] seq %!d(MISSING)" instead of "session FIX.4.4 seq 7". --- log/composite/composite_log.go | 2 +- log/composite/composite_log_test.go | 56 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/log/composite/composite_log.go b/log/composite/composite_log.go index 8de3b85e2..549c38eb4 100644 --- a/log/composite/composite_log.go +++ b/log/composite/composite_log.go @@ -43,7 +43,7 @@ func (l compositeLog) OnEvent(s string) { func (l compositeLog) OnEventf(format string, a ...interface{}) { for _, log := range l.logs { - log.OnEventf(format, a) + log.OnEventf(format, a...) } } diff --git a/log/composite/composite_log_test.go b/log/composite/composite_log_test.go index 690b10a44..894d1a44a 100644 --- a/log/composite/composite_log_test.go +++ b/log/composite/composite_log_test.go @@ -107,3 +107,59 @@ func (suite *CompositeLogTestSuite) TearDownTest() { func TestCompositeLogTestSuite(t *testing.T) { suite.Run(t, new(CompositeLogTestSuite)) } + +// recordingLog records the events it is handed so a test can assert on what the +// composite log forwarded to its wrapped logs. +type recordingLog struct { + events []string +} + +func (r *recordingLog) OnIncoming([]byte) {} + +func (r *recordingLog) OnOutgoing([]byte) {} + +func (r *recordingLog) OnEvent(s string) { r.events = append(r.events, s) } + +func (r *recordingLog) OnEventf(format string, a ...interface{}) { + r.events = append(r.events, fmt.Sprintf(format, a...)) +} + +// recordingLogFactory hands out one pre-built recordingLog. +type recordingLogFactory struct { + log *recordingLog +} + +func (f recordingLogFactory) Create() (quickfix.Log, error) { return f.log, nil } + +func (f recordingLogFactory) CreateSessionLog(quickfix.SessionID) (quickfix.Log, error) { + return f.log, nil +} + +func TestCompositeLogOnEventfExpandsFormatArgs(t *testing.T) { + tests := []struct { + name string + format string + args []interface{} + expected string + }{ + {"single string arg", "session %s", []interface{}{"FIX.4.4"}, "session FIX.4.4"}, + {"mixed args", "session %s seq %d", []interface{}{"FIX.4.4", 7}, "session FIX.4.4 seq 7"}, + {"slice arg", "tags %v count %d", []interface{}{[]int{7, 9}, 2}, "tags [7 9] count 2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + first, second := &recordingLog{}, &recordingLog{} + l, err := NewLogFactory([]quickfix.LogFactory{ + recordingLogFactory{first}, + recordingLogFactory{second}, + }).Create() + require.NoError(t, err) + + l.OnEventf(tt.format, tt.args...) + + require.Equal(t, []string{tt.expected}, first.events) + require.Equal(t, []string{tt.expected}, second.events) + }) + } +} From c0b8db2dd9d77d23913767ca9a0b75bf68a19c8a Mon Sep 17 00:00:00 2001 From: Grant Birchmeier Date: Thu, 24 Sep 2026 12:44:03 -0500 Subject: [PATCH 09/10] correct error message in SessionFactory --- session_factory.go | 12 ++++++------ session_factory_test.go | 8 ++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/session_factory.go b/session_factory.go index 1b79f5ee1..60829ee42 100644 --- a/session_factory.go +++ b/session_factory.go @@ -147,16 +147,16 @@ func (f sessionFactory) newSession( if s.transportDataDictionary, err = datadictionary.Parse(transportDataDictionaryPath); err != nil { err = errors.Wrapf( - err, "problem parsing XML datadictionary path '%v' for setting '%v", - settings.settings[config.TransportDataDictionary], config.TransportDataDictionary, + err, "problem parsing XML datadictionary path '%v' for setting '%v'", + string(settings.settings[config.TransportDataDictionary]), config.TransportDataDictionary, ) return } if s.appDataDictionary, err = datadictionary.Parse(appDataDictionaryPath); err != nil { err = errors.Wrapf( - err, "problem parsing XML datadictionary path '%v' for setting '%v", - settings.settings[config.AppDataDictionary], config.AppDataDictionary, + err, "problem parsing XML datadictionary path '%v' for setting '%v'", + string(settings.settings[config.AppDataDictionary]), config.AppDataDictionary, ) return } @@ -171,8 +171,8 @@ func (f sessionFactory) newSession( if s.appDataDictionary, err = datadictionary.Parse(dataDictionaryPath); err != nil { err = errors.Wrapf( - err, "problem parsing XML datadictionary path '%v' for setting '%v", - settings.settings[config.DataDictionary], config.DataDictionary, + err, "problem parsing XML datadictionary path '%v' for setting '%v'", + string(settings.settings[config.DataDictionary]), config.DataDictionary, ) return } diff --git a/session_factory_test.go b/session_factory_test.go index 349e7580c..63bf18ae5 100644 --- a/session_factory_test.go +++ b/session_factory_test.go @@ -744,3 +744,11 @@ func (s *SessionFactorySuite) TestPersistMessages() { s.Equal(test.expected, session.DisableMessagePersist) } } + +func (s *SessionFactorySuite) TestBadDictionaryPath() { + s.SetupTest() + s.SessionSettings.Set(config.DataDictionary, "file/does/not/exist.xml") + _, err := s.newSession(s.SessionID, s.MessageStoreFactory, s.SessionSettings, s.LogFactory, s.App) + s.NotNil(err) + s.Contains(err.Error(), "problem parsing XML datadictionary path 'file/does/not/exist.xml' for setting 'DataDictionary':") +} From eff945dd59a41caeaed20f71916f08d37e0fda99 Mon Sep 17 00:00:00 2001 From: Michael Ackley Date: Wed, 30 Sep 2026 10:46:00 -0500 Subject: [PATCH 10/10] v0.9.12 release notes --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7211fda..9e3180476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.9.12 (September 30, 2026) + +### BUG FIXES +* Stop misattributing body fields that follow a nested repeating group [#756](https://github.com/quickfixgo/quickfix/pull/756) +* Forward OnEventf format arguments to the wrapped logs in the composite log [#779](https://github.com/quickfixgo/quickfix/pull/779) +* Correct the datadictionary parse error message in SessionFactory [#780](https://github.com/quickfixgo/quickfix/pull/780) + ## 0.9.11 (September 14, 2026) ### FEATURES