aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/tests/test_runview.py20
-rw-r--r--app/ui/runview.py47
2 files changed, 59 insertions, 8 deletions
diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py
index 73cae5f..df697c8 100644
--- a/app/tests/test_runview.py
+++ b/app/tests/test_runview.py
@@ -119,6 +119,12 @@ class StateTransitionTests(_FakeTui, unittest.TestCase):
view.handle_event({"kind": "server_down"})
self.assertEqual(view.server, "down")
+ def test_server_stopped_event_marks_server_stopped(self):
+ view, _ = self.make_view()
+ view.server = "stopping"
+ view.handle_event({"kind": "server_stopped"})
+ self.assertEqual(view.server, "stopped")
+
class RenderTests(_FakeTui, unittest.TestCase):
def _strings(self, screen):
@@ -149,6 +155,20 @@ class RenderTests(_FakeTui, unittest.TestCase):
self.assertIn("book.txt", text)
self.assertIn("press any key", text)
+ def test_stopping_status_shows_elapsed(self):
+ view, screen = self.make_view()
+ view.handle_event({"kind": "book", "index": 1, "total": 1,
+ "name": "book.txt"})
+ view.handle_event({"kind": "book_done", "name": "book.txt",
+ "ok": True})
+ view.handle_event({"kind": "done", "ok": 1, "total": 1})
+ view.server = "stopping"
+ view.stop_started = 999.0
+ view.render()
+ text = self._strings(screen)
+ self.assertIn("stopping", text)
+ self.assertIn("(1s)", text)
+
def test_error_screen_shows_detail_and_log(self):
view, screen = self.make_view(log_path="/tmp/audiobook.log")
view.handle_event({"kind": "book", "index": 1, "total": 1,
diff --git a/app/ui/runview.py b/app/ui/runview.py
index d65978d..f016922 100644
--- a/app/ui/runview.py
+++ b/app/ui/runview.py
@@ -44,6 +44,7 @@ _SERVER_STATES = {
"starting": ("starting", "warn"),
"ready": ("ready", "ok"),
"processing": ("processing", "ok"),
+ "stopping": ("stopping", "warn"),
"down": ("not responding", "err"),
"error": ("error", "err"),
"stopped": ("stopped", "info"),
@@ -113,6 +114,7 @@ class RunView:
self.finished_at: Optional[float] = None
self.boot_started: Optional[float] = None
self.convert_started: Optional[float] = None
+ self.stop_started: Optional[float] = None
self.server_log_path = ""
# -- threads ---------------------------------------------------
self._queue: Queue = Queue()
@@ -156,6 +158,8 @@ class RunView:
elif kind == "server_down":
if self.phase == "convert":
self.server = "down"
+ elif kind == "server_stopped":
+ self.server = "stopped"
elif kind == "book":
self.phase = "convert"
self.book = (event.get("index"), event.get("total"),
@@ -349,7 +353,13 @@ class RunView:
return True
def _confirm_stop_server(self) -> None:
- """Ask whether to stop the server this run started (once)."""
+ """Ask whether to stop the server this run started (once).
+
+ On "yes" the stop runs on a background thread while the screen keeps
+ redrawing the server panel — showing "stopping" with an elapsed clock,
+ mirroring the boot screen — so the SIGTERM grace period never freezes
+ the TUI. Returns once the server is gone.
+ """
if not self.started_server or self._server_stopped_confirmed:
return
self._server_stopped_confirmed = True
@@ -357,13 +367,32 @@ class RunView:
if not name or not servers.alive(name):
return
self._blocking()
- answer = tui.confirm(self.scr,
- f"Stop the '{name}' server now?", default=True,
- cancel_value=False)
- if answer:
- with contextlib.redirect_stdout(io.StringIO()):
- servers.stop(name)
- self.server = "stopped"
+ try:
+ answer = tui.confirm(self.scr,
+ f"Stop the '{name}' server now?", default=True,
+ cancel_value=False)
+ finally:
+ self._nonblocking()
+ if not answer:
+ return
+ self.server = "stopping"
+ self.stop_started = self._now()
+ done = threading.Event()
+
+ def _stop() -> None:
+ try:
+ with contextlib.redirect_stdout(io.StringIO()):
+ servers.stop(name)
+ finally:
+ self._queue.put({"kind": "server_stopped"})
+ done.set()
+
+ threading.Thread(target=_stop, daemon=True).start()
+ while not done.wait(_DRAW_TIMEOUT_MS / 1000.0):
+ self._drain()
+ self.render()
+ self._drain()
+ self.render()
def _blocking(self) -> None:
"""Make getch block (used while a confirm dialog owns the screen)."""
@@ -417,6 +446,8 @@ class RunView:
self.server, (self.server, "info"))
if self.server == "starting" and self.boot_started is not None:
state_text += f" ({int(self._now() - self.boot_started)}s)"
+ elif self.server == "stopping" and self.stop_started is not None:
+ state_text += f" ({int(self._now() - self.stop_started)}s)"
if self.config.autostart_spec is None and self.server == "ready":
state_text += " (external)"
_text(scr, theme, y, inner_x, "Status".ljust(label_w), theme["dim"])