2121
2222from __future__ import annotations
2323
24+ import atexit
2425import json
2526import os
2627import tempfile
2728import threading
28- import time
2929import weakref
3030from typing import Any
3131
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).
5358KICK_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
6168def _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
6976def kick (context : ts .Context ) -> list :
@@ -86,38 +93,50 @@ def kick(context: ts.Context) -> list:
8693
8794def 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
102110def _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+
114132def _forget_watchdog () -> None :
115133 global _watchdog
116134 _watchdog = None # a forked child has no such thread; the next watch() starts one
117135
118136
119137if hasattr (os , "register_at_fork" ):
120138 os .register_at_fork (after_in_child = _forget_watchdog )
139+ atexit .register (_stop_watchdog )
121140
122141
123142THREADS_VAR = "WORLDARRAY_THREADS"
0 commit comments