Skip to content

Commit bde4524

Browse files
Merge pull request #15 from JaneliaSciComp/watchdog-exit-0.9.1
Stop the watchdog at exit (0.9.1)
2 parents af9801d + 5c8997e commit bde4524

7 files changed

Lines changed: 79 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 0.9.1 (2026-10-26)
4+
5+
A fix for exit. No placement change, no unit table change, and no default changes.
6+
7+
- The watchdog thread stops at exit, before the interpreter finalizes. On Python up to
8+
3.13 a process could abort once all its work was done ("terminate called without an
9+
active exception", exit 134) when finalization began while the thread was inside a
10+
tensorstore call; an LSF job that did this looked failed. Now an `atexit` handler,
11+
which runs once the program's other threads are joined, ends the thread and waits
12+
for it, and no new one starts after that.
13+
314
## 0.9.0 (2026-10-23)
415

516
A job script can size each worker's threads. No placement change, no unit table change, and

‎docs/spec.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,15 @@ this document and that one differ, fix one of them.
692692
copy and a local read of a key that does not exist; no store is touched, about 0.3 ms
693693
of CPU). A stranded task then runs within about a second whoever waits on it: the
694694
package, `vol.ts` directly, or `await`, at any concurrency. The retry stays the outer
695-
net. Open question 14 says when the watchdog goes.
695+
net. The thread stops at exit (0.9.1): an `atexit` handler, which Python runs once the
696+
program's other threads are joined and before it finalizes, ends it and waits for it
697+
(at most the longest one kick takes), and no new one starts after that. Python up to
698+
3.13 ends a daemon thread that takes the GIL back during finalization with
699+
`pthread_exit`, and inside a tensorstore call that aborts the process with "terminate
700+
called without an active exception" and exit 134 after all the work is done (once in
701+
six of mesh-n-bone's Linux CI runs on Python 3.12, 2026-10-26; every time when a
702+
process exits while the thread kicks flat out). Open question 14 says when the
703+
watchdog goes.
696704

