Skip to content

Commit 01fdcfc

Browse files
fix: keep SDS causalHistory wire-compatible with pre-SDS-R nodes (#86)
1 parent 3aab2fb commit 01fdcfc

5 files changed

Lines changed: 136 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ jobs:
145145

146146
- uses: maxim-lobanov/setup-xcode@v1
147147
with:
148-
xcode-version: '16.2'
148+
# 'latest-stable' tracks whatever the macos-latest runner ships, so
149+
# this survives Xcode rotations (the runner dropped 16.2 for 26.x).
150+
xcode-version: 'latest-stable'
149151

150152
- uses: jiro4989/setup-nim-action@v2
151153
with:

library/libsds.nim

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@
1313
import std/[base64, json]
1414
import ffi
1515
import sds
16-
import ./events/[
17-
json_message_ready_event, json_message_sent_event, json_missing_dependencies_event,
18-
json_periodic_sync_event, json_repair_ready_event,
19-
]
16+
import
17+
./events/[
18+
json_message_ready_event, json_message_sent_event, json_missing_dependencies_event,
19+
json_periodic_sync_event, json_repair_ready_event,
20+
]
21+
22+
# Frees memory the retrieval-hint provider allocated with malloc (the Go
23+
# binding's C.CBytes). Must match that allocator — Nim's deallocShared here
24+
# corrupts Nim's own heap.
25+
proc c_free(p: pointer) {.importc: "free", header: "<stdlib.h>".}
2026

2127
# Bootstrap (pragmas, linker flags, libsdsNimMain, initializeLibrary) plus the
2228
# `sds_set_event_callback(ctx, callback, userData)` C export. It also declares
@@ -114,7 +120,10 @@ proc sdsCreate*(
114120
if not hint.isNil() and hintLen > 0:
115121
var hintBytes = newSeq[byte](hintLen)
116122
copyMem(addr hintBytes[0], hint, hintLen)
117-
deallocShared(hint)
123+
# The provider allocates `hint` with malloc (the Go binding's C.CBytes)
124+
# and hands us ownership; free it with libc free. Nim's deallocShared here
125+
# corrupts Nim's allocator (SIGSEGV in deallocBigChunk/avltree).
126+
c_free(hint)
118127
return hintBytes
119128
return @[]
120129

@@ -219,9 +228,7 @@ proc sds_set_retrieval_hint_provider(
219228
try:
220229
ffi_context.sendRequestToFFIThread(
221230
ctx,
222-
SdsSetHintReq.ffiNewReq(
223-
sdsNoopCallback, nil, cast[pointer](callback), userData
224-
),
231+
SdsSetHintReq.ffiNewReq(sdsNoopCallback, nil, cast[pointer](callback), userData),
225232
)
226233
except Exception as exc:
227234
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)

sds.nimble

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ task test, "Run the test suite":
6969
exec "nim c -r --outdir:build tests/test_reliability.nim"
7070
exec "nim c -r --outdir:build tests/test_persistence.nim"
7171
exec "nim c -r --outdir:build tests/test_snapshot_codec.nim"
72+
exec "nim c -r --outdir:build tests/test_wire_compat.nim"
7273

7374
task libsdsDynamicWindows, "Generate bindings":
7475
let outLibNameAndExt = "libsds.dll"

sds/protobuf.nim

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
{.push raises: [].}
1717

18+
import std/tables
1819
import endians
1920
import protobuf_serialization
2021
import protobuf_serialization/pkg/results
@@ -34,11 +35,17 @@ type
3435
SdsMessagePB* {.proto3.} = object
3536
messageId* {.fieldNumber: 1.}: Opt[seq[byte]]
3637
lamportTimestamp* {.fieldNumber: 2, pint.}: Opt[int64]
37-
causalHistory* {.fieldNumber: 3.}: seq[HistoryEntryPB]
38+
# Field 3 is a repeated string/bytes of message IDs, wire-compatible with
39+
# pre-SDS-R nodes (v0.2.x/v0.3.x). Richer per-entry SDS-R metadata
40+
# (senderId, retrievalHint) rides in the additive field 8, keyed by message
41+
# ID; older nodes skip it as an unknown field while still reading the causal
42+
# history from field 3.
43+
causalHistory* {.fieldNumber: 3.}: seq[seq[byte]]
3844
channelId* {.fieldNumber: 4.}: Opt[seq[byte]]
3945
content* {.fieldNumber: 5.}: Opt[seq[byte]]
4046
bloomFilter* {.fieldNumber: 6.}: Opt[seq[byte]]
4147
senderId* {.fieldNumber: 7.}: Opt[seq[byte]]
48+
causalHistoryMeta* {.fieldNumber: 8.}: seq[HistoryEntryPB]
4249
repairRequest* {.fieldNumber: 13.}: seq[HistoryEntryPB]
4350

4451
BloomFilterPB {.proto3.} = object
@@ -98,15 +105,26 @@ func toPB*(m: SdsMessage): SdsMessagePB =
98105
senderId: optBytes(m.senderId.string.toBytes),
99106
)
100107
for e in m.causalHistory:
101-
pb.causalHistory.add(e.toPB)
108+
pb.causalHistory.add(e.messageId.toBytes)
109+
if e.retrievalHint.len > 0 or e.senderId.len > 0:
110+
pb.causalHistoryMeta.add(e.toPB)
102111
for e in m.repairRequest:
103112
pb.repairRequest.add(e.toPB)
104113
return pb
105114

106115
func fromPB*(pb: SdsMessagePB): SdsMessage =
116+
# Rebuild causal history from field 3 (authoritative ID list) and enrich each
117+
# entry with the additive field-8 metadata (senderId/retrievalHint) keyed by
118+
# message ID. Messages from pre-SDS-R nodes carry no field 8, so entries
119+
# decode to bare IDs.
120+
var meta = initTable[SdsMessageID, HistoryEntry]()
121+
for e in pb.causalHistoryMeta:
122+
let he = e.fromPB
123+
meta[he.messageId] = he
107124
var causal: seq[HistoryEntry]
108-
for e in pb.causalHistory:
109-
causal.add(e.fromPB)
125+
for idBytes in pb.causalHistory:
126+
let id = idBytes.toStr
127+
causal.add(meta.getOrDefault(id, HistoryEntry.init(id)))
110128
var repair: seq[HistoryEntry]
111129
for e in pb.repairRequest:
112130
repair.add(e.fromPB)

tests/test_wire_compat.nim

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
## Verifies the SDS message wire stays backward-compatible with pre-SDS-R nodes
2+
## (v0.2.x/v0.3.x): causalHistory is a repeated string of message IDs in field 3,
3+
## and the richer per-entry metadata (senderId, retrievalHint) rides in the
4+
## additive field 8, which older nodes skip.
5+
6+
import std/unittest
7+
import protobuf_serialization
8+
import protobuf_serialization/pkg/results
9+
import ../sds/protobuf
10+
import ../sds/types/[sds_message, history_entry, sds_message_id]
11+
12+
converter toParticipantID(s: string): SdsParticipantID =
13+
s.SdsParticipantID
14+
15+
func toStr(b: seq[byte]): string =
16+
result = newString(b.len)
17+
if b.len > 0:
18+
copyMem(addr result[0], unsafeAddr b[0], b.len)
19+
20+
func toBytes(s: string): seq[byte] =
21+
result = newSeq[byte](s.len)
22+
if s.len > 0:
23+
copyMem(addr result[0], unsafeAddr s[0], s.len)
24+
25+
# How a pre-SDS-R node sees the message: field 3 is a repeated string of message
26+
# IDs; it knows nothing of the additive field 8.
27+
type OldSdsMessagePB {.proto3.} = object
28+
messageId {.fieldNumber: 1.}: Opt[seq[byte]]
29+
causalHistory {.fieldNumber: 3.}: seq[seq[byte]]
30+
channelId {.fieldNumber: 4.}: Opt[seq[byte]]
31+
content {.fieldNumber: 5.}: Opt[seq[byte]]
32+
33+
suite "SDS v0.4 wire backward-compatibility":
34+
test "old node reads causal history IDs from a v0.4-encoded message":
35+
let msg = SdsMessage.init(
36+
messageId = "0xdeadbeef",
37+
lamportTimestamp = 7,
38+
causalHistory = @[
39+
HistoryEntry.init("0xaaa", @[byte 1, 2, 3], "0xsender".SdsParticipantID),
40+
HistoryEntry.init("0xbbb"),
41+
],
42+
channelId = "0xchannel",
43+
content = @[byte 9, 9, 9],
44+
bloomFilter = @[],
45+
senderId = "0xmsgsender".SdsParticipantID,
46+
repairRequest = @[],
47+
)
48+
49+
let wire = serializeMessage(msg).get()
50+
51+
# An old node recovers the EXACT message IDs from field 3 and never sees the
52+
# senderId/hint that ride in field 8.
53+
let old = Protobuf.decode(wire, OldSdsMessagePB)
54+
check old.causalHistory.len == 2
55+
check old.causalHistory[0].toStr == "0xaaa"
56+
check old.causalHistory[1].toStr == "0xbbb"
57+
check old.channelId.get(@[]).toStr == "0xchannel"
58+
check old.content.get(@[]) == @[byte 9, 9, 9]
59+
60+
test "v0.4 round-trips message IDs, hints and senderIds":
61+
let msg = SdsMessage.init(
62+
messageId = "0xfeed",
63+
lamportTimestamp = 3,
64+
causalHistory = @[
65+
HistoryEntry.init("0xaaa", @[byte 1, 2, 3], "0xsender".SdsParticipantID),
66+
HistoryEntry.init("0xbbb"),
67+
],
68+
channelId = "0xchannel",
69+
content = @[byte 1],
70+
bloomFilter = @[],
71+
)
72+
73+
let decoded = deserializeMessage(serializeMessage(msg).get()).get()
74+
check decoded.causalHistory.len == 2
75+
check decoded.causalHistory[0].messageId == "0xaaa"
76+
check decoded.causalHistory[0].retrievalHint == @[byte 1, 2, 3]
77+
check decoded.causalHistory[0].senderId.string == "0xsender"
78+
check decoded.causalHistory[1].messageId == "0xbbb"
79+
check decoded.causalHistory[1].retrievalHint.len == 0
80+
check decoded.causalHistory[1].senderId.string.len == 0
81+
82+
test "v0.4 decodes a legacy message (field 3 IDs only, no field 8)":
83+
# Build a message the way a pre-SDS-R node would: field 3 = bare ID strings.
84+
let legacy = OldSdsMessagePB(
85+
messageId: Opt.some("0xlegacy".toBytes),
86+
causalHistory: @["0xaaa".toBytes, "0xbbb".toBytes],
87+
channelId: Opt.some("0xchannel".toBytes),
88+
content: Opt.some(@[byte 5]),
89+
)
90+
let decoded = deserializeMessage(Protobuf.encode(legacy)).get()
91+
check decoded.causalHistory.len == 2
92+
check decoded.causalHistory[0].messageId == "0xaaa"
93+
check decoded.causalHistory[0].retrievalHint.len == 0
94+
check decoded.causalHistory[0].senderId.string.len == 0
95+
check decoded.causalHistory[1].messageId == "0xbbb"

0 commit comments

Comments
 (0)