Skip to content

Commit 8ed4f8a

Browse files
committed
runtime: add drain and step_until_idle
- Add Runtime.drain() with snapshot semantics (process only commands queued at call start) - Introduce _handle_commands() to route commands with per-command exception isolation and logging - Add has_pending property and step_until_idle() convenience method - Update Runtime.step() to use _handle_commands() - Bump package version to 0.1.0 - Add tests tests/test_runtime.py (drain semantics, handler exception isolation) - Update docs docs/API_REFERENCE.md, docs/ARCHITECTURE.md and add CHANGELOG.md
1 parent d33828d commit 8ed4f8a

4 files changed

Lines changed: 140 additions & 22 deletions

File tree

docs/API_REFERENCE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ Execute one simulation step:
4343
2. Route queued commands to registered handlers
4444
3. Increment step counter
4545

46+
#### `drain() -> int`
47+
48+
Route pending commands present at drain start toward registered handlers without running systems.
49+
50+
- Only commands queued at the start of the call are processed (snapshot semantics).
51+
- Commands emitted by handlers during drain remain pending for subsequent processing.
52+
- Exceptions in individual handlers are logged and do not stop other handlers.
53+
- Returns the number of commands routed to handlers.
54+
4655
---
4756

4857
## World

docs/ARCHITECTURE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ The **Runtime** orchestrates the simulation loop. It is the main entry point for
7171
- Execute simulation steps in correct order
7272
- Provide convenient access to subsystems
7373

74+
**Runtime notes:**
75+
- `drain()` processes a snapshot of pending commands (commands queued at call start); commands emitted by handlers remain pending.
76+
- Command handler exceptions are logged and isolated — a failing handler does not stop other commands.
77+
7478
**Key Insight:** Runtime separates the "how" (orchestration) from the "what" (simulation logic). The World contains the simulation; Runtime runs it.
7579

7680
### 2. World

hive/runtime.py

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,91 @@
11
"""Runtime: simple orchestration for World, Dispatcher and Command Routing."""
22

3-
from typing import TypeVar
3+
import logging
4+
45
from .core import World
56
from .command.dispatcher import CommandDispatcher
67
from .command.router import CommandRouter
78

8-
T = TypeVar("T")
9+
logger = logging.getLogger(__name__)
910

1011

1112
class Runtime:
12-
"""Main runtime that orchestrates the ECS simulation.
13-
14-
Runtime runs the simulation.
15-
World is the simulation.
16-
Store owns the simulation data (entities, components).
17-
Resources hold static data (config, assets).
18-
"""
13+
"""Orchestrate World, Dispatcher and Router."""
1914

2015
def __init__(self):
2116
self._world = World()
2217
self._dispatcher = CommandDispatcher()
2318
self._router = CommandRouter()
24-
self.steps = 0
25-
2619
self.event_bus = self._world.event_bus
20+
self.steps = 0
2721

2822
@property
2923
def world(self) -> World:
30-
"""Access the simulation world."""
3124
return self._world
3225

3326
@property
3427
def resources(self):
35-
"""Access resources (static data like config, assets)."""
3628
return self._world.resources
3729

3830
@property
3931
def router(self) -> CommandRouter:
40-
"""Access the command router for registering command handlers."""
4132
return self._router
4233

4334
@property
4435
def dispatcher(self) -> CommandDispatcher:
45-
"""Access the command dispatcher for dispatching commands."""
4636
return self._dispatcher
4737

38+
@property
39+
def has_pending(self) -> bool:
40+
return bool(self._dispatcher.queue)
41+
42+
def _handle_commands(self, commands) -> int:
43+
"""Process commands with per-command exception handling.
44+
45+
Returns number of commands routed to a handler.
46+
"""
47+
processed = 0
48+
for cmd in commands:
49+
try:
50+
routed = self._router.route(cmd, self._world)
51+
except Exception:
52+
logger.exception(f"Command handler failed for {type(cmd).__name__}")
53+
else:
54+
if routed:
55+
processed += 1
56+
return processed
57+
58+
def drain(self) -> int:
59+
"""Route pending commands present at drain start toward handlers.
60+
61+
Only commands that were in the dispatcher when drain() was called are
62+
processed. Commands emitted by handlers during this call remain in the
63+
dispatcher's queue and will be processed later.
64+
"""
65+
commands = self._dispatcher.pop_all()
66+
if not commands:
67+
return 0
68+
return self._handle_commands(commands)
69+
4870
def step(self) -> None:
4971
"""Execute one simulation step.
5072
51-
Entity cleanup (e.g., Destroyed component) should be handled
52-
by user-registered systems.
73+
Runs systems, routes commands produced during the step, and increments
74+
the global `steps` counter.
5375
"""
54-
# Phase 1: Systems run and emit commands
5576
self._world.step(self._dispatcher)
56-
57-
# Phase 2: Route commands to handlers
5877
commands = self._dispatcher.pop_all()
5978
if commands:
60-
self._router.handle_all(commands, self._world)
61-
79+
self._handle_commands(commands)
6280
self.steps += 1
81+
82+
def step_until_idle(self) -> int:
83+
"""Run steps until there are no pending commands.
84+
85+
Returns the number of steps executed during this call.
86+
"""
87+
steps = 0
88+
while self._dispatcher.queue:
89+
self.step()
90+
steps += 1
91+
return steps

tests/test_runtime.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import logging
2+
3+
from hive import Runtime
4+
5+
6+
class CmdA:
7+
pass
8+
9+
10+
class CmdB:
11+
pass
12+
13+
14+
class BadCmd:
15+
pass
16+
17+
18+
def test_drain_processes_initial_queue():
19+
rt = Runtime()
20+
called = []
21+
22+
def handle_a(cmd, world):
23+
called.append(("A", cmd))
24+
25+
rt.router.register(CmdA, handle_a)
26+
27+
rt.dispatcher.dispatch(CmdA())
28+
rt.dispatcher.dispatch(CmdA())
29+
30+
n = rt.drain()
31+
assert n == 2
32+
assert len(called) == 2
33+
assert not rt.dispatcher.queue
34+
35+
36+
def test_drain_does_not_process_commands_added_by_handlers():
37+
rt = Runtime()
38+
called = []
39+
40+
def handle_a(cmd, world):
41+
rt.dispatcher.dispatch(CmdB())
42+
called.append("A")
43+
44+
def handle_b(cmd, world):
45+
called.append("B")
46+
47+
rt.router.register(CmdA, handle_a)
48+
rt.router.register(CmdB, handle_b)
49+
50+
rt.dispatcher.dispatch(CmdA())
51+
n = rt.drain()
52+
assert n == 1
53+
assert called == ["A"]
54+
assert len(rt.dispatcher.queue) == 1
55+
56+
57+
def test_handler_exception_is_caught_and_logged(caplog):
58+
rt = Runtime()
59+
60+
def bad_handler(cmd, world):
61+
raise RuntimeError("handler failed")
62+
63+
def good_handler(cmd, world):
64+
pass
65+
66+
rt.router.register(BadCmd, bad_handler)
67+
rt.router.register(CmdA, good_handler)
68+
69+
rt.dispatcher.dispatch(BadCmd())
70+
rt.dispatcher.dispatch(CmdA())
71+
72+
caplog.set_level(logging.ERROR)
73+
n = rt.drain()
74+
# only CmdA routed successfully (BadCmd raised)
75+
assert n == 1
76+
assert any("Command handler failed" in rec.getMessage() for rec in caplog.records)

0 commit comments

Comments
 (0)