From fd24285e819f834f54ae21ff5945595fa7ab820c Mon Sep 17 00:00:00 2001 From: "v.sinev" Date: Wed, 22 Jul 2026 01:17:41 +0300 Subject: [PATCH 1/2] pack: format JSON floats using shortest round-trip representation Serialize double values with the shortest decimal string that parses back to the same value using yyjson's dtoa, so e.g. 0.072 is emitted as "0.072" instead of "0.07199999999999999" while preserving round-trip precision. Add flb_pack_double_to_str() and use it in flb_msgpack_to_json(). The integer-valued special case is dropped because the helper already renders whole numbers with a trailing ".0". Non-finite values (nan, inf) keep the previous formatting. Fixes #6447 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: v.sinev --- include/fluent-bit/flb_pack.h | 9 +++++ src/flb_pack.c | 69 ++++++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/include/fluent-bit/flb_pack.h b/include/fluent-bit/flb_pack.h index aba8f401434..298588e861e 100644 --- a/include/fluent-bit/flb_pack.h +++ b/include/fluent-bit/flb_pack.h @@ -103,6 +103,15 @@ flb_sds_t flb_pack_msgpack_to_json_format(const char *data, uint64_t bytes, int flb_pack_to_json_format_type(const char *str); int flb_pack_to_json_date_type(const char *str); +/* + * Convert a double to its shortest string representation that round-trips + * back to the same value (e.g. 0.072 -> "0.072" instead of + * "0.07199999999999999"). Non-finite values (nan/inf) fall back to the + * legacy "%.17g" formatting. A 64-byte buffer is always sufficient. Returns + * the number of bytes written (excluding the NUL) or -1 on error. + */ +int flb_pack_double_to_str(double val, char *out_buf, size_t buf_size); + void flb_pack_print(const char *data, size_t bytes); int flb_msgpack_to_json(char *json_str, size_t str_len, const msgpack_object *obj, diff --git a/src/flb_pack.c b/src/flb_pack.c index 23a5269f579..644a7c52076 100644 --- a/src/flb_pack.c +++ b/src/flb_pack.c @@ -1014,6 +1014,63 @@ static void msgpack2json_depth_warn(int *warned) } } +int flb_pack_double_to_str(double val, char *out_buf, size_t buf_size) +{ + int len; + + if (out_buf == NULL || buf_size == 0) { + return -1; + } + +#ifdef FLB_HAVE_YYJSON + if (isfinite(val)) { + /* yyjson_write_number() requires at least 40 bytes for a double */ + char tmp[64]; + char *end; + /* + * Build a transient yyjson real value on the stack and let yyjson's + * dtoa produce the shortest representation that round-trips back to + * the same double (e.g. 0.072 -> "0.072"). The value is zero + * initialized because yyjson_set_real() inspects the existing tag + * before overwriting it. + */ + yyjson_val num = { 0 }; + + yyjson_set_real(&num, val); + end = yyjson_write_number(&num, tmp); + if (end != NULL) { + len = (int) (end - tmp); + if (len >= 0 && (size_t) len < buf_size) { + memcpy(out_buf, tmp, len); + out_buf[len] = '\0'; + return len; + } + } + } +#endif + + /* + * Non-finite (nan/inf), unexpected yyjson failure, or a build without the + * yyjson backend. Integer-valued doubles keep a trailing ".0" (matching + * the yyjson output and the pre-existing behavior); other values use a + * round-trip-safe precision. floor()/fabs() avoid the undefined behavior + * of casting an out-of-range double to a long long. + */ + if (isfinite(val) && val == floor(val) && fabs(val) < 1e15) { + len = snprintf(out_buf, buf_size, "%.1f", val); + } + else { + len = snprintf(out_buf, buf_size, "%.17g", val); + } + if (len < 0) { + return -1; + } + if ((size_t) len >= buf_size) { + len = (int) (buf_size - 1); + } + return len; +} + static int msgpack2json_scalar(char *buf, int *off, size_t left, const msgpack_object *o, int escape_unicode) { @@ -1050,15 +1107,15 @@ static int msgpack2json_scalar(char *buf, int *off, size_t left, case MSGPACK_OBJECT_FLOAT32: case MSGPACK_OBJECT_FLOAT64: { - char temp[512] = {0}; - if (o->via.f64 == (double)(long long int)o->via.f64) { - i = snprintf(temp, sizeof(temp)-1, "%.1f", o->via.f64); - } - else if (convert_nan_to_null && isnan(o->via.f64) ) { + char temp[64] = {0}; + if (convert_nan_to_null && isnan(o->via.f64) ) { i = snprintf(temp, sizeof(temp)-1, "null"); } else { - i = snprintf(temp, sizeof(temp)-1, "%.16g", o->via.f64); + i = flb_pack_double_to_str(o->via.f64, temp, sizeof(temp)); + if (i < 0) { + goto msg2json_end; + } } ret = try_to_write(buf, off, left, temp, i); } From 9b93931d70b2d2295eb385980998e50daf9f94ac Mon Sep 17 00:00:00 2001 From: "v.sinev" Date: Wed, 22 Jul 2026 01:17:42 +0300 Subject: [PATCH 2/2] tests: internal: pack: add float round-trip formatting test Verify that doubles are serialized to JSON using their shortest representation, e.g. 0.072 is emitted as "0.072" and not "0.07199999999999999", covering the regression from #6447. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: v.sinev --- tests/internal/pack.c | 127 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/internal/pack.c b/tests/internal/pack.c index bb475b0aff6..6604600189c 100644 --- a/tests/internal/pack.c +++ b/tests/internal/pack.c @@ -16,6 +16,7 @@ #include #include #include /* for NAN */ +#include /* for strtod */ #include "flb_tests_internal.h" @@ -971,6 +972,130 @@ void test_json_pack_truncation() msgpack_sbuffer_destroy(&mp_sbuf); } +/* + * https://github.com/fluent/fluent-bit/issues/6447 + * Floating point values must be serialized using their shortest + * representation that round-trips back to the same double, so that e.g. + * 0.072 is emitted as "0.072" and not "0.07199999999999999". + */ +void test_json_pack_float() +{ + int i; + int ret; + char json_str[256]; + msgpack_sbuffer mp_sbuf; + msgpack_packer mp_pck; + msgpack_object obj; + msgpack_zone mempool; + struct float_case { + double val; + const char *expected; + }; + struct float_case cases[] = { + { 0.072, "0.072" }, + { 0.1, "0.1" }, + { 3.14, "3.14" }, + { 1.005, "1.005" }, + { 5.0, "5.0" }, + { -0.0, "-0.0" }, + }; + + for (i = 0; i < (int) (sizeof(cases) / sizeof(cases[0])); i++) { + memset(json_str, 0, sizeof(json_str)); + + msgpack_sbuffer_init(&mp_sbuf); + msgpack_packer_init(&mp_pck, &mp_sbuf, msgpack_sbuffer_write); + msgpack_pack_double(&mp_pck, cases[i].val); + msgpack_zone_init(&mempool, 2048); + msgpack_unpack(mp_sbuf.data, mp_sbuf.size, NULL, &mempool, &obj); + + ret = flb_msgpack_to_json(&json_str[0], sizeof(json_str), &obj, + FLB_TRUE); + TEST_CHECK(ret >= 0); + + /* the serialized token must always parse back to the same value */ + if (!TEST_CHECK(strtod(json_str, NULL) == cases[i].val)) { + TEST_MSG("value %.17g did not round-trip, got \"%s\"", + cases[i].val, json_str); + } +#ifdef FLB_HAVE_YYJSON + /* with the yyjson backend the shortest number token is emitted */ + if (!TEST_CHECK(strcmp(json_str, cases[i].expected) == 0)) { + TEST_MSG("expected \"%s\", got \"%s\"", + cases[i].expected, json_str); + } +#endif + + msgpack_zone_destroy(&mempool); + msgpack_sbuffer_destroy(&mp_sbuf); + } +} + +/* + * Direct coverage of flb_pack_double_to_str(), including the non-finite + * fallback path, negatives, exponent and subnormal formatting, and the + * undersized-buffer boundary. + */ +void test_pack_double_to_str() +{ + int i; + int len; + char buf[64]; + double vals[] = { + 0.072, -0.072, 0.1, 3.14, 1.005, 5.0, 100.0, 0.0, -0.0, + 1e-7, 1e20, 1e-300, 1e300, + 2.2250738585072014e-308, /* DBL_MIN */ + 4.9e-324 /* smallest subnormal */ + }; + + /* every finite value must round-trip back to the same double */ + for (i = 0; i < (int) (sizeof(vals) / sizeof(vals[0])); i++) { + len = flb_pack_double_to_str(vals[i], buf, sizeof(buf)); + TEST_CHECK(len > 0); + TEST_CHECK((int) strlen(buf) == len); + if (!TEST_CHECK(strtod(buf, NULL) == vals[i])) { + TEST_MSG("value %.17g rendered as \"%s\" did not round-trip", + vals[i], buf); + } + } + +#ifdef FLB_HAVE_YYJSON + /* known shortest representations (only with the yyjson backend) */ + flb_pack_double_to_str(0.072, buf, sizeof(buf)); + TEST_CHECK(strcmp(buf, "0.072") == 0); + flb_pack_double_to_str(5.0, buf, sizeof(buf)); + TEST_CHECK(strcmp(buf, "5.0") == 0); + flb_pack_double_to_str(-0.0, buf, sizeof(buf)); + TEST_CHECK(strcmp(buf, "-0.0") == 0); +#else + /* without yyjson, integer-valued doubles still keep the float marker */ + flb_pack_double_to_str(5.0, buf, sizeof(buf)); + TEST_CHECK(strcmp(buf, "5.0") == 0); + flb_pack_double_to_str(-0.0, buf, sizeof(buf)); + TEST_CHECK(strcmp(buf, "-0.0") == 0); +#endif + + /* non-finite values fall back to legacy formatting */ + flb_pack_double_to_str(NAN, buf, sizeof(buf)); + TEST_CHECK(strcmp(buf, "nan") == 0); + flb_pack_double_to_str(INFINITY, buf, sizeof(buf)); + TEST_CHECK(strcmp(buf, "inf") == 0); + flb_pack_double_to_str(-INFINITY, buf, sizeof(buf)); + TEST_CHECK(strcmp(buf, "-inf") == 0); + + /* an undersized buffer must truncate safely, never overflow */ + { + char small[4]; + len = flb_pack_double_to_str(3.14159, small, sizeof(small)); + TEST_CHECK(len >= 0 && len < (int) sizeof(small)); + TEST_CHECK((int) strlen(small) == len); + } + + /* invalid arguments must be rejected without touching memory */ + TEST_CHECK(flb_pack_double_to_str(3.14, NULL, sizeof(buf)) == -1); + TEST_CHECK(flb_pack_double_to_str(3.14, buf, 0) == -1); +} + static int check_msgpack_val(msgpack_object obj, int expected_type, char *expected_val) { int len; @@ -1895,6 +2020,8 @@ TEST_LIST = { { "json_pack_bug1278" , test_json_pack_bug1278}, { "json_pack_nan" , test_json_pack_nan}, { "json_pack_truncation", test_json_pack_truncation}, + { "json_pack_float" , test_json_pack_float}, + { "pack_double_to_str" , test_pack_double_to_str}, { "json_pack_bug5336" , test_json_pack_bug5336}, { "json_pack_empty_array", test_json_pack_empty_array}, { "json_pack_deep_map_boundary", test_json_pack_deep_map_boundary},