Skip to content

Capture the exception at the catch site, not inside the library - #87

Merged
graphicsMan merged 1 commit into
mainfrom
fix/capture-exception-at-catch-site
Sep 2, 2026
Merged

graphicsMan merged 1 commit into
mainfrom
fix/capture-exception-at-catch-site

Conversation

@graphicsMan

@graphicsMan graphicsMan commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #90.

An exception thrown from a task scheduled with scheduleBulk does not reach the
caller on Windows clang shared-library builds. Instead of the thrown type, the
caller sees std::bad_exception out of wait().

task_set_shared_test reproduces it deterministically — 10 failures in 10 runs
of TaskSet.ScheduleBulkException. The static build passes, and MSVC passes.

Cause

TaskSetBase::trySetCurrentException() calls std::current_exception() in its
own body. That body is compiled into the dispenso library, while every caller's
catch (...) is header code compiled into the caller's module.

Under clang on Windows, std::current_exception() called from a DLL, for an
exception thrown in the executable, returns null. The null exception_ptr
is stored, and testAndResetException() later calls
std::rethrow_exception(nullptr) — undefined behaviour, which the MSVC runtime
converts into std::bad_exception. Nothing reports the original loss, so the
failure surfaces far from its cause.

Reduced to a minimal reproduction with no threads and no dispenso — throw in the
executable, call std::current_exception() inside a library module:

static, clang, release :  capture-in-lib  OK
shared, clang, release :  capture-in-lib  NULL exception_ptr   <-- the bug
shared, MSVC,  release :  capture-in-lib  OK

Why only the bulk path

Non-bulk tasks run on pool threads created inside the library, where the
library's std::current_exception() works. The bulk path can run tasks inline
on the caller's thread via invokeInline — the executable's thread — which is
exactly the configuration that returns null.

Fix

Capture where the exception is live: the four call sites pass
std::current_exception() in, and the library only stores it.
testAndResetException() additionally stops rethrowing a null pointer, so if
capture ever fails again the set is reported as cancelled rather than
manufacturing a bad_exception.

This changes the signature of TaskSetBase::trySetCurrentException, which is
DISPENSO_DLL_ACCESS. It is a detail member and the header ships with the
library, but it is an ABI change for anyone mixing versions.

The patch also moves #include <exception> in detail/pipeline_impl.h out of
that file's #if __cplusplus >= 201703L block: std::current_exception() is
used there under a plain __cpp_exceptions guard, so a C++14 build with
exceptions needs the header unconditionally. It currently resolves only through
a transitive include from task_set.h.

Results with the fix, on Windows:

Target Toolchain Before After
task_set_shared_test clang 52 / 53 53 / 53
task_set_test (static) clang 53 / 53 53 / 53
future_shared_test clang 73 / 73
task_set_shared_test MSVC 53 / 53 53 / 53

Linux, full suite: 1564 passed, 0 failed.

Note on cost

Capture now happens at every catch site rather than only for the winner of the
CAS, so concurrent throwers each construct an exception_ptr — an atomic
refcount increment under the Itanium ABI, an object copy under MSVC, on a path
that has just paid for a throw and an unwind. The non-throwing path is
untouched.

Suggested follow-up test

No existing test would have caught this outside a Windows shared build. A test
asserting that the thrown type survives — rather than merely that something
was thrown — from a scheduleBulk task would.

An exception thrown from a task scheduled with scheduleBulk is lost on
Windows clang shared-library builds, surfacing to the caller as
std::bad_exception instead of the thrown type.

TaskSetBase::trySetCurrentException() called std::current_exception() in
its own body, which is compiled into the dispenso library, while every
caller's catch (...) is header code compiled into the caller's module.
Under clang on Windows, std::current_exception() called from a DLL for
an exception thrown in the executable returns null. The null
exception_ptr was stored, and testAndResetException() then called
std::rethrow_exception(nullptr) -- undefined behaviour, which the MSVC
runtime converts into std::bad_exception.

Only scheduleBulk surfaced it because non-bulk tasks run on pool threads
created inside the library, where the library's std::current_exception()
works. The bulk path can run tasks inline on the caller's thread via
invokeInline, which is exactly the configuration that returns null.

The four call sites now pass std::current_exception() in and the library
only stores it. testAndResetException() also stops rethrowing a null
pointer, so a future capture failure reports a cancelled set rather than
a meaningless bad_exception.

Also moves #include <exception> in detail/pipeline_impl.h out of that
file's #if __cplusplus >= 201703L block: std::current_exception() is
used there under a plain __cpp_exceptions guard, so a C++14 build with
exceptions enabled needs the header unconditionally. It resolves today
only through a transitive include from task_set.h.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 2, 2026
@graphicsMan
graphicsMan merged commit 68b6dc3 into main Sep 2, 2026
24 checks passed
@graphicsMan
graphicsMan deleted the fix/capture-exception-at-catch-site branch September 2, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Exception thrown in a scheduleBulk task surfaces as std::bad_exception (Windows, clang, shared)

1 participant