From 547942cac845b1e7c4f91ec4b26a86f502fcc2f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 09:09:10 +0200 Subject: [PATCH] fix(terminal): handle completed scripts without websocket error --- AppImage/components/script-terminal-modal.tsx | 92 +++++++++++++++++-- AppImage/components/virtual-machines.tsx | 16 ++-- AppImage/messages/en/common.json | 8 ++ AppImage/messages/sk/common.json | 8 ++ AppImage/scripts/flask_terminal_routes.py | 43 +++++++-- 5 files changed, 147 insertions(+), 20 deletions(-) diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index 32448c18..96b9b990 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -50,12 +50,14 @@ interface ScriptTerminalModalProps { description: string scriptName?: string params?: Record + completedSuccessfullyMessage?: string + completedWithErrorMessage?: (exitCode: number) => string // Optional callback fired when the script's WebSocket closes // (script_runner sends an exit code and then closes). Lets the // parent auto-dismiss the modal — used by host-backup's Restore // flow so "Press Enter to close" in the bash script actually // closes the modal without an extra click. Other callers ignore. - onComplete?: () => void + onComplete?: (exitCode?: number) => void } export function ScriptTerminalModal({ @@ -65,6 +67,8 @@ export function ScriptTerminalModal({ title, description, params = { EXECUTION_MODE: "web" }, + completedSuccessfullyMessage, + completedWithErrorMessage, onComplete, }: ScriptTerminalModalProps) { const t = useT() @@ -85,6 +89,7 @@ export function ScriptTerminalModal({ const reconnectTimeoutRef = useRef(null) const reconnectAttemptsRef = useRef(0) const keepAliveIntervalRef = useRef(null) + const completionReceivedRef = useRef(false) const [isMobile, setIsMobile] = useState(false) const [isTablet, setIsTablet] = useState(false) @@ -96,6 +101,14 @@ export function ScriptTerminalModal({ const resizeBarRef = useRef(null) const modalHeightRef = useRef(600) + const getCompletionMessage = useCallback( + (exitCode: number) => + exitCode === 0 + ? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully")) + : (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })), + [completedSuccessfullyMessage, completedWithErrorMessage, t], + ) + const terminalContainerRef = useRef(null) const paramsRef = useRef(params) @@ -106,7 +119,7 @@ export function ScriptTerminalModal({ // Same trick for onComplete — we want the latest callback inside // the ws.onclose handler without re-running the connection effect. - const onCompleteRef = useRef<(() => void) | undefined>(undefined) + const onCompleteRef = useRef<((exitCode?: number) => void) | undefined>(undefined) useEffect(() => { onCompleteRef.current = onComplete }, [onComplete]) @@ -167,6 +180,23 @@ const initMessage = { if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') { return } + + // The PTY worker always emits this final line. Treat it as a + // completion fallback because some WebSocket servers tear down the + // connection before the following structured message is flushed. + const exitMatch = typeof event.data === "string" + ? event.data.match(/\[Script exited with code (-?\d+)\]/) + : null + if (exitMatch) { + const exitCode = Number(exitMatch[1]) + termRef.current?.write(event.data) + completionReceivedRef.current = true + setIsComplete(true) + termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) + onCompleteRef.current?.(exitCode) + if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") + return + } try { const msg = JSON.parse(event.data) @@ -189,6 +219,15 @@ const initMessage = { termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`) return } + if (msg.type === "script_complete") { + const exitCode = Number(msg.exit_code ?? 1) + completionReceivedRef.current = true + setIsComplete(true) + termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) + onCompleteRef.current?.(exitCode) + if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") + return + } } catch {} termRef.current?.write(event.data) setIsWaitingNextInteraction(false) @@ -199,6 +238,9 @@ const initMessage = { ws.onerror = () => { setConnectionStatus("offline") + if (!completionReceivedRef.current) { + termRef.current?.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`) + } } ws.onclose = (event) => { @@ -207,16 +249,19 @@ const initMessage = { clearInterval(keepAliveIntervalRef.current) keepAliveIntervalRef.current = null } + if (completionReceivedRef.current) { + return + } if (!isComplete && reconnectAttemptsRef.current < 3) { reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000) } else { setIsComplete(true) - onCompleteRef.current?.() + onCompleteRef.current?.(-1) } } } }, 1000) - }, [isOpen, isComplete, scriptPath]) + }, [isOpen, isComplete, scriptPath, getCompletionMessage, t]) const sendKey = useCallback((key: string) => { if (!termRef.current) return @@ -352,6 +397,23 @@ const initMessage = { if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') { return } + + // See the reconnect handler above. The exit line is guaranteed to be + // sent with the PTY output and is therefore a robust fallback when a + // final JSON frame is lost during server-side socket teardown. + const exitMatch = typeof event.data === "string" + ? event.data.match(/\[Script exited with code (-?\d+)\]/) + : null + if (exitMatch) { + const exitCode = Number(exitMatch[1]) + term.write(event.data) + completionReceivedRef.current = true + setIsComplete(true) + term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) + onCompleteRef.current?.(exitCode) + if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") + return + } try { const msg = JSON.parse(event.data) @@ -376,6 +438,15 @@ const initMessage = { term.writeln(`\x1b[31m${msg.message}\x1b[0m`) return } + if (msg.type === "script_complete") { + const exitCode = Number(msg.exit_code ?? 1) + completionReceivedRef.current = true + setIsComplete(true) + term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) + onCompleteRef.current?.(exitCode) + if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") + return + } } catch { // Not JSON, es output normal de terminal } @@ -390,21 +461,25 @@ const initMessage = { ws.onerror = (error) => { setConnectionStatus("offline") - term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`) + if (!completionReceivedRef.current) { + term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`) + } } ws.onclose = (event) => { setConnectionStatus("offline") - term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`) + if (!completionReceivedRef.current) { + term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`) + } if (keepAliveIntervalRef.current) { clearInterval(keepAliveIntervalRef.current) keepAliveIntervalRef.current = null } - if (!isComplete) { + if (!completionReceivedRef.current && !isComplete) { setIsComplete(true) - onCompleteRef.current?.() + onCompleteRef.current?.(-1) } } @@ -491,6 +566,7 @@ const initMessage = { sessionIdRef.current = Math.random().toString(36).substring(2, 8) reconnectAttemptsRef.current = 0 + completionReceivedRef.current = false setIsComplete(false) setInteractionInput("") setCurrentInteraction(null) diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 91b517f3..01d579b2 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -1464,7 +1464,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setApplyStartedAt(Date.now()) setApplyOpen(true) } - const handleApplyComplete = async () => { + const handleApplyComplete = async (exitCode = 0) => { if (applyVmid == null) return const duration = Math.max(0, Math.round((Date.now() - applyStartedAt) / 1000)) // The modal fires onComplete on any WS close (success or user cancel); @@ -1477,7 +1477,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - success: true, + success: exitCode === 0, target: applyTarget, duration_seconds: duration, ct_name: selectedVM?.name || `CT-${applyVmid}`, @@ -4967,13 +4967,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { onComplete={handleApplyComplete} scriptPath="/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh" scriptName="lxc_apply_updates" - title={`Apply updates — CT ${applyVmid}`} + title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })} + completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")} + completedWithErrorMessage={(exitCode) => + t("vmLxc.updates.terminalCompletedWithError", { code: exitCode }) + } description={ applyTarget === "os" - ? "Applying OS package updates inside the container..." + ? t("vmLxc.updates.terminalDescriptionOs") : applyTarget === "app" - ? "Running the application updater inside the container..." - : "Applying OS package + application updates inside the container..." + ? t("vmLxc.updates.terminalDescriptionApp") + : t("vmLxc.updates.terminalDescriptionBoth") } params={{ VMID: String(applyVmid), diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 64808876..4ff2e03a 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1201,6 +1201,12 @@ "applyUpdate": "Apply update", "upToDate": "Up to date", "runUpdater": "Run updater", + "terminalTitle": "Apply updates — CT {vmid}", + "terminalDescriptionOs": "Applying OS package updates inside the container...", + "terminalDescriptionApp": "Running the application updater inside the container...", + "terminalDescriptionBoth": "Applying OS package and application updates inside the container...", + "terminalCompletedSuccessfully": "Updates completed successfully", + "terminalCompletedWithError": "Updates finished with error code {code}", "applyOsPlusApp": "Apply OS + {appName} updates", "applyOsPlusApps": "Apply OS + Apps updates", "osPlusApp": "OS + {appName} updates", @@ -4649,6 +4655,8 @@ "offline": "Offline", "websocketError": "WebSocket error occurred", "connectionClosed": "Connection closed", + "completedSuccessfully": "Script completed successfully", + "completedWithError": "Script finished with error code {code}", "yes": "Yes", "yourInput": "Your input:", "submit": "Submit", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index f85a5ccf..de3c2cdb 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1201,6 +1201,12 @@ "applyUpdate": "Aktualizovať", "upToDate": "Aktuálne", "runUpdater": "Spustiť aktualizáciu", + "terminalTitle": "Aktualizácia — CT {vmid}", + "terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...", + "terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...", + "terminalDescriptionBoth": "Aktualizujem systém aj aplikácie v kontajneri...", + "terminalCompletedSuccessfully": "Aktualizácia bola úspešne dokončená", + "terminalCompletedWithError": "Aktualizácia skončila s chybovým kódom {code}", "applyOsPlusApp": "Aktualizovať systém + {appName}", "applyOsPlusApps": "Aktualizovať systém + aplikácie", "osPlusApp": "Aktualizácie systému + {appName}", @@ -4155,6 +4161,8 @@ "offline": "Offline", "websocketError": "Chyba WebSocket spojenia", "connectionClosed": "Spojenie bolo zatvorené", + "completedSuccessfully": "Skript sa úspešne dokončil", + "completedWithError": "Skript skončil s chybovým kódom {code}", "yes": "Áno", "yourInput": "Tvoj vstup:", "submit": "Odoslať", diff --git a/AppImage/scripts/flask_terminal_routes.py b/AppImage/scripts/flask_terminal_routes.py index 2ac08f47..72151873 100644 --- a/AppImage/scripts/flask_terminal_routes.py +++ b/AppImage/scripts/flask_terminal_routes.py @@ -478,6 +478,15 @@ def script_websocket(ws, session_id): preexec_fn=os.setsid, env=env ) + + # The child inherited the slave side of the PTY. Keeping the parent's + # duplicate open can prevent the reader from seeing EOF after the script + # exits, which in turn hides the final script_complete message. + try: + os.close(slave_fd) + slave_fd = None + except OSError: + pass # Set non-blocking mode for master_fd flags = fcntl.fcntl(master_fd, fcntl.F_GETFL) @@ -573,6 +582,13 @@ def script_websocket(ws, session_id): try: ws.send(f'\r\n[Script exited with code {exit_code}]\r\n') + # Send an explicit terminal result before the connection is + # closed. The browser previously saw the worker disappear as a + # generic WebSocket failure even when the script exited with 0. + ws.send(json.dumps({ + 'type': 'script_complete', + 'exit_code': exit_code, + })) except Exception as e: pass @@ -581,10 +597,16 @@ def script_websocket(ws, session_id): try: while True: - data = ws.receive(timeout=None) + data = ws.receive(timeout=0.25) if data is None: - break + if script_process.poll() is not None: + # The output worker owns the final PTY drain and emits + # both `[Script exited with code N]` and + # `script_complete`. Wait briefly for it before cleanup. + output_thread.join(timeout=2.0) + break + continue try: msg = json.loads(data) @@ -625,6 +647,14 @@ def script_websocket(ws, session_id): break if script_process.poll() is not None: + # The output worker owns the final PTY drain and emits both + # `[Script exited with code N]` and `script_complete`. A + # resize/ping arriving just after process exit used to make + # this receive loop enter cleanup immediately, closing the + # socket before those final frames were sent. Wait briefly + # for the worker so a normal script exit is delivered before + # teardown. + output_thread.join(timeout=2.0) break except Exception as e: @@ -644,10 +674,11 @@ def script_websocket(ws, session_id): except: pass - try: - os.close(slave_fd) - except: - pass + if slave_fd is not None: + try: + os.close(slave_fd) + except: + pass try: os.close(web_log_fd)