Skip to content

Commit 2e567cf

Browse files
committed
fix: never let an older copy answer for a newly installed one
A second launch handed the window to whatever was listening on the socket and exited, so opening a fresh install while an older build was still running raised the old window and said nothing: Settings showed the previous version with no explanation, and on macOS the login item kept the old copy coming back. The listener now advertises its version, and a launch that finds a different one explains what is on screen instead of disappearing. Released as 0.8.1.
1 parent e13389d commit 2e567cf

7 files changed

Lines changed: 249 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## v0.8.1
4+
5+
- Opening a newly installed sushTun while an older one was still running raised the *old* window
6+
and the new launch disappeared without a word: Settings then showed the previous version number
7+
and nothing explained why. Every launch now says which version it is, and one that finds a
8+
different build tells you what is on screen instead of vanishing. Found on macOS, where a login
9+
item pointing at the old copy kept the new one from ever getting a turn.
10+
311
## v0.8.0 — macOS
412

513
Tested on a real Mac (Apple Silicon, macOS 26) for the first time. Connecting on macOS never

‎tests/test_main.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ def _stub_common(monkeypatch, argv):
1717
monkeypatch.setattr(elevate, "is_admin", lambda: True)
1818
monkeypatch.setattr(elevate, "relaunch_as_admin", lambda: False)
1919
monkeypatch.setattr(paths, "ensure_dirs", lambda: None)
20-
# Never knock on a real sushTun running on the test machine.
21-
monkeypatch.setattr(single_instance, "notify_running", lambda **kw: False)
20+
# Never knock on a real sushTun running on the test machine, and never
21+
# let the different-build dialog open behind a test run.
22+
monkeypatch.setattr(single_instance, "hand_over", lambda **kw: False)
2223
captured = {}
2324
monkeypatch.setattr(app_mod, "run", lambda argv, **kw: captured.update(argv=argv, **kw)
2425
or 0)
@@ -42,7 +43,7 @@ def test_a_second_launch_hands_over_before_asking_for_the_password(monkeypatch):
4243
knocks, elevations = [], []
4344
monkeypatch.setattr(elevate, "is_admin", lambda: False)
4445
monkeypatch.setattr(elevate, "relaunch_as_admin", lambda: elevations.append(1) or True)
45-
monkeypatch.setattr(single_instance, "notify_running",
46+
monkeypatch.setattr(single_instance, "hand_over",
4647
lambda **kw: knocks.append(kw) or True)
4748
assert main_mod.main() == 0
4849
assert knocks == [{"show": True}]
@@ -52,7 +53,7 @@ def test_a_second_launch_hands_over_before_asking_for_the_password(monkeypatch):
5253
def test_a_login_launch_leaves_the_running_copy_hidden(monkeypatch):
5354
_stub_common(monkeypatch, ["sushtun", "--autostart"])
5455
knocks = []
55-
monkeypatch.setattr(single_instance, "notify_running",
56+
monkeypatch.setattr(single_instance, "hand_over",
5657
lambda **kw: knocks.append(kw) or True)
5758
assert main_mod.main() == 0
5859
assert knocks == [{"show": False}]

‎tests/test_single_instance.py‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""A second launch shows the running window instead of starting another copy."""
22
from __future__ import annotations
33

4+
import json
45
import os
56
import socket
67
import sys
@@ -14,6 +15,7 @@
1415
from PySide6.QtCore import QCoreApplication, QDeadlineTimer # noqa: E402
1516
from PySide6.QtWidgets import QApplication # noqa: E402
1617

18+
from xrayui import paths # noqa: E402
1719
from xrayui.ui import single_instance # noqa: E402
1820

1921

@@ -70,3 +72,99 @@ def test_a_socket_file_left_by_a_crash_does_not_block_the_next_start(qapp, name)
7072
finally:
7173
if os.path.exists(path):
7274
os.unlink(path)
75+
76+
77+
# -- which build is answering ------------------------------------------------
78+
# The handover was blind: any build on the socket got the window, so opening a
79+
# freshly installed version while an older one still ran raised the OLD window
80+
# and the new launch vanished. Settings then showed the old version number with
81+
# nothing to explain it.
82+
83+
@pytest.fixture
84+
def state(tmp_path, monkeypatch):
85+
monkeypatch.setattr(paths, "base_dir", lambda: tmp_path)
86+
monkeypatch.setattr(paths, "state_dir", lambda: tmp_path / "state")
87+
return tmp_path / "state"
88+
89+
90+
# -- the advertisement -------------------------------------------------------
91+
def test_the_listener_advertises_its_version_and_withdraws_it(state):
92+
single_instance.advertise()
93+
other = single_instance.running_instance()
94+
assert other is not None
95+
assert other.version == single_instance.__version__ and other.pid == os.getpid()
96+
assert other.is_this_build
97+
single_instance.withdraw()
98+
assert single_instance.running_instance() is None
99+
100+
101+
def test_nothing_advertised_reads_as_unknown(state):
102+
# What an older build leaves behind: no file at all.
103+
assert single_instance.running_instance() is None
104+
105+
106+
def test_a_leftover_from_a_dead_process_is_not_believed(state, monkeypatch):
107+
single_instance.advertise()
108+
monkeypatch.setattr(single_instance, "_alive", lambda _pid: False)
109+
assert single_instance.running_instance() is None
110+
111+
112+
def test_a_corrupt_advertisement_is_not_believed(state):
113+
single_instance.identity_path().parent.mkdir(parents=True, exist_ok=True)
114+
single_instance.identity_path().write_text("{not json", encoding="utf-8")
115+
assert single_instance.running_instance() is None
116+
single_instance.identity_path().write_text(json.dumps({"version": "9.9.9"}), encoding="utf-8")
117+
assert single_instance.running_instance() is None # no pid to check
118+
119+
120+
def test_a_root_owned_listener_counts_as_alive(monkeypatch):
121+
# The running copy is root and we are not: kill(pid, 0) is refused, and a
122+
# refusal proves the process is there.
123+
def refuse(_pid, _sig):
124+
raise PermissionError
125+
126+
monkeypatch.setattr(single_instance.os, "kill", refuse)
127+
assert single_instance._alive(1234) is True
128+
129+
130+
# -- what a second launch does -----------------------------------------------
131+
@pytest.fixture
132+
def warned(monkeypatch):
133+
seen = []
134+
monkeypatch.setattr(single_instance, "_warn_other_build", lambda other: seen.append(other))
135+
return seen
136+
137+
138+
def test_no_copy_running_means_carry_on(monkeypatch, warned):
139+
monkeypatch.setattr(single_instance, "notify_running", lambda **_kw: False)
140+
assert single_instance.hand_over() is False
141+
assert warned == []
142+
143+
144+
def test_the_same_build_hands_over_without_a_word(monkeypatch, state, warned):
145+
monkeypatch.setattr(single_instance, "notify_running", lambda **_kw: True)
146+
single_instance.advertise()
147+
assert single_instance.hand_over() is True
148+
assert warned == []
149+
150+
151+
def test_a_different_version_is_called_out(monkeypatch, state, warned):
152+
monkeypatch.setattr(single_instance, "notify_running", lambda **_kw: True)
153+
single_instance.identity_path().parent.mkdir(parents=True, exist_ok=True)
154+
single_instance.identity_path().write_text(json.dumps(
155+
{"version": "0.7.1", "exe": "/old/sushtun", "pid": os.getpid()}), encoding="utf-8")
156+
assert single_instance.hand_over() is True
157+
assert [o.version for o in warned] == ["0.7.1"]
158+
159+
160+
def test_a_build_too_old_to_advertise_is_called_out_too(monkeypatch, state, warned):
161+
monkeypatch.setattr(single_instance, "notify_running", lambda **_kw: True)
162+
assert single_instance.hand_over() is True
163+
assert warned == [None]
164+
165+
166+
def test_a_login_launch_never_opens_a_dialog(monkeypatch, state, warned):
167+
# --autostart raises no window; a dialog would appear out of nowhere.
168+
monkeypatch.setattr(single_instance, "notify_running", lambda **_kw: True)
169+
assert single_instance.hand_over(show=False) is True
170+
assert warned == []

‎xrayui/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.8.0"
1+
__version__ = "0.8.1"

‎xrayui/__main__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ def main() -> int:
1818
autostart = "--autostart" in argv
1919
# Checked before elevating, so a second launch neither asks for the
2020
# password again nor starts a copy that would fight over the tunnel.
21-
from xrayui.ui.single_instance import notify_running
22-
if notify_running(show=not autostart):
21+
from xrayui.ui.single_instance import hand_over
22+
if hand_over(show=not autostart):
2323
return 0
2424
if not elevate.is_admin() and elevate.relaunch_as_admin():
2525
return 0 # elevated instance takes over

‎xrayui/i18n_fa.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,14 @@
523523
"و دستی جایگزین کنید.",
524524
"Couldn't open a browser. The page is {url}":
525525
"مرورگر باز نشد. نشانی صفحه: {url}",
526+
"You opened sushTun {this}, but another copy is already running — the "
527+
"window on screen is that one. Quit it first, then open this copy again.":
528+
"شما sushTun {this} را باز کردید، ولی نسخه‌ی دیگری از قبل در حال اجراست — "
529+
"پنجره‌ای که می‌بینید مال آن است. اول آن را ببندید، بعد این نسخه را باز کنید.",
530+
"You opened sushTun {this}, but sushTun {running} is already running — the "
531+
"window on screen is that one. Quit it first, then open this copy again.":
532+
"شما sushTun {this} را باز کردید، ولی sushTun {running} از قبل در حال اجراست — "
533+
"پنجره‌ای که می‌بینید مال آن است. اول آن را ببندید، بعد این نسخه را باز کنید.",
526534
"Stopping…": "در حال توقف…",
527535
"Downloading {name}…": "در حال دانلود {name}…",
528536
"Downloaded {done} of {total}": "{done} از {total} دانلود شد",

‎xrayui/ui/single_instance.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,101 @@
33
Two copies share one state directory, one TUN and one resolver, so a second
44
copy that quits runs the first one's teardown and cuts its tunnel. Each extra
55
launch also asked for the admin password again, then sat hidden in the tray.
6+
7+
The handover used to be blind: any build answering on the socket got the
8+
window, so opening a freshly installed version while an older one was still
9+
running raised the *older* window and the new launch vanished. Settings then
10+
showed the old version number and nothing said why -- and with a login item
11+
pointing at the old copy, the new one could never get a turn to fix it. The
12+
listener therefore advertises which version it is, and a launch that finds a
13+
different one says so instead of disappearing.
614
"""
715
from __future__ import annotations
816

17+
import json
18+
import os
19+
import sys
20+
from dataclasses import dataclass
21+
from pathlib import Path
22+
923
from PySide6.QtCore import QObject, Signal
1024
from PySide6.QtNetwork import QLocalServer, QLocalSocket
1125

26+
from .. import __version__, paths
27+
1228
# One fixed name for every user: the elevated copy (root) listens and the
1329
# user's unelevated launch knocks, and there is only one tunnel to own anyway.
1430
NAME = "sushtun-single-instance"
1531
_SHOW = b"show\n"
1632

1733

34+
# Written by the listener next to the rest of the state, read by every later
35+
# launch. A build older than this one writes none, which reads as "unknown".
36+
IDENTITY = "instance.json"
37+
38+
39+
@dataclass(frozen=True)
40+
class Instance:
41+
"""The copy that is already running, as it advertised itself."""
42+
43+
version: str
44+
exe: str
45+
pid: int
46+
47+
@property
48+
def is_this_build(self) -> bool:
49+
return self.version == __version__
50+
51+
52+
def identity_path() -> Path:
53+
return paths.state_dir() / IDENTITY
54+
55+
56+
def advertise() -> None:
57+
"""Record who is listening. Best effort: failing to write it only costs
58+
a later launch the version in its message."""
59+
try:
60+
identity_path().parent.mkdir(parents=True, exist_ok=True)
61+
identity_path().write_text(json.dumps({
62+
"version": __version__, "exe": sys.executable, "pid": os.getpid(),
63+
}), encoding="utf-8")
64+
except OSError:
65+
pass
66+
67+
68+
def withdraw() -> None:
69+
try:
70+
identity_path().unlink(missing_ok=True)
71+
except OSError:
72+
pass
73+
74+
75+
def _alive(pid: int) -> bool:
76+
if pid <= 0:
77+
return False
78+
try:
79+
os.kill(pid, 0)
80+
except ProcessLookupError:
81+
return False
82+
except PermissionError:
83+
return True # running as root, which is exactly what we expect
84+
except (OSError, AttributeError):
85+
return True # Windows has no signal 0; assume the listener is real
86+
return True
87+
88+
89+
def running_instance() -> Instance | None:
90+
"""The advertised identity of the copy that is running, or None when there
91+
is none to read -- a crashed leftover, or a build too old to write one."""
92+
try:
93+
data = json.loads(identity_path().read_text(encoding="utf-8"))
94+
pid = int(data["pid"])
95+
version, exe = str(data["version"]), str(data.get("exe", ""))
96+
except (OSError, ValueError, KeyError, TypeError):
97+
return None
98+
return Instance(version, exe, pid) if _alive(pid) else None
99+
100+
18101
def notify_running(show: bool = True, name: str = NAME) -> bool:
19102
"""True if a copy is already running; asks it to show its window unless
20103
show is False. Works before any QApplication exists."""
@@ -29,6 +112,47 @@ def notify_running(show: bool = True, name: str = NAME) -> bool:
29112
return True
30113

31114

115+
def hand_over(show: bool = True, name: str = NAME) -> bool:
116+
"""True when a copy is already running and this launch must stop.
117+
118+
Stopping silently is right only when the window that comes up is this same
119+
build. When it is a different one, nothing on screen would tell the user
120+
which version they are looking at, so say it.
121+
"""
122+
if not notify_running(show=show, name=name):
123+
return False
124+
other = running_instance()
125+
# A login launch raises no window and must never pop a dialog behind one.
126+
if show and (other is None or not other.is_this_build):
127+
_warn_other_build(other)
128+
return True
129+
130+
131+
def _warn_other_build(other: Instance | None) -> None:
132+
from PySide6.QtWidgets import QApplication, QMessageBox
133+
134+
from .. import i18n
135+
from ..core import settings as app_settings
136+
from ..i18n import ltr, tr
137+
138+
# Nothing has set the language yet: this runs before ui.app.run.
139+
try:
140+
i18n.set_language(app_settings.load().get("language", "en"))
141+
except Exception: # a broken settings file must not cost the message
142+
pass
143+
app = QApplication.instance() or QApplication([])
144+
if other is None:
145+
text = tr("You opened sushTun {this}, but another copy is already running — the "
146+
"window on screen is that one. Quit it first, then open this copy again.",
147+
this=ltr(__version__))
148+
else:
149+
text = tr("You opened sushTun {this}, but sushTun {running} is already running — the "
150+
"window on screen is that one. Quit it first, then open this copy again.",
151+
this=ltr(__version__), running=ltr(other.version))
152+
QMessageBox.information(None, "sushTun", text)
153+
app.processEvents()
154+
155+
32156
class InstanceServer(QObject):
33157
"""Listens for later launches and emits show_requested for each one."""
34158

@@ -45,6 +169,9 @@ def __init__(self, name: str = NAME, parent: QObject | None = None) -> None:
45169
QLocalServer.removeServer(name)
46170
self._server.listen(name)
47171
self._server.newConnection.connect(self._accept)
172+
if self._server.isListening():
173+
advertise()
174+
self.destroyed.connect(lambda *_: withdraw())
48175

49176
def is_listening(self) -> bool:
50177
return self._server.isListening()

0 commit comments

Comments
 (0)