ezmsg 3.7.2__tar.gz → 3.8.0__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 (131) hide show
  1. {ezmsg-3.7.2 → ezmsg-3.8.0}/.github/workflows/ci.yml +1 -1
  2. {ezmsg-3.7.2 → ezmsg-3.8.0}/PKG-INFO +1 -1
  3. ezmsg-3.8.0/examples/simple_async_publisher.py +33 -0
  4. ezmsg-3.8.0/examples/simple_async_subscriber.py +34 -0
  5. ezmsg-3.8.0/examples/simple_publisher.py +35 -0
  6. ezmsg-3.8.0/examples/simple_subscriber.py +30 -0
  7. {ezmsg-3.7.2 → ezmsg-3.8.0}/pyproject.toml +1 -1
  8. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/__init__.py +6 -0
  9. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/backend.py +86 -3
  10. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/backendprocess.py +194 -14
  11. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/graphcontext.py +9 -1
  12. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/graphserver.py +15 -5
  13. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/netprotocol.py +4 -1
  14. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/subclient.py +10 -5
  15. ezmsg-3.8.0/src/ezmsg/core/sync.py +444 -0
  16. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/unit.py +21 -7
  17. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/debuglog.py +1 -1
  18. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messagereplay.py +1 -1
  19. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messages/key.py +2 -2
  20. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messages/modify.py +1 -1
  21. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/perf/impl.py +3 -3
  22. ezmsg-3.8.0/tests/clean_shutdown_examples_runner.py +171 -0
  23. ezmsg-3.8.0/tests/perf_sync_overhead.py +119 -0
  24. ezmsg-3.8.0/tests/shutdown_runner.py +102 -0
  25. ezmsg-3.8.0/tests/test_clean_shutdown.py +225 -0
  26. ezmsg-3.8.0/tests/test_messagereplay.py +92 -0
  27. ezmsg-3.8.0/tests/test_subclient.py +148 -0
  28. ezmsg-3.8.0/tests/test_sync_api.py +171 -0
  29. ezmsg-3.7.2/examples/lowlevel_api.py +0 -119
  30. {ezmsg-3.7.2 → ezmsg-3.8.0}/.flake8 +0 -0
  31. {ezmsg-3.7.2 → ezmsg-3.8.0}/.git-blame-ignore-revs +0 -0
  32. {ezmsg-3.7.2 → ezmsg-3.8.0}/.github/workflows/docs.yml +0 -0
  33. {ezmsg-3.7.2 → ezmsg-3.8.0}/.github/workflows/publish.yml +0 -0
  34. {ezmsg-3.7.2 → ezmsg-3.8.0}/.gitignore +0 -0
  35. {ezmsg-3.7.2 → ezmsg-3.8.0}/.pre-commit-config.yaml +0 -0
  36. {ezmsg-3.7.2 → ezmsg-3.8.0}/LICENSE +0 -0
  37. {ezmsg-3.7.2 → ezmsg-3.8.0}/README.md +0 -0
  38. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/Makefile +0 -0
  39. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/make.bat +0 -0
  40. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/_static/_images/cloning.png +0 -0
  41. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/_static/_images/ezmsg_logo.png +0 -0
  42. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/_static/_images/tutorial_graph.png +0 -0
  43. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/_static/_images/tutorial_graph_compact.png +0 -0
  44. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/_static/_images/tutorial_graphviz.png +0 -0
  45. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/_static/_images/tutorial_pipeline.png +0 -0
  46. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/_static/_images/tutorial_system.png +0 -0
  47. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/_static/custom.css +0 -0
  48. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/conf.py +0 -0
  49. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/index.rst +0 -0
  50. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/reference/API/axisarray-util-units.rst +0 -0
  51. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/reference/API/axisarray.rst +0 -0
  52. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/reference/API/components.rst +0 -0
  53. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/reference/API/content-api.rst +0 -0
  54. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/reference/API/entrypoint.rst +0 -0
  55. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/reference/API/functiondecorators.rst +0 -0
  56. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/reference/API/util-units.rst +0 -0
  57. {ezmsg-3.7.2 → ezmsg-3.8.0}/docs/source/reference/content-reference.rst +0 -0
  58. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/.gitignore +0 -0
  59. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_attach.py +0 -0
  60. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_configs.py +0 -0
  61. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_count.py +0 -0
  62. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_generator.py +0 -0
  63. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_intro.py +0 -0
  64. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_leaky_subscriber.py +0 -0
  65. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_log_to_file.py +0 -0
  66. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_normalterm.py +0 -0
  67. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_stop.py +0 -0
  68. {ezmsg-3.7.2 → ezmsg-3.8.0}/examples/ezmsg_toy.py +0 -0
  69. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/__main__.py +0 -0
  70. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/addressable.py +0 -0
  71. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/backpressure.py +0 -0
  72. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/channelmanager.py +0 -0
  73. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/collection.py +0 -0
  74. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/command.py +0 -0
  75. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/component.py +0 -0
  76. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/dag.py +0 -0
  77. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/graph_util.py +0 -0
  78. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/message.py +0 -0
  79. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/messagecache.py +0 -0
  80. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/messagechannel.py +0 -0
  81. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/messagemarshal.py +0 -0
  82. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/pubclient.py +0 -0
  83. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/settings.py +0 -0
  84. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/shm.py +0 -0
  85. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/state.py +0 -0
  86. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/stream.py +0 -0
  87. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/test.py +0 -0
  88. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/core/util.py +0 -0
  89. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/__init__.py +0 -0
  90. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/gen_to_unit.py +0 -0
  91. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/generator.py +0 -0
  92. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messagecodec.py +0 -0
  93. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messagegate.py +0 -0
  94. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messagelogger.py +0 -0
  95. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messagequeue.py +0 -0
  96. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messages/__init__.py +0 -0
  97. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messages/axisarray.py +0 -0
  98. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messages/chunker.py +0 -0
  99. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/messages/util.py +0 -0
  100. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/perf/__init__.py +0 -0
  101. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/perf/analysis.py +0 -0
  102. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/perf/command.py +0 -0
  103. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/perf/envinfo.py +0 -0
  104. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/perf/run.py +0 -0
  105. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/perf/util.py +0 -0
  106. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/profiler.py +0 -0
  107. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/rate.py +0 -0
  108. {ezmsg-3.7.2 → ezmsg-3.8.0}/src/ezmsg/util/terminate.py +0 -0
  109. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/__init__.py +0 -0
  110. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/ez_test_utils.py +0 -0
  111. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/messages/__init__.py +0 -0
  112. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/messages/test_axisarray.py +0 -0
  113. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/messages/test_chunker.py +0 -0
  114. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/messages/test_key.py +0 -0
  115. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/messages/test_modify.py +0 -0
  116. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_addressable.py +0 -0
  117. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_attach.py +0 -0
  118. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_channel.py +0 -0
  119. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_channelmanager.py +0 -0
  120. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_connections.py +0 -0
  121. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_dag.py +0 -0
  122. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_generator.py +0 -0
  123. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_graph.py +0 -0
  124. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_graph_visualization.py +0 -0
  125. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_leaky_subscriber.py +0 -0
  126. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_perf_configs.py +0 -0
  127. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_profiler.py +0 -0
  128. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_run.py +0 -0
  129. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_shm.py +0 -0
  130. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_state.py +0 -0
  131. {ezmsg-3.7.2 → ezmsg-3.8.0}/tests/test_test.py +0 -0
