Skip to content

Make sync proxied waits safe cancellation points - #27613

Open
guybedford wants to merge 5 commits into
emscripten-core:mainfrom
guybedford:proxy-cancel
Open

Make sync proxied waits safe cancellation points#27613
guybedford wants to merge 5 commits into
emscripten-core:mainfrom
guybedford:proxy-cancel

Conversation

@guybedford

@guybedford guybedford commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

This fixes a use-after-free when a thread blocked in emscripten_proxy_sync[_with_ctx] is canceled, and with it makes those waits proper POSIX cancellation points.

The wait already woke on pthread_cancel (the cond wait's futex honors it), but the canceled thread then unwound with the em_proxying_ctx and its caller-owned argument still on its stack while the target thread kept referencing both; for PROXY_SYNC_ASYNC JS imports a later promise resolution wrote the result into the freed stack. Any blocking proxied wait could hit this, e.g. poll()/epoll_wait() with an infinite timeout.

  • The sync ctx is heap allocated and refcounted between caller and target; whichever drops the last reference frees it.
  • A pthread_cleanup_push handler on the caller waits until the target has released the argument - immediately after a JS import has been dispatched, or on completion for generic callers - then marks the ctx orphaned and drops its reference. The thread exits with PTHREAD_CANCELED; a late emscripten_proxy_finish on the target is harmless.
  • The PROXY_SYNC JS path now goes through the same guarded result write as PROXY_SYNC_ASYNC.
  • Readiness listeners left by a canceled wait are one-shot and self-delete when they fire, so no JS changes were needed there.
  • maybeExit passed an unset EXITSTATUS to _emscripten_thread_exit for a pthread that unwound to the event loop rather than returning a result. Harmless on wasm32, but BigInt(undefined) throws under MEMORY64, which the new test hits since its target thread parks in the event loop.

Tests: test_pthread_proxying_canceled_caller (cancel while the target still runs the work, and while it holds an unfinished ctx) and test_epoll_cancel (threads canceled in epoll_wait(-1) and poll(-1), then the orphaned listeners fired by a real datagram). Both fail on main. test_codesize_minimal_pthreads grows by 94 bytes (cleanup push/pop and pthread_cond_timedwait now linked).

Made with AI assistance under my review

@sbc100

sbc100 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

If you rebase it should fix the browser test failures.

Comment thread ChangeLog.md Outdated
Comment thread test/test_sockets_node.py Outdated
Comment thread system/include/emscripten/proxying.h Outdated
Comment thread test/pthread/test_pthread_proxying_canceled_caller.c Outdated
Comment thread test/pthread/test_pthread_proxying_canceled_caller.c Outdated
guybedford and others added 4 commits August 28, 2026 17:55
A thread blocked in emscripten_proxy_sync[_with_ctx] already woke on
pthread_cancel (the cond wait's futex honors it), but unwound with the
em_proxying_ctx and the caller-owned argument still on its stack while the
target thread kept referencing both, and for PROXY_SYNC_ASYNC JS imports a
later promise resolution wrote the result into the freed stack. Any blocking
proxied wait could hit this, e.g. poll()/epoll_wait() with an infinite
timeout.

The sync ctx is now heap allocated and refcounted between caller and target.
A cancellation cleanup handler on the caller waits until the target has
released the argument (immediately after a JS import has been dispatched;
on completion for generic callers), marks the ctx orphaned and drops its
reference, so a late completion on the target is harmless. The PROXY_SYNC JS
path routes through the same guarded result write.
Comment thread system/lib/pthread/proxying.c Outdated
Comment thread system/lib/pthread/proxying.c Outdated
pthread_mutex_lock(&ctx->sync.mutex);
ctx->sync.arg_released = true;
pthread_cond_signal(&ctx->sync.cond);
pthread_mutex_unlock(&ctx->sync.mutex);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love that we're accessing the ctx here. Ideally run_js_func_with_ctx would be no more privileged than any other function passed to emscripten_proxy_sync_with_ctx and would treat the ctx as opaque.

I think we need to expose public emscripten_proxy_release_arg(ctx) and emscripten_proxy_is_canceled(ctx) (or similar) functions that this function or any other async or long-running user-defined work functions can call.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this one's tough - I don't really like the design either. But I think the suggestion might be racy (I just tried to implement it and an alternative as well).

Will put some more thought to if there's a better way here, and follow-up next week.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants