feat(format): implement %i, %o and floating point in mux_vsnprintf (#1416)
mux_vsnprintf implements printf's conversions by hand, and anything it did
not implement fell through to mux_assert(0) -- so a caller reaching for a
standard C conversion took the server down. That is #1382 in fun_astbench,
and independently the same shape in @list. Both were fixed by rewriting the
call site to avoid %f, which leaves the trap in place for the next caller.
A sweep of every call site through the four wrappers (tprintf,
safe_tprintf_str, mux_sprintf, mux_fprintf) found no third instance today:
986 call sites, 909 with a format, 1749 conversion specs, all supported. But
"no third instance today" is not a property anyone can maintain by reading,
the restriction is invisible at the call site, and the penalty is the whole
process. Better to implement the conversions than to keep forbidding them.
Added: %i (alias of %d), %o, and %f %F %e %E %g %G.
Floating point takes its digits from mux_dtoa -- the same correctly rounded
generator mux_ftoa, fval and NearestPretty already use -- so float output does
not depend on the host libc. mux_ftoa itself is not usable here: it is MUX's
own float rendering and switches to exponent form once the decimal point
passes 18, which %f never does. dtoa suppresses trailing zeros and reports a
decimal-point position, so padding back out to the requested precision, the
%g e-vs-f selection, and the exponent form are assembled here. Note dtoa owns
its buffer and reclaims it on the next call (MULTIPLE_THREADS is not defined),
so callers must not free -- matching fval.
Octal needed mux_utoo/mux_ui64too, and a wider scratch: 64-bit octal is 22
digits, one more than LONGEST_I64 allows for decimal.
Hand-assembled float formatting is exactly the code that looks right and is
wrong at the boundaries, so tests/format compares against the platform
snprintf over 6 conversions x 9 precisions x 5 widths x 3 flags x ~40 values,
plus infinities, NaN, negative zero, ties where round-half-even differs, the
integer/fraction boundary, %i, %o, 64-bit octal, and truncation.
That oracle immediately earned itself: the first run was 31396 passed / 332
failed, all of them %g of values rendering as the empty string. The strip of
trailing zeros ran over the whole buffer instead of the fraction, so it ate
the integer digit -- "%.1g" of 0 produced "" rather than "0". Bounded to the
fraction, 31728 pass and none fail.
Wired into `make test` as test-format. Smoke unchanged at 1488 passed,
314/314 dispatched.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:45:04 -06:00
|
|
|
// test_format.cpp — differential tests for mux_vsnprintf.
|
|
|
|
|
//
|
|
|
|
|
// mux_vsnprintf implements printf's conversions by hand. Anything it did not
|
|
|
|
|
// implement fell through to mux_assert(0) and aborted the process, so a
|
|
|
|
|
// caller reaching for a standard conversion took the server down: that was
|
|
|
|
|
// #1382 in fun_astbench and, independently, the same shape in @list. A grep
|
|
|
|
|
// of the tree found no third instance, but "no third instance today" is not a
|
|
|
|
|
// property anyone can maintain by hand -- so %i, %o and the floating-point
|
fix(lib): echo an unimplemented conversion instead of aborting (#1429)
An unimplemented conversion reached mux_assert(0), and that is an
unconditional abort() in the shipping build -- mux_assert has no NDEBUG
guard and AssertionFailed calls abort() outright. So an ordinary looking
T("%+d") did not produce wrong output, it killed the game.
#1416 implemented %i, %o and the floating-point conversions, which shrank
the surface considerably. This is the other half: the remaining gaps stop
being fatal. Both matter together -- implementing conversions means fewer
gaps, and not aborting means the next gap someone finds is a formatting
bug rather than an outage. That is the pattern #1382 produced twice
already (@list cache and astbench), and both were fixed by rewriting the
call site to dodge %f rather than by making the formatter survive.
The spec is now echoed literally and formatting continues. Emitting it
rather than dropping it leaves the evidence in the output, visible without
a debugger. A bounded formatter already truncates when it runs out of
room, so callers are written against "imperfect output", not against "no
server".
Caveat, documented at the site: no va_arg is consumed for an unimplemented
spec, so a later conversion in the same format string takes a shifted
argument. That is inherent to recovering without fully parsing an unknown
spec, and is still strictly better than terminating.
Added tests/format coverage for the seven forms named in the issue, plus
surrounding text and the %% case so the recovery cannot break what already
worked. snprintf is deliberately not the oracle for these -- echoing
literally is not what printf does -- so they carry explicit expectations.
Verified both directions on Win64. With the fix, 31738 passed / 0 failed.
Against a libmux rebuilt without it, the same test binary:
EXIT=127
C:\tinymux\mux\lib\stringutil.cpp(6701): Assertion failed.
Note that stdout was empty in that run: the abort discards even the
thousands of results that had already passed. That is the cost of the old
behaviour in miniature.
Smoke: 315 dispatched, 1477 succeeded, 17 failed -- the known
build-configuration failures on this box (exp3, UNIX_DIGEST,
REALITY_LVLS). No assertions or crashes in the log.
Scope is mux_vsnprintf only; mux_assert elsewhere in the tree is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:12:36 -06:00
|
|
|
// conversions are implemented rather than forbidden, and what remains
|
|
|
|
|
// unimplemented is echoed literally rather than being fatal (#1429). The two
|
|
|
|
|
// halves matter together: implementing conversions shrinks the surface, and
|
|
|
|
|
// not aborting means the next gap found is a formatting bug instead of an
|
|
|
|
|
// outage.
|
feat(format): implement %i, %o and floating point in mux_vsnprintf (#1416)
mux_vsnprintf implements printf's conversions by hand, and anything it did
not implement fell through to mux_assert(0) -- so a caller reaching for a
standard C conversion took the server down. That is #1382 in fun_astbench,
and independently the same shape in @list. Both were fixed by rewriting the
call site to avoid %f, which leaves the trap in place for the next caller.
A sweep of every call site through the four wrappers (tprintf,
safe_tprintf_str, mux_sprintf, mux_fprintf) found no third instance today:
986 call sites, 909 with a format, 1749 conversion specs, all supported. But
"no third instance today" is not a property anyone can maintain by reading,
the restriction is invisible at the call site, and the penalty is the whole
process. Better to implement the conversions than to keep forbidding them.
Added: %i (alias of %d), %o, and %f %F %e %E %g %G.
Floating point takes its digits from mux_dtoa -- the same correctly rounded
generator mux_ftoa, fval and NearestPretty already use -- so float output does
not depend on the host libc. mux_ftoa itself is not usable here: it is MUX's
own float rendering and switches to exponent form once the decimal point
passes 18, which %f never does. dtoa suppresses trailing zeros and reports a
decimal-point position, so padding back out to the requested precision, the
%g e-vs-f selection, and the exponent form are assembled here. Note dtoa owns
its buffer and reclaims it on the next call (MULTIPLE_THREADS is not defined),
so callers must not free -- matching fval.
Octal needed mux_utoo/mux_ui64too, and a wider scratch: 64-bit octal is 22
digits, one more than LONGEST_I64 allows for decimal.
Hand-assembled float formatting is exactly the code that looks right and is
wrong at the boundaries, so tests/format compares against the platform
snprintf over 6 conversions x 9 precisions x 5 widths x 3 flags x ~40 values,
plus infinities, NaN, negative zero, ties where round-half-even differs, the
integer/fraction boundary, %i, %o, 64-bit octal, and truncation.
That oracle immediately earned itself: the first run was 31396 passed / 332
failed, all of them %g of values rendering as the empty string. The strip of
trailing zeros ran over the whole buffer instead of the fraction, so it ate
the integer digit -- "%.1g" of 0 produced "" rather than "0". Bounded to the
fraction, 31728 pass and none fail.
Wired into `make test` as test-format. Smoke unchanged at 1488 passed,
314/314 dispatched.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:45:04 -06:00
|
|
|
//
|
|
|
|
|
// Floating point is assembled here from mux_dtoa digits (the same correctly
|
|
|
|
|
// rounded generator mux_ftoa and fval use) rather than delegated to the host
|
|
|
|
|
// libc. Hand-assembled float formatting is exactly the kind of code that
|
|
|
|
|
// looks right and is subtly wrong at the boundaries, so the platform's
|
|
|
|
|
// snprintf is used as an oracle across a wide corpus of values, precisions,
|
|
|
|
|
// widths and flags.
|
|
|
|
|
//
|
|
|
|
|
// The oracle is only valid for conversions where mux_vsnprintf intends to
|
|
|
|
|
// match printf. It does, for everything tested here.
|
|
|
|
|
|
|
|
|
|
#include "autoconf.h"
|
|
|
|
|
#include "config.h"
|
|
|
|
|
#include "externs.h"
|
|
|
|
|
|
|
|
|
|
#include <cstdio>
|
|
|
|
|
#include <cstring>
|
|
|
|
|
#include <cmath>
|
|
|
|
|
#include <cfloat>
|
|
|
|
|
#include <string>
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
|
|
static int g_pass = 0;
|
|
|
|
|
static int g_fail = 0;
|
|
|
|
|
|
|
|
|
|
static void report(const char *what, const char *fmt,
|
|
|
|
|
const std::string &got, const std::string &want)
|
|
|
|
|
{
|
|
|
|
|
if (got == want)
|
|
|
|
|
{
|
|
|
|
|
g_pass++;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
g_fail++;
|
|
|
|
|
if (g_fail <= 40)
|
|
|
|
|
{
|
|
|
|
|
printf("not ok - %s fmt=%-14s mux=%-28s libc=%s\n",
|
|
|
|
|
what, fmt, ("[" + got + "]").c_str(), ("[" + want + "]").c_str());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::string mux_fmt(const char *fmt, ...)
|
|
|
|
|
{
|
|
|
|
|
UTF8 buf[LBUF_SIZE];
|
|
|
|
|
va_list ap;
|
|
|
|
|
va_start(ap, fmt);
|
|
|
|
|
size_t n = mux_vsnprintf(buf, sizeof(buf), (const UTF8 *)fmt, ap);
|
|
|
|
|
va_end(ap);
|
|
|
|
|
return std::string((const char *)buf, n);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::string libc_fmt_d(const char *fmt, double v)
|
|
|
|
|
{
|
|
|
|
|
char buf[4096];
|
|
|
|
|
snprintf(buf, sizeof(buf), fmt, v);
|
|
|
|
|
return std::string(buf);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::string libc_fmt_i(const char *fmt, int v)
|
|
|
|
|
{
|
|
|
|
|
char buf[4096];
|
|
|
|
|
snprintf(buf, sizeof(buf), fmt, v);
|
|
|
|
|
return std::string(buf);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::string libc_fmt_u(const char *fmt, unsigned int v)
|
|
|
|
|
{
|
|
|
|
|
char buf[4096];
|
|
|
|
|
snprintf(buf, sizeof(buf), fmt, v);
|
|
|
|
|
return std::string(buf);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::string libc_fmt_ull(const char *fmt, unsigned long long v)
|
|
|
|
|
{
|
|
|
|
|
char buf[4096];
|
|
|
|
|
snprintf(buf, sizeof(buf), fmt, v);
|
|
|
|
|
return std::string(buf);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Floating point: the conversions that used to abort.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static void test_floats(void)
|
|
|
|
|
{
|
|
|
|
|
const double values[] = {
|
|
|
|
|
0.0, -0.0, 1.0, -1.0, 0.5, -0.5, 3.14159265358979,
|
|
|
|
|
2.5, 3.5, 0.125, 0.1, 1.0/3.0, 100.0, -100.0,
|
|
|
|
|
1e-5, 1e-4, 1e-3, 1e5, 1e6, 1e15, 1e16, 1e17,
|
|
|
|
|
123456789.0, 0.000123456789, 9.999999999,
|
|
|
|
|
1e300, 1e-300, DBL_MAX, DBL_MIN,
|
|
|
|
|
// Ties, where round-half-even vs round-half-away actually differs.
|
|
|
|
|
0.005, 0.015, 0.025, 0.045, 1.005, 2.675, 8.835,
|
|
|
|
|
// Values that stress the integer/fraction boundary.
|
|
|
|
|
0.9999999, 9.9999999, 99.999999,
|
|
|
|
|
};
|
|
|
|
|
const char *convs[] = { "f", "F", "e", "E", "g", "G" };
|
|
|
|
|
const char *precs[] = { "", ".0", ".1", ".2", ".3", ".6", ".9", ".15", ".17" };
|
|
|
|
|
const char *widths[] = { "", "1", "8", "15", "20" };
|
|
|
|
|
const char *flags[] = { "", "-", "0" };
|
|
|
|
|
|
|
|
|
|
for (double v : values)
|
|
|
|
|
for (const char *c : convs)
|
|
|
|
|
for (const char *p : precs)
|
|
|
|
|
for (const char *w : widths)
|
|
|
|
|
for (const char *f : flags)
|
|
|
|
|
{
|
|
|
|
|
// "%-0..." is contradictory; skip rather than pin a corner printf
|
|
|
|
|
// itself treats as implementation-flavoured.
|
|
|
|
|
std::string fmt = std::string("%") + f + w + p + c;
|
|
|
|
|
report("float", fmt.c_str(), mux_fmt(fmt.c_str(), v),
|
|
|
|
|
libc_fmt_d(fmt.c_str(), v));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void test_float_specials(void)
|
|
|
|
|
{
|
|
|
|
|
const double specials[] = {
|
|
|
|
|
HUGE_VAL, -HUGE_VAL, std::nan(""),
|
|
|
|
|
};
|
|
|
|
|
const char *fmts[] = {
|
|
|
|
|
"%f", "%F", "%e", "%E", "%g", "%G", "%.3f", "%10f", "%-10f",
|
|
|
|
|
};
|
|
|
|
|
for (double v : specials)
|
|
|
|
|
for (const char *fmt : fmts)
|
|
|
|
|
{
|
|
|
|
|
report("special", fmt, mux_fmt(fmt, v), libc_fmt_d(fmt, v));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// %i and %o: standard conversions that also used to abort.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static void test_i_and_o(void)
|
|
|
|
|
{
|
|
|
|
|
const int ivals[] = { 0, 1, -1, 42, -42, 12345, -12345, 2147483647,
|
|
|
|
|
-2147483647 - 1 };
|
|
|
|
|
const char *ifmts[] = { "%i", "%8i", "%-8i", "%08i", "%1i" };
|
|
|
|
|
for (int v : ivals)
|
|
|
|
|
for (const char *fmt : ifmts)
|
|
|
|
|
{
|
|
|
|
|
report("int-i", fmt, mux_fmt(fmt, v), libc_fmt_i(fmt, v));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const unsigned int uvals[] = { 0u, 1u, 7u, 8u, 63u, 64u, 511u, 512u,
|
|
|
|
|
4294967295u, 123456u };
|
|
|
|
|
const char *ofmts[] = { "%o", "%12o", "%-12o", "%012o" };
|
|
|
|
|
for (unsigned int v : uvals)
|
|
|
|
|
for (const char *fmt : ofmts)
|
|
|
|
|
{
|
|
|
|
|
report("int-o", fmt, mux_fmt(fmt, v), libc_fmt_u(fmt, v));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 64-bit octal is 22 digits — one more than LONGEST_I64 allows for
|
|
|
|
|
// decimal, which is why the scratch buffer had to grow.
|
|
|
|
|
const unsigned long long lvals[] = {
|
|
|
|
|
0ull, 1ull, 0xFFFFFFFFFFFFFFFFull, 0x8000000000000000ull,
|
|
|
|
|
1234567890123456789ull,
|
|
|
|
|
};
|
|
|
|
|
for (unsigned long long v : lvals)
|
|
|
|
|
{
|
|
|
|
|
report("int-llo", "%llo", mux_fmt("%llo", v), libc_fmt_ull("%llo", v));
|
|
|
|
|
report("int-llu", "%llu", mux_fmt("%llu", v), libc_fmt_ull("%llu", v));
|
|
|
|
|
report("int-llx", "%llx", mux_fmt("%llx", v), libc_fmt_ull("%llx", v));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Regressions on what already worked, so the new arms cannot disturb them.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static void test_existing(void)
|
|
|
|
|
{
|
|
|
|
|
report("d", "%d", mux_fmt("%d", -42), libc_fmt_i("%d", -42));
|
|
|
|
|
report("08d", "%08d", mux_fmt("%08d", -42), libc_fmt_i("%08d", -42));
|
|
|
|
|
report("-8d", "%-8d", mux_fmt("%-8d", -42), libc_fmt_i("%-8d", -42));
|
|
|
|
|
report("u", "%u", mux_fmt("%u", 4294967295u), libc_fmt_u("%u", 4294967295u));
|
|
|
|
|
report("x", "%x", mux_fmt("%x", 48879u), libc_fmt_u("%x", 48879u));
|
|
|
|
|
report("X", "%X", mux_fmt("%X", 48879u), libc_fmt_u("%X", 48879u));
|
|
|
|
|
|
|
|
|
|
// Strings and literal percent are not covered by the numeric oracle.
|
|
|
|
|
report("s", "%s", mux_fmt("%s", (const UTF8 *)"hello"), "hello");
|
|
|
|
|
report("pct", "%%", mux_fmt("100%%"), "100%");
|
|
|
|
|
report("mix", "%d/%s", mux_fmt("%d/%s", 7, (const UTF8 *)"x"), "7/x");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Truncation: a format that does not fit must truncate, never overrun and
|
|
|
|
|
// never abort.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static void test_truncation(void)
|
|
|
|
|
{
|
|
|
|
|
UTF8 small[8];
|
|
|
|
|
memset(small, 0xAA, sizeof(small));
|
|
|
|
|
UTF8 canary = small[7];
|
|
|
|
|
(void)canary;
|
|
|
|
|
|
|
|
|
|
va_list dummy;
|
|
|
|
|
(void)dummy;
|
|
|
|
|
|
|
|
|
|
UTF8 buf[16];
|
|
|
|
|
size_t n;
|
|
|
|
|
{
|
|
|
|
|
// 12 chars into an 8-byte buffer.
|
|
|
|
|
struct Local {
|
|
|
|
|
static size_t go(UTF8 *b, size_t nb, const char *fmt, ...)
|
|
|
|
|
{
|
|
|
|
|
va_list ap;
|
|
|
|
|
va_start(ap, fmt);
|
|
|
|
|
size_t r = mux_vsnprintf(b, nb, (const UTF8 *)fmt, ap);
|
|
|
|
|
va_end(ap);
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
n = Local::go(buf, 8, "%f", 123456.789);
|
|
|
|
|
if (n <= 7 && buf[n] == '\0')
|
|
|
|
|
{
|
|
|
|
|
g_pass++;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
g_fail++;
|
|
|
|
|
printf("not ok - truncation: n=%zu buf=[%s]\n", n, (const char *)buf);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A precision large enough to exceed the float scratch must also
|
|
|
|
|
// truncate rather than overrun or abort.
|
|
|
|
|
n = Local::go(buf, sizeof(buf), "%.2000f", 1.0);
|
|
|
|
|
if (n < sizeof(buf))
|
|
|
|
|
{
|
|
|
|
|
g_pass++;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
g_fail++;
|
|
|
|
|
printf("not ok - huge precision: n=%zu\n", n);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(lib): echo an unimplemented conversion instead of aborting (#1429)
An unimplemented conversion reached mux_assert(0), and that is an
unconditional abort() in the shipping build -- mux_assert has no NDEBUG
guard and AssertionFailed calls abort() outright. So an ordinary looking
T("%+d") did not produce wrong output, it killed the game.
#1416 implemented %i, %o and the floating-point conversions, which shrank
the surface considerably. This is the other half: the remaining gaps stop
being fatal. Both matter together -- implementing conversions means fewer
gaps, and not aborting means the next gap someone finds is a formatting
bug rather than an outage. That is the pattern #1382 produced twice
already (@list cache and astbench), and both were fixed by rewriting the
call site to dodge %f rather than by making the formatter survive.
The spec is now echoed literally and formatting continues. Emitting it
rather than dropping it leaves the evidence in the output, visible without
a debugger. A bounded formatter already truncates when it runs out of
room, so callers are written against "imperfect output", not against "no
server".
Caveat, documented at the site: no va_arg is consumed for an unimplemented
spec, so a later conversion in the same format string takes a shifted
argument. That is inherent to recovering without fully parsing an unknown
spec, and is still strictly better than terminating.
Added tests/format coverage for the seven forms named in the issue, plus
surrounding text and the %% case so the recovery cannot break what already
worked. snprintf is deliberately not the oracle for these -- echoing
literally is not what printf does -- so they carry explicit expectations.
Verified both directions on Win64. With the fix, 31738 passed / 0 failed.
Against a libmux rebuilt without it, the same test binary:
EXIT=127
C:\tinymux\mux\lib\stringutil.cpp(6701): Assertion failed.
Note that stdout was empty in that run: the abort discards even the
thousands of results that had already passed. That is the cost of the old
behaviour in miniature.
Smoke: 315 dispatched, 1477 succeeded, 17 failed -- the known
build-configuration failures on this box (exp3, UNIX_DIGEST,
REALITY_LVLS). No assertions or crashes in the log.
Scope is mux_vsnprintf only; mux_assert elsewhere in the tree is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:12:36 -06:00
|
|
|
// Conversions mux_vsnprintf still does not implement must not kill the
|
|
|
|
|
// process (#1429).
|
|
|
|
|
//
|
|
|
|
|
// snprintf is not an oracle here: the required behaviour deliberately differs
|
|
|
|
|
// from printf's. An unimplemented spec is echoed literally, so the evidence
|
fix(lib): stop formatting at an unimplemented conversion (#1445)
#1435 stopped an unimplemented conversion from aborting the process: the
spec is echoed literally and formatting continues. Echoing is right;
continuing is not.
No va_arg is consumed for a spec the formatter cannot parse, so from
that point the argument list is misaligned and every later conversion in
the same format string reads the wrong argument. Measured on master,
Linux x86-64:
"%d|%d" 11, 22 -> "11|22" baseline
"%s|%+d" "head", 11 -> "head|%+d" safe, nothing follows
"%+d|%d" 11, 22 -> "%+d|11" silently the WRONG argument
"%+d|%s" 11, "tail" -> SIGSEGV
The last one hands an int to the %s path as a UTF8* and dereferences it.
That trades a clean abort for a wild pointer, which is not the
improvement #1429 was for -- a crash that used to name its own file and
line is now an unattributed segfault somewhere else.
There is no way to resynchronise without knowing what the unknown spec
would have taken, and no way to learn that without implementing it. So
recovery now stops at the first spec it cannot parse: the remainder of
the format is echoed verbatim and interpretation ends. Conversions
BEFORE the bad spec are still honoured -- "%s|%+d" still renders its
%s -- so stopping is not the same as discarding the format.
Echoing the whole remainder rather than just the offending spec keeps
the evidence intact: the spec appears in full, with the text that
followed it, while nothing further is interpreted.
Bug-catch: with the formatter reverted to master's continue and these
tests kept, the test binary SEGFAULTS rather than reporting a failure --
the same unambiguous signal astbench gave before #1385.
tests/format 31744 passed, 0 failed
smoke 1497/1497 on both routes, 0 crashes, 315/315 dispatched
Closes #1445.
2026-07-26 15:59:50 -06:00
|
|
|
// lands in the output where it is visible, and formatting then STOPS. Before
|
fix(lib): echo an unimplemented conversion instead of aborting (#1429)
An unimplemented conversion reached mux_assert(0), and that is an
unconditional abort() in the shipping build -- mux_assert has no NDEBUG
guard and AssertionFailed calls abort() outright. So an ordinary looking
T("%+d") did not produce wrong output, it killed the game.
#1416 implemented %i, %o and the floating-point conversions, which shrank
the surface considerably. This is the other half: the remaining gaps stop
being fatal. Both matter together -- implementing conversions means fewer
gaps, and not aborting means the next gap someone finds is a formatting
bug rather than an outage. That is the pattern #1382 produced twice
already (@list cache and astbench), and both were fixed by rewriting the
call site to dodge %f rather than by making the formatter survive.
The spec is now echoed literally and formatting continues. Emitting it
rather than dropping it leaves the evidence in the output, visible without
a debugger. A bounded formatter already truncates when it runs out of
room, so callers are written against "imperfect output", not against "no
server".
Caveat, documented at the site: no va_arg is consumed for an unimplemented
spec, so a later conversion in the same format string takes a shifted
argument. That is inherent to recovering without fully parsing an unknown
spec, and is still strictly better than terminating.
Added tests/format coverage for the seven forms named in the issue, plus
surrounding text and the %% case so the recovery cannot break what already
worked. snprintf is deliberately not the oracle for these -- echoing
literally is not what printf does -- so they carry explicit expectations.
Verified both directions on Win64. With the fix, 31738 passed / 0 failed.
Against a libmux rebuilt without it, the same test binary:
EXIT=127
C:\tinymux\mux\lib\stringutil.cpp(6701): Assertion failed.
Note that stdout was empty in that run: the abort discards even the
thousands of results that had already passed. That is the cost of the old
behaviour in miniature.
Smoke: 315 dispatched, 1477 succeeded, 17 failed -- the known
build-configuration failures on this box (exp3, UNIX_DIGEST,
REALITY_LVLS). No assertions or crashes in the log.
Scope is mux_vsnprintf only; mux_assert elsewhere in the tree is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:12:36 -06:00
|
|
|
// this, each of these reached mux_assert(0), which is an unconditional abort()
|
|
|
|
|
// in the shipping build -- mux_assert has no NDEBUG guard -- so an ordinary
|
|
|
|
|
// looking T("%+d") took the whole game down.
|
|
|
|
|
//
|
fix(lib): stop formatting at an unimplemented conversion (#1445)
#1435 stopped an unimplemented conversion from aborting the process: the
spec is echoed literally and formatting continues. Echoing is right;
continuing is not.
No va_arg is consumed for a spec the formatter cannot parse, so from
that point the argument list is misaligned and every later conversion in
the same format string reads the wrong argument. Measured on master,
Linux x86-64:
"%d|%d" 11, 22 -> "11|22" baseline
"%s|%+d" "head", 11 -> "head|%+d" safe, nothing follows
"%+d|%d" 11, 22 -> "%+d|11" silently the WRONG argument
"%+d|%s" 11, "tail" -> SIGSEGV
The last one hands an int to the %s path as a UTF8* and dereferences it.
That trades a clean abort for a wild pointer, which is not the
improvement #1429 was for -- a crash that used to name its own file and
line is now an unattributed segfault somewhere else.
There is no way to resynchronise without knowing what the unknown spec
would have taken, and no way to learn that without implementing it. So
recovery now stops at the first spec it cannot parse: the remainder of
the format is echoed verbatim and interpretation ends. Conversions
BEFORE the bad spec are still honoured -- "%s|%+d" still renders its
%s -- so stopping is not the same as discarding the format.
Echoing the whole remainder rather than just the offending spec keeps
the evidence intact: the spec appears in full, with the text that
followed it, while nothing further is interpreted.
Bug-catch: with the formatter reverted to master's continue and these
tests kept, the test binary SEGFAULTS rather than reporting a failure --
the same unambiguous signal astbench gave before #1385.
tests/format 31744 passed, 0 failed
smoke 1497/1497 on both routes, 0 crashes, 315/315 dispatched
Closes #1445.
2026-07-26 15:59:50 -06:00
|
|
|
// Stopping rather than continuing is what test_unimplemented_stops below
|
|
|
|
|
// pins. Recovery consumes no va_arg, so anything interpreted afterwards
|
|
|
|
|
// reads a shifted argument list (#1445).
|
|
|
|
|
//
|
fix(lib): echo an unimplemented conversion instead of aborting (#1429)
An unimplemented conversion reached mux_assert(0), and that is an
unconditional abort() in the shipping build -- mux_assert has no NDEBUG
guard and AssertionFailed calls abort() outright. So an ordinary looking
T("%+d") did not produce wrong output, it killed the game.
#1416 implemented %i, %o and the floating-point conversions, which shrank
the surface considerably. This is the other half: the remaining gaps stop
being fatal. Both matter together -- implementing conversions means fewer
gaps, and not aborting means the next gap someone finds is a formatting
bug rather than an outage. That is the pattern #1382 produced twice
already (@list cache and astbench), and both were fixed by rewriting the
call site to dodge %f rather than by making the formatter survive.
The spec is now echoed literally and formatting continues. Emitting it
rather than dropping it leaves the evidence in the output, visible without
a debugger. A bounded formatter already truncates when it runs out of
room, so callers are written against "imperfect output", not against "no
server".
Caveat, documented at the site: no va_arg is consumed for an unimplemented
spec, so a later conversion in the same format string takes a shifted
argument. That is inherent to recovering without fully parsing an unknown
spec, and is still strictly better than terminating.
Added tests/format coverage for the seven forms named in the issue, plus
surrounding text and the %% case so the recovery cannot break what already
worked. snprintf is deliberately not the oracle for these -- echoing
literally is not what printf does -- so they carry explicit expectations.
Verified both directions on Win64. With the fix, 31738 passed / 0 failed.
Against a libmux rebuilt without it, the same test binary:
EXIT=127
C:\tinymux\mux\lib\stringutil.cpp(6701): Assertion failed.
Note that stdout was empty in that run: the abort discards even the
thousands of results that had already passed. That is the cost of the old
behaviour in miniature.
Smoke: 315 dispatched, 1477 succeeded, 17 failed -- the known
build-configuration failures on this box (exp3, UNIX_DIGEST,
REALITY_LVLS). No assertions or crashes in the log.
Scope is mux_vsnprintf only; mux_assert elsewhere in the tree is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:12:36 -06:00
|
|
|
// Reaching this function at all is the test. If the recovery regresses to an
|
|
|
|
|
// abort, the harness dies here rather than reporting a failure, which is
|
|
|
|
|
// itself an unambiguous signal.
|
|
|
|
|
//
|
|
|
|
|
static void test_unimplemented(void)
|
|
|
|
|
{
|
|
|
|
|
static const struct
|
|
|
|
|
{
|
|
|
|
|
const char *fmt;
|
|
|
|
|
const char *want;
|
|
|
|
|
} cases[] =
|
|
|
|
|
{
|
|
|
|
|
// The seven forms named in #1429. Each is a perfectly ordinary
|
|
|
|
|
// printf spec that no one would expect to be fatal.
|
|
|
|
|
//
|
|
|
|
|
{ "%+d", "%+d" },
|
|
|
|
|
{ "% d", "% d" },
|
|
|
|
|
{ "%#o", "%#o" },
|
|
|
|
|
{ "%#x", "%#x" },
|
|
|
|
|
{ "%+f", "%+f" },
|
|
|
|
|
{ "%hd", "%hd" },
|
|
|
|
|
{ "%a", "%a" },
|
|
|
|
|
|
|
|
|
|
// Surrounding text must survive on both sides, and a spec must not
|
|
|
|
|
// swallow what follows it.
|
|
|
|
|
//
|
|
|
|
|
{ "before %+d after", "before %+d after" },
|
|
|
|
|
{ "[%a]", "[%a]" },
|
|
|
|
|
|
|
|
|
|
// A trailing bare '%' and a doubled '%' are already handled; included
|
|
|
|
|
// so the recovery path cannot break them.
|
|
|
|
|
//
|
|
|
|
|
{ "100%% sure", "100% sure" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++)
|
|
|
|
|
{
|
|
|
|
|
// Deliberately passing an argument the spec will not consume: the
|
|
|
|
|
// recovery does not call va_arg, and must not read one either.
|
|
|
|
|
//
|
|
|
|
|
std::string got = mux_fmt(cases[i].fmt, 42);
|
|
|
|
|
report("unimplemented", cases[i].fmt, got, cases[i].want);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(lib): stop formatting at an unimplemented conversion (#1445)
#1435 stopped an unimplemented conversion from aborting the process: the
spec is echoed literally and formatting continues. Echoing is right;
continuing is not.
No va_arg is consumed for a spec the formatter cannot parse, so from
that point the argument list is misaligned and every later conversion in
the same format string reads the wrong argument. Measured on master,
Linux x86-64:
"%d|%d" 11, 22 -> "11|22" baseline
"%s|%+d" "head", 11 -> "head|%+d" safe, nothing follows
"%+d|%d" 11, 22 -> "%+d|11" silently the WRONG argument
"%+d|%s" 11, "tail" -> SIGSEGV
The last one hands an int to the %s path as a UTF8* and dereferences it.
That trades a clean abort for a wild pointer, which is not the
improvement #1429 was for -- a crash that used to name its own file and
line is now an unattributed segfault somewhere else.
There is no way to resynchronise without knowing what the unknown spec
would have taken, and no way to learn that without implementing it. So
recovery now stops at the first spec it cannot parse: the remainder of
the format is echoed verbatim and interpretation ends. Conversions
BEFORE the bad spec are still honoured -- "%s|%+d" still renders its
%s -- so stopping is not the same as discarding the format.
Echoing the whole remainder rather than just the offending spec keeps
the evidence intact: the spec appears in full, with the text that
followed it, while nothing further is interpreted.
Bug-catch: with the formatter reverted to master's continue and these
tests kept, the test binary SEGFAULTS rather than reporting a failure --
the same unambiguous signal astbench gave before #1385.
tests/format 31744 passed, 0 failed
smoke 1497/1497 on both routes, 0 crashes, 315/315 dispatched
Closes #1445.
2026-07-26 15:59:50 -06:00
|
|
|
// Recovery must STOP at the first spec it cannot parse (#1445).
|
|
|
|
|
//
|
|
|
|
|
// No va_arg is consumed for an unimplemented spec, so a later conversion in
|
|
|
|
|
// the same format string reads the wrong argument. Continuing was measured
|
|
|
|
|
// before this was fixed:
|
|
|
|
|
//
|
|
|
|
|
// "%+d|%d" 11, 22 -> "%+d|11" silently the wrong argument
|
|
|
|
|
// "%+d|%s" 11, "tail" -> SIGSEGV an int dereferenced as UTF8*
|
|
|
|
|
//
|
|
|
|
|
// The %s case is why this is a crash test and not a cosmetic one, and it is
|
|
|
|
|
// why "echo and carry on" is not a safe recovery: there is no way to
|
|
|
|
|
// resynchronise without knowing what the unknown spec would have taken.
|
|
|
|
|
//
|
|
|
|
|
// Conversions BEFORE the bad spec must still be honoured -- stopping is not
|
|
|
|
|
// the same as discarding the whole format.
|
|
|
|
|
//
|
|
|
|
|
static void test_unimplemented_stops(void)
|
|
|
|
|
{
|
|
|
|
|
static const struct
|
|
|
|
|
{
|
|
|
|
|
const char *fmt;
|
|
|
|
|
const char *want;
|
|
|
|
|
} cases[] =
|
|
|
|
|
{
|
|
|
|
|
// Everything from the bad spec onward is echoed verbatim.
|
|
|
|
|
//
|
|
|
|
|
{ "%+d|%d", "%+d|%d" },
|
|
|
|
|
{ "a%#xb%sc", "a%#xb%sc" },
|
|
|
|
|
{ "%hd tail", "%hd tail" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++)
|
|
|
|
|
{
|
|
|
|
|
std::string got = mux_fmt(cases[i].fmt, 11, 22);
|
|
|
|
|
report("stops", cases[i].fmt, got, cases[i].want);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The %s tail: this is the shape that segfaulted. Reaching the report
|
|
|
|
|
// at all is most of the assertion.
|
|
|
|
|
//
|
|
|
|
|
report("stops", "%+d|%s", mux_fmt("%+d|%s", 11, "tail"), "%+d|%s");
|
|
|
|
|
|
|
|
|
|
// A conversion before the bad spec is still performed.
|
|
|
|
|
//
|
|
|
|
|
report("stops", "%s|%+d", mux_fmt("%s|%+d", "head", 11), "head|%+d");
|
|
|
|
|
report("stops", "%d then %a", mux_fmt("%d then %a", 7, 1.5), "7 then %a");
|
|
|
|
|
}
|
|
|
|
|
|
feat(format): implement %i, %o and floating point in mux_vsnprintf (#1416)
mux_vsnprintf implements printf's conversions by hand, and anything it did
not implement fell through to mux_assert(0) -- so a caller reaching for a
standard C conversion took the server down. That is #1382 in fun_astbench,
and independently the same shape in @list. Both were fixed by rewriting the
call site to avoid %f, which leaves the trap in place for the next caller.
A sweep of every call site through the four wrappers (tprintf,
safe_tprintf_str, mux_sprintf, mux_fprintf) found no third instance today:
986 call sites, 909 with a format, 1749 conversion specs, all supported. But
"no third instance today" is not a property anyone can maintain by reading,
the restriction is invisible at the call site, and the penalty is the whole
process. Better to implement the conversions than to keep forbidding them.
Added: %i (alias of %d), %o, and %f %F %e %E %g %G.
Floating point takes its digits from mux_dtoa -- the same correctly rounded
generator mux_ftoa, fval and NearestPretty already use -- so float output does
not depend on the host libc. mux_ftoa itself is not usable here: it is MUX's
own float rendering and switches to exponent form once the decimal point
passes 18, which %f never does. dtoa suppresses trailing zeros and reports a
decimal-point position, so padding back out to the requested precision, the
%g e-vs-f selection, and the exponent form are assembled here. Note dtoa owns
its buffer and reclaims it on the next call (MULTIPLE_THREADS is not defined),
so callers must not free -- matching fval.
Octal needed mux_utoo/mux_ui64too, and a wider scratch: 64-bit octal is 22
digits, one more than LONGEST_I64 allows for decimal.
Hand-assembled float formatting is exactly the code that looks right and is
wrong at the boundaries, so tests/format compares against the platform
snprintf over 6 conversions x 9 precisions x 5 widths x 3 flags x ~40 values,
plus infinities, NaN, negative zero, ties where round-half-even differs, the
integer/fraction boundary, %i, %o, 64-bit octal, and truncation.
That oracle immediately earned itself: the first run was 31396 passed / 332
failed, all of them %g of values rendering as the empty string. The strip of
trailing zeros ran over the whole buffer instead of the fraction, so it ate
the integer digit -- "%.1g" of 0 produced "" rather than "0". Bounded to the
fraction, 31728 pass and none fail.
Wired into `make test` as test-format. Smoke unchanged at 1488 passed,
314/314 dispatched.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:45:04 -06:00
|
|
|
int main(void)
|
|
|
|
|
{
|
|
|
|
|
printf("=== mux_vsnprintf differential tests (vs snprintf) ===\n");
|
|
|
|
|
|
|
|
|
|
test_existing();
|
|
|
|
|
test_i_and_o();
|
|
|
|
|
test_floats();
|
|
|
|
|
test_float_specials();
|
|
|
|
|
test_truncation();
|
fix(lib): echo an unimplemented conversion instead of aborting (#1429)
An unimplemented conversion reached mux_assert(0), and that is an
unconditional abort() in the shipping build -- mux_assert has no NDEBUG
guard and AssertionFailed calls abort() outright. So an ordinary looking
T("%+d") did not produce wrong output, it killed the game.
#1416 implemented %i, %o and the floating-point conversions, which shrank
the surface considerably. This is the other half: the remaining gaps stop
being fatal. Both matter together -- implementing conversions means fewer
gaps, and not aborting means the next gap someone finds is a formatting
bug rather than an outage. That is the pattern #1382 produced twice
already (@list cache and astbench), and both were fixed by rewriting the
call site to dodge %f rather than by making the formatter survive.
The spec is now echoed literally and formatting continues. Emitting it
rather than dropping it leaves the evidence in the output, visible without
a debugger. A bounded formatter already truncates when it runs out of
room, so callers are written against "imperfect output", not against "no
server".
Caveat, documented at the site: no va_arg is consumed for an unimplemented
spec, so a later conversion in the same format string takes a shifted
argument. That is inherent to recovering without fully parsing an unknown
spec, and is still strictly better than terminating.
Added tests/format coverage for the seven forms named in the issue, plus
surrounding text and the %% case so the recovery cannot break what already
worked. snprintf is deliberately not the oracle for these -- echoing
literally is not what printf does -- so they carry explicit expectations.
Verified both directions on Win64. With the fix, 31738 passed / 0 failed.
Against a libmux rebuilt without it, the same test binary:
EXIT=127
C:\tinymux\mux\lib\stringutil.cpp(6701): Assertion failed.
Note that stdout was empty in that run: the abort discards even the
thousands of results that had already passed. That is the cost of the old
behaviour in miniature.
Smoke: 315 dispatched, 1477 succeeded, 17 failed -- the known
build-configuration failures on this box (exp3, UNIX_DIGEST,
REALITY_LVLS). No assertions or crashes in the log.
Scope is mux_vsnprintf only; mux_assert elsewhere in the tree is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:12:36 -06:00
|
|
|
test_unimplemented();
|
fix(lib): stop formatting at an unimplemented conversion (#1445)
#1435 stopped an unimplemented conversion from aborting the process: the
spec is echoed literally and formatting continues. Echoing is right;
continuing is not.
No va_arg is consumed for a spec the formatter cannot parse, so from
that point the argument list is misaligned and every later conversion in
the same format string reads the wrong argument. Measured on master,
Linux x86-64:
"%d|%d" 11, 22 -> "11|22" baseline
"%s|%+d" "head", 11 -> "head|%+d" safe, nothing follows
"%+d|%d" 11, 22 -> "%+d|11" silently the WRONG argument
"%+d|%s" 11, "tail" -> SIGSEGV
The last one hands an int to the %s path as a UTF8* and dereferences it.
That trades a clean abort for a wild pointer, which is not the
improvement #1429 was for -- a crash that used to name its own file and
line is now an unattributed segfault somewhere else.
There is no way to resynchronise without knowing what the unknown spec
would have taken, and no way to learn that without implementing it. So
recovery now stops at the first spec it cannot parse: the remainder of
the format is echoed verbatim and interpretation ends. Conversions
BEFORE the bad spec are still honoured -- "%s|%+d" still renders its
%s -- so stopping is not the same as discarding the format.
Echoing the whole remainder rather than just the offending spec keeps
the evidence intact: the spec appears in full, with the text that
followed it, while nothing further is interpreted.
Bug-catch: with the formatter reverted to master's continue and these
tests kept, the test binary SEGFAULTS rather than reporting a failure --
the same unambiguous signal astbench gave before #1385.
tests/format 31744 passed, 0 failed
smoke 1497/1497 on both routes, 0 crashes, 315/315 dispatched
Closes #1445.
2026-07-26 15:59:50 -06:00
|
|
|
test_unimplemented_stops();
|
feat(format): implement %i, %o and floating point in mux_vsnprintf (#1416)
mux_vsnprintf implements printf's conversions by hand, and anything it did
not implement fell through to mux_assert(0) -- so a caller reaching for a
standard C conversion took the server down. That is #1382 in fun_astbench,
and independently the same shape in @list. Both were fixed by rewriting the
call site to avoid %f, which leaves the trap in place for the next caller.
A sweep of every call site through the four wrappers (tprintf,
safe_tprintf_str, mux_sprintf, mux_fprintf) found no third instance today:
986 call sites, 909 with a format, 1749 conversion specs, all supported. But
"no third instance today" is not a property anyone can maintain by reading,
the restriction is invisible at the call site, and the penalty is the whole
process. Better to implement the conversions than to keep forbidding them.
Added: %i (alias of %d), %o, and %f %F %e %E %g %G.
Floating point takes its digits from mux_dtoa -- the same correctly rounded
generator mux_ftoa, fval and NearestPretty already use -- so float output does
not depend on the host libc. mux_ftoa itself is not usable here: it is MUX's
own float rendering and switches to exponent form once the decimal point
passes 18, which %f never does. dtoa suppresses trailing zeros and reports a
decimal-point position, so padding back out to the requested precision, the
%g e-vs-f selection, and the exponent form are assembled here. Note dtoa owns
its buffer and reclaims it on the next call (MULTIPLE_THREADS is not defined),
so callers must not free -- matching fval.
Octal needed mux_utoo/mux_ui64too, and a wider scratch: 64-bit octal is 22
digits, one more than LONGEST_I64 allows for decimal.
Hand-assembled float formatting is exactly the code that looks right and is
wrong at the boundaries, so tests/format compares against the platform
snprintf over 6 conversions x 9 precisions x 5 widths x 3 flags x ~40 values,
plus infinities, NaN, negative zero, ties where round-half-even differs, the
integer/fraction boundary, %i, %o, 64-bit octal, and truncation.
That oracle immediately earned itself: the first run was 31396 passed / 332
failed, all of them %g of values rendering as the empty string. The strip of
trailing zeros ran over the whole buffer instead of the fraction, so it ate
the integer digit -- "%.1g" of 0 produced "" rather than "0". Bounded to the
fraction, 31728 pass and none fail.
Wired into `make test` as test-format. Smoke unchanged at 1488 passed,
314/314 dispatched.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:45:04 -06:00
|
|
|
|
|
|
|
|
printf("=== %d passed, %d failed ===\n", g_pass, g_fail);
|
|
|
|
|
return (0 == g_fail) ? 0 : 1;
|
|
|
|
|
}
|