Skip to content

Commit f82ee7b

Browse files
committed
Guard against duplicate instances stealing the RPC registration
iTerm2 starts a new copy of an AutoLaunch script every time it's launched (Scripts → AutoLaunch) without stopping the prior one. Each copy registers the same `remote_paste` RPC, so ⌃V dispatch becomes ambiguous and invocations get routed to a stale instance and silently dropped — pastes stop working with nothing in the log, even though iTerm2 reports the script as running. ensure_single_instance() now pgreps for sibling copies on startup, SIGTERMs them, and waits for their API connections to close before registering, so exactly one instance owns the function. The PID-selection logic is split into a pure sibling_pids() helper with unit tests. Also add __version__ (1.1), log it on startup, and document the failure mode in the README troubleshooting section.
1 parent c97fd9e commit f82ee7b

3 files changed

Lines changed: 81 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ tail -f ~/Library/Logs/remote_paste.log
102102
A successful paste writes a line like `paste[-CC]: host=my-server tmux=work image=148293 ok=True`.
103103

104104
- **Pasting inserts literal `0x16`.** The script isn't delivering a real keystroke. Confirm the key binding is set to *Invoke Script Function* with exactly `remote_paste(session_id: id)`, and that the script is running (Scripts → Console).
105+
- **⌃V does nothing and no new log lines appear.** You likely have more than one copy of the script running, each fighting over the same RPC registration — this happens when you relaunch from Scripts → AutoLaunch without stopping the old copy first. Check with `pgrep -fl remote_paste.py`; you should see exactly one. The script now evicts older copies on startup, so relaunching it once clears the jam.
105106
- **"No image found in clipboard."** Your clipboard had no image when you pasted. On macOS, Cmd+Shift+Ctrl+4 copies a screenshot to the clipboard; plain Cmd+Shift+4 saves it to a file instead. If `image=0` shows in the log, the image never reached the script. Installing `pngpaste` helps with unusual clipboard formats.
106107
- **`pngpaste: command not found` in the log.** Harmless. The script falls back to osascript. Install `pngpaste` to silence it.
107108
- **Image lands in the wrong tmux pane.** remote-paste targets the active pane of the focused window. If you split a window and the focus tracking is off, open an issue.

remote_paste.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,21 @@
4848
import re
4949
import shlex
5050
import shutil
51+
import signal
5152
import subprocess
5253
import tempfile
54+
import time
5355
import traceback
5456

5557
import iterm2
5658

59+
__version__ = "1.1"
60+
61+
# Marker matched (via pgrep -f) to find sibling copies of this script. iTerm2
62+
# relaunches AutoLaunch scripts without stopping the prior copy, so duplicates
63+
# pile up and fight over the same RPC registration — see ensure_single_instance.
64+
INSTANCE_MARKER = "AutoLaunch/remote_paste.py"
65+
5766
# --------------------------------------------------------------------------- #
5867
# Configuration
5968
# --------------------------------------------------------------------------- #
@@ -262,7 +271,7 @@ async def main(connection):
262271
pngpaste = discover("pngpaste", cfg["pngpaste"],
263272
["/opt/homebrew/bin/pngpaste", "/usr/local/bin/pngpaste"])
264273
ssh = discover("ssh", cfg["ssh"], ["/usr/bin/ssh"]) or "ssh"
265-
log("remote_paste: starting (pngpaste=%s ssh=%s)" % (pngpaste, ssh))
274+
log("remote_paste %s: starting (pngpaste=%s ssh=%s)" % (__version__, pngpaste, ssh))
266275

267276
app = await iterm2.async_get_app(connection)
268277

@@ -325,5 +334,58 @@ async def remote_paste(session_id):
325334
await asyncio.Future() # stay alive so the RPC remains registered
326335

327336

337+
def sibling_pids(pgrep_output, me, parent):
338+
"""PIDs from `pgrep -f` output to terminate: everything but us and our parent.
339+
340+
The parent is excluded because it's the it2_api_wrapper.sh shell that launched
341+
this script — its command line also matches the marker, but killing it would
342+
take us down with it.
343+
"""
344+
return [p for p in (int(x) for x in pgrep_output.split() if x.isdigit())
345+
if p not in (me, parent)]
346+
347+
348+
def ensure_single_instance():
349+
"""Terminate any other running copies of this script before we register.
350+
351+
iTerm2 starts a *new* instance every time the script is launched (Scripts →
352+
AutoLaunch) without stopping the old one, and each instance registers the same
353+
`remote_paste` RPC. Multiple registrations make ⌃V dispatch ambiguous, so
354+
invocations get routed to a stale instance and silently dropped. Killing
355+
siblings on startup guarantees exactly one owner of the function.
356+
"""
357+
me, parent = os.getpid(), os.getppid()
358+
try:
359+
out = subprocess.run(["/usr/bin/pgrep", "-f", INSTANCE_MARKER],
360+
capture_output=True, text=True, timeout=5).stdout
361+
except Exception:
362+
log("singleton: pgrep failed:\n" + traceback.format_exc())
363+
return
364+
others = sibling_pids(out, me, parent)
365+
for pid in others:
366+
try:
367+
os.kill(pid, signal.SIGTERM)
368+
log("singleton: terminated prior instance pid=%d" % pid)
369+
except ProcessLookupError:
370+
pass
371+
except Exception:
372+
log("singleton: kill pid=%d failed:\n%s" % (pid, traceback.format_exc()))
373+
# Wait briefly for the killed instances' API connections to close so iTerm2
374+
# frees the old registration before we claim it.
375+
for _ in range(20):
376+
if not any(_alive(p) for p in others):
377+
break
378+
time.sleep(0.05)
379+
380+
381+
def _alive(pid):
382+
try:
383+
os.kill(pid, 0)
384+
return True
385+
except OSError:
386+
return False
387+
388+
328389
if __name__ == "__main__":
390+
ensure_single_instance()
329391
iterm2.run_forever(main)

tests/test_detect.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,20 @@ def test_remote_tmux_expr_uses_configured_path():
8989
def test_remote_tmux_expr_probes_when_unset():
9090
expr = rp.remote_tmux_expr(None)
9191
assert "command -v tmux" in expr
92+
93+
94+
# --- sibling_pids (singleton guard) --------------------------------------- #
95+
96+
def test_sibling_pids_excludes_self_and_parent():
97+
# pgrep lists us (10), our wrapper parent (9), and one stale instance (42).
98+
assert rp.sibling_pids("9\n10\n42\n", me=10, parent=9) == [42]
99+
100+
def test_sibling_pids_none_to_kill_on_clean_start():
101+
# First launch: only this process and its wrapper are present.
102+
assert rp.sibling_pids("9\n10\n", me=10, parent=9) == []
103+
104+
def test_sibling_pids_kills_multiple_duplicates():
105+
assert rp.sibling_pids("9 10 14899 14913 25459", me=10, parent=9) == [14899, 14913, 25459]
106+
107+
def test_sibling_pids_ignores_non_numeric_noise():
108+
assert rp.sibling_pids("10\n\n \nfoo\n42\n", me=10, parent=9) == [42]

0 commit comments

Comments
 (0)