UNPKG

@bytecodealliance/jco

Version:

JavaScript tooling for working with WebAssembly Components

1,584 lines (1,308 loc) 263 kB
"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],