Skip to content

🐛 propagate halt out of scoped() + race() when callcc resolves externally#1187

Closed
taras wants to merge 1 commit into
v4from
fix/1185-halt-through-scoped-race
Closed

🐛 propagate halt out of scoped() + race() when callcc resolves externally#1187
taras wants to merge 1 commit into
v4from
fix/1185-halt-through-scoped-race

Conversation

@taras

@taras taras commented Jun 10, 2026

Copy link
Copy Markdown
Member

Fixes #1185.

Problem

When callcc is resolved externally (scope.run(() => resolve(...))) inside a scoped() body that uses race(), the halt signal failed to stop the surrounding loop — all rounds ran to completion. This broke graceful SIGINT shutdown for the exact main() + scoped() round-boundary + race()-timeout pattern.

Root cause

scoped() runs on the same coroutine as its caller. On halt, routine.unwind() sets data.unwinding = true, and step() consumes it exactly once — it calls iterator.return() and resets the flag. But scoped's teardown (yield* destroy()) is async: it halts the race children across multiple scheduler turns. By the time teardown finished, unwinding had already been reset, so step() took the normal iterator.next() path and the outer loop simply continued to the next round instead of finishing its own unwind. (race/callcc matter only because they introduce the spawned children that make teardown span multiple turns.)

Fix

Localize the fix to scoped()/trap() and reuse the existing unwind-propagation machinery — no change to the coroutine step() state machine:

  • Route the scoped body through a Trap (capturing success/error in t.outcome).
  • Run teardown as yield* critical(destroy) so multi-step async cleanup completes via .next() rather than being short-circuited by the unwind branch.
  • After teardown, yield t.exit() — when no outcome exists (the body was interrupted), Trap.exit() calls routine.unwind() again, re-asserting the unwind so the outer loop finishes instead of continuing.

This preserves the single-use-return unwind contract (the test/coroutine.test.ts "uses 'return' for a single iteration when unwound" canary still passes) and exports Trap so scoped can reuse it.

Relationship to #1186

This implements the same approach as #1186 (Trap-routed scoped + critical(destroy) + t.exit()) and additionally adds the missing regression test pinning the callcc + scoped + race halt path — there was previously no test covering this exact combination, so the bug could silently return.

Test

test/scoped.test.ts adds "propagates halt out of a scoped() + race() loop when callcc resolves externally". It reproduces the issue's scenario: the loop must stop after round-1-teardown and never start round 2. Verified to fail on v4 HEAD (all 3 rounds run) and pass with this fix.

…ally

Fixes #1185. scoped() runs on the caller's coroutine, and its async
teardown (destroy() halting race children) spans multiple scheduler
turns. The single-use unwinding flag was already consumed by the time
teardown finished, so the outer loop resumed via iterator.next() and
ran to completion instead of halting — breaking SIGINT shutdown for the
main()/scoped()/race() pattern.

Route the scoped body through a Trap, run teardown as critical(destroy),
then re-assert the unwind via the trap's exit() once teardown completes.
This keeps the fix inside scoped()/trap() and preserves the
single-use-return unwind contract (no change to the coroutine step()
state machine). Adds a regression test reproducing the
callcc + scoped + race halt path.
@pkg-pr-new

pkg-pr-new Bot commented Jun 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/effection@1187

commit: 6b863b5

@codspeed-hq

codspeed-hq Bot commented Jun 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing fix/1185-halt-through-scoped-race (6b863b5) with v4 (846b97e)

Open in CodSpeed

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.

scoped() + race() prevents halt propagation in callcc resolution path

1 participant