@@ -15,7 +15,7 @@ jobs:
15
15
  pytest:
16
16
  strategy:
17
17
  matrix:
18
- python-version: ["3.10.15", "3.11"] # Omit until shutdown fixed: , "3.12", "3.13"]
18
+ python-version: ["3.10.15", "3.11", "3.12", "3.13"]
19
19
  os:
20
20
  - "ubuntu-latest"
21
21
  - "windows-latest"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ezmsg
3
- Version: 3.7.2
3
+ Version: 3.8.0
4
4
  Summary: A simple DAG-based computation model
5
5
  Author-email: Griffin Milsap <griffin.milsap@gmail.com>, Preston Peranich <pperanich@gmail.com>, Chadwick Boulay <chadwick.boulay@gmail.com>
6
6
  License: MIT License
@@ -0,0 +1,33 @@
1
+ import asyncio
2
+
3
+ import ezmsg.core as ez
4
+
5
+ TOPIC = "/TEST"
6
+
7
+
8
+ async def main(host: str = "127.0.0.1", port: int = 12345) -> None:
9
+ async with ez.GraphContext((host, port), auto_start=True) as ctx:
10
+ pub = await ctx.publisher(TOPIC)
11
+ try:
12
+ print("Publisher Task Launched")
13
+ count = 0
14
+ while True:
15
+ await pub.broadcast(f"{count=}")
16
+ await asyncio.sleep(0.1)
17
+ count += 1
18
+ except asyncio.CancelledError:
19
+ pass
20
+ finally:
21
+ print("Publisher Task Concluded")
22
+
23
+
24
+ if __name__ == "__main__":
25
+ from argparse import ArgumentParser
26
+
27
+ parser = ArgumentParser()
28
+ parser.add_argument("--host", default="127.0.0.1", help="hostname for graphserver")
29
+ parser.add_argument("--port", default=12345, type=int, help="port for graphserver")
30
+
31
+ args = parser.parse_args()
32
+
33
+ asyncio.run(main(host=args.host, port=args.port))
@@ -0,0 +1,34 @@
1
+ import asyncio
2
+
3
+ import ezmsg.core as ez
4
+
5
+ TOPIC = "/TEST"
6
+
7
+
8
+ async def main(host: str = "127.0.0.1", port: int = 12345) -> None:
9
+ async with ez.GraphContext((host, port), auto_start=True) as ctx:
10
+ sub = await ctx.subscriber(TOPIC)
11
+ try:
12
+ print("Subscriber Task Launched")
13
+ while True:
14
+ async with sub.recv_zero_copy() as msg:
15
+ # Uncomment if you want to witness backpressure!
16
+ # await asyncio.sleep(1.0)
17
+ print(msg)
18
+ except asyncio.CancelledError:
19
+ pass
20
+ finally:
21
+ print("Subscriber Task Concluded")
22
+ print("Detached")
23
+
24
+
25
+ if __name__ == "__main__":
26
+ from argparse import ArgumentParser
27
+
28
+ parser = ArgumentParser()
29
+ parser.add_argument("--host", default="127.0.0.1", help="hostname for graphserver")
30
+ parser.add_argument("--port", default=12345, type=int, help="port for graphserver")
31
+
32
+ args = parser.parse_args()
33
+
34
+ asyncio.run(main(host=args.host, port=args.port))
@@ -0,0 +1,35 @@
1
+ import time
2
+
3
+ import ezmsg.core as ez
4
+
5
+ TOPIC = "/TEST"
6
+
7
+ def main(host: str = "127.0.0.1", port: int = 12345) -> None:
8
+ with ez.sync.init((host, port), auto_start=True) as ctx:
9
+ pub = ctx.create_publisher(TOPIC, force_tcp=True)
10
+
11
+ print("Publisher Task Launched")
12
+ count = 0
13
+ try:
14
+ while True:
15
+ output = f"{count=}"
16
+ pub.publish(output)
17
+ print(output)
18
+ time.sleep(0.1)
19
+ count += 1
20
+ except KeyboardInterrupt:
21
+ pass
22
+ print("Publisher Task Concluded")
23
+
24
+ print("Done")
25
+
26
+
27
+ if __name__ == "__main__":
28
+ from argparse import ArgumentParser
29
+
30
+ parser = ArgumentParser()
31
+ parser.add_argument("--host", default="127.0.0.1", help="hostname for graphserver")
32
+ parser.add_argument("--port", default=12345, type=int, help="port for graphserver")
33
+ args = parser.parse_args()
34
+
35
+ main(host=args.host, port=args.port)
@@ -0,0 +1,30 @@
1
+ import ezmsg.core as ez
2
+
3
+ TOPIC = "/TEST"
4
+
5
+
6
+ def main(host: str = "127.0.0.1", port: int = 12345) -> None:
7
+ with ez.sync.init((host, port), auto_start=True) as ctx:
8
+ print("Subscriber Task Launched")
9
+
10
+ def on_message(msg: str) -> None:
11
+ # Uncomment if you want to witness backpressure!
12
+ # import time
13
+ # time.sleep(1.0)
14
+ print(msg)
15
+
16
+ ctx.create_subscription(TOPIC, callback=on_message)
17
+ ez.sync.spin(ctx)
18
+
19
+ print("Subscriber Task Concluded")
20
+
21
+
22
+ if __name__ == "__main__":
23
+ from argparse import ArgumentParser
24
+
25
+ parser = ArgumentParser()
26
+ parser.add_argument("--host", default="127.0.0.1", help="hostname for graphserver")
27
+ parser.add_argument("--port", default=12345, type=int, help="port for graphserver")
28
+ args = parser.parse_args()
29
+
30
+ main(host=args.host, port=args.port)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ezmsg"
3
- version = "3.7.2"
3
+ version = "3.8.0"
4
4
  description = "A simple DAG-based computation model"