697705
7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
698706
tensorstore's cache is off and the levels of one pyramid share one context. Its
@@ -1089,7 +1097,8 @@ through a pickle, and a create whose first attempt lands after its timeout adopt
10891097
array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other variable,
10901098
blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
10911099
ignored with a thread count, and carried through a pickle; a handle pickled on the driver
1092-
sized in a spawned worker by that worker's `WORLDARRAY_THREADS`.
1100+
sized in a spawned worker by that worker's `WORLDARRAY_THREADS`. 0.9.1: a process whose
1101+
finalization takes a while, exiting while the watchdog kicks flat out, exits cleanly.
10931102
Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
10941103
`tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
10951104
worker exits idle, so a plain context strands some for good (13 of about 7,000 on

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "worldarray"
3-
version = "0.9.0"
3+
version = "0.9.1"
44
description = "Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only."
55
readme = "README.md"
66
license = { file = "LICENSE" }

‎src/worldarray/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
parse_chunk_key,
2929
)
3030

31-
__version__ = "0.9.0"
31+
__version__ = "0.9.1"
3232

3333
__all__ = [
3434
"ArrayMeta",

‎src/worldarray/context.py‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121

2222
from __future__ import annotations
2323

24+
import atexit
2425
import json
2526
import os
2627
import tempfile
2728
import threading
28-
import time
2929
import weakref
3030
from typing import Any
3131

@@ -50,20 +50,27 @@
5050
# `gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` says "ahead" or "identical"
5151
# for a release that has it, and `WORLDARRAY_STRESS=1 pytest tests/test_lost_wakeup.py -s`
5252
# then strands nothing on a plain context.
53+
#
54+
# The thread stops at exit, before the interpreter finalizes. Python up to 3.13 ends a
55+
# daemon thread that takes the GIL back during finalization with pthread_exit, and inside
56+
# a tensorstore call that aborts the process: "terminate called without an active
57+
# exception", exit 134, once all the work is done (mesh-n-bone's CI, 2026-10-26).
5358
KICK_EVERY = 1.0
59+
KICK_TIMEOUT = 10.0
5460
_KICK_DIR = tempfile.gettempdir()
5561
_KICK_KEY = "worldarray-kick-no-such-key"
5662
_watched: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
5763
_watch_lock = threading.Lock()
5864
_watchdog: threading.Thread | None = None
65+
_stop = threading.Event()
5966

6067

6168
def _kickers(context: ts.Context) -> tuple:
6269
"""An in-memory array and a local kvstore on ``context``: reading the one copies on its
6370
decoding pool, reading a missing key from the other runs on its file I/O pool."""
6471
array = ts.open({"driver": "array", "array": [0], "dtype": "uint8"}, context=context)
6572
kv = ts.KvStore.open({"driver": "file", "path": _KICK_DIR + "/"}, context=context)
66-
return array.result(timeout=10), kv.result(timeout=10)
73+
return array.result(timeout=KICK_TIMEOUT), kv.result(timeout=KICK_TIMEOUT)
6774

6875

6976
def kick(context: ts.Context) -> list:
@@ -86,38 +93,50 @@ def kick(context: ts.Context) -> list:
8693

8794
def watch(context: ts.Context | None) -> ts.Context | None:
8895
"""Kick ``context``'s pools every ``KICK_EVERY`` seconds for as long as it lives, starting
89-
the watchdog thread on first use. Returns ``context``."""
96+
the watchdog thread on first use (not once it has stopped at exit). Returns
97+
``context``."""
9098
global _watchdog
9199
if context is None:
92100
return None
93101
with _watch_lock:
94102
if context not in _watched:
95103
_watched[context] = None
96-
if _watchdog is None or not _watchdog.is_alive():
104+
if not _stop.is_set() and (_watchdog is None or not _watchdog.is_alive()):
97105
_watchdog = threading.Thread(target=_watch, name="worldarray-kick", daemon=True)
98106
_watchdog.start()
99107
return context
100108

101109

102110
def _watch() -> None:
103111
held: list = []
104-
while True:
105-
time.sleep(KICK_EVERY)
112+
while not _stop.wait(KICK_EVERY):
106113
with _watch_lock:
107114
contexts = list(_watched.keys())
108115
held = [f for f in held if not f.done()]
109116
for context in contexts:
117+
if _stop.is_set():
118+
break
110119
held += kick(context)
111120
del contexts
112121

113122

123+
def _stop_watchdog() -> None:
124+
"""End every watchdog thread and wait for it, at exit: atexit runs once the program's
125+
other threads are joined, so they are kicked to the end, and before finalization."""
126+
_stop.set()
127+
for thread in threading.enumerate():
128+
if thread.name == "worldarray-kick" and thread is not threading.current_thread():
129+
thread.join(2 * KICK_TIMEOUT) # the longest one kick takes: two opens
130+
131+
114132
def _forget_watchdog() -> None:
115133
global _watchdog
116134
_watchdog = None # a forked child has no such thread; the next watch() starts one
117135

118136

119137
if hasattr(os, "register_at_fork"):
120138
os.register_at_fork(after_in_child=_forget_watchdog)
139+
atexit.register(_stop_watchdog)
121140

122141

123142
THREADS_VAR = "WORLDARRAY_THREADS"

‎tests/test_access.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
import logging
99
import os
1010
import socket
11+
import subprocess
12+
import sys
13+
import textwrap
1114
import threading
1215
import time
1316
import weakref
@@ -205,6 +208,32 @@ def test_every_context_the_package_touches_is_kicked(monkeypatch, tmp_path):
205208
assert len(wa.context._watched) == n - 1 # a context that died is no longer watched
206209

207210

211+
def test_a_process_exits_cleanly_while_the_watchdog_kicks(tmp_path):
212+
# Python up to 3.13 ends a daemon thread that takes the GIL back during finalization
213+
# with pthread_exit, which aborts inside a tensorstore call; the watchdog stops at exit
214+
script = tmp_path / "exit.py"
215+
script.write_text(
216+
textwrap.dedent("""
217+
import time
218+
import tensorstore as ts
219+
from worldarray import context
220+
context.KICK_EVERY = 0.0 # kick flat out
221+
limits = [{"cache_pool": {"total_bytes_limit": i}} for i in range(20)]
222+
contexts = [context.watch(ts.Context(spec)) for spec in limits]
223+
class SlowExit: # finalization takes a while, with the GIL free
224+
def __del__(self):
225+
time.sleep(0.5)
226+
slow = SlowExit()
227+
time.sleep(0.2)
228+
""")
229+
)
230+
done = subprocess.run(
231+
[sys.executable, str(script)], capture_output=True, text=True, timeout=120
232+
)
233+
assert done.returncode == 0, done.stderr
234+
assert "terminate" not in done.stderr
235+
236+
208237
class _StalledView:
209238
"""A view whose first ``stalls`` reads never finish (a future nothing resolves, as a
210239
read stuck on NFS looks from Python); later reads return ``0, 1, 2, 3``."""

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)