Skip to content

Commit

Permalink
feat: add Host Functions support (#13)
Browse files Browse the repository at this point in the history
Fixes #11 

 - [x] Implement FFI calls
 - [x] Implement CurrentPlugin
 - [x] Implement HostFunction
 - [x] Fail fast when validating callback params
 - [x] Add PHPDoc comments
 - [x] Add README section
 - [x] Add tests
 - [x] Review for breaking changes
- [ ] ~~User Data~~. Wasn't able to find a way to pin PHP objects and
get their address. Users can use captured variables in the callback
closures instead.

---------

Co-authored-by: Steve Manuel <steve@dylib.so>
  • Loading branch information
mhmd-azeez and nilslice authored Jan 29, 2024
1 parent b7a60b9 commit 0a522eb
Show file tree
Hide file tree
Showing 12 changed files with 611 additions and 34 deletions.
7 changes: 3 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,19 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
rust:
- stable
php: ['8.3']
steps:
- name: Checkout sources
uses: actions/checkout@v3
- uses: ./.github/actions/libextism
- name: Setup PHP env
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
php-version: ${{ matrix.php }}
extensions: ffi
tools: composer
env:
fail-fast: true
- name: Test PHP SDK
run: |
make test
make test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
/composer.lock
**/vendor/
src/ExtismLib.php
example/php_errors.log
php_errors.log
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,91 @@ $output = $plugin->call("count_vowels", "Yellow, World!");
// => {"count": 4, "total": 4, "vowels": "aeiouAEIOUY"}
```

### Host Functions

> **Note**
>
> Host Functions support is experimental. Due to usage of callbacks with FFI, It may leak memory.
Let's extend our count-vowels example a little bit: Instead of storing the `total` in an ephemeral plug-in var, let's store it in a persistent key-value store!

Wasm can't use our KV store on it's own. This is where `Host Functions` come in.

[Host functions](https://extism.org/docs/concepts/host-functions) allow us to grant new capabilities to our plug-ins from our application. They are simply some Go functions you write which can be passed down and invoked from any language inside the plug-in.

Let's load the manifest like usual but load up this `count_vowels_kvstore` plug-in:

```php
$manifest = new Manifest(new UrlWasmSource("https://github.com/extism/plugins/releases/latest/download/count_vowels_kvstore.wasm"));
```

> *Note*: The source code for this is [here](https://github.com/extism/plugins/blob/main/count_vowels_kvstore/src/lib.rs) and is written in rust, but it could be written in any of our PDK languages.
Unlike our previous plug-in, this plug-in expects you to provide host functions that satisfy our its import interface for a KV store.

We want to expose two functions to our plugin, `void kv_write(key string, value byte[])` which writes a bytes value to a key and `byte[] kv_read(key string)` which reads the bytes at the given `key`.

```php
// pretend this is Redis or something :)
$kvstore = [];
$kvRead = new HostFunction("kv_read", [ExtismValType::I64], [ExtismValType::I64], function (string $key) use (&$kvstore) {
$value = $kvstore[$key] ?? "\0\0\0\0";

echo "Read " . bytesToInt($value) . " from key=$key" . PHP_EOL;
return $value;
});

$kvWrite = new HostFunction("kv_write", [ExtismValType::I64, ExtismValType::I64], [], function (string $key, string $value) use (&$kvstore) {
echo "Writing value=" . bytesToInt($value) . " from key=$key" . PHP_EOL;
$kvstore[$key] = $value;
});

function bytesToInt(string $bytes): int {
$result = unpack("L", $bytes);
return $result[1];
}
```

> *Note*: The plugin provides memory pointers, which the SDK automatically converts into a `string`. Similarly, when a host function returns a `string`, the SDK allocates it in the plugin memory and provides a pointer back to the plugin. For manual memory management, request `CurrentPlugin` as the first parameter of the host function. For example:
>
> ```php
> $kvRead = new HostFunction("kv_read", [ExtismValType::I64], [ExtismValType::I64], function (CurrentPlugin $p, int $keyPtr) use ($kvstore) {
> $key = $p->read_block($keyPtr);
>
> $value = $kvstore[$key] ?? "\0\0\0\0";
>
> return $p->write_block($value);
> });
> ```
We need to pass these imports to the plug-in to create them. All imports of a plug-in must be satisfied for it to be initialized:
```php
$plugin = new Plugin($manifest, true, [$kvRead, $kvWrite]);
$output = $plugin->call("count_vowels", "Hello World!");
echo($output . PHP_EOL);
// => Read 0 from key=count-vowels"
// => Writing value=3 from key=count-vowels"
// => {"count": 3, "total": 3, "vowels": "aeiouAEIOU"}
$output = $plugin->call("count_vowels", "Hello World!");
echo($output . PHP_EOL);
// => Read 3 from key=count-vowels"
// => Writing value=6 from key=count-vowels"
// => {"count": 3, "total": 6, "vowels": "aeiouAEIOU"}
```
For host function callbacks, these are the valid parameter types:
- `CurrentPlugin`: Only if its the first parameter. Allows you to manually manage memory. Optional.
- `string`: If the parameter represents a memory offset (an `i64`), then the SDK can automatically load the buffer into a `string` for you.
- `int`: For `i32` and `i64` parameters.
- `float`: For `f32` and `f64` parameters.

Valid return types:
- `void`
- `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.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
},
"files": [
"src/Manifest.php",
"src/Plugin.php"
"src/Plugin.php",
"src/CurrentPlugin.php"
]
},
"autoload-dev": {
Expand Down
35 changes: 35 additions & 0 deletions example/memory_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php
use Extism\PathWasmSource;
use Extism\UrlWasmSource;
use Extism\Manifest;
use Extism\Plugin;
use Extism\HostFunction;
use Extism\ExtismValType;
use Extism\CurrentPlugin;

require_once __DIR__ . "/../src/Plugin.php";
require_once __DIR__ . "/../src/HostFunction.php";

$wasm = new PathWasmSource(__DIR__ . "/../wasm/count_vowels_kvstore.wasm");
$manifest = new Manifest($wasm);

for ($i = 0; $i < 10_000; $i++){
$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;
});

