From 3c3ba4a9fb4c8409960e60093aef9fa58c9c5d6b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 6 May 2025 19:17:17 +0530 Subject: [PATCH] @ launch: Add a --wait-for-child-exit flag to get the child processes exit code even when it is running in a window. --- docs/changelog.rst | 4 ++++ kitty/boss.py | 4 ++++ kitty/launch.py | 13 +++++++++--- kitty/rc/launch.py | 50 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 319bfd63c..1612e6d80 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -125,6 +125,10 @@ Detailed list of changes - Remote control: Allow modifying desktop panels and showing/hiding OS Windows using the `kitten @ resize-os-window` command (:iss:`8550`) +- Remote control launch: Allow waiting for a program launched in a new window + to exit and get the exit code via the `kitty +launch + --wait-for-child-to-exit` command line flag (:disc:`8573`) + - Allow starting kitty with the OS window hidden via :option:`kitty --start-as=hidden `, useful for single instance mode (:iss:`3466`) - Allow configuring the mouse unhide behavior when using :opt:`mouse_hide_wait` (:pull:`8508`) diff --git a/kitty/boss.py b/kitty/boss.py index f77450279..96aff216c 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2782,6 +2782,10 @@ class Boss: self.update_check_process.kill() self.update_check_process = process + def monitor_pid(self, pid: int, callback: Callable[[int, Exception | None], None]) -> None: + self.background_process_death_notify_map[pid] = callback + monitor_pid(pid) + def on_monitored_pid_death(self, pid: int, exit_status: int) -> None: callback = self.background_process_death_notify_map.pop(pid, None) if callback is not None: diff --git a/kitty/launch.py b/kitty/launch.py index b826ef3cb..a60f73388 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -6,7 +6,7 @@ import os import shutil from collections.abc import Container, Iterable, Iterator, Sequence from contextlib import suppress -from typing import Any, NamedTuple, TypedDict +from typing import Any, Callable, NamedTuple, TypedDict from .boss import Boss from .child import Child @@ -593,6 +593,7 @@ def _launch( is_clone_launch: str = '', rc_from_window: Window | None = None, base_env: dict[str, str] | None = None, + child_death_callback: Callable[[int, Exception | None], None] | None = None, ) -> Window | None: source_window = boss.active_window_for_cwd if opts.source_window: @@ -730,7 +731,8 @@ def _launch( raise ValueError('The cmd to run must be specified when running a background process') boss.run_background_process( cmd, cwd=kw['cwd'], cwd_from=kw['cwd_from'], env=env or None, stdin=kw['stdin'], - allow_remote_control=kw['allow_remote_control'], remote_control_passwords=kw['remote_control_passwords'] + allow_remote_control=kw['allow_remote_control'], remote_control_passwords=kw['remote_control_passwords'], + notify_on_death=child_death_callback, ) elif opts.type in ('clipboard', 'primary'): stdin = kw.get('stdin') @@ -741,6 +743,8 @@ def _launch( else: set_primary_selection(stdin) boss.handle_clipboard_loss('primary') + if child_death_callback is not None: + child_death_callback(0, None) else: kw['hold'] = opts.hold if force_target_tab and target_tab is not None: @@ -751,6 +755,8 @@ def _launch( with Window.set_ignore_focus_changes_for_new_windows(opts.keep_focus): new_window: Window = tab.new_window( env=env or None, watchers=watchers or None, is_clone_launch=is_clone_launch, next_to=next_to, **kw) + if child_death_callback is not None: + boss.monitor_pid(new_window.child.pid or 0, child_death_callback) if spacing: patch_window_edges(new_window, spacing) tab.relayout() @@ -781,12 +787,13 @@ def launch( is_clone_launch: str = '', rc_from_window: Window | None = None, base_env: dict[str, str] | None = None, + child_death_callback: Callable[[int, Exception | None], None] | None = None, ) -> Window | None: active = boss.active_window if opts.keep_focus and active: orig, active.ignore_focus_changes = active.ignore_focus_changes, True try: - return _launch(boss, opts, args, target_tab, force_target_tab, is_clone_launch, rc_from_window, base_env) + return _launch(boss, opts, args, target_tab, force_target_tab, is_clone_launch, rc_from_window, base_env, child_death_callback) finally: if opts.keep_focus and active: active.ignore_focus_changes = orig diff --git a/kitty/rc/launch.py b/kitty/rc/launch.py index 37f95129d..635238c83 100644 --- a/kitty/rc/launch.py +++ b/kitty/rc/launch.py @@ -2,12 +2,14 @@ # License: GPLv3 Copyright: 2020, Kovid Goyal +import os from typing import TYPE_CHECKING from kitty.cli_stub import LaunchCLIOptions from kitty.launch import launch as do_launch from kitty.launch import options_spec as launch_options_spec from kitty.launch import parse_launch_args +from kitty.types import AsyncResponse from .base import MATCH_TAB_OPTION, ArgsType, Boss, PayloadGetType, PayloadType, RCOptions, RemoteCommand, ResponseType, Window @@ -54,6 +56,7 @@ class Launch(RemoteCommand): color/list.str: list of color specifications such as foreground=red watcher/list.str: list of paths to watcher files bias/float: The bias with which to create the new window in the current layout + wait_for_child_to_exit/bool: Boolean indicating whether to wait and return child exit code ''' short_desc = 'Run an arbitrary process in a new window/tab' @@ -64,6 +67,21 @@ class Launch(RemoteCommand): ' kitten @ launch --title=Email mutt' ) options_spec = MATCH_TAB_OPTION + '\n\n' + '''\ +--wait-for-child-to-exit +type=bool-set +Wait until the launched program exits and print out its exit code. The exit code is +printed out instead of the window id. If the program exited nromally its exit code is printed, which +is always greater than or equal to zero. If the program was killed by a signal, the symbolic name +of the SIGNAL is printed, if available, otherwise the signal number with a leading minus sign is printed. + + +--response-timeout +type=float +default=86400 +The time in seconds to wait for the started process to exit, when using the :option:`--wait-for-child-to-exit` +option. Defaults to one day. + + --no-response type=bool-set Do not print out the id of the newly created window. @@ -76,14 +94,17 @@ instead of the active tab ''' + '\n\n' + launch_options_spec().replace(':option:`launch', ':option:`kitten @ launch') args = RemoteCommand.Args(spec='[CMD ...]', json_field='args', completion=RemoteCommand.CompletionSpec.from_string( 'type:special group:cli.CompleteExecutableFirstArg')) + is_asynchronous = True def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: ans = {'args': args or []} for attr, val in opts.__dict__.items(): ans[attr] = val + # ans['wait_for_child_to_exit'] = opts.wait_for_child_to_exit return ans def response_from_kitty(self, boss: Boss, window: Window | None, payload_get: PayloadGetType) -> ResponseType: + # responder.send_data(getattr(w, 'id', 0)) default_opts = parse_launch_args()[0] opts = LaunchCLIOptions() for key, default_value in default_opts.__dict__.items(): @@ -108,10 +129,31 @@ instead of the active tab tabs = self.tabs_for_match_payload(boss, window, payload_get) if tabs and tabs[0]: target_tab = tabs[0] - elif payload_get('type') not in ('background', 'os-window', 'tab', 'window'): - return None - w = do_launch(boss, opts, payload_get('args') or [], target_tab=target_tab, rc_from_window=window, base_env=base_env) - return None if payload_get('no_response') else str(getattr(w, 'id', 0)) + def on_child_death(exit_status: int, exc: Exception | None) -> None: + code = os.waitstatus_to_exitcode(exit_status) + ans = str(code) + if code < 0: + try: + from signal import Signals + ans = Signals(-code).name + except ValueError: + pass + responder.send_data(ans) + + w = do_launch( + boss, opts, payload_get('args') or [], target_tab=target_tab, rc_from_window=window, base_env=base_env, + child_death_callback=on_child_death if payload_get('wait_for_child_to_exit') and not payload_get('no_response') else None) + if payload_get('no_response'): + return None + + if not payload_get('wait_for_child_to_exit'): + return str(0 if w is None else w.id) + + responder = self.create_async_responder(payload_get, window) + return AsyncResponse() + + def cancel_async_request(self, boss: 'Boss', window: Window | None, payload_get: PayloadGetType) -> None: + pass launch = Launch()