Skip to content

Never start a queued task that is cancelled or stopped; two queues for the process backend - #508

Merged
woutdenolf merged 7 commits into
mainfrom
process_start_gate
Oct 24, 2026
Merged

woutdenolf merged 7 commits into
mainfrom
process_start_gate

Conversation

@woutdenolf

@woutdenolf woutdenolf commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Closes #514

Stack (split from #501, merge in order):

  1. Qt test fixes: drop test_owwidget_drop timing dependency, fix Windows crash at exit #506 Qt test fixes: drop test_owwidget_drop timing dependency, fix Windows crash at exit
  2. Rename TaskFuture.abort/aborted to request_stop/stop_requested #507 Rename TaskFuture.abort/aborted to request_stop/stop_requested
  3. Never start a queued task that is cancelled or stopped; two queues for the process backend #508 Never start a queued task that is cancelled or stopped; two queues for the process backend 👈 this PR
  4. EwoksExecutor: completion signals after start signals, finished for every execution #509 EwoksExecutor: completion signals after start signals, finished for every execution
  5. Execute subprocess tasks with pebble, add TaskFuture.abort and TaskFuture.kill #501 Execute subprocess tasks with pebble, add TaskFuture.abort and TaskFuture.kill
  6. Doc: executor contract, backend diagrams and glossary #510 Doc: executor contract, backend diagrams and glossary

PR summary

  • Start gate: the first of cancel() / request_stop() that reaches a queued task prevents it from starting.
    • cancel(): result() raises CancelledError.
    • request_stop(): result() raises the new TaskStoppedError.
    • Such a task never emits aboutToStart or started.
    • Later calls on the same queued task return False. On a finished task they do nothing.
  • Process backend: the manager Events and the single events queue are replaced by two queues.
    • Runner → controller: "about to start", "started", progress.
    • Controller → runner: the start decision, then stop requests.
    • The runner asks the controller before creating the task, so a cancel or stop that races with the worker picking up the task is honoured.
  • _progress.py → _messages.py. The endpoints are named controller and runner.
  • Still concurrent.futures.ProcessPoolExecutor (pebble comes later).

AI Disclosure

  • AI tools claude used for generate the code

🤖 Generated with Claude Code

… talk to the process runner through one queue per direction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

@woutdenolf
woutdenolf changed the base branch from request_stop_rename to main October 22, 2026 08:41

@payno payno left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good but this was an hard one.
I didn't went deeply into the tests.

Comment on lines +17 to 20
def try_start(self) -> Optional[StartDenial]:
"""Mark the task as started unless it was denied. Returns the denial."""
return self._gate.open()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems a bit weird because it uses _gate that is defined in parent class TaskController and there is no other operation using the gate or try_start in the children class.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

  • In-process controller (sync, thread): the runner is in the same process, so it calls controller.try_start() directly.
  • Process controller: the runner is in another process, so it asks over the queue, and the controller calls self._gate.open() in _allow_start.

The process controller opens the gate too, in _allow_start, when the runner asks over the queue. Only in-process runners can call it directly, so try_start lives here.

Comment on lines +32 to +33
future.cancel()
return True

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would expects that we return the result of the future.cancel no ?

Suggested change
future.cancel()
return True
return future.cancel()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No: if a worker already picked up the task, future.cancel() returns False, but the gate already denied the start, so the task is cancelled anyway. future.cancel() only completes a still-queued future early.



class StartGate:
"""Decides once whether a task may start."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"""Decides once whether a task may start."""
"""Decides once whether a task may start.
The gate is shared by two sides, each calling one method:
- the runner, which executes the task, calls `open` right before creating
the task to ask whether it may start it.
- the caller, which controls the task, calls `deny` to prevent the task from
starting.
The first of the two to take the lock makes the decision.
"""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added, shortened. In the process backend the controller calls open for the runner, so I wrote "the side executing the task" instead of "the runner".

return self._denial

def deny(self, denial: StartDenial) -> bool:
"""Prevent the task from starting.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"""Prevent the task from starting.
"""Prevent the task from starting. Called by the side controlling the task.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

return True

def open(self) -> Optional[StartDenial]:
"""Let the task start unless it was denied. Returns the denial."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"""Let the task start unless it was denied. Returns the denial."""
"""Let the task start unless it was denied. Called by the side executing
the task, right before creating it.
Returns the denial, or `None` when the task may start.
"""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

raise NotImplementedError
if self._gate.deny(StartDenial.STOPPED):
return True
if not self._gate.started:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Was not that direct to me. Nevertheless the comment might be over-killed. I let you judge.

Suggested change
if not self._gate.started:
# case task already started or already denied
if not self._gate.started:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added, placed on the return False: past deny(), not started means denied before.

raise NotImplementedError

def stop_requested(self) -> bool:
"""Return True if the stop request prevented the start or reached the task."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"""Return True if the stop request prevented the start or reached the task."""
"""Return True if the stop request prevented the start or reached the task."""
# Stopped before starting: the gate holds the denial, nothing was sent (set self._gate.denial).
# Stopped while running: the stop request was sent to the task (set self._stop_sent).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added, without the parentheses.

cancelled). Manager queues are FIFO, so draining up to the sentinel
delivers all of them.
"""
if self._relay_thread is None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not related to the PR but re-reading the classes this was not obvious

Suggested change
if self._relay_thread is None:
# Guard in case never watched. Should never happen.
if self._relay_thread is None:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

name = task_class.__name__
if denial is StartDenial.STOPPED:
return TaskStoppedError(f"Task {name} was stopped before it started")
raise ValueError(f"Start denial {denial} is not handled")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

raise is done from raise _denial_error(self._task_class, denial). So from this and the docstring I would expect more something like:

Suggested change
raise ValueError(f"Start denial {denial} is not handled")
return ValueError(f"Start denial {denial} is not handled")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

Comment on lines +55 to +64
def _read_to_runner_queue():
try:
while True:
message = self._to_runner_queue.get()
if message is ToRunner.REQUEST_STOP:
stop_event.set()
elif message is ToRunner.END:
return
except self._TRANSIENT_ERRORS:
pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nested function + a try + a while starts to get difficult to read. I would remove at least one.

Suggested change
def _read_to_runner_queue():
try:
while True:
message = self._to_runner_queue.get()
if message is ToRunner.REQUEST_STOP:
stop_event.set()
elif message is ToRunner.END:
return
except self._TRANSIENT_ERRORS:
pass
def _relay_stop_requests() -> None:
"""Set `stop_event` on stop requests until `ToRunner.END`."""
while True:
message = self._to_runner_queue.get()
if message is ToRunner.REQUEST_STOP:
stop_event.set()
elif message is ToRunner.END:
return
def _read_to_runner_queue() -> None:
"""Set `stop_event` on stop requests until `ToRunner.END` or the manager shuts down."""
try:
_relay_stop_requests()
except self._TRANSIENT_ERRORS:
pass

Maybe the function could also be moved to private at the class scope. No strong opinion there.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Moved to a private method instead of splitting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
woutdenolf and others added 5 commits October 24, 2026 10:03
…turns False

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er never watched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sing it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@woutdenolf
woutdenolf enabled auto-merge October 24, 2026 08:17
@woutdenolf
woutdenolf merged commit 67b2926 into main Oct 24, 2026
15 checks passed
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.

Race condition with InterruptStopTask

2 participants