Skip to content

Fix stack overflow in auto_batcher::new_id() under contention - #1492

Merged
ihsandemir merged 1 commit into
hazelcast:masterfrom
ihsandemir:fix/auto-batcher-flat-retry
Sep 16, 2026
Merged

ihsandemir merged 1 commit into
hazelcast:masterfrom
ihsandemir:fix/auto-batcher-flat-retry

Conversation

@ihsandemir

Copy link
Copy Markdown
Contributor

Fixes #1491

Problem

The macOS nightly aborts in AutoBatcherTest.concurrencySmokeTest with an AddressSanitizer stack-overflow on an executor thread (for example https://github.com/hazelcast/hazelcast-cpp-client/actions/runs/34794206547). The trace repeats the same four Boost frames up to the truncation limit:

future_unwrap_shared_state<...>::launch_continuation()      future.hpp:5432
shared_state<long long>::mark_finished_with_result_internal  future.hpp:611
shared_state_base::mark_finished_internal                    future.hpp:344
shared_state<long long>::do_continuation                     future.hpp:590

auto_batcher::new_id() retried a caller that lost the race for an ID from the fresh batch by returning new_id() from a launch::sync continuation and calling .unwrap(). Each lost round wrapped the caller's future in one more future_unwrap_shared_state. Boost completes that chain recursively on the thread that finishes the last fetch.

Waiters lose repeatedly because the fetch lambda publishes the new block before Boost marks the fetch future ready. A caller that gets an ID loops straight back into the lock-free fast path and drains the rest of the batch while the executor thread is still running the coalesced continuations one by one. One waiter can lose hundreds of rounds in a row. macOS gives secondary threads a 512 KiB stack, and ASan frames are large, so the cascade overflows.

Fix

Each caller's result now lives in one boost::promise<int64_t>:

  • new_id() keeps the allocation-free fast path. Otherwise it creates a promise, returns its future and calls the new private try_get_id(p).
  • try_get_id(p) tries the fast path, then joins or elects the single in-flight fetch (election code unchanged) and registers a void continuation on it.
  • The continuation sets the value when it wins, sets the supplier's exception on failure, and posts try_get_id(p) back to the executor when it loses. Posting instead of calling inline also handles the case where the next fetch has already completed and a sync continuation would run on the current thread.

Nothing waits on the continuation's own future, so no chain forms and stack depth stays constant however many rounds a waiter loses. Single-flight fetching, coalescing and error propagation work as before, and callers see the same new_id() contract.

Test

AutoBatcherTest.starvedWaiterDoesNotOverflowTheStack reproduces the starvation on every run: 128 waiters, a supplier that takes 1 ms, batch size 3. All threads coalesce onto the in-flight fetch. When it completes, the first three continuations win and their threads re-register on the next fetch ahead of the continuations still queued, so the last waiter loses every round until the others use up their quota.

Verified on macOS arm64, Debug + ASan, the configuration of the failing job:

Check Before After
New regression test, 3 runs crashed 3/3 (EXC_BAD_ACCESS in do_continuation) passed 3/3
concurrencySmokeTest + new test, 12 concurrent processes 3/12 crashed 24/24 passed
FlakeIdGeneratorApiTest.*, AutoBatcherTest.*, FlakeIdBatchTest.* against a remote controller 11/11 passed

A caller that lost the race for an ID from a freshly fetched batch was
retried by returning new_id() from a launch::sync continuation and
unwrapping it. Every lost round therefore wrapped the caller's future in
one more future_unwrap_shared_state, and Boost completes such a chain
recursively on the thread that finishes the last fetch. Under contention
a single waiter can lose hundreds of consecutive rounds, and the
completion cascade overflowed the executor thread's stack (512 KiB on
macOS), aborting the macOS nightly in
AutoBatcherTest.concurrencySmokeTest.

The retry is now flat: new_id() hands out one promise per caller and
try_get_id() either completes it or registers a single void continuation
on the in-flight fetch. A continuation that loses the race posts the
retry back to the executor instead of nesting another future, so every
retry starts from an empty stack regardless of whether the next fetch is
already complete. Single-flight fetching, coalescing and error
propagation are unchanged.

Adds AutoBatcherTest.starvedWaiterDoesNotOverflowTheStack, which
reproduces the starvation deterministically (128 waiters, 1 ms fetch)
and crashed on every run before the fix.

Fixes hazelcast#1491
@ihsandemir
ihsandemir enabled auto-merge (squash) September 16, 2026 10:26
@ihsandemir ihsandemir added this to the 5.7.1 milestone Sep 16, 2026
// More concurrent waiters than the batch could serve: retry
// If a caller that cannot get an ID from the fresh batch
// transparently retries (async analogue of Java's for(;;)).
return new_id();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

took me a minute to get my head around it, but this is the problematic bit.

@ihsandemir ihsandemir self-assigned this Sep 16, 2026
@ihsandemir
ihsandemir merged commit def4f94 into hazelcast:master Sep 16, 2026
60 of 61 checks passed
@ihsandemir ihsandemir modified the milestones: 5.7.1, 6.0.0 Sep 18, 2026
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.

auto_batcher::new_id() stack overflow: unbounded nesting of unwrap() continuations under contention (macOS nightly)

2 participants