5
5
  authors = [
6
6
  { name = "Griffin Milsap", email = "griffin.milsap@gmail.com" },
@@ -25,6 +25,10 @@ __all__ = [
25
25
  "NormalTermination",
26
26
  "GraphServer",
27
27
  "GraphContext",
28
+ "sync",
29
+ "SyncContext",
30
+ "SyncPublisher",
31
+ "SyncSubscriber",
28
32
  "run_command",
29
33
  "Publisher",
30
34
  "Subscriber",
@@ -45,6 +49,8 @@ from .backend import run, GraphRunner, GraphRunnerStartError
45
49
  from .backendprocess import Complete, NormalTermination
46
50
  from .graphserver import GraphServer
47
51
  from .graphcontext import GraphContext
52
+ from . import sync
53
+ from .sync import SyncContext, SyncPublisher, SyncSubscriber
48
54
  from .command import run_command
49
55
  from .pubclient import Publisher
50
56
  from .subclient import Subscriber
@@ -4,6 +4,7 @@ from collections.abc import Collection as AbstractCollection
4
4
  import enum
5
5
  import logging
6
6
  import os
7
+ import signal
7
8
  from threading import BrokenBarrierError
8
9
  from multiprocessing import Event, Barrier
9
10
  from multiprocessing.synchronize import Event as EventType
@@ -23,6 +24,7 @@ from .graphcontext import GraphContext
23
24
  from .backendprocess import (
24
25
  BackendProcess,
25
26
  DefaultBackendProcess,
27
+ ShutdownSummary,
26
28
  new_threaded_event_loop,
27
29
  )
28
30
 
@@ -170,6 +172,7 @@ class GraphRunner:
170
172
  _graph_context: GraphContext | None
171
173
  _loop: asyncio.AbstractEventLoop | None
172
174
  _loop_cm: object | None
175
+ _loop_shutdown_summary: ShutdownSummary | None
173
176
  _main_process: BackendProcess | None
174
177
  _spawned_processes: list[BackendProcess]
175
178
  _start_participant: bool
@@ -208,6 +211,7 @@ class GraphRunner:
208
211
  self._graph_context = None
209
212
  self._loop = None
210
213
  self._loop_cm = None
214
+ self._loop_shutdown_summary = None
211
215
  self._main_process = None
212
216
  self._spawned_processes = []
213
217
  self._start_participant = False
@@ -222,6 +226,15 @@ class GraphRunner:
222
226
  return self._graph_context.graph_address
223
227
  return self._graph_address
224
228
 
229
+ @property
230
+ def strict_shutdown(self) -> bool:
231
+ value = os.environ.get("EZMSG_STRICT_SHUTDOWN", "")
232
+ return value.lower() in ("1", "true", "yes", "on")
233
+
234
+ @strict_shutdown.setter
235
+ def strict_shutdown(self, value: bool) -> None:
236
+ os.environ["EZMSG_STRICT_SHUTDOWN"] = "1" if value else "0"
237
+
225
238
  @property
226
239
  def graph_server_spawned(self) -> bool:
227
240
  return self._graph_server_spawned
@@ -311,7 +324,10 @@ class GraphRunner:
311
324
  if self._execution_context is None:
312
325
  return False
313
326
 
314
- self._loop_cm = new_threaded_event_loop()
327
+ self._loop_shutdown_summary = ShutdownSummary()
328
+ self._loop_cm = new_threaded_event_loop(
329
+ shutdown_summary=self._loop_shutdown_summary
330
+ )
315
331
  self._loop = self._loop_cm.__enter__()
316
332
 
317
333
  try:
@@ -380,12 +396,15 @@ class GraphRunner:
380
396
  self._main_process = self._execution_context.processes[0]
381
397
  self._start_processes(self._execution_context.processes[1:])
382
398
 
399
+ interrupts = 0
400
+ forced_sigint = False
383
401
  try:
384
402
  self._main_process.process(self._loop)
385
403
  self._join_spawned_processes()
386
404
  logger.info("All processes exited normally")
387
405
 
388
406
  except KeyboardInterrupt:
407
+ interrupts += 1
389
408
  logger.info(
390
409
  "Attempting graceful shutdown, interrupt again to force quit..."
391
410
  )
@@ -395,6 +414,8 @@ class GraphRunner:
395
414
  self._join_spawned_processes()
396
415
 
397
416
  except KeyboardInterrupt:
417
+ interrupts += 1
418
+ forced_sigint = True
398
419
  logger.warning("Interrupt intercepted, force quitting")
399
420
  self._execution_context.start_barrier.abort()
400
421
  self._execution_context.stop_barrier.abort()
@@ -402,10 +423,72 @@ class GraphRunner:
402
423
  proc.terminate()
403
424
 
404
425
  finally:
405
- self._join_spawned_processes()
406
- self._cleanup()
426
+ while True:
427
+ try:
428
+ self._join_spawned_processes()
429
+ self._cleanup()
430
+ break
431
+ except KeyboardInterrupt:
432
+ interrupts += 1
433
+ if interrupts >= 2:
434
+ forced_sigint = True
435
+ logger.warning("Interrupt intercepted, force quitting")
436
+ if self._execution_context is not None:
437
+ self._execution_context.start_barrier.abort()
438
+ self._execution_context.stop_barrier.abort()
439
+ for proc in self._spawned_processes:
440
+ proc.terminate()
441
+ self._cleanup()
442
+ break
443
+ logger.info(
444
+ "Interrupt received during cleanup; attempting graceful shutdown..."
445
+ )
446
+ if self._execution_context is not None:
447
+ self._execution_context.term_ev.set()
407
448
  self._started = False
408
449
  self._stopped = True
450
+ if interrupts and not forced_sigint and self._shutdown_was_unclean():
451
+ forced_sigint = True
452
+ if forced_sigint:
453
+ self._exit_with_sigint()
454
+
455
+ def _shutdown_was_unclean(self) -> bool:
456
+ main_shutdown_errors = bool(
457
+ self._main_process is not None
458
+ and getattr(self._main_process, "_shutdown_errors", False)
459
+ )
460
+ summary = self._loop_shutdown_summary
461
+ loop_unclean = bool(summary is not None and summary.unclean)
462
+ return main_shutdown_errors or loop_unclean
463
+
464
+ def _exit_with_sigint(self) -> None:
465
+ code = 0xC000013A if os.name == "nt" else 130
466
+ if os.name == "nt":
467
+ try:
468
+ import ctypes
469
+ except Exception:
470
+ os._exit(1)
471
+ try:
472
+ ctypes.windll.kernel32.ExitProcess(ctypes.c_uint(code).value)
473
+ except Exception:
474
+ os._exit(ctypes.c_int32(code).value)
475
+ return
476
+
477
+ prev_handler = None
478
+ try:
479
+ prev_handler = signal.getsignal(signal.SIGINT)
480
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
481
+ signal.raise_signal(signal.SIGINT)
482
+ except Exception:
483
+ raise SystemExit(code)
484
+ finally:
485
+ if prev_handler is not None:
486
+ try:
487
+ signal.signal(signal.SIGINT, prev_handler)
488
+ except Exception:
489
+ pass
490
+
491
+ raise SystemExit(code)
409
492
 
410
493
  def _cleanup(self) -> None:
411
494
  if self._cleanup_done:
@@ -2,15 +2,19 @@ import asyncio
2
2
  import concurrent.futures
3
3
  import logging
4
4
  import inspect
5
+ import os
5
6
  import time
6
7
  import traceback
7
8
  import threading
9
+ import weakref
8
10
 
9
11
  from abc import abstractmethod
12
+ from dataclasses import dataclass
10
13
  from collections import defaultdict
11
14
  from collections.abc import Callable, Coroutine, Generator, Sequence
12
15
  from functools import wraps, partial
13
- from copy import deepcopy
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from concurrent.futures.thread import _worker
14
18
  from multiprocessing import Process
15
19
  from multiprocessing.synchronize import Event as EventType
16
20
  from multiprocessing.synchronize import Barrier as BarrierType
@@ -19,8 +23,7 @@ from concurrent.futures import TimeoutError
19
23
  from typing import Any
20
24
 
21
25
  from .stream import Stream, InputStream, OutputStream
22
- from .unit import Unit, TIMEIT_ATTR, SUBSCRIBES_ATTR, ZERO_COPY_ATTR
23
- from .messagechannel import LeakyQueue
26
+ from .unit import Unit, TIMEIT_ATTR, SUBSCRIBES_ATTR
24
27
 
25
28
  from .graphcontext import GraphContext
26
29
  from .pubclient import Publisher
@@ -29,6 +32,75 @@ from .netprotocol import AddressType
29
32
 
30
33
  logger = logging.getLogger("ezmsg")
31
34
 
35
+ STRICT_SHUTDOWN_ENV = "EZMSG_STRICT_SHUTDOWN"
36
+
37
+
38
+ def _strict_shutdown_enabled() -> bool:
39
+ value = os.environ.get(STRICT_SHUTDOWN_ENV, "")
40
+ return value.lower() in ("1", "true", "yes", "on")
41
+
42
+
43
+ @dataclass
44
+ class ShutdownSummary:
45
+ cancelled_tasks: int = 0
46
+ executor_active: int = 0
47
+ pending_tasks: int = 0
48
+ suppressed_errors: int = 0
49
+ forced_interrupt: bool = False
50
+
51
+ @property
52
+ def unclean(self) -> bool:
53
+ return bool(
54
+ self.executor_active
55
+ or self.pending_tasks
56
+ or self.suppressed_errors
57
+ or self.forced_interrupt
58
+ )
59
+
60
+
61
+ class _DaemonThreadPoolExecutor(ThreadPoolExecutor):
62
+ def __init__(self, *args, **kwargs) -> None:
63
+ super().__init__(*args, **kwargs)
64
+ self._active_count = 0
65
+ self._active_lock = threading.Lock()
66
+
67
+ def _adjust_thread_count(self) -> None:
68
+ if self._broken:
69
+ return
70
+ num_threads = len(self._threads)
71
+ if num_threads >= self._max_workers:
72
+ return
73
+ thread_name = f"{self._thread_name_prefix or 'ThreadPool'}_{num_threads}"
74
+ thread = threading.Thread(
75
+ name=thread_name,
76
+ target=_worker,
77
+ args=(
78
+ weakref.ref(self),
79
+ self._work_queue,
80
+ self._initializer,
81
+ self._initargs,
82
+ ),
83
+ )
84
+ thread.daemon = True
85
+ thread.start()
86
+ self._threads.add(thread)
87
+
88
+ def submit(self, fn, /, *args, **kwargs):
89
+ fut = super().submit(fn, *args, **kwargs)
90
+ with self._active_lock:
91
+ self._active_count += 1
92
+
93
+ def _decrement(_):
94
+ with self._active_lock:
95
+ self._active_count -= 1
96
+
97
+ fut.add_done_callback(_decrement)
98
+ return fut
99
+
100
+ def active_count(self) -> int:
101
+ with self._active_lock:
102
+ return self._active_count
103
+
32
104
 
33
105
  class Complete(Exception):
34
106
  """
@@ -146,11 +218,13 @@ class DefaultBackendProcess(BackendProcess):
146
218
  """
147
219
 
148
220
  pubs: dict[str, Publisher]
221
+ _shutdown_errors: bool
149
222
 
150
223
  def process(self, loop: asyncio.AbstractEventLoop) -> None:
151
224
  main_func = None
152
225
  context = GraphContext(self.graph_address)
153
226
  coro_callables: dict[str, Callable[[], Coroutine[Any, Any, None]]] = dict()
227
+ self._shutdown_errors = False
154
228
 
155
229
  try:
156
230
  self.pubs = dict()
@@ -228,9 +302,14 @@ class DefaultBackendProcess(BackendProcess):
228
302
  ),
229
303
  loop=loop,
230
304
  ).result()
