Skip to content

Commit fbd15d7

Browse files
pedjaradenkovicVitjanZ
authored andcommitted
Decouple renderer from TUI by running it in an independent thread
1 parent bc5b726 commit fbd15d7

2 files changed

Lines changed: 39 additions & 82 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

@@ -18,6 +19,7 @@
1819
from module_renderer import ModuleRenderer
1920
from plain2code_arguments import parse_arguments
2021
from plain2code_console import console
22+
from plain2code_events import RenderFailed
2123
from plain2code_exceptions import (
2224
ConflictingRequirements,
2325
CreditBalanceTooLow,
@@ -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 & 61 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,8 +43,6 @@
4843
UnitTestsHandler,
4944
)
5045

51-
FORCE_EXIT_DELAY = 0.5 # seconds
52-
5346

5447
class Plain2CodeTUI(App):
5548
"""A Textual TUI for plain2code."""
@@ -64,7 +57,7 @@ class Plain2CodeTUI(App):
6457
def __init__(
6558
self,
6659
event_bus: EventBus,
67-
worker_fun: Callable[[], None],
60+
on_ready: Callable[[], None],
6861
render_id: str,
6962
unittests_script: str,
7063
conformance_tests_script: str,
@@ -75,7 +68,7 @@ def __init__(
7568
super().__init__(**kwargs)
7669
self.dark = True # Set dark mode as default
7770
self.event_bus = event_bus
78-
self.worker_fun = worker_fun
71+
self._on_ready = on_ready
7972
self.render_id = render_id
8073
self.unittests_script: Optional[str] = unittests_script
8174
self.conformance_tests_script: Optional[str] = conformance_tests_script
@@ -134,39 +127,7 @@ def on_mount(self) -> None:
134127
self.event_bus.subscribe(RenderModuleCompleted, self.on_render_module_completed)
135128
self.event_bus.subscribe(LogMessageEmitted, self.on_log_message_emitted)
136129

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)
130+
self._on_ready()
170131

171132
def compose(self) -> ComposeResult:
172133
"""Create child widgets for the app."""
@@ -290,24 +251,12 @@ def on_render_completed(self, event: RenderCompleted):
290251
def on_render_failed(self, event: RenderFailed):
291252
"""Handle render failure."""
292253
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()
254+
self._render_finished = True
255+
try:
256+
footer = self.screen.query_one(CustomFooter)
257+
footer.show_render_finished()
258+
except NoMatches:
259+
pass
311260

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

0 commit comments

Comments
 (0)