worldarray 0.9.0__tar.gz → 0.9.1__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 (37) hide show
  1. {worldarray-0.9.0 → worldarray-0.9.1}/CHANGELOG.md +11 -0
  2. {worldarray-0.9.0 → worldarray-0.9.1}/PKG-INFO +1 -1
  3. {worldarray-0.9.0 → worldarray-0.9.1}/docs/spec.md +11 -2
  4. {worldarray-0.9.0 → worldarray-0.9.1}/pyproject.toml +1 -1
  5. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/__init__.py +1 -1
  6. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/context.py +25 -6
  7. {worldarray-0.9.0 → worldarray-0.9.1}/tests/test_access.py +29 -0
  8. {worldarray-0.9.0 → worldarray-0.9.1}/uv.lock +1 -1
  9. {worldarray-0.9.0 → worldarray-0.9.1}/.github/workflows/ci.yml +0 -0
  10. {worldarray-0.9.0 → worldarray-0.9.1}/.github/workflows/docs.yml +0 -0
  11. {worldarray-0.9.0 → worldarray-0.9.1}/.github/workflows/publish.yml +0 -0
  12. {worldarray-0.9.0 → worldarray-0.9.1}/.gitignore +0 -0
  13. {worldarray-0.9.0 → worldarray-0.9.1}/LICENSE +0 -0
  14. {worldarray-0.9.0 → worldarray-0.9.1}/README.md +0 -0
  15. {worldarray-0.9.0 → worldarray-0.9.1}/docs/changelog.md +0 -0
  16. {worldarray-0.9.0 → worldarray-0.9.1}/docs/index.md +0 -0
  17. {worldarray-0.9.0 → worldarray-0.9.1}/mkdocs.yml +0 -0
  18. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/array.py +0 -0
  19. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/geometry.py +0 -0
  20. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/grid.py +0 -0
  21. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/levels.py +0 -0
  22. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/placement.py +0 -0
  23. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/resample.py +0 -0
  24. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/retry.py +0 -0
  25. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/store.py +0 -0
  26. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/units.py +0 -0
  27. {worldarray-0.9.0 → worldarray-0.9.1}/src/worldarray/write.py +0 -0
  28. {worldarray-0.9.0 → worldarray-0.9.1}/tests/conftest.py +0 -0
  29. {worldarray-0.9.0 → worldarray-0.9.1}/tests/fixtures/metadata.json +0 -0
  30. {worldarray-0.9.0 → worldarray-0.9.1}/tests/fixtures/write.json +0 -0
  31. {worldarray-0.9.0 → worldarray-0.9.1}/tests/test_geometry.py +0 -0
  32. {worldarray-0.9.0 → worldarray-0.9.1}/tests/test_grid.py +0 -0
  33. {worldarray-0.9.0 → worldarray-0.9.1}/tests/test_lost_wakeup.py +0 -0
  34. {worldarray-0.9.0 → worldarray-0.9.1}/tests/test_metadata.py +0 -0
  35. {worldarray-0.9.0 → worldarray-0.9.1}/tests/test_read.py +0 -0
  36. {worldarray-0.9.0 → worldarray-0.9.1}/tests/test_resample.py +0 -0
  37. {worldarray-0.9.0 → worldarray-0.9.1}/tests/test_write.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
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
+
3
14
  ## 0.9.0 (2026-10-23)
4
15
 
5
16
  A job script can size each worker's threads. No placement change, no unit table change, and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only.
5
5
  Project-URL: Homepage, https://janeliascicomp.github.io/worldarray/
6
6
  Project-URL: Documentation, https://janeliascicomp.github.io/worldarray/
@@ -692,7 +692,15 @@ this document and that one differ, fix one of them.
692
692
  copy and a local read of a key that does not exist; no store is touched, about 0.3 ms
693
693
  of CPU). A stranded task then runs within about a second whoever waits on it: the
694
694
  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.
696
704
 
697
705
  7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
698
706
  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
1089
1097
  array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other variable,
1090
1098
  blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
1091
1099
  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.
1093
1102
  Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1094
