Skip to content

Commit a25006c

Browse files
Decouple renderer from TUI by running it in an independent thread
1 parent 051d794 commit a25006c

2 files changed

Lines changed: 39 additions & 83 deletions

File tree

plain2code.py

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging.config
44
import os
55
import sys
6+
import threading
67
from pathlib import Path
78
from typing import Optional
89

@@ -16,6 +17,7 @@
1617
import plain_spec
1718
from event_bus import EventBus
1819
from module_renderer import ModuleRenderer
20+
from plain2code_events import RenderFailed
1921
from plain2code_arguments import parse_arguments
2022
from plain2code_console import console
2123
from plain2code_exceptions import (
@@ -235,30 +237,37 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901
235237
event_bus,
236238
)
237239

240+
render_error: list[Exception] = []
241+
242+
def run_render():
243+
try:
244+
module_renderer.render_module()
245+
except Exception as e:
246+
render_error.append(e)
247+
event_bus.publish(RenderFailed(error_message=str(e)))
248+
249+
render_thread = threading.Thread(target=run_render, daemon=True)
250+
238251
if args.headless:
239-
# In headless mode, this will be the only output
240252
print(f"Render started. Render ID: {run_state.render_id}")
241-
module_renderer.render_module()
242-
return
243-
244-
app = Plain2CodeTUI(
245-
event_bus=event_bus,
246-
worker_fun=module_renderer.render_module,
247-
render_id=run_state.render_id,
248-
unittests_script=args.unittests_script,
249-
conformance_tests_script=args.conformance_tests_script,
250-
prepare_environment_script=args.prepare_environment_script,
251-
state_machine_version=system_config.client_version,
252-
css_path="styles.css",
253-
)
254-
result = app.run()
255-
256-
# If the app exited due to a worker error, re-raise it here
257-
# so it hits the exception handlers in main()
258-
if isinstance(result, Exception):
259-
raise result
253+
render_thread.start()
254+
render_thread.join()
255+
else:
256+
app = Plain2CodeTUI(
257+
event_bus=event_bus,
258+
on_ready=render_thread.start,
259+
render_id=run_state.render_id,
260+
unittests_script=args.unittests_script,
261+
conformance_tests_script=args.conformance_tests_script,
262+
prepare_environment_script=args.prepare_environment_script,
263+
state_machine_version=system_config.client_version,
264+
css_path="styles.css",
265+
)
266+
app.run()
267+
render_thread.join(timeout=1)
260268

261-
return
269+
if render_error:
270+
raise render_error[0]
262271

263272

264273
def main(): # noqa: C901

tui/plain2code_tui.py

Lines changed: 9 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
1-
import os
2-
import threading
3-
import time
41
from typing import Callable, Optional
52

63
from textual.app import App, ComposeResult
74
from textual.binding import Binding
85
from textual.containers import Vertical, VerticalScroll
96
from textual.css.query import NoMatches
107
from textual.widgets import ContentSwitcher, Static
11-
from textual.worker import Worker, WorkerFailed, WorkerState
128

139
from event_bus import EventBus
14-
from plain2code_console import console
1510
from plain2code_events import (
1611
LogMessageEmitted,
1712
RenderCompleted,
@@ -48,9 +43,6 @@
4843
UnitTestsHandler,
4944
)
5045

51-
FORCE_EXIT_DELAY = 0.5 # seconds
52-
53-
5446
class Plain2CodeTUI(App):
5547
"""A Textual TUI for plain2code."""
5648

