Drop OSC 52 clipboard writes, force-kill on close, bound the reader, find the ST libs for tests

- OSC 52 is ignored: any output, cat'd or remote, could plant a command
  on the clipboard to paste
- kill(): terminate(force=True), so a child ignoring HUP and INT does
  not outlive its tab with the pty open
- reader pauses once 64 unparsed reads wait, so a large dump blocks the
  child at the pty instead of being buffered whole in memory
- tests/_bootstrap.py: add every Lib/python3* directory; Package Control
  puts the libraries under the active plugin host's own directory
This commit is contained in:
Vaclav Tvrdik
2026-10-25 10:40:25 +01:00
parent e478f41f7c
commit a556dab638
6 changed files with 28 additions and 32 deletions

View File

@@ -100,7 +100,8 @@ are marked ✅.
truecolor, `ESC[0m` reset. This is what TUIs emit most. truecolor, `ESC[0m` reset. This is what TUIs emit most.
- **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings - **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings
rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default
foreground/background, `OSC 52` reads or writes the system clipboard. foreground/background, `OSC 52` reads or writes the system clipboard (vterm
ignores it: untrusted output must not plant a paste).
- **DCS / APC** — Device Control String (`ESC P`) and Application Program Command - **DCS / APC** — Device Control String (`ESC P`) and Application Program Command
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and (`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
discards the payload, so an image never leaks into the text. discards the payload, so an image never leaks into the text.

View File

@@ -126,7 +126,8 @@ terminal protocols — see `Architecture.md`.
multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no
timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude
Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard
probe, and OSC 10/11 colour queries; OSC 52 goes to the Sublime clipboard. probe, and OSC 10/11 colour queries; OSC 52 (clipboard write) is ignored,
since any output could plant a command to paste.
Combining characters are merged as NFC, because Sublime draws decomposed Combining characters are merged as NFC, because Sublime draws decomposed
sequences badly. sequences badly.
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz. - `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
@@ -209,6 +210,6 @@ python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-b
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the `harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
emulator with no Sublime involved, and prints the queries answered plus the emulator with no Sublime involved, and prints the queries answered plus the
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
`Lib/python38` (pure Python, so any Python 3 runs them). `Lib/python3*` (pure Python, so any Python 3 runs them).
Code style is in `CODE.md`, working agreements in `CLAUDE.md`. Code style is in `CODE.md`, working agreements in `CLAUDE.md`.

View File

@@ -3,14 +3,16 @@
They run as plain scripts (see README), so each imports this first to get the They run as plain scripts (see README), so each imports this first to get the
sublime stubs and the Package Control libraries on the path. sublime stubs and the Package Control libraries on the path.
""" """
import glob
import os import os
import sys import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ST_LIB = os.path.expanduser( # Package Control puts the libraries under the plugin host's own directory
"~/Library/Application Support/Sublime Text/Lib/python38") ST_LIBS = sorted(glob.glob(os.path.expanduser(
"~/Library/Application Support/Sublime Text/Lib/python3*")))
for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")): for path in [ROOT] + ST_LIBS + [os.path.join(ROOT, "tests", "stubs")]:
if path not in sys.path: if path not in sys.path:
sys.path.insert(0, path) sys.path.insert(0, path)

View File

@@ -83,13 +83,11 @@ def run_case(char_by_char):
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written) assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
assert screen.buffer[0][0].data == "y", tag assert screen.buffer[0][0].data == "y", tag
# OSC 52 clipboard (base64 "hi") # OSC 52 (clipboard write) is dropped, not drawn: untrusted output must
got = [] # not plant a paste
proc = FakeProcess() proc, screen, stream = make()
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append) feed(stream, "\x1b]52;c;aGk=\x07z", char_by_char)
stream = VtStream(screen) assert screen.buffer[0][0].data == "z" and proc.written == "", tag
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
assert got == ["hi"], (tag, got)
# bracketed paste and sync modes tracked # bracketed paste and sync modes tracked
proc, screen, stream = make() proc, screen, stream = make()

View File

@@ -1,5 +1,4 @@
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """ """ Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
import base64
import logging import logging
import re import re
import time import time
@@ -119,7 +118,7 @@ class Cursor:
class VtScreen(pyte.Screen): class VtScreen(pyte.Screen):
"""Screen with alternate buffer, scrollback, synchronized output, """Screen with alternate buffer, scrollback, synchronized output,
mouse/focus/paste modes, cursor styles, query replies and OSC handling. mouse/focus/paste modes, cursor styles, query replies and OSC titles/colours.
Mutated only by the renderer thread, so it carries no lock. Mutated only by the renderer thread, so it carries no lock.
""" """
@@ -137,7 +136,6 @@ class VtScreen(pyte.Screen):
history=5000, history=5000,
erase_scrollback=True, erase_scrollback=True,
clear_callback=lambda: None, clear_callback=lambda: None,
clipboard_callback=lambda text: None,
fg_color="ffffff", fg_color="ffffff",
bg_color="000000", bg_color="000000",
is_dark=True, is_dark=True,
@@ -146,7 +144,6 @@ class VtScreen(pyte.Screen):
self._process = process self._process = process
self._erase_scrollback = erase_scrollback self._erase_scrollback = erase_scrollback
self._clear_callback = clear_callback self._clear_callback = clear_callback
self._clipboard_callback = clipboard_callback
self.primary_buffer = {} self.primary_buffer = {}
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False) self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params) # SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
@@ -650,7 +647,7 @@ class VtScreen(pyte.Screen):
self.write_process_input("\x1b[?0u") self.write_process_input("\x1b[?0u")
def handle_osc(self, code, payload, terminator): def handle_osc(self, code, payload, terminator):
"""Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query, 52 clipboard.""" """Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query."""
if code in (0, 2): if code in (0, 2):
self.set_title(payload) self.set_title(payload)
elif code == 1: elif code == 1:
@@ -661,14 +658,8 @@ class VtScreen(pyte.Screen):
rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6]) rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6])
# answer with the terminator asked with: meli only ends an OSC at ST # answer with the terminator asked with: meli only ends an OSC at ST
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator)) self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
elif code == 52: # other OSC codes are intentionally dropped, 52 (clipboard write) among
parts = payload.split(";", 1) # them: any output, cat'd or remote, could plant a command to paste
if len(parts) == 2 and parts[1] and parts[1] != "?":
try:
self._clipboard_callback(base64.b64decode(parts[1]).decode("utf-8", "replace"))
except ValueError:
LOG.warning("osc52: undecodable payload", exc_info=True)
# other OSC codes are intentionally dropped
# a complete plain CSI: params then a final byte. No private marker, prefix or # a complete plain CSI: params then a final byte. No private marker, prefix or

View File

@@ -26,6 +26,9 @@ UNFOCUSED_RENDER_PERIOD = 0.075
# max chars parsed per tick, so a giant burst still renders intermediate frames. # max chars parsed per tick, so a giant burst still renders intermediate frames.
FEED_LIMIT = 48 * 1024 FEED_LIMIT = 48 * 1024
# unparsed 8K reads the reader may hold before it pauses: ~10 ticks of parsing
PENDING_CHUNKS = 64
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this # SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
REPAINT_NUDGE_DELAY = 0.25 REPAINT_NUDGE_DELAY = 0.25
@@ -200,7 +203,6 @@ class Terminal:
history=self.scrollback_size, history=self.scrollback_size,
erase_scrollback=settings().get("honor_erase_scrollback", False), erase_scrollback=settings().get("honor_erase_scrollback", False),
clear_callback=self._clear_callback, clear_callback=self._clear_callback,
clipboard_callback=self._clipboard_callback,
fg_color=self.scheme.foreground.lstrip("#"), fg_color=self.scheme.foreground.lstrip("#"),
bg_color=self.scheme.background.lstrip("#"), bg_color=self.scheme.background.lstrip("#"),
is_dark=self.scheme.is_dark(), is_dark=self.scheme.is_dark(),
@@ -233,7 +235,8 @@ class Terminal:
self.alive = False self.alive = False
self._wake.set() self._wake.set()
try: try:
self.process.terminate() # force: a child ignoring HUP and INT would otherwise outlive the tab
self.process.terminate(force=True)
except Exception: except Exception:
LOG.warning("terminate failed", exc_info=True) LOG.warning("terminate failed", exc_info=True)
vid = self.view.id() vid = self.view.id()
@@ -244,10 +247,6 @@ class Terminal:
"""Screen asked for a clear; the next render performs it on the UI thread.""" """Screen asked for a clear; the next render performs it on the UI thread."""
self.pending_clear = True self.pending_clear = True
def _clipboard_callback(self, text):
"""OSC 52 payload; the clipboard may only be touched on the UI thread."""
sublime.set_timeout(lambda: sublime.set_clipboard(text))
def _need_to_render(self): def _need_to_render(self):
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor.""" """Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
screen = self.screen screen = self.screen
@@ -283,6 +282,10 @@ class Terminal:
break break
chunks.append(self._decoder.decode(raw)) chunks.append(self._decoder.decode(raw))
self._wake.set() self._wake.set()
# backpressure: once this much waits unparsed, stop reading so
# the pty fills and the child blocks, as under a real terminal
while len(chunks) > PENDING_CHUNKS and not done[0] and self.is_hosted():
time.sleep(RENDER_PERIOD)
if done[0] or not self.is_hosted(): if done[0] or not self.is_hosted():
LOG.debug("[reader] stops") LOG.debug("[reader] stops")
break break