pivot 0.3.7__tar.gz → 0.3.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {pivot-0.3.7 → pivot-0.3.8}/PKG-INFO +3 -1
  2. {pivot-0.3.7 → pivot-0.3.8}/pyproject.toml +3 -1
  3. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/worker_pool.py +58 -11
  4. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/AGENTS.md +4 -2
  5. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/core.py +5 -2
  6. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/worker.py +179 -3
  7. {pivot-0.3.7 → pivot-0.3.8}/LICENSE +0 -0
  8. {pivot-0.3.7 → pivot-0.3.8}/README.md +0 -0
  9. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/__init__.py +0 -0
  10. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/ast_utils.py +0 -0
  11. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/AGENTS.md +0 -0
  12. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/CLAUDE.md +0 -0
  13. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/__init__.py +0 -0
  14. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/_run_common.py +0 -0
  15. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/check_ignore.py +0 -0
  16. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/checkout.py +0 -0
  17. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/commit.py +0 -0
  18. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/completion.py +0 -0
  19. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/config.py +0 -0
  20. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/console.py +0 -0
  21. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/dag.py +0 -0
  22. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/data.py +0 -0
  23. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/decorators.py +0 -0
  24. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/doctor.py +0 -0
  25. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/errors.py +0 -0
  26. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/fingerprint.py +0 -0
  27. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/helpers.py +0 -0
  28. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/history.py +0 -0
  29. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/import_cmd.py +0 -0
  30. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/init.py +0 -0
  31. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/list.py +0 -0
  32. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/metrics.py +0 -0
  33. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/params.py +0 -0
  34. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/plots.py +0 -0
  35. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/remote.py +0 -0
  36. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/repro.py +0 -0
  37. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/run.py +0 -0
  38. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/status.py +0 -0
  39. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/targets.py +0 -0
  40. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/track.py +0 -0
  41. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/update.py +0 -0
  42. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/verify.py +0 -0
  43. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/compose.py +0 -0
  44. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/config/__init__.py +0 -0
  45. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/config/io.py +0 -0
  46. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/config/models.py +0 -0
  47. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/dag/__init__.py +0 -0
  48. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/dag/render.py +0 -0
  49. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/decorators.py +0 -0
  50. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/discovery.py +0 -0
  51. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/__init__.py +0 -0
  52. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/agent_rpc.py +0 -0
  53. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/engine.py +0 -0
  54. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/graph.py +0 -0
  55. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/scheduler.py +0 -0
  56. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/sinks.py +0 -0
  57. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/sources.py +0 -0
  58. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/types.py +0 -0
  59. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/watch.py +0 -0
  60. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/exceptions.py +0 -0
  61. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/CLAUDE.md +0 -0
  62. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/__init__.py +0 -0
  63. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/commit.py +0 -0
  64. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/explain.py +0 -0
  65. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/fingerprint.py +0 -0
  66. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/git.py +0 -0
  67. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/ignore.py +0 -0
  68. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/import_artifact.py +0 -0
  69. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/loaders.py +0 -0
  70. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/metrics.py +0 -0
  71. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/names.py +0 -0
  72. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/outputs.py +0 -0
  73. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/parameters.py +0 -0
  74. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/path_policy.py +0 -0
  75. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/project.py +0 -0
  76. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/py.typed +0 -0
  77. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/registry.py +0 -0
  78. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/AGENTS.md +0 -0
  79. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/__init__.py +0 -0
  80. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/config.py +0 -0
  81. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/git_archive.py +0 -0
  82. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/github.py +0 -0
  83. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/storage.py +0 -0
  84. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/sync.py +0 -0
  85. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/run_history.py +0 -0
  86. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/__init__.py +0 -0
  87. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/common.py +0 -0
  88. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/data.py +0 -0
  89. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/metrics.py +0 -0
  90. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/params.py +0 -0
  91. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/plots.py +0 -0
  92. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/skip.py +0 -0
  93. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/stage_def.py +0 -0
  94. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/status.py +0 -0
  95. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/AGENTS.md +0 -0
  96. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/CLAUDE.md +0 -0
  97. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/__init__.py +0 -0
  98. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/artifact_lock.py +0 -0
  99. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/cache.py +0 -0
  100. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/lock.py +0 -0
  101. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/presentation.py +0 -0
  102. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/restore.py +0 -0
  103. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/state.py +0 -0
  104. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/store.py +0 -0
  105. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/track.py +0 -0
  106. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/types.py +0 -0
  107. {pivot-0.3.7 → pivot-0.3.8}/src/pivot/yaml_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pivot
3
- Version: 0.3.7
3
+ Version: 0.3.8
4
4
  Summary: High-performance Python pipeline tool with automatic code change detection
5
5
  Author: Sami Jawhar
6
6
  Author-email: Sami Jawhar <sami@metr.org>
@@ -23,6 +23,7 @@ Requires-Dist: loky>=3.4
23
23
  Requires-Dist: networkx>=3.0
24
24
  Requires-Dist: pathspec>=0.12.0
25
25
  Requires-Dist: pandas>=2.0
26
+ Requires-Dist: psutil>=7.0
26
27
  Requires-Dist: pydantic>=2.13
27
28
  Requires-Dist: pygtrie>=2.5.0
28
29
  Requires-Dist: pyyaml>=6.0
@@ -55,6 +56,7 @@ Requires-Dist: pytest-xdist>=3.8.0 ; extra == 'dev'
55
56
  Requires-Dist: pytest>=7.0 ; extra == 'dev'
56
57
  Requires-Dist: ruff>=0.8.0 ; extra == 'dev'
57
58
  Requires-Dist: types-aioboto3[all]>=13.0.0 ; extra == 'dev'
59
+ Requires-Dist: types-psutil>=7.0 ; extra == 'dev'
58
60
  Requires-Dist: mkdocs>=1.6.0 ; extra == 'docs'
59
61
  Requires-Dist: mkdocs-material>=9.5.0 ; extra == 'docs'
60
62
  Requires-Dist: mkdocstrings[python]>=0.26.0 ; extra == 'docs'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pivot"
3
- version = "0.3.7"
3
+ version = "0.3.8"
4
4
  description = "High-performance Python pipeline tool with automatic code change detection"
5
5
  requires-python = ">=3.13,<3.14"
6
6
  readme = "README.md"
