fix(strings): keep a mixed sprintf() argument when the format string is not a literal - #717
Open
Guikingone wants to merge 1 commit into
Open
fix(strings): keep a mixed sprintf() argument when the format string is not a literal#717Guikingone wants to merge 1 commit into
Guikingone wants to merge 1 commit into
Conversation
Greptile SummaryThe PR fixes argument marshalling for runtime
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| 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]
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
force-pushed
the
fix/sprintf-mixed-argument
branch
from
August 14, 2026 07:50
a70317f to
d864a97
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
printed
0and0. PHP prints42andff.echorendered the same values correctly throughout, which is why this read as a formatting bugwhen it was argument marshalling.
It needed two halves at once
sprintf_spec_cats_for_formatreturns an EMPTY list as soon as the format is not a compile-timeConstStr, so no conversion category is known for any argument.pack_static_sprintf_argthenpacks 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 —
mixedincluded.Either half alone was fine, which is why this survived:
sprintf("%5d", $row[1])load_sprintf_arg_as_*path, which already handledmixedsprintf($row[0], 42)Intoperand the packer namessprintf($row[0], $row[1])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 shapessprintf()got wrong.__rt_vsprintfalreadycontained 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_mixedand have both paths call it, rather than write a second copy of thesame assembly for them to drift apart from.
Reading the cell's runtime tag is enough —
__rt_sprintfalready coerces a record whose tagdisagrees with the conversion character, so the caller never needs to know whether the format
asks for
%d,%sor%f.Extracting it surfaced a second silent defect
0x7FFF_FFFF_FFFF_FFFE— the raw internal null sentinel, printed straight into the output. Aboxed null fell down the ladder's "anything else, treat as an integer" arm, whose payload is
that sentinel.
PHP renders null as
""under%sand0under%d. Packing it as a zero-length stringgives both and costs nothing:
__rt_sprintfalready guards a null string pointer on all threeconversion paths ("treat a null string pointer as empty" for
%s, "a null pointer parses aszero" 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 staticallyVoid, notMixed, so it never reached the helper and landed in the same catch-all. It now packs as thesame 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-cleancargo test --doccodegen::strings— 311/311error_testsstrings — 172/172elephc-magicianstrings — 53/53php -n: identical on all fourteen probe linescodegen::eval::test_eval_dispatches_printf_family_builtin_callstrips nextest's 60s guardunder 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.