305
+
306
+ except asyncio.CancelledError:
307
+ pass
308
+
231
309
  except Exception:
232
310
  self.start_barrier.abort()
233
- logger.error(f"{traceback.format_exc()}")
311
+ # logger.error(f"{traceback.format_exc()}")
312
+ raise
234
313
 
235
314
  try:
236
315
  logger.debug("Waiting at start barrier!")
@@ -260,9 +339,9 @@ class DefaultBackendProcess(BackendProcess):
260
339
  fn(unit)
261
340
  except NormalTermination:
262
341
  self.term_ev.set()
263
- except Exception:
264
- logger.error(f"Exception in Main: {unit.address}")
265
- logger.error(traceback.format_exc())
342
+ # except Exception:
343
+ # logger.error(f"Exception in Main: {unit.address}")
344
+ # logger.error(traceback.format_exc())
266
345
 
267
346
  while True:
268
347
  try:
@@ -309,12 +388,24 @@ class DefaultBackendProcess(BackendProcess):
309
388
  else:
310
389
  unit.shutdown() # type: ignore
311
390
 
312
- asyncio.run_coroutine_threadsafe(shutdown_units(), loop=loop).result()
313
-
314
- # for cache in MessageCache.values():
315
- # cache.clear()
391
+ shutdown_future = asyncio.run_coroutine_threadsafe(shutdown_units(), loop=loop)
392
+ try:
393
+ shutdown_future.result()
394
+ except KeyboardInterrupt:
395
+ logger.warning("Interrupted during unit shutdown. This may indicate units with slow shutdown methods."
396
+ "Re-trying... Press ctrl-c again to terminate immediately.")
397
+ shutdown_future.result()
316
398
 
317
- asyncio.run_coroutine_threadsafe(context.revert(), loop=loop).result()
399
+ revert_future = asyncio.run_coroutine_threadsafe(context.revert(), loop=loop)
400
+ try:
401
+ revert_future.result()
402
+ except KeyboardInterrupt:
403
+ logger.warning("Interrupted during context revert."
404
+ "Re-trying... (will timeout in 10 seconds)")
405
+ try:
406
+ revert_future.result(timeout=10.0)
407
+ except TimeoutError:
408
+ logger.warning("Timed out waiting for retry on context revert")
318
409
 
319
410
  logger.debug(f"Remaining tasks in event loop = {asyncio.all_tasks(loop)}")
320
411
 
@@ -340,6 +431,7 @@ class DefaultBackendProcess(BackendProcess):
340
431
  self, unit: Unit, task: Callable
341
432
  ) -> Callable[..., Coroutine[Any, Any, None]]:
342
433
  task_address = f"{unit.address}:{task.__name__}"
434
+ strict_shutdown = _strict_shutdown_enabled()
343
435
 
344
436
  async def publish(stream: Stream, obj: Any) -> None:
345
437
  if stream.address in self.pubs:
@@ -374,8 +466,6 @@ class DefaultBackendProcess(BackendProcess):
374
466
  result = call_fn(msg)
