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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/long-running.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ structures (class entries, handler blocks) never carry a pointer into the next r
freed trampolines. After shutdown, z-engine performs no engine writes at all — hooks are
inactive during shutdown-phase object destructors, and installing a new hook throws.

`ObserverHook` (the `zend_observer` fcall bridge) follows this same lifecycle but has an extra
boot-time constraint: it can only be installed from the `Core::preload()` path and only when a
startup-time observer provider has enabled the engine observer machinery, otherwise it refuses with
a typed exception rather than corrupting memory. See [observer-hook.md](observer-hook.md).

### Runtime models

- **Worker loops** (RoadRunner, Swoole, ReactPHP, FrankenPHP worker mode): the whole worker
Expand Down
189 changes: 189 additions & 0 deletions docs/observer-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Observer hook (`zend_observer` fcall bridge)

`ObserverHook` bridges the engine's `zend_observer` fcall *begin*/*end* handlers to userland
callbacks, following the same install/uninstall/reinstall lifecycle and `Core` hook registry
semantics as the other hooks (see [long-running.md](long-running.md#hook-lifecycle)). It targets a
single `zend_function` and attaches a begin and an end handler through the engine's per-function
runtime API (`zend_observer_add_begin_handler` / `zend_observer_add_end_handler`).

```php
// From the opcache.preload script only (see below):
$function = (new ReflectionFunction('some_function'))->getRawFunctionPointer();
Core::observeFunction(
$function,
fn(ExecutionData $frame) => /* begin */,
fn(ExecutionData $frame, ?ReflectionValue $return) => /* end; omit for a begin-only hook */,
);
```

The callbacks receive an [`ExecutionData`](../src/System/ExecutionData.php) frame; the end callback
also receives the return value (or `null` for abrupt/generator returns). Exceptions thrown by a
callback are contained and downgraded to an `E_USER_WARNING` — a throw must never cross the FFI
boundary into the engine ([#50](https://github.com/lisachenko/z-engine/issues/50)).

The full firing path — begin/end with return values, clean uninstall, nested-call ordering,
throwing functions under begin-only hooks, containment, and internal functions — is verified
end-to-end by [`ObserverHookFiringTest`](../tests/System/Hook/ObserverHookFiringTest.php) against
the reference provider described below.

## The hard constraints

The `zend_observer` fcall machinery is designed for **C extensions that register during MINIT**, and
that assumption leaks into every part of its API. The constraints below are not policy choices;
they are what the engine does, verified against php-src 8.4.19.

### 1. Registration timing — preload only, and even that is too late to *enable* observers

`zend_observer_fcall_register()` is only honoured before startup finishes. The engine freezes the
observer configuration in `zend_observer_post_startup()`, which runs at the tail of
`php_module_startup()` (`main.c`), **before** the `opcache.preload` script executes — preloading is
driven from `zend_post_startup()` → `accel_post_startup()` → `accel_finish_startup()`, which the
engine calls *after* `zend_observer_post_startup()`.

Consequently, by the time `Core::preload()` runs, `zend_observer_fcall_op_array_extension` is already
`-1` (observers disabled) unless a startup-time provider reserved the slot. This is directly
observable:

```
$ php -d ffi.enable=1 -d opcache.enable_cli=1 \
-d opcache.preload=probe.php -r ''
# probe.php, during preload:
op_array_extension = -1 # ZEND_OBSERVER_ENABLED is false
```

`ObserverHook` therefore requires the preload boot path (`Core::isPreloaded()`), and refuses with
`ObserverException::notPreloaded()` under a plain `Core::init()` request. But the preload requirement
is necessary, not sufficient: the machinery must additionally have been *enabled* by a startup
provider (next point).

### 2. Already-compiled op_arrays and the retroactive-stamping verdict

Observer support is stamped into each function at **compile time**, in `pass_two`
(`zend_opcode.c`): `op_array->cache_size = zend_observer_fcall_op_array_extension_handles *
sizeof(void*)` is set in `init_op_array`, and `op_array->T += ZEND_OBSERVER_ENABLED` reserves the
per-frame temporary that stores the observed-frame linked list. Internal functions get one shared
`run_time_cache` block sized once at startup by `zend_init_internal_run_time_cache()`.

This makes **retroactive stamping unsafe** and, in fact, makes userland self-enablement impossible:

- A function compiled while observers were **disabled** has a `cache_size` and `T` that do **not**
include an observer slot. Writing observer handler data into its `run_time_cache`, or enabling
observers so the VM reads a `prev_observed_frame` temporary the frame never reserved, is an
out-of-bounds access — heap and stack corruption.
- Internal functions share a single startup-sized cache block; growing the extension handle count
afterwards cannot grow that block.

Enabling observers late (setting `zend_observer_fcall_op_array_extension` by hand from the preload
script) was tested and **segfaults**: the engine's observer install path invokes the registered
`zend_observer_fcall_init` — a callback that returns a struct by value — from inside call-frame
setup, and driving that through an FFI trampoline corrupts execution state
(`SIGSEGV` on the first observed call). z-engine therefore never self-enables observers.

**Observed/unobserved boundary.** Only functions compiled **after** the engine's observer machinery
was enabled — by a startup-time provider — can be observed. On a stock z-engine build with no such
provider, observers are disabled and `ObserverHook::install()` refuses with
`ObserverException::observersDisabled()` rather than corrupting memory. This boundary is pinned by a
test: [`ObserverHookPreloadTest`](../tests/System/Hook/ObserverHookPreloadTest.php) boots through the
preload path and asserts `PRELOADED=1`, `OBSERVER_ENABLED=0`, `OBSERVE=rejected`.

### 3. Callback-exception containment — and why throwing functions need begin-only hooks

`handleBegin()` / `handleEnd()` wrap the userland callback in a catch-all that downgrades any
`Throwable` to an `E_USER_WARNING` (a user error handler converting that warning back into an
exception is swallowed too), exactly like the other FFI-callback hooks
([#50](https://github.com/lisachenko/z-engine/issues/50)). This is verified end-to-end: a begin
callback that throws produces the warning and the function call — including its end handler —
continues unharmed.

There is a second, harder containment problem that **cannot** be solved from userland: the engine
invokes **end handlers while unwinding a throwing frame**, i.e. with `EG(exception)` set — and
ext/ffi refuses to run any callback in that state. `zend_call_function()` skips the PHP closure
outright when `EG(exception)` is set ("we would result in an unstable executor otherwise"), and the
FFI trampoline then aborts the whole process with the fatal error *"Throwing from FFI callbacks is
not allowed"* — all in C, before any z-engine code gets control. Therefore:

> **A function that can throw must be observed with a begin-only hook** (`$end = null` /
> omitted). Begin handlers run at frame entry, where no exception can be in flight, and the
> exception then propagates through the observed function exactly as without the hook.

Both sides are pinned by [`ObserverHookFiringTest`](../tests/System/Hook/ObserverHookFiringTest.php):
the begin-only hook observes the throwing function and the exception is caught normally, while a
deliberately attached end handler reproduces the documented ext/ffi abort in a sacrificial child
process. If a future PHP release lifts the ext/ffi restriction, that pin fails and the begin-only
rule can be revisited.

### 4. Internal vs userland functions

For a **user function**, `install()` warms the lazily-allocated `run_time_cache`
(`zend_init_func_run_time_cache`) so the observer slot exists before the first call, then attaches
via the op_array observer extension slot. For an **internal function**, observation uses the separate
`zend_observer_fcall_internal_function_extension` slot and the startup-sized shared cache block;
z-engine refuses whenever that slot is `-1`, because the block is frozen at startup and cannot be
grown from userland. Both kinds fire verifiably
([`ObserverHookFiringTest`](../tests/System/Hook/ObserverHookFiringTest.php) asserts begin/end for a
preload-compiled user function and for `strrev`), and the guard paths are covered by
[`ObserverHookTest`](../tests/System/Hook/ObserverHookTest.php) /
[`ObserverHookPreloadTest`](../tests/System/Hook/ObserverHookPreloadTest.php).

Note on `zend_execute_internal`-based paths: observer begin/end for internal functions is driven by
the *calling* op_array's `DO_ICALL`/`DO_FCALL` observer handler variants, not by replacing
`zend_execute_internal`, so the two interception mechanisms are independent and can coexist.

### 5. JIT

Out of scope — z-engine already requires `opcache.jit=off`.

## The reference startup-time provider

z-engine ships the minimal provider as a test fixture:
[`tests/fixtures/observer-enabler`](../tests/fixtures/observer-enabler/observer_enabler.c) — a
~50-line extension whose MINIT registers an fcall observer returning `{NULL, NULL}` handlers for
every function. Registering it is enough to make the engine reserve the observer extension slots
(`ZEND_OBSERVER_ENABLED` becomes true) while observing nothing itself; the per-function runtime API
then becomes fully usable by `ObserverHook`. Consumers who want observer support in production can
replicate it verbatim (build with `phpize && ./configure && make`, load with `extension=...`), or
load any existing observer-registering extension instead.
[`ObserverHookFiringTest`](../tests/System/Hook/ObserverHookFiringTest.php) builds this fixture on
demand with the local toolchain and skips cleanly when `phpize`/`cc` are unavailable.

## Slot priming and provider interaction

The engine initialises a function's observer handler slots lazily, on the function's first call in
a request, by walking every registered provider's init callback (`zend_observer_fcall_install`); the
runtime add-handler API is only legal on initialised slots. `ObserverHook::install()` therefore
primes a never-called function's slots itself, writing the engine's own `NOT_OBSERVED` sentinel —
exactly what the install routine would write for a `{NULL, NULL}` provider — before attaching.

Two consequences, both accepted and documented:

- Priming marks the function "installed", so **other providers' lazy init callbacks are not
consulted for that function** for the rest of the request. With the reference enabler (which
observes nothing) this changes nothing; alongside a real observing extension it means a
z-engine-hooked function is not seen by that extension's per-function init in the same request.
- The engine reserves exactly `2 × count` handler slots per function (count = registered
providers, derived via `Core::observerFcallObserverCount()`), and z-engine cannot prove a second
begin/end pair would fit — so **only one `ObserverHook` per function** is allowed;
a second `install()` throws `ObserverException::alreadyObserved()`.

## Lifecycle and long-running processes

`ObserverHook` registers in the `Core` hook registry under the synthetic key
`observer-fcall::<function address>`, so `Core::shutdown()` detaches every still-installed hook while
the libffi trampolines are guaranteed alive (`zend_observer_remove_begin_handler` /
`remove_end_handler`), and `Core::reinstallHooks()` re-mints the begin/end trampolines for SAPIs that
cycle FFI callback state between requests. Each installed hook holds up to two live trampolines
(begin, and end when attached); both are owned by ext/ffi and freed at its `RSHUTDOWN`, covered by
the generic "one live libffi trampoline per installed hook" row in the
[immortal allocation table](long-running.md).

## Summary

| Requirement | Behaviour |
|-------------|-----------|
| Non-preload boot (`Core::init()`) | `ObserverException::notPreloaded()` |
| Preload boot, observers disabled (stock build) | `ObserverException::observersDisabled()` |
| Preload boot, observers enabled by a startup provider | begin/end fire for functions compiled after enablement, userland and internal (verified) |
| Second hook on the same function | `ObserverException::alreadyObserved()` |
| Callback throws | contained, `E_USER_WARNING`, execution continues |
| Observed function throws | supported with a begin-only hook; an end handler would be aborted by ext/ffi (pinned) |
| `Core::shutdown()` | handlers detached while trampolines are alive |
71 changes: 71 additions & 0 deletions include/8.4/linux-x64-nts/engine.h
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ typedef struct _zend_lazy_objects_store {
HashTable infos;
} zend_lazy_objects_store;
typedef struct _zend_property_info zend_property_info;
typedef struct _zend_fcall_info zend_fcall_info;
typedef struct _zend_fcall_info_cache zend_fcall_info_cache;
struct _zend_property_info;
typedef zval *(*zend_object_read_property_t)(zend_object *object, zend_string *member, int type, void **cache_slot, zval *rv);
typedef zval *(*zend_object_read_dimension_t)(zend_object *object, zval *offset, int type, zval *rv);
Expand Down Expand Up @@ -882,6 +884,22 @@ struct _zend_function_entry {
const zend_frameless_function_info *frameless_function_infos;
const char *doc_comment;
};
struct _zend_fcall_info {
size_t size;
zval function_name;
zval *retval;
zval *params;
zend_object *object;
uint32_t param_count;
HashTable *named_params;
};
struct _zend_fcall_info_cache {
zend_function *function_handler;
zend_class_entry *calling_scope;
zend_class_entry *called_scope;
zend_object *object;
zend_object *closure;
};
struct _zend_ini_entry {
zend_string *name;
int (*on_modify)(zend_ini_entry *entry, zend_string *new_value, void *mh_arg1, void *mh_arg2, void *mh_arg3, int stage);
Expand Down Expand Up @@ -946,6 +964,50 @@ typedef struct _zend_lex_state {
zend_ast *ast;
zend_arena *ast_arena;
} zend_lex_state;
typedef enum {
ZEND_FIBER_STATUS_INIT,
ZEND_FIBER_STATUS_RUNNING,
ZEND_FIBER_STATUS_SUSPENDED,
ZEND_FIBER_STATUS_DEAD,
} zend_fiber_status;
typedef struct _zend_fiber_stack zend_fiber_stack;
typedef struct _zend_fiber_transfer {
zend_fiber_context *context;
zval value;
uint8_t flags;
} zend_fiber_transfer;
typedef void (*zend_fiber_coroutine)(zend_fiber_transfer *transfer);
typedef void (*zend_fiber_clean)(zend_fiber_context *context);
struct _zend_fiber_context {
void *handle;
void *kind;
zend_fiber_coroutine function;
zend_fiber_clean cleanup;
zend_fiber_stack *stack;
zend_fiber_status status;
zend_execute_data *top_observed_frame;
void *reserved[6];
};
struct _zend_fiber {
zend_object std;
uint8_t flags;
zend_fiber_context context;
zend_fiber_context *caller;
zend_fiber_context *previous;
zend_fcall_info fci;
zend_fcall_info_cache fci_cache;
zend_execute_data *execute_data;
zend_execute_data *stack_bottom;
zend_vm_stack vm_stack;
zval result;
};
typedef void (*zend_observer_fcall_begin_handler)(zend_execute_data *execute_data);
typedef void (*zend_observer_fcall_end_handler)(zend_execute_data *execute_data, zval *retval);
typedef struct _zend_observer_fcall_handlers {
zend_observer_fcall_begin_handler begin;
zend_observer_fcall_end_handler end;
} zend_observer_fcall_handlers;
typedef zend_observer_fcall_handlers (*zend_observer_fcall_init)(zend_execute_data *execute_data);
typedef struct _zend_closure {
zend_object std;
zend_function func;
Expand All @@ -964,6 +1026,13 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong);
extern void zend_hash_destroy(HashTable *);
extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t);
extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t);
extern void zend_observer_fcall_register(zend_observer_fcall_init);
extern void zend_observer_add_begin_handler(zend_function *, zend_observer_fcall_begin_handler);
extern void zend_observer_add_end_handler(zend_function *, zend_observer_fcall_end_handler);
extern _Bool zend_observer_remove_begin_handler(zend_function *, zend_observer_fcall_begin_handler, zend_observer_fcall_begin_handler *);
extern _Bool zend_observer_remove_end_handler(zend_function *, zend_observer_fcall_end_handler, zend_observer_fcall_end_handler *);
extern void zend_init_func_run_time_cache(zend_op_array *);
extern size_t zend_internal_run_time_cache_reserved_size(void);
extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool);
extern zend_object * zend_objects_new(zend_class_entry *);
extern void zend_object_std_init(zend_object *, zend_class_entry *);
Expand Down Expand Up @@ -1003,3 +1072,5 @@ extern struct _zend_compiler_globals compiler_globals;
extern HashTable module_registry;
extern const zend_object_handlers std_object_handlers;
extern zend_ast_process_t zend_ast_process;
extern int zend_observer_fcall_op_array_extension;
extern int zend_observer_fcall_internal_function_extension;
Loading
Loading