Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions module_renderer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import argparse
import os
import threading

import git_utils
import plain_file
Expand Down Expand Up @@ -28,6 +29,7 @@ def __init__(
args: argparse.Namespace,
run_state: RunState,
event_bus: EventBus,
stop_event: threading.Event | None = None,
):
self.codeplainAPI = codeplainAPI
self.filename = filename
Expand All @@ -36,6 +38,7 @@ def __init__(
self.args = args
self.run_state = run_state
self.event_bus = event_bus
self.stop_event = stop_event

def _ensure_module_folders_exist(self, module_name: str, first_render_frid: str) -> tuple[str, str]:
"""
Expand Down Expand Up @@ -177,6 +180,7 @@ def _build_render_context_for_module(
run_state=self.run_state,
event_bus=self.event_bus,
test_script_timeout=self.args.test_script_timeout,
stop_event=self.stop_event,
)

def _render_module(
Expand Down
17 changes: 15 additions & 2 deletions plain2code.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import logging.config
import os
import signal
import sys
import threading
from pathlib import Path
Expand Down Expand Up @@ -35,6 +36,7 @@
NetworkConnectionError,
OutdatedClientVersion,
PlainSyntaxError,
RenderCancelledError,
)
from plain2code_logger import (
LOGGER_NAME,
Expand All @@ -50,6 +52,7 @@
from tui.plain2code_tui import Plain2CodeTUI

DEFAULT_TEMPLATE_DIRS = importlib.resources.files("standard_template_library")
RENDER_THREAD_SHUTDOWN_TIMEOUT = 0.7


def get_render_range(render_range, plain_source):
Expand Down Expand Up @@ -199,6 +202,9 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901

_check_connection(codeplainAPI)

stop_event = threading.Event()
signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set())

module_renderer = ModuleRenderer(
codeplainAPI,
args.filename,
Expand All @@ -207,20 +213,26 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901
args,
run_state,
event_bus,
stop_event=stop_event,
)

render_error: list[Exception] = []

def run_render():
try:
module_renderer.render_module()
except RenderCancelledError:
pass # TUI already closed, nothing to report
except Exception as e:
render_error.append(e)
event_bus.publish(RenderFailed(error_message=str(e)))

if args.headless:
print(f"Render started. Render ID: {run_state.render_id}")
module_renderer.render_module()
try:
module_renderer.render_module()
except RenderCancelledError:
pass
return
else:
render_thread = threading.Thread(target=run_render, daemon=True)
Expand All @@ -235,7 +247,8 @@ def run_render():
css_path="styles.css",
)
app.run()
render_thread.join(timeout=1)
stop_event.set()
render_thread.join(timeout=RENDER_THREAD_SHUTDOWN_TIMEOUT)

if render_error:
raise render_error[0]
Expand Down
6 changes: 6 additions & 0 deletions plain2code_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,9 @@ class AmbiguousConfigFileError(Exception):
"""Raised when a config file is found in both the plain file directory and the current working directory."""

pass


class RenderCancelledError(Exception):
"""Raised when the render is cancelled by the user closing the TUI."""

pass
1 change: 1 addition & 0 deletions render_machine/actions/prepare_testing_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any |
render_context.verbose,
"Testing Environment Preparation",
timeout=render_context.test_script_timeout,
stop_event=render_context.stop_event,
)

render_context.conformance_tests_running_context.should_prepare_testing_environment = False
Expand Down
1 change: 1 addition & 0 deletions render_machine/actions/run_conformance_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any |
"Conformance Tests",
frid=render_context.conformance_tests_running_context.current_testing_frid,
timeout=render_context.test_script_timeout,
stop_event=render_context.stop_event,
)
render_context.script_execution_history.latest_conformance_test_output_path = conformance_tests_temp_file_path
render_context.script_execution_history.should_update_script_outputs = True
Expand Down
1 change: 1 addition & 0 deletions render_machine/actions/run_unit_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any |
render_context.verbose,
"Unit Tests",
timeout=render_context.test_script_timeout,
stop_event=render_context.stop_event,
)

render_context.script_execution_history.latest_unit_test_output_path = unittests_temp_file_path
Expand Down
3 changes: 3 additions & 0 deletions render_machine/render_context.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import threading
from copy import deepcopy
from typing import Optional

Expand Down Expand Up @@ -52,6 +53,7 @@ def __init__(
run_state: RunState,
event_bus: EventBus,
test_script_timeout: Optional[int] = None,
stop_event: Optional[threading.Event] = None,
):
self.codeplain_api: CodeplainAPI = codeplain_api
self.memory_manager = memory_manager
Expand All @@ -74,6 +76,7 @@ def __init__(
self.verbose = verbose
self.run_state = run_state
self.event_bus = event_bus
self.stop_event = stop_event
self.script_execution_history = ScriptExecutionHistory()
self.starting_frid = None
self.test_script_timeout = test_script_timeout
Expand Down
88 changes: 72 additions & 16 deletions render_machine/render_utils.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import os
import signal
import subprocess
import sys
import tempfile
import threading
import time
from typing import Optional

import file_utils
import git_utils
import plain_spec
from plain2code_console import console
from plain2code_exceptions import RenderCancelledError

SCRIPT_EXECUTION_TIMEOUT = 120
TIMEOUT_ERROR_EXIT_CODE = 124
POLL_INTERVAL_SECONDS = 0.2
SIGTERM_GRACE_PERIOD_SECONDS = 0.2
STDOUT_READ_TIMEOUT_SECONDS = 5


def revert_changes_for_frid(render_context):
Expand Down Expand Up @@ -42,13 +49,35 @@ def print_inputs(render_context, existing_files_content, message):
)


def execute_script(
def _kill_process(proc: subprocess.Popen) -> None:
"""Kill a process and its entire process group."""
if sys.platform != "win32":
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except OSError:
proc.terminate()
else:
proc.terminate()
try:
proc.wait(timeout=SIGTERM_GRACE_PERIOD_SECONDS)
except subprocess.TimeoutExpired:
if sys.platform != "win32":
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except OSError:
proc.kill()
else:
proc.kill()


def execute_script( # noqa: C901
script: str,
scripts_args: list[str],
verbose: bool,
script_type: str,
frid: Optional[str] = None,
timeout: Optional[int] = None,
stop_event: Optional[threading.Event] = None,
) -> tuple[int, str, Optional[str]]:
temp_file_path = None
script_timeout = timeout if timeout is not None else SCRIPT_EXECUTION_TIMEOUT
Expand All @@ -59,36 +88,60 @@ def execute_script(
cmd = ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script_path] + scripts_args
else:
cmd = [script_path] + scripts_args

start_time = time.time()
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
start_new_session=(sys.platform != "win32"),
)

try:
start_time = time.time()
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
timeout=script_timeout,
)
while proc.poll() is None:
if time.time() - start_time >= script_timeout:
_kill_process(proc)
partial_stdout = proc.stdout.read()
exc = subprocess.TimeoutExpired(cmd, script_timeout)
exc.stdout = partial_stdout
raise exc
if stop_event is not None:
stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
if stop_event.is_set():
_kill_process(proc)
raise RenderCancelledError()
else:
time.sleep(POLL_INTERVAL_SECONDS)

# Use communicate() with a timeout because child processes may hold the pipe open after the main process exits.
try:
stdout, _ = proc.communicate(timeout=STDOUT_READ_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
proc.stdout.close()
stdout = ""
elapsed_time = time.time() - start_time

# Log the info about the script execution
if verbose:
with tempfile.NamedTemporaryFile(
mode="w+", encoding="utf-8", delete=False, suffix=".script_output"
) as temp_file:
temp_file.write(f"\n═════════════════════════ {script_type} Script Output ═════════════════════════\n")
temp_file.write(result.stdout)
temp_file.write(stdout)
temp_file.write("\n══════════════════════════════════════════════════════════════════════\n")
temp_file_path = temp_file.name
if result.returncode != 0:
temp_file.write(f"{script_type} script {script} failed with exit code {result.returncode}.\n")
if proc.returncode != 0:
temp_file.write(f"{script_type} script {script} failed with exit code {proc.returncode}.\n")
else:
temp_file.write(f"{script_type} script {script} successfully passed.\n")
temp_file.write(f"{script_type} script execution time: {elapsed_time:.2f} seconds.\n")

console.info(f"[#888888]{script_type} script output stored in: {temp_file_path.strip()}[/#888888]")

if result.returncode != 0:
if proc.returncode != 0:
if frid is not None:
console.info(
f"The {script_type} script for ID {frid} has failed. Initiating the patching mode to automatically correct the discrepancies."
Expand All @@ -103,7 +156,10 @@ def execute_script(
else:
console.info(f"[#79FC96]All {script_type} script passed successfully.[/#79FC96]")

return result.returncode, result.stdout, temp_file_path
return proc.returncode, stdout, temp_file_path

except RenderCancelledError:
raise
except subprocess.TimeoutExpired as e:
# Store timeout output in a temporary file
if verbose:
Expand Down
Loading