@bytecodealliance/jco
Version:
JavaScript tooling for working with WebAssembly Components
1,584 lines (1,308 loc) • 256 kB
JavaScript
"use jco";
import { environment, exit as exit$1, stderr, stdin, stdout, terminalInput, terminalOutput, terminalStderr, terminalStdin, terminalStdout } from '@bytecodealliance/preview2-shim/cli';
import { preopens, types } from '@bytecodealliance/preview2-shim/filesystem';
import { error, streams } from '@bytecodealliance/preview2-shim/io';
import { random } from '@bytecodealliance/preview2-shim/random';
const { getEnvironment } = environment;
getEnvironment._isHostProvided = true;
if (getEnvironment=== undefined) {
const err = new Error("unexpectedly undefined local import 'getEnvironment', was 'getEnvironment' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { exit } = exit$1;
exit._isHostProvided = true;
if (exit=== undefined) {
const err = new Error("unexpectedly undefined local import 'exit', was 'exit' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { getStderr } = stderr;
getStderr._isHostProvided = true;
if (getStderr=== undefined) {
const err = new Error("unexpectedly undefined local import 'getStderr', was 'getStderr' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { getStdin } = stdin;
getStdin._isHostProvided = true;
if (getStdin=== undefined) {
const err = new Error("unexpectedly undefined local import 'getStdin', was 'getStdin' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { getStdout } = stdout;
getStdout._isHostProvided = true;
if (getStdout=== undefined) {
const err = new Error("unexpectedly undefined local import 'getStdout', was 'getStdout' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { TerminalInput } = terminalInput;
TerminalInput._isHostProvided = true;
if (TerminalInput=== undefined) {
const err = new Error("unexpectedly undefined local import 'TerminalInput', was 'TerminalInput' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { TerminalOutput } = terminalOutput;
TerminalOutput._isHostProvided = true;
if (TerminalOutput=== undefined) {
const err = new Error("unexpectedly undefined local import 'TerminalOutput', was 'TerminalOutput' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { getTerminalStderr } = terminalStderr;
getTerminalStderr._isHostProvided = true;
if (getTerminalStderr=== undefined) {
const err = new Error("unexpectedly undefined local import 'getTerminalStderr', was 'getTerminalStderr' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { getTerminalStdin } = terminalStdin;
getTerminalStdin._isHostProvided = true;
if (getTerminalStdin=== undefined) {
const err = new Error("unexpectedly undefined local import 'getTerminalStdin', was 'getTerminalStdin' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { getTerminalStdout } = terminalStdout;
getTerminalStdout._isHostProvided = true;
if (getTerminalStdout=== undefined) {
const err = new Error("unexpectedly undefined local import 'getTerminalStdout', was 'getTerminalStdout' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { getDirectories } = preopens;
getDirectories._isHostProvided = true;
if (getDirectories=== undefined) {
const err = new Error("unexpectedly undefined local import 'getDirectories', was 'getDirectories' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { Descriptor,
DirectoryEntryStream,
filesystemErrorCode } = types;
Descriptor._isHostProvided = true;
if (Descriptor=== undefined) {
const err = new Error("unexpectedly undefined local import 'Descriptor', was 'Descriptor' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
DirectoryEntryStream._isHostProvided = true;
if (DirectoryEntryStream=== undefined) {
const err = new Error("unexpectedly undefined local import 'DirectoryEntryStream', was 'DirectoryEntryStream' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
filesystemErrorCode._isHostProvided = true;
if (filesystemErrorCode=== undefined) {
const err = new Error("unexpectedly undefined local import 'filesystemErrorCode', was 'filesystemErrorCode' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { Error: Error$1 } = error;
Error$1._isHostProvided = true;
if (Error$1=== undefined) {
const err = new Error("unexpectedly undefined local import 'Error$1', was 'Error' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { InputStream,
OutputStream } = streams;
InputStream._isHostProvided = true;
if (InputStream=== undefined) {
const err = new Error("unexpectedly undefined local import 'InputStream', was 'InputStream' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
OutputStream._isHostProvided = true;
if (OutputStream=== undefined) {
const err = new Error("unexpectedly undefined local import 'OutputStream', was 'OutputStream' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const { getRandomBytes } = random;
getRandomBytes._isHostProvided = true;
if (getRandomBytes=== undefined) {
const err = new Error("unexpectedly undefined local import 'getRandomBytes', was 'getRandomBytes' available at instantiation?");
console.error("ERROR:", err.toString());
throw err;
}
const _debugLog = (...args) => {
if (!globalThis?.process?.env?.JCO_DEBUG) { return; }
console.debug(...args);
}
const ASYNC_DETERMINISM = 'random';
class GlobalComponentAsyncLowers {
static map = new Map();
constructor() { throw new Error('GlobalComponentAsyncLowers should not be constructed'); }
static define(args) {
const { componentIdx, qualifiedImportFn, fn } = args;
let inner = GlobalComponentAsyncLowers.map.get(componentIdx);
if (!inner) {
inner = new Map();
GlobalComponentAsyncLowers.map.set(componentIdx, inner);
}
inner.set(qualifiedImportFn, fn);
}
static lookup(componentIdx, qualifiedImportFn) {
let inner = GlobalComponentAsyncLowers.map.get(componentIdx);
if (!inner) {
inner = new Map();
GlobalComponentAsyncLowers.map.set(componentIdx, inner);
}
const found = inner.get(qualifiedImportFn);
if (found) { return found; }
// In some cases, async lowers are *not* host provided, and
// but contain/will call an async function in the host.
//
// One such case is `stream.write`/`stream.read` trampolines which are
// actually re-exported through a patch up container *before*
// they call the relevant async host trampoline.
//
// So the path of execution from a component export would be:
//
// async guest export --> stream.write import (host wired) -> guest export (patch component) -> async host trampoline
//
// On top of all this, the trampoline that is eventually called is async,
// so we must await the patched guest export call.
//
if (qualifiedImportFn.includes("[stream-write-") || qualifiedImportFn.includes("[stream-read-")) {
return async (...args) => {
const [originalFn, ...params] = args;
return await originalFn(...params);
};
}
// All other cases can call the registered function directly
return (...args) => {
const [originalFn, ...params] = args;
return originalFn(...params);
};
}
}
class GlobalAsyncParamLowers {
static map = new Map();
static generateKey(args) {
const { componentIdx, iface, fnName } = args;
if (componentIdx === undefined) { throw new TypeError("missing component idx"); }
if (iface === undefined) { throw new TypeError("missing iface name"); }
if (fnName === undefined) { throw new TypeError("missing function name"); }
return `${componentIdx}-${iface}-${fnName}`;
}
static define(args) {
const { componentIdx, iface, fnName, fn } = args;
if (!fn) { throw new TypeError('missing function'); }
const key = GlobalAsyncParamLowers.generateKey(args);
GlobalAsyncParamLowers.map.set(key, fn);
}
static lookup(args) {
const { componentIdx, iface, fnName } = args;
const key = GlobalAsyncParamLowers.generateKey(args);
return GlobalAsyncParamLowers.map.get(key);
}
}
class GlobalComponentMemories {
static map = new Map();
constructor() { throw new Error('GlobalComponentMemories should not be constructed'); }
static save(args) {
const { idx, componentIdx, memory } = args;
let inner = GlobalComponentMemories.map.get(componentIdx);
if (!inner) {
inner = [];
GlobalComponentMemories.map.set(componentIdx, inner);
}
inner.push({ memory, idx });
}
static getMemoriesForComponentIdx(componentIdx) {
const metas = GlobalComponentMemories.map.get(componentIdx);
return metas.map(meta => meta.memory);
}
static getMemory(componentIdx, idx) {
const metas = GlobalComponentMemories.map.get(componentIdx);
return metas.find(meta => meta.idx === idx)?.memory;
}
}
class RepTable {
#data = [0, null];
#target;
constructor(args) {
this.target = args?.target;
}
insert(val) {
_debugLog('[RepTable#insert()] args', { val, target: this.target });
const freeIdx = this.#data[0];
if (freeIdx === 0) {
this.#data.push(val);
this.#data.push(null);
return (this.#data.length >> 1) - 1;
}
this.#data[0] = this.#data[freeIdx << 1];
const placementIdx = freeIdx << 1;
this.#data[placementIdx] = val;
this.#data[placementIdx + 1] = null;
return freeIdx;
}
get(rep) {
_debugLog('[RepTable#get()] args', { rep, target: this.target });
const baseIdx = rep << 1;
const val = this.#data[baseIdx];
return val;
}
contains(rep) {
_debugLog('[RepTable#contains()] args', { rep, target: this.target });
const baseIdx = rep << 1;
return !!this.#data[baseIdx];
}
remove(rep) {
_debugLog('[RepTable#remove()] args', { rep, target: this.target });
if (this.#data.length === 2) { throw new Error('invalid'); }
const baseIdx = rep << 1;
const val = this.#data[baseIdx];
if (val === 0) { throw new Error('invalid resource rep (cannot be 0)'); }
this.#data[baseIdx] = this.#data[0];
this.#data[0] = rep;
return val;
}
clear() {
_debugLog('[RepTable#clear()] args', { rep, target: this.target });
this.#data = [0, null];
}
}
const _coinFlip = () => { return Math.random() > 0.5; };
let SCOPE_ID = 0;
const I32_MIN = -2_147_483_648;
const I32_MAX = 2_147_483_647;
const _typeCheckValidI32 = (n) => typeof n === 'number' && n >= I32_MIN && n <= I32_MAX;
const _typeCheckAsyncFn= (f) => {
return f instanceof ASYNC_FN_CTOR;
};
const ASYNC_FN_CTOR = (async () => {}).constructor;
const ASYNC_CURRENT_TASK_IDS = [];
const ASYNC_CURRENT_COMPONENT_IDXS = [];
function unpackCallbackResult(result) {
_debugLog('[unpackCallbackResult()] args', { result });
if (!(_typeCheckValidI32(result))) { throw new Error('invalid callback return value [' + result + '], not a valid i32'); }
const eventCode = result & 0xF;
if (eventCode < 0 || eventCode > 3) {
throw new Error('invalid async return value [' + eventCode + '], outside callback code range');
}
if (result < 0 || result >= 2**32) { throw new Error('invalid callback result'); }
// TODO: table max length check?
const waitableSetRep = result >> 4;
return [eventCode, waitableSetRep];
}
function promiseWithResolvers() {
if (Promise.withResolvers) {
return Promise.withResolvers();
} else {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
}
function _prepareCall(
memoryIdx,
getMemoryFn,
startFn,
returnFn,
callerInstanceIdx,
calleeInstanceIdx,
taskReturnTypeIdx,
isCalleeAsyncInt,
stringEncoding,
resultCountOrAsync,
) {
_debugLog('[_prepareCall()]', {
callerInstanceIdx,
calleeInstanceIdx,
taskReturnTypeIdx,
isCalleeAsyncInt,
stringEncoding,
resultCountOrAsync,
});
const argArray = [...arguments];
// Since Rust will happily pass large u32s over, resultCountOrAsync should be one of:
// (a) u32 max size => callee is async fn with no result
// (b) u32 max size - 1 => callee is async fn with result
// (c) any other value => callee is sync with the given result count
//
// Due to JS handling the value as 2s complement, the `resultCountOrAsync` ends up being:
// (a) -1 as u32 max size
// (b) -2 as u32 max size - 1
// (c) x
//
// Due to JS mishandling the value as 2s complement, the actual values we get are:
// see. https://github.com/wasm-bindgen/wasm-bindgen/issues/1388
let isAsync = false;
let hasResultPointer = false;
if (resultCountOrAsync === -1) {
isAsync = true;
hasResultPointer = false;
} else if (resultCountOrAsync === -2) {
isAsync = true;
hasResultPointer = true;
}
const currentCallerTaskMeta = getCurrentTask(callerInstanceIdx);
if (!currentCallerTaskMeta) {
throw new Error('invalid/missing current task for caller during prepare call');
}
const currentCallerTask = currentCallerTaskMeta.task;
if (!currentCallerTask) {
throw new Error('unexpectedly missing task in meta for caller during prepare call');
}
if (currentCallerTask.componentIdx() !== callerInstanceIdx) {
throw new Error(`task component idx [${ currentCallerTask.componentIdx() }] !== [${ callerInstanceIdx }] (callee ${ calleeInstanceIdx })`);
}
let getCalleeParamsFn;
let resultPtr = null;
if (hasResultPointer) {
const directParamsArr = argArray.slice(11);
getCalleeParamsFn = () => directParamsArr;
resultPtr = argArray[10];
} else {
const directParamsArr = argArray.slice(10);
getCalleeParamsFn = () => directParamsArr;
}
let encoding;
switch (stringEncoding) {
case 0:
encoding = 'utf8';
break;
case 1:
encoding = 'utf16';
break;
case 2:
encoding = 'compact-utf16';
break;
default:
throw new Error(`unrecognized string encoding enum [${stringEncoding}]`);
}
const [newTask, newTaskID] = createNewCurrentTask({
componentIdx: calleeInstanceIdx,
isAsync: isCalleeAsyncInt !== 0,
getCalleeParamsFn,
// TODO: find a way to pass the import name through here
entryFnName: 'task/' + currentCallerTask.id() + '/new-prepare-task',
stringEncoding,
});
const subtask = currentCallerTask.createSubtask({
componentIdx: callerInstanceIdx,
parentTask: currentCallerTask,
childTask: newTask,
callMetadata: {
memory: getMemoryFn(),
memoryIdx,
resultPtr,
returnFn,
startFn,
}
});
newTask.setParentSubtask(subtask);
// NOTE: This isn't really a return memory idx for the caller, it's for checking
// against the task.return (which will be called from the callee)
newTask.setReturnMemoryIdx(memoryIdx);
}
function _asyncStartCall(args, callee, paramCount, resultCount, flags) {
const { getCallbackFn, callbackIdx, getPostReturnFn, postReturnIdx } = args;
_debugLog('[_asyncStartCall()] args', args);
const taskMeta = getCurrentTask(ASYNC_CURRENT_COMPONENT_IDXS.at(-1), ASYNC_CURRENT_TASK_IDS.at(-1));
if (!taskMeta) { throw new Error('invalid/missing current async task meta during prepare call'); }
const argArray = [...arguments];
// NOTE: at this point we know the current task is the one that was started
// in PrepareCall, so we *should* be able to pop it back off and be left with
// the previous task
const preparedTask = taskMeta.task;
if (!preparedTask) { throw new Error('unexpectedly missing task in task meta during prepare call'); }
if (resultCount < 0 || resultCount > 1) { throw new Error('invalid/unsupported result count'); }
const callbackFnName = 'callback_' + callbackIdx;
const callbackFn = getCallbackFn();
preparedTask.setCallbackFn(callbackFn, callbackFnName);
preparedTask.setPostReturnFn(getPostReturnFn());
const subtask = preparedTask.getParentSubtask();
if (resultCount < 0 || resultCount > 1) { throw new Error(`unsupported result count [${ resultCount }]`); }
const params = preparedTask.getCalleeParams();
if (paramCount !== params.length) {
throw new Error(`unexpected callee param count [${ params.length }], _asyncStartCall invocation expected [${ paramCount }]`);
}
subtask.setOnProgressFn(() => {
subtask.setPendingEventFn(() => {
if (subtask.resolved()) { subtask.deliverResolve(); }
return {
code: ASYNC_EVENT_CODE.SUBTASK,
index: rep,
result: subtask.getStateNumber(),
}
});
});
const subtaskState = subtask.getStateNumber();
if (subtaskState < 0 || subtaskState > 2**5) {
throw new Error('invalid subtask state, out of valid range');
}
const callerComponentState = getOrCreateAsyncState(subtask.componentIdx());
const rep = callerComponentState.subtasks.insert(subtask);
subtask.setRep(rep);
const calleeComponentState = getOrCreateAsyncState(preparedTask.componentIdx());
const calleeBackpressure = calleeComponentState.hasBackpressure();
// Set up a handler on subtask completion to lower results from the call into the caller's memory region.
//
// NOTE: during fused guest->guest calls this handler is triggered, but does not actually perform
// lowering manually, as fused modules provider helper functions that can
subtask.registerOnResolveHandler((res) => {
_debugLog('[_asyncStartCall()] handling subtask result', { res, subtaskID: subtask.id() });
let subtaskCallMeta = subtask.getCallMetadata();
// NOTE: in the case of guest -> guest async calls, there may be no memory/realloc present,
// as the host will intermediate the value storage/movement between calls.
//
// We can simply take the value and lower it as a parameter
if (subtaskCallMeta.memory || subtaskCallMeta.realloc) {
throw new Error("call metadata unexpectedly contains memory/realloc for guest->guest call");
}
const callerTask = subtask.getParentTask();
const calleeTask = preparedTask;
const callerMemoryIdx = callerTask.getReturnMemoryIdx();
const callerComponentIdx = callerTask.componentIdx();
// If a helper function was provided we are likely in a fused guest->guest call,
// and the result will be delivered (lift/lowered) via helper function
if (subtaskCallMeta.returnFn) {
_debugLog('[_asyncStartCall()] return function present while ahndling subtask result, returning early (skipping lower)');
return;
}
// If there is no where to lower the results, exit early
if (!subtaskCallMeta.resultPtr) {
_debugLog('[_asyncStartCall()] no result ptr during subtask result handling, returning early (skipping lower)');
return;
}
let callerMemory;
if (callerMemoryIdx) {
callerMemory = GlobalComponentMemories.getMemory(callerComponentIdx, callerMemoryIdx);
} else {
const callerMemories = GlobalComponentMemories.getMemoriesForComponentIdx(callerComponentIdx);
if (callerMemories.length != 1) { throw new Error(`unsupported amount of caller memories`); }
callerMemory = callerMemories[0];
}
if (!callerMemory) {
throw new Error(`missing memory for to guest->guest call result (subtask [${subtask.id()}])`);
}
const lowerFns = calleeTask.getReturnLowerFns();
if (!lowerFns || lowerFns.length === 0) {
throw new Error(`missing result lower metadata for guest->guests call (subtask [${subtask.id()}])`);
}
if (lowerFns.length !== 1) {
throw new Error(`only single result supported for guest->guest calls (subtask [${subtask.id()}])`);
}
lowerFns[0]({
realloc: undefined,
memory: callerMemory,
vals: [res],
storagePtr: subtaskCallMeta.resultPtr,
componentIdx: callerComponentIdx
});
});
// Build call params
const subtaskCallMeta = subtask.getCallMetadata();
let startFnParams = [];
let calleeParams = [];
if (subtaskCallMeta.startFn && subtaskCallMeta.resultPtr) {
// If we're using a fused component start fn and a result pointer is present,
// then we need to pass the result pointer and other params to the start fn
startFnParams.push(subtaskCallMeta.resultPtr, ...params);
} else {
// if not we need to pass params to the callee instead
startFnParams.push(...params);
calleeParams.push(...params);
}
preparedTask.registerOnResolveHandler((res) => {
_debugLog('[_asyncStartCall()] signaling subtask completion due to task completion', {
childTaskID: preparedTask.id(),
subtaskID: subtask.id(),
parentTaskID: subtask.getParentTask().id(),
});
subtask.onResolve(res);
});
// TODO(fix): start fns sometimes produce results, how should they be used?
// the result should theoretically be used for flat lowering, but fused components do
// this automatically!
subtask.onStart({ startFnParams });
_debugLog("[_asyncStartCall()] initial call", {
task: preparedTask.id(),
subtaskID: subtask.id(),
calleeFnName: callee.name,
});
const callbackResult = callee.apply(null, calleeParams);
_debugLog("[_asyncStartCall()] after initial call", {
task: preparedTask.id(),
subtaskID: subtask.id(),
calleeFnName: callee.name,
});
const doSubtaskResolve = () => {
subtask.deliverResolve();
};
// If a single call resolved the subtask and there is no backpressure in the guest,
// we can return immediately
if (subtask.resolved() && !calleeBackpressure) {
_debugLog("[_asyncStartCall()] instantly resolved", {
calleeComponentIdx: preparedTask.componentIdx(),
task: preparedTask.id(),
subtaskID: subtask.id(),
callerComponentIdx: subtask.componentIdx(),
});
// If a fused component return function was specified for the subtask,
// we've likely already called it during resolution of the task.
//
// In this case, we do not want to actually return 2 AKA "RETURNED",
// but the normal started task state, because the fused component expects to get
// the waitable + the original subtask state (0 AKA "STARTING")
//
if (subtask.getCallMetadata().returnFn) {
return Number(subtask.waitableRep()) << 4 | subtaskState;
}
doSubtaskResolve();
return AsyncSubtask.State.RETURNED;
}
// Start the (event) driver loop that will resolve the task
new Promise(async (resolve, reject) => {
if (subtask.resolved() && calleeBackpressure) {
await calleeComponentState.waitForBackpressure();
_debugLog("[_asyncStartCall()] instantly resolved after cleared backpressure", {
calleeComponentIdx: preparedTask.componentIdx(),
task: preparedTask.id(),
subtaskID: subtask.id(),
callerComponentIdx: subtask.componentIdx(),
});
return;
}
const started = await preparedTask.enter();
if (!started) {
_debugLog('[_asyncStartCall()] task failed early', {
taskID: preparedTask.id(),
subtaskID: subtask.id(),
});
throw new Error("task failed to start");
return;
}
// TODO: retrieve/pass along actual fn name the callback corresponds to
// (at least something like `<lifted fn name>_callback`)
const fnName = [
'<task ',
subtask.parentTaskID(),
'/subtask ',
subtask.id(),
'/task ',
preparedTask.id(),
'>',
].join("");
try {
_debugLog("[_asyncStartCall()] starting driver loop", { fnName, componentIdx: preparedTask.componentIdx(), });
await _driverLoop({
componentState: calleeComponentState,
task: preparedTask,
fnName,
isAsync: true,
callbackResult,
resolve,
reject
});
} catch (err) {
_debugLog("[AsyncStartCall] drive loop call failure", { err });
}
});
return Number(subtask.waitableRep()) << 4 | subtaskState;
}
function _syncStartCall(callbackIdx) {
_debugLog('[_syncStartCall()] args', { callbackIdx });
throw new Error('synchronous start call not implemented!');
}
let dv = new DataView(new ArrayBuffer());
const dataView = mem => dv.buffer === mem.buffer ? dv : dv = new DataView(mem.buffer);
const toUint64 = val => BigInt.asUintN(64, BigInt(val));
function toUint32(val) {
return val >>> 0;
}
const TEXT_DECODER_UTF8 = new TextDecoder();
const TEXT_ENCODER_UTF8 = new TextEncoder();
function _utf8AllocateAndEncode(s, realloc, memory) {
if (typeof s !== 'string') {
throw new TypeError('expected a string, received [' + typeof s + ']');
}
if (s.length === 0) { return { ptr: 1, len: 0 }; }
let buf = TEXT_ENCODER_UTF8.encode(s);
let ptr = realloc(0, 0, 1, buf.length);
new Uint8Array(memory.buffer).set(buf, ptr);
return { ptr, len: buf.length, codepoints: [...s].length };
}
const T_FLAG = 1 << 30;
function rscTableCreateOwn(table, rep) {
const free = table[0] & ~T_FLAG;
if (free === 0) {
table.push(0);
table.push(rep | T_FLAG);
return (table.length >> 1) - 1;
}
table[0] = table[free << 1];
table[free << 1] = 0;
table[(free << 1) + 1] = rep | T_FLAG;
return free;
}
function rscTableRemove(table, handle) {
const scope = table[handle << 1];
const val = table[(handle << 1) + 1];
const own = (val & T_FLAG) !== 0;
const rep = val & ~T_FLAG;
if (val === 0 || (scope & T_FLAG) !== 0) {
throw new TypeError("Invalid handle");
}
table[handle << 1] = table[0] | T_FLAG;
table[0] = handle | T_FLAG;
return { rep, scope, own };
}
let curResourceBorrows = [];
function getCurrentTask(componentIdx) {
if (componentIdx === undefined || componentIdx === null) {
throw new Error('missing/invalid component instance index [' + componentIdx + '] while getting current task');
}
const tasks = ASYNC_TASKS_BY_COMPONENT_IDX.get(componentIdx);
if (tasks === undefined) { return undefined; }
if (tasks.length === 0) { return undefined; }
return tasks[tasks.length - 1];
}
function createNewCurrentTask(args) {
_debugLog('[createNewCurrentTask()] args', args);
const {
componentIdx,
isAsync,
entryFnName,
parentSubtaskID,
callbackFnName,
getCallbackFn,
getParamsFn,
stringEncoding,
errHandling,
getCalleeParamsFn,
resultPtr,
callingWasmExport,
} = args;
if (componentIdx === undefined || componentIdx === null) {
throw new Error('missing/invalid component instance index while starting task');
}
const taskMetas = ASYNC_TASKS_BY_COMPONENT_IDX.get(componentIdx);
const callbackFn = getCallbackFn ? getCallbackFn() : null;
const newTask = new AsyncTask({
componentIdx,
isAsync,
entryFnName,
callbackFn,
callbackFnName,
stringEncoding,
getCalleeParamsFn,
resultPtr,
errHandling,
});
const newTaskID = newTask.id();
const newTaskMeta = { id: newTaskID, componentIdx, task: newTask };
ASYNC_CURRENT_TASK_IDS.push(newTaskID);
ASYNC_CURRENT_COMPONENT_IDXS.push(componentIdx);
if (!taskMetas) {
ASYNC_TASKS_BY_COMPONENT_IDX.set(componentIdx, [newTaskMeta]);
} else {
taskMetas.push(newTaskMeta);
}
return [newTask, newTaskID];
}
function endCurrentTask(componentIdx, taskID) {
componentIdx ??= ASYNC_CURRENT_COMPONENT_IDXS.at(-1);
taskID ??= ASYNC_CURRENT_TASK_IDS.at(-1);
_debugLog('[endCurrentTask()] args', { componentIdx, taskID });
if (componentIdx === undefined || componentIdx === null) {
throw new Error('missing/invalid component instance index while ending current task');
}
const tasks = ASYNC_TASKS_BY_COMPONENT_IDX.get(componentIdx);
if (!tasks || !Array.isArray(tasks)) {
throw new Error('missing/invalid tasks for component instance while ending task');
}
if (tasks.length == 0) {
throw new Error('no current task(s) for component instance while ending task');
}
if (taskID) {
const last = tasks[tasks.length - 1];
if (last.id !== taskID) {
// throw new Error('current task does not match expected task ID');
return;
}
}
ASYNC_CURRENT_TASK_IDS.pop();
ASYNC_CURRENT_COMPONENT_IDXS.pop();
const taskMeta = tasks.pop();
return taskMeta.task;
}
const ASYNC_TASKS_BY_COMPONENT_IDX = new Map();
class AsyncTask {
static _ID = 0n;
static State = {
INITIAL: 'initial',
CANCELLED: 'cancelled',
CANCEL_PENDING: 'cancel-pending',
CANCEL_DELIVERED: 'cancel-delivered',
RESOLVED: 'resolved',
}
static BlockResult = {
CANCELLED: 'block.cancelled',
NOT_CANCELLED: 'block.not-cancelled',
}
#id;
#componentIdx;
#state;
#isAsync;
#entryFnName = null;
#subtasks = [];
#onResolveHandlers = [];
#completionPromise = null;
#memoryIdx = null;
#callbackFn = null;
#callbackFnName = null;
#postReturnFn = null;
#getCalleeParamsFn = null;
#stringEncoding = null;
#parentSubtask = null;
#needsExclusiveLock = false;
#errHandling;
#backpressurePromise;
#backpressureWaiters = 0n;
#returnLowerFns = null;
cancelled = false;
requested = false;
alwaysTaskReturn = false;
returnCalls = 0;
storage = [0, 0];
borrowedHandles = {};
awaitableResume = null;
awaitableCancel = null;
constructor(opts) {
this.#id = ++AsyncTask._ID;
if (opts?.componentIdx === undefined) {
throw new TypeError('missing component id during task creation');
}
this.#componentIdx = opts.componentIdx;
this.#state = AsyncTask.State.INITIAL;
this.#isAsync = opts?.isAsync ?? false;
this.#entryFnName = opts.entryFnName;
const {
promise: completionPromise,
resolve: resolveCompletionPromise,
reject: rejectCompletionPromise,
} = promiseWithResolvers();
this.#completionPromise = completionPromise;
this.#onResolveHandlers.push((results) => {
resolveCompletionPromise(results);
})
if (opts.callbackFn) { this.#callbackFn = opts.callbackFn; }
if (opts.callbackFnName) { this.#callbackFnName = opts.callbackFnName; }
if (opts.getCalleeParamsFn) { this.#getCalleeParamsFn = opts.getCalleeParamsFn; }
if (opts.stringEncoding) { this.#stringEncoding = opts.stringEncoding; }
if (opts.parentSubtask) { this.#parentSubtask = opts.parentSubtask; }
this.#needsExclusiveLock = this.isSync() || !this.hasCallback();
if (opts.errHandling) { this.#errHandling = opts.errHandling; }
}
taskState() { return this.#state; }
id() { return this.#id; }
componentIdx() { return this.#componentIdx; }
isAsync() { return this.#isAsync; }
entryFnName() { return this.#entryFnName; }
completionPromise() { return this.#completionPromise; }
isAsync() { return this.#isAsync; }
isSync() { return !this.isAsync(); }
getErrHandling() { return this.#errHandling; }
hasCallback() { return this.#callbackFn !== null; }
setReturnMemoryIdx(idx) { this.#memoryIdx = idx; }
getReturnMemoryIdx() { return this.#memoryIdx; }
setReturnLowerFns(fns) { this.#returnLowerFns = fns; }
getReturnLowerFns() { return this.#returnLowerFns; }
setParentSubtask(subtask) {
if (!subtask || !(subtask instanceof AsyncSubtask)) { return }
if (this.#parentSubtask) { throw new Error('parent subtask can only be set once'); }
this.#parentSubtask = subtask;
}
getParentSubtask() { return this.#parentSubtask; }
// TODO(threads): this is very inefficient, we can pass along a root task,
// and ideally do not need this once thread support is in place
getRootTask() {
let currentSubtask = this.getParentSubtask();
let task = this;
while (currentSubtask) {
task = currentSubtask.getParentTask();
currentSubtask = task.getParentSubtask();
}
return task;
}
setPostReturnFn(f) {
if (!f) { return; }
if (this.#postReturnFn) { throw new Error('postReturn fn can only be set once'); }
this.#postReturnFn = f;
}
setCallbackFn(f, name) {
if (!f) { return; }
if (this.#callbackFn) { throw new Error('callback fn can only be set once'); }
this.#callbackFn = f;
this.#callbackFnName = name;
}
getCallbackFnName() {
if (!this.#callbackFnName) { return undefined; }
return this.#callbackFnName;
}
runCallbackFn(...args) {
if (!this.#callbackFn) { throw new Error('on callback function has been set for task'); }
return this.#callbackFn.apply(null, args);
}
getCalleeParams() {
if (!this.#getCalleeParamsFn) { throw new Error('missing/invalid getCalleeParamsFn'); }
return this.#getCalleeParamsFn();
}
mayEnter(task) {
const cstate = getOrCreateAsyncState(this.#componentIdx);
if (cstate.hasBackpressure()) {
_debugLog('[AsyncTask#mayEnter()] disallowed due to backpressure', { taskID: this.#id });
return false;
}
if (!cstate.callingSyncImport()) {
_debugLog('[AsyncTask#mayEnter()] disallowed due to sync import call', { taskID: this.#id });
return false;
}
const callingSyncExportWithSyncPending = cstate.callingSyncExport && !task.isAsync;
if (!callingSyncExportWithSyncPending) {
_debugLog('[AsyncTask#mayEnter()] disallowed due to sync export w/ sync pending', { taskID: this.#id });
return false;
}
return true;
}
async enter() {
_debugLog('[AsyncTask#enter()] args', { taskID: this.#id });
const cstate = getOrCreateAsyncState(this.#componentIdx);
if (this.isSync()) { return true; }
if (cstate.hasBackpressure()) {
cstate.addBackpressureWaiter();
const result = await this.waitUntil({
readyFn: () => !cstate.hasBackpressure(),
cancellable: true,
});
cstate.removeBackpressureWaiter();
if (result === AsyncTask.BlockResult.CANCELLED) {
this.cancel();
return false;
}
}
if (this.needsExclusiveLock()) { cstate.exclusiveLock(); }
return true;
}
isRunning() {
return this.#state !== AsyncTask.State.RESOLVED;
}
async waitUntil(opts) {
const { readyFn, waitableSetRep, cancellable } = opts;
_debugLog('[AsyncTask#waitUntil()] args', { taskID: this.#id, waitableSetRep, cancellable });
const state = getOrCreateAsyncState(this.#componentIdx);
const wset = state.waitableSets.get(waitableSetRep);
let event;
wset.incrementNumWaiting();
const keepGoing = await this.suspendUntil({
readyFn: () => {
const hasPendingEvent = wset.hasPendingEvent();
return readyFn() && hasPendingEvent;
},
cancellable,
});
if (keepGoing) {
event = wset.getPendingEvent();
} else {
event = {
code: ASYNC_EVENT_CODE.TASK_CANCELLED,
index: 0,
result: 0,
};
}
wset.decrementNumWaiting();
return event;
}
async onBlock(awaitable) {
_debugLog('[AsyncTask#onBlock()] args', { taskID: this.#id, awaitable });
if (!(awaitable instanceof Awaitable)) {
throw new Error('invalid awaitable during onBlock');
}
// Build a promise that this task can await on which resolves when it is awoken
const { promise, resolve, reject } = promiseWithResolvers();
this.awaitableResume = () => {
_debugLog('[AsyncTask] resuming after onBlock', { taskID: this.#id });
resolve();
};
this.awaitableCancel = (err) => {
_debugLog('[AsyncTask] rejecting after onBlock', { taskID: this.#id, err });
reject(err);
};
// Park this task/execution to be handled later
const state = getOrCreateAsyncState(this.#componentIdx);
state.parkTaskOnAwaitable({ awaitable, task: this });
try {
await promise;
return AsyncTask.BlockResult.NOT_CANCELLED;
} catch (err) {
// rejection means task cancellation
return AsyncTask.BlockResult.CANCELLED;
}
}
async asyncOnBlock(awaitable) {
_debugLog('[AsyncTask#asyncOnBlock()] args', { taskID: this.#id, awaitable });
if (!(awaitable instanceof Awaitable)) {
throw new Error('invalid awaitable during onBlock');
}
// TODO: watch for waitable AND cancellation
// TODO: if it WAS cancelled:
// - return true
// - only once per subtask
// - do not wait on the scheduler
// - control flow should go to the subtask (only once)
// - Once subtask blocks/resolves, reqlinquishControl() will tehn resolve request_cancel_end (without scheduler lock release)
// - control flow goes back to request_cancel
//
// Subtask cancellation should work similarly to an async import call -- runs sync up until
// the subtask blocks or resolves
//
throw new Error('AsyncTask#asyncOnBlock() not yet implemented');
}
async yieldUntil(opts) {
const { readyFn, cancellable } = opts;
_debugLog('[AsyncTask#yieldUntil()] args', { taskID: this.#id, cancellable });
const keepGoing = await this.suspendUntil({ readyFn, cancellable });
if (!keepGoing) {
return {
code: ASYNC_EVENT_CODE.TASK_CANCELLED,
index: 0,
result: 0,
};
}
return {
code: ASYNC_EVENT_CODE.NONE,
index: 0,
result: 0,
};
}
async suspendUntil(opts) {
const { cancellable, readyFn } = opts;
_debugLog('[AsyncTask#suspendUntil()] args', { cancellable });
const pendingCancelled = this.deliverPendingCancel({ cancellable });
if (pendingCancelled) { return false; }
const completed = await this.immediateSuspendUntil({ readyFn, cancellable });
return completed;
}
// TODO(threads): equivalent to thread.suspend_until()
async immediateSuspendUntil(opts) {
const { cancellable, readyFn } = opts;
_debugLog('[AsyncTask#immediateSuspendUntil()] args', { cancellable, readyFn });
const ready = readyFn();
if (ready && !ASYNC_DETERMINISM && _coinFlip()) {
return true;
}
const cstate = getOrCreateAsyncState(this.#componentIdx);
cstate.addPendingTask(this);
const keepGoing = await this.immediateSuspend({ cancellable, readyFn });
return keepGoing;
}
async immediateSuspend(opts) { // NOTE: equivalent to thread.suspend()
// TODO(threads): store readyFn on the thread
const { cancellable, readyFn } = opts;
_debugLog('[AsyncTask#immediateSuspend()] args', { cancellable, readyFn });
const pendingCancelled = this.deliverPendingCancel({ cancellable });
if (pendingCancelled) { return false; }
const cstate = getOrCreateAsyncState(this.#componentIdx);
// TODO(fix): update this to tick until there is no more action to take.
setTimeout(() => cstate.tick(), 0);
const taskWait = await cstate.suspendTask({ task: this, readyFn });
const keepGoing = await taskWait;
return keepGoing;
}
deliverPendingCancel(opts) {
const { cancellable } = opts;
_debugLog('[AsyncTask#deliverPendingCancel()] args', { cancellable });
if (cancellable && this.#state === AsyncTask.State.PENDING_CANCEL) {
this.#state = Task.State.CANCEL_DELIVERED;
return true;
}
return false;
}
isCancelled() { return this.cancelled }
cancel() {
_debugLog('[AsyncTask#cancel()] args', { });
if (!this.taskState() !== AsyncTask.State.CANCEL_DELIVERED) {
throw new Error(`(component [${this.#componentIdx}]) task [${this.#id}] invalid task state for cancellation`);
}
if (this.borrowedHandles.length > 0) { throw new Error('task still has borrow handles'); }
this.cancelled = true;
this.onResolve(new Error('cancelled'));
this.#state = AsyncTask.State.RESOLVED;
}
onResolve(taskValue) {
for (const f of this.#onResolveHandlers) {
try {
f(taskValue);
} catch (err) {
console.error("error during task resolve handler", err);
throw err;
}
}
if (this.#postReturnFn) {
_debugLog('[AsyncTask#onResolve()] running post return ', {
componentIdx: this.#componentIdx,
taskID: this.#id,
});
this.#postReturnFn();
}
}
registerOnResolveHandler(f) {
this.#onResolveHandlers.push(f);
}
resolve(results) {
_debugLog('[AsyncTask#resolve()] args', {
results,
componentIdx: this.#componentIdx,
taskID: this.#id,
});
if (this.#state === AsyncTask.State.RESOLVED) {
throw new Error(`(component [${this.#componentIdx}]) task [${this.#id}] is already resolved (did you forget to wait for an import?)`);
}
if (this.borrowedHandles.length > 0) { throw new Error('task still has borrow handles'); }
switch (results.length) {
case 0:
this.onResolve(undefined);
break;
case 1:
this.onResolve(results[0]);
break;
default:
throw new Error('unexpected number of results');
}
this.#state = AsyncTask.State.RESOLVED;
}
exit() {
_debugLog('[AsyncTask#exit()] args', { });
// TODO: ensure there is only one task at a time (scheduler.lock() functionality)
if (this.#state !== AsyncTask.State.RESOLVED) {
// TODO(fix): only fused, manually specified post returns seem to break this invariant,
// as the TaskReturn trampoline is not activated it seems.
//
// see: test/p3/ported/wasmtime/component-async/post-return.js
//
// We *should* be able to upgrade this to be more strict and throw at some point,
// which may involve rewriting the upstream test to surface task return manually somehow.
//
//throw new Error(`(component [${this.#componentIdx}]) task [${this.#id}] exited without resolution`);
_debugLog('[AsyncTask#exit()] task exited without resolution', {
componentIdx: this.#componentIdx,
taskID: this.#id,
subtask: this.getParentSubtask(),
subtaskID: this.getParentSubtask()?.id(),
});
this.#state = AsyncTask.State.RESOLVED;
}
if (this.borrowedHandles > 0) {
throw new Error('task [${this.#id}] exited without clearing borrowed handles');
}
const state = getOrCreateAsyncState(this.#componentIdx);
if (!state) { throw new Error('missing async state for component [' + this.#componentIdx + ']'); }
if (!this.#isAsync && !state.inSyncExportCall) {
throw new Error('sync task must be run from components known to be in a sync export call');
}
state.inSyncExportCall = false;
if (this.needsExclusiveLock() && !state.isExclusivelyLocked()) {
throw new Error('task [' + this.#id + '] exit: component [' + this.#componentIdx + '] should have been exclusively locked');
}
state.exclusiveRelease();
}
needsExclusiveLock() { return this.#needsExclusiveLock; }
createSubtask(args) {
_debugLog('[AsyncTask#createSubtask()] args', args);
const { componentIdx, childTask, callMetadata } = args;
const newSubtask = new AsyncSubtask({
componentIdx,
childTask,
parentTask: this,
callMetadata,
});
this.#subtasks.push(newSubtask);
return newSubtask;
}
getLatestSubtask() { return this.#subtasks.at(-1); }
currentSubtask() {
_debugLog('[AsyncTask#currentSubtask()]');
if (this.#subtasks.length === 0) { return undefined; }
return this.#subtasks.at(-1);
}
endCurrentSubtask() {
_debugLog('[AsyncTask#endCurrentSubtask()]');
if (this.#subtasks.length === 0) { throw new Error('cannot end current subtask: no current subtask'); }
const subtask = this.#subtasks.pop();
subtask.drop();
return subtask;
}
}
function _lowerImport(args, exportFn) {
const params = [...arguments].slice(2);
_debugLog('[_lowerImport()] args', { args, params, exportFn });
const {
functionIdx,
componentIdx,
isAsync,
paramLiftFns,
resultLowerFns,
metadata,
memoryIdx,
getMemoryFn,
getReallocFn,
} = args;
const parentTaskMeta = getCurrentTask(componentIdx);
const parentTask = parentTaskMeta?.task;
if (!parentTask) { throw new Error('missing parent task during lower of import'); }
const cstate = getOrCreateAsyncState(componentIdx);
const subtask = parentTask.createSubtask({
componentIdx,
parentTask,
callMetadata: {
memoryIdx,
memory: getMemoryFn(),
realloc: getReallocFn(),
resultPtr: params[0],
}
});
parentTask.setReturnMemoryIdx(memoryIdx);
const rep = cstate.subtasks.insert(subtask);
subtask.setRep(rep);
subtask.setOnProgressFn(() => {
subtask.setPendingEventFn(() => {
if (subtask.resolved()) { subtask.deliverResolve(); }
return {
code: ASYNC_EVENT_CODE.SUBTASK,
index: rep,
result: subtask.getStateNumber(),
}
});
});
// Set up a handler on subtask completion to lower results from the call into the caller's memory region.
subtask.registerOnResolveHandler((res) => {
_debugLog('[_lowerImport()] handling subtask result', { res, subtaskID: subtask.id() });
const { memory, resultPtr, realloc } = subtask.getCallMetadata();
if (resultLowerFns.length === 0) { return; }
resultLowerFns[0]({ componentIdx, memory, realloc, vals: [res], storagePtr: resultPtr });
});
const subtaskState = subtask.getStateNumber();
if (subtaskState < 0 || subtaskState > 2**5) {
throw new Error('invalid subtask state, out of valid range');
}
// NOTE: we must wait a bit before calling the export function,
// to ensure the subtask state is not modified before the lower call return
//
// TODO: we should trigger via subtask state changing, rather than a static wait?
setTimeout(async () => {
try {
_debugLog('[_lowerImport()] calling lowered import', { exportFn, params });
exportFn.apply(null, params);
const task = subtask.getChildTask();
task.registerOnResolveHandler((res) => {
_debugLog('[_lowerImport()] cascading subtask completion', {
childTaskID: task.id(),
subtaskID: subtask.id(),
parentTaskID: parentTask.id(),
});
subtask.onResolve(res);
cstate.tick();
});
} catch (err) {
console.error("post-lower import fn error:", err);
throw err;
}
}, 100);
return Number(subtask.waitableRep()) << 4 | subtaskState;
}
function _liftFlatU8(ctx) {
_debugLog('[_liftFlatU8()] args', { ctx });
let val;
if (ctx.useDirectParams) {
if (ctx.params.length === 0) { throw new Error('expected at least a single i32 argument'); }
val = ctx.params[0];
ctx.params = ctx.params.slice(1);
return [val, ctx];
}
if (ctx.storageLen !== undefined && ctx.storageLen < ctx.storagePtr + 1) {
throw new Error('not enough storage remaining for lift');
}
val = new DataView(ctx.memory.buffer).getUint8(ctx.storagePtr, true);
ctx.storagePtr += 1;
if (ctx.storageLen !== undefined) { ctx.storageLen -= 1; }
return [val, ctx];
}
function _liftFlatU16(ctx) {
_debugLog('[_liftFlatU16()] args', { ctx });
let val;
if (ctx.useDirectParams) {
if (params.length === 0) { throw new Error('expected at least a single i32 argument'); }
val = ctx.params[0];
ctx.params = ctx.params.slice(1);
return [val, ctx];
}
if (ctx.storageLen !== undefined && ctx.storageLen < ctx.storagePtr + 2) {
throw new Error('not enough storage remaining for lift');
}
val = new DataView(ctx.memory.buffer).getUint16(ctx.storagePtr, true);
ctx.storagePtr += 2;
if (ctx.storageLen !== undefined) { ctx.storageLen -= 2; }
return [val, ctx];
}
function _liftFlatU32(ctx) {
_debugLog('[_liftFlatU32()] args', { ctx });
let val;
if (ctx.useDirectParams) {
if (ctx.params.length === 0) { throw new Error('expected at least a single i34 argument'); }
val = ctx.params[0];
ctx.params = ctx.params.slice(1);
return [val, ctx];
}
if (ctx.storageLen !== undefined && ctx.storageLen < ctx.storagePtr + 4) {
throw new Error('not enough storage remaining for lift');
}
val = new DataView(ctx.memory.buffer).getUint32(ctx.storagePtr, true);
ctx.storagePtr += 4;
if (ctx.storageLen !== undefined) { ctx.storageLen -= 4; }
return [val, ctx];
}
function _liftFlatU64(ctx) {
_debugLog('[_liftFlatU64()] args', { ctx });
let val;
if (ctx.useDirectParams) {
if (ctx.params.length === 0) { throw new Error('expected at least one single i64 argument'); }
if (typeof ctx.params[0] !== 'bigint') { throw new Error('expected bigint'); }
val = ctx.params[0];
ctx.params = ctx.params.slice(1);
return [val, ctx];
}
if (ctx.storageLen !== undefined && ctx.storageLen < ctx.storagePtr + 8) {
throw new Error('not enough storage remaining for lift');
}
val = new DataView(ctx.memory.buffer).getUint64(ctx.storagePtr, true);
ctx.storagePtr += 8;
if (ctx.storageLen !== undefined) { ctx.storageLen -= 8; }
return [val, ctx];
}
function _liftFlatStringUTF8(ctx) {
_debugLog('[_liftFlatStringUTF8()] args', { ctx });
let val;
if (ctx.useDirectParams) {
if (ctx.params.length < 2) { throw new Error('expected at least two u32 arguments'); }
const offset = ctx.params[0];
if (!Number.isSafeInteger(offset)) { throw new Error('invalid offset'); }
const len = ctx.params[1];
if (!Number.isSafeInteger(len)) { throw new Error('invalid len'); }
val = TEXT_DECODER_UTF8.decode(new DataView(ctx.memory.buffer, offset, len));
ctx.params = ctx.params.slice(2);
return [val, ctx];
}
const start = new DataView(ctx.memory.buffer).getUint32(ctx.storagePtr, params[0],