11from __future__ import annotations
22
3+ from collections import deque
34import json
45import os
5- import subprocess
6- import time
76from pathlib import Path
87from typing import Any
98
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
200328def 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 ,
0 commit comments