From b5642e5af61755df3f06e37d70319f8b5bd9f22f Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Mon, 20 Jul 2026 22:43:51 +0200 Subject: [PATCH] Fix pane lifecycle and terminal leakage --- README.md | 4 +-- autoload/euporie.vim | 15 +++++--- doc/euporie.txt | 5 +-- python/vim_euporie_console.py | 64 +++++++++++++++++++++++++++++++++ python/vim_euporie_sidecar.py | 62 +++++++++++++++++++++++++++----- tests/test_sidecar.py | 67 +++++++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 python/vim_euporie_console.py diff --git a/README.md b/README.md index 982666e..8dfb55e 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ The default Python-buffer mappings use `maplocalleader` (a backslash unless you Commands include `:EuporieStart`, `:EuporieStop`, `:EuporieRestart`, `:EuporieFocus`, `:EuporieStatus`, `:EuporieInterrupt`, `:EuporieSendCell`, `:EuporieRunCell`, `:[range]EuporieSend`, and `:EuporieSendFile`. -The Euporie pane is a real interactive console: focus it with `\ef`, type exploratory code directly, inspect and scroll output, use completions, or work with widgets. Input sent from Vim and input typed into Euporie share the same kernel. When the final attached Vim exits, the pane and kernel close automatically after a short grace period. +The Euporie pane is a real interactive console: focus it with `\ef`, type exploratory code directly, inspect and scroll output, use completions, or work with widgets. Input sent from Vim and input typed into Euporie share the same kernel. When the final attached Vim exits, the pane and kernel close automatically. Set a positive idle timeout if you want a grace period for reopening Vim. ## Configuration @@ -122,7 +122,7 @@ let g:vim_euporie_auto_start = 1 let g:vim_euporie_graphics = 'kitty-unicode' let g:vim_euporie_split = 'horizontal' let g:vim_euporie_pane_percent = 40 -let g:vim_euporie_idle_timeout = 20 +let g:vim_euporie_idle_timeout = 0 ``` Set `g:vim_euporie_split = 'vertical'` for a pane below Vim. To keep only `` mappings and define your own: diff --git a/autoload/euporie.vim b/autoload/euporie.vim index 0ece150..d82d297 100644 --- a/autoload/euporie.vim +++ b/autoload/euporie.vim @@ -179,7 +179,9 @@ function! s:uv_command(ctx) abort \ 'python', s:plugin_root . '/python/vim_euporie_sidecar.py', \ '--state-file', a:ctx.state, \ '--root', a:ctx.root, - \ '--idle-timeout', string(s:setting('idle_timeout', 20)), + \ '--owner-client', a:ctx.client, + \ '--owner-pid', string(getpid()), + \ '--idle-timeout', string(s:setting('idle_timeout', 0)), \ '--graphics', s:setting('graphics', 'kitty-unicode'), \ '--euporie-args-json', json_encode(s:setting('euporie_args', [])), \ ]) @@ -229,6 +231,9 @@ function! euporie#start() abort return 0 endif let ctx.pane = pane + " The sidecar receives this client at launch, before its control socket is + " ready. This also lets it notice a Vim which exits during startup. + let ctx.attached = 1 call s:tmux(['select-pane', '-t', pane, '-T', 'euporie:' . fnamemodify(ctx.root, ':t')]) call s:ensure_heartbeat() return 1 @@ -450,9 +455,9 @@ endfunction function! euporie#detach_all() abort for ctx in values(s:contexts) - if ctx.attached - call s:request(ctx, {'action': 'detach', 'client': ctx.client}, 1) - let ctx.attached = 0 - endif + " Always attempt the detach. A context can be registered by the sidecar's + " launch arguments before Vim has observed the state file. + call s:request(ctx, {'action': 'detach', 'client': ctx.client}, 1) + let ctx.attached = 0 endfor endfunction diff --git a/doc/euporie.txt b/doc/euporie.txt index 7833793..b66e969 100644 --- a/doc/euporie.txt +++ b/doc/euporie.txt @@ -44,7 +44,7 @@ g:vim_euporie_pane_percent Default: 40 g:vim_euporie_split "horizontal" or "vertical" g:vim_euporie_graphics Default: "kitty-unicode" g:vim_euporie_configure_tmux Enable allow-passthrough; default: 1 -g:vim_euporie_idle_timeout Seconds after final Vim detaches; default: 20 +g:vim_euporie_idle_timeout Seconds after final Vim detaches; default: 0 g:vim_euporie_start_timeout First-start timeout; default: 90 g:vim_euporie_with uv overlay packages; default: ["euporie", "ipykernel"] @@ -57,7 +57,8 @@ Otherwise the nearest pyproject.toml, uv.lock, or .git directory is used. Each tmux window and project root shares one pane and kernel. Clients send heartbeats, so the pane survives a handoff between Vim instances and is -removed automatically after the final client exits. +removed automatically after the final client exits. Set a positive +g:vim_euporie_idle_timeout to keep it alive briefly during a handoff. Python files containing a PEP 723 "# /// script" metadata block use the dependencies declared in that block and receive a separate pane and kernel. diff --git a/python/vim_euporie_console.py b/python/vim_euporie_console.py new file mode 100644 index 0000000..1801734 --- /dev/null +++ b/python/vim_euporie_console.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Launch Euporie without terminal probes which tmux can route to Vim.""" + +from __future__ import annotations + +import sys + + +PASSTHROUGH_QUERY_METHODS = ( + # Euporie 2.x + "get_colors", + "get_kitty_graphics_status", + "get_device_attributes", + "get_iterm_graphics_status", + # Euporie 3.x / apptk + "ask_for_colors", + "ask_for_kitty_graphics_status", + "ask_for_device_attributes", + "ask_for_iterm_graphics_status", +) + + +def suppress_passthrough_queries() -> None: + """Disable only startup queries which escape an inactive tmux pane. + + Euporie's actual Kitty graphics transmissions still use tmux passthrough. + The other capability probes remain enabled because tmux answers those + itself instead of forwarding them to Ghostty's currently active pane. + """ + try: + from apptk.output.vt100 import Vt100_Output + except ModuleNotFoundError: + from euporie.core.io import Vt100_Output + + def ignore_query(_output: Vt100_Output) -> None: + return None + + for method_name in PASSTHROUGH_QUERY_METHODS: + if hasattr(Vt100_Output, method_name): + setattr(Vt100_Output, method_name, ignore_query) + + +def main() -> None: + sys.argv[0] = "euporie-console" + from euporie.core import __main__ as euporie_main + + if hasattr(euporie_main, "available_apps"): + # Euporie 2.x must install its prompt_toolkit layout patches before the + # output module is imported. + from euporie.core.layout import containers as _containers # noqa: F401 + + suppress_passthrough_queries() + euporie_main.main("console") + else: + # Euporie 3.x moved the output layer to apptk and no longer uses the + # entry-point discovery launcher. + from euporie.console.app import ConsoleApp + + suppress_passthrough_queries() + ConsoleApp.launch() + + +if __name__ == "__main__": + main() diff --git a/python/vim_euporie_sidecar.py b/python/vim_euporie_sidecar.py index 70fbf78..84d74d8 100755 --- a/python/vim_euporie_sidecar.py +++ b/python/vim_euporie_sidecar.py @@ -43,9 +43,12 @@ def __init__(self, args: argparse.Namespace) -> None: self.kernel_lock = threading.Lock() self.kernel_manager: Any = None self.kernel_client: Any = None + self.kernel_log: Any = None + self.transport_encryption = "disabled" self.console: subprocess.Popen[bytes] | None = None self.server: socketserver.TCPServer | None = None self.no_clients_since = time.monotonic() + self.touch_client(args.owner_client, args.owner_pid) def touch_client(self, client: str, pid: int) -> None: if not client: @@ -66,7 +69,7 @@ def prune_clients(self) -> int: self.clients = { client: (seen, pid) for client, (seen, pid) in self.clients.items() - if seen >= cutoff or process_is_alive(pid) + if (process_is_alive(pid) if pid > 0 else seen >= cutoff) } if not self.clients and not self.no_clients_since: self.no_clients_since = time.monotonic() @@ -171,6 +174,7 @@ def start_kernel(runtime: Runtime, connection_file: Path) -> None: from jupyter_client import KernelManager manager = KernelManager(kernel_name="python3", connection_file=str(connection_file)) + runtime.kernel_manager = manager # Force the Python kernel to use the interpreter selected by `uv run`. This # is what makes project dependencies importable without installing a global # kernelspec for every uv environment. @@ -181,12 +185,44 @@ def start_kernel(runtime: Runtime, connection_file: Path) -> None: "-f", "{connection_file}", ] - manager.start_kernel(cwd=str(runtime.args.root)) - client = manager.client() + + # jupyter_client 8.9 can provision CurveZMQ keys locally when the kernel + # advertises support. This avoids the ipykernel plaintext-TCP warning and + # protects all kernel traffic, even though it remains bound to localhost. + encrypted = False + try: + import zmq + + if hasattr(manager, "transport_encryption") and zmq.has("curve"): + manager.transport_encryption = "required" + manager.kernel_spec.metadata = { + **(manager.kernel_spec.metadata or {}), + "supported_encryption": ["curve"], + } + encrypted = True + except Exception: + LOG.exception("CurveZMQ setup failed; falling back to localhost transport") + + kernel_log_path = runtime.args.state_file.with_suffix(".kernel.log") + runtime.kernel_log = kernel_log_path.open("ab", buffering=0) + manager.start_kernel( + cwd=str(runtime.args.root), + stdout=runtime.kernel_log, + stderr=subprocess.STDOUT, + ) + client_options = {} + if encrypted: + # jupyter_client 8.9 serializes these keys to text in manager.client(), + # while the client traits correctly require raw CurveZMQ key bytes. + client_options = { + "curve_publickey": manager.curve_publickey, + "curve_secretkey": manager.curve_secretkey, + } + client = manager.client(**client_options) + runtime.kernel_client = client client.start_channels() client.wait_for_ready(timeout=runtime.args.kernel_timeout) - runtime.kernel_manager = manager - runtime.kernel_client = client + runtime.transport_encryption = "curve" if encrypted else "disabled" # Match notebook behavior for `plt.show()` when matplotlib is part of the # uv project. A missing matplotlib is deliberately ignored. client.execute( @@ -201,7 +237,8 @@ def start_kernel(runtime: Runtime, connection_file: Path) -> None: def euporie_command(runtime: Runtime, connection_file: Path) -> list[str]: command = [ - "euporie-console", + sys.executable, + str(Path(__file__).with_name("vim_euporie_console.py")), "--connection-file", str(connection_file), "--show-remote-inputs", @@ -212,6 +249,8 @@ def euporie_command(runtime: Runtime, connection_file: Path) -> list[str]: "--multiplexer-passthrough", "--color-scheme", "dark", + "--color-depth", + "24", ] command.extend(json.loads(runtime.args.euporie_args_json)) return command @@ -221,7 +260,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--state-file", required=True, type=Path) parser.add_argument("--root", required=True, type=Path) - parser.add_argument("--idle-timeout", type=float, default=20.0) + parser.add_argument("--owner-client", default="") + parser.add_argument("--owner-pid", type=int, default=0) + parser.add_argument("--idle-timeout", type=float, default=0.0) parser.add_argument("--client-timeout", type=float, default=45.0) parser.add_argument("--kernel-timeout", type=float, default=60.0) parser.add_argument("--graphics", default="kitty-unicode") @@ -259,7 +300,9 @@ def request_stop(_signum: int, _frame: Any) -> None: server_thread.start() runtime.console = subprocess.Popen( - euporie_command(runtime, connection_file), cwd=args.root + euporie_command(runtime, connection_file), + cwd=args.root, + stderr=runtime.kernel_log, ) # Give Euporie time to subscribe to IOPub before Vim can send the first # cell, otherwise very fast first executions can be invisible. @@ -278,6 +321,7 @@ def request_stop(_signum: int, _frame: Any) -> None: "kernel_pid": runtime.kernel_manager.provisioner.pid, "root": str(args.root), "connection_file": str(connection_file), + "transport_encryption": runtime.transport_encryption, }, ) @@ -311,6 +355,8 @@ def request_stop(_signum: int, _frame: Any) -> None: runtime.kernel_client.stop_channels() if runtime.kernel_manager is not None and runtime.kernel_manager.has_kernel: runtime.kernel_manager.shutdown_kernel(now=True) + if runtime.kernel_log is not None: + runtime.kernel_log.close() state_file.unlink(missing_ok=True) connection_file.unlink(missing_ok=True) diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index 0280cdf..db59b28 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -1,5 +1,6 @@ import importlib.util from pathlib import Path +from types import SimpleNamespace import unittest @@ -9,6 +10,14 @@ SIDECAR = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(SIDECAR) +CONSOLE_PATH = Path(__file__).parents[1] / "python" / "vim_euporie_console.py" +CONSOLE_SPEC = importlib.util.spec_from_file_location( + "vim_euporie_console", CONSOLE_PATH +) +assert CONSOLE_SPEC and CONSOLE_SPEC.loader +CONSOLE = importlib.util.module_from_spec(CONSOLE_SPEC) +CONSOLE_SPEC.loader.exec_module(CONSOLE) + class PrepareCodeTests(unittest.TestCase): def test_code_is_unchanged(self): @@ -21,5 +30,63 @@ def test_markdown_becomes_rich_display(self): self.assertIn("'# Title\\n$x^2$'", rendered) +class LifecycleTests(unittest.TestCase): + def test_new_owner_is_registered_before_control_socket_starts(self): + runtime = SIDECAR.Runtime( + SimpleNamespace( + owner_client="vim-123-project", + owner_pid=123, + client_timeout=45.0, + ) + ) + self.assertIn("vim-123-project", runtime.clients) + self.assertEqual(0.0, runtime.no_clients_since) + + def test_idle_timeout_defaults_to_immediate_cleanup(self): + args = SIDECAR.parse_args(["--state-file", "state", "--root", "."]) + self.assertEqual(0.0, args.idle_timeout) + + def test_dead_owner_is_pruned_without_waiting_for_heartbeat_timeout(self): + runtime = SIDECAR.Runtime( + SimpleNamespace( + owner_client="vim-dead-project", + owner_pid=2_147_483_647, + client_timeout=45.0, + ) + ) + self.assertEqual(0, runtime.prune_clients()) + self.assertGreater(runtime.no_clients_since, 0.0) + + +class ConsoleLaunchTests(unittest.TestCase): + def test_only_passthrough_startup_queries_are_suppressed(self): + self.assertEqual( + { + "get_colors", + "get_kitty_graphics_status", + "get_device_attributes", + "get_iterm_graphics_status", + "ask_for_colors", + "ask_for_kitty_graphics_status", + "ask_for_device_attributes", + "ask_for_iterm_graphics_status", + }, + set(CONSOLE.PASSTHROUGH_QUERY_METHODS), + ) + + def test_sidecar_launches_the_guarded_console_with_fixed_color_depth(self): + runtime = SimpleNamespace( + args=SimpleNamespace( + graphics="kitty-unicode", + euporie_args_json="[]", + ) + ) + command = SIDECAR.euporie_command(runtime, Path("kernel.json")) + self.assertEqual(SIDECAR.sys.executable, command[0]) + self.assertTrue(command[1].endswith("vim_euporie_console.py")) + self.assertIn("--multiplexer-passthrough", command) + self.assertEqual("24", command[command.index("--color-depth") + 1]) + + if __name__ == "__main__": unittest.main()