diff --git a/README.md b/README.md index 9893399..8a1b251 100644 --- a/README.md +++ b/README.md @@ -194,3 +194,56 @@ Valid return types: - `int`: For `i32` and `i64` parameters. - `float`: For `f32` and `f64` parameters. - `string`: the content of the string will be allocated in the wasm plugin memory and the offset (`i64`) will be returned. + +### Fuel Limits + +Plugins can be initialized with a fuel limit to constrain their execution. When a plugin runs out of fuel, it will throw an exception. This is useful for preventing infinite loops or limiting resource usage. + +```php +// Create plugin with fuel limit of 1000 instructions +$plugin = new Plugin($manifest, true, [], new PluginOptions(true, 1000)); + +try { + $output = $plugin->call("run_test", ""); +} catch (\Exception $e) { + // Plugin ran out of fuel + // The exception message will contain "fuel" +} +``` + +### Call Host Context + +Call Host Context provides a way to pass per-call context data when invoking a plugin function. This is useful when you need to provide data specific to a particular function call rather than data that persists across all calls. + +Here's an example of using call host context to implement a multi-user key-value store where each user has their own isolated storage: + +```php +$multiUserKvStore = [[]]; + +$kvRead = new HostFunction("kv_read", [ExtismValType::I64], [ExtismValType::I64], function (CurrentPlugin $p, string $key) use (&$multiUserKvStore) { + $userId = $p->getCallHostContext(); // get a copy of the host context data + $kvStore = $multiUserKvStore[$userId] ?? []; + + return $kvStore[$key] ?? "\0\0\0\0"; +}); + +$kvWrite = new HostFunction("kv_write", [ExtismValType::I64, ExtismValType::I64], [], function (CurrentPlugin $p, string $key, string $value) use (&$multiUserKvStore) { + $userId = $p->getCallHostContext(); // get a copy of the host context data + $kvStore = $multiUserKvStore[$userId] ?? []; + + $kvStore[$key] = $value; + $multiUserKvStore[$userId] = $kvStore; +}); + +$plugin = self::loadPlugin("count_vowels_kvstore.wasm", [$kvRead, $kvWrite]); + +$userId = 1; + +$response = $plugin->callWithContext("count_vowels", "Hello World!", $userId); +$this->assertEquals('{"count":3,"total":3,"vowels":"aeiouAEIOU"}', $response); + +$response = $plugin->callWithContext("count_vowels", "Hello World!", $userId); +$this->assertEquals('{"count":3,"total":6,"vowels":"aeiouAEIOU"}', $response); +``` + +Note: Unlike some other language SDKS, in the Extism PHP SDK the host context is copied when accessed via `getCallHostContext()`. This means that modifications to the context object within host functions won't affect the original context object passed to `callWithContext()`. diff --git a/src/CompiledPlugin.php b/src/CompiledPlugin.php new file mode 100644 index 0000000..9bf8e2d --- /dev/null +++ b/src/CompiledPlugin.php @@ -0,0 +1,87 @@ +lib = $lib; + $this->functions = $functions; + + $data = json_encode($manifest); + if (!$data) { + throw new \Extism\PluginLoadException("Failed to encode manifest"); + } + + $errPtr = $lib->ffi->new($lib->ffi->type("char*")); + + $handle = $this->lib->extism_compiled_plugin_new( + $data, + strlen($data), + $functions, + count($functions), + $withWasi, + \FFI::addr($errPtr) + ); + + if (\FFI::isNull($errPtr) === false) { + $error = \FFI::string($errPtr); + $this->lib->extism_plugin_new_error_free($errPtr); + throw new \Extism\PluginLoadException("Extism: unable to compile plugin: " . $error); + } + + $this->handle = $handle; + } + + /** + * Instantiate a plugin from this compiled plugin. + * + * @return Plugin + */ + public function instantiate(): Plugin + { + $errPtr = $this->lib->ffi->new($this->lib->ffi->type("char*")); + $nativeHandle = $this->lib->extism_plugin_new_from_compiled($this->handle, \FFI::addr($errPtr)); + + if (\FFI::isNull($errPtr) === false) { + $error = \FFI::string($errPtr); + $this->lib->extism_plugin_new_error_free($errPtr); + throw new \Extism\PluginLoadException("Extism: unable to load plugin from compiled: " . $error); + } + + $handle = new \Extism\Internal\PluginHandle($this->lib, $nativeHandle); + + return new Plugin($handle); + } + + /** + * Destructor to clean up resources + */ + public function __destruct() + { + $this->lib->extism_compiled_plugin_free($this->handle); + } +} \ No newline at end of file diff --git a/src/CurrentPlugin.php b/src/CurrentPlugin.php index e931a55..cfb9dec 100644 --- a/src/CurrentPlugin.php +++ b/src/CurrentPlugin.php @@ -24,6 +24,21 @@ public function __construct($lib, \FFI\CData $handle) $this->lib = $lib; } + /** + * Get *a copy* of the current plugin call's associated host context data. Returns null if call was made without host context. + * + * @return mixed|null Returns a copy of the host context data or null if none was provided + */ + public function getCallHostContext() + { + $serialized = $this->lib->extism_current_plugin_host_context($this->handle); + if ($serialized === null) { + return null; + } + + return unserialize($serialized); + } + /** * Reads a string from the plugin's memory at the given offset. * @@ -33,7 +48,8 @@ public function read_block(int $offset): string { $ptr = $this->lib->extism_current_plugin_memory($this->handle); $ptr = $this->lib->ffi->cast("char *", $ptr); - $ptr = $this->lib->ffi->cast("char *", $ptr + $offset); + $blockStart = $ptr + $offset; + $ptr = $this->lib->ffi->cast("char *", $blockStart); $length = $this->lib->extism_current_plugin_memory_length($this->handle, $offset); @@ -72,7 +88,8 @@ private function fill_block(int $offset, string $data): void { $ptr = $this->lib->extism_current_plugin_memory($this->handle); $ptr = $this->lib->ffi->cast("char *", $ptr); - $ptr = $this->lib->ffi->cast("char *", $ptr + $offset); + $blockStart = $ptr + $offset; + $ptr = $this->lib->ffi->cast("char *", $blockStart); \FFI::memcpy($ptr, $data, strlen($data)); } diff --git a/src/Internal/LibExtism.php b/src/Internal/LibExtism.php index f0bda39..b4fa86f 100644 --- a/src/Internal/LibExtism.php +++ b/src/Internal/LibExtism.php @@ -61,7 +61,7 @@ public function extism_current_plugin_memory(\FFI\CData $plugin): \FFI\CData return $this->ffi->extism_current_plugin_memory($plugin); } - public function extism_current_plugin_memory_free(\FFI\CData $plugin, \FFI\CData $ptr): void + public function extism_current_plugin_memory_free(\FFI\CData $plugin, int $ptr): void { $this->ffi->extism_current_plugin_memory_free($plugin, $ptr); } @@ -101,6 +101,129 @@ public function extism_plugin_function_exists(\FFI\CData $plugin, string $func_n return $this->ffi->extism_plugin_function_exists($plugin, $func_name); } + /** + * Create a new plugin from an ExtismCompiledPlugin + */ + public function extism_plugin_new_from_compiled(\FFI\CData $compiled, \FFI\CData $errPtr): ?\FFI\CData + { + return $this->ffi->extism_plugin_new_from_compiled($compiled, $errPtr); + } + + /** + * Create a new plugin with a fuel limit + */ + public function extism_plugin_new_with_fuel_limit(string $wasm, int $wasm_size, array $functions, int $n_functions, bool $with_wasi, int $fuel_limit, \FFI\CData $errPtr): ?\FFI\CData + { + $functionHandles = array_map(function ($function) { + return $function->handle; + }, $functions); + + $functionHandles = $this->toCArray($functionHandles, "ExtismFunction*"); + + $ptr = $this->owned("uint8_t", $wasm); + $pluginPtr = $this->ffi->extism_plugin_new_with_fuel_limit($ptr, $wasm_size, $functionHandles, $n_functions, $with_wasi ? 1 : 0, $fuel_limit, $errPtr); + return $this->ffi->cast("ExtismPlugin*", $pluginPtr); + } + + /** + * Get handle for plugin cancellation + */ + public function extism_plugin_cancel_handle(\FFI\CData $plugin): \FFI\CData + { + return $this->ffi->extism_plugin_cancel_handle($plugin); + } + + /** + * Cancel a running plugin + */ + public function extism_plugin_cancel(\FFI\CData $handle): bool + { + return $this->ffi->extism_plugin_cancel($handle); + } + + /** + * Pre-compile an Extism plugin + */ + public function extism_compiled_plugin_new(string $wasm, int $wasm_size, array $functions, int $n_functions, bool $with_wasi, \FFI\CData $errPtr): ?\FFI\CData + { + $functionHandles = array_map(function ($function) { + return $function->handle; + }, $functions); + + $functionHandles = $this->toCArray($functionHandles, "ExtismFunction*"); + + + $ptr = $this->owned("uint8_t", $wasm); + $pluginPtr = $this->ffi->extism_compiled_plugin_new($ptr, $wasm_size, $functionHandles, $n_functions, $with_wasi ? 1 : 0, $errPtr); + return $this->ffi->cast("ExtismCompiledPlugin*", $pluginPtr); + } + + /** + * Free ExtismCompiledPlugin + */ + public function extism_compiled_plugin_free(\FFI\CData $plugin): void + { + $this->ffi->extism_compiled_plugin_free($plugin); + } + + /** + * Enable HTTP response headers in plugins + */ + public function extism_plugin_allow_http_response_headers(\FFI\CData $plugin): void + { + $this->ffi->extism_plugin_allow_http_response_headers($plugin); + } + + /** + * Get plugin's ID + */ + public function extism_plugin_id(\FFI\CData $plugin): \FFI\CData + { + return $this->ffi->extism_plugin_id($plugin); + } + + /** + * Update plugin config + */ + public function extism_plugin_config(\FFI\CData $plugin, string $json, int $json_size): bool + { + $ptr = $this->owned("uint8_t", $json); + return $this->ffi->extism_plugin_config($plugin, $ptr, $json_size); + } + + /** + * Call a function with host context + */ + public function extism_plugin_call_with_host_context(\FFI\CData $plugin, string $func_name, string $data, int $data_len, $host_context): int + { + $dataPtr = $this->owned("uint8_t", $data); + + if ($host_context === null) { + return $this->ffi->extism_plugin_call_with_host_context($plugin, $func_name, $dataPtr, $data_len, null); + } + + $serialized = serialize($host_context); + $contextPtr = $this->ffi->new("char*"); + $contextArray = $this->ownedZero($serialized); + $contextPtr = \FFI::addr($contextArray); + + return $this->ffi->extism_plugin_call_with_host_context( + $plugin, + $func_name, + $dataPtr, + $data_len, + $contextPtr + ); + } + + /** + * Reset plugin + */ + public function extism_plugin_reset(\FFI\CData $plugin): bool + { + return $this->ffi->extism_plugin_reset($plugin); + } + public function extism_version(): string { return $this->ffi->extism_version(); @@ -112,6 +235,19 @@ public function extism_plugin_call(\FFI\CData $plugin, string $func_name, string return $this->ffi->extism_plugin_call($plugin, $func_name, $dataPtr, $data_len); } + /** + * Get the current plugin's associated host context data + */ + public function extism_current_plugin_host_context(\FFI\CData $plugin): ?string + { + $ptr = $this->ffi->extism_current_plugin_host_context($plugin); + if ($ptr === null || \FFI::isNull($ptr)) { + return null; + } + + return \FFI::string($this->ffi->cast("char *", $ptr)); + } + public function extism_error(\FFI\CData $plugin): ?string { return $this->ffi->extism_error($plugin); diff --git a/src/Internal/PluginHandle.php b/src/Internal/PluginHandle.php new file mode 100644 index 0000000..4d88b6e --- /dev/null +++ b/src/Internal/PluginHandle.php @@ -0,0 +1,18 @@ +native = $handle; + $this->lib = $lib; + } +} \ No newline at end of file diff --git a/src/Internal/extism.h b/src/Internal/extism.h index a5ff1d4..ead53ef 100644 --- a/src/Internal/extism.h +++ b/src/Internal/extism.h @@ -52,6 +52,8 @@ typedef enum { */ typedef struct ExtismCancelHandle ExtismCancelHandle; +typedef struct ExtismCompiledPlugin ExtismCompiledPlugin; + /** * CurrentPlugin stores data that is available to the caller in PDK functions, this should * only be accessed from inside a host function @@ -176,6 +178,21 @@ void extism_function_free(ExtismFunction *f); */ void extism_function_set_namespace(ExtismFunction *ptr, const char *namespace_); +/** + * Pre-compile an Extism plugin + */ +ExtismCompiledPlugin *extism_compiled_plugin_new(const uint8_t *wasm, + ExtismSize wasm_size, + const ExtismFunction **functions, + ExtismSize n_functions, + bool with_wasi, + char **errmsg); + +/** + * Free `ExtismCompiledPlugin` + */ +void extism_compiled_plugin_free(ExtismCompiledPlugin *plugin); + /** * Create a new plugin with host functions, the functions passed to this function no longer need to be manually freed using * @@ -192,13 +209,34 @@ ExtismPlugin *extism_plugin_new(const uint8_t *wasm, bool with_wasi, char **errmsg); +/** + * Create a new plugin from an `ExtismCompiledPlugin` + */ +ExtismPlugin *extism_plugin_new_from_compiled(const ExtismCompiledPlugin *compiled, char **errmsg); + +/** + * Create a new plugin and set the number of instructions a plugin is allowed to execute + */ +ExtismPlugin *extism_plugin_new_with_fuel_limit(const uint8_t *wasm, + ExtismSize wasm_size, + const ExtismFunction **functions, + ExtismSize n_functions, + bool with_wasi, + uint64_t fuel_limit, + char **errmsg); + +/** + * Enable HTTP response headers in plugins using `extism:host/env::http_request` + */ +void extism_plugin_allow_http_response_headers(ExtismPlugin *plugin); + /** * Free the error returned by `extism_plugin_new`, errors returned from `extism_plugin_error` don't need to be freed */ void extism_plugin_new_error_free(char *err); /** - * Remove a plugin from the registry and free associated memory + * Free `ExtismPlugin` */ void extism_plugin_free(ExtismPlugin *plugin); diff --git a/src/Plugin.php b/src/Plugin.php index 4c88394..de4510f 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,12 +4,12 @@ namespace Extism; +use Extism\Internal\PluginHandle; use Extism\Manifest\ByteArrayWasmSource; class Plugin { - private \Extism\Internal\LibExtism $lib; - private \FFI\CData $handle; + private PluginHandle $handle; /** * Initialize a plugin from a byte array. @@ -30,45 +30,109 @@ public static function fromBytes(string $bytes, bool $with_wasi = false, array $ /** * Constructor * - * @param Manifest $manifest A manifest that describes the Wasm binaries and configures permissions. - * @param bool $with_wasi Enable WASI + * @param Manifest|PluginHandle $manifest A manifest that describes the Wasm binaries and configures permissions. + * @param bool|PluginOptions $withWasiOrOptions Enable WASI or PluginOptions instance * @param array $functions Array of host functions */ - public function __construct(Manifest $manifest, bool $with_wasi = false, array $functions = []) + public function __construct($manifest, $withWasiOrOptions = false, array $functions = []) { + if ($manifest instanceof PluginHandle) { + $this->handle = $manifest; + return; + } + global $lib; - if ($lib == null) { + if ($lib === null) { $lib = new \Extism\Internal\LibExtism(); } - $this->lib = $lib; - $data = json_encode($manifest); + // Handle backwards compatibility + $options = $withWasiOrOptions; + if (is_bool($withWasiOrOptions)) { + $options = new PluginOptions($withWasiOrOptions); + } + $data = json_encode($manifest); if (!$data) { - echo "failed to encode manifest!" . PHP_EOL; - var_dump($manifest); - return; + throw new \Extism\PluginLoadException("Failed to encode manifest"); } $errPtr = $lib->ffi->new($lib->ffi->type("char*")); - $handle = $this->lib->extism_plugin_new($data, strlen($data), $functions, count($functions), $with_wasi, \FFI::addr($errPtr)); + + if ($options->getFuelLimit() !== null) { + $handle = $lib->extism_plugin_new_with_fuel_limit( + $data, + strlen($data), + $functions, + count($functions), + $options->getWithWasi(), + $options->getFuelLimit(), + \FFI::addr($errPtr) + ); + } else { + $handle = $lib->extism_plugin_new( + $data, + strlen($data), + $functions, + count($functions), + $options->getWithWasi(), + \FFI::addr($errPtr) + ); + } - if (\FFI::isNull($errPtr) == false) { + if (\FFI::isNull($errPtr) === false) { $error = \FFI::string($errPtr); - $this->lib->extism_plugin_new_error_free($errPtr); + $lib->extism_plugin_new_error_free($errPtr); throw new \Extism\PluginLoadException("Extism: unable to load plugin: " . $error); } - $this->handle = $handle; + $this->handle = new PluginHandle($lib, $handle); } /** - * Destructor + * Enable HTTP response headers in plugins using `extism:host/env::http_request` */ - public function __destruct() + public function allowHttpResponseHeaders(): void + { + $this->handle->lib->extism_plugin_allow_http_response_headers($this->handle->native); + } + + /** + * Reset the Extism runtime, this will invalidate all allocated memory + * + * @return bool + */ + public function reset(): bool { - $this->lib->extism_plugin_free($this->handle); + return $this->handle->lib->extism_plugin_reset($this->handle->native); + } + + /** + * Update plugin config values. + * + * @param array $config New configuration values + * @return bool + */ + public function updateConfig(array $config): bool + { + $json = json_encode($config); + if (!$json) { + return false; + } + + return $this->handle->lib->extism_plugin_config($this->handle->native, $json, strlen($json)); + } + + /** + * Get the plugin's ID. + * + * @return string UUID string + */ + public function getId(): string + { + $bytes = $this->handle->lib->extism_plugin_id($this->handle->native); + return bin2hex(\FFI::string($bytes, 16)); } /** @@ -78,9 +142,9 @@ public function __destruct() * * @return bool `true` if the function exists, `false` otherwise */ - public function functionExists(string $name) + public function functionExists(string $name): bool { - return $this->lib->extism_plugin_function_exists($this->handle, $name); + return $this->handle->lib->extism_plugin_function_exists($this->handle->native, $name); } /** @@ -91,22 +155,40 @@ public function functionExists(string $name) * * @return string Output buffer */ - public function call(string $name, string $input = null): string + public function call(string $name, string $input = ""): string { - if ($input == null) { - $input = ""; + $rc = $this->handle->lib->extism_plugin_call($this->handle->native, $name, $input, strlen($input)); + + $msg = "code = " . $rc; + $err = $this->handle->lib->extism_error($this->handle->native); + if ($err) { + $msg = $msg . ", error = " . $err; + throw new \Extism\FunctionCallException("Extism: call to '" . $name . "' failed with " . $msg, $err, $name); } - $rc = $this->lib->extism_plugin_call($this->handle, $name, $input, strlen($input)); + return $this->handle->lib->extism_plugin_output_data($this->handle->native); + } + + /** + * Call a function with host context. + * + * @param string $name Name of function + * @param string $input Input buffer + * @param mixed $context Host context data + * @return string Output buffer + */ + public function callWithContext(string $name, string $input = "", $context = null): string + { + $rc = $this->handle->lib->extism_plugin_call_with_host_context($this->handle->native, $name, $input, strlen($input), $context); $msg = "code = " . $rc; - $err = $this->lib->extism_error($this->handle); + $err = $this->handle->lib->extism_error($this->handle->native); if ($err) { $msg = $msg . ", error = " . $err; throw new \Extism\FunctionCallException("Extism: call to '" . $name . "' failed with " . $msg, $err, $name); } - return $this->lib->extism_plugin_output_data($this->handle); + return $this->handle->lib->extism_plugin_output_data($this->handle->native); } /** @@ -126,9 +208,17 @@ public static function setLogFile(string $filename, string $level): void * Get the Extism version string * @return string */ - public static function version() + public static function version(): string { $lib = new \Extism\Internal\LibExtism(); return $lib->extism_version(); } -} + + /** + * Destructor + */ + public function __destruct() + { + $this->handle->lib->extism_plugin_free($this->handle->native); + } +} \ No newline at end of file diff --git a/src/PluginOptions.php b/src/PluginOptions.php new file mode 100644 index 0000000..e0c30cd --- /dev/null +++ b/src/PluginOptions.php @@ -0,0 +1,73 @@ +withWasi = $withWasi; + $this->fuelLimit = $fuelLimit; + } + + /** + * @return bool + */ + public function getWithWasi(): bool + { + return $this->withWasi; + } + + /** + * @param bool $withWasi + * @return self + */ + public function setWithWasi(bool $withWasi): self + { + $this->withWasi = $withWasi; + return $this; + } + + /** + * @return int|null + */ + public function getFuelLimit(): ?int + { + return $this->fuelLimit; + } + + /** + * @param int|null $fuelLimit + * @return self + */ + public function setFuelLimit(?int $fuelLimit): self + { + $this->fuelLimit = $fuelLimit; + return $this; + } +} \ No newline at end of file diff --git a/tests/CompiledPluginTest.php b/tests/CompiledPluginTest.php new file mode 100644 index 0000000..3db7ecf --- /dev/null +++ b/tests/CompiledPluginTest.php @@ -0,0 +1,63 @@ +instantiate(); + + $response = $plugin->call("count_vowels", "Hello World!"); + $actual = json_decode($response); + + $this->assertEquals(3, $actual->count); + } + + public function testCompiledHostFunctions(): void + { + $kvstore = []; + + $kvRead = new HostFunction("kv_read", [ExtismValType::I64], [ExtismValType::I64], function (CurrentPlugin $p, string $key) use (&$kvstore) { + return $kvstore[$key] ?? "\0\0\0\0"; + }); + + $kvWrite = new HostFunction("kv_write", [ExtismValType::I64, ExtismValType::I64], [], function (string $key, string $value) use (&$kvstore) { + $kvstore[$key] = $value; + }); + + $compiledPlugin = self::compilePlugin("count_vowels_kvstore.wasm", [$kvRead, $kvWrite]); + + $plugin = $compiledPlugin->instantiate(); + + $response = $plugin->call("count_vowels", "Hello World!"); + $this->assertEquals('{"count":3,"total":3,"vowels":"aeiouAEIOU"}', $response); + + $response = $plugin->call("count_vowels", "Hello World!"); + $this->assertEquals('{"count":3,"total":6,"vowels":"aeiouAEIOU"}', $response); + } + + public static function compilePlugin(string $name, array $functions, ?callable $config = null) + { + $path = __DIR__ . '/../wasm/' . $name; + $manifest = new Manifest(new PathWasmSource($path, 'main')); + + if ($config !== null) { + $config($manifest); + } + + return new CompiledPlugin($manifest, $functions, true); + } +} diff --git a/tests/ManifestTest.php b/tests/ManifestTest.php index ddf766d..233506b 100644 --- a/tests/ManifestTest.php +++ b/tests/ManifestTest.php @@ -73,7 +73,11 @@ public function testCanMakeHttpCallsWhenAllowed(): void $manifest->allowed_hosts = ["jsonplaceholder.*.com"]; }); - $response = $plugin->call("run_test", ""); + $plugin->allowHttpResponseHeaders(); + + $req = json_encode(["url" => "https://jsonplaceholder.typicode.com/todos/1"]); + + $response = $plugin->call("http_get", $req); $actual = json_decode($response); $this->assertEquals(1, $actual->userId); } @@ -86,7 +90,9 @@ public function testCantMakeHttpCallsWhenDenied(): void $manifest->allowed_hosts = []; }); - $plugin->call("run_test", ""); + $req = json_encode(["url" => "https://jsonplaceholder.typicode.com/todos/1"]); + + $plugin->call("http_get", $req); } public static function loadPlugin(string $name, ?callable $config = null) diff --git a/tests/PluginTest.php b/tests/PluginTest.php index 7fbd78f..43d4cc8 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -6,6 +6,7 @@ use Extism\CurrentPlugin; use Extism\HostFunction; +use Extism\PluginOptions; use PHPUnit\Framework\TestCase; use Extism\Plugin; use Extism\Manifest; @@ -97,6 +98,38 @@ public function testHostFunctions(): void $this->assertEquals('{"count":3,"total":6,"vowels":"aeiouAEIOU"}', $response); } + public function testCallWithHostContext(): void + { + $multiUserKvStore = [[]]; + + $kvRead = new HostFunction("kv_read", [ExtismValType::I64], [ExtismValType::I64], function (CurrentPlugin $p, string $key) use (&$multiUserKvStore) { + $ctx = $p->getCallHostContext(); // get a copy of the host context data + $userId = $ctx["userId"]; + $kvStore = $multiUserKvStore[$userId] ?? []; + + return $kvStore[$key] ?? "\0\0\0\0"; + }); + + $kvWrite = new HostFunction("kv_write", [ExtismValType::I64, ExtismValType::I64], [], function (CurrentPlugin $p, string $key, string $value) use (&$multiUserKvStore) { + $ctx = $p->getCallHostContext(); // get a copy of the host context data + $userId = $ctx["userId"]; + $kvStore = $multiUserKvStore[$userId] ?? []; + + $kvStore[$key] = $value; + $multiUserKvStore[$userId] = $kvStore; + }); + + $plugin = self::loadPlugin("count_vowels_kvstore.wasm", [$kvRead, $kvWrite]); + + $ctx = ["userId" => 1]; + + $response = $plugin->callWithContext("count_vowels", "Hello World!", $ctx); + $this->assertEquals('{"count":3,"total":3,"vowels":"aeiouAEIOU"}', $response); + + $response = $plugin->callWithContext("count_vowels", "Hello World!", $ctx); + $this->assertEquals('{"count":3,"total":6,"vowels":"aeiouAEIOU"}', $response); + } + public function testHostFunctionManual(): void { $kvstore = []; @@ -125,7 +158,7 @@ public function testHostFunctionManual(): void public function testHostFunctionNamespace(): void { - $this->expectExceptionMessage("Extism: unable to load plugin: Unable to create Extism plugin: unknown import: `extism:host/user::kv_read` has not been defined"); + $this->expectExceptionMessage("Extism: unable to load plugin: Unable to compile Extism plugin: unknown import: `extism:host/user::kv_read` has not been defined"); $kvRead = new HostFunction("kv_read", [ExtismValType::I64], [ExtismValType::I64], function (string $key) { // @@ -135,12 +168,23 @@ public function testHostFunctionNamespace(): void $kvWrite = new HostFunction("kv_write", [ExtismValType::I64, ExtismValType::I64], [], function (string $key, string $value) { // }); - $kvRead->set_namespace("custom"); + $kvWrite->set_namespace("custom"); $plugin = self::loadPlugin("count_vowels_kvstore.wasm", [$kvRead, $kvWrite]); } - public static function loadPlugin(string $name, array $functions, ?callable $config = null) + public function testFuelLimit(): void + { + $plugin = self::loadPlugin("sleep.wasm", [], null, new PluginOptions(true, 10)); + + try { + $plugin->call("run_test", ""); + } catch (\Exception $e) { + $this->assertStringContainsString("fuel", $e->getMessage()); + } + } + + public static function loadPlugin(string $name, array $functions, ?callable $config = null, $withWasi = true) { $path = __DIR__ . '/../wasm/' . $name; $manifest = new Manifest(new PathWasmSource($path, 'main')); @@ -149,6 +193,6 @@ public static function loadPlugin(string $name, array $functions, ?callable $con $config($manifest); } - return new Plugin($manifest, true, $functions); + return new Plugin($manifest, $withWasi, $functions); } } diff --git a/wasm/http.wasm b/wasm/http.wasm index e6e3562..18ece61 100644 Binary files a/wasm/http.wasm and b/wasm/http.wasm differ