Skip to content

Commit 6cc1c4a

Browse files
authored
Merge pull request #10 from maebahesioru/fix/windows-stability
fix: Windows Japanese locale support and stability improvements
2 parents b5b5fef + 4d5f0b5 commit 6cc1c4a

12 files changed

Lines changed: 174 additions & 49 deletions

auto_update.bat

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
@echo off
2+
cd /d "%~dp0"
3+
4+
where git >nul 2>&1
5+
if %errorlevel% neq 0 (
6+
echo Git not found, skipping update check.
7+
exit /b 0
8+
)
9+
10+
echo Checking for updates...
11+
12+
git fetch origin main --quiet 2>nul
13+
if %errorlevel% neq 0 (
14+
echo Could not reach GitHub, skipping update check.
15+
exit /b 0
16+
)
17+
18+
for /f %%i in ('git rev-parse HEAD') do set LOCAL=%%i
19+
for /f %%i in ('git rev-parse origin/main') do set REMOTE=%%i
20+
21+
if "%LOCAL%"=="%REMOTE%" (
22+
echo Already up to date.
23+
exit /b 0
24+
)
25+
26+
echo Update available! Applying...
27+
git pull origin main --quiet
28+
if %errorlevel% neq 0 (
29+
echo Update failed. Please run 'git pull' manually.
30+
exit /b 1
31+
)
32+
33+
echo Running uv sync...
34+
uv sync --quiet
35+
if %errorlevel% neq 0 (
36+
echo Dependency update failed.
37+
exit /b 1
38+
)
39+
40+
echo Update complete!
41+
exit /b 0

src/api/request_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ async def listen_for_disconnect():
200200
# 直接等待 ASGI 消息,不再轮询
201201
message = await http_request.receive()
202202
if message['type'] == 'http.disconnect':
203-
logger.warning(f'[{req_id}] 🔌 收到 http.disconnect 信号')
203+
logger.debug(f'[{req_id}] 🔌 收到 http.disconnect 信号')
204204
client_disconnected_event.set()
205205
if not result_future.done():
206206
result_future.set_exception(HTTPException(status_code=499, detail=f'[{req_id}] 客户端关闭了请求'))

src/browser/model_management.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,10 @@ async def _handle_initial_model_state_and_storage(page: AsyncPage):
383383
logger.warning(f' ⚠️ 重新加载后UI状态验证失败')
384384
break
385385
except Exception as reload_err:
386+
err_str = str(reload_err)
387+
if 'Target page, context or browser has been closed' in err_str or 'Browser has been closed' in err_str:
388+
logger.warning(f' ⚠️ 浏览器已关闭,跳过重新加载。')
389+
return
386390
logger.warning(f' ⚠️ 页面重新加载尝试 {attempt + 1}/{max_retries} 失败: {reload_err}')
387391
if attempt < max_retries - 1:
388392
logger.info(f' 将在5秒后重试...')

src/browser/page_controller.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ async def set_system_instructions(self, system_prompt: str, check_client_disconn
105105
self.logger.warning(f'[{self.req_id}] ⚠️ 系统指令填充可能不完整 (期望: {len(system_prompt)}, 实际: {len(filled_value)})')
106106
for close_attempt in range(1, 4):
107107
try:
108+
if not await sys_prompt_textarea.is_visible():
109+
self.logger.info(f'[{self.req_id}] ✅ 系统指令面板已关闭。')
110+
break
111+
# Try clicking the button again to close the panel
112+
await sys_prompt_button.click(timeout=2000)
113+
await asyncio.sleep(DELAY_AFTER_FILL)
114+
if not await sys_prompt_textarea.is_visible():
115+
self.logger.info(f'[{self.req_id}] ✅ 系统指令面板已关闭。')
116+
break
117+
# Fallback: Escape key
108118
await self.page.keyboard.press("Escape")
109119
await asyncio.sleep(DELAY_AFTER_FILL)
110120
if not await sys_prompt_textarea.is_visible():
@@ -570,24 +580,24 @@ def is_equal(val1, val2):
570580
await asyncio.sleep(DELAY_AFTER_TOGGLE)
571581

572582
if attempt == 0:
583+
strategy_name = "JS Injection"
584+
await locator.evaluate('(el, val) => { el.value = val; el.dispatchEvent(new Event("input", {bubbles: true})); el.dispatchEvent(new Event("change", {bubbles: true})); }', str(target_value))
585+
await asyncio.sleep(DELAY_AFTER_FILL)
586+
await locator.press('Enter')
587+
elif attempt == 1:
573588
strategy_name = "Standard Fill"
574589
await locator.focus()
575590
await locator.fill(str(target_value))
576591
await locator.dispatch_event('change')
577592
await locator.press('Enter')
578-
elif attempt == 1:
593+
else:
579594
strategy_name = "Select & Type"
580595
await locator.focus()
581596
await locator.select_text()
582597
await locator.press('Backspace')
583598
await asyncio.sleep(SLEEP_TICK)
584599
await locator.type(str(target_value), delay=50)
585600
await locator.press('Enter')
586-
else:
587-
strategy_name = "JS Injection"
588-
await locator.evaluate('(el, val) => { el.value = val; el.dispatchEvent(new Event("input", {bubbles: true})); el.dispatchEvent(new Event("change", {bubbles: true})); }', str(target_value))
589-
await asyncio.sleep(DELAY_AFTER_FILL)
590-
await locator.press('Enter')
591601

592602
await asyncio.sleep(SLEEP_LONG)
593603

src/browser/selector_utils.py

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,41 +9,22 @@ async def wait_for_any_selector(
99
timeout: int = 5000,
1010
state: str = 'visible'
1111
) -> Tuple[Optional[Locator], Optional[str]]:
12-
async def check_one(selector: str) -> Tuple[bool, str]:
13-
try:
14-
locator = page.locator(selector)
15-
await locator.wait_for(state=state, timeout=timeout)
16-
return (True, selector)
17-
except:
18-
return (False, selector)
19-
20-
tasks = [asyncio.create_task(check_one(sel)) for sel in selectors]
21-
22-
async def cancel_tasks(pending_tasks):
23-
for p in pending_tasks:
24-
p.cancel()
25-
await asyncio.gather(*pending_tasks, return_exceptions=True)
26-
12+
combined = ", ".join(selectors)
2713
try:
28-
done, pending = await asyncio.wait(
29-
tasks,
30-
return_when=asyncio.FIRST_COMPLETED,
31-
timeout=timeout / 1000 + 1
32-
)
33-
34-
for task in done:
35-
success, selector = task.result()
36-
if success:
37-
await cancel_tasks(pending)
38-
return (page.locator(selector), selector)
39-
40-
await cancel_tasks(pending)
41-
return (None, None)
42-
43-
except asyncio.TimeoutError:
44-
await cancel_tasks(tasks)
14+
await page.locator(combined).first.wait_for(state=state, timeout=timeout)
15+
except Exception:
4516
return (None, None)
4617

18+
for selector in selectors:
19+
try:
20+
loc = page.locator(selector)
21+
if await loc.count() > 0 and await loc.first.is_visible():
22+
return (loc, selector)
23+
except Exception:
24+
continue
25+
26+
return (page.locator(combined), combined)
27+
4728

4829

4930
async def get_first_visible_locator(

src/gateway.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import aiohttp
99
import uvicorn
1010
from fastapi import FastAPI, HTTPException, Request
11+
from fastapi.middleware.cors import CORSMiddleware
1112
from fastapi.responses import Response, StreamingResponse
1213

1314

@@ -117,6 +118,13 @@ async def lifespan(app: FastAPI):
117118
lifespan=lifespan,
118119
)
119120

121+
app.add_middleware(
122+
CORSMiddleware,
123+
allow_origins=["*"],
124+
allow_methods=["*"],
125+
allow_headers=["*"],
126+
)
127+
120128

121129
@app.get("/", tags=["System"], summary="网关状态")
122130
async def root():
@@ -151,6 +159,8 @@ async def models():
151159
async def chat_completions(request: Request):
152160
await refresh_workers()
153161
body = await request.body()
162+
if not body:
163+
raise HTTPException(status_code=400, detail="Request body is empty")
154164
body_json = json.loads(body)
155165
is_stream = body_json.get("stream", False)
156166
model_id = body_json.get("model", "")

src/launch_camoufox.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
launch_server = None
3333
DefaultAddons = None
3434
PYTHON_EXECUTABLE = sys.executable
35-
ENDPOINT_CAPTURE_TIMEOUT = int(os.environ.get('ENDPOINT_CAPTURE_TIMEOUT', '45'))
35+
ENDPOINT_CAPTURE_TIMEOUT = int(os.environ.get('ENDPOINT_CAPTURE_TIMEOUT', '90'))
3636
DEFAULT_SERVER_PORT = int(os.environ.get('DEFAULT_FASTAPI_PORT', '2048'))
3737
DEFAULT_CAMOUFOX_PORT = int(os.environ.get('DEFAULT_CAMOUFOX_PORT', '9222'))
3838
DEFAULT_STREAM_PORT = int(os.environ.get('STREAM_PORT', '3120'))
@@ -129,7 +129,7 @@ def cleanup():
129129
camoufox_proc.terminate()
130130
elif sys.platform == 'win32':
131131
logger.info(f'进程树 (PID: {pid}) 发送终止请求')
132-
subprocess.call(['taskkill', '/T', '/PID', str(pid)])
132+
subprocess.call(['taskkill', '/F', '/T', '/PID', str(pid)])
133133
else:
134134
logger.info(f' 向 Camoufox (PID: {pid}) 发送 SIGTERM 信号...')
135135
camoufox_proc.terminate()
@@ -465,17 +465,42 @@ def determine_proxy_configuration(internal_camoufox_proxy_arg=None):
465465
print(f'--- [内部Camoufox启动] 正在调用 camoufox.server.launch_server ... ---', flush=True)
466466
try:
467467
memory_optimization_prefs = {
468+
# Disable memory cache
468469
'browser.cache.memory.enable': False,
469470
'browser.cache.memory.capacity': 0,
470-
'browser.sessionhistory.max_entries': 3,
471+
# Minimal session history
472+
'browser.sessionhistory.max_entries': 2,
471473
'browser.sessionhistory.max_total_viewers': 0,
474+
# JS memory limits
472475
'javascript.options.mem.gc_frequency': 300,
473-
'javascript.options.mem.high_water_mark': 128,
476+
'javascript.options.mem.high_water_mark': 32,
477+
'javascript.options.mem.nursery.max_kb': 2048,
478+
# Single process mode
474479
'dom.ipc.processCount': 1,
480+
'dom.ipc.processCount.webIsolated': 1,
481+
'browser.tabs.remote.autostart': False,
482+
'browser.tabs.remote.autostart.2': False,
483+
# Disable unused features
475484
'layout.css.grid-template-masonry-value.enabled': False,
476485
'toolkit.cosmeticAnimations.enabled': False,
486+
'media.memory_cache_max_size': 256,
487+
'image.mem.max_ms_before_yield': 50,
488+
# Disable telemetry/background services
489+
'datareporting.healthreport.uploadEnabled': False,
490+
'datareporting.policy.dataSubmissionEnabled': False,
491+
'browser.ping-centre.telemetry': False,
492+
'toolkit.telemetry.enabled': False,
493+
'toolkit.telemetry.unified': False,
494+
# Disable disk cache
495+
'browser.cache.disk.enable': False,
496+
'browser.cache.offline.enable': False,
497+
# Disable prefetch
498+
'network.prefetch-next': False,
499+
'network.dns.disablePrefetch': True,
500+
# Reduce font memory
501+
'gfx.font_rendering.fontconfig.max_generic_substitutions': 3,
477502
}
478-
launch_args_for_internal_camoufox = {'port': camoufox_port_internal, 'addons': [], 'exclude_addons': [DefaultAddons.UBO], 'window': (1920, 1080), 'firefox_user_prefs': memory_optimization_prefs}
503+
launch_args_for_internal_camoufox = {'port': camoufox_port_internal, 'addons': [], 'exclude_addons': [DefaultAddons.UBO], 'window': (1280, 720), 'firefox_user_prefs': memory_optimization_prefs}
479504
if camoufox_proxy_internal:
480505
launch_args_for_internal_camoufox['proxy'] = {'server': camoufox_proxy_internal}
481506
if auth_file:

src/manager/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ async def _start_worker_mode(
450450
started_count += 1
451451
logger.info(f"启动Worker {worker_id} (端口:{worker.port})")
452452
if index < len(worker_ids) - 1:
453-
await asyncio.sleep(3)
453+
await asyncio.sleep(15)
454454

455455
if self.stop_event.is_set():
456456
self.service_status = "stopped"

src/proxy/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,19 @@ def _parse_headers(header_bytes: bytes) -> Dict[str, str]:
5555
"bad record mac",
5656
"Connection reset",
5757
"Connection aborted",
58+
"ConnectionResetError",
5859
"EOF occurred in violation",
5960
"WRONG_VERSION_NUMBER",
6061
"\u9023\u7dda\u5df2\u88ab\u60a8\u4e3b\u6a5f\u4e0a\u7684\u8edf\u9ad4\u4e2d\u6b62",
6162
"\u9060\u7aef\u4e3b\u6a5f\u5df2\u5f37\u5236\u95dc\u9589",
63+
# Japanese locale (WinError 10053 / 10054)
64+
"\u78ba\u7acb\u3055\u308c\u305f\u63a5\u7d9a\u304c\u30db\u30b9\u30c8",
65+
"\u30ea\u30e2\u30fc\u30c8 \u30db\u30b9\u30c8\u306b\u3088\u3063\u3066\u5f37\u5236\u7684\u306b\u9589\u3058\u3089\u308c",
6266
)
6367

6468
@classmethod
6569
def _should_ignore_connection_error(cls, exc: BaseException) -> bool:
66-
err_str = str(exc)
70+
err_str = f"{type(exc).__name__}: {exc}"
6771
return any(marker in err_str for marker in cls._IGNORABLE_ERRORS)
6872

6973
async def _run_relay_tasks(self, *coroutines) -> None:
@@ -121,7 +125,8 @@ async def accept_client(
121125
if method == "CONNECT":
122126
await self._process_tunnel(reader, writer, target)
123127
except Exception as e:
124-
self.log.error(f"Client error: {e}")
128+
if not self._should_ignore_connection_error(e):
129+
self.log.error(f"Client error: {e!r}", exc_info=True)
125130
finally:
126131
writer.close()
127132

src/worker/pool.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,36 @@ def _cancel_restart(self, worker_id: str):
244244
if task and not task.done():
245245
task.cancel()
246246

247+
def _free_port(self, port: int) -> None:
248+
"""Kill any process occupying the given TCP port."""
249+
try:
250+
if platform.system() == "Windows":
251+
result = subprocess.run(
252+
["netstat", "-ano"],
253+
capture_output=True, text=True
254+
)
255+
for line in result.stdout.splitlines():
256+
if f":{port} " in line and "LISTENING" in line:
257+
parts = line.split()
258+
pid = parts[-1]
259+
if pid.isdigit():
260+
subprocess.run(
261+
["taskkill", "/PID", pid, "/F"],
262+
capture_output=True
263+
)
264+
logger.info(f"Freed port {port} (killed PID {pid})")
265+
else:
266+
result = subprocess.run(
267+
["lsof", "-ti", f"tcp:{port}"],
268+
capture_output=True, text=True
269+
)
270+
for pid in result.stdout.strip().splitlines():
271+
if pid.isdigit():
272+
subprocess.run(["kill", "-9", pid], capture_output=True)
273+
logger.info(f"Freed port {port} (killed PID {pid})")
274+
except Exception as e:
275+
logger.warning(f"Failed to free port {port}: {e}")
276+
247277
def start_worker(self, worker_id: str) -> tuple[bool, str]:
248278
if worker_id not in self.workers:
249279
return False, "Worker not found"
@@ -255,6 +285,11 @@ def start_worker(self, worker_id: str) -> tuple[bool, str]:
255285
):
256286
return False, "Worker already running"
257287
self._cancel_restart(worker_id)
288+
self._free_port(worker.camoufox_port)
289+
self._free_port(worker.port)
290+
stream_port = self._resolve_stream_port(worker)
291+
if stream_port:
292+
self._free_port(stream_port)
258293
try:
259294
worker.process = subprocess.Popen(
260295
self._build_worker_command(worker),
@@ -269,6 +304,7 @@ def start_worker(self, worker_id: str) -> tuple[bool, str]:
269304
worker.health_failures = 0
270305
worker.last_health_check = None
271306
worker.last_error = None
307+
worker._start_time = time.time()
272308
self._notify_process_started(worker)
273309
self._notify_status_change(worker, "started")
274310
logger.info(f"Started worker {worker_id} on port {worker.port}")
@@ -520,6 +556,13 @@ async def health_check(
520556
for worker in list(self.workers.values()):
521557
if worker.status != "running":
522558
continue
559+
# Grace period: skip health check for 60s after start
560+
if worker.last_health_check is None and worker.process is not None:
561+
start_time = getattr(worker, '_start_time', None)
562+
if start_time is None or time.time() - start_time < 60:
563+
if start_time is None:
564+
worker._start_time = time.time()
565+
continue
523566
is_healthy, error = await self._probe_worker_health(worker)
524567
worker.last_health_check = time.time()
525568
if is_healthy:
@@ -538,6 +581,7 @@ async def health_check(
538581
self._schedule_restart(worker.id)
539582

540583
async def health_check_loop(self):
584+
await asyncio.sleep(90)
541585
while True:
542586
try:
543587
await self.health_check()

0 commit comments

Comments
 (0)