Skip to content

fix(strings): keep a mixed sprintf() argument when the format string is not a literal - #717

Open
Guikingone wants to merge 1 commit into
mainfrom
fix/sprintf-mixed-argument
Open

fix(strings): keep a mixed sprintf() argument when the format string is not a literal#717
Guikingone wants to merge 1 commit into
mainfrom
fix/sprintf-mixed-argument

Conversation

@Guikingone

Copy link
Copy Markdown
Contributor
foreach ([["%5d", 42], ["%x", 255]] as $row) {
    echo sprintf($row[0], $row[1]);
}

printed 0 and 0. PHP prints 42 and ff.

echo rendered the same values correctly throughout, which is why this read as a formatting bug
when it was argument marshalling.

It needed two halves at once

sprintf_spec_cats_for_format returns an EMPTY list as soon as the format is not a compile-time
ConstStr, so no conversion category is known for any argument. pack_static_sprintf_arg then
packs from the operand's static type, and its catch-all arm pushes a zero payload tagged as an
integer for every shape it does not name — mixed included.

Either half alone was fine, which is why this survived:

call why it worked
sprintf("%5d", $row[1]) literal format → known category → the load_sprintf_arg_as_* path, which already handled mixed
sprintf($row[0], 42) non-literal format, but a plain Int operand the packer names
sprintf($row[0], $row[1]) neither — falls to the catch-all

The existing runtime-format test used a statically typed value ($f = "%d"; sprintf($f, "42")),
so it passed the whole time.

The fix was already written, in the function next door

vsprintf() was correct on all five shapes sprintf() got wrong. __rt_vsprintf already
contained a boxed-Mixed → record ladder, inline in its per-element loop. That contradiction
identified both the cause (marshalling, not formatting) and the fix: extract the ladder as
__rt_sprintf_pack_mixed and have both paths call it, rather than write a second copy of the
same assembly for them to drift apart from.

Reading the cell's runtime tag is enough — __rt_sprintf already coerces a record whose tag
disagrees with the conversion character, so the caller never needs to know whether the format
asks for %d, %s or %f.

Extracting it surfaced a second silent defect

vsprintf("%s", [null]);   // 9223372036854775806

0x7FFF_FFFF_FFFF_FFFE — the raw internal null sentinel, printed straight into the output. A
boxed null fell down the ladder's "anything else, treat as an integer" arm, whose payload is
that sentinel.

PHP renders null as "" under %s and 0 under %d. Packing it as a zero-length string
gives both and costs nothing: __rt_sprintf already guards a null string pointer on all three
conversion paths ("treat a null string pointer as empty" for %s, "a null pointer parses as
zero" for the int and float paths).

Writing the regression test surfaced a third

$f = "%s"; sprintf($f, null) still printed "0". A null LITERAL is statically Void, not
Mixed, so it never reached the helper and landed in the same catch-all. It now packs as the
same zero-length string record.

All three are one design fault: a catch-all arm that answers with a value instead of
refusing.
Every type it does not name becomes a silent wrong answer rather than an error.

Verification

macOS aarch64, on this branch's own base (the work was written over another branch; a
cherry-pick is a different tree, so every gate was re-run here):

  • cargo build --workspace --all-targets — warning-clean
  • cargo test --doc
  • codegen::strings — 311/311
  • printf/sprintf by name — 44/44, including the two new regression tests
  • error_tests strings — 172/172
  • elephc-magician strings — 53/53
  • measured against php -n: identical on all fourteen probe lines

codegen::eval::test_eval_dispatches_printf_family_builtin_calls trips nextest's 60s guard
under machine load; run directly it passes in 17s at load 10.9 and 39.7s at load 35, so the
timeout tracks the host, not this change.

@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Aug 14, 2026
@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown

Greptile Summary

The PR fixes argument marshalling for runtime sprintf() formats and centralizes boxed mixed packing so the scalar and array-based printf families share the same conversion logic.

  • Routes statically mixed operands through a runtime-tag-aware packing helper.
  • Encodes null as an empty-string record to preserve PHP coercion behavior.
  • Reuses the helper from vsprintf() and adds focused formatting regressions.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/codegen/lower_inst/builtins/strings/printf.rs Routes mixed runtime-format operands through runtime-tag-aware packing and represents statically known null as an empty string.
