This is an automated email from the ASF dual-hosted git repository.

astitcher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/qpid-proton.git

commit 5aa83a6d162fc4bf405149756b9f67dcfc43d0cc
Author: Andrew Stitcher <[email protected]>
AuthorDate: Wed Dec 3 19:37:13 2025 -0500

    PROTON-1779: Omit empty HEADERS and PROPERTIES message sections
---
 c/src/core/message.c                 |  8 ++---
 c/tests/message_test.cpp             | 12 +++++++
 c/tools/codec-generator/generate.py  | 35 +++++++++++++++++--
 c/tools/codec-generator/specs.json   |  4 +--
 python/tests/proton_tests/message.py | 67 +++++++++++++++++-------------------
 5 files changed, 81 insertions(+), 45 deletions(-)

diff --git a/c/src/core/message.c b/c/src/core/message.c
index 787a5ba09..a4ddb5f7a 100644
--- a/c/src/core/message.c
+++ b/c/src/core/message.c
@@ -855,8 +855,8 @@ int pn_message_encode(pn_message_t *msg, char *bytes, 
size_t *isize)
   size_t remaining = *isize;
   size_t total = 0;
 
-  /* "DL[?o?B?I?o?I]" */
-  size_t last_size = pn_amqp_encode_bytes_DLEQoQBQIQoQIe(bytes, remaining, 
AMQP_DESC_HEADER,
+  /* "DL[?o?B?I?o?I]!" */
+  size_t last_size = pn_amqp_encode_bytes_DLEQoQBQIQoQIeX(bytes, remaining, 
AMQP_DESC_HEADER,
                         msg->durable, msg->durable,
                          msg->priority!=AMQP_HEADER_PRIORITY_DEFAULT, 
msg->priority,
                          (bool)msg->ttl, msg->ttl,
@@ -887,10 +887,10 @@ int pn_message_encode(pn_message_t *msg, char *bytes, 
size_t *isize)
     total += last_size;
   }
 
-  /* "DL[CzSSSCss?t?tS?IS]" */
+  /* "DL[azSSSass?t?tS?IS]!" */
   pn_atom_t id = pn_message_get_id(msg);
   pn_atom_t correlation_id = pn_message_get_correlation_id(msg);
-  last_size = pn_amqp_encode_bytes_DLEazSSSassQtQtSQISe(bytes, remaining, 
AMQP_DESC_PROPERTIES,
+  last_size = pn_amqp_encode_bytes_DLEazSSSassQtQtSQISeX(bytes, remaining, 
AMQP_DESC_PROPERTIES,
                      &id,
                      pn_string_size(msg->user_id), pn_string_get(msg->user_id),
                      pn_string_bytes(msg->address),
diff --git a/c/tests/message_test.cpp b/c/tests/message_test.cpp
index 5d76abe4f..1d3dea9d1 100644
--- a/c/tests/message_test.cpp
+++ b/c/tests/message_test.cpp
@@ -29,6 +29,8 @@ using namespace pn_test;
 
 TEST_CASE("message_overflow_error") {
   pn_message_t *message = pn_message();
+  // Set a field so message is non-empty (empty messages encode to 0 bytes)
+  pn_message_set_durable(message, true);
   char buf[6];
   size_t size = 6;
 
@@ -38,6 +40,16 @@ TEST_CASE("message_overflow_error") {
   pn_message_free(message);
 }
 
+TEST_CASE("message_empty_encodes") {
+  pn_message_t *message = pn_message();
+  char buf[64];
+  size_t size = 64;
+
+  int err = pn_message_encode(message, buf, &size);
+  CHECK(0 == err);
+  pn_message_free(message);
+}
+
 static void recode(pn_message_t *dst, pn_message_t *src) {
   pn_rwbytes_t buf = {0};
   int size = pn_message_encode2(src, &buf);
diff --git a/c/tools/codec-generator/generate.py 
b/c/tools/codec-generator/generate.py
index da573db13..c9d8322ac 100644
--- a/c/tools/codec-generator/generate.py
+++ b/c/tools/codec-generator/generate.py
@@ -170,6 +170,31 @@ class DescListNode(ListNode):
         ]
 
 
+class DescListOmitIfEmptyNode(ListNode):
+    """Described list that is omitted entirely if all fields would be null 
(resulting in LIST0)"""
+    def __init__(self, l: List[ASTNode]):
+        super().__init__('described_list_omit_empty', l, ['uint64_t'])
+
+    def gen_emit_code(self, prefix: List[str], first_arg: int, indent: int) -> 
List[str]:
+        args = self.gen_args(first_arg)
+        return [
+            f'{self.mk_indent(indent)}size_t section_start = 
emitter->position;',
+            f'{self.mk_indent(indent)}emit_descriptor({", 
".join(prefix+args)});',
+            f'{self.mk_indent(indent)}for (bool small_encoding = true; ; 
small_encoding = false) {{',
+            f'{self.mk_indent(indent+1)}pni_compound_context c = '
+            f'{self.mk_funcall("emit_list", prefix+["small_encoding", 
"true"])};',
+            f'{self.mk_indent(indent+1)}pni_compound_context compound = c;',
+            *(self.gen_emit_list_code(prefix, first_arg+len(args), indent + 
1)),
+            f'{self.mk_indent(indent+1)}if (compound.count == 0) {{',
+            f'{self.mk_indent(indent+2)}emitter->position = section_start;',
+            f'{self.mk_indent(indent+2)}break;',
+            f'{self.mk_indent(indent+1)}}}',
+            f'{self.mk_indent(indent+1)}{self.mk_funcall("emit_end_list", 
prefix+["small_encoding"])};',
+            f'{self.mk_indent(indent+1)}if (encode_succeeded({", 
".join(prefix)})) break;',
+            f'{self.mk_indent(indent)}}}',
+        ]
+
+
 class DescListIgnoreTypeNode(ListNode):
     def __init__(self, l: List[ASTNode]):
         super().__init__('described_unknown_list', l, [])
