Skip to content

Commit

Permalink
feat,fix: respect EXTISM_ENABLE_WASI_OUTPUT
Browse files Browse the repository at this point in the history
I implemented this as an `ExtismPluginOption`, `enableWasiOutput`, that
defaults to checking the environment variable (on platforms that support
runtime environment variables.)

- There was a WASI browser polyfill bug with the wasistdout module.
  Since `wasistdout` doesn't export a `initialize` or `start` method,
  we weren't initializing the context appropriately. This now fixed.
- Neither Deno nor Node squelched WASI output before this commit. Now
  they do by default -- by opening /dev/null (or NUL on Windows) and
  sending that FD in instead.
- And now the browser supports WASI output as well!

Fixes #43.
  • Loading branch information
chrisdickinson committed Jan 5, 2024
1 parent b06803d commit 8036bc8
Show file tree
Hide file tree
Showing 15 changed files with 184 additions and 17 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"author": "The Extism Authors <oss@extism.org>",
"license": "BSD-3-Clause",
"devDependencies": {
"@bjorn3/browser_wasi_shim": "^0.2.14",
"@bjorn3/browser_wasi_shim": "^0.2.17",
"@playwright/test": "^1.39.0",
"@types/node": "^20.8.7",
"@typescript-eslint/eslint-plugin": "^6.8.0",
Expand Down
2 changes: 1 addition & 1 deletion src/foreground-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export async function createForegroundPlugin(
modules: WebAssembly.Module[],
context: CallContext = new CallContext(ArrayBuffer, opts.logger, opts.config),
): Promise<ForegroundPlugin> {
const wasi = opts.wasiEnabled ? await loadWasi(opts.allowedPaths) : null;
const wasi = opts.wasiEnabled ? await loadWasi(opts.allowedPaths, opts.enableWasiOutput) : null;

const imports: Record<string, Record<string, any>> = {
...(wasi ? { wasi_snapshot_preview1: await wasi.importObject() } : {}),
Expand Down
15 changes: 15 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ export interface ExtismPluginOptions {
functions?: { [key: string]: { [key: string]: (callContext: CallContext, ...args: any[]) => any } } | undefined;
allowedPaths?: { [key: string]: string } | undefined;
allowedHosts?: string[] | undefined;

/**
* Whether WASI stdout should be forwarded to the host.
*
* Overrides the `EXTISM_ENABLE_WASI_OUTPUT` environment variable.
*/
enableWasiOutput?: boolean | undefined;
config?: PluginConfigLike | undefined;
fetch?: typeof fetch;
sharedArrayBufferSize?: number;
Expand All @@ -172,6 +179,7 @@ export interface InternalConfig {
logger: Console;
allowedHosts: string[];
allowedPaths: { [key: string]: string };
enableWasiOutput: boolean;
functions: { [namespace: string]: { [func: string]: any } };
fetch: typeof fetch;
wasiEnabled: boolean;
Expand Down Expand Up @@ -373,4 +381,11 @@ export interface Capabilities {
* - ✅ webkit (via [`@bjorn3/browser_wasi_shim`](https://www.npmjs.com/package/@bjorn3/browser_wasi_shim))
*/
supportsWasiPreview1: boolean;

/**
* Whether or not the `EXTISM_ENABLE_WASI_OUTPUT` environment variable has been set.
*
* This value is consulted whenever {@link ExtismPluginOptions#enableWasiOutput} is omitted.
*/
extismStdoutEnvVarSet: boolean;
}
42 changes: 42 additions & 0 deletions src/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,48 @@ if (typeof WebAssembly === 'undefined') {
await plugin.close();
}
});

// TODO(chrisdickinson): this turns out to be pretty tricky to test, since
// deno and node's wasi bindings bypass JS entirely and write directly to
// their respective FDs. I'm settling for tests that exercise both behaviors.
test('when EXTISM_ENABLE_WASI_OUTPUT is not set, WASI output is stifled', async () => {
if ((globalThis as unknown as any).process) {
(
globalThis as unknown as Record<string, { env: Record<string, string> }>
).process.env.EXTISM_ENABLE_WASI_OUTPUT = '';
} else if ((globalThis as unknown as any).Deno) {
globalThis.Deno.env.set('EXTISM_ENABLE_WASI_OUTPUT', '');
}
const plugin = await createPlugin('http://localhost:8124/wasm/wasistdout.wasm', {
useWasi: true,
});

try {
await plugin.call('say_hello');
} finally {
await plugin.close();
}
});

test('respects enableWasiOutput', async () => {
if ((globalThis as unknown as any).process) {
(
globalThis as unknown as Record<string, { env: Record<string, string> }>
).process.env.EXTISM_ENABLE_WASI_OUTPUT = '';
} else if ((globalThis as unknown as any).Deno) {
globalThis.Deno.env.set('EXTISM_ENABLE_WASI_OUTPUT', '');
}
const plugin = await createPlugin('http://localhost:8124/wasm/wasistdout.wasm', {
useWasi: true,
enableWasiOutput: true,
});

try {
await plugin.call('say_hello');
} finally {
await plugin.close();
}
});
}

if (CAPABILITIES.fsAccess && CAPABILITIES.supportsWasiPreview1) {
Expand Down
2 changes: 2 additions & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export async function createPlugin(
): Promise<Plugin> {
opts = { ...opts };
opts.useWasi ??= false;
opts.enableWasiOutput ??= opts.useWasi ? CAPABILITIES.extismStdoutEnvVarSet : false;
opts.functions = opts.functions || {};
opts.allowedPaths ??= {};
opts.allowedHosts ??= <any>[].concat(opts.allowedHosts || []);
Expand All @@ -93,6 +94,7 @@ export async function createPlugin(
wasiEnabled: opts.useWasi,
logger: opts.logger,
config: opts.config,
enableWasiOutput: opts.enableWasiOutput,
sharedArrayBufferSize: Number(opts.sharedArrayBufferSize) || 1 << 16,
};

Expand Down
2 changes: 2 additions & 0 deletions src/polyfills/browser-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ export const CAPABILITIES: Capabilities = {
: true,

supportsWasiPreview1: true,

extismStdoutEnvVarSet: false,
};
48 changes: 40 additions & 8 deletions src/polyfills/browser-wasi.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
import { WASI, Fd, File, OpenFile } from '@bjorn3/browser_wasi_shim';
import { WASI, Fd, File, OpenFile, wasi } from '@bjorn3/browser_wasi_shim';
import { type InternalWasi } from '../mod.ts';

export async function loadWasi(_allowedPaths: { [from: string]: string }): Promise<InternalWasi> {
class Output extends Fd {
#mode: string;

constructor(mode: string) {
super();
this.#mode = mode;
}

fd_write(view8: Uint8Array, iovs: [wasi.Iovec]): { ret: number; nwritten: number } {
let nwritten = 0;
const decoder = new TextDecoder();
const str = iovs.reduce((acc, iovec, idx, all) => {
nwritten += iovec.buf_len;
const buffer = view8.slice(iovec.buf, iovec.buf + iovec.buf_len);
return acc + decoder.decode(buffer, { stream: idx !== all.length - 1 });
}, '');

(console[this.#mode] as any)(str);

return { ret: 0, nwritten };
}
}

export async function loadWasi(
_allowedPaths: { [from: string]: string },
enableWasiOutput: boolean,
): Promise<InternalWasi> {
const args: Array<string> = [];
const envVars: Array<string> = [];
const fds: Fd[] = [
new OpenFile(new File([])), // stdin
new OpenFile(new File([])), // stdout
new OpenFile(new File([])), // stderr
];
const fds: Fd[] = enableWasiOutput
? [
new Output('log'), // fd 0 is dup'd to stdout
new Output('log'),
new Output('error'),
]
: [
new OpenFile(new File([])), // stdin
new OpenFile(new File([])), // stdout
new OpenFile(new File([])), // stderr
];

const context = new WASI(args, envVars, fds);

Expand Down Expand Up @@ -38,7 +70,7 @@ export async function loadWasi(_allowedPaths: { [from: string]: string }): Promi
} else {
init();
}
} else if (instance.exports._start) {
} else {
context.start({
exports: {
memory,
Expand Down
2 changes: 2 additions & 0 deletions src/polyfills/bun-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export const CAPABILITIES: Capabilities = {

// See https://github.com/oven-sh/bun/issues/1960
supportsWasiPreview1: false,

extismStdoutEnvVarSet: Boolean(process.env.EXTISM_ENABLE_WASI_OUTPUT),
};
4 changes: 4 additions & 0 deletions src/polyfills/deno-capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Capabilities } from '../interfaces.ts';

const { Deno } = globalThis as unknown as { Deno: { env: Map<string, string> } };

export const CAPABILITIES: Capabilities = {
// When false, shared buffers have to be copied to an array
// buffer before passing to Text{En,De}coding()
Expand All @@ -16,4 +18,6 @@ export const CAPABILITIES: Capabilities = {
hasWorkerCapability: true,

supportsWasiPreview1: true,

extismStdoutEnvVarSet: Boolean(Deno.env.get('EXTISM_ENABLE_WASI_OUTPUT')),
};
28 changes: 27 additions & 1 deletion src/polyfills/deno-wasi.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
import Context from 'https://deno.land/std@0.200.0/wasi/snapshot_preview1.ts';
import { type InternalWasi } from '../interfaces.ts';
import { devNull } from 'node:os';
import { open } from 'node:fs/promises';
import { closeSync } from 'node:fs';

export async function loadWasi(allowedPaths: { [from: string]: string }): Promise<InternalWasi> {
async function createDevNullFDs() {
const [stdin, stdout] = await Promise.all([open(devNull, 'r'), open(devNull, 'w')]);

const fr = new globalThis.FinalizationRegistry((held: number) => {
try {
closeSync(held);
} catch {
// The fd may already be closed.
}
});
fr.register(stdin, stdin.fd);
fr.register(stdout, stdout.fd);

return [stdin.fd, stdout.fd, stdout.fd];
}

export async function loadWasi(
allowedPaths: { [from: string]: string },
enableWasiOutput: boolean,
): Promise<InternalWasi> {
const [stdin, stdout, stderr] = enableWasiOutput ? [0, 1, 2] : await createDevNullFDs();
const context = new Context({
preopens: allowedPaths,
exitOnReturn: false,
stdin,
stdout,
stderr,
});

return {
Expand Down
2 changes: 2 additions & 0 deletions src/polyfills/node-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ export const CAPABILITIES: Capabilities = {
hasWorkerCapability: true,

supportsWasiPreview1: true,

extismStdoutEnvVarSet: Boolean(process.env.EXTISM_ENABLE_WASI_OUTPUT),
};
31 changes: 29 additions & 2 deletions src/polyfills/node-wasi.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
import { WASI } from 'wasi';
import { type InternalWasi } from '../mod.ts';
import { type InternalWasi } from '../interfaces.ts';
import { devNull } from 'node:os';
import { open } from 'node:fs/promises';
import { closeSync } from 'node:fs';

async function createDevNullFDs() {
const [stdin, stdout] = await Promise.all([open(devNull, 'r'), open(devNull, 'w')]);

const fr = new globalThis.FinalizationRegistry((held: number) => {
try {
closeSync(held);
} catch {
// The fd may already be closed.
}
});
fr.register(stdin, stdin.fd);
fr.register(stdout, stdout.fd);

return [stdin.fd, stdout.fd, stdout.fd];
}

export async function loadWasi(
allowedPaths: { [from: string]: string },
enableWasiOutput: boolean,
): Promise<InternalWasi> {
const [stdin, stdout, stderr] = enableWasiOutput ? [0, 1, 2] : await createDevNullFDs();

export async function loadWasi(allowedPaths: { [from: string]: string }): Promise<InternalWasi> {
const context = new WASI({
version: 'preview1',
preopens: allowedPaths,
stdin,
stdout,
stderr,
} as any);

return {
Expand Down
13 changes: 13 additions & 0 deletions types/deno/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
declare module 'https://deno.land/x/minimatch@v3.0.4/index.js' {
export default function matches(text: string, pattern: string): boolean;
}

declare module 'https://deno.land/std@0.200.0/wasi/snapshot_preview1.ts' {
export default class Context {
constructor(opts: Record<string, any>);

exports: WebAssembly.Exports;
start(opts: any);
initialize?(opts: any);
}
}
Binary file added wasm/wasistdout.wasm
Binary file not shown.

0 comments on commit 8036bc8

Please sign in to comment.