From 13f448310950f84df06c025749308c2d12d7c456 Mon Sep 17 00:00:00 2001 From: mirchaemanuel Date: Wed, 29 Jul 2026 16:52:57 +0200 Subject: [PATCH] fix(codegen): guard print_r's container walk against the null-container sentinel emit_print_r_array had no null branch at all -- not even the zero-pointer check var_dump carried -- so a missed element read wrote "Array\n" and handed the null-container sentinel to the walker as a live container, segfaulting mid-output. The guard uses the shared sentinels helper, recognizing both the zero pointer and the sentinel, and jumps past BOTH the "Array\n" write and the walker call. Skipping every write is what makes all three call modes correct at once: echo mode prints nothing and still returns true, and the capture modes leave the buffer empty so __rt_pr_finish yields "". Note this is deliberately not var_dump's behaviour: PHP prints nothing for print_r(null), where var_dump(null) prints NULL, so the branch target differs from the one added for issue #581. --- CHANGELOG.md | 1 + src/codegen/lower_inst/builtins/debug.rs | 19 +++ tests/codegen/io/printing.rs | 179 +++++++++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e30abca731..e108e57659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Releases are listed newest first. - Fixed reading a value through `?? default` and using the result directly as a condition or logical operand leaking the boxed coalesce temporary on every evaluation (issue #586): `if`, ternary, `while`/`do-while`/`for`, short-circuit `&&`/`||`, logical `xor`, negation `!`, and magic `__isset` now release the owned operand after coercing it to a boolean, so shapes like `($r[0] ?? 0 ? 1 : 0)` in a loop keep a flat heap. Values still owned by a local (short ternary `?:`, direct element reads) stay balanced without double frees. - Fixed associative-array literals leaking a boxed value on every rebind (issue #595): a `mixed`-valued container (hash, indexed array, or object property) stores a freshly boxed arithmetic result — `$i + 1`, `$i * 2`, and other checked-integer or mixed-numeric operations — by stealing its sole reference, but the value materializer still re-retained those producers, so the previous value leaked one block per loop iteration. Lowering now publishes its owning-temporary proof through EIR `Ownership::Owned` metadata, and codegen combines that contract with explicit EIR cleanup uses when deciding whether a consumer may steal or must retain the value, instead of maintaining a second producer allow-list. Rebinding loops stay heap-clean while captured receivers and values that escape into outer arrays, function arguments, or return values remain balanced. - Fixed `implode()` over an indexed array held in a boxed `mixed` cell leaking one heap block per string element (issue #601). Each boxed element is stringified through the persisting mixed-string cast, which allocates an owned copy for string payloads; implode now releases that copy after copying its bytes into the result buffer, on every supported target. Integer, float, and bool elements (which stringify into shared scratch storage) and typed non-mixed string arrays (whose elements are borrowed) are unaffected, and the joined output remains valid. +- Fixed `print_r()` segfaulting on an array- or hash-typed local carrying the in-band null-container sentinel (issue #647). Its lowering had no null branch at all — not even the zero-pointer check its `var_dump` sibling carried — so `Array` was written and the sentinel from a missed element read was then handed to the runtime walker as a live container. The guard now recognizes both null shapes and skips the header write together with the walk on every supported target, matching PHP's *empty* rendering of `print_r(null)` (distinct from `var_dump(null)`, which prints `NULL`). All three modes are correct: echo mode prints nothing and still returns `true`, `print_r($value, true)` returns `""`, and the runtime-flag form follows the flag. Missed indexed reads, missed hash reads (of indexed-array or hash values), and misses forwarded through `?? null` all continue execution; genuine null locals and live arrays/hashes render exactly as before. - Fixed `opcache_get_status()` reporting a `revalidate` key in every script entry under `--php-version 8.2`, where reference PHP added it only in 8.3. Verified against the official `php:8.2-cli` … `php:8.5-cli` images on Linux, which is the only place the `scripts` map is observable. - Fixed checker error recovery for failed local-binding assignments (issue #597): simple, typed, reference, static, and list-destructuring targets are now registered as `mixed` when their right-hand side fails, suppressing misleading follow-on `Undefined variable` diagnostics while preserving valid earlier bindings. Method checking now starts from a PHP-local environment instead of inheriting ordinary top-level locals, eliminating the recovery-specific method-seed filter and the underlying cross-scope type leak. - Fixed managed native dependency downloads leaving temporary files behind when another process won the cache-publication race; successful fallback now cleans up the losing download. diff --git a/src/codegen/lower_inst/builtins/debug.rs b/src/codegen/lower_inst/builtins/debug.rs index 054d7496c2..379985b142 100644 --- a/src/codegen/lower_inst/builtins/debug.rs +++ b/src/codegen/lower_inst/builtins/debug.rs @@ -295,8 +295,26 @@ fn emit_print_r_tagged_scalar(ctx: &mut FunctionContext<'_>) -> Result<()> { /// the recursive `(\n ... )\n` body emitted by the runtime `walker`. The array /// pointer is preserved across the header write (the write syscall clobbers the /// integer result register), then passed with a base indent of 0. +/// +/// Both null container shapes — the zero pointer and the in-band null-container +/// sentinel a missed element read materializes — skip the whole rendering (issue +/// #647). This lowering previously had no null branch at all, so `Array` was +/// written and the sentinel was then handed to the walker as a live container. +/// The guard jumps PAST the header write as well as the walk: PHP renders null as +/// EMPTY output here, unlike `var_dump`, which prints `NULL`. Skipping every write +/// is what makes all three modes correct at once — echo mode prints nothing and +/// still returns `true`, and the capture modes leave the buffer empty so +/// `print_r($null, true)` finalizes to `""`. fn emit_print_r_array(ctx: &mut FunctionContext<'_>, walker: &str) -> Result<()> { let result_reg = abi::int_result_reg(ctx.emitter); + let skip_label = ctx.next_label("print_r_skip_null_array"); + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + result_reg, + scratch_reg, + &skip_label, + ); abi::emit_push_reg(ctx.emitter, result_reg); emit_write_literal(ctx, b"Array\n"); abi::emit_pop_reg(ctx.emitter, result_reg); @@ -310,6 +328,7 @@ fn emit_print_r_array(ctx: &mut FunctionContext<'_>, walker: &str) -> Result<()> } } abi::emit_call_label(ctx.emitter, walker); + ctx.emitter.label(&skip_label); Ok(()) } diff --git a/tests/codegen/io/printing.rs b/tests/codegen/io/printing.rs index c7cbaa5b1c..a9a768551e 100644 --- a/tests/codegen/io/printing.rs +++ b/tests/codegen/io/printing.rs @@ -522,3 +522,182 @@ echo var_export(123, true); } // --- File I/O: CSV, timestamps, directory listing, temp files, seek/rewind/eof --- + +// --- Issue #647: print_r() of a value carrying the null-container sentinel --- + +/// Regression for issue #647: the exact repro. `$a[7]` misses, so the Array-typed local +/// `$arr` carries the in-band null-container sentinel. `print_r($arr)` must print NOTHING +/// and keep running — PHP's `print_r(null)` is empty output, unlike `var_dump(null)`. +#[test] +fn test_print_r_missed_indexed_read_prints_nothing() { + let out = compile_and_run_capture( + r#" ['x', 'y']]; +$arr = $a['zz']; +print_r($arr); +echo "done\n"; +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!(out.stdout, "done\n"); + assert!(out.stderr.contains(r#"Warning: Undefined array key "zz""#)); +} + +/// Regression for issue #647: a hash source whose value type is itself a hash routes to +/// `__rt_print_r_hash`, the other walker reached through the same unguarded lowering. +#[test] +fn test_print_r_missed_hash_read_of_hash_value_prints_nothing() { + let out = compile_and_run_capture( + r#" ['x' => 1]]; +$arr = $a['zz']; +print_r($arr); +echo "done\n"; +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!(out.stdout, "done\n"); + assert!(out.stderr.contains(r#"Warning: Undefined array key "zz""#)); +} + +/// Regression for issue #647: a miss forwarded through `?? null` keeps the sentinel payload +/// while suppressing the warning; the render must still be silent rather than crash. +#[test] +fn test_print_r_missed_read_through_coalesce_null_prints_nothing() { + let out = compile_and_run_capture( + r#" 99; +$r = print_r($arr, $flag); +echo "[", $r, "]\n"; +echo "done\n"; +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!(out.stdout, "[1]\ndone\n"); + assert!(out.stderr.contains("Warning: Undefined array key 7")); +} + +/// Guard for issue #647: a genuine null local and present indexed/associative arrays keep +/// their existing renderings, so the added guard does not silence live containers. +#[test] +fn test_print_r_null_local_and_present_arrays_are_unchanged() { + let out = compile_and_run_capture( + r#" 'v']; +print_r($h); +echo "done\n"; +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!( + out.stdout, + "Array\n(\n [0] => x\n [1] => y\n)\nArray\n(\n [k] => v\n)\ndone\n" + ); + assert_eq!(out.stderr, ""); +} + +/// Regression for issue #647: the null-container sentinel must be recognized before the +/// `Array` header write and the walker call, on every supported target. The lowering had no +/// null branch at all, so the assertion is on ordering: the sentinel comparison has to +/// precede the walker call inside the guarded body. Run under `ELEPHC_TEST_TARGET` to cover +/// the non-host architectures. +#[test] +fn test_print_r_array_emits_null_container_guard_before_walker_call() { + let dir = make_cli_test_dir("elephc_print_r_null_container_guard"); + let (user_asm, _runtime_asm, _libs) = compile_source_to_asm_with_options( + r#" ("cmp x0, x10", "bl __rt_print_r_indexed"), + Arch::X86_64 => ("cmp rax, r10", "call __rt_print_r_indexed"), + }; + let cmp_pos = body + .find(sentinel_cmp) + .unwrap_or_else(|| panic!("missing sentinel comparison `{sentinel_cmp}` in:\n{body}")); + let call_pos = body + .find(walker_call) + .unwrap_or_else(|| panic!("missing walker call `{walker_call}` in:\n{body}")); + assert!( + cmp_pos < call_pos, + "sentinel comparison must precede the print_r walker call, got:\n{body}" + ); +}