@@ -274,7 +299,11 @@ def parse_item(format: str) -> Tuple[ASTNode, str]:
         b, rest = expect_char(rest, ']')
         if not b:
             raise ParseError(format)
-        return DescListNode(l), rest
+        # Check for ! suffix - omit entire described list if empty
+        if rest.startswith('!'):
+            return DescListOmitIfEmptyNode(l), rest[1:]
+        else:
+            return DescListNode(l), rest
     elif format.startswith('D.['):
         l, rest = parse_list(format[3:])
         b, rest = expect_char(rest, ']')
@@ -354,10 +383,10 @@ def parse_item(format: str) -> Tuple[ASTNode, str]:
         raise ParseError(format)
 
 
-# Need to translate '@[]*?.' to legal identifier characters
+# Need to translate '@[]*?.!' to legal identifier characters
 # These will be fairly arbitrary and just need to avoid already used codes
 def make_legal_identifier(s: str) -> str:
-    subs = {'@': 'A', '[': 'E', ']': 'e', '*': 'j', '.': 'q', '?': 'Q'}
+    subs = {'@': 'A', '[': 'E', ']': 'e', '*': 'j', '.': 'q', '?': 'Q', '!': 
'X'}
     r = ''
     for c in s:
         if c in subs:
diff --git a/c/tools/codec-generator/specs.json 
b/c/tools/codec-generator/specs.json
index c77f26e1d..2def140f8 100644
--- a/c/tools/codec-generator/specs.json
+++ b/c/tools/codec-generator/specs.json
@@ -6,7 +6,7 @@
     "DL[c]",
     "DL[?HIIII]",
     "DL[?IIII?I?I?In?o]",
-    "DL[?o?B?I?o?I]",
+    "DL[?o?B?I?o?I]!",
     "DL[@T[*s]]",
     "DL[Bz]",
     "DL[I?oc]",
@@ -16,7 +16,7 @@
     "DL[SS?I?H?InnMMR]",
     "DL[S]",
     "DL[Z]",
-    "DL[azSSSass?t?tS?IS]",
+    "DL[azSSSass?t?tS?IS]!",
     "DL[oI?I?o?DL[]]",
     "DL[oIn?od]",
     "DL[szS]"
