Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dba409a
Test: body field after nested repeating group
agmt Apr 22, 2026
e6591d9
parseGroup: stop misattributing body fields after nested groups
agmt Apr 22, 2026
3b4f830
fix(repeating_group): add Reset() method to support reusable instances
caiyi0616 Sep 4, 2026
35f546d
Fix potential deadlock in FieldMap.GetTime
smulube Sep 6, 2026
6946c92
Remove redundant buffer Reset
smulube Sep 6, 2026
cd08ce4
Merge pull request #776 from appital/gettime-potential-deadlock
michaelwilner Sep 8, 2026
0624034
Merge pull request #777 from appital/readmessage-reset
michaelwilner Sep 8, 2026
6790bb9
Revert "Fix duplicate reject reason value for ConditionallyRequiredFi…
michaelwilner Sep 14, 2026
cfa816c
Merge pull request #775 from caiyi0616/fix/repeating-group-reset
michaelwilner Sep 14, 2026
af62645
Merge pull request #778 from quickfixgo/revert-760-fix/conditionally-…
ackleymi Sep 14, 2026
2600e52
v0.9.11 release notes
ackleymi Sep 14, 2026
fbbb42e
fix(composite): forward OnEventf format arguments to wrapped logs
Robin1987China Sep 23, 2026
1f2fef3
Merge pull request #756 from agmt/body-field-after-nested-repeating-g…
michaelwilner Sep 23, 2026
c0b8db2
correct error message in SessionFactory
gbirchmeier Sep 24, 2026
56e2b02
Merge pull request #780 from gbirchmeier/session-factory-error-message
michaelwilner Sep 24, 2026
b6d0aa5
Merge pull request #779 from Robin1987China/fix/composite-oneventf-va…
ackleymi Sep 30, 2026
eff945d
v0.9.12 release notes
ackleymi Sep 30, 2026
25e51b0
Merge upstream v0.9.12 and harden parser input boundaries
SilkageNet Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
## Pinealctx 1.2.0 (October 23, 2026)

Synchronize upstream QuickFIX/Go v0.9.12 (`eff945dd`) while retaining the fork module path and tested parser boundary safeguards.

### FEATURES
* Add `RepeatingGroup.Reset()` for explicit reuse of group instances.

### BUG FIXES
* Avoid recursive read locking in `FieldMap.GetTime` when a writer is waiting.
* Forward variadic arguments correctly in the composite logger.
* Restore the upstream business reject reason for conditionally missing fields.
* Correct dictionary path diagnostics and remove a redundant parser buffer reset.
* Reject short/truncated messages, empty integer fields, and incomplete XML data without panicking.
* Include upstream nested-group regression coverage and preserve the fork's additional ancestor/sibling-group and malformed-input protections.

## Pinealctx 1.1.2 (October 22, 2026)

### BUG FIXES
Expand Down Expand Up @@ -31,6 +46,31 @@ tool, and unused conversion helpers.
### BUG FIXES
* Honor `ValidateFieldsHaveValues=N` when a data dictionary is enabled.
* Make the test suite portable across Windows filesystems, absent MongoDB integration settings, and equal clock ticks.
## 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
* 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)

Expand Down
13 changes: 13 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ Fork-specific changes should remain focused, documented, and covered by tests.
Business-specific generators, logging adapters, credentials, and application
wrappers belong in their consuming projects.

## Reviewed upstream baseline

The fork includes upstream v0.9.12, commit `eff945dd59a41caeaed20f71916f08d37e0fda99`.
Upstream PR #756 fixes body fields after nested groups, a defect inherited from
upstream commit `6889cf10` (May 2024). The fork also resolves the ancestor before
classifying the next field, resets the path when switching root groups, and
returns errors for malformed/truncated groups, short messages, empty integer
fields, and truncated XML payloads. These differences are covered by
`message_group_boundary_test.go`; the unmodified v0.9.12 parser fails its nested
sibling-group and truncated-input cases. The remaining parser differences do not
alter session sequencing, transport writes, FileStore persistence, or resend
scheduling.

## Synchronizing upstream

The upstream remote is named `github.com` in the maintainer checkout. Fetch it
Expand Down
2 changes: 1 addition & 1 deletion errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const (
rejectReasonUnsupportedMessageType = 3
rejectReasonTagSpecifiedWithoutAValue = 4
rejectReasonValueIsIncorrect = 5
rejectReasonConditionallyRequiredFieldMissing = 8
rejectReasonConditionallyRequiredFieldMissing = 5
rejectReasonIncorrectDataFormatForValue = 6
rejectReasonCompIDProblem = 9
rejectReasonSendingTimeAccuracyProblem = 10
Expand Down
8 changes: 1 addition & 7 deletions errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion field_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
45 changes: 45 additions & 0 deletions field_map_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ package quickfix

import (
"bytes"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -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")
}
}
2 changes: 1 addition & 1 deletion fix_int.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const (

// atoi is similar to the function in strconv, but is tuned for ints appearing in FIX field types.
func atoi(d []byte) (int, error) {
if d[0] == asciiMinus {
if len(d) > 0 && d[0] == asciiMinus {
n, err := parseUInt(d[1:])
return (-1) * n, err
}
Expand Down
6 changes: 6 additions & 0 deletions fix_int_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ func BenchmarkFIXInt_Read(b *testing.B) {
_ = field.Read(intBytes)
}
}

func TestFIXInt_ReadEmpty(t *testing.T) {
field := FIXInt(7)
assert.NotPanics(t, func() { assert.Error(t, field.Read(nil)) })
assert.Equal(t, FIXInt(7), field)
}
2 changes: 1 addition & 1 deletion log/composite/composite_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
}
}

