diff --git a/CHANGELOG.md b/CHANGELOG.md index ac862e5513..f11a511de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Releases are listed newest first. - Added PHP 8.3 typed class constants for classes, interfaces, traits, and enums. Declared types are enforced for initializers and inherited overrides, including covariant narrowing, and are exposed through `ReflectionClassConstant::hasType()` and `getType()`; untyped constants and enum cases retain PHP-compatible reflection defaults. - Fixed `--web --max-requests` crash-loop accounting (issue #516): workers that exit after a planned request-quota recycle now use a dedicated status that the master excludes from startup-death streaks, so sustained traffic with a low quota keeps respawning workers instead of shutting down the server; genuine setup failures, other exit codes, and signal deaths still feed the guard. - Fixed nullable chained array reads (issue #525): consuming a value from a `?array` receiver now releases the one-shot hidden owned Mixed temporary created by nullable access, on both null and non-null paths, without invalidating the extracted result or double-releasing repeated reads. +- Fixed chained container reads after a missing outer array offset (issues #526 and #554): indexed, associative, mixed-key, truthiness, `empty()`, `(bool)`, `count()`, spread-length, object-property, and method-call consumers now recognize null-container receivers before dereferencing them, preventing segfaults across every supported target. Direct nested reads emit PHP's missing-key and null-offset diagnostics, associative misses warn consistently, and invalid `count()`, spread, and method operations raise catchable PHP errors without evaluating skipped method arguments; `isset()`, `empty()`, and null coalescing remain silent across the full subscript chain, string-valued misses retain their null marker so `??` selects its default without conflating real empty strings, and `foreach` skips a missing container instead of crashing. - Fixed owned boxed Mixed temporaries leaking after string conversion (issue #527): explicit `(string)` casts plus implicit concatenation and interpolation now release detached Mixed sources, including by-value `foreach` element reads and non-null unions represented as Mixed; borrowed, persistent, moved, and non-heap values remain release no-ops. - Fixed boxed Mixed values leaking when nested loops reinitialize a local whose storage widens after lowering (issue #534): EIR now records a deferred `ReleaseLocalSlot` before the overwrite, prunes it for final scalar or ref-bound slots, and lowers it using the final slot representation. Cleanup is flow-sensitive across conditional ref-cell promotion, aliases, by-reference `foreach` and pointer paths, and widened parameters, preventing both missed releases and double frees with the EIR optimizer enabled or disabled. - Fixed function- and method-local indexed arrays leaking previous COW hash generations after promotion to string-keyed associative storage (issue #538): lowering now releases the boxed Mixed slot owner before consuming mutations and finalizes provisional concrete-load releases against the slot's final storage type. This preserves real COW aliases while avoiding an artificial clone on every insertion, leaving heap-debug clean with the EIR optimizer enabled or disabled across every supported target. diff --git a/docs/internals/builtins/misc/empty.md b/docs/internals/builtins/misc/empty.md index fe367e862f..6427743662 100644 --- a/docs/internals/builtins/misc/empty.md +++ b/docs/internals/builtins/misc/empty.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/types/signatures.rs`](https://github.com/illegalstudio/elephc/blob/main/src/types/signatures.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1216](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1216) (`lower_empty`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1232](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1232) (`lower_empty`) - **Function symbol**: `lower_empty()` diff --git a/docs/internals/builtins/string/strlen.md b/docs/internals/builtins/string/strlen.md index 872fc4ec8d..3f03332476 100644 --- a/docs/internals/builtins/string/strlen.md +++ b/docs/internals/builtins/string/strlen.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strlen.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strlen.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1077](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1077) (`lower_strlen`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1093](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1093) (`lower_strlen`) - **Function symbol**: `lower_strlen()` diff --git a/docs/internals/builtins/type/boolval.md b/docs/internals/builtins/type/boolval.md index 1230c0e181..46fec33146 100644 --- a/docs/internals/builtins/type/boolval.md +++ b/docs/internals/builtins/type/boolval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/boolval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/boolval.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1174](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1174) (`lower_boolval`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1190](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1190) (`lower_boolval`) - **Function symbol**: `lower_boolval()` diff --git a/docs/internals/builtins/type/floatval.md b/docs/internals/builtins/type/floatval.md index 813d03f851..aa5b0c5fba 100644 --- a/docs/internals/builtins/type/floatval.md +++ b/docs/internals/builtins/type/floatval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/floatval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/floatval.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1140](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1140) (`lower_floatval`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1156](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1156) (`lower_floatval`) - **Function symbol**: `lower_floatval()` diff --git a/docs/internals/builtins/type/intval.md b/docs/internals/builtins/type/intval.md index 8cde0f5d6b..32bdf511fa 100644 --- a/docs/internals/builtins/type/intval.md +++ b/docs/internals/builtins/type/intval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/intval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/intval.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1107](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1107) (`lower_intval`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1123](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1123) (`lower_intval`) - **Function symbol**: `lower_intval()` diff --git a/docs/internals/builtins/type/is_array.md b/docs/internals/builtins/type/is_array.md index 266b6a2189..723e633168 100644 --- a/docs/internals/builtins/type/is_array.md +++ b/docs/internals/builtins/type/is_array.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_array.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_array.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1601](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1601) (`lower_is_array`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1617](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1617) (`lower_is_array`) - **Function symbol**: `lower_is_array()` diff --git a/docs/internals/builtins/type/is_bool.md b/docs/internals/builtins/type/is_bool.md index c409247bc4..130c3d56c4 100644 --- a/docs/internals/builtins/type/is_bool.md +++ b/docs/internals/builtins/type/is_bool.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_bool.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_bool.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1334](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1334) (`lower_static_type_predicate`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1350](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1350) (`lower_static_type_predicate`) - **Function symbol**: `lower_static_type_predicate()` diff --git a/docs/internals/builtins/type/is_float.md b/docs/internals/builtins/type/is_float.md index 2a70b99832..191c5a11ca 100644 --- a/docs/internals/builtins/type/is_float.md +++ b/docs/internals/builtins/type/is_float.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_float.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_float.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1334](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1334) (`lower_static_type_predicate`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1350](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1350) (`lower_static_type_predicate`) - **Function symbol**: `lower_static_type_predicate()` diff --git a/docs/internals/builtins/type/is_int.md b/docs/internals/builtins/type/is_int.md index 60d6ab7819..d326a35c40 100644 --- a/docs/internals/builtins/type/is_int.md +++ b/docs/internals/builtins/type/is_int.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_int.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_int.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1334](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1334) (`lower_static_type_predicate`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1350](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1350) (`lower_static_type_predicate`) - **Function symbol**: `lower_static_type_predicate()` diff --git a/docs/internals/builtins/type/is_iterable.md b/docs/internals/builtins/type/is_iterable.md index 0ceb5c15e8..f5a387b916 100644 --- a/docs/internals/builtins/type/is_iterable.md +++ b/docs/internals/builtins/type/is_iterable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_iterable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_iterable.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1392](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1392) (`lower_is_iterable`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1408](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1408) (`lower_is_iterable`) - **Function symbol**: `lower_is_iterable()` diff --git a/docs/internals/builtins/type/is_null.md b/docs/internals/builtins/type/is_null.md index 43d925866b..3e1744fc7a 100644 --- a/docs/internals/builtins/type/is_null.md +++ b/docs/internals/builtins/type/is_null.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_null.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_null.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1591](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1591) (`lower_is_null_builtin`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1607](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1607) (`lower_is_null_builtin`) - **Function symbol**: `lower_is_null_builtin()` diff --git a/docs/internals/builtins/type/is_object.md b/docs/internals/builtins/type/is_object.md index ac062b6fc5..f38cc47b3a 100644 --- a/docs/internals/builtins/type/is_object.md +++ b/docs/internals/builtins/type/is_object.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_object.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_object.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1616](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1616) (`lower_is_object`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1632](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1632) (`lower_is_object`) - **Function symbol**: `lower_is_object()` diff --git a/docs/internals/builtins/type/is_scalar.md b/docs/internals/builtins/type/is_scalar.md index c2f88a9dea..d2e39f2879 100644 --- a/docs/internals/builtins/type/is_scalar.md +++ b/docs/internals/builtins/type/is_scalar.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_scalar.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_scalar.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1632](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1632) (`lower_is_scalar`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1648](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1648) (`lower_is_scalar`) - **Function symbol**: `lower_is_scalar()` diff --git a/docs/internals/builtins/type/is_string.md b/docs/internals/builtins/type/is_string.md index 1954c30c4a..365820d012 100644 --- a/docs/internals/builtins/type/is_string.md +++ b/docs/internals/builtins/type/is_string.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_string.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_string.rs) -- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1334](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1334) (`lower_static_type_predicate`) +- **Lowering**: [`src/codegen/lower_inst/builtins.rs`:1350](https://github.com/illegalstudio/elephc/blob/main/src/codegen/lower_inst/builtins.rs#L1350) (`lower_static_type_predicate`) - **Function symbol**: `lower_static_type_predicate()` diff --git a/scripts/docs/builtin_registry.json b/scripts/docs/builtin_registry.json index ffb7021d8d..8a8100caa7 100644 --- a/scripts/docs/builtin_registry.json +++ b/scripts/docs/builtin_registry.json @@ -4784,7 +4784,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_boolval", - "codegen_line": 1174, + "codegen_line": 1190, "notes": [ "Lowers `boolval()` using the same concrete scalar PHP truthiness rules as `IsTruthy`." ], @@ -7717,7 +7717,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_empty", - "codegen_line": 1216, + "codegen_line": 1232, "notes": [ "Lowers `empty()` for concrete scalar and array-like operands." ], @@ -9476,7 +9476,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_floatval", - "codegen_line": 1140, + "codegen_line": 1156, "notes": [ "Lowers `floatval()` for concrete scalar operands." ], @@ -14509,7 +14509,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_intval", - "codegen_line": 1107, + "codegen_line": 1123, "notes": [ "Lowers `intval()` for concrete scalar operands." ], @@ -14718,7 +14718,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_array", - "codegen_line": 1601, + "codegen_line": 1617, "notes": [ "Lowers `is_array()`: true for statically-known arrays/hashes, or a boxed Mixed/Union value", "whose runtime tag is an indexed (4) or associative (5) array. An `iterable`-typed value is", @@ -14779,7 +14779,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_static_type_predicate", - "codegen_line": 1334, + "codegen_line": 1350, "notes": [ "Lowers a static `is_*` predicate for concrete non-Mixed values." ], @@ -15161,7 +15161,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_static_type_predicate", - "codegen_line": 1334, + "codegen_line": 1350, "notes": [ "Lowers a static `is_*` predicate for concrete non-Mixed values." ], @@ -15279,7 +15279,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_static_type_predicate", - "codegen_line": 1334, + "codegen_line": 1350, "notes": [ "Lowers a static `is_*` predicate for concrete non-Mixed values." ], @@ -15338,7 +15338,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_iterable", - "codegen_line": 1392, + "codegen_line": 1408, "notes": [ "Lowers `is_iterable()` for concrete values and boxed Mixed payloads." ], @@ -15520,7 +15520,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_null_builtin", - "codegen_line": 1591, + "codegen_line": 1607, "notes": [ "Lowers `is_null()` for concrete scalar values and boxed Mixed payloads." ], @@ -15638,7 +15638,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_object", - "codegen_line": 1616, + "codegen_line": 1632, "notes": [ "Lowers `is_object()`: true for statically-known objects, or a boxed Mixed/Union value whose", "runtime tag is an object (6)." @@ -15820,7 +15820,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_is_scalar", - "codegen_line": 1632, + "codegen_line": 1648, "notes": [ "Lowers `is_scalar()`: true for int/float/string/bool, a non-null tagged scalar, or a boxed", "Mixed/Union value whose runtime tag is int (0), string (1), float (2), or bool (3). Null,", @@ -15881,7 +15881,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_static_type_predicate", - "codegen_line": 1334, + "codegen_line": 1350, "notes": [ "Lowers a static `is_*` predicate for concrete non-Mixed values." ], @@ -27868,7 +27868,7 @@ "checker_line": null, "codegen_file": "src/codegen/lower_inst/builtins.rs", "codegen_function": "lower_strlen", - "codegen_line": 1077, + "codegen_line": 1093, "notes": [ "Lowers `strlen()` by coercing string-like values and returning the byte length." ], diff --git a/src/codegen/lower_inst.rs b/src/codegen/lower_inst.rs index afe4ed6538..4dff94df3c 100644 --- a/src/codegen/lower_inst.rs +++ b/src/codegen/lower_inst.rs @@ -40,6 +40,7 @@ mod callables; mod comparisons; mod conversions; mod enums; +mod exceptions; mod externs; mod floats; mod hashes; @@ -151,7 +152,8 @@ pub(super) fn lower_instruction(ctx: &mut FunctionContext<'_>, inst_id: InstId) Op::ArrayToHash => arrays::lower_array_to_hash(ctx, &inst), Op::HashNew => hashes::lower_hash_new(ctx, &inst), Op::HashLen => hashes::lower_hash_len(ctx, &inst), - Op::HashGet => hashes::lower_hash_get(ctx, &inst), + Op::HashGet => hashes::lower_hash_get(ctx, &inst, true), + Op::HashGetSilent => hashes::lower_hash_get(ctx, &inst, false), Op::HashIsset => builtins::lower_hash_isset(ctx, &inst), Op::HashSet => hashes::lower_hash_set(ctx, &inst), Op::HashUnset => hashes::lower_hash_unset(ctx, &inst), @@ -234,6 +236,8 @@ pub(super) fn lower_instruction(ctx: &mut FunctionContext<'_>, inst_id: InstId) Op::EchoValue => lower_echo_value(ctx, &inst), Op::PrintValue => lower_print_value(ctx, &inst), Op::ThrowException => lower_throw_exception(ctx, &inst), + Op::ThrowError => lower_throw_error(ctx, &inst), + Op::ThrowErrorValue => lower_throw_error_value(ctx, &inst), Op::TryPushHandler => lower_try_push_handler(ctx, &inst), Op::TryPopHandler => lower_try_pop_handler(ctx, &inst), Op::CatchCurrent => lower_catch_current(ctx, &inst), @@ -2787,6 +2791,32 @@ fn lower_throw_exception(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> R super::lower_term::lower_throw_value(ctx, value) } +/// Lowers a static-message catchable PHP `Error` without evaluating later operands. +fn lower_throw_error(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { + if !inst.operands.is_empty() { + return Err(CodegenIrError::invalid_module(format!( + "{} expects no operands", + inst.op.name() + ))); + } + let data = expect_data(inst)?; + let message = ctx + .module + .data + .strings + .get(data.as_raw() as usize) + .ok_or_else(|| CodegenIrError::missing_entry("data string", data.as_raw()))? + .clone(); + exceptions::emit_error(ctx, &message); + Ok(()) +} + +/// Lowers a runtime-string catchable PHP `Error` without evaluating later operands. +fn lower_throw_error_value(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { + let message = expect_operand(inst, 0)?; + exceptions::emit_error_value(ctx, message) +} + /// Pushes an EIR exception handler and branches to the handler block after `longjmp`. fn lower_try_push_handler(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { let token = expect_i64(inst)?; @@ -2899,6 +2929,7 @@ fn lower_method_call(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Resul object_ty ))); }; + guard_static_method_receiver(ctx, object, &method_name)?; if let Some(state) = fiber_state_predicate(&class_name, &method_name) { return lower_fiber_state_predicate(ctx, inst, object, state); } @@ -2964,6 +2995,30 @@ fn lower_method_call(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Resul emit_ref_arg_writebacks(ctx, &call_args.ref_writebacks) } +/// Rejects the raw null-container representation before a static object method dispatch. +fn guard_static_method_receiver( + ctx: &mut FunctionContext<'_>, + object: ValueId, + method_name: &str, +) -> Result<()> { + let receiver_reg = abi::symbol_scratch_reg(ctx.emitter); + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + let null_label = ctx.next_label("static_method_receiver_null"); + let done_label = ctx.next_label("static_method_receiver_checked"); + ctx.load_value_to_reg(object, receiver_reg)?; + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + receiver_reg, + scratch_reg, + &null_label, + ); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); + emit_method_call_on_null_fatal(ctx, method_name); + ctx.emitter.label(&done_label); + Ok(()) +} + /// Lowers an instance-method call whose receiver is boxed as `Mixed`. fn lower_mixed_method_call( ctx: &mut FunctionContext<'_>, @@ -3353,31 +3408,10 @@ fn lower_nullable_receiver_interface_method_call( /// Emits PHP's fatal diagnostic for calling an instance method on null. fn emit_method_call_on_null_fatal(ctx: &mut FunctionContext<'_>, method_name: &str) { - let message = format!( - "Fatal error: Call to a member function {}() on null\n", - method_name + exceptions::emit_error( + ctx, + &format!("Call to a member function {}() on null", method_name), ); - let (message_label, message_len) = ctx.data.add_string(message.as_bytes()); - match ctx.emitter.target.arch { - Arch::AArch64 => { - ctx.emitter.instruction("mov x0, #2"); // write the member-call-on-null fatal to stderr - ctx.emitter.adrp("x1", &message_label); - ctx.emitter.add_lo12("x1", "x1", &message_label); - ctx.emitter - .instruction(&format!("mov x2, #{}", message_len)); // pass the member-call-on-null fatal byte length - ctx.emitter.syscall(4); - abi::emit_exit(ctx.emitter, 1); - } - Arch::X86_64 => { - ctx.emitter.instruction("mov edi, 2"); // write the member-call-on-null fatal to Linux stderr - abi::emit_symbol_address(ctx.emitter, "rsi", &message_label); - ctx.emitter - .instruction(&format!("mov edx, {}", message_len)); // pass the member-call-on-null fatal byte length - ctx.emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write - ctx.emitter.instruction("syscall"); // emit the member-call-on-null fatal before exiting - abi::emit_exit(ctx.emitter, 1); - } - } } /// Returns the direct runtime intrinsic for built-in `Generator` instance methods. diff --git a/src/codegen/lower_inst/arrays.rs b/src/codegen/lower_inst/arrays.rs index d649f0c1e1..7185e22ada 100644 --- a/src/codegen/lower_inst/arrays.rs +++ b/src/codegen/lower_inst/arrays.rs @@ -58,7 +58,23 @@ pub(super) fn lower_array_len(ctx: &mut FunctionContext<'_>, inst: &Instruction) let array = expect_operand(inst, 0)?; require_indexed_array(ctx.load_value_to_result(array)?, inst)?; let result_reg = abi::int_result_reg(ctx.emitter); + let null_label = ctx.next_label("array_len_null"); + let done_label = ctx.next_label("array_len_done"); + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + result_reg, + scratch_reg, + &null_label, + ); abi::emit_load_from_address(ctx.emitter, result_reg, result_reg, 0); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); + super::exceptions::emit_error( + ctx, + "Only arrays and Traversables can be unpacked, null given", + ); + ctx.emitter.label(&done_label); store_if_result(ctx, inst) } @@ -616,8 +632,17 @@ fn lower_array_get_aarch64( ctx.load_value_to_reg(index, result_reg)?; ctx.load_value_to_reg(array, array_reg)?; let null_label = ctx.next_label("array_get_null"); + let null_receiver_label = ctx.next_label("array_get_null_recv"); + let fallback_label = ctx.next_label("array_get_fallback"); let done_label = ctx.next_label("array_get_done"); + // -- guard the receiver: a missed outer read carries a null/sentinel container -- + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + array_reg, + len_reg, + &null_receiver_label, + ); ctx.emitter.instruction(&format!("cmp {}, #0", result_reg)); // check whether the indexed-array offset is negative ctx.emitter.instruction(&format!("b.lt {}", null_label)); // negative indexed-array offsets read as null abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); @@ -629,6 +654,12 @@ fn lower_array_get_aarch64( if warn_on_missing { emit_undefined_array_key_warning(ctx); } + abi::emit_jump(ctx.emitter, &fallback_label); + ctx.emitter.label(&null_receiver_label); + if warn_on_missing { + emit_array_offset_on_null_warning(ctx); + } + ctx.emitter.label(&fallback_label); emit_array_get_null_fallback(ctx, result_ty); ctx.emitter.label(&done_label); store_if_result(ctx, inst) @@ -695,8 +726,17 @@ fn lower_array_get_x86_64( ctx.load_value_to_reg(array, array_reg)?; ctx.load_value_to_reg(index, result_reg)?; let null_label = ctx.next_label("array_get_null"); + let null_receiver_label = ctx.next_label("array_get_null_recv"); + let fallback_label = ctx.next_label("array_get_fallback"); let done_label = ctx.next_label("array_get_done"); + // -- guard the receiver: a missed outer read carries a null/sentinel container -- + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + array_reg, + len_reg, + &null_receiver_label, + ); ctx.emitter.instruction(&format!("cmp {}, 0", result_reg)); // check whether the indexed-array offset is negative ctx.emitter.instruction(&format!("jl {}", null_label)); // negative indexed-array offsets read as null abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); @@ -708,6 +748,12 @@ fn lower_array_get_x86_64( if warn_on_missing { emit_undefined_array_key_warning(ctx); } + abi::emit_jump(ctx.emitter, &fallback_label); + ctx.emitter.label(&null_receiver_label); + if warn_on_missing { + emit_array_offset_on_null_warning(ctx); + } + ctx.emitter.label(&fallback_label); emit_array_get_null_fallback(ctx, result_ty); ctx.emitter.label(&done_label); store_if_result(ctx, inst) @@ -986,8 +1032,13 @@ fn emit_undefined_array_key_warning(ctx: &mut FunctionContext<'_>) { abi::emit_call_label(ctx.emitter, "__rt_warn_undefined_array_key_int"); } +/// Emits PHP's warning for a direct array-offset read whose receiver is null. +pub(super) fn emit_array_offset_on_null_warning(ctx: &mut FunctionContext<'_>) { + abi::emit_call_label(ctx.emitter, "__rt_warn_array_offset_on_null"); +} + /// Emits the null/miss fallback in the result shape expected by the array element type. -fn emit_array_get_null_fallback(ctx: &mut FunctionContext<'_>, elem_ty: &PhpType) { +pub(super) fn emit_array_get_null_fallback(ctx: &mut FunctionContext<'_>, elem_ty: &PhpType) { match elem_ty { PhpType::TaggedScalar => { crate::codegen::sentinels::emit_tagged_scalar_null(ctx.emitter); @@ -1002,7 +1053,11 @@ fn emit_array_get_null_fallback(ctx: &mut FunctionContext<'_>, elem_ty: &PhpType }, PhpType::Str => { let (ptr_reg, len_reg) = abi::string_result_regs(ctx.emitter); - abi::emit_load_int_immediate(ctx.emitter, ptr_reg, 0); + abi::emit_load_int_immediate( + ctx.emitter, + ptr_reg, + crate::codegen::NULL_SENTINEL, + ); abi::emit_load_int_immediate(ctx.emitter, len_reg, 0); } PhpType::Mixed => match ctx.emitter.target.arch { diff --git a/src/codegen/lower_inst/builtins.rs b/src/codegen/lower_inst/builtins.rs index f189924150..7b99d7c392 100644 --- a/src/codegen/lower_inst/builtins.rs +++ b/src/codegen/lower_inst/builtins.rs @@ -1028,7 +1028,23 @@ pub(crate) fn lower_count(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> PhpType::Array(_) | PhpType::AssocArray { .. } => { ctx.load_value_to_result(value)?; let result_reg = abi::int_result_reg(ctx.emitter); + let null_label = ctx.next_label("count_null_container"); + let done_label = ctx.next_label("count_done"); + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + result_reg, + scratch_reg, + &null_label, + ); abi::emit_load_from_address(ctx.emitter, result_reg, result_reg, 0); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); + super::exceptions::emit_type_error( + ctx, + "count(): Argument #1 ($value) must be of type Countable|array, null given", + ); + ctx.emitter.label(&done_label); store_if_result(ctx, inst) } PhpType::Mixed | PhpType::Union(_) => { diff --git a/src/codegen/lower_inst/builtins/isset.rs b/src/codegen/lower_inst/builtins/isset.rs index c5a3b4acc7..09161a2665 100644 --- a/src/codegen/lower_inst/builtins/isset.rs +++ b/src/codegen/lower_inst/builtins/isset.rs @@ -74,7 +74,9 @@ fn emit_isset_missing_result(ctx: &mut FunctionContext<'_>, value: ValueId) -> R if let Some(inst) = source_instruction(ctx, value)? { match inst.op { Op::StrCharAt => return emit_isset_string_offset_missing_result(ctx, &inst), - Op::ArrayGet => return emit_isset_array_offset_missing_result(ctx, &inst), + Op::ArrayGet | Op::ArrayGetSilent => { + return emit_isset_array_offset_missing_result(ctx, &inst) + } Op::HashGet => return emit_isset_hash_offset_missing_result(ctx, &inst), _ => {} } @@ -317,6 +319,8 @@ fn emit_isset_array_offset_missing_aarch64( let result_reg = abi::int_result_reg(ctx.emitter); ctx.load_value_to_reg(index, result_reg)?; ctx.load_value_to_reg(array, array_reg)?; + // -- guard the receiver: a missed outer read carries a null/sentinel container -- + crate::codegen::sentinels::emit_branch_if_null_container(ctx.emitter, array_reg, len_reg, missing); ctx.emitter.instruction(&format!("cmp {}, #0", result_reg)); // reject negative indexes as missing array elements ctx.emitter.instruction(&format!("b.lt {}", missing)); // missing indexes make isset return false abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); @@ -344,6 +348,8 @@ fn emit_isset_array_offset_missing_x86_64( let result_reg = abi::int_result_reg(ctx.emitter); ctx.load_value_to_reg(array, array_reg)?; ctx.load_value_to_reg(index, result_reg)?; + // -- guard the receiver: a missed outer read carries a null/sentinel container -- + crate::codegen::sentinels::emit_branch_if_null_container(ctx.emitter, array_reg, len_reg, missing); ctx.emitter.instruction(&format!("cmp {}, 0", result_reg)); // reject negative indexes as missing array elements ctx.emitter.instruction(&format!("jl {}", missing)); // missing indexes make isset return false abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); @@ -467,17 +473,22 @@ fn require_isset_integer_like_index( } } -/// Returns the instruction that produced an SSA value, when it has one. -fn source_instruction(ctx: &FunctionContext<'_>, value: ValueId) -> Result> { - let Some(value_ref) = ctx.function.value(value) else { - return Err(CodegenIrError::missing_entry("value", value.as_raw())); - }; - let ValueDef::Instruction { inst, .. } = value_ref.def else { - return Ok(None); - }; - let inst_ref = ctx - .function - .instruction(inst) - .ok_or_else(|| CodegenIrError::missing_entry("instruction", inst.as_raw()))?; - Ok(Some(inst_ref.clone())) +/// Returns the meaningful producer of an SSA value, looking through ownership acquires. +fn source_instruction(ctx: &FunctionContext<'_>, mut value: ValueId) -> Result> { + loop { + let Some(value_ref) = ctx.function.value(value) else { + return Err(CodegenIrError::missing_entry("value", value.as_raw())); + }; + let ValueDef::Instruction { inst, .. } = value_ref.def else { + return Ok(None); + }; + let inst_ref = ctx + .function + .instruction(inst) + .ok_or_else(|| CodegenIrError::missing_entry("instruction", inst.as_raw()))?; + if inst_ref.op != Op::Acquire { + return Ok(Some(inst_ref.clone())); + } + value = expect_operand(inst_ref, 0)?; + } } diff --git a/src/codegen/lower_inst/exceptions.rs b/src/codegen/lower_inst/exceptions.rs new file mode 100644 index 0000000000..2f6846e968 --- /dev/null +++ b/src/codegen/lower_inst/exceptions.rs @@ -0,0 +1,201 @@ +//! Purpose: +//! Emits catchable built-in `Error` and `TypeError` objects for codegen guards. +//! +//! Called from: +//! - EIR instruction lowerers that detect PHP runtime type/null errors. +//! +//! Key details: +//! - Active handlers receive a normal throwable through `__rt_throw_current`. +//! - Unhandled errors keep a specific PHP-style fatal diagnostic instead of the +//! runtime unwinder's generic uncaught-exception fallback. + +use crate::codegen::abi; +use crate::codegen::platform::Arch; +use crate::ir::ValueId; + +use super::super::context::FunctionContext; +use super::super::Result; + +/// Throws a catchable PHP `Error` carrying a static message. +pub(super) fn emit_error(ctx: &mut FunctionContext<'_>, message: &str) { + emit_static_exception(ctx, "Error", "_spl_error_class_id", message); +} + +/// Throws a catchable PHP `TypeError` carrying a static message. +pub(super) fn emit_type_error(ctx: &mut FunctionContext<'_>, message: &str) { + emit_static_exception(ctx, "TypeError", "_spl_type_error_class_id", message); +} + +/// Throws a catchable PHP `Error` whose message is a runtime string value. +pub(super) fn emit_error_value(ctx: &mut FunctionContext<'_>, message: ValueId) -> Result<()> { + let (message_ptr_reg, message_len_reg) = abi::string_result_regs(ctx.emitter); + ctx.load_string_value_to_regs(message, message_ptr_reg, message_len_reg)?; + abi::emit_push_reg_pair(ctx.emitter, message_ptr_reg, message_len_reg); + emit_uncaught_dynamic_error_fatal_if_no_handler(ctx); + emit_dynamic_error_object(ctx); + Ok(()) +} + +/// Allocates one built-in throwable and transfers control to the standard unwinder. +fn emit_static_exception( + ctx: &mut FunctionContext<'_>, + class_name: &str, + class_id_symbol: &str, + message: &str, +) { + let fatal_message = format!("Fatal error: Uncaught {}: {}\n", class_name, message); + let (fatal_label, fatal_len) = ctx.data.add_string(fatal_message.as_bytes()); + emit_uncaught_exception_fatal_if_no_handler(ctx, &fatal_label, fatal_len); + + let (message_label, message_len) = ctx.data.add_string(message.as_bytes()); + match ctx.emitter.target.arch { + Arch::AArch64 => { + abi::emit_load_int_immediate(ctx.emitter, "x0", 32); + abi::emit_call_label(ctx.emitter, "__rt_heap_alloc"); + ctx.emitter.instruction("mov x9, #6"); // heap kind 6 = throwable object instance + ctx.emitter.instruction("str x9, [x0, #-8]"); // stamp the allocation as a runtime object + abi::emit_load_symbol_to_reg(ctx.emitter, "x9", class_id_symbol, 0); + ctx.emitter.instruction("str x9, [x0]"); // store the built-in throwable class id + abi::emit_symbol_address(ctx.emitter, "x9", &message_label); + ctx.emitter.instruction("str x9, [x0, #8]"); // store the static exception message pointer + abi::emit_load_int_immediate(ctx.emitter, "x9", message_len as i64); + ctx.emitter.instruction("str x9, [x0, #16]"); // store the exception message length + ctx.emitter.instruction("str xzr, [x0, #24]"); // exception code defaults to zero + abi::emit_store_reg_to_symbol(ctx.emitter, "x0", "_exc_value", 0); + abi::emit_jump(ctx.emitter, "__rt_throw_current"); + } + Arch::X86_64 => { + abi::emit_load_int_immediate(ctx.emitter, "rax", 32); + abi::emit_call_label(ctx.emitter, "__rt_heap_alloc"); + ctx.emitter.instruction("mov r10, 0x4548504c00000006"); // x86_64 heap kind 6 with the runtime magic marker + ctx.emitter.instruction("mov QWORD PTR [rax - 8], r10"); // stamp the allocation as a runtime object + abi::emit_load_symbol_to_reg(ctx.emitter, "r10", class_id_symbol, 0); + ctx.emitter.instruction("mov QWORD PTR [rax], r10"); // store the built-in throwable class id + abi::emit_symbol_address(ctx.emitter, "r10", &message_label); + ctx.emitter.instruction("mov QWORD PTR [rax + 8], r10"); // store the static exception message pointer + abi::emit_load_int_immediate(ctx.emitter, "r10", message_len as i64); + ctx.emitter.instruction("mov QWORD PTR [rax + 16], r10"); // store the exception message length + ctx.emitter.instruction("mov QWORD PTR [rax + 24], 0"); // exception code defaults to zero + abi::emit_store_reg_to_symbol(ctx.emitter, "rax", "_exc_value", 0); + abi::emit_jump(ctx.emitter, "__rt_throw_current"); + } + } +} + +/// Writes the specific uncaught diagnostic and exits when no catch handler is active. +fn emit_uncaught_exception_fatal_if_no_handler( + ctx: &mut FunctionContext<'_>, + fatal_label: &str, + fatal_len: usize, +) { + let throw_label = ctx.next_label("static_exception_throw"); + match ctx.emitter.target.arch { + Arch::AArch64 => { + abi::emit_load_symbol_to_reg(ctx.emitter, "x9", "_exc_handler_top", 0); + ctx.emitter.instruction(&format!("cbnz x9, {}", throw_label)); // use the standard unwinder when a catch handler is active + abi::emit_symbol_address(ctx.emitter, "x1", fatal_label); + abi::emit_load_int_immediate(ctx.emitter, "x2", fatal_len as i64); + ctx.emitter.instruction("mov x0, #2"); // write the uncaught PHP diagnostic to stderr + ctx.emitter.syscall(4); + abi::emit_exit(ctx.emitter, 1); + } + Arch::X86_64 => { + abi::emit_load_symbol_to_reg(ctx.emitter, "r10", "_exc_handler_top", 0); + ctx.emitter.instruction("test r10, r10"); // check whether a catch handler is active + ctx.emitter.instruction(&format!("jnz {}", throw_label)); // use the standard unwinder when a handler can receive the error + abi::emit_symbol_address(ctx.emitter, "rsi", fatal_label); + abi::emit_load_int_immediate(ctx.emitter, "rdx", fatal_len as i64); + ctx.emitter.instruction("mov edi, 2"); // write the uncaught PHP diagnostic to stderr + ctx.emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write + ctx.emitter.instruction("syscall"); // emit the specific fatal message + abi::emit_exit(ctx.emitter, 1); + } + } + ctx.emitter.label(&throw_label); +} + +/// Writes an uncaught dynamic `Error` diagnostic, or continues when a handler exists. +fn emit_uncaught_dynamic_error_fatal_if_no_handler(ctx: &mut FunctionContext<'_>) { + let throw_label = ctx.next_label("dynamic_error_throw"); + let (prefix_label, prefix_len) = ctx.data.add_string(b"Fatal error: Uncaught Error: "); + let (suffix_label, suffix_len) = ctx.data.add_string(b"\n"); + match ctx.emitter.target.arch { + Arch::AArch64 => { + abi::emit_load_symbol_to_reg(ctx.emitter, "x9", "_exc_handler_top", 0); + ctx.emitter.instruction(&format!("cbnz x9, {}", throw_label)); // use the standard unwinder when a catch handler is active + ctx.emitter.instruction("mov x0, #2"); // write the uncaught dynamic-error prefix to stderr + abi::emit_symbol_address(ctx.emitter, "x1", &prefix_label); + abi::emit_load_int_immediate(ctx.emitter, "x2", prefix_len as i64); + ctx.emitter.syscall(4); + ctx.emitter.instruction("mov x0, #2"); // write the runtime error message to stderr + abi::emit_load_temporary_stack_slot(ctx.emitter, "x1", 0); + abi::emit_load_temporary_stack_slot(ctx.emitter, "x2", 8); + ctx.emitter.syscall(4); + ctx.emitter.instruction("mov x0, #2"); // terminate the uncaught diagnostic with a newline + abi::emit_symbol_address(ctx.emitter, "x1", &suffix_label); + abi::emit_load_int_immediate(ctx.emitter, "x2", suffix_len as i64); + ctx.emitter.syscall(4); + abi::emit_exit(ctx.emitter, 1); + } + Arch::X86_64 => { + abi::emit_load_symbol_to_reg(ctx.emitter, "r10", "_exc_handler_top", 0); + ctx.emitter.instruction("test r10, r10"); // check whether a catch handler is active + ctx.emitter.instruction(&format!("jnz {}", throw_label)); // use the standard unwinder when a handler can receive the error + abi::emit_symbol_address(ctx.emitter, "rsi", &prefix_label); + abi::emit_load_int_immediate(ctx.emitter, "rdx", prefix_len as i64); + ctx.emitter.instruction("mov edi, 2"); // write the uncaught dynamic-error prefix to stderr + ctx.emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write + ctx.emitter.instruction("syscall"); // emit the dynamic-error prefix + abi::emit_load_temporary_stack_slot(ctx.emitter, "rsi", 0); + abi::emit_load_temporary_stack_slot(ctx.emitter, "rdx", 8); + ctx.emitter.instruction("mov edi, 2"); // write the runtime error message to stderr + ctx.emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write + ctx.emitter.instruction("syscall"); // emit the runtime error message + abi::emit_symbol_address(ctx.emitter, "rsi", &suffix_label); + abi::emit_load_int_immediate(ctx.emitter, "rdx", suffix_len as i64); + ctx.emitter.instruction("mov edi, 2"); // terminate the uncaught diagnostic with a newline + ctx.emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write + ctx.emitter.instruction("syscall"); // emit the dynamic-error suffix + abi::emit_exit(ctx.emitter, 1); + } + } + ctx.emitter.label(&throw_label); +} + +/// Allocates a built-in `Error` that owns the runtime message stored on the stack. +fn emit_dynamic_error_object(ctx: &mut FunctionContext<'_>) { + match ctx.emitter.target.arch { + Arch::AArch64 => { + abi::emit_load_int_immediate(ctx.emitter, "x0", 32); + abi::emit_call_label(ctx.emitter, "__rt_heap_alloc"); + ctx.emitter.instruction("mov x9, #6"); // heap kind 6 = throwable object instance + ctx.emitter.instruction("str x9, [x0, #-8]"); // stamp the allocation as a runtime object + abi::emit_load_symbol_to_reg(ctx.emitter, "x9", "_spl_error_class_id", 0); + ctx.emitter.instruction("str x9, [x0]"); // store the built-in Error class id + abi::emit_load_temporary_stack_slot(ctx.emitter, "x9", 0); + ctx.emitter.instruction("str x9, [x0, #8]"); // store the runtime exception message pointer + abi::emit_load_temporary_stack_slot(ctx.emitter, "x9", 8); + ctx.emitter.instruction("str x9, [x0, #16]"); // store the runtime exception message length + ctx.emitter.instruction("str xzr, [x0, #24]"); // exception code defaults to zero + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_store_reg_to_symbol(ctx.emitter, "x0", "_exc_value", 0); + abi::emit_jump(ctx.emitter, "__rt_throw_current"); + } + Arch::X86_64 => { + abi::emit_load_int_immediate(ctx.emitter, "rax", 32); + abi::emit_call_label(ctx.emitter, "__rt_heap_alloc"); + ctx.emitter.instruction("mov r10, 0x4548504c00000006"); // x86_64 heap kind 6 with the runtime magic marker + ctx.emitter.instruction("mov QWORD PTR [rax - 8], r10"); // stamp the allocation as a runtime object + abi::emit_load_symbol_to_reg(ctx.emitter, "r10", "_spl_error_class_id", 0); + ctx.emitter.instruction("mov QWORD PTR [rax], r10"); // store the built-in Error class id + abi::emit_load_temporary_stack_slot(ctx.emitter, "r10", 0); + ctx.emitter.instruction("mov QWORD PTR [rax + 8], r10"); // store the runtime exception message pointer + abi::emit_load_temporary_stack_slot(ctx.emitter, "r10", 8); + ctx.emitter.instruction("mov QWORD PTR [rax + 16], r10"); // store the runtime exception message length + ctx.emitter.instruction("mov QWORD PTR [rax + 24], 0"); // exception code defaults to zero + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_store_reg_to_symbol(ctx.emitter, "rax", "_exc_value", 0); + abi::emit_jump(ctx.emitter, "__rt_throw_current"); + } + } +} diff --git a/src/codegen/lower_inst/hashes.rs b/src/codegen/lower_inst/hashes.rs index 23ff001ace..1699b3bc3a 100644 --- a/src/codegen/lower_inst/hashes.rs +++ b/src/codegen/lower_inst/hashes.rs @@ -46,7 +46,23 @@ pub(super) fn lower_hash_len(ctx: &mut FunctionContext<'_>, inst: &Instruction) let hash = expect_operand(inst, 0)?; require_hash(ctx.load_value_to_result(hash)?, inst)?; let result_reg = abi::int_result_reg(ctx.emitter); + let null_label = ctx.next_label("hash_len_null"); + let done_label = ctx.next_label("hash_len_done"); + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + result_reg, + scratch_reg, + &null_label, + ); abi::emit_load_from_address(ctx.emitter, result_reg, result_reg, 0); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); + super::exceptions::emit_error( + ctx, + "Only arrays and Traversables can be unpacked, null given", + ); + ctx.emitter.label(&done_label); store_if_result(ctx, inst) } @@ -74,15 +90,23 @@ pub(super) fn lower_hash_to_mixed(ctx: &mut FunctionContext<'_>, inst: &Instruct } /// Lowers an associative-array lookup with PHP null-sentinel fallback on misses. -pub(super) fn lower_hash_get(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { +pub(super) fn lower_hash_get( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + warn_on_missing: bool, +) -> Result<()> { let hash = expect_operand(inst, 0)?; let key = expect_operand(inst, 1)?; let value_ty = assoc_value_type(&ctx.value_php_type(hash)?, inst)?; require_hash_get_result(&value_ty, inst)?; let result_ty = inst.result_php_type.codegen_repr(); match ctx.emitter.target.arch { - Arch::AArch64 => lower_hash_get_aarch64(ctx, inst, hash, key, &value_ty, &result_ty), - Arch::X86_64 => lower_hash_get_x86_64(ctx, inst, hash, key, &value_ty, &result_ty), + Arch::AArch64 => { + lower_hash_get_aarch64(ctx, inst, hash, key, &value_ty, &result_ty, warn_on_missing) + } + Arch::X86_64 => { + lower_hash_get_x86_64(ctx, inst, hash, key, &value_ty, &result_ty, warn_on_missing) + } } } @@ -259,16 +283,34 @@ fn lower_hash_get_aarch64( key: ValueId, value_ty: &PhpType, result_ty: &PhpType, + warn_on_missing: bool, ) -> Result<()> { materialize_hash_key_aarch64(ctx, key)?; ctx.load_value_to_reg(hash, "x0")?; - abi::emit_call_label(ctx.emitter, "__rt_hash_get"); let miss = ctx.next_label("hash_get_miss"); + let null_receiver = ctx.next_label("hash_get_null_recv"); + let fallback = ctx.next_label("hash_get_fallback"); let done = ctx.next_label("hash_get_done"); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + "x0", + "x9", + &null_receiver, + ); + abi::emit_call_label(ctx.emitter, "__rt_hash_get"); ctx.emitter.instruction(&format!("cbz x0, {}", miss)); // branch to the null fallback when the associative lookup misses emit_hash_get_success_aarch64(ctx, value_ty, result_ty)?; ctx.emitter.instruction(&format!("b {}", done)); // skip the miss fallback after materializing the hash value ctx.emitter.label(&miss); + if warn_on_missing { + emit_undefined_hash_key_warning_aarch64(ctx, key)?; + } + abi::emit_jump(ctx.emitter, &fallback); + ctx.emitter.label(&null_receiver); + if warn_on_missing { + super::arrays::emit_array_offset_on_null_warning(ctx); + } + ctx.emitter.label(&fallback); emit_hash_get_miss(ctx, result_ty); ctx.emitter.label(&done); store_if_result(ctx, inst) @@ -282,17 +324,35 @@ fn lower_hash_get_x86_64( key: ValueId, value_ty: &PhpType, result_ty: &PhpType, + warn_on_missing: bool, ) -> Result<()> { materialize_hash_key_x86_64(ctx, key)?; ctx.load_value_to_reg(hash, "rdi")?; - abi::emit_call_label(ctx.emitter, "__rt_hash_get"); let miss = ctx.next_label("hash_get_miss"); + let null_receiver = ctx.next_label("hash_get_null_recv"); + let fallback = ctx.next_label("hash_get_fallback"); let done = ctx.next_label("hash_get_done"); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + "rdi", + "r9", + &null_receiver, + ); + abi::emit_call_label(ctx.emitter, "__rt_hash_get"); ctx.emitter.instruction("test rax, rax"); // check whether the associative lookup found a matching key ctx.emitter.instruction(&format!("jz {}", miss)); // branch to the null fallback when the associative lookup misses emit_hash_get_success_x86_64(ctx, value_ty, result_ty)?; ctx.emitter.instruction(&format!("jmp {}", done)); // skip the miss fallback after materializing the hash value ctx.emitter.label(&miss); + if warn_on_missing { + emit_undefined_hash_key_warning_x86_64(ctx, key)?; + } + abi::emit_jump(ctx.emitter, &fallback); + ctx.emitter.label(&null_receiver); + if warn_on_missing { + super::arrays::emit_array_offset_on_null_warning(ctx); + } + ctx.emitter.label(&fallback); emit_hash_get_miss(ctx, result_ty); ctx.emitter.label(&done); store_if_result(ctx, inst) @@ -528,6 +588,46 @@ pub(super) fn materialize_hash_key_x86_64(ctx: &mut FunctionContext<'_>, key: Va } } +/// Emits PHP's undefined-key warning for a normalized associative key on AArch64. +fn emit_undefined_hash_key_warning_aarch64( + ctx: &mut FunctionContext<'_>, + key: ValueId, +) -> Result<()> { + let integer_label = ctx.next_label("hash_warn_integer_key"); + let done_label = ctx.next_label("hash_warn_key_done"); + materialize_hash_key_aarch64(ctx, key)?; + ctx.emitter.instruction("cmn x2, #1"); // integer hash keys carry key_hi = -1 + ctx.emitter.instruction(&format!("b.eq {}", integer_label)); // select the decimal undefined-key warning + abi::emit_call_label(ctx.emitter, "__rt_warn_undefined_array_key_str"); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&integer_label); + ctx.emitter.instruction("mov x0, x1"); // pass the missing integer key in the warning ABI result register + abi::emit_call_label(ctx.emitter, "__rt_warn_undefined_array_key_int"); + ctx.emitter.label(&done_label); + Ok(()) +} + +/// Emits PHP's undefined-key warning for a normalized associative key on x86_64. +fn emit_undefined_hash_key_warning_x86_64( + ctx: &mut FunctionContext<'_>, + key: ValueId, +) -> Result<()> { + let integer_label = ctx.next_label("hash_warn_integer_key"); + let done_label = ctx.next_label("hash_warn_key_done"); + materialize_hash_key_x86_64(ctx, key)?; + ctx.emitter.instruction("cmp rdx, -1"); // integer hash keys carry key_hi = -1 + ctx.emitter.instruction(&format!("je {}", integer_label)); // select the decimal undefined-key warning + ctx.emitter.instruction("mov rdi, rsi"); // pass the missing string key pointer to the warning helper + ctx.emitter.instruction("mov rsi, rdx"); // pass the missing string key length to the warning helper + abi::emit_call_label(ctx.emitter, "__rt_warn_undefined_array_key_str"); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&integer_label); + ctx.emitter.instruction("mov rax, rsi"); // pass the missing integer key in the warning ABI result register + abi::emit_call_label(ctx.emitter, "__rt_warn_undefined_array_key_int"); + ctx.emitter.label(&done_label); + Ok(()) +} + /// Materializes a boxed Mixed key as the AArch64 hash key pair `x1`/`x2`. fn materialize_mixed_hash_key_aarch64( ctx: &mut FunctionContext<'_>, @@ -1092,7 +1192,11 @@ fn emit_hash_get_miss(ctx: &mut FunctionContext<'_>, value_ty: &PhpType) { }, PhpType::Str => { let (ptr_reg, len_reg) = abi::string_result_regs(ctx.emitter); - abi::emit_load_int_immediate(ctx.emitter, ptr_reg, 0); + abi::emit_load_int_immediate( + ctx.emitter, + ptr_reg, + crate::codegen::NULL_SENTINEL, + ); abi::emit_load_int_immediate(ctx.emitter, len_reg, 0); } PhpType::Mixed => match ctx.emitter.target.arch { diff --git a/src/codegen/lower_inst/iterators.rs b/src/codegen/lower_inst/iterators.rs index f5ce35ad0d..d7853ad221 100644 --- a/src/codegen/lower_inst/iterators.rs +++ b/src/codegen/lower_inst/iterators.rs @@ -846,11 +846,26 @@ fn store_iterator_cursor(ctx: &mut FunctionContext<'_>, offset: usize, cursor: i /// Snapshots the indexed-array length into the iterator state so `IterNext` compares /// against the original length rather than re-reading it each iteration. PHP snapshots /// the array length at loop entry, so elements appended during iteration are not visited. +/// A null/sentinel source (a missed outer array read) snapshots length zero so the loop +/// body never runs instead of dereferencing the sentinel as an array header. fn snapshot_indexed_array_length(ctx: &mut FunctionContext<'_>, offset: usize) { let array_reg = abi::int_result_reg(ctx.emitter); let len_reg = abi::secondary_scratch_reg(ctx.emitter); + let null_label = ctx.next_label("iter_len_null_source"); + let done_label = ctx.next_label("iter_len_done"); abi::load_at_offset(ctx.emitter, array_reg, offset - ITER_SOURCE_OFFSET_DELTA); + // -- guard the source: a missed outer read carries a null/sentinel container -- + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + array_reg, + len_reg, + &null_label, + ); abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); + abi::emit_load_int_immediate(ctx.emitter, len_reg, 0); + ctx.emitter.label(&done_label); abi::store_at_offset(ctx.emitter, len_reg, offset - ITER_SNAPSHOT_LEN_OFFSET_DELTA); } diff --git a/src/codegen/lower_inst/objects.rs b/src/codegen/lower_inst/objects.rs index ef2dc25bff..cdb82c080d 100644 --- a/src/codegen/lower_inst/objects.rs +++ b/src/codegen/lower_inst/objects.rs @@ -2436,28 +2436,75 @@ fn emit_constructor_call( pub(super) fn lower_prop_get(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { let object = expect_operand(inst, 0)?; let property = property_name_immediate(ctx, inst)?.to_string(); + if matches!(ctx.value_php_type(object)?.codegen_repr(), PhpType::Object(_)) { + return lower_object_prop_get_with_null_guard(ctx, inst, object, &property); + } + lower_prop_get_nonnull(ctx, inst, object, &property) +} + +/// Guards statically typed object receivers before selecting declared, dynamic, +/// stdClass, or magic-property lowering. +fn lower_object_prop_get_with_null_guard( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + object: ValueId, + property: &str, +) -> Result<()> { + let null_label = ctx.next_label("prop_get_null_receiver"); + let done_label = ctx.next_label("prop_get_done"); + let base_reg = abi::symbol_scratch_reg(ctx.emitter); + ctx.load_value_to_reg(object, base_reg)?; + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + base_reg, + scratch_reg, + &null_label, + ); + lower_prop_get_nonnull(ctx, inst, object, property)?; + abi::emit_jump(ctx.emitter, &done_label); + + ctx.emitter.label(&null_label); + if inst.op != Op::NullsafePropGet { + emit_property_on_null_warning(ctx, property); + } + super::arrays::emit_array_get_null_fallback(ctx, &inst.result_php_type.codegen_repr()); + store_if_result(ctx, inst)?; + + ctx.emitter.label(&done_label); + Ok(()) +} + +/// Selects the property representation after a statically typed object receiver +/// has been proven non-null, or for receiver shapes with their own null handling. +fn lower_prop_get_nonnull( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + object: ValueId, + property: &str, +) -> Result<()> { if let Some((class_name, true)) = nullable_object_receiver_class(ctx, object)? { - return lower_nullable_prop_get_with_warning(ctx, inst, object, &class_name, &property); + return lower_nullable_prop_get_with_warning(ctx, inst, object, &class_name, property); } if let Some(class_name) = union_object_member_class(ctx, object)? { - return lower_union_object_prop_get(ctx, inst, object, &class_name, &property); + return lower_union_object_prop_get(ctx, inst, object, &class_name, property); } if matches!( ctx.value_php_type(object)?.codegen_repr(), PhpType::Mixed | PhpType::Union(_) ) { - return lower_mixed_prop_get(ctx, inst, object, &property); + return lower_mixed_prop_get(ctx, inst, object, property); } if object_is_builtin_stdclass(ctx, object)? { - return lower_stdclass_prop_get(ctx, inst, object, &property); + return lower_stdclass_prop_get(ctx, inst, object, property); } - if let Some(class_name) = magic_get_receiver_class(ctx, object, &property)? { - return lower_magic_get_prop(ctx, inst, object, &class_name, &property); + if let Some(class_name) = magic_get_receiver_class(ctx, object, property)? { + return lower_magic_get_prop(ctx, inst, object, &class_name, property); } - if let Some(offset) = dynamic_property_hash_offset_for_object(ctx, object, &property)? { - return lower_allow_dynamic_prop_get(ctx, inst, object, &property, offset); + if let Some(offset) = dynamic_property_hash_offset_for_object(ctx, object, property)? { + return lower_allow_dynamic_prop_get(ctx, inst, object, property, offset); } - let slot = resolve_property_slot(ctx, object, &property, inst)?; + let slot = resolve_property_slot(ctx, object, property, inst)?; let base_reg = abi::symbol_scratch_reg(ctx.emitter); ctx.load_value_to_reg(object, base_reg)?; if slot.is_declared { @@ -3104,6 +3151,56 @@ pub(super) fn lower_dynamic_prop_get( ) -> Result<()> { let object = expect_operand(inst, 0)?; let property_value = expect_operand(inst, 1)?; + if matches!(ctx.value_php_type(object)?.codegen_repr(), PhpType::Object(_)) { + return lower_object_dynamic_prop_get_with_null_guard( + ctx, + inst, + object, + property_value, + ); + } + lower_dynamic_prop_get_nonnull(ctx, inst, object, property_value) +} + +/// Guards a statically typed object before evaluating any runtime-name property +/// representation that would otherwise dereference the null-container sentinel. +fn lower_object_dynamic_prop_get_with_null_guard( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + object: ValueId, + property_value: ValueId, +) -> Result<()> { + let null_label = ctx.next_label("dynamic_prop_get_null_receiver"); + let done_label = ctx.next_label("dynamic_prop_get_done"); + let object_reg = abi::symbol_scratch_reg(ctx.emitter); + ctx.load_value_to_reg(object, object_reg)?; + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + object_reg, + scratch_reg, + &null_label, + ); + lower_dynamic_prop_get_nonnull(ctx, inst, object, property_value)?; + abi::emit_jump(ctx.emitter, &done_label); + + ctx.emitter.label(&null_label); + emit_dynamic_property_on_null_warning(ctx, property_value)?; + super::arrays::emit_array_get_null_fallback(ctx, &inst.result_php_type.codegen_repr()); + store_if_result(ctx, inst)?; + + ctx.emitter.label(&done_label); + Ok(()) +} + +/// Selects dynamic-property lowering after a typed object receiver has been +/// proven non-null, or for receiver shapes with their own runtime null checks. +fn lower_dynamic_prop_get_nonnull( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + object: ValueId, + property_value: ValueId, +) -> Result<()> { if let Some(property) = const_string_operand(ctx, property_value)? { return lower_const_dynamic_prop_get(ctx, object, property, inst); } @@ -3119,6 +3216,37 @@ pub(super) fn lower_dynamic_prop_get( lower_runtime_dynamic_declared_prop_get(ctx, object, property_value, inst) } +/// Emits PHP's runtime-name warning for a dynamic property read on null. +fn emit_dynamic_property_on_null_warning( + ctx: &mut FunctionContext<'_>, + property_value: ValueId, +) -> Result<()> { + emit_property_warning_fragment(ctx, b"Warning: Attempt to read property \""); + match ctx.emitter.target.arch { + Arch::AArch64 => ctx.load_string_value_to_regs(property_value, "x1", "x2")?, + Arch::X86_64 => ctx.load_string_value_to_regs(property_value, "rdi", "rsi")?, + } + abi::emit_call_label(ctx.emitter, "__rt_diag_warning"); + emit_property_warning_fragment(ctx, b"\" on null\n"); + Ok(()) +} + +/// Writes one static fragment through the suppressible PHP warning channel. +fn emit_property_warning_fragment(ctx: &mut FunctionContext<'_>, bytes: &[u8]) { + let (label, len) = ctx.data.add_string(bytes); + match ctx.emitter.target.arch { + Arch::AArch64 => { + abi::emit_symbol_address(ctx.emitter, "x1", &label); + abi::emit_load_int_immediate(ctx.emitter, "x2", len as i64); + } + Arch::X86_64 => { + abi::emit_symbol_address(ctx.emitter, "rdi", &label); + abi::emit_load_int_immediate(ctx.emitter, "rsi", len as i64); + } + } + abi::emit_call_label(ctx.emitter, "__rt_diag_warning"); +} + /// Lowers a dynamic property read when the property expression is a literal string. fn lower_const_dynamic_prop_get( ctx: &mut FunctionContext<'_>, @@ -3392,7 +3520,9 @@ fn ensure_dynamic_property_slot_results_supported( return Ok(()); } for slot in slots { - if slot.php_type.codegen_repr() != result_ty { + let slot_ty = slot.php_type.codegen_repr(); + let can_tag_nullable_int = result_ty == PhpType::TaggedScalar && slot_ty == PhpType::Int; + if slot_ty != result_ty && !can_tag_nullable_int { return Err(CodegenIrError::unsupported(format!( "{} with declared property {}::${} PHP type {:?} and result PHP type {:?}", inst.op.name(), @@ -3409,7 +3539,7 @@ fn ensure_dynamic_property_slot_results_supported( /// Verifies that a runtime miss can be materialized in the EIR result register shape. fn ensure_dynamic_property_miss_supported(inst: &Instruction) -> Result<()> { match inst.result_php_type.codegen_repr() { - PhpType::Mixed | PhpType::Bool | PhpType::Int => Ok(()), + PhpType::Mixed | PhpType::TaggedScalar | PhpType::Bool | PhpType::Int => Ok(()), ty => Err(CodegenIrError::unsupported(format!( "{} runtime miss for result PHP type {:?}", inst.op.name(), @@ -3444,15 +3574,17 @@ fn materialize_loaded_property_result( /// Emits a PHP null value for a dynamic property lookup that matched no declared slot. fn emit_dynamic_property_miss_result(ctx: &mut FunctionContext<'_>, inst: &Instruction) { - if inst.result_php_type.codegen_repr() == PhpType::Mixed { - emit_boxed_null(ctx); - return; + match inst.result_php_type.codegen_repr() { + PhpType::Mixed => emit_boxed_null(ctx), + PhpType::TaggedScalar => { + crate::codegen::sentinels::emit_tagged_scalar_null(ctx.emitter); + } + _ => abi::emit_load_int_immediate( + ctx.emitter, + abi::int_result_reg(ctx.emitter), + RUNTIME_NULL_SENTINEL, + ), } - abi::emit_load_int_immediate( - ctx.emitter, - abi::int_result_reg(ctx.emitter), - RUNTIME_NULL_SENTINEL, - ); } /// Emits a runtime string comparison branch against one declared property name. diff --git a/src/codegen/lower_inst/predicates.rs b/src/codegen/lower_inst/predicates.rs index 5c5f170512..119db67272 100644 --- a/src/codegen/lower_inst/predicates.rs +++ b/src/codegen/lower_inst/predicates.rs @@ -73,8 +73,21 @@ fn emit_tagged_scalar_truthiness(ctx: &mut FunctionContext<'_>, value: ValueId) pub(super) fn emit_array_truthiness(ctx: &mut FunctionContext<'_>, value: ValueId) -> Result<()> { ctx.load_value_to_result(value)?; let result_reg = abi::int_result_reg(ctx.emitter); + let null_label = ctx.next_label("array_truthy_null"); + let done_label = ctx.next_label("array_truthy_done"); + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + result_reg, + scratch_reg, + &null_label, + ); abi::emit_load_from_address(ctx.emitter, result_reg, result_reg, 0); emit_int_result_nonzero_bool(ctx); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); + abi::emit_load_int_immediate(ctx.emitter, result_reg, 0); + ctx.emitter.label(&done_label); Ok(()) } @@ -107,6 +120,12 @@ pub(super) fn emit_is_null_result(ctx: &mut FunctionContext<'_>, value: ValueId) emit_int_result_null_sentinel_bool(ctx); Ok(()) } + PhpType::Array(_) | PhpType::AssocArray { .. } | PhpType::Iterable | PhpType::Object(_) => { + ctx.load_value_to_result(value)?; + emit_int_result_null_container_bool(ctx); + Ok(()) + } + PhpType::Str => emit_string_null_bool(ctx, value), _ => { abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 0); Ok(()) @@ -114,6 +133,47 @@ pub(super) fn emit_is_null_result(ctx: &mut FunctionContext<'_>, value: ValueId) } } +/// Treats the dedicated string-pointer sentinel used by miss fallbacks as PHP null. +fn emit_string_null_bool(ctx: &mut FunctionContext<'_>, value: ValueId) -> Result<()> { + match ctx.emitter.target.arch { + Arch::AArch64 => { + ctx.load_string_value_to_regs(value, "x1", "x2")?; + abi::emit_load_int_immediate(ctx.emitter, "x9", crate::codegen::NULL_SENTINEL); + ctx.emitter.instruction("cmp x1, x9"); // distinguish a missing string from a real empty string + ctx.emitter.instruction("cset x0, eq"); // materialize true only for the null string representation + } + Arch::X86_64 => { + ctx.load_string_value_to_regs(value, "rax", "rdx")?; + abi::emit_load_int_immediate(ctx.emitter, "r10", crate::codegen::NULL_SENTINEL); + ctx.emitter.instruction("cmp rax, r10"); // distinguish a missing string from a real empty string + ctx.emitter.instruction("sete al"); // materialize true only for the null string representation + ctx.emitter.instruction("movzx rax, al"); // widen the null predicate byte + } + } + Ok(()) +} + +/// Compares the loaded container pointer against PHP's null representations — a zero +/// pointer or the in-band null-container sentinel produced by missed reads of refcounted +/// slots — and materializes the boolean result. +fn emit_int_result_null_container_bool(ctx: &mut FunctionContext<'_>) { + let null_label = ctx.next_label("is_null_container"); + let done_label = ctx.next_label("is_null_container_done"); + let result_reg = abi::int_result_reg(ctx.emitter); + let scratch_reg = abi::secondary_scratch_reg(ctx.emitter); + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + result_reg, + scratch_reg, + &null_label, + ); + abi::emit_load_int_immediate(ctx.emitter, result_reg, 0); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); + abi::emit_load_int_immediate(ctx.emitter, result_reg, 1); + ctx.emitter.label(&done_label); +} + /// Compares the loaded tagged-scalar tag register against PHP's null tag. fn emit_tagged_scalar_null_bool(ctx: &mut FunctionContext<'_>) { match ctx.emitter.target.arch { diff --git a/src/codegen_support/runtime/arrays/array_get_mixed_key.rs b/src/codegen_support/runtime/arrays/array_get_mixed_key.rs index 70f928a85d..4dd0e0f20c 100644 --- a/src/codegen_support/runtime/arrays/array_get_mixed_key.rs +++ b/src/codegen_support/runtime/arrays/array_get_mixed_key.rs @@ -44,7 +44,14 @@ pub fn emit_array_get_mixed_key(emitter: &mut Emitter) { emitter.instruction("str x2, [sp, #16]"); // save the key high word (sentinel) emitter.instruction("str x3, [sp, #24]"); // save whether missing keys should emit PHP warnings - emitter.instruction("cbz x0, __rt_array_get_mixed_key_null"); // null array → Mixed(null) + emitter.instruction("cbz x0, __rt_array_get_mixed_key_null_receiver"); // null array → optional warning + Mixed(null) + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "x9", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp x0, x9"); // does the receiver carry the in-band null-container sentinel? + emitter.instruction("b.eq __rt_array_get_mixed_key_null_receiver"); // sentinel-null receivers from missed reads → optional warning + Mixed(null) // -- dispatch on array storage kind -- emitter.instruction("ldr x9, [x0, #-8]"); // load packed kind metadata from the array header @@ -165,6 +172,13 @@ pub fn emit_array_get_mixed_key(emitter: &mut Emitter) { emitter.instruction("bl __rt_warn_undefined_array_key_int"); // emit or suppress the undefined integer-key warning emitter.instruction("b __rt_array_get_mixed_key_null"); // return boxed Mixed(null) after the integer-key warning + // -- null receiver: direct reads warn, silent reads only return null -- + emitter.label("__rt_array_get_mixed_key_null_receiver"); + emitter.instruction("ldr x9, [sp, #24]"); // reload the warn-on-missing flag + emitter.instruction("cbz x9, __rt_array_get_mixed_key_null"); // silent reads suppress the null-receiver warning + emitter.instruction("bl __rt_warn_array_offset_on_null"); // emit or suppress PHP's array-offset-on-null warning + emitter.instruction("b __rt_array_get_mixed_key_null"); // return boxed Mixed(null) after the warning + // -- return Mixed(null) --- emitter.label("__rt_array_get_mixed_key_null"); emitter.instruction("mov x0, #8"); // x0 = null runtime value_type tag @@ -196,7 +210,14 @@ fn emit_array_get_mixed_key_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov QWORD PTR [rbp - 32], rcx"); // save whether missing keys should emit PHP warnings emitter.instruction("test rdi, rdi"); // null array check - emitter.instruction("je __rt_array_get_mixed_key_null"); // null array → Mixed(null) + emitter.instruction("je __rt_array_get_mixed_key_null_receiver"); // null array → optional warning + Mixed(null) + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "r9", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp rdi, r9"); // does the receiver carry the in-band null-container sentinel? + emitter.instruction("je __rt_array_get_mixed_key_null_receiver"); // sentinel-null receivers from missed reads → optional warning + Mixed(null) // -- dispatch on array storage kind -- emitter.instruction("mov r9, QWORD PTR [rdi - 8]"); // load packed kind metadata from the array header @@ -323,6 +344,14 @@ fn emit_array_get_mixed_key_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("call __rt_warn_undefined_array_key_int"); // emit or suppress the undefined integer-key warning emitter.instruction("jmp __rt_array_get_mixed_key_null"); // return boxed Mixed(null) after the integer-key warning + // -- null receiver: direct reads warn, silent reads only return null -- + emitter.label("__rt_array_get_mixed_key_null_receiver"); + emitter.instruction("mov r10, QWORD PTR [rbp - 32]"); // reload the warn-on-missing flag + emitter.instruction("test r10, r10"); // should this read emit the null-receiver warning? + emitter.instruction("jz __rt_array_get_mixed_key_null"); // silent reads suppress the null-receiver warning + emitter.instruction("call __rt_warn_array_offset_on_null"); // emit or suppress PHP's array-offset-on-null warning + emitter.instruction("jmp __rt_array_get_mixed_key_null"); // return boxed Mixed(null) after the warning + // -- return Mixed(null) --- emitter.label("__rt_array_get_mixed_key_null"); emitter.instruction("mov rax, 8"); // rax = null runtime value_type tag diff --git a/src/codegen_support/runtime/arrays/hash_get.rs b/src/codegen_support/runtime/arrays/hash_get.rs index 60b3ecb8ab..83c9d45c75 100644 --- a/src/codegen_support/runtime/arrays/hash_get.rs +++ b/src/codegen_support/runtime/arrays/hash_get.rs @@ -42,6 +42,13 @@ pub fn emit_hash_get(emitter: &mut Emitter) { emitter.instruction("str x1, [sp, #8]"); // save key_ptr emitter.instruction("str x2, [sp, #16]"); // save key_len emitter.instruction("cbz x0, __rt_hash_get_not_found"); // null tables cannot contain the requested key + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "x5", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp x0, x5"); // does the receiver carry the in-band null-container sentinel? + emitter.instruction("b.eq __rt_hash_get_not_found"); // sentinel-null receivers from missed reads cannot contain the key emitter.instruction("ldr x5, [x0, #8]"); // load capacity before hashing to avoid division by zero on empty tables emitter.instruction("cbz x5, __rt_hash_get_not_found"); // zero-capacity tables cannot contain the requested key @@ -145,6 +152,13 @@ fn emit_hash_get_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov QWORD PTR [rbp - 24], rdx"); // save the key length across helper calls and probe iterations emitter.instruction("test rdi, rdi"); // null tables cannot contain the requested key emitter.instruction("jz __rt_hash_get_not_found"); // return a miss before reading a null table header + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "r11", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp rdi, r11"); // does the receiver carry the in-band null-container sentinel? + emitter.instruction("je __rt_hash_get_not_found"); // sentinel-null receivers from missed reads cannot contain the key emitter.instruction("mov r11, QWORD PTR [rdi + 8]"); // load capacity before hashing to avoid division by zero on empty tables emitter.instruction("test r11, r11"); // zero capacity means there are no live entries to probe emitter.instruction("jz __rt_hash_get_not_found"); // return a miss for empty hash tables diff --git a/src/codegen_support/runtime/arrays/hash_iter.rs b/src/codegen_support/runtime/arrays/hash_iter.rs index 3d97cc017f..7430cbdec1 100644 --- a/src/codegen_support/runtime/arrays/hash_iter.rs +++ b/src/codegen_support/runtime/arrays/hash_iter.rs @@ -48,6 +48,14 @@ pub fn emit_hash_iter(emitter: &mut Emitter) { // >0 = slot index + 1 of the next entry to return // -2 = post-last cursor returned with the final yielded entry // -1 = no more entries + emitter.instruction("cbz x0, __rt_hash_iter_end"); // null tables from missed reads have nothing to iterate + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "x7", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp x0, x7"); // does the table carry the in-band null-container sentinel? + emitter.instruction("b.eq __rt_hash_iter_end"); // sentinel-null tables from missed reads have nothing to iterate emitter.instruction("cmp x1, #-1"); // has the caller already consumed the end sentinel? emitter.instruction("b.eq __rt_hash_iter_end"); // repeated end probes stay at done emitter.instruction("cmp x1, #-2"); // was the previous yielded entry the tail? @@ -109,6 +117,15 @@ fn emit_hash_iter_linux_x86_64(emitter: &mut Emitter) { emitter.comment("--- runtime: hash_iter_next ---"); emitter.label_global("__rt_hash_iter_next"); + emitter.instruction("test rdi, rdi"); // null tables from missed reads have nothing to iterate + emitter.instruction("jz __rt_hash_iter_end"); // return the done sentinel before reading a null table header + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "r11", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp rdi, r11"); // does the table carry the in-band null-container sentinel? + emitter.instruction("je __rt_hash_iter_end"); // sentinel-null tables from missed reads have nothing to iterate emitter.instruction("cmp rsi, -1"); // has the caller already consumed the terminal done sentinel? emitter.instruction("je __rt_hash_iter_end"); // repeated end probes keep returning the done sentinel emitter.instruction("cmp rsi, -2"); // did the previous yielded entry already encode the post-last cursor? diff --git a/src/codegen_support/runtime/arrays/undefined_array_key_warning.rs b/src/codegen_support/runtime/arrays/undefined_array_key_warning.rs index 42fd3f37d0..4680f90943 100644 --- a/src/codegen_support/runtime/arrays/undefined_array_key_warning.rs +++ b/src/codegen_support/runtime/arrays/undefined_array_key_warning.rs @@ -16,12 +16,14 @@ use crate::codegen_support::platform::Arch; const UNDEFINED_ARRAY_KEY_PREFIX_LEN: usize = "Warning: Undefined array key ".len(); const UNDEFINED_ARRAY_KEY_QUOTE_LEN: usize = "\"".len(); const UNDEFINED_ARRAY_KEY_SUFFIX_LEN: usize = "\n".len(); +const ARRAY_OFFSET_ON_NULL_LEN: usize = "Warning: Trying to access array offset on null\n".len(); /// Emits `__rt_warn_undefined_array_key_int` for the active target. pub fn emit_undefined_array_key_warning(emitter: &mut Emitter) { if emitter.target.arch == Arch::X86_64 { emit_undefined_array_key_warning_x86_64(emitter); emit_undefined_array_key_string_warning_x86_64(emitter); + emit_array_offset_on_null_warning_x86_64(emitter); return; } @@ -62,6 +64,36 @@ pub fn emit_undefined_array_key_warning(emitter: &mut Emitter) { emitter.instruction("ret"); // return to the array-miss caller emit_undefined_array_key_string_warning_aarch64(emitter); + emit_array_offset_on_null_warning_aarch64(emitter); +} + +/// Emits the fixed PHP warning used when an array-offset receiver is null on AArch64. +fn emit_array_offset_on_null_warning_aarch64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: array_offset_on_null_warning ---"); + emitter.label_global("__rt_warn_array_offset_on_null"); + emitter.instruction("stp x29, x30, [sp, #-16]!"); // preserve frame linkage across the diagnostic call + emitter.instruction("mov x29, sp"); // establish a stable warning helper frame + abi::emit_symbol_address(emitter, "x1", "_diag_array_offset_on_null"); + emitter.instruction(&format!("mov x2, #{}", ARRAY_OFFSET_ON_NULL_LEN)); // pass the complete array-offset-on-null warning length + abi::emit_call_label(emitter, "__rt_diag_warning"); // emit or suppress the PHP warning + emitter.instruction("ldp x29, x30, [sp], #16"); // restore frame linkage + emitter.instruction("ret"); // return to the null-receiver fallback +} + +/// Emits the fixed PHP warning used when an array-offset receiver is null on x86_64. +fn emit_array_offset_on_null_warning_x86_64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: array_offset_on_null_warning ---"); + emitter.label_global("__rt_warn_array_offset_on_null"); + emitter.instruction("push rbp"); // preserve the caller frame and align the diagnostic call + emitter.instruction("mov rbp, rsp"); // establish a stable warning helper frame + abi::emit_symbol_address(emitter, "rdi", "_diag_array_offset_on_null"); + emitter.instruction(&format!("mov esi, {}", ARRAY_OFFSET_ON_NULL_LEN)); // pass the complete array-offset-on-null warning length + abi::emit_call_label(emitter, "__rt_diag_warning"); // emit or suppress the PHP warning + emitter.instruction("mov rsp, rbp"); // release the warning helper frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return to the null-receiver fallback } /// Emits the x86_64 implementation of `__rt_warn_undefined_array_key_int`. diff --git a/src/codegen_support/runtime/data/fixed.rs b/src/codegen_support/runtime/data/fixed.rs index 7f40ffaf4d..67be5bfa7c 100644 --- a/src/codegen_support/runtime/data/fixed.rs +++ b/src/codegen_support/runtime/data/fixed.rs @@ -263,6 +263,7 @@ pub(crate) fn emit_runtime_data_fixed(heap_size: usize, target: Target) -> Strin out.push_str(".globl _diag_undefined_array_key_prefix\n_diag_undefined_array_key_prefix:\n .ascii \"Warning: Undefined array key \"\n"); out.push_str(".globl _diag_undefined_array_key_quote\n_diag_undefined_array_key_quote:\n .ascii \"\\\"\"\n"); out.push_str(".globl _diag_undefined_array_key_suffix\n_diag_undefined_array_key_suffix:\n .ascii \"\\n\"\n"); + out.push_str(".globl _diag_array_offset_on_null\n_diag_array_offset_on_null:\n .ascii \"Warning: Trying to access array offset on null\\n\"\n"); out.push_str(".globl _fiber_msg_already_started\n_fiber_msg_already_started:\n .ascii \"Cannot start a fiber that has already been started\"\n"); out.push_str(".globl _fiber_msg_not_suspended\n_fiber_msg_not_suspended:\n .ascii \"Cannot resume a fiber that is not suspended\"\n"); out.push_str(".globl _fiber_msg_throw_not_suspended\n_fiber_msg_throw_not_suspended:\n .ascii \"Cannot resume a fiber that is not suspended\"\n"); diff --git a/src/codegen_support/runtime/strings/str_persist.rs b/src/codegen_support/runtime/strings/str_persist.rs index 6fe0ab4d4d..968a611cd2 100644 --- a/src/codegen_support/runtime/strings/str_persist.rs +++ b/src/codegen_support/runtime/strings/str_persist.rs @@ -26,6 +26,14 @@ pub fn emit_str_persist(emitter: &mut Emitter) { emitter.comment("--- runtime: str_persist ---"); emitter.label_global("__rt_str_persist"); + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "x9", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp x1, x9"); // preserve the dedicated null-string sentinel across ownership stabilization + emitter.instruction("b.eq __rt_str_persist_done"); // a missing string has no payload to allocate or copy + // -- zero-length strings still get an owned heap block so callers never alias a borrowed source pointer -- // (the old early-return let explode()'s empty segment alias the subject string and double-free it on release) @@ -90,6 +98,14 @@ fn emit_str_persist_linux_x86_64(emitter: &mut Emitter) { emitter.comment("--- runtime: str_persist ---"); emitter.label_global("__rt_str_persist"); + crate::codegen_support::abi::emit_load_int_immediate( + emitter, + "r10", + crate::codegen_support::sentinels::NULL_SENTINEL, + ); + emitter.instruction("cmp rax, r10"); // preserve the dedicated null-string sentinel across ownership stabilization + emitter.instruction("je __rt_str_persist_done"); // a missing string has no payload to allocate or copy + // -- zero-length strings still get an owned heap block so callers never alias a borrowed source pointer -- // -- preserve the source payload across the heap allocation helper call -- diff --git a/src/codegen_support/sentinels.rs b/src/codegen_support/sentinels.rs index 5685085262..453740d8b4 100644 --- a/src/codegen_support/sentinels.rs +++ b/src/codegen_support/sentinels.rs @@ -116,6 +116,33 @@ pub(crate) fn emit_tagged_scalar_from_int_result(emitter: &mut Emitter) { } } +/// Branches to `label` when the container pointer in `value_reg` represents PHP null: +/// either a zero pointer or the in-band `NULL_SENTINEL` that missed reads of refcounted +/// slots materialize. Clobbers `scratch_reg` with the sentinel bit pattern. Used to keep +/// container reads from dereferencing a null/sentinel receiver (issue #526). +pub(crate) fn emit_branch_if_null_container( + emitter: &mut Emitter, + value_reg: &str, + scratch_reg: &str, + label: &str, +) { + match emitter.target.arch { + Arch::AArch64 => { + emitter.instruction(&format!("cbz {}, {}", value_reg, label)); // zero container pointers take the caller's null path + super::abi::emit_load_int_immediate(emitter, scratch_reg, NULL_SENTINEL); + emitter.instruction(&format!("cmp {}, {}", value_reg, scratch_reg)); // does the container carry the in-band null sentinel? + emitter.instruction(&format!("b.eq {}", label)); // sentinel-null containers take the caller's null path + } + Arch::X86_64 => { + emitter.instruction(&format!("test {}, {}", value_reg, value_reg)); // is the container pointer zero (PHP null)? + emitter.instruction(&format!("jz {}", label)); // zero container pointers take the caller's null path + super::abi::emit_load_int_immediate(emitter, scratch_reg, NULL_SENTINEL); + emitter.instruction(&format!("cmp {}, {}", value_reg, scratch_reg)); // does the container carry the in-band null sentinel? + emitter.instruction(&format!("je {}", label)); // sentinel-null containers take the caller's null path + } + } +} + /// Branches to `label` when the tagged scalar in the result registers is PHP null /// (tag register == null tag). pub(crate) fn emit_branch_if_tagged_scalar_null(emitter: &mut Emitter, label: &str) { diff --git a/src/ir/instr.rs b/src/ir/instr.rs index 3cabd040f7..2c9542d61e 100644 --- a/src/ir/instr.rs +++ b/src/ir/instr.rs @@ -297,6 +297,7 @@ pub enum Op { ArrayGet, ArrayGetSilent, HashGet, + HashGetSilent, ArrayIsset, HashIsset, ArrayElemAddr, @@ -426,6 +427,8 @@ pub enum Op { ErrorSuppressEnd, Warn, ThrowException, + ThrowError, + ThrowErrorValue, TryPushHandler, TryPopHandler, CatchCurrent, @@ -542,18 +545,21 @@ impl Op { | ClosureNew | FirstClassCallableNew | CallableArrayNew | BufferNew | GeneratorNew => { E::ALLOC_HEAP } - MixedUnbox | MixedCastBool | MixedCastInt | MixedCastFloat | ArrayGetSilent | HashGet + MixedUnbox | MixedCastBool | MixedCastInt | MixedCastFloat | ArrayGetSilent + | HashGetSilent | ArrayIsset | HashIsset | BufferGet | BufferLen | PackedFieldGet | PtrRead | PtrReadString => { E::READS_HEAP | E::MAY_FATAL } - ArrayGet => E::READS_HEAP | E::MAY_FATAL | E::MAY_WARN, + ArrayGet | HashGet => E::READS_HEAP | E::MAY_FATAL | E::MAY_WARN, StrPersist | ArrayEnsureUnique | HashEnsureUnique | ArrayCloneShallow | HashCloneShallow | ObjectCloneShallow => { E::READS_HEAP | E::ALLOC_HEAP | E::REFCOUNT_OP } - ArrayLen | HashLen | ArrayKeyExists | OffsetExists | PropGet | PropInitialized - | LoadPropRefCell => E::READS_HEAP, + ArrayLen | HashLen => E::READS_HEAP | E::MAY_FATAL, + ArrayKeyExists | OffsetExists | PropGet | PropInitialized | LoadPropRefCell => { + E::READS_HEAP + } LoadArrayElemRefCell => E::READS_HEAP | E::MAY_FATAL, BindRefCellPtr => E::WRITES_LOCAL, ArraySet | HashSet | HashUnset | ArrayPush | HashAppend | OffsetUnset | PropSet @@ -610,6 +616,13 @@ impl Op { PrintValue => E::OUTPUT, ErrorSuppressBegin | ErrorSuppressEnd => E::READS_GLOBAL | E::WRITES_GLOBAL, ThrowException => E::MAY_THROW | E::WRITES_GLOBAL, + ThrowError | ThrowErrorValue => { + E::MAY_THROW + | E::READS_GLOBAL + | E::WRITES_GLOBAL + | E::ALLOC_HEAP + | E::WRITES_HEAP + } Acquire | Release | EnsureOwned => E::REFCOUNT_OP | E::WRITES_HEAP, GcCollect => E::READS_HEAP | E::WRITES_HEAP | E::REFCOUNT_OP, ClassConstant => E::MAY_DEOPT, @@ -746,6 +759,7 @@ impl Op { ArrayGet => "array_get", ArrayGetSilent => "array_get_silent", HashGet => "hash_get", + HashGetSilent => "hash_get_silent", ArrayIsset => "array_isset", HashIsset => "hash_isset", ArrayElemAddr => "array_elem_addr", @@ -857,6 +871,8 @@ impl Op { ErrorSuppressEnd => "error_suppress_end", Warn => "warn", ThrowException => "throw_exception", + ThrowError => "throw_error", + ThrowErrorValue => "throw_error_value", TryPushHandler => "try_push_handler", TryPopHandler => "try_pop_handler", CatchCurrent => "catch_current", diff --git a/src/ir/validator.rs b/src/ir/validator.rs index dec41be83f..62a80e533a 100644 --- a/src/ir/validator.rs +++ b/src/ir/validator.rs @@ -451,6 +451,8 @@ fn validate_opcode_rules( | LoadReflectionStaticProperty | ReflectionStaticPropertyInitialized | ExternGlobalLoad => check_count(inst_id, inst, 0, "0"), + ThrowError => check_count(inst_id, inst, 0, "0"), + ThrowErrorValue => check_unary(function, inst_id, inst, IrType::Str, "Str"), UnsetLocal | PromoteLocalRefCell | AliasLocalRefCell | ReleaseLocalRefCell | ReleaseLocalSlot => { check_count(inst_id, inst, 0, "0") @@ -504,7 +506,7 @@ fn validate_opcode_rules( "Heap(Mixed)", ) } - HashLen | HashGet | HashIsset | HashSet | HashAppend | HashEnsureUnique + HashLen | HashGet | HashGetSilent | HashIsset | HashSet | HashAppend | HashEnsureUnique | HashCloneShallow => { check_first_heap(function, inst_id, inst, IrHeapKind::Hash, "Heap(Hash)") } diff --git a/src/ir_lower/context.rs b/src/ir_lower/context.rs index 41bf45a60a..cea7aa64ad 100644 --- a/src/ir_lower/context.rs +++ b/src/ir_lower/context.rs @@ -1728,7 +1728,7 @@ impl<'m, 'f> LoweringContext<'m, 'f> { let op = self.builder.value_defining_op(value); (matches!(php_type, PhpType::Mixed | PhpType::Union(_)) || (php_type.is_refcounted() && php_type != PhpType::Str)) - && matches!(op, Some(Op::ArrayGet | Op::HashGet)) + && matches!(op, Some(Op::ArrayGet | Op::HashGet | Op::HashGetSilent)) } /// Returns whether an index-read receiver is itself an owned intermediate @@ -1755,6 +1755,7 @@ impl<'m, 'f> LoweringContext<'m, 'f> { Op::ArrayGet | Op::ArrayGetSilent | Op::HashGet + | Op::HashGetSilent | Op::ArrayGetMixedKey | Op::ArrayGetMixedKeySilent ) diff --git a/src/ir_lower/expr/mod.rs b/src/ir_lower/expr/mod.rs index bbffa7840b..4f96d76186 100644 --- a/src/ir_lower/expr/mod.rs +++ b/src/ir_lower/expr/mod.rs @@ -1797,6 +1797,9 @@ fn lower_function_call(ctx: &mut LoweringContext<'_, '_>, name: &Name, args: &[E if let Some(value) = lower_lazy_empty(ctx, canonical, args, expr) { return value; } + if let Some(value) = lower_desugared_dynamic_method_call(ctx, canonical, args, expr) { + return value; + } if let Some(value) = lower_static_call_user_func(ctx, canonical, args, expr) { return value; } @@ -2615,7 +2618,13 @@ fn lower_lazy_isset( let merge = ctx.builder.create_named_block("isset.lazy_merge", Vec::new()); for (idx, arg) in args.iter().enumerate() { let checked = lower_lazy_isset_operand(ctx, arg).unwrap_or_else(|| { - let value = lower_expr(ctx, arg); + // `isset()` never emits undefined-offset warnings, so eager array + // operands must be lowered with the silent read variants. + let value = if let ExprKind::ArrayAccess { array, index } = &arg.kind { + lower_array_access_with_missing_warning(ctx, array, index, arg, false) + } else { + lower_expr(ctx, arg) + }; emit_builtin_call_value(ctx, name, vec![value.value], PhpType::Int, arg.span, None) }); let then_target = if idx + 1 == args.len() { @@ -2703,6 +2712,17 @@ fn lower_lazy_empty( { return None; } + if let ExprKind::ArrayAccess { array, index } = &args[0].kind { + let value = lower_array_access_with_missing_warning(ctx, array, index, &args[0], false); + return Some(emit_builtin_call_value( + ctx, + name, + vec![value.value], + PhpType::Bool, + expr.span, + None, + )); + } let (exists_call, get_call) = lazy_empty_magic_property_calls(ctx, &args[0])?; let temp_name = ctx.declare_hidden_temp(PhpType::Bool); @@ -2824,7 +2844,7 @@ fn lower_native_isset_offset_probe( index: &Expr, expr: &Expr, ) -> LoweredValue { - let array_value = lower_expr(ctx, array); + let array_value = lower_subscript_receiver_silently(ctx, array); if value_is_nullable(ctx, array_value.value) { return lower_nullable_native_isset_offset_probe(ctx, array_value, index, expr); } @@ -8567,7 +8587,9 @@ fn lower_array_access( } /// Lowers array, hash, string, or ArrayAccess indexing with configurable -/// undefined-offset warning behavior for native indexed-array reads. +/// undefined-offset warning behavior for native indexed-array reads. Suppressed +/// warnings propagate through the whole subscript chain: PHP's `isset()` and `??` +/// are silent for every level of `$a[1][2][3]`, not just the outermost read. fn lower_array_access_with_missing_warning( ctx: &mut LoweringContext<'_, '_>, array: &Expr, @@ -8575,13 +8597,29 @@ fn lower_array_access_with_missing_warning( expr: &Expr, warn_on_missing: bool, ) -> LoweredValue { - let array_value = lower_expr(ctx, array); + let array_value = if warn_on_missing { + lower_expr(ctx, array) + } else { + lower_subscript_receiver_silently(ctx, array) + }; if value_is_nullable(ctx, array_value.value) { return lower_nullable_array_access(ctx, array_value, index, expr, warn_on_missing); } lower_array_access_from_value(ctx, array_value, index, expr, warn_on_missing) } +/// Lowers a subscript-chain receiver with undefined-offset warnings suppressed on +/// nested array reads, so `isset()`/`??` stay silent across chained subscripts. +fn lower_subscript_receiver_silently( + ctx: &mut LoweringContext<'_, '_>, + array: &Expr, +) -> LoweredValue { + if let ExprKind::ArrayAccess { array: inner_array, index: inner_index } = &array.kind { + return lower_array_access_with_missing_warning(ctx, inner_array, inner_index, array, false); + } + lower_expr(ctx, array) +} + /// Lowers array access once the receiver is already evaluated. fn lower_array_access_from_value( ctx: &mut LoweringContext<'_, '_>, @@ -8611,7 +8649,13 @@ fn lower_array_access_from_value( } } } - IrType::Heap(IrHeapKind::Hash) => Op::HashGet, + IrType::Heap(IrHeapKind::Hash) => { + if warn_on_missing { + Op::HashGet + } else { + Op::HashGetSilent + } + } IrType::Heap(IrHeapKind::Buffer) => Op::BufferGet, IrType::Str => { index_value = coerce_to_int_at_span(ctx, index_value, Some(index.span)); @@ -8713,7 +8757,7 @@ fn array_access_result_type( } _ => fallback_expr_type(expr), }, - Op::HashGet => match ctx.builder.value_php_type(array).codegen_repr() { + Op::HashGet | Op::HashGetSilent => match ctx.builder.value_php_type(array).codegen_repr() { PhpType::AssocArray { value, .. } => { array_access_element_result_type(normalize_value_php_type(*value)) } @@ -9616,6 +9660,145 @@ fn lower_expr_call(ctx: &mut LoweringContext<'_, '_>, callee: &Expr, args: &[Exp ctx.emit_value(Op::ExprCall, operands, None, result_type, Op::ExprCall.default_effects(), Some(expr.span)) } +/// Recognizes the parser's internal `call_user_func([$object, $method], ...)` +/// desugaring for ordinary dynamic method syntax without changing explicit calls. +fn lower_desugared_dynamic_method_call( + ctx: &mut LoweringContext<'_, '_>, + name: &str, + args: &[Expr], + expr: &Expr, +) -> Option { + if php_symbol_key(name.trim_start_matches('\\')) != "call_user_func" { + return None; + } + let callback = args.first()?; + if callback.span != expr.span { + return None; + } + let ExprKind::ArrayLiteral(items) = &callback.kind else { + return None; + }; + let [object, method] = items.as_slice() else { + return None; + }; + Some(lower_dynamic_method_expr_call( + ctx, + object, + method, + &args[1..], + expr, + )) +} + +/// Lowers `$object->{$method}(...)` as a dynamic method call, preserving PHP's +/// receiver/name evaluation before the null check and lazy argument evaluation. +fn lower_dynamic_method_expr_call( + ctx: &mut LoweringContext<'_, '_>, + object: &Expr, + method: &Expr, + args: &[Expr], + expr: &Expr, +) -> LoweredValue { + let object = lower_expr(ctx, object); + let method = lower_expr(ctx, method); + let method_type = ctx.builder.value_php_type(method.value); + let method_name = ctx.declare_hidden_temp(method_type.clone()); + ctx.store_local(&method_name, method, method_type, Some(expr.span)); + let method_expr = Expr::new(ExprKind::Variable(method_name), expr.span); + let object_type = ctx.builder.value_php_type(object.value).codegen_repr(); + if !matches!(object_type, PhpType::Object(_)) + && !value_is_nullable(ctx, object.value) + && !value_may_carry_container_miss(ctx, object.value) + { + return lower_dynamic_method_call_with_receiver(ctx, object, &method_expr, args, expr); + } + lower_nullable_dynamic_method_expr_call(ctx, object, &method_expr, args, expr) +} + +/// Splits a dynamic method call so a null receiver throws before lowering any +/// call argument, while the already evaluated runtime method name is preserved. +fn lower_nullable_dynamic_method_expr_call( + ctx: &mut LoweringContext<'_, '_>, + object: LoweredValue, + method: &Expr, + args: &[Expr], + expr: &Expr, +) -> LoweredValue { + let fatal_block = ctx + .builder + .create_named_block("dynamic_method.null.fatal", Vec::new()); + let call_block = ctx + .builder + .create_named_block("dynamic_method.non_null.call", Vec::new()); + let is_null = ctx.emit_value( + Op::IsNull, + vec![object.value], + None, + PhpType::Bool, + Op::IsNull.default_effects(), + Some(expr.span), + ); + ctx.builder.terminate(Terminator::CondBr { + cond: is_null.value, + then_target: fatal_block, + then_args: Vec::new(), + else_target: call_block, + else_args: Vec::new(), + }); + + ctx.builder.position_at_end(fatal_block); + terminate_dynamic_method_call_on_null(ctx, method, expr); + + ctx.builder.position_at_end(call_block); + lower_dynamic_method_call_with_receiver(ctx, object, method, args, expr) +} + +/// Throws a catchable PHP `Error` with the runtime dynamic method name. +fn terminate_dynamic_method_call_on_null( + ctx: &mut LoweringContext<'_, '_>, + method: &Expr, + expr: &Expr, +) { + let prefix = Expr::new( + ExprKind::StringLiteral("Call to a member function ".to_string()), + expr.span, + ); + let prefix_and_method = Expr::new( + ExprKind::BinaryOp { + left: Box::new(prefix), + op: BinOp::Concat, + right: Box::new(method.clone()), + }, + expr.span, + ); + let suffix = Expr::new(ExprKind::StringLiteral("() on null".to_string()), expr.span); + let message = Expr::new( + ExprKind::BinaryOp { + left: Box::new(prefix_and_method), + op: BinOp::Concat, + right: Box::new(suffix), + }, + expr.span, + ); + let message = lower_expr(ctx, &message); + let message = ctx.emit_value( + Op::StrPersist, + vec![message.value], + None, + PhpType::Str, + Op::StrPersist.default_effects(), + Some(expr.span), + ); + ctx.emit_void( + Op::ThrowErrorValue, + vec![message.value], + None, + Op::ThrowErrorValue.default_effects(), + Some(expr.span), + ); + ctx.builder.terminate(Terminator::Unreachable); +} + /// Lowers direct calls to literal callable arrays through descriptor metadata. fn lower_literal_callable_array_expr_call( ctx: &mut LoweringContext<'_, '_>, @@ -10506,6 +10689,7 @@ fn property_get_result_type( } return fallback_expr_type(expr); }; + let nullable = nullable || value_may_carry_container_miss(ctx, object); let normalized = class_name.trim_start_matches('\\'); if is_builtin_stdclass_name(normalized) { return if nullable { @@ -10550,6 +10734,25 @@ fn property_get_result_type( } } +/// Returns whether a container read can carry PHP null in a statically non-null pointer type. +fn value_may_carry_container_miss( + ctx: &LoweringContext<'_, '_>, + value: crate::ir::ValueId, +) -> bool { + let Some(inst) = ctx.builder.value_defining_instruction(value) else { + return false; + }; + match inst.op { + Op::ArrayGet | Op::ArrayGetSilent | Op::HashGet | Op::HashGetSilent => true, + Op::Acquire => inst + .operands + .first() + .copied() + .is_some_and(|source| value_may_carry_container_miss(ctx, source)), + _ => false, + } +} + /// Returns the normalized return type for a class `__get` magic property hook. fn magic_get_result_type(ctx: &LoweringContext<'_, '_>, class_name: &str) -> Option { class_method_signature(ctx, class_name, &php_symbol_key("__get")) @@ -10692,6 +10895,7 @@ fn dynamic_property_get_result_type( let Some((class_name, nullable)) = singular_object_class(&object_ty) else { return fallback_expr_type(expr); }; + let nullable = nullable || value_may_carry_container_miss(ctx, object); let normalized = class_name.trim_start_matches('\\'); if is_builtin_stdclass_name(normalized) { return if nullable { @@ -10845,7 +11049,10 @@ fn lower_method_call( return value; } } - if op == Op::MethodCall && value_is_nullable(ctx, object.value) { + if op == Op::MethodCall + && (value_is_nullable(ctx, object.value) + || value_may_carry_container_miss(ctx, object.value)) + { return lower_nullable_regular_method_call(ctx, object, method, args, expr); } if op == Op::MethodCall && is_reflection_class_new_instance_call(ctx, object.value, method) { @@ -13407,9 +13614,16 @@ fn is_reflection_class_construction_receiver( /// Emits the PHP fatal terminator for an ordinary method call on null. fn terminate_method_call_on_null(ctx: &mut LoweringContext<'_, '_>, method: &str) { - let message = format!("Fatal error: Call to a member function {}() on null\n", method); + let message = format!("Call to a member function {}() on null", method); let message = ctx.intern_string(&message); - ctx.builder.terminate(Terminator::Fatal { message }); + ctx.emit_void( + Op::ThrowError, + Vec::new(), + Some(Immediate::Data(message)), + Op::ThrowError.default_effects(), + None, + ); + ctx.builder.terminate(Terminator::Unreachable); } /// Lowers a nullsafe method call with lazy argument evaluation for nullable receivers. @@ -13561,7 +13775,7 @@ pub(super) fn lower_dynamic_method_call_with_receiver( let receiver = Expr::new(ExprKind::Variable(receiver_name), expr.span); let callback = Expr::new( ExprKind::ArrayLiteral(vec![receiver, method.clone()]), - expr.span, + Span::dummy(), ); let mut call_args = Vec::with_capacity(args.len() + 1); call_args.push(callback); diff --git a/src/ir_lower/stmt/mod.rs b/src/ir_lower/stmt/mod.rs index ea746204e8..f258b6189d 100644 --- a/src/ir_lower/stmt/mod.rs +++ b/src/ir_lower/stmt/mod.rs @@ -2117,7 +2117,14 @@ fn acquire_borrowed_return_value( } if !matches!( ctx.builder.value_defining_op(value.value), - Some(Op::ArrayGet | Op::HashGet | Op::PropGet | Op::DynamicPropGet | Op::NullsafePropGet) + Some( + Op::ArrayGet + | Op::HashGet + | Op::HashGetSilent + | Op::PropGet + | Op::DynamicPropGet + | Op::NullsafePropGet + ) ) { return value; } diff --git a/tests/codegen/regressions/arrays.rs b/tests/codegen/regressions/arrays.rs index 97e673763a..4ffe80dd7c 100644 --- a/tests/codegen/regressions/arrays.rs +++ b/tests/codegen/regressions/arrays.rs @@ -965,3 +965,306 @@ echo $a[0], ":", $b[0]; ); assert_eq!(out, "5:6"); } + +// --- Issue #526: chained subscript read with a miss on the FIRST index --- + +/// Regression for issue #526: a chained subscript read whose FIRST index misses +/// must warn and propagate null instead of dereferencing the scalar null +/// sentinel as an array pointer (historical SIGSEGV in the outer length load). +#[test] +fn test_chained_read_first_index_miss_warns_and_yields_null() { + let out = compile_and_run_capture( + r#" ['x' => 1, 'y' => 2], 'b' => ['x' => 3]]; +$v = $m['nope']['x']; +echo is_null($v) ? 'null' : 'notnull'; +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!(out.stdout, "null"); +} + +/// Regression for issue #526: a three-level chain with a miss at each level +/// yields null at every level without crashing. +#[test] +fn test_three_level_chain_miss_at_each_level_yields_null() { + let out = compile_and_run_capture( + r#"getMessage() . "\n"; } +try { $copy = [...$a[7]]; } catch (Error $e) { echo $e->getMessage() . "\n"; } +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!( + out.stdout, + "count(): Argument #1 ($value) must be of type Countable|array, null given\n\ +Only arrays and Traversables can be unpacked, null given\n" + ); + assert_eq!(out.stderr.matches("Warning: Undefined array key 7").count(), 2); +} + +/// Regression for issue #526: property and method consumers recognize the raw +/// object null sentinel produced by a missed object-array read. +#[test] +fn test_array_miss_object_property_warns_and_method_throws() { + let out = compile_and_run_capture( + r#"value); +$std = new stdClass(); +$std->value = 1; +$stdObjects = [$std]; +var_dump($stdObjects[7]->value); +$magicObjects = [new MagicMissBox()]; +var_dump($magicObjects[7]->value); +$property = "value"; +var_dump($objects[7]->{$property}); +try { $objects[7]->take(should_not_run()); } +catch (Error $e) { echo $e->getMessage(); } +$method = "take"; +try { $objects[7]->{$method}(should_not_run()); } +catch (Error $e) { echo ":" . $e->getMessage(); } +$miss = $objects[7]; +try { $miss->{$method}(should_not_run()); } +catch (Error $e) { echo ":" . $e->getMessage(); } +echo ":" . $objects[0]->{$method}(3); +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!( + out.stdout, + "NULL\nNULL\nNULL\nNULL\nCall to a member function take() on null:\ +Call to a member function take() on null:\ +Call to a member function take() on null:3" + ); + assert_eq!(out.stderr.matches("Warning: Undefined array key 7").count(), 7); + assert_eq!( + out.stderr + .matches("Warning: Attempt to read property \"value\" on null") + .count(), + 4 + ); +} + +/// Regression for issue #526: direct associative misses emit both PHP warnings, +/// while the same chained lookup under null coalescing remains silent. +#[test] +fn test_array_miss_assoc_chained_warns_directly_and_is_silent_under_coalesce() { + let out = compile_and_run_capture( + r#" ["leaf" => 1]]; +var_dump($map["missing"]["leaf"]); +echo $map["missing"]["leaf"] ?? 42; +"#, + ); + assert!(out.success, "program crashed: {}", out.stderr); + assert_eq!(out.stdout, "NULL\n42"); + assert_eq!( + out.stderr + .matches("Warning: Undefined array key \"missing\"") + .count(), + 1 + ); + assert_eq!( + out.stderr + .matches("Warning: Trying to access array offset on null") + .count(), + 1 + ); +} + +/// Regression for issue #554: a missing string-valued chained read retains its +/// null marker through ownership stabilization, while real empty strings do not. +#[test] +fn test_array_miss_string_coalesces_but_real_empty_strings_do_not() { + let out = compile_and_run_capture( + r#"