@@ -64,7 +56,7 @@ class Plain2CodeTUI(App):
6456
def __init__(
6557
self,
6658
event_bus: EventBus,
67-
worker_fun: Callable[[], None],
59+
on_ready: Callable[[], None],
6860
render_id: str,
6961
unittests_script: str,
7062
conformance_tests_script: str,
@@ -75,7 +67,7 @@ def __init__(
7567
super().__init__(**kwargs)
7668
self.dark = True # Set dark mode as default
7769
self.event_bus = event_bus
78-
self.worker_fun = worker_fun
70+
self._on_ready = on_ready
7971
self.render_id = render_id
8072
self.unittests_script: Optional[str] = unittests_script
8173
self.conformance_tests_script: Optional[str] = conformance_tests_script
@@ -134,39 +126,7 @@ def on_mount(self) -> None:
134126
self.event_bus.subscribe(RenderModuleCompleted, self.on_render_module_completed)
135127
self.event_bus.subscribe(LogMessageEmitted, self.on_log_message_emitted)
136128

137-
self.render_worker = self.run_worker(self.worker_fun, thread=True)
138-
139-
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
140-
"""Handle worker state changes."""
141-
if event.worker.state == WorkerState.ERROR:
142-
# Extract the original exception from WorkerFailed wrapper
143-
error = event.worker.error
144-
original_error = error.__cause__ if isinstance(error, WorkerFailed) and error.__cause__ else error
145-
self.exit(result=original_error)
146-
147-
def _handle_exception(self, error: Exception) -> None:
148-
"""Override Textual's exception handler to suppress console tracebacks for worker errors.
149-
150-
Worker exceptions are logged to file via the logging system (configured in file handler in plain2code.py),
151-
but the verbose Textual/Rich traceback is suppressed from the terminal. The clean error message
152-
is displayed via the exception handlers in main().
153-
"""
154-
# Because TUI is running in main thread and code renderer is running in a worker thread, textual models this
155-
# by raising a WorkerFailed exception in this case
156-
if isinstance(error, WorkerFailed):
157-
# Here, we still print the error to get some additional information to the file, but it's probably
158-
# not necessary because we either way print the entire traceback to the console. Here, we could in the future
159-
# print more information if we would want to.
160-
original_error = error.__cause__ if error.__cause__ else error
161-
console.error(
162-
f"Worker failed with exception: {type(original_error).__name__}: {original_error}",
163-
exc_info=(type(original_error), original_error, original_error.__traceback__),
164-
stack_info=False,
165-
)
166-
return
167-
168-
# For non-worker exceptions, use the default Textual behavior
169-
super()._handle_exception(error)
129+
self._on_ready()
170130

171131
def compose(self) -> ComposeResult:
172132
"""Create child widgets for the app."""
@@ -290,24 +250,12 @@ def on_render_completed(self, event: RenderCompleted):
290250
def on_render_failed(self, event: RenderFailed):
291251
"""Handle render failure."""
292252
self._render_error_handler.handle(event.error_message)
293-
294-
def _ensure_exit(self) -> None:
295-
"""Ensure exit the application immediately."""
296-
297-
def ensure_exit():
298-
time.sleep(FORCE_EXIT_DELAY)
299-
try:
300-
if hasattr(self, "_driver") and self._driver is not None:
301-
# suspend_application_mode() calls stop_application_mode() and close()
302-
# It ensures terminal reset sequences are flushed before exiting.
303-
self._driver.suspend_application_mode()
304-
except Exception as e:
305-
log_to_widget(self, "WARNING", f"Error suspending application mode: {type(e).__name__}: {e}")
306-
finally:
307-
os._exit(0) # Kill process immediately, no cleanup - terminates all threads
308-
309-
# daemon=True ensures this thread dies with the process if it exits before the timer fires
310-
threading.Thread(target=ensure_exit, daemon=True).start()
253+
self._render_finished = True
254+
try:
255+
footer = self.screen.query_one(CustomFooter)
256+
footer.show_render_finished()
257+
except NoMatches:
258+
pass
311259

312260
async def action_copy_selection(self) -> None:
313261
"""Handle ctrl+c: copy selected text if any.
@@ -332,5 +280,4 @@ def action_quit(self) -> None:
332280
Note: Force exit may leave files partially written if interrupted during file I/O operations.
333281
This is acceptable since the folders in which we are writing are git versioned and are reset in the next render.
334282
"""
335-
self.render_worker.cancel()
336283
self.exit()

0 commit comments

Comments
 (0)