Skip to content

Restore the park state when a parked thunk unwinds - #1844

Open
dg1sbg wants to merge 1 commit into
clasp-developers:mainfrom
dg1sbg:fix/park-unwind-safety
Open

Restore the park state when a parked thunk unwinds#1844
dg1sbg wants to merge 1 commit into
clasp-developers:mainfrom
dg1sbg:fix/park-unwind-safety

Conversation

@dg1sbg

@dg1sbg dg1sbg commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

gctools::call_parked saves the old park state, calls the thunk, then restores it:

int oldstate = begin_park(__builtin_frame_address(0));
decltype(auto) result = f();
end_park(oldstate);

A thunk that exits non-locally skips the restore. The thread then resumes running Lisp while still marked GC-safe and blocking — racing the collector, and reporting blockingp() forever after, which is what Process_O::interrupt uses to decide whether to pthread_kill(SIGCONT). call_unparked has the mirror-image flaw.

This is not an edge case. Cancelling a thread blocked in a syscall is precisely a non-local exit out of a parked region, and handle_SIGCONT is built to do exactly that — it calls handle_queued_interrupts() from inside the handler, and its own comment ("Nothing jumped out of there, so we're back to blocking") anticipates that something can jump out.

It is reachable today: a thread cancelled while in safe_open, clasp_musleep, or ConditionVariable::wait/timed_wait takes this path.

Both now restore through a destructor, so any exit — normal return, C++ exception, or a Lisp non-local exit — puts the thread back.

Caveat worth stating

This assumes unwinding runs destructors. That holds for ordinary Lisp non-local exits, but not for the SJLJ routing used on macOS arm64 (where _longjmp has no unwind tables). A longjmp past a park would still skip the restore. Fixing that properly needs the park state restored by the unwinder itself rather than by a C++ destructor; this change is a strict improvement over the status quo, not a complete guarantee.

How it was found

While making blocking syscalls cancellable (#1843). Once accept, read and select park, cancelling them exercises this path constantly rather than rarely. It is independent of that work, applies to main as-is, and is worth taking on its own.

No behaviour change on the normal path: the guard compiles to the same two calls in the same places.

CALL-PARKED and CALL-UNPARKED assigned the old state to a local, called the
thunk, and restored the state afterwards. A thunk that exits non-locally skipped
the restore, leaving the thread running Lisp while still marked GC-safe and
blocking: racing the collector, and lying to PROCESS-KILL about its state
forever.

Cancelling a thread blocked in a syscall is exactly a non-local exit out of a
parked region, so this is on the normal path rather than an edge case, and it
becomes commonplace once accept, read and select park.

Both now restore via a destructor, so any exit path -- normal return, C++
exception, or a Lisp non-local exit -- puts the thread back. Note the assumption:
unwinding must run destructors, which holds for clasp's ordinary exits but not
for the SJLJ paths used on macOS arm64 for save-lisp-and-die.

Pre-existing; found while making blocking calls cancellable.
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.

1 participant