diff --git a/python/tests/proton_tests/message.py 
b/python/tests/proton_tests/message.py
index 5876da0d7..f0b5bb1de 100644
--- a/python/tests/proton_tests/message.py
+++ b/python/tests/proton_tests/message.py
@@ -224,20 +224,36 @@ class CodecTest(Test):
         assert self.msg.subject == msg2.subject, (self.msg.subject, 
msg2.subject)
         assert self.msg.body == msg2.body, (self.msg.body, msg2.body)
 
-    def testExpiryEncodeAsNull(self):
-        self.msg.group_id = "A"  # Force creation and expiry fields to be 
present
-        data = self.msg.encode()
-
+    def _skip_header_section(self, data):
+        """Skip HEADER section (0x70) if present, then return the next section.
+        HEADER (0x70) may or may not be present depending on whether it has 
non-default values."""
         decoder = Data()
+        if not data:
+            return None
 
-        # Skip past the headers
+        # Decode first section
         consumed = decoder.decode(data)
-        decoder.clear()
-        data = data[consumed:]
+        described = decoder.get_py_described()
 
-        decoder.decode(data)
-        dproperties = decoder.get_py_described()
+        # If first section is HEADER (0x70), skip it
+        if described.descriptor == 0x70:  # HEADER
+            decoder.clear()
+            data = data[consumed:]
+            if not data:
+                return None
+            # Decode next section
+            consumed = decoder.decode(data)
+            described = decoder.get_py_described()
+
+        return described
+
+    def testExpiryEncodeAsNull(self):
+        self.msg.group_id = "A"  # Force creation and expiry fields to be 
present
+        data = self.msg.encode()
+
+        dproperties = self._skip_header_section(data)
         # Check we've got the correct described list
+        assert dproperties is not None, "PROPERTIES section not found"
         assert dproperties.descriptor == 0x73, (dproperties.descriptor)
 
         properties = dproperties.value
@@ -247,16 +263,9 @@ class CodecTest(Test):
         self.msg.group_id = "A"  # Force creation and expiry fields to be 
present
         data = self.msg.encode()
 
-        decoder = Data()
-
-        # Skip past the headers
-        consumed = decoder.decode(data)
-        decoder.clear()
-        data = data[consumed:]
-
-        decoder.decode(data)
-        dproperties = decoder.get_py_described()
+        dproperties = self._skip_header_section(data)
         # Check we've got the correct described list
+        assert dproperties is not None, "PROPERTIES section not found"
         assert dproperties.descriptor == 0x73, (dproperties.descriptor)
 
         properties = dproperties.value
@@ -266,16 +275,9 @@ class CodecTest(Test):
         self.msg.reply_to_group_id = "R"  # Force group_id and group_sequence 
fields to be present
         data = self.msg.encode()
 
-        decoder = Data()
-
-        # Skip past the headers
-        consumed = decoder.decode(data)
-        decoder.clear()
-        data = data[consumed:]
-
-        decoder.decode(data)
-        dproperties = decoder.get_py_described()
+        dproperties = self._skip_header_section(data)
         # Check we've got the correct described list
+        assert dproperties is not None, "PROPERTIES section not found"
         assert dproperties.descriptor == 0x73, (dproperties.descriptor)
 
         properties = dproperties.value
@@ -287,16 +289,9 @@ class CodecTest(Test):
         self.msg.reply_to_group_id = "R"  # Force group_id and group_sequence 
fields to be present
         data = self.msg.encode()
 
-        decoder = Data()
-
-        # Skip past the headers
-        consumed = decoder.decode(data)
-        decoder.clear()
-        data = data[consumed:]
-
-        decoder.decode(data)
-        dproperties = decoder.get_py_described()
+        dproperties = self._skip_header_section(data)
         # Check we've got the correct described list
+        assert dproperties is not None, "PROPERTIES section not found"
         assert dproperties.descriptor == 0x73, (dproperties.descriptor)
 
         properties = dproperties.value


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to