src/codegen_support/runtime/strings/sprintf_pack_mixed.rs Adds matching AArch64 and x86_64 leaf helpers that translate boxed runtime cells into sprintf operand records.
src/codegen_support/runtime/strings/vsprintf.rs Replaces duplicated mixed-element conversion ladders with the shared helper while preserving loop and stack state.
tests/codegen/strings/formatting.rs Adds regressions for heterogeneous runtime-format arguments and null formatting across sprintf and vsprintf.
src/codegen_support/runtime/emitters.rs Registers the new mixed-argument helper with runtime emission.
src/codegen_support/runtime/strings/mod.rs Declares and exports the new runtime helper module.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[printf-family argument] --> B{Compile-time conversion category known?}
    B -->|Yes| C[Load argument as requested category]
    B -->|No| D{Static representation}
    D -->|Mixed or Union| E[__rt_sprintf_pack_mixed]
    D -->|Known scalar or null| F[Static argument packer]
    E --> G[16-byte payload and metadata record]
    F --> G
    H[vsprintf mixed array element] --> E
    G --> I[__rt_sprintf conversion and formatting]
Loading

Reviews (2): Last reviewed commit: "fix(strings): keep a mixed sprintf() arg..." | Re-trigger Greptile

…is not a literal

```php
foreach ([["%5d", 42], ["%x", 255]] as $row) {
    echo sprintf($row[0], $row[1]);
}
```

printed `    0` and `0`. PHP prints `   42` and `ff`. `echo` rendered the same values correctly
throughout, which is why this read as a formatting bug when it was argument marshalling.

The defect needed two halves at once. `sprintf_spec_cats_for_format` returns an EMPTY list as
soon as the format is not a compile-time `ConstStr`, so no conversion category is known for any
argument; and `pack_static_sprintf_arg` then packs from the operand's static type, whose
catch-all arm pushes a zero payload tagged as an integer for every shape it does not name —
`mixed` included. Either half alone was fine: `sprintf("%5d", $row[1])` took the
`load_sprintf_arg_as_*` path, which already handled mixed, and `sprintf($row[0], 42)` packed a
plain `Int`. That is also why the existing runtime-format test passed: it used a statically typed
value.

A mixed operand now goes through `__rt_sprintf_pack_mixed`, which reads the cell's real runtime
tag. Knowing the runtime type is enough — `__rt_sprintf` already coerces a record whose tag
disagrees with the conversion character, so the caller does not need to know whether the format
asks for `%d`, `%s` or `%f`.

That ladder was not written for this. `__rt_vsprintf` already contained it, inline in its
per-element loop, and `vsprintf()` was CORRECT on all five shapes `sprintf()` got wrong. That
contradiction is what identified both the cause and the fix: share the ladder rather than write
a second one, since two hand-written copies of the same assembly drift. It now lives once and
both paths call it.

Extracting it surfaced a second silent defect. `vsprintf("%s", [null])` answered
`9223372036854775806` — `0x7FFF_FFFF_FFFF_FFFE`, the raw internal null sentinel, printed straight
into the output — because a boxed null fell down the ladder's "anything else, treat as an
integer" arm, whose payload is that sentinel. PHP renders null as `""` under `%s` and `0` under
`%d`. Packing it as a ZERO-LENGTH STRING gives both, and costs nothing: `__rt_sprintf` already
guards a null string pointer on all three conversion paths ("treat a null string pointer as
empty" for `%s`, "a null pointer parses as zero" for the int and float paths).

Writing the regression test then surfaced a third: `$f = "%s"; sprintf($f, null)` still printed
`"0"`. A null LITERAL is statically `Void`, not `Mixed`, so it never reached the helper and fell
into the same catch-all. It now packs as the same zero-length string record.

All three are one design fault: a catch-all arm that answers with a value instead of refusing.
Every type it does not name becomes a silent wrong answer rather than an error.

Verified on macOS aarch64 against `php -n`, identical on all fourteen probe lines:
`codegen::strings` 309/309, printf/sprintf by name 45/45 (including the two new regression
tests), `error_tests` strings 172/172, magician strings 53/53, workspace build warning-clean.
@Guikingone
Guikingone force-pushed the fix/sprintf-mixed-argument branch from a70317f to d864a97 Compare August 14, 2026 07:50
@Guikingone
Guikingone requested a review from nahime0 August 14, 2026 12:00
@Guikingone Guikingone self-assigned this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:builtins Touches PHP builtin declarations or emitters. area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant