Deliver cross-thread task cancellation on the owning scheduler - #466
Deliver cross-thread task cancellation on the owning scheduler#466infiton wants to merge 1 commit into
Conversation
|
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
|
@infiton do you mind explaining the use case/scenario where you want to do this? |
60cac5e to
718f9fe
Compare
|
@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) |
|
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. |
|
@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? |
|
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. |
|
@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 |
|
I think the default is we should prevent it. |
Problem
Task#cancelcurrently raises throughFiber.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#raisefails withFiberError. The fallback then queuesCancel::Lateron the caller's scheduler, where it repeatedly retries the same impossible cross-thread raise.As a result:
task.cancel; task.waitdoes not complete.Cancel::Lateroperations.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@fiberbefore removing the task from its parent's child list. If handoff depends on@fiber&.alive?, a foreign canceller can observe the cleared fiber and runcancel!/finish!concurrently with the owner, causing a double removal and task-tree corruption.Fix
While a task remains attached to another scheduler, enqueue
Cancel::Lateron that scheduler through its thread-safeunblockpath, 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_cancelbehavior and cancellation causes, and leaves same-reactor cancellation unchanged.Testing
Added regression coverage for both cases:
ensureblock executes, and the original cause is preserved.@fiberis 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: