Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions src/codegen/lower_inst/builtins/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(())
}

Expand Down
179 changes: 179 additions & 0 deletions tests/codegen/io/printing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<?php
$a = [['x', 'y']];
$arr = $a[7];
print_r($arr);
echo "done\n";
"#,
);
assert!(out.success, "program crashed: {}", out.stderr);
assert_eq!(out.stdout, "done\n");
assert!(out.stderr.contains("Warning: Undefined array key 7"));
}

/// Regression for issue #647: the same miss taken from a hash source whose value type is
/// an indexed array reaches the identical `__rt_print_r_indexed` branch.
#[test]
fn test_print_r_missed_hash_read_of_array_value_prints_nothing() {
let out = compile_and_run_capture(
r#"<?php
$a = ['k' => ['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#"<?php
$a = ['k' => ['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#"<?php
$a = [['x', 'y']];
$arr = $a[7] ?? null;
print_r($arr);
echo "done\n";
"#,
);
assert!(out.success, "program crashed: {}", out.stderr);
assert_eq!(out.stdout, "done\n");
assert_eq!(out.stderr, "");
}

/// Regression for issue #647: the return mode renders into the capture buffer instead of
/// stdout, so the guard has to leave that buffer empty — PHP's `print_r(null, true)` is `""`.
#[test]
fn test_print_r_missed_read_return_mode_yields_empty_string() {
let out = compile_and_run_capture(
r#"<?php
$a = [['x', 'y']];
$arr = $a[7];
$s = print_r($arr, true);
echo "[", $s, "] len=", strlen($s), "\n";
echo "done\n";
"#,
);
assert!(out.success, "program crashed: {}", out.stderr);
assert_eq!(out.stdout, "[] len=0\ndone\n");
assert!(out.stderr.contains("Warning: Undefined array key 7"));
}

/// Regression for issue #647: the runtime-flag mode picks echo or return at run time from
/// the same rendering; a false flag must render nothing and still return PHP's `true`.
#[test]
fn test_print_r_missed_read_runtime_flag_mode_prints_nothing() {
let out = compile_and_run_capture(
r#"<?php
$a = [['x', 'y']];
$arr = $a[7];
$flag = $argc > 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#"<?php
$n = null;
print_r($n);
$a = [['x', 'y']];
print_r($a[0]);
$h = ['k' => '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#"<?php
$a = [['x', 'y']];
$arr = $a[7];
print_r($arr);
"#,
&dir,
8_388_608,
false,
false,
);

// The zero check branches to the skip label first, so the label's first mention opens
// the guarded body and its definition closes it; the header write and walk sit between.
let body_start = user_asm
.find("print_r_skip_null_array")
.expect("missing print_r null-container skip branch");
let body_end = user_asm
.match_indices("print_r_skip_null_array")
.map(|(pos, _)| pos)
.find(|pos| user_asm[*pos..].lines().next().is_some_and(|l| l.ends_with(':')))
.expect("missing print_r null-container skip label definition");
let body = &user_asm[body_start..body_end];

let (sentinel_cmp, walker_call) = match target().arch {
Arch::AArch64 => ("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}"
);
}
Loading