From b0c45e5fee2c5cde9dd1a3199d053b3f903603c4 Mon Sep 17 00:00:00 2001 From: nvms Date: Thu, 10 Sep 2026 09:28:28 -0400 Subject: [PATCH] add the extension api --- .github/workflows/ci.yml | 22 + Makefile | 10 +- README.md | 30 + build.zig | 39 ++ include/zphp_extension.h | 288 +++++++++ src/extension.zig | 1019 +++++++++++++++++++++++++++++++ src/main.zig | 40 +- src/runtime/vm.zig | 25 +- src/serve.zig | 2 + src/stdlib/types.zig | 3 +- tests/extensions/app.php | 14 + tests/extensions/bad_abi.c | 3 + tests/extensions/bench.php | 9 + tests/extensions/clash.c | 5 + tests/extensions/demo.c | 332 ++++++++++ tests/extensions/demo.expected | 77 +++ tests/extensions/demo.php | 43 ++ tests/extensions/dup.c | 5 + tests/extensions/failing_init.c | 4 + tests/extensions/noentry.c | 1 + tests/extensions/run | 124 ++++ tests/serve/app.php | 2 + tests/serve_test | 1 + 23 files changed, 2088 insertions(+), 10 deletions(-) create mode 100644 include/zphp_extension.h create mode 100644 src/extension.zig create mode 100644 tests/extensions/app.php create mode 100644 tests/extensions/bad_abi.c create mode 100644 tests/extensions/bench.php create mode 100644 tests/extensions/clash.c create mode 100644 tests/extensions/demo.c create mode 100644 tests/extensions/demo.expected create mode 100644 tests/extensions/demo.php create mode 100644 tests/extensions/dup.c create mode 100644 tests/extensions/failing_init.c create mode 100644 tests/extensions/noentry.c create mode 100755 tests/extensions/run diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6789d4a0..2b3a6278 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -372,6 +372,28 @@ jobs: - run: ./tests/wordpress/setup - run: ./tests/wordpress/run + extensions: + needs: changes + if: needs.changes.outputs.run-tests == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: mlugg/setup-zig@v2 + with: + version: 0.15.1 + - name: install system libs + timeout-minutes: 5 + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo sed -i 's|http://azure.archive.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' /etc/apt/apt-mirrors.txt /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || true + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 update + sudo apt-get install -y libpcre2-dev libsqlite3-dev zlib1g-dev libmysqlclient-dev libpq-dev libssl-dev libnghttp2-dev libcurl4-openssl-dev libxml2-dev libicu-dev libgmp-dev libgd-dev libsodium-dev libldap2-dev + - run: zig build + - name: Run ./tests/extensions/run (dynamic and static) + run: STATIC=1 ./tests/extensions/run + compile: needs: changes if: needs.changes.outputs.run-tests == 'true' diff --git a/Makefile b/Makefile index bea7e756..2757e7fb 100644 --- a/Makefile +++ b/Makefile @@ -2,12 +2,12 @@ PKG_CONFIG_PATH := /opt/homebrew/opt/mysql-client/lib/pkgconfig:/opt/homebrew/op export PKG_CONFIG_PATH .PHONY: build -build: ## Build zphp (Debug; ~30x slower than release due to Zig's debug allocator stack-trace capture). use `make release` for benchmarking - zig build +build: ## Build zphp (Debug; ~30x slower than release due to Zig's debug allocator stack-trace capture). use `make release` for benchmarking. ZIG_FLAGS adds build options such as -Dextension=path.c + zig build $(ZIG_FLAGS) .PHONY: release release: ## Build zphp in ReleaseFast (no debug allocator overhead). prefer for any perf-sensitive run; for testing zphp's actual PHP-execution speed - zig build -Doptimize=ReleaseFast + zig build -Doptimize=ReleaseFast $(ZIG_FLAGS) .PHONY: test test: ## Run zig unit tests @@ -40,6 +40,10 @@ bench-compare: ## Time this tree against its merge base with main on this machin fuzz: build ## Mutation-fuzz the pipeline and decoders on the Debug build (FUZZ_SECONDS per fuzzer, default 120) python3 ./tests/fuzz/run all $(or $(FUZZ_SECONDS),120) +.PHONY: ext +ext: build ## Run extension API tests (builds tests/extensions/demo.c with zig cc; STATIC=1 also builds a zphp with it compiled in, BENCH=1 prints call overhead) + ./tests/extensions/run + .PHONY: soak soak: ## Run the memory soak (ReleaseFast): a string-heavy loop must hold a flat RSS zig build -Doptimize=ReleaseFast diff --git a/README.md b/README.md index 4dd38344..0128c76b 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,36 @@ The glibc and macOS builds run on the platform they were made for; the musl buil See the [documentation](https://nvms.github.io/zphp/) for build instructions and usage guides. +## Extensions + +zphp loads native extensions written against a C ABI. An extension registers functions, classes, constants, ini defaults, and resource types once at load time, and every PHP call into it goes straight to a resolved function pointer. The header is `include/zphp_extension.h`; the same source builds as a shared library or gets compiled into zphp. + +```c +#include "zphp_extension.h" + +static void hello_add(zphp_ctx *ctx) { + zphp_return_int(ctx, zphp_get_int(zphp_arg(ctx, 0)) + zphp_get_int(zphp_arg(ctx, 1))); +} + +static int module_init(zphp_module *m) { + return zphp_register_function(m, "hello_add", hello_add); +} + +static const zphp_extension hello = { + .abi = ZPHP_EXTENSION_ABI, .name = "hello", .version = "1.0.0", .module_init = module_init, +}; + +ZPHP_EXTENSION(hello, &hello) +``` + +```sh +zig cc -shared -O2 -I include -o hello.so hello.c +zphp --extension=hello.so run app.php # dynamic +zig build -Doptimize=ReleaseFast -Dextension=hello.c # static, compiled into zphp +``` + +`ZPHP_EXTENSION_DIR` names a directory whose libraries load automatically. Extensions get module, worker, and request lifecycle hooks, a request-local and a worker-local data slot, and a destructor per resource type that runs when the PHP value is unset, goes out of scope, unwinds through an exception, or the request ends. Values cross the boundary as opaque handles, so the runtime's internals can change without breaking compiled extensions; an ABI version in the descriptor rejects mismatches at load time. The static musl release binaries cannot load shared libraries and take static extensions only. `tests/extensions/demo.c` exercises the whole API. + ## Project Status zphp was originally developed and maintained heavily through AI-assisted development. diff --git a/build.zig b/build.zig index d6f6a76d..b7a63c87 100644 --- a/build.zig +++ b/build.zig @@ -25,6 +25,17 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // static extensions: C sources compiled into the binary. the generated + // module lists their entry points so the loader finds them without dlopen + const extension_sources = b.option([]const []const u8, "extension", "C source of a static extension, repeatable; the file stem is the extension name") orelse &.{}; + const static_extensions = staticExtensionsModule(b, extension_sources); + exe_mod.addImport("static_extensions", static_extensions); + fast_loop_mod.addImport("static_extensions", static_extensions); + for (extension_sources) |source| { + exe_mod.addCSourceFile(.{ .file = .{ .cwd_relative = source }, .flags = &.{ "-std=c11", "-DZPHP_STATIC_EXTENSION" } }); + } + exe_mod.addIncludePath(b.path("include")); + exe_mod.linkSystemLibrary("pcre2-8", .{ .preferred_link_mode = .static }); exe_mod.linkSystemLibrary("sqlite3", .{ .preferred_link_mode = .static }); exe_mod.linkSystemLibrary("z", .{ .preferred_link_mode = .static }); @@ -84,6 +95,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + test_mod.addImport("static_extensions", static_extensions); test_mod.linkSystemLibrary("pcre2-8", .{ .preferred_link_mode = .static }); test_mod.linkSystemLibrary("sqlite3", .{ .preferred_link_mode = .static }); @@ -241,6 +253,33 @@ fn archiveIn(b: *std.Build, dir: []const u8, file: []const u8) ?[]const u8 { return path; } +fn staticExtensionsModule(b: *std.Build, sources: []const []const u8) *std.Build.Module { + var code = std.ArrayListUnmanaged(u8){}; + const w = code.writer(b.allocator); + w.writeAll("pub const Entry = *const fn (*const anyopaque) callconv(.c) ?*const anyopaque;\n") catch @panic("OOM"); + w.writeAll("pub const StaticExtension = struct { name: []const u8, entry: Entry };\n") catch @panic("OOM"); + for (sources) |source| { + const stem = std.fs.path.stem(source); + if (!validIdentifier(stem)) std.debug.panic("-Dextension={s}: the file stem must be a C identifier, it names the extension entry", .{source}); + w.print("extern fn zphp_extension_entry_{s}(api: *const anyopaque) callconv(.c) ?*const anyopaque;\n", .{stem}) catch @panic("OOM"); + } + w.writeAll("pub const entries = [_]StaticExtension{") catch @panic("OOM"); + for (sources) |source| { + const stem = std.fs.path.stem(source); + w.print(" .{{ .name = \"{s}\", .entry = &zphp_extension_entry_{s} }},", .{ source, stem }) catch @panic("OOM"); + } + w.writeAll(" };\n") catch @panic("OOM"); + const files = b.addWriteFiles(); + const path = files.add("static_extensions.zig", code.items); + return b.createModule(.{ .root_source_file = path }); +} + +fn validIdentifier(s: []const u8) bool { + if (s.len == 0 or std.ascii.isDigit(s[0])) return false; + for (s) |ch| if (!(std.ascii.isAlphanumeric(ch) or ch == '_')) return false; + return true; +} + fn pkgConfigVariable(b: *std.Build, pkg: []const u8, name: []const u8) ?[]const u8 { const arg = std.fmt.allocPrint(b.allocator, "--variable={s}", .{name}) catch return null; const r = std.process.Child.run(.{ diff --git a/include/zphp_extension.h b/include/zphp_extension.h new file mode 100644 index 00000000..574910aa --- /dev/null +++ b/include/zphp_extension.h @@ -0,0 +1,288 @@ +/* + * zphp extension API. + * + * An extension is a C (or C-ABI) module that exports one entry point and + * registers functions, classes, constants, ini defaults, and resource types + * with zphp. The same source builds as a dynamic extension (a shared library + * loaded with `--extension=PATH` or from `ZPHP_EXTENSION_DIR`) or as a static + * extension compiled into zphp with `zig build -Dextension=PATH`. + * + * Every runtime call goes through the `zphp_api` table zphp hands to the entry + * point, so an extension never links against zphp symbols and never sees the + * layout of a PHP value. The inline wrappers below give the table ordinary + * function names. + * + * Ownership: every `zphp_value` an extension receives or creates belongs to + * the current request and must not be kept past the function that obtained + * it. Bytes returned by zphp_get_string and zphp_ini_get follow the same + * rule. Pointers stored with zphp_set_request_data are released by the + * extension in request_shutdown; pointers stored with zphp_set_worker_data + * live until worker_shutdown. Native handles wrapped with zphp_resource are + * destroyed by the registered destructor when the PHP value is unset, goes + * out of scope, is freed by an exception, or when the request ends. + */ +#ifndef ZPHP_EXTENSION_H +#define ZPHP_EXTENSION_H + +#include +#include +#include + +#define ZPHP_EXTENSION_ABI 1u + +#if defined(_WIN32) +#define ZPHP_EXPORT __declspec(dllexport) +#else +#define ZPHP_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct zphp_ctx zphp_ctx; /* one call, hook invocation, or lifecycle event */ +typedef struct zphp_value zphp_value; /* a PHP value */ +typedef struct zphp_module zphp_module; /* registration handle passed to module_init */ +typedef struct zphp_class zphp_class; /* a class being registered */ + +typedef void (*zphp_fn)(zphp_ctx *ctx); +typedef void (*zphp_resource_dtor)(void *ptr); + +typedef enum zphp_type { + ZPHP_NULL = 0, + ZPHP_BOOL = 1, + ZPHP_INT = 2, + ZPHP_FLOAT = 3, + ZPHP_STRING = 4, + ZPHP_ARRAY = 5, + ZPHP_OBJECT = 6, + ZPHP_OTHER = 7 +} zphp_type; + +enum { + ZPHP_METHOD_STATIC = 1 +}; + +/* + * Lifecycle. module_init runs once per process when the extension loads and + * is the only place registration is allowed. worker_init/worker_shutdown run + * once per VM (one per serve worker, one for a CLI run); request_init and + * request_shutdown wrap every request. Hooks returning int report failure + * with a non-zero value: module_init failure rejects the extension, the + * others fail the worker or request. Any hook may be NULL. + */ +typedef struct zphp_extension { + uint32_t abi; + const char *name; + const char *version; + int (*module_init)(zphp_module *m); + void (*module_shutdown)(void); + int (*worker_init)(zphp_ctx *ctx); + void (*worker_shutdown)(zphp_ctx *ctx); + int (*request_init)(zphp_ctx *ctx); + void (*request_shutdown)(zphp_ctx *ctx); +} zphp_extension; + +typedef struct zphp_api { + uint32_t abi; + + /* registration (module_init only). 0 on success, -1 on failure */ + int (*register_function)(zphp_module *m, const char *name, zphp_fn fn); + zphp_class *(*register_class)(zphp_module *m, const char *name, const char *parent); + int (*class_add_method)(zphp_class *cls, const char *name, zphp_fn fn, uint8_t arity, uint32_t flags); + int (*class_add_constant_int)(zphp_class *cls, const char *name, int64_t value); + int (*class_add_constant_float)(zphp_class *cls, const char *name, double value); + int (*class_add_constant_bool)(zphp_class *cls, const char *name, bool value); + int (*class_add_constant_string)(zphp_class *cls, const char *name, const char *value); + int (*class_add_property)(zphp_class *cls, const char *name); + int (*class_add_property_int)(zphp_class *cls, const char *name, int64_t value); + int (*class_add_property_string)(zphp_class *cls, const char *name, const char *value); + int (*class_implements)(zphp_class *cls, const char *interface_name); + int (*register_interface)(zphp_module *m, const char *name, const char *const *methods, size_t method_count); + int (*register_constant_int)(zphp_module *m, const char *name, int64_t value); + int (*register_constant_float)(zphp_module *m, const char *name, double value); + int (*register_constant_bool)(zphp_module *m, const char *name, bool value); + int (*register_constant_string)(zphp_module *m, const char *name, const char *value); + int (*register_ini)(zphp_module *m, const char *name, const char *default_value); + uint32_t (*register_resource)(zphp_module *m, const char *class_name, zphp_resource_dtor dtor); + + /* arguments and $this */ + size_t (*arg_count)(zphp_ctx *ctx); + const zphp_value *(*arg)(zphp_ctx *ctx, size_t index); + const zphp_value *(*this_object)(zphp_ctx *ctx); + + /* reading values */ + zphp_type (*type_of)(const zphp_value *v); + int64_t (*get_int)(const zphp_value *v); + double (*get_float)(const zphp_value *v); + bool (*get_bool)(const zphp_value *v); + const char *(*get_string)(zphp_ctx *ctx, const zphp_value *v, size_t *len); + + /* creating values */ + zphp_value *(*make_null)(zphp_ctx *ctx); + zphp_value *(*make_bool)(zphp_ctx *ctx, bool value); + zphp_value *(*make_int)(zphp_ctx *ctx, int64_t value); + zphp_value *(*make_float)(zphp_ctx *ctx, double value); + zphp_value *(*make_string)(zphp_ctx *ctx, const char *bytes, size_t len); + + /* arrays */ + zphp_value *(*make_array)(zphp_ctx *ctx); + size_t (*array_count)(const zphp_value *array); + int (*array_push)(zphp_ctx *ctx, zphp_value *array, const zphp_value *value); + int (*array_set_int)(zphp_ctx *ctx, zphp_value *array, int64_t key, const zphp_value *value); + int (*array_set_string)(zphp_ctx *ctx, zphp_value *array, const char *key, size_t key_len, const zphp_value *value); + const zphp_value *(*array_get_int)(zphp_ctx *ctx, const zphp_value *array, int64_t key); + const zphp_value *(*array_get_string)(zphp_ctx *ctx, const zphp_value *array, const char *key, size_t key_len); + int (*array_at)(zphp_ctx *ctx, const zphp_value *array, size_t index, const zphp_value **key, const zphp_value **value); + + /* objects */ + zphp_value *(*make_object)(zphp_ctx *ctx, const char *class_name); + const char *(*object_class)(zphp_ctx *ctx, const zphp_value *object, size_t *len); + bool (*instance_of)(zphp_ctx *ctx, const zphp_value *value, const char *class_name); + const zphp_value *(*object_get)(zphp_ctx *ctx, const zphp_value *object, const char *name); + int (*object_set)(zphp_ctx *ctx, zphp_value *object, const char *name, const zphp_value *value); + + /* resources: a native pointer wrapped in an instance of the class registered for the type */ + zphp_value *(*make_resource)(zphp_ctx *ctx, uint32_t type, void *ptr); + void *(*resource_ptr)(zphp_ctx *ctx, const zphp_value *value, uint32_t type); + + /* calling back into PHP. NULL means the call threw; the exception propagates when the extension function returns */ + zphp_value *(*call)(zphp_ctx *ctx, const char *function, const zphp_value *const *args, size_t arg_count); + zphp_value *(*call_method)(zphp_ctx *ctx, const zphp_value *object, const char *method, const zphp_value *const *args, size_t arg_count); + + /* exceptions: the extension function should return after throwing */ + void (*throw_exception)(zphp_ctx *ctx, const char *class_name, const char *message); + + /* the function's return value (null when none is set) */ + void (*return_null)(zphp_ctx *ctx); + void (*return_bool)(zphp_ctx *ctx, bool value); + void (*return_int)(zphp_ctx *ctx, int64_t value); + void (*return_float)(zphp_ctx *ctx, double value); + void (*return_string)(zphp_ctx *ctx, const char *bytes, size_t len); + void (*return_value)(zphp_ctx *ctx, const zphp_value *value); + + /* output and configuration */ + void (*echo)(zphp_ctx *ctx, const char *bytes, size_t len); + const char *(*ini_get)(zphp_ctx *ctx, const char *name, size_t *len); + + /* per-extension state slots on the current VM */ + void *(*request_data)(zphp_ctx *ctx); + void (*set_request_data)(zphp_ctx *ctx, void *data); + void *(*worker_data)(zphp_ctx *ctx); + void (*set_worker_data)(zphp_ctx *ctx, void *data); +} zphp_api; + +typedef const zphp_extension *(*zphp_entry_fn)(const zphp_api *api); + +extern const zphp_api *zphp_api_v1; + +/* + * ZPHP_EXTENSION(ident, descriptor_pointer) defines the entry point. `ident` + * must be a C identifier; for a static extension it must match the source + * file's stem, which is how the build finds the entry. + */ +#ifdef ZPHP_STATIC_EXTENSION +#define ZPHP_EXTENSION_DYNAMIC_ENTRY(ident) +#else +#define ZPHP_EXTENSION_DYNAMIC_ENTRY(ident) \ + ZPHP_EXPORT const zphp_extension *zphp_extension_entry(const zphp_api *api) { return zphp_extension_entry_##ident(api); } +#endif + +#define ZPHP_EXTENSION(ident, descriptor) \ + const zphp_api *zphp_api_v1 = 0; \ + ZPHP_EXPORT const zphp_extension *zphp_extension_entry_##ident(const zphp_api *api) { \ + zphp_api_v1 = api; \ + return (descriptor); \ + } \ + ZPHP_EXTENSION_DYNAMIC_ENTRY(ident) + +/* registration */ +static inline int zphp_register_function(zphp_module *m, const char *name, zphp_fn fn) { return zphp_api_v1->register_function(m, name, fn); } +static inline zphp_class *zphp_register_class(zphp_module *m, const char *name, const char *parent) { return zphp_api_v1->register_class(m, name, parent); } +static inline int zphp_class_add_method(zphp_class *cls, const char *name, zphp_fn fn, uint8_t arity, uint32_t flags) { return zphp_api_v1->class_add_method(cls, name, fn, arity, flags); } +static inline int zphp_class_add_constant_int(zphp_class *cls, const char *name, int64_t value) { return zphp_api_v1->class_add_constant_int(cls, name, value); } +static inline int zphp_class_add_constant_float(zphp_class *cls, const char *name, double value) { return zphp_api_v1->class_add_constant_float(cls, name, value); } +static inline int zphp_class_add_constant_bool(zphp_class *cls, const char *name, bool value) { return zphp_api_v1->class_add_constant_bool(cls, name, value); } +static inline int zphp_class_add_constant_string(zphp_class *cls, const char *name, const char *value) { return zphp_api_v1->class_add_constant_string(cls, name, value); } +static inline int zphp_class_add_property(zphp_class *cls, const char *name) { return zphp_api_v1->class_add_property(cls, name); } +static inline int zphp_class_add_property_int(zphp_class *cls, const char *name, int64_t value) { return zphp_api_v1->class_add_property_int(cls, name, value); } +static inline int zphp_class_add_property_string(zphp_class *cls, const char *name, const char *value) { return zphp_api_v1->class_add_property_string(cls, name, value); } +static inline int zphp_class_implements(zphp_class *cls, const char *interface_name) { return zphp_api_v1->class_implements(cls, interface_name); } +static inline int zphp_register_interface(zphp_module *m, const char *name, const char *const *methods, size_t method_count) { return zphp_api_v1->register_interface(m, name, methods, method_count); } +static inline int zphp_register_constant_int(zphp_module *m, const char *name, int64_t value) { return zphp_api_v1->register_constant_int(m, name, value); } +static inline int zphp_register_constant_float(zphp_module *m, const char *name, double value) { return zphp_api_v1->register_constant_float(m, name, value); } +static inline int zphp_register_constant_bool(zphp_module *m, const char *name, bool value) { return zphp_api_v1->register_constant_bool(m, name, value); } +static inline int zphp_register_constant_string(zphp_module *m, const char *name, const char *value) { return zphp_api_v1->register_constant_string(m, name, value); } +static inline int zphp_register_ini(zphp_module *m, const char *name, const char *default_value) { return zphp_api_v1->register_ini(m, name, default_value); } +static inline uint32_t zphp_register_resource(zphp_module *m, const char *class_name, zphp_resource_dtor dtor) { return zphp_api_v1->register_resource(m, class_name, dtor); } + +/* arguments and $this */ +static inline size_t zphp_arg_count(zphp_ctx *ctx) { return zphp_api_v1->arg_count(ctx); } +static inline const zphp_value *zphp_arg(zphp_ctx *ctx, size_t index) { return zphp_api_v1->arg(ctx, index); } +static inline const zphp_value *zphp_this(zphp_ctx *ctx) { return zphp_api_v1->this_object(ctx); } + +/* reading values. get_int/get_float/get_bool convert the way PHP casts do */ +static inline zphp_type zphp_type_of(const zphp_value *v) { return zphp_api_v1->type_of(v); } +static inline int64_t zphp_get_int(const zphp_value *v) { return zphp_api_v1->get_int(v); } +static inline double zphp_get_float(const zphp_value *v) { return zphp_api_v1->get_float(v); } +static inline bool zphp_get_bool(const zphp_value *v) { return zphp_api_v1->get_bool(v); } +static inline const char *zphp_get_string(zphp_ctx *ctx, const zphp_value *v, size_t *len) { return zphp_api_v1->get_string(ctx, v, len); } + +/* creating values */ +static inline zphp_value *zphp_null(zphp_ctx *ctx) { return zphp_api_v1->make_null(ctx); } +static inline zphp_value *zphp_bool(zphp_ctx *ctx, bool value) { return zphp_api_v1->make_bool(ctx, value); } +static inline zphp_value *zphp_int(zphp_ctx *ctx, int64_t value) { return zphp_api_v1->make_int(ctx, value); } +static inline zphp_value *zphp_float(zphp_ctx *ctx, double value) { return zphp_api_v1->make_float(ctx, value); } +static inline zphp_value *zphp_string(zphp_ctx *ctx, const char *bytes, size_t len) { return zphp_api_v1->make_string(ctx, bytes, len); } + +/* arrays */ +static inline zphp_value *zphp_array(zphp_ctx *ctx) { return zphp_api_v1->make_array(ctx); } +static inline size_t zphp_array_count(const zphp_value *array) { return zphp_api_v1->array_count(array); } +static inline int zphp_array_push(zphp_ctx *ctx, zphp_value *array, const zphp_value *value) { return zphp_api_v1->array_push(ctx, array, value); } +static inline int zphp_array_set_int(zphp_ctx *ctx, zphp_value *array, int64_t key, const zphp_value *value) { return zphp_api_v1->array_set_int(ctx, array, key, value); } +static inline int zphp_array_set_string(zphp_ctx *ctx, zphp_value *array, const char *key, size_t key_len, const zphp_value *value) { return zphp_api_v1->array_set_string(ctx, array, key, key_len, value); } +static inline const zphp_value *zphp_array_get_int(zphp_ctx *ctx, const zphp_value *array, int64_t key) { return zphp_api_v1->array_get_int(ctx, array, key); } +static inline const zphp_value *zphp_array_get_string(zphp_ctx *ctx, const zphp_value *array, const char *key, size_t key_len) { return zphp_api_v1->array_get_string(ctx, array, key, key_len); } +static inline int zphp_array_at(zphp_ctx *ctx, const zphp_value *array, size_t index, const zphp_value **key, const zphp_value **value) { return zphp_api_v1->array_at(ctx, array, index, key, value); } + +/* objects */ +static inline zphp_value *zphp_object(zphp_ctx *ctx, const char *class_name) { return zphp_api_v1->make_object(ctx, class_name); } +static inline const char *zphp_object_class(zphp_ctx *ctx, const zphp_value *object, size_t *len) { return zphp_api_v1->object_class(ctx, object, len); } +static inline bool zphp_instance_of(zphp_ctx *ctx, const zphp_value *value, const char *class_name) { return zphp_api_v1->instance_of(ctx, value, class_name); } +static inline const zphp_value *zphp_object_get(zphp_ctx *ctx, const zphp_value *object, const char *name) { return zphp_api_v1->object_get(ctx, object, name); } +static inline int zphp_object_set(zphp_ctx *ctx, zphp_value *object, const char *name, const zphp_value *value) { return zphp_api_v1->object_set(ctx, object, name, value); } + +/* resources */ +static inline zphp_value *zphp_resource(zphp_ctx *ctx, uint32_t type, void *ptr) { return zphp_api_v1->make_resource(ctx, type, ptr); } +static inline void *zphp_resource_ptr(zphp_ctx *ctx, const zphp_value *value, uint32_t type) { return zphp_api_v1->resource_ptr(ctx, value, type); } + +/* calling PHP */ +static inline zphp_value *zphp_call(zphp_ctx *ctx, const char *function, const zphp_value *const *args, size_t arg_count) { return zphp_api_v1->call(ctx, function, args, arg_count); } +static inline zphp_value *zphp_call_method(zphp_ctx *ctx, const zphp_value *object, const char *method, const zphp_value *const *args, size_t arg_count) { return zphp_api_v1->call_method(ctx, object, method, args, arg_count); } + +/* exceptions */ +static inline void zphp_throw(zphp_ctx *ctx, const char *class_name, const char *message) { zphp_api_v1->throw_exception(ctx, class_name, message); } + +/* return values */ +static inline void zphp_return_null(zphp_ctx *ctx) { zphp_api_v1->return_null(ctx); } +static inline void zphp_return_bool(zphp_ctx *ctx, bool value) { zphp_api_v1->return_bool(ctx, value); } +static inline void zphp_return_int(zphp_ctx *ctx, int64_t value) { zphp_api_v1->return_int(ctx, value); } +static inline void zphp_return_float(zphp_ctx *ctx, double value) { zphp_api_v1->return_float(ctx, value); } +static inline void zphp_return_string(zphp_ctx *ctx, const char *bytes, size_t len) { zphp_api_v1->return_string(ctx, bytes, len); } +static inline void zphp_return_value(zphp_ctx *ctx, const zphp_value *value) { zphp_api_v1->return_value(ctx, value); } + +/* output and configuration */ +static inline void zphp_echo(zphp_ctx *ctx, const char *bytes, size_t len) { zphp_api_v1->echo(ctx, bytes, len); } +static inline const char *zphp_ini_get(zphp_ctx *ctx, const char *name, size_t *len) { return zphp_api_v1->ini_get(ctx, name, len); } + +/* state slots */ +static inline void *zphp_request_data(zphp_ctx *ctx) { return zphp_api_v1->request_data(ctx); } +static inline void zphp_set_request_data(zphp_ctx *ctx, void *data) { zphp_api_v1->set_request_data(ctx, data); } +static inline void *zphp_worker_data(zphp_ctx *ctx) { return zphp_api_v1->worker_data(ctx); } +static inline void zphp_set_worker_data(zphp_ctx *ctx, void *data) { zphp_api_v1->set_worker_data(ctx, data); } + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/extension.zig b/src/extension.zig new file mode 100644 index 00000000..9650d371 --- /dev/null +++ b/src/extension.zig @@ -0,0 +1,1019 @@ +// third-party extensions: a C ABI (include/zphp_extension.h) over the native +// function table. an extension registers functions, classes, constants, ini +// defaults, and resource types once per process in module_init; every VM +// applies that registry when it starts, and each PHP call reaches the +// extension through a comptime trampoline that carries its slot index +const std = @import("std"); +const builtin = @import("builtin"); +const vm_mod = @import("runtime/vm.zig"); +const VM = vm_mod.VM; +const NativeContext = vm_mod.NativeContext; +const NativeFn = vm_mod.NativeFn; +const RuntimeError = vm_mod.RuntimeError; +const NativeResult = vm_mod.NativeResult; +const ClassDef = vm_mod.ClassDef; +const InterfaceDef = vm_mod.InterfaceDef; +const value_mod = @import("runtime/value.zig"); +const Value = value_mod.Value; +const PhpArray = value_mod.PhpArray; +const PhpObject = value_mod.PhpObject; +const static_extensions = @import("static_extensions"); + +pub const abi_version: u32 = 1; +const max_functions = 2048; +const ptr_property = "__ext_ptr"; +const type_property = "__ext_type"; + +pub const Descriptor = extern struct { + abi: u32, + name: ?[*:0]const u8, + version: ?[*:0]const u8, + module_init: ?*const fn (*Extension) callconv(.c) c_int, + module_shutdown: ?*const fn () callconv(.c) void, + worker_init: ?*const fn (*Call) callconv(.c) c_int, + worker_shutdown: ?*const fn (*Call) callconv(.c) void, + request_init: ?*const fn (*Call) callconv(.c) c_int, + request_shutdown: ?*const fn (*Call) callconv(.c) void, +}; + +pub const ExtFn = *const fn (*Call) callconv(.c) void; +pub const ResourceDtor = *const fn (?*anyopaque) callconv(.c) void; +pub const EntryFn = *const fn (*const Api) callconv(.c) ?*const Descriptor; + +const CStr = ?[*:0]const u8; + +const FunctionReg = struct { name: []const u8, slot: u32 }; +const MethodReg = struct { name: []const u8, slot: u32, arity: u8, is_static: bool }; +const ConstantReg = struct { name: []const u8, value: Value }; +const PropertyReg = struct { name: []const u8, default: Value }; +const IniReg = struct { name: []const u8, default: []const u8 }; +const ResourceReg = struct { class_name: []const u8, dtor: ResourceDtor }; + +pub const ClassReg = struct { + ext: *Extension, + name: []const u8, + parent: ?[]const u8, + interfaces: std.ArrayListUnmanaged([]const u8) = .{}, + methods: std.ArrayListUnmanaged(MethodReg) = .{}, + constants: std.ArrayListUnmanaged(ConstantReg) = .{}, + properties: std.ArrayListUnmanaged(PropertyReg) = .{}, + resource_type: ?u32 = null, +}; + +const InterfaceReg = struct { name: []const u8, methods: std.ArrayListUnmanaged([]const u8) = .{} }; + +// the zphp_module handle: one loaded extension and everything it registered +pub const Extension = struct { + index: u32, + desc: *const Descriptor, + name: []const u8, + version: []const u8, + lib: ?std.DynLib = null, + registering: bool = false, + functions: std.ArrayListUnmanaged(FunctionReg) = .{}, + classes: std.ArrayListUnmanaged(*ClassReg) = .{}, + interfaces: std.ArrayListUnmanaged(InterfaceReg) = .{}, + constants: std.ArrayListUnmanaged(ConstantReg) = .{}, + inis: std.ArrayListUnmanaged(IniReg) = .{}, + resources: std.ArrayListUnmanaged(ResourceReg) = .{}, +}; + +// the zphp_ctx handle: one native call or lifecycle hook on one VM +pub const Call = struct { + ctx: *NativeContext, + ext: *Extension, + args: []const Value, + result: NativeResult = NativeResult.scalar(.null), + threw: bool = false, +}; + +// per-VM state slots, one per extension +pub const VmSlot = struct { request: ?*anyopaque = null, worker: ?*anyopaque = null }; + +const Slot = struct { ext: *Extension, func: ExtFn }; + +var registry_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); +var extensions: std.ArrayListUnmanaged(*Extension) = .{}; +var slots: [max_functions]Slot = undefined; +var slot_count: u32 = 0; +var resource_types: std.ArrayListUnmanaged(ResourceReg) = .{}; +var shutdown_registered = false; + +fn trampoline(comptime i: usize) NativeFn { + return &struct { + fn call(ctx: *NativeContext, args: []const Value) RuntimeError!NativeResult { + return invoke(&slots[i], ctx, args); + } + }.call; +} + +const trampolines: [max_functions]NativeFn = blk: { + @setEvalBranchQuota(max_functions * 4); + var table: [max_functions]NativeFn = undefined; + for (&table, 0..) |*entry, i| entry.* = trampoline(i); + break :blk table; +}; + +fn invoke(slot: *const Slot, ctx: *NativeContext, args: []const Value) RuntimeError!NativeResult { + var call = Call{ .ctx = ctx, .ext = slot.ext, .args = args }; + slot.func(&call); + if (call.threw) return error.RuntimeError; + return call.result; +} + +fn fail(comptime fmt: []const u8, args: anytype) noreturn { + std.debug.print("zphp: " ++ fmt ++ "\n", args); + std.process.exit(1); +} + +fn persistent() std.mem.Allocator { + return registry_arena.allocator(); +} + +fn dupe(s: []const u8) []const u8 { + return persistent().dupe(u8, s) catch fail("extension registry out of memory", .{}); +} + +fn cstr(s: CStr) ?[]const u8 { + return if (s) |p| std.mem.span(p) else null; +} + +pub fn loaded() []const *Extension { + return extensions.items; +} + +pub fn isLoaded(name: []const u8) bool { + for (extensions.items) |ext| if (std.ascii.eqlIgnoreCase(ext.name, name)) return true; + return false; +} + +// --------------------------------------------------------------------------- +// loading + +pub fn loadStatic() void { + inline for (static_extensions.entries) |entry| { + const desc: ?*const Descriptor = @ptrCast(@alignCast(entry.entry(&api_v1))); + adopt(entry.name, desc, null); + } +} + +pub fn loadDynamic(path: []const u8) void { + if (builtin.abi.isMusl() and builtin.link_mode == .static) { + fail("extension '{s}': this zphp binary is static and cannot load dynamic extensions; compile it in with -Dextension", .{path}); + } + var lib = std.DynLib.open(path) catch |err| fail("extension '{s}': cannot load ({s})", .{ path, @errorName(err) }); + const entry = lib.lookup(EntryFn, "zphp_extension_entry") orelse fail("extension '{s}': no zphp_extension_entry symbol; is ZPHP_EXTENSION() used?", .{path}); + adopt(path, entry(&api_v1), lib); +} + +pub fn loadDirectory(dir_path: []const u8) void { + var dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch |err| fail("extension directory '{s}': {s}", .{ dir_path, @errorName(err) }); + defer dir.close(); + var names: std.ArrayListUnmanaged([]const u8) = .{}; + defer names.deinit(persistent()); + var it = dir.iterate(); + while (it.next() catch null) |entry| { + if (entry.kind != .file and entry.kind != .sym_link) continue; + if (!isLibraryName(entry.name)) continue; + names.append(persistent(), dupe(entry.name)) catch fail("extension registry out of memory", .{}); + } + std.mem.sort([]const u8, names.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + for (names.items) |name| { + const full = std.fs.path.join(persistent(), &.{ dir_path, name }) catch fail("extension registry out of memory", .{}); + loadDynamic(full); + } +} + +fn isLibraryName(name: []const u8) bool { + const suffix: []const u8 = switch (builtin.os.tag) { + .macos, .ios => ".dylib", + .windows => ".dll", + else => ".so", + }; + return std.mem.endsWith(u8, name, suffix); +} + +fn adopt(origin: []const u8, desc_ptr: ?*const Descriptor, lib: ?std.DynLib) void { + const desc = desc_ptr orelse fail("extension '{s}': entry point returned no descriptor", .{origin}); + if (desc.abi != abi_version) fail("extension '{s}': built for ABI {d}, this zphp provides ABI {d}", .{ origin, desc.abi, abi_version }); + const name = cstr(desc.name) orelse fail("extension '{s}': descriptor has no name", .{origin}); + if (name.len == 0) fail("extension '{s}': descriptor has an empty name", .{origin}); + if (isLoaded(name)) fail("extension '{s}': an extension named '{s}' is already loaded", .{ origin, name }); + const ext = persistent().create(Extension) catch fail("extension registry out of memory", .{}); + ext.* = .{ + .index = @intCast(extensions.items.len), + .desc = desc, + .name = dupe(name), + .version = dupe(cstr(desc.version) orelse "0"), + .lib = lib, + }; + extensions.append(persistent(), ext) catch fail("extension registry out of memory", .{}); + if (desc.module_init) |init| { + ext.registering = true; + const rc = init(ext); + ext.registering = false; + if (rc != 0) fail("extension '{s}': module_init failed with status {d}", .{ name, rc }); + } + if (!shutdown_registered) { + shutdown_registered = true; + _ = atexit(shutdownAll); + } +} + +extern "c" fn atexit(?*const fn () callconv(.c) void) c_int; + +fn shutdownAll() callconv(.c) void { + var i = extensions.items.len; + while (i > 0) { + i -= 1; + if (extensions.items[i].desc.module_shutdown) |shutdown| shutdown(); + } +} + +// --------------------------------------------------------------------------- +// applying the registry to a VM + +pub fn vmInit(vm: *VM) RuntimeError!void { + if (extensions.items.len == 0) return; + vm.ic.?.ext_slots = try vm.allocator.alloc(VmSlot, extensions.items.len); + @memset(vm.ic.?.ext_slots, .{}); + for (extensions.items) |ext| { + for (ext.functions.items) |f| try registerNative(vm, f.name, f.slot); + for (ext.interfaces.items) |iface| { + if (vm.interfaces.contains(iface.name) or vm.classes.contains(iface.name)) fail("extension '{s}': interface '{s}' is already defined", .{ ext.name, iface.name }); + var def = InterfaceDef{ .name = iface.name }; + for (iface.methods.items) |m| try def.methods.append(vm.allocator, m); + try vm.interfaces.put(vm.allocator, iface.name, def); + } + for (ext.classes.items) |cls| try registerClass(vm, cls); + } + checkConstantConflicts(vm); + try applyConstants(vm); + for (extensions.items) |ext| { + const init = ext.desc.worker_init orelse continue; + var ctx = vm.makeContext(null); + var call = Call{ .ctx = &ctx, .ext = ext, .args = &.{} }; + if (init(&call) != 0) fail("extension '{s}': worker_init failed", .{ext.name}); + } +} + +fn registerNative(vm: *VM, name: []const u8, slot: u32) RuntimeError!void { + if (vm.native_fns.contains(name)) fail("extension '{s}': function '{s}' is already defined", .{ slots[slot].ext.name, name }); + try vm.native_fns.put(vm.allocator, name, trampolines[slot]); +} + +fn registerClass(vm: *VM, cls: *ClassReg) RuntimeError!void { + const a = vm.allocator; + if (vm.classes.contains(cls.name) or vm.interfaces.contains(cls.name)) fail("extension '{s}': class '{s}' is already defined", .{ cls.ext.name, cls.name }); + var def = ClassDef{ .name = cls.name, .parent = cls.parent }; + if (cls.resource_type != null) def.native_cleanup = resourceCleanup; + for (cls.interfaces.items) |iface| try def.interfaces.append(a, iface); + for (cls.properties.items) |p| try def.properties.append(a, .{ .name = p.name, .default = p.default, .has_default = true }); + for (cls.constants.items) |c| { + try def.static_props.put(a, c.name, c.value); + try def.constant_names.put(a, c.name, {}); + try def.constant_order.append(a, c.name); + } + for (cls.methods.items) |m| { + try def.addMethod(a, .{ .name = m.name, .arity = m.arity, .is_static = m.is_static }); + const full = try std.fmt.allocPrint(a, "{s}::{s}", .{ cls.name, m.name }); + try vm.persistent_strings.append(a, full); + try registerNative(vm, full, m.slot); + } + try vm.classes.put(a, cls.name, def); +} + +// constants are cleared and re-seeded on every serve reset, so this runs +// after initConstants both at init and at reset +pub fn applyConstants(vm: *VM) RuntimeError!void { + for (extensions.items) |ext| { + for (ext.constants.items) |c| { + try vm.php_constants.put(vm.allocator, c.name, c.value); + } + } +} + +pub fn checkConstantConflicts(vm: *VM) void { + for (extensions.items) |ext| { + for (ext.constants.items) |c| { + if (vm.php_constants.contains(c.name)) fail("extension '{s}': constant '{s}' is already defined", .{ ext.name, c.name }); + } + } +} + +pub fn beginRequest(vm: *VM) RuntimeError!void { + if (extensions.items.len == 0 or vm.ic.?.ext_request_active) return; + vm.ic.?.ext_request_active = true; + for (extensions.items) |ext| { + for (ext.inis.items) |ini| { + if (!vm.ini_settings.contains(ini.name)) try vm.ini_settings.put(vm.allocator, ini.name, ini.default); + } + const init = ext.desc.request_init orelse continue; + var ctx = vm.makeContext(null); + var call = Call{ .ctx = &ctx, .ext = ext, .args = &.{} }; + if (init(&call) != 0) { + vm.error_msg = "extension request_init failed"; + return error.RuntimeError; + } + } +} + +pub fn endRequest(vm: *VM) void { + if (!vm.ic.?.ext_request_active) return; + vm.ic.?.ext_request_active = false; + var i = extensions.items.len; + while (i > 0) { + i -= 1; + const ext = extensions.items[i]; + if (ext.desc.request_shutdown) |shutdown| { + var ctx = vm.makeContext(null); + var call = Call{ .ctx = &ctx, .ext = ext, .args = &.{} }; + shutdown(&call); + } + vm.ic.?.ext_slots[ext.index].request = null; + } + _ = vm.ic.?.ext_arena.reset(.retain_capacity); +} + +pub fn vmDeinit(vm: *VM) void { + const ic = vm.ic orelse return; + if (ic.ext_slots.len == 0) return; + endRequest(vm); + var i = extensions.items.len; + while (i > 0) { + i -= 1; + const ext = extensions.items[i]; + const shutdown = ext.desc.worker_shutdown orelse continue; + var ctx = vm.makeContext(null); + var call = Call{ .ctx = &ctx, .ext = ext, .args = &.{} }; + shutdown(&call); + vm.ic.?.ext_slots[ext.index].worker = null; + } + vm.ic.?.ext_arena.deinit(); + vm.allocator.free(vm.ic.?.ext_slots); + vm.ic.?.ext_slots = &.{}; +} + +// resource objects: the native pointer and its type live in two hidden +// properties; the destructor runs once, then the pointer is zeroed +fn resourceCleanup(obj: *PhpObject) bool { + const ptr = obj.get(ptr_property); + const kind = obj.get(type_property); + if (ptr != .int or ptr.int == 0 or kind != .int) return true; + const index: usize = @intCast(kind.int - 1); + if (index < resource_types.items.len) resource_types.items[index].dtor(@ptrFromInt(@as(usize, @intCast(ptr.int)))); + if (obj.properties.getPtr(ptr_property)) |slot| slot.* = .{ .int = 0 }; + return true; +} + +pub fn cleanupResources(objects: std.ArrayListUnmanaged(*PhpObject)) void { + if (resource_types.items.len == 0) return; + for (objects.items) |obj| { + if (obj.pooled) continue; + if (obj.get(type_property) != .int) continue; + _ = resourceCleanup(obj); + } +} + +// --------------------------------------------------------------------------- +// the C API table + +fn regOnly(ext: *Extension) bool { + if (!ext.registering) { + std.debug.print("zphp: extension '{s}': registration is only allowed during module_init\n", .{ext.name}); + return false; + } + return true; +} + +fn apiRegisterFunction(ext: *Extension, name: CStr, func: ?ExtFn) callconv(.c) c_int { + if (!regOnly(ext)) return -1; + const n = cstr(name) orelse return -1; + const f = func orelse return -1; + if (n.len == 0) return -1; + const slot = newSlot(ext, f) orelse return -1; + for (extensions.items) |other| for (other.functions.items) |existing| if (std.ascii.eqlIgnoreCase(existing.name, n)) { + fail("extension '{s}': function '{s}' is already registered by extension '{s}'", .{ ext.name, n, other.name }); + }; + ext.functions.append(persistent(), .{ .name = dupe(n), .slot = slot }) catch return -1; + return 0; +} + +fn newSlot(ext: *Extension, func: ExtFn) ?u32 { + if (slot_count >= max_functions) { + std.debug.print("zphp: extension '{s}': too many extension functions (limit {d})\n", .{ ext.name, max_functions }); + return null; + } + slots[slot_count] = .{ .ext = ext, .func = func }; + slot_count += 1; + return slot_count - 1; +} + +fn apiRegisterClass(ext: *Extension, name: CStr, parent: CStr) callconv(.c) ?*ClassReg { + if (!regOnly(ext)) return null; + const n = cstr(name) orelse return null; + if (n.len == 0) return null; + for (extensions.items) |other| for (other.classes.items) |existing| if (std.ascii.eqlIgnoreCase(existing.name, n)) { + fail("extension '{s}': class '{s}' is already registered by extension '{s}'", .{ ext.name, n, other.name }); + }; + const cls = persistent().create(ClassReg) catch return null; + cls.* = .{ .ext = ext, .name = dupe(n), .parent = if (cstr(parent)) |p| dupe(p) else null }; + ext.classes.append(persistent(), cls) catch return null; + return cls; +} + +fn apiClassAddMethod(cls: ?*ClassReg, name: CStr, func: ?ExtFn, arity: u8, flags: u32) callconv(.c) c_int { + const c = cls orelse return -1; + if (!regOnly(c.ext)) return -1; + const n = cstr(name) orelse return -1; + const f = func orelse return -1; + const slot = newSlot(c.ext, f) orelse return -1; + c.methods.append(persistent(), .{ .name = dupe(n), .slot = slot, .arity = arity, .is_static = flags & 1 != 0 }) catch return -1; + return 0; +} + +fn classConstant(cls: ?*ClassReg, name: CStr, value: Value) c_int { + const c = cls orelse return -1; + if (!regOnly(c.ext)) return -1; + const n = cstr(name) orelse return -1; + c.constants.append(persistent(), .{ .name = dupe(n), .value = value }) catch return -1; + return 0; +} + +fn apiClassAddConstantInt(cls: ?*ClassReg, name: CStr, value: i64) callconv(.c) c_int { + return classConstant(cls, name, .{ .int = value }); +} + +fn apiClassAddConstantFloat(cls: ?*ClassReg, name: CStr, value: f64) callconv(.c) c_int { + return classConstant(cls, name, .{ .float = value }); +} + +fn apiClassAddConstantBool(cls: ?*ClassReg, name: CStr, value: bool) callconv(.c) c_int { + return classConstant(cls, name, .{ .bool = value }); +} + +fn apiClassAddConstantString(cls: ?*ClassReg, name: CStr, value: CStr) callconv(.c) c_int { + const v = cstr(value) orelse return -1; + return classConstant(cls, name, .{ .string = Value.String.borrowed(dupe(v)) }); +} + +fn classProperty(cls: ?*ClassReg, name: CStr, default: Value) c_int { + const c = cls orelse return -1; + if (!regOnly(c.ext)) return -1; + const n = cstr(name) orelse return -1; + c.properties.append(persistent(), .{ .name = dupe(n), .default = default }) catch return -1; + return 0; +} + +fn apiClassAddProperty(cls: ?*ClassReg, name: CStr) callconv(.c) c_int { + return classProperty(cls, name, .null); +} + +fn apiClassAddPropertyInt(cls: ?*ClassReg, name: CStr, value: i64) callconv(.c) c_int { + return classProperty(cls, name, .{ .int = value }); +} + +fn apiClassAddPropertyString(cls: ?*ClassReg, name: CStr, value: CStr) callconv(.c) c_int { + const v = cstr(value) orelse return -1; + return classProperty(cls, name, .{ .string = Value.String.borrowed(dupe(v)) }); +} + +fn apiClassImplements(cls: ?*ClassReg, iface: CStr) callconv(.c) c_int { + const c = cls orelse return -1; + if (!regOnly(c.ext)) return -1; + const n = cstr(iface) orelse return -1; + c.interfaces.append(persistent(), dupe(n)) catch return -1; + return 0; +} + +fn apiRegisterInterface(ext: *Extension, name: CStr, methods: ?[*]const CStr, count: usize) callconv(.c) c_int { + if (!regOnly(ext)) return -1; + const n = cstr(name) orelse return -1; + var reg = InterfaceReg{ .name = dupe(n) }; + if (methods) |list| for (list[0..count]) |m| { + const method = cstr(m) orelse return -1; + reg.methods.append(persistent(), dupe(method)) catch return -1; + }; + ext.interfaces.append(persistent(), reg) catch return -1; + return 0; +} + +fn constant(ext: *Extension, name: CStr, value: Value) c_int { + if (!regOnly(ext)) return -1; + const n = cstr(name) orelse return -1; + if (n.len == 0) return -1; + ext.constants.append(persistent(), .{ .name = dupe(n), .value = value }) catch return -1; + return 0; +} + +fn apiRegisterConstantInt(ext: *Extension, name: CStr, value: i64) callconv(.c) c_int { + return constant(ext, name, .{ .int = value }); +} + +fn apiRegisterConstantFloat(ext: *Extension, name: CStr, value: f64) callconv(.c) c_int { + return constant(ext, name, .{ .float = value }); +} + +fn apiRegisterConstantBool(ext: *Extension, name: CStr, value: bool) callconv(.c) c_int { + return constant(ext, name, .{ .bool = value }); +} + +fn apiRegisterConstantString(ext: *Extension, name: CStr, value: CStr) callconv(.c) c_int { + const v = cstr(value) orelse return -1; + return constant(ext, name, .{ .string = Value.String.borrowed(dupe(v)) }); +} + +fn apiRegisterIni(ext: *Extension, name: CStr, default: CStr) callconv(.c) c_int { + if (!regOnly(ext)) return -1; + const n = cstr(name) orelse return -1; + const d = cstr(default) orelse return -1; + ext.inis.append(persistent(), .{ .name = dupe(n), .default = dupe(d) }) catch return -1; + return 0; +} + +fn apiRegisterResource(ext: *Extension, class_name: CStr, dtor: ?ResourceDtor) callconv(.c) u32 { + if (!regOnly(ext)) return 0; + const d = dtor orelse return 0; + const cls = apiRegisterClass(ext, class_name, null) orelse return 0; + const reg = ResourceReg{ .class_name = cls.name, .dtor = d }; + resource_types.append(persistent(), reg) catch return 0; + ext.resources.append(persistent(), reg) catch return 0; + cls.resource_type = @intCast(resource_types.items.len); + cls.properties.append(persistent(), .{ .name = ptr_property, .default = .{ .int = 0 } }) catch return 0; + cls.properties.append(persistent(), .{ .name = type_property, .default = .{ .int = @intCast(resource_types.items.len) } }) catch return 0; + return cls.resource_type.?; +} + +fn cell(call: *Call, v: Value) ?*Value { + const p = call.ctx.vm.ic.?.ext_arena.allocator().create(Value) catch return null; + p.* = v; + return p; +} + +fn apiArgCount(call: *Call) callconv(.c) usize { + return call.args.len; +} + +fn apiArg(call: *Call, index: usize) callconv(.c) ?*const Value { + if (index >= call.args.len) return null; + return &call.args[index]; +} + +fn apiThis(call: *Call) callconv(.c) ?*const Value { + const vm = call.ctx.vm; + if (vm.frame_count == 0) return null; + const this = vm.currentFrame().vars.get("$this") orelse return null; + if (this != .object) return null; + return cell(call, this); +} + +fn apiTypeOf(v: ?*const Value) callconv(.c) c_int { + const value = v orelse return 0; + return switch (value.*) { + .null => 0, + .bool => 1, + .int => 2, + .float => 3, + .string => 4, + .array => 5, + .object => 6, + else => 7, + }; +} + +fn apiGetInt(v: ?*const Value) callconv(.c) i64 { + return Value.toInt((v orelse return 0).*); +} + +fn apiGetFloat(v: ?*const Value) callconv(.c) f64 { + return Value.toFloat((v orelse return 0).*); +} + +fn apiGetBool(v: ?*const Value) callconv(.c) bool { + return (v orelse return false).isTruthy(); +} + +fn apiGetString(call: *Call, v: ?*const Value, len: ?*usize) callconv(.c) ?[*]const u8 { + const value = (v orelse return null).*; + const bytes: []const u8 = switch (value) { + .string => |s| s.bytes(), + .array, .object, .generator, .fiber => return null, + else => blk: { + var buf = std.ArrayListUnmanaged(u8){}; + defer buf.deinit(call.ctx.allocator); + value.format(&buf, call.ctx.allocator) catch return null; + break :blk call.ctx.createString(buf.items) catch return null; + }, + }; + if (len) |l| l.* = bytes.len; + return bytes.ptr; +} + +fn apiMakeNull(call: *Call) callconv(.c) ?*Value { + return cell(call, .null); +} + +fn apiMakeBool(call: *Call, value: bool) callconv(.c) ?*Value { + return cell(call, .{ .bool = value }); +} + +fn apiMakeInt(call: *Call, value: i64) callconv(.c) ?*Value { + return cell(call, .{ .int = value }); +} + +fn apiMakeFloat(call: *Call, value: f64) callconv(.c) ?*Value { + return cell(call, .{ .float = value }); +} + +fn apiMakeString(call: *Call, bytes: ?[*]const u8, len: usize) callconv(.c) ?*Value { + const src: []const u8 = if (bytes) |b| b[0..len] else ""; + const owned = call.ctx.createString(src) catch return null; + return cell(call, .{ .string = Value.String.borrowed(owned) }); +} + +fn apiMakeArray(call: *Call) callconv(.c) ?*Value { + const arr = call.ctx.createArray() catch return null; + return cell(call, .{ .array = arr }); +} + +fn apiArrayCount(v: ?*const Value) callconv(.c) usize { + const value = v orelse return 0; + if (value.* != .array) return 0; + return value.array.entries.items.len; +} + +fn apiArrayPush(call: *Call, arr: ?*Value, v: ?*const Value) callconv(.c) c_int { + const target = arr orelse return -1; + if (target.* != .array) return -1; + target.array.append(call.ctx.allocator, (v orelse return -1).*) catch return -1; + return 0; +} + +fn apiArraySetInt(call: *Call, arr: ?*Value, key: i64, v: ?*const Value) callconv(.c) c_int { + const target = arr orelse return -1; + if (target.* != .array) return -1; + target.array.set(call.ctx.allocator, .{ .int = key }, (v orelse return -1).*) catch return -1; + return 0; +} + +fn apiArraySetString(call: *Call, arr: ?*Value, key: ?[*]const u8, key_len: usize, v: ?*const Value) callconv(.c) c_int { + const target = arr orelse return -1; + if (target.* != .array) return -1; + const k = call.ctx.createString(if (key) |p| p[0..key_len] else "") catch return -1; + target.array.set(call.ctx.allocator, .{ .string = Value.String.borrowed(k) }, (v orelse return -1).*) catch return -1; + return 0; +} + +fn entryValue(entry: PhpArray.Entry) Value { + return if (entry.ref) |r| r.* else entry.value; +} + +fn apiArrayGetInt(call: *Call, arr: ?*const Value, key: i64) callconv(.c) ?*const Value { + const source = arr orelse return null; + if (source.* != .array) return null; + if (!source.array.contains(.{ .int = key })) return null; + return cell(call, source.array.get(.{ .int = key })); +} + +fn apiArrayGetString(call: *Call, arr: ?*const Value, key: ?[*]const u8, key_len: usize) callconv(.c) ?*const Value { + const source = arr orelse return null; + if (source.* != .array) return null; + const k = PhpArray.Key{ .string = Value.String.borrowed(if (key) |p| p[0..key_len] else "") }; + if (!source.array.contains(k)) return null; + return cell(call, source.array.get(k)); +} + +fn apiArrayAt(call: *Call, arr: ?*const Value, index: usize, key_out: ?*?*const Value, value_out: ?*?*const Value) callconv(.c) c_int { + const source = arr orelse return -1; + if (source.* != .array) return -1; + if (index >= source.array.entries.items.len) return -1; + const entry = source.array.entries.items[index]; + if (key_out) |k| k.* = cell(call, switch (entry.key) { + .int => |i| .{ .int = i }, + .string => |s| .{ .string = s }, + }); + if (value_out) |v| v.* = cell(call, entryValue(entry)); + return 0; +} + +fn apiMakeObject(call: *Call, class_name: CStr) callconv(.c) ?*Value { + const name = cstr(class_name) orelse return null; + const vm = call.ctx.vm; + if (!vm.classes.contains(name)) return null; + const stable = vm.classes.getKey(name).?; + const obj = call.ctx.createObject(stable) catch return null; + return cell(call, .{ .object = obj }); +} + +fn apiObjectClass(call: *Call, v: ?*const Value, len: ?*usize) callconv(.c) ?[*]const u8 { + _ = call; + const value = v orelse return null; + if (value.* != .object) return null; + if (len) |l| l.* = value.object.class_name.len; + return value.object.class_name.ptr; +} + +fn apiInstanceOf(call: *Call, v: ?*const Value, class_name: CStr) callconv(.c) bool { + const value = v orelse return false; + const name = cstr(class_name) orelse return false; + if (value.* != .object) return false; + return call.ctx.vm.isInstanceOf(value.object.class_name, name); +} + +fn apiObjectGet(call: *Call, v: ?*const Value, name: CStr) callconv(.c) ?*const Value { + const value = v orelse return null; + const n = cstr(name) orelse return null; + if (value.* != .object) return null; + return cell(call, value.object.get(n)); +} + +fn apiObjectSet(call: *Call, v: ?*Value, name: CStr, new_value: ?*const Value) callconv(.c) c_int { + const value = v orelse return -1; + const n = cstr(name) orelse return -1; + if (value.* != .object) return -1; + const stable = call.ctx.createString(n) catch return -1; + value.object.set(call.ctx.allocator, stable, (new_value orelse return -1).*) catch return -1; + return 0; +} + +fn apiMakeResource(call: *Call, kind: u32, ptr: ?*anyopaque) callconv(.c) ?*Value { + if (kind == 0 or kind > resource_types.items.len) return null; + const class_name = resource_types.items[kind - 1].class_name; + const obj = call.ctx.createObject(class_name) catch return null; + obj.set(call.ctx.allocator, ptr_property, .{ .int = @intCast(@intFromPtr(ptr)) }) catch return null; + obj.set(call.ctx.allocator, type_property, .{ .int = kind }) catch return null; + return cell(call, .{ .object = obj }); +} + +fn apiResourcePtr(call: *Call, v: ?*const Value, kind: u32) callconv(.c) ?*anyopaque { + _ = call; + const value = v orelse return null; + if (value.* != .object) return null; + const stored_kind = value.object.get(type_property); + if (stored_kind != .int or stored_kind.int != kind) return null; + const ptr = value.object.get(ptr_property); + if (ptr != .int or ptr.int == 0) return null; + return @ptrFromInt(@as(usize, @intCast(ptr.int))); +} + +fn collectArgs(buf: []Value, args: ?[*]const ?*const Value, count: usize) ?[]Value { + if (count > buf.len) return null; + const list = args orelse return if (count == 0) buf[0..0] else null; + for (list[0..count], 0..) |arg, i| buf[i] = (arg orelse return null).*; + return buf[0..count]; +} + +fn apiCall(call: *Call, name: CStr, args: ?[*]const ?*const Value, count: usize) callconv(.c) ?*Value { + const n = cstr(name) orelse return null; + var buf: [64]Value = undefined; + const list = collectArgs(&buf, args, count) orelse return null; + const result = call.ctx.callFunction(n, list) catch { + failedCall(call, "Call to undefined function {s}()", .{n}); + return null; + }; + return cell(call, result); +} + +// a call that failed without raising a PHP exception (no such function or +// method) becomes an Error so the extension's caller sees what happened +fn failedCall(call: *Call, comptime fmt: []const u8, args: anytype) void { + call.threw = true; + const vm = call.ctx.vm; + if (vm.pending_exception != null) return; + const msg = std.fmt.allocPrint(call.ctx.allocator, fmt, args) catch return; + call.ctx.strings.append(call.ctx.allocator, msg) catch return; + _ = vm.throwBuiltinException("Error", vm.error_msg orelse msg) catch {}; +} + +fn apiCallMethod(call: *Call, target: ?*const Value, name: CStr, args: ?[*]const ?*const Value, count: usize) callconv(.c) ?*Value { + const n = cstr(name) orelse return null; + const obj = target orelse return null; + if (obj.* != .object) return null; + var buf: [64]Value = undefined; + const list = collectArgs(&buf, args, count) orelse return null; + const result = call.ctx.callMethod(obj.object, n, list) catch { + failedCall(call, "Call to undefined method {s}::{s}()", .{ obj.object.class_name, n }); + return null; + }; + return cell(call, result); +} + +fn apiThrow(call: *Call, class_name: CStr, message: CStr) callconv(.c) void { + const cls = cstr(class_name) orelse "Exception"; + const msg = call.ctx.createString(cstr(message) orelse "") catch ""; + _ = call.ctx.vm.throwBuiltinException(cls, msg) catch {}; + call.threw = true; +} + +fn apiReturnNull(call: *Call) callconv(.c) void { + call.result = NativeResult.scalar(.null); +} + +fn apiReturnBool(call: *Call, value: bool) callconv(.c) void { + call.result = NativeResult.scalar(.{ .bool = value }); +} + +fn apiReturnInt(call: *Call, value: i64) callconv(.c) void { + call.result = NativeResult.scalar(.{ .int = value }); +} + +fn apiReturnFloat(call: *Call, value: f64) callconv(.c) void { + call.result = NativeResult.scalar(.{ .float = value }); +} + +fn apiReturnString(call: *Call, bytes: ?[*]const u8, len: usize) callconv(.c) void { + const src: []const u8 = if (bytes) |b| b[0..len] else ""; + call.result = NativeResult.copyString(call.ctx.allocator, src) catch NativeResult.scalar(.null); +} + +fn apiReturnValue(call: *Call, v: ?*const Value) callconv(.c) void { + const value = (v orelse return apiReturnNull(call)).*; + call.result = switch (value) { + .string => |s| if (s.owner != null) NativeResult.shareString(s) else NativeResult.copyString(call.ctx.allocator, s.bytes()) catch NativeResult.scalar(.null), + else => NativeResult.borrowed(value), + }; +} + +fn apiEcho(call: *Call, bytes: ?[*]const u8, len: usize) callconv(.c) void { + const src = bytes orelse return; + call.ctx.vm.output.appendSlice(call.ctx.allocator, src[0..len]) catch {}; +} + +fn apiIniGet(call: *Call, name: CStr, len: ?*usize) callconv(.c) ?[*]const u8 { + const n = cstr(name) orelse return null; + const stored = call.ctx.vm.ini_settings.get(n) orelse blk: { + for (call.ext.inis.items) |ini| if (std.mem.eql(u8, ini.name, n)) break :blk ini.default; + return null; + }; + if (len) |l| l.* = stored.len; + return stored.ptr; +} + +fn apiRequestData(call: *Call) callconv(.c) ?*anyopaque { + return call.ctx.vm.ic.?.ext_slots[call.ext.index].request; +} + +fn apiSetRequestData(call: *Call, data: ?*anyopaque) callconv(.c) void { + call.ctx.vm.ic.?.ext_slots[call.ext.index].request = data; +} + +fn apiWorkerData(call: *Call) callconv(.c) ?*anyopaque { + return call.ctx.vm.ic.?.ext_slots[call.ext.index].worker; +} + +fn apiSetWorkerData(call: *Call, data: ?*anyopaque) callconv(.c) void { + call.ctx.vm.ic.?.ext_slots[call.ext.index].worker = data; +} + +// field order is the ABI: append only, and bump abi_version for anything else +pub const Api = extern struct { + abi: u32, + register_function: *const @TypeOf(apiRegisterFunction), + register_class: *const @TypeOf(apiRegisterClass), + class_add_method: *const @TypeOf(apiClassAddMethod), + class_add_constant_int: *const @TypeOf(apiClassAddConstantInt), + class_add_constant_float: *const @TypeOf(apiClassAddConstantFloat), + class_add_constant_bool: *const @TypeOf(apiClassAddConstantBool), + class_add_constant_string: *const @TypeOf(apiClassAddConstantString), + class_add_property: *const @TypeOf(apiClassAddProperty), + class_add_property_int: *const @TypeOf(apiClassAddPropertyInt), + class_add_property_string: *const @TypeOf(apiClassAddPropertyString), + class_implements: *const @TypeOf(apiClassImplements), + register_interface: *const @TypeOf(apiRegisterInterface), + register_constant_int: *const @TypeOf(apiRegisterConstantInt), + register_constant_float: *const @TypeOf(apiRegisterConstantFloat), + register_constant_bool: *const @TypeOf(apiRegisterConstantBool), + register_constant_string: *const @TypeOf(apiRegisterConstantString), + register_ini: *const @TypeOf(apiRegisterIni), + register_resource: *const @TypeOf(apiRegisterResource), + arg_count: *const @TypeOf(apiArgCount), + arg: *const @TypeOf(apiArg), + this_object: *const @TypeOf(apiThis), + type_of: *const @TypeOf(apiTypeOf), + get_int: *const @TypeOf(apiGetInt), + get_float: *const @TypeOf(apiGetFloat), + get_bool: *const @TypeOf(apiGetBool), + get_string: *const @TypeOf(apiGetString), + make_null: *const @TypeOf(apiMakeNull), + make_bool: *const @TypeOf(apiMakeBool), + make_int: *const @TypeOf(apiMakeInt), + make_float: *const @TypeOf(apiMakeFloat), + make_string: *const @TypeOf(apiMakeString), + make_array: *const @TypeOf(apiMakeArray), + array_count: *const @TypeOf(apiArrayCount), + array_push: *const @TypeOf(apiArrayPush), + array_set_int: *const @TypeOf(apiArraySetInt), + array_set_string: *const @TypeOf(apiArraySetString), + array_get_int: *const @TypeOf(apiArrayGetInt), + array_get_string: *const @TypeOf(apiArrayGetString), + array_at: *const @TypeOf(apiArrayAt), + make_object: *const @TypeOf(apiMakeObject), + object_class: *const @TypeOf(apiObjectClass), + instance_of: *const @TypeOf(apiInstanceOf), + object_get: *const @TypeOf(apiObjectGet), + object_set: *const @TypeOf(apiObjectSet), + make_resource: *const @TypeOf(apiMakeResource), + resource_ptr: *const @TypeOf(apiResourcePtr), + call: *const @TypeOf(apiCall), + call_method: *const @TypeOf(apiCallMethod), + throw_exception: *const @TypeOf(apiThrow), + return_null: *const @TypeOf(apiReturnNull), + return_bool: *const @TypeOf(apiReturnBool), + return_int: *const @TypeOf(apiReturnInt), + return_float: *const @TypeOf(apiReturnFloat), + return_string: *const @TypeOf(apiReturnString), + return_value: *const @TypeOf(apiReturnValue), + echo: *const @TypeOf(apiEcho), + ini_get: *const @TypeOf(apiIniGet), + request_data: *const @TypeOf(apiRequestData), + set_request_data: *const @TypeOf(apiSetRequestData), + worker_data: *const @TypeOf(apiWorkerData), + set_worker_data: *const @TypeOf(apiSetWorkerData), +}; + +pub const api_v1 = Api{ + .abi = abi_version, + .register_function = apiRegisterFunction, + .register_class = apiRegisterClass, + .class_add_method = apiClassAddMethod, + .class_add_constant_int = apiClassAddConstantInt, + .class_add_constant_float = apiClassAddConstantFloat, + .class_add_constant_bool = apiClassAddConstantBool, + .class_add_constant_string = apiClassAddConstantString, + .class_add_property = apiClassAddProperty, + .class_add_property_int = apiClassAddPropertyInt, + .class_add_property_string = apiClassAddPropertyString, + .class_implements = apiClassImplements, + .register_interface = apiRegisterInterface, + .register_constant_int = apiRegisterConstantInt, + .register_constant_float = apiRegisterConstantFloat, + .register_constant_bool = apiRegisterConstantBool, + .register_constant_string = apiRegisterConstantString, + .register_ini = apiRegisterIni, + .register_resource = apiRegisterResource, + .arg_count = apiArgCount, + .arg = apiArg, + .this_object = apiThis, + .type_of = apiTypeOf, + .get_int = apiGetInt, + .get_float = apiGetFloat, + .get_bool = apiGetBool, + .get_string = apiGetString, + .make_null = apiMakeNull, + .make_bool = apiMakeBool, + .make_int = apiMakeInt, + .make_float = apiMakeFloat, + .make_string = apiMakeString, + .make_array = apiMakeArray, + .array_count = apiArrayCount, + .array_push = apiArrayPush, + .array_set_int = apiArraySetInt, + .array_set_string = apiArraySetString, + .array_get_int = apiArrayGetInt, + .array_get_string = apiArrayGetString, + .array_at = apiArrayAt, + .make_object = apiMakeObject, + .object_class = apiObjectClass, + .instance_of = apiInstanceOf, + .object_get = apiObjectGet, + .object_set = apiObjectSet, + .make_resource = apiMakeResource, + .resource_ptr = apiResourcePtr, + .call = apiCall, + .call_method = apiCallMethod, + .throw_exception = apiThrow, + .return_null = apiReturnNull, + .return_bool = apiReturnBool, + .return_int = apiReturnInt, + .return_float = apiReturnFloat, + .return_string = apiReturnString, + .return_value = apiReturnValue, + .echo = apiEcho, + .ini_get = apiIniGet, + .request_data = apiRequestData, + .set_request_data = apiSetRequestData, + .worker_data = apiWorkerData, + .set_worker_data = apiSetWorkerData, +}; + +test "the api table matches the header field by field" { + const header = std.fs.cwd().readFileAlloc(std.testing.allocator, "include/zphp_extension.h", 1 << 20) catch return error.SkipZigTest; + defer std.testing.allocator.free(header); + const fields = @typeInfo(Api).@"struct".fields; + var pos: usize = std.mem.indexOf(u8, header, "typedef struct zphp_api {").?; + inline for (fields[1..]) |field| { + const needle = "(*" ++ field.name ++ ")("; + const at = std.mem.indexOfPos(u8, header, pos, needle) orelse { + std.debug.print("api field '{s}' missing from the header, or out of order\n", .{field.name}); + return error.TestUnexpectedResult; + }; + pos = at + needle.len; + } + const end = std.mem.indexOf(u8, header, "} zphp_api;").?; + try std.testing.expect(pos < end); + try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, header[pos..end], "(*")); +} diff --git a/src/main.zig b/src/main.zig index b9814169..c747c904 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,6 +5,7 @@ const runtime_value = @import("runtime/value.zig"); const VM = @import("runtime/vm.zig").VM; const Value = runtime_value.Value; const CompileResult = @import("pipeline/compiler.zig").CompileResult; +const extension = @import("extension.zig"); const bytecode_format = @import("bytecode_format.zig"); const error_format = @import("error_format.zig"); @@ -23,15 +24,20 @@ pub fn main() !void { }; const allocator = if (release_allocator) std.heap.smp_allocator else gpa.allocator(); - const args = try std.process.argsAlloc(allocator); - defer std.process.argsFree(allocator, args); + const raw_args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, raw_args); + + extension.loadStatic(); if (bytecode_format.detectEmbeddedBytecode(allocator)) |bc| { defer allocator.free(bc); - try runBytecode(allocator, bc, args[0], if (args.len > 1) args[1..] else &.{}); + try runBytecode(allocator, bc, raw_args[0], if (raw_args.len > 1) raw_args[1..] else &.{}); return; } + const args = try loadExtensionFlags(allocator, raw_args); + defer allocator.free(args); + if (args.len < 2) { try writeStdout("zphp 0.8.0\n"); return; @@ -40,6 +46,34 @@ pub fn main() !void { try dispatch(allocator, args); } +// `zphp [--extension=PATH]... ...` loads dynamic extensions before +// any VM exists, then ZPHP_EXTENSION_DIR adds every library in that +// directory. returns the args with the flags removed +fn loadExtensionFlags(allocator: std.mem.Allocator, raw_args: []const []const u8) ![]const []const u8 { + var args = std.ArrayListUnmanaged([]const u8){}; + errdefer args.deinit(allocator); + try args.append(allocator, raw_args[0]); + var i: usize = 1; + while (i < raw_args.len) : (i += 1) { + const arg = raw_args[i]; + if (std.mem.startsWith(u8, arg, "--extension=")) { + extension.loadDynamic(arg["--extension=".len..]); + } else if (std.mem.eql(u8, arg, "--extension")) { + if (i + 1 >= raw_args.len) { + try writeStderr("usage: zphp --extension=PATH \n"); + std.process.exit(1); + } + i += 1; + extension.loadDynamic(raw_args[i]); + } else { + try args.appendSlice(allocator, raw_args[i..]); + break; + } + } + if (std.posix.getenv("ZPHP_EXTENSION_DIR")) |dir| extension.loadDirectory(dir); + return args.toOwnedSlice(allocator); +} + fn dispatch(allocator: std.mem.Allocator, args: []const []const u8) !void { const cmd = args[1]; diff --git a/src/runtime/vm.zig b/src/runtime/vm.zig index 2e914dfa..132321b4 100644 --- a/src/runtime/vm.zig +++ b/src/runtime/vm.zig @@ -223,7 +223,8 @@ pub const NativeContext = struct { }; pub const NativeResult = @import("native_result.zig").NativeResult; -const NativeFn = *const fn (*NativeContext, []const Value) RuntimeError!NativeResult; +const extension = @import("../extension.zig"); +pub const NativeFn = *const fn (*NativeContext, []const Value) RuntimeError!NativeResult; pub const NativeBinop = enum { add, sub, mul, div, mod, pow, compare, negate }; pub const CaptureEntry = struct { @@ -1130,6 +1131,13 @@ pub const VM = struct { // when a different class arrives or new code is declared intent: []IntentIC = &.{}, arg_stack: []RefSource = &.{}, + // third-party extension state lives here, off the VM struct, so the + // VM's field offsets stay put (see GOTCHAS on codegen perturbation): + // one slot per loaded extension, an arena for the value handles a + // call hands out, and whether request_init has run + ext_slots: []extension.VmSlot = &.{}, + ext_arena: std.heap.ArenaAllocator = undefined, + ext_request_active: bool = false, // provenance of the call family opcode being executed, saved across a // nested call so a native callback's own calls cannot clobber it saved_sources: std.ArrayListUnmanaged(RefSource) = .{}, @@ -1378,6 +1386,8 @@ pub const VM = struct { const locals_buf = try allocator.alloc(Value, 8192); vm.ic.?.locals_buf = locals_buf.ptr; vm.ic.?.locals_cap = 8192; + vm.ic.?.ext_arena = std.heap.ArenaAllocator.init(allocator); + try extension.vmInit(vm); // snapshot the builtin heap + class registration so serve-mode reset can // keep it instead of rebuilding every request (registry native fns are // never cleared; stdlib classes + their enum-case objects ARE the churn) @@ -2323,8 +2333,12 @@ pub const VM = struct { value.* = .null; } } - var class_it = self.classes.valueIterator(); - while (class_it.next()) |class| { + // builtin classes survive a serve reset with their constants, enum + // cases, and property defaults intact; only user classes are torn down + var class_it = self.classes.iterator(); + while (class_it.next()) |class_entry| { + if (!free_all and self.builtin_classes.contains(class_entry.key_ptr.*)) continue; + const class = class_entry.value_ptr; for (class.properties.items) |*property| { if (property.default == .string) { self.releaseValue(property.default); @@ -2390,6 +2404,7 @@ pub const VM = struct { @import("../stdlib/xmlwriter.zig").cleanupResources(self.objects); @import("../stdlib/intl.zig").cleanupResources(self.objects); @import("../stdlib/gmp.zig").cleanupResources(self.objects); + extension.cleanupResources(self.objects); @import("../stdlib/gd.zig").cleanupResources(self.objects); @import("../stdlib/ftp.zig").cleanupResources(self.objects); @import("../stdlib/ldap.zig").cleanupResources(self.objects); @@ -2524,6 +2539,7 @@ pub const VM = struct { } pub fn deinit(self: *VM) void { + extension.vmDeinit(self); if (self.ic) |ic| for (ic.arg_stack) |*source| self.releaseArgSource(source); self.clearActiveArgSources(); self.clearArgArraySources(null); @@ -2710,6 +2726,7 @@ pub const VM = struct { self.releaseCallbackRegistries(); // reap before freeHeapItems frees the proc objects (the map keys) self.reapProcChildren(); + extension.endRequest(self); self.frame_high_water = 0; self.obj_ref_active = false; self.array_ref_active = false; @@ -2838,6 +2855,7 @@ pub const VM = struct { self.php_constants.clearRetainingCapacity(); self.user_constants.clearRetainingCapacity(); initConstants(&self.php_constants, self.allocator) catch {}; + extension.applyConstants(self) catch {}; // builtins persist across reset now (freeClassState kept them), so the // stdlib classes + their native methods + enum objects DON'T need // rebuilding every request - that re-registration was the dominant @@ -2866,6 +2884,7 @@ pub const VM = struct { pub fn interpret(self: *VM, result: *const CompileResult) RuntimeError!void { self.installHooks(); + try extension.beginRequest(self); try self.registerResultFunctions(result); for (result.type_hints.items) |th| { try g_type_info.put(self.allocator, th.name, .{ .param_types = th.param_types, .return_type = th.return_type }); diff --git a/src/serve.zig b/src/serve.zig index d34b0e57..f2be6b2f 100644 --- a/src/serve.zig +++ b/src/serve.zig @@ -1021,6 +1021,7 @@ fn processHttpRead(w: *Worker, c: *Connection) void { // persist session data if session was started var session_ctx = w.vm.makeContext(null); @import("stdlib/session.zig").finalizeSession(&session_ctx); + @import("extension.zig").endRequest(&w.vm); const ct = w.vm.response_content_type; const code = w.vm.response_code; @@ -1132,6 +1133,7 @@ fn handleH2Request(w: *Worker, conn: *Connection, session: *h2.H2Session, stream var session_ctx = w.vm.makeContext(null); @import("stdlib/session.zig").finalizeSession(&session_ctx); + @import("extension.zig").endRequest(&w.vm); const ct = w.vm.response_content_type; const code: u16 = std.math.cast(u16, w.vm.response_code) orelse 200; diff --git a/src/stdlib/types.zig b/src/stdlib/types.zig index 50d8089e..07696099 100644 --- a/src/stdlib/types.zig +++ b/src/stdlib/types.zig @@ -1706,12 +1706,13 @@ fn native_extension_loaded(_: *NativeContext, args: []const Value) RuntimeError! } // also accept the historic "datetime" alias for "date" if (std.ascii.eqlIgnoreCase(name, "datetime")) return NativeResult.scalar(.{ .bool = true }); - return NativeResult.scalar(.{ .bool = false }); + return NativeResult.scalar(.{ .bool = @import("../extension.zig").isLoaded(name) }); } fn native_get_loaded_extensions(ctx: *NativeContext, _: []const Value) RuntimeError!NativeResult { const arr = try ctx.createArray(); for (SUPPORTED_EXTENSIONS) |s| try arr.append(ctx.allocator, .{ .string = Value.String.borrowed(s) }); + for (@import("../extension.zig").loaded()) |ext| try arr.append(ctx.allocator, .{ .string = Value.String.borrowed(ext.name) }); return NativeResult.borrowed(.{ .array = arr }); } diff --git a/tests/extensions/app.php b/tests/extensions/app.php new file mode 100644 index 00000000..1f02752e --- /dev/null +++ b/tests/extensions/app.php @@ -0,0 +1,14 @@ +increment()->value(), "|", demo_describe($c); break; + case '/throw': try { demo_throw("x"); } catch (DemoException $e) { echo "caught ", $e->getMessage(); } break; + default: http_response_code(404); echo "nf"; +} diff --git a/tests/extensions/bad_abi.c b/tests/extensions/bad_abi.c new file mode 100644 index 00000000..ee086e4b --- /dev/null +++ b/tests/extensions/bad_abi.c @@ -0,0 +1,3 @@ +#include "zphp_extension.h" +static const zphp_extension ext = { .abi = 99, .name = "bad_abi", .version = "1" }; +ZPHP_EXTENSION(bad_abi, &ext) diff --git a/tests/extensions/bench.php b/tests/extensions/bench.php new file mode 100644 index 00000000..ccf6c33c --- /dev/null +++ b/tests/extensions/bench.php @@ -0,0 +1,9 @@ + +#include +#include +#include "zphp_extension.h" + +static uint32_t buffer_type; +static long freed_buffers; + +typedef struct demo_buffer { + char *data; + size_t len; + size_t cap; +} demo_buffer; + +typedef struct request_state { + long counter; +} request_state; + +typedef struct worker_state { + long hits; +} worker_state; + +static void buffer_dtor(void *ptr) +{ + demo_buffer *buf = ptr; + free(buf->data); + free(buf); + freed_buffers++; +} + +/* demo_add(int $a, int $b): int */ +static void demo_add(zphp_ctx *ctx) +{ + if (zphp_arg_count(ctx) < 2) { + zphp_throw(ctx, "ArgumentCountError", "demo_add() expects exactly 2 arguments"); + return; + } + zphp_return_int(ctx, zphp_get_int(zphp_arg(ctx, 0)) + zphp_get_int(zphp_arg(ctx, 1))); +} + +/* demo_greet(string $name): string */ +static void demo_greet(zphp_ctx *ctx) +{ + size_t name_len = 0, greeting_len = 0; + const char *name = zphp_arg_count(ctx) > 0 ? zphp_get_string(ctx, zphp_arg(ctx, 0), &name_len) : NULL; + const char *greeting = zphp_ini_get(ctx, "demo.greeting", &greeting_len); + char out[256]; + int n = snprintf(out, sizeof out, "%.*s, %.*s!", (int)greeting_len, greeting ? greeting : "", (int)name_len, name ? name : ""); + zphp_return_string(ctx, out, (size_t)n); +} + +/* demo_stats(array $values): array{count, sum, keys, first} */ +static void demo_stats(zphp_ctx *ctx) +{ + const zphp_value *input = zphp_arg(ctx, 0); + if (zphp_type_of(input) != ZPHP_ARRAY) { + zphp_throw(ctx, "TypeError", "demo_stats(): Argument #1 ($values) must be of type array"); + return; + } + zphp_value *keys = zphp_array(ctx); + int64_t sum = 0; + size_t count = zphp_array_count(input); + for (size_t i = 0; i < count; i++) { + const zphp_value *key, *value; + if (zphp_array_at(ctx, input, i, &key, &value) != 0) break; + sum += zphp_get_int(value); + zphp_array_push(ctx, keys, key); + } + zphp_value *result = zphp_array(ctx); + zphp_array_set_string(ctx, result, "count", 5, zphp_int(ctx, (int64_t)count)); + zphp_array_set_string(ctx, result, "sum", 3, zphp_int(ctx, sum)); + zphp_array_set_string(ctx, result, "keys", 4, keys); + const zphp_value *first = zphp_array_get_int(ctx, input, 0); + zphp_array_set_string(ctx, result, "first", 5, first ? first : zphp_null(ctx)); + const zphp_value *named = zphp_array_get_string(ctx, input, "name", 4); + zphp_array_set_string(ctx, result, "name", 4, named ? named : zphp_string(ctx, "none", 4)); + zphp_return_value(ctx, result); +} + +/* demo_types(mixed ...$values): string[] */ +static void demo_types(zphp_ctx *ctx) +{ + static const char *const names[] = { "null", "bool", "int", "float", "string", "array", "object", "other" }; + zphp_value *out = zphp_array(ctx); + for (size_t i = 0; i < zphp_arg_count(ctx); i++) { + const char *name = names[zphp_type_of(zphp_arg(ctx, i))]; + zphp_array_push(ctx, out, zphp_string(ctx, name, strlen(name))); + } + zphp_return_value(ctx, out); +} + +/* demo_apply(string $function, mixed $value): mixed, calls back into PHP */ +static void demo_apply(zphp_ctx *ctx) +{ + size_t len = 0; + const char *name = zphp_get_string(ctx, zphp_arg(ctx, 0), &len); + char fn[128]; + snprintf(fn, sizeof fn, "%.*s", (int)len, name); + const zphp_value *args[1] = { zphp_arg(ctx, 1) }; + zphp_value *result = zphp_call(ctx, fn, args, 1); + if (result == NULL) return; + zphp_return_value(ctx, result); +} + +/* demo_throw(string $message): never */ +static void demo_throw(zphp_ctx *ctx) +{ + size_t len = 0; + const char *msg = zphp_get_string(ctx, zphp_arg(ctx, 0), &len); + char out[256]; + snprintf(out, sizeof out, "%.*s", (int)len, msg); + zphp_throw(ctx, "DemoException", out); +} + +/* demo_counter(): int, increments the request-local counter */ +static void demo_counter(zphp_ctx *ctx) +{ + request_state *state = zphp_request_data(ctx); + zphp_return_int(ctx, ++state->counter); +} + +/* demo_hits(): int, increments the worker-local counter */ +static void demo_hits(zphp_ctx *ctx) +{ + worker_state *state = zphp_worker_data(ctx); + zphp_return_int(ctx, ++state->hits); +} + +/* demo_echo(string $text): void */ +static void demo_echo(zphp_ctx *ctx) +{ + size_t len = 0; + const char *text = zphp_get_string(ctx, zphp_arg(ctx, 0), &len); + zphp_echo(ctx, text, len); +} + +/* demo_open(int $capacity): DemoBuffer */ +static void demo_open(zphp_ctx *ctx) +{ + int64_t cap = zphp_arg_count(ctx) > 0 ? zphp_get_int(zphp_arg(ctx, 0)) : 64; + if (cap <= 0) { + zphp_throw(ctx, "ValueError", "demo_open(): Argument #1 ($capacity) must be greater than 0"); + return; + } + demo_buffer *buf = calloc(1, sizeof *buf); + buf->cap = (size_t)cap; + buf->data = malloc(buf->cap); + zphp_return_value(ctx, zphp_resource(ctx, buffer_type, buf)); +} + +/* demo_write(DemoBuffer $buffer, string $text): int */ +static void demo_write(zphp_ctx *ctx) +{ + demo_buffer *buf = zphp_resource_ptr(ctx, zphp_arg(ctx, 0), buffer_type); + if (buf == NULL) { + zphp_throw(ctx, "TypeError", "demo_write(): Argument #1 ($buffer) must be an open DemoBuffer"); + return; + } + size_t len = 0; + const char *text = zphp_get_string(ctx, zphp_arg(ctx, 1), &len); + size_t room = buf->cap - buf->len; + size_t n = len < room ? len : room; + memcpy(buf->data + buf->len, text, n); + buf->len += n; + zphp_return_int(ctx, (int64_t)n); +} + +/* demo_read(DemoBuffer $buffer): string */ +static void demo_read(zphp_ctx *ctx) +{ + demo_buffer *buf = zphp_resource_ptr(ctx, zphp_arg(ctx, 0), buffer_type); + if (buf == NULL) { + zphp_throw(ctx, "TypeError", "demo_read(): Argument #1 ($buffer) must be an open DemoBuffer"); + return; + } + zphp_return_string(ctx, buf->data, buf->len); +} + +/* demo_freed(): int, buffers destroyed so far in this process */ +static void demo_freed(zphp_ctx *ctx) +{ + zphp_return_int(ctx, freed_buffers); +} + +/* demo_describe(object $o): string, reads a property and calls a method */ +static void demo_describe(zphp_ctx *ctx) +{ + const zphp_value *obj = zphp_arg(ctx, 0); + if (zphp_type_of(obj) != ZPHP_OBJECT) { + zphp_throw(ctx, "TypeError", "demo_describe(): Argument #1 ($o) must be of type object"); + return; + } + size_t class_len = 0; + const char *class_name = zphp_object_class(ctx, obj, &class_len); + zphp_value *label = zphp_call_method(ctx, obj, "label", NULL, 0); + if (label == NULL) return; + size_t label_len = 0; + const char *label_text = zphp_get_string(ctx, label, &label_len); + const zphp_value *count = zphp_object_get(ctx, obj, "count"); + char out[256]; + int n = snprintf(out, sizeof out, "%.*s(%.*s, count=%lld, tally=%s)", (int)class_len, class_name, (int)label_len, label_text, + (long long)zphp_get_int(count), zphp_instance_of(ctx, obj, "Demo\\Tally") ? "yes" : "no"); + zphp_return_string(ctx, out, (size_t)n); +} + +/* Demo\Counter */ +static void counter_construct(zphp_ctx *ctx) +{ + zphp_value *this = (zphp_value *)zphp_this(ctx); + int64_t start = zphp_arg_count(ctx) > 0 ? zphp_get_int(zphp_arg(ctx, 0)) : 0; + zphp_object_set(ctx, this, "count", zphp_int(ctx, start)); +} + +static void counter_increment(zphp_ctx *ctx) +{ + zphp_value *this = (zphp_value *)zphp_this(ctx); + int64_t count = zphp_get_int(zphp_object_get(ctx, this, "count")); + int64_t step = zphp_arg_count(ctx) > 0 ? zphp_get_int(zphp_arg(ctx, 0)) : 1; + if (count + step > 10) { + zphp_throw(ctx, "DemoException", "counter limit reached"); + return; + } + zphp_object_set(ctx, this, "count", zphp_int(ctx, count + step)); + zphp_return_value(ctx, this); +} + +static void counter_value(zphp_ctx *ctx) +{ + zphp_return_value(ctx, zphp_object_get(ctx, zphp_this(ctx), "count")); +} + +static void counter_label(zphp_ctx *ctx) +{ + zphp_return_string(ctx, "native counter", 14); +} + +static void counter_make(zphp_ctx *ctx) +{ + zphp_value *obj = zphp_object(ctx, "Demo\\Counter"); + if (obj == NULL) { + zphp_throw(ctx, "Error", "Demo\\Counter is not registered"); + return; + } + const zphp_value *args[1] = { zphp_arg(ctx, 0) }; + if (zphp_call_method(ctx, obj, "__construct", args, 1) == NULL) return; + zphp_return_value(ctx, obj); +} + +static int module_init(zphp_module *m) +{ + zphp_register_function(m, "demo_add", demo_add); + zphp_register_function(m, "demo_greet", demo_greet); + zphp_register_function(m, "demo_stats", demo_stats); + zphp_register_function(m, "demo_types", demo_types); + zphp_register_function(m, "demo_apply", demo_apply); + zphp_register_function(m, "demo_throw", demo_throw); + zphp_register_function(m, "demo_counter", demo_counter); + zphp_register_function(m, "demo_hits", demo_hits); + zphp_register_function(m, "demo_echo", demo_echo); + zphp_register_function(m, "demo_open", demo_open); + zphp_register_function(m, "demo_write", demo_write); + zphp_register_function(m, "demo_read", demo_read); + zphp_register_function(m, "demo_freed", demo_freed); + zphp_register_function(m, "demo_describe", demo_describe); + + static const char *const tally_methods[] = { "value" }; + zphp_register_interface(m, "Demo\\Tally", tally_methods, 1); + + zphp_class *counter = zphp_register_class(m, "Demo\\Counter", NULL); + zphp_class_implements(counter, "Demo\\Tally"); + zphp_class_add_property_int(counter, "count", 0); + zphp_class_add_constant_int(counter, "LIMIT", 10); + zphp_class_add_constant_string(counter, "NAME", "counter"); + zphp_class_add_method(counter, "__construct", counter_construct, 1, 0); + zphp_class_add_method(counter, "increment", counter_increment, 1, 0); + zphp_class_add_method(counter, "value", counter_value, 0, 0); + zphp_class_add_method(counter, "label", counter_label, 0, 0); + zphp_class_add_method(counter, "make", counter_make, 1, ZPHP_METHOD_STATIC); + + zphp_register_class(m, "DemoException", "Exception"); + + zphp_register_constant_string(m, "DEMO_VERSION", "1.2.3"); + zphp_register_constant_int(m, "DEMO_ANSWER", 42); + zphp_register_constant_float(m, "DEMO_RATIO", 0.5); + zphp_register_constant_bool(m, "DEMO_ENABLED", true); + zphp_register_ini(m, "demo.greeting", "hello"); + + buffer_type = zphp_register_resource(m, "DemoBuffer", buffer_dtor); + return buffer_type == 0 ? -1 : 0; +} + +static int worker_init(zphp_ctx *ctx) +{ + zphp_set_worker_data(ctx, calloc(1, sizeof(worker_state))); + return 0; +} + +static void worker_shutdown(zphp_ctx *ctx) +{ + free(zphp_worker_data(ctx)); +} + +static int request_init(zphp_ctx *ctx) +{ + zphp_set_request_data(ctx, calloc(1, sizeof(request_state))); + return 0; +} + +static void request_shutdown(zphp_ctx *ctx) +{ + free(zphp_request_data(ctx)); +} + +static const zphp_extension demo_extension = { + .abi = ZPHP_EXTENSION_ABI, + .name = "demo", + .version = "1.0.0", + .module_init = module_init, + .module_shutdown = NULL, + .worker_init = worker_init, + .worker_shutdown = worker_shutdown, + .request_init = request_init, + .request_shutdown = request_shutdown, +}; + +ZPHP_EXTENSION(demo, &demo_extension) diff --git a/tests/extensions/demo.expected b/tests/extensions/demo.expected new file mode 100644 index 00000000..31fdf42d --- /dev/null +++ b/tests/extensions/demo.expected @@ -0,0 +1,77 @@ +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +string(9) "Exception" +int(5) +string(13) "hello, world!" +string(5) "1.2.3" +int(42) +float(0.5) +bool(true) +int(10) +string(7) "counter" +int(7) +bool(true) +string(48) "Demo\Counter(native counter, count=7, tally=yes)" +int(7) +caught: counter limit reached +DemoException: boom +array(5) { + ["count"]=> + int(4) + ["sum"]=> + int(6) + ["keys"]=> + array(4) { + [0]=> + int(0) + [1]=> + int(1) + [2]=> + int(2) + [3]=> + string(4) "name" + } + ["first"]=> + int(1) + ["name"]=> + string(1) "x" +} +array(7) { + [0]=> + string(4) "null" + [1]=> + string(4) "bool" + [2]=> + string(3) "int" + [3]=> + string(5) "float" + [4]=> + string(6) "string" + [5]=> + string(5) "array" + [6]=> + string(6) "object" +} +string(3) "ABC" +int(42) +Error: Call to undefined function nope_missing() +int(1) +int(2) +int(1) +echoed +string(5) "hello" +string(10) "hi, again!" +int(8) +string(8) "hello wo" +string(10) "DemoBuffer" +int(0) +int(1) +int(2) +unwound +int(3) +demo_read(): Argument #1 ($buffer) must be an open DemoBuffer +done +shutdown sees 2 diff --git a/tests/extensions/demo.php b/tests/extensions/demo.php new file mode 100644 index 00000000..2ef5253f --- /dev/null +++ b/tests/extensions/demo.php @@ -0,0 +1,43 @@ +increment()->increment(2)->value()); +var_dump($c instanceof Demo\Tally); +var_dump(demo_describe($c)); +var_dump(Demo\Counter::make(7)->value()); +try { $c->increment(10); } catch (DemoException $e) { echo "caught: ", $e->getMessage(), "\n"; } +try { demo_throw("boom"); } catch (DemoException $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; } +var_dump(demo_stats([1, 2, 3, "name" => "x"])); +var_dump(demo_types(null, true, 1, 1.5, "s", [], $c)); +var_dump(demo_apply("strtoupper", "abc")); +function twice($x) { return $x * 2; } +var_dump(demo_apply("twice", 21)); +try { demo_apply("nope_missing", 1); } catch (Error $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; } +var_dump(demo_counter(), demo_counter(), demo_hits()); +demo_echo("echoed\n"); +var_dump(ini_get("demo.greeting")); +ini_set("demo.greeting", "hi"); +var_dump(demo_greet("again")); +$b = demo_open(8); +var_dump(demo_write($b, "hello world"), demo_read($b)); +var_dump(get_class($b)); +var_dump(demo_freed()); +unset($b); +var_dump(demo_freed()); +function scoped() { $x = demo_open(4); demo_write($x, "zz"); } +scoped(); +var_dump(demo_freed()); +function throws() { $x = demo_open(4); throw new RuntimeException("mid"); } +try { throws(); } catch (RuntimeException $e) { echo "unwound\n"; } +var_dump(demo_freed()); +try { demo_read("not a buffer"); } catch (TypeError $e) { echo $e->getMessage(), "\n"; } +$keep = demo_open(2); +register_shutdown_function(function () { echo "shutdown sees ", demo_add(1, 1), "\n"; }); +echo "done\n"; diff --git a/tests/extensions/dup.c b/tests/extensions/dup.c new file mode 100644 index 00000000..dd52962e --- /dev/null +++ b/tests/extensions/dup.c @@ -0,0 +1,5 @@ +#include "zphp_extension.h" +static void f(zphp_ctx *ctx) { zphp_return_null(ctx); } +static int init(zphp_module *m) { return zphp_register_function(m, "demo_add", f); } +static const zphp_extension ext = { .abi = ZPHP_EXTENSION_ABI, .name = "dup", .version = "1", .module_init = init }; +ZPHP_EXTENSION(dup, &ext) diff --git a/tests/extensions/failing_init.c b/tests/extensions/failing_init.c new file mode 100644 index 00000000..9eb726b0 --- /dev/null +++ b/tests/extensions/failing_init.c @@ -0,0 +1,4 @@ +#include "zphp_extension.h" +static int init(zphp_module *m) { (void)m; return 7; } +static const zphp_extension ext = { .abi = ZPHP_EXTENSION_ABI, .name = "failing_init", .version = "1", .module_init = init }; +ZPHP_EXTENSION(failing_init, &ext) diff --git a/tests/extensions/noentry.c b/tests/extensions/noentry.c new file mode 100644 index 00000000..c6095783 --- /dev/null +++ b/tests/extensions/noentry.c @@ -0,0 +1 @@ +int not_an_extension(void) { return 1; } diff --git a/tests/extensions/run b/tests/extensions/run new file mode 100755 index 00000000..f140dfed --- /dev/null +++ b/tests/extensions/run @@ -0,0 +1,124 @@ +#!/bin/bash +# extension api tests: builds the demo extension as a shared library with +# zig cc, loads it into zphp, and checks loading, rejection, isolation under +# serve, and resource cleanup. STATIC=1 additionally builds a zphp with the +# demo compiled in and runs the same script through it. BENCH=1 prints call +# overhead numbers (ReleaseFast build recommended) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ZPHP="${ZPHP:-$ROOT/zig-out/bin/zphp}" +PORT="${PORT:-19913}" +OUT="$(mktemp -d "${TMPDIR:-/tmp}/zphp-ext.XXXXXX")" +trap 'rm -rf "$OUT"; [ -n "${SERVER_PID:-}" ] && kill "$SERVER_PID" 2>/dev/null; true' EXIT + +case "$(uname -s)" in + Darwin) LIB=dylib ;; + *) LIB=so ;; +esac +if [ "$(uname -s)" = Darwin ] && [ -z "${DEVELOPER_DIR:-}" ] && [ -d /Library/Developer/CommandLineTools ]; then + export DEVELOPER_DIR=/Library/Developer/CommandLineTools +fi + +passed=0 +failed=0 +check() { + local name="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + printf 'FAIL %s\n expected: %s\n actual: %s\n' "$name" "$expected" "$actual" + fi +} +contains() { + local name="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + printf 'FAIL %s\n expected to contain: %s\n actual: %s\n' "$name" "$needle" "$haystack" + fi +} +build_ext() { + zig cc -shared -O2 -I "$ROOT/include" -o "$OUT/$1.$LIB" "$SCRIPT_DIR/$1.c" +} + +for ext in demo bad_abi dup clash failing_init noentry; do build_ext "$ext"; done +DEMO="$OUT/demo.$LIB" + +# the script under a dynamic extension +actual="$("$ZPHP" --extension="$DEMO" run "$SCRIPT_DIR/demo.php" 2>&1)" || true +check "demo.php output" "$(cat "$SCRIPT_DIR/demo.expected")" "$actual" +actual="$("$ZPHP" --extension "$DEMO" run "$SCRIPT_DIR/demo.php" 2>&1 | head -1)" || true +check "--extension PATH form" 'bool(true)' "$actual" +actual="$(ZPHP_EXTENSION_DIR="$OUT" "$ZPHP" run "$SCRIPT_DIR/demo.php" 2>&1 | tail -1)" || true +contains "ZPHP_EXTENSION_DIR loads a directory" "zphp: extension" "$actual" +mkdir -p "$OUT/dir" && cp "$DEMO" "$OUT/dir/" +actual="$(ZPHP_EXTENSION_DIR="$OUT/dir" "$ZPHP" run "$SCRIPT_DIR/demo.php" 2>&1 | head -1)" || true +check "ZPHP_EXTENSION_DIR loads the demo" 'bool(true)' "$actual" + +# rejected extensions never reach the script +reject() { + local name="$1" needle="$2"; shift 2 + local out status + out="$("$ZPHP" "$@" run "$SCRIPT_DIR/demo.php" 2>&1)" && status=0 || status=$? + check "$name exits 1" "1" "$status" + contains "$name message" "$needle" "$out" +} +reject "abi mismatch" "built for ABI 99, this zphp provides ABI 1" --extension="$OUT/bad_abi.$LIB" +reject "duplicate function across extensions" "function 'demo_add' is already registered by extension 'demo'" --extension="$DEMO" --extension="$OUT/dup.$LIB" +reject "clash with a builtin" "function 'strlen' is already defined" --extension="$OUT/clash.$LIB" +reject "failing module_init" "module_init failed with status 7" --extension="$OUT/failing_init.$LIB" +reject "missing entry point" "no zphp_extension_entry symbol" --extension="$OUT/noentry.$LIB" +reject "missing file" "cannot load" --extension="$OUT/missing.$LIB" +echo "not a library" > "$OUT/text.$LIB" +reject "not a library" "cannot load" --extension="$OUT/text.$LIB" +reject "same extension twice" "an extension named 'demo' is already loaded" --extension="$DEMO" --extension="$DEMO" +out="$("$ZPHP" run "$SCRIPT_DIR/demo.php" 2>&1)" && status=0 || status=$? +check "without the extension the script fails" "255" "$status" + +# serve: request-local state resets, worker-local state persists, resources +# held past the end of a request are destroyed, ini changes do not leak +"$ZPHP" --extension="$DEMO" serve "$SCRIPT_DIR/app.php" --port "$PORT" --workers 1 >"$OUT/serve.log" 2>&1 & +SERVER_PID=$! +for _ in $(seq 1 50); do + curl -s -o /dev/null "http://localhost:$PORT/health" 2>/dev/null && break + sleep 0.1 +done +get() { curl -s "http://localhost:$PORT/$1"; } +for round in 1 2 3; do + check "serve round $round request-local counter" "2" "$(get counter)" + check "serve round $round worker-local hits" "$round" "$(get hits)" + check "serve round $round class and global constants" "10|counter|1.2.3|42" "$(get const)" + check "serve round $round ini isolation" "hello|changed, x!" "$(get ini)" + check "serve round $round object round trip" "2|Demo\\Counter(native counter, count=2, tally=yes)" "$(get obj)" + check "serve round $round exception" "caught x" "$(get throw)" + check "serve round $round resource kept past the request" "made" "$(get leak)" + check "serve round $round resource freed at request end" "$round" "$(get freed)" +done +for _ in $(seq 1 25); do get counter >/dev/null; done +check "serve after 25 more requests" "2" "$(get counter)" +kill "$SERVER_PID" 2>/dev/null || true +wait "$SERVER_PID" 2>/dev/null || true +SERVER_PID="" + +# a static build runs the same script with nothing loaded at runtime +if [ "${STATIC:-0}" = 1 ]; then + make -C "$ROOT" --no-print-directory "${STATIC_TARGET:-build}" ZIG_FLAGS="-Dextension=$SCRIPT_DIR/demo.c --prefix $OUT/static" >/dev/null + actual="$("$OUT/static/bin/zphp" run "$SCRIPT_DIR/demo.php" 2>&1)" || true + check "static extension: demo.php output" "$(cat "$SCRIPT_DIR/demo.expected")" "$actual" + out="$("$OUT/static/bin/zphp" --extension="$DEMO" run "$SCRIPT_DIR/demo.php" 2>&1)" && status=0 || status=$? + check "static extension: loading it again dynamically is rejected" "1" "$status" + contains "static extension: duplicate message" "an extension named 'demo' is already loaded" "$out" + if [ "${BENCH:-0}" = 1 ]; then + echo "static: $("$OUT/static/bin/zphp" run "$SCRIPT_DIR/bench.php")" + fi +fi +if [ "${BENCH:-0}" = 1 ]; then + echo "dynamic: $("$ZPHP" --extension="$DEMO" run "$SCRIPT_DIR/bench.php")" +fi + +echo "$passed passed, $failed failed" +[ "$failed" -eq 0 ] diff --git a/tests/serve/app.php b/tests/serve/app.php index abfd3aef..c2730e10 100644 --- a/tests/serve/app.php +++ b/tests/serve/app.php @@ -157,6 +157,8 @@ function isolation_counter() { static $n = 0; return ++$n; } "strtok" => strtok(","), "last_error" => error_get_last(), "dt_errors" => DateTime::getLastErrors(), + "atom" => DateTime::ATOM, + "enum_case" => RoundingMode::HalfEven->name, ]); } elseif ($path === "/header-trailing-space") { header("X-Trailing-Space: hello "); diff --git a/tests/serve_test b/tests/serve_test index ee459284..87534053 100755 --- a/tests/serve_test +++ b/tests/serve_test @@ -510,6 +510,7 @@ curl -s -o /dev/null http://localhost:$PORT/isolation/dirty iso2=$(curl -s http://localhost:$PORT/isolation/probe) iso3=$(curl -s http://localhost:$PORT/isolation/probe) check_contains "isolation probe shape" '"tz":"UTC"' "$iso1" +check_contains "builtin class constants survive the per-request reset" '"atom":"Y-m-d\\\\TH:i:sP","enum_case":"HalfEven"' "$iso1" check "request isolation after dirty request" "$iso1" "$iso2" check "request isolation second probe" "$iso1" "$iso3" iso_loop_ok=1