diff --git a/cobot/commands/delete.py b/cobot/commands/delete.py index 2f99049..0041a6c 100644 --- a/cobot/commands/delete.py +++ b/cobot/commands/delete.py @@ -178,13 +178,15 @@ def _task_delete(screen: LogScreen, remove_ros: bool, remove_webots: bool) -> No screen.set_progress(88, "Removing project directory...") _remove_project_dir(screen.write) - screen.set_progress(100, "Done") - screen.write("\n[green]Project fully removed.[/green]") - screen.finish(True) + if not screen.is_stopped(): + screen.set_progress(100, "Done") + screen.write("\n[green]Project fully removed.[/green]") + screen.finish(True) except Exception as exc: - screen.write(f"\n[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) # Multi-step confirmation wizard before anything is deleted. diff --git a/cobot/commands/doc_setup.py b/cobot/commands/doc_setup.py index ca650a7..c5afaa7 100644 --- a/cobot/commands/doc_setup.py +++ b/cobot/commands/doc_setup.py @@ -53,6 +53,7 @@ def _image_exists() -> bool: def _build_docs_image( write: Write, on_progress: Optional[Callable[[float], None]] = None, + register_proc: Optional[Callable] = None, ) -> bool: write("[cyan][*][/cyan] Building documentation image (runs once)...") # DOCKER_BUILDKIT=0 gives us "Step X/Y" lines that we can parse for progress. @@ -62,6 +63,8 @@ def _build_docs_image( ["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, ) + if register_proc: + register_proc(proc) for line in proc.stdout: s = line.rstrip() if s: @@ -72,6 +75,8 @@ def _build_docs_image( step, total = int(m.group(1)), int(m.group(2)) on_progress(step / total * 100) proc.wait() + if proc.returncode in (-9, -15): + return False if proc.returncode == 0: write("[green][ok][/green] Documentation image ready") return True @@ -86,25 +91,34 @@ def _task_up(screen: LogScreen, port: str) -> None: if _is_running(): screen.write(f"[green]Docs already running at:[/green] http://localhost:{port}") screen.write(" Stop with: [bold]cobot doc-setup down[/bold]") - screen.finish(True) + if not screen.is_stopped(): + screen.finish(True) return if not _DOC_DIR.exists(): screen.write(f"[red]Doc directory not found:[/red] {_DOC_DIR}") - screen.finish(False) + if not screen.is_stopped(): + screen.finish(False) return if not _image_exists(): screen.set_progress(0, "Building documentation image...") - if not _build_docs_image( + ok = _build_docs_image( screen.write, on_progress=lambda p: screen.set_progress(p * 0.85, "Building documentation image..."), - ): + register_proc=screen.set_proc, + ) + if screen.is_stopped(): + return + if not ok: screen.finish(False) return else: screen.write("[dim]Documentation image already built, skipping.[/dim]") + if screen.is_stopped(): + return + screen.set_progress(88, "Starting MkDocs server...") screen.write("\n[cyan][*][/cyan] Starting MkDocs server...") result = _docker( @@ -116,6 +130,8 @@ def _task_up(screen: LogScreen, port: str) -> None: _IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000", capture=True, ) + if screen.is_stopped(): + return if result.returncode != 0: screen.write(f"[red]Failed to start container.[/red]\n{result.stderr}") screen.finish(False) @@ -125,11 +141,13 @@ def _task_up(screen: LogScreen, port: str) -> None: screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}") screen.write(" Edit files in [bold]doc/lwc-doc/docs/[/bold] — reloads automatically.") screen.write(" Stop with: [bold]cobot doc-setup down[/bold]") - screen.finish(True) + if not screen.is_stopped(): + screen.finish(True) except Exception as exc: - screen.write(f"[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"[red]Error:[/red] {exc}") + screen.finish(False) # Stop the running docs container. @@ -138,17 +156,21 @@ def _task_down(screen: LogScreen) -> None: try: if not _is_running(): screen.write("[yellow]Docs container is not running.[/yellow]") - screen.finish(True) + if not screen.is_stopped(): + screen.finish(True) return screen.set_progress(30, "Stopping container...") screen.write("[cyan][*][/cyan] Stopping documentation server...") _docker("stop", _CONTAINER_NAME) + if screen.is_stopped(): + return screen.set_progress(100, "Done") screen.write("[green][ok][/green] Container stopped.") screen.finish(True) except Exception as exc: - screen.write(f"[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"[red]Error:[/red] {exc}") + screen.finish(False) # Stop the container, remove the old image, rebuild it, and start a new container. @@ -159,19 +181,27 @@ def _task_rebuild(screen: LogScreen, port: str) -> None: screen.set_progress(5, "Stopping container...") screen.write("[cyan][*][/cyan] Stopping existing container...") _docker("stop", _CONTAINER_NAME) + if screen.is_stopped(): + return screen.write("[green][ok][/green] Stopped.") if _image_exists(): screen.set_progress(15, "Removing old image...") screen.write("[cyan][*][/cyan] Removing old image...") _docker("rmi", "-f", _IMAGE_NAME) + if screen.is_stopped(): + return screen.write("[green][ok][/green] Image removed.") screen.set_progress(20, "Building documentation image...") - if not _build_docs_image( + ok = _build_docs_image( screen.write, on_progress=lambda p: screen.set_progress(20 + p * 0.68, "Building documentation image..."), - ): + register_proc=screen.set_proc, + ) + if screen.is_stopped(): + return + if not ok: screen.finish(False) return @@ -184,6 +214,8 @@ def _task_rebuild(screen: LogScreen, port: str) -> None: _IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000", capture=True, ) + if screen.is_stopped(): + return if result.returncode != 0: screen.write(f"[red]Failed to start container.[/red]\n{result.stderr}") screen.finish(False) @@ -192,11 +224,13 @@ def _task_rebuild(screen: LogScreen, port: str) -> None: screen.set_progress(100, "Server running") screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}") screen.write(" Stop with: [bold]cobot doc-setup down[/bold]") - screen.finish(True) + if not screen.is_stopped(): + screen.finish(True) except Exception as exc: - screen.write(f"[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"[red]Error:[/red] {exc}") + screen.finish(False) # One app handles all three actions (up/down/rebuild) by branching in on_mount. diff --git a/cobot/commands/docker_setup.py b/cobot/commands/docker_setup.py index d708f21..4d43c8b 100644 --- a/cobot/commands/docker_setup.py +++ b/cobot/commands/docker_setup.py @@ -68,6 +68,7 @@ def _build_image( on_progress: Optional[Callable[[float], None]] = None, parent_tag: Optional[str] = None, build_type: str = "release", + register_proc: Optional[Callable] = None, ) -> bool: write(f"[cyan][*][/cyan] Building [bold]{name}[/bold]...") # DOCKER_BUILDKIT=0 gives us "Step X/Y" lines in the output which we parse for progress. @@ -84,6 +85,8 @@ def _build_image( proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, ) + if register_proc: + register_proc(proc) for line in proc.stdout: s = line.rstrip() if s: @@ -94,6 +97,8 @@ def _build_image( step, total = int(m.group(1)), int(m.group(2)) on_progress(step / total * 100) proc.wait() + if proc.returncode in (-9, -15): + return False if proc.returncode == 0: write(f"[green][ok][/green] {name}") return True @@ -102,12 +107,13 @@ def _build_image( # Pull a Docker image from Hub and track progress by counting downloaded layers. -# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеству скачанных слоёв. +# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеством скачанных слоёв. def _pull_image( name: str, tag: str, write: Write, on_progress: Optional[Callable[[float], None]] = None, + register_proc: Optional[Callable] = None, ) -> bool: write(f"[cyan][*][/cyan] Pulling [bold]{name}[/bold] ({tag})...") if on_progress: @@ -117,6 +123,8 @@ def _pull_image( ["docker", "pull", tag], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) + if register_proc: + register_proc(proc) layers_total = 0 layers_done = 0 for line in proc.stdout: @@ -132,6 +140,8 @@ def _pull_image( if on_progress and layers_total > 0: on_progress(5 + layers_done / layers_total * 90) proc.wait() + if proc.returncode in (-9, -15): + return False if proc.returncode == 0: write(f"[green][ok][/green] {name}") if on_progress: @@ -156,6 +166,8 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None: f"ROS {cfg.ros_version} — {cfg.build_type}[/bold]\n" ) for i, name in enumerate(chain): + if screen.is_stopped(): + return lo = i / n * 100 hi = (i + 1) / n * 100 screen.set_progress(lo, f"Image {i + 1}/{n}: building {name}...") @@ -164,7 +176,8 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None: dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile" if not dockerfile.exists(): screen.write(f"[red]Dockerfile not found:[/red] {dockerfile}") - screen.finish(False) + if not screen.is_stopped(): + screen.finish(False) return ctx = _PROJECT_DIR if name in _NEEDS_PROJECT_CTX else dockerfile.parent parent_name = _IMAGE_PARENT.get(name) @@ -172,23 +185,28 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None: f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}" if parent_name else None ) - if not _build_image( + ok = _build_image( name, tag, dockerfile, ctx, screen.write, on_progress=lambda p, lo=lo, hi=hi: screen.set_progress( lo + p * (hi - lo) / 100, f"Image {i + 1}/{n}: building {name}..." ), parent_tag=parent_tag, build_type=cfg.build_type, - ): + register_proc=screen.set_proc, + ) + if screen.is_stopped(): + return + if not ok: screen.finish(False) return screen.set_progress(hi) - screen.set_progress(100, "All images built") - screen.write( - f"\n[green]Done.[/green] " - f"Images tagged [bold]{cfg.image_prefix}:-{cfg.ros_version}[/bold]." - ) + if not screen.is_stopped(): + screen.set_progress(100, "All images built") + screen.write( + f"\n[green]Done.[/green] " + f"Images tagged [bold]{cfg.image_prefix}:-{cfg.ros_version}[/bold]." + ) else: short = "webots" if cfg.variant == "webots" else "iiwa" @@ -199,20 +217,26 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None: f"ROS {cfg.ros_version} — {cfg.build_type}[/bold]\n" ) screen.set_progress(0, f"Pulling {full_ref}...") - if not _pull_image( + ok = _pull_image( short, full_ref, screen.write, on_progress=lambda p: screen.set_progress(p, f"Pulling {full_ref}..."), - ): + register_proc=screen.set_proc, + ) + if screen.is_stopped(): + return + if not ok: screen.finish(False) return screen.set_progress(100, "Pull complete") screen.write(f"\n[green]Done.[/green] Image ready: [bold]{full_ref}[/bold].") - screen.finish(True) + if not screen.is_stopped(): + screen.finish(True) except Exception as exc: - screen.write(f"\n[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) # Scan the docker/ directory for subdirectories named after ROS versions (e.g. jazzy). diff --git a/cobot/commands/local_setup.py b/cobot/commands/local_setup.py index e0ab50d..bebc5a9 100644 --- a/cobot/commands/local_setup.py +++ b/cobot/commands/local_setup.py @@ -78,7 +78,13 @@ def _run_quiet(cmd: List[str], write: Write | None = None, env: dict | None = No # Run a command and stream every output line to the log in real time. # Запускаем команду и транслируем каждую строку вывода в лог в реальном времени. -def _run_logged(cmd: List[str], write: Write, env: dict | None = None, cwd=None) -> None: +def _run_logged( + cmd: List[str], + write: Write, + env: dict | None = None, + cwd=None, + register_proc: Callable | None = None, +) -> None: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -87,12 +93,14 @@ def _run_logged(cmd: List[str], write: Write, env: dict | None = None, cwd=None) env=env or os.environ, cwd=cwd, ) + if register_proc: + register_proc(proc) for line in proc.stdout: s = line.rstrip() if s: write(s) proc.wait() - if proc.returncode != 0: + if proc.returncode not in (0, -9): raise RuntimeError(f"Command failed: {cmd[0]}") @@ -101,6 +109,7 @@ def _run_apt_with_progress( write: Write, on_progress: Callable[[float], None], env: dict | None = None, + register_proc: Callable | None = None, ) -> None: """Run an apt command and feed real percentage from APT::Status-Fd to on_progress(0-100).""" # APT::Status-Fd makes apt write progress lines to a pipe descriptor instead of stdout. @@ -110,7 +119,7 @@ def _run_apt_with_progress( r_fd, w_fd = os.pipe() try: proc = subprocess.Popen( - cmd + [f"-o", f"APT::Status-Fd={w_fd}"], + cmd + ["-o", f"APT::Status-Fd={w_fd}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, @@ -122,6 +131,11 @@ def _run_apt_with_progress( # Закрываем пишущий конец в родительском процессе, чтобы читающий поток получил EOF при выходе apt. os.close(w_fd) + # Tell the caller about this process so it can be killed if the user cancels. + # Сообщаем вызывающему о процессе, чтобы его можно было завершить при отмене пользователем. + if register_proc: + register_proc(proc) + def _read_status() -> None: with os.fdopen(r_fd, "r") as f: for line in f: @@ -141,7 +155,7 @@ def _run_apt_with_progress( write(s) proc.wait() t.join() - if proc.returncode != 0: + if proc.returncode not in (0, -9): raise RuntimeError(f"Command failed: {cmd[0]}") @@ -162,7 +176,11 @@ def _setup_locale(write: Write) -> None: # Add the official ROS2 apt repository and its signing key so we can install ROS2 packages. # Добавляем официальный apt-репозиторий ROS2 и его ключ подписи, чтобы можно было установить пакеты ROS2. -def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None: +def _add_ros2_repo( + write: Write, + on_progress: Optional[Callable[[float], None]] = None, + register_proc: Callable | None = None, +) -> None: def _prog(p: float) -> None: if on_progress: on_progress(p) @@ -171,7 +189,6 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] # Best-effort update - 60 second timeout so a bad mirror doesn't hang forever. # Фоновое обновление с таймаутом 60 секунд, чтобы зависший зеркальный сервер не блокировал процесс. - # subprocess.run(["sudo", "apt-get", "update", "-qq"] + _APT_TIMEOUTS, capture_output=True, timeout=120) subprocess.run(["sudo", "apt-get", "update"] + _APT_TIMEOUTS, capture_output=True, timeout=120) _prog(15) @@ -224,6 +241,7 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] write, lambda p: _prog(70 + p * 0.30), _APT_ENV, + register_proc=register_proc, ) write("[green][ok][/green] ROS2 repository ready") _prog(100) @@ -231,13 +249,18 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] # Install the full ROS2 Jazzy Desktop and the developer tools (colcon, rosdep, etc.). # Устанавливаем полный ROS2 Jazzy Desktop и инструменты разработчика (colcon, rosdep и т.д.). -def _install_ros2_jazzy(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None: +def _install_ros2_jazzy( + write: Write, + on_progress: Optional[Callable[[float], None]] = None, + register_proc: Callable | None = None, +) -> None: write("[cyan][*][/cyan] Installing ros-jazzy-desktop and ros-dev-tools...") _run_apt_with_progress( ["sudo", "apt-get", "install", "-y", "ros-jazzy-desktop", "ros-dev-tools"] + _APT_TIMEOUTS, write, on_progress or (lambda _: None), _APT_ENV, + register_proc=register_proc, ) write("[green][ok][/green] ROS2 Jazzy Desktop installed") @@ -288,16 +311,24 @@ def _task_install_jazzy(screen: LogScreen) -> None: _add_ros2_repo( screen.write, on_progress=lambda p: screen.set_progress(5 + p * 0.15), + register_proc=screen.set_proc, ) + if screen.is_stopped(): + return + # Step 3 — ROS2 Jazzy (20 → 85 %) screen.set_progress(20, "Installing ROS2 Jazzy Desktop...") screen.write("\n[bold]Step 3 / 5 — ROS2 Jazzy Desktop[/bold]") _install_ros2_jazzy( screen.write, on_progress=lambda p: screen.set_progress(20 + p * 0.65), + register_proc=screen.set_proc, ) + if screen.is_stopped(): + return + # Step 4 — colcon (85 → 92 %) screen.set_progress(85, "Installing colcon...") screen.write("\n[bold]Step 4 / 5 — colcon[/bold]") @@ -309,13 +340,15 @@ def _task_install_jazzy(screen: LogScreen) -> None: _setup_shell_rc(screen.write) screen.set_progress(100, "Done") - screen.write( - "\nRestart the terminal, then run [bold]cobot local-setup[/bold] again to build." - ) - screen.finish(True) + if not screen.is_stopped(): + screen.write( + "\nRestart the terminal, then run [bold]cobot local-setup[/bold] again to build." + ) + screen.finish(True) except Exception as exc: - screen.write(f"\n[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) # Build all project packages with colcon and track progress by counting finished packages. @@ -348,14 +381,16 @@ def _task_build(screen: LogScreen) -> None: built += 1 screen.set_progress(built / total * 100, f"{built} / {total} packages done") - _run_logged(["colcon", "build", "--symlink-install"], _track, cwd=_PROJECT_DIR) + _run_logged(["colcon", "build", "--symlink-install"], _track, cwd=_PROJECT_DIR, register_proc=screen.set_proc) - screen.set_progress(100, "Build complete") - screen.write("\nActivate workspace: [bold]source install/setup.bash[/bold]") - screen.finish(True) + if not screen.is_stopped(): + screen.set_progress(100, "Build complete") + screen.write("\nActivate workspace: [bold]source install/setup.bash[/bold]") + screen.finish(True) except Exception as exc: - screen.write(f"\n[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) # Webots version that matches the Docker images used in this project. @@ -386,16 +421,31 @@ def _task_install_webots(screen: LogScreen) -> None: screen.write(f"[dim]{_WEBOTS_DEB_URL}[/dim]\n") screen.set_progress(0, "Downloading Webots...") - # urllib calls this hook periodically with how many bytes have been downloaded. - # urllib вызывает этот обратный вызов периодически с количеством скачанных байт. - def _hook(blocks: int, block_size: int, total: int) -> None: - if total > 0: - pct = min(blocks * block_size / total * 65, 65) - mb = blocks * block_size / 1_048_576 - total_mb = total / 1_048_576 - screen.set_progress(pct, f"Downloading... {mb:.0f} / {total_mb:.0f} MB") + # Download in 64 KB chunks so we can update the progress bar and bail out if the user + # cancels midway through instead of blocking in urlretrieve until the full file arrives. + # Скачиваем по 64 КБ, чтобы обновлять прогресс-бар и прерваться при отмене пользователем, + # а не блокироваться в urlretrieve до получения всего файла. + with urllib.request.urlopen(_WEBOTS_DEB_URL, timeout=30) as resp: + total = int(resp.headers.get("Content-Length", 0)) + downloaded = 0 + with open(deb_path, "wb") as f: + while True: + if screen.is_stopped(): + return + chunk = resp.read(65536) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if total > 0: + pct = min(downloaded / total * 65, 65) + mb = downloaded / 1_048_576 + total_mb = total / 1_048_576 + screen.set_progress(pct, f"Downloading... {mb:.0f} / {total_mb:.0f} MB") + + if screen.is_stopped(): + return - urllib.request.urlretrieve(_WEBOTS_DEB_URL, deb_path, _hook) screen.write("[green]Download complete.[/green]") screen.set_progress(65, "Installing package...") @@ -404,23 +454,28 @@ def _task_install_webots(screen: LogScreen) -> None: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) + screen.set_proc(proc) for line in proc.stdout: s = line.rstrip() if s: screen.write(s) proc.wait() - if proc.returncode != 0: + if proc.returncode not in (0, -9): screen.write("\n[red]Installation failed.[/red]") screen.finish(False) return + if screen.is_stopped(): + return + screen.set_progress(100, "Done") screen.write("\n[green]Webots installed successfully.[/green]") screen.finish(True) except Exception as exc: - screen.write(f"\n[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) # Minimal single-question app used between steps where a full wizard is not needed. diff --git a/cobot/commands/update.py b/cobot/commands/update.py index 3505755..e99581e 100644 --- a/cobot/commands/update.py +++ b/cobot/commands/update.py @@ -32,6 +32,8 @@ def _task_update(screen: LogScreen) -> None: ["git", "fetch", "origin"], cwd=_PROJECT_DIR, capture_output=True, text=True, ) + if screen.is_stopped(): + return if fetch.returncode != 0: screen.write(f"[red]Fetch failed:[/red] {fetch.stderr.strip()}") screen.finish(False) @@ -46,9 +48,10 @@ def _task_update(screen: LogScreen) -> None: ).strip() if behind == "0": - screen.set_progress(100, "Already up to date") - screen.write("[green][ok][/green] Already up to date.") - screen.finish(True) + if not screen.is_stopped(): + screen.set_progress(100, "Already up to date") + screen.write("[green][ok][/green] Already up to date.") + screen.finish(True) return # Show which commits are coming in so the user knows what changed. @@ -68,6 +71,8 @@ def _task_update(screen: LogScreen) -> None: ["git", "pull", "origin", branch], capture_output=True, text=True, cwd=_PROJECT_DIR, ) + if screen.is_stopped(): + return if pull.returncode != 0: for line in (pull.stdout + pull.stderr).splitlines(): if line.strip(): @@ -86,18 +91,22 @@ def _task_update(screen: LogScreen) -> None: ["uv", "tool", "install", "--editable", str(_PROJECT_DIR)], capture_output=True, text=True, ) + if screen.is_stopped(): + return if reinstall.returncode == 0: screen.write("[green][ok][/green] cobot reinstalled") else: screen.write(f"[yellow]Warning:[/yellow] reinstall failed — {reinstall.stderr.strip()}") - screen.set_progress(100, "Done") - screen.write("\n[green]Project updated successfully.[/green]") - screen.finish(True) + if not screen.is_stopped(): + screen.set_progress(100, "Done") + screen.write("\n[green]Project updated successfully.[/green]") + screen.finish(True) except Exception as exc: - screen.write(f"\n[red]Error:[/red] {exc}") - screen.finish(False) + if not screen.is_stopped(): + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) class _UpdateApp(App[None]): diff --git a/cobot/tui.py b/cobot/tui.py index 1d62d1a..779a8e5 100644 --- a/cobot/tui.py +++ b/cobot/tui.py @@ -184,6 +184,10 @@ class LogScreen(Screen[bool]): self._finished = False self._success = False self._show_progress = show_progress + # Tracks the subprocess that is currently running so on_unmount can kill it. + # Отслеживает текущий subprocess, чтобы on_unmount мог его завершить. + self._active_proc = None + self._stopped = False def compose(self) -> ComposeResult: yield Static(self._title, id="step") @@ -201,6 +205,29 @@ class LogScreen(Screen[bool]): # Запускаем задачу в отдельном потоке, чтобы интерфейс не зависал. self.app.run_worker(lambda: self._run_fn(self), thread=True) + def set_proc(self, proc) -> None: + # Register the subprocess that is currently running. + # Called from the worker thread - GIL makes simple assignment safe here. + # Регистрируем текущий subprocess. + # Вызывается из рабочего потока - простое присваивание безопасно благодаря GIL. + self._active_proc = proc + + def is_stopped(self) -> bool: + # Return True if the user has closed the screen before the task finished. + # Возвращает True если пользователь закрыл экран до завершения задачи. + return self._stopped + + def on_unmount(self) -> None: + # Kill the active subprocess when the screen closes so it does not keep running in the background. + # Убиваем активный subprocess при закрытии экрана, чтобы он не продолжал работать в фоне. + self._stopped = True + proc = self._active_proc + if proc is not None: + try: + proc.kill() + except Exception: + pass + def set_progress(self, pct: float, label: str = "") -> None: # Thread-safe - this is called from the worker thread, not the UI thread. # Потокобезопасно - вызывается из рабочего потока, а не из потока интерфейса.