@ launch: Add a --wait-for-child-exit flag to get the child processes exit code even when it is running in a window.

This commit is contained in:
Kovid Goyal 2025-05-06 19:17:17 +05:30
parent a4963a58e3
commit 3c3ba4a9fb
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
4 changed files with 64 additions and 7 deletions

View file

@ -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 <kitty --start-as>`, useful for single instance mode (:iss:`3466`)
- Allow configuring the mouse unhide behavior when using :opt:`mouse_hide_wait` (:pull:`8508`)

View file

@ -2782,6 +2782,10 @@ def set_update_check_process(self, process: Optional['PopenType[bytes]'] = None)
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:

View file

@ -6,7 +6,7 @@
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

View file

@ -2,12 +2,14 @@
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
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 @@ class Launch(RemoteCommand):
''' + '\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 @@ def response_from_kitty(self, boss: Boss, window: Window | None, payload_get: Pa
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()