diff --git a/Html/apps/extension_ase/ase_client/manager.py b/Html/apps/extension_ase/ase_client/manager.py index fd295cb..03dae0f 100644 --- a/Html/apps/extension_ase/ase_client/manager.py +++ b/Html/apps/extension_ase/ase_client/manager.py @@ -1,14 +1,28 @@ +import signal import sys -import os +import os +import time +import uuid current_directory = os.path.dirname(os.path.abspath(__file__)) sys.path.append(current_directory) from flask_socketio import SocketIO, join_room, emit, Namespace import os from ...function_tools.function_manager import FunctionManager - +from dataclasses import dataclass, field +from typing import Dict, Optional +import subprocess +import threading from ...utils import * EMPTY_CHAPTER_CHAIN = [ Chapter(1, CHAPTER_FOCUS, "本章是未打开某个具体章节时的默认章节。", "处于本章节时不会有任何章节跳转。请作为一名经验丰富的算法教师,回答用户的问题。") ] +@dataclass +class ProcRecord: + popen: subprocess.Popen + user_id: str + path_dir: str + port: int + deadline_ts: Optional[float] = None + timer: Optional[threading.Timer] = field(default=None, repr=False) class ChatManager: @@ -24,6 +38,13 @@ class ChatManager: self.chapter_chain = load_chapters(raw_markdown, raw_markdown_prompts, raw_score_prompts) self.function_manager = FunctionManager() self.bb = None + self._procs: Dict[str, ProcRecord] = {} + self._lock = threading.RLock() + + def disconnect(self, uuid): + self.stop(uuid) + self.ase_client.disconnect() + def next_chapter(self): assert self.chapter_chain_now + 1 < len(self.chapter_chain), "chapter_chain_now out of range" @@ -110,3 +131,103 @@ class ChatManager: if (str(e) == "no path specified"): self.socketio.emit("system_message", "未加载IDE文件同步,请尝试重新打开IDE文件或刷新页面,并确保插件已连接", room=id, namespace='/agent') + + + + + + + + def start_code_server(self, key: str, user_id: str, path_dir: str, code_server_port: int, ttl_seconds: Optional[int] = None) -> ProcRecord: + # key is user_uuid + # 注意:建议使用 start_new_session=True 方便后续 kill 进程组 + # 如果必须 sudo,最好加 -n(非交互)避免卡住;也可以考虑用 systemd-run 或专用守护 + cmd = [ + "sudo", "-n", "-u", user_id, + "code-server", + "--bind-addr", f"0.0.0.0:{code_server_port}", + "--auth", "none", + path_dir, + "--extensions-dir", "/share/extension/" + ] + + pop = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + start_new_session=True # 等价 setsid,新建进程组,方便整体结束 + ) + + record = ProcRecord( + popen=pop, + user_id=user_id, + path_dir=path_dir, + port=code_server_port, + deadline_ts=(time.time() + ttl_seconds) if ttl_seconds else None + ) + + # 设置了到期停 + if ttl_seconds: + t = threading.Timer(ttl_seconds, self.stop, kwargs={"key": key}) + t.daemon = True + t.start() + record.timer = t + + with self._lock: + self._procs[key] = record + + return record + + def stop(self, key: str, grace_seconds: int = 5) -> bool: + with self._lock: + rec = self._procs.pop(key, None) + + if not rec: + return False + + # 先取消到期定时器(如果是手动停) + if rec.timer: + try: + rec.timer.cancel() + except Exception: + pass + + pop = rec.popen + if pop.poll() is not None: + return True # 已退出 + + try: + # 优雅:向进程组发 SIGTERM + os.killpg(pop.pid, signal.SIGTERM) + except ProcessLookupError: + return True + + # 等待优雅退出 + try: + pop.wait(timeout=grace_seconds) + return True + except subprocess.TimeoutExpired: + pass + + # 兜底:强杀进程组 + try: + os.killpg(pop.pid, signal.SIGKILL) + except ProcessLookupError: + pass + finally: + try: + pop.wait(timeout=1) + except Exception: + pass + + return True + + def get(self, key: str) -> Optional[ProcRecord]: + with self._lock: + return self._procs.get(key) + + def stop_all(self): + with self._lock: + keys = list(self._procs.keys()) + for k in keys: + self.stop(k) \ No newline at end of file diff --git a/Html/apps/sockets/namespaces.py b/Html/apps/sockets/namespaces.py index c7cc79a..03d3ebd 100644 --- a/Html/apps/sockets/namespaces.py +++ b/Html/apps/sockets/namespaces.py @@ -83,8 +83,6 @@ class AgentNamespace(Namespace): chatmanager.function_manager.add_function(judge, {'course_id': course_id, 'lesson_name': lesson_name, 'root_path': f'../../study/{user_id}/{course_id}/{chapter_name}/{lesson_name}'}) chatmanager.function_manager.add_function(next_chapter, {'user_uuid': user_uuid}) - user_uuid2ase_client[user_uuid].loadmarkdown(fmd, fmdp, fsp) - join_room(user_uuid, namespace='/agent') # 将该用户加入以user_id为名的room backboard_manager.add_backboard(user_uuid, user_id, course_id, lesson_name, root_path=f'../../study/{user_id}/{course_id}/{chapter_name}/{lesson_name}') chatmanager.bb = backboard_manager.get_backboard(user_uuid) @@ -143,5 +141,10 @@ class AgentNamespace(Namespace): def on_disconnect(self,data): + ex = current_app.extensions + uuid = session.get('user_uuid') + user_uuid2chatmanager = ex["user_uuid2chatmanager"] + user_uuid2chatmanager[uuid].disconnect(uuid) + print("VSCode client disconnected") print("Disconnect reason:"+str(data)) \ No newline at end of file diff --git a/Html/apps/views/vscode.py b/Html/apps/views/vscode.py index 36d22bd..1e41d1d 100644 --- a/Html/apps/views/vscode.py +++ b/Html/apps/views/vscode.py @@ -11,20 +11,12 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name): username2uuid = current_app.extensions["username2uuid"] uuid2username = current_app.extensions["uuid2username"] userid_recorder = current_app.extensions["userid_recorder"] - # session 中放 uuid(访客也能进则给一个) if "user_uuid" not in session: - user_uuid = "user_" + str(uuid.uuid4()) - session["user_uuid"] = user_uuid - uuid2username[user_uuid] = "guest"+str(uuid.uuid4())[0:5] - username2uuid[uuid2username[user_uuid]] = user_uuid - + return redirect(url_for("auth.login")) # 全局映射 user_id = uuid2username[user_uuid] userid_recorder[f"{user_id}&{course_id}"] = session["user_uuid"] - # 按课程/章节创建工作目录 - base_root = current_app.config["STUDENT_WORKSPACE_ROOT"] - user_home = os.path.join("/home", user_id) try: subprocess.run(["sudo", "useradd", "-m", "-d", user_home, user_id]) # 创建用户 @@ -40,19 +32,13 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name): print(f"Error creating directory {path_dir} details {str(e)}") - + chatmanager = current_app.extensions["user_uuid2chatmanager"][user_uuid] code_server_port = 10000 + int(uuid.uuid4().int % 10000) - try: - subprocess.Popen([ - "sudo", "-u", user_id, - "code-server", "--bind-addr", f"0.0.0.0:{code_server_port}", - "--auth", "none", path_dir, - "--extensions-dir", "/share/extension/" - ]) - except subprocess.CalledProcessError as e: - print(f"Error start code-server at {code_server_port} with path{path_dir} {e}") + chatmanager.start_code_server(user_uuid, user_id, path_dir, code_server_port) + + caddy_conf = f""" handle_path /vsc{user_uuid}/* {{ reverse_proxy localhost:{code_server_port}