Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/compiling/output-and-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ elephc --gc-stats heavy.php
./heavy
```

Combined with `--web`, the server never reaches the process-exit report, so the
counters are printed to stderr after every handled request instead — a growing
`allocs - frees` gap across requests indicates a per-request leak.

### `--heap-debug`

Enables runtime heap verification in the compiled program: double-free
Expand Down
11 changes: 9 additions & 2 deletions src/codegen/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,19 @@ pub(super) fn emit_web_handler_prologue(ctx: &mut FunctionContext<'_>) {
///
/// Like `emit_main_epilogue` it runs the per-request main local cleanup (so
/// owned refcounted top-level locals are released each request) and restores the
/// frame, but it `ret`s instead of exiting and skips the process-end gc-stats and
/// heap-debug diagnostics, which are wrong to report per request.
/// frame, but it `ret`s instead of exiting. Requested gc-stats are emitted after
/// cleanup for every request; process-end heap-debug diagnostics remain skipped.
pub(super) fn emit_web_handler_epilogue(ctx: &mut FunctionContext<'_>) {
ctx.emitter.blank();
ctx.emitter.comment("web handler epilogue + ret");
emit_main_local_epilogue_cleanup(ctx);
// Under `--web` the handler returns to the bridge server loop instead of
// exiting, so the exit-based main epilogue (where `--gc-stats` normally
// prints) is never reached. Emitting the counters here, once per request,
// is the only way to observe them in server mode.
if ctx.gc_stats {
emit_gc_stats(ctx);
}
emit_callee_saved_restores(ctx);
abi::emit_frame_restore(ctx.emitter, ctx.frame_size);
abi::emit_return(ctx.emitter);
Expand Down
30 changes: 30 additions & 0 deletions src/ir_lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,36 @@ impl<'m, 'f> LoweringContext<'m, 'f> {
&& matches!(op, Some(Op::ArrayGet | Op::HashGet))
}

/// Returns whether an index-read receiver is itself an owned intermediate
/// produced by an index read, i.e. the inner step of a chained subscript
/// read such as `$a[$i][$j]`.
///
/// Container reads of refcounted or boxed-Mixed elements return a +1 caller
/// reference (the `array_get`/`hash_get` emitters incref pointer payloads and
/// box Mixed cells), so when that result is consumed directly as the receiver
/// of another index read there is no local slot whose release machinery would
/// ever drop the reference — the consuming read must release it explicitly.
/// String results are excluded: they are borrowed pointers into the container
/// payload and carry no reference of their own.
pub(crate) fn value_is_owned_index_read_temp(&self, value: LoweredValue) -> bool {
let php_type = self.builder.value_php_type(value.value).codegen_repr();
if !(matches!(php_type, PhpType::Mixed | PhpType::Union(_))
|| (php_type.is_refcounted() && php_type != PhpType::Str))
{
return false;
}
matches!(
self.builder.value_defining_op(value.value),
Some(
Op::ArrayGet
| Op::ArrayGetSilent
| Op::HashGet
| Op::ArrayGetMixedKey
| Op::ArrayGetMixedKeySilent
)
)
}

/// Returns true for builtin calls whose return value is newly allocated for the caller.
fn value_is_owning_builtin_temporary(&self, value: ValueId) -> bool {
let Some(inst) = self.builder.value_defining_instruction(value) else {
Expand Down
34 changes: 32 additions & 2 deletions src/ir_lower/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8593,14 +8593,38 @@ fn lower_array_access_from_value(
_ => Op::RuntimeCall,
};
let result_type = array_access_result_type(ctx, array_value.value, op, expr);
ctx.emit_value(
let release_receiver = ctx.value_is_owned_index_read_temp(array_value);
let mut result = ctx.emit_value(
op,
vec![array_value.value, index_value.value],
None,
result_type,
op.default_effects(),
Some(expr.span),
)
);
// A chained subscript read (`$a[$i][$j]`) consumes the inner read's result
// directly as its receiver. That intermediate carries a +1 reference (the
// get emitters incref refcounted payloads and box Mixed cells), and unlike
// the hoisted form `$inner = $a[$i]; $inner[$j]` no local-slot release
// machinery ever drops it. Typed string reads borrow their bytes from that
// receiver, so stabilize the leaf before releasing the receiver: a later
// expression can otherwise drop the parent's last reference and leave the
// extracted string dangling. Mixed reads already return independent owned
// cells and do not need this copy.
if release_receiver && result.ir_type == IrType::Str {
result = ctx.emit_value(
Op::StrPersist,
vec![result.value],
None,
PhpType::Str,
Op::StrPersist.default_effects(),
Some(expr.span),
);
}
if release_receiver {
crate::ir_lower::ownership::release_if_owned(ctx, array_value, Some(expr.span));
}
result
}

/// Lowers nullable receiver indexing without evaluating the index on a null receiver.
Expand Down Expand Up @@ -8929,6 +8953,12 @@ fn lower_ternary(
/// Lowers a cast expression.
fn lower_cast(ctx: &mut LoweringContext<'_, '_>, target: &CastType, inner: &Expr, expr: &Expr) -> LoweredValue {
let value = lower_expr(ctx, inner);
// Keep the original producer visible for a no-op string cast. Wrapping an
// owned string temporary in `Cast(Str)` would hide its ownership from the
// retaining store/call cleanup and leak the detached string allocation.
if matches!(target, CastType::String) && value.ir_type == IrType::Str {
return value;
}
let php_type = cast_php_type(target);
let result = ctx.emit_value(
Op::Cast,
Expand Down
51 changes: 41 additions & 10 deletions tests/codegen/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19832,32 +19832,63 @@ eval('enum bool { case Ready; }');
);
}

/// Verifies eval rejects PHP-reserved bare class-like reference names.
/// Asserts one eval fragment rejects a PHP-reserved bare class-like reference name.
fn assert_eval_reserved_class_like_reference_name_fails(source: &str) {
let err = compile_and_run_expect_failure(source);
assert!(
err.contains("Fatal error: eval() fragment uses an unsupported construct"),
"stderr did not contain eval unsupported-construct diagnostic: {err}"
);
}

/// Verifies eval rejects a reserved class name in an `extends` reference.
#[test]
fn test_eval_reserved_class_like_reference_name_fails() {
for source in [
fn test_eval_reserved_extends_class_reference_name_fails() {
assert_eval_reserved_class_like_reference_name_fails(
r#"<?php
eval('class EvalBadExtends extends match {}');
"#,
);
}

/// Verifies eval rejects a reserved class name in an `implements` reference.
#[test]
fn test_eval_reserved_implements_class_reference_name_fails() {
assert_eval_reserved_class_like_reference_name_fails(
r#"<?php
eval('class EvalBadImplements implements match {}');
"#,
);
}

/// Verifies eval rejects a reserved trait name in a `use` reference.
#[test]
fn test_eval_reserved_trait_use_reference_name_fails() {
assert_eval_reserved_class_like_reference_name_fails(
r#"<?php
eval('class EvalBadTraitUse { use match; }');
"#,
);
}

/// Verifies eval rejects a reserved class name in a `new` expression.
#[test]
fn test_eval_reserved_new_class_reference_name_fails() {
assert_eval_reserved_class_like_reference_name_fails(
r#"<?php
eval('$box = new match();');
"#,
);
}

/// Verifies eval rejects a reserved class name in an `instanceof` expression.
#[test]
fn test_eval_reserved_instanceof_class_reference_name_fails() {
assert_eval_reserved_class_like_reference_name_fails(
r#"<?php
eval('$ok = $box instanceof match;');
"#,
] {
let err = compile_and_run_expect_failure(source);
assert!(
err.contains("Fatal error: eval() fragment uses an unsupported construct"),
"stderr did not contain eval unsupported-construct diagnostic: {err}"
);
}
);
}

/// Verifies eval-declared final class constants cannot be redeclared.
Expand Down
131 changes: 131 additions & 0 deletions tests/codegen/runtime_gc/regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1230,3 +1230,134 @@ echo "done";
"promoting an indexed array literal to hash storage must free the source array (issue #408)"
);
}

/// Regression test for issue #516: a chained subscript read (`$a[$t][1]`) on a
/// nested indexed array materializes the inner array as an owned +1 temporary
/// (the inner `array_get` increfs refcounted elements), and nothing ever
/// released it, leaking the inner array's blocks on every evaluation. The
/// consuming (outer) index read must release the intermediate once its result
/// is extracted. Regression: heap must be clean at exit and output unchanged.
#[test]
fn test_regression_516_chained_subscript_read_does_not_leak() {
let out = compile_and_run_with_heap_debug(
r#"<?php
function make(int $n): array {
$a = [];
for ($i = 0; $i < $n; $i++) { $a[] = ['kw', 'word' . $i]; }
return $a;
}
$a = make(50);
$n = count($a);
$out = 0;
for ($t = 0; $t < $n; $t++) { $x = (string) $a[$t][1]; $out = $out + strlen($x); }
echo 'out=' . $out;
"#,
);
assert!(out.success, "program failed: {}", out.stderr);
// 10 five-char values (word0..word9) + 40 six-char values (word10..word49).
assert_eq!(out.stdout, "out=290");
assert!(
out.stderr.contains("HEAP DEBUG: leak summary: clean"),
"expected a clean heap, got: {}",
out.stderr
);
}

/// Regression test for issue #516 (three-level chain): every intermediate of a
/// deeper chained subscript read (`$a[$i][$j][$k]`) is an owned container
/// temporary and each must be released by its consuming read. Also guards
/// against over-eager releases: each intermediate stays alive through its
/// parent container's reference, so the extracted leaf string must stay valid.
/// Building the triple-nested structure has a small pre-existing constant leak
/// (2 blocks, unrelated to reads), so this asserts via GC counters that the
/// alloc/free gap stays constant instead of growing with read iterations.
#[test]
fn test_regression_516_three_level_chained_read_does_not_leak() {
let out = compile_and_run_with_gc_stats(
r#"<?php
$a = [];
for ($i = 0; $i < 3; $i++) {
$mid = [];
for ($j = 0; $j < 3; $j++) { $mid[] = ['leaf', 'val' . $i . $j]; }
$a[] = $mid;
}
$out = 0;
for ($r = 0; $r < 40; $r++) {
for ($i = 0; $i < 3; $i++) {
for ($j = 0; $j < 3; $j++) {
$x = (string) $a[$i][$j][1];
$out = $out + strlen($x);
}
}
}
echo 'out=' . $out;
"#,
);
assert!(out.success, "program failed: {}", out.stderr);
assert_eq!(out.stdout, "out=1800");
let (allocs, frees) = parse_gc_stats(&out.stderr);
// Before the fix the leaked +1 references pinned every distinct mid/leaf
// container past process cleanup (gap of 32 blocks on this fixture); after
// the fix only the 2-block build residue may remain.
assert!(
allocs >= frees && allocs - frees < 10,
"chained three-level reads must release their intermediates: allocs={} frees={}",
allocs,
frees
);
}

/// Regression test for issue #516 (string-keyed nesting): a chained
/// associative read (`$m['x']['y']`) goes through `hash_get`, whose refcounted
/// results also carry a +1 caller reference. The chained consumer must release
/// the intermediate hash exactly like the indexed-array path. Regression: heap
/// must be clean at exit after repeated evaluation.
#[test]
fn test_regression_516_chained_string_keyed_read_does_not_leak() {
let out = compile_and_run_with_heap_debug(
r#"<?php
$m = ['x' => ['y' => 'deep', 'z' => 'other'], 'w' => ['y' => 'second']];
for ($i = 0; $i < 200; $i++) {
$v = (string) $m['x']['y'];
}
echo $m['x']['y'];
"#,
);
assert!(out.success, "program failed: {}", out.stderr);
assert_eq!(out.stdout, "deep");
assert!(
out.stderr.contains("HEAP DEBUG: leak summary: clean"),
"expected a clean heap, got: {}",
out.stderr
);
}

/// Regression test for issue #516: a typed string extracted from a chained
/// read must remain valid when a later call argument releases the parent array.
#[test]
fn test_regression_516_chained_string_survives_parent_release() {
let out = compile_and_run_with_heap_debug(
r#"<?php
function replace_parent(array &$value): string {
$value = [];
return str_repeat('Z', 128);
}
function emit_pair(string $left, string $right): void {
echo $left;
echo $right;
}
$a = [[str_repeat('L', 128)]];
emit_pair($a[0][0], replace_parent($a));
"#,
);
assert!(out.success, "program failed: {}", out.stderr);
assert_eq!(
out.stdout,
format!("{}{}", "L".repeat(128), "Z".repeat(128))
);
assert!(
out.stderr.contains("HEAP DEBUG: leak summary: clean"),
"expected a clean heap, got: {}",
out.stderr
);
}
Loading
Loading