diff --git a/CHANGELOG.md b/CHANGELOG.md index ce88cd35a3..0f1202f401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,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-, hash-, or object-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 a runtime walker as a live container. The guard now recognizes both null shapes and skips the array header together with every affected 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), missed object reads, and misses forwarded through `?? null` all continue execution; genuine null locals and live arrays, hashes, and objects 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 414f550009..52b6c2216c 100644 --- a/src/codegen/lower_inst/builtins/debug.rs +++ b/src/codegen/lower_inst/builtins/debug.rs @@ -304,8 +304,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); @@ -319,6 +337,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(()) } @@ -351,8 +370,18 @@ fn emit_print_r_mixed(ctx: &mut FunctionContext<'_>) { /// a top-level render and a render at depth cannot drift apart. That helper owns /// the whole layout: the `ClassName Object` header (or PHP's `ClassName Enum[:t]` /// for an enum case), the `(` / `)` lines, the per-property body and the -/// `*RECURSION*` guard. +/// `*RECURSION*` guard. A zero pointer or the in-band null-container sentinel from +/// a missed object read skips the walker entirely, matching `print_r(null)`. fn emit_print_r_object(ctx: &mut FunctionContext<'_>) { + let result_reg = abi::int_result_reg(ctx.emitter); + let skip_label = ctx.next_label("print_r_skip_null_object"); + 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, + ); match ctx.emitter.target.arch { Arch::AArch64 => { ctx.emitter.instruction("mov x1, #0"); // base indent = 0 for the top-level object @@ -363,6 +392,7 @@ fn emit_print_r_object(ctx: &mut FunctionContext<'_>) { } } abi::emit_call_label(ctx.emitter, "__rt_print_r_object"); + ctx.emitter.label(&skip_label); } /// Emits `var_dump` output for a boxed Mixed payload in the integer result register. diff --git a/tests/codegen/io/printing.rs b/tests/codegen/io/printing.rs index c7cbaa5b1c..4341192ec4 100644 --- a/tests/codegen/io/printing.rs +++ b/tests/codegen/io/printing.rs @@ -522,3 +522,267 @@ 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's post-`main` integration: object rendering was added after +/// the original fix, and a missed object read carried the same sentinel into its walker. +#[test] +fn test_print_r_missed_object_read_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}" + ); +} + +/// Verifies the object path added by the merged `main` also branches around its runtime +/// walker for both null-container representations on every supported architecture. +#[test] +fn test_print_r_object_emits_null_container_guard_before_walker_call() { + let dir = make_cli_test_dir("elephc_print_r_null_object_guard"); + let (user_asm, _runtime_asm, _libs) = compile_source_to_asm_with_options( + r#" ("cmp x0, x10", "bl __rt_print_r_object"), + Arch::X86_64 => ("cmp rax, r10", "call __rt_print_r_object"), + }; + 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 object walker call `{walker_call}` in:\n{body}")); + assert!( + cmp_pos < call_pos, + "sentinel comparison must precede the print_r object walker call, got:\n{body}" + ); +}