Expand Down
56 changes: 56 additions & 0 deletions log/composite/composite_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
10 changes: 10 additions & 0 deletions message.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ func doParsing(mp *msgParser) (err error) {
if fieldCount == 0 {
return parseError{OrigError: fmt.Sprintf("No Fields detected in %s", string(mp.rawBytes))}
}
// A message needs the three leading header fields and a checksum.
if fieldCount < 4 {
return parseError{OrigError: "incomplete message header or trailer"}
}
if cap(mp.msg.fields) < fieldCount {
mp.msg.fields = make([]TagValue, fieldCount)
} else {
Expand Down Expand Up @@ -235,6 +239,9 @@ func doParsing(mp *msgParser) (err error) {
mp.foundBody = false
mp.foundTrailer = false
for {
if mp.fieldIndex >= len(mp.msg.fields) {
return parseError{OrigError: "unterminated message"}
}
mp.parsedFieldBytes = &mp.msg.fields[mp.fieldIndex]
if xmlDataLen > 0 {
mp.rawBytes, err = extractXMLDataField(mp.parsedFieldBytes, mp.rawBytes, xmlDataLen)
Expand Down Expand Up @@ -561,6 +568,9 @@ func extractXMLDataField(parsedFieldBytes *TagValue, buffer []byte, dataLen int)
remBytes = buffer
return
}
if dataLen < 0 || dataLen > len(buffer)-endIndex-2 {
return buffer, parseError{OrigError: "incomplete XML data field"}
}
endIndex += dataLen + 1

err = parsedFieldBytes.parse(buffer[:endIndex+1])
Expand Down
49 changes: 49 additions & 0 deletions message_group_boundary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,52 @@ func TestNestedGroupUnterminatedInputReturnsError(t *testing.T) {
})
}
}

func TestNestedGroupsResumeAtSiblingGroup(t *testing.T) {
dictionary, err := datadictionary.Parse("spec/FIX44.xml")
require.NoError(t, err)
for _, tc := range []struct{ name, prefix string }{
{"sibling child after nested child", ""},
{"new root after nested group", "555=1|600=leg|539=1|524=nested|804=1|545=leg-sub|"},
} {
t.Run(tc.name, func(t *testing.T) {
wire := groupBoundaryWire("AE", tc.prefix+"552=1|54=1|37=order|453=1|448=party|447=D|452=3|802=1|523=sub|576=1|577=0|17=execution|")
message := NewMessage()
require.NoError(t, ParseMessageWithDataDictionary(message, bytes.NewBuffer(wire), dictionary, dictionary))
sides := NewRepeatingGroup(552, GroupTemplate{GroupElement(54), GroupElement(37), NewRepeatingGroup(453, GroupTemplate{GroupElement(448), GroupElement(447), GroupElement(452), NewRepeatingGroup(802, GroupTemplate{GroupElement(523)})}), NewRepeatingGroup(576, GroupTemplate{GroupElement(577)})})
require.NoError(t, message.Body.GetGroup(sides))
require.Equal(t, 1, sides.Len())
clearing := NewRepeatingGroup(576, GroupTemplate{GroupElement(577)})
require.NoError(t, sides.Get(0).GetGroup(clearing))
require.Equal(t, 1, clearing.Len())
value, err := clearing.Get(0).GetString(577)
require.NoError(t, err)
require.Equal(t, "0", value)
require.False(t, message.Body.Has(577))
execution, err := message.Body.GetString(17)
require.NoError(t, err)
require.Equal(t, "execution", execution)
reparsed := NewMessage()
require.NoError(t, ParseMessageWithDataDictionary(reparsed, bytes.NewBuffer(message.build()), dictionary, dictionary))
sides.Reset()
require.NoError(t, reparsed.Body.GetGroup(sides))
require.Equal(t, 1, sides.Len())
})
}
}

func FuzzDictionaryGroupParsing(f *testing.F) {
dictionary, err := datadictionary.Parse("spec/FIX44.xml")
if err != nil {
f.Fatal(err)
}
f.Add([]byte("8=FIX.4.4\x019=20\x0135=n\x01212=999999\x01213=x\x0110=000\x01"))
f.Add(groupBoundaryWire("8", "453=1|448=party|447=D|452=3|802=1|523=sub|17=execution|"))
f.Add(groupBoundaryWire("AE", "555=1|600=leg|539=1|524=nested|804=1|545=sub|552=1|54=1|37=order|17=execution|"))
f.Fuzz(func(t *testing.T, raw []byte) {
if len(raw) > 65536 {
t.Skip()
}
_ = ParseMessageWithDataDictionary(NewMessage(), bytes.NewBuffer(raw), dictionary, dictionary)
})
}
39 changes: 39 additions & 0 deletions message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,45 @@ 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)))

// 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() {
s.msg.Header.SetField(tagBeginString, FIXString(BeginStringFIX44))
s.msg.Header.SetField(tagMsgType, FIXString("A"))
Expand Down
1 change: 0 additions & 1 deletion parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:]

Expand Down
Loading