@@ -27,6 +27,7 @@ dependencies = [
27
27
  "networkx>=3.0",
28
28
  "pathspec>=0.12.0",
29
29
  "pandas>=2.0",
30
+ "psutil>=7.0",
30
31
  "pydantic>=2.13",
31
32
  "pygtrie>=2.5.0",
32
33
  "pyyaml>=6.0",
@@ -76,6 +77,7 @@ dev = [
76
77
  "pytest>=7.0",
77
78
  "ruff>=0.8.0",
78
79
  "types-aioboto3[all]>=13.0.0",
80
+ "types-psutil>=7.0",
79
81
  ]
80
82
 
81
83
  [project.scripts]
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import errno
4
5
  import logging
5
6
  import os
6
7
  import pathlib
8
+ import signal
7
9
  import threading
10
+ import time
8
11
  import typing
9
12
  from dataclasses import dataclass, field
10
13
 
@@ -17,6 +20,9 @@ _T = typing.TypeVar("_T")
17
20
 
18
21
  _logger = logging.getLogger(__name__)
19
22
 
23
+ # How long hard_cancel waits for a SIGKILLed worker to be reaped; it never blocks pivot's exit.
24
+ _KILLED_WORKER_JOIN_S = 5.0
25
+
20
26
  _FileStamp = tuple[int, int, int, int, int]
21
27
 
22
28
 
@@ -194,16 +200,57 @@ class WorkerPool:
194
200
  self._shutdown_event = None
195
201
 
196
202
  def hard_cancel(self) -> None:
197
- """Forcefully shut down workers and signal drain thread to stop."""
203
+ """Stop workers mid-stage and signal the drain thread to stop.
204
+
205
+ Each worker is sent SIGTERM, which unwinds its running stage
206
+ (``worker.unwind_when_stopped``) so the stage's cleanup runs, and is given
207
+ ``worker.TERMINATION_GRACE_S`` to exit; a worker still alive then is killed with its
208
+ descendants.
209
+
210
+ The workers are first taken out of loky's executor. Its manager thread takes a worker
211
+ that exits without being asked to for a broken pool and SIGKILLs every worker the
212
+ executor still lists, so the first worker to exit would cut the others' cleanup off.
213
+ """
198
214
  self._accepting = False
199
215
  self._in_run = False
200
- if self._executor is not None:
201
- self._executor.shutdown(wait=False, kill_workers=True)
202
- self._executor = None
203
- if self._manager is not None:
204
- self._manager.shutdown()
205
- self._manager = None
206
- if self._shutdown_event is not None:
207
- self._shutdown_event.set()
208
- self._output_queue = None
209
- self._shutdown_event = None
216
+ try:
217
+ if self._executor is not None:
218
+ executor = self._executor
219
+ self._executor = None
220
+ try:
221
+ self._stop_workers(executor)
222
+ finally:
223
+ # Only shutdown wakes loky's manager thread once its workers are gone.
224
+ executor.shutdown(wait=False, kill_workers=True)
225
+ finally:
226
+ if self._manager is not None:
227
+ self._manager.shutdown()
228
+ self._manager = None
229
+ if self._shutdown_event is not None:
230
+ self._shutdown_event.set()
231
+ self._output_queue = None
232
+ self._shutdown_event = None
233
+
234
+ @staticmethod
235
+ def _stop_workers(executor: loky.ProcessPoolExecutor) -> None:
236
+ """Take ``executor``'s workers out of it, send each SIGTERM, and SIGKILL with its
237
+ descendants any worker still alive after ``worker.TERMINATION_GRACE_S``."""
238
+ # loky exposes no public list of its worker processes. A watch reload may have
239
+ # replaced this executor already, which leaves it no lock and no workers.
240
+ with executor._processes_management_lock or contextlib.nullcontext():
241
+ processes = list(executor._processes.values())
242
+ executor._processes.clear()
243
+ for process in processes:
244
+ if process.pid is not None:
245
+ with contextlib.suppress(ProcessLookupError):
246
+ os.kill(process.pid, signal.SIGTERM)
247
+ deadline = time.monotonic() + worker.TERMINATION_GRACE_S
248
+ for process in processes:
249
+ process.join(max(0.0, deadline - time.monotonic()))
250
+ for process in processes:
251
+ if process.pid is not None and process.exitcode is None:
252
+ worker.kill_descendants(process.pid)
253
+ # loky's process object has no kill(); signal the pid directly.
254
+ with contextlib.suppress(ProcessLookupError):
255
+ os.kill(process.pid, signal.SIGKILL)
256
+ process.join(_KILLED_WORKER_JOIN_S)
@@ -3,8 +3,10 @@
3
3
  Workers execute in separate processes via `loky.get_reusable_executor()`. An Engine keeps its
4
4
  workers and manager alive across its runs; each run receives a fresh manager queue for output,
5
5
  and a manager that cannot be reached when a run starts is replaced then.
6
- The loky initializer and the manager's initializer start the parent-death watcher, so a
7
- worker or manager exits when its Pivot process dies. Keep `timeout=None` and
6
+ The loky initializer (`worker.unwind_when_stopped`) and the manager's initializer
7
+ (`core._exit_when_parent_dies`) start the parent-death watcher, so a worker or manager exits
8
+ when its Pivot process dies; a worker first unwinds its running stage so its `finally` blocks
9
+ run, within `worker.TERMINATION_GRACE_S`. Keep `timeout=None` and
8
10
  `reuse=False if kill_workers else "auto"` in the shared executor policy.
9
11
 
10
12
  ## WorkerStageInfo Contract
@@ -37,7 +37,10 @@ def _noop() -> None:
37
37
 
38
38
 
39
39
  def _exit_when_parent_dies(parent_pid: int) -> None:
40
- """Exit a long-lived child (worker or manager) after its owning Pivot process dies."""
40
+ """Exit the output-queue manager after its owning Pivot process dies.
41
+
42
+ Workers use ``worker.unwind_when_stopped`` instead, which lets the running stage unwind.
43
+ """
41
44
 
42
45
  def _watch_parent() -> None:
43
46
  poll = threading.Event()
@@ -82,7 +85,7 @@ def _get_executor(max_workers: int, *, kill_workers: bool = False) -> loky.Proce
82
85
  timeout=_WORKER_IDLE_TIMEOUT_S,
83
86
  kill_workers=kill_workers,
84
87
  reuse=False if kill_workers else "auto",
85
- initializer=_exit_when_parent_dies,
88
+ initializer=worker.unwind_when_stopped,
86
89
  initargs=(os.getpid(),),
87
90
  )
