Skip to content

Deliver cross-thread task cancellation on the owning scheduler - #466

Open
infiton wants to merge 1 commit into
socketry:mainfrom
infiton:fix/cross-thread-task-cancellation
Open

Deliver cross-thread task cancellation on the owning scheduler#466
infiton wants to merge 1 commit into
socketry:mainfrom
infiton:fix/cross-thread-task-cancellation

Conversation

@infiton

@infiton infiton commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

Task#cancel currently raises through Fiber.scheduler, which is the caller's scheduler rather than necessarily the scheduler that owns the task's fiber.

When cancellation originates from another reactor thread, Fiber#raise fails with FiberError. The fallback then queues Cancel::Later on the caller's scheduler, where it repeatedly retries the same impossible cross-thread raise.

As a result:

  • The target task is never cancelled.
  • task.cancel; task.wait does not complete.
  • The caller's reactor busy-spins, repeatedly allocating exceptions and Cancel::Later operations.

In the reproduction, this caused roughly 150,000 failed raises over three seconds.

Cross-thread cancellation must also remain on the owning thread during task finalization. Task#finish! clears @fiber before removing the task from its parent's child list. If handoff depends on @fiber&.alive?, a foreign canceller can observe the cleared fiber and run cancel!/finish! concurrently with the owner, causing a double removal and task-tree corruption.

Fix

While a task remains attached to another scheduler, enqueue Cancel::Later on that scheduler through its thread-safe unblock path, regardless of the current value of @fiber.

The owning reactor is woken and performs the complete existing cancellation operation on the correct thread. This serializes promise, child-tree, and finalization mutations with the task owner, preserves defer_cancel behavior and cancellation causes, and leaves same-reactor cancellation unchanged.

Testing

Added regression coverage for both cases:

  • A separate reactor thread cancels and waits for a task, verifying both reactors terminate, the target is cancelled, its ensure block executes, and the original cause is preserved.
  • A deterministic finalization interleaving pauses the owner after @fiber is cleared, cancels from another thread, and verifies there is no double finalization and the completed result is preserved.

Additional local verification on Ruby 4.0.6:

  • 540 tests, 1,198 assertions.
  • 147 RuboCop files, no offenses.
  • 500 finalization-race runs and 200 original reproducer runs without a crash or failure.

@infiton

infiton commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

cc @tavianator @samuel-williams-shopify curious if ya'll think this is the right approach; talking with @tavianator in slack and he suggested that cross-thread cancellation should just not be supported

Assisted-By: devx/018c26c0-16c2-4676-bd97-d1799cdb0afc
@ioquatix

ioquatix commented Aug 5, 2026

Copy link
Copy Markdown
Member

@infiton do you mind explaining the use case/scenario where you want to do this?

@infiton
infiton force-pushed the fix/cross-thread-task-cancellation branch from 60cac5e to 718f9fe Compare August 5, 2026 22:35
@infiton

infiton commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@ioquatix I actually don't want to do this on purpose; this was discovered as I was hunting a bug where in certain situations our app would go crazy and show these huge stacks of async cancellations. The issue was that we had a worker thread that was working off a queue of tasks but through a certain path the main thread was pushing tasks created on its scheduler into the work threads queue. I think this is probably an anti pattern and something best avoided so if you think it better to just fail fast and loudly that makes sense too (I don't see a super easy mechanism for that)

@ioquatix

Copy link
Copy Markdown
Member

Sorry for the brief and delayed response.

Basically, I also agree with you, but I also don't want to burden the implementation with a lot of overhead. I tried to introduce things like the debug scheduler (in io-event) and async-safe that can be layered on top during testing (or production) to avoid making the happy path slow.

If you have some idea how to achieve this or want to have a go at improving things, I'm happy to provide more feedback.

As a counter point, I also agree that this kind of pain point is bothersome and we should do something about it - perhaps more proactively.

@infiton

infiton commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@ioquatix all good; would you like me to close this or would you like me to explore another option that doesn't take any perf hits?

@samuel-williams-shopify

samuel-williams-shopify commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

I would like you to investigate this a bit further, figure out if the performance concern is warranted, and then decide if it makes sense, if so, submit a new PR. Also please consider the semantic model - i.e. if we widen the semantics, it's usually impossible to narrow them in the future without breaking stuff.

@infiton

infiton commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

@ioquatix yup happy to do that; of the two semantics which do you prefer? I think that allowing cross thread cancellation is fine and better than the current state (the footgun that causes a busy spin); but if you prefer early, loud failure I could get down with that

I defer to your vision for this library

@ioquatix

Copy link
Copy Markdown
Member

I think the default is we should prevent it.

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