375
467
  if inspect.isasyncgen(result):
376
468
  async for stream, obj in result:
377
- if obj and getattr(task, ZERO_COPY_ATTR, False) and obj is msg:
378
- obj = deepcopy(obj)
379
469
  await pub_fn(stream, obj)
380
470
 
381
471
  elif asyncio.iscoroutine(result):
@@ -390,9 +480,17 @@ class DefaultBackendProcess(BackendProcess):
390
480
  self.term_ev.set()
391
481
  raise
392
482
 
483
+ except asyncio.CancelledError:
484
+ # Normal during shutdown; propagate without logging.
485
+ raise
486
+
393
487
  except Exception:
394
488
  logger.error(f"Exception in Task: {task_address}")
395
489
  logger.error(traceback.format_exc())
490
+ if self.term_ev.is_set():
491
+ self._shutdown_errors = True
492
+ if strict_shutdown:
493
+ raise
396
494
 
397
495
  return wrapped_task
398
496
 
@@ -468,6 +566,7 @@ def run_loop(loop: asyncio.AbstractEventLoop):
468
566
  @contextmanager
469
567
  def new_threaded_event_loop(
470
568
  ev: threading.Event | None = None,
569
+ shutdown_summary: ShutdownSummary | None = None,
471
570
  ) -> Generator[asyncio.AbstractEventLoop, None, None]:
472
571
  """
473
572
  Create a new asyncio event loop running in a separate thread.
@@ -477,10 +576,30 @@ def new_threaded_event_loop(
477
576
 
478
577
  :param ev: Optional event to signal when the loop is ready.
479
578
  :type ev: threading.Event | None
579
+ :param shutdown_summary: Optional shutdown summary object to populate.
580
+ :type shutdown_summary: ShutdownSummary | None
480
581
  :return: Context manager yielding the event loop.
481
582
  :rtype: Generator[asyncio.AbstractEventLoop, None, None]
482
583
  """
483
584
  loop = asyncio.new_event_loop()
585
+ strict_shutdown = _strict_shutdown_enabled()
586
+ shutdown_suppress = threading.Event()
587
+ suppressed_shutdown_errors = {"count": 0}
588
+ suppressed_lock = threading.Lock()
589
+ executor = None
590
+ if not strict_shutdown:
591
+ executor = _DaemonThreadPoolExecutor(thread_name_prefix="EZMSG")
592
+ loop.set_default_executor(executor)
593
+ def _loop_exception_handler(
594
+ loop_obj: asyncio.AbstractEventLoop, context: dict
595
+ ) -> None:
596
+ if shutdown_suppress.is_set():
597
+ with suppressed_lock:
598
+ suppressed_shutdown_errors["count"] += 1
599
+ return
600
+ loop_obj.default_exception_handler(context)
601
+
602
+ loop.set_exception_handler(_loop_exception_handler)
484
603
  thread = threading.Thread(target=run_loop, name="TaskThread", args=(loop,))
485
604
  thread.start()
486
605
 
@@ -492,6 +611,67 @@ def new_threaded_event_loop(
492
611
  logger.debug("Waiting at event...")
493
612
  # ev.wait()
494
613
  logger.debug("Stopping and closing task thread")
614
+
615
+ if not strict_shutdown:
616
+ shutdown_suppress.set()
617
+ # Cancel and await remaining tasks before stopping the loop.
618
+ async def _cancel_remaining(timeout: float = 1.0) -> tuple[int, int]:
619
+ tasks = [
620
+ t
621
+ for t in asyncio.all_tasks()
622
+ if t is not asyncio.current_task() and not t.done()
623
+ ]
624
+ for t in tasks:
625
+ t.cancel()
626
+ if not tasks:
627
+ return 0, 0
628
+ _, pending = await asyncio.wait(tasks, timeout=timeout)
629
+ return len(tasks), len(pending)
630
+
631
+ cancelled_count = 0
632
+ pending_count = 0
633
+ forced_interrupt = False
634
+ fut = asyncio.run_coroutine_threadsafe(_cancel_remaining(), loop)
635
+ try:
636
+ cancelled_count, pending_count = fut.result()
637
+ except KeyboardInterrupt:
638
+ forced_interrupt = True
639
+ fut.cancel()
640
+ except Exception:
641
+ cancelled_count = 0
642
+ pending_count = 0
643
+
644
+ suppressed_count = suppressed_shutdown_errors["count"]
645
+ if cancelled_count or suppressed_count or forced_interrupt or pending_count:
646
+ if forced_interrupt and not cancelled_count and not suppressed_count:
647
+ logger.warning(
648
+ "Shutdown interrupted; tasks may still be running. "
649
+ "Re-run with EZMSG_STRICT_SHUTDOWN=1 to debug tasks with poor shutdown behavior."
650
+ )
651
+ elif pending_count:
652
+ logger.warning(
653
+ "Shutdown timed out waiting for %d task(s). "
654
+ "Re-run with EZMSG_STRICT_SHUTDOWN=1 to debug tasks with poor shutdown behavior.",
655
+ pending_count,
656
+ )
657
+ else:
658
+ logger.warning(
659
+ "Shutdown suppressed %d error(s) and cancelled %d task(s). "
660
+ "Shutdown was NOT clean; re-run with EZMSG_STRICT_SHUTDOWN=1 "
661
+ "to debug tasks with poor shutdown behavior.",
662
+ suppressed_count,
663
+ cancelled_count,
664
+ )
665
+
666
+ if shutdown_summary is not None:
667
+ shutdown_summary.cancelled_tasks = cancelled_count
668
+ shutdown_summary.pending_tasks = pending_count
669
+ shutdown_summary.executor_active = (
670
+ executor.active_count() if executor is not None else 0
671
+ )
672
+ shutdown_summary.suppressed_errors = suppressed_count
673
+ shutdown_summary.forced_interrupt = forced_interrupt
674
+
495
675
  loop.call_soon_threadsafe(loop.stop)
496
676
  thread.join()
497
677
  loop.close()