88
91
 
@@ -10,17 +10,31 @@ import codecs
10
10
  import collections
11
11
  import collections.abc as collections_abc
12
12
  import contextlib
13
+ import dataclasses
13
14
  import logging
14
15
  import os
15
16
  import pathlib
16
17
  import queue
17
18
  import random
19
+ import signal
18
20
  import sys
19
21
  import threading
20
22
  import traceback
21
23
  import warnings
22
- from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, cast, overload, override
24
+ from typing import (
25
+ IO,
26
+ TYPE_CHECKING,
27
+ Any,
28
+ Literal,
29
+ NamedTuple,
30
+ NoReturn,
31
+ TypedDict,
32
+ cast,
33
+ overload,
34
+ override,
35
+ )
23
36
 
37
+ import psutil
24
38
  import pydantic
25
39
 
26
40
  from pivot import (
@@ -58,8 +72,142 @@ if TYPE_CHECKING:
58
72
  from multiprocessing import Queue
59
73
  from types import TracebackType
60
74
 
75
+
61
76
  logger = logging.getLogger(__name__)
62
77
 
78
+ # How long a stopped worker's running stage may take to unwind before the worker exits.
79
+ TERMINATION_GRACE_S = 10.0
80
+
81
+
82
+ class WorkerTerminated(KeyboardInterrupt): # noqa: N818 - names the event, as KeyboardInterrupt does
83
+ """Raised in a worker's running stage when its run is cancelled or its Pivot process dies.
84
+
85
+ It is a KeyboardInterrupt, as Ctrl-C raises at a terminal: a stage's ``except Exception``
86
+ does not stop it, and its ``except KeyboardInterrupt`` cleanup, ``finally`` blocks and
87
+ context managers run. A stage that catches it and carries on has its result discarded.
88
+ """
89
+
90
+
91
+ @dataclasses.dataclass(slots=True)
92
+ class _StopState:
93
+ """Process-wide stop state of a worker, set by its signal handlers and its watchdog.
94
+
95
+ Signals and the parent watchdog act on the whole process, so this state is too.
96
+ """
97
+
98
+ stage_running: bool = False
99
+ # The running stage's run was cancelled: its result must not be committed.
100
+ stop_requested: bool = False
101
+ # WorkerTerminated was raised into the running stage; never raise it again mid-cleanup.
102
+ interrupted: bool = False
103
+ # The worker exits once the running stage has unwound (SIGTERM or parent death).
104
+ exit_requested: bool = False
105
+ exit_timer_started: bool = False
106
+ lock: threading.Lock = dataclasses.field(default_factory=threading.Lock)
107
+
108
+
109
+ _stop = _StopState()
110
+
111
+
112
+ def kill_descendants(pid: int) -> None:
113
+ """SIGKILL every live descendant of process ``pid``. A process that detached from that
114
+ tree (double-forked, or orphaned when its parent exited) is not one."""
115
+ try:
116
+ descendants = psutil.Process(pid).children(recursive=True)
117
+ except psutil.Error:
118
+ return
119
+ for descendant in descendants:
120
+ with contextlib.suppress(psutil.Error):
121
+ descendant.kill()
122
+
123
+
124
+ def _exit_worker(code: int) -> NoReturn:
125
+ """Exit this worker, first killing the processes still descended from it, which would
126
+ otherwise be left to init (a stage's ``Popen`` with no cleanup, or one whose cleanup was
127
+ cut off)."""
128
+ kill_descendants(os.getpid())
129
+ os._exit(code)
130
+
131
+
132
+ def _exit_after_grace() -> None:
133
+ threading.Event().wait(TERMINATION_GRACE_S)
134
+ _exit_worker(1)
135
+
136
+
137
+ def _start_exit_timer() -> None:
138
+ """Exit this worker ``TERMINATION_GRACE_S`` from now, whatever its running stage does."""
139
+ with _stop.lock:
140
+ if _stop.exit_timer_started:
141
+ return
142
+ _stop.exit_timer_started = True
143
+ threading.Thread(target=_exit_after_grace, daemon=True, name="pivot-stop-grace").start()
144
+
145
+
146
+ def _interrupt_stage(_signum: int, _frame: object) -> None:
147
+ """SIGINT handler of a worker: Ctrl-C at a terminal, which reaches Pivot and its workers at
148
+ once. Interrupt the running stage once, so its cleanup runs, and keep the worker up until
149
+ Pivot stops it: loky takes a worker that exits on its own for a broken pool and SIGKILLs
150
+ every other worker, cutting their cleanup off."""
151
+ if not _stop.stage_running or _stop.interrupted:
152
+ return
153
+ _stop.stop_requested = True
154
+ _stop.interrupted = True
155
+ raise WorkerTerminated
156
+
157
+
158
+ def _stop_worker(_signum: int, _frame: object) -> None:
159
+ """SIGTERM handler of a worker: interrupt the running stage once, exit once it has unwound,
160
+ and exit regardless after ``TERMINATION_GRACE_S``. An idle worker exits at once."""
161
+ _stop.stop_requested = True
162
+ _stop.exit_requested = True
163
+ _start_exit_timer()
164
+ if not _stop.stage_running:
165
+ _exit_worker(0)
166
+ if _stop.interrupted:
167
+ return
168
+ _stop.interrupted = True
169
+ raise WorkerTerminated
170
+
171
+
172
+ def _raise_if_stopped() -> None:
173
+ """Refuse to commit the result of a stage whose run was cancelled, which can only be here
174
+ because the stage caught ``WorkerTerminated`` and carried on."""
175
+ if _stop.stop_requested:
176
+ raise WorkerTerminated
177
+
178
+
179
+ def unwind_when_stopped(parent_pid: int) -> None:
180
+ """Loky worker initializer: stop this worker when it is told to or its parent dies.
181
+
182
+ SIGTERM (``WorkerPool.hard_cancel``) or SIGINT (Ctrl-C at a terminal) raises
183
+ ``WorkerTerminated`` in the running stage, so its cleanup runs (stopping the processes or
184
+ containers it started); after SIGTERM the worker then exits. When the Pivot process that
185
+ started the worker dies, as when it is SIGKILLed, a watchdog thread sends this worker's
186
+ main thread SIGTERM (a signal to that thread interrupts a blocking call there) and exits
187
+ the worker after ``TERMINATION_GRACE_S`` itself, in case the stage replaced the SIGTERM
188
+ handler or is blocked where no signal reaches it. Every exit first kills the processes
189
+ still descended from the worker.
190
+
191
+ A SIGINT the worker inherited as ignored (pivot run as a background job of a
192
+ non-interactive shell) stays ignored, as it does for pivot itself.
193
+ """
194
+ signal.signal(signal.SIGTERM, _stop_worker)
195
+ if signal.getsignal(signal.SIGINT) is not signal.SIG_IGN:
196
+ signal.signal(signal.SIGINT, _interrupt_stage)
197
+ main_thread = threading.main_thread().ident
198
+ assert main_thread is not None
199
+
200
+ def _watch_parent() -> None:
201
+ poll = threading.Event()
202
+ while os.getppid() == parent_pid:
203
+ poll.wait(1)
204
+ _stop.exit_requested = True # first: a stage starting now keeps stop_requested set
205
+ _stop.stop_requested = True
206
+ _start_exit_timer()
207
+ signal.pthread_kill(main_thread, signal.SIGTERM)
208
+
209
+ threading.Thread(target=_watch_parent, daemon=True, name="pivot-parent-watch").start()
210
+
63
211
 
64
212
  class _QueueLoggingHandler(logging.Handler):
65
213
  """Logging handler that sends records to the output queue.
@@ -375,6 +523,7 @@ def _execute_stage(
375
523
  ),
376
524
  output_paths=lock_output_paths(stage_outs, store, project_root),
377
525
  )
526
+ _raise_if_stopped()
378
527
  deferred = _commit_lock_and_build_deferred(
379
528
  stage_info,
380
529
  new_lock_data,
@@ -447,6 +596,7 @@ def _execute_stage(
447
596
 
448
597
  # Single StateDB open for post-execution work
449
598
  with state.StateDB(state_dir, readonly=True) as state_db:
599
+ _raise_if_stopped()
450
600
  deferred = _commit_lock_and_build_deferred(
451
601
  stage_info,
452
602
  new_lock_data,
@@ -509,14 +659,39 @@ def user_code_files() -> list[str]:
509
659
  return sorted(files)
510
660
 
511
661
 
662
+ def _begin_stage() -> None:
663
+ """Mark a stage running, clearing a Ctrl-C that interrupted the previous one. The running
664
+ flag is set last, so a stop signal before it is handled as one to an idle worker."""
665
+ _stop.interrupted = False
666
+ if not _stop.exit_requested:
667
+ _stop.stop_requested = False
668
+ _stop.stage_running = True
669
+
670
+
512
671
  def execute_stage(
513
672
  stage_name: str,
514
673
  stage_info: WorkerStageInfo,
515
674
  cache_dir: pathlib.Path,
516
675
  output_queue: Queue[OutputMessage],
517
676
  ) -> StageResult:
518
- """Execute a stage and report every user-code module resident in this worker."""
519
- result = _execute_stage(stage_name, stage_info, cache_dir, output_queue)
677
+ """Execute a stage and report every user-code module resident in this worker.
678
+
679
+ A stage interrupted by a stop signal (``unwind_when_stopped``) fails without committing
680
+ anything. After SIGTERM or the death of its Pivot process, the worker exits once the stage
681
+ has unwound instead of returning.
682
+ """
683
+ try:
684
+ _begin_stage()
685
+ result = _execute_stage(stage_name, stage_info, cache_dir, output_queue)
686
+ except WorkerTerminated:
687
+ # Raised outside the stage's own error handling, as while its logging is set up.
688
+ result = StageResult(
689
+ status=StageStatus.FAILED, reason="KeyboardInterrupt", input_hash=None, output_lines=[]
690
+ )
691
+ finally:
692
+ _stop.stage_running = False
693
+ if _stop.exit_requested:
694
+ _exit_worker(1)
520
695
  result["user_code_files"] = user_code_files()
521
696
  return result
522
697
 
@@ -1116,6 +1291,7 @@ def _run_stage_function_with_store(
1116
1291
  _set_deterministic_seeds()
1117
1292
 
1118
1293
  result = _execute_with_joblib_protection(func, kwargs)
1294
+ _raise_if_stopped()
1119
1295
 
1120
1296
  output_values = _resolve_output_values(result, outs, stage_name)
1121
1297
  output_paths = dict[types.ArtifactRef, pathlib.Path]()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes