Skip to content

Make sync proxied waits safe cancellation points - #27613

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

Make sync proxied waits safe cancellation points#27613
guybedford wants to merge 8 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
guybedford force-pushed the proxy-cancel branch 2 times, most recently from bf2dfbc to 31a5fd9 Compare August 29, 2026 01:00
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.

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.

Yeah, I guess emscripten_proxy_is_canceled would be prone to TOCTOU races. We need something that can atomically check if the caller is canceled and if not, prevent it from being canceled until released.

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.

Ok I've pushed up an experimental exploration of this with a emscripten_proxy_release_arg(ctx) and emscripten_proxy_acquire_arg(ctx) and atomic per-call context state.

@sbc100

sbc100 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Can we use __pthread_setcancelstate to suspend cancelation while the caller is waiting on proxied work?

@guybedford what kind of workloads are you running that depend on thread cancellation? i.e. is this a fix for a real issue you are hitting?

guybedford and others added 5 commits August 31, 2026 16:31
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.
Sync proxying ctxs are now allocated from a per-thread free list and
coordinate through a single atomic lifecycle word that the caller
futex-waits on directly, removing the per-call heap allocation and
mutex/cond operations. emscripten_proxy_release_arg and
emscripten_proxy_acquire_arg are exposed as public API implemented as
transitions on that word, and a caller canceled before its task starts
now exits immediately with the task dropped.
Collapse the five-phase lifecycle into three ownership flags (LOANED,
DONE+OK, ORPHANED): task start and emscripten_proxy_acquire_arg become
the same loan-taking transition, unifying the dropped-task and orphaned
paths. Also removes the pthread cond/mutex machinery from minimal
pthread builds entirely, coming out ~1KB smaller than main.
@guybedford

Copy link
Copy Markdown
Collaborator Author

Can we use __pthread_setcancelstate to suspend cancelation while the caller is waiting on proxied work?

Unfortunately not, if there is genuinely stalled work, the wait could be delayed indefinitely. This allows the worker to be fully released even in that case.

@guybedford what kind of workloads are you running that depend on thread cancellation? i.e. is this a fix for a real issue you are hitting?

This is not actually a problem I have personally, I just posted this as a quick AI-generated fix when I became aware of the gap, which then turned out to be harder than I expected! I think the fix now works in its current state using atomics to properly check the state, whether it's the absolute best approach is another question.

@sbc100

sbc100 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Unfortunately not, if there is genuinely stalled work, the wait could be delayed indefinitely. This allows the worker to be fully released even in that case.

Is this really an issue though. Do you think you will get folks complaining "I can't cancel a thread that is blocked forever waiting on another thread"?

Maybe its reasonable to wait for the current operation to complete and then cancel?

Pthread cancellation is very rare/dangerous/obscure thing, and I'm not sure we need to jump through too many hoops to make these edge cases work perfectly. So I'd be tempted to wait until you have a real user saying "Hey why can't I cancel this pthread" and in the mean time just delay cancelation. Maybe we can use the existing PTHREAD_CANCEL_MASKED thing that pthread_cond_timedwait uses?

@guybedford

Copy link
Copy Markdown
Collaborator Author

Is this really an issue though. Do you think you will get folks complaining "I can't cancel a thread that is blocked forever waiting on another thread"?

Yeah I would suggest leaving this PR open / stalled, until the matter comes up with stronger motivation.

Mask cancellation around the wait as pthread_cond_timedwait does, so a
cancel surfaces as ECANCELED and the ctx handover runs as straight-line
code before exiting, rather than unwinding out of the wait through a
cleanup handler.
@guybedford

Copy link
Copy Markdown
Collaborator Author

Maybe we can use the existing PTHREAD_CANCEL_MASKED thing that pthread_cond_timedwait uses?

I was able to refactor the cancellation delivery to use this mechanism. Note that the atomic ctx state and arg handover is still required though.

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