Use a fork() without exec() to create prewarm process

This has the advantages:

1) Even first kitten use is fast
2) Computer has to do less work overall since prewarm process is itself prewarmed
This commit is contained in:
Kovid Goyal 2022-06-12 18:06:51 +05:30
parent e1ad75e932
commit da6faa656c
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 150 additions and 95 deletions

View File

@ -229,7 +229,8 @@ class Boss:
opts: Options,
args: CLIOptions,
cached_values: Dict[str, Any],
global_shortcuts: Dict[str, SingleKey]
global_shortcuts: Dict[str, SingleKey],
prewarm: PrewarmProcess,
):
set_layout_options(opts)
self.update_check_started = False
@ -254,7 +255,7 @@ class Boss:
self.allow_remote_control = opts.allow_remote_control
if args.listen_on and (self.allow_remote_control in ('y', 'socket-only')):
listen_fd = listen_on(args.listen_on)
self.prewarm = PrewarmProcess()
self.prewarm = prewarm
self.child_monitor = ChildMonitor(
self.on_child_death,
DumpCommands(args) if args.dump_commands or args.dump_bytes else None,

View File

@ -199,6 +199,15 @@ def is_prewarmable(argv: Sequence[str]) -> bool:
return argv[1] != '+open'
@run_once
def cmdline_of_prewarmer() -> List[str]:
# we need this check in case the prewarmed process has done an exec and
# changed its cmdline
with suppress(Exception):
return cmdline_of_pid(fast_data_types.get_boss().prewarm.worker_pid)
return ['']
class Child:
child_fd: Optional[int] = None
@ -339,7 +348,7 @@ class Child:
ans = cmdline_of_pid(pid)
except Exception:
ans = []
if pid == self.pid and (not ans or (self.is_prewarmed and fast_data_types.get_boss().prewarm.is_prewarmed_argv(ans))):
if pid == self.pid and (not ans or (self.is_prewarmed and ans == cmdline_of_prewarmer())):
ans = list(self.argv)
return ans

View File

@ -30,6 +30,7 @@ from .fonts.render import set_font_family
from .options.types import Options
from .options.utils import DELETE_ENV_VAR
from .os_window_size import initial_window_size_func
from .prewarm import PrewarmProcess, fork_prewarm_process
from .session import create_sessions, get_os_window_sizing_data
from .types import SingleKey
from .utils import (
@ -141,7 +142,7 @@ def set_x11_window_icon() -> None:
set_default_window_icon(f'{path}-128{ext}')
def _run_app(opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = ()) -> None:
def _run_app(opts: Options, args: CLIOptions, prewarm: PrewarmProcess, bad_lines: Sequence[BadLine] = ()) -> None:
global_shortcuts: Dict[str, SingleKey] = {}
if is_macos:
from collections import defaultdict
@ -180,7 +181,7 @@ def _run_app(opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = ())
pre_show_callback,
args.title or appname, args.name or args.cls or appname,
wincls, load_all_shaders, disallow_override_title=bool(args.title))
boss = Boss(opts, args, cached_values, global_shortcuts)
boss = Boss(opts, args, cached_values, global_shortcuts, prewarm)
boss.start(window_id, startup_sessions)
if bad_lines:
boss.show_bad_config_lines(bad_lines)
@ -197,12 +198,12 @@ class AppRunner:
self.first_window_callback = lambda window_handle: None
self.initial_window_size_func = initial_window_size_func
def __call__(self, opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = ()) -> None:
def __call__(self, opts: Options, args: CLIOptions, prewarm: PrewarmProcess, bad_lines: Sequence[BadLine] = ()) -> None:
set_scale(opts.box_drawing_scale)
set_options(opts, is_wayland(), args.debug_rendering, args.debug_font_fallback)
try:
set_font_family(opts, debug_font_matching=args.debug_font_fallback)
_run_app(opts, args, bad_lines)
_run_app(opts, args, prewarm, bad_lines)
finally:
set_options(None)
free_font_data() # must free font data before glfw/freetype/fontconfig/opengl etc are finalized
@ -404,8 +405,11 @@ def _main() -> None:
return
bad_lines: List[BadLine] = []
opts = create_opts(cli_opts, accumulate_bad_lines=bad_lines)
init_glfw(opts, cli_opts.debug_keyboard, cli_opts.debug_rendering)
setup_environment(opts, cli_opts)
prewarm = fork_prewarm_process(opts)
if prewarm is None:
raise SystemExit(1)
init_glfw(opts, cli_opts.debug_keyboard, cli_opts.debug_rendering)
if cli_opts.watcher:
from .window import global_watchers
global_watchers.set_extra(cli_opts.watcher)
@ -413,7 +417,7 @@ def _main() -> None:
try:
with setup_profiling():
# Avoid needing to launch threads to reap zombies
run_app(opts, cli_opts, bad_lines)
run_app(opts, cli_opts, prewarm, bad_lines)
finally:
glfw_terminate()
cleanup_ssh_control_masters()

View File

@ -5,6 +5,7 @@ import io
import json
import os
import select
import signal
import sys
import time
import warnings
@ -13,16 +14,17 @@ from dataclasses import dataclass
from importlib import import_module
from itertools import count
from typing import (
IO, TYPE_CHECKING, Any, Dict, List, NoReturn, Optional, Sequence, Tuple,
Union, cast
IO, TYPE_CHECKING, Any, Dict, List, NoReturn, Optional, Tuple, Union, cast
)
from kitty.constants import clear_handled_signals, kitty_exe
from kitty.constants import kitty_exe, running_in_kitty
from kitty.entry_points import main as main_entry_point
from kitty.fast_data_types import (
establish_controlling_tty, get_options, safe_pipe
establish_controlling_tty, get_options, safe_pipe, set_options
)
from kitty.options.types import Options
from kitty.shm import SharedMemory
from kitty.utils import log_error
if TYPE_CHECKING:
from _typeshed import ReadableBuffer, WriteableBuffer
@ -44,67 +46,58 @@ class Child:
class PrewarmProcess:
def __init__(self, create_file_to_read_from_worker: bool = False) -> None:
self.from_worker_fd, self.in_worker_fd = safe_pipe()
def __init__(
self,
prewarm_process_pid: int,
to_prewarm_stdin: int,
from_prewarm_stdout: int,
from_prewarm_death_notify: int,
) -> None:
self.children: Dict[int, Child] = {}
if create_file_to_read_from_worker:
os.set_blocking(self.from_worker_fd, True)
self.from_worker = open(self.from_worker_fd, mode='r', closefd=True)
self.from_worker_fd = -1
self.worker_pid = prewarm_process_pid
self.from_prewarm_death_notify = from_prewarm_death_notify
self.write_to_process_fd = to_prewarm_stdin
self.read_from_process_fd = from_prewarm_stdout
self.poll = select.poll()
self.poll.register(self.read_from_process_fd, select.POLLIN)
def take_from_worker_fd(self) -> int:
ans, self.from_worker_fd = self.from_worker_fd, -1
def take_from_worker_fd(self, create_file: bool = False) -> int:
if create_file:
os.set_blocking(self.from_prewarm_death_notify, True)
self.from_worker = open(self.from_prewarm_death_notify, mode='r', closefd=True)
self.from_prewarm_death_notify = -1
return -1
ans, self.from_prewarm_death_notify = self.from_prewarm_death_notify, -1
return ans
def __del__(self) -> None:
if self.from_worker_fd > -1:
os.close(self.from_worker_fd)
self.from_worker_fd = -1
if self.write_to_process_fd > -1:
os.close(self.write_to_process_fd)
self.write_to_process_fd = -1
if self.from_prewarm_death_notify > -1:
os.close(self.from_prewarm_death_notify)
self.from_prewarm_death_notify = -1
if self.read_from_process_fd > -1:
os.close(self.read_from_process_fd)
self.read_from_process_fd = -1
if hasattr(self, 'from_worker'):
self.from_worker.close()
del self.from_worker
if self.worker_started:
import subprocess
self.process.stdin and self.process.stdin.close()
self.process.stdout and self.process.stdout.close()
try:
self.process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
self.process.kill()
del self.process
@property
def worker_started(self) -> bool:
return self.in_worker_fd == -1
@property
def prewarm_config(self) -> str:
opts = get_options()
return json.dumps({'paths': opts.config_paths, 'overrides': opts.config_overrides})
def is_prewarmed_argv(self, argv: Sequence[str]) -> bool:
if argv[:2] != [kitty_exe(), '+runpy']:
return False
return len(argv) > 2 and argv[2].startswith('from kitty.prewarm import main; main(')
def ensure_worker(self) -> None:
if not self.worker_started:
import subprocess
env = dict(os.environ)
env['KITTY_PREWARM_CONFIG'] = self.prewarm_config
self.process = subprocess.Popen(
[kitty_exe(), '+runpy', f'from kitty.prewarm import main; main({self.in_worker_fd})'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, pass_fds=(self.in_worker_fd,), env=env,
start_new_session=True, preexec_fn=clear_handled_signals)
os.close(self.in_worker_fd)
self.in_worker_fd = -1
assert self.process.stdin is not None and self.process.stdout is not None
self.write_to_process_fd = self.process.stdin.fileno()
self.read_from_process_fd = self.process.stdout.fileno()
os.set_blocking(self.write_to_process_fd, False)
os.set_blocking(self.read_from_process_fd, False)
self.poll = select.poll()
self.poll.register(self.process.stdout.fileno(), select.POLLIN)
if self.worker_pid > 0:
st = time.monotonic()
while time.monotonic() - st < 1:
try:
pid, status = os.waitpid(self.worker_pid, os.WNOHANG)
except ChildProcessError:
return
else:
if pid == self.worker_pid:
return
time.sleep(0.01)
log_error('Prewarm process failed to quite gracefully, killing it')
os.kill(self.worker_pid, signal.SIGKILL)
os.waitpid(self.worker_pid, 0)
def poll_to_send(self, yes: bool = True) -> None:
if yes:
@ -112,9 +105,12 @@ class PrewarmProcess:
else:
self.poll.unregister(self.write_to_process_fd)
def reload_kitty_config(self) -> None:
if self.worker_started:
self.send_to_prewarm_process('reload_kitty_config:{self.prewarm_config}\n')
def reload_kitty_config(self, opts: Optional[Options] = None) -> None:
if opts is None:
opts = get_options()
data = json.dumps({'paths': opts.config_paths, 'overrides': opts.config_overrides})
if self.write_to_process_fd > -1:
self.send_to_prewarm_process(f'reload_kitty_config:{data}\n')
def __call__(
self,
@ -125,7 +121,6 @@ class PrewarmProcess:
stdin_data: Optional[Union[str, bytes]] = None,
timeout: float = TIMEOUT,
) -> Child:
self.ensure_worker()
tty_name = os.ttyname(tty_fd)
if isinstance(stdin_data, str):
stdin_data = stdin_data.encode()
@ -195,14 +190,13 @@ class PrewarmProcess:
return True
def reload_kitty_config() -> None:
d = json.loads(os.environ.pop('KITTY_PREWARM_CONFIG'))
def reload_kitty_config(payload: str) -> None:
d = json.loads(payload)
from kittens.tui.utils import set_kitty_opts
set_kitty_opts(paths=d['paths'], overrides=d['overrides'])
def prewarm() -> None:
reload_kitty_config()
from kittens.runner import all_kitten_names
for kitten in all_kitten_names():
with suppress(Exception):
@ -274,7 +268,7 @@ def child_main(cmd: Dict[str, Any], ready_fd: int) -> NoReturn:
raise SystemExit(0)
def fork(shm_address: str, ready_fd: int) -> Tuple[int, int]:
def fork(shm_address: str) -> Tuple[int, int, int]:
sz = pos = 0
with SharedMemory(name=shm_address, unlink_on_exit=True) as shm:
data = shm.read_data_with_size()
@ -284,8 +278,8 @@ def fork(shm_address: str, ready_fd: int) -> Tuple[int, int]:
pos = shm.tell()
shm.unlink_on_exit = False
r, w = os.pipe()
os.set_inheritable(r, False)
r, w = safe_pipe()
ready_fd_read, ready_fd_write = safe_pipe()
try:
child_pid = os.fork()
except OSError:
@ -295,16 +289,18 @@ def fork(shm_address: str, ready_fd: int) -> Tuple[int, int]:
if child_pid:
# master process
os.close(w)
os.close(ready_fd_read)
poll = select.poll()
poll.register(r, select.POLLIN)
for (fd, event) in poll.poll():
if event & select.POLLIN:
os.read(r, 1)
return child_pid, r
return child_pid, r, ready_fd_write
else:
raise ValueError('Child process pipe failed')
# child process
os.set_inheritable(w, False)
os.close(r)
os.close(ready_fd_write)
os.setsid()
tty_name = cmd.get('tty_name')
if tty_name:
@ -313,25 +309,22 @@ def fork(shm_address: str, ready_fd: int) -> Tuple[int, int]:
establish_controlling_tty(tty_name, sys.__stdin__.fileno(), sys.__stdout__.fileno(), sys.__stderr__.fileno())
os.write(w, b'1') # this will be closed on process exit and thereby used to detect child death
if shm.unlink_on_exit:
child_main(cmd, ready_fd)
child_main(cmd, ready_fd_read)
else:
with SharedMemory(shm_address, unlink_on_exit=True) as shm:
stdin_data = memoryview(shm.mmap)[pos:pos + sz]
if stdin_data:
sys.stdin = MemoryViewReadWrapper(stdin_data)
try:
child_main(cmd, ready_fd)
child_main(cmd, ready_fd_read)
finally:
stdin_data.release()
sys.stdin = sys.__stdin__
def main(notify_child_death_fd: int) -> None:
def main(stdin_fd: int, stdout_fd: int, notify_child_death_fd: int) -> None:
os.set_blocking(notify_child_death_fd, False)
prewarm()
stdin_fd = sys.__stdin__.fileno()
os.set_blocking(stdin_fd, False)
stdout_fd = sys.__stdout__.fileno()
os.set_blocking(stdout_fd, False)
poll = select.poll()
poll.register(stdin_fd, select.POLLIN)
@ -344,6 +337,7 @@ def main(notify_child_death_fd: int) -> None:
# runpy issues a warning when running modules that have already been
# imported. Ignore it.
warnings.filterwarnings('ignore', category=RuntimeWarning, module='runpy')
prewarm()
def check_event(event: int, err_msg: str) -> None:
if event & select.POLLHUP:
@ -365,19 +359,18 @@ def main(notify_child_death_fd: int) -> None:
input_buf = input_buf[idx+1:]
cmd, _, payload = line.partition(':')
if cmd == 'reload_kitty_config':
os.environ['KITTY_PREWARM_CONFIG'] = payload
reload_kitty_config()
reload_kitty_config(payload)
elif cmd == 'ready':
child_id = int(payload)
cfd = child_ready_fds.pop(child_id)
if cfd is not None:
os.write(cfd, b'1')
os.close(cfd)
elif cmd == 'quit':
raise SystemExit(0)
elif cmd == 'fork':
r, w = os.pipe()
os.set_inheritable(w, False)
try:
child_pid, child_death_fd = fork(payload, r)
child_pid, child_death_fd, ready_fd_write = fork(payload)
except Exception as e:
es = str(e).replace('\n', ' ')
output_buf += f'ERR:{es}\n'.encode()
@ -385,13 +378,10 @@ def main(notify_child_death_fd: int) -> None:
if os.getpid() == self_pid:
child_id = next(child_id_counter)
child_id_map[child_id] = child_pid
child_ready_fds[child_id] = w
child_ready_fds[child_id] = ready_fd_write
child_death_fds[child_death_fd] = child_id
poll.register(child_death_fd, select.POLLIN)
output_buf += f'CHILD:{child_id}:{child_pid}\n'.encode()
finally:
if os.getpid() == self_pid:
os.close(r)
elif cmd == 'echo':
output_buf += f'{payload}\n'.encode()
@ -463,3 +453,51 @@ def main(notify_child_death_fd: int) -> None:
for fmd in child_ready_fds.values():
with suppress(OSError):
os.close(fmd)
def exec_main(stdin_read: int, stdout_write: int, death_notify_write: int) -> None:
os.setsid()
# SIGUSR1 is used for reloading kitty config, we rely on the parent process
# to inform us of that
signal.signal(signal.SIGUSR1, signal.SIG_IGN)
signal.siginterrupt(signal.SIGUSR1, False)
os.set_inheritable(stdin_read, False)
os.set_inheritable(stdout_write, False)
os.set_inheritable(death_notify_write, False)
running_in_kitty(False)
try:
main(stdin_read, stdout_write, death_notify_write)
finally:
set_options(None)
def fork_prewarm_process(opts: Options, use_exec: bool = False) -> Optional[PrewarmProcess]:
stdin_read, stdin_write = safe_pipe()
stdout_read, stdout_write = safe_pipe()
death_notify_read, death_notify_write = safe_pipe()
if use_exec:
import subprocess
tp = subprocess.Popen(
[kitty_exe(), '+runpy', f'from kitty.prewarm import exec_main; exec_main({stdin_read}, {stdout_write}, {death_notify_write})'],
pass_fds=(stdin_read, stdout_write, death_notify_write))
child_pid = tp.pid
tp.returncode = 0
else:
child_pid = os.fork()
if child_pid:
# master
os.close(stdin_read)
os.close(stdout_write)
os.close(death_notify_write)
p = PrewarmProcess(child_pid, stdin_write, stdout_read, death_notify_read)
if use_exec:
p.reload_kitty_config()
return p
# child
os.close(stdin_write)
os.close(stdout_read)
os.close(death_notify_read)
set_options(opts)
exec_main(stdin_read, stdout_write, death_notify_write)
raise SystemExit(0)

View File

@ -17,9 +17,8 @@ class Prewarm(BaseTest):
maxDiff = None
def test_prewarming(self):
from kitty.prewarm import PrewarmProcess
from kitty.prewarm import fork_prewarm_process
p = PrewarmProcess(create_file_to_read_from_worker=True)
cwd = tempfile.gettempdir()
env = {'TEST_ENV_PASS': 'xyz'}
cols = 117
@ -28,6 +27,10 @@ class Prewarm(BaseTest):
ttyname = os.ttyname(pty.slave_fd)
opts = get_options()
opts.config_overrides = 'font_family prewarm',
p = fork_prewarm_process(opts, use_exec=True)
if p is None:
return
p.take_from_worker_fd(create_file=True)
child = p(pty.slave_fd, [kitty_exe(), '+runpy', """\
import os, json; from kitty.utils import *; from kitty.fast_data_types import get_options; print(json.dumps({
'cterm': os.ctermid(),