$plugin = new Plugin($manifest, true, [$kvRead, $kvWrite]);
$output = $plugin->call("count_vowels", "Hello World!");

if ($i % 100 === 0) {
echo "Iteration: $i\n";
}
}

readline();
89 changes: 89 additions & 0 deletions src/CurrentPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Extism;

require_once __DIR__ . "/LibExtism.php";

/**
* Represents a plugin that is calling the currently running host function.
*/
class CurrentPlugin
{
private \FFI\CData $handle;
private \LibExtism $lib;

/**
* constructor.
*
* @param \LibExtism $lib
* @param \FFI\CData $handle
*/
function __construct($lib, \FFI\CData $handle)
{
$this->handle = $handle;
$this->lib = $lib;
}

/**
* Reads a string from the plugin's memory at the given offset.
*
* @param int $offset Offset of the block to read.
*/
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);

$length = $this->lib->extism_current_plugin_memory_length($this->handle, $offset);

return \FFI::string($ptr, $length);
}

/**
* Allocates a block of memory in the plugin's memory and returns the offset.
*
* @param int $size Size of the block to allocate in bytes.
*/
function allocate_block(int $size) : int
{
return $this->lib->extism_current_plugin_memory_alloc($this->handle, $size);
}

/**
* Writes a string to the plugin's memory, returning the offset of the block.
*
* @param string $data Buffer to write to the plugin's memory.
*/
function write_block(string $data) : int
{
$offset = $this->allocate_block(strlen($data));
$this->fill_block($offset, $data);
return $offset;
}

/**
* Fills a block of memory in the plugin's memory.
*
* @param int $offset Offset of the block to fill.
* @param string $data Buffer to fill the block with.
*/
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);

\FFI::memcpy($ptr, $data, strlen($data));
}

/**
* Frees a block of memory in the plugin's memory.
*
* @param int $offset Offset of the block to free.
*/
function free_block(int $offset) : void
{
$this->lib->extism_current_plugin_memory_free($this->handle, $offset);
}
}
Loading

0 comments on commit 0a522eb

Please sign in to comment.