diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a81e6704..3f3be210 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,15 @@ jobs: needs: resolve_version if: github.event.inputs.build_linux == 'true' || github.event_name == 'push' || github.event_name == 'release' runs-on: ubuntu-latest + strategy: + matrix: + include: + - arch: x64 + package_command: npm run package:linux:x64 + artifact_name: gsm3-management-panel-linux-x64-build + - arch: arm64 + package_command: npm run package:linux:arm64 + artifact_name: gsm3-management-panel-linux-arm64-build steps: - uses: actions/checkout@v4 - name: Setup Node.js for build @@ -72,17 +81,17 @@ jobs: npm install cd client && npm install cd ../server && npm install - - name: Build package - run: npm run package:linux + - name: Build Linux ${{ matrix.arch }} package + run: ${{ matrix.package_command }} env: CI: true NODE_ENV: production APP_VERSION: ${{ needs.resolve_version.outputs.version }} VITE_APP_VERSION: ${{ needs.resolve_version.outputs.version }} - - name: Upload artifacts + - name: Upload Linux ${{ matrix.arch }} artifact uses: actions/upload-artifact@v4 with: - name: gsm3-management-panel-linux-build + name: ${{ matrix.artifact_name }} path: dist/package/ retention-days: 30 @@ -205,29 +214,37 @@ jobs: needs: [resolve_version, build_linux, build_windows] runs-on: ubuntu-latest steps: - - name: Download Linux artifact + - name: Download Linux x64 artifact uses: actions/download-artifact@v4 with: - name: gsm3-management-panel-linux-build - path: linux-build/ + name: gsm3-management-panel-linux-x64-build + path: linux-x64-build/ + - name: Download Linux arm64 artifact + uses: actions/download-artifact@v4 + with: + name: gsm3-management-panel-linux-arm64-build + path: linux-arm64-build/ - name: Download Windows artifact uses: actions/download-artifact@v4 with: name: gsm3-management-panel-windows-build path: windows-build/ - - name: Create Linux tar.gz - run: | - cd linux-build - tar -czf ../gsm3-management-panel-linux-v${{ needs.resolve_version.outputs.version }}.tar.gz * - - name: Create Windows zip + - name: Create platform archives run: | + tar -czf gsm3-management-panel-linux-x64-v${{ needs.resolve_version.outputs.version }}.tar.gz -C linux-x64-build . + cp gsm3-management-panel-linux-x64-v${{ needs.resolve_version.outputs.version }}.tar.gz gsm3-management-panel-linux-x64.tar.gz + tar -czf gsm3-management-panel-linux-arm64-v${{ needs.resolve_version.outputs.version }}.tar.gz -C linux-arm64-build . + cp gsm3-management-panel-linux-arm64-v${{ needs.resolve_version.outputs.version }}.tar.gz gsm3-management-panel-linux-arm64.tar.gz cd windows-build - zip -r ../gsm3-management-panel-windows-v${{ needs.resolve_version.outputs.version }}.zip * + zip -r ../gsm3-management-panel-windows-v${{ needs.resolve_version.outputs.version }}.zip . - name: Create Release uses: softprops/action-gh-release@v1 with: files: | - gsm3-management-panel-linux-v${{ needs.resolve_version.outputs.version }}.tar.gz + gsm3-management-panel-linux-x64-v${{ needs.resolve_version.outputs.version }}.tar.gz + gsm3-management-panel-linux-x64.tar.gz + gsm3-management-panel-linux-arm64-v${{ needs.resolve_version.outputs.version }}.tar.gz + gsm3-management-panel-linux-arm64.tar.gz gsm3-management-panel-windows-v${{ needs.resolve_version.outputs.version }}.zip generate_release_notes: true draft: false diff --git a/.gitignore b/.gitignore index 0b7f6a6a..20e1deff 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ data/lib/ data/tunnels/ data/easytier/ data/tools/ +data/terminal-control/ +server/data/terminal-control/ dist/ diff --git a/Dockerfile b/Dockerfile index 7746e6ce..0f1b391d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -228,11 +228,11 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \ npm config set fetch-retry-maxtimeout 120000 && \ npm config set fetch-timeout 300000 && \ npm run install:all && \ - npm run package:linux:no-zip; \ + npm run package:linux:arm64:no-zip; \ else \ echo "AMD64架构构建,使用标准配置..." && \ npm run install:all && \ - npm run package:linux:no-zip; \ + npm run package:linux:x64:no-zip; \ fi # ---------- 运行阶段(最终镜像) ---------- @@ -270,8 +270,8 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ COPY --from=builder /app/dist/package/ /root/ COPY --from=builder /app/server/data/ /root/server/data/ -# 下载 Zip-Tools 二进制文件(从 GitHub Releases latest,构建时预置) -RUN mkdir -p /root/server/data/lib && \ +# 下载运行时二进制文件(从 GitHub Releases latest,构建时预置到不会被数据卷遮蔽的位置) +RUN mkdir -p /root/server/builtin/data/lib && \ if [ "$TARGETARCH" = "amd64" ]; then \ BINARY_NAME="file_zip_linux_x64"; \ elif [ "$TARGETARCH" = "arm64" ]; then \ @@ -279,9 +279,9 @@ RUN mkdir -p /root/server/data/lib && \ fi && \ echo "正在下载 Zip-Tools (${BINARY_NAME})..." && \ wget -t 3 --retry-connrefused --waitretry=2 --read-timeout=30 --timeout=15 \ - -O /root/server/data/lib/${BINARY_NAME} \ + -O /root/server/builtin/data/lib/${BINARY_NAME} \ "https://github.com/MCSManager/Zip-Tools/releases/latest/download/${BINARY_NAME}" && \ - chmod 755 /root/server/data/lib/${BINARY_NAME} && \ + chmod 755 /root/server/builtin/data/lib/${BINARY_NAME} && \ echo "Zip-Tools 下载完成: ${BINARY_NAME}" # 下载 7z 二进制文件(从 GitHub Releases latest,构建时预置) @@ -292,23 +292,22 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ fi && \ echo "正在下载 7z (${BINARY_7Z})..." && \ wget -t 3 --retry-connrefused --waitretry=2 --read-timeout=30 --timeout=15 \ - -O /root/server/data/lib/${BINARY_7Z} \ + -O /root/server/builtin/data/lib/${BINARY_7Z} \ "https://github.com/MCSManager/Zip-Tools/releases/latest/download/${BINARY_7Z}" && \ - chmod 755 /root/server/data/lib/${BINARY_7Z} && \ + chmod 755 /root/server/builtin/data/lib/${BINARY_7Z} && \ echo "7z 下载完成: ${BINARY_7Z}" -# 下载 PTY 二进制文件(从 GitHub Releases latest,构建时预置) +# 通过打包产物中的固定资产 CLI 校验并预置当前架构的 PTY RUN if [ "$TARGETARCH" = "amd64" ]; then \ - PTY_NAME="pty_linux_x64"; \ - elif [ "$TARGETARCH" = "arm64" ]; then \ - PTY_NAME="pty_linux_arm64"; \ - fi && \ - echo "正在下载 PTY (${PTY_NAME})..." && \ - wget -t 3 --retry-connrefused --waitretry=2 --read-timeout=30 --timeout=15 \ - -O /root/server/data/lib/${PTY_NAME} \ - "https://github.com/MCSManager/PTY/releases/download/latest/${PTY_NAME}" && \ - chmod 755 /root/server/data/lib/${PTY_NAME} && \ - echo "PTY 下载完成: ${PTY_NAME}" + PTY_ASSET="linux-x64"; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + PTY_ASSET="linux-arm64"; \ + else \ + echo "不支持的 PTY 架构: $TARGETARCH" >&2; exit 1; \ + fi && \ + node /root/server/utils/ptyAssetCli.js ensure \ + --asset "$PTY_ASSET" \ + --target-dir /root/server/builtin/data/lib # 拷贝 Python 依赖清单并安装 COPY --from=builder /app/server/src/Python/requirements.txt /tmp/requirements.txt # 安装Python依赖并配置最终权限 diff --git a/client/package-lock.json b/client/package-lock.json index f9308b36..d922f261 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -187,7 +187,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1803,7 +1802,6 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1868,7 +1866,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2190,8 +2187,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.18.0", @@ -2199,7 +2195,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2529,7 +2524,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -2826,8 +2820,7 @@ "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -3101,7 +3094,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3904,7 +3896,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4278,8 +4269,7 @@ "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/motion-dom": { "version": "11.18.1", @@ -4588,7 +4578,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -5410,7 +5399,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5423,7 +5411,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6144,7 +6131,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6259,7 +6245,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6338,7 +6323,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6455,7 +6439,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/client/src/pages/GameDeploymentPage.tsx b/client/src/pages/GameDeploymentPage.tsx index 26073c66..face9941 100644 --- a/client/src/pages/GameDeploymentPage.tsx +++ b/client/src/pages/GameDeploymentPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { Download, @@ -22,6 +22,7 @@ import { import { useNotificationStore } from '@/stores/notificationStore' import { useSystemStore } from '@/stores/systemStore' import apiClient from '@/utils/api' +import socketClient from '@/utils/socket' import { MinecraftServerCategory, MinecraftDownloadOptions, MinecraftDownloadProgress, MoreGameInfo, Platform, InstanceType, SteamBranchInfo } from '@/types' import { io, Socket } from 'socket.io-client' import config from '@/config' @@ -85,6 +86,7 @@ interface LastSteamcmdInstallTask { gameInfo: GameInfo request: SteamcmdInstallRequest terminalSessionId?: string + retainedTerminalSessionId?: string instanceId?: string requiresBetaPassword?: boolean updatedAt: string @@ -231,6 +233,25 @@ const GameDeploymentPage: React.FC = () => { const steamBranchRequestId = useRef(0) const installModalRequestId = useRef(0) const installingRef = useRef(false) + // 安装失败时服务端 500 响应携带的 retained 终端会话 ID 与关闭状态 + const [retainedTerminalSessionId, setRetainedTerminalSessionId] = useState(() => + lastSteamcmdInstallTask?.retainedTerminalSessionId || null + ) + const [closingRetainedTerminal, setClosingRetainedTerminal] = useState(false) + const retainedTerminalCloseCleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => { + if (lastSteamcmdInstallTask?.retainedTerminalSessionId) { + setRetainedTerminalSessionId(lastSteamcmdInstallTask.retainedTerminalSessionId) + } + }, [lastSteamcmdInstallTask?.retainedTerminalSessionId]) + + useEffect(() => { + return () => { + retainedTerminalCloseCleanupRef.current?.() + retainedTerminalCloseCleanupRef.current = null + } + }, []) // 实例更新确认弹窗相关状态 const [showInstanceUpdateDialog, setShowInstanceUpdateDialog] = useState(false) @@ -3157,10 +3178,31 @@ const GameDeploymentPage: React.FC = () => { return response.data } catch (error: any) { console.error('游戏安装失败:', error) + // 服务端 failed-retained 的 500 响应携带 retainedTerminalSessionId: + // 保存到 state 并展示可操作的清理入口,关闭后才能重试安装(安装锁在该会话退出前不释放)。 + const retainedId = typeof error?.retainedTerminalSessionId === 'string' + ? error.retainedTerminalSessionId + : null + if (retainedId) { + setRetainedTerminalSessionId(retainedId) + saveLastSteamcmdInstallTask({ + gameKey: request.gameKey, + gameInfo, + request: { + ...request, + steamPassword: undefined + }, + retainedTerminalSessionId: retainedId, + instanceId: request.existingInstanceId, + updatedAt: new Date().toISOString() + }) + } addNotification({ type: 'error', title: '安装失败', - message: formatInstallErrorMessage(error) + message: retainedId + ? `${formatInstallErrorMessage(error)}\n已保留残留终端会话(${retainedId}),请先关闭该会话后再重试安装。` + : formatInstallErrorMessage(error) }) throw error } finally { @@ -3169,6 +3211,81 @@ const GameDeploymentPage: React.FC = () => { } } + // 关闭上次失败安装保留的残留终端会话(调用现有 close-pty 通道,等待服务端 ACK) + const closeRetainedTerminalSession = useCallback(() => { + const sessionId = retainedTerminalSessionId + if (!sessionId || closingRetainedTerminal) { + return + } + if (!socketClient.isConnected()) { + addNotification({ + type: 'error', + title: '关闭残留终端失败', + message: 'Socket 未连接,无法关闭残留终端会话,请重试。' + }) + return + } + + retainedTerminalCloseCleanupRef.current?.() + setClosingRetainedTerminal(true) + let settled = false + + const cleanupListeners = () => { + socketClient.off('pty-closed', onPtyClosed) + socketClient.off('terminal-error', onTerminalError) + if (retainedTerminalCloseCleanupRef.current === cleanupListeners) { + retainedTerminalCloseCleanupRef.current = null + } + } + + const settleClose = (): boolean => { + if (settled) { + return false + } + settled = true + cleanupListeners() + setClosingRetainedTerminal(false) + return true + } + + const onPtyClosed = (data: { sessionId?: string }) => { + if (data?.sessionId !== sessionId || !settleClose()) { + return + } + setRetainedTerminalSessionId(null) + if (lastSteamcmdInstallTask?.retainedTerminalSessionId === sessionId) { + saveLastSteamcmdInstallTask({ + ...lastSteamcmdInstallTask, + retainedTerminalSessionId: undefined, + updatedAt: new Date().toISOString() + }) + } + addNotification({ + type: 'success', + title: '残留终端已关闭', + message: '残留终端会话已关闭,可以重新开始安装。' + }) + } + + const onTerminalError = (data: { sessionId?: string; retained?: boolean; error?: string }) => { + if (data?.sessionId !== sessionId || !settleClose()) { + return + } + addNotification({ + type: 'error', + title: data?.retained ? '残留终端仍在运行' : '关闭残留终端失败', + message: data?.retained + ? '终端进程仍在运行,会话已保留,请稍后重试关闭。' + : (data?.error || '关闭残留终端会话失败,请稍后重试。') + }) + } + + socketClient.on('pty-closed', onPtyClosed) + socketClient.on('terminal-error', onTerminalError) + retainedTerminalCloseCleanupRef.current = cleanupListeners + socketClient.closeTerminal(sessionId) + }, [retainedTerminalSessionId, closingRetainedTerminal, lastSteamcmdInstallTask, addNotification]) + const restoreLastSteamcmdInstallTask = () => { if (!lastSteamcmdInstallTask) return @@ -4306,6 +4423,38 @@ const GameDeploymentPage: React.FC = () => { {/* Steam网络状态提示 */} + {/* 上次安装失败的残留终端会话:提供可操作的 cleanup 入口 */} + {retainedTerminalSessionId && ( +
+
+
+

+ 残留终端会话待关闭 +

+

+ 上次安装失败保留了终端会话({retainedTerminalSessionId}),安装操作锁仍被占用。 + 请先关闭残留终端会话后再重试安装。 +

+
+
+ +
+
+
+ )} + {lastSteamcmdInstallTask && (
diff --git a/client/src/pages/TerminalPage.tsx b/client/src/pages/TerminalPage.tsx index 60b15a62..c9bf816b 100644 --- a/client/src/pages/TerminalPage.tsx +++ b/client/src/pages/TerminalPage.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef, useState, useCallback } from 'react' import { useSearchParams, useNavigate } from 'react-router-dom' -import { Terminal } from '@xterm/xterm' -import { FitAddon } from '@xterm/addon-fit' -import { WebLinksAddon } from '@xterm/addon-web-links' +import type { IDisposable, Terminal } from '@xterm/xterm' +import type { FitAddon } from '@xterm/addon-fit' +import type { TerminalErrorEvent } from '@/types' import socketClient from '@/utils/socket' import apiClient from '@/utils/api' +import { createTerminalView } from '@/utils/terminalFactory' import { useNotificationStore } from '@/stores/notificationStore' import { Plus, @@ -25,18 +26,84 @@ import { } from 'lucide-react' import '@xterm/xterm/css/xterm.css' -interface TerminalSession { +interface TerminalTabMeta { id: string name: string +} + +type TerminalState = + | 'creating' + | 'ready' + | 'disconnected' + | 'reconnecting' + | 'closing' + | 'exited' + | 'disposed' + +interface TerminalSize { + cols: number + rows: number +} + +interface TerminalRuntime { terminal: Terminal fitAddon: FitAddon - active: boolean + state: TerminalState + createSize?: TerminalSize + pendingSize?: TerminalSize + lastWrittenSize?: TerminalSize + lastReportedSize?: TerminalSize + resizeTimer?: ReturnType + closeRequestInFlight: boolean + cleanupRequired: boolean + disposables: IDisposable[] +} + +interface PendingTerminalCreate { + name: string + cwd?: string + enableStreamForward?: boolean + programPath?: string +} + +function isValidTerminalSize( + size: TerminalSize | null | undefined +): size is TerminalSize { + return Boolean( + size && + Number.isSafeInteger(size.cols) && + Number.isSafeInteger(size.rows) && + size.cols >= 2 && + size.cols <= 1000 && + size.rows >= 1 && + size.rows <= 1000 + ) +} + +function isSameTerminalSize( + left: TerminalSize | null | undefined, + right: TerminalSize | null | undefined +): boolean { + return Boolean( + left && + right && + left.cols === right.cols && + left.rows === right.rows + ) +} + +function clearPendingResize(runtime: TerminalRuntime): void { + if (runtime.resizeTimer !== undefined) { + clearTimeout(runtime.resizeTimer) + runtime.resizeTimer = undefined + } + runtime.pendingSize = undefined } const TerminalPage: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams() + const [searchParams] = useSearchParams() const navigate = useNavigate() - const [sessions, setSessions] = useState([]) + const [sessions, setSessions] = useState([]) const [activeSessionId, setActiveSessionId] = useState(null) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarHovered, setSidebarHovered] = useState(false) @@ -55,64 +122,440 @@ const TerminalPage: React.FC = () => { enableStreamForward: false, programPath: '' }) - - const terminalContainerRef = useRef(null) - // 使用useRef来保存最新的sessions状态,避免重复注册事件监听器 - const sessionsRef = useRef([]) + + const runtimesRef = useRef(new Map()) + const activeSessionIdRef = useRef(null) + const terminalContainerRef = useRef(null) + const observerRef = useRef(null) + const fitFrameRef = useRef(null) + const sessionsRef = useRef([]) + const isMobileRef = useRef(false) + const isUnmountingRef = useRef(false) + const sessionSequenceRef = useRef(0) + const pendingCreatesRef = useRef(new Map()) + const componentTimersRef = useRef(new Set>()) const processedUrlParamKey = useRef(null) const { addNotification } = useNotificationStore() - + // 检测移动端设备 useEffect(() => { const checkMobile = () => { - setIsMobile(window.innerWidth < 768) + const mobile = window.innerWidth < 768 + isMobileRef.current = mobile + setIsMobile(mobile) // 在移动端默认折叠侧边栏 - if (window.innerWidth < 768) { + if (mobile) { setSidebarCollapsed(true) } } - + checkMobile() window.addEventListener('resize', checkMobile) - + return () => { window.removeEventListener('resize', checkMobile) } }, []) - // 计算合适的终端大小 - const calculateTerminalSize = useCallback(() => { - if (terminalContainerRef.current) { + const setSessionTabs = useCallback((nextSessions: TerminalTabMeta[]) => { + sessionsRef.current = nextSessions + setSessions(nextSessions) + }, []) + + const requestCloseIfIdle = useCallback((sessionId: string): void => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime || runtime.closeRequestInFlight) { + return + } + + // N2-I2:socket 断开时不静默丢弃——closeTerminal 会排队并在重连后重发, + // 避免"服务端 retained 且客户端永久丢失"组合。 + runtime.closeRequestInFlight = true + socketClient.closeTerminal(sessionId) + }, []) + + const scheduleComponentTimeout = useCallback((callback: () => void, delay: number) => { + const timer = setTimeout(() => { + componentTimersRef.current.delete(timer) + callback() + }, delay) + componentTimersRef.current.add(timer) + return timer + }, []) + + const flushResizeReporter = useCallback((sessionId: string) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + runtime.resizeTimer = undefined + const size = runtime.pendingSize + if ( + activeSessionIdRef.current !== sessionId || + runtime.state !== 'ready' || + !socketClient.isConnected() || + !isValidTerminalSize(size) || + isSameTerminalSize(size, runtime.lastWrittenSize) + ) { + clearPendingResize(runtime) + return + } + + socketClient.resizeTerminal(sessionId, size.cols, size.rows) + runtime.lastWrittenSize = { cols: size.cols, rows: size.rows } + clearPendingResize(runtime) + }, []) + + const scheduleResizeReporter = useCallback((sessionId: string) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + const size = runtime.pendingSize + if ( + activeSessionIdRef.current !== sessionId || + runtime.state !== 'ready' || + !socketClient.isConnected() || + !isValidTerminalSize(size) || + isSameTerminalSize(size, runtime.lastWrittenSize) + ) { + clearPendingResize(runtime) + return + } + + if (runtime.resizeTimer !== undefined) { + clearTimeout(runtime.resizeTimer) + } + runtime.resizeTimer = setTimeout(() => flushResizeReporter(sessionId), 50) + }, [flushResizeReporter]) + + const seedResize = useCallback((sessionId: string) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + const size = { + cols: runtime.terminal.cols, + rows: runtime.terminal.rows + } + if (!isValidTerminalSize(size)) { + return + } + + runtime.pendingSize = size + scheduleResizeReporter(sessionId) + }, [scheduleResizeReporter]) + + const createRuntime = useCallback((sessionId: string, state: TerminalState): TerminalRuntime => { + const existingRuntime = runtimesRef.current.get(sessionId) + if (existingRuntime) { + return existingRuntime + } + + const { terminal, fitAddon } = createTerminalView({ isMobile: isMobileRef.current }) + const runtime: TerminalRuntime = { + terminal, + fitAddon, + state, + closeRequestInFlight: false, + cleanupRequired: false, + disposables: [] + } + runtimesRef.current.set(sessionId, runtime) + + runtime.disposables.push( + terminal.onData((data) => { + const currentRuntime = runtimesRef.current.get(sessionId) + if ( + currentRuntime === runtime && + activeSessionIdRef.current === sessionId && + currentRuntime.state === 'ready' && + socketClient.isConnected() + ) { + socketClient.sendTerminalInput(sessionId, data) + } + }), + terminal.onResize(({ cols, rows }) => { + const currentRuntime = runtimesRef.current.get(sessionId) + const size = { cols, rows } + if (currentRuntime !== runtime) { + return + } + if ( + activeSessionIdRef.current !== sessionId || + currentRuntime.state !== 'ready' || + !socketClient.isConnected() || + !isValidTerminalSize(size) || + isSameTerminalSize(size, currentRuntime.lastWrittenSize) + ) { + clearPendingResize(currentRuntime) + return + } + + currentRuntime.pendingSize = size + scheduleResizeReporter(sessionId) + }) + ) + + return runtime + }, [scheduleResizeReporter]) + + const attachTerminal = useCallback((sessionId: string, container: HTMLDivElement) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + try { + const terminalElement = runtime.terminal.element + if (terminalElement?.parentElement !== container) { + while (container.firstChild) { + container.removeChild(container.firstChild) + } + + if (terminalElement) { + container.appendChild(terminalElement) + } else { + runtime.terminal.open(container) + } + } + runtime.terminal.focus() + } catch (error) { + console.error('挂载终端失败:', error) + } + }, []) + + const scheduleFit = useCallback(() => { + if (fitFrameRef.current !== null) { + cancelAnimationFrame(fitFrameRef.current) + } + + fitFrameRef.current = requestAnimationFrame(() => { + fitFrameRef.current = null + + const sessionId = activeSessionIdRef.current const container = terminalContainerRef.current - const containerWidth = container.clientWidth || (isMobile ? 360 : 800) - const containerHeight = container.clientHeight || (isMobile ? 400 : 600) - - // 基于实际字体大小计算字符尺寸 - // 移动端使用较小的字体 - const fontSize = isMobile ? 12 : 14 - const lineHeight = 1.2 - const charWidth = fontSize * 0.6 - const charHeight = fontSize * lineHeight - - const cols = Math.floor(containerWidth / charWidth) - const rows = Math.floor(containerHeight / charHeight) - - console.log(`容器大小: ${containerWidth}x${containerHeight}, 计算终端大小: ${cols}x${rows}`) - - // 移动端使用更小的最小值 - const minCols = isMobile ? 40 : 80 - const minRows = isMobile ? 20 : 24 - - return { cols: Math.max(cols, minCols), rows: Math.max(rows, minRows) } + const runtime = sessionId ? runtimesRef.current.get(sessionId) : undefined + if ( + !sessionId || + !container || + !runtime || + (runtime.state !== 'creating' && runtime.state !== 'ready') || + container.clientWidth <= 0 || + container.clientHeight <= 0 + ) { + return + } + + try { + const proposal = runtime.fitAddon.proposeDimensions() + const proposedSize = proposal + ? { cols: proposal.cols, rows: proposal.rows } + : undefined + if (!isValidTerminalSize(proposedSize)) { + return + } + + runtime.fitAddon.fit() + + const fittedSize = { + cols: runtime.terminal.cols, + rows: runtime.terminal.rows + } + if (!isValidTerminalSize(fittedSize)) { + return + } + + if (runtime.state === 'creating' && !runtime.createSize) { + const pendingCreate = pendingCreatesRef.current.get(sessionId) + if (!pendingCreate) { + return + } + if (!socketClient.isConnected()) { + pendingCreatesRef.current.delete(sessionId) + runtime.state = 'exited' + addNotification({ + type: 'error', + title: '创建失败', + message: 'Socket 连接已断开,终端创建已取消,请连接后重试。' + }) + return + } + + runtime.createSize = fittedSize + pendingCreatesRef.current.delete(sessionId) + socketClient.createTerminal({ + sessionId, + name: pendingCreate.name, + cols: fittedSize.cols, + rows: fittedSize.rows, + cwd: pendingCreate.cwd, + enableStreamForward: pendingCreate.enableStreamForward, + programPath: pendingCreate.programPath + }) + } + + if (runtime.state === 'ready') { + seedResize(sessionId) + } + } catch (error) { + console.error('调整终端大小失败:', error) + } + }) + }, [addNotification, seedResize]) + + const ensureObserver = useCallback(() => { + if (!observerRef.current) { + observerRef.current = new ResizeObserver(() => { + scheduleFit() + }) + } + }, [scheduleFit]) + + const setTerminalContainer = useCallback((node: HTMLDivElement | null) => { + const previousNode = terminalContainerRef.current + if (previousNode) { + observerRef.current?.unobserve(previousNode) + } + + terminalContainerRef.current = node + if (node === null) { + return + } + + ensureObserver() + const sessionId = activeSessionIdRef.current + if (sessionId) { + attachTerminal(sessionId, node) + } + observerRef.current?.observe(node) + scheduleFit() + }, [attachTerminal, ensureObserver, scheduleFit]) + + const activateTerminal = useCallback((sessionId: string) => { + if (!runtimesRef.current.has(sessionId)) { + return + } + + const previousSessionId = activeSessionIdRef.current + if (previousSessionId && previousSessionId !== sessionId) { + const previousRuntime = runtimesRef.current.get(previousSessionId) + if (previousRuntime) { + clearPendingResize(previousRuntime) + } + } + + activeSessionIdRef.current = sessionId + setActiveSessionId(sessionId) + + const container = terminalContainerRef.current + if (container) { + attachTerminal(sessionId, container) + } + scheduleFit() + }, [attachTerminal, scheduleFit]) + + const disposeRuntime = useCallback((sessionId: string): void => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime || runtime.state === 'disposed') { + return } - return { cols: isMobile ? 50 : 100, rows: isMobile ? 25 : 30 } - }, [isMobile]) + + const nextSessions = sessionsRef.current.filter(session => session.id !== sessionId) + const wasActive = activeSessionIdRef.current === sessionId + + runtime.state = 'disposed' + clearPendingResize(runtime) + runtime.disposables.forEach(disposable => { + try { + disposable.dispose() + } catch (error) { + console.error(`释放终端监听器失败: ${sessionId}`, error) + } + }) + runtime.disposables = [] + try { + runtime.terminal.dispose() + } catch (error) { + console.error(`释放终端失败: ${sessionId}`, error) + } + runtimesRef.current.delete(sessionId) + pendingCreatesRef.current.delete(sessionId) + setSessionTabs(nextSessions) + + if (!wasActive) { + return + } + + if (isUnmountingRef.current) { + activeSessionIdRef.current = null + setActiveSessionId(null) + return + } + + const nextSession = nextSessions[nextSessions.length - 1] + if (nextSession && runtimesRef.current.has(nextSession.id)) { + activateTerminal(nextSession.id) + return + } + + activeSessionIdRef.current = null + setActiveSessionId(null) + }, [activateTerminal, setSessionTabs]) + + useEffect(() => { + isUnmountingRef.current = false + + return () => { + isUnmountingRef.current = true + + if (fitFrameRef.current !== null) { + cancelAnimationFrame(fitFrameRef.current) + fitFrameRef.current = null + } + + observerRef.current?.disconnect() + observerRef.current = null + terminalContainerRef.current = null + + componentTimersRef.current.forEach(timer => clearTimeout(timer)) + componentTimersRef.current.clear() + + // N2-I2:cleanupRequired(failed-create/input/resize 或 close retained)、 + // closeRequestInFlight(close 已发出但未确认)与 creating(create in-flight 断线后 + // 服务端可能已把 attempt 关闭为 close-retained)一律先 enqueue/emit guarded close, + // 再 dispose。socketClient.closeTerminal 幂等:已在本连接飞行则不重复发送(ACK-owned + // 队列跟踪),断线时进入待发送队列、重连后自动重发——不再静默丢弃唯一 cleanup handle。 + for (const [sessionId, runtime] of runtimesRef.current.entries()) { + if (runtime.state === 'disposed') { + continue + } + const needsGuardedClose = + runtime.cleanupRequired || + runtime.closeRequestInFlight || + runtime.state === 'creating' + if (!needsGuardedClose) { + continue + } + if (runtime.state !== 'closing') { + runtime.state = 'closing' + } + socketClient.closeTerminal(sessionId) + } + + Array.from(runtimesRef.current.keys()).forEach(disposeRuntime) + pendingCreatesRef.current.clear() + } + }, [disposeRuntime, requestCloseIfIdle]) // 打开创建终端模态框 const openCreateModal = useCallback((cwd?: string) => { setCreateModalData({ - name: cwd && typeof cwd === 'string' - ? `终端 - ${cwd.split(/[/\\]/).pop()}` + name: cwd && typeof cwd === 'string' + ? `终端 - ${cwd.split(/[/\\]/).pop()}` : `终端 ${sessionsRef.current.length + 1}`, workingDirectory: cwd || '', enableStreamForward: false, @@ -120,14 +563,14 @@ const TerminalPage: React.FC = () => { }) setShowCreateModal(true) // 延迟设置动画状态,确保DOM已渲染 - setTimeout(() => setCreateModalAnimating(true), 10) - }, []) + scheduleComponentTimeout(() => setCreateModalAnimating(true), 10) + }, [scheduleComponentTimeout]) // 关闭创建终端模态框 const closeCreateModal = useCallback(() => { setCreateModalAnimating(false) // 等待淡出动画完成后再隐藏模态框 - setTimeout(() => { + scheduleComponentTimeout(() => { setShowCreateModal(false) setCreateModalData({ name: '', @@ -136,23 +579,23 @@ const TerminalPage: React.FC = () => { programPath: '' }) }, 300) // 300ms 动画时长 - }, []) + }, [scheduleComponentTimeout]) // 打开帮助模态框 const openHelpModal = useCallback(() => { setShowHelpModal(true) // 延迟设置动画状态,确保DOM已渲染 - setTimeout(() => setHelpModalAnimating(true), 10) - }, []) + scheduleComponentTimeout(() => setHelpModalAnimating(true), 10) + }, [scheduleComponentTimeout]) // 关闭帮助模态框 const closeHelpModal = useCallback(() => { setHelpModalAnimating(false) // 等待淡出动画完成后再隐藏模态框 - setTimeout(() => { + scheduleComponentTimeout(() => { setShowHelpModal(false) }, 300) // 300ms 动画时长 - }, []) + }, [scheduleComponentTimeout]) // 创建新的终端会话 const createTerminalSession = useCallback((options?: { @@ -161,119 +604,33 @@ const TerminalPage: React.FC = () => { enableStreamForward?: boolean programPath?: string }) => { - const sessionId = `terminal-${Date.now()}` - const sessionName = options?.name || `终端 ${sessionsRef.current.length + 1}` - - // 计算初始终端大小 - const { cols, rows } = calculateTerminalSize() - - const terminal = new Terminal({ - cols: cols, - rows: rows, - theme: { - background: '#1a1a1a', - foreground: '#ffffff', - cursor: '#ffffff', - selectionBackground: '#ffffff30', - black: '#000000', - red: '#ff6b6b', - green: '#51cf66', - yellow: '#ffd43b', - blue: '#74c0fc', - magenta: '#f06292', - cyan: '#4dd0e1', - white: '#ffffff', - brightBlack: '#666666', - brightRed: '#ff8a80', - brightGreen: '#69f0ae', - brightYellow: '#ffff8d', - brightBlue: '#82b1ff', - brightMagenta: '#ff80ab', - brightCyan: '#84ffff', - brightWhite: '#ffffff' - }, - fontFamily: 'JetBrains Mono, Fira Code, Consolas, Monaco, monospace', - fontSize: isMobile ? 12 : 14, - lineHeight: 1.2, - cursorBlink: true, - cursorStyle: 'block', - scrollback: isMobile ? 500 : 1000, - tabStopWidth: 4, - allowTransparency: true, - // 移动端优化 - disableStdin: false, - convertEol: true - }) - - const fitAddon = new FitAddon() - const webLinksAddon = new WebLinksAddon() - - terminal.loadAddon(fitAddon) - terminal.loadAddon(webLinksAddon) - - // 监听终端输入 - terminal.onData((data) => { - socketClient.sendTerminalInput(sessionId, data) - }) - - // 监听终端大小变化 - terminal.onResize(({ cols, rows }) => { - if (socketClient.isConnected()) { - socketClient.resizeTerminal(sessionId, cols, rows) - } - }) - - const newSession: TerminalSession = { - id: sessionId, - name: sessionName, - terminal, - fitAddon, - active: true + if (!socketClient.isConnected()) { + addNotification({ + type: 'error', + title: '创建失败', + message: 'Socket 未连接,无法创建终端,请连接后重试。' + }) + return } - - setSessions(prev => { - const updated = prev.map(s => ({ ...s, active: false })) - return [...updated, newSession] - }) - setActiveSessionId(sessionId) + let sessionId: string + do { + sessionSequenceRef.current += 1 + sessionId = `terminal-${Date.now()}-${sessionSequenceRef.current}` + } while (runtimesRef.current.has(sessionId)) - // 延迟挂载终端到DOM,确保状态更新完成 - setTimeout(() => { - if (terminalContainerRef.current && !newSession.terminal.element) { - try { - newSession.terminal.open(terminalContainerRef.current) - // 确保终端获得焦点 - newSession.terminal.focus() - // 调整终端大小 - setTimeout(() => { - newSession.fitAddon.fit() - // 再次确保焦点,防止在调整大小过程中丢失焦点 - newSession.terminal.focus() - }, 50) - } catch (error) { - console.error('挂载新终端失败:', error) - } - } - }, 100) - - // 请求创建PTY - socketClient.createTerminal({ - sessionId: sessionId, + const sessionName = options?.name || `终端 ${sessionsRef.current.length + 1}` + + pendingCreatesRef.current.set(sessionId, { name: sessionName, - cols: cols, - rows: rows, cwd: options?.cwd, enableStreamForward: options?.enableStreamForward, programPath: options?.programPath }) - - addNotification({ - type: 'success', - title: '终端创建成功', - message: `已创建新的终端会话: ${sessionName}` - }) - }, [addNotification]) + createRuntime(sessionId, 'creating') + setSessionTabs([...sessionsRef.current, { id: sessionId, name: sessionName }]) + activateTerminal(sessionId) + }, [activateTerminal, addNotification, createRuntime, setSessionTabs]) // 处理创建终端表单提交 const handleCreateTerminal = useCallback(() => { @@ -344,186 +701,78 @@ const TerminalPage: React.FC = () => { }, [createModalData, addNotification, createTerminalSession, closeCreateModal]) // 关闭终端会话 - const closeTerminalSession = (sessionId: string) => { - const session = sessions.find(s => s.id === sessionId) - if (!session) return - - // 清理终端 - session.terminal.dispose() - - // 通知后端关闭PTY - socketClient.closeTerminal(sessionId) - - setSessions(prev => { - const filtered = prev.filter(s => s.id !== sessionId) - - // 如果关闭的是当前活动会话,切换到其他会话 - if (sessionId === activeSessionId) { - if (filtered.length > 0) { - const newActive = filtered[filtered.length - 1] - newActive.active = true - setActiveSessionId(newActive.id) + const closeTerminalSession = useCallback((sessionId: string) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + switch (runtime.state) { + case 'creating': + case 'ready': + runtime.state = 'closing' + clearPendingResize(runtime) + requestCloseIfIdle(sessionId) + return + case 'disconnected': + case 'reconnecting': + runtime.state = 'closing' + clearPendingResize(runtime) + requestCloseIfIdle(sessionId) + return + case 'closing': + requestCloseIfIdle(sessionId) + return + case 'exited': + if (runtime.cleanupRequired) { + runtime.state = 'closing' + requestCloseIfIdle(sessionId) } else { - setActiveSessionId(null) + disposeRuntime(sessionId) } - } - - return filtered - }) - - addNotification({ - type: 'info', - title: '终端已关闭', - message: `终端会话 ${session.name} 已关闭` - }) - } - + return + case 'disposed': + return + } + }, [disposeRuntime, requestCloseIfIdle]) + // 切换终端会话 const switchTerminalSession = useCallback((sessionId: string) => { - setSessions(prev => { - const updated = prev.map(s => ({ - ...s, - active: s.id === sessionId - })) - sessionsRef.current = updated - return updated - }) - setActiveSessionId(sessionId) - - // 延迟聚焦到切换的终端并调整大小 - setTimeout(() => { - const session = sessionsRef.current.find(s => s.id === sessionId) - if (session && session.terminal.element) { - session.terminal.focus() - - // 在全屏模式下或容器大小可能变化时,调整终端大小 - if (terminalContainerRef.current) { - try { - // 重新计算理想的终端大小 - const { cols: targetCols, rows: targetRows } = calculateTerminalSize() - - // 先设置终端的目标大小 - session.terminal.resize(targetCols, targetRows) - - // 调整终端大小以适应容器 - session.fitAddon.fit() - - // 获取调整后的实际大小 - const { cols, rows } = session.terminal - - // 通知服务端新的大小 - if (cols && rows && socketClient.isConnected()) { - console.log(`切换终端会话,终端 ${session.id} 大小调整为: ${cols}x${rows} (目标: ${targetCols}x${targetRows})`) - socketClient.resizeTerminal(session.id, cols, rows) - } - } catch (error) { - console.error(`切换终端会话时调整大小失败:`, error) - } - } - } - }, 100) - }, []) + activateTerminal(sessionId) + }, [activateTerminal]) - const createAttachedTerminalSession = useCallback((sessionId: string, name: string): TerminalSession => { - const { cols, rows } = calculateTerminalSize() - const terminal = new Terminal({ - cols, - rows, - theme: { - background: '#1a1a1a', - foreground: '#ffffff', - cursor: '#ffffff', - selectionBackground: '#ffffff30', - black: '#000000', - red: '#ff6b6b', - green: '#51cf66', - yellow: '#ffd43b', - blue: '#74c0fc', - magenta: '#f06292', - cyan: '#4dd0e1', - white: '#ffffff', - brightBlack: '#666666', - brightRed: '#ff8a80', - brightGreen: '#69f0ae', - brightYellow: '#ffff8d', - brightBlue: '#82b1ff', - brightMagenta: '#ff80ab', - brightCyan: '#84ffff', - brightWhite: '#ffffff' - }, - fontFamily: 'JetBrains Mono, Fira Code, Consolas, Monaco, monospace', - fontSize: isMobile ? 12 : 14, - lineHeight: 1.2, - cursorBlink: true, - cursorStyle: 'block', - scrollback: isMobile ? 500 : 1000, - tabStopWidth: 4, - allowTransparency: true, - disableStdin: false, - convertEol: true - }) - - const fitAddon = new FitAddon() - const webLinksAddon = new WebLinksAddon() - terminal.loadAddon(fitAddon) - terminal.loadAddon(webLinksAddon) - - terminal.onData((data) => { - socketClient.sendTerminalInput(sessionId, data) - }) - - terminal.onResize(({ cols, rows }) => { - if (socketClient.isConnected()) { - socketClient.resizeTerminal(sessionId, cols, rows) - } - }) - - return { - id: sessionId, - name, - terminal, - fitAddon, - active: true - } - }, [calculateTerminalSize, isMobile]) + const createAttachedTerminalSession = useCallback((sessionId: string, name: string): TerminalTabMeta => { + createRuntime(sessionId, 'disconnected') + return { id: sessionId, name } + }, [createRuntime]) const ensureTerminalSessionVisible = useCallback((sessionId: string, name: string) => { const existingSession = sessionsRef.current.find(s => s.id === sessionId) - if (existingSession) { - switchTerminalSession(sessionId) - return + if (!existingSession) { + const attachedSession = createAttachedTerminalSession(sessionId, name) + setSessionTabs([...sessionsRef.current, attachedSession]) + } else if (!runtimesRef.current.has(sessionId)) { + createRuntime(sessionId, 'disconnected') } - const attachedSession = createAttachedTerminalSession(sessionId, name) - setSessions(prev => { - const existingInState = prev.find(s => s.id === sessionId) - const updated = existingInState - ? prev.map(s => ({ ...s, active: s.id === sessionId })) - : [...prev.map(s => ({ ...s, active: false })), attachedSession] - sessionsRef.current = updated - return updated - }) - setActiveSessionId(sessionId) - }, [createAttachedTerminalSession, switchTerminalSession]) + activateTerminal(sessionId) + }, [activateTerminal, createAttachedTerminalSession, createRuntime, setSessionTabs]) const reconnectTerminalSession = useCallback((sessionId: string) => { - const reconnect = () => { - socketClient.emit('reconnect-session', { sessionId }) - - setTimeout(() => { - const { cols, rows } = calculateTerminalSize() - if (socketClient.isConnected()) { - socketClient.resizeTerminal(sessionId, cols, rows) - } - }, 300) + const runtime = runtimesRef.current.get(sessionId) + if ( + !runtime || + !socketClient.isConnected() || + (runtime.state !== 'disconnected' && runtime.state !== 'closing') + ) { + return } - if (socketClient.isConnected()) { - reconnect() - } else { - socketClient.once('connect', reconnect) + if (runtime.state === 'disconnected') { + runtime.state = 'reconnecting' } - }, [calculateTerminalSize]) + socketClient.reconnectTerminal(sessionId) + }, []) // 重命名终端会话 const startRenaming = (sessionId: string, currentName: string) => { @@ -534,14 +783,14 @@ const TerminalPage: React.FC = () => { const finishRenaming = async () => { if (editingSessionId && editingName.trim()) { const newName = editingName.trim() - + // 更新本地状态 - setSessions(prev => prev.map(s => - s.id === editingSessionId - ? { ...s, name: newName } - : s + setSessionTabs(sessionsRef.current.map(session => + session.id === editingSessionId + ? { ...session, name: newName } + : session )) - + // 调用后端API持久化保存 try { const response = await apiClient.updateTerminalSessionName(editingSessionId, newName) @@ -566,17 +815,18 @@ const TerminalPage: React.FC = () => { setEditingSessionId(null) setEditingName('') } - + const cancelRenaming = () => { setEditingSessionId(null) setEditingName('') } - + // 重置终端 const resetTerminal = () => { - const activeSession = sessions.find(s => s.id === activeSessionId) - if (activeSession) { - activeSession.terminal.reset() + const sessionId = activeSessionIdRef.current + const runtime = sessionId ? runtimesRef.current.get(sessionId) : undefined + if (runtime) { + runtime.terminal.reset() addNotification({ type: 'info', title: '终端已重置', @@ -584,7 +834,7 @@ const TerminalPage: React.FC = () => { }) } } - + // 切换全屏模式 const toggleFullscreen = async () => { try { @@ -607,51 +857,6 @@ const TerminalPage: React.FC = () => { message: '全屏模式已关闭' }) } - - // 调整终端大小 - 增加延迟确保DOM完全更新 - setTimeout(() => { - if (terminalContainerRef.current) { - try { - // 强制重新计算容器大小 - const container = terminalContainerRef.current - - // 强制浏览器重新计算布局 - container.offsetHeight - - const containerWidth = container.clientWidth || 800 - const containerHeight = container.clientHeight || 600 - - console.log(`全屏切换后容器大小: ${containerWidth}x${containerHeight}, 全屏状态: ${!isFullscreen}`) - - // 重新计算理想的终端大小 - const { cols: targetCols, rows: targetRows } = calculateTerminalSize() - - // 遍历所有终端会话并调整大小 - sessions.forEach(session => { - try { - // 先设置终端的目标大小 - session.terminal.resize(targetCols, targetRows) - - // 然后调整终端大小以适应容器 - session.fitAddon.fit() - - // 获取调整后的实际大小 - const { cols, rows } = session.terminal - - // 通知服务端新的大小 - if (cols && rows && socketClient.isConnected()) { - console.log(`全屏状态变化,终端 ${session.id} 大小调整为: ${cols}x${rows} (目标: ${targetCols}x${targetRows})`) - socketClient.resizeTerminal(session.id, cols, rows) - } - } catch (error) { - console.error(`调整终端 ${session.id} 大小失败:`, error) - } - }) - } catch (error) { - console.error('全屏状态变化时调整终端失败:', error) - } - } - }, 800) } catch (error) { console.error('全屏切换失败:', error) addNotification({ @@ -664,220 +869,369 @@ const TerminalPage: React.FC = () => { // 页面加载时获取现有终端会话 useEffect(() => { + let cancelled = false + const loadExistingSessions = async () => { try { const response = await apiClient.getTerminalSessions() - if (response.success && response.data) { - // 获取活跃会话和保存的会话 - const activeSessions = response.data.activeSessions || [] - const savedSessions = response.data.savedSessions || [] - - // 创建活跃会话ID的Set,用于去重 - const activeSessionIds = new Set(activeSessions.map((s: any) => s.id)) - - // 过滤掉已经在活跃会话中的保存会话,避免重复 - const uniqueSavedSessions = savedSessions.filter((s: any) => !activeSessionIds.has(s.id)) - - // 合并去重后的会话列表,优先使用活跃会话 - const sessionData = [...activeSessions, ...uniqueSavedSessions] - - if (sessionData.length > 0) { - - // 在设置初始会话之前,检查URL参数 - const params = new URLSearchParams(window.location.search) - const sessionIdFromUrl = params.get('sessionId') - const initialActiveId = sessionIdFromUrl && sessionData.some(s => s.id === sessionIdFromUrl) - ? sessionIdFromUrl - : sessionData[0].id - - const { cols, rows } = calculateTerminalSize() - - const newSessions: TerminalSession[] = sessionData.map((session: any, index: number) => { - const terminal = new Terminal({ - cols: cols, - rows: rows, - theme: { - background: '#1a1a1a', - foreground: '#ffffff', - cursor: '#ffffff', - selectionBackground: '#ffffff30', - black: '#000000', - red: '#ff6b6b', - green: '#51cf66', - yellow: '#ffd43b', - blue: '#74c0fc', - magenta: '#f06292', - cyan: '#4dd0e1', - white: '#ffffff', - brightBlack: '#666666', - brightRed: '#ff8a80', - brightGreen: '#69f0ae', - brightYellow: '#ffff8d', - brightBlue: '#82b1ff', - brightMagenta: '#ff80ab', - brightCyan: '#84ffff', - brightWhite: '#ffffff' - }, - fontFamily: 'JetBrains Mono, Fira Code, Consolas, Monaco, monospace', - fontSize: 14, - lineHeight: 1.2, - cursorBlink: true, - cursorStyle: 'block', - scrollback: 1000, - tabStopWidth: 4, - allowTransparency: true - }) - - const fitAddon = new FitAddon() - const webLinksAddon = new WebLinksAddon() - terminal.loadAddon(fitAddon) - terminal.loadAddon(webLinksAddon) - - terminal.onData((data) => { - socketClient.sendTerminalInput(session.id, data) - }) - - terminal.onResize(({ cols, rows }) => { - if (socketClient.isConnected()) { - socketClient.resizeTerminal(session.id, cols, rows) - } - }) - - return { - id: session.id, - name: session.name || `终端 ${index + 1}`, - terminal, - fitAddon, - active: session.id === initialActiveId - } - }) - - sessionsRef.current = newSessions - setSessions(newSessions) - setActiveSessionId(initialActiveId) - - addNotification({ - type: 'info', - title: '发现现有会话', - message: `找到 ${sessionData.length} 个现有终端会话,正在恢复...` - }) - - // 延迟重连 - setTimeout(() => { - const attemptReconnect = () => { - if (socketClient.isConnected()) { - newSessions.forEach(session => { - socketClient.emit('reconnect-session', { sessionId: session.id }) - // 通知后端调整PTY大小以匹配前端终端 - socketClient.resizeTerminal(session.id, cols, rows) - }) - } else { - const onConnect = () => { - newSessions.forEach(session => { - socketClient.emit('reconnect-session', { sessionId: session.id }) - // 通知后端调整PTY大小以匹配前端终端 - socketClient.resizeTerminal(session.id, cols, rows) - }) - socketClient.off('connect', onConnect) - } - socketClient.on('connect', onConnect as () => void) - } - } - - attemptReconnect() - }, 1000) + if (cancelled || !response.success || !response.data) { + return + } + + // 获取活跃会话和保存的会话 + const activeSessions = response.data.activeSessions || [] + const savedSessions = response.data.savedSessions || [] + + // 创建活跃会话ID的Set,用于去重 + const activeSessionIds = new Set(activeSessions.map((session: any) => session.id)) + + // 过滤掉已经在活跃会话中的保存会话,避免重复 + const uniqueSavedSessions = savedSessions.filter((session: any) => !activeSessionIds.has(session.id)) + + // 合并去重后的会话列表,优先使用活跃会话 + const sessionData = [...activeSessions, ...uniqueSavedSessions] + if (sessionData.length === 0) { + return + } + + // 在设置初始会话之前,检查URL参数 + const params = new URLSearchParams(window.location.search) + const sessionIdFromUrl = params.get('sessionId') + const initialActiveId = sessionIdFromUrl && sessionData.some((session: any) => session.id === sessionIdFromUrl) + ? sessionIdFromUrl + : sessionData[0].id + + const newSessions: TerminalTabMeta[] = sessionData.map((session: any, index: number) => { + createRuntime(session.id, 'disconnected') + return { + id: session.id, + name: session.name || `终端 ${index + 1}` } + }) + + if (cancelled) { + return } - } catch (error) { - console.error('获取现有终端会话失败:', error) + + setSessionTabs(newSessions) + activateTerminal(initialActiveId) + addNotification({ - type: 'error', - title: '加载会话失败', - message: '无法从服务器获取现有的终端会话。' + type: 'info', + title: '发现现有会话', + message: `找到 ${sessionData.length} 个现有终端会话,正在恢复...` }) + + newSessions.forEach(session => reconnectTerminalSession(session.id)) + } catch (error) { + if (!cancelled) { + console.error('获取现有终端会话失败:', error) + addNotification({ + type: 'error', + title: '加载会话失败', + message: '无法从服务器获取现有的终端会话。' + }) + } } finally { - setSessionsLoaded(true) + if (!cancelled) { + setSessionsLoaded(true) + } } } - - loadExistingSessions() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useEffect(() => { - sessionsRef.current = sessions - }, [sessions]) + + void loadExistingSessions() + return () => { + cancelled = true + } + }, [activateTerminal, addNotification, createRuntime, reconnectTerminalSession, setSessionTabs]) useEffect(() => { - // 监听终端输出 + const fitAndSeedActiveRuntime = (sessionId: string) => { + if (activeSessionIdRef.current !== sessionId) { + return + } + + const container = terminalContainerRef.current + if (container) { + attachTerminal(sessionId, container) + } + scheduleFit() + } + + const handlePtyCreated = ({ sessionId }: { sessionId: string; workingDirectory: string }) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + if (runtime.state === 'creating' || runtime.state === 'reconnecting') { + runtime.state = 'ready' + // N6-I3:creating 断线置的 cleanupRequired 只在确认成功恢复 ready 时清除—— + // 否则离开页面会误关已确认健康的 live session + runtime.cleanupRequired = false + fitAndSeedActiveRuntime(sessionId) + + const sessionName = sessionsRef.current.find(session => session.id === sessionId)?.name || sessionId + addNotification({ + type: 'success', + title: '终端创建成功', + message: `已创建新的终端会话: ${sessionName}` + }) + return + } + + if (runtime.state === 'closing') { + requestCloseIfIdle(sessionId) + } + } + + const handlePtyClosed = ({ sessionId }: { sessionId: string }) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + runtime.closeRequestInFlight = false + runtime.cleanupRequired = false + if (runtime.state === 'disposed') { + return + } + + const sessionName = sessionsRef.current.find(session => session.id === sessionId)?.name || sessionId + disposeRuntime(sessionId) + addNotification({ + type: 'info', + title: '终端已关闭', + message: `终端会话 ${sessionName} 已关闭` + }) + } + const handleTerminalOutput = ({ sessionId, data, isHistorical }: { sessionId: string; data: string; isHistorical?: boolean }) => { - const session = sessionsRef.current.find(s => s.id === sessionId) - if (session) { - // 如果是历史输出,先清空终端再写入,避免重复显示 - if (isHistorical) { - session.terminal.clear() - } - session.terminal.write(data) + const runtime = runtimesRef.current.get(sessionId) + if (!runtime || runtime.state === 'disposed') { + return + } + + if (isHistorical) { + runtime.terminal.clear() } + runtime.terminal.write(data) } - - // 监听终端创建成功 - const handleTerminalCreated = ({ sessionId, name }: { sessionId: string; name: string }) => { - console.log(`终端创建成功: ${sessionId} - ${name}`) + + const handleTerminalResized = ({ sessionId, cols, rows }: { sessionId: string; cols: number; rows: number }) => { + const runtime = runtimesRef.current.get(sessionId) + const size = { cols, rows } + if (runtime?.state === 'ready' && isValidTerminalSize(size)) { + runtime.lastReportedSize = size + } } - - // 监听终端关闭 - const handleTerminalClosed = ({ sessionId }: { sessionId: string }) => { - console.log(`终端已关闭: ${sessionId}`) + + const handleTerminalError = ({ + sessionId, + operation, + error, + retained + }: TerminalErrorEvent) => { + console.error(`终端操作失败 (${operation}):`, error) + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + if (operation === 'close') { + const closeRequestWasInFlight = runtime.closeRequestInFlight + runtime.closeRequestInFlight = false + if (retained) { + runtime.cleanupRequired = true + runtime.state = 'closing' + clearPendingResize(runtime) + addNotification({ + type: 'error', + title: '终端仍在关闭', + message: '终端进程仍在运行,请再次点击关闭重试。' + }) + } else if (runtime.state === 'closing' && closeRequestWasInFlight) { + addNotification({ + type: 'error', + title: '终端关闭失败', + message: '终端关闭失败,请再次点击关闭重试。' + }) + } + return + } + + if ( + operation === 'create' && + (runtime.state === 'creating' || runtime.state === 'reconnecting') + ) { + runtime.state = 'exited' + // 服务端的 bounded close 可能仍在进行或最终保留 target: + // 从错误时刻起即假定 cleanup 可能未完成,关闭 tab 时走 guarded close, + // 避免 server retained 信号到达前关闭标签页而丢失 cleanup retry 入口。 + runtime.cleanupRequired = true + addNotification({ + type: 'error', + title: '终端创建失败', + message: '终端创建失败,请关闭该会话后重试。' + }) + return + } + + if (operation === 'input' && runtime.state === 'ready') { + runtime.state = 'exited' + runtime.cleanupRequired = true + clearPendingResize(runtime) + addNotification({ + type: 'error', + title: '终端输入失败', + message: '终端输入失败,请关闭该会话后重新创建。' + }) + return + } + + if (operation === 'resize' && runtime.state === 'ready') { + runtime.state = 'exited' + runtime.cleanupRequired = true + clearPendingResize(runtime) + addNotification({ + type: 'error', + title: '终端尺寸同步失败', + message: '终端尺寸同步失败,请关闭该会话后重新创建。' + }) + } } - - // 监听会话重连成功 - const handleSessionReconnected = ({ sessionId }: { sessionId: string }) => { - console.log(`会话重连成功: ${sessionId}`) + + const handleTerminalExit = ({ sessionId }: { sessionId: string; code: number | null; signal: string | null }) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime || runtime.state === 'disposed') { + return + } + + runtime.cleanupRequired = false + runtime.closeRequestInFlight = false + if (runtime.state !== 'exited') { + runtime.state = 'exited' + clearPendingResize(runtime) + } } - - // 监听会话重连失败 + + const handleSessionReconnected = ({ + sessionId, + state + }: { + sessionId: string + state: 'ready' | 'closing' + }) => { + const runtime = runtimesRef.current.get(sessionId) + if (!runtime) { + return + } + + if (runtime.state === 'closing') { + requestCloseIfIdle(sessionId) + return + } + + if (runtime.state !== 'reconnecting') { + return + } + if (state === 'closing') { + runtime.state = 'closing' + requestCloseIfIdle(sessionId) + return + } + + runtime.state = 'ready' + // N6-I3:确认成功恢复 ready 时清除断线遗留的 cleanupRequired(仅此分支) + runtime.cleanupRequired = false + fitAndSeedActiveRuntime(sessionId) + } + const handleSessionReconnectFailed = ({ sessionId }: { sessionId: string }) => { - console.log(`会话重连失败: ${sessionId}`) + const runtime = runtimesRef.current.get(sessionId) + if (!runtime || (runtime.state !== 'reconnecting' && runtime.state !== 'closing')) { + return + } + + if (runtime.state === 'reconnecting') { + runtime.state = 'exited' + clearPendingResize(runtime) + } else { + runtime.closeRequestInFlight = false + disposeRuntime(sessionId) + } + addNotification({ type: 'error', title: '会话重连失败', - message: `终端会话 ${sessionId} 重连失败,可能已过期` + message: '终端会话重连失败,请关闭该会话后重新创建。' }) } - - // 监听终端大小调整完成 - const handleTerminalResized = ({ sessionId, cols, rows }: { sessionId: string; cols: number; rows: number }) => { - console.log(`终端大小调整完成: ${sessionId}, ${cols}x${rows}`) - const session = sessionsRef.current.find(s => s.id === sessionId) - if (session) { - // 确保前端终端的大小与服务端同步 - setTimeout(() => { - try { - session.fitAddon.fit() - } catch (error) { - console.error('同步终端大小失败:', error) + + const handleConnectionStatus = ({ connected }: { connected: boolean; reason?: string }) => { + if (!connected) { + runtimesRef.current.forEach(runtime => { + if (runtime.state === 'disposed' || runtime.state === 'exited') { + return + } + + const wasCreating = runtime.state === 'creating' + if (runtime.state !== 'closing') { + runtime.state = 'disconnected' + } + runtime.closeRequestInFlight = false + // N2-I2:create in-flight 断线——服务端 handleDisconnect 会对 starting/fallback + // attempt 执行有界关闭并可能转为 close-retained;置 cleanupRequired,unmount + // 时才会 enqueue guarded close,避免该 attempt 的唯一 ID 永久丢失 + //(createAttempts 不在可重连的 active session 列表中)。 + if (wasCreating) { + runtime.cleanupRequired = true } - }, 100) + clearPendingResize(runtime) + runtime.lastWrittenSize = undefined + runtime.lastReportedSize = undefined + }) + return } + + runtimesRef.current.forEach((runtime, sessionId) => { + if (runtime.state === 'disconnected') { + runtime.state = 'reconnecting' + socketClient.reconnectTerminal(sessionId) + } else if (runtime.state === 'closing') { + socketClient.reconnectTerminal(sessionId) + } + }) } - + + socketClient.on('pty-created', handlePtyCreated) + socketClient.on('pty-closed', handlePtyClosed) socketClient.on('terminal-output', handleTerminalOutput) - socketClient.on('terminal-created', handleTerminalCreated) - socketClient.on('terminal-closed', handleTerminalClosed) + socketClient.on('terminal-resized', handleTerminalResized) + socketClient.on('terminal-error', handleTerminalError) + socketClient.on('terminal-exit', handleTerminalExit) socketClient.on('session-reconnected', handleSessionReconnected) socketClient.on('session-reconnect-failed', handleSessionReconnectFailed) - socketClient.on('terminal-resized', handleTerminalResized) - + socketClient.on('connection-status', handleConnectionStatus) + return () => { + socketClient.off('pty-created', handlePtyCreated) + socketClient.off('pty-closed', handlePtyClosed) socketClient.off('terminal-output', handleTerminalOutput) - socketClient.off('terminal-created', handleTerminalCreated) - socketClient.off('terminal-closed', handleTerminalClosed) + socketClient.off('terminal-resized', handleTerminalResized) + socketClient.off('terminal-error', handleTerminalError) + socketClient.off('terminal-exit', handleTerminalExit) socketClient.off('session-reconnected', handleSessionReconnected) socketClient.off('session-reconnect-failed', handleSessionReconnectFailed) - socketClient.off('terminal-resized', handleTerminalResized) + socketClient.off('connection-status', handleConnectionStatus) } - }, []) // 移除addNotification依赖,避免重复注册事件监听器 + }, [ + addNotification, + attachTerminal, + disposeRuntime, + requestCloseIfIdle, + scheduleFit + ]) useEffect(() => { // 处理URL参数:cwd和instance @@ -907,21 +1261,12 @@ const TerminalPage: React.FC = () => { if (sessionId) { // 如果有sessionId参数,直接查找对应的终端会话 - const targetSession = sessionsRef.current.find(s => s.id === sessionId) - + const targetSession = sessionsRef.current.find(session => session.id === sessionId) + if (targetSession) { // 如果找到对应的会话,切换到该会话 switchTerminalSession(targetSession.id) reconnectTerminalSession(targetSession.id) - - // 延迟调整终端大小,确保切换完成 - setTimeout(() => { - const { cols, rows } = calculateTerminalSize() - if (socketClient.isConnected()) { - console.log(`切换到实例终端,调整大小为: ${cols}x${rows}`) - socketClient.resizeTerminal(targetSession.id, cols, rows) - } - }, 800) } else { // 启动实例后,HTTP会话列表可能还没刷新到新会话。 // 先创建同ID的前端标签并重连,避免停留在旧的活动终端。 @@ -938,10 +1283,10 @@ const TerminalPage: React.FC = () => { } } else if (instanceId) { // 如果有instance参数,查找对应的终端会话 - const instanceSession = sessionsRef.current.find(s => - s.name.includes(instanceId) || s.id.includes(instanceId) + const instanceSession = sessionsRef.current.find(session => + session.name.includes(instanceId) || session.id.includes(instanceId) ) - + if (instanceSession) { // 如果找到对应的会话,切换到该会话 switchTerminalSession(instanceSession.id) @@ -952,9 +1297,9 @@ const TerminalPage: React.FC = () => { }) } else { // 如果没有找到对应的会话,等待一段时间后再次查找 - setTimeout(() => { - const delayedSession = sessionsRef.current.find(s => - s.name.includes(instanceId) || s.id.includes(instanceId) + scheduleComponentTimeout(() => { + const delayedSession = sessionsRef.current.find(session => + session.name.includes(instanceId) || session.id.includes(instanceId) ) if (delayedSession) { switchTerminalSession(delayedSession.id) @@ -974,22 +1319,13 @@ const TerminalPage: React.FC = () => { } } else if (cwd) { // 延迟创建新终端,确保现有会话加载完成 - setTimeout(() => { + scheduleComponentTimeout(() => { createTerminalSession({ cwd }) - - // 确保新创建的终端获得焦点,延迟时间更长 - setTimeout(() => { - const activeSession = sessionsRef.current.find(s => s.active) - if (activeSession && activeSession.terminal.element) { - activeSession.terminal.focus() - } - }, 500) }, 100) } - + processedUrlParamKey.current = urlParamKey navigate('/terminal', { replace: true }) - }, [ sessionsLoaded, navigate, @@ -998,100 +1334,11 @@ const TerminalPage: React.FC = () => { switchTerminalSession, ensureTerminalSessionVisible, reconnectTerminalSession, - calculateTerminalSize, + scheduleComponentTimeout, addNotification ]) - // 当活动会话改变时,挂载终端到DOM - useEffect(() => { - const activeSession = sessions.find(s => s.id === activeSessionId) - if (activeSession && terminalContainerRef.current) { - const container = terminalContainerRef.current - - // 清空容器 - while (container.firstChild) { - container.removeChild(container.firstChild) - } - - try { - // 检查终端是否已经挂载到DOM - if (!activeSession.terminal.element) { - // 如果终端还没有挂载,则挂载到容器 - activeSession.terminal.open(container) - } else { - // 如果终端已经挂载,则将其移动到当前容器 - container.appendChild(activeSession.terminal.element) - } - - // 确保终端获得焦点 - activeSession.terminal.focus() - - // 延迟调整大小,确保DOM更新完成 - setTimeout(() => { - try { - // 先调整大小 - activeSession.fitAddon.fit() - - // 获取调整后的实际大小 - const { cols, rows } = activeSession.terminal - - // 通知服务端当前的终端大小,确保前后端同步 - if (cols && rows && socketClient.isConnected()) { - console.log(`终端大小已调整为: ${cols}x${rows}`) - socketClient.resizeTerminal(activeSession.id, cols, rows) - } - - // 在调整大小后再次确保焦点 - activeSession.terminal.focus() - } catch (error) { - console.error('调整终端大小失败:', error) - } - }, 100) - - // 额外的焦点确保机制 - setTimeout(() => { - if (activeSession.terminal.element) { - activeSession.terminal.focus() - } - }, 200) - } catch (error) { - console.error('挂载终端失败:', error) - } - } - }, [activeSessionId, sessions]) - useEffect(() => { - // 窗口大小变化时调整终端大小 - const handleResize = () => { - const activeSession = sessions.find(s => s.id === activeSessionId) - if (activeSession) { - setTimeout(() => { - try { - // 重新计算理想的终端大小 - const { cols: targetCols, rows: targetRows } = calculateTerminalSize() - - // 先设置终端的目标大小 - activeSession.terminal.resize(targetCols, targetRows) - - // 调整终端大小以适应容器 - activeSession.fitAddon.fit() - - // 获取调整后的实际大小 - const { cols, rows } = activeSession.terminal - - // 通知服务端新的大小 - if (cols && rows && socketClient.isConnected()) { - console.log(`窗口大小变化,终端大小调整为: ${cols}x${rows} (目标: ${targetCols}x${targetRows})`) - socketClient.resizeTerminal(activeSession.id, cols, rows) - } - } catch (error) { - console.error('窗口大小变化时调整终端失败:', error) - } - }, 100) - } - } - - // 监听全屏状态变化 const handleFullscreenChange = () => { const isCurrentlyFullscreen = !!document.fullscreenElement if (isCurrentlyFullscreen !== isFullscreen) { @@ -1103,67 +1350,14 @@ const TerminalPage: React.FC = () => { message: '全屏模式已关闭' }) } - - // 调整所有终端大小 - 增加延迟确保DOM完全更新 - setTimeout(() => { - if (terminalContainerRef.current) { - try { - // 强制重新计算容器大小 - const container = terminalContainerRef.current - - // 清除所有内联样式,让CSS类控制布局 - container.style.width = '' - container.style.height = '' - container.style.maxWidth = '' - - // 强制浏览器重新计算布局 - container.offsetHeight - - const containerWidth = container.clientWidth || 800 - const containerHeight = container.clientHeight || 600 - - console.log(`全屏模式切换后容器大小: ${containerWidth}x${containerHeight}, 全屏状态: ${isCurrentlyFullscreen}`) - - // 重新计算理想的终端大小 - const { cols: targetCols, rows: targetRows } = calculateTerminalSize() - - // 调整所有终端的大小,不仅仅是当前活动的 - sessions.forEach(session => { - try { - // 先设置终端的目标大小 - session.terminal.resize(targetCols, targetRows) - - // 调整终端大小以适应新的容器 - session.fitAddon.fit() - - // 获取调整后的实际大小 - const { cols, rows } = session.terminal - - // 通知服务端新的大小 - if (cols && rows && socketClient.isConnected()) { - console.log(`终端 ${session.id} 大小调整为: ${cols}x${rows} (目标: ${targetCols}x${targetRows})`) - socketClient.resizeTerminal(session.id, cols, rows) - } - } catch (error) { - console.error(`调整终端 ${session.id} 大小失败:`, error) - } - }) - } catch (error) { - console.error('全屏模式切换时调整终端大小失败:', error) - } - } - }, 300) } } - - window.addEventListener('resize', handleResize) + document.addEventListener('fullscreenchange', handleFullscreenChange) - return () => { - window.removeEventListener('resize', handleResize) document.removeEventListener('fullscreenchange', handleFullscreenChange) } - }, [activeSessionId, sessions, isFullscreen, addNotification]) + }, [addNotification, isFullscreen]) // 计算是否应该显示侧边栏内容 const shouldShowSidebar = (!sidebarCollapsed || sidebarHovered) && !isMobile @@ -1237,7 +1431,7 @@ const TerminalPage: React.FC = () => { key={session.id} className={` group relative p-3 rounded-lg cursor-pointer transition-all - ${session.active + ${session.id === activeSessionId ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'text-gray-300 hover:bg-white/5 hover:text-white' } @@ -1328,7 +1522,7 @@ const TerminalPage: React.FC = () => { key={session.id} className={` w-10 h-10 rounded-lg cursor-pointer transition-all flex items-center justify-center - ${session.active + ${session.id === activeSessionId ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'text-gray-400 hover:bg-white/5 hover:text-white' } @@ -1426,7 +1620,7 @@ const TerminalPage: React.FC = () => {
- {sessions.find(s => s.active)?.name || '终端'} + {sessions.find(session => session.id === activeSessionId)?.name || '终端'}
@@ -1447,7 +1641,7 @@ const TerminalPage: React.FC = () => { {/* 终端内容 */}
@@ -1539,7 +1733,7 @@ const TerminalPage: React.FC = () => { key={session.id} className={` group relative p-3 rounded-lg cursor-pointer transition-all - ${session.active + ${session.id === activeSessionId ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'text-gray-300 hover:bg-white/5 hover:text-white' } @@ -1630,7 +1824,7 @@ const TerminalPage: React.FC = () => { key={session.id} className={` w-10 h-10 rounded-lg cursor-pointer transition-all flex items-center justify-center - ${session.active + ${session.id === activeSessionId ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'text-gray-400 hover:bg-white/5 hover:text-white' } @@ -1714,7 +1908,7 @@ const TerminalPage: React.FC = () => {
- {sessions.find(s => s.active)?.name || '终端'} + {sessions.find(session => session.id === activeSessionId)?.name || '终端'}
@@ -1746,7 +1940,7 @@ const TerminalPage: React.FC = () => { {/* 终端内容 */}
{ // Socket事件类型 export interface SocketEvents { // 终端事件 - 'terminal-output': (data: { sessionId: string; data: string }) => void - 'terminal-created': (data: { sessionId: string; name: string }) => void - 'terminal-closed': (data: { sessionId: string }) => void + 'pty-created': (data: { + sessionId: string + workingDirectory: string + }) => void + 'pty-closed': (data: { sessionId: string }) => void + 'terminal-output': (data: { + sessionId: string + data: string + isHistorical?: boolean + }) => void + 'terminal-resized': (data: { + sessionId: string + cols: number + rows: number + }) => void + 'terminal-error': (data: TerminalErrorEvent) => void + 'terminal-exit': (data: { + sessionId: string + code: number | null + signal: string | null + }) => void + 'session-reconnected': (data: { + sessionId: string + state: 'ready' | 'closing' + }) => void + 'session-reconnect-failed': (data: { sessionId: string }) => void + 'connection-status': (data: { + connected: boolean + reason?: string + }) => void // 系统监控事件 'system-stats': (data: SystemStats) => void diff --git a/client/src/utils/socket.ts b/client/src/utils/socket.ts index c9683c51..4916a1b0 100644 --- a/client/src/utils/socket.ts +++ b/client/src/utils/socket.ts @@ -1,5 +1,5 @@ import { io, Socket } from 'socket.io-client' -import { SocketEvents } from '@/types' +import type { CreateTerminalRequest, SocketEvents, TerminalErrorEvent } from '@/types' import config from '@/config' class SocketClient { @@ -15,6 +15,21 @@ class SocketClient { private visibilityChangeHandler?: () => void private intersectionObserver?: IntersectionObserver private disconnectContext: 'manual' | 'low-power' | 'auth-update' | null = null + /** + * ACK-owned close-pty 重试队列(N2-I2): + * - pendingCloseOnReconnect:待发送项(断线时 closeTerminal 入队,或已发送未确认项在 + * 断线时放回); + * - awaitingCloseAck:当前连接已发送、等待 pty-closed/terminal-exit(或明确失败)的请求; + * - closeSentForConnection:当前连接已发送过的 ID(同一连接内自动重发只发生一次, + * 防重发风暴)。 + * 只有收到 pty-closed / terminal-exit(确认关闭)才从队列移除;terminal-error + * {operation:'close', retained:true} 表示该次 close 未完成(target 被保留),放回待发送 + * 队列,下次连接自动重发(服务端对 retained target 每次 close-pty 都重新驱动有界关闭)。 + * 队列挂在单例上,跨页面卸载存活——页面卸载不再能丢失唯一 cleanup handle。 + */ + private pendingCloseOnReconnect = new Set() + private awaitingCloseAck = new Set() + private closeSentForConnection = new Set() constructor() { // 不在构造函数中立即连接,等待用户登录后再连接 @@ -71,10 +86,20 @@ class SocketClient { this.clearReconnectTimer() this.reconnectAttempts = 0 this.emit('connection-status', { connected: true }) + // 重发断线期间排队的 close 请求(N2-I2:ACK-owned,发送后保留到确认/断线放回) + this.flushPendingCloseOnReconnect() }) socket.on('disconnect', (reason) => { console.log('Socket断开连接:', reason) + // N2-I2:已发送未确认的 close 请求放回待发送队列——服务端会在断开时移除 requester, + // 若 target 再次 timeout retained,重连后必须重发,否则唯一 cleanup handle 随断线丢失。 + for (const sessionId of this.awaitingCloseAck) { + this.pendingCloseOnReconnect.add(sessionId) + } + this.awaitingCloseAck.clear() + this.closeSentForConnection.clear() + this.emit('connection-status', { connected: false, reason }) if (this.shouldReconnect(reason)) { @@ -84,6 +109,22 @@ class SocketClient { this.disconnectContext = null }) + // N2-I2:close 请求的 ACK 跟踪(队列在单例上,跨页面卸载存活) + socket.on('pty-closed', (data: { sessionId: string }) => { + this.handleCloseAcknowledged(data?.sessionId) + }) + socket.on('terminal-exit', (data: { sessionId: string }) => { + this.handleCloseAcknowledged(data?.sessionId) + }) + socket.on('terminal-error', (data: TerminalErrorEvent) => { + if (data?.operation === 'close' && data?.retained) { + // 该次 close 未完成(target 被服务端保留):放回待发送队列,下次连接自动重发 + if (this.awaitingCloseAck.delete(data.sessionId)) { + this.pendingCloseOnReconnect.add(data.sessionId) + } + } + }) + socket.on('connect_error', (error) => { console.error('Socket连接错误:', error) this.emit('connection-status', { connected: false, reason: 'connect_error' }) @@ -123,6 +164,14 @@ class SocketClient { return } + // N2-I2:removeAllListeners 会阻止 disconnect handler 把未确认 close 放回队列, + // 因此先在这里显式迁移(未确认项在重连后重发,不随 socket 销毁丢失)。 + for (const sessionId of this.awaitingCloseAck) { + this.pendingCloseOnReconnect.add(sessionId) + } + this.awaitingCloseAck.clear() + this.closeSentForConnection.clear() + this.socket.removeAllListeners() this.socket.disconnect() this.socket = null @@ -316,7 +365,7 @@ class SocketClient { } // 终端相关方法 - createTerminal(data: { sessionId: string; name?: string; cols?: number; rows?: number; cwd?: string; enableStreamForward?: boolean; programPath?: string }) { + createTerminal(data: CreateTerminalRequest): void { this.emit('create-pty', data) } @@ -329,7 +378,60 @@ class SocketClient { } closeTerminal(sessionId: string) { - this.emit('close-pty', { sessionId }) + if (this.socket?.connected) { + // N2-I2:已在飞行且未收到结果(pty-closed/terminal-exit/明确失败)时幂等跳过—— + // 该请求已被 ACK-owned 队列跟踪,unmount/重复点击不会重复发送;断线时自动回到 + // 待发送队列。terminal-error{retained} 会把该项放回待发送队列,用户重试可再次发送。 + if (this.awaitingCloseAck.has(sessionId)) { + return + } + this.pendingCloseOnReconnect.delete(sessionId) + this.awaitingCloseAck.add(sessionId) + this.closeSentForConnection.add(sessionId) + this.emit('close-pty', { sessionId }) + } else { + // N2-I2:断线时不静默丢弃——排队待重连后重发(对 retained target 服务端继续 bounded close) + this.pendingCloseOnReconnect.add(sessionId) + console.warn('Socket未连接,close 请求已排队,将在重连后重发:', sessionId) + } + } + + /** connect 后重发断线期间排队的 close-pty 请求(N2-I2,ACK-owned)。 */ + private flushPendingCloseOnReconnect(): void { + if (!this.socket?.connected) { + return + } + if (this.pendingCloseOnReconnect.size === 0) { + return + } + const pending = Array.from(this.pendingCloseOnReconnect) + this.pendingCloseOnReconnect.clear() + pending.forEach(sessionId => { + // 同一连接内已发送过(且未确认):不再重发,等待 ACK 或断线放回 + if (this.closeSentForConnection.has(sessionId)) { + return + } + this.closeSentForConnection.add(sessionId) + this.awaitingCloseAck.add(sessionId) + this.emit('close-pty', { sessionId }) + }) + } + + /** close-pty 被确认(pty-closed / terminal-exit):从所有队列移除。 */ + private handleCloseAcknowledged(sessionId: string | undefined): void { + if (!sessionId) { + return + } + this.awaitingCloseAck.delete(sessionId) + this.pendingCloseOnReconnect.delete(sessionId) + // Minor:确认关闭后同步删除 closeSentForConnection 对应项,避免长连接按历史 + // 关闭数无限增长;flush 重发守卫语义保持——被守卫的是未确认项(仍在 awaiting/ + // pending 的 ID 不会被误删,重新 closeTerminal 会再次加入) + this.closeSentForConnection.delete(sessionId) + } + + reconnectTerminal(sessionId: string): void { + this.emit('reconnect-session', { sessionId }) } // 系统监控相关方法 diff --git a/client/src/utils/terminalFactory.ts b/client/src/utils/terminalFactory.ts new file mode 100644 index 00000000..2de12dbc --- /dev/null +++ b/client/src/utils/terminalFactory.ts @@ -0,0 +1,57 @@ +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { Terminal } from '@xterm/xterm' + +export interface TerminalViewOptions { + isMobile: boolean +} + +export function createTerminalView( + options: TerminalViewOptions +): { + terminal: Terminal + fitAddon: FitAddon +} { + const terminal = new Terminal({ + theme: { + background: '#1a1a1a', + foreground: '#ffffff', + cursor: '#ffffff', + selectionBackground: '#ffffff30', + black: '#000000', + red: '#ff6b6b', + green: '#51cf66', + yellow: '#ffd43b', + blue: '#74c0fc', + magenta: '#f06292', + cyan: '#4dd0e1', + white: '#ffffff', + brightBlack: '#666666', + brightRed: '#ff8a80', + brightGreen: '#69f0ae', + brightYellow: '#ffff8d', + brightBlue: '#82b1ff', + brightMagenta: '#ff80ab', + brightCyan: '#84ffff', + brightWhite: '#ffffff' + }, + fontFamily: 'JetBrains Mono, Fira Code, Consolas, Monaco, monospace', + fontSize: options.isMobile ? 12 : 14, + lineHeight: 1.2, + cursorBlink: true, + cursorStyle: 'block', + scrollback: options.isMobile ? 500 : 1000, + tabStopWidth: 4, + allowTransparency: true, + disableStdin: false, + convertEol: true + }) + + const fitAddon = new FitAddon() + const webLinksAddon = new WebLinksAddon() + + terminal.loadAddon(fitAddon) + terminal.loadAddon(webLinksAddon) + + return { terminal, fitAddon } +} diff --git "a/docs/Docker\346\236\204\345\273\272\350\257\264\346\230\216.md" "b/docs/Docker\346\236\204\345\273\272\350\257\264\346\230\216.md" index 8656e11f..4f118009 100644 --- "a/docs/Docker\346\236\204\345\273\272\350\257\264\346\230\216.md" +++ "b/docs/Docker\346\236\204\345\273\272\350\257\264\346\230\216.md" @@ -106,28 +106,36 @@ docker run -d \ - `./game_data` → `/home/steam/games` - 游戏数据 - `./game_file` → `/home/steam/.config` 和 `/home/steam/.local` - 游戏配置 -- `./gsm3_data` → `/home/steam/server/data` - GSM3 应用数据 +- `./gsm3_data` → `/root/server/data` - GSM3 应用数据 ## 运行依赖预置目录说明 -为避免容器启动后重复下载运行依赖,Docker 构建阶段下载的二进制文件(如 `file_zip_linux_x64`、`7z_linux_x64`、`pty_linux_x64`)需要预置到以下目录: +运行时路径候选以 Node.js 的 `process.cwd()` 为基准,`data/lib` 与 `server/data/lib` 都相对于该目录解析,并按此顺序尝试。Docker 的 `/root/start.sh` 在启动 Node.js 前执行 `cd server`,因此服务进程的 cwd 是 `/root/server`,两个实际候选依次是: -- `/root/server/data/lib` +1. `/root/server/data/lib` +2. `/root/server/server/data/lib` -原因: +为避免容器启动后重复下载运行依赖,同时避免 `./gsm3_data` 数据卷遮蔽镜像内置文件,Docker 构建阶段将当前镜像架构需要的二进制文件预置到 `/root/server/builtin/data/lib`;`/root/start.sh` 启动时只补齐缺失文件到第一候选 `/root/server/data/lib`,不覆盖用户已有资产。这只是明确现有候选路径的解析基准,不改变运行时查找顺序。 -- 服务启动时会优先从 `/root/server/data/lib` 检测依赖是否就绪。 -- 若构建阶段写入了其他目录(例如 `/root/data/lib`),启动时仍会触发二次下载。 +### PTY 固定资产策略 -### 下载源策略 +`Zip-Tools` 和 `7z` 保持现有 GitHub Releases 下载逻辑;PTY 不使用可变发布下载地址。应用打包完成后,最终镜像调用已经随服务端产物打包的 CLI: -Docker 构建阶段对 `Zip-Tools`、`7z`、`PTY` 统一从 GitHub Releases 下载。 +```text +node /root/server/utils/ptyAssetCli.js ensure \ + --asset \ + --target-dir /root/server/builtin/data/lib +``` + +`TARGETARCH` 与资产键的映射为: -其中 PTY 的下载地址使用: +- `amd64` → `linux-x64` +- `arm64` → `linux-arm64` +- 其他架构 → 构建失败 -- `https://github.com/MCSManager/PTY/releases/download/latest/` +CLI 读取服务端唯一的固定 release/asset manifest,通过 GitHub API 获取固定资产,并要求文件名、字节数和 SHA-256 全部匹配。最终镜像只选择当前镜像的原生架构,因此只对该原生 PTY 执行 `-h` 能力探测,并要求帮助文本包含 `-fifo`;不会在镜像构建中执行其他架构的二进制文件。 -说明:`PTY` 项目发布资产不保证支持 `releases/latest/download/` 形式,使用 `releases/download/latest/` 更稳定。 +无法验证、无法下载、原生探测失败或架构不受支持时,Docker 构建直接失败,不会保留或接受不可验证的 PTY 文件。固定 release ID、asset ID、大小和哈希详见 `docs/PTY集成说明.md`,不要在 Dockerfile 中另行维护或添加可变 URL 回退。 ## 访问管理界面 diff --git "a/docs/PTY\351\233\206\346\210\220\350\257\264\346\230\216.md" "b/docs/PTY\351\233\206\346\210\220\350\257\264\346\230\216.md" index c62dda6a..ec41beb8 100644 --- "a/docs/PTY\351\233\206\346\210\220\350\257\264\346\230\216.md" +++ "b/docs/PTY\351\233\206\346\210\220\350\257\264\346\230\216.md" @@ -1,61 +1,91 @@ # PTY 集成说明 -## 概述 +## 固定发布契约 -项目使用 [MCSManager/PTY](https://github.com/MCSManager/PTY) 外部二进制工具处理终端伪终端(PTY)会话。该工具提供跨平台的终端模拟能力。 +项目使用 [MCSManager/PTY](https://github.com/MCSManager/PTY) 提供真实伪终端能力。所有运行时、离线打包、Docker 构建和常规安装都复用 `server/src/utils/ptyAssets.ts` 中的固定清单,不使用可变发布地址,也不在分发脚本中重复维护资产信息。 -## 自动下载机制 +- Release ID:`297277624` +- 上游构建 commit:`09fc369dfa278504831260de2771d7cbd98d01c4` -服务端启动时会自动检测 PTY 二进制文件是否存在: +| 资产键 | 平台 / 架构 | Asset ID | 文件名 | 大小(字节) | SHA-256 | +|---|---|---:|---|---:|---| +| `linux-x64` | Linux x64 | `374651721` | `pty_linux_x64` | `2654360` | `bbdfc8a5d0f57493e78c64bca56d370524c068c1d4d31cac653458a843d47f72` | +| `linux-arm64` | Linux ARM64 | `374651727` | `pty_linux_arm64` | `2752664` | `48d8496997053b60eb84d2b02f4ec751298c7f214c615b08aca43309739ebf83` | +| `win32-x64` | Windows x64 | `374651714` | `pty_win32_x64.exe` | `3627520` | `fe35c154e623707d0dd2b728f41fd200bd3ead0a8cda8eb216b1e5e3e3ab2d40` | -- **已存在**:跳过下载,记录日志 `PTY 已存在,跳过下载` -- **不存在**:自动从 GitHub Releases 下载对应平台的二进制文件 -- **下载失败**:记录警告日志但不阻塞服务启动,终端功能可能不可用 +## 固定资产获取流程 -### 下载地址 +`ptyAssetCli.js ensure` 会先校验目标目录中的现有资产;现有资产通过固定清单校验和本机能力探测时直接复用,不发起网络请求。仅在需要下载或替换资产时执行两步 GitHub API 请求: -运行时从 GitHub Releases 下载: +1. 请求 `GET https://api.github.com/repos/MCSManager/PTY/releases/297277624`,使用 `Accept: application/vnd.github+json`。响应中的 release ID 必须等于 `297277624`,并且必须恰好有一个资产同时匹配固定的 Asset ID、文件名和大小。 +2. 请求 `GET https://api.github.com/repos/MCSManager/PTY/releases/assets/`,使用 `Accept: application/octet-stream` 下载匹配资产。允许跟随 GitHub 下载重定向,但离开 `api.github.com` 时不会转发 `Authorization`。 -- `https://github.com/MCSManager/PTY/releases/download/latest/` +如设置 `GITHUB_TOKEN`,CLI 会仅向 GitHub API 请求发送令牌。任一步元数据验证、下载、完整性校验或本机能力探测失败,`ensure` 都以非零状态退出。 -### CI/CD 构建与打包 +## 完整性校验与本机能力探测 -打包脚本(`scripts/package.js`)和 Docker 构建(`Dockerfile`)在构建时直接从 GitHub Releases 下载 PTY 并内置到产物中,确保用户部署后无需额外下载。 +已有文件和新下载文件都必须通过同一套校验: -## 支持的平台和架构 +1. 资产来自上表对应的固定清单,最终文件名必须精确匹配。 +2. 文件必须是普通文件,字节数必须与清单中的大小完全一致;下载过程中一旦超过固定大小会立即失败。 +3. 对完整文件计算 SHA-256,结果必须与清单值完全一致。 +4. 仅对当前操作系统和 CPU 架构对应的原生资产执行能力探测:运行 ` -h`,要求在 3 秒内以状态 `0` 退出、输出严格少于 64 KiB,并且帮助文本包含必需参数 `-fifo`。 +5. 非本机架构资产只做大小和 SHA-256 校验,绝不执行。POSIX 使用旧目标的同目录硬链接作为备份,再以单次原子 `rename` 覆盖目标;Windows 保留可恢复的重命名与回滚路径。`ensure` 会在校验前恢复可信的遗留备份并清理其余备份,校验或探测失败的文件不会被当作可用 PTY。 -| 操作系统 | CPU 架构 | 二进制文件名 | -|---------|---------|-------------| -| Windows | x64 | `pty_win32_x64.exe` | -| Linux | x64 | `pty_linux_x64` | -| Linux | ARM64 | `pty_linux_arm64` | +因此,仅判断文件存在、可执行或为 ELF/PE 文件并不足以通过校验。 -## 二进制文件存放位置 +## 分发路径 -使用多路径尝试策略,按以下顺序查找: +### 离线打包 -1. `{项目根目录}/data/lib/` — 打包后环境 -2. `{项目根目录}/server/data/lib/` — 开发环境 +`scripts/package.js` 会先复制编译后的服务端并安装其生产依赖,再按明确目标调用打包产物中的 CLI: + +```text +npm run package:linux:x64 # ensure --asset linux-x64 +npm run package:linux:arm64 # ensure --asset linux-arm64 +npm run package:windows # ensure --asset win32-x64 +``` + +每个 Linux 包只包含对应架构的 Node.js、PTY、Zip-Tools 和 7z:x64 发布归档为 `gsm3-management-panel-linux-x64.tar.gz`,ARM64 发布归档为 `gsm3-management-panel-linux-arm64.tar.gz`。Windows 包包含 `win32-x64` PTY;未指定目标时的通用开发入口仍可包含全部资产。任何 PTY 资产无法校验、下载或进行适用的本机探测时,打包操作失败,不生成声称已包含 PTY 的产物。 + +### Docker 镜像 + +最终镜像根据 `TARGETARCH` 只选择当前镜像的原生资产:`amd64` 对应 `linux-x64`,`arm64` 对应 `linux-arm64`。镜像调用: + +```text +node /root/server/utils/ptyAssetCli.js ensure --asset --target-dir /root/server/builtin/data/lib +``` -> **注意**:PTY 文件已从旧的 `server/PTY/` 目录迁移到 `data/lib/` 目录,与 Zip-Tools 统一管理。 +不支持的架构或校验、下载、原生 `-fifo` 探测失败都会使镜像构建失败。 -## 手动放置二进制文件(离线环境) +### 常规安装脚本 -如果服务器无法访问外网,可以手动下载并放置二进制文件: +`install-gsm3.sh` 在下载前读取 `uname -m`:`x86_64/amd64` 选择 `gsm3-management-panel-linux-x64.tar.gz` 与 `linux-x64`,`aarch64/arm64` 选择 `gsm3-management-panel-linux-arm64.tar.gz` 与 `linux-arm64`;其他架构直接退出。解压并启用包内 Node.js 后调用: + +```text +/node/bin/node /server/utils/ptyAssetCli.js ensure --asset --target-dir /data/lib +``` + +若安装阶段无法完成固定资产校验或按需下载,脚本会明确提示:在运行时校验成功前,终端创建功能保持不可用。 + +## 运行时路径 + +`PtyManager` 按顺序尝试: + +1. `{项目根目录}/data/lib/` — 打包后环境 +2. `{项目根目录}/server/data/lib/` — 开发环境 -1. 从 [PTY Releases](https://github.com/MCSManager/PTY/releases/tag/latest) 下载对应平台的二进制文件 -2. 将文件放置到 `server/data/lib/` 或 `data/lib/` 目录下 -3. Linux 平台需要设置可执行权限:`chmod 755 pty_linux_x64` +运行时只返回通过固定清单校验和本机 `-fifo` 探测的 PTY 路径。缺失、损坏或能力不满足的文件会通过同一固定 CLI 底层逻辑替换。 -## 使用的模块 +## 离线环境与禁止自定义二进制 -- `PtyManager`(`server/src/utils/ptyManager.ts`)— PTY 二进制文件的路径解析、检测、下载管理 -- `TerminalManager`(`server/src/modules/terminal/TerminalManager.ts`)— 通过 PtyManager 获取 PTY 路径并创建终端会话 +离线部署可以预先把上表中的官方固定资产放入 `data/lib/` 或 `server/data/lib/`,但文件名、字节数和 SHA-256 必须全部精确匹配;Linux 文件还需具备执行权限。随后仍应运行对应的 `ptyAssetCli.js ensure`,让本机资产完成 `-fifo` 探测。 -## 迁移说明(从旧版本升级) +项目不接受自定义下载 URL、重命名资产、不同版本 PTY 或仅凭文件格式判断可信的二进制,也不得绕过大小、SHA-256 和原生能力探测。需要更新 PTY 时,应统一更新固定 release/asset manifest 及其验证资料,而不是在打包、Docker、安装脚本或运行时加入独立回退地址。 -旧版本将 PTY 文件存放在 `server/PTY/` 目录下,新版本已迁移到 `data/lib/` 目录。升级后: +## 相关模块 -- 旧的 `server/PTY/` 目录可以安全删除 -- 服务启动时会自动检测并下载 PTY 到新目录 -- 打包产物中不再包含 `server/PTY/` 目录 +- `server/src/utils/ptyAssets.ts`:固定 release/asset manifest、GitHub API 下载、完整性校验、原生探测和原子替换。 +- `server/src/utils/ptyAssetCli.ts`:供打包、Docker 和安装脚本调用的机器可读入口。 +- `server/src/utils/ptyManager.ts`:运行时路径选择和固定资产确保逻辑。 +- `server/src/modules/terminal/TerminalManager.ts`:使用已验证 PTY 创建终端会话。 diff --git a/docs/superpowers/plans/2026-08-02-terminal-real-pty-resize.md b/docs/superpowers/plans/2026-08-02-terminal-real-pty-resize.md new file mode 100644 index 00000000..5469299b --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-terminal-real-pty-resize.md @@ -0,0 +1,2048 @@ +# Terminal Real PTY Resize Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make browser Xterm dimensions, server-side session state, and the native MCSManager/PTY winsize converge after creation, reconnect, sidebar changes, fullscreen changes, and rapid container resizing. + +**Architecture:** Use one fixed PTY asset manifest and shared CLI-backed installer contract; create one random FIFO or Named Pipe control channel per PTY; serialize RESIZE type `4` frames through a latest-wins queue; manage creation, close, reconnect, and retained processes with explicit server-side state; keep Xterm runtimes in a frontend `Map` ref and drive one active terminal through a callback ref, one `ResizeObserver`, one rAF fit scheduler, and one resize reporter. + +**Tech Stack:** Node.js `>=18`, TypeScript 5.9, Axios 1.14, Socket.IO 4.8, React 18.3, Zustand 4.5, `@xterm/xterm` 5.5, FitAddon 0.10, WebLinksAddon 0.11, Jest/ts-jest, Vitest, Docker, Bash, POSIX FIFO, Windows Named Pipe. + +**Global Constraints:** Execute every implementation, review, and verification task through the configured secondary model slot. The approved design has no unresolved blocking questions, so no further explore-agent investigation is required before implementation. Do not upgrade Xterm or existing dependencies, do not add an HTTP resize API, do not add a `SIGWINCH` fallback, do not add owner/actor authorization in this scope, and do not expose control endpoints to browsers or normal logs. Store runtime data under the existing `data/...` and `server/data/...` candidate paths. Use only existing panel notifications and styled interactions. Temporary tests and diagnostics must be deleted after passing. Run client and server `npx tsc --noEmit` before delivery. Every proposed `git commit` requires fresh, commit-specific user confirmation at execution time; approval of this plan or any earlier commit is not authorization for a later commit. + +## File Structure and Responsibilities + +### New files + +- `server/src/utils/ptyAssets.ts` + - Canonical release ID, build commit, asset tuples, integrity validation, two-step GitHub API download, redirect policy, native `-fifo` probe, and atomic replacement. +- `server/src/utils/ptyAssetCli.ts` + - Thin compiled CLI used by packaging, Docker, and the installation script so those paths reuse the same manifest and download implementation. +- `server/src/utils/ptyControlChannel.ts` + - Shared size validator, RESIZE frame encoder, latest-wins write queue, POSIX FIFO transport, Windows Named Pipe transport, and confirmed-exit endpoint cleanup. +- `client/src/utils/terminalFactory.ts` + - The single Xterm/FitAddon/WebLinksAddon factory with the existing theme and desktop/mobile options. + +### Modified runtime files + +- `server/src/utils/ptyManager.ts` + - Resolve candidate paths and ensure that only a manifest-valid, native-probed PTY can be returned. +- `server/src/modules/terminal/TerminalManager.ts` + - Add create-attempt reservation, per-session control channels, real resize, single-flight close, retained targets, reconnect, and bounded cleanup. +- `server/src/modules/instance/InstanceManager.ts` + - Await close results and only mark instances stopped after confirmed target removal. +- `server/src/index.ts` + - Await terminal Socket handlers and implement ordered, isolated, 15-second graceful shutdown. +- `client/src/pages/TerminalPage.tsx` + - Replace duplicated Xterm instances and resize paths with runtime refs, callback ref attachment, observer-driven fitting, state-machine events, and single-flight close. +- `client/src/utils/socket.ts` + - Require create dimensions and expose typed terminal request methods. +- `client/src/types/index.ts` + - Replace nonexistent terminal events with the actual event payload contract. +- `.gitignore` + - Ignore normal `data/terminal-control/` and `server/data/terminal-control/` contents. + +### Modified distribution and documentation files + +- `scripts/package.js` + - Invoke the compiled PTY asset CLI for the selected fixed assets. +- `Dockerfile` + - Validate or install the current native asset through the same CLI. +- `install-gsm3.sh` + - Use the bundled Node runtime and CLI instead of a mutable direct URL. +- `docs/PTY集成说明.md` + - Document fixed assets, integrity checks, native probe, and the control channel. +- `docs/Docker构建说明.md` + - Remove obsolete mutable PTY URL guidance and document the fixed installer path. + +### Temporary files that must not remain + +- `server/src/__tests__/pty-assets.tmp.test.ts` +- `scripts/.tmp-verify-pty-distribution.cjs` +- `server/src/__tests__/pty-control-channel.tmp.test.ts` +- `server/src/__tests__/pty-control-platform.tmp.test.ts` +- `server/src/__tests__/terminal-create-attempt.tmp.test.ts` +- `server/src/__tests__/terminal-resize.tmp.test.ts` +- `server/src/__tests__/terminal-close.tmp.test.ts` +- `server/src/__tests__/terminal-call-sites.tmp.test.ts` +- `client/src/pages/TerminalPage.resize.tmp.test.ts` +- `client/src/pages/TerminalPage.state.tmp.test.ts` + +--- + +### Task 1: Add the canonical PTY manifest, downloader, probe, and runtime manager + +**Files** + +- Create: `server/src/utils/ptyAssets.ts` +- Create: `server/src/utils/ptyAssetCli.ts` +- Modify: `server/src/utils/ptyManager.ts` +- Modify: `server/src/modules/terminal/TerminalManager.ts` +- Temporary: `server/src/__tests__/pty-assets.tmp.test.ts` + +**Interfaces** + +```ts +export type PtyAssetKey = + | 'linux-x64' + | 'linux-arm64' + | 'win32-x64' + +export interface PtyAsset { + key: PtyAssetKey + platform: 'linux' | 'win32' + arch: 'x64' | 'arm64' + assetId: number + name: string + size: number + sha256: string +} + +export interface EnsurePtyAssetOptions { + asset: PtyAsset + targetDir: string + token?: string + logger?: { + info(message: string): void + warn(message: string): void + error(message: string): void + } +} + +export function getPtyAsset( + platform?: NodeJS.Platform, + arch?: string +): PtyAsset + +export async function verifyPtyAsset( + filePath: string, + asset: PtyAsset +): Promise + +export async function probePtyAsset( + filePath: string, + asset: PtyAsset +): Promise + +export async function ensurePtyAsset( + options: EnsurePtyAssetOptions +): Promise +``` + +CLI contract: + +```text +node ptyAssetCli.js ensure --asset --target-dir +``` + +**Steps** + +- [ ] Create `server/src/__tests__/pty-assets.tmp.test.ts` with assertions for the exact release metadata, platform mapping, exact-size/hash validation, and the rule that a hash-mismatched file is rejected before the probe function is called. + +- [ ] Run the new test before implementation: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/pty-assets.tmp.test.ts + ``` + + Expected result: non-zero exit because `../utils/ptyAssets.js` does not yet exist. + +- [ ] Add the canonical constants to `server/src/utils/ptyAssets.ts` exactly as follows: + + ```ts + export const PTY_RELEASE_ID = 297277624 + export const PTY_BUILD_COMMIT = + '09fc369dfa278504831260de2771d7cbd98d01c4' + + export const PTY_ASSETS: Record = { + 'linux-x64': { + key: 'linux-x64', + platform: 'linux', + arch: 'x64', + assetId: 374651721, + name: 'pty_linux_x64', + size: 2654360, + sha256: 'bbdfc8a5d0f57493e78c64bca56d370524c068c1d4d31cac653458a843d47f72' + }, + 'linux-arm64': { + key: 'linux-arm64', + platform: 'linux', + arch: 'arm64', + assetId: 374651727, + name: 'pty_linux_arm64', + size: 2752664, + sha256: '48d8496997053b60eb84d2b02f4ec751298c7f214c615b08aca43309739ebf83' + }, + 'win32-x64': { + key: 'win32-x64', + platform: 'win32', + arch: 'x64', + assetId: 374651714, + name: 'pty_win32_x64.exe', + size: 3627520, + sha256: 'fe35c154e623707d0dd2b728f41fd200bd3ead0a8cda8eb216b1e5e3e3ab2d40' + } + } + ``` + +- [ ] Implement `getPtyAsset()` so only Linux x64, Linux ARM64, and Windows x64 resolve. Unsupported platform/architecture combinations must throw before filesystem or network work begins. + +- [ ] Implement `verifyPtyAsset()` using `path.basename`, exact `stat.size`, and streamed SHA-256. Do not accept an otherwise executable file with the wrong basename, size, or digest. + +- [ ] Implement a process-and-path keyed probe cache: + + ```ts + const probeCache = new Map>() + ``` + + Run ` -h`, enforce a 3-second timeout, cap combined stdout/stderr at 64 KiB, and require the literal text `-fifo`. Do not execute cross-architecture assets. + +- [ ] Implement the release metadata request: + + ```text + GET https://api.github.com/repos/MCSManager/PTY/releases/297277624 + Accept: application/vnd.github+json + User-Agent: GameServerManager-PTY-Installer + X-GitHub-Api-Version: 2022-11-28 + ``` + + Require `release.id === 297277624` and exactly one asset matching the manifest asset ID, name, and size. + +- [ ] Implement the asset request: + + ```text + GET https://api.github.com/repos/MCSManager/PTY/releases/assets/ + Accept: application/octet-stream + User-Agent: GameServerManager-PTY-Installer + X-GitHub-Api-Version: 2022-11-28 + ``` + + Set `maxRedirects: 5`. If `Authorization` is configured, send it only to `api.github.com`; remove it in `beforeRedirect` whenever the destination hostname differs. + +- [ ] Stream the asset into a randomly named temporary file in the target directory. Count bytes while streaming, abort immediately above the manifest size, then verify basename mapping, exact size, and SHA-256. + +- [ ] On POSIX, apply mode `0755`. If the downloaded asset is native, probe the temporary file before replacement. Then use same-directory `rename`, clear the old and final-path probe cache entries, and verify/probe the final path again. + +- [ ] Ensure all failure branches remove only the temporary file. Preserve an existing target file, but do not return or execute it when it is invalid or lacks `-fifo`. + +- [ ] Add `server/src/utils/ptyAssetCli.ts` as a strict argument parser around `ensurePtyAsset()`. Reject unknown commands, missing arguments, unknown asset keys, and non-directory target arguments with exit code `1`; never print tokens or full redirect headers. + +- [ ] Refactor `PtyManager` to preserve the existing candidate order: + + ```ts + const candidates = [ + path.join(process.cwd(), 'data', 'lib'), + path.join(process.cwd(), 'server', 'data', 'lib') + ] + ``` + + The first existing candidate must pass manifest validation and native probe or be replaced there. If no candidate exists, install into the first writable candidate. + +- [ ] Remove `PtyManager.DOWNLOAD_URL`, the zero-byte-only check, and the “file exists means installed” behavior. `getPtyPath()` must return only a verified and native-probed path. + +- [ ] In `TerminalManager.initialize()`, remove the fallback that constructs an unchecked `data/lib/` path. Leave `ptyPath` empty and log capability unavailability when `getPtyPath()` fails; later `createPty` calls must emit an explicit create error instead of spawning an unchecked file. + +- [ ] Run the temporary tests again: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/pty-assets.tmp.test.ts + ``` + + Expected result: exit code `0`; all manifest, validation, and probe-order tests pass. + +- [ ] Delete `server/src/__tests__/pty-assets.tmp.test.ts`. + +- [ ] Run the server type check: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Confirm the runtime manager no longer references a mutable PTY URL: + + ```bash + cd /root/github_projects/GameServerManager + if grep -n 'MCSManager/PTY.*latest' server/src/utils/ptyManager.ts; then exit 1; fi + ``` + + Expected result: no output and exit code `0`. + +- [ ] Review the Task 1 diff. Before committing, obtain fresh user confirmation specifically for this commit; plan approval and prior confirmations do not count. Only after confirmation run: + + ```bash + git add \ + server/src/utils/ptyAssets.ts \ + server/src/utils/ptyAssetCli.ts \ + server/src/utils/ptyManager.ts \ + server/src/modules/terminal/TerminalManager.ts + git commit -m "feat: pin and verify PTY runtime assets" + ``` + + Expected result: one commit containing no temporary test file. + +--- + +### Task 2: Route packaging, Docker, and installation through the fixed asset contract + +**Files** + +- Modify: `scripts/package.js` +- Modify: `Dockerfile` +- Modify: `install-gsm3.sh` +- Modify: `docs/PTY集成说明.md` +- Modify: `docs/Docker构建说明.md` +- Temporary: `scripts/.tmp-verify-pty-distribution.cjs` + +**Interfaces** + +Package asset selection: + +```js +function getPtyAssetKeys(platform) { + if (platform === 'linux') return ['linux-x64', 'linux-arm64'] + if (platform === 'windows') return ['win32-x64'] + return ['linux-x64', 'linux-arm64', 'win32-x64'] +} +``` + +CLI invocations: + +```text +node /server/utils/ptyAssetCli.js ensure --asset linux-x64 --target-dir /data/lib +node /server/utils/ptyAssetCli.js ensure --asset linux-arm64 --target-dir /data/lib +node /server/utils/ptyAssetCli.js ensure --asset win32-x64 --target-dir /data/lib +``` + +**Steps** + +- [ ] Create `scripts/.tmp-verify-pty-distribution.cjs`. Make it read `server/src/utils/ptyManager.ts`, `scripts/package.js`, `Dockerfile`, and `install-gsm3.sh`; fail if any contains a PTY `latest` URL or if the three distribution paths do not invoke `ptyAssetCli.js`. + +- [ ] Run the diagnostic before edits: + + ```bash + cd /root/github_projects/GameServerManager + node scripts/.tmp-verify-pty-distribution.cjs + ``` + + Expected result: non-zero exit identifying mutable PTY URLs in `scripts/package.js`, `Dockerfile`, and `install-gsm3.sh`. + +- [ ] In `scripts/package.js`, import `execFileSync` alongside `execSync` and replace `PTY_GITHUB_URL`, PTY filename loops, and direct `downloadFile()` calls with `getPtyAssetKeys()` plus the compiled CLI. + +- [ ] Invoke the CLI only after `package/server` has been copied and its production dependencies have been installed: + + ```js + execFileSync(process.execPath, [ + path.join(packageDir, 'server', 'utils', 'ptyAssetCli.js'), + 'ensure', + '--asset', assetKey, + '--target-dir', libDir + ], { stdio: 'inherit' }) + ``` + + This avoids adding Axios to the root package because the CLI resolves Axios from `package/server/node_modules`. + +- [ ] Make a PTY verification/download failure fail the package operation instead of producing a package that claims to contain PTY. Preserve the existing non-PTY Zip-Tools and 7z behavior. + +- [ ] Replace the Docker PTY `wget` block with an architecture-to-key mapping and the packaged CLI: + + ```dockerfile + RUN if [ "$TARGETARCH" = "amd64" ]; then \ + PTY_ASSET="linux-x64"; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + PTY_ASSET="linux-arm64"; \ + else \ + echo "不支持的 PTY 架构: $TARGETARCH" >&2; exit 1; \ + fi && \ + node /root/server/utils/ptyAssetCli.js ensure \ + --asset "$PTY_ASSET" \ + --target-dir /root/server/data/lib + ``` + + The Docker build must fail if the current native asset cannot be verified or probed. + +- [ ] Replace the direct PTY download block in `install-gsm3.sh`. Map `uname -m` to `linux-x64` or `linux-arm64`, then invoke: + + ```bash + "$install_path/node/bin/node" \ + "$install_path/server/utils/ptyAssetCli.js" ensure \ + --asset "$PTY_ASSET" \ + --target-dir "$install_path/data/lib" + ``` + + The bundled Node binary is already available after extraction and chmod. If this operation fails, print that terminal creation will remain unavailable until runtime verification succeeds; do not restore a mutable URL. + +- [ ] Update `docs/PTY集成说明.md` with release ID `297277624`, build commit `09fc369dfa278504831260de2771d7cbd98d01c4`, all three asset IDs/names/sizes/hashes, the two-step API request, exact integrity verification, native `-fifo` probe, and the no-custom-binary rule. + +- [ ] Update `docs/Docker构建说明.md` so it no longer recommends `releases/download/latest`. Document that the final image invokes the bundled `ptyAssetCli.js`, probes only the native image architecture, and rejects unverifiable PTY files. + +- [ ] Run syntax checks: + + ```bash + cd /root/github_projects/GameServerManager + node --check scripts/package.js + bash -n install-gsm3.sh + ``` + + Expected result: both commands are silent and exit `0`. + +- [ ] Run the distribution diagnostic again: + + ```bash + cd /root/github_projects/GameServerManager + node scripts/.tmp-verify-pty-distribution.cjs + ``` + + Expected result: exit code `0`. + +- [ ] Delete `scripts/.tmp-verify-pty-distribution.cjs`. + +- [ ] Run the fixed-entry grep: + + ```bash + cd /root/github_projects/GameServerManager + if grep -n 'MCSManager/PTY.*latest' \ + server/src/utils/ptyManager.ts \ + scripts/package.js \ + Dockerfile \ + install-gsm3.sh; then + exit 1 + fi + ``` + + Expected result: no output and exit code `0`. + +- [ ] Run the server type check because the compiled CLI is part of the server build: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 2 diff. Before committing, obtain fresh user confirmation specifically for this commit; no earlier authorization applies. Only after confirmation run: + + ```bash + git add \ + scripts/package.js \ + Dockerfile \ + install-gsm3.sh \ + docs/PTY集成说明.md \ + docs/Docker构建说明.md + git commit -m "build: use fixed PTY assets across distribution" + ``` + + Expected result: one commit without the temporary diagnostic script. + +--- + +### Task 3: Implement size validation, RESIZE framing, and the serialized latest-wins queue + +**Files** + +- Create: `server/src/utils/ptyControlChannel.ts` +- Temporary: `server/src/__tests__/pty-control-channel.tmp.test.ts` + +**Interfaces** + +```ts +export interface PtySize { + cols: number + rows: number +} + +export interface PtyControlChannel { + readonly endpoint: string + waitUntilReady(timeoutMs: number): Promise + enqueueResize(size: PtySize): Promise<'written' | 'skipped'> + close(): Promise +} + +export function validatePtySize( + cols: unknown, + rows: unknown +): PtySize + +export function encodePtyResizeFrame(size: PtySize): Buffer +``` + +Internal transport boundary: + +```ts +interface PtyControlWriter { + write( + frame: Buffer, + callback: (error?: Error | null) => void + ): boolean + destroy(error?: Error): void +} + +interface PtyControlTransport { + waitUntilReady(timeoutMs: number): Promise + destroyWriter(): void + close(): Promise +} +``` + +**Steps** + +- [ ] Create `server/src/__tests__/pty-control-channel.tmp.test.ts` with a fake callback-driven writer. Cover validator boundaries, exact `120x40` bytes, one in-flight write, one latest pending write, overwritten requests returning `skipped`, duplicate requests returning `skipped`, and close waiting for all returned promises. + +- [ ] Use this exact frame assertion: + + ```ts + const frame = encodePtyResizeFrame({ cols: 120, rows: 40 }) + expect(frame.subarray(0, 3)).toEqual(Buffer.from([0x04, 0x00, 0x19])) + expect(frame.subarray(3).toString('utf8')) + .toBe('{"width":120,"height":40}') + ``` + +- [ ] Run the test before implementation: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/pty-control-channel.tmp.test.ts + ``` + + Expected result: non-zero exit because the module is missing. + +- [ ] Implement `validatePtySize()` with `Number.isSafeInteger`. Accept only `2 <= cols <= 1000` and `1 <= rows <= 1000`; throw on strings, floats, NaN, Infinity, or out-of-range values. Do not clamp. + +- [ ] Implement `encodePtyResizeFrame()` using: + + ```ts + const payload = Buffer.from( + JSON.stringify({ width: size.cols, height: size.rows }), + 'utf8' + ) + const frame = Buffer.allocUnsafe(3 + payload.length) + frame.writeUInt8(4, 0) + frame.writeUInt16BE(payload.length, 1) + payload.copy(frame, 3) + ``` + + Reject a payload above `0xffff` even though the current validator makes that impossible. + +- [ ] Implement queue state containing exactly one current write, one latest pending request, `lastWrittenSize`, a closed flag, and a `Set>` or equivalent collection of unsettled resize operations. + +- [ ] When idle, write immediately. Resolve `written` only from a successful full-frame write callback. While a write is active, keep only the newest distinct pending size and resolve the superseded pending request as `skipped`. + +- [ ] Treat a request equal to `lastWrittenSize`, the current in-flight size, or the already-pending size as `skipped`. + +- [ ] Implement `close()` so it atomically marks the queue closed, resolves pending and future requests as `skipped`, destroys the active writer, maps destruction-caused write errors to `skipped`, and waits for every resize promise created before close to settle. + +- [ ] Preserve non-close write errors as rejected promises so `TerminalManager` can emit one resize error and terminate the affected session. + +- [ ] Run the temporary test: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/pty-control-channel.tmp.test.ts + ``` + + Expected result: exit code `0`, including the `04 00 19` assertion and latest-pending behavior. + +- [ ] Delete `server/src/__tests__/pty-control-channel.tmp.test.ts`. + +- [ ] Run the server type check: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 3 diff. Obtain new user confirmation specifically for this commit before running it; current plan approval is insufficient. After confirmation run: + + ```bash + git add server/src/utils/ptyControlChannel.ts + git commit -m "feat: encode and serialize PTY resize frames" + ``` + + Expected result: one commit without the temporary queue test. + +--- + +### Task 4: Add secure POSIX FIFO and Windows Named Pipe transports + +**Files** + +- Modify: `server/src/utils/ptyControlChannel.ts` +- Modify: `.gitignore` +- Temporary: `server/src/__tests__/pty-control-platform.tmp.test.ts` + +**Interfaces** + +```ts +export interface CreatePtyControlChannelOptions { + sessionId: string + logger: { + debug(message: string): void + warn(message: string): void + error(message: string): void + } + platform?: NodeJS.Platform + directoryCandidates?: string[] +} + +export async function createPtyControlChannel( + options: CreatePtyControlChannelOptions +): Promise + +export async function removePtyControlEndpoint( + endpoint: string, + platform?: NodeJS.Platform +): Promise +``` + +**Steps** + +- [ ] Add the production POSIX candidates exactly in this order: + + ```ts + const candidates = [ + path.join(process.cwd(), 'data', 'terminal-control'), + path.join(process.cwd(), 'server', 'data', 'terminal-control') + ] + ``` + +- [ ] Select the first creatable directory, call `chmod(directory, 0o700)`, and generate each endpoint with at least `randomBytes(16).toString('hex')`. Do not put `sessionId` in the path. + +- [ ] Generate Windows endpoints in this form: + + ```ts + `\\\\.\\pipe\\gsm3-pty-${randomBytes(16).toString('hex')}` + ``` + + Do not attempt custom pipe ACL configuration; rely on the upstream pipe server’s default DACL as specified. + +- [ ] Implement POSIX `waitUntilReady(timeoutMs)` as a bounded sequence: wait for the path, `lstat`, reject symlinks and non-FIFO entries, `chmod 0600`, then open a persistent write stream. Every stage must observe the same timeout and closed flag. + +- [ ] Implement Windows readiness using `net.createConnection(endpoint)`. Resolve only after the connection event; reject on timeout, connection error, or channel close. + +- [ ] Ensure readiness timeout or cancellation destroys any partial writer but does not unlink the endpoint while the PTY process may still be running. + +- [ ] Implement `removePtyControlEndpoint()` as a confirmed-exit-only helper. On POSIX, re-check with `lstat`, refuse to follow symlinks, and unlink only a FIFO. On Windows, return without filesystem work. + +- [ ] Restrict logs to platform, `sessionId`, and a failure-stage label such as `directory`, `lstat`, `chmod`, `open`, or `connect`. Never log the complete endpoint. + +- [ ] Add these ignore entries: + + ```gitignore + data/terminal-control/ + server/data/terminal-control/ + ``` + +- [ ] Create `server/src/__tests__/pty-control-platform.tmp.test.ts`. On Linux, use an injected temporary directory, create the returned endpoint with `mkfifo`, open a reader, verify readiness and one frame, verify missing FIFO timeout, verify symlink rejection, verify `close()` does not unlink, and verify explicit `removePtyControlEndpoint()` does unlink after simulated process exit. + +- [ ] Run the platform test: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/pty-control-platform.tmp.test.ts + ``` + + Expected result on Linux: exit code `0`. If run on Windows, skip only the POSIX cases and execute a native Named Pipe connection case instead. + +- [ ] Delete `server/src/__tests__/pty-control-platform.tmp.test.ts`. + +- [ ] Run the server type check: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 4 diff. Obtain new user confirmation for this exact commit before committing; do not reuse prior approval. After confirmation run: + + ```bash + git add server/src/utils/ptyControlChannel.ts .gitignore + git commit -m "feat: add native PTY control transports" + ``` + + Expected result: one commit with no temporary platform test. + +--- + +### Task 5: Add create-attempt reservation, control readiness, and deterministic fallback + +**Files** + +- Modify: `server/src/modules/terminal/TerminalManager.ts` +- Temporary: `server/src/__tests__/terminal-create-attempt.tmp.test.ts` + +**Interfaces** + +```ts +type CreateAttemptPhase = + | 'starting' + | 'fallback' + | 'closing' + | 'close-retained' + +interface CreateCancellationToken { + cancelled: boolean +} + +interface CreateAttempt { + id: string + phase: CreateAttemptPhase + cancellation: CreateCancellationToken + createSize: PtySize + process?: ChildProcess + control?: PtyControlChannel + endpoint?: string + socket: Socket + closePromise?: Promise + processExited: boolean + finalEventSent: boolean + // Existing name, cwd, output, persistence, stream-forward, + // redactor, runtime option, and callback references. +} + +interface PtySession { + // Existing fields. + state: 'ready' | 'closing' + size: PtySize + control: PtyControlChannel + endpoint: string + closePromise?: Promise + processExited: boolean + finalEventSent: boolean +} + +export interface TerminalManagerDependencies { + spawnPty?: typeof spawn + createControlChannel?: typeof createPtyControlChannel +} +``` + +Existing constructor remains source-compatible: + +```ts +constructor( + io: SocketIOServer, + logger: winston.Logger, + configManager: ConfigManager, + dependencies: TerminalManagerDependencies = {} +) +``` + +**Steps** + +- [ ] Create `server/src/__tests__/terminal-create-attempt.tmp.test.ts` with fake sockets, fake child processes, and deferred control readiness. Assert that two same-ID `createPty()` calls result in one spawn and one create error, with no `closePty()` call and no `pty-closed`. + +- [ ] Add a test proving `pty-created` is not emitted until the channel readiness promise resolves. + +- [ ] Add a fallback test proving the fallback occurs only when all three conditions are true: no explicit `runtimeOptions.command`, a configured non-empty default user, and primary exit code `0` within 1000ms. Assert that fallback uses the original measured size and a new endpoint. + +- [ ] Run the tests before refactoring: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/terminal-create-attempt.tmp.test.ts + ``` + + Expected result: non-zero exit because the current implementation closes and replaces duplicate IDs, emits before control readiness, and uses fallback size `100x30`. + +- [ ] Add: + + ```ts + private createAttempts = new Map() + private acceptingTerminalOperations = true + ``` + +- [ ] Make `CreatePtyData.cols` and `CreatePtyData.rows` required. At the beginning of `createPty()`, synchronously validate the payload, resolve the working directory, validate stream-forward arguments, and call `validatePtySize()` before allocating a process or endpoint. + +- [ ] Add the no-await reservation block: + + ```ts + if ( + this.sessions.has(sessionId) || + this.createAttempts.has(sessionId) + ) { + this.emitTerminalError(socket, sessionId, 'create', '会话ID已存在') + return + } + + const attempt = this.createAttemptRecord(/* validated data */) + this.createAttempts.set(sessionId, attempt) + ``` + + There must be no `await` between either map check and `set()`. + +- [ ] Remove the existing duplicate-ID call to `closePty()`. A duplicate create must emit exactly one `terminal-error` with `operation: 'create'`, leave the existing target untouched, and never emit `pty-closed`. + +- [ ] Reject create immediately with `operation: 'create'` when `acceptingTerminalOperations` is false or `ptyPath` is empty. + +- [ ] Move asynchronous directory permission changes, user lookup, `sudo`/`su` lookup, endpoint preparation, and spawn work after reservation. Every continuation must check both the cancellation token and map identity before mutating state or emitting. + +- [ ] For each primary candidate, create a fresh channel before spawn and append: + + ```ts + '-size', `${attempt.createSize.cols},${attempt.createSize.rows}`, + '-fifo', control.endpoint + ``` + +- [ ] Register process output and close handlers by resolving the current owner from `sessions` or `createAttempts` using both `sessionId` and process identity. Do not capture a stale copied owner that can survive promotion or replacement. + +- [ ] Wait concurrently for control readiness and the primary 1000ms stability window. If the process exits before stability, classify it using the exact fallback conditions. Any non-fallback early exit becomes one create failure. + +- [ ] Before fallback, close the primary channel, confirm that the primary process exited, and remove only its confirmed-exit FIFO. Set `phase = 'fallback'`, generate a new channel/endpoint, reuse `createSize`, and start `/bin/bash --login` without a user switch. + +- [ ] Remove the fallback `'-size', '100,30'`. The fallback must use: + + ```ts + '-size', `${attempt.createSize.cols},${attempt.createSize.rows}` + ``` + +- [ ] Promote only if the process is still alive, control is ready, the attempt remains the same map object, and cancellation is false. Move ownership to `sessions`, set `state: 'ready'`, and emit exactly one `pty-created`. + +- [ ] Start stream forwarding and the initial carriage return only after promotion. Preserve the existing output redaction and runtime callbacks. + +- [ ] Ensure a process exit while still in `createAttempts` never emits `terminal-exit`. It may trigger fallback or a single create error, then performs confirmed-exit cleanup. + +- [ ] Run the create-attempt tests: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/terminal-create-attempt.tmp.test.ts + ``` + + Expected result: exit code `0`; duplicate reservation, delayed `pty-created`, fallback conditions, original size reuse, and new endpoint checks pass. + +- [ ] Delete `server/src/__tests__/terminal-create-attempt.tmp.test.ts`. + +- [ ] Run the server type check: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 5 diff. Obtain fresh confirmation for this commit before executing it; no previous approval applies. After confirmation run: + + ```bash + git add server/src/modules/terminal/TerminalManager.ts + git commit -m "feat: reserve PTY creation attempts safely" + ``` + + Expected result: one commit without the temporary create-attempt test. + +--- + +### Task 6: Replace fake SIGWINCH resize with control-channel writes + +**Files** + +- Modify: `server/src/modules/terminal/TerminalManager.ts` +- Temporary: `server/src/__tests__/terminal-resize.tmp.test.ts` + +**Interfaces** + +```ts +interface TerminalResizeData { + sessionId: string + cols: number + rows: number +} + +public async resizeTerminal( + socket: Socket, + data: TerminalResizeData +): Promise +``` + +Error payload: + +```ts +interface TerminalErrorPayload { + sessionId: string + operation: 'create' | 'input' | 'resize' | 'close' + error: string +} +``` + +**Steps** + +- [ ] Create `server/src/__tests__/terminal-resize.tmp.test.ts`. Inject a fake control channel and verify validator rejection, `skipped` producing no event, `written` producing one event, write failure producing one resize error, and a session/channel identity change during the awaited write suppressing the late acknowledgement. + +- [ ] Run the test before implementation: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/terminal-resize.tmp.test.ts + ``` + + Expected result: non-zero exit because `resizeTerminal()` is synchronous and only sends `SIGWINCH`. + +- [ ] Change `resizeTerminal()` to `async`. Validate `cols/rows` first with `validatePtySize()` and emit `terminal-error` with `operation: 'resize'` on invalid data. + +- [ ] Look up a `ready` session only. Save both the session object and control channel object before awaiting: + + ```ts + const session = this.sessions.get(sessionId) + if (!session || session.state !== 'ready') return + + const control = session.control + const result = await control.enqueueResize(size) + ``` + +- [ ] After `await`, re-read `sessions.get(sessionId)` and require all of the following: + - The object is the same `session`. + - Its state is still `ready`. + - Its control channel is still the same `control`. + - The queue result is `written`. + +- [ ] Only after that identity check, update `session.size` and `lastActivity`, then emit to the requesting socket: + + ```ts + socket.emit('terminal-resized', { + sessionId, + cols: size.cols, + rows: size.rows + }) + ``` + +- [ ] For `skipped`, closed-channel cancellation, or failed identity checks, return silently without `terminal-resized`. + +- [ ] On a non-close write error, emit one stable `terminal-error` with `operation: 'resize'`. If the same ready session still exists, terminate it with `intentional: false`; its process close handler must emit `terminal-exit`, not `pty-closed`. + +- [ ] Remove all `SIGWINCH` calls and comments claiming that signals resize the native PTY. Do not add a fallback signal path. + +- [ ] Update input handling so only `session.state === 'ready'` accepts input. Missing or unusable stdin must emit `operation: 'input'`; failed input is not queued or replayed. + +- [ ] Run the resize tests: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/terminal-resize.tmp.test.ts + ``` + + Expected result: exit code `0`, including the delayed identity replacement case. + +- [ ] Delete `server/src/__tests__/terminal-resize.tmp.test.ts`. + +- [ ] Confirm no resize signal fallback remains: + + ```bash + cd /root/github_projects/GameServerManager + if grep -n 'SIGWINCH' server/src/modules/terminal/TerminalManager.ts; then + exit 1 + fi + ``` + + Expected result: no output and exit code `0`. + +- [ ] Run the server type check: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 6 diff. Obtain fresh, commit-specific confirmation before committing; prior permission is not reusable. After confirmation run: + + ```bash + git add server/src/modules/terminal/TerminalManager.ts + git commit -m "feat: write real PTY resize control frames" + ``` + + Expected result: one commit without the temporary resize test. + +--- + +### Task 7: Implement single-flight close, retained targets, reconnect, and bounded cleanup + +**Files** + +- Modify: `server/src/modules/terminal/TerminalManager.ts` +- Temporary: `server/src/__tests__/terminal-close.tmp.test.ts` + +**Interfaces** + +```ts +export type CloseResult = + | 'closed' + | 'not-found' + | 'still-running' + +public async closePty( + socket: Socket, + data: { sessionId: string } +): Promise + +public async reconnectSession( + socket: Socket, + sessionId: string +): Promise + +public hasTarget(sessionId: string): boolean + +public async cleanup(): Promise +``` + +**Steps** + +- [ ] Create `server/src/__tests__/terminal-close.tmp.test.ts` using fake child processes and fake timers. Assert that concurrent closes share one signal sequence, one result, and one event. + +- [ ] Add cases for: + - Missing target returns `not-found` and emits one `pty-closed`. + - Exit after SIGTERM returns `closed`. + - Exit after SIGKILL returns `closed`. + - No exit after 3+1 seconds returns `still-running`, emits one close error, and preserves the target. + - A retained create attempt reconnects to the latest socket. + - A late process exit after timeout emits `pty-closed` once and removes the retained target. + - Endpoint cleanup failure logs a warning but does not change `closed`. + +- [ ] Run the tests before implementation: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/terminal-close.tmp.test.ts + ``` + + Expected result: non-zero exit because the current close is synchronous, deletes immediately, and emits success before process exit. + +- [ ] Implement a target lookup that checks `sessions` first and `createAttempts` second. If both are absent, emit one `pty-closed` and return `not-found`. + +- [ ] Before the first await, reuse or install the target’s single-flight promise: + + ```ts + if (target.closePromise) { + return target.closePromise + } + + const closePromise = this.closeTarget(target, socket) + target.closePromise = closePromise + return closePromise + ``` + +- [ ] On close start, set a ready session to `state = 'closing'`, or a create attempt to `phase = 'closing'`; set the cancellation token before closing the channel or process stdin. + +- [ ] Call and await `control.close()` before emitting a terminal close result. This guarantees all prior resize promises settle before `pty-closed`. + +- [ ] End PTY stdin, terminate the stream-forward child with isolated warning handling, send PTY `SIGTERM`, and wait up to 3000ms for actual `close`/`exit`. Do not use `ChildProcess.killed` as proof of exit. + +- [ ] If still alive, send `SIGKILL` and wait another 1000ms. Use the process close event or explicit tracked `processExited` state as confirmation. + +- [ ] Add an idempotent finalizer that: + - Confirms the map still owns the same target. + - Removes it from the correct map. + - Removes the persistence record. + - Calls `removePtyControlEndpoint()` only after process exit. + - Emits exactly one final event when events are enabled. + - Never changes `CloseResult` because persistence or endpoint cleanup failed. + +- [ ] For confirmed intentional close, emit `pty-closed` and return `closed`. For confirmed non-intentional session exit, emit only `terminal-exit`. An attempt that exits before promotion must never emit `terminal-exit`. + +- [ ] On timeout, leave a session in `sessions` with `state = 'closing'`. Leave an attempt in `createAttempts`, change its phase to `close-retained`, preserve process/control/endpoint/socket/cancellation references, clear `closePromise`, emit one `terminal-error` with `operation: 'close'`, and return `still-running`. + +- [ ] Ensure a later process close invokes the same finalizer and emits at most one `pty-closed`. Do not clear the map merely because a kill signal was accepted. + +- [ ] Convert `reconnectSession()` to async. Check ready or closing sessions first, then `phase === 'close-retained'` attempts. On a match, update the stored socket and return `true`. + +- [ ] If a create attempt is currently transitioning through `closing` and has a `closePromise`, await that promise and restart the lookup. Return `false` only after a stable recheck confirms both maps contain no matching ID. + +- [ ] Update `handleDisconnect()` so ready sessions remain owned and are marked disconnected. Cancel owned `starting` or `fallback` attempts and explicitly consume their bounded termination promise without emitting a terminal final event. Preserve retained references when termination times out. + +- [ ] Update the delayed stream-forward auto-close call and disabled inactive cleanup path to use `void closePty(...).catch(...)`, `await`, or `Promise.allSettled`; no Promise may be dropped. + +- [ ] Implement `cleanup()` by first setting `acceptingTerminalOperations = false`, stopping terminal process monitoring, cancelling every attempt, and collecting all unique attempt/session close tasks before awaiting. + +- [ ] Use one parallel barrier: + + ```ts + await Promise.allSettled([ + ...attemptTasks, + ...sessionTasks + ]) + ``` + + Each target remains bounded by the 3-second SIGTERM plus 1-second SIGKILL waits. Finalize confirmed exits without emitting client events; log `error` or `critical` for still-running targets and retain their references for the process-wide 15-second shutdown deadline. + +- [ ] Do not call `sessions.clear()` or `createAttempts.clear()` during cleanup. Confirmed finalizers remove owned entries individually; unconfirmed targets remain represented. + +- [ ] Run the close tests: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/terminal-close.tmp.test.ts + ``` + + Expected result: exit code `0`; all three `CloseResult` values, retention, reconnect, finalizer idempotence, and single-flight cases pass. + +- [ ] Delete `server/src/__tests__/terminal-close.tmp.test.ts`. + +- [ ] Run the server type check: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 7 diff. Obtain fresh user confirmation for this exact commit before running it; earlier authorizations do not apply. After confirmation run: + + ```bash + git add server/src/modules/terminal/TerminalManager.ts + git commit -m "feat: make terminal closure single flight" + ``` + + Expected result: one commit without the temporary close test. + +--- + +### Task 8: Await all server call sites and make graceful shutdown fault-isolated + +**Files** + +- Modify: `server/src/index.ts` +- Modify: `server/src/modules/instance/InstanceManager.ts` +- Modify: `server/src/modules/terminal/TerminalManager.ts` +- Modify: `server/src/routes/gameDeployment.ts` +- Temporary: `server/src/__tests__/terminal-call-sites.tmp.test.ts` + +**Interfaces** + +```ts +type SettledCleanup = () => void | Promise + +async function settle( + name: string, + cleanup: SettledCleanup +): Promise +``` + +Instance close contract: + +```ts +public async closeTerminal(id: string): Promise +``` + +**Steps** + +- [ ] Create `server/src/__tests__/terminal-call-sites.tmp.test.ts` as a temporary source-contract diagnostic. Assert that the terminal resize, close, and reconnect Socket handlers await their manager methods; both `InstanceManager.closePty()` sites consume the result; and shutdown uses a 15000ms deadline plus sequential `settle()` calls. + +- [ ] Run the diagnostic before edits: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/terminal-call-sites.tmp.test.ts + ``` + + Expected result: non-zero exit listing the currently unawaited handlers and instance close calls. + +- [ ] Convert the Socket.IO handlers in `server/src/index.ts`: + + ```ts + socket.on('terminal-resize', async data => { + await terminalManager.resizeTerminal(socket, data) + }) + + socket.on('close-pty', async data => { + await terminalManager.closePty(socket, data) + }) + + socket.on('reconnect-session', async data => { + const success = await terminalManager.reconnectSession( + socket, + data.sessionId + ) + socket.emit( + success ? 'session-reconnected' : 'session-reconnect-failed', + { sessionId: data.sessionId } + ) + }) + ``` + + Wrap unexpected handler failures with logging so Socket.IO callbacks do not create unhandled rejections. + +- [ ] Keep `create-pty` awaiting `createPty()`. Verify that it only maps `cwd` to `workingDirectory`; it must not close or replace an existing ID. + +- [ ] In the 10-second `InstanceManager.stopInstance()` timer, use an async IIFE whose rejection is explicitly caught. Await `closePty()` and only clear instance status, PID, and terminal ID when the result is `closed` or `not-found` and `terminalManager.hasTarget(sessionId)` is false. + +- [ ] If the forced stop returns `still-running`, keep the instance in `stopping`, preserve `terminalSessionId`, and log that manual retry is required. + +- [ ] In `InstanceManager.closeTerminal()`, await `closePty()`. Return `false` and preserve ownership fields for `still-running`; only mark `stopped` after `closed | not-found` plus the no-target recheck. + +- [ ] Keep `TerminalManager.hasSession()` as the ready-session check used by deployment code, and add `hasTarget()` for session-or-attempt lifecycle checks. + +- [ ] In `server/src/routes/gameDeployment.ts`, retain `await terminalManager.createPty(...)` and replace the arbitrary post-create one-second wait with an immediate `hasSession()` check after the awaited method returns. + +- [ ] Update every internal `closePty()` call in `TerminalManager`, including stream-forward timeout and inactive cleanup, so it is awaited, collected by `Promise.allSettled`, or explicitly consumed with `void ...catch(...)`. + +- [ ] Convert `gracefulShutdown()` to `async`, retain the `shuttingDown` guard, and create the 15-second forced-exit timer immediately after setting the guard. + +- [ ] Implement `settle(name, cleanup)` so it catches both synchronous throws and rejected promises, logs the manager name, and always resolves. + +- [ ] Await managers sequentially in this exact order: + + ```text + instanceManager + terminalManager + gameManager + systemManager + fileWatchManager + steamcmdManager log-only step + schedulerManager + pluginManager + ``` + +- [ ] Put the manager sequence inside `try`. In `finally`, use `settle()` to destroy every tracked raw socket, then use `Promise.allSettled()` to close Socket.IO and the HTTP server through Promise wrappers. + +- [ ] On successful completion, clear the 15-second timer and exit `0`. Register signals using a top-level last-resort catch: + + ```ts + process.on('SIGTERM', () => { + void gracefulShutdown('SIGTERM').catch(handleShutdownEscape) + }) + ``` + +- [ ] Run the call-site diagnostic again: + + ```bash + cd /root/github_projects/GameServerManager/server + npx jest --runInBand src/__tests__/terminal-call-sites.tmp.test.ts + ``` + + Expected result: exit code `0`. + +- [ ] Delete `server/src/__tests__/terminal-call-sites.tmp.test.ts`. + +- [ ] Search all terminal manager calls: + + ```bash + cd /root/github_projects/GameServerManager + grep -R -nE '\b(closePty|resizeTerminal|createPty|reconnectSession)\s*\(' \ + server/src \ + --include='*.ts' + ``` + + Expected result: every Promise-returning call is visibly awaited, returned, collected, or prefixed with `void` and followed by `.catch(...)`. + +- [ ] Run the server type check: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 8 diff. Obtain fresh user confirmation specifically for this commit before committing; no current or earlier authorization covers it. After confirmation run: + + ```bash + git add \ + server/src/index.ts \ + server/src/modules/instance/InstanceManager.ts \ + server/src/modules/terminal/TerminalManager.ts \ + server/src/routes/gameDeployment.ts + git commit -m "fix: await terminal lifecycle operations" + ``` + + Expected result: one commit without the temporary call-site diagnostic. + +--- + +### Task 9: Add the unified Xterm factory and correct Socket event types + +**Files** + +- Create: `client/src/utils/terminalFactory.ts` +- Modify: `client/src/utils/socket.ts` +- Modify: `client/src/types/index.ts` + +**Interfaces** + +```ts +export interface TerminalViewOptions { + isMobile: boolean +} + +export function createTerminalView( + options: TerminalViewOptions +): { + terminal: Terminal + fitAddon: FitAddon +} +``` + +```ts +export interface CreateTerminalRequest { + sessionId: string + name?: string + cols: number + rows: number + cwd?: string + enableStreamForward?: boolean + programPath?: string +} +``` + +```ts +export type TerminalOperation = + | 'create' + | 'input' + | 'resize' + | 'close' + +export interface TerminalErrorEvent { + sessionId: string + operation: TerminalOperation + error: string +} +``` + +**Steps** + +- [ ] Create `client/src/utils/terminalFactory.ts` and move the existing theme object into it without changing any color. + +- [ ] Configure the factory with: + - `convertEol: true` + - `disableStdin: false` + - `cursorBlink: true` + - `cursorStyle: 'block'` + - `lineHeight: 1.2` + - Desktop `fontSize: 14`, `scrollback: 1000` + - Mobile `fontSize: 12`, `scrollback: 500` + - Existing font family, tab width, and transparency settings + +- [ ] Load one `FitAddon` and one `WebLinksAddon` inside the factory. Return only `{ terminal, fitAddon }`; do not accept or calculate `cols/rows`. + +- [ ] Update `SocketClient.createTerminal()` so `cols` and `rows` are required: + + ```ts + createTerminal(data: CreateTerminalRequest): void { + this.emit('create-pty', data) + } + ``` + +- [ ] Add a named `reconnectTerminal(sessionId: string): void` wrapper for `reconnect-session`, while preserving the existing generic `emit()` API for unrelated events. + +- [ ] Replace nonexistent event definitions in `SocketEvents`. Include the real payloads: + + ```ts + 'pty-created': (data: { + sessionId: string + workingDirectory: string + }) => void + + 'pty-closed': (data: { sessionId: string }) => void + + 'terminal-output': (data: { + sessionId: string + data: string + isHistorical?: boolean + }) => void + + 'terminal-resized': (data: { + sessionId: string + cols: number + rows: number + }) => void + + 'terminal-error': (data: TerminalErrorEvent) => void + + 'terminal-exit': (data: { + sessionId: string + code: number | null + signal: string | null + }) => void + + 'session-reconnected': (data: { sessionId: string }) => void + 'session-reconnect-failed': (data: { sessionId: string }) => void + 'connection-status': (data: { + connected: boolean + reason?: string + }) => void + ``` + +- [ ] Remove `terminal-created` and `terminal-closed` from the type interface. Do not introduce aliases. + +- [ ] Run the client type check: + + ```bash + cd /root/github_projects/GameServerManager/client + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 9 diff. Obtain fresh confirmation for this specific commit before running it; earlier approval is not valid. After confirmation run: + + ```bash + git add \ + client/src/utils/terminalFactory.ts \ + client/src/utils/socket.ts \ + client/src/types/index.ts + git commit -m "refactor: centralize terminal view creation" + ``` + + Expected result: one commit containing the factory and corrected event types. + +--- + +### Task 10: Move terminal runtimes into refs and install the single observer/reporter path + +**Files** + +- Modify: `client/src/pages/TerminalPage.tsx` +- Temporary: `client/src/pages/TerminalPage.resize.tmp.test.ts` + +**Interfaces** + +```ts +interface TerminalTabMeta { + id: string + name: string +} + +type TerminalState = + | 'creating' + | 'ready' + | 'disconnected' + | 'reconnecting' + | 'closing' + | 'exited' + | 'disposed' + +interface TerminalSize { + cols: number + rows: number +} + +interface TerminalRuntime { + terminal: Terminal + fitAddon: FitAddon + state: TerminalState + createSize?: TerminalSize + pendingSize?: TerminalSize + lastWrittenSize?: TerminalSize + lastReportedSize?: TerminalSize + resizeTimer?: ReturnType + closeRequestInFlight: boolean + disposables: IDisposable[] +} +``` + +Required refs: + +```ts +const runtimesRef = useRef(new Map()) +const activeSessionIdRef = useRef(null) +const terminalContainerRef = useRef(null) +const observerRef = useRef(null) +const fitFrameRef = useRef(null) +``` + +**Steps** + +- [ ] Create `client/src/pages/TerminalPage.resize.tmp.test.ts` as a temporary source diagnostic. Assert that the final source has no `fontSize * 0.6`, no `calculateTerminalSize`, no direct `terminal.resize()`, exactly two `ref={setTerminalContainer}` uses, one `ResizeObserver` construction, and exactly one `socketClient.resizeTerminal()` call in `TerminalPage.tsx`. + +- [ ] Run the diagnostic before refactoring: + + ```bash + cd /root/github_projects/GameServerManager/client + npx vitest --run src/pages/TerminalPage.resize.tmp.test.ts + ``` + + Expected result: non-zero exit identifying the current manual estimator, two object refs, direct resizes, and multiple reporters. + +- [ ] Replace `TerminalSession[]` React state with `TerminalTabMeta[]`. Keep only tab metadata and `activeSessionId` in terminal-related React state; move `Terminal`, `FitAddon`, timers, and disposables into `runtimesRef`. + +- [ ] Add a runtime creation helper that calls `createTerminalView({ isMobile })`, initializes `closeRequestInFlight: false`, registers one `onData` and one `onResize`, and stores both returned disposables. + +- [ ] Gate `onData` so it forwards only when the runtime is the active session and `state === 'ready'`. Drop input in all other states without buffering. + +- [ ] Implement a client size validator matching the server bounds exactly: + + ```ts + function isValidTerminalSize( + size: TerminalSize | null | undefined + ): size is TerminalSize { + return Boolean( + size && + Number.isSafeInteger(size.cols) && + Number.isSafeInteger(size.rows) && + size.cols >= 2 && + size.cols <= 1000 && + size.rows >= 1 && + size.rows <= 1000 + ) + } + ``` + +- [ ] Implement `ensureObserver()` so it lazily creates the component’s only `ResizeObserver`. Its callback must only call `scheduleFit()` and must read the current runtime, active ID, and container through refs. + +- [ ] Implement `setTerminalContainer(node)` in this fixed order: + 1. Unobserve the old node. + 2. Store the new node. + 3. Return after clearing when `node === null`. + 4. Call `ensureObserver()`. + 5. Open or move the active Xterm element into the new node. + 6. Observe the node. + 7. Call `scheduleFit()`. + +- [ ] Replace both normal and fullscreen container refs with: + + ```tsx + ref={setTerminalContainer} + ``` + +- [ ] Implement `scheduleFit()` so it cancels the prior rAF and schedules one new frame. The frame handles only the active runtime and requires non-zero container width and height. + +- [ ] In the rAF callback, call `fitAddon.proposeDimensions()` first, validate the proposal, call `fitAddon.fit()`, then validate `terminal.cols/terminal.rows`. On any invalid or unavailable value, perform no create and no resize; wait for the next observer or attachment trigger. + +- [ ] Remove `calculateTerminalSize()`, fallback dimensions, all `fontSize * 0.6` calculations, and every direct `terminal.resize()` call. + +- [ ] Change normal creation to create a tab/runtime in `creating`, activate it, attach it, and defer `create-pty` until a valid post-fit size exists. Set `createSize` before emitting so repeated observer callbacks cannot create twice. + +- [ ] For attached and restored server sessions, create runtimes in `disconnected`. Do not invent create dimensions or emit `create-pty`; attachment and reconnect behavior will use the existing server target. + +- [ ] Implement the only reporter. `onResize` stores a valid `pendingSize` and schedules a 50ms trailing timer only when the runtime is active, ready, connected, and differs from `lastWrittenSize`. + +- [ ] In the timer callback, re-read all state from refs, send only the newest valid pending size, update `lastWrittenSize`, and clear pending. The single outbound call must be: + + ```ts + socketClient.resizeTerminal(sessionId, size.cols, size.rows) + ``` + +- [ ] Add `seedResize(sessionId: string)` for transitions to ready and active-session switching. It must resolve the runtime from `runtimesRef`, place the current validated terminal size into `pendingSize`, and call the same reporter flush path; it must not introduce a second direct Socket emit path. + +- [ ] Make active-session switching update metadata, move the selected Xterm element, call `scheduleFit()`, and seed only after a valid fit when the selected runtime is ready. Non-active runtimes must not fit or resize. + +- [ ] Remove resize-related `setTimeout()` blocks from create, switch, fullscreen, mount, reconnect, and window-resize flows. Keep unrelated modal animation and focus timers only where still required. + +- [ ] Ensure `terminal-resized` handling only stores `lastReportedSize`; it must not call fit, resize, seed, or reporter code. + +- [ ] Run the resize diagnostic: + + ```bash + cd /root/github_projects/GameServerManager/client + npx vitest --run src/pages/TerminalPage.resize.tmp.test.ts + ``` + + Expected result: exit code `0`. + +- [ ] Delete `client/src/pages/TerminalPage.resize.tmp.test.ts`. + +- [ ] Run the client type check: + + ```bash + cd /root/github_projects/GameServerManager/client + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 10 diff. Obtain fresh user confirmation specifically for this commit before committing; prior approval does not apply. After confirmation run: + + ```bash + git add client/src/pages/TerminalPage.tsx + git commit -m "refactor: observe and fit the active terminal" + ``` + + Expected result: one commit without the temporary resize diagnostic. + +--- + +### Task 11: Complete the frontend terminal state machine, reconnect, close retry, and cleanup + +**Files** + +- Modify: `client/src/pages/TerminalPage.tsx` +- Modify: `client/src/types/index.ts` if payload refinements are required +- Temporary: `client/src/pages/TerminalPage.state.tmp.test.ts` + +**Interfaces** + +```ts +function requestCloseIfIdle(sessionId: string): void +function clearPendingResize(runtime: TerminalRuntime): void +function disposeRuntime(sessionId: string): void +function seedResize(sessionId: string): void +``` + +**Steps** + +- [ ] Create `client/src/pages/TerminalPage.state.tmp.test.ts` as a temporary source-contract diagnostic. Require the real event names, `closeRequestInFlight`, `requestCloseIfIdle`, `connection-status`, `operation === 'close'`, and explicit disposal order. Reject `terminal-created` and `terminal-closed`. + +- [ ] Run the diagnostic before state-machine completion: + + ```bash + cd /root/github_projects/GameServerManager/client + npx vitest --run src/pages/TerminalPage.state.tmp.test.ts + ``` + + Expected result: non-zero exit until the legacy listeners and immediate close behavior are removed. + +- [ ] Implement `clearPendingResize()` to clear the timer and pending size. Call it on disconnect, close start, exit, resize/input failure, and disposal. + +- [ ] Implement `requestCloseIfIdle()` so it returns unless the runtime exists, Socket.IO is connected, and `closeRequestInFlight === false`. Set the flag to `true` before emitting `close-pty`. + +- [ ] Handle `pty-created`: + - `creating -> ready`, then active fit and seed. + - `closing -> closing`, with no fit; call `requestCloseIfIdle()`. + - Ignore the event in all other states. + - Move the success notification here instead of showing it immediately after the create request. + +- [ ] Handle transport disconnect from `connection-status`: + - Preserve `closing`. + - Move other live states to `disconnected`. + - Clear `closeRequestInFlight`, timer, pending size, `lastWrittenSize`, and `lastReportedSize`. + - Do not queue input, resize, or close requests while disconnected. + +- [ ] Handle transport connect: + - `disconnected -> reconnecting`. + - Preserve `closing`. + - For both cases, emit only `reconnect-session`. + - Do not emit resize or close directly from the connect callback. + +- [ ] Handle `session-reconnected`: + - `reconnecting -> ready`, then attach, fit, and seed if active. + - `closing -> closing`, do not fit or seed, then call `requestCloseIfIdle()`. + - Do not infer readiness for disposed or exited runtimes. + +- [ ] Handle `session-reconnect-failed`: + - `reconnecting -> exited`. + - `closing -> disposed` after clearing the close flag. + - Show one stable panel notification for the reconnect failure. + +- [ ] Replace immediate `closeTerminalSession()` disposal with state-based behavior: + - `creating | ready -> closing`, clear resize work, then request close. + - `disconnected | reconnecting -> closing`, clear resize work and wait for reconnect outcome. + - `closing` calls `requestCloseIfIdle()` only when the flag is false. + - `exited` disposes immediately. + - `disposed` does nothing. + +- [ ] Handle `terminal-error` by `operation`: + - `create`: `creating -> exited`. + - `input`: `ready -> exited`, clear resize work. + - `resize`: `ready -> exited`, clear resize work. + - `close`: clear `closeRequestInFlight`, preserve `closing`, and wait for an explicit user retry. + - Never start an automatic close retry timer. + +- [ ] Use stable notification text for repeated resize failures, for example: + + ```text + 终端尺寸同步失败,请关闭该会话后重新创建。 + ``` + + Send it through `useNotificationStore`; log detailed raw errors only to the console. + +- [ ] Handle `terminal-exit` idempotently. Any non-disposed, non-closing active state becomes `exited`; do not send close or resize automatically. + +- [ ] Handle `pty-closed` by clearing `closeRequestInFlight`; only a `closing` runtime transitions to `disposed` and is then removed. Move the close notification here instead of displaying it at button click time. + +- [ ] Implement `disposeRuntime()` in this exact order: + 1. Set `state = 'disposed'`. + 2. Clear resize timer and pending size. + 3. Dispose every `IDisposable`. + 4. Call `terminal.dispose()`. + 5. Delete the runtime from `runtimesRef`. + 6. Remove its tab metadata. + 7. Select another tab or set the active ID to `null`. + +- [ ] Ensure a new terminal always receives a new session ID. Do not recreate with an ID that is still creating, ready, closing, retained, or waiting for `pty-closed`. + +- [ ] Delete listeners for `terminal-created` and `terminal-closed`. Register and remove only: + - `pty-created` + - `pty-closed` + - `terminal-output` + - `terminal-resized` + - `terminal-error` + - `terminal-exit` + - `session-reconnected` + - `session-reconnect-failed` + - `connection-status` + +- [ ] On component unmount, cancel the current rAF, disconnect the observer, remove Socket/fullscreen/window listeners, and dispose every runtime through the same disposal helper. + +- [ ] Run the state diagnostic: + + ```bash + cd /root/github_projects/GameServerManager/client + npx vitest --run src/pages/TerminalPage.state.tmp.test.ts + ``` + + Expected result: exit code `0`. + +- [ ] Delete `client/src/pages/TerminalPage.state.tmp.test.ts`. + +- [ ] Run focused static checks: + + ```bash + cd /root/github_projects/GameServerManager + + if grep -nE 'terminal-created|terminal-closed|calculateTerminalSize|fontSize\s*\*\s*0\.6' \ + client/src/pages/TerminalPage.tsx; then + exit 1 + fi + + test "$( + grep -c 'socketClient\.resizeTerminal(' \ + client/src/pages/TerminalPage.tsx + )" -eq 1 + ``` + + Expected result: no forbidden output and exit code `0`. + +- [ ] Run the client type check: + + ```bash + cd /root/github_projects/GameServerManager/client + npx tsc --noEmit + ``` + + Expected result: exit code `0`. + +- [ ] Review the Task 11 diff. Obtain fresh confirmation for this exact commit before committing; no earlier confirmation applies. After confirmation run: + + ```bash + git add \ + client/src/pages/TerminalPage.tsx \ + client/src/types/index.ts + git commit -m "feat: enforce terminal client lifecycle states" + ``` + + Expected result: one commit without the temporary state diagnostic. + +--- + +### Task 12: Perform complete static, race, distribution, and native-platform verification + +**Files** + +- Verify all implementation files listed above. +- Do not leave any temporary source or test files. +- Do not commit native binaries or runtime FIFO contents. + +**Interfaces** + +Final contracts to verify: + +```ts +type CloseResult = 'closed' | 'not-found' | 'still-running' + +type TerminalState = + | 'creating' + | 'ready' + | 'disconnected' + | 'reconnecting' + | 'closing' + | 'exited' + | 'disposed' +``` + +```text +RESIZE frame: +04 | uint16be(JSON byte length) | {"width":cols,"height":rows} +``` + +**Steps** + +- [ ] Run both required TypeScript checks: + + ```bash + cd /root/github_projects/GameServerManager/client + npx tsc --noEmit + + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: both commands exit `0`. + +- [ ] Check script syntax: + + ```bash + cd /root/github_projects/GameServerManager + node --check scripts/package.js + bash -n install-gsm3.sh + ``` + + Expected result: both commands exit `0`. + +- [ ] Print and review the canonical manifest without downloading: + + ```bash + cd /root/github_projects/GameServerManager/server + npx tsx -e " + import { + PTY_RELEASE_ID, + PTY_BUILD_COMMIT, + PTY_ASSETS + } from './src/utils/ptyAssets.ts'; + console.log(JSON.stringify({ + releaseId: PTY_RELEASE_ID, + buildCommit: PTY_BUILD_COMMIT, + assets: PTY_ASSETS + }, null, 2)); + " + ``` + + Expected result: release ID `297277624`, build commit `09fc369dfa278504831260de2771d7cbd98d01c4`, and the three exact asset tuples from Task 1. + +- [ ] Confirm all four PTY entry points are free of mutable PTY release URLs: + + ```bash + cd /root/github_projects/GameServerManager + if grep -n 'MCSManager/PTY.*latest' \ + server/src/utils/ptyManager.ts \ + scripts/package.js \ + Dockerfile \ + install-gsm3.sh; then + exit 1 + fi + ``` + + Expected result: no output and exit code `0`. + +- [ ] Confirm no temporary diagnostics remain: + + ```bash + cd /root/github_projects/GameServerManager + test ! -e server/src/__tests__/pty-assets.tmp.test.ts + test ! -e scripts/.tmp-verify-pty-distribution.cjs + test ! -e server/src/__tests__/pty-control-channel.tmp.test.ts + test ! -e server/src/__tests__/pty-control-platform.tmp.test.ts + test ! -e server/src/__tests__/terminal-create-attempt.tmp.test.ts + test ! -e server/src/__tests__/terminal-resize.tmp.test.ts + test ! -e server/src/__tests__/terminal-close.tmp.test.ts + test ! -e server/src/__tests__/terminal-call-sites.tmp.test.ts + test ! -e client/src/pages/TerminalPage.resize.tmp.test.ts + test ! -e client/src/pages/TerminalPage.state.tmp.test.ts + ``` + + Expected result: exit code `0`. + +- [ ] Search changed implementation files for unresolved placeholders: + + ```bash + cd /root/github_projects/GameServerManager + if grep -nE 'T[B]D|T[O]DO|F[I]XME|待[定]' \ + server/src/utils/ptyAssets.ts \ + server/src/utils/ptyAssetCli.ts \ + server/src/utils/ptyManager.ts \ + server/src/utils/ptyControlChannel.ts \ + server/src/modules/terminal/TerminalManager.ts \ + server/src/modules/instance/InstanceManager.ts \ + server/src/index.ts \ + scripts/package.js \ + Dockerfile \ + install-gsm3.sh \ + client/src/utils/terminalFactory.ts \ + client/src/utils/socket.ts \ + client/src/types/index.ts \ + client/src/pages/TerminalPage.tsx; then + exit 1 + fi + ``` + + Expected result: no output and exit code `0`. + +- [ ] Inspect all server call sites: + + ```bash + cd /root/github_projects/GameServerManager + grep -R -nE '\b(closePty|resizeTerminal|createPty|reconnectSession)\s*\(' \ + server/src \ + --include='*.ts' + ``` + + Expected result: every Promise is awaited, returned, collected in `Promise.allSettled`, or explicitly consumed with an error handler. + +- [ ] Confirm the frontend has one reporter and the two layout nodes share the callback ref: + + ```bash + cd /root/github_projects/GameServerManager + test "$( + grep -c 'socketClient\.resizeTerminal(' \ + client/src/pages/TerminalPage.tsx + )" -eq 1 + + test "$( + grep -c 'ref={setTerminalContainer}' \ + client/src/pages/TerminalPage.tsx + )" -eq 2 + ``` + + Expected result: exit code `0`. + +- [ ] Recheck the protocol manually with a short one-off command or temporary diagnostic, then remove it immediately. For `120x40`, confirm JSON is `{"width":120,"height":40}` and the first bytes are `04 00 19`. + +- [ ] Exercise rapid queue behavior with the same fake blocked writer used during Task 3: enqueue every 40ms for one second, release the first write, and confirm only the current in-flight plus final pending size are written. Remove the diagnostic after it passes. + +- [ ] Exercise duplicate create and retained close fault injection with fake children: two concurrent same-ID creates must spawn once; a process that ignores SIGTERM and SIGKILL must remain in its map; reconnect must bind the new socket; a later close event must finalize once. Remove the diagnostic after it passes. + +- [ ] Temporarily log the active reporter’s final `{ sessionId, cols, rows }` during native browser acceptance. Remove the log and rerun the client type check before delivery. + +- [ ] On native Linux x64, verify the asset: + + ```bash + stat -c '%s' data/lib/pty_linux_x64 + sha256sum data/lib/pty_linux_x64 + ./data/lib/pty_linux_x64 -h 2>&1 | grep -- '-fifo' + ``` + + Expected values: + - Size: `2654360` + - SHA-256: `bbdfc8a5d0f57493e78c64bca56d370524c068c1d4d31cac653458a843d47f72` + - Probe output contains `-fifo` + +- [ ] On native Linux ARM64, verify: + + ```bash + stat -c '%s' data/lib/pty_linux_arm64 + sha256sum data/lib/pty_linux_arm64 + ./data/lib/pty_linux_arm64 -h 2>&1 | grep -- '-fifo' + ``` + + Expected values: + - Size: `2752664` + - SHA-256: `48d8496997053b60eb84d2b02f4ec751298c7f214c615b08aca43309739ebf83` + - Probe output contains `-fifo` + +- [ ] On native Windows x64, verify in PowerShell: + + ```powershell + (Get-Item .\data\lib\pty_win32_x64.exe).Length + (Get-FileHash .\data\lib\pty_win32_x64.exe -Algorithm SHA256).Hash.ToLower() + & .\data\lib\pty_win32_x64.exe -h 2>&1 | + Select-String -- '-fifo' + ``` + + Expected values: + - Size: `3627520` + - SHA-256: `fe35c154e623707d0dd2b728f41fd200bd3ead0a8cda8eb216b1e5e3e3ab2d40` + - Probe output contains `-fifo` + +- [ ] On each available native platform, create a terminal and run: + + ```bash + stty size + tput cols + tput lines + python3 -c 'print("0123456789" * 30)' + ``` + + On Windows PowerShell, also run: + + ```powershell + [Console]::WindowWidth + [Console]::WindowHeight + ``` + + Expected result: after layout movement stops, reported native dimensions match the final active reporter values within 500ms. + +- [ ] Toggle the sidebar, enter and leave fullscreen, drag the window continuously for one second, and switch between two tabs. Confirm only the active ready tab sends resize, the final dimensions win, and no `terminal-resized -> fit -> terminal-resize` loop appears. + +- [ ] Run `vim`, `top`, and, when installed, `htop`. Confirm they redraw correctly after every layout change, ASCII wrapping follows the final column boundary, and the cursor has no logical offset. + +- [ ] Disconnect the network transport with a ready session, reconnect it, and confirm the client sends `reconnect-session` before any resize. Confirm `session-reconnected` returns the active runtime to ready and seeds one final resize. + +- [ ] Start closing a session, disconnect, reconnect, and verify that the runtime remains `closing`; `session-reconnected` triggers only one guarded close request, with no fit or resize. + +- [ ] Inject a close timeout and verify the close error clears `closeRequestInFlight` while preserving `closing`. Click close once more and confirm exactly one new server close attempt is made. + +- [ ] Send two simultaneous create requests with the same ID. Confirm one create error, no `closePty` invocation, no `pty-closed`, and no mutation of the existing process, endpoint, or socket. + +- [ ] Trigger graceful shutdown with one manager cleanup forced to reject. Confirm subsequent managers still run in order, sockets are destroyed in `finally`, Socket.IO and HTTP close are attempted, and the normal path completes before the 15-second forced-exit timer. + +- [ ] If any native platform is unavailable, record it explicitly as `NOT RUN` with the missing host, container, or architecture as the reason. Do not report that platform as passing based on cross-architecture hash verification alone. + +- [ ] Remove the temporary browser reporter log or any final fault-injection code, then rerun: + + ```bash + cd /root/github_projects/GameServerManager/client + npx tsc --noEmit + + cd /root/github_projects/GameServerManager/server + npx tsc --noEmit + ``` + + Expected result: both exit `0`. + +- [ ] Inspect `git status --short`. Expected result: only intended implementation and documentation changes are present; there are no downloaded binaries, FIFO files, temporary tests, diagnostic scripts, or generated build outputs. + +## Spec Coverage and Final Self-Review + +- [ ] **Fixed assets:** Release ID is `297277624`; build commit is `09fc369dfa278504831260de2771d7cbd98d01c4`; Linux x64, Linux ARM64, and Windows x64 asset IDs, names, sizes, and hashes exactly match the approved specification. + +- [ ] **Download contract:** Runtime, package, Docker, and install paths all perform release JSON lookup followed by asset API download, use the fixed headers, follow at most five redirects, strip cross-host Authorization, enforce exact size/hash, use same-directory temporary files, and atomically rename only after validation. + +- [ ] **Probe contract:** A local binary with the wrong name, size, or hash is never probed. A native valid asset must pass ` -h` within three seconds, stay below 64 KiB output, and contain `-fifo`. + +- [ ] **Distribution check:** `server/src/utils/ptyManager.ts`, `scripts/package.js`, `Dockerfile`, and `install-gsm3.sh` contain no PTY `releases/download/latest` path. + +- [ ] **Protocol:** `validatePtySize()` enforces safe integers and `cols 2..1000`, `rows 1..1000`. `120x40` encodes as JSON `{"width":120,"height":40}` with frame prefix `04 00 19`. + +- [ ] **Queue:** There is at most one in-flight frame and one latest pending resize. Superseded, duplicate, close-pending, and close-caused write failures resolve as `skipped`. `close()` resolves only after all resize promises settle. + +- [ ] **Endpoints:** POSIX directories are `0700`, FIFOs are confirmed non-symlink FIFOs and chmodded `0600`, Windows uses random Named Pipe names, session IDs do not enter endpoint paths, and complete endpoints do not enter browser payloads or ordinary logs. + +- [ ] **Create lifecycle:** `sessions` and `createAttempts` are checked and reserved synchronously without an intervening await. Duplicate IDs emit only a create error, never call `closePty`, and never emit `pty-closed`. + +- [ ] **Fallback:** Fallback requires all three approved conditions, uses the 1000ms primary window, preserves the original `createSize`, creates a new endpoint, observes cancellation, and produces only one final create result. + +- [ ] **Ready semantics:** A target enters `sessions` and emits `pty-created` only after the native control channel is ready. + +- [ ] **Real resize:** No `SIGWINCH` fallback remains. `terminal-resized` means only that the complete frame was written to the OS pipe; it does not claim an upstream ACK. + +- [ ] **Resize identity:** A delayed write acknowledgement is suppressed unless the same session object remains ready with the same control channel. + +- [ ] **CloseResult consistency:** `CloseResult` is exactly `'closed' | 'not-found' | 'still-running'` everywhere. It is used only by genuine close operations, not duplicate create handling. + +- [ ] **Single-flight close:** Concurrent closes share one promise, one signal sequence, one result, and one final event. SIGTERM waits three seconds and SIGKILL waits one second. + +- [ ] **Retained targets:** A timed-out create attempt becomes `close-retained` and preserves process, channel, endpoint, socket, cancellation token, and the reusable `closePromise` slot. A timed-out session remains represented as closing. + +- [ ] **Reconnect:** Closing sessions and `close-retained` attempts update their socket and return `session-reconnected`. `session-reconnect-failed` is emitted only after stable confirmation that both maps lack the ID. + +- [ ] **Cleanup:** `TerminalManager.cleanup()` disables new work and performs one parallel `Promise.allSettled([...attemptTasks, ...sessionTasks])`. It does not clear unconfirmed references. + +- [ ] **Shutdown:** The forced deadline is 15 seconds; managers are processed sequentially through isolated `settle()` calls; socket destruction and Socket.IO/HTTP closure always run in `finally`. + +- [ ] **Call-site handling:** Every `createPty`, `resizeTerminal`, `closePty`, and `reconnectSession` call is awaited, returned, collected, or explicitly consumed. No Promise is silently dropped. + +- [ ] **Frontend factory:** All three Xterm initialization paths use `createTerminalView()`, which returns only `{ terminal, fitAddon }`, preserves the existing theme, and does not accept or invent dimensions. + +- [ ] **Frontend ownership:** Terminal tab metadata and active ID are the only terminal-session data in React state. Xterm instances, FitAddon instances, timers, state, dimensions, flags, and disposables live in `runtimesRef`. + +- [ ] **TerminalState consistency:** The frontend state union is exactly `creating | ready | disconnected | reconnecting | closing | exited | disposed`, and every event transition matches the approved event table. + +- [ ] **Measurement:** Both terminal containers share one callback ref; one observer drives one rAF scheduler; create and resize occur only after non-zero container dimensions, valid `proposeDimensions()`, `fit()`, and validated final Xterm dimensions. + +- [ ] **Reporter:** There is one `terminal.onResize` per runtime and one `socketClient.resizeTerminal()` call site in `TerminalPage.tsx`. Only the active ready runtime can emit. + +- [ ] **Acknowledgement behavior:** `terminal-resized` only updates `lastReportedSize`; it never triggers fit, direct resize, or another report. + +- [ ] **Close retry:** `closeRequestInFlight` starts false, is set before emitting, and is cleared by close error, disconnect, and `pty-closed`. There is no timer-driven or error-driven infinite retry. + +- [ ] **Event payloads:** `pty-created`, `pty-closed`, `terminal-output`, `terminal-resized`, `terminal-error`, `terminal-exit`, `session-reconnected`, and `session-reconnect-failed` payload types match between server emissions, client types, and handlers. No `terminal-created` or `terminal-closed` alias remains. + +- [ ] **Notifications:** Errors and lifecycle results use the existing notification store. Resize errors use stable text for deduplication. No browser `alert`, `confirm`, or `prompt` is introduced. + +- [ ] **Placeholder scan:** The unresolved-placeholder scan in Task 12 returns zero matches across all changed implementation files. + +- [ ] **Type checks:** `cd client && npx tsc --noEmit` and `cd server && npx tsc --noEmit` both exit with code `0`. + +- [ ] **Temporary artifacts:** Every temporary test and diagnostic file listed in this plan has been deleted after passing. No runtime FIFO or downloaded binary is committed. + +- [ ] **Native verification honesty:** Linux x64, Linux ARM64, and Windows x64 results are reported individually. Any platform not exercised natively is recorded as `NOT RUN` with the reason and is not claimed as passing. + +- [ ] **Remaining risks:** The implementation report explicitly states that owner/actor authorization remains out of scope, upstream provides no positive resize ACK, and crash-left FIFO scanning remains out of scope. diff --git a/docs/superpowers/specs/2026-08-02-terminal-resize-design.md b/docs/superpowers/specs/2026-08-02-terminal-resize-design.md new file mode 100644 index 00000000..38ccb4cd --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-terminal-resize-design.md @@ -0,0 +1,580 @@ +# 终端真实 PTY Resize 设计 + +## 1. 目标与背景 + +GameServerManager 当前使用 `@xterm/xterm` 5.5.0 和 MCSManager/PTY。已确认 A 类错位的表现为:调整窗口、侧边栏、普通/全屏布局后,纯 ASCII 自动换行位置和光标逻辑位置错误,`vim`、`top`、`htop` 仍按旧尺寸绘制。 + +根因分为两部分: + +1. `client/src/pages/TerminalPage.tsx` 中 Xterm 的 `cols/rows` 会随 fit 改变,但存在手工字符尺寸估算、无容器时 `100x30` 回退、多个初始化和 resize 路径、仅监听 `window.resize`、回执再次 fit 等问题。 +2. `server/src/modules/terminal/TerminalManager.ts` 的 `resizeTerminal` 只向 PTY 包装进程发送 `SIGWINCH`。该信号不携带新尺寸,未修改真实 PTY winsize;服务端随后仍发送 `terminal-resized`,造成虚假成功语义。 + +本设计保留 Xterm 5.5.0,通过 FitAddon、ResizeObserver 和 MCSManager/PTY `-fifo` 控制协议,使浏览器尺寸、服务端记录和真实 PTY winsize 最终一致。 + +### 1.1 成功标准 +- `stty size`、`tput cols`、`tput lines` 在容器停止变化后 500ms 内等于活动 Xterm 的最终 `rows/cols`。 +- 纯 ASCII 长行、光标、`vim`、`top`、`htop` 在侧边栏、全屏和窗口拖动后正确重绘。 +- 仅活动且处于 `ready` 的终端发送 input/resize。 +- resize 只有一个前端 reporter 和一个服务端串行控制队列。 +- `terminal-resized` 只表示控制帧写入成功,不表示上游已 ACK `SetSize`。 +- 旧 PTY 不支持 `-fifo` 时明确失败,不回退到 `SIGWINCH`。 +- Client 和 Server 分别通过 `npx tsc --noEmit`。 + +## 2. 范围与已知风险 + +### 2.1 本阶段范围 +- 保留 Xterm 5.5.0,不升级、不替换终端库。 +- 统一普通终端、实例附加终端、恢复会话的 Xterm 初始化。 +- 容器挂载并得到合法 FitAddon 尺寸后才创建 PTY。 +- 使用单一 ResizeObserver、callback ref、rAF fit 和单一 resize reporter。 +- 固定前端状态机和资源清理规则。 +- 新增每会话 PTY 控制端点,按 RESIZE 类型 4 写入真实尺寸。 +- 固定现有 Socket.IO 事件名和错误来源。 +- 增加原生 PTY 能力探测和旧二进制受控替换。 +- 覆盖 Linux x64、Linux ARM64、Windows x64 原生运行。 + +### 2.2 明确非目标 +- 不实现 hterm、ghostty-web 或其他 renderer。 +- 不升级 Xterm、FitAddon、WebLinksAddon。 +- 不做无关页面、WebSocket、实例管理或安全重构。 +- 不新增 HTTP resize 接口。 +- 不使用浏览器 `alert`、`confirm`、`prompt`。 +- 不自动扫描和删除崩溃遗留 FIFO。 + +### 2.3 已知既有安全风险 + +当前 `PtySession` 不保存 owner/actor,Socket.IO 只有连接级 token 认证。任意已认证连接如果获得其他会话的 sessionId,现有代码可能允许其 input、resize、close 或 reconnect 该会话。 + +这是既有会话授权风险,本阶段不引入 owner/actor,也不扩大到所有终端操作的授权改造。本文不得得出“终端控制面已经安全”的结论。该问题必须作为后续独立安全任务处理。 + +## 3. 现有事件兼容合同 + +请求事件保持不变: +- `create-pty` +- `terminal-input` +- `terminal-resize` +- `close-pty` +- `reconnect-session` + +服务端事件固定为: +- `pty-created` +- `pty-closed` +- `terminal-output` +- `terminal-resized` +- `terminal-error` +- `terminal-exit` +- `session-reconnected` +- `session-reconnect-failed` + +前端删除不存在的 `terminal-created`、`terminal-closed` 监听,补齐真实事件类型。不新增任何同义事件。 + +### 3.1 唯一失败事件表 + +| 操作/结果 | 服务端事件 | 前端状态处理 | +| --- | --- | --- | +| create 失败 | `terminal-error`,`operation: 'create'` | `creating -> exited` | +| input 失败 | `terminal-error`,`operation: 'input'` | `ready -> exited`,失败输入不重放 | +| resize 失败 | `terminal-error`,`operation: 'resize'` | `ready -> exited`,清 pending/timer;后续 `terminal-exit` 幂等 | +| SIGKILL 后 1 秒仍未确认 close/exit | 一次 `terminal-error`,`operation: 'close'` | 保持 `closing`,清 `closeRequestInFlight`,等待用户重试 | +| reconnect 失败 | 仅 `session-reconnect-failed` | `reconnecting -> exited`;`closing -> disposed` | +| 非 closing 的活动进程主动/异常退出 | 仅 `terminal-exit` | 非 disposed 状态进入 `exited` | +| closing target 已确认进程 close/exit | 仅 `pty-closed` | `closing -> disposed` | +| 请求开始前 create attempt/session 已不存在 | 仅 `pty-closed` | `closing -> disposed` | +| resize 写入成功 | `terminal-resized` | 记录确认尺寸,不 fit、不回发 | + +`CloseResult` 只属于真正的 close 调用:`closed`/`not-found` 发 `pty-closed`,`still-running` 发 close error。重复 sessionId 的 create 只发一次 create error,绝不调用 `closePty`、不发 `pty-closed`。reconnect 仅在 sessions/createAttempts 均无该 ID 时失败;辅助清理 warning 不改变 CloseResult。 + +## 4. 前端设计 + +### 4.1 数据所有权 + +React state 只保存: +```ts +interface TerminalTabMeta { + id: string + name: string +} + +const [terminalTabs, setTerminalTabs] = useState([]) +const [activeSessionId, setActiveSessionId] = useState(null) +``` + +Xterm 和频繁变化状态只保存在 ref: +```ts +type TerminalState = + | 'creating' + | 'ready' + | 'disconnected' + | 'reconnecting' + | 'closing' + | 'exited' + | 'disposed' + +interface TerminalSize { + cols: number + rows: number +} + +interface TerminalRuntime { + terminal: Terminal + fitAddon: FitAddon + state: TerminalState + createSize?: TerminalSize + pendingSize?: TerminalSize + lastWrittenSize?: TerminalSize + lastReportedSize?: TerminalSize + resizeTimer?: ReturnType + closeRequestInFlight: boolean + disposables: IDisposable[] +} + +const runtimesRef = useRef(new Map()) +``` + +不把 `Terminal`、`FitAddon`、timer 或 disposable 放入 React state。长期回调通过 `runtimesRef`、`activeSessionIdRef` 和 container ref 读取最新状态,消除 stale closure。 + +### 4.2 统一 factory + +新增 `client/src/utils/terminalFactory.ts`: +```ts +function createTerminalView(options: TerminalViewOptions): { + terminal: Terminal + fitAddon: FitAddon +} +``` + +factory 只负责: +- 创建 Xterm,并加载 FitAddon 和 WebLinksAddon; +- 固定 `convertEol: true`、`disableStdin: false`、`cursorBlink: true`、`lineHeight: 1.2`; +- 固定桌面 `fontSize: 14`、`scrollback: 1000`,移动端 `fontSize: 12`、`scrollback: 500`; +- 将 `TerminalPage.tsx` 现有 theme 对象原样迁入 factory,不改变颜色。 + +三种初始化路径全部调用 factory。factory 不接收 `cols/rows`,不计算 `fontSize * 0.6`,不提供 `100x30` 或 Xterm 默认尺寸给后端。 + +### 4.3 callback ref 与单一 observer + +普通布局和全屏布局的终端 `
` 使用同一个 callback ref: +```ts +const setTerminalContainer = useCallback((node: HTMLDivElement | null) => { + // unobserve old -> set current -> ensureObserver -> attach active + // -> observe new -> scheduleFit +}, []) +``` + +`ensureObserver()` 按需创建并返回全组件唯一的 `ResizeObserver`。callback ref 可能早于 effect 执行,因此收到非空节点后必须在 `observe` 前调用 `ensureObserver()`,不得假设 observer 已由 effect 创建。新节点处理顺序固定为: + +1. 通过 `observerRef.current` unobserve 旧节点。 +2. 保存新节点。 +3. 调用 `ensureObserver()`。 +4. 将活动 Xterm element 打开或移动到新节点。 +5. observe 新节点并调用 `scheduleFit()`。 + +callback ref 收到 `null` 时只 unobserve 旧节点并清空引用;卸载时 `observerRef.current?.disconnect()`。callback ref、ResizeObserver、rAF 和 Socket 回调只通过 `observerRef`、`activeSessionIdRef`、`runtimesRef` 等 ref 读取最新对象,不捕获 render 时的 runtime 或活动 id。 + +ResizeObserver 回调只调用 `scheduleFit()`。`scheduleFit()` 取消旧 rAF,在下一帧只处理活动 runtime;容器 `clientWidth` 和 `clientHeight` 必须都大于零。 + +### 4.4 唯一尺寸测量规则 + +fit/create 前必须: + +1. Xterm 已 `open` 到当前容器。 +2. 容器宽高都大于零。 +3. `fitAddon.proposeDimensions()` 返回非空值。 +4. proposed `cols/rows` 通过与服务端相同的范围校验。 +5. 执行 `fitAddon.fit()`。 +6. 读取并再次校验 `terminal.cols/terminal.rows`。 + +任一步失败都不 create、不 resize;等待下次 callback ref、ResizeObserver 或显式 `scheduleFit()`。禁止用 Xterm 构造默认值代替实际容器尺寸。 + +统一尺寸范围: +```text +2 <= cols <= 1000 +1 <= rows <= 1000 +``` + +`SocketClient.createTerminal` 的 `cols`、`rows` 改为必填参数。普通创建和实例附加必须先得到合法 `createSize` 再发送 `create-pty`。恢复会话 runtime 初始为 `disconnected`,open/fit 后进入 `reconnecting` 并发送 `reconnect-session`;裸 connect 不发送 resize。 + +### 4.5 固定状态机 + +只有 `ready` 可发送 input/resize。非 `ready` 输入直接丢弃,不缓存、不重放。 + +所有 runtime 创建时 `closeRequestInFlight = false`。统一 `requestCloseIfIdle()` 仅在 Socket 已连接且该值为 false 时执行,并在 emit `close-pty` 前置为 true。 + +状态转换和动作固定如下: +- 收到 `pty-created`:仅 `creating -> ready` 并执行活动 fit/seed;若已 `closing`,不 fit,只调用一次 `requestCloseIfIdle()`。 +- transport disconnect:非 closing 状态按原合同进入 disconnected 并清尺寸状态;closing 保持不变;两者都将 `closeRequestInFlight` 清为 false。 +- transport connect:disconnected 进入 reconnecting;closing 保持 closing;都只发 `reconnect-session`,不直接 resize/close。 +- 收到 `session-reconnected`:ready session 对应的 reconnecting 进入 ready 并 fit/seed;`close-retained` target 对应的 closing 保持 closing、不 fit,只调用一次 `requestCloseIfIdle()`。 +- 只有服务端稳定复查 sessions/createAttempts 均无该 ID 才收 `session-reconnect-failed`:reconnecting 进入 exited;closing 清 close flag 后 disposed。 +- 用户关闭 creating/ready:进入 closing,取消 timer/pending并调用 `requestCloseIfIdle()`;关闭 disconnected/reconnecting 时进入 closing,等待 reconnect 结果。 +- 用户在 closing 再点击关闭:仅当 `closeRequestInFlight` 为 false 时调用 `requestCloseIfIdle()`;已有请求时不重复 emit。 +- input/resize error 使 ready 进入 exited。close error 清 close flag、保持 closing,等待用户显式重试;不得用 timer 或 error handler 自动无限重试。 +- 用户关闭 exited 时直接 disposed;`terminal-exit` 进入 exited并禁止 input/resize。 +- 收到 `pty-closed` 时先清 close flag,再由 closing 进入 disposed 并释放全部资源。 + +### 4.6 单一 resize reporter + +每个 runtime 只注册一次 `terminal.onResize`。项目中只有 reporter 可以调用 `socketClient.resizeTerminal`。 + +`terminal.onResize` 将合法尺寸写入 `pendingSize`,但只有同时满足以下条件才启动 50ms trailing timer: +- runtime.state 为 `ready`; +- runtime 是活动会话; +- Socket 已连接; +- 尺寸不同于 `lastWrittenSize`。 + +定时器触发时重新检查条件,发送最新 pending,更新 `lastWrittenSize`,清 pending。`pty-created` 和使 runtime 进入 ready 的 `session-reconnected` 才显式 seed;close-retained 对应的 closing 不 seed。 + +`terminal-resized` 只把 payload 写入 `lastReportedSize`。它不得调用 `fitAddon.fit()`、`terminal.resize()` 或 reporter。 + +切换活动会话时:移动目标 Xterm element、`scheduleFit()`、合法 fit 后显式 seed 当前尺寸并 flush。非活动 runtime 不 fit、不 resize。 + +### 4.7 前端清理与通知 + +close、exit、disconnect、unmount 都先清 timer/pending。runtime dispose 顺序: + +1. 状态设为 `disposed`。 +2. 清 timer。 +3. dispose `onData`、`onResize` 等 disposables。 +4. `terminal.dispose()`。 +5. 从 `runtimesRef` 和 `terminalTabs` 删除。 + +页面卸载还必须取消 rAF、disconnect observer、移除 Socket/fullscreen/window 监听并 dispose 全部 runtime。 + +错误使用现有 `useNotificationStore().addNotification()`;相同 resize 错误使用稳定文本,依赖现有去重。不得使用浏览器对话框。 + +## 5. 服务端设计 + +### 5.1 共享尺寸验证 + +在 `server/src/utils/ptyControlChannel.ts` 导出: +```ts +interface PtySize { + cols: number + rows: number +} + +function validatePtySize(cols: unknown, rows: unknown): PtySize +``` + +它要求 safe integer 且满足 `cols 2..1000`、`rows 1..1000`。create 和 resize 必须调用同一个 validator;非法值不截断。 + +`create-pty` 的服务端数据类型把 cols/rows 定为必填。create 在分配进程或端点前验证;resize 在查找并写通道前验证。 + +### 5.2 控制通道接口与队列 + +新增 `server/src/utils/ptyControlChannel.ts`,对 TerminalManager 暴露: +```ts +interface PtyControlChannel { + readonly endpoint: string + waitUntilReady(timeoutMs: number): Promise + enqueueResize(size: PtySize): Promise<'written' | 'skipped'> + close(): Promise +} +``` + +每个通道内部只有一个 in-flight frame、一个 latest pending resize、一个 lastWrittenSize、一个关闭标志,以及当前通道产生但尚未 settle 的 resize Promise 集合。 + +规则: + +1. 空闲时立即写入;写入中只保留 latest pending。被覆盖或重复请求返回 `skipped`。 +2. 完整 frame 的 write callback 成功才返回 `written`;当前写入完成后只写最终 pending。 +3. `close()` 首先原子设置关闭标志;此后所有 `enqueueResize` 立即返回 `skipped`,已有 pending 也返回 `skipped`。 +4. `close()` 立即 destroy 当前 writer,使无法继续完成的 in-flight write 尽快回调;由主动 close 导致的 callback error 归一为 `skipped`,不产生 resize error。 +5. `close()` 等待该通道全部已有 resize Promise settle 后才 resolve。TerminalManager 必须先 `await channel.close()`,因此 `pty-closed` 必然晚于全部相关 resize Promise settle。 +6. 只有 `written` 且会话复核通过才发送 `terminal-resized`。非主动写失败才抛错;TerminalManager 发送一次 `terminal-error operation=resize`,关闭该 PTY,随后由进程退出发送 `terminal-exit`。 + +### 5.3 精确 RESIZE 协议 + +帧格式固定为: +```text +1 byte type = 4 +2 bytes UTF-8 JSON byte length,uint16 big-endian +N bytes compact UTF-8 JSON +``` + +JSON 固定为: +```json +{"width":120,"height":40} +``` + +编码使用 `JSON.stringify({ width: cols, height: rows })`、`Buffer.byteLength`、`writeUInt8(4, 0)`、`writeUInt16BE(length, 1)`,不附加换行。 + +`120x40` JSON 长度是 25 字节,frame 前三个字节必须为: +```text +04 00 19 +``` + +上游没有成功 ACK,且当前控制实现丢弃错误响应。因此 `terminal-resized` 仅表示 GameServerManager 已把完整 frame 写入 OS 管道,不表示 `SetSize` 已被上游确认。 + +### 5.4 POSIX FIFO 与 Windows Named Pipe + +Linux FIFO 放在项目数据目录: +```ts +const candidates = [ + path.join(process.cwd(), 'data', 'terminal-control'), + path.join(process.cwd(), 'server', 'data', 'terminal-control'), +] +``` + +沿用项目多路径选择规则。目录创建后显式 `chmod 0700`。每个 endpoint 名使用至少 128-bit `crypto.randomBytes`,sessionId 不进入路径。 + +POSIX ready 必须同时满足: + +1. FIFO 路径已出现; +2. `lstat` 确认是 FIFO 且不是 symlink; +3. 显式 `chmod 0600` 成功; +4. writer 成功打开。 + +任一条件在 3 秒内未满足则 create attempt 失败。只有进程已确认退出的 close/error/cleanup 才尝试删除该会话自己的 FIFO;未确认退出时保留,本阶段不扫描历史残留。 + +Windows endpoint 固定形态: +```text +\\.\pipe\gsm3-pty-<128-bit-random-hex> +``` + +依赖随机不可预测名称和 MCSManager/PTY 创建 Named Pipe 时的系统默认 DACL。Node 客户端不声明也不尝试收紧 Pipe ACL。ready 条件是 3 秒内成功连接。 + +endpoint 不发送到浏览器,不进入普通 info/debug 日志;仅允许安全审计日志记录平台、sessionId 和失败阶段,不记录完整 endpoint。 + +### 5.5 PTY 固定资产、下载与原生 probe + +协议历史与 binary 身份分开记录:`v1.5.1` commit `418a696cd04de09dd366db70f6d275ab77d43422` 的源码已有 `-fifo` 和 RESIZE 类型 4,但该 release 无 binary assets。实际资产固定为 2026-03-16 release ID `297277624`,其构建源码身份记录为 commit `09fc369dfa278504831260de2771d7cbd98d01c4`;mutable tag、tag URL 和 commit 下载 URL 均不参与资产选择。 + +| 平台 | asset ID | name | size | SHA-256 digest | +| --- | ---: | --- | ---: | --- | +| Linux x64 | `374651721` | `pty_linux_x64` | `2654360` | `bbdfc8a5d0f57493e78c64bca56d370524c068c1d4d31cac653458a843d47f72` | +| Linux ARM64 | `374651727` | `pty_linux_arm64` | `2752664` | `48d8496997053b60eb84d2b02f4ec751298c7f214c615b08aca43309739ebf83` | +| Windows x64 | `374651714` | `pty_win32_x64.exe` | `3627520` | `fe35c154e623707d0dd2b728f41fd200bd3ead0a8cda8eb216b1e5e3e3ab2d40` | + +本地 binary 必须先按当前平台 manifest 校验 basename、精确 size 和 SHA-256;三项完全匹配才允许执行 ` -h`。任何一项不匹配都直接替换,即使该文件的 probe 输出包含 `-fifo`;不支持 custom/unmanaged binary。原生 probe timeout 3 秒,stdout+stderr 合计上限 64 KiB,输出必须含字面量 `-fifo`,结果按进程和 binary path 缓存为 Promise。匹配 manifest 但 probe 失败时标记 capability unavailable。 + +下载合同固定为两步: + +1. GET `https://api.github.com/repos/MCSManager/PTY/releases/297277624`,使用 `Accept: application/vnd.github+json`,确认 release ID,并从 JSON 中找到与 manifest 的 asset ID、name、size 完全一致的唯一 asset。 +2. GET `https://api.github.com/repos/MCSManager/PTY/releases/assets/`,使用 `Accept: application/octet-stream`,流式写入目标同目录临时文件。 + +两步都设置固定 `User-Agent: GameServerManager-PTY-Installer` 和 `X-GitHub-Api-Version: 2022-11-28`,最多跟随 5 次重定向;若使用 Authorization,只向 `api.github.com` 发送,跨 host 重定向必须剥离。下载最多接收 manifest size 字节,超限立即终止;落盘后再次验证 basename 对应的 manifest name、精确 size 和 SHA-256,POSIX 设置可执行权限。当前 OS/arch 文件再通过原生 probe 后才以同目录 `rename` 原子替换;清 probe 缓存并对最终路径复验。异架构文件在构建机只校验 name/size/hash,不执行。任一步失败都删除临时文件、保留旧文件并拒绝 create,不回退 `SIGWINCH`。 + +`server/src/utils/ptyManager.ts`、`scripts/package.js`、`Dockerfile`、`install-gsm3.sh` 必须使用同一固定 manifest、release JSON 校验和 asset API 下载合同;任何入口都不得使用 `releases/download/latest`。 + +### 5.6 create attempt、重复 ID 拒绝与 reconnect + +`type CreateAttemptPhase = 'starting' | 'fallback' | 'closing' | 'close-retained'`。每个 attempt 保存 phase、cancellation token、createSize、process、endpoint、socket、持久化所需引用和 `closePromise?: Promise`;control ready 前不加入 sessions。close 超时必须把 phase 设为 `close-retained` 并原对象留在 createAttempts,保留全部引用及 single-flight slot,重试期间再填充 closePromise。 + +`createPty` 完成 payload/尺寸验证后,在一个无 await 的同步临界段依次检查 `sessions.has(sessionId)` 与 `createAttempts.has(sessionId)`:任一存在即只发一次 create error 并 return;两者均不存在则立即把初始 attempt 预占进 createAttempts,之后才允许任何异步准备或 spawn。create 路径绝不调用 `closePty`、不消费 CloseResult、不覆盖旧引用,也不发送 `pty-closed`。 + +调用方若要替换终端,必须先独立 close,收到 `pty-closed` 后生成新的 sessionId 再 create;不支持同 ID 自动替换。 + +新 attempt 使用随机 endpoint spawn primary,参数含唯一 createSize、`-size cols,rows` 和 `-fifo endpoint`。fallback 仅在已配置 default user、无显式 command、primary 1000ms 内 code 0 三条件同时满足时使用;候选 primary 同时等待 control ready 与稳定窗口,fallback 复用 createSize 但换新 endpoint。成功只发一次 `pty-created`,全部启动失败只发一次 create error;进程确认退出才清引用,超时进入 close-retained。 + +create attempt 内退出不发 `terminal-exit`。creating close 只有确认退出或请求前 attempt 已不存在才发 `pty-closed`;4 秒仍存活返回 still-running。disconnect 取消但不发终态事件。 + +`reconnectSession` 对 closing 客户端先检查 ready sessions,再检查 phase=`close-retained` 的 createAttempts;命中任一目标都更新其 socket 并发 `session-reconnected`,前端保持 closing 后调用 `requestCloseIfIdle()`。若发现仍在 closing 转换中的 attempt,await 其 single-flight 后重新检查;只有稳定复查两个 map 都无该 ID 才发 `session-reconnect-failed`。 + +### 5.7 确定的 async 签名和调用顺序 + +TerminalManager 签名固定为: +```ts +type CloseResult = 'closed' | 'not-found' | 'still-running' + +public async resizeTerminal( + socket: Socket, + data: TerminalResizeData +): Promise + +public async closePty( + socket: Socket, + data: { sessionId: string } +): Promise + +public async cleanup(): Promise +``` + +`resizeTerminal` 顺序:验证尺寸 -> 查找 ready session 并保存 control channel identity -> `await enqueueResize` -> 重新按 sessionId 查找,确认仍是同一 session、状态仍为 ready、channel identity 未变化 -> 仅在结果为 written 时更新尺寸并向请求 socket 发 `terminal-resized`。复核失败静默结束,避免 close/recreate 后的迟到回执。非主动 resize 失败只发一次 resize error;会话仍存在时 `await terminateSession(session, { intentional: false })`,由 process close handler 发 `terminal-exit`。 + +每个 target 保存 `closePromise?: Promise`。`closePty` 开始时若 sessions/createAttempts 均无目标,发一次 `pty-closed` 并返回 `not-found`;已有 closePromise 则 await 并返回同一结果,不重复信号/事件。owner 标记 closing/cancel token,关闭 channel/stdin,SIGTERM 最多 3 秒、必要时 SIGKILL 最多 1 秒。 + +确认 close/exit 后,幂等 finalizer 删除引用并尝试清 endpoint/持久化记录,发一次 `pty-closed` 并返回 `closed`。超时则 session 留在原 map;create attempt 设为 `close-retained`,owner 发一次 close error、清 closePromise 并返回 `still-running`。进程以后退出仍执行 finalizer;辅助清理 warning 不改变返回值。 + +`cleanup()` 禁止新 create/input/resize,取消全部 attempts,并为每个 target 加入已有或创建新的 single-flight 3+1 秒有界任务,必须一次 `await Promise.allSettled([...attemptTasks, ...sessionTasks])`。已退出者执行无事件 finalizer;still-running 记 error/critical、保留引用到 15 秒强制退出,cleanup 有界返回。 + +全仓调用点必须逐项改造:真正的 close Socket handler await 后可忽略 CloseResult;create handler 只执行同步 map 检查与预占,禁止调用 `closePty`;InstanceManager 只有 `closed | not-found` 且复查目标不存在才标记 stopped;timer/stream-forward/inactive cleanup 均 await、allSettled 或显式消费异常,禁止裸丢 Promise。 + +`gracefulShutdown` 设置 15 秒强制退出 timer。`settle(name, cleanup)` 捕获同步/异步异常、记录 manager 名并总是 resolve;按实际顺序逐个 `await settle`:instanceManager、terminalManager、gameManager、systemManager、fileWatchManager,记录 steamcmdManager 无 cleanup,再执行 schedulerManager、pluginManager。整个 manager 段放在 try;finally 中也通过 settle 销毁 sockets,并用 `Promise.allSettled` 执行 Socket.IO 与 HTTP server close,保证任何 manager reject 都不能跳过网络关闭。成功后清 timer 并退出 0;signal handler 的顶层 `.catch(...)` 只作为意外逃逸的最后防线。 + +## 6. 端到端时序 + +### 6.1 新建 +```text +callback ref 获得容器 +-> open Xterm +-> proposeDimensions 合法 +-> fit +-> createSize 合法 +-> create-pty(cols/rows 必填) +-> primary/fallback create attempt +-> control ready +-> pty-created +-> 前端 ready +-> 活动会话 fit + seed pending + reporter flush +``` + +### 6.2 布局变化 +```text +ResizeObserver +-> rAF scheduleFit +-> 活动 ready runtime fit +-> terminal.onResize +-> 50ms reporter +-> terminal-resize +-> validatePtySize +-> control queue +-> RESIZE frame +-> terminal-resized +-> 前端只记录,不 fit +``` + +### 6.3 断线重连 +```text +disconnect -> ready 等状态进入 disconnected;closing 保持 closing并清 close flag +connect -> 只发 reconnect-session +服务端 -> 查 ready sessions,再查 close-retained createAttempts +session-reconnected -> reconnecting 进入 ready 并 fit/seed + -> closing 保持 closing并 requestCloseIfIdle +两个 map 均无目标 -> session-reconnect-failed -> exited/disposed +``` + +reconnect 不创建新 target,不改变 retained phase;人工 close 重试继续复用同一 target 和前后端 single-flight。 + +## 7. 验证计划 + +### 7.1 静态检查 +```bash +cd client && npx tsc --noEmit +cd server && npx tsc --noEmit +``` + +两条命令都必须退出码 0。 + +### 7.2 协议与队列 +- 编码 `120x40`,JSON 必须是 `{"width":120,"height":40}`,frame 头为 `04 00 19`。 +- 阻塞 writer 并每 40ms enqueue,确认最多一个 in-flight 和一个 latest pending;解除后只有最终尺寸 written。 +- close 期间 pending、后续 enqueue 和主动 destroy 导致的 callback error 都返回 skipped,不产生 resize error。 +- `close()` 必须晚于全部相关 resize Promise settle 才 resolve,`pty-closed` 更晚;不得出现迟到 resize error/回执。 +- enqueue written 后替换或关闭 session,确认 identity/ready 复核阻止 `terminal-resized`;只有仍有效的 written 产生回执。 + +### 7.3 浏览器与 PTY + +Linux 终端执行: +```bash +stty size +tput cols +tput lines +python3 -c 'print("0123456789" * 30)' +``` + +通过条件: +- 每次停止调整容器后 500ms 内,`stty size` 与活动 Xterm 最终 rows/cols 一致。 +- ASCII 长行在当前列边界换行,光标无偏移。 +- `vim`、`top`、已安装时的 `htop` 在侧边栏、普通/全屏切换和窗口拖动后完整重绘。 +- 普通/全屏节点替换时 callback ref 先 unobserve 旧节点,再 observe 新节点并完成一次 fit。 +- 非活动会话不发送 resize;切换为活动后 500ms 内同步。 + +快速 resize 测试:连续 1 秒、每 40ms 改变容器尺寸。最后一次变化后 500ms 内: +- `stty size` 等于最终尺寸; +- 没有回退到中间值; +- 没有 `terminal-resized -> fit -> terminal-resize` 循环; +- 前端只保留一个 timer,服务端只保留一个 pending。 + +### 7.4 状态机、关闭与 shutdown + +逐项验证: +- runtime 初始化 close flag=false;发送前置 true;close error、pty-closed、disconnect 都清 false。 +- closing 再点击仅在无 in-flight 时重发;迟到 created/reconnected 各最多触发一次,不存在 timer/error 自动重试循环。 +- 并发 close 只运行一次信号序列并返回同一 CloseResult;closed/not-found/still-running 分别符合事件表。 +- creating close 注入 SIGKILL 超时:attempt 进入 close-retained,断线重连收到 reconnected 而不 disposed,人工重试命中同一 process/endpoint。 +- sessions 或 createAttempts 已有同 ID 时,create 只发一次 create error;断言 `closePty` 未调用、无 `pty-closed`,旧 target 完全不变。 +- 两个并发同 ID create 只能一个同步预占并进入 spawn,另一个只收 create error,不得双创建。 +- 人工 close 重试仍受 single-flight 门控;替换流程必须等待 `pty-closed` 后用新 sessionId create,不测试或支持同 ID 自动重建。 +- 超时后进程退出只发一次 `pty-closed`;辅助清理失败只记 warning,不改变 `closed`。 +- `cleanup()` 对全部 attempts/sessions 同时启动任务并 `Promise.allSettled`;单任务 4 秒有界,未退出者记 critical、保留引用并返回。 +- 任一 manager cleanup reject 时后续 manager 仍按序执行,finally 仍销毁 sockets 并关闭 Socket.IO/HTTP;正常流程不触发 15 秒强制退出。 +- fallback 三条件、1000ms 窗口和 cancellation token 仍只产生一个最终 create 结果。 + +### 7.5 固定资产与平台原生验收 +- 对三项 manifest 逐一核对 release/asset ID、name、size、SHA-256;伪造“支持 `-fifo` 但 hash 错误”的文件,确认未 probe 即被替换或拒绝。 +- 模拟 release JSON tuple 不匹配、超过 5 次重定向、跨 host Authorization、下载超限和落盘 hash 错误,均必须安全失败;跨 host 请求不得携带 Authorization。 +- `ptyManager.ts`、`scripts/package.js`、`Dockerfile`、`install-gsm3.sh` 均不得含 `releases/download/latest`,并使用同一固定 metadata。 +- Linux x64、Linux ARM64、Windows x64 分别在原生主机/容器执行 probe、FIFO/Named Pipe 和完整交互;Windows 用 `[Console]::WindowWidth/WindowHeight` 检查尺寸。 +- 构建机对异架构文件只校验 name/size/hash,不执行;原生验收机必须执行 probe。 + +永久保留本节的可重复验收步骤。实现期间创建的临时协议、诊断或故障注入测试代码在成功后删除,遵守 `AGENTS.md`。 + +## 8. 落地、回滚与文件 + +### 8.1 落地顺序 + +1. 在 `ptyManager.ts` 固定三平台 manifest,实现 manifest-first 校验、release JSON/asset API 两步下载、落盘复验、原生 probe 和原子替换。 +2. 将 `scripts/package.js`、`Dockerfile`、`install-gsm3.sh` 固定到相同 asset ID、size、hash 和下载合同,移除所有 `releases/download/latest`。 +3. 实现 `ptyControlChannel.ts` 和真实 PTY resize 协议。 +4. 将 TerminalManager create 改为同步重复 ID 拒绝/预占;CloseResult 仅用于 close,并完成 fallback/resize/cleanup 合同。 +5. 更新全部 Socket、TerminalManager 内部 timer/cleanup、InstanceManager 和 gracefulShutdown 调用点。 +6. 实现前端 factory、Map runtime、状态机、observer/reporter,以及 close 完成后生成新 sessionId 的调用方合同。 +7. 对齐事件类型并执行全部原生平台验收。 + +### 8.2 回滚 +- 前端可单独回滚,服务端事件名保持兼容;旧前端可能重复 fit,但真实 PTY resize 仍有效。 +- 服务端或 PTY binary 回滚时必须同时回滚前端;新前端配旧服务端会重新出现真实 winsize 不同步。 +- 不提供 `SIGWINCH` 运行时降级开关。 +- 固定版本替换失败时保留旧文件,但 terminal capability 标记不可用并明确拒绝 create。 + +### 8.3 涉及文件 + +前端: +- `client/src/pages/TerminalPage.tsx`(等待 `pty-closed` 后用新 sessionId 创建) +- `client/src/utils/terminalFactory.ts`(新增) +- `client/src/utils/socket.ts` +- `client/src/types/index.ts` + +服务端: +- `server/src/utils/ptyControlChannel.ts`(新增) +- `server/src/utils/ptyManager.ts` +- `server/src/modules/terminal/TerminalManager.ts`(同步重复 ID 拒绝/预占,CloseResult 仅用于 close) +- `server/src/modules/instance/InstanceManager.ts` +- `server/src/index.ts` +- `.gitignore`(忽略正常运行产生的 terminal-control 内容) + +分发/构建: +- `scripts/package.js` +- `Dockerfile` +- `install-gsm3.sh` + +不修改 Xterm 版本或 client lockfile。 + +## 9. 验收清单与剩余风险 + +验收必须全部满足: +- [ ] 三条 Xterm 初始化路径共用 factory,factory 只返回 `{ terminal, fitAddon }`,且统一 `convertEol: true`。 +- [ ] React state 只保存 tab meta 和 active id,runtime 全部在 Map ref。 +- [ ] 普通/全屏容器共用 callback ref,observer 只创建一次。 +- [ ] proposeDimensions、容器尺寸和统一 validator 全部通过后才 fit/create。 +- [ ] 固定状态机和事件表已实现,只有 ready 可 input/resize,close flag 的置位/清除完整。 +- [ ] closing 人工重试受 in-flight 门控,无自动无限重试;disconnect/reconnect seed 行为符合设计。 +- [ ] `terminal.onResize` reporter 是唯一 emit 路径,create/resize 共用 validator。 +- [ ] fallback 三条件、1000ms 窗口和 cancellation token 已实现,每个 attempt 只有一个最终事件。 +- [ ] 每会话控制端点随机且不泄露;队列 close 等全部 resize settle,written 回执经过 identity/ready 复核。 +- [ ] `closePty` 返回 CloseResult,同 target 并发调用共享同一结果和事件。 +- [ ] close 超时的 create attempt 进入 close-retained 并保留全部引用;reconnect 可重新绑定 socket。 +- [ ] create 同步检查两个 map 并立即预占;重复 ID 只发 create error,不调用 close、不发 `pty-closed`。 +- [ ] 调用方先 close 并等待 `pty-closed`,随后使用新 sessionId create;无同 ID 自动替换。 +- [ ] cleanup 并行 allSettled、有界返回并保留未退出引用。 +- [ ] 15 秒 graceful shutdown 使用逐 manager settle,网络销毁/关闭始终位于 finally。 +- [ ] 所列 TerminalManager、Socket handler、InstanceManager 调用点全部 await 或显式消费异常。 +- [ ] 四个分发/运行入口固定 manifest 和两步 API 合同;三平台原生验收通过。 +- [ ] 两端 TypeScript 检查通过,临时测试代码已删除。 + +实现后仍明确存在以下风险: + +1. 会话没有 owner/actor 授权;这是后续独立安全任务。 +2. 上游没有成功 ACK,`terminal-resized` 只能证明 OS 管道写入成功;最终效果依赖 500ms 内的 PTY 行为验收。 +3. 本阶段不扫描崩溃残留 FIFO;正常流程只在确认进程退出后清理,未确认退出的 endpoint/引用会保留到最终强制退出。 diff --git a/install-gsm3.sh b/install-gsm3.sh index ef0cf159..b6ee14c5 100755 --- a/install-gsm3.sh +++ b/install-gsm3.sh @@ -105,10 +105,27 @@ mkdir -pv "$install_path" cd "$install_path" if test "$install_type" = "1"; then + ARCH=$(uname -m) + case "$ARCH" in + x86_64|amd64) + ARCHIVE_NAME="gsm3-management-panel-linux-x64.tar.gz" + PTY_ASSET="linux-x64" + ;; + aarch64|arm64) + ARCHIVE_NAME="gsm3-management-panel-linux-arm64.tar.gz" + PTY_ASSET="linux-arm64" + ;; + *) + echo -e "\x1b[31m不支持的系统架构: $ARCH\x1b[0m" + exit 1 + ;; + esac + DOWNLOAD_URL="https://ghfast.top/https://github.com/GSManagerXZ/GameServerManager/releases/latest/download/$ARCHIVE_NAME" + if command -v curl &>/dev/null;then - curl -Lo gsm3.tgz https://ghfast.top/https://github.com/GSManagerXZ/GameServerManager/releases/latest/download/gsm3-management-panel-linux.tar.gz + curl -fL -o gsm3.tgz "$DOWNLOAD_URL" elif command -v wget &>/dev/null; then - wget -O gsm3.tgz https://ghfast.top/https://github.com/GSManagerXZ/GameServerManager/releases/latest/download/gsm3-management-panel-linux.tar.gz + wget --server-response -O gsm3.tgz "$DOWNLOAD_URL" else echo -e "\x1b[31m错误:既没有安装curl也没有安装wget,无法下载gsm3程序,请安装这俩其中一个工具后再次执行该脚本!" exit 1 @@ -120,54 +137,24 @@ if test "$install_type" = "1"; then fi echo "下载完毕,解压中,请稍等" tar -xzf gsm3.tgz -C "$install_path" + if test "$?" != "0"; then + echo -e "\x1b[31m解压失败,请检查下载的安装包是否有效...\x1b[0m" + rm -rf gsm3.tgz + exit 1 + fi rm -rf gsm3.tgz chmod 755 "$install_path/node/bin/node" "$install_path/start.sh" 2>/dev/null || true - # 验证并修复PTY二进制文件 - ARCH=$(uname -m) - if [ "$ARCH" = "x86_64" ]; then - PTY_NAME="pty_linux_x64" - elif [ "$ARCH" = "aarch64" ]; then - PTY_NAME="pty_linux_arm64" + # 通过打包产物中的固定资产 CLI 校验或修复 PTY + mkdir -p "$install_path/data/lib" + echo -e "\x1b[33m正在校验固定 PTY 资产...\x1b[0m" + if "$install_path/node/bin/node" \ + "$install_path/server/utils/ptyAssetCli.js" ensure \ + --asset "$PTY_ASSET" \ + --target-dir "$install_path/data/lib"; then + echo -e "\x1b[32mPTY 资产校验完成\x1b[0m" else - PTY_NAME="" - fi - - if [ -n "$PTY_NAME" ]; then - PTY_FILE="$install_path/data/lib/$PTY_NAME" - PTY_VALID=false - - # 检查PTY文件是否存在且为有效的ELF二进制文件 - if [ -f "$PTY_FILE" ]; then - if file "$PTY_FILE" 2>/dev/null | grep -q "ELF"; then - PTY_VALID=true - else - echo -e "\x1b[33mPTY文件无效(非ELF二进制),将重新下载...\x1b[0m" - rm -f "$PTY_FILE" - fi - fi - - if [ "$PTY_VALID" = "false" ]; then - echo -e "\x1b[33m正在下载PTY二进制文件...\x1b[0m" - mkdir -p "$install_path/data/lib" - PTY_URL="https://github.com/MCSManager/PTY/releases/download/latest/$PTY_NAME" - if command -v curl &>/dev/null; then - curl -Lo "$PTY_FILE" "$PTY_URL" - elif command -v wget &>/dev/null; then - wget -O "$PTY_FILE" "$PTY_URL" - fi - if [ -f "$PTY_FILE" ] && file "$PTY_FILE" 2>/dev/null | grep -q "ELF"; then - echo -e "\x1b[32mPTY下载完成\x1b[0m" - else - echo -e "\x1b[33mPTY下载失败,终端功能将在服务启动时自动重试下载\x1b[0m" - rm -f "$PTY_FILE" 2>/dev/null - fi - fi - - # 设置可执行权限 - if [ -f "$PTY_FILE" ]; then - chmod 755 "$PTY_FILE" - fi + echo -e "\x1b[33mPTY 资产校验或下载失败;在运行时校验成功前,终端创建功能将保持不可用\x1b[0m" fi # 设置其他lib文件权限 diff --git a/opencode.config.json b/opencode.config.json new file mode 100644 index 00000000..9c397146 --- /dev/null +++ b/opencode.config.json @@ -0,0 +1,11 @@ +{ + "permissions": [ + { "permission": "bash", "pattern": "*", "action": "allow" }, + { "permission": "read", "pattern": "*", "action": "allow" }, + { "permission": "write", "pattern": "*", "action": "allow" }, + { "permission": "delete", "pattern": "*", "action": "allow" }, + { "permission": "external_directory", "pattern": "*", "action": "allow" }, + { "permission": "network", "pattern": "*", "action": "allow" }, + { "permission": "process", "pattern": "*", "action": "allow" } + ] +} diff --git a/package-lock.json b/package-lock.json index 21e8659d..80527e37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -248,7 +248,6 @@ "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, diff --git a/package.json b/package.json index d515f0f6..1926d14f 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,14 @@ "build:client": "cd client && npm run build", "package": "npm run build && node scripts/package.js", "package:create": "node scripts/package.js", - "package:linux": "npm run build && node scripts/package.js --target=linux", + "package:linux": "npm run package:linux:x64", + "package:linux:x64": "npm run build && node scripts/package.js --target=linux-x64", + "package:linux:arm64": "npm run build && node scripts/package.js --target=linux-arm64", "package:windows": "npm run build && node scripts/package.js --target=windows", "package:no-zip": "npm run build && node scripts/package.js --no-zip", - "package:linux:no-zip": "npm run build && node scripts/package.js --target=linux --no-zip", + "package:linux:no-zip": "npm run package:linux:x64:no-zip", + "package:linux:x64:no-zip": "npm run build && node scripts/package.js --target=linux-x64 --no-zip", + "package:linux:arm64:no-zip": "npm run build && node scripts/package.js --target=linux-arm64 --no-zip", "package:windows:no-zip": "npm run build && node scripts/package.js --target=windows --no-zip", "start": "cd server && npm start", "start:prod": "cd dist && node index.js", diff --git a/scripts/package.js b/scripts/package.js index 052d31db..db6b15c4 100644 --- a/scripts/package.js +++ b/scripts/package.js @@ -1,7 +1,7 @@ const fs = require('fs-extra') const path = require('path') const archiver = require('archiver') -const { execSync } = require('child_process') +const { execFileSync, execSync } = require('child_process') const https = require('https') const { pipeline } = require('stream') const { promisify } = require('util') @@ -16,9 +16,23 @@ const packageDir = path.join(distDir, 'package') // 获取命令行参数 const args = process.argv.slice(2) -const buildTarget = args.find(arg => arg.startsWith('--target='))?.split('=')[1] +const requestedBuildTarget = args.find(arg => arg.startsWith('--target='))?.split('=')[1] const skipZip = args.includes('--no-zip') || args.includes('--skip-zip') -const outputFile = buildTarget + +function resolveBuildTarget(target) { + if (!target || target === 'windows' || target === 'linux-x64' || target === 'linux-arm64') { + return target + } + if (target === 'linux') { + if (process.arch === 'x64') return 'linux-x64' + if (process.arch === 'arm64') return 'linux-arm64' + throw new Error(`不支持当前架构的 Linux 打包: ${process.arch}`) + } + throw new Error(`不支持的打包目标: ${target}`) +} + +const buildTarget = resolveBuildTarget(requestedBuildTarget) +const outputFile = buildTarget ? path.join(distDir, `${packageName}-${buildTarget}-v${version}.zip`) : path.join(distDir, `${packageName}-v${version}.zip`) @@ -27,21 +41,14 @@ const nodeVersion = '22.17.0' // Zip-Tools GitHub 下载配置(始终使用最新版本) const ZIP_TOOLS_GITHUB_URL = 'https://github.com/MCSManager/Zip-Tools/releases/latest/download/' -// PTY GitHub 下载配置(tag 名为 latest) -const PTY_GITHUB_URL = 'https://github.com/MCSManager/PTY/releases/download/latest/' - /** - * 获取目标平台对应的 Zip-Tools 二进制文件名列表 - * 打包时下载所有该平台支持的架构版本 + * 获取目标包对应的 Zip-Tools 二进制文件名列表 */ -function getZipToolsBinaries(platform) { - if (platform === 'linux') { - return ['file_zip_linux_x64', 'file_zip_linux_arm64'] - } else if (platform === 'windows') { - // GitHub Releases 上 Zip-Tools 只有 win32_x64 版本 - return ['file_zip_win32_x64.exe'] - } - // 未指定平台时下载所有版本 +function getZipToolsBinaries(target) { + if (target === 'linux-x64') return ['file_zip_linux_x64'] + if (target === 'linux-arm64') return ['file_zip_linux_arm64'] + if (target === 'windows') return ['file_zip_win32_x64.exe'] + // 未指定目标时下载所有版本 return [ 'file_zip_linux_x64', 'file_zip_linux_arm64', @@ -52,16 +59,13 @@ function getZipToolsBinaries(platform) { } /** - * 获取目标平台对应的 7z 二进制文件名列表 - * 打包时下载所有该平台支持的架构版本 + * 获取目标包对应的 7z 二进制文件名列表 */ -function get7zBinaries(platform) { - if (platform === 'linux') { - return ['7z_linux_x64', '7z_linux_arm64'] - } else if (platform === 'windows') { - return ['7z_win32_x64.exe', '7z_win32_arm64.exe'] - } - // 未指定平台时下载所有版本 +function get7zBinaries(target) { + if (target === 'linux-x64') return ['7z_linux_x64'] + if (target === 'linux-arm64') return ['7z_linux_arm64'] + if (target === 'windows') return ['7z_win32_x64.exe', '7z_win32_arm64.exe'] + // 未指定目标时下载所有版本 return [ '7z_linux_x64', '7z_linux_arm64', '7z_linux_386', '7z_linux_arm', '7z_win32_x64.exe', '7z_win32_arm64.exe', @@ -213,79 +217,51 @@ async function download7z(platform) { } /** - * 获取目标平台对应的 PTY 二进制文件名列表 - * 打包时下载所有该平台支持的架构版本 + * 获取目标包对应的固定 PTY 资产键列表 */ -function getPtyBinaries(platform) { - if (platform === 'linux') { - return ['pty_linux_x64', 'pty_linux_arm64'] - } else if (platform === 'windows') { - return ['pty_win32_x64.exe'] - } - // 未指定平台时下载所有版本 - return [ - 'pty_linux_x64', - 'pty_linux_arm64', - 'pty_win32_x64.exe', - ] +function getPtyAssetKeys(target) { + if (target === 'linux-x64') return ['linux-x64'] + if (target === 'linux-arm64') return ['linux-arm64'] + if (target === 'windows') return ['win32-x64'] + return ['linux-x64', 'linux-arm64', 'win32-x64'] } /** - * 下载 PTY 二进制文件到打包目录的 data/lib/ - * 从 GitHub Releases 下载,确保打包产物内置 PTY + * 通过服务端固定资产 CLI 校验或下载 PTY 到打包目录 */ -async function downloadPty(platform) { - const binaries = getPtyBinaries(platform) +async function ensurePtyAssets(target) { const libDir = path.join(packageDir, 'data', 'lib') await fs.ensureDir(libDir) - console.log('📥 正在从 GitHub 下载 PTY (latest)...') - let hasSuccess = false - - for (const binaryName of binaries) { - const url = `${PTY_GITHUB_URL}${binaryName}` - const destPath = path.join(libDir, binaryName) - - console.log(` 下载: ${binaryName}`) - try { - await downloadFile(url, destPath) - // 非 Windows 二进制文件设置可执行权限 - if (!binaryName.endsWith('.exe')) { - try { - execSync(`chmod +x "${destPath}"`) - } catch (e) { - // Windows 构建环境无法 chmod,忽略 - } - } - console.log(` ✅ ${binaryName} 下载完成`) - hasSuccess = true - } catch (err) { - console.error(` ⚠️ ${binaryName} 下载失败(跳过): ${err.message}`) - } - } - - if (!hasSuccess) { - throw new Error('所有 PTY 文件下载均失败') + console.log('📥 正在校验固定 PTY 资产...') + for (const assetKey of getPtyAssetKeys(target)) { + execFileSync(process.execPath, [ + path.join(packageDir, 'server', 'utils', 'ptyAssetCli.js'), + 'ensure', + '--asset', assetKey, + '--target-dir', libDir + ], { stdio: 'inherit' }) } - console.log('✅ PTY 下载完成') + console.log('✅ PTY 资产校验完成') } -async function downloadNodejs(platform) { +async function downloadNodejs(target) { const nodeUrls = { - linux: `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-linux-x64.tar.xz`, + 'linux-x64': `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-linux-x64.tar.xz`, + 'linux-arm64': `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-linux-arm64.tar.xz`, windows: `https://nodejs.org/download/release/latest-v22.x/win-x64/node.exe` } - - const url = nodeUrls[platform] + + const url = nodeUrls[target] if (!url) { - throw new Error(`不支持的平台: ${platform}`) + throw new Error(`不支持的 Node.js 打包目标: ${target}`) } - + const fileName = url.split('/').pop() const filePath = path.join(__dirname, '..', fileName) - - console.log(`📥 正在下载 Node.js ${nodeVersion} for ${platform}...`) - + + console.log(`📥 正在下载 Node.js ${nodeVersion} for ${target}...`) + return new Promise((resolve, reject) => { const file = fs.createWriteStream(filePath) https.get(url, (response) => { @@ -293,7 +269,7 @@ async function downloadNodejs(platform) { reject(new Error(`下载失败: ${response.statusCode}`)) return } - + response.pipe(file) file.on('finish', () => { file.close() @@ -308,33 +284,36 @@ async function downloadNodejs(platform) { } // 解压和部署Node.js -async function deployNodejs(platform, downloadedFile) { +async function deployNodejs(target, downloadedFile) { const projectRoot = path.join(__dirname, '..') - - if (platform === 'linux') { + + if (target === 'linux-x64' || target === 'linux-arm64') { console.log('📦 正在解压 Linux Node.js...') // 解压到临时目录 execSync(`tar -xf "${downloadedFile}"`, { cwd: projectRoot }) - - // 重命名为node文件夹 - const extractedDir = path.join(projectRoot, `node-v${nodeVersion}-linux-x64`) + + const extractedDirNames = { + 'linux-x64': `node-v${nodeVersion}-linux-x64`, + 'linux-arm64': `node-v${nodeVersion}-linux-arm64` + } + const extractedDir = path.join(projectRoot, extractedDirNames[target]) const targetDir = path.join(packageDir, 'node') - + if (await fs.pathExists(extractedDir)) { await fs.move(extractedDir, targetDir) console.log('✅ Linux Node.js 部署到项目根目录/node') } else { throw new Error('Linux Node.js 解压失败') } - } else if (platform === 'windows') { + } else if (target === 'windows') { console.log('📦 正在部署 Windows Node.js...') // 复制node.exe到打包根目录(start.bat不再cd server,cwd为根目录) const targetFile = path.join(packageDir, 'node.exe') - + await fs.copy(downloadedFile, targetFile) console.log('✅ Windows Node.js 部署到打包根目录/node.exe') } - + // 清理下载的文件 await fs.remove(downloadedFile) } @@ -360,7 +339,7 @@ async function createPackage() { path.join(packageDir, 'server', 'package.json') ) - // PTY 文件不再从本地复制,改为从 GitHub 下载到 data/lib/ 目录 + // PTY 文件不从本地复制,待生产依赖安装后由固定资产 CLI 写入 data/lib/ // 复制环境变量配置文件 await fs.copy( @@ -425,6 +404,9 @@ async function createPackage() { console.error('❌ 服务端依赖安装失败:', error) throw error } + + // 服务端构建文件和生产依赖就绪后,通过固定清单校验或下载 PTY + await ensurePtyAssets(buildTarget) console.log('🎨 复制前端文件...') // 复制前端构建文件 @@ -457,14 +439,6 @@ async function createPackage() { console.log(' 用户启动时会自动从镜像站下载') } - // 下载 PTY 二进制文件(从 GitHub Releases) - try { - await downloadPty(buildTarget) - } catch (error) { - console.error('⚠️ PTY 下载失败,打包产物中将不包含 PTY:', error.message) - console.log(' 用户启动时会自动从镜像站下载') - } - console.log('📝 创建启动脚本...') // 根据目标平台创建启动脚本 if (buildTarget === 'windows') { @@ -473,7 +447,7 @@ async function createPackage() { path.join(__dirname, 'start.bat'), path.join(packageDir, 'start.bat') ) - } else if (buildTarget === 'linux') { + } else if (buildTarget === 'linux-x64' || buildTarget === 'linux-arm64') { const startShScript = `#!/bin/bash echo "正在启动GSM3管理面板..." # PTY 文件已迁移到 data/lib/ 目录,启动时由服务端自动检测 diff --git a/server/src/index.ts b/server/src/index.ts index 70458b0c..57cebf4b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,7 +31,7 @@ import { setupAuthRoutes } from './routes/auth.js' import { setupScheduledTaskRoutes } from './routes/scheduledTasks.js' import { setupConfigRoutes } from './routes/config.js' import { setupSettingsRoutes } from './routes/settings.js' -import { setAuthManager, setExternalApiConfigManager } from './middleware/auth.js' +import { authenticateToken, setAuthManager, setExternalApiConfigManager } from './middleware/auth.js' import filesRouter from './routes/files.js' import { setupInstanceRoutes } from './routes/instances.js' import { setupExternalApiRoutes } from './routes/externalApi.js' @@ -58,6 +58,7 @@ import { consoleLogBuffer } from './utils/logger.js' import { zipToolsManager } from './utils/zipToolsManager.js' import { ptyManager } from './utils/ptyManager.js' import { cleanupSteamCMDRunScripts } from './utils/steamcmdRunScript.js' +import { registerTerminalSocketHandlers } from './socket/terminalSocketHandlers.js' // 获取当前文件目录 const __filename = fileURLToPath(import.meta.url) @@ -184,106 +185,169 @@ app.use((err: any, req: express.Request, res: express.Response, next: express.Ne // 404处理将在startServer函数中设置 // 优雅关闭处理 -let shuttingDown = false -async function gracefulShutdown(signal: string, exitCode = 0) { - if (shuttingDown) { - logger.warn('已在关闭中,忽略重复信号。') - return - } - shuttingDown = true +const SHUTDOWN_FORCE_EXIT_TIMEOUT_MS = 30000 - logger.info(`收到${signal}信号,开始优雅关闭...`) - const forceExitTimer = setTimeout(() => { - logger.error('优雅关闭超时,强制退出!') - process.exit(1) - }, 5000) +type SettledCleanup = () => void | Promise - // 1. 立即清理所有管理器,特别是会创建子进程的TerminalManager - logger.info('开始清理管理器...') +async function settle(name: string, cleanup: SettledCleanup): Promise { try { - if (terminalManager) { - await terminalManager.cleanup() - logger.info('TerminalManager 已清理') - } - if (gameManager) { - await gameManager.cleanup() - logger.info('GameManager 已清理') - } - if (systemManager) { - await systemManager.cleanup() - logger.info('SystemManager 已清理') - } - if (easyTierManager) { - easyTierManager.cleanup() - logger.info('EasyTierManager 已清理') - } - if (fileWatchManager) { - await fileWatchManager.cleanup() - logger.info('FileWatchManager 已清理') - } - if (instanceManager) { - await instanceManager.cleanup() - logger.info('InstanceManager 已清理') - } - if (steamcmdManager) { - await steamcmdManager.cleanup() - logger.info('SteamCMDManager 已清理') + await cleanup() + } catch (error) { + try { + logger.error(`${name} 清理失败:`, error) + } catch { + // 清理错误不得阻断后续关闭步骤 } - if (schedulerManager) { - await schedulerManager.destroy() - logger.info('SchedulerManager 已清理') + } +} + +function closeSocketIOServer(): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: unknown) => { + if (settled) return + settled = true + if ( + !error || + (error as NodeJS.ErrnoException | undefined)?.code === 'ERR_SERVER_NOT_RUNNING' + ) { + resolve() + return + } + reject(error) } - if (pluginManager) { - await pluginManager.cleanup() - logger.info('PluginManager 已清理') + + try { + const closeResult: unknown = io.close(error => finish(error)) + if ( + closeResult !== null && + ( + typeof closeResult === 'object' || + typeof closeResult === 'function' + ) && + typeof (closeResult as PromiseLike).then === 'function' + ) { + void Promise.resolve(closeResult).then( + () => finish(), + error => finish(error) + ) + } + } catch (error) { + finish(error) } - logger.info('管理器清理完成。') - } catch (cleanupErr) { - logger.error('清理管理器时出错:', cleanupErr) - } + }) +} - // 2. 关闭服务器 +/** HTTP/Socket 关闭(幂等):在 final flush 之前调用以冻结外部准入,可重复调用。 */ +let httpAndSocketsClosed = false +async function closeHttpAndSockets(): Promise { + if (httpAndSocketsClosed) { + return + } + httpAndSocketsClosed = true logger.info('开始关闭服务器...') - // 强制销毁所有活动的socket logger.info(`正在销毁 ${sockets.size} 个活动的socket...`) + let socketIndex = 0 for (const socket of sockets) { - socket.destroy() + const currentSocketIndex = socketIndex + socketIndex += 1 + await settle(`Raw socket ${currentSocketIndex}`, () => { + socket.destroy() + }) } - server.close(err => { - if (err && (err as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') { - logger.error('关闭HTTP服务器时出错:', err) - } else { - logger.info('HTTP服务器已关闭。') - } - // 无论HTTP服务器关闭是否出错,都准备退出 - logger.info('优雅关闭完成,服务器退出。') - clearTimeout(forceExitTimer) - process.exit(exitCode) - }) + const closeResults = await Promise.allSettled([ + closeSocketIOServer(), + new Promise((resolve, reject) => { + server.close(err => { + if (err && (err as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') { + reject(err) + return + } + logger.info('HTTP服务器已关闭。') + resolve() + }) + }) + ]) - io.close(() => { + const [ioCloseResult, httpCloseResult] = closeResults + if (ioCloseResult.status === 'rejected') { + logger.error('关闭Socket.IO服务器时出错:', ioCloseResult.reason) + } else { logger.info('Socket.IO 服务器已关闭') - }) + } + if (httpCloseResult.status === 'rejected') { + logger.error('关闭HTTP服务器时出错:', httpCloseResult.reason) + } +} + +let shuttingDown = false +async function gracefulShutdown(signal: string, exitCode = 0): Promise { + if (shuttingDown) { + logger.warn('已在关闭中,忽略重复信号。') + return + } + shuttingDown = true + const forcedExitTimer = setTimeout(() => { + logger.error('优雅关闭超时,强制退出!') + process.exit(1) + }, SHUTDOWN_FORCE_EXIT_TIMEOUT_MS) + + logger.info(`收到${signal}信号,开始优雅关闭...`) + try { + logger.info('开始清理管理器...') + // N-I3b/N5-I1:先冻结传输层准入(幂等 closeHttpAndSockets,at-most-once)—— + // HTTP/Socket 不再接受新请求,新 mutation 无法产生;已准入的 async handler 的 + // mutation 由 InstanceManager 的 admission gate + mutationChain drain 兜底: + // cleanup 先 drain 链尾(已准入 mutation 全部 settle 并落盘),再并行 internal 停止。 + await settle('HTTP/Socket 关闭', () => closeHttpAndSockets()) + // 再冻结任务调度(停 cron、等待在途任务 settle),之后不再产生新的 scheduler 触发的实例保存; + // 否则 InstanceManager final flush 之后仍可能有新 save 丢失。 + await settle('SchedulerManager', () => schedulerManager?.destroy()) + await settle('InstanceManager', () => instanceManager?.cleanup()) + await settle('TerminalManager', () => terminalManager?.cleanup()) + // TerminalManager.cleanup 关闭会话时会触发实例最终状态保存(fire-and-forget): + // 必须在退出前强制 flush,保证最新最终状态真正落盘(process exit 前最后一次写入)。 + await settle('InstanceManager final flush', () => instanceManager?.flushPendingSaves()) + await settle('GameManager', () => gameManager?.cleanup()) + await settle('SystemManager', () => systemManager?.cleanup()) + await settle('EasyTierManager', () => easyTierManager?.cleanup()) + await settle('FileWatchManager', () => fileWatchManager?.cleanup()) + await settle('SteamCMDManager', () => steamcmdManager?.cleanup()) + await settle('PluginManager', () => pluginManager?.cleanup()) + logger.info('管理器清理完成。') + } finally { + // 错误路径兜底:重复调用幂等,已关闭则直接返回。 + await settle('HTTP/Socket 关闭(finally)', () => closeHttpAndSockets()) + } + + clearTimeout(forcedExitTimer) + logger.info('优雅关闭完成,服务器退出。') + process.exit(exitCode) +} + +function handleShutdownEscape(error: unknown): void { + logger.error('优雅关闭发生未隔离异常,立即退出:', error) + process.exit(1) } process.on('SIGTERM', () => { - void gracefulShutdown('SIGTERM') + void gracefulShutdown('SIGTERM').catch(handleShutdownEscape) }) process.on('SIGINT', () => { - void gracefulShutdown('SIGINT') + void gracefulShutdown('SIGINT').catch(handleShutdownEscape) }) // 未捕获异常处理 process.on('uncaughtException', (error) => { logger.error('未捕获的异常:', error) - void gracefulShutdown('未捕获异常', 1) + void gracefulShutdown('未捕获异常', 1).catch(handleShutdownEscape) }) process.on('unhandledRejection', (reason, promise) => { logger.error('未处理的Promise拒绝:', reason) - void gracefulShutdown('未处理的Promise拒绝', 1) + void gracefulShutdown('未处理的Promise拒绝', 1).catch(handleShutdownEscape) }) // 艺术字输出函数 @@ -706,7 +770,7 @@ async function startServer() { // 设置路由 app.use('/api/auth', setupAuthRoutes(authManager)) - app.use('/api/terminal', setupTerminalRoutes(terminalManager)) + app.use('/api/terminal', authenticateToken, setupTerminalRoutes(terminalManager)) app.use('/api/games', setupGameRoutes(gameManager)) app.use('/api/system', setupSystemRoutes(systemManager)) app.use('/api/files', filesRouter) @@ -848,36 +912,7 @@ app.use('/api/easytier', createEasyTierRouter(easyTierManager, easyTierInstaller } // 终端相关事件 - socket.on('create-pty', async (data) => { - // 将前端的cwd参数映射到后端的workingDirectory - const mappedData = { - ...data, - workingDirectory: data.cwd || data.workingDirectory - } - delete mappedData.cwd - await terminalManager.createPty(socket, mappedData) - }) - - socket.on('terminal-input', (data) => { - terminalManager.handleInput(socket, data) - }) - - socket.on('terminal-resize', (data) => { - terminalManager.resizeTerminal(socket, data) - }) - - socket.on('close-pty', (data) => { - terminalManager.closePty(socket, data) - }) - - socket.on('reconnect-session', (data) => { - const success = terminalManager.reconnectSession(socket, data.sessionId) - if (success) { - socket.emit('session-reconnected', { sessionId: data.sessionId }) - } else { - socket.emit('session-reconnect-failed', { sessionId: data.sessionId }) - } - }) + registerTerminalSocketHandlers(socket, terminalManager, logger) // 游戏管理事件 socket.on('game-start', (data) => { diff --git a/server/src/modules/instance/InstanceManager.ts b/server/src/modules/instance/InstanceManager.ts index e53c2b53..4df8e657 100644 --- a/server/src/modules/instance/InstanceManager.ts +++ b/server/src/modules/instance/InstanceManager.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'events' import fs from 'fs/promises' import path from 'path' import { v4 as uuidv4 } from 'uuid' -import { TerminalManager } from '../terminal/TerminalManager.js' +import { TerminalManager, CloseResult } from '../terminal/TerminalManager.js' import os from 'os' import { exec } from 'child_process' import { promisify } from 'util' @@ -70,11 +70,37 @@ export class InstanceOperationLockedError extends Error { } } +interface PendingInstanceSave { + promise: Promise + resolve: () => void + reject: (error: unknown) => void +} + +export type InstanceTerminalLifecycleResult = + | { status: 'close-initiated'; terminalSessionId: string } + | { status: 'closed'; terminalSessionId: string } + | { status: 'still-running'; terminalSessionId: string } + export class InstanceManager extends EventEmitter { private instances: Map = new Map() private operationLocks: Map = new Map() private configPath: string private saveTimeout: NodeJS.Timeout | null = null + private pendingSave: PendingInstanceSave | null = null + private saveInProgress = false + private saveRequested = false + /** 真实写盘完成的 generation;每次成功写盘递增。 */ + private saveEpoch = 0 + /** + * 最近一次真实写盘失败的 generation 与错误(成功后清除)。 + * 供 durability barrier 观察"刚被 fire-and-forget 消费者捕获的失败": + * flushPendingSave 失败会先清空 pendingSave,仅看 pendingSave 会误判成功。 + */ + private lastSaveFailure: { epoch: number; error: unknown } | null = null + /** 关闭开始即置位:之后所有实例保存跳过 debounce,立即串行 flush,保证 shutdown 期写入不丢失。 */ + private shuttingDown = false + /** 数据类 mutation(create/update/delete)串行队列,保证失败回滚不产生逆序覆盖。 */ + private mutationChain: Promise = Promise.resolve() private logger: any private terminalManager: TerminalManager private javaManager: JavaManager @@ -86,30 +112,30 @@ export class InstanceManager extends EventEmitter { this.configPath = configPath this.javaManager = new JavaManager() } - + // 获取系统负载信息 private async getSystemLoad(): Promise<{ cpuUsage: number; memoryUsage: number }> { const os = await import('os') - + // 获取内存使用率 const totalMemory = os.totalmem() const freeMemory = os.freemem() const usedMemory = totalMemory - freeMemory const memoryUsage = (usedMemory / totalMemory) * 100 - + // 获取CPU使用率 const cpuUsage = await this.getCpuUsage() - + return { cpuUsage, memoryUsage } } - + // 获取CPU使用率 private async getCpuUsage(): Promise { const os = await import('os') - + return new Promise((resolve) => { const cpus = os.cpus() const startMeasure = cpus.map(cpu => { @@ -117,14 +143,14 @@ export class InstanceManager extends EventEmitter { const idle = cpu.times.idle return { total, idle } }) - + setTimeout(() => { const endMeasure = os.cpus().map(cpu => { const total = Object.values(cpu.times).reduce((acc, time) => acc + time, 0) const idle = cpu.times.idle return { total, idle } }) - + let totalUsage = 0 for (let i = 0; i < startMeasure.length; i++) { const totalDiff = endMeasure[i].total - startMeasure[i].total @@ -132,57 +158,57 @@ export class InstanceManager extends EventEmitter { const usage = 100 - (100 * idleDiff / totalDiff) totalUsage += usage } - + const avgUsage = totalUsage / cpus.length resolve(Math.round(avgUsage * 100) / 100) }, 100) }) } - + // 等待系统负载降低 private async waitForLoadDecrease(): Promise { const maxWaitTime = 300000 // 最大等待5分钟 const checkInterval = 5000 // 每5秒检查一次 const startTime = Date.now() - + while (Date.now() - startTime < maxWaitTime) { const systemLoad = await this.getSystemLoad() - + // 如果内存使用率超过90%,直接退出 if (systemLoad.memoryUsage > 90) { this.logger.warn(`内存使用率过高 (${systemLoad.memoryUsage.toFixed(1)}%),停止等待`) throw new Error('内存使用率过高,终止启动') } - + // 如果CPU使用率降到85%以下,继续启动 if (systemLoad.cpuUsage <= 85) { this.logger.info(`CPU使用率已降低到 ${systemLoad.cpuUsage.toFixed(1)}%,继续启动`) return } - + this.logger.info(`等待CPU负载降低,当前: CPU ${systemLoad.cpuUsage.toFixed(1)}%, 内存 ${systemLoad.memoryUsage.toFixed(1)}%`) await this.delay(checkInterval) } - + this.logger.warn('等待超时,继续启动剩余实例') } - + // 延迟函数 private async delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } - + // 检测工作目录中的启动脚本 private async detectStartScript(workingDirectory: string): Promise { try { const files = await fs.readdir(workingDirectory) const platform = os.platform() - + // 根据平台定义启动脚本文件名优先级 - const scriptNames = platform === 'win32' + const scriptNames = platform === 'win32' ? ['start.bat', 'run.bat', 'start.cmd', 'run.cmd'] : ['start.sh', 'run.sh'] - + // 按优先级查找启动脚本 for (const scriptName of scriptNames) { if (files.includes(scriptName)) { @@ -190,35 +216,35 @@ export class InstanceManager extends EventEmitter { return scriptName } } - + return null } catch (error) { this.logger.error('检测启动脚本失败:', error) return null } } - + // 检测工作目录中的jar文件 private async detectJarFile(workingDirectory: string): Promise { try { const files = await fs.readdir(workingDirectory) const jarFiles = files.filter(file => file.endsWith('.jar')) - + if (jarFiles.length === 0) { return null } - + // 如果只有一个jar文件,直接返回 if (jarFiles.length === 1) { return jarFiles[0] } - + // 如果有多个jar文件,优先选择包含server的文件名 const serverJar = jarFiles.find(file => file.toLowerCase().includes('server')) if (serverJar) { return serverJar } - + // 否则返回第一个jar文件 return jarFiles[0] } catch (error) { @@ -226,27 +252,27 @@ export class InstanceManager extends EventEmitter { return null } } - + // 获取Java路径 private async getJavaPath(javaVersion?: string): Promise { // 如果未指定Java版本,使用系统PATH中的java if (!javaVersion) { return 'java' } - + try { // 从JavaManager获取Java环境列表 const javaEnvironments = await this.javaManager.getJavaEnvironments() - + // 查找匹配的Java版本 const javaEnv = javaEnvironments.find(env => env.version === javaVersion) - + if (javaEnv && javaEnv.installed && javaEnv.javaExecutable) { this.logger.info(`找到Java ${javaVersion} 路径: ${javaEnv.javaExecutable}`) // 返回带引号的路径(处理包含空格的情况) return `"${javaEnv.javaExecutable}"` } - + // 如果没有找到指定版本的Java,回退到系统PATH中的java this.logger.warn(`未找到已安装的Java版本 ${javaVersion},使用系统PATH中的java`) return 'java' @@ -274,7 +300,7 @@ export class InstanceManager extends EventEmitter { // 尝试读取配置文件 const data = await fs.readFile(this.configPath, 'utf-8') const instancesData = JSON.parse(data) - + for (const instanceData of instancesData) { // 迁移旧的 auto-detect-jar 占位符 let startCommand = instanceData.startCommand @@ -282,7 +308,7 @@ export class InstanceManager extends EventEmitter { startCommand = 'echo Minecraft Java Edition' this.logger.info(`迁移实例 ${instanceData.name} 的旧启动命令占位符`) } - + const instance: Instance = { ...instanceData, startCommand, @@ -304,11 +330,13 @@ export class InstanceManager extends EventEmitter { } this.instances.set(instance.id, instance) } - + this.logger.info(`已加载 ${this.instances.size} 个实例配置`) - + // 启动自动启动的实例 - this.startAutoStartInstances() + void this.startAutoStartInstances().catch(error => { + this.logger.error('自动启动实例任务失败:', error) + }) } catch (error: any) { if (error.code === 'ENOENT') { this.logger.info('实例配置文件不存在,将创建新文件') @@ -319,85 +347,255 @@ export class InstanceManager extends EventEmitter { } } - // 保存实例配置 - private async saveInstances(): Promise { - try { - // 防抖保存 + private serializeInstances(): string { + const instancesData = Array.from(this.instances.values()).map(instance => ({ + id: instance.id, + name: instance.name, + description: instance.description, + workingDirectory: instance.workingDirectory, + startCommand: instance.startCommand, + autoStart: instance.autoStart, + stopCommand: instance.stopCommand, + createdAt: instance.createdAt, + lastStarted: instance.lastStarted, + lastStopped: instance.lastStopped, + enableStreamForward: instance.enableStreamForward, + programPath: instance.programPath, + terminalUser: instance.terminalUser, + instanceType: instance.instanceType, + javaVersion: instance.javaVersion, + steam: instance.steam + })) + return JSON.stringify(instancesData, null, 2) + } + + private createPendingSave(): PendingInstanceSave { + let resolve!: () => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } + } + + /** + * 数据类 mutation(create/update/delete)串行执行:共享同一个 pending save 的并发回滚 + * 若无序恢复旧快照会产生逆序覆盖(U1 A→B、U2 B→C,P reject 后 U1 恢复 A、U2 再恢复 B, + * 失败 mutation B 留在 map)。串行化后每个 mutation 的 rollback 都是对"最后写入值"的恢复, + * 最终内存状态与磁盘一致。前一 mutation 失败不阻塞后续(then(fn, fn))。 + * + * N3-I1:start/stop/close/restart 等 lifecycle 操作也经此队列进入同一 per-instance 串行域, + * 与 CRUD 共用单条串行链(不得为 lifecycle 另开第二条写路径)。链内互斥保证操作期间 + * map entry 不会被并发 update 替换或 delete 删除;配套 identity 重校验(assertInstanceIdentity) + * 在每次 await 后防御性确认 `this.instances.get(id) === instance`。 + */ + private enqueueMutation(operation: () => Promise): Promise { + // N-I3b:shutdown 一旦开始(cleanup 置 shuttingDown),所有公开 CRUD/lifecycle + // wrapper 拒绝新请求(可重试错误)。已准入(在置位前入链)的 mutation 由 cleanup + // 在 drain 阶段等待其 settle;置位后的新入链请求立即拒绝,不再追加到链尾。 + if (this.shuttingDown) { + return Promise.reject(new Error('服务器正在关闭,请稍后重试')) + } + const run = this.mutationChain.then(operation, operation) + this.mutationChain = run.then( + () => undefined, + () => undefined + ) + return run + } + + /** + * 校验 map 中仍是该对象:lifecycle/CRUD 共用串行队列后正常情况下恒为真; + * 若对象已被替换或删除(防御性路径),不得继续在 detached 对象上写状态/owner—— + * 否则 live PTY 脱离 map,stop/delete/status/shutdown 均不可见。 + */ + private assertInstanceIdentity(id: string, instance: Instance): void { + if (this.instances.get(id) !== instance) { + throw new Error('实例已被修改或删除,启动操作已中止') + } + } + + // 防抖请求共享同一个 promise,直到真实写盘完成后才 settle。 + // 关闭期间(shuttingDown)跳过防抖:立即串行 flush,保证 shutdown 期每个 mutation 都真实落盘。 + private saveInstances(): Promise { + if (this.shuttingDown) { + return this.flushSaveNow() + } + + this.saveRequested = true + if (!this.pendingSave) { + this.pendingSave = this.createPendingSave() + } + + if (!this.saveInProgress) { if (this.saveTimeout) { clearTimeout(this.saveTimeout) } - - this.saveTimeout = setTimeout(async () => { - const instancesData = Array.from(this.instances.values()).map(instance => ({ - id: instance.id, - name: instance.name, - description: instance.description, - workingDirectory: instance.workingDirectory, - startCommand: instance.startCommand, - autoStart: instance.autoStart, - stopCommand: instance.stopCommand, - createdAt: instance.createdAt, - lastStarted: instance.lastStarted, - lastStopped: instance.lastStopped, - enableStreamForward: instance.enableStreamForward, - programPath: instance.programPath, - terminalUser: instance.terminalUser, - instanceType: instance.instanceType, - javaVersion: instance.javaVersion, - steam: instance.steam - })) - - await fs.writeFile(this.configPath, JSON.stringify(instancesData, null, 2)) - this.logger.debug('实例配置已保存') + this.saveTimeout = setTimeout(() => { + this.saveTimeout = null + void this.flushPendingSave().catch(error => { + this.logger.error('保存实例配置失败:', error) + }) }, 1000) + } + + return this.pendingSave.promise + } + + /** 关闭期间跳过 1000ms 防抖,立即 flush 当前(或新建的)pending 并等待真实写盘 settle。 */ + private async flushSaveNow(): Promise { + // 置 dirty 请求:若另一份写盘正在 in-flight,其 do-while 循环看到该标志会 + // 在完成后续写下一快照(否则共享 pending 的调用方会在旧快照上误判成功)。 + this.saveRequested = true + let pending = this.pendingSave + if (!pending) { + this.pendingSave = this.createPendingSave() + pending = this.pendingSave + } + await this.flushPendingSave() + await pending.promise + } + + private async writeInstancesToDisk(): Promise { + await fs.writeFile(this.configPath, this.serializeInstances()) + this.logger.debug('实例配置已保存') + this.saveEpoch += 1 + this.lastSaveFailure = null + } + + private async flushPendingSave(): Promise { + const pending = this.pendingSave + if (!pending) { + return + } + if (this.saveInProgress) { + return pending.promise + } + + if (this.saveTimeout) { + clearTimeout(this.saveTimeout) + this.saveTimeout = null + } + this.saveInProgress = true + try { + do { + this.saveRequested = false + await this.writeInstancesToDisk() + } while (this.saveRequested) + + if (this.pendingSave === pending) { + this.pendingSave = null + } + pending.resolve() } catch (error) { - this.logger.error('保存实例配置失败:', error) + this.saveRequested = false + if (this.pendingSave === pending) { + this.pendingSave = null + } + // 记录失败的 save generation:barrier 即使采样不到 pendingSave 也能观察到该失败。 + this.saveEpoch += 1 + this.lastSaveFailure = { epoch: this.saveEpoch, error } + // 失败只通过共享 pending promise 的 rejection 传播(单一来源、单一消费者), + // 不再在此处二次 throw,避免 cleanup() 中 `await finalSave` 变成无人消费的 rejection。 + pending.reject(error) + } finally { + this.saveInProgress = false + } + } + + /** + * 触发挂起的保存并等待真实写盘 settle(成功或失败都通过共享 pending promise 传播)。 + * 供 stop/close 的 callback 路径与 shutdown 使用:callback 清 ID 不能绕过 awaited save。 + * 即使 flush 失败后 pendingSave 已被清空、rejection 已被 fire-and-forget 消费者捕获, + * 也能通过 lastSaveFailure generation 观察到刚发生的真实失败。 + * + * N-I3a barrier 语义:入口采样目标 epoch,随后等待"采样时在途/挂起 + 采样后产生"的 + * 全部 mutation generation 真实落盘(每次写盘成功/失败都递增 saveEpoch)后才 resolve; + * 任何真实失败(含已被 fire-and-forget 消费的失败 generation)都以 throw 传播, + * closeTerminal/stopInstance 不得在真实失败后返回 closed。 + */ + private async awaitPendingSaveDurability(): Promise { + // 采样目标 epoch:仅统计采样点之后的 generation(失败水印在后续成功写盘时由 + // writeInstancesToDisk 清空,与 round-3 语义一致:被消费的失败在下次成功前必须抛错)。 + const targetEpoch = this.saveEpoch + while (true) { + const pending = this.pendingSave + if (pending) { + // 与 flushSaveNow 一致置 dirty:若另一份写盘 in-flight,其 do-while 在完成后续写 + // 下一快照,覆盖"采样后产生且尚未序列化"的 mutation generation,再 resolve 共享 pending。 + this.saveRequested = true + await this.flushPendingSave() + await pending.promise + } + if (this.lastSaveFailure) { + throw this.lastSaveFailure.error + } + if ( + this.saveEpoch >= targetEpoch && + !this.saveInProgress && + !this.saveRequested && + !this.pendingSave && + !this.saveTimeout + ) { + return + } + // 仍有在途写/dirty 请求/新 pending(采样后产生的 generation):继续等待落盘。 } } + /** + * 强制 flush 挂起的实例保存;供 shutdown 在 TerminalManager.cleanup 之后 + * 调用,确保其触发的最新最终状态落盘后才退出进程。 + * 使用立即写盘语义:即使没有挂起保存也会写入当前状态(对上次失败的写盘做最终重试)。 + */ + public async flushPendingSaves(): Promise { + await this.flushSaveNow() + } + // 启动自动启动的实例(错峰启动) private async startAutoStartInstances(): Promise { const autoStartInstances = Array.from(this.instances.values()).filter(instance => instance.autoStart) - + if (autoStartInstances.length === 0) { return } - + this.logger.info(`开始错峰启动 ${autoStartInstances.length} 个自动启动实例`) - + for (let i = 0; i < autoStartInstances.length; i++) { const instance = autoStartInstances[i] - + try { // 检查系统负载 const systemLoad = await this.getSystemLoad() - + // 如果内存使用率超过90%,直接终止启动 if (systemLoad.memoryUsage > 90) { this.logger.warn(`内存使用率过高 (${systemLoad.memoryUsage.toFixed(1)}%),终止剩余实例启动`) break } - + // 如果CPU使用率超过90%,等待负载降低 if (systemLoad.cpuUsage > 90) { this.logger.warn(`CPU使用率过高 (${systemLoad.cpuUsage.toFixed(1)}%),等待负载降低后继续启动`) await this.waitForLoadDecrease() } - + this.logger.info(`错峰启动实例 (${i + 1}/${autoStartInstances.length}): ${instance.name}`) await this.startInstance(instance.id) - + // 启动间隔,避免同时启动造成负载峰值 if (i < autoStartInstances.length - 1) { await this.delay(2000) // 2秒间隔 } - + } catch (error) { this.logger.error(`启动实例 ${instance.name} 失败:`, error) // 继续启动下一个实例 } } - + this.logger.info('错峰启动完成') } @@ -439,126 +637,187 @@ export class InstanceManager extends EventEmitter { } // 创建实例 - public async createInstance( + public createInstance( data: CreateInstanceRequest, operationLock?: InstanceOperationLockRequest ): Promise { - const id = uuidv4() - const instance: Instance = { - id, - ...data, - status: 'stopped', - createdAt: new Date().toISOString() - } - - this.instances.set(id, instance) - if (operationLock) { - this.operationLocks.set(id, operationLock) - } - try { - await this.saveInstances() - } catch (error) { - this.instances.delete(id) - if (operationLock) this.releaseOperationLock(id, operationLock.token) - throw error - } - - this.logger.info(`创建实例: ${instance.name} (${id})`) - this.emit('instance-created', instance) - - return this.getInstance(id)! + return this.enqueueMutation(async () => { + const id = uuidv4() + const instance: Instance = { + id, + ...data, + status: 'stopped', + createdAt: new Date().toISOString() + } + + this.instances.set(id, instance) + if (operationLock) { + this.operationLocks.set(id, operationLock) + } + try { + await this.saveInstances() + } catch (error) { + this.instances.delete(id) + if (operationLock) this.releaseOperationLock(id, operationLock.token) + throw error + } + + this.logger.info(`创建实例: ${instance.name} (${id})`) + this.emit('instance-created', instance) + + return this.getInstance(id)! + }) } // 更新实例 - public async updateInstance( + public updateInstance( id: string, data: CreateInstanceRequest, operationToken?: string ): Promise { - const instance = this.instances.get(id) - if (!instance) { - return null - } - this.assertOperationLockOwner(id, operationToken) - - // 如果实例正在运行,不允许修改某些关键配置 - if (instance.status === 'running') { - throw new Error('无法修改正在运行的实例配置') - } - - const updatedInstance: Instance = { - ...instance, - ...data - } - - this.instances.set(id, updatedInstance) - await this.saveInstances() - - this.logger.info(`更新实例: ${updatedInstance.name} (${id})`) - this.emit('instance-updated', updatedInstance) - - return this.getInstance(id)! + return this.enqueueMutation(async () => { + const instance = this.instances.get(id) + if (!instance) { + return null + } + this.assertOperationLockOwner(id, operationToken) + + // N3-I1:不得把 live/retained terminal owner 复制到新 map object——`{...instance,...data}` + // 会保留旧 terminalSessionId,而旧 PTY callback 捕获的是旧对象、其 identity guard 在 + // 对象替换后直接返回,map/磁盘将永久保留 stale error/stopping + session 组合。 + // 与 start 的 I9 守卫一致:先完成 confirmed close(closeTerminal/delete)再修改配置。 + if ( + instance.terminalSessionId && + this.terminalManager.hasTarget(instance.terminalSessionId) + ) { + throw new Error('实例终端会话仍在运行或保留中,请先关闭终端后重试') + } + + // 如果实例正在运行,不允许修改某些关键配置 + if (instance.status === 'running') { + throw new Error('无法修改正在运行实例的配置') + } + + const updatedInstance: Instance = { + ...instance, + ...data + } + + this.instances.set(id, updatedInstance) + try { + await this.saveInstances() + } catch (error) { + // 真实写失败:仅当 map 中仍是我们写入的对象时恢复旧对象(串行队列保证这一点), + // 与 createInstance 的 rollback 一致;失败 mutation 不留在内存。 + this.instances.set(id, instance) + throw error + } + + this.logger.info(`更新实例: ${updatedInstance.name} (${id})`) + this.emit('instance-updated', updatedInstance) + + return this.getInstance(id)! + }) } // 删除实例 - public async deleteInstance(id: string, operationToken?: string): Promise { - const instance = this.instances.get(id) - if (!instance) { - return false - } - this.assertOperationLockOwner(id, operationToken) - - // 如果实例正在运行,先停止它 - if (instance.status === 'running') { - await this.stopInstance(id, operationToken) - } - - this.instances.delete(id) - await this.saveInstances() - - this.logger.info(`删除实例: ${instance.name} (${id})`) - this.emit('instance-deleted', { id, name: instance.name }) - - return true + public deleteInstance(id: string, operationToken?: string): Promise { + return this.enqueueMutation(async () => { + const instance = this.instances.get(id) + if (!instance) { + return false + } + this.assertOperationLockOwner(id, operationToken) + + // 只有终端目标确认删除后才能移除实例元数据。 + if (instance.terminalSessionId) { + const closeResult = await this.closeTerminalInternal(id) + if (closeResult.status === 'still-running') { + throw new Error('实例终端仍在运行,请稍后重试删除') + } + } + + this.instances.delete(id) + try { + await this.saveInstances() + } catch (error) { + // 真实写失败:恢复实例,与 createInstance 的 rollback 一致 + this.instances.set(id, instance) + throw error + } + + this.logger.info(`删除实例: ${instance.name} (${id})`) + this.emit('instance-deleted', { id, name: instance.name }) + + return true + }) } // 启动实例 - public async startInstance(id: string, operationToken?: string): Promise<{ success: boolean; terminalSessionId?: string }> { + // N3-I1:lifecycle 与 CRUD 进入同一串行队列;internal body 在每次 await 后重校验对象 identity。 + public startInstance( + id: string, + operationToken?: string + ): Promise<{ success: boolean; terminalSessionId?: string }> { + return this.enqueueMutation(() => this.startInstanceInternal(id, operationToken)) + } + + private async startInstanceInternal( + id: string, + operationToken?: string + ): Promise<{ success: boolean; terminalSessionId?: string }> { const instance = this.instances.get(id) if (!instance) { throw new Error('实例不存在') } + this.assertOperationLockOwner(id, operationToken) - + if (instance.status === 'running') { throw new Error('实例已在运行') } - + if (instance.status === 'starting') { throw new Error('实例正在启动中') } - + + // retained ownership 闭环:error/stopping 实例若仍持有 live/retained 终端目标, + // 下一次 start 必须拒绝而不是覆盖旧 owner;先完成 confirmed close 再重试。 + if ( + instance.terminalSessionId && + this.terminalManager.hasTarget(instance.terminalSessionId) + ) { + throw new Error('实例终端会话仍在运行或保留中,请先关闭终端后重试') + } + + let retainedTerminalSessionId: string | undefined + let startCommandTimer: NodeJS.Timeout | undefined + let createdSessionId: string | undefined + let terminalFinalized = false + let terminalOwnershipConsumed = false try { // 更新状态为启动中 instance.status = 'starting' this.emit('instance-status-changed', { id, status: 'starting' }) - + // 检查工作目录是否存在 try { await fs.access(instance.workingDirectory) } catch { throw new Error(`工作目录不存在: ${instance.workingDirectory}`) } - + this.assertInstanceIdentity(id, instance) + // 根据平台检查和处理启动命令 const platform = os.platform() let startCommand = instance.startCommand.trim() - + // 我的世界Java版 - 自动检测启动脚本或jar文件 if (instance.instanceType === 'minecraft-java') { // 优先检测启动脚本 const startScript = await this.detectStartScript(instance.workingDirectory) - + this.assertInstanceIdentity(id, instance) + if (startScript) { // 检测到启动脚本,直接使用脚本启动 if (platform === 'win32') { @@ -571,18 +830,20 @@ export class InstanceManager extends EventEmitter { } else { // 未检测到启动脚本,使用jar文件启动 const jarFile = await this.detectJarFile(instance.workingDirectory) + this.assertInstanceIdentity(id, instance) if (!jarFile) { const errorMsg = `启动失败:工作目录中未找到启动脚本或.jar文件\n\n请确保工作目录(${instance.workingDirectory})中包含以下文件之一:\n` + - (platform === 'win32' - ? '• 启动脚本:start.bat, run.bat, start.cmd, run.cmd\n• 或服务端核心:.jar文件' + (platform === 'win32' + ? '• 启动脚本:start.bat, run.bat, start.cmd, run.cmd\n• 或服务端核心:.jar文件' : '• 启动脚本:start.sh, run.sh\n• 或服务端核心:.jar文件') this.logger.error(errorMsg) throw new Error(errorMsg) } - + // 获取Java路径 const javaPath = await this.getJavaPath(instance.javaVersion) - + this.assertInstanceIdentity(id, instance) + // 构建启动命令 // 在Windows PowerShell中,如果路径包含引号,需要使用 & 调用运算符 if (platform === 'win32' && javaPath.startsWith('"')) { @@ -593,25 +854,26 @@ export class InstanceManager extends EventEmitter { this.logger.info(`我的世界Java版自动生成启动命令: ${startCommand}`) } } - + // 我的世界基岩版 - 检测对应平台的启动文件 if (instance.instanceType === 'minecraft-bedrock') { const bedrockExecutable = platform === 'win32' ? 'bedrock_server.exe' : 'bedrock_server' const bedrockPath = path.join(instance.workingDirectory, bedrockExecutable) - + try { await fs.access(bedrockPath) + this.assertInstanceIdentity(id, instance) this.logger.info(`我的世界基岩版检测到启动文件: ${bedrockExecutable}`) } catch { const errorMsg = `启动失败:工作目录中未找到基岩版服务端启动文件\n\n请确保工作目录(${instance.workingDirectory})中包含以下文件:\n` + - (platform === 'win32' - ? '• 基岩版服务端:bedrock_server.exe' + (platform === 'win32' + ? '• 基岩版服务端:bedrock_server.exe' : '• 基岩版服务端:bedrock_server') this.logger.error(errorMsg) throw new Error(errorMsg) } } - + // 检查是否是 ./ 开头的命令 if (startCommand.startsWith('./') && (platform === 'linux' || platform === 'darwin')) { // Linux/Mac 平台:自动为文件添加可执行权限 @@ -619,16 +881,23 @@ export class InstanceManager extends EventEmitter { const commandParts = startCommand.split(/\s+/) const scriptPath = commandParts[0].substring(2) // 去掉 ./ const fullPath = path.join(instance.workingDirectory, scriptPath) - + try { // 检查文件是否存在 await fs.access(fullPath) - + this.assertInstanceIdentity(id, instance) + // 添加可执行权限 this.logger.info(`为文件添加可执行权限: ${fullPath}`) await execAsync(`chmod +x "${fullPath}"`) + this.assertInstanceIdentity(id, instance) this.logger.info(`已为 ${scriptPath} 添加可执行权限`) } catch (error: any) { + // N3-I1(b):identity 丢失不是 chmod 失败——不得在此吞掉(否则会在已替换/删除的 + // 对象上继续启动),必须向外传播,由外层 catch 做 detached 回收/拒绝。 + if (this.instances.get(id) !== instance) { + throw error + } if (error.code === 'ENOENT') { this.logger.warn(`启动脚本不存在: ${fullPath},将尝试继续启动`) } else { @@ -636,31 +905,44 @@ export class InstanceManager extends EventEmitter { } } } - + // 生成终端会话ID const terminalSessionId = `instance-${id}-${Date.now()}` - + const handleTerminalFinalized = () => { + terminalFinalized = true + if (this.instances.get(id) !== instance) { + // N3-I1:map 中对象已被替换/删除:不在 detached 旧对象上写状态/触发保存 + this.logger.warn(`实例 ${instance.name} 已被修改或删除,忽略旧终端退出回调: ${terminalSessionId}`) + return + } + if (instance.terminalSessionId !== terminalSessionId) { + return + } + terminalOwnershipConsumed = true + this.logger.info(`实例 ${instance.name} 终端会话退出`) + instance.status = 'stopped' + instance.pid = undefined + instance.terminalSessionId = undefined + instance.lastStopped = new Date().toISOString() + this.emit('instance-status-changed', { id, status: 'stopped' }) + void this.saveInstances().catch(error => { + this.logger.error(`保存实例 ${instance.name} 的退出状态失败:`, error) + }) + } + // 创建一个虚拟socket对象用于终端管理器 const virtualSocket = { id: terminalSessionId, emit: (event: string, data: any) => { - // 转发终端输出事件 if (event === 'terminal-output') { this.emit('instance-output', { id, data: data.data }) } else if (event === 'terminal-exit') { - // 防止旧终端会话的退出事件覆盖新会话的状态 - if (instance.terminalSessionId !== terminalSessionId) { - this.logger.info(`忽略旧终端会话退出事件: ${terminalSessionId} (当前: ${instance.terminalSessionId})`) + handleTerminalFinalized() + } else if (event === 'terminal-error') { + if (this.instances.get(id) !== instance) { + this.logger.warn(`实例 ${instance.name} 已被修改或删除,忽略旧终端错误事件: ${terminalSessionId}`) return } - this.logger.info(`实例 ${instance.name} 终端会话退出`) - instance.status = 'stopped' - instance.pid = undefined - instance.terminalSessionId = undefined - instance.lastStopped = new Date().toISOString() - this.emit('instance-status-changed', { id, status: 'stopped' }) - this.saveInstances() - } else if (event === 'terminal-error') { if (instance.terminalSessionId !== terminalSessionId) { this.logger.info(`忽略旧终端会话错误事件: ${terminalSessionId} (当前: ${instance.terminalSessionId})`) return @@ -668,37 +950,18 @@ export class InstanceManager extends EventEmitter { this.logger.error(`实例 ${instance.name} 终端错误:`, data.error) instance.status = 'error' instance.pid = undefined - instance.terminalSessionId = undefined + if (!data.retained && !this.terminalManager.hasTarget(terminalSessionId)) { + instance.terminalSessionId = undefined + } this.emit('instance-status-changed', { id, status: 'error' }) - this.saveInstances() + void this.saveInstances().catch(error => { + this.logger.error(`保存实例 ${instance.name} 的错误状态失败:`, error) + }) } } } as any - - // 等待终端会话创建完成 - const terminalCreated = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('终端会话创建超时')) - }, 5000) - - const originalEmit = virtualSocket.emit - virtualSocket.emit = (event: string, data: any) => { - if (event === 'pty-created') { - clearTimeout(timeout) - resolve(data) - } else if (event === 'terminal-error') { - clearTimeout(timeout) - reject(new Error(data.error)) - } - - // 调用原始的emit方法处理其他事件 - originalEmit.call(virtualSocket, event, data) - } - }) - - // 通过终端管理器创建PTY会话 - // 使用更大的默认终端大小,前端会根据实际容器大小进行调整 - await this.terminalManager.createPty(virtualSocket, { + + const createResult = await this.terminalManager.createPty(virtualSocket, { sessionId: terminalSessionId, name: `实例: ${instance.name} (${instance.id})`, cols: 100, @@ -708,222 +971,476 @@ export class InstanceManager extends EventEmitter { programPath: instance.programPath || '', autoCloseOnForwardExit: instance.enableStreamForward || false, terminalUser: instance.terminalUser + }, { + onExit: handleTerminalFinalized }) - - // 等待终端创建完成 - await terminalCreated - - // 保存终端会话ID - instance.terminalSessionId = terminalSessionId - + // N3-I1(b):先记录已创建的 session ID,再做 identity 校验——若 identity 在 + // createPty 返回后的窗口丢失(防御性路径),catch 仍能以 createdSessionId 对 + // 该 session 执行 bounded close 回收,而不是无 session 可关。 + createdSessionId = createResult.sessionId + this.assertInstanceIdentity(id, instance) + if (createResult.status !== 'ready') { + if ( + createResult.status === 'failed-retained' && + !terminalFinalized && + this.terminalManager.hasTarget(createResult.sessionId) + ) { + retainedTerminalSessionId = createResult.sessionId + } + throw new Error(createResult.error) + } + if (terminalFinalized || !this.terminalManager.hasSession(createResult.sessionId)) { + throw new Error('终端会话在创建完成后立即退出') + } + + instance.terminalSessionId = createResult.sessionId + + // 更新实例状态 + instance.status = 'running' + instance.lastStarted = new Date().toISOString() + + this.logger.info(`启动实例: ${instance.name} (终端会话: ${terminalSessionId}), 启动命令: ${startCommand}`) + + this.emit('instance-status-changed', { id, status: 'running' }) + + // 启动命令必须与持久化成功绑定:先完成可观察的持久化并成功,才安排启动命令; + // 保存失败则保留 live owner、不安排命令、API 返回失败(既有 owner guard 继续生效)。 + await this.saveInstances() + + // awaited save 窗口内 terminal 可能已退出(callback 清 owner、改 stopped): + // 返回前重查,已退出则返回失败(可重试),不得返回 success。 + // N2-I4 扩展:同时校验 map identity(未被替换/删除)与 status 仍为 running + // (save 窗口内 terminal-error 可把状态改为 error 但仍保留 live target)。 + if ( + this.instances.get(id) !== instance || + terminalFinalized || + instance.status !== 'running' || + instance.terminalSessionId !== terminalSessionId || + !this.terminalManager.hasSession(createResult.sessionId) + ) { + throw new Error('终端会话在启动保存期间已退出,请重试启动') + } + // 只有在未启用输出流转发时才执行启动命令 // 启用输出流转发时,程序会通过programPath直接启动,避免重复执行 if (!instance.enableStreamForward) { // 延迟执行启动命令,确保终端完全初始化 - setTimeout(() => { + // timer 句柄被捕获:保存失败时可取消,避免 API 返回失败后仍启动游戏 + startCommandTimer = setTimeout(() => { + if (instance.terminalSessionId !== terminalSessionId) { + return + } this.terminalManager.handleInput(virtualSocket, { sessionId: terminalSessionId, data: startCommand + '\r' // 使用动态生成的启动命令 }) }, 1000) } - - // 更新实例状态 - instance.status = 'running' - instance.lastStarted = new Date().toISOString() - - this.logger.info(`启动实例: ${instance.name} (终端会话: ${terminalSessionId}), 启动命令: ${startCommand}`) - - this.emit('instance-status-changed', { id, status: 'running' }) - await this.saveInstances() - + return { success: true, terminalSessionId } } catch (error) { this.logger.error(`启动实例 ${instance.name} 失败:`, error) - instance.status = 'error' - instance.pid = undefined - instance.terminalSessionId = undefined - this.emit('instance-status-changed', { id, status: 'error' }) + if (startCommandTimer) { + clearTimeout(startCommandTimer) + startCommandTimer = undefined + } + if (this.instances.get(id) !== instance) { + // N3-I1:map entry 已被并发 update 替换或 delete 删除——不得在 detached 旧对象上 + // 写状态/owner、不得向 map/磁盘写 detached 状态;对已创建的 live PTY 执行 bounded + // close 回收(仍 running 则由服务端 retained 语义保留,可经 close/重连路径回收)。 + if (createdSessionId && this.terminalManager.hasTarget(createdSessionId)) { + this.logger.warn(`实例 ${instance.name} 已被修改或删除,回收已创建的终端会话: ${createdSessionId}`) + void this.terminalManager.closePty( + { id: createdSessionId, emit: () => {} } as any, + { sessionId: createdSessionId } + ).catch((closeError: unknown) => { + this.logger.error(`回收已创建的终端会话失败: ${createdSessionId}`, closeError) + }) + } + throw error + } + if (terminalOwnershipConsumed) { + // callback 已消费归属并置 stopped(终端在保存窗口内退出): + // 保持 stopped 状态,允许直接重试启动,不覆盖为 error。 + } else { + instance.status = 'error' + instance.pid = undefined + if (retainedTerminalSessionId !== undefined) { + instance.terminalSessionId = retainedTerminalSessionId + } + // ready 分支的 save 失败:保留 live terminal owner(不清 terminalSessionId), + // 并已取消启动命令 timer;API 返回失败但不会产生业务 orphan。 + this.emit('instance-status-changed', { id, status: 'error' }) + } throw error } } // 重启实例 - public async restartInstance(id: string, operationToken?: string): Promise<{ success: boolean; terminalSessionId?: string }> { - const instance = this.instances.get(id) - if (!instance) { - throw new Error('实例不存在') - } - this.assertOperationLockOwner(id, operationToken) - + // N5-I1:拆分为链内发起 → 链外等待 → 链内裁决/重启 的流水线;长等待(优雅停止 10s、 + // bounded close 4s)不持有全局 mutationChain,不再阻塞其它实例的 CRUD/lifecycle。 + public async restartInstance( + id: string, + operationToken?: string + ): Promise<{ success: boolean; terminalSessionId?: string }> { + const restartDeadline = Date.now() + 20_000 try { - this.logger.info(`重启实例: ${instance.name}`) - - // 如果实例正在运行,先停止它 - if (instance.status === 'running') { - await this.stopInstance(id, operationToken) - - // 等待实例完全停止 - await new Promise(resolve => { - const checkStatus = () => { - if (instance.status === 'stopped' || instance.status === 'error') { - resolve(void 0) - } else { - setTimeout(checkStatus, 500) - } - } - checkStatus() - }) - - // 额外等待2秒确保旧终端会话完全清理 - await new Promise(resolve => setTimeout(resolve, 2000)) - } - - // 重新启动实例 - const result = await this.startInstance(id, operationToken) - this.logger.info(`实例 ${instance.name} 重启完成`) - - return result + // Phase 1(链内):校验 + 捕获 + 发起停止/关闭(无终端则直接进入启动阶段) + const phase1 = await this.enqueueMutation(async () => { + const instance = this.instances.get(id) + if (!instance) { + throw new Error('实例不存在') + } + this.assertOperationLockOwner(id, operationToken) + this.logger.info(`重启实例: ${instance.name}`) + if (!instance.terminalSessionId) { + return { mode: 'start-only' } as { mode: 'start-only' } + } + if (instance.status === 'running') { + return { mode: 'stop', ...this.stopInitiateSerial(id, operationToken) } as const + } + return { mode: 'close', ...this.closeInitiateSerial(id) } as const + }) + + // Phase 2(链外):等待优雅退出 / bounded close 完成 + let closeOutcome: InstanceTerminalLifecycleResult | null = null + if (phase1.mode === 'stop') { + closeOutcome = await this.stopReleaseAwaitAndFinalize(id, phase1) + } else if (phase1.mode === 'close') { + const closeResult = await phase1.closePromise + closeOutcome = await this.enqueueMutation(() => + this.closeFinalizeSerial(id, phase1, closeResult) + ) + } + + // Phase 3(链内):still-running/超时裁决 + 重新启动(start 全程链内,保留 N3-I1 串行域) + return this.enqueueMutation(async () => { + if (closeOutcome && closeOutcome.status === 'still-running') { + throw new Error('实例终端仍在运行,请稍后重试重启') + } + if (Date.now() > restartDeadline) { + throw new Error('实例停止超时,请稍后重试重启') + } + const result = await this.startInstanceInternal(id, operationToken) + this.logger.info(`实例 ${id} 重启完成`) + return result + }) } catch (error) { - this.logger.error(`重启实例 ${instance.name} 失败:`, error) + this.logger.error(`重启实例 ${id} 失败:`, error) throw error } } - // 停止实例 - public async stopInstance(id: string, operationToken?: string): Promise { + private async waitForTerminalRelease( + instance: Instance, + terminalSessionId: string, + timeoutMs: number + ): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if ( + instance.terminalSessionId !== terminalSessionId || + !this.terminalManager.hasTarget(terminalSessionId) + ) { + return true + } + await this.delay(100) + } + return instance.terminalSessionId !== terminalSessionId || + !this.terminalManager.hasTarget(terminalSessionId) + } + + /** + * 停止:链内"发起"(校验 + 置 stopping + 发停止命令),不做任何等待。 + * N5-I1:与最终落盘分离,调用方在链外等待 release 后再回到链内落盘, + * 10s 优雅等待不再独占全局 mutationChain。 + */ + private stopInitiateSerial( + id: string, + operationToken?: string, + skipOperationLock = false + ): { instance: Instance; terminalSessionId: string } { const instance = this.instances.get(id) if (!instance) { throw new Error('实例不存在') } - this.assertOperationLockOwner(id, operationToken) - + if (!skipOperationLock) { + this.assertOperationLockOwner(id, operationToken) + } if (instance.status !== 'running') { throw new Error('实例未在运行') } - if (!instance.terminalSessionId) { throw new Error('终端会话ID不存在') } - + + const terminalSessionId = instance.terminalSessionId + instance.status = 'stopping' + this.emit('instance-status-changed', { id, status: 'stopping' }) + this.logger.info(`停止实例: ${instance.name} (终端会话: ${terminalSessionId})`) + + const virtualSocket = { + id: terminalSessionId, + emit: () => {} + } as any + const stopInput = instance.stopCommand === 'ctrl+c' + ? '\u0003' + : `${instance.stopCommand}\r` + this.terminalManager.handleInput(virtualSocket, { + sessionId: terminalSessionId, + data: stopInput + }) + return { instance, terminalSessionId } + } + + /** + * 停止最终落盘(链内):优雅等待成功后的状态清理。map 对象已被替换/删除时 + * 不在 detached 对象上写状态(N3-I1),直接按已释放返回 closed。 + */ + private async stopFinalizeSerial( + id: string, + captured: { instance: Instance; terminalSessionId: string } + ): Promise { + const { instance, terminalSessionId } = captured + if (this.instances.get(id) !== instance) { + this.logger.warn( + `实例 ${instance.name} 在停止等待期间已被修改或删除,跳过旧会话状态落盘` + ) + return { status: 'closed', terminalSessionId } + } + if (instance.terminalSessionId === terminalSessionId) { + await this.markInstanceTerminalClosed(id, instance, terminalSessionId) + } else { + // callback 已清 ID:其 fire-and-forget save 必须真正落盘后才可返回成功 + await this.awaitPendingSaveDurability() + } + return { status: 'closed', terminalSessionId } + } + + /** + * 关闭:链内"发起"(校验 + 发起 bounded closePty,返回 closePromise handle)。 + * 可复用 stop 已捕获的实例/会话(stop 优雅等待失败后的升级路径)。 + */ + private closeInitiateSerial( + id: string, + captured?: { instance: Instance; terminalSessionId: string } + ): { instance: Instance; terminalSessionId: string; closePromise: Promise } { + const instance = captured?.instance ?? this.instances.get(id) + if (!instance) { + throw new Error('实例不存在') + } + const terminalSessionId = captured?.terminalSessionId ?? instance.terminalSessionId + if (!terminalSessionId) { + throw new Error('终端会话不存在') + } + + this.logger.info(`关闭实例终端: ${instance.name} (终端会话: ${terminalSessionId})`) + const virtualSocket = { + id: terminalSessionId, + emit: () => {} + } as any + + const closePromise = this.terminalManager.closePty(virtualSocket, { + sessionId: terminalSessionId + }) + return { instance, terminalSessionId, closePromise } + } + + /** + * 关闭最终裁决与落盘(链内):still-running/hasTarget 保留实例状态; + * map 对象已被替换/删除时不在 detached 对象上写状态(N3-I1)。 + */ + private async closeFinalizeSerial( + id: string, + init: { instance: Instance; terminalSessionId: string }, + closeResult: CloseResult + ): Promise { + const { instance, terminalSessionId } = init + if (this.instances.get(id) !== instance) { + this.logger.warn( + `实例 ${instance.name} 在关闭等待期间已被修改或删除,跳过旧会话状态落盘` + ) + return { + status: closeResult === 'still-running' ? 'still-running' : 'closed', + terminalSessionId + } + } + if ( + closeResult === 'still-running' || + this.terminalManager.hasTarget(terminalSessionId) + ) { + this.logger.error( + `实例 ${instance.name} 的终端进程仍在运行,保留实例状态,需要手动重试关闭终端` + ) + return { status: 'still-running', terminalSessionId } + } + + if (instance.terminalSessionId === terminalSessionId) { + await this.markInstanceTerminalClosed(id, instance, terminalSessionId) + } else { + // callback 已清 ID:其 fire-and-forget save 必须真正落盘后才可返回成功 + await this.awaitPendingSaveDurability() + } + return { status: 'closed', terminalSessionId } + } + + /** + * 停止的链外等待 + 链内落盘流水线(公开 stopInstance 使用): + * 链内发起 → 链外等待优雅退出(最长 10s)→ 链内落盘;优雅退出失败则 + * 链内发起 bounded close → 链外等待(SIGTERM 3s + SIGKILL 1s)→ 链内裁决。 + */ + private async stopReleaseAwaitAndFinalize( + id: string, + captured: { instance: Instance; terminalSessionId: string } + ): Promise { + if (await this.waitForTerminalRelease(captured.instance, captured.terminalSessionId, 10_000)) { + return this.enqueueMutation(() => this.stopFinalizeSerial(id, captured)) + } + + this.logger.warn(`实例 ${captured.instance.name} 未能优雅退出,强制关闭终端会话`) + const escalated = await this.enqueueMutation(async () => this.closeInitiateSerial(id, captured)) + const closeResult = await escalated.closePromise + return this.enqueueMutation(() => this.closeFinalizeSerial(id, escalated, closeResult)) + } + + /** + * 链内完整停止(供 cleanup 等 admission 已冻结、串行域已排空的调用方使用; + * 等待在调用方上下文中进行,多个实例可并行)。 + */ + private async stopInstanceInternal(id: string): Promise { + const captured = this.stopInitiateSerial(id, undefined, true) try { - instance.status = 'stopping' - this.emit('instance-status-changed', { id, status: 'stopping' }) - - this.logger.info(`停止实例: ${instance.name} (终端会话: ${instance.terminalSessionId})`) - - // 创建虚拟socket用于终端操作 - const virtualSocket = { - id: instance.terminalSessionId, - emit: () => {} - } as any - - // 根据配置的停止命令发送相应的输入 - switch (instance.stopCommand) { - case 'ctrl+c': - // 发送 Ctrl+C 字符 (ASCII 3) - this.terminalManager.handleInput(virtualSocket, { - sessionId: instance.terminalSessionId, - data: '\u0003' // Ctrl+C - }) - break - case 'stop': - // 向终端输入 'stop' 命令 - this.terminalManager.handleInput(virtualSocket, { - sessionId: instance.terminalSessionId, - data: 'stop\r' - }) - break - case 'exit': - // 向终端输入 'exit' 命令 - this.terminalManager.handleInput(virtualSocket, { - sessionId: instance.terminalSessionId, - data: 'exit\r' - }) - break - case 'quit': - // 向终端输入 'quit' 命令 - this.terminalManager.handleInput(virtualSocket, { - sessionId: instance.terminalSessionId, - data: 'quit\r' - }) - break + if (await this.waitForTerminalRelease(captured.instance, captured.terminalSessionId, 10_000)) { + return await this.stopFinalizeSerial(id, captured) } - - // 等待一段时间后如果实例仍在运行,则强制关闭终端会话 - setTimeout(() => { - if (instance.status === 'stopping') { - this.logger.warn(`实例 ${instance.name} 未能优雅退出,强制关闭终端会话`) - this.terminalManager.closePty(virtualSocket, { - sessionId: instance.terminalSessionId! - }) - - // 手动更新实例状态 - instance.status = 'stopped' - instance.pid = undefined - instance.terminalSessionId = undefined - instance.lastStopped = new Date().toISOString() - this.emit('instance-status-changed', { id, status: 'stopped' }) - this.saveInstances() - } - }, 10000) // 10秒超时 - - return true + + this.logger.warn(`实例 ${captured.instance.name} 未能优雅退出,强制关闭终端会话`) + const escalated = this.closeInitiateSerial(id, captured) + const closeResult = await escalated.closePromise + return await this.closeFinalizeSerial(id, escalated, closeResult) } catch (error) { - this.logger.error(`停止实例 ${instance.name} 失败:`, error) - instance.status = 'error' + this.logger.error(`停止实例 ${captured.instance.name} 失败:`, error) + if ( + this.instances.get(id) === captured.instance && + captured.instance.terminalSessionId === captured.terminalSessionId + ) { + captured.instance.status = 'error' + } throw error } } - // 关闭终端 - public async closeTerminal(id: string): Promise { - const instance = this.instances.get(id) - if (!instance) { - throw new Error('实例不存在') + // 停止实例:等待 10 秒优雅退出,然后进入 bounded 强制关闭。 + // N5-I1:公开 wrapper 只把"发起"留在链内,10s 优雅等待在链外进行, + // 唤醒后重新入链做 identity 校验与最终状态落盘——长等待不再阻塞其它实例 CRUD。 + public async stopInstance( + id: string, + operationToken?: string + ): Promise { + const captured = await this.enqueueMutation(async () => this.stopInitiateSerial(id, operationToken)) + try { + return await this.stopReleaseAwaitAndFinalize(id, captured) + } catch (error) { + this.logger.error(`停止实例 ${captured.instance.name} 失败:`, error) + if ( + this.instances.get(id) === captured.instance && + captured.instance.terminalSessionId === captured.terminalSessionId + ) { + captured.instance.status = 'error' + } + throw error } - - if (!instance.terminalSessionId) { - throw new Error('终端会话不存在') + } + + private async markInstanceTerminalClosed( + id: string, + instance: Instance, + terminalSessionId: string + ): Promise { + if (instance.terminalSessionId !== terminalSessionId) { + this.logger.warn(`实例 ${instance.name} 的终端归属已变化,跳过旧会话状态清理`) + return + } + + const stateSnapshot = { + status: instance.status, + pid: instance.pid, + terminalSessionId: instance.terminalSessionId, + lastStopped: instance.lastStopped } - + const stoppedAt = new Date().toISOString() + instance.status = 'stopped' + instance.pid = undefined + instance.terminalSessionId = undefined + instance.lastStopped = stoppedAt try { - this.logger.info(`关闭实例终端: ${instance.name} (终端会话: ${instance.terminalSessionId})`) - - // 创建虚拟socket用于终端操作 - const virtualSocket = { - id: instance.terminalSessionId, - emit: () => {} - } as any - - // 强制关闭终端会话 - this.terminalManager.closePty(virtualSocket, { - sessionId: instance.terminalSessionId - }) - - // 更新实例状态 - instance.status = 'stopped' - instance.pid = undefined - instance.terminalSessionId = undefined - instance.lastStopped = new Date().toISOString() - - this.emit('instance-status-changed', { id, status: 'stopped' }) await this.saveInstances() - - return true + } catch (saveError) { + if ( + instance.status === 'stopped' && + instance.pid === undefined && + instance.terminalSessionId === undefined && + instance.lastStopped === stoppedAt + ) { + instance.status = stateSnapshot.status + instance.pid = stateSnapshot.pid + instance.terminalSessionId = stateSnapshot.terminalSessionId + instance.lastStopped = stateSnapshot.lastStopped + } else { + this.logger.warn( + `实例 ${instance.name} 在保存停止状态失败期间归属已变化,跳过旧状态回滚` + ) + } + throw saveError + } + if ( + instance.status === 'stopped' && + instance.pid === undefined && + instance.terminalSessionId === undefined && + instance.lastStopped === stoppedAt + ) { + this.emit('instance-status-changed', { id, status: 'stopped' }) + } + } + + // 关闭终端 + // N5-I1:链内发起 closePty → 链外等待 bounded close(SIGTERM 3s + SIGKILL 1s)→ + // 链内裁决/落盘;等待期间不持有全局 mutationChain。 + public async closeTerminal( + id: string, + operationToken?: string + ): Promise { + const init = await this.enqueueMutation(async () => { + const instance = this.instances.get(id) + if (!instance) { + throw new Error('实例不存在') + } + this.assertOperationLockOwner(id, operationToken) + return this.closeInitiateSerial(id) + }) + try { + const closeResult = await init.closePromise + return await this.enqueueMutation(() => this.closeFinalizeSerial(id, init, closeResult)) } catch (error) { - this.logger.error(`关闭实例 ${instance.name} 终端失败:`, error) + this.logger.error(`关闭实例 ${init.instance.name} 终端失败:`, error) throw error } } + /** 链内完整关闭(供 delete/cleanup 等链内或冻结上下文调用;等待在调用方上下文中进行)。 */ + private async closeTerminalInternal(id: string): Promise { + const init = this.closeInitiateSerial(id) + const closeResult = await init.closePromise + return this.closeFinalizeSerial(id, init, closeResult) + } + // 获取实例状态 public getInstanceStatus(id: string): { status: string; pid?: number } | null { const instance = this.instances.get(id) if (!instance) { return null } - + return { status: instance.status, pid: instance.pid @@ -936,19 +1453,19 @@ export class InstanceManager extends EventEmitter { if (!instance || !instance.terminalSessionId || instance.status !== 'running') { return false } - + try { // 创建虚拟socket用于终端操作 const virtualSocket = { id: instance.terminalSessionId, emit: () => {} } as any - + this.terminalManager.handleInput(virtualSocket, { sessionId: instance.terminalSessionId, data: input }) - + return true } catch (error) { this.logger.error(`向实例 ${instance.name} 发送输入失败:`, error) @@ -959,26 +1476,43 @@ export class InstanceManager extends EventEmitter { // 清理资源 public async cleanup(): Promise { this.logger.info('清理实例管理器资源...') - - // 停止所有运行中的实例 + // 冻结准入:shuttingDown 置位后所有公开 CRUD/lifecycle wrapper 拒绝新请求(可重试), + // 且新 save 跳过 debounce、立即串行 flush。之后才 drain 已准入 mutation 链尾—— + // 保证"已准入 async handler 的写回"在 final flush 之前完成并真实落盘。 + this.shuttingDown = true + await this.mutationChain.catch(() => {}) + + // N5-I1:admission 已冻结 + 链已排空,直接并行(Promise.allSettled)执行 internal + // 停止,不再经全局 mutationChain 逐个串行——多个不响应实例不会各自独占 10s 优雅等待 + // 耗尽 shutdown 预算;每个实例仍保持独立 fault isolation。 const runningInstances = Array.from(this.instances.values()) - .filter(instance => instance.status === 'running') - - for (const instance of runningInstances) { - try { - this.operationLocks.delete(instance.id) - await this.stopInstance(instance.id) - } catch (error) { + .filter(instance => instance.status === 'running' && instance.terminalSessionId) + + await Promise.allSettled(runningInstances.map(instance => { + this.operationLocks.delete(instance.id) + return this.stopInstanceInternal(instance.id).catch(error => { this.logger.error(`清理时停止实例 ${instance.name} 失败:`, error) - } - } - - // 保存最终状态 - await this.saveInstances() - - if (this.saveTimeout) { - clearTimeout(this.saveTimeout) - } + }) + })) + + // N3-I1(c):回收非 running 但仍持有 live/retained target 的 owner(error/stopping/ + // 启动失败保留/关闭超时保留),与 delete 的 confirmed-close-first 语义一致: + // 先 bounded close,再按结果落盘最终状态;仍 running 的目标由 TerminalManager.cleanup + // 统一有界关闭兜底。 + const retainedOwners = Array.from(this.instances.values()) + .filter(instance => + instance.terminalSessionId && + this.terminalManager.hasTarget(instance.terminalSessionId) + ) + + await Promise.allSettled(retainedOwners.map(instance => + this.closeTerminalInternal(instance.id).catch(error => { + this.logger.error(`清理时关闭实例 ${instance.name} 的保留终端失败:`, error) + }) + )) + + // 保存并刷新最终状态(shuttingDown 模式下为立即写盘,不会在真实写盘前清掉 debounce timer)。 + await this.flushPendingSaves() } } diff --git a/server/src/modules/scheduler/SchedulerManager.ts b/server/src/modules/scheduler/SchedulerManager.ts index fab72f99..8bbedfe2 100644 --- a/server/src/modules/scheduler/SchedulerManager.ts +++ b/server/src/modules/scheduler/SchedulerManager.ts @@ -51,6 +51,12 @@ export class SchedulerManager extends EventEmitter { private gameManager: GameManager | null = null private instanceManager: InstanceManager | null = null private terminalManager: TerminalManager | null = null + /** 正在执行的(含 cron 回调与立即执行)任务 promise 集合,destroy 时等待其 settle。 */ + private inFlightExecutions = new Set>() + /** destroy 完成后置位:saveTasks 不得再覆盖任务文件(map 已清空)。 */ + private destroyed = false + /** destroy 等待在途任务 settle 的有界预算。 */ + private static readonly DESTROY_WAIT_MS = 5000 constructor(dataDir: string, logger: winston.Logger) { super() @@ -144,6 +150,11 @@ export class SchedulerManager extends EventEmitter { } private async saveTasks(): Promise { + if (this.destroyed) { + // destroy 已清空任务 map:在途任务完成后的保存不得把任务文件覆盖为空数组。 + this.logger.debug('定时任务管理器已销毁,跳过任务文件保存') + return + } try { const tasks = Array.from(this.tasks.values()).map(task => { const { job, ...taskData } = task @@ -206,10 +217,27 @@ export class SchedulerManager extends EventEmitter { return } - await this.executeTaskDirectly(taskId) + try { + await this.executeTaskDirectly(taskId) + } catch (error) { + // cron 调度路径:失败已由 executeTaskDirectly 发出 success:false audit 并 rethrow; + // 此处消费 rejection,避免定时回调产生未处理 rejection。 + this.logger.debug(`定时任务 ${task.name} 执行失败已记录:`, error) + } } - private async executeTaskDirectly(taskId: string): Promise { + private executeTaskDirectly(taskId: string): Promise { + const tracked = this.runTaskDirectly(taskId) + this.inFlightExecutions.add(tracked) + void tracked.finally(() => { + this.inFlightExecutions.delete(tracked) + }).catch(() => { + // 追踪 promise 的 rejection 已由调用方消费,此处仅确保追踪链不产生未处理 rejection。 + }) + return tracked + } + + private async runTaskDirectly(taskId: string): Promise { const task = this.tasks.get(taskId) if (!task) { return @@ -251,6 +279,10 @@ export class SchedulerManager extends EventEmitter { success: false, error: error instanceof Error ? error.message : String(error) }) + + // 失败必须传播给调用方:executeTaskImmediately 因此 reject、HTTP 路由返回非成功、 + // 客户端显示失败;内部 success:false audit 只发一次,不双重报错。 + throw error } } @@ -263,9 +295,13 @@ export class SchedulerManager extends EventEmitter { case 'start': await this.instanceManager.startInstance(instanceId) break - case 'stop': - await this.instanceManager.stopInstance(instanceId) + case 'stop': { + const result = await this.instanceManager.stopInstance(instanceId) + if (result.status === 'still-running') { + throw new Error('实例仍在运行,停止未完成,请稍后重试') + } break + } case 'restart': await this.instanceManager.restartInstance(instanceId) break @@ -602,12 +638,42 @@ export class SchedulerManager extends EventEmitter { } // 清理所有任务 - destroy(): void { + async destroy(): Promise { for (const task of this.tasks.values()) { if (task.job) { task.job.stop() } } + + // 先等待在途任务 settle(有界预算),其完成时的 saveTasks 仍正常写入最后状态; + // 超时后不再等待,由 destroyed 标志保护文件不被覆盖。 + // N-I3b:等待必须真正有界——每轮以剩余预算做 Promise.race,任一在途任务 settle + // 或预算耗尽即进入下一轮检查;单个永不 settle 的任务不得把 destroy 永久阻塞 + // (否则只能被全局 15s forced exit 截断,后续 Instance/Terminal cleanup 与 final flush 不可达)。 + if (this.inFlightExecutions.size > 0) { + const deadline = Date.now() + SchedulerManager.DESTROY_WAIT_MS + while (this.inFlightExecutions.size > 0) { + const remaining = deadline - Date.now() + if (remaining <= 0) { + break + } + const snapshot = Array.from(this.inFlightExecutions) + let budgetTimer: NodeJS.Timeout | undefined + const budgetElapsed = new Promise(resolve => { + budgetTimer = setTimeout(resolve, remaining) + budgetTimer.unref?.() + }) + await Promise.race([Promise.allSettled(snapshot), budgetElapsed]) + if (budgetTimer) { + clearTimeout(budgetTimer) + } + } + if (this.inFlightExecutions.size > 0) { + this.logger.warn(`定时任务管理器销毁时仍有 ${this.inFlightExecutions.size} 个任务在执行,已停止等待`) + } + } + + this.destroyed = true this.tasks.clear() this.logger.info('定时任务管理器已销毁') } diff --git a/server/src/modules/terminal/TerminalManager.ts b/server/src/modules/terminal/TerminalManager.ts index 2f65b0b1..0ca84564 100755 --- a/server/src/modules/terminal/TerminalManager.ts +++ b/server/src/modules/terminal/TerminalManager.ts @@ -14,8 +14,14 @@ import { ConfigManager } from '../config/ConfigManager.js' import { ptyManager } from '../../utils/ptyManager.js' import { buildUtf8LocaleEnv } from '../../utils/filenameEncoding.js' import { StreamingRedactor } from '../../utils/streamingRedactor.js' -import { getCurrentUsername } from '../../utils/currentUser.js' import { buildChildProcessEnvironment } from '../../utils/childProcessEnvironment.js' +import { + createPtyControlChannel, + PtyControlChannel, + PtySize, + removePtyControlEndpoint, + validatePtySize +} from '../../utils/ptyControlChannel.js' const execAsync = promisify(exec) const buildManagedChildEnvironment = (overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv => ( @@ -28,7 +34,11 @@ const __dirname = path.dirname(__filename) interface PtySession { id: string name: string // 终端会话名称 + state: 'ready' | 'closing' + size: PtySize process: ChildProcess + control: PtyControlChannel + endpoint: string socket: Socket workingDirectory: string createdAt: Date @@ -37,6 +47,8 @@ interface PtySession { disconnectedAt?: Date outputBuffer: string[] // 存储终端输出历史 streamForwardProcess?: ChildProcess // 输出流转发进程 + pendingForwardAutoCloseProcess?: ChildProcess + streamForwardRestartGeneration: number enableStreamForward?: boolean // 是否启用输出流转发 programPath?: string // 程序启动参数的绝对路径 autoCloseOnForwardExit?: boolean // 转发进程退出时是否自动关闭终端会话 @@ -46,6 +58,21 @@ interface PtySession { onOutput?: (output: string) => void onExit?: (code: number | null, signal: NodeJS.Signals | null) => void exitNotified?: boolean + closePromise?: Promise + controlClosePromise?: Promise + finalizationPromise?: Promise + closeContext?: CloseContext + persistenceRemoval?: Promise + activePersistenceOwned: boolean + processExited: boolean + processExitCode?: number | null + processExitSignal?: NodeJS.Signals | null + processErrorSent?: boolean + finalEventSent: boolean + publicCloseRequesters?: Map + publicCloseAckedIds?: Set + notifyRetainedOnTimeout?: boolean + retainedTimeoutNotified?: boolean } interface CreatePtyData { @@ -89,6 +116,98 @@ export interface PtyRuntimeOptions { onExit?: (code: number | null, signal: NodeJS.Signals | null) => void } +export type CloseResult = 'closed' | 'not-found' | 'still-running' +export type CreatePtyResult = + | { status: 'ready'; sessionId: string } + | { status: 'failed-closed'; sessionId: string; error: string } + | { status: 'failed-retained'; sessionId: string; error: string } +export type ReconnectResult = 'ready' | 'pending' | 'closing' | 'not-found' + +interface CloseContext { + readonly intentional: boolean + readonly emitEvents: boolean + readonly emitTimeoutError: boolean +} + +interface CloseRequestOptions extends CloseContext { + readonly publicRequester?: Socket + readonly notifyRetained?: boolean +} + +type CreateAttemptPhase = + | 'starting' + | 'fallback' + | 'closing' + | 'close-retained' + +interface CreateCancellationToken { + cancelled: boolean +} + +interface CreateAttempt { + id: string + name: string + phase: CreateAttemptPhase + cancellation: CreateCancellationToken + createSize: PtySize + process?: ChildProcess + control?: PtyControlChannel + endpoint?: string + socket: Socket + workingDirectory: string + createdAt: Date + lastActivity: Date + outputBuffer: string[] + streamForwardProcess?: ChildProcess + enableStreamForward?: boolean + programPath?: string + autoCloseOnForwardExit?: boolean + stdoutRedactor: StreamingRedactor + stderrRedactor: StreamingRedactor + onOutput?: (output: string) => void + onExit?: (code: number | null, signal: NodeJS.Signals | null) => void + exitNotified?: boolean + runtimeOptions: PtyRuntimeOptions + selectedUser: string + fallbackEligibleFromConfiguredDefault: boolean + terminalEnv: NodeJS.ProcessEnv + closePromise?: Promise + controlClosePromise?: Promise + finalizationPromise?: Promise + closeContext?: CloseContext + activePersistenceOwned: boolean + processExited: boolean + processExitCode?: number | null + processExitSignal?: NodeJS.Signals | null + processError?: Error + failureMessage?: string + finalEventSent: boolean + publicCloseRequesters?: Map + publicCloseAckedIds?: Set + notifyRetainedOnTimeout?: boolean + retainedTimeoutNotified?: boolean +} + +export interface TerminalManagerDependencies { + spawnPty?: typeof spawn + createControlChannel?: typeof createPtyControlChannel +} + +interface PtyProcessOutcome { + kind: 'close' | 'error' + code: number | null + signal: NodeJS.Signals | null + error?: Error + elapsedMs: number +} + +interface PtyProcessLaunch { + process: ChildProcess + control: PtyControlChannel + endpoint: string + outcome: Promise +} + interface ManagedProcessResult { code: number | null signal: NodeJS.Signals | null @@ -98,17 +217,32 @@ interface ManagedProcessResult { export class TerminalManager { private sessions: Map = new Map() private managedProcesses: Set = new Set() + private createAttempts = new Map() + private persistenceOperations = new Map>() + private internallyStoppedForwardProcesses = new WeakSet() + private ptyStdinHandlers = new WeakSet() + private forwardStdinHandlers = new WeakSet() + private acceptingTerminalOperations = true private io: SocketIOServer private logger: winston.Logger private ptyPath: string private sessionManager: TerminalSessionManager private configManager: ConfigManager - - constructor(io: SocketIOServer, logger: winston.Logger, configManager: ConfigManager) { + private readonly spawnPty: typeof spawn + private readonly createControlChannel: typeof createPtyControlChannel + + constructor( + io: SocketIOServer, + logger: winston.Logger, + configManager: ConfigManager, + dependencies: TerminalManagerDependencies = {} + ) { this.io = io this.logger = logger this.configManager = configManager this.sessionManager = new TerminalSessionManager(logger) + this.spawnPty = dependencies.spawnPty ?? spawn + this.createControlChannel = dependencies.createControlChannel ?? createPtyControlChannel // PTY 路径将在 initialize() 中通过 ptyManager 异步获取 this.ptyPath = '' @@ -131,20 +265,13 @@ export class TerminalManager { async initialize(): Promise { await this.sessionManager.initialize() - // 通过 ptyManager 获取 PTY 路径(已在启动时确保下载) + // 仅使用已经过固定清单校验和本机能力探测的 PTY 路径。 try { this.ptyPath = await ptyManager.getPtyPath() this.logger.info(`终端管理器初始化完成,PTY路径: ${this.ptyPath}`) } catch (error: any) { - this.logger.error(`无法找到 PTY 文件: ${error.message}`) - // 使用 ptyManager 获取平台对应的文件名作为备用路径 - try { - const ptyFileName = ptyManager.getBinaryName() - this.ptyPath = path.join(process.cwd(), 'data', 'lib', ptyFileName) - this.logger.warn(`使用备用 PTY 路径: ${this.ptyPath}`) - } catch (nameError: any) { - this.logger.error(`无法获取 PTY 文件名: ${nameError.message}`) - } + this.ptyPath = '' + this.logger.error(`PTY 能力不可用,终端会话将无法创建: ${error.message}`) } } @@ -165,7 +292,7 @@ export class TerminalManager { const child = spawn(executablePath, args, { stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], cwd: workingDirectory, - env: buildManagedChildEnvironment(), + env: buildManagedChildEnvironment(), shell: false, windowsHide: true }) @@ -251,532 +378,1808 @@ export class TerminalManager { socket: Socket, data: CreatePtyData, runtimeOptions: PtyRuntimeOptions = {} - ): Promise { - try { - const { sessionId, name, cols, rows, workingDirectory: rawWorkingDirectory = process.cwd(), enableStreamForward = false, programPath, autoCloseOnForwardExit = false, terminalUser } = data - const { - command, - environmentOverrides = {}, - redactValues = [], - onOutput, - onExit - } = runtimeOptions - const workingDirectory = path.resolve(rawWorkingDirectory) - const sessionName = name || `终端会话 ${sessionId.slice(-8)}` - - // 获取终端配置和默认用户(提升到方法开始处) - const terminalConfig = this.configManager.getTerminalConfig() - // 优先使用传入的terminalUser,如果没有则使用配置的defaultUser - const defaultUser = terminalUser || terminalConfig.defaultUser - const currentUser = getCurrentUsername() - const shouldSwitchUser = os.platform() === 'linux' && Boolean( - defaultUser && defaultUser.trim() !== '' && (!currentUser || defaultUser !== currentUser) - ) + ): Promise { + const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : '' + let attempt: CreateAttempt - // 如果是Linux系统且使用非root用户,先设置工作目录权限为777 - if (shouldSwitchUser && defaultUser !== 'root') { - try { - await this.setDirectoryPermissions777(workingDirectory) - this.logger.info(`已为非root用户 ${defaultUser} 设置工作目录权限为777: ${workingDirectory}`) - } catch (error) { - this.logger.warn(`设置工作目录权限失败: ${error}`) - } - } - - // 验证输出流转发参数 - if (enableStreamForward && os.platform() !== 'win32') { - this.logger.warn(`输出流转发功能仅在Windows平台支持,当前平台: ${os.platform()}`) - socket.emit('terminal-error', { - sessionId, - error: '输出流转发功能仅在Windows平台支持' - }) - return - } - - if (enableStreamForward && !programPath) { - this.logger.warn(`启用输出流转发时必须提供程序启动命令`) - socket.emit('terminal-error', { - sessionId, - error: '启用输出流转发时必须提供程序启动命令' - }) - return + try { + if (!this.acceptingTerminalOperations) { + const error = '终端管理器正在关闭' + this.emitTerminalError(socket, sessionId, 'create', error) + return { status: 'failed-closed', sessionId, error } } - - if (enableStreamForward && programPath) { - // 解析命令行,检查可执行文件路径是否为绝对路径 - const commandLine = programPath.trim() - let executablePath: string - - if (commandLine.startsWith('"')) { - // 处理带引号的可执行文件路径 - const endQuoteIndex = commandLine.indexOf('"', 1) - if (endQuoteIndex === -1) { - this.logger.warn(`未找到匹配的引号: ${commandLine}`) - socket.emit('terminal-error', { - sessionId, - error: '未找到匹配的引号' - }) - return - } - executablePath = commandLine.substring(1, endQuoteIndex) - } else { - // 处理不带引号的路径 - const parts = commandLine.split(/\s+/) - executablePath = parts[0] - } - - if (!path.isAbsolute(executablePath)) { - this.logger.warn(`可执行文件路径必须是绝对路径: ${executablePath}`) - socket.emit('terminal-error', { - sessionId, - error: '可执行文件路径必须是绝对路径' - }) - return - } + if (!this.ptyPath) { + const error = 'PTY 能力不可用:可信 PTY 运行时未初始化' + this.logger.error(`创建PTY会话失败: ${error}`) + this.emitTerminalError(socket, sessionId, 'create', error) + return { status: 'failed-closed', sessionId, error } } - - this.logger.info(`创建PTY会话: ${sessionId} (${sessionName}), 大小: ${cols}x${rows}`) - - // 检查会话是否已存在 - if (this.sessions.has(sessionId)) { - this.logger.warn(`会话 ${sessionId} 已存在,先关闭旧会话`) - this.closePty(socket, { sessionId }) + if (!data || typeof data.sessionId !== 'string' || data.sessionId.trim() === '') { + throw new Error('会话ID不能为空') } - - // 构建PTY命令参数 - const args = [ - '-dir', workingDirectory, - '-size', `${cols},${rows}`, - '-coder', 'UTF-8' - ] + const createSize = validatePtySize(data.cols, data.rows) + const workingDirectory = path.resolve(data.workingDirectory ?? process.cwd()) + const enableStreamForward = data.enableStreamForward ?? false + const autoCloseOnForwardExit = data.autoCloseOnForwardExit ?? false + this.validateStreamForwardArguments(enableStreamForward, data.programPath) + + const terminalConfig = this.configManager.getTerminalConfig() + const configuredDefaultUser = terminalConfig.defaultUser || '' + const selectedUser = data.terminalUser || configuredDefaultUser + const environmentOverrides = runtimeOptions.environmentOverrides ?? {} const terminalEnv = buildManagedChildEnvironment({ ...environmentOverrides, TERM: 'xterm-256color', COLORTERM: 'truecolor' }) - const preservedEnvironmentNames = Object.keys(environmentOverrides) - .filter(name => /^[A-Z_][A-Z0-9_]*$/i.test(name)) - const shellLocaleEnvArgs = this.buildShellLocaleEnvArgs(terminalEnv) - const shellLocaleExport = this.buildShellLocaleExport(terminalEnv) - - // 内部调用方可以直接启动目标程序,避免将敏感命令写入shell历史。 - if (command && command.length > 0) { - args.push('-cmd', JSON.stringify(command)) - } else if (os.platform() === 'win32') { - args.push('-cmd', JSON.stringify(['powershell.exe'])) - } else { - // Linux下检查是否配置了默认用户 - if (defaultUser && defaultUser.trim() !== '' && (!currentUser || defaultUser !== currentUser)) { - // 检查用户是否存在 - const userExists = await this.checkUserExists(defaultUser) - if (userExists) { - // 检查sudo命令是否存在 - const sudoExists = await this.checkCommandExists('sudo') - - if (sudoExists) { - // 如果sudo存在,使用sudo切换用户,使用简化的方式 - args.push('-cmd', JSON.stringify([ - 'sudo', - ...(preservedEnvironmentNames.length > 0 - ? [`--preserve-env=${preservedEnvironmentNames.join(',')}`] - : []), - '-u', defaultUser, - 'env', - ...shellLocaleEnvArgs, - '/bin/bash', '-c', - `cd "${workingDirectory}" && exec /bin/bash --login` - ])) - this.logger.info(`使用sudo切换到默认用户启动终端: ${defaultUser},工作目录: ${workingDirectory}`) - } else { - // 如果sudo不存在,检查su命令 - const suExists = await this.checkCommandExists('su') - - if (suExists) { - // 使用su命令切换用户,使用简化的方式 - args.push('-cmd', JSON.stringify([ - 'su', - ...(preservedEnvironmentNames.length > 0 ? ['-m'] : []), - defaultUser, '-c', - `${shellLocaleExport}; ` + - `cd "${workingDirectory}" && exec /bin/bash --login` - ])) - this.logger.info(`使用su切换到默认用户启动终端: ${defaultUser},工作目录: ${workingDirectory}`) - } else { - // 既没有sudo也没有su,记录警告并使用当前用户 - this.logger.warn(`系统中既没有sudo也没有su命令,无法切换到用户 '${defaultUser}',使用当前用户`) - args.push('-cmd', JSON.stringify(['/bin/bash', '--login'])) - } - } - } else { - // 用户不存在,记录警告并使用默认bash - this.logger.warn(`配置的默认用户 '${defaultUser}' 不存在,使用默认bash`) - args.push('-cmd', JSON.stringify(['/bin/bash', '--login'])) - } - } else { - // 没有配置默认用户,使用默认bash - args.push('-cmd', JSON.stringify(['/bin/bash', '--login'])) - } + const sessionName = data.name || `终端会话 ${sessionId.slice(-8)}` + + if ( + this.sessions.has(sessionId) || + this.createAttempts.has(sessionId) + ) { + const error = '会话ID已存在' + this.emitTerminalError(socket, sessionId, 'create', error) + return { status: 'failed-closed', sessionId, error } } - - this.logger.info(`启动PTY进程: ${this.ptyPath} ${args.join(' ')}`) - - // 启动PTY进程 - const ptyProcess = spawn(this.ptyPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - cwd: workingDirectory, - env: terminalEnv, - // Linux下创建新的进程组,确保信号正确传递 - detached: os.platform() !== 'win32' - }) - - this.logger.info(`PTY进程已启动,PID: ${ptyProcess.pid}`) - - // 创建会话对象 - const session: PtySession = { - id: sessionId, - name: sessionName, - process: ptyProcess, + + attempt = this.createAttemptRecord({ + sessionId, + sessionName, socket, workingDirectory, - createdAt: new Date(), - lastActivity: new Date(), - outputBuffer: [], + createSize, enableStreamForward, - programPath, + programPath: data.programPath, autoCloseOnForwardExit, - stdoutRedactor: new StreamingRedactor(redactValues), - stderrRedactor: new StreamingRedactor(redactValues), - onOutput, - onExit + runtimeOptions, + selectedUser, + fallbackEligibleFromConfiguredDefault: configuredDefaultUser.trim() !== '', + terminalEnv + }) + this.createAttempts.set(sessionId, attempt) + } catch (error) { + const message = error instanceof Error ? error.message : '未知错误' + this.logger.error(`创建PTY会话失败: ${message}`) + this.emitTerminalError(socket, sessionId, 'create', message) + return { status: 'failed-closed', sessionId, error: message } + } + + try { + this.logger.info( + `创建PTY会话: ${attempt.id} (${attempt.name}), ` + + `大小: ${attempt.createSize.cols}x${attempt.createSize.rows}` + ) + + await this.prepareAttemptDirectory(attempt) + if (!this.isActiveAttempt(attempt)) { + return this.resolveCreatePtyResult(attempt) } - - // 保存会话到内存 - this.sessions.set(sessionId, session) - - // 持久化保存会话信息 - try { - await this.sessionManager.saveSession({ - id: sessionId, - name: sessionName, - workingDirectory, - createdAt: session.createdAt, - lastActivity: session.lastActivity, - isActive: true - }) - } catch (error) { - this.logger.error(`保存会话到配置文件失败: ${sessionId}`, error) + + const primaryCommand = await this.buildPrimaryCommand(attempt) + if (!this.isActiveAttempt(attempt) || !primaryCommand) { + return this.resolveCreatePtyResult(attempt) } - const emitOutput = (output: string, isError = false) => { - if (!output) return - session.lastActivity = new Date() - session.outputBuffer.push(output) - if (session.outputBuffer.length > 1000) { - session.outputBuffer.shift() - } - try { - session.onOutput?.(output) - } catch (error) { - this.logger.warn(`PTY输出回调执行失败: ${sessionId}`, error) - } - if (isError) { - this.logger.warn(`PTY错误输出 ${sessionId}: ${JSON.stringify(output)}`) - } else { - this.logger.debug(`PTY输出 ${sessionId}: ${JSON.stringify(output)}`) - } - session.socket.emit('terminal-output', { sessionId, data: output }) + const primary = await this.launchAttemptProcess( + attempt, + primaryCommand, + 'PTY进程' + ) + if (!this.isActiveAttempt(attempt) || !primary) { + return this.resolveCreatePtyResult(attempt) } - // 处理PTY输出 - ptyProcess.stdout?.on('data', (data: Buffer) => { - emitOutput(session.stdoutRedactor.write(data)) - }) - ptyProcess.stdout?.once('end', () => emitOutput(session.stdoutRedactor.end())) - - // 处理PTY错误输出 - ptyProcess.stderr?.on('data', (data: Buffer) => { - emitOutput(session.stderrRedactor.write(data), true) - }) - ptyProcess.stderr?.once('end', () => emitOutput(session.stderrRedactor.end(), true)) - - // 处理进程退出 - ptyProcess.once('close', (code, signal) => { - this.logger.info(`PTY进程退出: ${sessionId}, 退出码: ${code}, 信号: ${signal}`) - - // 如果退出码为0但进程立即退出,可能是命令执行有问题 - if (code === 0 && (Date.now() - session.createdAt.getTime()) < 1000) { - this.logger.warn(`PTY进程启动后立即退出,可能是用户切换命令有问题: ${sessionId}`) - this.logger.warn(`使用的命令参数: ${JSON.stringify(args)}`) - - // 如果是用户切换失败,尝试使用当前用户重新启动 - if (!command && defaultUser && defaultUser.trim() !== '' && !session.fallbackRetried) { - this.logger.info(`尝试使用当前用户重新启动终端: ${sessionId}`) - session.fallbackRetried = true - - // 使用当前用户重新启动 - setTimeout(() => { - this.createPtyFallback( - sessionId, - sessionName, - workingDirectory, - socket, - enableStreamForward, - programPath, - autoCloseOnForwardExit, - runtimeOptions - ) - }, 100) - return - } - } - - // 清理输出流转发进程 - if (session.streamForwardProcess && !session.streamForwardProcess.killed) { - this.logger.info(`PTY退出时清理输出流转发进程: ${sessionId}`) - this.forceKillProcess(session.streamForwardProcess, '输出流转发进程', () => { - session.streamForwardProcess = undefined - }) - } - - session.socket.emit('terminal-exit', { - sessionId, - code: code || 0, - signal - }) - if (!session.exitNotified) { - session.exitNotified = true - session.onExit?.(code, signal) - } - - // 从内存中删除会话 - this.sessions.delete(sessionId) - - // 从持久化存储中删除会话 - this.sessionManager.removeSession(sessionId).catch(error => { - this.logger.error(`PTY退出时从配置文件删除会话失败: ${sessionId}`, error) - }) - }) - - // 处理进程错误 - ptyProcess.on('error', (error) => { - this.logger.error(`PTY进程错误 ${sessionId}:`, error) - - // 清理输出流转发进程 - if (session.streamForwardProcess && !session.streamForwardProcess.killed) { - this.logger.info(`PTY错误时清理输出流转发进程: ${sessionId}`) - this.forceKillProcess(session.streamForwardProcess, '输出流转发进程', () => { - session.streamForwardProcess = undefined - }) - } - - session.socket.emit('terminal-error', { - sessionId, - error: error.message - }) - - // 从内存中删除会话 - this.sessions.delete(sessionId) - - // 从持久化存储中删除会话 - this.sessionManager.removeSession(sessionId).catch(error => { - this.logger.error(`PTY错误时从配置文件删除会话失败: ${sessionId}`, error) - }) - }) - - // Linux下设置进程组,确保信号正确传递 - if (os.platform() !== 'win32' && ptyProcess.pid) { - try { - // 将PTY进程设置为新进程组的组长 - process.kill(-ptyProcess.pid, 0) // 测试进程组是否存在 - this.logger.info(`PTY进程组设置成功: ${ptyProcess.pid}`) - } catch (error) { - this.logger.warn(`设置PTY进程组失败: ${error}`) - } + await this.establishPrimaryAttempt(attempt, primary) + } catch (error) { + if (!this.isActiveAttempt(attempt)) { + return this.resolveCreatePtyResult(attempt) } - - // 如果启用了输出流转发,启动转发进程 - if (enableStreamForward && programPath) { - this.startStreamForwardProcess(session, programPath) + const message = error instanceof Error ? error.message : '未知错误' + this.logger.error(`创建PTY会话失败: ${attempt.id}`, error) + await this.failCreateAttempt(attempt, message) + } + + return this.resolveCreatePtyResult(attempt) + } + + private resolveCreatePtyResult(attempt: CreateAttempt): CreatePtyResult { + if (this.sessions.has(attempt.id)) { + return { status: 'ready', sessionId: attempt.id } + } + + const error = attempt.failureMessage ?? '终端会话创建未完成' + if (this.createAttempts.get(attempt.id) === attempt) { + return { status: 'failed-retained', sessionId: attempt.id, error } + } + return { status: 'failed-closed', sessionId: attempt.id, error } + } + + private createAttemptRecord(options: { + sessionId: string + sessionName: string + socket: Socket + workingDirectory: string + createSize: PtySize + enableStreamForward: boolean + programPath?: string + autoCloseOnForwardExit: boolean + runtimeOptions: PtyRuntimeOptions + selectedUser: string + fallbackEligibleFromConfiguredDefault: boolean + terminalEnv: NodeJS.ProcessEnv + }): CreateAttempt { + const now = new Date() + const redactValues = options.runtimeOptions.redactValues ?? [] + return { + id: options.sessionId, + name: options.sessionName, + phase: 'starting', + cancellation: { cancelled: false }, + createSize: { + cols: options.createSize.cols, + rows: options.createSize.rows + }, + socket: options.socket, + workingDirectory: options.workingDirectory, + createdAt: now, + lastActivity: now, + outputBuffer: [], + enableStreamForward: options.enableStreamForward, + programPath: options.programPath, + autoCloseOnForwardExit: options.autoCloseOnForwardExit, + stdoutRedactor: new StreamingRedactor(redactValues), + stderrRedactor: new StreamingRedactor(redactValues), + onOutput: options.runtimeOptions.onOutput, + onExit: options.runtimeOptions.onExit, + runtimeOptions: options.runtimeOptions, + selectedUser: options.selectedUser, + fallbackEligibleFromConfiguredDefault: + options.fallbackEligibleFromConfiguredDefault, + terminalEnv: options.terminalEnv, + activePersistenceOwned: false, + processExited: false, + finalEventSent: false + } + } + + private validateStreamForwardArguments( + enableStreamForward: boolean, + programPath?: string + ): void { + if (enableStreamForward && os.platform() !== 'win32') { + throw new Error('输出流转发功能仅在Windows平台支持') + } + if (enableStreamForward && !programPath) { + throw new Error('启用输出流转发时必须提供程序启动命令') + } + if (!enableStreamForward || !programPath) return + + const commandLine = programPath.trim() + let executablePath: string + if (commandLine.startsWith('"')) { + const endQuoteIndex = commandLine.indexOf('"', 1) + if (endQuoteIndex === -1) { + throw new Error('未找到匹配的引号') } - - // 发送创建成功事件 - socket.emit('pty-created', { - sessionId, - workingDirectory + executablePath = commandLine.substring(1, endQuoteIndex) + } else { + executablePath = commandLine.split(/\s+/)[0] + } + + if (!path.isAbsolute(executablePath)) { + throw new Error('可执行文件路径必须是绝对路径') + } + } + + private isActiveAttempt(attempt: CreateAttempt): boolean { + return !attempt.cancellation.cancelled && + this.createAttempts.get(attempt.id) === attempt + } + + private hasAttemptIdentity(attempt: CreateAttempt): boolean { + return this.createAttempts.get(attempt.id) === attempt + } + + private async prepareAttemptDirectory(attempt: CreateAttempt): Promise { + if (!this.isActiveAttempt(attempt)) return + if ( + os.platform() !== 'linux' || + !attempt.selectedUser || + attempt.selectedUser.trim() === '' || + attempt.selectedUser === 'root' + ) { + return + } + + try { + await this.setDirectoryPermissions777(attempt.workingDirectory) + if (!this.isActiveAttempt(attempt)) return + this.logger.info( + `已为非root用户 ${attempt.selectedUser} 设置工作目录权限为777: ` + + attempt.workingDirectory + ) + } catch (error) { + if (!this.isActiveAttempt(attempt)) return + this.logger.warn(`设置工作目录权限失败: ${error}`) + } + } + + private async buildPrimaryCommand( + attempt: CreateAttempt + ): Promise { + if (!this.isActiveAttempt(attempt)) return null + + const command = attempt.runtimeOptions.command + if (command && command.length > 0) { + return [...command] + } + if (os.platform() === 'win32') { + return ['powershell.exe'] + } + if (!attempt.selectedUser || attempt.selectedUser.trim() === '') { + return ['/bin/bash', '--login'] + } + + const userExists = await this.checkUserExists(attempt.selectedUser) + if (!this.isActiveAttempt(attempt)) return null + if (!userExists) { + this.logger.warn( + `配置的默认用户 '${attempt.selectedUser}' 不存在,使用默认bash` + ) + return ['/bin/bash', '--login'] + } + + const environmentOverrides = attempt.runtimeOptions.environmentOverrides ?? {} + const preservedEnvironmentNames = Object.keys(environmentOverrides) + .filter(name => /^[A-Z_][A-Z0-9_]*$/i.test(name)) + const shellLocaleEnvArgs = this.buildShellLocaleEnvArgs(attempt.terminalEnv) + const shellLocaleExport = this.buildShellLocaleExport(attempt.terminalEnv) + const sudoExists = await this.checkCommandExists('sudo') + if (!this.isActiveAttempt(attempt)) return null + if (sudoExists) { + this.logger.info( + `使用sudo切换到默认用户启动终端: ${attempt.selectedUser},` + + `工作目录: ${attempt.workingDirectory}` + ) + return [ + 'sudo', + ...(preservedEnvironmentNames.length > 0 + ? [`--preserve-env=${preservedEnvironmentNames.join(',')}`] + : []), + '-u', attempt.selectedUser, + 'env', + ...shellLocaleEnvArgs, + '/bin/bash', '-c', + `cd "${attempt.workingDirectory}" && exec /bin/bash --login` + ] + } + + const suExists = await this.checkCommandExists('su') + if (!this.isActiveAttempt(attempt)) return null + if (suExists) { + this.logger.info( + `使用su切换到默认用户启动终端: ${attempt.selectedUser},` + + `工作目录: ${attempt.workingDirectory}` + ) + return [ + 'su', + ...(preservedEnvironmentNames.length > 0 ? ['-m'] : []), + attempt.selectedUser, '-c', + `${shellLocaleExport}; ` + + `cd "${attempt.workingDirectory}" && exec /bin/bash --login` + ] + } + + this.logger.warn( + `系统中既没有sudo也没有su命令,无法切换到用户 ` + + `'${attempt.selectedUser}',使用当前用户` + ) + return ['/bin/bash', '--login'] + } + + private async launchAttemptProcess( + attempt: CreateAttempt, + command: string[], + processLabel: string + ): Promise { + if (!this.isActiveAttempt(attempt)) return null + + const control = await this.createControlChannel({ + sessionId: attempt.id, + logger: this.logger + }) + if (!this.isActiveAttempt(attempt)) { + await this.closeControlQuietly(attempt.id, control) + return null + } + + attempt.control = control + attempt.endpoint = control.endpoint + attempt.controlClosePromise = undefined + attempt.finalizationPromise = undefined + attempt.closeContext = undefined + attempt.processExited = false + attempt.processExitCode = undefined + attempt.processExitSignal = undefined + attempt.processError = undefined + + const args = [ + '-dir', attempt.workingDirectory, + '-size', `${attempt.createSize.cols},${attempt.createSize.rows}`, + '-coder', 'UTF-8', + '-fifo', control.endpoint, + '-cmd', JSON.stringify(command) + ] + const startedAt = Date.now() + + this.logger.info( + `启动${processLabel}: sessionId=${attempt.id}, ` + + `cwd=${attempt.workingDirectory}, ` + + `size=${attempt.createSize.cols}x${attempt.createSize.rows}` + ) + let ptyProcess: ChildProcess + try { + ptyProcess = this.spawnPty(this.ptyPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: attempt.workingDirectory, + env: attempt.terminalEnv, + detached: os.platform() !== 'win32' }) - - this.logger.info(`PTY会话创建成功: ${sessionId}`) - - // 发送初始欢迎信息和提示符 - setTimeout(() => { - if (ptyProcess.stdin && !ptyProcess.stdin.destroyed) { - // 发送一个回车来触发初始提示符 - ptyProcess.stdin.write('\r') - } - }, 500) // 延迟500ms确保PTY完全初始化 - } catch (error) { - this.logger.error(`创建PTY会话失败:`, error) - socket.emit('terminal-error', { - sessionId: data.sessionId, - error: error instanceof Error ? error.message : '未知错误' + attempt.processExited = true + await this.closeControlQuietly(attempt.id, control) + if (this.isActiveAttempt(attempt) && attempt.control === control) { + await this.removeEndpointQuietly(attempt.id, control.endpoint) + } + if (this.isActiveAttempt(attempt) && attempt.control === control) { + attempt.control = undefined + attempt.endpoint = undefined + } + throw error + } + + if (!this.isActiveAttempt(attempt)) { + try { + ptyProcess.kill('SIGTERM') + } catch (error) { + this.logger.warn(`取消创建时终止PTY进程失败: ${attempt.id}`, error) + } + await this.closeControlQuietly(attempt.id, control) + return null + } + + attempt.process = ptyProcess + const outcome = this.registerPtyProcessHandlers( + attempt.id, + ptyProcess, + startedAt + ) + this.logger.info(`${processLabel}已启动,PID: ${ptyProcess.pid}`) + + if (os.platform() !== 'win32' && ptyProcess.pid) { + try { + process.kill(-ptyProcess.pid, 0) + this.logger.info(`PTY进程组设置成功: ${ptyProcess.pid}`) + } catch (error) { + this.logger.warn(`设置PTY进程组失败: ${error}`) + } + } + + return { + process: ptyProcess, + control, + endpoint: control.endpoint, + outcome + } + } + + private observeControlReadiness(control: PtyControlChannel): Promise<{ + ready: boolean + error?: unknown + }> { + return control.waitUntilReady(3000).then( + () => ({ ready: true }), + error => ({ ready: false, error }) + ) + } + + private async establishPrimaryAttempt( + attempt: CreateAttempt, + launch: PtyProcessLaunch + ): Promise { + const readiness = this.observeControlReadiness(launch.control) + const stability = await Promise.race([ + launch.outcome.then(outcome => ({ stable: false as const, outcome })), + new Promise<{ stable: true }>(resolve => { + setTimeout(() => resolve({ stable: true }), 1000) }) + ]) + if (!this.isActiveAttempt(attempt)) return + + if ('outcome' in stability) { + const outcome = stability.outcome + if (this.shouldFallback(attempt, outcome)) { + await this.retireExitedLaunch(attempt, launch) + if (!this.isActiveAttempt(attempt)) return + + attempt.phase = 'fallback' + this.logger.info(`尝试使用当前用户重新启动终端: ${attempt.id}`) + const fallback = await this.launchAttemptProcess( + attempt, + ['/bin/bash', '--login'], + 'PTY回退进程' + ) + if (!this.isActiveAttempt(attempt) || !fallback) return + await this.establishFallbackAttempt(attempt, fallback) + return + } + + await this.failCreateAttempt( + attempt, + this.describeStartupOutcome(outcome) + ) + return + } + + const readinessResult = await readiness + if (!this.isActiveAttempt(attempt)) return + if (!readinessResult.ready) { + await this.failCreateAttempt( + attempt, + this.describeReadinessError(readinessResult.error) + ) + return + } + if ( + attempt.processError || + attempt.processExited || + attempt.process !== launch.process || + attempt.control !== launch.control + ) { + await this.failCreateAttempt( + attempt, + attempt.processError?.message || 'PTY进程在创建完成前退出' + ) + return + } + + await this.promoteAttempt(attempt, launch) + } + + private async establishFallbackAttempt( + attempt: CreateAttempt, + launch: PtyProcessLaunch + ): Promise { + const readiness = this.observeControlReadiness(launch.control) + const result = await Promise.race([ + readiness.then(value => ({ kind: 'readiness' as const, value })), + launch.outcome.then(value => ({ kind: 'outcome' as const, value })) + ]) + if (!this.isActiveAttempt(attempt)) return + + if (result.kind === 'outcome') { + await this.failCreateAttempt( + attempt, + this.describeStartupOutcome(result.value) + ) + return + } + if (!result.value.ready) { + await this.failCreateAttempt( + attempt, + this.describeReadinessError(result.value.error) + ) + return + } + if ( + attempt.processError || + attempt.processExited || + attempt.process !== launch.process || + attempt.control !== launch.control + ) { + await this.failCreateAttempt( + attempt, + attempt.processError?.message || 'PTY回退进程在创建完成前退出' + ) + return + } + + await this.promoteAttempt(attempt, launch) + } + + private shouldFallback( + attempt: CreateAttempt, + outcome: PtyProcessOutcome + ): boolean { + return attempt.runtimeOptions.command === undefined && + attempt.fallbackEligibleFromConfiguredDefault && + outcome.kind === 'close' && + outcome.code === 0 && + outcome.elapsedMs < 1000 + } + + private describeStartupOutcome(outcome: PtyProcessOutcome): string { + if (outcome.kind === 'error') { + return outcome.error?.message || 'PTY进程启动失败' + } + return `PTY进程在创建期间退出(退出码: ${outcome.code ?? 'null'},` + + `信号: ${outcome.signal ?? 'none'})` + } + + private describeReadinessError(error: unknown): string { + if (error instanceof Error) { + return `PTY控制通道未就绪: ${error.message}` + } + return 'PTY控制通道未就绪' + } + + private async retireExitedLaunch( + attempt: CreateAttempt, + launch: PtyProcessLaunch + ): Promise { + if ( + !this.isActiveAttempt(attempt) || + attempt.process !== launch.process || + !attempt.processExited + ) { + return + } + + await this.closeControlQuietly(attempt.id, launch.control) + if (!this.isActiveAttempt(attempt)) return + + await this.removeEndpointQuietly(attempt.id, launch.endpoint) + if (!this.isActiveAttempt(attempt)) return + if (attempt.process === launch.process) { + attempt.process = undefined + } + if (attempt.control === launch.control) { + attempt.control = undefined + attempt.endpoint = undefined + } + } + + private runPersistenceOperation( + sessionId: string, + operation: () => Promise + ): Promise { + const previous = this.persistenceOperations.get(sessionId) ?? Promise.resolve() + const result = previous.catch(() => undefined).then(operation) + const barrier = result.then(() => undefined, () => undefined) + this.persistenceOperations.set(sessionId, barrier) + void barrier.then(() => { + if (this.persistenceOperations.get(sessionId) === barrier) { + this.persistenceOperations.delete(sessionId) + } + }) + return result + } + + private hasReplacementPersistenceOwner( + sessionId: string, + owner: PtySession | CreateAttempt + ): boolean { + const currentSession = this.sessions.get(sessionId) + if ( + currentSession && + currentSession !== owner && + currentSession.activePersistenceOwned + ) { + return true + } + const currentAttempt = this.createAttempts.get(sessionId) + return Boolean( + currentAttempt && + currentAttempt !== owner && + currentAttempt.activePersistenceOwned + ) + } + + private async removePersistenceWithRetry( + sessionId: string, + owner: PtySession | CreateAttempt, + maxAttempts = 2 + ): Promise { + let lastError: unknown + for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) { + try { + await this.runPersistenceOperation(sessionId, async () => { + if (this.hasReplacementPersistenceOwner(sessionId, owner)) { + owner.activePersistenceOwned = false + return + } + await this.sessionManager.removeSession(sessionId) + owner.activePersistenceOwned = false + }) + return + } catch (error) { + lastError = error + if (attemptNumber < maxAttempts) { + this.logger.warn( + `删除PTY会话持久化失败,将重试: ${sessionId}`, + error + ) + } + } + } + throw lastError + } + + private async promoteAttempt( + attempt: CreateAttempt, + launch: PtyProcessLaunch + ): Promise { + if ( + !this.isActiveAttempt(attempt) || + attempt.processError || + attempt.processExited || + attempt.process !== launch.process || + attempt.control !== launch.control + ) { + return + } + + let promoted = false + let rollbackRetry: Promise | undefined + await this.runPersistenceOperation(attempt.id, async () => { + let activeSaved = false + attempt.activePersistenceOwned = false + try { + await this.sessionManager.saveSession({ + id: attempt.id, + name: attempt.name, + workingDirectory: attempt.workingDirectory, + createdAt: attempt.createdAt, + lastActivity: attempt.lastActivity, + isActive: true + }) + activeSaved = true + attempt.activePersistenceOwned = true + } catch (error) { + this.logger.error(`保存会话到配置文件失败: ${attempt.id}`, error) + } + + const canPromote = + this.isActiveAttempt(attempt) && + !attempt.processError && + !attempt.processExited && + attempt.process === launch.process && + attempt.control === launch.control + if (!canPromote) { + if (activeSaved) { + if (this.hasReplacementPersistenceOwner(attempt.id, attempt)) { + attempt.activePersistenceOwned = false + } else { + try { + await this.sessionManager.removeSession(attempt.id) + attempt.activePersistenceOwned = false + } catch (error) { + this.logger.error(`回滚未完成PTY会话失败: ${attempt.id}`, error) + rollbackRetry = this.removePersistenceWithRetry( + attempt.id, + attempt, + 1 + ) + } + } + } + return + } + + const session: PtySession = { + id: attempt.id, + name: attempt.name, + state: 'ready', + size: { + cols: attempt.createSize.cols, + rows: attempt.createSize.rows + }, + process: launch.process, + control: launch.control, + endpoint: launch.endpoint, + socket: attempt.socket, + workingDirectory: attempt.workingDirectory, + createdAt: attempt.createdAt, + lastActivity: attempt.lastActivity, + outputBuffer: attempt.outputBuffer, + streamForwardProcess: attempt.streamForwardProcess, + streamForwardRestartGeneration: 0, + enableStreamForward: attempt.enableStreamForward, + programPath: attempt.programPath, + autoCloseOnForwardExit: attempt.autoCloseOnForwardExit, + fallbackRetried: attempt.phase === 'fallback', + stdoutRedactor: attempt.stdoutRedactor, + stderrRedactor: attempt.stderrRedactor, + onOutput: attempt.onOutput, + onExit: attempt.onExit, + exitNotified: attempt.exitNotified, + activePersistenceOwned: attempt.activePersistenceOwned, + processExited: false, + finalEventSent: false + } + + this.sessions.set(attempt.id, session) + attempt.activePersistenceOwned = false + this.createAttempts.delete(attempt.id) + session.socket.emit('pty-created', { + sessionId: session.id, + workingDirectory: session.workingDirectory + }) + this.logger.info(`PTY会话创建成功: ${session.id}`) + + if (session.enableStreamForward && session.programPath) { + this.startStreamForwardProcess(session, session.programPath) + } + + setTimeout(() => { + const current = this.sessions.get(session.id) + if ( + current !== session || + current.state !== 'ready' || + current.process !== launch.process || + current.processExited + ) { + return + } + this.writePtyStdin(current, '\r') + }, 500) + promoted = true + }) + + if (rollbackRetry) { + try { + await rollbackRetry + } catch (error) { + this.logger.error(`重试回滚未完成PTY会话失败: ${attempt.id}`, error) + } + } + + if ( + !promoted && + this.isActiveAttempt(attempt) && + (attempt.processError || attempt.processExited) + ) { + await this.failCreateAttempt( + attempt, + attempt.processError?.message || 'PTY进程在创建完成前退出' + ) + } + } + + private registerPtyProcessHandlers( + sessionId: string, + ptyProcess: ChildProcess, + startedAt: number + ): Promise { + this.installPtyStdinErrorHandler(sessionId, ptyProcess) + let settled = false + let resolveOutcome!: (outcome: PtyProcessOutcome) => void + const outcome = new Promise(resolve => { + resolveOutcome = resolve + }) + const settleOutcome = (value: PtyProcessOutcome) => { + if (settled) return + settled = true + resolveOutcome(value) + } + + ptyProcess.stdout?.on('data', (data: Buffer) => { + const owner = this.resolveProcessOwner(sessionId, ptyProcess) + if (!owner || !this.canForwardPtyOutput(owner)) return + this.emitPtyOutput(owner, owner.stdoutRedactor.write(data)) + }) + ptyProcess.stdout?.once('end', () => { + const owner = this.resolveProcessOwner(sessionId, ptyProcess) + if (!owner || !this.canForwardPtyOutput(owner)) return + this.emitPtyOutput(owner, owner.stdoutRedactor.end()) + }) + ptyProcess.stderr?.on('data', (data: Buffer) => { + const owner = this.resolveProcessOwner(sessionId, ptyProcess) + if (!owner || !this.canForwardPtyOutput(owner)) return + this.emitPtyOutput(owner, owner.stderrRedactor.write(data), true) + }) + ptyProcess.stderr?.once('end', () => { + const owner = this.resolveProcessOwner(sessionId, ptyProcess) + if (!owner || !this.canForwardPtyOutput(owner)) return + this.emitPtyOutput(owner, owner.stderrRedactor.end(), true) + }) + + const observeProcessExit = ( + code: number | null, + signal: NodeJS.Signals | null + ) => { + const current = this.resolveProcessOwner(sessionId, ptyProcess) + if (!current) return + + this.ensureCloseContext(current, { + intentional: false, + emitEvents: true, + emitTimeoutError: false + }) + const firstObservation = !current.processExited + if (firstObservation) { + current.processExited = true + current.processExitCode = code + current.processExitSignal = signal + this.logger.info( + `PTY进程退出: ${sessionId}, 退出码: ${code}, 信号: ${signal}` + ) + } + + if ('phase' in current) { + if ( + current.cancellation.cancelled || + current.phase === 'closing' || + current.phase === 'close-retained' + ) { + void this.finalizeConfirmedExit(current).catch(error => { + this.logger.error(`清理已退出PTY创建尝试失败: ${sessionId}`, error) + }) + } + return + } + + void this.finalizeConfirmedExit(current).catch(error => { + this.logger.error(`清理已退出PTY会话失败: ${sessionId}`, error) + }) + } + + const settleCloseOutcome = ( + code: number | null, + signal: NodeJS.Signals | null + ) => { + settleOutcome({ + kind: 'close', + code, + signal, + elapsedMs: Date.now() - startedAt + }) + observeProcessExit(code, signal) + } + + ptyProcess.once('exit', settleCloseOutcome) + ptyProcess.once('close', settleCloseOutcome) + + ptyProcess.on('error', (error: Error) => { + settleOutcome({ + kind: 'error', + code: null, + signal: null, + error, + elapsedMs: Date.now() - startedAt + }) + const current = this.resolveProcessOwner(sessionId, ptyProcess) + if (!current) return + + if (!('phase' in current)) { + this.ensureCloseContext(current, { + intentional: false, + emitEvents: true, + emitTimeoutError: false + }) + } + this.logger.error(`PTY进程错误 ${sessionId}:`, error) + if ('phase' in current) { + if ( + !current.cancellation.cancelled && + this.createAttempts.get(sessionId) === current + ) { + current.processError = error + } + return + } + this.handlePromotedSessionProcessError( + sessionId, + ptyProcess, + error + ) + }) + + return outcome + } + + private installPtyStdinErrorHandler( + sessionId: string, + ptyProcess: ChildProcess + ): void { + const stdin = ptyProcess.stdin + if (!stdin) return + if (!this.ptyStdinHandlers) { + this.ptyStdinHandlers = new WeakSet() + } + if (this.ptyStdinHandlers.has(ptyProcess)) return + + this.ptyStdinHandlers.add(ptyProcess) + stdin.on('error', (error: NodeJS.ErrnoException) => { + this.handlePtyStdinError(sessionId, ptyProcess, error) + }) + } + + private isExpectedClosingStdinError(error: NodeJS.ErrnoException): boolean { + return error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED' + } + + private handlePtyStdinError( + sessionId: string, + ptyProcess: ChildProcess, + error: NodeJS.ErrnoException + ): void { + const owner = this.resolveProcessOwner(sessionId, ptyProcess) + if (!owner) return + + const closing = owner.processExited || ( + this.isSessionTarget(owner) + ? owner.state === 'closing' + : owner.cancellation.cancelled || + owner.phase === 'closing' || + owner.phase === 'close-retained' + ) + if (closing) { + if (this.isExpectedClosingStdinError(error)) { + this.logger.debug(`PTY进程stdin在关闭期间已不可写: ${sessionId}`) + } else { + this.logger.warn(`PTY进程stdin在关闭期间发生错误: ${sessionId}`, error) + } + return + } + + if (!this.isSessionTarget(owner)) { + if (owner.processError) return + owner.processError = error + void this.failCreateAttempt(owner, error.message || 'PTY进程stdin写入失败') + .catch(closeError => { + this.logger.error(`PTY创建期间stdin错误清理失败: ${sessionId}`, closeError) + }) + return + } + + if (owner.state !== 'ready' || owner.processErrorSent) { + return + } + owner.processErrorSent = true + owner.state = 'closing' + this.logger.error(`PTY进程stdin写入失败: ${sessionId}`, error) + this.emitTerminalError( + owner.socket, + sessionId, + 'input', + error.message || 'PTY进程stdin写入失败' + ) + void this.requestTargetClose(owner, { + intentional: false, + emitEvents: true, + emitTimeoutError: false, + notifyRetained: true + }).catch(closeError => { + this.logger.error(`PTY进程stdin错误后关闭失败: ${sessionId}`, closeError) + }) + } + + private writePtyStdin(session: PtySession, data: string): boolean { + if ( + this.sessions.get(session.id) !== session || + session.state !== 'ready' || + session.processExited + ) { + return false + } + + const stdin = session.process.stdin + if ( + !stdin || + stdin.destroyed || + stdin.writableEnded || + stdin.writable === false + ) { + this.handlePtyStdinError( + session.id, + session.process, + Object.assign(new Error('PTY进程stdin不可用'), { + code: 'ERR_STREAM_DESTROYED' + }) + ) + return false + } + + try { + stdin.write(data) + return true + } catch (error) { + this.handlePtyStdinError( + session.id, + session.process, + error instanceof Error ? error : new Error(String(error)) + ) + return false + } + } + + private endPtyStdin(target: PtySession | CreateAttempt): void { + if (!this.ownsTarget(target) || !target.process) return + const stdin = target.process.stdin + if ( + !stdin || + stdin.destroyed || + stdin.writableEnded || + stdin.writable === false + ) { + return + } + + try { + stdin.end() + } catch (error) { + this.handlePtyStdinError( + target.id, + target.process, + error instanceof Error ? error : new Error(String(error)) + ) + } + } + + /** + * 为输出流转发进程的 stdin 安装永久 'error' listener,与主 PTY stdin 一致。 + * 必须在任何 write/end 之前调用;异步 EPIPE 只能由 'error' 事件消费。 + */ + private installForwardStdinErrorHandler( + session: PtySession, + forwardProcess: ChildProcess + ): void { + const stdin = forwardProcess.stdin + if (!stdin) return + if (this.forwardStdinHandlers.has(forwardProcess)) return + + this.forwardStdinHandlers.add(forwardProcess) + stdin.on('error', (error: NodeJS.ErrnoException) => { + this.handleForwardStdinError(session, forwardProcess, error) + }) + } + + /** + * 归一化输出流转发进程 stdin 的错误: + * 旧 child 迟到的 EPIPE/ERR_STREAM_DESTROYED 必须先做 identity 校验(与主 PTY + * resolveProcessOwner 同等):restart/internal-stop 后旧 child 的迟到错误不得关闭 + * 健康的新 child/session;确认是当前 child 后,closing/confirmed-exit 阶段的 + * EPIPE/ERR_STREAM_DESTROYED 视为预期关闭结果;ready 阶段真实写失败只报告一次 + * input error,并进入统一 bounded shutdown。 + */ + private handleForwardStdinError( + session: PtySession, + forwardProcess: ChildProcess, + error: NodeJS.ErrnoException + ): void { + if ( + this.sessions.get(session.id) !== session || + session.streamForwardProcess !== forwardProcess + ) { + this.logger.debug( + `忽略旧输出流转发进程stdin错误: ${session.id}(当前进程已替换或会话已移除)` + ) + return + } + + const closing = + session.processExited || + session.state === 'closing' || + this.internallyStoppedForwardProcesses.has(forwardProcess) + if (closing) { + if (this.isExpectedClosingStdinError(error)) { + this.logger.debug(`输出流转发进程stdin在关闭期间已不可写: ${session.id}`) + } else { + this.logger.warn( + `输出流转发进程stdin在关闭期间发生错误: ${session.id}`, + error + ) + } + return + } + + if (session.state !== 'ready' || session.processErrorSent) { + return + } + session.processErrorSent = true + session.state = 'closing' + this.logger.error(`输出流转发进程stdin写入失败: ${session.id}`, error) + this.emitTerminalError( + session.socket, + session.id, + 'input', + error.message || '输出流转发进程stdin写入失败' + ) + void this.requestTargetClose(session, { + intentional: false, + emitEvents: true, + emitTimeoutError: false, + notifyRetained: true + }).catch(closeError => { + this.logger.error( + `输出流转发进程stdin错误后关闭失败: ${session.id}`, + closeError + ) + }) + } + + private writeForwardStdin(session: PtySession, data: string): boolean { + if ( + this.sessions.get(session.id) !== session || + session.state !== 'ready' || + session.processExited + ) { + return false + } + + const forwardProcess = session.streamForwardProcess + if (!forwardProcess || forwardProcess.killed) { + return false + } + const stdin = forwardProcess.stdin + if ( + !stdin || + stdin.destroyed || + stdin.writableEnded || + stdin.writable === false + ) { + this.handleForwardStdinError( + session, + forwardProcess, + Object.assign(new Error('输出流转发进程stdin不可用'), { + code: 'ERR_STREAM_DESTROYED' + }) + ) + return false + } + + try { + stdin.write(data) + return true + } catch (error) { + this.handleForwardStdinError( + session, + forwardProcess, + error instanceof Error ? error : new Error(String(error)) + ) + return false + } + } + + private endForwardStdin( + session: PtySession, + forwardProcess: ChildProcess + ): void { + const stdin = forwardProcess.stdin + if ( + !stdin || + stdin.destroyed || + stdin.writableEnded || + stdin.writable === false + ) { + return + } + + try { + stdin.end() + } catch (error) { + this.handleForwardStdinError( + session, + forwardProcess, + error instanceof Error ? error : new Error(String(error)) + ) + } + } + + private resolveProcessOwner( + sessionId: string, + ptyProcess: ChildProcess + ): PtySession | CreateAttempt | undefined { + const session = this.sessions.get(sessionId) + if (session?.process === ptyProcess) { + return session + } + const attempt = this.createAttempts.get(sessionId) + if (attempt?.process === ptyProcess) { + return attempt + } + return undefined + } + + private canForwardPtyOutput(owner: PtySession | CreateAttempt): boolean { + if ('phase' in owner) { + return !owner.cancellation.cancelled && + this.createAttempts.get(owner.id) === owner + } + return this.sessions.get(owner.id) === owner + } + + private emitPtyOutput( + owner: PtySession | CreateAttempt, + output: string, + isError = false + ): void { + if (!output) return + if ('phase' in owner) { + if ( + owner.cancellation.cancelled || + this.createAttempts.get(owner.id) !== owner + ) { + return + } + } else if (this.sessions.get(owner.id) !== owner) { + return + } + owner.lastActivity = new Date() + owner.outputBuffer.push(output) + if (owner.outputBuffer.length > 1000) { + owner.outputBuffer.shift() + } + try { + owner.onOutput?.(output) + } catch (error) { + this.logger.warn(`PTY输出回调执行失败: ${owner.id}`, error) + } + if (isError) { + this.logger.warn(`PTY错误输出 ${owner.id}: ${JSON.stringify(output)}`) + } else { + this.logger.debug(`PTY输出 ${owner.id}: ${JSON.stringify(output)}`) + } + owner.socket.emit('terminal-output', { sessionId: owner.id, data: output }) + } + + private handlePromotedSessionProcessError( + sessionId: string, + ptyProcess: ChildProcess, + error: Error + ): void { + const session = this.sessions.get(sessionId) + if ( + !session || + session.process !== ptyProcess || + session.processErrorSent + ) { + return + } + + session.processErrorSent = true + session.state = 'closing' + this.emitTerminalError(session.socket, sessionId, 'input', error.message) + void this.requestTargetClose(session, { + intentional: false, + emitEvents: true, + emitTimeoutError: false, + notifyRetained: true + }).catch(closeError => { + this.logger.error(`PTY进程错误后关闭失败: ${sessionId}`, closeError) + }) + } + + private cancelStreamForwardRestart(session: PtySession): void { + session.streamForwardRestartGeneration += 1 + } + + private async stopSessionStreamForward( + session: PtySession, + logMessage: string + ): Promise { + this.cancelStreamForwardRestart(session) + const forwardProcess = session.streamForwardProcess + if (!forwardProcess) { + return true + } + + this.internallyStoppedForwardProcesses.add(forwardProcess) + if (session.pendingForwardAutoCloseProcess === forwardProcess) { + session.pendingForwardAutoCloseProcess = undefined + } + this.logger.info(`${logMessage}: ${session.id}`) + this.endForwardStdin(session, forwardProcess) + + const exited = await this.forceKillProcess(forwardProcess, '输出流转发进程') + if (exited && session.streamForwardProcess === forwardProcess) { + session.streamForwardProcess = undefined + } + return exited + } + + private removeSessionPersistence(session: PtySession): Promise { + if (session.persistenceRemoval) { + return session.persistenceRemoval + } + + const removal = this.removePersistenceWithRetry(session.id, session) + session.persistenceRemoval = removal + void removal.catch(() => { + if (session.persistenceRemoval === removal) { + session.persistenceRemoval = undefined + } + }) + return removal + } + + private createDeferred(): { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: unknown) => void + } { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } + } + + private ensureCloseContext( + target: PtySession | CreateAttempt, + requested: CloseContext + ): CloseContext { + if (target.closeContext) { + return target.closeContext + } + + const context = Object.freeze({ + intentional: requested.intentional, + emitEvents: requested.emitEvents, + emitTimeoutError: requested.emitTimeoutError + }) + target.closeContext = context + return context + } + + private isSessionTarget( + target: PtySession | CreateAttempt + ): target is PtySession { + return 'state' in target + } + + private ownsTarget(target: PtySession | CreateAttempt): boolean { + if (this.isSessionTarget(target)) { + return this.sessions.get(target.id) === target + } + return this.createAttempts.get(target.id) === target + } + + private closeTargetControl( + target: PtySession | CreateAttempt + ): Promise { + if (target.controlClosePromise) { + return target.controlClosePromise + } + + const deferred = this.createDeferred() + target.controlClosePromise = deferred.promise + const control = target.control + if (!control) { + deferred.resolve() + return deferred.promise + } + + void this.closeControlQuietly(target.id, control).then( + deferred.resolve, + deferred.reject + ) + return deferred.promise + } + + private finalizeConfirmedExit( + target: PtySession | CreateAttempt + ): Promise { + if (!target.processExited) { + return Promise.resolve('still-running') + } + if (target.finalizationPromise) { + return target.finalizationPromise + } + + this.ensureCloseContext(target, { + intentional: false, + emitEvents: true, + emitTimeoutError: false + }) + const deferred = this.createDeferred() + target.finalizationPromise = deferred.promise + void this.finishConfirmedExit(target).then( + result => { + if ( + result === 'still-running' && + target.finalizationPromise === deferred.promise + ) { + target.finalizationPromise = undefined + } + deferred.resolve(result) + }, + error => { + if (target.finalizationPromise === deferred.promise) { + target.finalizationPromise = undefined + } + deferred.reject(error) + } + ) + return deferred.promise + } + + private async finishConfirmedExit( + target: PtySession | CreateAttempt + ): Promise { + if (!target.processExited) { + return 'still-running' + } + + let forwardExited = true + try { + forwardExited = this.isSessionTarget(target) + ? await this.stopSessionStreamForward(target, 'PTY退出时清理输出流转发进程') + : await this.stopAttemptStreamForward(target) + } catch (error) { + forwardExited = false + this.logger.warn(`PTY退出时清理输出流转发进程失败: ${target.id}`, error) + } + if (!forwardExited) { + this.retainTargetAfterCloseTimeout(target) + return 'still-running' + } + + const ownedBeforeCleanup = this.ownsTarget(target) + await this.closeTargetControl(target) + if (!target.processExited) { + return 'still-running' + } + + const stillOwned = this.ownsTarget(target) + if (stillOwned) { + if (this.isSessionTarget(target)) { + this.sessions.delete(target.id) + } else { + this.createAttempts.delete(target.id) + } + } + if (ownedBeforeCleanup && stillOwned) { + this.emitTargetFinalEvent(target) + this.notifyTargetExitCallback(target) + } + + try { + if (target.activePersistenceOwned) { + if (this.isSessionTarget(target)) { + await this.removeSessionPersistence(target) + } else { + await this.removePersistenceWithRetry(target.id, target) + } + } + } catch (error) { + this.logger.error(`PTY退出时从配置文件删除会话失败: ${target.id}`, error) + } + + if (target.endpoint) { + await this.removeEndpointQuietly(target.id, target.endpoint) + } + return 'closed' + } + + private registerPublicCloseRequester( + target: PtySession | CreateAttempt, + socket: Socket + ): void { + if (!target.publicCloseRequesters) { + target.publicCloseRequesters = new Map() + } + if (!target.publicCloseAckedIds) { + target.publicCloseAckedIds = new Set() + } + if (target.publicCloseAckedIds.has(socket.id)) { + return } + target.publicCloseRequesters.set(socket.id, socket) + } + + private markTargetSocketAcked( + target: PtySession | CreateAttempt, + socket: Socket + ): void { + if (!target.publicCloseAckedIds) { + target.publicCloseAckedIds = new Set() + } + target.publicCloseAckedIds.add(socket.id) + target.publicCloseRequesters?.delete(socket.id) } /** - * 强制终止进程 + * 每个 public requester 在 confirmed removal 后恰好收到一次 pty-closed; + * requester 覆盖/重置不会让早期 requester 丢 ACK,final events 不会重复。 */ - private forceKillProcess(process: any, processName: string, onKilled?: () => void): void { - if (!process || process.killed) { - onKilled?.() + private emitPublicCloseAck(target: PtySession | CreateAttempt): void { + const requesters = target.publicCloseRequesters + if (!requesters || requesters.size === 0) { return } + if (!target.publicCloseAckedIds) { + target.publicCloseAckedIds = new Set() + } - const pid = process.pid - this.logger.info(`开始强制终止${processName},PID: ${pid}`) + for (const [socketId, requester] of [...requesters.entries()]) { + requesters.delete(socketId) + target.publicCloseAckedIds.add(socketId) + requester.emit('pty-closed', { sessionId: target.id }) + } + } - // 监听进程退出事件 - const onExit = () => { - this.logger.info(`${processName}已退出: ${pid}`) - onKilled?.() + private notifyTargetExitCallback(target: PtySession | CreateAttempt): void { + if (!target.process || target.exitNotified) { + return } - - process.once('exit', onExit) + target.exitNotified = true try { - // Linux下优先处理进程组 - if (os.platform() !== 'win32' && pid) { - try { - // 首先尝试向整个进程组发送SIGINT信号 - process.kill(-pid, 'SIGINT') - this.logger.info(`已向${processName}进程组发送SIGINT信号: -${pid}`) - } catch (error) { - // 如果进程组不存在,向单个进程发送信号 - this.logger.warn(`向进程组发送信号失败,尝试向单个进程发送: ${error}`) - process.kill('SIGINT') - this.logger.info(`已向${processName}发送SIGINT信号: ${pid}`) + target.onExit?.( + target.processExitCode ?? 0, + target.processExitSignal ?? null + ) + } catch (error) { + this.logger.warn(`PTY退出回调执行失败: ${target.id}`, error) + } + } + + private emitTargetFinalEvent(target: PtySession | CreateAttempt): void { + const context = this.ensureCloseContext(target, { + intentional: false, + emitEvents: true, + emitTimeoutError: false + }) + + if (!target.finalEventSent && context.emitEvents) { + if (!this.isSessionTarget(target)) { + if (context.intentional) { + target.finalEventSent = true + target.socket.emit('pty-closed', { sessionId: target.id }) + this.markTargetSocketAcked(target, target.socket) } } else { - // Windows下直接向进程发送信号 - process.kill('SIGINT') - this.logger.info(`已向${processName}发送SIGINT信号: ${pid}`) + target.finalEventSent = true + if (context.intentional) { + target.socket.emit('pty-closed', { sessionId: target.id }) + this.markTargetSocketAcked(target, target.socket) + } else { + target.socket.emit('terminal-exit', { + sessionId: target.id, + code: target.processExitCode ?? 0, + signal: target.processExitSignal ?? null + }) + } } + } - // 设置2秒超时,如果进程还没退出就使用SIGTERM - setTimeout(() => { - if (!process.killed) { - this.logger.warn(`${processName}未响应SIGINT信号,尝试SIGTERM: ${pid}`) - try { - if (os.platform() !== 'win32' && pid) { - try { - // 向进程组发送SIGTERM - process.kill(-pid, 'SIGTERM') - this.logger.info(`已向${processName}进程组发送SIGTERM信号: -${pid}`) - } catch (error) { - // 向单个进程发送SIGTERM - process.kill('SIGTERM') - this.logger.info(`已向${processName}发送SIGTERM信号: ${pid}`) - } - } else { - process.kill('SIGTERM') - } - } catch (error) { - this.logger.warn(`发送SIGTERM信号失败:`, error) - } + this.emitPublicCloseAck(target) + } - // 再等待2秒,如果还没退出就强制杀死 - setTimeout(() => { - if (!process.killed) { - this.logger.warn(`${processName}未响应SIGTERM信号,强制杀死: ${pid}`) - try { - if (os.platform() !== 'win32' && pid) { - try { - // 向进程组发送SIGKILL - process.kill(-pid, 'SIGKILL') - this.logger.info(`已向${processName}进程组发送SIGKILL信号: -${pid}`) - } catch (error) { - // 向单个进程发送SIGKILL - process.kill('SIGKILL') - this.logger.info(`已向${processName}发送SIGKILL信号: ${pid}`) - } - } else { - process.kill('SIGKILL') - } - } catch (error) { - this.logger.error(`强制杀死进程失败:`, error) - - // 在Windows上尝试使用taskkill命令 - if (os.platform() === 'win32' && pid) { - exec(`taskkill /F /PID ${pid}`, (error: any) => { - if (error) { - this.logger.error(`taskkill命令执行失败:`, error) - } else { - this.logger.info(`使用taskkill成功终止${processName}: ${pid}`) - } - // 即使taskkill失败,也调用回调函数清理引用 - if (!process.killed) { - process.removeListener('exit', onExit) - onKilled?.() - } - }) - } else { - // 非Windows平台,尝试使用系统命令强制杀死进程组 - if (pid) { - exec(`pkill -9 -g ${pid}`, (error: any) => { - if (error) { - this.logger.error(`pkill命令执行失败:`, error) - } else { - this.logger.info(`使用pkill成功终止${processName}进程组: ${pid}`) - } - // 强制清理引用 - if (!process.killed) { - process.removeListener('exit', onExit) - onKilled?.() - } - }) - } else { - // 如果所有方法都失败,强制清理引用 - process.removeListener('exit', onExit) - onKilled?.() - } - } - } - } - }, 2000) - } - }, 2000) + private async failCreateAttempt( + attempt: CreateAttempt, + error: string + ): Promise { + if (!this.isActiveAttempt(attempt)) return + + attempt.failureMessage = error + attempt.finalEventSent = true + this.emitTerminalError(attempt.socket, attempt.id, 'create', error) + await this.requestTargetClose(attempt, { + intentional: false, + emitEvents: false, + emitTimeoutError: false, + notifyRetained: true + }) + } + + private async closeControlQuietly( + sessionId: string, + control: PtyControlChannel + ): Promise { + try { + await control.close() + } catch (error) { + this.logger.warn(`关闭PTY控制通道失败: ${sessionId}`, error) + } + } + + private async removeEndpointQuietly( + sessionId: string, + endpoint: string + ): Promise { + try { + await removePtyControlEndpoint(endpoint) + } catch (error) { + this.logger.warn(`删除PTY控制端点失败: ${sessionId}`, error) + } + } + + private emitTerminalError( + socket: Socket, + sessionId: string, + operation: 'create' | 'input' | 'resize' | 'close', + error: string, + details: { retained?: boolean } = {} + ): void { + socket.emit('terminal-error', { sessionId, operation, error, ...details }) + } + + private hasChildProcessExited(child: ChildProcess): boolean { + if (child.exitCode !== null && child.exitCode !== undefined) { + return true + } + if (child.signalCode !== null && child.signalCode !== undefined) { + return true + } + + const pid = child.pid + if (!pid) { + return false + } + try { + process.kill(pid, 0) + return false + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ESRCH' + } + } + + private waitForChildProcessExit( + child: ChildProcess, + timeoutMs: number + ): Promise { + if (this.hasChildProcessExited(child)) { + return Promise.resolve(true) + } + + return new Promise(resolve => { + let settled = false + let timer: NodeJS.Timeout + const finish = (exited: boolean) => { + if (settled) return + settled = true + clearTimeout(timer) + child.removeListener('exit', onExit) + child.removeListener('close', onExit) + resolve(exited) + } + const onExit = () => finish(true) + + child.once('exit', onExit) + child.once('close', onExit) + timer = setTimeout(() => finish(this.hasChildProcessExited(child)), timeoutMs) + timer.unref?.() + if (this.hasChildProcessExited(child)) { + finish(true) + } + }) + } + + private sendSignalToPtyProcessGroup( + child: ChildProcess, + processName: string, + signal: NodeJS.Signals + ): void { + const pid = child.pid + if (os.platform() !== 'win32' && pid) { + try { + process.kill(-pid, signal) + this.logger.info(`已向${processName}进程组发送${signal}信号: -${pid}`) + return + } catch (error) { + this.logger.warn(`向${processName}进程组发送${signal}失败,尝试主进程: ${error}`) + } + } + try { + child.kill(signal) + this.logger.info(`已向${processName}发送${signal}信号: ${pid}`) } catch (error) { - this.logger.error(`强制终止${processName}失败:`, error) - process.removeListener('exit', onExit) + this.logger.warn(`向${processName}发送${signal}信号失败:`, error) + } + } + + /** + * 逐级终止进程,并且只在 exit/close 或 PID 不存在时确认退出。 + */ + private async forceKillProcess( + child: ChildProcess | undefined, + processName: string, + onKilled?: () => void + ): Promise { + if (!child) { onKilled?.() + return true + } + + const complete = () => { + this.logger.info(`${processName}已确认退出: ${child.pid}`) + try { + onKilled?.() + } catch (error) { + this.logger.warn(`${processName}退出回调执行失败:`, error) + } + return true + } + if (this.hasChildProcessExited(child)) { + return complete() + } + + const pid = child.pid + this.logger.info(`开始终止${processName},PID: ${pid}`) + this.sendSignalToPtyProcessGroup(child, processName, 'SIGINT') + if (await this.waitForChildProcessExit(child, 2000)) { + return complete() + } + + this.logger.warn(`${processName}未响应SIGINT,尝试SIGTERM: ${pid}`) + this.sendSignalToPtyProcessGroup(child, processName, 'SIGTERM') + if (await this.waitForChildProcessExit(child, 2000)) { + return complete() + } + + this.logger.warn(`${processName}未响应SIGTERM,尝试SIGKILL: ${pid}`) + this.sendSignalToPtyProcessGroup(child, processName, 'SIGKILL') + if (await this.waitForChildProcessExit(child, 1000)) { + return complete() + } + + if (os.platform() === 'win32' && pid) { + try { + await execAsync(`taskkill /F /T /PID ${pid}`, { timeout: 3000 }) + } catch (error) { + this.logger.warn(`taskkill终止${processName}失败:`, error) + } + if (await this.waitForChildProcessExit(child, 1000)) { + return complete() + } } + + this.logger.error(`${processName}在终止期限内仍未确认退出: ${pid}`) + return false } /** * 重启输出流转发进程 */ - public restartStreamForwardProcess(sessionId: string): boolean { + public async restartStreamForwardProcess(sessionId: string): Promise { const session = this.sessions.get(sessionId) - if (!session || !session.enableStreamForward || !session.programPath) { + if ( + !session || + session.state !== 'ready' || + !session.enableStreamForward || + !session.programPath + ) { this.logger.warn(`无法重启转发进程: 会话不存在或未启用输出流转发: ${sessionId}`) return false } - // 先终止现有进程 - if (session.streamForwardProcess && !session.streamForwardProcess.killed) { - this.forceKillProcess(session.streamForwardProcess, '输出流转发进程', () => { + const programPath = session.programPath + const restartGeneration = session.streamForwardRestartGeneration + 1 + session.streamForwardRestartGeneration = restartGeneration + + const forwardProcess = session.streamForwardProcess + if (forwardProcess) { + this.internallyStoppedForwardProcesses.add(forwardProcess) + if (session.pendingForwardAutoCloseProcess === forwardProcess) { + session.pendingForwardAutoCloseProcess = undefined + } + const exited = await this.forceKillProcess(forwardProcess, '输出流转发进程') + if (!exited) { + return false + } + if ( + this.sessions.get(sessionId) !== session || + session.state !== 'ready' || + session.streamForwardRestartGeneration !== restartGeneration || + (session.streamForwardProcess && + session.streamForwardProcess !== forwardProcess) + ) { + return false + } + if (session.streamForwardProcess === forwardProcess) { session.streamForwardProcess = undefined - // 重新启动进程 - this.startStreamForwardProcess(session, session.programPath!) - }) - } else { - // 直接启动新进程 - this.startStreamForwardProcess(session, session.programPath) + } } + this.startStreamForwardProcess(session, programPath) return true } @@ -784,6 +2187,7 @@ export class TerminalManager { * 启动输出流转发进程 */ private startStreamForwardProcess(session: PtySession, programPath: string): void { + this.cancelStreamForwardRestart(session) try { this.logger.info(`启动输出流转发进程: ${programPath}`) @@ -845,7 +2249,10 @@ export class TerminalManager { }) } + session.pendingForwardAutoCloseProcess = undefined session.streamForwardProcess = forwardProcess + // 永久 stdin error listener:任何 write/end 之前安装,防止异步 EPIPE 逃逸为 uncaughtException + this.installForwardStdinErrorHandler(session, forwardProcess) this.logger.info(`输出流转发进程已启动,PID: ${forwardProcess.pid}`) @@ -900,6 +2307,33 @@ export class TerminalManager { // 处理转发进程退出 forwardProcess.on('exit', (code, signal) => { + if (this.internallyStoppedForwardProcesses.delete(forwardProcess)) { + if (session.streamForwardProcess === forwardProcess) { + session.streamForwardProcess = undefined + } + if (session.pendingForwardAutoCloseProcess === forwardProcess) { + session.pendingForwardAutoCloseProcess = undefined + } + // 首次 forward shutdown 返回 false(retained)时,后续 child exit 必须 + // 重新触发 finalizeConfirmedExit,不能只清引用:PTY 进程已退出后, + // target/map/endpoint/persistence 不应继续 retained。 + if ( + this.sessions.get(session.id) === session && + session.processExited + ) { + void this.finalizeConfirmedExit(session).catch(error => { + this.logger.error(`清理已退出PTY会话失败: ${session.id}`, error) + }) + } + return + } + if ( + session.streamForwardProcess && + session.streamForwardProcess !== forwardProcess + ) { + return + } + this.logger.info(`转发进程退出: ${session.id}, 退出码: ${code}, 信号: ${signal}`) let exitMessage: string @@ -922,10 +2356,13 @@ export class TerminalManager { data: exitMessage }) - session.streamForwardProcess = undefined + if (session.streamForwardProcess === forwardProcess) { + session.streamForwardProcess = undefined + } // 如果配置了自动关闭,则在转发进程退出后关闭终端会话 if (session.autoCloseOnForwardExit) { + session.pendingForwardAutoCloseProcess = forwardProcess session.socket.emit('terminal-output', { sessionId: session.id, data: `\r\n[转发进程已退出,正在关闭终端会话...]\r\n` @@ -933,7 +2370,18 @@ export class TerminalManager { // 延迟关闭,让用户看到消息 setTimeout(() => { - this.closePty(session.socket, { sessionId: session.id }) + if ( + this.sessions.get(session.id) !== session || + session.pendingForwardAutoCloseProcess !== forwardProcess || + session.streamForwardProcess + ) { + return + } + session.pendingForwardAutoCloseProcess = undefined + void this.closePty(session.socket, { sessionId: session.id }) + .catch(error => { + this.logger.error(`转发进程退出后关闭PTY会话失败: ${session.id}`, error) + }) }, 2000) } else { // 如果是异常退出或错误退出,提供重启选项 @@ -948,6 +2396,13 @@ export class TerminalManager { // 处理转发进程错误 forwardProcess.on('error', (error: NodeJS.ErrnoException) => { + if ( + this.internallyStoppedForwardProcesses.has(forwardProcess) || + (session.streamForwardProcess && + session.streamForwardProcess !== forwardProcess) + ) { + return + } this.logger.error(`转发进程错误 ${session.id}:`, error) let errorMessage: string @@ -965,7 +2420,9 @@ export class TerminalManager { sessionId: session.id, data: errorMessage }) - session.streamForwardProcess = undefined + if (session.streamForwardProcess === forwardProcess) { + session.streamForwardProcess = undefined + } }) // 将终端输入转发到目标进程 @@ -984,19 +2441,24 @@ export class TerminalManager { * 处理终端输入 */ public handleInput(socket: Socket, data: TerminalInputData): void { + const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : '' try { - const { sessionId, data: inputData } = data + if (typeof data?.data !== 'string') { + this.emitTerminalError(socket, sessionId, 'input', '终端输入无效') + return + } + const inputData = data.data const session = this.sessions.get(sessionId) - + if (!session) { this.logger.warn(`会话不存在: ${sessionId}`) - socket.emit('terminal-error', { - sessionId, - error: '会话不存在' - }) + this.emitTerminalError(socket, sessionId, 'input', '会话不存在') return } - + if (session.state !== 'ready') { + return + } + // 如果会话之前断开连接,现在重新连接 // 仅当传入的是真实的Socket.IO socket时才替换(避免虚拟socket覆盖) if (session.disconnected && (socket as any).connected !== undefined) { @@ -1005,7 +2467,7 @@ export class TerminalManager { session.socket = socket this.logger.info(`会话 ${sessionId} 重新连接成功`) } - + // 更新最后活动时间 session.lastActivity = new Date() @@ -1017,13 +2479,25 @@ export class TerminalManager { data: `\r\n[正在重启输出流转发进程...]\r\n` }) - const success = this.restartStreamForwardProcess(sessionId) - if (!success) { - session.socket.emit('terminal-output', { - sessionId: session.id, - data: `\r\n[重启转发进程失败]\r\n` - }) - } + void this.restartStreamForwardProcess(sessionId).then( + success => { + if (!success && this.sessions.get(sessionId) === session) { + session.socket.emit('terminal-output', { + sessionId: session.id, + data: `\r\n[重启转发进程失败]\r\n` + }) + } + }, + error => { + this.logger.error(`重启输出流转发进程失败: ${sessionId}`, error) + if (this.sessions.get(sessionId) === session) { + session.socket.emit('terminal-output', { + sessionId: session.id, + data: `\r\n[重启转发进程失败]\r\n` + }) + } + } + ) } else { session.socket.emit('terminal-output', { sessionId: session.id, @@ -1051,34 +2525,43 @@ export class TerminalManager { // 对于某些控制字符,直接传递给PTY而不发送信号 if (controlChar.signal === 'EOF' || controlChar.signal === 'KILL_LINE' || controlChar.signal === 'CLEAR') { this.logger.info(`直接传递控制字符 ${controlChar.name} 到 PTY 进程: ${sessionId}`) - if (session.process.stdin && !session.process.stdin.destroyed) { - session.process.stdin.write(inputData) - } + this.writePtyStdin(session, inputData) return } // 如果有输出流转发进程,优先处理它 if (session.streamForwardProcess && !session.streamForwardProcess.killed) { - const pid = session.streamForwardProcess.pid + // 捕获调用时刻的转发进程引用:异步 taskkill fallback 只作用于该进程, + // restart 替换 child 后旧 taskkill 失败不得误杀健康的新 child。 + const targetForwardProcess = session.streamForwardProcess + const pid = targetForwardProcess.pid this.logger.info(`向输出流转发进程(PID: ${pid})及其子进程发送${controlChar.name}信号...`) if (os.platform() === 'win32') { // Windows下的处理 if (controlChar.signal === 'SIGINT') { - // 使用 taskkill /T 来优雅地终止整个进程树 - exec(`taskkill /PID ${pid} /T`, (err) => { - if (err) { - this.logger.error(`使用 taskkill /T 终止进程树 PID: ${pid} 失败:`, err) - // 作为后备,尝试原来的方法 - try { - session.streamForwardProcess.kill('SIGINT') - } catch (killError) { - this.logger.error(`后备的 kill SIGINT 信号也失败了:`, killError) - } - } else { + // 使用 taskkill /T 来优雅地终止整个进程树(有界超时,避免 exec 挂起) + void execAsync(`taskkill /PID ${pid} /T`, { timeout: 3000 }).then( + () => { this.logger.info(`成功通过 taskkill /T 向进程树 PID: ${pid} 发送关闭信号`) + }, + (taskkillError) => { + this.logger.error(`使用 taskkill /T 终止进程树 PID: ${pid} 失败:`, taskkillError) + // 作为后备,尝试原来的方法(仅作用于调用时刻捕获的进程,做 identity 校验) + if ( + this.sessions.get(session.id) === session && + session.streamForwardProcess === targetForwardProcess + ) { + try { + targetForwardProcess.kill('SIGINT') + } catch (killError) { + this.logger.error(`后备的 kill SIGINT 信号也失败了:`, killError) + } + } else { + this.logger.debug(`输出流转发进程已替换,跳过后备 kill: ${session.id}`) + } } - }) + ) } else { // 其他信号直接发送,但需要确保是有效的信号类型 if (typeof controlChar.signal === 'string' && controlChar.signal.startsWith('SIG')) { @@ -1113,144 +2596,352 @@ export class TerminalManager { } else { // 如果没有输出流转发进程,则将控制字符发送到PTY进程 this.logger.info(`向 PTY 进程发送 ${controlChar.name}: ${sessionId}`) - if (session.process.stdin && !session.process.stdin.destroyed) { - session.process.stdin.write(inputData) - } + this.writePtyStdin(session, inputData) } return } - + // 发送输入到PTY进程 - if (session.process.stdin && !session.process.stdin.destroyed) { - session.process.stdin.write(inputData) - } else { - this.logger.warn(`PTY进程stdin不可用: ${sessionId}`) - } + this.writePtyStdin(session, inputData) // 如果启用了输出流转发,也将输入转发到目标进程 if (session.streamForwardProcess && !session.streamForwardProcess.killed) { - if (session.streamForwardProcess.stdin && !session.streamForwardProcess.stdin.destroyed) { - session.streamForwardProcess.stdin.write(inputData) - } + this.writeForwardStdin(session, inputData) } - + } catch (error) { + const message = error instanceof Error ? error.message : '未知错误' this.logger.error(`处理终端输入失败:`, error) - socket.emit('terminal-error', { - sessionId: data.sessionId, - error: error instanceof Error ? error.message : '未知错误' + this.emitTerminalError(socket, sessionId, 'input', message) + } + } + + /** + * 调整终端大小 + */ + public async resizeTerminal( + socket: Socket, + data: TerminalResizeData + ): Promise { + const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : '' + let size: PtySize + try { + size = validatePtySize(data?.cols, data?.rows) + } catch (error) { + const message = error instanceof Error ? error.message : '终端大小无效' + this.emitTerminalError(socket, sessionId, 'resize', message) + return + } + + const session = this.sessions.get(sessionId) + if (!session || session.state !== 'ready') { + return + } + + const control = session.control + try { + const result = await control.enqueueResize(size) + const current = this.sessions.get(sessionId) + if ( + current !== session || + current.state !== 'ready' || + current.control !== control || + result !== 'written' + ) { + return + } + + current.size = { cols: size.cols, rows: size.rows } + current.lastActivity = new Date() + socket.emit('terminal-resized', { + sessionId, + cols: size.cols, + rows: size.rows + }) + } catch (error) { + const current = this.sessions.get(sessionId) + if ( + current !== session || + current.state !== 'ready' || + current.control !== control + ) { + return + } + + this.ensureCloseContext(session, { + intentional: false, + emitEvents: true, + emitTimeoutError: false }) + this.logger.error(`调整终端大小失败: ${sessionId}`, error) + this.emitTerminalError( + socket, + sessionId, + 'resize', + 'PTY 控制通道写入 resize 失败' + ) + await this.terminateSession(session, { intentional: false }) + } + } + + private async terminateSession( + session: PtySession, + options: { intentional: boolean } + ): Promise { + if (this.sessions.get(session.id) !== session) { + return + } + if (!options.intentional) { + session.processErrorSent = true + } + + await this.requestTargetClose(session, { + intentional: options.intentional, + emitEvents: true, + emitTimeoutError: false, + notifyRetained: !options.intentional + }) + } + + private requestTargetClose( + target: PtySession | CreateAttempt, + options: CloseRequestOptions + ): Promise { + if (options.publicRequester) { + this.registerPublicCloseRequester(target, options.publicRequester) + } + if (options.notifyRetained) { + target.notifyRetainedOnTimeout = true + target.retainedTimeoutNotified = false + } + if (target.closePromise) { + return target.closePromise + } + + this.ensureCloseContext(target, options) + if (this.isSessionTarget(target)) { + target.state = 'closing' + } else { + target.cancellation.cancelled = true + target.phase = 'closing' + } + + const deferred = this.createDeferred() + target.closePromise = deferred.promise + void this.closeTarget(target).then( + result => { + if ( + result === 'still-running' && + target.closePromise === deferred.promise + ) { + target.closePromise = undefined + } + deferred.resolve(result) + }, + error => { + if (target.closePromise === deferred.promise) { + target.closePromise = undefined + } + deferred.reject(error) + } + ) + return deferred.promise + } + + private async closeTarget( + target: PtySession | CreateAttempt + ): Promise { + await this.closeTargetControl(target) + + let forwardExited = true + try { + forwardExited = this.isSessionTarget(target) + ? await this.stopSessionStreamForward(target, '终止输出流转发进程') + : await this.stopAttemptStreamForward(target) + } catch (error) { + forwardExited = false + this.logger.warn(`终止输出流转发进程失败: ${target.id}`, error) + } + + const finalize = (): Promise => { + if (!forwardExited) { + this.retainTargetAfterCloseTimeout(target) + return Promise.resolve('still-running') + } + return this.finalizeConfirmedExit(target) + } + + const ptyProcess = target.process + if (!ptyProcess) { + target.processExited = true + target.processExitCode = 0 + target.processExitSignal = null + return finalize() + } + + this.endPtyStdin(target) + + if (!target.processExited) { + this.sendSignalToPtyProcessGroup(ptyProcess, 'PTY进程', 'SIGTERM') + } + if (await this.waitForTargetExit(target, 3000)) { + return finalize() + } + + this.logger.warn(`PTY进程未响应SIGTERM,发送SIGKILL: ${target.id}`) + this.sendSignalToPtyProcessGroup(ptyProcess, 'PTY进程', 'SIGKILL') + if (await this.waitForTargetExit(target, 1000)) { + return finalize() } + + this.retainTargetAfterCloseTimeout(target) + return 'still-running' } - /** - * 调整终端大小 - */ - public resizeTerminal(socket: Socket, data: TerminalResizeData): void { - try { - const { sessionId, cols, rows } = data - const session = this.sessions.get(sessionId) - - if (!session) { - this.logger.warn(`会话不存在: ${sessionId}`) - return + private retainTargetAfterCloseTimeout( + target: PtySession | CreateAttempt + ): void { + if (this.isSessionTarget(target)) { + target.state = 'closing' + } else { + target.phase = 'close-retained' + } + + const shouldNotify = + target.notifyRetainedOnTimeout || target.closeContext?.emitTimeoutError + if (!shouldNotify || target.retainedTimeoutNotified) { + return + } + + target.retainedTimeoutNotified = true + target.notifyRetainedOnTimeout = false + const requesters = target.publicCloseRequesters + // N6-I2:retained 通知发给全部仍连接的 public close requester(handleDisconnect + // 已移除断开 socket),而不是只通知最后一个——否则其余 requester 的客户端 + // ACK-owned 队列永久停在 awaitingCloseAck。一次性 flag 语义保持:每轮每个 + // requester 恰好一次(retainedTimeoutNotified);requester 不在此处移出 Map, + // confirmed removal 的 pty-closed ACK 仍由 emitPublicCloseAck 恰好一次发出。 + if (requesters && requesters.size > 0) { + for (const requester of [...requesters.values()]) { + this.emitTerminalError( + requester, + target.id, + 'close', + 'PTY进程未在关闭期限内退出,已保留会话以便重试', + { retained: true } + ) } - - this.logger.info(`调整终端大小: ${sessionId}, ${cols}x${rows}`) - - // 更新最后活动时间 - session.lastActivity = new Date() - - // 注意:由于PTY进程的限制,我们无法直接获取当前终端大小 - // 这里直接进行大小调整操作,让PTY进程处理实际的大小变化 - - // 由于当前PTY程序在启动时设置固定大小,动态调整大小功能有限 - // 我们只发送SIGWINCH信号通知进程窗口大小变化,不发送可见的命令到终端 - - // 发送SIGWINCH信号通知子进程窗口大小变化 + } else { + this.emitTerminalError( + target.socket, + target.id, + 'close', + 'PTY进程未在关闭期限内退出,已保留会话以便重试', + { retained: true } + ) + } + } + + private async stopAttemptStreamForward(attempt: CreateAttempt): Promise { + const forwardProcess = attempt.streamForwardProcess + if (!forwardProcess) { + return true + } + + this.internallyStoppedForwardProcesses.add(forwardProcess) + if (forwardProcess.stdin && !forwardProcess.stdin.destroyed) { try { - if (session.process.pid && !session.process.killed) { - process.kill(session.process.pid, 'SIGWINCH') - this.logger.info(`已发送SIGWINCH信号调整终端大小: ${sessionId}, ${cols}x${rows}`) - } - } catch (signalError) { - this.logger.debug(`发送SIGWINCH信号失败: ${signalError}`) + forwardProcess.stdin.end() + } catch (error) { + this.logger.warn(`关闭创建尝试的输出流转发stdin失败: ${attempt.id}`, error) } - - // 通知前端大小调整完成 - session.socket.emit('terminal-resized', { - sessionId, - cols, - rows - }) - - } catch (error) { - this.logger.error(`调整终端大小失败:`, error) } + + const exited = await this.forceKillProcess( + forwardProcess, + '创建尝试的输出流转发进程' + ) + if (exited && attempt.streamForwardProcess === forwardProcess) { + attempt.streamForwardProcess = undefined + } + return exited } - /** - * 关闭PTY会话 - */ - public closePty(socket: Socket, data: { sessionId: string }): void { - try { - const { sessionId } = data - const session = this.sessions.get(sessionId) - - if (!session) { - this.logger.warn(`尝试关闭不存在的会话: ${sessionId}`) - return + private waitForTargetExit( + target: PtySession | CreateAttempt, + timeoutMs: number + ): Promise { + if (target.processExited || !target.process) { + return Promise.resolve(true) + } + + const ptyProcess = target.process + return new Promise(resolve => { + let settled = false + let timer: NodeJS.Timeout + const finish = (exited: boolean) => { + if (settled) return + settled = true + clearTimeout(timer) + ptyProcess.removeListener('exit', onExit) + ptyProcess.removeListener('close', onExit) + resolve(exited) } - - this.logger.info(`关闭PTY会话: ${sessionId}`) - - // 终止输出流转发进程 - if (session.streamForwardProcess && !session.streamForwardProcess.killed) { - this.logger.info(`终止输出流转发进程: ${sessionId}`) - - // 关闭输入流 - if (session.streamForwardProcess.stdin && !session.streamForwardProcess.stdin.destroyed) { - session.streamForwardProcess.stdin.end() + const onExit = ( + code: number | null, + signal: NodeJS.Signals | null + ) => { + if (!target.processExited) { + target.processExited = true + target.processExitCode = code + target.processExitSignal = signal } - - // 使用强制终止方法 - this.forceKillProcess(session.streamForwardProcess, '输出流转发进程', () => { - session.streamForwardProcess = undefined - }) + finish(true) } - - // 终止PTY进程 - if (!session.process.killed) { - // 关闭输入流 - if (session.process.stdin && !session.process.stdin.destroyed) { - session.process.stdin.end() - } - - // 发送SIGTERM信号 - session.process.kill('SIGTERM') - - // 如果进程在3秒内没有退出,强制杀死 - setTimeout(() => { - if (!session.process.killed) { - this.logger.warn(`强制终止PTY进程: ${sessionId}`) - session.process.kill('SIGKILL') - } - }, 3000) + + ptyProcess.once('exit', onExit) + ptyProcess.once('close', onExit) + timer = setTimeout(() => finish(target.processExited), timeoutMs) + timer.unref?.() + if (target.processExited) { + finish(true) } - - // 从会话列表中移除 - this.sessions.delete(sessionId) - - // 从持久化存储中移除会话 - this.sessionManager.removeSession(sessionId).catch(error => { - this.logger.error(`从配置文件删除会话失败: ${sessionId}`, error) - }) - - // 通知客户端会话已关闭 + }) + } + + /** + * 关闭PTY会话或仍处于创建阶段的PTY目标。 + */ + public closePty( + socket: Socket, + data: { sessionId: string } + ): Promise { + const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : '' + const target = this.sessions.get(sessionId) ?? this.createAttempts.get(sessionId) + if (!target) { + this.logger.warn(`尝试关闭不存在的会话: ${sessionId}`) socket.emit('pty-closed', { sessionId }) - - } catch (error) { - this.logger.error(`关闭PTY会话失败:`, error) + return Promise.resolve('not-found') + } + + const closePromise = this.requestTargetClose(target, { + intentional: true, + emitEvents: true, + emitTimeoutError: true, + publicRequester: socket, + notifyRetained: true + }) + this.logger.info(`关闭PTY会话: ${sessionId}`) + return closePromise + } + + /** + * 客户端断开时从所有目标的 publicCloseRequester 集合中移除该 socket: + * 避免长期 retained target 累积对已断开 Socket 的强引用。 + */ + private removeDisconnectedPublicRequester(socket: Socket): void { + for (const target of [...this.sessions.values(), ...this.createAttempts.values()]) { + target.publicCloseRequesters?.delete(socket.id) } } @@ -1259,36 +2950,53 @@ export class TerminalManager { */ public handleDisconnect(socket: Socket): void { try { - // 找到属于该socket的所有会话并标记为断开状态 - const sessionsToMark: string[] = [] - - for (const [sessionId, session] of this.sessions.entries()) { - if (session.socket.id === socket.id) { - sessionsToMark.push(sessionId) - } + this.removeDisconnectedPublicRequester(socket) + let markedSessionCount = 0 + for (const session of this.sessions.values()) { + if (session.socket.id !== socket.id) continue + + session.disconnected = true + session.disconnectedAt = new Date() + session.lastActivity = new Date() + markedSessionCount += 1 + void this.sessionManager.setSessionActive(session.id, false).catch(error => { + this.logger.error(`更新会话断开状态失败: ${session.id}`, error) + }) + this.logger.info(`会话 ${session.id} 已标记为断开状态`) } - - for (const sessionId of sessionsToMark) { - const session = this.sessions.get(sessionId) - if (session) { - // 标记会话为断开状态,但不关闭PTY进程 - session.disconnected = true - session.disconnectedAt = new Date() - session.lastActivity = new Date() - - // 更新持久化状态 - this.sessionManager.setSessionActive(sessionId, false).catch(error => { - this.logger.error(`更新会话断开状态失败: ${sessionId}`, error) - }) - - this.logger.info(`会话 ${sessionId} 已标记为断开状态`) + + for (const attempt of this.createAttempts.values()) { + if ( + attempt.socket.id !== socket.id || + (attempt.phase !== 'starting' && attempt.phase !== 'fallback') + ) { + continue } + + const termination = this.requestTargetClose(attempt, { + intentional: true, + emitEvents: false, + emitTimeoutError: false + }) + void termination.then( + result => { + if (result === 'still-running') { + this.logger.error( + `客户端断开后PTY创建尝试仍在运行: ${attempt.id}` + ) + } + }, + error => { + this.logger.error(`客户端断开后终止PTY创建尝试失败: ${attempt.id}`, error) + } + ) } - - if (sessionsToMark.length > 0) { - this.logger.info(`客户端断开连接,标记了 ${sessionsToMark.length} 个会话为断开状态`) + + if (markedSessionCount > 0) { + this.logger.info( + `客户端断开连接,标记了 ${markedSessionCount} 个会话为断开状态` + ) } - } catch (error) { this.logger.error(`处理客户端断开连接失败:`, error) } @@ -1319,7 +3027,9 @@ export class TerminalManager { const session = this.sessions.get(sessionId) if (session) { this.logger.info(`清理会话: ${sessionId} (${session.disconnected ? '断开连接' : '不活跃'})`) - this.closePty(session.socket, { sessionId }) + void this.closePty(session.socket, { sessionId }).catch(error => { + this.logger.error(`清理不活跃PTY会话失败: ${sessionId}`, error) + }) } } @@ -1331,45 +3041,122 @@ export class TerminalManager { /** * 重新连接现有会话 */ - public reconnectSession(socket: Socket, sessionId: string): boolean { + public async reconnectSession( + socket: Socket, + sessionId: string + ): Promise { try { - const session = this.sessions.get(sessionId) - - if (!session) { - this.logger.warn(`尝试重连不存在的会话: ${sessionId}`) - return false - } - - // 更新socket连接 - session.socket = socket - session.disconnected = false - session.disconnectedAt = undefined - session.lastActivity = new Date() - - // 更新持久化状态 - this.sessionManager.setSessionActive(sessionId, true).catch(error => { - this.logger.error(`更新会话重连状态失败: ${sessionId}`, error) - }) + while (true) { + const session = this.sessions.get(sessionId) + if (session) { + session.socket = socket + session.disconnected = false + session.disconnectedAt = undefined + session.lastActivity = new Date() + if (session.state === 'ready') { + void this.sessionManager.setSessionActive(sessionId, true).catch(error => { + this.logger.error(`更新会话重连状态失败: ${sessionId}`, error) + }) + } - this.logger.info(`会话 ${sessionId} 重新连接成功`) - - // 输出监听始终通过 session.socket 发送,重连只需替换socket并重放已脱敏缓存。 - if (session.outputBuffer.length > 0) { - const historicalOutput = session.outputBuffer.join('') - socket.emit('terminal-output', { - sessionId: session.id, - data: historicalOutput, - isHistorical: true - }) + this.logger.info(`会话 ${sessionId} 重新连接成功`) + if (session.outputBuffer.length > 0) { + socket.emit('terminal-output', { + sessionId: session.id, + data: session.outputBuffer.join(''), + isHistorical: true + }) + } + return session.state + } + + const attempt = this.createAttempts.get(sessionId) + if (!attempt) { + this.logger.warn(`尝试重连不存在的会话: ${sessionId}`) + return 'not-found' + } + + attempt.socket = socket + attempt.lastActivity = new Date() + if (attempt.phase === 'close-retained') { + // N2-I2:close-retained create attempt 纳入 reconnect/owner 可见性—— + // 新 socket 重连即重新驱动 bounded close,并注册为该次 close 的 public requester + // (I2 多 requester ACK):关闭确认时向新 socket 发 pty-closed;仍超时保留时 + // 向新 socket 发 terminal-error {retained:true}。不再让唯一 cleanup handle + // 只存在于已断开的客户端内存中。关闭本身有界(SIGTERM 3s + SIGKILL 1s)。 + this.logger.info(`保留的PTY创建尝试 ${sessionId} 重新连接,重新驱动有界关闭`) + const closePromise = this.requestTargetClose(attempt, { + intentional: true, + emitEvents: true, + emitTimeoutError: true, + publicRequester: socket, + notifyRetained: true + }) + void closePromise.then( + result => { + if (result === 'still-running') { + this.logger.error( + `重新连接后关闭保留的PTY创建尝试仍超时: ${sessionId},继续保留待重试` + ) + } + }, + error => { + this.logger.error(`重新连接后关闭保留的PTY创建尝试失败: ${sessionId}`, error) + } + ) + return 'closing' + } + if (attempt.phase === 'closing') { + const closePromise = attempt.closePromise ?? this.requestTargetClose(attempt, { + intentional: false, + emitEvents: false, + emitTimeoutError: false + }) + try { + await closePromise + } catch (error) { + this.logger.error(`等待PTY创建尝试关闭失败: ${sessionId}`, error) + if (this.createAttempts.get(sessionId) === attempt) { + return 'closing' + } + } + if ( + this.createAttempts.get(sessionId) === attempt && + attempt.phase === 'closing' + ) { + return 'closing' + } + continue + } + + this.logger.info(`创建中的PTY尝试 ${sessionId} 已绑定新连接`) + return 'pending' } - - return true } catch (error) { this.logger.error(`重连会话失败:`, error) - return false + const session = this.sessions.get(sessionId) + if (session) { + session.socket = socket + session.lastActivity = new Date() + return session.state + } + + const attempt = this.createAttempts.get(sessionId) + if (!attempt) { + return 'not-found' + } + attempt.socket = socket + attempt.lastActivity = new Date() + return attempt.phase === 'closing' || attempt.phase === 'close-retained' + ? 'closing' + : 'pending' } } + public hasTarget(sessionId: string): boolean { + return this.sessions.has(sessionId) || this.createAttempts.has(sessionId) + } + public hasSession(sessionId: string): boolean { return this.sessions.has(sessionId) } @@ -1685,153 +3472,7 @@ export class TerminalManager { } /** - * 使用当前用户创建PTY会话(回退方案) - */ - private async createPtyFallback( - sessionId: string, - sessionName: string, - workingDirectory: string, - socket: Socket, - enableStreamForward?: boolean, - programPath?: string, - autoCloseOnForwardExit?: boolean, - runtimeOptions: PtyRuntimeOptions = {} - ): Promise { - try { - const { - environmentOverrides = {}, - redactValues = [], - onOutput, - onExit - } = runtimeOptions - workingDirectory = path.resolve(workingDirectory) - this.logger.info(`使用当前用户创建PTY回退会话: ${sessionId}`) - - // 构建PTY命令参数,使用当前用户 - const args = [ - '-dir', workingDirectory, - '-size', '100,30', // 使用默认大小 - '-coder', 'UTF-8' - ] - - const terminalEnv = buildManagedChildEnvironment({ - ...environmentOverrides, - TERM: 'xterm-256color', - COLORTERM: 'truecolor' - }) - - // 使用默认bash,不切换用户 - args.push('-cmd', JSON.stringify(['/bin/bash', '--login'])) - this.logger.info(`使用当前用户启动终端,工作目录: ${workingDirectory}`) - - this.logger.info(`启动PTY回退进程: ${this.ptyPath} ${args.join(' ')}`) - - // 启动PTY进程 - const ptyProcess = spawn(this.ptyPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - cwd: workingDirectory, - env: terminalEnv, - detached: os.platform() !== 'win32' - }) - - this.logger.info(`PTY回退进程已启动,PID: ${ptyProcess.pid}`) - - // 创建会话对象 - const session: PtySession = { - id: sessionId, - name: sessionName, - process: ptyProcess, - socket, - workingDirectory, - createdAt: new Date(), - lastActivity: new Date(), - outputBuffer: [], - enableStreamForward, - programPath, - autoCloseOnForwardExit, - stdoutRedactor: new StreamingRedactor(redactValues), - stderrRedactor: new StreamingRedactor(redactValues), - onOutput, - onExit, - fallbackRetried: true // 标记为已重试 - } - - // 保存会话到内存 - this.sessions.set(sessionId, session) - - // 处理PTY输出 - ptyProcess.stdout?.on('data', (data: Buffer) => { - session.lastActivity = new Date() - const output = session.stdoutRedactor.write(data) - if (!output) return - session.outputBuffer.push(output) - if (session.outputBuffer.length > 1000) { - session.outputBuffer.shift() - } - session.onOutput?.(output) - session.socket.emit('terminal-output', { sessionId, data: output }) - }) - ptyProcess.stdout?.once('end', () => { - const output = session.stdoutRedactor.end() - if (!output) return - session.outputBuffer.push(output) - session.onOutput?.(output) - session.socket.emit('terminal-output', { sessionId, data: output }) - }) - - // 处理PTY错误输出 - ptyProcess.stderr?.on('data', (data: Buffer) => { - session.lastActivity = new Date() - const output = session.stderrRedactor.write(data) - if (!output) return - session.outputBuffer.push(output) - if (session.outputBuffer.length > 1000) { - session.outputBuffer.shift() - } - session.onOutput?.(output) - session.socket.emit('terminal-output', { sessionId, data: output }) - }) - ptyProcess.stderr?.once('end', () => { - const output = session.stderrRedactor.end() - if (!output) return - session.outputBuffer.push(output) - session.onOutput?.(output) - session.socket.emit('terminal-output', { sessionId, data: output }) - }) - - // 处理进程退出 - ptyProcess.once('close', (code, signal) => { - this.logger.info(`PTY回退进程退出: ${sessionId}, 退出码: ${code}, 信号: ${signal}`) - session.socket.emit('terminal-exit', { sessionId, code: code || 0, signal }) - if (!session.exitNotified) { - session.exitNotified = true - session.onExit?.(code, signal) - } - this.sessions.delete(sessionId) - }) - - // 处理进程错误 - ptyProcess.on('error', (error) => { - this.logger.error(`PTY回退进程错误 ${sessionId}:`, error) - session.socket.emit('terminal-error', { sessionId, error: error.message }) - this.sessions.delete(sessionId) - }) - - // 发送创建成功事件 - socket.emit('pty-created', { sessionId, workingDirectory }) - this.logger.info(`PTY回退会话创建成功: ${sessionId}`) - - } catch (error) { - this.logger.error(`创建PTY回退会话失败:`, error) - socket.emit('terminal-error', { - sessionId, - error: error instanceof Error ? error.message : '未知错误' - }) - } - } - - /** - * 清理所有会话 + * 清理所有托管进程。 */ private async cleanupManagedProcesses(): Promise { const processes = Array.from(this.managedProcesses) @@ -1888,56 +3529,51 @@ export class TerminalManager { this.logger.info('托管进程已清理完成') } + /** + * 在有界等待内清理所有PTY目标;未确认退出的目标继续保留引用。 + */ public async cleanup(): Promise { this.logger.info('开始清理所有终端会话...') + this.acceptingTerminalOperations = false this.stopActiveProcessesMonitoring() const managedProcessCleanup = this.cleanupManagedProcesses() - - for (const [sessionId, session] of this.sessions.entries()) { - try { - // 清理输出流转发进程 - if (session.streamForwardProcess && !session.streamForwardProcess.killed) { - this.logger.info(`清理输出流转发进程: ${sessionId}`) - - // 关闭输入流 - if (session.streamForwardProcess.stdin && !session.streamForwardProcess.stdin.destroyed) { - session.streamForwardProcess.stdin.end() - } - - session.streamForwardProcess.kill('SIGTERM') - - // 延迟强制杀死 - setTimeout(() => { - if (session.streamForwardProcess && !session.streamForwardProcess.killed) { - session.streamForwardProcess.kill('SIGKILL') - } - }, 1000) - } - - // 清理PTY进程 - if (!session.process.killed) { - // 关闭输入流 - if (session.process.stdin && !session.process.stdin.destroyed) { - session.process.stdin.end() - } - - session.process.kill('SIGTERM') - - // 延迟强制杀死 - setTimeout(() => { - if (!session.process.killed) { - session.process.kill('SIGKILL') - } - }, 1000) - } - } catch (error) { - this.logger.error(`清理会话 ${sessionId} 失败:`, error) - } + + const attempts = [...this.createAttempts.values()] + const sessions = [...this.sessions.values()] + for (const attempt of attempts) { + attempt.cancellation.cancelled = true } - - this.sessions.clear() + + const targets: Array = [] + const tasks: Array> = [] + const seenTargets = new Set() + const collect = (target: PtySession | CreateAttempt) => { + if (seenTargets.has(target)) return + seenTargets.add(target) + targets.push(target) + tasks.push(this.requestTargetClose(target, { + intentional: true, + emitEvents: false, + emitTimeoutError: false + })) + } + + attempts.forEach(collect) + sessions.forEach(collect) + const results = await Promise.allSettled(tasks) await managedProcessCleanup - this.logger.info('所有终端会话已清理完成') + + results.forEach((result, index) => { + const target = targets[index] + if (result.status === 'rejected') { + this.logger.error(`清理PTY目标失败: ${target.id}`, result.reason) + return + } + if (result.value === 'still-running') { + this.logger.error(`清理期限结束后PTY目标仍在运行: ${target.id}`) + } + }) + this.logger.info('终端会话有界清理流程已完成') } // WebSocket 相关方法 diff --git a/server/src/modules/terminal/TerminalSessionManager.ts b/server/src/modules/terminal/TerminalSessionManager.ts index 029847ca..d2a847b0 100644 --- a/server/src/modules/terminal/TerminalSessionManager.ts +++ b/server/src/modules/terminal/TerminalSessionManager.ts @@ -1,5 +1,6 @@ import fs from 'fs/promises' import path from 'path' +import { randomUUID } from 'crypto' import winston from 'winston' // 终端会话持久化数据接口 @@ -27,6 +28,7 @@ export class TerminalSessionManager { private configPath: string private logger: winston.Logger private config: TerminalSessionsConfig + private mutationQueue: Promise = Promise.resolve() constructor(logger: winston.Logger) { this.logger = logger @@ -93,15 +95,30 @@ export class TerminalSessionManager { } } + private enqueueMutation(mutation: () => Promise): Promise { + const operation = this.mutationQueue.then(mutation) + this.mutationQueue = operation.then( + () => undefined, + () => undefined + ) + return operation + } + /** - * 保存配置文件 + * 使用同目录临时文件和原子 rename 保存完整配置。 */ private async saveConfig(): Promise { + this.config.lastUpdated = new Date().toISOString() + const tempPath = path.join( + this.configDir, + `.${path.basename(this.configPath)}.${process.pid}.${randomUUID()}.tmp` + ) try { - this.config.lastUpdated = new Date().toISOString() - await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8') + await fs.writeFile(tempPath, JSON.stringify(this.config, null, 2), 'utf-8') + await fs.rename(tempPath, this.configPath) this.logger.debug('终端会话配置保存成功') } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => undefined) this.logger.error('保存终端会话配置失败:', error) throw error } @@ -119,29 +136,29 @@ export class TerminalSessionManager { isActive: boolean }): Promise { try { - const persistedSession: PersistedTerminalSession = { - id: sessionData.id, - name: sessionData.name, - workingDirectory: sessionData.workingDirectory, - createdAt: sessionData.createdAt.toISOString(), - lastActivity: sessionData.lastActivity.toISOString(), - isActive: sessionData.isActive - } + await this.enqueueMutation(async () => { + const persistedSession: PersistedTerminalSession = { + id: sessionData.id, + name: sessionData.name, + workingDirectory: sessionData.workingDirectory, + createdAt: sessionData.createdAt.toISOString(), + lastActivity: sessionData.lastActivity.toISOString(), + isActive: sessionData.isActive + } - // 查找是否已存在该会话 - const existingIndex = this.config.sessions.findIndex(s => s.id === sessionData.id) - - if (existingIndex >= 0) { - // 更新现有会话 - this.config.sessions[existingIndex] = persistedSession - this.logger.debug(`更新终端会话: ${sessionData.id} - ${sessionData.name}`) - } else { - // 添加新会话 - this.config.sessions.push(persistedSession) - this.logger.debug(`保存新终端会话: ${sessionData.id} - ${sessionData.name}`) - } + const existingIndex = this.config.sessions.findIndex( + session => session.id === sessionData.id + ) + if (existingIndex >= 0) { + this.config.sessions[existingIndex] = persistedSession + this.logger.debug(`更新终端会话: ${sessionData.id} - ${sessionData.name}`) + } else { + this.config.sessions.push(persistedSession) + this.logger.debug(`保存新终端会话: ${sessionData.id} - ${sessionData.name}`) + } - await this.saveConfig() + await this.saveConfig() + }) } catch (error) { this.logger.error('保存终端会话失败:', error) throw error @@ -153,16 +170,17 @@ export class TerminalSessionManager { */ async updateSessionName(sessionId: string, newName: string): Promise { try { - const session = this.config.sessions.find(s => s.id === sessionId) - - if (session) { - session.name = newName - session.lastActivity = new Date().toISOString() - await this.saveConfig() - this.logger.info(`更新终端会话名称: ${sessionId} -> ${newName}`) - } else { - this.logger.warn(`尝试更新不存在的会话名称: ${sessionId}`) - } + await this.enqueueMutation(async () => { + const session = this.config.sessions.find(s => s.id === sessionId) + if (session) { + session.name = newName + session.lastActivity = new Date().toISOString() + await this.saveConfig() + this.logger.info(`更新终端会话名称: ${sessionId} -> ${newName}`) + } else { + this.logger.warn(`尝试更新不存在的会话名称: ${sessionId}`) + } + }) } catch (error) { this.logger.error('更新会话名称失败:', error) throw error @@ -174,16 +192,16 @@ export class TerminalSessionManager { */ async removeSession(sessionId: string): Promise { try { - const initialLength = this.config.sessions.length - this.config.sessions = this.config.sessions.filter(s => s.id !== sessionId) - - if (this.config.sessions.length < initialLength) { - await this.saveConfig() - this.logger.info(`删除终端会话: ${sessionId}`) - } else { - // 改为debug级别,避免不必要的警告日志 - this.logger.debug(`尝试删除不存在的会话: ${sessionId}`) - } + await this.enqueueMutation(async () => { + const initialLength = this.config.sessions.length + this.config.sessions = this.config.sessions.filter(s => s.id !== sessionId) + if (this.config.sessions.length < initialLength) { + await this.saveConfig() + this.logger.info(`删除终端会话: ${sessionId}`) + } else { + this.logger.debug(`尝试删除不存在的会话: ${sessionId}`) + } + }) } catch (error) { this.logger.error('删除会话失败:', error) throw error @@ -209,21 +227,22 @@ export class TerminalSessionManager { */ async cleanupExpiredSessions(): Promise { try { - const now = new Date() - const expirationThreshold = 7 * 24 * 60 * 60 * 1000 // 7天 - - const initialLength = this.config.sessions.length - this.config.sessions = this.config.sessions.filter(session => { - const lastActivity = new Date(session.lastActivity) - const timeDiff = now.getTime() - lastActivity.getTime() - return timeDiff < expirationThreshold + await this.enqueueMutation(async () => { + const now = new Date() + const expirationThreshold = 7 * 24 * 60 * 60 * 1000 // 7天 + const initialLength = this.config.sessions.length + this.config.sessions = this.config.sessions.filter(session => { + const lastActivity = new Date(session.lastActivity) + const timeDiff = now.getTime() - lastActivity.getTime() + return timeDiff < expirationThreshold + }) + + const removedCount = initialLength - this.config.sessions.length + if (removedCount > 0) { + await this.saveConfig() + this.logger.info(`清理了 ${removedCount} 个过期的终端会话`) + } }) - - const removedCount = initialLength - this.config.sessions.length - if (removedCount > 0) { - await this.saveConfig() - this.logger.info(`清理了 ${removedCount} 个过期的终端会话`) - } } catch (error) { this.logger.error('清理过期会话失败:', error) } @@ -234,14 +253,15 @@ export class TerminalSessionManager { */ async setSessionActive(sessionId: string, isActive: boolean): Promise { try { - const session = this.config.sessions.find(s => s.id === sessionId) - - if (session) { - session.isActive = isActive - session.lastActivity = new Date().toISOString() - await this.saveConfig() - this.logger.debug(`设置会话活动状态: ${sessionId} -> ${isActive}`) - } + await this.enqueueMutation(async () => { + const session = this.config.sessions.find(s => s.id === sessionId) + if (session) { + session.isActive = isActive + session.lastActivity = new Date().toISOString() + await this.saveConfig() + this.logger.debug(`设置会话活动状态: ${sessionId} -> ${isActive}`) + } + }) } catch (error) { this.logger.error('设置会话活动状态失败:', error) throw error diff --git a/server/src/routes/gameDeployment.ts b/server/src/routes/gameDeployment.ts index bff10326..e392aaf0 100644 --- a/server/src/routes/gameDeployment.ts +++ b/server/src/routes/gameDeployment.ts @@ -1790,7 +1790,7 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) = // 安装与更新均使用面板服务进程用户,避免同一安装目录出现混合所有权。 const steamCommand = [steamcmdPath, ...steamArguments] - await terminalManager.createPty(virtualSocket, { + const createResult = await terminalManager.createPty(virtualSocket, { sessionId: terminalSessionId, cols: 80, rows: 24, @@ -1843,9 +1843,18 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) = })() } }) - await new Promise(resolve => setTimeout(resolve, 1000)) - if (!terminalManager.hasSession(terminalSessionId)) { - throw new Error('SteamCMD终端会话未能启动') + if (createResult.status !== 'ready') { + if (createResult.status === 'failed-retained') { + installProcessStarted = true + } + const createError = new Error(createResult.error) + // 可操作的 retained handle:failed-retained 时向调用方暴露 retained 终端 ID, + // 便于重试关闭该会话后再发起安装;不再只发无 handle 的 500。 + ;(createError as any).retainedTerminalSessionId = + createResult.status === 'failed-retained' + ? createResult.sessionId + : undefined + throw createError } installProcessStarted = true @@ -1871,7 +1880,9 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) = res.status(500).json({ success: false, error: '创建安装会话失败', - message: error.message + message: error.message, + // failed-retained 时携带 retained terminal ID,调用方可先关闭该会话再重试 + retainedTerminalSessionId: error.retainedTerminalSessionId }) } diff --git a/server/src/routes/instances.ts b/server/src/routes/instances.ts index 8842118b..6976ae4e 100644 --- a/server/src/routes/instances.ts +++ b/server/src/routes/instances.ts @@ -427,10 +427,13 @@ router.delete('/:id', authenticateToken, async (req: Request, res: Response) => }) } catch (error: any) { logger.error('删除实例失败:', error) - if (typeof error.message === 'string' && error.message.startsWith('实例正在')) { + if ( + typeof error.message === 'string' && + (error.message.startsWith('实例正在') || error.message.includes('终端仍在运行')) + ) { return res.status(409).json({ success: false, - error: '实例正在执行其他操作', + error: '实例暂时无法删除', message: error.message }) } @@ -491,13 +494,21 @@ router.post('/:id/stop', authenticateToken, async (req: Request, res: Response) } const { id } = req.params - await instanceManager.stopInstance(id) - + const result = await instanceManager.stopInstance(id) + if (result.status === 'still-running') { + return res.status(409).json({ + success: false, + error: '实例仍在停止中', + message: '终端进程未在关闭期限内退出,请稍后重试。', + data: result + }) + } + logger.info(`用户停止实例: ${id}`) - res.json({ success: true, - message: '实例停止成功' + message: '实例停止成功', + data: result }) } catch (error: any) { logger.error('停止实例失败:', error) @@ -526,13 +537,21 @@ router.post('/:id/close-terminal', authenticateToken, async (req: Request, res: } const { id } = req.params - await instanceManager.closeTerminal(id) - + const result = await instanceManager.closeTerminal(id) + if (result.status === 'still-running') { + return res.status(409).json({ + success: false, + error: '终端仍在关闭中', + message: '终端进程未在关闭期限内退出,请稍后重试。', + data: result + }) + } + logger.info(`用户关闭实例终端: ${id}`) - res.json({ success: true, - message: '终端关闭成功' + message: '终端关闭成功', + data: result }) } catch (error: any) { logger.error('关闭终端失败:', error) diff --git a/server/src/routes/pluginApi.ts b/server/src/routes/pluginApi.ts index 978e6315..99d39205 100644 --- a/server/src/routes/pluginApi.ts +++ b/server/src/routes/pluginApi.ts @@ -1557,6 +1557,13 @@ router.post('/instances/:id/stop', async (req: Request, res: Response) => { } const result = await instanceManager.stopInstance(id) + if (result.status === 'still-running') { + return res.status(409).json({ + success: false, + data: result, + message: '实例终端仍在运行,请稍后重试停止。' + }) + } res.json({ success: true, data: result, diff --git a/server/src/socket/terminalSocketHandlers.ts b/server/src/socket/terminalSocketHandlers.ts new file mode 100644 index 00000000..d9cd037a --- /dev/null +++ b/server/src/socket/terminalSocketHandlers.ts @@ -0,0 +1,148 @@ +import type { Socket } from 'socket.io' +import type winston from 'winston' +import type { TerminalManager } from '../modules/terminal/TerminalManager.js' + +type TerminalSocketManager = Pick< + TerminalManager, + | 'createPty' + | 'handleInput' + | 'resizeTerminal' + | 'closePty' + | 'reconnectSession' + | 'hasTarget' +> + +type TerminalLogger = Pick + +type PayloadRecord = Record + +function normalizeRecord(payload: unknown): PayloadRecord { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return {} + } + return payload as PayloadRecord +} + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function normalizeNumber(value: unknown): number { + return typeof value === 'number' ? value : Number.NaN +} + +function createSafeSocketHandler( + eventName: string, + logger: TerminalLogger, + normalize: (payload: unknown) => T, + handler: (payload: T) => void | Promise +): (payload: unknown) => void { + return (payload: unknown): void => { + void Promise.resolve() + .then(() => normalize(payload)) + .then(handler) + .catch(error => { + logger.error(`终端 Socket 事件处理失败(${eventName}):`, error) + }) + } +} + +export function registerTerminalSocketHandlers( + socket: Socket, + terminalManager: TerminalSocketManager, + logger: TerminalLogger +): void { + socket.on('create-pty', createSafeSocketHandler( + 'create-pty', + logger, + payload => { + const data = normalizeRecord(payload) + const cwd = normalizeOptionalString(data.cwd) + const workingDirectory = cwd ?? normalizeOptionalString(data.workingDirectory) + return { + sessionId: normalizeString(data.sessionId), + name: normalizeOptionalString(data.name), + cols: normalizeNumber(data.cols), + rows: normalizeNumber(data.rows), + workingDirectory, + enableStreamForward: data.enableStreamForward === true, + programPath: normalizeOptionalString(data.programPath), + autoCloseOnForwardExit: data.autoCloseOnForwardExit === true, + terminalUser: normalizeOptionalString(data.terminalUser) + } + }, + async data => { + await terminalManager.createPty(socket, data) + } + )) + + socket.on('terminal-input', createSafeSocketHandler( + 'terminal-input', + logger, + payload => { + const data = normalizeRecord(payload) + return { + sessionId: normalizeString(data.sessionId), + data: normalizeString(data.data) + } + }, + async data => { + await terminalManager.handleInput(socket, data) + } + )) + + socket.on('terminal-resize', createSafeSocketHandler( + 'terminal-resize', + logger, + payload => { + const data = normalizeRecord(payload) + return { + sessionId: normalizeString(data.sessionId), + cols: normalizeNumber(data.cols), + rows: normalizeNumber(data.rows) + } + }, + async data => { + await terminalManager.resizeTerminal(socket, data) + } + )) + + socket.on('close-pty', createSafeSocketHandler( + 'close-pty', + logger, + payload => { + const data = normalizeRecord(payload) + return { sessionId: normalizeString(data.sessionId) } + }, + async data => { + await terminalManager.closePty(socket, data) + } + )) + + socket.on('reconnect-session', createSafeSocketHandler( + 'reconnect-session', + logger, + payload => { + const data = normalizeRecord(payload) + return { sessionId: normalizeString(data.sessionId) } + }, + async data => { + const result = await terminalManager.reconnectSession(socket, data.sessionId) + if (result === 'pending') { + return + } + if (result === 'not-found') { + socket.emit('session-reconnect-failed', { sessionId: data.sessionId }) + return + } + socket.emit('session-reconnected', { + sessionId: data.sessionId, + state: result + }) + } + )) +} diff --git a/server/src/utils/ptyAssetCli.ts b/server/src/utils/ptyAssetCli.ts new file mode 100644 index 00000000..5fa6dbb2 --- /dev/null +++ b/server/src/utils/ptyAssetCli.ts @@ -0,0 +1,82 @@ +import fs from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' +import { + PTY_ASSETS, + ensurePtyAsset, + type PtyAssetKey +} from './ptyAssets.js' + +interface ParsedArguments { + assetKey: PtyAssetKey + targetDir: string +} + +function parseArguments(args: string[]): ParsedArguments { + if (args[0] !== 'ensure') { + throw new Error('仅支持 ensure 命令') + } + + let assetKey: string | undefined + let targetDir: string | undefined + const seen = new Set() + + for (let index = 1; index < args.length; index += 2) { + const flag = args[index] + const value = args[index + 1] + if (flag !== '--asset' && flag !== '--target-dir') { + throw new Error(`未知参数: ${flag || '(空)'}`) + } + if (seen.has(flag)) { + throw new Error(`参数重复: ${flag}`) + } + if (!value || value.startsWith('--')) { + throw new Error(`参数缺少值: ${flag}`) + } + seen.add(flag) + + if (flag === '--asset') { + assetKey = value + } else { + targetDir = value + } + } + + if (!assetKey || !targetDir) { + throw new Error('必须提供 --asset 和 --target-dir') + } + if (!Object.prototype.hasOwnProperty.call(PTY_ASSETS, assetKey)) { + throw new Error(`未知 PTY 资产: ${assetKey}`) + } + + return { + assetKey: assetKey as PtyAssetKey, + targetDir: path.resolve(targetDir) + } +} + +export async function runPtyAssetCli(args: string[] = process.argv.slice(2)): Promise { + const parsed = parseArguments(args) + const targetStat = await fs.stat(parsed.targetDir).catch(() => null) + if (!targetStat?.isDirectory()) { + throw new Error(`PTY 目标路径不是目录: ${parsed.targetDir}`) + } + + return ensurePtyAsset({ + asset: PTY_ASSETS[parsed.assetKey], + targetDir: parsed.targetDir, + token: process.env.GITHUB_TOKEN + }) +} + +const currentFile = fileURLToPath(import.meta.url) +if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) { + runPtyAssetCli() + .then(installedPath => { + console.log(installedPath) + }) + .catch(error => { + console.error(error instanceof Error ? error.message : 'PTY 资产安装失败') + process.exitCode = 1 + }) +} diff --git a/server/src/utils/ptyAssets.ts b/server/src/utils/ptyAssets.ts new file mode 100644 index 00000000..5f6a2a6c --- /dev/null +++ b/server/src/utils/ptyAssets.ts @@ -0,0 +1,898 @@ +import { spawn } from 'child_process' +import { createHash, randomBytes } from 'crypto' +import { createReadStream, createWriteStream } from 'fs' +import fs from 'fs/promises' +import path from 'path' +import { Transform } from 'stream' +import { pipeline } from 'stream/promises' +import axios from 'axios' + +export type PtyAssetKey = + | 'linux-x64' + | 'linux-arm64' + | 'win32-x64' + +export interface PtyAsset { + key: PtyAssetKey + platform: 'linux' | 'win32' + arch: 'x64' | 'arm64' + assetId: number + name: string + size: number + sha256: string +} + +export interface EnsurePtyAssetOptions { + asset: PtyAsset + targetDir: string + token?: string + logger?: { + info(message: string): void + warn(message: string): void + error(message: string): void + } +} + +export const PTY_RELEASE_ID = 297277624 +export const PTY_BUILD_COMMIT = + '09fc369dfa278504831260de2771d7cbd98d01c4' + +export const PTY_ASSETS: Record = { + 'linux-x64': { + key: 'linux-x64', + platform: 'linux', + arch: 'x64', + assetId: 374651721, + name: 'pty_linux_x64', + size: 2654360, + sha256: 'bbdfc8a5d0f57493e78c64bca56d370524c068c1d4d31cac653458a843d47f72' + }, + 'linux-arm64': { + key: 'linux-arm64', + platform: 'linux', + arch: 'arm64', + assetId: 374651727, + name: 'pty_linux_arm64', + size: 2752664, + sha256: '48d8496997053b60eb84d2b02f4ec751298c7f214c615b08aca43309739ebf83' + }, + 'win32-x64': { + key: 'win32-x64', + platform: 'win32', + arch: 'x64', + assetId: 374651714, + name: 'pty_win32_x64.exe', + size: 3627520, + sha256: 'fe35c154e623707d0dd2b728f41fd200bd3ead0a8cda8eb216b1e5e3e3ab2d40' + } +} + +const GITHUB_API_HOST = 'api.github.com' +const GITHUB_API_HEADERS = { + 'User-Agent': 'GameServerManager-PTY-Installer', + 'X-GitHub-Api-Version': '2022-11-28' +} +const PROBE_TIMEOUT_MS = 3000 +const PROBE_TERMINATION_GRACE_MS = 500 +const PROBE_OUTPUT_LIMIT = 64 * 1024 +const FILE_REMOVE_RETRY_DELAYS_MS = [25, 50, 100] as const +const probeCache = new Map>() +const ensureCache = new Map>() + +class PtyAssetRollbackError extends Error { + constructor(message: string, cause: unknown) { + super(message) + this.name = 'PtyAssetRollbackError' + ;(this as any).cause = cause + } +} + +interface GitHubReleaseAsset { + id: number + name: string + size: number +} + +interface GitHubRelease { + id: number + assets: GitHubReleaseAsset[] +} + +export function getPtyAsset( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): PtyAsset { + const key = `${platform}-${arch}` + if (key === 'linux-x64' || key === 'linux-arm64' || key === 'win32-x64') { + return PTY_ASSETS[key] + } + + throw new Error(`不支持的 PTY 平台或架构: ${platform}/${arch}`) +} + +export async function verifyPtyAsset( + filePath: string, + asset: PtyAsset +): Promise { + if (path.basename(filePath) !== asset.name) { + return false + } + + try { + const stat = await fs.stat(filePath) + if (!stat.isFile() || stat.size !== asset.size) { + return false + } + + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk as Buffer) + } + return hash.digest('hex') === asset.sha256 + } catch { + return false + } +} + +function getProbeCacheKey(filePath: string): string { + return `${process.pid}:${path.resolve(filePath)}` +} + +function clearProbeCache(filePath: string): void { + probeCache.delete(getProbeCacheKey(filePath)) +} + +function isNativeAsset(asset: PtyAsset): boolean { + return asset.platform === process.platform && asset.arch === process.arch +} + +async function runPtyProbe(filePath: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn(filePath, ['-h'], { + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + const output: Buffer[] = [] + const terminationDetails: string[] = [] + let outputBytes = 0 + let settled = false + let terminationError: Error | null = null + let timeout: NodeJS.Timeout | null = null + let terminationWatchdog: NodeJS.Timeout | null = null + + const stopCollecting = () => { + child.stdout?.off('data', collect) + child.stderr?.off('data', collect) + } + + const clearTimers = () => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (terminationWatchdog) { + clearTimeout(terminationWatchdog) + terminationWatchdog = null + } + } + + const removeProcessListeners = () => { + child.off('error', onError) + child.off('close', onClose) + } + + const finish = (error?: Error) => { + if (settled) return + settled = true + clearTimers() + stopCollecting() + removeProcessListeners() + if (error) { + reject(error) + } else { + resolve() + } + } + + const buildTerminationError = (cannotConfirmTermination = false): Error => { + const parts = [terminationError?.message || 'PTY 探测进程终止失败'] + parts.push(...terminationDetails) + if (cannotConfirmTermination) { + parts.push(`等待 ${PROBE_TERMINATION_GRACE_MS}ms 后无法确认探测进程已终止`) + } + return new Error(parts.join(';')) + } + + const tryKill = (stage: string) => { + try { + if (!child.kill('SIGKILL')) { + terminationDetails.push(`${stage} child.kill 返回 false`) + } + } catch (killError) { + terminationDetails.push(`${stage} child.kill 失败: ${killError instanceof Error ? killError.message : String(killError)}`) + } + } + + const onTerminationGraceExpired = () => { + if (settled) return + tryKill('termination grace 到期再次') + stopCollecting() + child.stdout?.destroy() + child.stderr?.destroy() + finish(buildTerminationError(true)) + } + + const requestTermination = (error: Error) => { + if (settled || terminationError) return + terminationError = error + if (timeout) { + clearTimeout(timeout) + timeout = null + } + stopCollecting() + terminationWatchdog = setTimeout( + onTerminationGraceExpired, + PROBE_TERMINATION_GRACE_MS + ) + terminationWatchdog.unref?.() + tryKill('首次') + } + + const collect = (chunk: Buffer) => { + if (settled || terminationError) return + outputBytes += chunk.length + if (outputBytes >= PROBE_OUTPUT_LIMIT) { + requestTermination(new Error(`PTY 探测输出超过 ${PROBE_OUTPUT_LIMIT} 字节限制`)) + return + } + output.push(Buffer.from(chunk)) + } + + function onError(error: Error): void { + if (terminationError) { + terminationDetails.push(`探测进程 error: ${error.message}`) + return + } + finish(error) + } + + function onClose(code: number | null): void { + if (settled) return + if (terminationError) { + finish(buildTerminationError()) + return + } + if (code !== 0) { + finish(new Error(`PTY 探测失败,退出码: ${code}`)) + return + } + + const helpText = Buffer.concat(output).toString('utf8') + if (!helpText.includes('-fifo')) { + finish(new Error('PTY 不支持必需的 -fifo 参数')) + return + } + finish() + } + + timeout = setTimeout(() => { + requestTermination(new Error(`PTY 探测超时(${PROBE_TIMEOUT_MS}ms)`)) + }, PROBE_TIMEOUT_MS) + timeout.unref?.() + + child.stdout?.on('data', collect) + child.stderr?.on('data', collect) + child.once('error', onError) + child.once('close', onClose) + }) +} + +export async function probePtyAsset( + filePath: string, + asset: PtyAsset +): Promise { + if (!isNativeAsset(asset)) { + throw new Error(`拒绝探测非本机 PTY 资产: ${asset.platform}/${asset.arch}`) + } + if (!await verifyPtyAsset(filePath, asset)) { + throw new Error(`PTY 资产校验失败,拒绝执行: ${filePath}`) + } + + const cacheKey = getProbeCacheKey(filePath) + const cached = probeCache.get(cacheKey) + if (cached) { + return cached + } + + let probePromise: Promise + probePromise = runPtyProbe(filePath).catch(error => { + if (probeCache.get(cacheKey) === probePromise) { + probeCache.delete(cacheKey) + } + throw error + }) + probeCache.set(cacheKey, probePromise) + return probePromise +} + +function assertCanonicalAsset(asset: PtyAsset): void { + const canonical = PTY_ASSETS[asset.key] + if ( + !canonical || + canonical.platform !== asset.platform || + canonical.arch !== asset.arch || + canonical.assetId !== asset.assetId || + canonical.name !== asset.name || + canonical.size !== asset.size || + canonical.sha256 !== asset.sha256 + ) { + throw new Error(`PTY 资产不在固定清单中: ${asset.key}`) + } +} + +function createGithubHeaders(accept: string, token?: string): Record { + const headers: Record = { + ...GITHUB_API_HEADERS, + Accept: accept + } + if (token) { + headers.Authorization = `Bearer ${token}` + } + return headers +} + +function removeAuthorizationOnUntrustedRedirect(options: Record): void { + if (String(options.hostname || '').toLowerCase() === GITHUB_API_HOST) { + return + } + + const headers = options.headers || {} + for (const headerName of Object.keys(headers)) { + if (headerName.toLowerCase() === 'authorization') { + delete headers[headerName] + } + } + delete options.auth +} + +async function validateReleaseAsset(asset: PtyAsset, token?: string): Promise { + const response = await axios.get( + `https://${GITHUB_API_HOST}/repos/MCSManager/PTY/releases/${PTY_RELEASE_ID}`, + { + headers: createGithubHeaders('application/vnd.github+json', token), + timeout: 60000, + maxRedirects: 5, + beforeRedirect: removeAuthorizationOnUntrustedRedirect + } + ) + const release = response.data + if (!release || release.id !== PTY_RELEASE_ID || !Array.isArray(release.assets)) { + throw new Error(`GitHub PTY release 元数据不匹配: ${PTY_RELEASE_ID}`) + } + + const matches = release.assets.filter(candidate => + candidate.id === asset.assetId && + candidate.name === asset.name && + candidate.size === asset.size + ) + if (matches.length !== 1) { + throw new Error(`GitHub PTY release 资产元数据不唯一或不匹配: ${asset.name}`) + } +} + +async function downloadAsset(asset: PtyAsset, tempPath: string, token?: string): Promise { + const response = await axios.get( + `https://${GITHUB_API_HOST}/repos/MCSManager/PTY/releases/assets/${asset.assetId}`, + { + headers: createGithubHeaders('application/octet-stream', token), + responseType: 'stream', + timeout: 60000, + maxRedirects: 5, + beforeRedirect: removeAuthorizationOnUntrustedRedirect + } + ) + + let downloadedBytes = 0 + const sizeLimiter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + downloadedBytes += chunk.length + if (downloadedBytes > asset.size) { + callback(new Error(`PTY 下载数据超过固定大小: ${asset.size}`)) + return + } + callback(null, chunk) + } + }) + + await pipeline( + response.data, + sizeLimiter, + createWriteStream(tempPath, { flags: 'wx' }) + ) +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function isBusyFileError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException)?.code + return code === 'EPERM' || code === 'EBUSY' +} + +function isPotentialRenameRace(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException)?.code + return code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM' || + code === 'EACCES' || code === 'ENOENT' +} + +async function wait(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +async function removeFileWithRetry(filePath: string, label: string): Promise { + let lastError: unknown + + for (let attempt = 0; attempt <= FILE_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) { + try { + await fs.rm(filePath, { force: true }) + return + } catch (error) { + lastError = error + if (!isBusyFileError(error) || attempt === FILE_REMOVE_RETRY_DELAYS_MS.length) { + break + } + await wait(FILE_REMOVE_RETRY_DELAYS_MS[attempt]) + } + } + + throw new Error(`清理${label}失败: ${filePath}: ${getErrorMessage(lastError)}`) +} + +function clearReplacementProbeCaches(...filePaths: string[]): void { + for (const filePath of filePaths) { + clearProbeCache(filePath) + } +} + +function isPtyBackupName(fileName: string, asset: PtyAsset): boolean { + const prefix = `.${asset.name}.` + const suffix = '.bak' + if (!fileName.startsWith(prefix) || !fileName.endsWith(suffix)) { + return false + } + + const identity = fileName.slice(prefix.length, -suffix.length) + // 兼容两种格式:`.`(旧格式)与 `..`(内嵌创建时间戳)。 + return /^\d+\.[0-9a-f]{24}(\.\d+)?$/.test(identity) +} + +/** + * 解析 backup 名中的 owner marker(PID + 随机 token), + * 与 replacePtyAssetTransaction 的命名 `...[.].bak` 一致。 + */ +function parsePtyBackupOwner( + fileName: string, + asset: PtyAsset +): number | null { + const prefix = `.${asset.name}.` + const suffix = '.bak' + if (!fileName.startsWith(prefix) || !fileName.endsWith(suffix)) { + return null + } + + const identity = fileName.slice(prefix.length, -suffix.length) + const match = /^(\d+)\.([0-9a-f]{24})(?:\.(\d+))?$/.exec(identity) + return match ? Number(match[1]) : null +} + +/** + * 解析 backup 名内嵌的创建时间戳(新格式 `.....bak`)。 + * 旧格式(无时间戳)返回 null。创建时间来自文件名本身而不是文件系统 mtime: + * backup 由 link/rename 创建,会继承旧 target 的 mtime,用它判年龄会把刚创建、 + * owner 仍存活的 in-flight backup 误判为过期。 + */ +function parsePtyBackupCreatedAt( + fileName: string, + asset: PtyAsset +): number | null { + const prefix = `.${asset.name}.` + const suffix = '.bak' + if (!fileName.startsWith(prefix) || !fileName.endsWith(suffix)) { + return null + } + + const identity = fileName.slice(prefix.length, -suffix.length) + const match = /^(\d+)\.([0-9a-f]{24})\.(\d+)$/.exec(identity) + return match ? Number(match[3]) : null +} + +function isPtyBackupOwnerAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +/** 备份存在超过该时长且 owner 仍存活时视为过期(PID 复用兜底)。 */ +const PTY_BACKUP_STALE_AGE_MS = 30 * 60 * 1000 + +/** + * 只清理/恢复过期 backup;owner 仍存活且新鲜的 backup 属于另一进程 + * in-flight 的替换事务,进程 B 不得删除,也不能把它当作遗留备份恢复。 + * stale 判定 = owner 已死(ESRCH)或内嵌创建时间戳超过 STALE_AGE; + * 不依赖 backup 文件的 mtime(link/rename 创建时继承旧 target 的 mtime)。 + * 旧格式(无内嵌时间戳)backup 且 owner 存活时无法安全判定年龄,保守视为 in-flight。 + */ +function isPtyBackupStale( + fileName: string, + asset: PtyAsset +): boolean { + const ownerPid = parsePtyBackupOwner(fileName, asset) + if (ownerPid === null || !isPtyBackupOwnerAlive(ownerPid)) { + return true + } + const createdAt = parsePtyBackupCreatedAt(fileName, asset) + if (createdAt !== null) { + return Date.now() - createdAt > PTY_BACKUP_STALE_AGE_MS + } + return false +} + +async function cleanupPtyAssetBackups( + backupPaths: string[], + logger?: EnsurePtyAssetOptions['logger'] +): Promise { + for (const backupPath of backupPaths) { + try { + await removeFileWithRetry(backupPath, '遗留 PTY 备份文件') + clearProbeCache(backupPath) + } catch (error) { + logger?.warn(`PTY 遗留备份清理失败: ${backupPath}: ${getErrorMessage(error)}`) + } + } +} + +async function recoverPtyAssetBackups( + targetPath: string, + asset: PtyAsset, + logger?: EnsurePtyAssetOptions['logger'] +): Promise { + const targetDir = path.dirname(targetPath) + const backupNames = (await fs.readdir(targetDir)) + .filter(fileName => isPtyBackupName(fileName, asset)) + .sort() + if (backupNames.length === 0) { + return + } + + // 跨进程 owner 分区:in-flight 事务(owner 存活且新鲜)的 backup 既不能删除 + // 也不能用于恢复;只有过期 backup(owner 已死或超龄)才参与遗留清理/恢复。 + const backupPathsByName = new Map() + for (const fileName of backupNames) { + backupPathsByName.set(fileName, path.join(targetDir, fileName)) + } + const staleBackupNames: string[] = [] + for (const fileName of backupNames) { + const backupPath = backupPathsByName.get(fileName)! + // fs.stat 错误只把 ENOENT(已被删除)视为可清理,其他错误保守跳过: + // 不删除、不恢复,避免在 stat 不可用时误破坏另一进程的备份。 + try { + const backupStat = await fs.stat(backupPath) + if (!backupStat.isFile()) { + continue + } + } catch (statError) { + if ((statError as NodeJS.ErrnoException)?.code === 'ENOENT') { + continue + } + logger?.warn(`PTY 备份文件状态检查失败,保守跳过: ${backupPath}: ${getErrorMessage(statError)}`) + continue + } + if (isPtyBackupStale(fileName, asset)) { + staleBackupNames.push(fileName) + } + } + if (backupNames.length > staleBackupNames.length) { + logger?.info( + `检测到 ${backupNames.length - staleBackupNames.length} 个进行中的 PTY 替换备份(保留),` + + `过期备份 ${staleBackupNames.length} 个` + ) + } + if (staleBackupNames.length === 0) { + return + } + + const backupNames2 = staleBackupNames + const backupPaths = backupNames2.map(fileName => backupPathsByName.get(fileName)!) + clearReplacementProbeCaches(targetPath, ...backupPaths) + + // 删除 stale backup 前,对当前 target 做与正常 ensure 一致的 probe 级校验 + // (不能只靠 size/SHA);target 不可 probe 时保留 backup,留作恢复路径。 + let targetUsable = false + if (await verifyPtyAsset(targetPath, asset)) { + if (!isNativeAsset(asset)) { + targetUsable = true + } else { + try { + await probePtyAsset(targetPath, asset) + targetUsable = true + } catch { + logger?.warn(`现有 PTY 目标不可执行,保留过期备份: ${targetPath}`) + } + } + } + + if (targetUsable) { + await cleanupPtyAssetBackups(backupPaths, logger) + return + } + + let trustedBackup: { path: string; asset: PtyAsset } | null = null + for (let index = 0; index < backupNames2.length; index += 1) { + const backupName = backupNames2[index] + const backupPath = backupPaths[index] + const backupAsset: PtyAsset = { ...asset, name: backupName } + if (!await verifyPtyAsset(backupPath, backupAsset)) { + continue + } + + try { + if (isNativeAsset(asset)) { + await probePtyAsset(backupPath, backupAsset) + } + trustedBackup = { path: backupPath, asset: backupAsset } + break + } catch { + clearProbeCache(backupPath) + } + } + + if (!trustedBackup) { + // target 不可用且无可信 backup:保留过期备份(不删除),等待下次 ensure 或离线下载; + // 不可 probe 的 target 下删除备份会失去唯一本地恢复路径。 + logger?.warn(`PTY 目标不可用且无可信备份,保留过期备份以待恢复: ${targetPath}`) + return + } + + if (process.platform === 'win32') { + await removeFileWithRetry(targetPath, '无效 PTY 目标文件') + } + await fs.rename(trustedBackup.path, targetPath) + clearReplacementProbeCaches(targetPath, trustedBackup.path) + + if (!await verifyPtyAsset(targetPath, asset)) { + throw new Error(`恢复后的 PTY 资产校验失败: ${targetPath}`) + } + if (isNativeAsset(asset)) { + await probePtyAsset(targetPath, asset) + } + + logger?.info(`已恢复可信 PTY 备份: ${targetPath}`) + await cleanupPtyAssetBackups( + backupPaths.filter(backupPath => backupPath !== trustedBackup.path), + logger + ) +} + +async function replacePtyAssetTransaction( + tempPath: string, + targetPath: string, + asset: PtyAsset +): Promise { + // 名称内嵌创建时间戳:backup 由 link/rename 创建会继承旧 target 的 mtime, + // 跨进程 stale 判定必须使用独立时间来源(文件名内嵌时间戳),不能依赖文件 mtime。 + const backupName = `.${asset.name}.${process.pid}.${randomBytes(12).toString('hex')}.${Date.now()}.bak` + const backupPath = path.join(path.dirname(targetPath), backupName) + let backupExists = false + let targetHasDownloadedAsset = false + + clearReplacementProbeCaches(tempPath, targetPath, backupPath) + + try { + try { + if (process.platform === 'win32') { + await fs.rename(targetPath, backupPath) + } else { + await fs.link(targetPath, backupPath) + } + backupExists = true + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw error + } + } + + clearReplacementProbeCaches(tempPath, targetPath, backupPath) + await fs.rename(tempPath, targetPath) + targetHasDownloadedAsset = true + clearReplacementProbeCaches(tempPath, targetPath, backupPath) + + if (!await verifyPtyAsset(targetPath, asset)) { + throw new Error(`安装后的 PTY 资产校验失败: ${targetPath}`) + } + if (isNativeAsset(asset)) { + await probePtyAsset(targetPath, asset) + } + + if (backupExists) { + await removeFileWithRetry(backupPath, 'PTY 备份文件') + backupExists = false + } + } catch (error) { + const rollbackErrors: string[] = [] + clearReplacementProbeCaches(tempPath, targetPath, backupPath) + + if (process.platform !== 'win32' && backupExists) { + try { + if (targetHasDownloadedAsset) { + await fs.rename(backupPath, targetPath) + targetHasDownloadedAsset = false + } else { + await removeFileWithRetry(backupPath, 'PTY 备份文件') + } + backupExists = false + } catch (rollbackError) { + rollbackErrors.push(`恢复原 PTY: ${getErrorMessage(rollbackError)}`) + } + } else { + if (targetHasDownloadedAsset) { + try { + await fs.rename(targetPath, tempPath) + targetHasDownloadedAsset = false + } catch (rollbackError) { + if ((rollbackError as NodeJS.ErrnoException)?.code !== 'ENOENT') { + rollbackErrors.push(`移走失败的新 PTY: ${getErrorMessage(rollbackError)}`) + } + } + } + + if (backupExists) { + try { + await fs.rename(backupPath, targetPath) + backupExists = false + } catch (rollbackError) { + rollbackErrors.push(`恢复原 PTY: ${getErrorMessage(rollbackError)}`) + } + } + } + + clearReplacementProbeCaches(tempPath, targetPath, backupPath) + if (rollbackErrors.length > 0) { + throw new PtyAssetRollbackError( + `PTY 替换失败且回滚未完成: ${rollbackErrors.join('; ')};原始错误: ${getErrorMessage(error)}`, + error + ) + } + throw error + } +} + +async function verifyConcurrentWinner(targetPath: string, asset: PtyAsset): Promise { + clearProbeCache(targetPath) + if (!await verifyPtyAsset(targetPath, asset)) { + return false + } + + try { + if (isNativeAsset(asset)) { + await probePtyAsset(targetPath, asset) + } + return true + } catch { + return false + } +} + +async function ensurePtyAssetInternal( + options: EnsurePtyAssetOptions, + targetPath: string +): Promise { + const { asset, token, logger } = options + const targetDir = path.dirname(targetPath) + const targetStat = await fs.stat(targetDir) + if (!targetStat.isDirectory()) { + throw new Error(`PTY 目标路径不是目录: ${targetDir}`) + } + + await recoverPtyAssetBackups(targetPath, asset, logger) + + if (await verifyPtyAsset(targetPath, asset)) { + try { + if (isNativeAsset(asset)) { + await probePtyAsset(targetPath, asset) + } + logger?.info(`PTY 资产已通过校验: ${targetPath}`) + return targetPath + } catch { + logger?.warn(`现有 PTY 资产探测失败,将下载固定版本: ${targetPath}`) + } + } else { + logger?.warn(`现有 PTY 资产缺失或校验失败,将下载固定版本: ${targetPath}`) + } + + await validateReleaseAsset(asset, token) + + const tempName = `.${asset.name}.${process.pid}.${randomBytes(12).toString('hex')}.tmp` + const tempPath = path.join(targetDir, tempName) + const tempAsset: PtyAsset = { ...asset, name: tempName } + let resultPath: string | null = null + let installError: unknown + + try { + logger?.info(`正在下载固定 PTY 资产: ${asset.name}`) + await downloadAsset(asset, tempPath, token) + + if (process.platform !== 'win32') { + await fs.chmod(tempPath, 0o755) + } + if (!await verifyPtyAsset(tempPath, tempAsset)) { + throw new Error(`下载的 PTY 资产校验失败: ${asset.name}`) + } + if (isNativeAsset(asset)) { + await probePtyAsset(tempPath, tempAsset) + } + + await replacePtyAssetTransaction(tempPath, targetPath, asset) + resultPath = targetPath + } catch (error) { + if ( + !(error instanceof PtyAssetRollbackError) && + isPotentialRenameRace(error) && + await verifyConcurrentWinner(targetPath, asset) + ) { + logger?.info(`检测到并发进程已安装可信 PTY 资产: ${targetPath}`) + resultPath = targetPath + } else { + installError = error + } + } + + try { + await removeFileWithRetry(tempPath, '临时 PTY 文件') + } catch (cleanupError) { + logger?.error(`PTY 临时文件清理失败: ${asset.name}`) + if (installError) { + const combinedError = new Error( + `${getErrorMessage(cleanupError)};原始安装错误: ${getErrorMessage(installError)}` + ) + ;(combinedError as any).cause = installError + throw combinedError + } + throw cleanupError + } + + if (installError) { + logger?.error(`PTY 资产安装失败: ${asset.name}`) + throw installError + } + if (!resultPath) { + throw new Error(`PTY 资产安装未返回有效路径: ${asset.name}`) + } + + logger?.info(`PTY 资产安装完成: ${resultPath}`) + return resultPath +} + +export async function ensurePtyAsset( + options: EnsurePtyAssetOptions +): Promise { + assertCanonicalAsset(options.asset) + const targetPath = path.resolve(options.targetDir, options.asset.name) + const cached = ensureCache.get(targetPath) + if (cached) { + return cached + } + + let ensurePromise: Promise + ensurePromise = ensurePtyAssetInternal(options, targetPath).finally(() => { + if (ensureCache.get(targetPath) === ensurePromise) { + ensureCache.delete(targetPath) + } + }) + ensureCache.set(targetPath, ensurePromise) + return ensurePromise +} diff --git a/server/src/utils/ptyControlChannel.ts b/server/src/utils/ptyControlChannel.ts new file mode 100644 index 00000000..7bb2ad56 --- /dev/null +++ b/server/src/utils/ptyControlChannel.ts @@ -0,0 +1,1610 @@ +import { randomBytes } from 'node:crypto' +import { + close as closeFileDescriptor, + constants as fsConstants, + createWriteStream, + fchmod as fchmodFileDescriptor, + fstat as fstatFileDescriptor, + open as openFileDescriptor +} from 'node:fs' +import { + link, + lstat, + mkdir, + realpath, + rename, + unlink +} from 'node:fs/promises' +import net from 'node:net' +import path from 'node:path' + +export interface CreatePtyControlChannelOptions { + sessionId: string + logger: { + debug(message: string): void + warn(message: string): void + error(message: string): void + } + platform?: NodeJS.Platform + directoryCandidates?: string[] +} + +export interface PtySize { + cols: number + rows: number +} + +export interface PtyControlChannel { + readonly endpoint: string + waitUntilReady(timeoutMs: number): Promise + enqueueResize(size: PtySize): Promise<'written' | 'skipped'> + close(): Promise +} + +interface PtyControlWriter { + write( + frame: Buffer, + callback: (error?: Error | null) => void + ): boolean + destroy(error?: Error): void +} + +interface PtyControlTransport { + waitUntilReady(timeoutMs: number): Promise + destroyWriter(): void + close(): Promise +} + +type ResizeResult = 'written' | 'skipped' + +interface ResizeRequest { + size: PtySize + promise: Promise + resolve(result: ResizeResult): void + reject(error: unknown): void +} + +interface ReadinessAttempt { + promise: Promise + cancelled: boolean + resolve(): void + reject(error: unknown): void +} + +function sizesEqual(left: PtySize | null, right: PtySize): boolean { + return left !== null && left.cols === right.cols && left.rows === right.rows +} + +export function validatePtySize(cols: unknown, rows: unknown): PtySize { + if (!Number.isSafeInteger(cols) || (cols as number) < 2 || (cols as number) > 1000) { + throw new Error('PTY cols 必须是 2 到 1000 之间的安全整数') + } + if (!Number.isSafeInteger(rows) || (rows as number) < 1 || (rows as number) > 1000) { + throw new Error('PTY rows 必须是 1 到 1000 之间的安全整数') + } + + return { cols: cols as number, rows: rows as number } +} + +export function encodePtyResizeFrame(size: PtySize): Buffer { + const payload = Buffer.from( + JSON.stringify({ width: size.cols, height: size.rows }), + 'utf8' + ) + if (payload.length > 0xffff) { + throw new Error('PTY RESIZE payload 超过 uint16 长度限制') + } + + const frame = Buffer.allocUnsafe(3 + payload.length) + frame.writeUInt8(4, 0) + frame.writeUInt16BE(payload.length, 1) + payload.copy(frame, 3) + return frame +} + +class PtyControlChannelQueue implements PtyControlChannel { + private writer: PtyControlWriter | null = null + private readyPromise: Promise | null = null + private readinessAttempt: ReadinessAttempt | null = null + private currentWrite: ResizeRequest | null = null + private pendingResize: ResizeRequest | null = null + private lastWrittenSize: PtySize | null = null + private closed = false + private closePromise: Promise | null = null + private readonly unsettledResizeOperations = new Set>() + + constructor( + readonly endpoint: string, + private readonly transport: PtyControlTransport + ) {} + + waitUntilReady(timeoutMs: number): Promise { + if (this.writer) { + return Promise.resolve() + } + if (this.closed) { + return Promise.reject(new Error('PTY control channel 已关闭')) + } + if (this.readyPromise) { + return this.readyPromise + } + + let resolveReadiness!: () => void + let rejectReadiness!: (error: unknown) => void + const readiness = new Promise((resolve, reject) => { + resolveReadiness = resolve + rejectReadiness = reject + }) + const attempt: ReadinessAttempt = { + promise: readiness, + cancelled: false, + resolve: resolveReadiness, + reject: rejectReadiness + } + + this.readyPromise = readiness + this.readinessAttempt = attempt + void readiness.catch(() => {}) + + const transportReadiness = Promise.resolve().then(() => { + if (attempt.cancelled) { + return null + } + return this.transport.waitUntilReady(timeoutMs) + }) + void transportReadiness.then( + writer => { + if (!writer) { + return + } + if ( + attempt.cancelled || + this.closed || + this.readinessAttempt !== attempt + ) { + try { + writer.destroy() + } catch { + // 关闭已由队列完成;迟到 writer 的销毁错误不能重新打开生命周期。 + } + return + } + + this.writer = writer + this.readinessAttempt = null + attempt.resolve() + }, + error => { + if (attempt.cancelled || this.readinessAttempt !== attempt) { + return + } + this.readinessAttempt = null + this.readyPromise = null + attempt.reject(error) + } + ).catch(() => {}) + + return readiness + } + + enqueueResize(size: PtySize): Promise { + if (this.closed) { + return Promise.resolve('skipped') + } + if (!this.writer) { + return Promise.reject(new Error('PTY control channel 尚未就绪')) + } + + if (this.currentWrite) { + if (sizesEqual(this.currentWrite.size, size)) { + if (this.pendingResize) { + this.pendingResize.resolve('skipped') + this.pendingResize = null + } + return Promise.resolve('skipped') + } + if (sizesEqual(this.pendingResize?.size ?? null, size)) { + return Promise.resolve('skipped') + } + + const request = this.createResizeRequest(size) + if (this.pendingResize) { + this.pendingResize.resolve('skipped') + } + this.pendingResize = request + return request.promise + } + + if (sizesEqual(this.lastWrittenSize, size)) { + return Promise.resolve('skipped') + } + + const request = this.createResizeRequest(size) + this.startWrite(request) + return request.promise + } + + close(): Promise { + if (this.closePromise) { + return this.closePromise + } + + let resolveClose!: () => void + let rejectClose!: (error: unknown) => void + const closePromise = new Promise((resolve, reject) => { + resolveClose = resolve + rejectClose = reject + }) + this.closePromise = closePromise + this.closed = true + + const readiness = this.readinessAttempt + if (readiness) { + readiness.cancelled = true + this.readinessAttempt = null + this.readyPromise = null + readiness.reject(new Error('PTY control channel 已关闭')) + } + + const current = this.currentWrite + this.currentWrite = null + if (current) { + current.resolve('skipped') + } + if (this.pendingResize) { + this.pendingResize.resolve('skipped') + this.pendingResize = null + } + + const resizeOperations = [...this.unsettledResizeOperations] + void this.finishClose(resizeOperations).then(resolveClose, rejectClose) + return closePromise + } + + private async finishClose( + resizeOperations: Promise[] + ): Promise { + let destroyError: unknown + try { + this.transport.destroyWriter() + } catch (error) { + destroyError = error + } + + const results = await Promise.allSettled([ + Promise.resolve().then(() => this.transport.close()), + ...resizeOperations + ]) + const transportResult = results[0] + if (destroyError && transportResult.status === 'rejected') { + throw new (globalThis as any).AggregateError( + [destroyError, transportResult.reason], + 'PTY control channel 关闭失败' + ) + } + if (destroyError) { + throw destroyError + } + if (transportResult.status === 'rejected') { + throw transportResult.reason + } + } + + private createResizeRequest(size: PtySize): ResizeRequest { + let resolve!: (result: ResizeResult) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + const request = { + size: { cols: size.cols, rows: size.rows }, + promise, + resolve, + reject + } + + this.unsettledResizeOperations.add(promise) + void promise.then( + () => this.unsettledResizeOperations.delete(promise), + () => this.unsettledResizeOperations.delete(promise) + ) + return request + } + + private startWrite(request: ResizeRequest): void { + this.currentWrite = request + + try { + const frame = encodePtyResizeFrame(request.size) + this.writer!.write(frame, error => { + this.finishWrite(request, error ?? null) + }) + } catch (error) { + this.finishWrite(request, error) + } + } + + private finishWrite(request: ResizeRequest, error: unknown): void { + if (this.currentWrite !== request) { + return + } + + this.currentWrite = null + if (error) { + if (this.closed) { + request.resolve('skipped') + } else { + request.reject(error) + } + } else { + this.lastWrittenSize = request.size + request.resolve('written') + } + + if (this.closed) { + return + } + + const next = this.pendingResize + this.pendingResize = null + if (next) { + this.startWrite(next) + } + } +} + +type PtyControlFailureStage = + | 'directory' + | 'path' + | 'lstat' + | 'chmod' + | 'open' + | 'connect' + +interface TransportReadinessAttempt { + stage: PtyControlFailureStage + cancelled: boolean + cancellation: Promise + cancel(error: Error): void +} + +class PtyControlStageError extends Error { + constructor( + readonly stage: PtyControlFailureStage, + message: string + ) { + super(message) + this.name = 'PtyControlStageError' + } +} + +const WINDOWS_PIPE_CONNECT_RETRY_DELAY_MS = 25 + +interface PosixSecurityFlags { + noFollow: number + directory: number +} + +interface PosixControlDirectory { + path: string + dev: number + ino: number + uid: number +} + +interface PosixControlTestHooks { + noFollowFlag?: number | null + beforeDirectoryFchmod?(directory: string): Promise | void + beforeFifoFchmod?(endpoint: string): Promise | void + beforeRemovalPathLstat?(endpoint: string): Promise | void + beforeEndpointQuarantineRename?(endpoint: string): Promise | void + renameEndpointForRemoval?(source: string, destination: string): Promise + restoreEndpointForRemoval?(source: string, destination: string): Promise + closeRemovalDescriptor?(descriptor: number): Promise +} + +const posixControlTestHooksSymbol = Symbol.for( + 'gsm3.ptyControlChannel.testHooks' +) + +function getPosixControlTestHooks(): PosixControlTestHooks { + if (process.env.NODE_ENV !== 'test') { + return {} + } + const testGlobal = globalThis as typeof globalThis & { + [key: symbol]: unknown + } + return (testGlobal[posixControlTestHooksSymbol] ?? {}) as PosixControlTestHooks +} + +function requirePosixSecurityFlags(): PosixSecurityFlags { + const hooks = getPosixControlTestHooks() + const noFollow = Object.prototype.hasOwnProperty.call(hooks, 'noFollowFlag') + ? hooks.noFollowFlag + : fsConstants.O_NOFOLLOW + if (typeof noFollow !== 'number' || noFollow <= 0) { + throw new PtyControlStageError( + 'directory', + 'PTY control POSIX security flag O_NOFOLLOW unavailable stage=directory' + ) + } + if (typeof fsConstants.O_DIRECTORY !== 'number' || fsConstants.O_DIRECTORY <= 0) { + throw new PtyControlStageError( + 'directory', + 'PTY control POSIX security flag O_DIRECTORY unavailable stage=directory' + ) + } + return { + noFollow, + directory: fsConstants.O_DIRECTORY + } +} + +function createTransportReadinessAttempt(): TransportReadinessAttempt { + let rejectCancellation!: (error: Error) => void + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject + }) + void cancellation.catch(() => {}) + + return { + stage: 'path', + cancelled: false, + cancellation, + cancel(error: Error) { + if (this.cancelled) { + return + } + this.cancelled = true + rejectCancellation(error) + } + } +} + +function assertReadinessActive( + attempt: TransportReadinessAttempt, + deadline: number, + closed: boolean +): void { + if (closed || attempt.cancelled) { + throw new PtyControlStageError( + attempt.stage, + `PTY control channel closed stage=${attempt.stage}` + ) + } + if (Date.now() >= deadline) { + throw new PtyControlStageError( + attempt.stage, + `PTY control readiness timed out stage=${attempt.stage}` + ) + } +} + +async function runReadinessStage( + attempt: TransportReadinessAttempt, + deadline: number, + stage: PtyControlFailureStage, + isClosed: () => boolean, + operation: () => Promise +): Promise { + attempt.stage = stage + assertReadinessActive(attempt, deadline, isClosed()) + + const remainingMs = deadline - Date.now() + let timeout: NodeJS.Timeout | null = null + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new PtyControlStageError( + stage, + `PTY control readiness timed out stage=${stage}` + )) + }, remainingMs) + }) + + try { + const result = await Promise.race([ + Promise.resolve().then(operation), + attempt.cancellation, + timeoutPromise + ]) + assertReadinessActive(attempt, deadline, isClosed()) + return result + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function waitForRetryDelay(): Promise { + return new Promise(resolve => setTimeout(resolve, 10)) +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === code +} + +function isRecoverableWindowsPipeConnectError(error: unknown): boolean { + if (error instanceof PtyControlStageError) { + return false + } + const code = typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined + return code === 'ENOENT' || + code === 'ECONNREFUSED' || + code === 'EBUSY' || + code === 'EAGAIN' +} + +function normalizeReadinessError( + error: unknown, + stage: PtyControlFailureStage +): Error { + if (error instanceof PtyControlStageError) { + return error + } + return new PtyControlStageError( + stage, + `PTY control readiness failed stage=${stage}` + ) +} + +function logPtyControlFailure( + options: CreatePtyControlChannelOptions, + platform: NodeJS.Platform, + stage: PtyControlFailureStage +): void { + try { + options.logger.warn( + `PTY control failure platform=${platform} sessionId=${options.sessionId} stage=${stage}` + ) + } catch { + // 日志失败不能改变控制通道的安全清理与错误语义。 + } +} + +function closeDescriptorQuietly(descriptor: number): void { + closeFileDescriptor(descriptor, () => {}) +} + +function closeDescriptor(descriptor: number): Promise { + return new Promise((resolve, reject) => { + closeFileDescriptor(descriptor, error => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +function fstatDescriptor(descriptor: number): Promise { + return new Promise((resolve, reject) => { + fstatFileDescriptor(descriptor, (error, stats) => { + if (error) { + reject(error) + return + } + resolve(stats) + }) + }) +} + +function fchmodDescriptor(descriptor: number, mode: number): Promise { + return new Promise((resolve, reject) => { + fchmodFileDescriptor(descriptor, mode, error => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +function getEffectiveUserId(stage: PtyControlFailureStage): number { + if (typeof process.geteuid !== 'function') { + throw new PtyControlStageError( + stage, + `PTY control POSIX owner verification unavailable stage=${stage}` + ) + } + return process.geteuid() +} + +function sameFileIdentity( + left: Pick, + right: Pick +): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +function assertControlledDirectoryStats( + stats: import('node:fs').Stats, + effectiveUserId: number, + stage: PtyControlFailureStage, + expected?: PosixControlDirectory +): void { + if (!stats.isDirectory() || stats.uid !== effectiveUserId) { + throw new PtyControlStageError( + stage, + `PTY control directory ownership verification failed stage=${stage}` + ) + } + if ((stats.mode & 0o777) !== 0o700) { + throw new PtyControlStageError( + stage, + `PTY control directory permissions verification failed stage=${stage}` + ) + } + if (expected && !sameFileIdentity(stats, expected)) { + throw new PtyControlStageError( + stage, + `PTY control directory identity changed stage=${stage}` + ) + } +} + +async function assertDirectoryPathIsControlled( + directory: string, + stats: import('node:fs').Stats, + effectiveUserId: number, + stage: PtyControlFailureStage +): Promise { + const controlledDirectory = await realpath(path.resolve(directory)) + const pathnameStats = await lstat(controlledDirectory) + if ( + pathnameStats.isSymbolicLink() || + !sameFileIdentity(pathnameStats, stats) + ) { + throw new PtyControlStageError( + stage, + `PTY control directory identity changed stage=${stage}` + ) + } + + let childPath = controlledDirectory + let childStats = pathnameStats + while (true) { + const parentPath = path.dirname(childPath) + if (parentPath === childPath) { + break + } + const parentStats = await lstat(parentPath) + if (parentStats.isSymbolicLink() || !parentStats.isDirectory()) { + throw new PtyControlStageError( + stage, + `PTY control directory ancestor verification failed stage=${stage}` + ) + } + if ((parentStats.mode & 0o022) !== 0) { + const sticky = (parentStats.mode & 0o1000) !== 0 + if (!sticky || childStats.uid !== effectiveUserId) { + throw new PtyControlStageError( + stage, + `PTY control directory ancestor permissions unsafe stage=${stage}` + ) + } + } + childPath = parentPath + childStats = parentStats + } + return controlledDirectory +} + +function openDescriptor( + endpoint: string, + flags: number, + canUseDescriptor: () => boolean +): Promise { + return new Promise((resolve, reject) => { + openFileDescriptor(endpoint, flags, (error, descriptor) => { + if (error) { + reject(error) + return + } + if (!canUseDescriptor()) { + closeDescriptorQuietly(descriptor) + reject(new PtyControlStageError( + 'open', + 'PTY control readiness timed out stage=open' + )) + return + } + resolve(descriptor) + }) + }) +} + +async function validateControlDirectory( + directory: PosixControlDirectory, + flags: PosixSecurityFlags, + stage: PtyControlFailureStage +): Promise { + const effectiveUserId = getEffectiveUserId(stage) + let descriptor: number | null = null + try { + descriptor = await openDescriptor( + directory.path, + fsConstants.O_RDONLY | flags.directory | flags.noFollow, + () => true + ) + const stats = await fstatDescriptor(descriptor) + assertControlledDirectoryStats(stats, effectiveUserId, stage, directory) + await assertDirectoryPathIsControlled( + directory.path, + stats, + effectiveUserId, + stage + ) + } finally { + if (descriptor !== null) { + closeDescriptorQuietly(descriptor) + } + } +} + +const ignoreWriterError = () => {} + +class PosixPtyControlTransport implements PtyControlTransport { + private writer: PtyControlWriter | null = null + private partialWriter: PtyControlWriter | null = null + private partialDescriptor: number | null = null + private activeAttempt: TransportReadinessAttempt | null = null + private closed = false + + constructor( + private readonly endpoint: string, + private readonly controlDirectory: PosixControlDirectory, + private readonly securityFlags: PosixSecurityFlags, + private readonly options: CreatePtyControlChannelOptions, + private readonly platform: NodeJS.Platform + ) {} + + async waitUntilReady(timeoutMs: number): Promise { + if (this.writer) { + return this.writer + } + if (this.closed) { + throw new PtyControlStageError('path', 'PTY control channel closed stage=path') + } + + const attempt = createTransportReadinessAttempt() + const deadline = Date.now() + Math.max(0, timeoutMs) + this.activeAttempt = attempt + let stage: PtyControlFailureStage = 'path' + + try { + await this.waitForPath(attempt, deadline) + + stage = 'lstat' + const stats = await runReadinessStage( + attempt, + deadline, + stage, + () => this.closed, + () => lstat(this.endpoint) + ) + if (stats.isSymbolicLink()) { + throw new PtyControlStageError( + stage, + 'PTY control endpoint is a symbolic link stage=lstat' + ) + } + if (!stats.isFIFO()) { + throw new PtyControlStageError( + stage, + 'PTY control endpoint is not a FIFO stage=lstat' + ) + } + + stage = 'chmod' + const fifoIdentity = await this.secureFifoPermissions(attempt, deadline) + + stage = 'open' + const writer = await this.openWriter(attempt, deadline, fifoIdentity) + assertReadinessActive(attempt, deadline, this.closed) + this.writer = writer + if (this.partialWriter === writer) { + this.partialWriter = null + } + return writer + } catch (error) { + this.destroyPartialWriter() + logPtyControlFailure(this.options, this.platform, stage) + throw normalizeReadinessError(error, stage) + } finally { + if (this.activeAttempt === attempt) { + this.activeAttempt = null + } + } + } + + destroyWriter(): void { + this.closed = true + const attempt = this.activeAttempt + if (attempt) { + attempt.cancel(new PtyControlStageError( + attempt.stage, + `PTY control channel closed stage=${attempt.stage}` + )) + } + this.destroyPartialWriter() + + const writer = this.writer + this.writer = null + if (writer) { + writer.destroy() + } + } + + async close(): Promise { + if (!this.closed) { + this.destroyWriter() + } + } + + private async validateDirectory( + attempt: TransportReadinessAttempt, + deadline: number, + stage: PtyControlFailureStage + ): Promise { + await runReadinessStage( + attempt, + deadline, + stage, + () => this.closed, + () => validateControlDirectory( + this.controlDirectory, + this.securityFlags, + stage + ) + ) + } + + private async waitForPath( + attempt: TransportReadinessAttempt, + deadline: number + ): Promise { + await this.validateDirectory(attempt, deadline, 'path') + while (true) { + try { + await runReadinessStage( + attempt, + deadline, + 'path', + () => this.closed, + () => lstat(this.endpoint).then(() => undefined) + ) + await this.validateDirectory(attempt, deadline, 'path') + return + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error + } + } + + await runReadinessStage( + attempt, + deadline, + 'path', + () => this.closed, + waitForRetryDelay + ) + } + } + + private async secureFifoPermissions( + attempt: TransportReadinessAttempt, + deadline: number + ): Promise { + await this.validateDirectory(attempt, deadline, 'chmod') + const flags = fsConstants.O_RDONLY | + fsConstants.O_NONBLOCK | + this.securityFlags.noFollow + const descriptor = await runReadinessStage( + attempt, + deadline, + 'chmod', + () => this.closed, + () => openDescriptor( + this.endpoint, + flags, + () => !this.closed && !attempt.cancelled && Date.now() < deadline + ) + ) + this.partialDescriptor = descriptor + + try { + const initialStats = await runReadinessStage( + attempt, + deadline, + 'chmod', + () => this.closed, + () => fstatDescriptor(descriptor) + ) + const effectiveUserId = getEffectiveUserId('chmod') + if (!initialStats.isFIFO() || initialStats.uid !== effectiveUserId) { + throw new PtyControlStageError( + 'chmod', + 'PTY control endpoint FIFO ownership verification failed stage=chmod' + ) + } + + await getPosixControlTestHooks().beforeFifoFchmod?.(this.endpoint) + await runReadinessStage( + attempt, + deadline, + 'chmod', + () => this.closed, + () => fchmodDescriptor(descriptor, 0o600) + ) + const securedStats = await runReadinessStage( + attempt, + deadline, + 'chmod', + () => this.closed, + () => fstatDescriptor(descriptor) + ) + if ( + !securedStats.isFIFO() || + securedStats.uid !== effectiveUserId || + (securedStats.mode & 0o777) !== 0o600 || + !sameFileIdentity(initialStats, securedStats) + ) { + throw new PtyControlStageError( + 'chmod', + 'PTY control endpoint FIFO verification failed stage=chmod' + ) + } + await runReadinessStage( + attempt, + deadline, + 'chmod', + () => this.closed, + () => { + if (this.partialDescriptor === descriptor) { + this.partialDescriptor = null + } + return closeDescriptor(descriptor) + } + ) + return securedStats + } finally { + if (this.partialDescriptor === descriptor) { + this.partialDescriptor = null + closeDescriptorQuietly(descriptor) + } + } + } + + private async openWriter( + attempt: TransportReadinessAttempt, + deadline: number, + fifoIdentity: import('node:fs').Stats + ): Promise { + const flags = fsConstants.O_WRONLY | + fsConstants.O_NONBLOCK | + this.securityFlags.noFollow + + while (true) { + await this.validateDirectory(attempt, deadline, 'open') + let descriptor: number + try { + descriptor = await runReadinessStage( + attempt, + deadline, + 'open', + () => this.closed, + () => openDescriptor( + this.endpoint, + flags, + () => !this.closed && !attempt.cancelled && Date.now() < deadline + ) + ) + } catch (error) { + if (!hasErrorCode(error, 'ENXIO')) { + throw error + } + await runReadinessStage( + attempt, + deadline, + 'open', + () => this.closed, + waitForRetryDelay + ) + continue + } + + this.partialDescriptor = descriptor + try { + const stats = await runReadinessStage( + attempt, + deadline, + 'open', + () => this.closed, + () => fstatDescriptor(descriptor) + ) + if ( + !stats.isFIFO() || + stats.uid !== fifoIdentity.uid || + (stats.mode & 0o777) !== 0o600 || + !sameFileIdentity(stats, fifoIdentity) + ) { + throw new PtyControlStageError( + 'open', + 'PTY control endpoint FIFO identity changed stage=open' + ) + } + + const stream = createWriteStream(this.endpoint, { + fd: descriptor, + autoClose: true + }) + const writer = stream as PtyControlWriter + this.partialDescriptor = null + this.partialWriter = writer + stream.on('error', ignoreWriterError) + assertReadinessActive(attempt, deadline, this.closed) + return writer + } catch (error) { + if (this.partialDescriptor === descriptor) { + this.partialDescriptor = null + closeDescriptorQuietly(descriptor) + } + throw error + } + } + } + + private destroyPartialWriter(): void { + const partialWriter = this.partialWriter + this.partialWriter = null + if (partialWriter) { + try { + partialWriter.destroy() + } catch { + // Readiness 失败只负责无参数销毁 partial writer。 + } + } + + const descriptor = this.partialDescriptor + this.partialDescriptor = null + if (descriptor !== null) { + closeDescriptorQuietly(descriptor) + } + } +} + +class WindowsPtyControlTransport implements PtyControlTransport { + private writer: PtyControlWriter | null = null + private partialSocket: net.Socket | null = null + private activeAttempt: TransportReadinessAttempt | null = null + private closed = false + + constructor( + private readonly endpoint: string, + private readonly options: CreatePtyControlChannelOptions, + private readonly platform: NodeJS.Platform + ) {} + + async waitUntilReady(timeoutMs: number): Promise { + if (this.writer) { + return this.writer + } + if (this.closed) { + throw new PtyControlStageError('connect', 'PTY control channel closed stage=connect') + } + + const attempt = createTransportReadinessAttempt() + attempt.stage = 'connect' + const deadline = Date.now() + Math.max(0, timeoutMs) + this.activeAttempt = attempt + let cleanupListeners = () => {} + + try { + let lastError: unknown + while (!this.closed && Date.now() <= deadline) { + assertReadinessActive(attempt, deadline, this.closed) + cleanupListeners = () => {} + const socket = net.createConnection(this.endpoint) + this.partialSocket = socket + socket.on('error', ignoreWriterError) + + const connected = new Promise((resolve, reject) => { + const onConnect = () => { + cleanupListeners() + resolve() + } + const onError = (error: Error) => { + cleanupListeners() + reject(error) + } + const onClose = () => { + cleanupListeners() + reject(new PtyControlStageError( + 'connect', + 'PTY control connection closed stage=connect' + )) + } + cleanupListeners = () => { + socket.off('connect', onConnect) + socket.off('error', onError) + socket.off('close', onClose) + } + socket.once('connect', onConnect) + socket.once('error', onError) + socket.once('close', onClose) + }) + + try { + await runReadinessStage( + attempt, + deadline, + 'connect', + () => this.closed, + () => connected + ) + assertReadinessActive(attempt, deadline, this.closed) + this.writer = socket + this.partialSocket = null + return socket + } catch (error) { + lastError = error + cleanupListeners() + if (this.partialSocket === socket) { + this.partialSocket = null + } + socket.destroy() + if (!isRecoverableWindowsPipeConnectError(error) || Date.now() >= deadline) { + throw error + } + await runReadinessStage( + attempt, + deadline, + 'connect', + () => this.closed, + async () => { + await new Promise(resolve => setTimeout(resolve, WINDOWS_PIPE_CONNECT_RETRY_DELAY_MS)) + } + ) + } + } + throw lastError ?? new PtyControlStageError( + 'connect', + 'PTY control readiness timed out stage=connect' + ) + } catch (error) { + cleanupListeners() + this.destroyPartialSocket() + logPtyControlFailure(this.options, this.platform, 'connect') + throw normalizeReadinessError(error, 'connect') + } finally { + if (this.activeAttempt === attempt) { + this.activeAttempt = null + } + } + } + + destroyWriter(): void { + this.closed = true + const attempt = this.activeAttempt + if (attempt) { + attempt.cancel(new PtyControlStageError( + 'connect', + 'PTY control channel closed stage=connect' + )) + } + this.destroyPartialSocket() + + const writer = this.writer + this.writer = null + if (writer) { + writer.destroy() + } + } + + async close(): Promise { + if (!this.closed) { + this.destroyWriter() + } + } + + private destroyPartialSocket(): void { + const socket = this.partialSocket + this.partialSocket = null + if (socket) { + try { + socket.destroy() + } catch { + // Readiness 失败只负责无参数销毁 partial writer。 + } + } + } +} + +async function selectPosixControlDirectory( + options: CreatePtyControlChannelOptions, + platform: NodeJS.Platform, + securityFlags: PosixSecurityFlags +): Promise { + const candidates = [ + path.join(process.cwd(), 'data', 'terminal-control'), + path.join(process.cwd(), 'server', 'data', 'terminal-control') + ] + const directoryCandidates = options.directoryCandidates ?? candidates + + for (const rawCandidate of directoryCandidates) { + let descriptor: number | null = null + try { + const candidate = path.resolve(rawCandidate) + await mkdir(candidate, { recursive: true }) + const effectiveUserId = getEffectiveUserId('directory') + descriptor = await openDescriptor( + candidate, + fsConstants.O_RDONLY | + securityFlags.directory | + securityFlags.noFollow, + () => true + ) + const initialStats = await fstatDescriptor(descriptor) + if (!initialStats.isDirectory() || initialStats.uid !== effectiveUserId) { + throw new PtyControlStageError( + 'directory', + 'PTY control directory ownership verification failed stage=directory' + ) + } + await assertDirectoryPathIsControlled( + candidate, + initialStats, + effectiveUserId, + 'directory' + ) + + await getPosixControlTestHooks().beforeDirectoryFchmod?.(candidate) + await fchmodDescriptor(descriptor, 0o700) + const securedStats = await fstatDescriptor(descriptor) + if (!sameFileIdentity(initialStats, securedStats)) { + throw new PtyControlStageError( + 'directory', + 'PTY control directory identity changed stage=directory' + ) + } + assertControlledDirectoryStats( + securedStats, + effectiveUserId, + 'directory' + ) + const controlledCandidate = await assertDirectoryPathIsControlled( + candidate, + securedStats, + effectiveUserId, + 'directory' + ) + return { + path: controlledCandidate, + dev: securedStats.dev, + ino: securedStats.ino, + uid: securedStats.uid + } + } catch { + logPtyControlFailure(options, platform, 'directory') + } finally { + if (descriptor !== null) { + closeDescriptorQuietly(descriptor) + } + } + } + + throw new PtyControlStageError( + 'directory', + `PTY control directory unavailable platform=${platform} sessionId=${options.sessionId} stage=directory` + ) +} + +export async function createPtyControlChannel( + options: CreatePtyControlChannelOptions +): Promise { + const platform = options.platform ?? process.platform + if (platform === 'win32') { + const endpoint = `\\\\.\\pipe\\gsm3-pty-${randomBytes(16).toString('hex')}` + return new PtyControlChannelQueue( + endpoint, + new WindowsPtyControlTransport(endpoint, options, platform) + ) + } + + let securityFlags: PosixSecurityFlags + try { + securityFlags = requirePosixSecurityFlags() + } catch (error) { + logPtyControlFailure(options, platform, 'directory') + throw error + } + const directory = await selectPosixControlDirectory( + options, + platform, + securityFlags + ) + const endpoint = path.join( + directory.path, + `gsm3-pty-${randomBytes(16).toString('hex')}` + ) + return new PtyControlChannelQueue( + endpoint, + new PosixPtyControlTransport( + endpoint, + directory, + securityFlags, + options, + platform + ) + ) +} + +async function inspectPrivateControlDirectory( + directoryPath: string, + securityFlags: PosixSecurityFlags +): Promise { + const resolvedDirectory = path.resolve(directoryPath) + const effectiveUserId = getEffectiveUserId('lstat') + let descriptor: number | null = null + try { + descriptor = await openDescriptor( + resolvedDirectory, + fsConstants.O_RDONLY | + securityFlags.directory | + securityFlags.noFollow, + () => true + ) + const stats = await fstatDescriptor(descriptor) + assertControlledDirectoryStats(stats, effectiveUserId, 'lstat') + const controlledDirectory = await assertDirectoryPathIsControlled( + resolvedDirectory, + stats, + effectiveUserId, + 'lstat' + ) + return { + path: controlledDirectory, + dev: stats.dev, + ino: stats.ino, + uid: stats.uid + } + } finally { + if (descriptor !== null) { + closeDescriptorQuietly(descriptor) + } + } +} + +async function renameEndpointForRemoval( + source: string, + destination: string +): Promise { + const hook = getPosixControlTestHooks().renameEndpointForRemoval + if (hook) { + await hook(source, destination) + return + } + await rename(source, destination) +} + +async function restoreQuarantinedEndpoint( + quarantineEndpoint: string, + endpoint: string +): Promise { + try { + const hook = getPosixControlTestHooks().restoreEndpointForRemoval + if (hook) { + await hook(quarantineEndpoint, endpoint) + return + } + await link(quarantineEndpoint, endpoint) + } catch { + // 私有目录中的隔离对象保留原状,避免覆盖或 unlink 未验证对象。 + } +} + +async function closeRemovalDescriptor(descriptor: number): Promise { + const hook = getPosixControlTestHooks().closeRemovalDescriptor + if (hook) { + await hook(descriptor) + return + } + await closeDescriptor(descriptor) +} + +function sanitizePtyControlRemovalError(error: unknown): PtyControlStageError { + if (error instanceof PtyControlStageError) { + return error + } + return new PtyControlStageError( + 'lstat', + 'PTY control endpoint removal failed stage=lstat' + ) +} + +async function removePosixPtyControlEndpoint(endpoint: string): Promise { + const securityFlags = requirePosixSecurityFlags() + let directory: PosixControlDirectory + try { + directory = await inspectPrivateControlDirectory( + path.dirname(endpoint), + securityFlags + ) + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return + } + throw error + } + const resolvedEndpoint = path.join(directory.path, path.basename(endpoint)) + + let descriptor: number | null = null + try { + try { + descriptor = await openDescriptor( + resolvedEndpoint, + fsConstants.O_RDONLY | + fsConstants.O_NONBLOCK | + securityFlags.noFollow, + () => true + ) + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return + } + if (hasErrorCode(error, 'ELOOP')) { + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint is a symbolic link stage=lstat' + ) + } + throw error + } + + const pinnedStats = await fstatDescriptor(descriptor) + if (!pinnedStats.isFIFO()) { + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint is not a FIFO stage=lstat' + ) + } + if (pinnedStats.uid !== directory.uid) { + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint ownership verification failed stage=lstat' + ) + } + + await getPosixControlTestHooks().beforeRemovalPathLstat?.(resolvedEndpoint) + + let pathnameStats: Awaited> + try { + pathnameStats = await lstat(resolvedEndpoint) + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return + } + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint cleanup failed stage=lstat' + ) + } + if (pathnameStats.isSymbolicLink()) { + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint is a symbolic link stage=lstat' + ) + } + if (!pathnameStats.isFIFO()) { + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint is not a FIFO stage=lstat' + ) + } + if (pathnameStats.uid !== pinnedStats.uid) { + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint ownership verification failed stage=lstat' + ) + } + if (!sameFileIdentity(pathnameStats, pinnedStats)) { + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint identity changed stage=lstat' + ) + } + + // 保持权威 FIFO fd 打开,阻止 unlink 后的 inode generation 被立即复用。 + await getPosixControlTestHooks().beforeEndpointQuarantineRename?.( + resolvedEndpoint + ) + await validateControlDirectory(directory, securityFlags, 'lstat') + const quarantineEndpoint = path.join( + directory.path, + `.gsm3-pty-remove-${randomBytes(16).toString('hex')}` + ) + try { + await renameEndpointForRemoval(resolvedEndpoint, quarantineEndpoint) + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return + } + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint quarantine failed stage=lstat' + ) + } + + let quarantinedStats: Awaited> + try { + quarantinedStats = await lstat(quarantineEndpoint) + } catch { + await restoreQuarantinedEndpoint(quarantineEndpoint, resolvedEndpoint) + throw new PtyControlStageError( + 'lstat', + 'PTY control quarantined endpoint verification failed stage=lstat' + ) + } + if (quarantinedStats.isSymbolicLink() || !quarantinedStats.isFIFO()) { + await restoreQuarantinedEndpoint(quarantineEndpoint, resolvedEndpoint) + throw new PtyControlStageError( + 'lstat', + quarantinedStats.isSymbolicLink() + ? 'PTY control endpoint is a symbolic link stage=lstat' + : 'PTY control endpoint is not a FIFO stage=lstat' + ) + } + if ( + quarantinedStats.uid !== pinnedStats.uid || + !sameFileIdentity(quarantinedStats, pinnedStats) + ) { + await restoreQuarantinedEndpoint(quarantineEndpoint, resolvedEndpoint) + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint identity changed stage=lstat' + ) + } + + try { + await unlink(quarantineEndpoint) + } catch { + await restoreQuarantinedEndpoint(quarantineEndpoint, resolvedEndpoint) + throw new PtyControlStageError( + 'lstat', + 'PTY control endpoint cleanup failed stage=lstat' + ) + } + } finally { + if (descriptor !== null) { + await closeRemovalDescriptor(descriptor) + } + } +} + +export async function removePtyControlEndpoint( + endpoint: string, + platform: NodeJS.Platform = process.platform +): Promise { + if (platform === 'win32') { + return + } + + // POSIX DAC boundary: effective UID is the OS security principal. Once the + // parent is eUID-owned, 0700, and ancestor-protected, other UIDs cannot race + // its entries. Malicious same-eUID processes are outside this boundary because + // they can already modify the same-UID service data and process. + try { + await removePosixPtyControlEndpoint(endpoint) + } catch (error) { + throw sanitizePtyControlRemovalError(error) + } +} diff --git a/server/src/utils/ptyManager.ts b/server/src/utils/ptyManager.ts index c6f30237..f078195d 100644 --- a/server/src/utils/ptyManager.ts +++ b/server/src/utils/ptyManager.ts @@ -1,178 +1,119 @@ -import path from 'path' +import { constants as fsConstants } from 'fs' import fs from 'fs/promises' -import { createWriteStream } from 'fs' -import { pipeline } from 'stream/promises' +import path from 'path' import logger from './logger.js' - -/** - * 支持的操作系统平台列表 - */ -const SUPPORTED_PLATFORMS = new Set(['win32', 'linux']) - -/** - * 支持的 CPU 架构列表 - */ -const SUPPORTED_ARCHS = new Set(['x64', 'arm64']) +import { + ensurePtyAsset, + getPtyAsset, + probePtyAsset, + verifyPtyAsset +} from './ptyAssets.js' /** * PTY 二进制文件管理器 - * 负责 PTY 二进制文件的路径解析、检测、下载 - * 参照 ZipToolsManager 的设计模式 + * 负责按固定清单解析、校验、探测和安装 PTY 二进制文件。 */ class PtyManager { - /** GitHub Releases 下载 URL(tag 名为 latest) */ - private readonly DOWNLOAD_URL = - 'https://github.com/MCSManager/PTY/releases/download/latest/' - - /** - * 获取当前平台对应的 PTY 二进制文件名 - * 命名规则:pty_{platform}_{arch},Windows 追加 .exe - * - * 实际文件命名: - * - win32/x64 → pty_win32_x64.exe - * - linux/x64 → pty_linux_x64 - * - linux/arm64 → pty_linux_arm64 - */ + /** 获取当前平台对应的固定 PTY 二进制文件名。 */ getBinaryName(): string { - const platform = process.platform - const arch = process.arch - - if (!SUPPORTED_PLATFORMS.has(platform)) { - throw new Error(`不支持的操作系统平台: ${platform}`) - } - if (!SUPPORTED_ARCHS.has(arch)) { - throw new Error(`不支持的 CPU 架构: ${arch}`) - } - - const name = `pty_${platform}_${arch}` - return platform === 'win32' ? `${name}.exe` : name + return getPtyAsset().name } /** - * 获取 lib 目录的候选路径列表 - * 使用多路径尝试策略,兼容打包后环境和开发环境 + * 获取 lib 目录的候选路径列表。 + * 顺序兼容打包后环境和开发环境,不得改变。 */ private getLibDirCandidates(): string[] { - const baseDir = process.cwd() - return [ - path.join(baseDir, 'data', 'lib'), // 打包后环境 - path.join(baseDir, 'server', 'data', 'lib'), // 开发环境 + const candidates = [ + path.join(process.cwd(), 'data', 'lib'), + path.join(process.cwd(), 'server', 'data', 'lib') ] + return candidates } - /** - * 使用多路径尝试策略获取 PTY 二进制文件绝对路径 - * 依次尝试 data/lib/ 和 server/data/lib/ 目录 - */ - async getPtyPath(): Promise { - const binaryName = this.getBinaryName() + /** 优先使用第一个已存在目录;均不存在时创建第一个可写目录。 */ + private async getTargetDir(): Promise { const candidates = this.getLibDirCandidates() - for (const libDir of candidates) { - const fullPath = path.join(libDir, binaryName) + for (const candidate of candidates) { try { - await fs.access(fullPath) - return fullPath - } catch { - // 该路径不存在,尝试下一个 + const stat = await fs.stat(candidate) + if (!stat.isDirectory()) { + throw new Error(`PTY 候选路径不是目录: ${candidate}`) + } + return candidate + } catch (error: any) { + if (error?.code === 'ENOENT') { + continue + } + throw error } } - throw new Error( - `未找到 PTY 二进制文件 (${binaryName}),已尝试路径: ${candidates.map(d => path.join(d, binaryName)).join(', ')}` - ) - } - - /** - * 检测 PTY 二进制文件是否存在 - */ - async isInstalled(): Promise { - try { - await this.getPtyPath() - return true - } catch { - return false + for (const candidate of candidates) { + try { + await fs.mkdir(candidate, { recursive: true }) + await fs.access(candidate, fsConstants.W_OK) + return candidate + } catch (error) { + logger.warn(`PTY 候选目录不可写: ${candidate}`) + } } + + throw new Error(`无法创建可写的 PTY lib 目录,已尝试: ${candidates.join(', ')}`) } /** - * 从指定 URL 下载二进制文件到目标路径 - * 非 Windows 平台设置 chmod 0o755 + * 返回经过固定清单校验和本机能力探测的 PTY 路径。 + * 缺失、损坏或不支持 -fifo 的资产会在选定候选目录中被固定版本替换。 */ - private async downloadFromUrl(url: string, targetPath: string): Promise { - const axios = (await import('axios')).default - const response = await axios.get(url, { - responseType: 'stream', - timeout: 60000, // 60 秒超时 - }) - - // 使用流式写入文件 - const writer = createWriteStream(targetPath) - await pipeline(response.data, writer) - - // 检查文件大小,防止下载空文件 - const stat = await fs.stat(targetPath) - if (stat.size === 0) { - await fs.unlink(targetPath) - throw new Error('下载的文件大小为 0,已删除') - } - - // 非 Windows 平台设置可执行权限 - if (process.platform !== 'win32') { - await fs.chmod(targetPath, 0o755) - } + async getPtyPath(): Promise { + const asset = getPtyAsset() + const targetDir = await this.getTargetDir() + return ensurePtyAsset({ asset, targetDir, logger }) } - /** - * 下载 PTY 二进制文件到第一个可写的 lib 目录 - * 从 GitHub Releases 下载 - */ - async download(): Promise { - const binaryName = this.getBinaryName() - const candidates = this.getLibDirCandidates() + /** 检查首个已存在候选目录中的 PTY 是否可信且可用,不触发下载。 */ + async isInstalled(): Promise { + const asset = getPtyAsset() - // 选择第一个可用的 lib 目录(优先打包后路径) - let targetDir: string | null = null - for (const dir of candidates) { + for (const candidate of this.getLibDirCandidates()) { try { - await fs.mkdir(dir, { recursive: true }) - targetDir = dir - break - } catch { - // 无法创建该目录,尝试下一个 + const stat = await fs.stat(candidate) + if (!stat.isDirectory()) { + return false + } + } catch (error: any) { + if (error?.code === 'ENOENT') { + continue + } + return false } - } - if (!targetDir) { - throw new Error(`无法创建 lib 目录,已尝试: ${candidates.join(', ')}`) + const targetPath = path.join(candidate, asset.name) + if (!await verifyPtyAsset(targetPath, asset)) { + return false + } + try { + await probePtyAsset(targetPath, asset) + return true + } catch { + return false + } } - const targetPath = path.join(targetDir, binaryName) - const downloadUrl = `${this.DOWNLOAD_URL}${binaryName}` + return false + } - logger.info(`正在从 GitHub 下载 PTY: ${downloadUrl}`) - try { - await this.downloadFromUrl(downloadUrl, targetPath) - logger.info(`PTY 下载完成: ${targetPath}`) - } catch (error: any) { - // 清理可能的残留文件 - try { await fs.unlink(targetPath) } catch { /* 忽略 */ } - const message = `PTY 下载失败(GitHub): ${error.message || error}` - logger.error(message) - throw new Error(message) - } + /** 安装或替换当前平台的固定 PTY 资产。 */ + async download(): Promise { + await this.getPtyPath() } - /** - * 确保 PTY 二进制文件可用(检测 + 自动下载) - * 服务端启动时调用 - */ + /** 服务启动时确保可信 PTY 资产可用。 */ async ensureInstalled(): Promise { - if (await this.isInstalled()) { - logger.info('PTY 已存在,跳过下载') - return - } - await this.download() + const ptyPath = await this.getPtyPath() + logger.info(`PTY 已就绪: ${ptyPath}`) } } diff --git a/start.sh b/start.sh index ab240033..24e9d388 100644 --- a/start.sh +++ b/start.sh @@ -34,13 +34,21 @@ if [ -f "server/index.js" ]; then done fi + # Docker 的持久卷会遮蔽镜像内的 server/data,补充卷中缺失的内置运行时资产。 + BUILTIN_LIB_DIR="server/builtin/data/lib" + RUNTIME_LIB_DIR="server/data/lib" + if [ -d "$BUILTIN_LIB_DIR" ]; then + mkdir -p "$RUNTIME_LIB_DIR" + cp -an "$BUILTIN_LIB_DIR"/. "$RUNTIME_LIB_DIR"/ 2>/dev/null || true + fi + # PTY 文件已迁移到 data/lib/ 目录,启动时由服务端自动检测和下载 # 如果 data/lib/ 中存在 PTY 文件,验证并设置可执行权限 ARCH=$(uname -m) if [ "$ARCH" = "x86_64" ]; then - PTY_FILE="data/lib/pty_linux_x64" + PTY_FILE="$RUNTIME_LIB_DIR/pty_linux_x64" elif [ "$ARCH" = "aarch64" ]; then - PTY_FILE="data/lib/pty_linux_arm64" + PTY_FILE="$RUNTIME_LIB_DIR/pty_linux_arm64" else PTY_FILE="" fi