From cfcc874f7468505f0250eb29a6ca0a1449b423b2 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 27 Oct 2026 19:51:34 -0700 Subject: [PATCH 1/2] Make the `sync` argument an explicit `__async` option --- site/source/docs/porting/asyncify.rst | 49 +++++++++++++++++++-------- src/jsifier.mjs | 37 ++++++++++++-------- src/lib/libsyscall.js | 4 +-- src/modules.mjs | 36 ++++++++++++++++++++ src/utility.mjs | 3 +- test/test_jslib.py | 36 +++++++++++++------- 6 files changed, 120 insertions(+), 45 deletions(-) diff --git a/site/source/docs/porting/asyncify.rst b/site/source/docs/porting/asyncify.rst index 06c71d2819b32..a4a86e496cd0a 100644 --- a/site/source/docs/porting/asyncify.rst +++ b/site/source/docs/porting/asyncify.rst @@ -200,30 +200,51 @@ You can use ``__async: 1`` if you just want to include the function in :ref:`ASYNCIFY_IMPORTS` or ``__async: 'auto'`` if you also want the function to wrapper in ``Asyncify.handleAsync``. -``__async: 'auto'`` allows the same function to work synchronously without -``ASYNCIFY``. A function whose last parameter is named ``sync`` is told there -whether it must return synchronously. This is ``false`` under -``ASYNCIFY``/JSPI, and when called from a pthread with ``__proxy: 'sync'`` (the -main thread awaits the result on its behalf); it is ``true`` otherwise, where a -returned ``Promise`` could not be waited for: +An ``'auto'`` function may return a Promise only when something can wait for +it. There are two such situations: the function suspends under ``ASYNCIFY`` +or JSPI, or it was called from a pthread with ``__proxy: 'sync'``, in which +case the pthread is blocked and the main thread awaits the Promise for it. On +a plain main thread call nothing can wait, and the function has to return a +value right away. + +To let one body handle both, give ``__async`` a list of options and include +``'sync'``. The body then takes a trailing ``sync`` parameter: ``true`` means +"return a value now", ``false`` means "you may return a Promise": .. code-block:: js addToLibrary({ lookup__sig: 'pp', - lookup__async: 'auto', + lookup__async: ['auto', 'sync'], lookup__proxy: 'sync', lookup: (name, sync) => { - if (sync) return -EAGAIN; - return fetchSomething(name); // returns a Promise + if (sync) return -EAGAIN; // main thread, nothing can wait + return fetchSomething(name); // returns a Promise }, }); -In the above example, the same ``lookup`` supports the async promise-returning -path for ``ASYNCIFY`` and a pthread, while returning the ``EAGAIN`` error when -called synchronously. As ``sync`` is always ``false`` under ``ASYNCIFY``/JSPI, -the function must then be called from a stack that can suspend, as with any -other async import (under JSPI, within one of the :ref:`JSPI_EXPORTS`). +With ``['auto', 'sync']`` the function suspends under ``ASYNCIFY``/JSPI like +any ``'auto'`` function (so it must be called from a stack that can suspend; +under JSPI, within one of the :ref:`JSPI_EXPORTS`), and also waits when called +from a pthread. ``sync`` is ``true`` only on a main thread call without +``ASYNCIFY``/JSPI. + +Some functions must never suspend, because they are called from everywhere: +``write()`` on a pipe is behind every ``printf``, and a suspending import there +would instrument or trap every caller. Such a function can still block when +called from a pthread. Listing ``['sync']`` without ``'auto'`` gives exactly +that: the function stays a plain import in every build, and ``sync`` is +``false`` only when a pthread is waiting on it. It requires ``__proxy: +'sync'``. + +====================== ============================ ======================= +``__async`` ``sync`` on the main thread ``sync`` from a pthread +====================== ============================ ======================= +``['auto', 'sync']`` ``false`` under ``false`` + ``ASYNCIFY``/JSPI, else + ``true`` +``['sync']`` ``true`` ``false`` +====================== ============================ ======================= Ways to use Asyncify APIs in older engines diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 188b6b234e4c3..269fb07943e4c 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -291,6 +291,13 @@ function sigToArgs(sig) { return args.join(','); } +// Whether the body returns a value or a Promise as it can: an 'auto' function, +// or one told by a `sync` argument. +function isAutoBody(symbol) { + const lib = LibraryManager.library; + return lib[symbol + '__async'] == 'auto' || !!lib[symbol + '__sync_arg']; +} + function handleI64Signatures(symbol, snippet, sig, i53abi, isAsyncFunction) { // Handle i64 parameters and return values. // @@ -354,7 +361,7 @@ function handleI64Signatures(symbol, snippet, sig, i53abi, isAsyncFunction) { // An 'auto' body may return a Promise even without ASYNCIFY (on a // pthread's behalf, which awaits it), which must not be cast but // resolve to the cast value. - const maybePromise = !isAsyncFunction && LibraryManager.library[symbol + '__async'] == 'auto'; + const maybePromise = !isAsyncFunction && isAutoBody(symbol); const convert = (v) => maybePromise ? `(${v} instanceof Promise ? ${v}.then((v) => ${makeReturn64('v')}) : ${makeReturn64(v)})` : makeReturn64(await_ + v); @@ -395,19 +402,18 @@ ${body}; }); } -// An `__async: 'auto'` body returns a value or a Promise, as it can. A body -// whose last parameter is named `sync` receives there whether it must complete -// synchronously on this call, as a returned Promise could not be waited for. -// That is never the case under ASYNCIFY/JSPI, nor on behalf of a sync-proxied -// pthread caller, which awaits it; otherwise it is. -function handleSyncArg(symbol, snippet, sig, proxyingMode) { - const sync = ASYNCIFY ? 'false' : +// A body listing 'sync' in its `__async` options receives, in its trailing +// `sync` parameter, whether it must complete synchronously on this call, as a +// returned Promise would have nowhere to go: it never must where the function +// itself suspends under ASYNCIFY/JSPI, nor on behalf of a sync-proxied pthread +// caller, which awaits it; otherwise it must. +function handleSyncArg(symbol, snippet, proxyingMode, canWait) { + const sync = canWait ? 'false' : PTHREADS && proxyingMode == 'sync' ? '!PThread.currentProxiedOperationCallerThread' : 'true'; return modifyJSFunction(snippet, (args, body, async_, oneliner) => { const params = args.split(',').map((a) => a.trim()).filter((a) => a); - if (params.at(-1) != 'sync') return snippet; - if (sig && params.length != sig.length) { - error(`JS library error: '${symbol}' must take 'sync' as parameter ${sig.length}, after the ${sig.length - 1} of its '__sig'`); + if (params.at(-1) != 'sync') { + error(`JS library error: '${symbol}' lists 'sync' in '__async' but its last parameter is not named 'sync'`); return snippet; } if (!oneliner) body = `{\n${body}\n}`; @@ -560,8 +566,8 @@ function(${args}) { const proxyingMode = LibraryManager.library[symbol + '__proxy']; - if (LibraryManager.library[symbol + '__async'] == 'auto') { - snippet = handleSyncArg(symbol, snippet, sig, proxyingMode); + if (LibraryManager.library[symbol + '__sync_arg']) { + snippet = handleSyncArg(symbol, snippet, proxyingMode, isAsyncFunction); } if (ASYNCIFY && isAsyncFunction == 'auto') { @@ -580,8 +586,9 @@ function(${args}) { } let proxyMode = PROXY_ASYNC; if (proxyingMode === 'sync') { - const isAsyncFunction = LibraryManager.library[symbol + '__async']; - if (isAsyncFunction) { + // A body told by `sync` may complete through a Promise on the + // pthread's behalf even where the function never suspends. + if (LibraryManager.library[symbol + '__async'] || isAutoBody(symbol)) { proxyMode = PROXY_SYNC_ASYNC; } else { proxyMode = PROXY_SYNC; diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index 6e74648624f4f..555c1664a97a2 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -621,7 +621,7 @@ var SyscallsLibrary = { return flags & (events | {{{ cDefs.POLLERR }}} | {{{ cDefs.POLLHUP }}} | {{{ cDefs.POLLNVAL }}}); }, __syscall_poll__proxy: 'sync', - __syscall_poll__async: 'auto', + __syscall_poll__async: ['auto', 'sync'], __syscall_poll__deps: ['$doPollSync', #if PTHREADS || ASYNCIFY '$doPollAsync', @@ -735,7 +735,7 @@ var SyscallsLibrary = { return epollCtl(ep.shared, op, fd, ev); }, __syscall_epoll_pwait__proxy: 'sync', - __syscall_epoll_pwait__async: 'auto', + __syscall_epoll_pwait__async: ['auto', 'sync'], __syscall_epoll_pwait__deps: ['$FS', '$epollPwait'], __syscall_epoll_pwait: (epfd, ev, maxevents, timeout, sigmask, sigsetsize, sync) => { var ep = FS.getStream(epfd); diff --git a/src/modules.mjs b/src/modules.mjs index 3691e964b69c2..4f169c7826369 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -286,11 +286,47 @@ export const LibraryManager = { } timer.stop('executeJS') + this.expandAsyncOptions(); this.addAliasDependencies(); timer.stop('load') }, + /** + * `__async: ['auto', 'sync']` lists async options. 'auto' is as + * `__async: 'auto'`; without it the function never suspends. 'sync' has the + * body told, in a trailing `sync` parameter, whether it must complete + * synchronously (see handleSyncArg in jsifier). Alone, 'sync' leaves the + * sync-proxied pthread path as the only asynchronous one. + */ + expandAsyncOptions() { + for (const [key, options] of Object.entries(this.library)) { + if (!key.endsWith('__async') || typeof options != 'object') continue; + const base = key.slice(0, -'__async'.length); + if (!Array.isArray(options) || !options.length) { + error(`JS library error: '${key}' must be true, 'auto', or a non-empty array of options`); + continue; + } + for (const option of options) { + if (!['auto', 'sync'].includes(option)) { + error(`JS library error: unknown async option '${option}' in '${key}'`); + } + } + if (options.includes('auto')) { + this.library[key] = 'auto'; + } else { + delete this.library[key]; + if (this.library[base + '__proxy'] != 'sync') { + error(`JS library error: '${key}' lists only 'sync', which has an asynchronous path only with '__proxy' set to 'sync'`); + continue; + } + } + if (options.includes('sync')) { + this.library[base + '__sync_arg'] = true; + } + } + }, + isAlias(entry) { return (typeof entry === 'string' && entry[0] != '=' && (this.library.hasOwnProperty(entry) || WASM_EXPORTS.has(entry))); }, diff --git a/src/utility.mjs b/src/utility.mjs index c163f82189f79..6fa924fba6072 100644 --- a/src/utility.mjs +++ b/src/utility.mjs @@ -182,7 +182,7 @@ export function mergeInto(obj, other, options = null) { __noleakcheck: 'boolean', __internal: 'boolean', __user: 'boolean', - __async: ['string', 'boolean'], + __async: ['string', 'boolean', 'object'], __i53abi: 'boolean', __export: 'boolean', __force: 'boolean', @@ -223,6 +223,7 @@ export const decoratorSuffixes = [ '__i53abi', '__export', '__force', + '__sync_arg', ]; export function isDecorator(ident) { diff --git a/test/test_jslib.py b/test/test_jslib.py index 2985daffdcb2c..4401fe0ccb2e8 100644 --- a/test/test_jslib.py +++ b/test/test_jslib.py @@ -132,32 +132,36 @@ def test_jslib_proxying(self): 'asyncify': (['-sASYNCIFY', '-DASYNC'],), 'jspi': (['-sJSPI', '-DASYNC'],), 'pthread': (['-pthread', '-sPROXY_TO_PTHREAD'],), + 'proxy_only': (['-DPROXY_ONLY'],), + 'proxy_only_asyncify': (['-sASYNCIFY', '-DASYNC', '-DPROXY_ONLY'],), + 'proxy_only_pthread': (['-pthread', '-sPROXY_TO_PTHREAD', '-DPROXY_ONLY'],), }) def test_jslib_async_auto_sync(self, args): # `answer(ms, value)` resolves to `value` after `ms` milliseconds # (synchronously if `ms` is 0), or is -1 where the caller cannot wait. + # With PROXY_ONLY (`['sync']`) only a proxied pthread call can wait. if '-sJSPI' in args: self.require_jspi() if '-sASYNCIFY' in args and self.get_setting('WASM_ESM_INTEGRATION'): self.skipTest('WASM_ESM_INTEGRATION is not compatible with ASYNCIFY') + options = "['sync']" if '-DPROXY_ONLY' in args else "['auto', 'sync']" create_file('lib.js', r''' addToLibrary({ answer__sig: 'pip', - answer__async: 'auto', + answer__async: OPTIONS, answer__proxy: 'sync', answer: (ms, value, sync) => { if (!ms) return value; if (sync) return -1; return new Promise((resolve) => setTimeout(() => resolve(value), ms)); }, - // No __sig: `sync` is found by name alone. - wait_ms__async: 'auto', + wait_ms__async: OPTIONS, wait_ms__proxy: 'sync', wait_ms: (ms, sync) => { if (ms && !sync) return new Promise((resolve) => setTimeout(resolve, ms)); }, }); -''') +'''.replace('OPTIONS', options)) create_file('src.c', r''' #include #include @@ -168,7 +172,7 @@ def test_jslib_async_auto_sync(self, args): int main() { assert(answer(0, 7) == 7); -#if defined(__EMSCRIPTEN_PTHREADS__) || defined(ASYNC) +#if defined(__EMSCRIPTEN_PTHREADS__) || (defined(ASYNC) && !defined(PROXY_ONLY)) assert(answer(5, 42) == 42); #else assert(answer(5, 42) == -1); @@ -181,16 +185,22 @@ def test_jslib_async_auto_sync(self, args): ''') self.do_runf('src.c', 'done\n', cflags=args + ['-sEXIT_RUNTIME', '--js-library', 'lib.js']) - def test_jslib_async_auto_sync_arity(self): - create_file('lib.js', ''' -addToLibrary({ - jslibfunc__sig: 'iii', - jslibfunc__async: 'auto', - jslibfunc: (x, sync) => x, -}); + @parameterized({ + 'empty': ('[]', '(x, sync) => x', "'jslibfunc__async' must be true, 'auto', or a non-empty array of options"), + 'unknown': ("['foo']", '(x, sync) => x', "unknown async option 'foo' in 'jslibfunc__async'"), + 'param': ("['auto', 'sync']", '(x) => x', "'jslibfunc' lists 'sync' in '__async' but its last parameter is not named 'sync'"), + 'proxy': ("['sync']", '(x, sync) => x', "'jslibfunc__async' lists only 'sync', which has an asynchronous path only with '__proxy' set to 'sync'"), + }) + def test_jslib_async_options_errors(self, options, body, expected): + create_file('lib.js', f''' +addToLibrary({{ + jslibfunc__sig: 'ii', + jslibfunc__async: {options}, + jslibfunc: {body}, +}}); ''') self.assert_fail([EMCC, test_file('hello_world.c'), '-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=jslibfunc', '--js-library', 'lib.js'], - "error: JS library error: 'jslibfunc' must take 'sync' as parameter 3, after the 2 of its '__sig'") + 'error: JS library error: ' + expected) def test_jslib_method_syntax(self): create_file('lib.js', r''' From 351f8f30b93ee8c7f2529bfa0cdae52979e0a9bf Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 27 Oct 2026 19:58:04 -0700 Subject: [PATCH 2/2] Skip a key with an unknown async option --- src/modules.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules.mjs b/src/modules.mjs index 4f169c7826369..add7dbc804f91 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -307,10 +307,10 @@ export const LibraryManager = { error(`JS library error: '${key}' must be true, 'auto', or a non-empty array of options`); continue; } - for (const option of options) { - if (!['auto', 'sync'].includes(option)) { - error(`JS library error: unknown async option '${option}' in '${key}'`); - } + const unknown = options.find((option) => !['auto', 'sync'].includes(option)); + if (unknown) { + error(`JS library error: unknown async option '${unknown}' in '${key}'`); + continue; } if (options.includes('auto')) { this.library[key] = 'auto';