diff --git a/logos_delivery/waku/waku_core/message/message.nim b/logos_delivery/waku/waku_core/message/message.nim index f6e6c9336..089e314f5 100644 --- a/logos_delivery/waku/waku_core/message/message.nim +++ b/logos_delivery/waku/waku_core/message/message.nim @@ -9,7 +9,20 @@ import ../topics, ../time const MaxMetaAttrLength* = 64 # 64 bytes -type WakuMessage* = object # Data payload transmitted. +type WakuMessage* = ref object + # Data payload transmitted. + ## `WakuMessage` is a `ref object` for performance: every assignment, + ## async-closure capture, and Result/Option bind-out costs a pointer + + ## refcount instead of deep-copying the three seqs + the string. + ## + ## By convention a `WakuMessage` is **immutable after construction**. Only + ## mutate an instance you just constructed yourself, or one you obtained via + ## `clone()`. Mutating a message received through an API or held elsewhere is + ## a shared-state bug under ref semantics. + ## + ## NOTE: this deliberately deviates from the AGENTS.md `Ref`-suffix naming + ## rule; renaming the ~2,100 occurrences of `WakuMessage` has no semantic + ## value and would be pure churn. payload*: seq[byte] # String identifier that can be used for content-based filtering. contentTopic*: ContentTopic @@ -28,7 +41,36 @@ type WakuMessage* = object # Data payload transmitted. # attribute will be used in the rln-relay protocol. proof*: seq[byte] +proc `==`*(a, b: WakuMessage): bool = + ## Structural equality. Two messages are equal when both are nil, or both are + ## non-nil with field-wise-equal contents (ref identity is *not* used). + if a.isNil() or b.isNil(): + return a.isNil() and b.isNil() + a.payload == b.payload and a.contentTopic == b.contentTopic and a.meta == b.meta and + a.version == b.version and a.timestamp == b.timestamp and a.ephemeral == b.ephemeral and + a.proof == b.proof + +proc clone*(msg: WakuMessage): WakuMessage = + ## Explicit deep copy into a fresh ref (copies the payload/meta/proof seqs). + ## Cloning is the deliberate, rare escape hatch when a held message must be + ## mutated without affecting other holders. + if msg.isNil(): + return nil + WakuMessage( + payload: msg.payload, + contentTopic: msg.contentTopic, + meta: msg.meta, + version: msg.version, + timestamp: msg.timestamp, + ephemeral: msg.ephemeral, + proof: msg.proof, + ) + proc ensureTimestampSet*(message: WakuMessage): WakuMessage = - result = message - if result.timestamp == 0: - result.timestamp = getNowInNanosecondTime() + ## Returns `message` unchanged when its timestamp is already set; otherwise + ## returns a fresh clone with the current timestamp. Never mutates the input + ## (which may be shared under ref semantics). + if message.timestamp != 0: + return message + result = message.clone() + result.timestamp = getNowInNanosecondTime() diff --git a/tests/all_tests_waku.nim b/tests/all_tests_waku.nim index 5498db307..f8ffc7b20 100644 --- a/tests/all_tests_waku.nim +++ b/tests/all_tests_waku.nim @@ -6,6 +6,7 @@ import ./test_waku import ./waku_core/test_namespaced_topics, ./waku_core/test_time, + ./waku_core/test_message, ./waku_core/test_message_digest, ./waku_core/test_peers, ./waku_core/test_published_address diff --git a/tests/waku_core/test_message.nim b/tests/waku_core/test_message.nim new file mode 100644 index 000000000..a0debfe34 --- /dev/null +++ b/tests/waku_core/test_message.nim @@ -0,0 +1,72 @@ +{.used.} + +import std/times, stew/byteutils, testutils/unittests +import logos_delivery/waku/waku_core/message/message + +suite "Waku Message - ref semantics": + test "structural == on equal contents": + let a = WakuMessage( + payload: "\x01\x02\x03".toBytes(), + contentTopic: "/test/1/proto", + meta: @[byte 9], + version: 1, + timestamp: 42, + ephemeral: true, + proof: @[byte 7], + ) + let b = WakuMessage( + payload: "\x01\x02\x03".toBytes(), + contentTopic: "/test/1/proto", + meta: @[byte 9], + version: 1, + timestamp: 42, + ephemeral: true, + proof: @[byte 7], + ) + check: + a == b # equal by content, distinct refs + not (a == WakuMessage(payload: "\x01\x02\x03".toBytes())) + + test "structural == nil cases": + let nilA: WakuMessage = nil + let nilB: WakuMessage = nil + let nonNil = WakuMessage(payload: @[byte 1]) + check: + nilA == nilB # both nil -> equal + not (nilA == nonNil) # one nil -> not equal + not (nonNil == nilB) + + test "clone is an independent deep copy": + let orig = + WakuMessage(payload: @[byte 1, 2, 3], contentTopic: "/a/1", proof: @[byte 8]) + let cp = orig.clone() + check: + cp == orig # equal contents + not cp.isNil() + # mutating the clone must not touch the original + cp.payload = @[byte 9] + cp.proof = @[byte 0] + check: + orig.payload == @[byte 1, 2, 3] + orig.proof == @[byte 8] + not (cp == orig) + + test "clone(nil) is nil": + let n: WakuMessage = nil + check n.clone().isNil() + + test "ensureTimestampSet returns same ref when timestamp already set": + let msg = WakuMessage(payload: @[byte 1], timestamp: 123) + let res = msg.ensureTimestampSet() + check: + cast[pointer](res) == cast[pointer](msg) # same instance, no clone + res.timestamp == 123 + + test "ensureTimestampSet returns a fresh ref, never mutating the shared input": + let msg = WakuMessage(payload: @[byte 1], timestamp: 0) + let res = msg.ensureTimestampSet() + check: + cast[pointer](res) != cast[pointer](msg) # distinct instance + res.timestamp != 0 + msg.timestamp == 0 # input untouched (would be a shared-state bug) + res.payload == msg.payload