1103
  `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1095
1104
  worker exits idle, so a plain context strands some for good (13 of about 7,000 on
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.9.0"
3
+ version = "0.9.1"
4
4
  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."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.9.0"
31
+ __version__ = "0.9.1"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -21,11 +21,11 @@ every open today; pass the same object twice to share pools (spec, Behaviour 7).
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import atexit
24
25
  import json
25
26
  import os
26
27
  import tempfile
27
28
  import threading
28
- import time
29
29
  import weakref
30
30
  from typing import Any
31
31
 
@@ -50,12 +50,19 @@ _lock = threading.Lock()
50
50
  # `gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` says "ahead" or "identical"
51
51
  # for a release that has it, and `WORLDARRAY_STRESS=1 pytest tests/test_lost_wakeup.py -s`
52
52
  # 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).
53
58
  KICK_EVERY = 1.0
59
+ KICK_TIMEOUT = 10.0
54
60
  _KICK_DIR = tempfile.gettempdir()
55
61
  _KICK_KEY = "worldarray-kick-no-such-key"
56
62
  _watched: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
57
63
  _watch_lock = threading.Lock()
58
64
  _watchdog: threading.Thread | None = None
65
+ _stop = threading.Event()
59
66
 
60
67
 
61
68
  def _kickers(context: ts.Context) -> tuple:
@@ -63,7 +70,7 @@ def _kickers(context: ts.Context) -> tuple:
63
70
  decoding pool, reading a missing key from the other runs on its file I/O pool."""
64
71
  array = ts.open({"driver": "array", "array": [0], "dtype": "uint8"}, context=context)
65
72
  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)
67
74
 
68
75
 
69
76
  def kick(context: ts.Context) -> list:
@@ -86,14 +93,15 @@ def kick(context: ts.Context) -> list:
86
93
 
87
94
  def watch(context: ts.Context | None) -> ts.Context | None:
88
95
  """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``."""
90
98
  global _watchdog
91
99
  if context is None:
92
100
  return None
93
101
  with _watch_lock:
94
102
  if context not in _watched:
95
103
  _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()):
97
105
  _watchdog = threading.Thread(target=_watch, name="worldarray-kick", daemon=True)
98
106
  _watchdog.start()
99
107
  return context
@@ -101,16 +109,26 @@ def watch(context: ts.Context | None) -> ts.Context | None:
101
109
 
102
110
  def _watch() -> None:
103
111
  held: list = []
104
- while True:
105
- time.sleep(KICK_EVERY)
112
+ while not _stop.wait(KICK_EVERY):
106
113
  with _watch_lock:
107
114
  contexts = list(_watched.keys())
108
115
  held = [f for f in held if not f.done()]
109
116
  for context in contexts:
117
+ if _stop.is_set():
118
+ break
110
119
  held += kick(context)
111
120
  del contexts
112
121
 
113
122
 
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
+
114
132
  def _forget_watchdog() -> None:
115
133
  global _watchdog
116
134
  _watchdog = None # a forked child has no such thread; the next watch() starts one
@@ -118,6 +136,7 @@ def _forget_watchdog() -> None:
118
136
 
119
137
  if hasattr(os, "register_at_fork"):
120
138
  os.register_at_fork(after_in_child=_forget_watchdog)
139
+ atexit.register(_stop_watchdog)
121
140
 
122
141
 
123
142
  THREADS_VAR = "WORLDARRAY_THREADS"
@@ -8,6 +8,9 @@ import http.server
8
8
  import logging
9
9
  import os
10
10
  import socket
11
+ import subprocess
12
+ import sys
13
+ import textwrap
11
14
  import threading
12
15
  import time
13
16
  import weakref
@@ -205,6 +208,32 @@ def test_every_context_the_package_touches_is_kicked(monkeypatch, tmp_path):
205
208
  assert len(wa.context._watched) == n - 1 # a context that died is no longer watched
206
209
 
207
210
 
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
+
208
237
  class _StalledView:
209
238
  """A view whose first ``stalls`` reads never finish (a future nothing resolves, as a
210
239
  read stuck on NFS looks from Python); later reads return ``0, 1, 2, 3``."""
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.9.0"
1250
+ version = "0.9.1"
1251
1251
  source = { editable = "." }
1252
1252
  dependencies = [
1253
1253
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes