fix: use nohup+file instead of subprocess pipe to prevent sshx process death

This commit is contained in:
OpenCode Agent 2026-08-17 22:33:19 +00:00
parent 4111590b18
commit 1a6c956666
1 changed files with 65 additions and 63 deletions

View File

@ -1,8 +1,10 @@
import asyncio import asyncio
import subprocess import subprocess
import re import re
import os
import shutil import shutil
import logging import logging
import time
import threading import threading
from typing import Optional from typing import Optional
from fastapi import APIRouter from fastapi import APIRouter
@ -11,8 +13,9 @@ from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_sshx_process = None
_sshx_link = None _sshx_link = None
_sshx_pid = None
_sshx_log = "/tmp/sshx_link.log"
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+") LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+")
@ -31,92 +34,90 @@ def _ensure_sshx() -> bool:
return False return False
def _start_reader(proc, future): def _kill_sshx():
global _sshx_link global _sshx_pid, _sshx_link
if _sshx_pid:
def _read():
try: try:
found = False os.kill(_sshx_pid, 15)
for line in proc.stdout: except ProcessLookupError:
line = line.strip() pass
if not found: _sshx_pid = None
m = LINK_RE.search(line) _sshx_link = None
if os.path.exists(_sshx_log):
os.remove(_sshx_log)
def _is_alive() -> bool:
global _sshx_pid
if not _sshx_pid:
return False
try:
os.kill(_sshx_pid, 0)
return True
except ProcessLookupError:
return False
def _start_sshx_background():
global _sshx_pid, _sshx_link
_kill_sshx()
_sshx_link = None
subprocess.Popen(
["nohup", "sshx", "--quiet"],
stdout=open(_sshx_log, "w"),
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
for _ in range(30):
time.sleep(1)
if os.path.exists(_sshx_log) and os.path.getsize(_sshx_log) > 0:
with open(_sshx_log) as f:
content = f.read().strip()
m = LINK_RE.search(content)
if m: if m:
_sshx_link = m.group(0) _sshx_link = m.group(0)
found = True try:
if not future.done(): with open("/tmp/sshx_pid") as pf:
future.set_result(_sshx_link) _sshx_pid = int(pf.read().strip())
if not found and not future.done(): except Exception:
future.set_exception(RuntimeError("sshx exited without link")) pass
except Exception as e: return True
if not future.done(): return False
future.set_exception(e)
t = threading.Thread(target=_read, daemon=True)
t.start()
return t
@router.get("/start") @router.get("/start")
async def start_sshx(): async def start_sshx():
global _sshx_process, _sshx_link global _sshx_link, _sshx_pid
if _sshx_process and _sshx_process.poll() is None and _sshx_link: if _is_alive() and _sshx_link:
return {"status": "running", "link": _sshx_link} return {"status": "running", "link": _sshx_link}
if not _ensure_sshx(): if not _ensure_sshx():
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"}) return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"})
if _sshx_process and _sshx_process.poll() is None:
_sshx_process.terminate()
try:
_sshx_process.wait(timeout=5)
except subprocess.TimeoutExpired:
_sshx_process.kill()
_sshx_process = subprocess.Popen(
["sshx", "--quiet"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
_sshx_link = None
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
link_future = loop.create_future() result = await loop.run_in_executor(None, _start_sshx_background)
_start_reader(_sshx_process, link_future)
try: if result:
link = await asyncio.wait_for(link_future, timeout=30) return {"status": "running", "link": _sshx_link}
return {"status": "running", "link": link}
except asyncio.TimeoutError:
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"}) return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/status") @router.get("/status")
async def sshx_status(): async def sshx_status():
global _sshx_process, _sshx_link global _sshx_link
if _sshx_process and _sshx_process.poll() is None and _sshx_link: if _is_alive() and _sshx_link:
return {"status": "running", "link": _sshx_link} return {"status": "running", "link": _sshx_link}
return {"status": "stopped", "link": None} return {"status": "stopped", "link": None}
@router.post("/stop") @router.post("/stop")
async def stop_sshx(): async def stop_sshx():
global _sshx_process, _sshx_link _kill_sshx()
if _sshx_process and _sshx_process.poll() is None:
_sshx_process.terminate()
try:
_sshx_process.wait(timeout=5)
except subprocess.TimeoutExpired:
_sshx_process.kill()
_sshx_process = None
_sshx_link = None
return {"status": "stopped"} return {"status": "stopped"}
return {"status": "already_stopped"}
@router.post("/restart-dashboard") @router.post("/restart-dashboard")
@ -125,6 +126,7 @@ async def restart_dashboard():
subprocess.Popen( subprocess.Popen(
["nohup", "bash", "-c", "sleep 1 && sudo systemctl restart hermes-dashboard"], ["nohup", "bash", "-c", "sleep 1 && sudo systemctl restart hermes-dashboard"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
) )
return {"status": "restarting"} return {"status": "restarting"}
except Exception as e: except Exception as e: