Commit graph

3 commits

Author SHA1 Message Date
Stephen Dennis
71412e0bb7 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
Stephen Dennis
c654f49dde 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
Stephen Dennis
5c06e4032b
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