Skip to content

Commit 45c698e

Browse files
committed
улучшен перезагрука тун адаптера
1 parent f492ee0 commit 45c698e

6 files changed

Lines changed: 234 additions & 47 deletions

File tree

xray_fluent/singbox_manager.py

Lines changed: 152 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
from __future__ import annotations
22

3+
from collections import deque
34
import json
45
import os
5-
import subprocess
6-
import time
76
from pathlib import Path
87
from typing import Any
98

@@ -17,10 +16,9 @@
1716
decode_output,
1817
kill_processes_by_path,
1918
result_output_text,
20-
run_text,
19+
run_text_pumped,
2120
sleep_with_events,
2221
wait_for_qprocess_finished,
23-
wait_for_qprocess_ready_read,
2422
wait_for_qprocess_started,
2523
)
2624

@@ -41,7 +39,13 @@ def __init__(self, parent: QObject | None = None):
4139
self._process.errorOccurred.connect(self._on_error)
4240
self._process.finished.connect(self._on_finished)
4341
self._running = False
42+
self._starting = False
4443
self._stop_requested = False
44+
self._startup_failure_reported = False
45+
self._runtime_error_reported = False
46+
self._last_output_lines: deque[str] = deque(maxlen=20)
47+
self._last_exit_code: int | None = None
48+
self._last_exit_status = QProcess.ExitStatus.NormalExit
4549

4650
@property
4751
def is_running(self) -> bool:
@@ -61,6 +65,11 @@ def start(self, singbox_path: str, config: dict[str, Any]) -> bool:
6165
self.error.emit(f"sing-box.exe not found: {exe}")
6266
return False
6367

68+
tun_interface_name = self._extract_tun_interface_name(config)
69+
if not tun_interface_name:
70+
self.error.emit("sing-box config does not contain a TUN inbound interface_name")
71+
return False
72+
6473
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
6574
SINGBOX_CONFIG_FILE.write_text(
6675
json.dumps(config, ensure_ascii=True, indent=2), encoding="utf-8"
@@ -82,32 +91,51 @@ def start(self, singbox_path: str, config: dict[str, Any]) -> bool:
8291

8392
# Set working directory to core/ so sing-box can find wintun.dll
8493
core_dir = exe.parent
94+
self._starting = True
95+
self._startup_failure_reported = False
96+
self._runtime_error_reported = False
97+
self._last_output_lines.clear()
8598

8699
# Try up to 3 times — wintun adapter may need time to be released
87100
for attempt in range(3):
101+
self._last_output_lines.clear()
88102
self._process.setWorkingDirectory(str(core_dir))
89103
self._process.setProgram(str(exe))
90104
self._process.setArguments(["run", "-c", str(SINGBOX_CONFIG_FILE), "-D", str(core_dir)])
91105
self._process.start()
92106

93107
if not wait_for_qprocess_started(self._process, 4000):
94-
self.error.emit(f"failed to start sing-box process: {self._process.errorString()}")
95-
return False
96-
97-
# TUN adapter creation can take several seconds — wait for output
98-
wait_for_qprocess_ready_read(self._process, 5000)
99-
# Brief pause to let FATAL errors surface
100-
sleep_with_events(0.3)
101-
if self._process.state() == QProcess.ProcessState.NotRunning:
102-
# "file already exists" — wait for TUN release and retry
103-
if attempt < 2:
104-
self._wait_tun_released()
105-
continue
106-
self.error.emit("sing-box process exited right after start")
108+
self._starting = False
109+
self._report_startup_failure(f"failed to start sing-box process: {self._process.errorString()}")
107110
return False
108111

109-
return True
112+
if self._wait_until_tun_ready(tun_interface_name):
113+
self._starting = False
114+
self._mark_running()
115+
return True
116+
117+
exited = self._process.state() == QProcess.ProcessState.NotRunning
118+
retryable = exited and self._startup_error_is_retryable()
119+
if not exited:
120+
self.stop(expected=True)
121+
122+
if retryable and attempt < 2:
123+
self._wait_tun_released()
124+
self._starting = True
125+
continue
126+
127+
self._starting = False
128+
if exited:
129+
self._report_startup_failure(
130+
self._unexpected_exit_message(self._last_exit_code, self._last_exit_status, startup=True)
131+
)
132+
else:
133+
self._report_startup_failure(
134+
f"sing-box started but TUN interface '{tun_interface_name}' did not become ready in time"
135+
)
136+
return False
110137

138+
self._starting = False
111139
return False
112140

113141
@staticmethod
@@ -127,6 +155,7 @@ def stop(self, expected: bool = True) -> bool:
127155
if self._running:
128156
self._running = False
129157
self.state_changed.emit(False)
158+
self._starting = False
130159
return True
131160

132161
self._stop_requested = expected
@@ -141,6 +170,7 @@ def stop(self, expected: bool = True) -> bool:
141170
return False
142171

143172
# Wait for TUN adapter to be released by OS (active polling)
173+
self._starting = False
144174
self._wait_tun_released()
145175
return True
146176

@@ -153,7 +183,7 @@ def _wait_tun_released(max_wait: float = 10.0) -> None:
153183
waited = 0.0
154184
while waited < max_wait:
155185
try:
156-
result = run_text(
186+
result = run_text_pumped(
157187
["netsh", "interface", "show", "interface"],
158188
timeout=3,
159189
creationflags=_CREATE_NO_WINDOW,
@@ -176,25 +206,123 @@ def _on_ready_read(self) -> None:
176206
for line in text.splitlines():
177207
clean = line.rstrip()
178208
if clean:
209+
self._last_output_lines.append(clean)
179210
self.log_received.emit(clean)
180211

181212
def _on_started(self) -> None:
182213
self._stop_requested = False
183-
self._running = True
184-
self.started.emit()
185-
self.state_changed.emit(True)
186214

187215
def _on_error(self, process_error: QProcess.ProcessError) -> None:
188216
if self._stop_requested and process_error == QProcess.ProcessError.Crashed:
189217
return
190218
message = f"sing-box process error: {process_error.name} ({self._process.errorString()})"
219+
if self._starting:
220+
self._report_startup_failure(message)
221+
return
222+
if self._runtime_error_reported:
223+
return
224+
self._runtime_error_reported = True
191225
self.error.emit(message)
192226

193227
def _on_finished(self, exit_code: int, _exit_status: int = 0) -> None:
228+
exit_status = QProcess.ExitStatus(_exit_status)
229+
expected = self._stop_requested
230+
self._last_exit_code = exit_code
231+
self._last_exit_status = exit_status
194232
self._stop_requested = False
233+
was_running = self._running
195234
self._running = False
235+
if self._starting and not expected:
236+
self._report_startup_failure(self._unexpected_exit_message(exit_code, exit_status, startup=True))
237+
elif was_running and not expected and not self._runtime_error_reported:
238+
self._runtime_error_reported = True
239+
self.error.emit(self._unexpected_exit_message(exit_code, exit_status, startup=False))
240+
self._starting = False
196241
self.stopped.emit(exit_code)
197-
self.state_changed.emit(False)
242+
if was_running:
243+
self.state_changed.emit(False)
244+
245+
def _mark_running(self) -> None:
246+
if self._running:
247+
return
248+
self._stop_requested = False
249+
self._running = True
250+
self.started.emit()
251+
self.state_changed.emit(True)
252+
253+
@staticmethod
254+
def _extract_tun_interface_name(config: dict[str, Any]) -> str:
255+
for inbound in config.get("inbounds") or []:
256+
if not isinstance(inbound, dict):
257+
continue
258+
if str(inbound.get("type") or "").strip().lower() != "tun":
259+
continue
260+
return str(inbound.get("interface_name") or "").strip()
261+
return ""
262+
263+
def _wait_until_tun_ready(self, tun_interface_name: str, max_wait: float = 18.0) -> bool:
264+
if os.name != "nt" or not tun_interface_name:
265+
return True
266+
step = 0.25
267+
waited = 0.0
268+
while waited < max_wait:
269+
if self._process.state() == QProcess.ProcessState.NotRunning:
270+
return False
271+
if self._tun_interface_has_ipv4(tun_interface_name):
272+
return True
273+
sleep_with_events(step)
274+
waited += step
275+
return False
276+
277+
@staticmethod
278+
def _tun_interface_has_ipv4(tun_interface_name: str) -> bool:
279+
escaped_name = tun_interface_name.replace("'", "''")
280+
script = (
281+
f"$ipv4 = Get-NetIPAddress -InterfaceAlias '{escaped_name}' -AddressFamily IPv4 -ErrorAction SilentlyContinue "
282+
"| Where-Object { $_.IPAddress -and $_.IPAddress -ne '0.0.0.0' } "
283+
"| Select-Object -First 1 IPAddress; "
284+
"if ($ipv4) { exit 0 } else { exit 1 }"
285+
)
286+
try:
287+
result = run_text_pumped(
288+
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
289+
timeout=4,
290+
check=False,
291+
creationflags=_CREATE_NO_WINDOW,
292+
)
293+
except Exception:
294+
return False
295+
return result.returncode == 0
296+
297+
def _startup_error_is_retryable(self) -> bool:
298+
needles = ("already exists", "cannot create a file when that file already exists")
299+
for line in self._last_output_lines:
300+
text = line.lower()
301+
if any(needle in text for needle in needles):
302+
return True
303+
return False
304+
305+
def _unexpected_exit_message(
306+
self,
307+
exit_code: int | None,
308+
exit_status: QProcess.ExitStatus,
309+
*,
310+
startup: bool,
311+
) -> str:
312+
stage = "during startup" if startup else "unexpectedly"
313+
detail = self._last_output_lines[-1].strip() if self._last_output_lines else ""
314+
if detail:
315+
return f"sing-box exited {stage}: {detail}"
316+
if exit_code is None:
317+
return f"sing-box exited {stage}."
318+
status_name = "CrashExit" if exit_status == QProcess.ExitStatus.CrashExit else "NormalExit"
319+
return f"sing-box exited {stage} with code {exit_code} ({status_name})."
320+
321+
def _report_startup_failure(self, message: str) -> None:
322+
if self._startup_failure_reported:
323+
return
324+
self._startup_failure_reported = True
325+
self.error.emit(message)
198326

199327

200328
def get_singbox_version(singbox_path: str) -> str | None:
@@ -209,7 +337,7 @@ def get_singbox_version(singbox_path: str) -> str | None:
209337
if not exe.exists():
210338
return None
211339
try:
212-
result = run_text(
340+
result = run_text_pumped(
213341
[str(exe), "version"],
214342
timeout=3,
215343
check=False,

xray_fluent/singbox_runtime_planner.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def plan_singbox_runtime(
121121
) -> SingboxRuntimePlan:
122122
runtime_config = deepcopy(document.payload)
123123
_ensure_singbox_metrics_contract(runtime_config)
124+
_ensure_singbox_tun_runtime_contract(runtime_config)
124125

125126
outbounds = runtime_config.get("outbounds")
126127
proxy_index = _find_proxy_outbound_index(outbounds)
@@ -320,6 +321,25 @@ def _ensure_singbox_metrics_contract(payload: dict[str, Any]) -> None:
320321
clash_api["external_controller"] = f"127.0.0.1:{SINGBOX_CLASH_API_PORT}"
321322

322323

324+
def _ensure_singbox_tun_runtime_contract(payload: dict[str, Any]) -> None:
325+
"""Patch app-owned runtime fields for raw sing-box configs.
326+
327+
The source document may keep a placeholder or stale interface name, but the
328+
runtime launch should always use a fresh xftun-prefixed adapter name. This
329+
avoids collisions during reconnect/apply while Windows is still releasing
330+
the previous wintun interface.
331+
"""
332+
inbounds = payload.get("inbounds")
333+
if not isinstance(inbounds, list):
334+
return
335+
for inbound in inbounds:
336+
if not isinstance(inbound, dict):
337+
continue
338+
if str(inbound.get("type") or "").strip().lower() != "tun":
339+
continue
340+
inbound["interface_name"] = _generate_tun_interface_name()
341+
342+
323343
def _find_proxy_outbound_index(outbounds: Any) -> int | None:
324344
if not isinstance(outbounds, list):
325345
return None
@@ -391,6 +411,10 @@ def _generate_ss_password(length: int = 24) -> str:
391411
return "".join(secrets.choice(alphabet) for _ in range(length))
392412

393413

414+
def _generate_tun_interface_name() -> str:
415+
return f"xftun{secrets.token_hex(3)}"
416+
417+
394418
def _format_json_error_message(text: str, exc: json.JSONDecodeError) -> str:
395419
lines = text.splitlines()
396420
line = lines[exc.lineno - 1] if 0 < exc.lineno <= len(lines) else ""

xray_fluent/subprocess_utils.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
34
import locale
45
import os
56
import subprocess
@@ -9,6 +10,7 @@
910

1011

1112
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
13+
_SUBPROCESS_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="xray_fluent_subprocess")
1214

1315

1416
def decode_output(data: bytes | None) -> str:
@@ -49,12 +51,13 @@ def run_text(
4951

5052
def pump_qt_events() -> None:
5153
try:
54+
from PyQt6.QtCore import QThread
5255
from PyQt6.QtWidgets import QApplication
5356
except Exception:
5457
return
5558

5659
app = QApplication.instance()
57-
if app is not None:
60+
if app is not None and QThread.currentThread() == app.thread():
5861
app.processEvents()
5962

6063

@@ -101,6 +104,35 @@ def wait_for_qprocess_ready_read(process: Any, timeout_ms: int) -> bool:
101104
return _wait_for_qprocess_call(process, "waitForReadyRead", timeout_ms)
102105

103106

107+
def wait_for_future_with_events(future: Any, timeout_sec: float, *, slice_sec: float = 0.05) -> Any:
108+
deadline = time.monotonic() + max(0.0, float(timeout_sec))
109+
while True:
110+
remaining = deadline - time.monotonic()
111+
if remaining <= 0:
112+
raise TimeoutError("Future did not complete before timeout")
113+
try:
114+
return future.result(timeout=min(slice_sec, remaining))
115+
except FutureTimeoutError:
116+
pump_qt_events()
117+
118+
119+
def run_text_pumped(
120+
command: list[str],
121+
*,
122+
timeout: float,
123+
check: bool = False,
124+
creationflags: int | None = None,
125+
) -> subprocess.CompletedProcess[bytes]:
126+
future = _SUBPROCESS_EXECUTOR.submit(
127+
run_text,
128+
command,
129+
timeout=timeout,
130+
check=check,
131+
creationflags=creationflags,
132+
)
133+
return wait_for_future_with_events(future, timeout + 0.5)
134+
135+
104136
def is_same_path(left: str | Path | None, right: str | Path | None) -> bool:
105137
if not left or not right:
106138
return False
@@ -121,7 +153,7 @@ def kill_processes_by_path(process_name: str, executable_path: str | Path, *, ti
121153
"$matches | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }; "
122154
"Write-Output $matches.Count"
123155
)
124-
result = run_text(
156+
result = run_text_pumped(
125157
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
126158
timeout=timeout,
127159
creationflags=CREATE_NO_WINDOW,

0 commit comments

Comments
 (0)