Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bf7c25fc |
No files matched your search
@@ -9,12 +9,6 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
|
||||
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
|
||||
|
||||
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="モニタリングビュー"></a>
|
||||
## モニタリングビュー
|
||||
|
||||
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
|
||||
---
|
||||
@@ -69,14 +69,14 @@
|
||||
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
|
||||
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
|
||||
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
|
||||
---
|
||||
@@ -159,10 +159,10 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
|
||||
|
||||
@@ -171,10 +171,10 @@ The main window is designed for long-running SSH workflows: quick access to sess
|
||||
|
||||
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
|
||||
|
||||
@@ -183,9 +183,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
|
||||
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ Inspect CPU, memory, disk, network, and latency readings, with GPU details where
|
||||
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
|
||||
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 多主机 —— Docker Swarm 集群搭建
|
||||
|
||||
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@
|
||||
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="监控视图"></a>
|
||||
## 监控视图
|
||||
|
||||
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
以 Electron、React 與 xterm.js 打造,介面精緻、功能完整的 SSH 工作區。<br/>
|
||||
🔥 內建 AI Agent · 分割終端 · Vault 檢視 · SFTP 工作流 · 自訂主題 —— 全部整合在一起。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
|
||||
|
||||
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@
|
||||
|
||||
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-檢視"></a>
|
||||
## Vault 檢視
|
||||
|
||||
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="監控檢視"></a>
|
||||
## 監控檢視
|
||||
|
||||
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分割終端"></a>
|
||||
## 分割終端
|
||||
|
||||
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
|
||||
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
|
||||
for (const accept of [false, true]) {
|
||||
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
|
||||
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
|
||||
if (!g.window) g.window = {};
|
||||
g.window.confirm = () => accept;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let releaseResume!: () => void;
|
||||
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
|
||||
t.after(async () => {
|
||||
releaseResume();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (hadWindow && g.window) g.window.confirm = previousConfirm; else delete g.window;
|
||||
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
const calls: string[] = [];
|
||||
const reports: Array<[boolean, string | undefined]> = [];
|
||||
let resumeSettled = false;
|
||||
let query!: () => Promise<void>;
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({
|
||||
tasks: [
|
||||
{ id: "running", status: "transferring" },
|
||||
{ id: "queued", status: "queued" },
|
||||
{ id: "already-paused", status: "paused" },
|
||||
{ id: "pausing", status: "pausing" },
|
||||
{ id: "interrupted", status: "interrupted" },
|
||||
{ id: "child", parentTaskId: "running", status: "transferring" },
|
||||
],
|
||||
}) as unknown as ReturnType<typeof sftpTransferCenterStore.getSnapshot>);
|
||||
t.mock.method(sftpTransferCenterStore, "pause", async (id: string) => { calls.push(`pause:${id}`); });
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async (id: string) => {
|
||||
calls.push(`resume:${id}`);
|
||||
await resumeGate;
|
||||
resumeSettled = true;
|
||||
});
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, reason?: string) => {
|
||||
calls.push("report");
|
||||
reports.push([dirty, reason]);
|
||||
},
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const paused = [
|
||||
"pause:running",
|
||||
"pause:queued",
|
||||
"pause:already-paused",
|
||||
"pause:pausing",
|
||||
"pause:interrupted",
|
||||
];
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
query(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("dirty check waited on resume")), 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
|
||||
if (accept) {
|
||||
assert.deepEqual(reports, [[false, undefined]]);
|
||||
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
|
||||
} else {
|
||||
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
|
||||
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
|
||||
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -356,51 +356,30 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
let reason: "editors" | "transfers" | undefined;
|
||||
// Captured before pause. Only transferring/queued rows were active
|
||||
// because of this prompt.
|
||||
let tasksToResume: { id: string }[] = [];
|
||||
try {
|
||||
// Pending handoffs retain source contents; accepted handoffs clear them.
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) {
|
||||
reason = "editors";
|
||||
toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
}
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (!hasDirty) {
|
||||
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
|
||||
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
|
||||
));
|
||||
if (unfinishedTasks.length > 0) {
|
||||
const resumable = unfinishedTasks.filter((task) => (
|
||||
task.status === "transferring" || task.status === "queued"
|
||||
));
|
||||
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
if (hasDirty) {
|
||||
// The user chose to stay. Main must hear that before any resume:
|
||||
// resume can outlive the 5s dirty-check timeout, and a late reply
|
||||
// lets the app quit anyway. The synchronous dialog is also skipped
|
||||
// because this prompt was already answered.
|
||||
reason = "transfers";
|
||||
tasksToResume = resumable;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
if (tasksToResume.length > 0) {
|
||||
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [enabled, t]);
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||
'terminal.toolbar.sessionLog.open': 'Open',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
// Multi-line paste confirmation dialog (#3398)
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||
'terminal.toolbar.sessionLog.open': 'Abrir',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
|
||||
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Guardar pantalla actual',
|
||||
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
|
||||
// Diálogo de confirmación de pegado multilínea (#3398)
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
||||
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
||||
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
||||
'terminal.toolbar.sessionLog.open': 'Открыть',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Показать в папке',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Открыть с помощью…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Открыть по умолчанию',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Не удалось открыть журнал.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
// Диалог подтверждения многострочной вставки (#3398)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en';
|
||||
import es from './es';
|
||||
import ru from './ru';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const keys = [
|
||||
'terminal.toolbar.openSessionLog',
|
||||
'terminal.toolbar.sessionLogActions',
|
||||
'terminal.toolbar.sessionLog.menu',
|
||||
'terminal.toolbar.sessionLog.open',
|
||||
'terminal.toolbar.sessionLog.reveal',
|
||||
'terminal.toolbar.sessionLog.chooseApplication',
|
||||
'terminal.toolbar.sessionLog.systemDefault',
|
||||
'terminal.toolbar.openSessionLogFailed',
|
||||
] as const;
|
||||
|
||||
test('saved session log actions are translated in every shipped locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
for (const key of keys) assert.ok(messages[key], `${locale} is missing ${key}`);
|
||||
assert.equal(messages['terminal.toolbar.sessionLog.rememberApplication'], undefined);
|
||||
}
|
||||
assert.equal(en['terminal.toolbar.sessionLog.menu'], 'Session log');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.open'], 'Open');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.reveal'], 'Show in folder');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.chooseApplication'], 'Open with…');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.systemDefault'], 'Open with system default');
|
||||
assert.equal(zhCN['terminal.toolbar.sessionLog.menu'], '会话日志');
|
||||
assert.equal(zhTW['terminal.toolbar.sessionLog.menu'], '工作階段日誌');
|
||||
});
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': '打开会话日志',
|
||||
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
||||
'terminal.toolbar.sessionLog.menu': '会话日志',
|
||||
'terminal.toolbar.sessionLog.open': '打开',
|
||||
'terminal.toolbar.sessionLog.reveal': '在文件夹中显示',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': '打开方式…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': '用系统默认应用打开',
|
||||
'terminal.toolbar.openSessionLogFailed': '无法打开会话日志。',
|
||||
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
// 多行粘贴确认对话框(#3398)
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
||||
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
||||
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
||||
'terminal.toolbar.sessionLog.open': '開啟',
|
||||
'terminal.toolbar.sessionLog.reveal': '在資料夾中顯示',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': '開啟方式…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': '用系統預設應用程式開啟',
|
||||
'terminal.toolbar.openSessionLogFailed': '無法開啟工作階段日誌。',
|
||||
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
// 多行貼上確認對話框(#3398)
|
||||
|
||||
@@ -717,103 +717,3 @@ test('writeSessionsForStorage makes a final attempt with only the newest session
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function freezeHistory<T>(value: T): T {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach(freezeHistory);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function makePersistenceHistory(count: number, activeMessages = 6): AISession[] {
|
||||
return Array.from({ length: count }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
count - index,
|
||||
Array.from({ length: index === 0 ? activeMessages : 6 }, (_, messageIndex) => ({
|
||||
id: `message-${index}-${messageIndex}`,
|
||||
role: messageIndex % 2 ? 'assistant' : 'user',
|
||||
content: 'ordinary text '.repeat(8),
|
||||
timestamp: messageIndex,
|
||||
...(messageIndex % 2 ? {
|
||||
toolResults: [{ toolCallId: `tool-${messageIndex}`, content: 'sample output '.repeat(700) }],
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: 'reasoning',
|
||||
providerOptions: { openai: { reasoningEncryptedContent: 'x'.repeat(256) } },
|
||||
}],
|
||||
},
|
||||
} : {}),
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
for (const [count, activeMessages] of [[20, 6], [200, 6], [20, 200]]) {
|
||||
test(`fitting history serializes only retained full sessions (${count} chats, ${activeMessages} active messages)`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(count, activeMessages));
|
||||
const expected = sessions.slice(0, 50);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
|
||||
const result = serializeSessionsForStorage(sessions);
|
||||
|
||||
assert.equal(stringify.mock.callCount(), expected.length);
|
||||
assert.equal(result.json, expectedJson);
|
||||
assert.deepEqual(result.sessions, expected);
|
||||
assert.equal(result.sessions[0], sessions[0]);
|
||||
});
|
||||
}
|
||||
|
||||
test('fit-first budget keeps string-length units and exact-fit replay data without mutating input', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = makePersistenceHistory(1);
|
||||
sessions[0].messages[0].content = '\u4e2d\u6587\ud83d\ude00'.repeat(100);
|
||||
freezeHistory(sessions);
|
||||
const fullJson = JSON.stringify(sessions);
|
||||
assert.ok(Buffer.byteLength(fullJson) > fullJson.length);
|
||||
for (const budget of [fullJson.length, fullJson.length + 1]) {
|
||||
assert.equal(serializeSessionsForStorage(sessions, budget).json, fullJson);
|
||||
}
|
||||
const overflow = serializeSessionsForStorage(sessions, fullJson.length - 1);
|
||||
assert.ok(overflow.json.length < fullJson.length);
|
||||
assert.equal(JSON.stringify(sessions), fullJson);
|
||||
});
|
||||
|
||||
test('fit-first empty history preserves output even below the minimum array budget', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
for (const budget of [0, 1, 2, 3]) {
|
||||
assert.deepEqual(serializeSessionsForStorage([], budget), { json: '[]', sessions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test('synced storage bypasses fit-first pruning and non-quota failures still propagate', async (t) => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(51, 201));
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
|
||||
const write = t.mock.method(localStorageAdapter, 'write', () => false);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
assert.equal(write.mock.callCount(), 1);
|
||||
assert.equal(write.mock.calls[0].arguments[1], sessions);
|
||||
assert.equal(writeString.mock.callCount(), 0);
|
||||
|
||||
read.mock.mockImplementation(() => ({ enabled: false }));
|
||||
const error = new Error('non-quota storage failure');
|
||||
writeString.mock.mockImplementation(() => { throw error; });
|
||||
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
|
||||
assert.equal(writeString.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
test(`overflow reuses prepared full JSON at budget ${budget}`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(20));
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
const result = serializeSessionsForStorage(sessions, budget);
|
||||
// Same work as the original ladder: no second full-session serialization
|
||||
// after the fit check, even when a last-resort degraded chat is needed.
|
||||
assert.equal(stringify.mock.callCount(), expectedCalls);
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
@@ -363,30 +363,12 @@ export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
return { session, json: JSON.stringify(session) };
|
||||
});
|
||||
|
||||
// Check whether retained history fits before preparing degradation. Use
|
||||
// the existing JSON string-length budget including array delimiters, and
|
||||
// avoid preparing unused stripped messages and serializing payloads again.
|
||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, prepared.length - 1);
|
||||
if (fullLength <= budgetBytes) {
|
||||
return {
|
||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
||||
sessions: prepared.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
// Reuse the full representation when the unchanged degradation ladder is
|
||||
// needed, including on quota retries with a smaller budget.
|
||||
const serialized = prepared.map(({ session, json }) => {
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
@@ -397,6 +379,7 @@ export function serializeSessionsForStorage(
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { performSessionLogAction } from './sessionLogActions';
|
||||
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
test('session log actions resolve and flush the selected session, remember apps and reset to default', async () => {
|
||||
const dom = new JSDOM('', { url: 'http://localhost' });
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
const calls: unknown[][] = [];
|
||||
let picked: { path: string; name: string } | null = { path: '/Applications/My Editor.app', name: 'My Editor' };
|
||||
let available = true;
|
||||
let failOpen = false;
|
||||
const bridge = {
|
||||
prepareSessionLog: async (payload: { sessionId: string }) => {
|
||||
calls.push(['prepare', payload]);
|
||||
return available ? { success: true, filePath: `/logs/${payload.sessionId}.html` }
|
||||
: { success: false, error: 'No saved log for this session' };
|
||||
},
|
||||
revealSessionLog: async (payload: { sessionId: string }) => {
|
||||
calls.push(['reveal', payload]);
|
||||
return { success: true };
|
||||
},
|
||||
selectApplication: async () => { calls.push(['choose']); return picked; },
|
||||
openWithApplication: async (file: string, app: string) => {
|
||||
calls.push(['application', file, app]);
|
||||
if (failOpen) throw new Error('Launch failed');
|
||||
return true;
|
||||
},
|
||||
openWithSystemDefault: async (file: string) => { calls.push(['default', file]); return { success: true }; },
|
||||
};
|
||||
Object.assign(dom.window, { netcatty: bridge });
|
||||
try {
|
||||
await performSessionLogAction('first', 'open');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['default', '/logs/first.html']]);
|
||||
await performSessionLogAction('second', 'chooseApplication');
|
||||
assert.deepEqual(calls.splice(0), [
|
||||
['prepare', { sessionId: 'second' }], ['choose'], ['prepare', { sessionId: 'second' }],
|
||||
['application', '/logs/second.html', picked!.path],
|
||||
]);
|
||||
assert.equal(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION)!).name, 'My Editor');
|
||||
await performSessionLogAction('first', 'open');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['application', '/logs/first.html', picked!.path]]);
|
||||
await performSessionLogAction('first', 'reveal');
|
||||
assert.deepEqual(calls.splice(0), [['reveal', { sessionId: 'first' }]]);
|
||||
await performSessionLogAction('first', 'systemDefault');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
|
||||
calls.length = 0;
|
||||
picked = null;
|
||||
await performSessionLogAction('first', 'chooseApplication');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['choose']]);
|
||||
available = false;
|
||||
await assert.rejects(performSessionLogAction('missing', 'chooseApplication'), /No saved log/);
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'missing' }]]);
|
||||
available = true;
|
||||
picked = { path: '/missing-editor', name: 'Missing' };
|
||||
failOpen = true;
|
||||
await assert.rejects(performSessionLogAction('first', 'chooseApplication'), /Launch failed/);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The integration boundary must translate backend errors, including ordinary
|
||||
// Error instances. Raw filesystem/app-launch details belong only in diagnostics.
|
||||
test('terminal log action errors use the translated toast instead of raw backend text', () => {
|
||||
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
|
||||
const handler = source.slice(source.indexOf(' const handleSessionLogAction ='),
|
||||
source.indexOf(' const handleToggleSessionLog ='));
|
||||
assert.match(handler, /logger\.error\("\[Terminal\] Failed to open or reveal session log:", err\)/);
|
||||
assert.match(handler, /toast\.error\(t\('terminal\.toolbar\.openSessionLogFailed'\)\)/);
|
||||
assert.doesNotMatch(handler, /err\.message/);
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export type SessionLogAction = 'open' | 'reveal' | 'chooseApplication' | 'systemDefault';
|
||||
type LogApplication = { path: string; name: string };
|
||||
|
||||
/** Resolve the session's actual log in its owning process before opening it. */
|
||||
export async function performSessionLogAction(sessionId: string, action: SessionLogAction): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.prepareSessionLog) throw new Error('Session log bridge unavailable');
|
||||
if (action === 'reveal') {
|
||||
const result = await bridge.revealSessionLog?.({ sessionId });
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to reveal session log');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability before presenting an application picker. Resolve again
|
||||
// afterwards so any output received while the picker was open is flushed.
|
||||
let result = await bridge.prepareSessionLog({ sessionId });
|
||||
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
|
||||
let application = localStorageAdapter.read<LogApplication>(STORAGE_KEY_SESSION_LOG_APPLICATION);
|
||||
if (action === 'chooseApplication') {
|
||||
if (!bridge.selectApplication) throw new Error('Application picker unavailable');
|
||||
application = await bridge.selectApplication();
|
||||
if (!application) return;
|
||||
result = await bridge.prepareSessionLog({ sessionId });
|
||||
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
|
||||
} else if (action === 'systemDefault') {
|
||||
application = null;
|
||||
}
|
||||
|
||||
if (application?.path) {
|
||||
if (!bridge.openWithApplication) throw new Error('Application opening unavailable');
|
||||
const opened = await bridge.openWithApplication(result.filePath, application.path);
|
||||
if (!opened) throw new Error('Failed to open session log');
|
||||
} else {
|
||||
const opened = await bridge.openWithSystemDefault?.(result.filePath);
|
||||
if (!opened?.success) throw new Error(opened?.error || 'Failed to open session log');
|
||||
}
|
||||
// Only remember a choice after a successful launch; failed launches leave
|
||||
// the previous preference intact and can be retried or reset from the menu.
|
||||
if (action === 'chooseApplication') {
|
||||
localStorageAdapter.write(STORAGE_KEY_SESSION_LOG_APPLICATION, application);
|
||||
} else if (action === 'systemDefault') {
|
||||
localStorageAdapter.remove(STORAGE_KEY_SESSION_LOG_APPLICATION);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { canRetryFromCheckpoint } from "./retryFromCheckpoint";
|
||||
|
||||
const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
|
||||
id: "t", fileName: "big.bin", sourcePath: "/l/big.bin", targetPath: "/r/big.bin",
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
|
||||
status: "failed", totalBytes: 100, transferredBytes: 40, checkpointBytes: 40,
|
||||
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
|
||||
});
|
||||
|
||||
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
|
||||
assert.equal(canRetryFromCheckpoint(failed()), true);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ checkpointBytes: 0 })), false, "nothing to resume");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error: "Permission denied" })), false, "content errors restart");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ isDirectory: true })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ parentTaskId: "dir" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ status: "cancelled" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ retryable: false })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "download" })), false, "downloads keep the owner retry");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "remote-to-remote" })), false, "remote copies keep the owner retry");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "local-copy" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(undefined), false);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
|
||||
// Failures that only mean "the SSH/SFTP connection went away". The staged
|
||||
// .part is intact up to the checkpoint, so Retry can continue from it instead
|
||||
// of restarting at byte 0 (which also strands the old stage on the server,
|
||||
// because the new attempt stages under a fresh transfer id).
|
||||
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
|
||||
|
||||
/**
|
||||
* True when Retry should resume the same task from its checkpoint.
|
||||
* Only local-to-remote uploads (`direction === "upload"`) do this. Downloads
|
||||
* and remote copies have no unfingerprinted stage to keep, so they stay on
|
||||
* the owner's fresh retry.
|
||||
*/
|
||||
export function canRetryFromCheckpoint(task: TransferTask | undefined): boolean {
|
||||
if (!task) return false;
|
||||
return task.status === "failed"
|
||||
&& !task.isDirectory
|
||||
&& !task.parentTaskId
|
||||
&& task.retryable !== false
|
||||
&& !task.conflict
|
||||
&& task.direction === "upload"
|
||||
&& (task.checkpointBytes ?? 0) > 0
|
||||
&& SESSION_LOSS_ERROR.test(task.error ?? "");
|
||||
}
|
||||
@@ -334,111 +334,6 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("a file batch that rejects mid-walk does not emit unhandledRejection", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const root = {
|
||||
...rootTask(),
|
||||
targetPath: "C:\\Users\\alice\\Downloads\\folder",
|
||||
};
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const setTransfers = (update: React.SetStateAction<TransferTask[]>) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
};
|
||||
let releaseLater!: () => void;
|
||||
const laterGate = new Promise<void>((resolve) => {
|
||||
releaseLater = resolve;
|
||||
});
|
||||
let laterListingStarted!: () => void;
|
||||
const laterListing = new Promise<void>((resolve) => {
|
||||
laterListingStarted = resolve;
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
},
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: "owner",
|
||||
cancelledTasksRef: { current: new Set() },
|
||||
pausedTasksRef: { current: new Set() },
|
||||
waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() },
|
||||
transfersRef,
|
||||
setTransfers,
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async (_sftpId: string, path: string) => {
|
||||
if (path === "/source") return [directoryEntry("bad"), directoryEntry("later")];
|
||||
if (path === "/source/bad") return [fileEntry("..\\outside.txt")];
|
||||
laterListingStarted();
|
||||
await laterGate;
|
||||
return [];
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
const rejectionText = (reason: unknown) => (
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
let running: Promise<number> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(
|
||||
root,
|
||||
"source-sftp",
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
"auto",
|
||||
"auto",
|
||||
root.id,
|
||||
);
|
||||
await laterListing;
|
||||
// The bad directory's batch is already queued. End this turn while the
|
||||
// sibling listing is still blocked so an unhandled rejection can surface.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(unhandled.map(rejectionText), []);
|
||||
releaseLater();
|
||||
await assert.rejects(running, /unsafe transfer path/i);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(unhandled.map(rejectionText), []);
|
||||
} finally {
|
||||
releaseLater();
|
||||
await running?.catch(() => {});
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
for (const ownerChange of ["same", "changed-active", "changed-completed"] as const) {
|
||||
test(`live folder settles superseded ownership without clobbering its replacement: ${ownerChange}`, async () => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
resolveSftpTransferConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
@@ -79,30 +78,11 @@ function createDirectoryListingGate(concurrency = DEFAULT_SFTP_DIRECTORY_LISTING
|
||||
};
|
||||
}
|
||||
|
||||
/** Observed at queue time so a mid-walk rejection is not unhandled. */
|
||||
type QueuedFileBatch =
|
||||
| { ok: true; errors: number }
|
||||
| { ok: false; error: unknown };
|
||||
|
||||
type DirectoryDiscoveryProgress = {
|
||||
discoveredFiles: number;
|
||||
nextEntryIndex: number;
|
||||
/** Tree-wide semaphore so nested parallel walks cannot multiply listings. */
|
||||
listingGate?: ReturnType<typeof createDirectoryListingGate>;
|
||||
/**
|
||||
* Tree-wide file-transfer gate (transfer concurrency setting). Each directory
|
||||
* hands its files to workers that share this gate, and the walk does not wait
|
||||
* for a directory's files before moving on, so there is no per-directory
|
||||
* barrier: trees of many small directories keep N files in flight.
|
||||
*/
|
||||
fileGate?: ReturnType<typeof createDirectoryListingGate>;
|
||||
/**
|
||||
* Per-directory file batches. Each promise is settled when queued; the root
|
||||
* walk inspects the outcomes after discovery.
|
||||
*/
|
||||
pendingFileBatches?: Promise<QueuedFileBatch>[];
|
||||
/** First session loss seen by any batch; later batches stop claiming files. */
|
||||
sessionLostError?: Error | null;
|
||||
};
|
||||
|
||||
function isCancelledLocalOrGlobal(
|
||||
@@ -547,7 +527,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
/** Returns number of failed child file transfers */
|
||||
const walkDirectory = async (
|
||||
const transferDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
@@ -561,8 +541,7 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
rootProgress?: DirectoryDiscoveryProgress,
|
||||
): Promise<number> => {
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -573,12 +552,11 @@ export function useSftpDirectoryTransferOps({
|
||||
// as soon as each directory is listed; compressed upload is a separate path.
|
||||
// UI should treat totalBytes as "discovered so far", not a fixed grand total.
|
||||
let totalErrors = 0;
|
||||
const progress: DirectoryDiscoveryProgress = discoveryProgress ?? rootProgress ?? {
|
||||
const progress = discoveryProgress ?? {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
};
|
||||
if (!progress.pendingFileBatches) progress.pendingFileBatches = [];
|
||||
if (!progress.listingGate) {
|
||||
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
|
||||
}
|
||||
@@ -706,7 +684,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
totalErrors += await walkDirectory(
|
||||
totalErrors += await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
@@ -743,21 +721,11 @@ export function useSftpDirectoryTransferOps({
|
||||
let sessionLostError: Error | null = null;
|
||||
const directoryEntryBase = progress.nextEntryIndex;
|
||||
progress.nextEntryIndex += regularFiles.length;
|
||||
const directoryFiles = regularFiles;
|
||||
if (!progress.fileGate) {
|
||||
// Same setting runSftpTransferWorkers reads, created on first file.
|
||||
progress.fileGate = createDirectoryListingGate(resolveSftpTransferConcurrency(
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
));
|
||||
}
|
||||
const fileGate = progress.fileGate;
|
||||
|
||||
const fileBatch = (async (): Promise<number> => {
|
||||
await runSftpTransferWorkers(
|
||||
directoryFiles,
|
||||
regularFiles,
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async (file, fileIndex) => fileGate.run(async () => {
|
||||
if (!sessionLostError && progress.sessionLostError) sessionLostError = progress.sessionLostError;
|
||||
async (file, fileIndex) => {
|
||||
if (sessionLostError) throw sessionLostError;
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -1025,7 +993,6 @@ export function useSftpDirectoryTransferOps({
|
||||
);
|
||||
if (isSessionError(err) && !sessionLostError) {
|
||||
sessionLostError = err instanceof Error ? err : new Error(message);
|
||||
progress.sessionLostError = progress.sessionLostError ?? sessionLostError;
|
||||
// Fail remaining queued siblings quickly with a clear cause.
|
||||
setTransfers((prev) => prev.map((t) => (
|
||||
t.parentTaskId === rootTaskId
|
||||
@@ -1048,10 +1015,10 @@ export function useSftpDirectoryTransferOps({
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
beforeClaim: async () => {
|
||||
if (sessionLostError || progress.sessionLostError) return;
|
||||
if (sessionLostError) return;
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
},
|
||||
},
|
||||
@@ -1063,69 +1030,17 @@ export function useSftpDirectoryTransferOps({
|
||||
throw err;
|
||||
});
|
||||
|
||||
totalErrors += errors.length;
|
||||
if (sessionLostError) {
|
||||
logger.warn("[SFTP] Directory transfer stopped early: session lost", sessionLostError.message);
|
||||
} else if (errors.length > 0) {
|
||||
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
|
||||
}
|
||||
return errors.length;
|
||||
})();
|
||||
// Do not wait here: the walk continues into sibling directories while
|
||||
// this directory's files drain through the shared fileGate. Settle the
|
||||
// batch now so a failure during that walk (unsafe filename, unexpected
|
||||
// worker error) is handled. The root drain still surfaces the error.
|
||||
progress.pendingFileBatches.push(fileBatch.then(
|
||||
(errors): QueuedFileBatch => ({ ok: true, errors }),
|
||||
(error: unknown): QueuedFileBatch => ({ ok: false, error }),
|
||||
));
|
||||
}
|
||||
|
||||
return totalErrors;
|
||||
};
|
||||
|
||||
const transferDirectory = async (
|
||||
...args: Parameters<typeof walkDirectory>
|
||||
): Promise<number> => {
|
||||
const [, , , , , , , , , , , discoveryProgress, traversalBudget] = args;
|
||||
if (discoveryProgress) return walkDirectory(...args);
|
||||
// Root of a folder task: own the tree-wide progress so we can drain every
|
||||
// directory's file batch, even when the walk itself throws, before the
|
||||
// caller decides the folder's final state.
|
||||
const rootProgress: DirectoryDiscoveryProgress = {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
pendingFileBatches: [],
|
||||
};
|
||||
let walkErrors = 0;
|
||||
let walkError: unknown = null;
|
||||
try {
|
||||
const [task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
|
||||
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks] = args;
|
||||
walkErrors = await walkDirectory(
|
||||
task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
|
||||
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks,
|
||||
undefined, traversalBudget, rootProgress,
|
||||
);
|
||||
} catch (error) {
|
||||
walkError = error;
|
||||
}
|
||||
let fileErrors = 0;
|
||||
let batchError: unknown = null;
|
||||
const batches = rootProgress.pendingFileBatches!;
|
||||
for (let index = 0; index < batches.length; index += 1) {
|
||||
const outcome = await batches[index];
|
||||
if (outcome.ok) {
|
||||
fileErrors += outcome.errors;
|
||||
} else {
|
||||
batchError = batchError ?? outcome.error;
|
||||
}
|
||||
}
|
||||
if (walkError) throw walkError;
|
||||
if (batchError) throw batchError;
|
||||
return walkErrors + fileErrors;
|
||||
};
|
||||
|
||||
|
||||
return { transferFile, transferDirectory };
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/** @test lifecycle repaints coalesce to one frame and still settle the final status */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { latchTransferPauseTree, releaseTransferPauseTree } from "./transferPauseLatch";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
|
||||
type Frame = { id: number; callback: (time: number) => void; cancelled: boolean };
|
||||
|
||||
function installFrameQueue() {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const frames: Frame[] = [];
|
||||
let nextId = 1;
|
||||
const requestAnimationFrame = (callback: (time: number) => void) => {
|
||||
const frame: Frame = { id: nextId++, callback, cancelled: false };
|
||||
frames.push(frame);
|
||||
return frame.id;
|
||||
};
|
||||
const cancelAnimationFrame = (id: number) => {
|
||||
const frame = frames.find((candidate) => candidate.id === id);
|
||||
if (frame) frame.cancelled = true;
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {},
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
};
|
||||
return {
|
||||
frames,
|
||||
flush: async () => {
|
||||
const pending = frames.splice(0, frames.length).filter((frame) => !frame.cancelled);
|
||||
await act(async () => {
|
||||
for (const frame of pending) frame.callback(0);
|
||||
});
|
||||
return pending.length;
|
||||
},
|
||||
restore: () => {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask(id: string, status: TransferTask["status"], error?: string): TransferTask {
|
||||
return {
|
||||
id,
|
||||
fileName: `${id}.txt`,
|
||||
sourcePath: `/source/${id}.txt`,
|
||||
targetPath: `/target/${id}.txt`,
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload",
|
||||
status,
|
||||
error,
|
||||
totalBytes: 10,
|
||||
transferredBytes: status === "completed" ? 10 : 2,
|
||||
speed: status === "transferring" ? 1 : 0,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
resumable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(ownerId: string) {
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: () => null,
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
get ops() {
|
||||
return ops;
|
||||
},
|
||||
render: async () => {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
},
|
||||
unmount: async () => {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
renderer = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("lifecycle frames keep the final completion, pause, cancel, and error status", async () => {
|
||||
const ownerId = "lifecycle-flush-final-status";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const taskId = "lifecycle-row";
|
||||
try {
|
||||
await view.render();
|
||||
const stages: Array<{ status: TransferTask["status"]; error?: string }> = [
|
||||
{ status: "transferring" },
|
||||
{ status: "paused" },
|
||||
{ status: "cancelled" },
|
||||
{ status: "failed", error: "connection reset" },
|
||||
{ status: "completed" },
|
||||
];
|
||||
for (const stage of stages) {
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, stage.status, stage.error)]);
|
||||
});
|
||||
const paintsNow = stage.status !== "transferring";
|
||||
if (paintsNow) {
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
} else {
|
||||
assert.notEqual(view.ops?.transfers.find((row) => row.id === taskId)?.status, stage.status);
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
assert.equal(await queue.flush(), 1);
|
||||
}
|
||||
const painted = view.ops?.transfers.find((row) => row.id === taskId);
|
||||
assert.equal(painted?.status, stage.status);
|
||||
assert.equal(painted?.error, stage.error);
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId).find((row) => row.id === taskId)?.status,
|
||||
stage.status,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
sftpTransferCenterStore.dismiss(taskId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("parent pause paints while a child frame is pending and a stale publish cannot reopen it", async () => {
|
||||
const ownerId = "lifecycle-flush-parent-pause";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const parentId = "folder-parent";
|
||||
const childId = "folder-child";
|
||||
const parent = (status: TransferTask["status"]): TransferTask => ({
|
||||
...makeTask(parentId, status),
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
lifecycleEpoch: 4,
|
||||
});
|
||||
const child = (status: TransferTask["status"]): TransferTask => ({
|
||||
...makeTask(childId, status),
|
||||
parentTaskId: parentId,
|
||||
lifecycleEpoch: 4,
|
||||
});
|
||||
try {
|
||||
await view.render();
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("transferring")]);
|
||||
assert.equal(view.ops?.transfers.length, 0, "child lifecycle churn stays on the pending frame");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
|
||||
// Pause lands while that frame is still queued. Latch afterwards: the
|
||||
// control-epoch guard drops a panel publish that arrives already latched,
|
||||
// and Resume is the equal-epoch republish that happens only after unlatch.
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("paused"), child("paused")]);
|
||||
});
|
||||
assert.equal(view.ops?.transfers.find((row) => row.id === parentId)?.status, "paused");
|
||||
assert.equal(view.ops?.transfers.find((row) => row.id === childId)?.status, "paused");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0, "pause cancels the pre-pause frame");
|
||||
|
||||
latchTransferPauseTree(parentId, [childId]);
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("paused")]);
|
||||
});
|
||||
await queue.flush();
|
||||
assert.equal(sftpTransferCenterStore.getTask(parentId)?.status, "paused");
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId)?.status, "paused");
|
||||
const paintedParent = view.ops?.transfers.find((row) => row.id === parentId);
|
||||
assert.equal(paintedParent?.status, "paused");
|
||||
assert.equal(
|
||||
view.ops?.transfers.filter((row) => (
|
||||
!row.parentTaskId && ["pending", "queued", "transferring", "pausing"].includes(row.status)
|
||||
)).length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
releaseTransferPauseTree(parentId, [childId]);
|
||||
sftpTransferCenterStore.dismiss(parentId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("unmount cancels the pending lifecycle frame and remount reads the settled store row", async () => {
|
||||
const ownerId = "lifecycle-flush-unmount";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const taskId = "lifecycle-unmount-row";
|
||||
try {
|
||||
await view.render();
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, "failed", "write failed")]);
|
||||
assert.equal(view.ops?.transfers.length, 0);
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
|
||||
await view.unmount();
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
assert.equal(await queue.flush(), 0);
|
||||
|
||||
await view.render();
|
||||
const painted = view.ops?.transfers.find((row) => row.id === taskId);
|
||||
assert.equal(painted?.status, "failed");
|
||||
assert.equal(painted?.error, "write failed");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
} finally {
|
||||
sftpTransferCenterStore.dismiss(taskId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
@@ -440,9 +440,6 @@ test("stat-less remote source keeps the listing size in the overwrite dialog and
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
// Lifecycle paints coalesce to one frame. The plan lives on the ref
|
||||
// immediately; React state catches up on the queued animation frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
resolveDirectDirectoryDownloadFinalStatus,
|
||||
} from "./downloadTransferTask";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
releaseTransferPauseTree,
|
||||
waitUntilTransferPauseReleased,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -159,89 +158,6 @@ export function finishTransferTask(
|
||||
return status;
|
||||
}
|
||||
|
||||
const STICKY_CONTROL_STATUSES = new Set<TransferStatus>([
|
||||
"paused",
|
||||
"pausing",
|
||||
"cancelled",
|
||||
"completed",
|
||||
"failed",
|
||||
"interrupted",
|
||||
"attention",
|
||||
]);
|
||||
|
||||
const LIVE_QUEUE_STATUSES = new Set<TransferStatus>([
|
||||
"transferring",
|
||||
"pending",
|
||||
"queued",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pause, resume, and cancel paint on the same turn. Per-file complete/queue
|
||||
* churn stays on the animation frame so a folder upload does not commit once
|
||||
* per child.
|
||||
*/
|
||||
export function shouldPaintTransferControlNow(
|
||||
previous: readonly TransferTask[],
|
||||
next: readonly TransferTask[],
|
||||
): boolean {
|
||||
const prevById = new Map(previous.map((row) => [row.id, row]));
|
||||
return next.some((row) => {
|
||||
const prevRow = prevById.get(row.id);
|
||||
if (prevRow?.status === row.status) return false;
|
||||
if (row.status === "paused" || row.status === "pausing" || row.status === "cancelled") return true;
|
||||
if (prevRow && (prevRow.status === "paused" || prevRow.status === "pausing" || prevRow.status === "cancelled")) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!row.parentTaskId
|
||||
&& prevRow
|
||||
&& (STICKY_CONTROL_STATUSES.has(row.status) || STICKY_CONTROL_STATUSES.has(prevRow.status))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Child lifecycle updates clone the ref they saw. If that clone still says the
|
||||
* folder is transferring after the store already paused it, publishing the
|
||||
* clone reopens the parent. An explicit status edit (resume, retry) is kept.
|
||||
*/
|
||||
export function reconcilePanelRowsWithStore(
|
||||
previous: readonly TransferTask[],
|
||||
next: readonly TransferTask[],
|
||||
storeRows: readonly TransferTask[],
|
||||
): TransferTask[] {
|
||||
const prevById = new Map(previous.map((row) => [row.id, row]));
|
||||
const storeById = new Map(storeRows.map((row) => [row.id, row]));
|
||||
let changed = false;
|
||||
const reconciled = next.map((row) => {
|
||||
const storeRow = storeById.get(row.id);
|
||||
const prevRow = prevById.get(row.id);
|
||||
if (!storeRow || !STICKY_CONTROL_STATUSES.has(storeRow.status) || !LIVE_QUEUE_STATUSES.has(row.status)) {
|
||||
return row;
|
||||
}
|
||||
// A full-list write can disagree with the ref only because the ref already
|
||||
// moved to the store's pause. That is a stale snapshot, not Resume.
|
||||
// Resume releases the latch before it republishes transferring.
|
||||
const rootId = row.parentTaskId ?? row.id;
|
||||
const latchedReopen = isTransferOrRootPauseLatched(rootId, row.id);
|
||||
if (prevRow && prevRow.status !== row.status && !latchedReopen) return row;
|
||||
if (row.status === storeRow.status) return row;
|
||||
changed = true;
|
||||
return {
|
||||
...row,
|
||||
status: storeRow.status,
|
||||
speed: 0,
|
||||
error: storeRow.error,
|
||||
endTime: storeRow.endTime,
|
||||
pauseUnavailableReason: storeRow.pauseUnavailableReason,
|
||||
};
|
||||
});
|
||||
return changed ? reconciled : next as TransferTask[];
|
||||
}
|
||||
|
||||
/** User-facing path exclusivity notice (toast + logs). */
|
||||
function notifyPathConflict(
|
||||
existing: Pick<TransferTask, "fileName" | "status">,
|
||||
@@ -321,85 +237,29 @@ export const useSftpTransfers = ({
|
||||
const transfersRef = useRef(transfers);
|
||||
const conflictsRef = useRef(conflicts);
|
||||
conflictsRef.current = conflicts;
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden.
|
||||
useEffect(() => {
|
||||
if (!surfaceVisible) return;
|
||||
setTransfersState(transfersRef.current);
|
||||
}, [surfaceVisible]);
|
||||
|
||||
// Keep owner lifecycle changes in the global transfer store. Byte progress
|
||||
// arrives independently through the unified process-wide event stream.
|
||||
//
|
||||
// Do not pre-assign transfersRef before publishing the same array reference —
|
||||
// identity equality skips the store merge (avoids store↔listener loops).
|
||||
// Lifecycle writers (child add/start/complete) fire several times per file.
|
||||
// Repainting the whole SFTP view synchronously on each one saturates the
|
||||
// renderer on folders with many small files, which then throttles the
|
||||
// transfer itself. transfersRef/store stay authoritative; React state
|
||||
// catches up once per frame, except pause/resume/cancel and folder terminal
|
||||
// rows, which paint immediately.
|
||||
//
|
||||
// A frame queued for child churn must not commit a pre-pause ref after the
|
||||
// store has already paused the parent. The flush re-reads the store, and a
|
||||
// synchronous control paint bumps paintEpoch so that queued frame no-ops.
|
||||
const stateFlushHandleRef = useRef<{ cancel: () => void } | null>(null);
|
||||
const paintEpochRef = useRef(0);
|
||||
const cancelScheduledTransferPaint = useCallback(() => {
|
||||
stateFlushHandleRef.current?.cancel();
|
||||
stateFlushHandleRef.current = null;
|
||||
}, []);
|
||||
const paintTransfersNow = useCallback((rows: TransferTask[]) => {
|
||||
cancelScheduledTransferPaint();
|
||||
paintEpochRef.current += 1;
|
||||
transfersRef.current = rows;
|
||||
setTransfersState(rows);
|
||||
}, [cancelScheduledTransferPaint]);
|
||||
const scheduleTransfersStateFlush = useCallback(() => {
|
||||
if (stateFlushHandleRef.current) return;
|
||||
const epochAtSchedule = paintEpochRef.current;
|
||||
const flush = () => {
|
||||
stateFlushHandleRef.current = null;
|
||||
// A pause/resume/cancel/terminal paint landed after this frame was queued.
|
||||
if (paintEpochRef.current !== epochAtSchedule) return;
|
||||
const storeRows = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
const painted = reconcilePanelRowsWithStore(transfersRef.current, transfersRef.current, storeRows);
|
||||
paintEpochRef.current += 1;
|
||||
transfersRef.current = painted;
|
||||
setTransfersState(painted);
|
||||
};
|
||||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
||||
const id = window.requestAnimationFrame(flush);
|
||||
stateFlushHandleRef.current = { cancel: () => window.cancelAnimationFrame(id) };
|
||||
} else {
|
||||
const id = setTimeout(flush, 16);
|
||||
stateFlushHandleRef.current = { cancel: () => clearTimeout(id) };
|
||||
}
|
||||
}, [ownerId]);
|
||||
useEffect(() => () => {
|
||||
cancelScheduledTransferPaint();
|
||||
}, [cancelScheduledTransferPaint]);
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden. Cancel any frame that
|
||||
// was queued against the hidden snapshot so it cannot paint over this catch-up.
|
||||
useEffect(() => {
|
||||
if (!surfaceVisible) return;
|
||||
paintTransfersNow(transfersRef.current);
|
||||
}, [paintTransfersNow, surfaceVisible]);
|
||||
const setTransfers = useCallback((update: SetStateAction<TransferTask[]>) => {
|
||||
const prev = transfersRef.current;
|
||||
const next = typeof update === "function"
|
||||
? (update as (value: TransferTask[]) => TransferTask[])(prev)
|
||||
: update;
|
||||
if (next === prev) return;
|
||||
const reconciled = reconcilePanelRowsWithStore(
|
||||
prev,
|
||||
next,
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId),
|
||||
);
|
||||
if (reconciled === prev) return;
|
||||
sftpTransferCenterStore.publishOwner(ownerId, reconciled);
|
||||
sftpTransferCenterStore.publishOwner(ownerId, next);
|
||||
const canonical = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
if (shouldPaintTransferControlNow(prev, canonical)) {
|
||||
paintTransfersNow(canonical);
|
||||
return;
|
||||
}
|
||||
transfersRef.current = canonical;
|
||||
scheduleTransfersStateFlush();
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
setTransfersState(canonical);
|
||||
}, [ownerId]);
|
||||
|
||||
// Coalesce progress-only patches to one paint per animation frame. Without
|
||||
// this, each stream tick maps the full transfer list + publishOwner + store
|
||||
@@ -431,19 +291,9 @@ export const useSftpTransfers = ({
|
||||
const prevPending = pendingProgressByIdRef.current.get(taskId) || {};
|
||||
pendingProgressByIdRef.current.set(taskId, { ...prevPending, ...updates });
|
||||
// Keep ref hot for cancel/pause checks without waiting for paint.
|
||||
// A byte tick that still says "transferring" must not reopen a latched pause.
|
||||
transfersRef.current = transfersRef.current.map((row) => {
|
||||
if (row.id !== taskId) return row;
|
||||
const merged = { ...row, ...updates };
|
||||
const latched = isTransferOrRootPauseLatched(row.parentTaskId ?? row.id, row.id);
|
||||
if (
|
||||
(latched || row.status === "paused" || row.status === "pausing")
|
||||
&& (updates.status === "transferring" || updates.status === "pending" || updates.status === "queued")
|
||||
) {
|
||||
return { ...merged, status: row.status, speed: 0 };
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
transfersRef.current = transfersRef.current.map((row) => (
|
||||
row.id === taskId ? { ...row, ...updates } : row
|
||||
));
|
||||
scheduleProgressFlush();
|
||||
}, [scheduleProgressFlush]);
|
||||
useEffect(() => () => {
|
||||
@@ -1222,9 +1072,8 @@ export const useSftpTransfers = ({
|
||||
const next = transfersRef.current.map((candidate) => (
|
||||
candidate.id === task.id ? canonicalTask : candidate
|
||||
));
|
||||
// Terminal rows used to setState directly and leave a child frame
|
||||
// queued. That frame later painted the pre-terminal ref.
|
||||
paintTransfersNow(next);
|
||||
transfersRef.current = next;
|
||||
setTransfersState(next);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2275,16 +2124,11 @@ export const useSftpTransfers = ({
|
||||
// panel list; byte-only ticks update refs without repainting the whole panel.
|
||||
useEffect(() => {
|
||||
return sftpTransferCenterStore.subscribe(() => {
|
||||
const previous = transfersRef.current;
|
||||
const ownerRows = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => task.ownerId === ownerId);
|
||||
transfersRef.current = ownerRows;
|
||||
if (shouldPaintTransferControlNow(previous, ownerRows)) {
|
||||
paintTransfersNow(ownerRows);
|
||||
return;
|
||||
}
|
||||
scheduleTransfersStateFlush();
|
||||
setTransfersState(ownerRows);
|
||||
});
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
}, [ownerId]);
|
||||
|
||||
useEffect(() => sftpTransferCenterStore.subscribeProgress(() => {
|
||||
const liveById = new Map(sftpTransferCenterStore.getSnapshot().tasks.map((task) => [task.id, task]));
|
||||
|
||||
@@ -97,85 +97,6 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
|
||||
assert.equal(store.getTask("background-retry")?.status, "completed");
|
||||
});
|
||||
|
||||
for (const [label, error, expectResume] of [
|
||||
["dead channel", "SFTP channel closed", true],
|
||||
["lost session", "SFTP session lost. Please reconnect.", true],
|
||||
["content failure", "Permission denied", false],
|
||||
] as const) {
|
||||
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
|
||||
const controllerRetries: string[] = [];
|
||||
const dedicatedTasks: TransferTask[] = [];
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.registerOwner("panel-a", {
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
cancel: async () => {},
|
||||
retry: async (id) => { controllerRetries.push(id); },
|
||||
prioritize: async () => {},
|
||||
dismiss: () => {},
|
||||
});
|
||||
store.setDedicatedResumeHandler(async (task) => {
|
||||
dedicatedTasks.push(task);
|
||||
return { success: true };
|
||||
});
|
||||
store.publishOwner("panel-a", [{
|
||||
...makeTask("dropped", "failed"),
|
||||
totalBytes: 100,
|
||||
checkpointBytes: 37,
|
||||
transferredBytes: 37,
|
||||
error,
|
||||
endTime: Date.now(),
|
||||
}]);
|
||||
|
||||
await store.retry("dropped");
|
||||
|
||||
if (expectResume) {
|
||||
assert.deepEqual(controllerRetries, [], "no fresh-id restart");
|
||||
assert.equal(dedicatedTasks.length, 1);
|
||||
assert.equal(dedicatedTasks[0]?.id, "dropped", "same id keeps the same staged .part");
|
||||
assert.equal(dedicatedTasks[0]?.checkpointBytes, 37);
|
||||
assert.equal(dedicatedTasks[0]?.reconnectRequired, true);
|
||||
} else {
|
||||
assert.deepEqual(controllerRetries, ["dropped"]);
|
||||
assert.equal(dedicatedTasks.length, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("panel Retry after a dropped download or remote copy keeps the owner restart", async () => {
|
||||
for (const direction of ["download", "remote-to-remote"] as const) {
|
||||
const controllerRetries: string[] = [];
|
||||
const dedicatedTasks: TransferTask[] = [];
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.registerOwner("panel-a", {
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
cancel: async () => {},
|
||||
retry: async (id) => { controllerRetries.push(id); },
|
||||
prioritize: async () => {},
|
||||
dismiss: () => {},
|
||||
});
|
||||
store.setDedicatedResumeHandler(async (task) => {
|
||||
dedicatedTasks.push(task);
|
||||
return { success: true };
|
||||
});
|
||||
store.publishOwner("panel-a", [{
|
||||
...makeTask("dropped", "failed"),
|
||||
direction,
|
||||
totalBytes: 100,
|
||||
checkpointBytes: 37,
|
||||
transferredBytes: 37,
|
||||
error: "SFTP channel closed",
|
||||
endTime: Date.now(),
|
||||
}]);
|
||||
|
||||
await store.retry("dropped");
|
||||
|
||||
assert.deepEqual(controllerRetries, ["dropped"], direction);
|
||||
assert.equal(dedicatedTasks.length, 0, direction);
|
||||
}
|
||||
});
|
||||
|
||||
test("resume without an owner uses a live backend transfer session when available", async (t) => {
|
||||
const resumeCalls: string[] = [];
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "./sftp/globalSftpTransferControl";
|
||||
import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryRestoreMigration";
|
||||
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
|
||||
import { canRetryFromCheckpoint } from "./sftp/retryFromCheckpoint";
|
||||
|
||||
type Listener = () => void;
|
||||
type TaskSettlementObserver = {
|
||||
@@ -366,13 +365,9 @@ export function mergeOwnerPublishedTask(
|
||||
} else if (
|
||||
existingPaused
|
||||
&& (incoming.status === "transferring" || incoming.status === "pending" || incoming.status === "queued")
|
||||
// A strictly older epoch is a stale snapshot. Equal epochs are how Resume
|
||||
// republishes after the latch drops. While the pause latch is still held,
|
||||
// that republish is a pre-pause panel snapshot and must not reopen the row.
|
||||
&& (
|
||||
incomingLifecycleEpoch < existingLifecycleEpoch
|
||||
|| isTransferOrRootPauseLatched(existing.parentTaskId ?? existing.id, existing.id)
|
||||
)
|
||||
// Only a strictly older epoch is a stale snapshot. Equal/missing epochs must
|
||||
// allow intentional Resume (publishOwner after unlatch) to re-open the row.
|
||||
&& incomingLifecycleEpoch < existingLifecycleEpoch
|
||||
) {
|
||||
// Soft-drain / late progress publish must not un-pause without resume epoch.
|
||||
merged.status = existing.status;
|
||||
@@ -2057,24 +2052,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
const controller = ownerId === "background-agent"
|
||||
? undefined
|
||||
: controllers.get(ownerId ?? "");
|
||||
// Live panel task whose connection dropped mid-file: the stage is valid
|
||||
// up to the checkpoint. Continue through the reconnect-resume path a
|
||||
// restored task uses (same id, so the same staged .part) instead of a
|
||||
// fresh id from byte 0, which strands the old stage on the server.
|
||||
// Orphaned rows (no live owner) keep the restart-from-0 contract below.
|
||||
if (controller && canRetryFromCheckpoint(task)) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "interrupted" as const,
|
||||
error: undefined,
|
||||
reconnectRequired: true,
|
||||
speed: 0,
|
||||
endTime: undefined,
|
||||
} : candidate);
|
||||
emit();
|
||||
await this.resume(taskId);
|
||||
return;
|
||||
}
|
||||
if (controller) {
|
||||
await controller.retry(taskId);
|
||||
return;
|
||||
|
||||
@@ -125,55 +125,6 @@ test('overlapping nonmodal menus yield with a picture; modal dialogs yield even
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
});
|
||||
|
||||
function panel(f: Awaited<ReturnType<typeof fixture>>, x: number, y: number, width: number, height: number) {
|
||||
const node = f.overlay('<div data-section="app-host-editor-layer"><div data-native-view-occluder></div></div>').firstElementChild!;
|
||||
node.getBoundingClientRect = () => new f.env.window.DOMRect(x, y, width, height);
|
||||
return node;
|
||||
}
|
||||
|
||||
test('an overlay host editor panel narrows the live page beside it, then restores the pane', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 900, 0, 400, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 }, 'a panel beside the pane changes nothing');
|
||||
const editor = panel(f, 450, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'the page stays live instead of becoming a picture');
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 250, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
editor.getBoundingClientRect = () => new f.env.window.DOMRect(400, 0, 650, 900);
|
||||
f.resize(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 200, height: 600 }, 'resizing the panel follows it');
|
||||
editor.parentElement!.remove(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('a popper inside the panel leaves the narrowed page live; one over the page keeps aligned pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 0, 0, 300, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
const select = f.overlay(tooltip, 140, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'a select inside the left panel does not reach the page');
|
||||
select.remove();
|
||||
f.overlay(tooltip, 320, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot.map((layer) => layer.bounds), [{ x: 100, y: 0, width: 500, height: 600 }],
|
||||
'pictures are placed where the narrowed page sits inside the pane');
|
||||
});
|
||||
|
||||
test('a panel over the middle of the pane or leaving too little room falls back to a picture', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const middle = panel(f, 300, 0, 100, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
middle.parentElement!.remove(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
panel(f, 250, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true, 'a 50px strip is not worth a live page');
|
||||
});
|
||||
|
||||
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let complete: (result: Result) => void = () => assert.fail('capture was not started');
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
|
||||
|
||||
function overlapsBrowser(rect: HostBrowserPaneRect, element: Element): boolean {
|
||||
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
|
||||
const overlay = element.getBoundingClientRect();
|
||||
return overlay.width > 0 && overlay.height > 0
|
||||
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
|
||||
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
|
||||
&& overlay.x < rect.right && overlay.right > rect.x
|
||||
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
|
||||
}
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
@@ -71,21 +70,12 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native views sit above renderer portals. Only overlapping poppers need
|
||||
// to yield; dialogs are modal even when their content is beside the pane.
|
||||
// Overlay panels stay open while the user works, so the live page moves
|
||||
// beside them when it can and only falls back to a picture otherwise.
|
||||
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
||||
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
||||
const overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
|
||||
+ '[data-native-view-occluder]',
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
));
|
||||
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
|
||||
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
|
||||
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
|
||||
// Poppers over the part of the pane hidden behind a panel (e.g. a select
|
||||
// inside the host editor) no longer touch the live page.
|
||||
const covered = modal || (!!rect && !live)
|
||||
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
@@ -100,11 +90,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
const pane = live || rect;
|
||||
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
|
||||
// Pictures are relative to the native page, which may sit beside a panel.
|
||||
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
|
||||
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
@@ -116,9 +102,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
|
||||
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
|
||||
})) } : null);
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -135,15 +119,14 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
|
||||
'href', 'media', 'disabled'],
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
|
||||
@@ -50,7 +50,7 @@ export const useSessionLogBackend = () => {
|
||||
|
||||
const getManualSessionLogStatus = useCallback(async (payload: ManualStatusPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, hasSessionLog: false, error: "Session log bridge unavailable" };
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
toVaultImportExistingHostRef,
|
||||
type VaultImportFormat,
|
||||
} from "../../domain/vaultImport";
|
||||
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||||
@@ -176,7 +175,6 @@ export function useVaultImportHandlers({
|
||||
files,
|
||||
encoding: options?.encoding,
|
||||
masterPassword: options?.masterPassword,
|
||||
existingHosts: hostsRef.current.map(toVaultImportExistingHostRef),
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
if (!signal.aborted) updateProgress(progress);
|
||||
|
||||
@@ -64,8 +64,6 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
writeTerminalDataMapWithQuotaRetry,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
@@ -278,57 +276,23 @@ const buildPersistedLogIds = (
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same result as comparing `JSON.stringify` of both maps (own keys in the
|
||||
* same order, equal values) without serializing the multi-MB replay data.
|
||||
*/
|
||||
const isSameTerminalDataMap = (
|
||||
a: ConnectionLogTerminalDataMap,
|
||||
b: ConnectionLogTerminalDataMap,
|
||||
): boolean => {
|
||||
if (typeof a !== "object" || a === null || Array.isArray(a)) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (let i = 0; i < aKeys.length; i += 1) {
|
||||
if (aKeys[i] !== bKeys[i] || a[aKeys[i]] !== b[bKeys[i]]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const writeConnectionLogTerminalDataMap = (
|
||||
existing: ConnectionLogTerminalDataMap,
|
||||
pruned: ConnectionLogTerminalDataMap,
|
||||
logs: ConnectionLog[],
|
||||
quotaFailedCharsRef: { current: number | null },
|
||||
localMaps: ConnectionLogTerminalDataMap[],
|
||||
usePersistedLogIdsFromDisk: boolean,
|
||||
): boolean => {
|
||||
if (isSameTerminalDataMap(existing, pruned)) return true;
|
||||
// On quota failure keep the newest replay buffers instead of none.
|
||||
// The hint stays on the hook so the domain helper remains a pure calculation.
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
const pruned = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
pruned,
|
||||
(next) => localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, next),
|
||||
quotaFailedCharsRef.current,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
quotaFailedCharsRef.current = result.quotaFailedChars;
|
||||
return result.persisted;
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
};
|
||||
|
||||
export type UseVaultStateOptions = {
|
||||
/**
|
||||
* When false, skip loading and cross-window sync of the bulky
|
||||
* connection-log terminal-data side store (~30–50 MB in localStorage).
|
||||
* Pass false from windows that never show connection-log replay
|
||||
* (Settings, tray panel, terminal popup). Default true.
|
||||
*/
|
||||
syncConnectionLogTerminalData?: boolean;
|
||||
};
|
||||
|
||||
export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const syncConnectionLogTerminalData = options?.syncConnectionLogTerminalData !== false;
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
@@ -455,7 +419,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
const connectionLogQuotaFailedCharsRef = useRef<number | null>(null);
|
||||
const pluginCredentialCatalogVersion = useRef(0);
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback((
|
||||
@@ -475,15 +438,18 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
const persisted = writeConnectionLogTerminalDataMap(
|
||||
existing,
|
||||
merged,
|
||||
logs,
|
||||
connectionLogQuotaFailedCharsRef,
|
||||
localMaps,
|
||||
usePersistedLogIdsFromDisk,
|
||||
);
|
||||
// On a failed write (e.g. QuotaExceeded once the side store nears ~50 MB)
|
||||
// keep the same capped map instead of accumulating every log's replay
|
||||
// buffer in memory; anything outside it would be pruned on the next write.
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
if (persisted) {
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
} else {
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...connectionLogTerminalDataRef.current,
|
||||
...buildTerminalDataMapFromLogs(logs),
|
||||
};
|
||||
}
|
||||
return { map: connectionLogTerminalDataRef.current, persisted };
|
||||
}, []);
|
||||
|
||||
@@ -1331,10 +1297,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const savedLogs = prev.filter((l) => l.saved);
|
||||
const unsavedLogs = prev.filter((l) => !l.saved);
|
||||
const updated = [newLog, ...unsavedLogs].slice(0, 500);
|
||||
const final = pruneInMemoryUnsavedTerminalData(
|
||||
[...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
),
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
);
|
||||
persistConnectionLogState(final, { pruneMainBlob: true });
|
||||
return final;
|
||||
@@ -1347,8 +1311,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const updateConnectionLog = useCallback(
|
||||
(id: string, updates: Partial<ConnectionLog>) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
@@ -1359,10 +1323,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
|
||||
const toggleConnectionLogSaved = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
),
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: false });
|
||||
return updated;
|
||||
@@ -1666,11 +1628,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
// Windows that never render connection-log replay skip the ~30–50 MB
|
||||
// side-store parse on boot (Settings / tray / popup).
|
||||
const terminalDataMap = syncConnectionLogTerminalData
|
||||
? readConnectionLogTerminalDataMap()
|
||||
: {};
|
||||
const terminalDataMap = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
if (savedConnectionLogs) {
|
||||
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
|
||||
@@ -1715,7 +1673,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [commitEncryptedHostsUnderVaultLock, syncConnectionLogTerminalData, updateHosts, updateSnippets]);
|
||||
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
@@ -1924,9 +1882,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
// Other windows still receive the storage event; skip the multi-MB
|
||||
// JSON.parse when this mount does not show connection-log replay.
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
@@ -1967,7 +1922,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
const next = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
@@ -1980,7 +1934,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
};
|
||||
}, [applyConnectionLogsFromStorage, syncConnectionLogTerminalData]);
|
||||
}, [applyConnectionLogsFromStorage]);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { normalizeVaultOrder, sortByVaultOrder } from "../domain/vaultOrder.ts";
|
||||
import type { GroupConfig } from "../types.ts";
|
||||
import GroupDetailsPanel from "./GroupDetailsPanel.tsx";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor.tsx";
|
||||
import { AsidePanel } from "./ui/aside-panel.tsx";
|
||||
import { Input } from "./ui/input.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
import { installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
async function saveGroup(config: GroupConfig | undefined, newName?: string) {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const dom = installDomEnvironment();
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
const previousCancelRaf = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(() => callback(0), 0) as unknown as number;
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let saved: GroupConfig | undefined;
|
||||
let savedName: string | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<TooltipProvider>
|
||||
<GroupDetailsPanel
|
||||
groupPath={config?.path ?? "new-group"}
|
||||
config={config}
|
||||
availableKeys={[]}
|
||||
identities={[]}
|
||||
allHosts={[]}
|
||||
groups={[]}
|
||||
terminalThemeId="default"
|
||||
terminalFontSize={14}
|
||||
onSave={(value, name) => { saved = value; savedName = name; }}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType(HostNotesEditor).props.onChange(" Updated note ");
|
||||
if (newName) {
|
||||
renderer!.root.findAllByType(Input)[0].props.onChange({ target: { value: newName } });
|
||||
}
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType(AsidePanel).props.actions.props.onClick();
|
||||
});
|
||||
assert.ok(saved, "the actual panel save handler must emit a config");
|
||||
return { saved, savedName };
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
globalThis.cancelAnimationFrame = previousCancelRaf;
|
||||
dom.cleanup();
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test("editing group notes retains its manual position through write normalization", async () => {
|
||||
const original = { path: "Fleet-0999", order: 1000 };
|
||||
const sibling = { path: "Fleet-0998", order: 2000 };
|
||||
const { saved, savedName } = await saveGroup(original);
|
||||
assert.deepEqual(saved, { ...original, notes: "Updated note" });
|
||||
assert.equal(savedName, undefined);
|
||||
// VaultView removes the old config and appends the panel's replacement.
|
||||
const written = normalizeVaultOrder([sibling, saved]);
|
||||
assert.deepEqual(sortByVaultOrder(written).map((config) => config.path), [original.path, sibling.path]);
|
||||
assert.deepEqual(original, { path: "Fleet-0999", order: 1000 });
|
||||
});
|
||||
|
||||
test("renaming a group in the details panel retains its manual position", async () => {
|
||||
const { saved, savedName } = await saveGroup({ path: "parent/old", order: 2500 }, "renamed");
|
||||
assert.equal(saved.path, "parent/renamed");
|
||||
assert.equal(saved.order, 2500);
|
||||
assert.equal(savedName, "renamed");
|
||||
});
|
||||
|
||||
test("group saves preserve zero and negative manual order values", async () => {
|
||||
for (const order of [0, -1000]) {
|
||||
const { saved } = await saveGroup({ path: "group", order });
|
||||
assert.equal(saved.order, order);
|
||||
}
|
||||
});
|
||||
|
||||
test("saving a new or unordered group leaves order assignment to the write path", async () => {
|
||||
for (const config of [undefined, { path: "existing-unordered" }]) {
|
||||
const { saved } = await saveGroup(config);
|
||||
assert.equal(Object.hasOwn(saved, "order"), false);
|
||||
assert.equal(normalizeVaultOrder([saved])[0].order, 1000);
|
||||
}
|
||||
});
|
||||
@@ -559,8 +559,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const result: GroupConfig = {
|
||||
path: newPath,
|
||||
// Editing defaults or notes must not change the group's manual position.
|
||||
...(config?.order !== undefined && { order: config.order }),
|
||||
notes: form.notes?.trim() || undefined,
|
||||
// Only include SSH fields if SSH section is enabled
|
||||
...(sshEnabled && {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { GroupNode } from "../types";
|
||||
|
||||
test("HostTreeView React updates invalidate order when configs or sort mode change", async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="scroll"><div id="root"></div></div></body></html>', {
|
||||
url: "http://localhost", pretendToBeVisual: true,
|
||||
});
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
for (const key of ["window", "document", "navigator", "HTMLElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent", "localStorage"] as const) {
|
||||
install(key, key === "window" ? dom.window : dom.window[key]);
|
||||
}
|
||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
const scroller = dom.window.document.getElementById("scroll")!;
|
||||
Object.defineProperties(scroller, { offsetHeight: { value: 800 }, offsetWidth: { value: 1280 } });
|
||||
const { act, createElement } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTreeView } = await import("./HostTreeView.tsx");
|
||||
const noop = () => undefined;
|
||||
const groupTree: GroupNode[] = ["alpha", "beta"].map((path) => ({ name: path, path, hosts: [], children: {} }));
|
||||
const common = {
|
||||
groupTree, hosts: [], expandedPaths: new Set<string>(), scrollRef: { current: scroller },
|
||||
onConnect: noop, onEditHost: noop, onDuplicateHost: noop, onDeleteHost: noop,
|
||||
onCopyCredentials: noop, onNewGroup: noop, onRenameGroup: noop, onEditGroup: noop,
|
||||
onDeleteGroup: noop, moveHostToGroup: noop, moveGroup: noop,
|
||||
};
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const paths = () => [...dom.window.document.querySelectorAll("[data-group-path]")].map((node) => node.getAttribute("data-group-path"));
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "beta", order: -1 }] }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["beta", "alpha"]);
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "alpha", order: -1 }] }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["alpha", "beta"]);
|
||||
const groupConfigs = [{ path: "beta", order: -1 }];
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "az", groupConfigs }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["alpha", "beta"]);
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["beta", "alpha"]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -498,75 +498,3 @@ test("HostTreeView IP sorting stays inside each group and leaves manual order in
|
||||
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
|
||||
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
|
||||
});
|
||||
|
||||
// Deterministic work-count assertions, not a wall-clock benchmark.
|
||||
test("HostTreeView scans manual group order only once across expanded leaf groups", () => {
|
||||
const groupTree: GroupNode[] = ["c", "b", "a"].map((path) => ({
|
||||
name: path, path, children: {}, hosts: [],
|
||||
}));
|
||||
const groupConfigs: GroupConfig[] = groupTree.map((node, order) => ({ path: node.path, order }));
|
||||
let configVisits = 0;
|
||||
Object.defineProperty(groupConfigs, "filter", {
|
||||
value: (predicate: (value: GroupConfig, index: number, array: GroupConfig[]) => unknown) =>
|
||||
Array.prototype.filter.call(groupConfigs, (value: GroupConfig, index: number, array: GroupConfig[]) => {
|
||||
configVisits += 1;
|
||||
return predicate(value, index, array);
|
||||
}),
|
||||
});
|
||||
for (const expandedPaths of [new Set<string>(), new Set(["a", "b", "c"])]) {
|
||||
configVisits = 0;
|
||||
const manual = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode: "manual" });
|
||||
assert.equal(configVisits, 3);
|
||||
assert.deepEqual(manual.map((item) => item.key), ["group:c", "group:b", "group:a"]);
|
||||
for (const sortMode of ["az", "za", "newest", "oldest", "group", "ip"] as const) {
|
||||
configVisits = 0;
|
||||
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode });
|
||||
assert.equal(configVisits, 0, `${sortMode} must not construct a manual order index`);
|
||||
assert.deepEqual(items.map((item) => item.key), sortMode === "za"
|
||||
? ["group:c", "group:b", "group:a"]
|
||||
: ["group:a", "group:b", "group:c"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("HostTreeView shared order index preserves valid duplicates, ties and nested sibling metadata", () => {
|
||||
const leaf = (path: string): GroupNode => ({ name: path.split("/").at(-1)!, path, children: {}, hosts: [] });
|
||||
const parent: GroupNode = {
|
||||
...leaf("parent"),
|
||||
children: { z: leaf("parent/z"), a: leaf("parent/a"), b: leaf("parent/b") },
|
||||
hosts: [{ ...baseHost, id: "child-host", group: "parent" }],
|
||||
};
|
||||
const groupTree = [leaf("unordered-z"), leaf("tie-b"), parent, leaf("tie-a"), leaf("unordered-a")];
|
||||
const groupConfigs: GroupConfig[] = [
|
||||
{ path: "parent", order: 10 }, { path: "parent", order: -1 },
|
||||
{ path: "parent", order: Number.NaN }, { path: "parent", order: Infinity },
|
||||
{ path: "tie-b", order: 0 }, { path: "tie-a", order: 0 },
|
||||
{ path: "unordered-a", order: Number.NaN },
|
||||
{ path: "parent/a", order: 0 }, { path: "parent/b", order: 0 },
|
||||
{ path: "parent/z", order: -2 },
|
||||
];
|
||||
const ungroupedHosts = [{ ...baseHost, id: "root-host", group: "" }];
|
||||
const expandedPaths = new Set(["parent", "parent/z", "parent/a", "parent/b"]);
|
||||
const inputBefore = structuredClone({ groupTree, groupConfigs, ungroupedHosts, expandedPaths });
|
||||
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, ungroupedHosts, expandedPaths, sortMode: "manual" });
|
||||
assert.deepEqual(items.map(({ key, depth, posInSet, setSize }) => ({ key, depth, posInSet, setSize })), [
|
||||
{ key: "group:parent", depth: 0, posInSet: 1, setSize: 6 },
|
||||
{ key: "group:parent/z", depth: 1, posInSet: 1, setSize: 4 },
|
||||
{ key: "group:parent/b", depth: 1, posInSet: 2, setSize: 4 },
|
||||
{ key: "group:parent/a", depth: 1, posInSet: 3, setSize: 4 },
|
||||
{ key: "host:child-host", depth: 1, posInSet: 4, setSize: 4 },
|
||||
{ key: "group:tie-a", depth: 0, posInSet: 2, setSize: 6 },
|
||||
{ key: "group:tie-b", depth: 0, posInSet: 3, setSize: 6 },
|
||||
{ key: "group:unordered-z", depth: 0, posInSet: 4, setSize: 6 },
|
||||
{ key: "group:unordered-a", depth: 0, posInSet: 5, setSize: 6 },
|
||||
{ key: "host:root-host", depth: 0, posInSet: 6, setSize: 6 },
|
||||
]);
|
||||
assert.equal(items[0].kind === "group" && items[0].node, parent);
|
||||
assert.equal(items[4].kind === "host" && items[4].host, parent.hosts[0]);
|
||||
assert.deepEqual({ groupTree, groupConfigs, ungroupedHosts, expandedPaths }, inputBefore);
|
||||
const changed = buildVisibleHostTreeItems({
|
||||
groupTree, groupConfigs: [...groupConfigs, { path: "tie-a", order: -10 }],
|
||||
ungroupedHosts, expandedPaths, sortMode: "manual",
|
||||
});
|
||||
assert.equal(changed[0].key, "group:tie-a");
|
||||
});
|
||||
@@ -64,25 +64,22 @@ export type VisibleHostTreeItem =
|
||||
setSize: number;
|
||||
};
|
||||
|
||||
const buildHostTreeGroupOrder = (groupConfigs: GroupConfig[]): Map<string, number> => (
|
||||
new Map(
|
||||
groupConfigs
|
||||
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
|
||||
.map((config) => [config.path, config.order as number]),
|
||||
)
|
||||
);
|
||||
|
||||
const sortHostTreeGroups = (
|
||||
nodes: GroupNode[],
|
||||
sortMode: HostTreeSortMode,
|
||||
orderByPath?: ReadonlyMap<string, number>,
|
||||
groupConfigs: GroupConfig[],
|
||||
): GroupNode[] => {
|
||||
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
|
||||
const orderByPath = new Map(
|
||||
groupConfigs
|
||||
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
|
||||
.map((config) => [config.path, config.order as number]),
|
||||
);
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (sortMode === 'za') return b.name.localeCompare(a.name);
|
||||
if (sortMode === 'manual') {
|
||||
const orderA = orderByPath?.get(a.path);
|
||||
const orderB = orderByPath?.get(b.path);
|
||||
const orderA = orderByPath.get(a.path);
|
||||
const orderB = orderByPath.get(b.path);
|
||||
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
|
||||
if (orderA !== undefined) return -1;
|
||||
if (orderB !== undefined) return 1;
|
||||
@@ -118,9 +115,8 @@ export function buildVisibleHostTreeItems({
|
||||
groupConfigs: GroupConfig[];
|
||||
}): VisibleHostTreeItem[] {
|
||||
const items: VisibleHostTreeItem[] = [];
|
||||
const orderByPath = sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined;
|
||||
const visitGroups = (nodes: GroupNode[], depth: number) => {
|
||||
const sortedGroups = sortHostTreeGroups(nodes, sortMode, orderByPath);
|
||||
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
|
||||
// Root-level ungrouped hosts are siblings of top-level groups.
|
||||
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
|
||||
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
|
||||
@@ -137,7 +133,7 @@ export function buildVisibleHostTreeItems({
|
||||
const childGroups = sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
orderByPath,
|
||||
groupConfigs,
|
||||
);
|
||||
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
|
||||
const childSetSize = childGroups.length + childHosts.length;
|
||||
@@ -186,7 +182,7 @@ export function buildVisibleHostTreeItems({
|
||||
const childGroups = sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
orderByPath,
|
||||
groupConfigs,
|
||||
);
|
||||
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
|
||||
const childSetSize = childGroups.length + childHosts.length;
|
||||
@@ -443,7 +439,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const childNodes = useMemo(() => sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined,
|
||||
groupConfigs,
|
||||
), [groupConfigs, node.children, sortMode]);
|
||||
const sortedHosts = useMemo(
|
||||
() => sortHostTreeHosts(node.hosts, sortMode),
|
||||
|
||||
@@ -270,7 +270,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
groupConfigs,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
} = useVaultState();
|
||||
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ import {
|
||||
} from "../application/state/terminalAutoReconnect";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
@@ -907,7 +906,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [isSessionLogging, setIsSessionLogging] = useState(false);
|
||||
const [canOpenSessionLog, setCanOpenSessionLog] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(CONNECTION_PROGRESS_START);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
@@ -4129,15 +4127,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
||||
});
|
||||
|
||||
const handleSessionLogAction = useCallback(async (action: SessionLogAction) => {
|
||||
try {
|
||||
await performSessionLogAction(sessionRef.current ?? sessionId, action);
|
||||
} catch (err) {
|
||||
logger.error("[Terminal] Failed to open or reveal session log:", err);
|
||||
toast.error(t('terminal.toolbar.openSessionLogFailed'));
|
||||
}
|
||||
}, [sessionId, t]);
|
||||
|
||||
const handleToggleSessionLog = useCallback(async () => {
|
||||
const currentSessionId = sessionRef.current ?? sessionId;
|
||||
if (!currentSessionId) {
|
||||
@@ -4187,7 +4176,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (startResult?.success) {
|
||||
if (!startResult?.started && startResult?.canceled) return;
|
||||
setIsSessionLogging(!!startResult?.started);
|
||||
if (startResult?.started) setCanOpenSessionLog(true);
|
||||
} else {
|
||||
toast.error(startResult?.error || "Failed to start session log");
|
||||
}
|
||||
@@ -4212,21 +4200,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const currentSessionId = sessionRef.current ?? sessionId;
|
||||
if (!currentSessionId) {
|
||||
setIsSessionLogging(false);
|
||||
setCanOpenSessionLog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void getManualSessionLogStatus({ sessionId: currentSessionId }).then((result) => {
|
||||
if (!cancelled) {
|
||||
setIsSessionLogging(!!result?.isLogging);
|
||||
setCanOpenSessionLog(!!result?.hasSessionLog);
|
||||
}
|
||||
if (!cancelled) setIsSessionLogging(!!result?.isLogging);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setIsSessionLogging(false);
|
||||
setCanOpenSessionLog(false);
|
||||
}
|
||||
if (!cancelled) setIsSessionLogging(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -4302,8 +4283,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSearchOpen={isSearchOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
showLogButton
|
||||
onSessionLogAction={handleSessionLogAction}
|
||||
canOpenSessionLog={canOpenSessionLog}
|
||||
onToggleSessionLog={handleToggleSessionLog}
|
||||
isSessionLogging={isSessionLogging}
|
||||
isSessionLogDisabled={status !== "connected" && !isSessionLogging}
|
||||
@@ -4336,8 +4315,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
handleToggleSessionLog,
|
||||
handleSessionLogAction,
|
||||
canOpenSessionLog,
|
||||
handleToggleSearch,
|
||||
handleToolbarRecordingToggle,
|
||||
host,
|
||||
|
||||
@@ -286,7 +286,7 @@ function TerminalPopupPageInner({
|
||||
groupConfigs,
|
||||
updateKnownHosts,
|
||||
deleteSelectedSnippets,
|
||||
} = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
} = useVaultState();
|
||||
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
|
||||
const [terminalReady, setTerminalReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
|
||||
@@ -148,7 +148,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
|
||||
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
|
||||
// independent from (and typically empty compared to) the main App's — it's
|
||||
// used here only for its storage-sync side effects, never for closeSession.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { AsidePanel, invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
import { invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
|
||||
test("AsideActionMenuItem closes its menu before running the action", () => {
|
||||
const events: string[] = [];
|
||||
@@ -23,12 +21,3 @@ test("AsideActionMenuItem still closes when no action is provided", () => {
|
||||
|
||||
assert.deepEqual(events, ["close"]);
|
||||
});
|
||||
|
||||
test("overlay aside panels ask native views to yield; inline panels do not", () => {
|
||||
const render = (layout: "overlay" | "inline") => renderToStaticMarkup(
|
||||
React.createElement(AsidePanel, { open: true, onClose: () => {}, layout, children: "content" }),
|
||||
);
|
||||
|
||||
assert.match(render("overlay"), /data-native-view-occluder=""/);
|
||||
assert.doesNotMatch(render("inline"), /data-native-view-occluder/);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../../types.ts";
|
||||
import { TERMINAL_TOOLBAR_LAYOUT_DEFAULTS, TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
|
||||
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -213,26 +213,3 @@ for (const host of [serialHost, pluginHost]) {
|
||||
assert.equal(renderToolbar(host).includes('aria-label="Open SFTP"'), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('open session log stays in the overflow menu until logging is active', () => {
|
||||
assert.equal(TERMINAL_TOOLBAR_LAYOUT_DEFAULTS.placement.openSessionLog, 'collapse');
|
||||
const hidden = renderToolbar(sshHost, 'disconnected', { onSessionLogAction: () => {} });
|
||||
assert.doesNotMatch(hidden, /aria-label="Open session log"/);
|
||||
assert.doesNotMatch(hidden, /Open session log/);
|
||||
|
||||
for (const status of ['connected', 'disconnected'] as const) {
|
||||
const markup = renderToolbar(sshHost, status, {
|
||||
onSessionLogAction: () => {},
|
||||
canOpenSessionLog: true,
|
||||
});
|
||||
assert.doesNotMatch(markup, /aria-label="Open session log"/);
|
||||
assert.doesNotMatch(markup, /aria-label="Session log actions"/);
|
||||
assert.match(markup, /aria-label="More actions"/);
|
||||
}
|
||||
assert.match(toolbarSource, /case 'openSessionLog':/);
|
||||
assert.match(toolbarSource, /data-session-log-submenu="true"/);
|
||||
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.menu/);
|
||||
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.\$\{action\}/);
|
||||
assert.equal(toolbarSource.includes('rememberApplication'), false);
|
||||
assert.equal(toolbarSource.includes('Open session log'), false);
|
||||
});
|
||||
@@ -6,8 +6,6 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FileSearch,
|
||||
Circle,
|
||||
Download,
|
||||
FileText,
|
||||
@@ -27,7 +25,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { SessionLogAction } from '../../application/state/sessionLogActions';
|
||||
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
|
||||
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
|
||||
import { STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys';
|
||||
@@ -54,7 +51,6 @@ export const TERMINAL_TOOLBAR_ITEM_IDS = [
|
||||
'compose',
|
||||
'search',
|
||||
'sessionLog',
|
||||
'openSessionLog',
|
||||
'scripts',
|
||||
'history',
|
||||
'configureOsc7',
|
||||
@@ -77,7 +73,6 @@ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
compose: 'show',
|
||||
search: 'show',
|
||||
sessionLog: 'show',
|
||||
openSessionLog: 'collapse',
|
||||
scripts: 'show',
|
||||
history: 'collapse',
|
||||
configureOsc7: 'collapse',
|
||||
@@ -119,9 +114,6 @@ export interface TerminalToolbarProps {
|
||||
// Manual session log
|
||||
showLogButton?: boolean;
|
||||
onToggleSessionLog?: () => void;
|
||||
onSessionLogAction?: (action: SessionLogAction) => void;
|
||||
/** Automatic logging is enabled, or this session has started a manual log. */
|
||||
canOpenSessionLog?: boolean;
|
||||
isSessionLogging?: boolean;
|
||||
isSessionLogDisabled?: boolean;
|
||||
// Compose bar
|
||||
@@ -160,8 +152,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onToggleSearch,
|
||||
showLogButton = false,
|
||||
onToggleSessionLog,
|
||||
onSessionLogAction,
|
||||
canOpenSessionLog = false,
|
||||
isSessionLogging = false,
|
||||
isSessionLogDisabled = false,
|
||||
isComposeBarOpen,
|
||||
@@ -206,13 +196,12 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const pendingScriptDeleteCount = (pendingScriptDeleteIds ?? []).filter((id) =>
|
||||
snippets.some((snippet) => snippet.id === id),
|
||||
).length;
|
||||
// Overflow popover and its submenus are controlled so that picking an
|
||||
// item closes the whole chain, and so the parent popover can ignore
|
||||
// clicks that land in a submenu portal (otherwise the submenu click
|
||||
// would read as "outside" and dismiss the parent).
|
||||
// Overflow popover + encoding submenu are both controlled so that
|
||||
// picking an encoding closes the whole chain, and so the parent popover
|
||||
// can ignore clicks that land in the submenu portal (otherwise the
|
||||
// submenu click would read as "outside" and dismiss the parent).
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false);
|
||||
const [sessionLogSubmenuOpen, setSessionLogSubmenuOpen] = useState(false);
|
||||
const buttonBase =
|
||||
'h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent';
|
||||
|
||||
@@ -259,7 +248,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
ids.push('ymodemSend', 'ymodemReceive');
|
||||
}
|
||||
if (showLogButton) ids.push('sessionLog');
|
||||
if (onSessionLogAction && canOpenSessionLog) ids.push('openSessionLog');
|
||||
if (historySupported) ids.push('history');
|
||||
if (onConfigureOsc7 && !hidesSftp) ids.push('configureOsc7');
|
||||
if (onStartRecording) ids.push('recording');
|
||||
@@ -276,8 +264,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onSetTerminalEncoding,
|
||||
onStartRecording,
|
||||
showLogButton,
|
||||
onSessionLogAction,
|
||||
canOpenSessionLog,
|
||||
]);
|
||||
|
||||
const itemLabels = useMemo(
|
||||
@@ -292,7 +278,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
sessionLog: isSessionLogging
|
||||
? t('terminal.toolbar.stopSessionLog')
|
||||
: t('terminal.toolbar.startSessionLog'),
|
||||
openSessionLog: t('terminal.toolbar.sessionLog.menu'),
|
||||
scripts: t('terminal.toolbar.scripts'),
|
||||
history: t('terminal.toolbar.history'),
|
||||
configureOsc7: t('terminal.toolbar.configureOsc7'),
|
||||
@@ -313,7 +298,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
compose: <TextCursorInput size={14} />,
|
||||
search: <Search size={14} />,
|
||||
sessionLog: <FileText size={14} />,
|
||||
openSessionLog: <FileSearch size={14} />,
|
||||
scripts: <Zap size={14} />,
|
||||
history: <History size={14} />,
|
||||
configureOsc7: <FolderSync size={14} />,
|
||||
@@ -453,52 +437,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const renderSessionLogSubmenu = () => {
|
||||
if (!onSessionLogAction) return null;
|
||||
return (
|
||||
<Popover open={sessionLogSubmenuOpen} onOpenChange={setSessionLogSubmenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={sessionLogSubmenuOpen}
|
||||
>
|
||||
<FileSearch size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t('terminal.toolbar.sessionLog.menu')}</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-session-log-submenu="true"
|
||||
data-section="terminal-toolbar-menu"
|
||||
className="w-56 p-1"
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
{(['open', 'reveal', 'chooseApplication', 'systemDefault'] as const).map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onSessionLogAction(action);
|
||||
setSessionLogSubmenuOpen(false);
|
||||
setOverflowOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileSearch size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{t(`terminal.toolbar.sessionLog.${action}`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEncodingSubmenu = () => {
|
||||
if (!encodingSwitchSupported || !onSetTerminalEncoding) return null;
|
||||
return (
|
||||
@@ -693,38 +631,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<TooltipContent side="bottom">{t('terminal.toolbar.searchTerminal')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'openSessionLog':
|
||||
return (
|
||||
<div key={id} className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="secondary" size="icon" className={buttonBase}
|
||||
aria-label={t('terminal.toolbar.openSessionLog')}
|
||||
onClick={() => onSessionLogAction?.('open')}>
|
||||
<FileSearch size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('terminal.toolbar.openSessionLog')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="secondary" size="icon" className={cn(buttonBase, 'w-4')}
|
||||
aria-label={t('terminal.toolbar.sessionLogActions')}>
|
||||
<ChevronDown size={10} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-1">
|
||||
{(['reveal', 'chooseApplication', 'systemDefault'] as const).map(action => (
|
||||
<PopoverClose asChild key={action}>
|
||||
<button type="button" className={menuItemClass} onClick={() => onSessionLogAction?.(action)}>
|
||||
{t(`terminal.toolbar.sessionLog.${action}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
case 'sessionLog':
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
@@ -997,8 +903,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</button>
|
||||
</PopoverClose>
|
||||
);
|
||||
case 'openSessionLog':
|
||||
return <React.Fragment key={id}>{renderSessionLogSubmenu()}</React.Fragment>;
|
||||
case 'sessionLog':
|
||||
return (
|
||||
<PopoverClose asChild key={id}>
|
||||
@@ -1116,10 +1020,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
open={overflowOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOverflowOpen(open);
|
||||
if (!open) {
|
||||
setEncodingSubmenuOpen(false);
|
||||
setSessionLogSubmenuOpen(false);
|
||||
}
|
||||
if (!open) setEncodingSubmenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
@@ -1147,7 +1048,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
// "outside" this popover; without this guard a click
|
||||
// in the submenu would dismiss the parent.
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest('[data-encoding-submenu="true"], [data-session-log-submenu="true"]')) {
|
||||
if (target?.closest('[data-encoding-submenu="true"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -475,10 +475,6 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// mark / trailing surrogate in the next) while xterm's grapheme provider
|
||||
// joins them across writes, so the next span must be able to rejoin it.
|
||||
let openGrapheme: string | null = null;
|
||||
// When set, `openGrapheme` still holds the whole non-ASCII piece the last
|
||||
// append wrote; its last grapheme is only segmented out when a following
|
||||
// span actually tries to rejoin it (rare), not on every append.
|
||||
let openGraphemeUnresolved = false;
|
||||
let openGraphemeCell = 0;
|
||||
let cacheDirty = true;
|
||||
let cacheCols = -1;
|
||||
@@ -555,10 +551,6 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
&& cursor === current.length
|
||||
&& cursorCell === openGraphemeCell
|
||||
) {
|
||||
if (openGraphemeUnresolved) {
|
||||
openGrapheme = lastGraphemeUnits(openGrapheme);
|
||||
openGraphemeUnresolved = false;
|
||||
}
|
||||
const probeEnd = Math.min(span.length, WRAP_PROBE_UTF16_UNITS);
|
||||
const head = openGrapheme + span.slice(0, probeEnd);
|
||||
let first = firstGraphemeUnits(head);
|
||||
@@ -711,9 +703,7 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// clusters and corrupt the transcript, while a boundary-aligned cut
|
||||
// keeps every piece's segmentation identical to the span's.
|
||||
let pieceEnd = offset + (maxChars - current.length);
|
||||
// A remainder no longer than the cap needs no boundary search: the
|
||||
// segment walk below would consume all of it and cut at its end.
|
||||
if (pieceEnd - offset > pieceCap && span.length - offset > pieceCap) {
|
||||
if (pieceEnd - offset > pieceCap) {
|
||||
const window = span.slice(offset, offset + pieceCap + WRAP_PROBE_UTF16_UNITS);
|
||||
let cutLength = 0;
|
||||
let end = window.length;
|
||||
@@ -804,8 +794,7 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// continuing it (a split cluster) can be rejoined (see writeSpan).
|
||||
// An empty open grapheme would corrupt the join, so track null then.
|
||||
openGrapheme = !fitting ? null
|
||||
: pieceAscii ? fitting.slice(-1) : fitting;
|
||||
openGraphemeUnresolved = !!fitting && !pieceAscii;
|
||||
: isAsciiOnly(fitting) ? fitting.slice(-1) : lastGraphemeUnits(fitting);
|
||||
openGraphemeCell = cursorCell;
|
||||
}
|
||||
cursor = appending ? current.length
|
||||
|
||||
@@ -330,8 +330,7 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
className
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}
|
||||
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
|
||||
data-section={dataSection}>
|
||||
<AsidePanelHeader
|
||||
title={currentItem.title}
|
||||
subtitle={currentItem.subtitle}
|
||||
@@ -430,10 +429,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
className,
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-section={dataSection}
|
||||
// Overlay panels float above content; native views (host browser)
|
||||
// must yield where they overlap instead of drawing over the panel.
|
||||
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
|
||||
data-section={dataSection}>
|
||||
{resizable ? (
|
||||
<div
|
||||
role="separator"
|
||||
|
||||
|
Before Width: | Height: | Size: 985 KiB |
|
Before Width: | Height: | Size: 959 KiB |
|
Before Width: | Height: | Size: 931 KiB |
|
Before Width: | Height: | Size: 794 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 757 KiB |
|
Before Width: | Height: | Size: 892 KiB |
@@ -1,27 +0,0 @@
|
||||
# Saved session logs
|
||||
|
||||
Recording and opening a session log stay separate.
|
||||
|
||||
- **Start / Stop session log** controls manual recording.
|
||||
- **Session log** lives in the terminal toolbar's existing **More actions**
|
||||
menu, as one submenu with **Open**, **Show in folder**, **Open with…**,
|
||||
and **Open with system default**. It is not a default toolbar button.
|
||||
Toolbar customization can show the shortcut on the bar or hide it. When it
|
||||
is shown, the arrow beside it offers the same reveal and open-with actions.
|
||||
|
||||
The submenu appears when this session has a saved log, and it stays available
|
||||
after recording stops. A successful reconnect drops a stopped log from the
|
||||
old connection, and the submenu goes with it. A failed reconnect keeps it.
|
||||
Pending terminal output is flushed before opening; recording continues
|
||||
normally. Automatic logs and manually recorded logs both open this way.
|
||||
|
||||
A successfully selected application is remembered locally for subsequent log
|
||||
opens, independently of SFTP file associations. Choosing the system default
|
||||
clears this preference. Text, raw, and HTML logs retain their original format.
|
||||
|
||||
The last log remains accessible after recording stops or a session disconnects.
|
||||
After a successful reconnect, a stopped log from the old connection is not reused.
|
||||
If reconnecting fails, the previous saved log remains available.
|
||||
Manual recording that is still active remains the current log. If the current session has no saved log,
|
||||
or the file was moved or deleted, the action reports an error rather than
|
||||
opening an unrelated file. The action does not enable logging automatically.
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
pruneTerminalDataMapForStorage,
|
||||
writeTerminalDataMapWithQuotaRetry,
|
||||
} from "./connectionLogTerminalData.ts";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
@@ -151,44 +149,6 @@ test("pruneTerminalDataMapForStorage caps unsaved replay buffers", () => {
|
||||
assert.equal(pruned["log-0"], undefined);
|
||||
});
|
||||
|
||||
test("pruneInMemoryUnsavedTerminalData drops oldest unsaved replay buffers beyond the persist cap", () => {
|
||||
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
saved: false,
|
||||
terminalData: `data-${index}`,
|
||||
}));
|
||||
// One bookmarked entry must keep its replay buffer even when unsaved count is high.
|
||||
logs.push({
|
||||
...baseLog,
|
||||
id: "saved-keep",
|
||||
startTime: 0,
|
||||
saved: true,
|
||||
terminalData: "saved-data",
|
||||
});
|
||||
|
||||
const pruned = pruneInMemoryUnsavedTerminalData(logs);
|
||||
const unsavedWithData = pruned.filter((log) => !log.saved && log.terminalData !== undefined);
|
||||
assert.equal(unsavedWithData.length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES);
|
||||
assert.equal(pruned.find((log) => log.id === "log-59")?.terminalData, "data-59");
|
||||
assert.equal(pruned.find((log) => log.id === "log-0")?.terminalData, undefined);
|
||||
assert.equal(pruned.find((log) => log.id === "log-0")?.id, "log-0"); // metadata kept
|
||||
assert.equal(pruned.find((log) => log.id === "saved-keep")?.terminalData, "saved-data");
|
||||
assert.equal(pruned.length, logs.length);
|
||||
});
|
||||
|
||||
test("pruneInMemoryUnsavedTerminalData is a no-op at or under the cap", () => {
|
||||
const logs: ConnectionLog[] = Array.from({ length: 10 }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
terminalData: `data-${index}`,
|
||||
}));
|
||||
const pruned = pruneInMemoryUnsavedTerminalData(logs);
|
||||
assert.equal(pruned, logs);
|
||||
});
|
||||
|
||||
test("mergeTerminalDataMapsForStorage keeps replay data from other windows", () => {
|
||||
const logs: ConnectionLog[] = [
|
||||
{ ...baseLog, id: "local", startTime: 2000 },
|
||||
@@ -233,132 +193,3 @@ test("mergeTerminalDataMapsForStorage drops deleted log replay buffers", () => {
|
||||
assert.equal(merged.local, "keep");
|
||||
assert.equal(merged.deleted, undefined);
|
||||
});
|
||||
|
||||
const quotaLogs = (count: number): ConnectionLog[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
saved: false,
|
||||
}));
|
||||
|
||||
const quotaWriter = (limitChars: number) => {
|
||||
const calls: number[] = [];
|
||||
const write = (next: Record<string, string>) => {
|
||||
const size = Object.values(next).reduce((sum, value) => sum + value.length, 0);
|
||||
calls.push(Object.keys(next).length);
|
||||
return size <= limitChars;
|
||||
};
|
||||
return { calls, write };
|
||||
};
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry writes once when it fits", () => {
|
||||
const logs = quotaLogs(5);
|
||||
const map = Object.fromEntries(logs.map((log) => [log.id, "x".repeat(10)]));
|
||||
const { calls, write } = quotaWriter(1000);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(result.written, map);
|
||||
assert.deepEqual(calls, [5]);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry drops the oldest unsaved entries until it fits", () => {
|
||||
const logs = quotaLogs(50);
|
||||
// Newest first, matching pruneTerminalDataMapForStorage order.
|
||||
const map = Object.fromEntries(
|
||||
[...logs].reverse().map((log) => [log.id, "x".repeat(10)]),
|
||||
);
|
||||
const { calls, write } = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, true);
|
||||
// 50 fails, drop 1 → 49 fails, drop 2 → 48 fails, drop 4 → 46 fits.
|
||||
assert.deepEqual(calls, [50, 49, 48, 46]);
|
||||
assert.equal(result.written["log-49"], map["log-49"]);
|
||||
assert.equal(result.written["log-4"], map["log-4"]);
|
||||
for (const id of ["log-0", "log-1", "log-2", "log-3"]) {
|
||||
assert.equal(result.written[id], undefined);
|
||||
}
|
||||
// Key order (newest first) is preserved.
|
||||
assert.deepEqual(Object.keys(result.written), Object.keys(map).filter((id) => id in result.written));
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry never drops saved or unknown entries and stops after all unsaved are gone", () => {
|
||||
const logs: ConnectionLog[] = [
|
||||
...quotaLogs(20),
|
||||
{ ...baseLog, id: "saved", startTime: -1, saved: true },
|
||||
];
|
||||
const map: Record<string, string> = Object.fromEntries(
|
||||
logs.map((log) => [log.id, "x".repeat(10)]),
|
||||
);
|
||||
map["other-window"] = "y".repeat(10);
|
||||
const { calls, write } = quotaWriter(5); // nothing fits
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, false);
|
||||
assert.equal(result.written, map);
|
||||
// 22, then drop 1, 2, 4, 8, 16, then all 20 unsaved: bounded attempts.
|
||||
assert.deepEqual(calls, [22, 21, 20, 18, 14, 6, 2]);
|
||||
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(20)));
|
||||
});
|
||||
|
||||
const newestFirstMap = (logs: ConnectionLog[], chars: number) =>
|
||||
Object.fromEntries([...logs].reverse().map((log) => [log.id, "x".repeat(chars)]));
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry starts the next write from the newest set that last fit", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const quota = quotaWriter(10 * 47);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(logs, newestFirstMap(logs, 10), quota.write, null);
|
||||
// Next close: oldest drops out of the 50, a newer session arrives.
|
||||
const nextLogs = [...logs.slice(1), { ...baseLog, id: "log-50", startTime: 50 }];
|
||||
const second = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
nextLogs,
|
||||
newestFirstMap(nextLogs, 10),
|
||||
second.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.deepEqual(second.calls, [47]);
|
||||
assert.equal(result.written["log-50"] !== undefined, true);
|
||||
assert.equal(result.written["log-3"], undefined);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry grows back when entries get smaller", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
quotaWriter(10 * 47).write,
|
||||
null,
|
||||
);
|
||||
const small = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 5),
|
||||
small.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.deepEqual(small.calls, [50]);
|
||||
assert.equal(Object.keys(result.written).length, 50);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry falls back to dropping when the hint is too optimistic", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
quotaWriter(10 * 47).write,
|
||||
null,
|
||||
);
|
||||
const tighter = quotaWriter(10 * 30); // quota shrank (other keys grew)
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
tighter.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(tighter.calls[0], 47);
|
||||
assert.ok(Object.keys(result.written).length <= 30);
|
||||
assert.equal(result.written["log-49"] !== undefined, true);
|
||||
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(50)));
|
||||
});
|
||||
@@ -88,90 +88,6 @@ export const pruneTerminalDataMapForStorage = (
|
||||
return next;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Drop in-memory `terminalData` from the oldest unsaved logs beyond the
|
||||
* persisted-entry cap. List metadata stays; only replay buffers are removed.
|
||||
* Matches what survives a restart (`pruneTerminalDataMapForStorage`).
|
||||
*/
|
||||
export const pruneInMemoryUnsavedTerminalData = (
|
||||
logs: ConnectionLog[],
|
||||
maxEntries: number = MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
|
||||
): ConnectionLog[] => {
|
||||
const unsavedWithData = logs
|
||||
.filter((log) => !log.saved && log.terminalData !== undefined)
|
||||
.sort((a, b) => b.startTime - a.startTime);
|
||||
if (unsavedWithData.length <= maxEntries) return logs;
|
||||
|
||||
const keep = new Set(unsavedWithData.slice(0, maxEntries).map((log) => log.id));
|
||||
let changed = false;
|
||||
const next = logs.map((log) => {
|
||||
if (log.saved || log.terminalData === undefined || keep.has(log.id)) return log;
|
||||
changed = true;
|
||||
const { terminalData: _omitted, ...rest } = log;
|
||||
return rest;
|
||||
});
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the side store; if the write is rejected (localStorage quota), retry
|
||||
* without the oldest unsaved replay buffers so the newest sessions are kept.
|
||||
* Drops 1, 2, 4, … oldest entries per retry, so attempts stay O(log n).
|
||||
* Saved logs and ids not in `logs` are never dropped.
|
||||
*
|
||||
* `quotaFailedChars` is owned by the caller. It is the summed entry length of
|
||||
* the smallest payload that last hit the quota, so the next write can start
|
||||
* from the newest set that fits. A successful write of the full map returns
|
||||
* null. The helper does not keep that hint itself.
|
||||
*/
|
||||
export const writeTerminalDataMapWithQuotaRetry = (
|
||||
logs: ConnectionLog[],
|
||||
map: ConnectionLogTerminalDataMap,
|
||||
write: (next: ConnectionLogTerminalDataMap) => boolean,
|
||||
quotaFailedChars: number | null,
|
||||
): { persisted: boolean; written: ConnectionLogTerminalDataMap; attempts: number; quotaFailedChars: number | null } => {
|
||||
const droppable = logs
|
||||
.filter((log) => !log.saved && map[log.id] !== undefined)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
.map((log) => log.id);
|
||||
let chars = 0;
|
||||
for (const data of Object.values(map)) chars += data.length;
|
||||
|
||||
let hint = quotaFailedChars;
|
||||
let dropCount = 0;
|
||||
if (hint !== null) {
|
||||
while (dropCount < droppable.length && chars >= hint) {
|
||||
chars -= map[droppable[dropCount]].length;
|
||||
dropCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
for (;;) {
|
||||
let next = map;
|
||||
if (dropCount > 0) {
|
||||
const dropped = new Set(droppable.slice(0, dropCount));
|
||||
next = {};
|
||||
for (const [id, data] of Object.entries(map)) {
|
||||
if (!dropped.has(id)) next[id] = data;
|
||||
}
|
||||
}
|
||||
attempts += 1;
|
||||
if (write(next)) {
|
||||
if (dropCount === 0) hint = null;
|
||||
return { persisted: true, written: next, attempts, quotaFailedChars: hint };
|
||||
}
|
||||
hint = hint === null ? chars : Math.min(hint, chars);
|
||||
if (dropCount >= droppable.length) {
|
||||
return { persisted: false, written: map, attempts, quotaFailedChars: hint };
|
||||
}
|
||||
const target = Math.min(Math.max(dropCount * 2, 1), droppable.length);
|
||||
for (let i = dropCount; i < target; i += 1) chars -= map[droppable[i]].length;
|
||||
dropCount = target;
|
||||
}
|
||||
};
|
||||
|
||||
/** Fold side-store maps together, then cap to the allowed unsaved/saved set. */
|
||||
export const mergeTerminalDataMapsForStorage = (
|
||||
logs: ConnectionLog[],
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { HOST_BROWSER_MIN_LIVE_EXTENT, resolveHostBrowserPaneBesidePanels } from './hostBrowser';
|
||||
|
||||
const pane = { x: 200, y: 100, width: 500, height: 600 };
|
||||
|
||||
test('panels beside the pane leave it unchanged, even when the pane itself is small', () => {
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 700, y: 0, width: 300, height: 900 }]), pane);
|
||||
const small = { x: 0, y: 0, width: HOST_BROWSER_MIN_LIVE_EXTENT - 1, height: 80 };
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(small, []), small);
|
||||
});
|
||||
|
||||
test('full-height or full-width edge panels shrink the pane to the remaining rectangle', () => {
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 450, y: 0, width: 600, height: 900 }]),
|
||||
{ x: 200, y: 100, width: 250, height: 600 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 0, width: 300, height: 900 }]),
|
||||
{ x: 300, y: 100, width: 400, height: 600 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 500, width: 900, height: 400 }]),
|
||||
{ x: 200, y: 100, width: 500, height: 400 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [
|
||||
{ x: 0, y: 0, width: 300, height: 900 },
|
||||
{ x: 550, y: 0, width: 400, height: 900 },
|
||||
]), { x: 300, y: 100, width: 250, height: 600 });
|
||||
});
|
||||
|
||||
test('panels over the middle, a corner, or leaving too little room cannot keep a live page', () => {
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 300, y: 0, width: 100, height: 900 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 600, y: 0, width: 300, height: 300 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 0, width: 900, height: 900 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 250, y: 0, width: 600, height: 900 }]), null);
|
||||
});
|
||||
@@ -63,39 +63,3 @@ export type HostBrowserPanelEvent = { browserId: string } & (
|
||||
| { type: 'bookmarks' }
|
||||
| { type: 'shortcut'; shortcut: HostBrowserShortcut }
|
||||
);
|
||||
|
||||
export interface HostBrowserPaneRect { x: number; y: number; width: number; height: number }
|
||||
|
||||
/** Below this extent a page beside an overlay panel is too cramped to keep live. */
|
||||
export const HOST_BROWSER_MIN_LIVE_EXTENT = 160;
|
||||
|
||||
/**
|
||||
* Native browser views draw above the renderer and cannot be clipped, so a
|
||||
* panel covering a full-height or full-width edge strip of the pane is avoided
|
||||
* by shrinking the page to the remaining rectangle. Returns null when a panel
|
||||
* covers the middle or leaves too little room; the pane then shows a picture.
|
||||
*/
|
||||
export function resolveHostBrowserPaneBesidePanels(
|
||||
pane: HostBrowserPaneRect,
|
||||
panels: readonly HostBrowserPaneRect[],
|
||||
): HostBrowserPaneRect | null {
|
||||
let left = pane.x;
|
||||
let top = pane.y;
|
||||
let right = pane.x + pane.width;
|
||||
let bottom = pane.y + pane.height;
|
||||
for (const panel of panels) {
|
||||
const panelRight = panel.x + panel.width;
|
||||
const panelBottom = panel.y + panel.height;
|
||||
if (panel.x >= right || panelRight <= left || panel.y >= bottom || panelBottom <= top) continue;
|
||||
const spansHeight = panel.y <= top && panelBottom >= bottom;
|
||||
const spansWidth = panel.x <= left && panelRight >= right;
|
||||
if (spansHeight && panel.x > left && panelRight >= right) right = panel.x;
|
||||
else if (spansHeight && panel.x <= left && panelRight < right) left = panelRight;
|
||||
else if (spansWidth && panel.y > top && panelBottom >= bottom) bottom = panel.y;
|
||||
else if (spansWidth && panel.y <= top && panelBottom < bottom) top = panelBottom;
|
||||
else return null;
|
||||
}
|
||||
if (left === pane.x && top === pane.y && right === pane.x + pane.width && bottom === pane.y + pane.height) return pane;
|
||||
if (right - left < HOST_BROWSER_MIN_LIVE_EXTENT || bottom - top < HOST_BROWSER_MIN_LIVE_EXTENT) return null;
|
||||
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||
}
|
||||
@@ -14,9 +14,8 @@ export {
|
||||
buildVaultHostMergeKey,
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { canServeAsSshJumpHost } from "./terminalProtocol";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase, decodeCsvProxyJump } from "./vaultImport/csvCredentialFields";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
@@ -101,29 +100,6 @@ const parseProxyJump = (value: string): ParsedJumpHost[] => {
|
||||
.filter((h): h is ParsedJumpHost => h !== null);
|
||||
};
|
||||
|
||||
/** Vault host fields needed to bind a CSV ProxyJump hop without copying secrets. */
|
||||
export interface VaultImportExistingHostRef {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
protocol?: Host["protocol"];
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export const toVaultImportExistingHostRef = (
|
||||
host: Pick<Host, "id" | "label" | "hostname" | "port" | "username" | "protocol" | "group">,
|
||||
): VaultImportExistingHostRef => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
protocol: host.protocol,
|
||||
group: host.group,
|
||||
});
|
||||
|
||||
export type VaultImportFormat =
|
||||
| "putty"
|
||||
| "mobaxterm"
|
||||
@@ -278,32 +254,9 @@ export function applyVaultImportDestination(
|
||||
return remapped;
|
||||
};
|
||||
|
||||
// Collapsing two group copies must not leave a chain pointed at the dropped id.
|
||||
// Ids that are not part of this import (an existing vault hop) stay as they are.
|
||||
const remappedHosts = hosts.map((host) => {
|
||||
const currentIds = host.hostChain?.hostIds;
|
||||
if (!currentIds?.length) return host;
|
||||
const hostIds: string[] = [];
|
||||
for (const hostId of currentIds) {
|
||||
const mapped = remapHostId(hostId);
|
||||
const nextId = mapped ?? (originalById.has(hostId) ? undefined : hostId);
|
||||
if (!nextId || nextId === host.id || hostIds.includes(nextId)) continue;
|
||||
hostIds.push(nextId);
|
||||
}
|
||||
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
|
||||
return host;
|
||||
}
|
||||
if (hostIds.length === 0) {
|
||||
const next = { ...host };
|
||||
delete next.hostChain;
|
||||
return next;
|
||||
}
|
||||
return { ...host, hostChain: { hostIds } };
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
hosts: remappedHosts,
|
||||
hosts,
|
||||
groups: [group],
|
||||
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
|
||||
// Keep remapped candidates for alias conflict checks (same as CSV same-group
|
||||
@@ -468,176 +421,8 @@ const parseTarget = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const CSV_JUMP_PORT = 22;
|
||||
|
||||
const csvJumpPort = (port: number | undefined): number => (
|
||||
port !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : CSV_JUMP_PORT
|
||||
);
|
||||
|
||||
type CsvJumpCandidate = {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
protocol?: Host["protocol"];
|
||||
group?: string;
|
||||
};
|
||||
|
||||
const csvJumpEndpointMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
|
||||
if (!canServeAsSshJumpHost(candidate)) return false;
|
||||
if (candidate.hostname.trim().toLowerCase() !== hop.hostname.toLowerCase()) return false;
|
||||
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
|
||||
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const csvJumpLabelMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
|
||||
if (!canServeAsSshJumpHost(candidate)) return false;
|
||||
const label = candidate.label.trim().toLowerCase();
|
||||
if (!label || label !== hop.hostname.toLowerCase()) return false;
|
||||
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
|
||||
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const pickCsvJumpCandidate = <T extends { group?: string }>(matches: readonly T[], group: string | undefined): T => (
|
||||
matches.find((candidate) => (candidate.group ?? "") === (group ?? "")) ?? matches[0]
|
||||
);
|
||||
|
||||
const attachCsvProxyJumpChains = (
|
||||
hosts: Host[],
|
||||
proxyJumpByHost: ReadonlyMap<Host, { value: string; row: number }>,
|
||||
issues: VaultImportIssue[],
|
||||
existingHosts: readonly VaultImportExistingHostRef[],
|
||||
): Host[] => {
|
||||
const inlineHosts: Host[] = [];
|
||||
const matchable: CsvJumpCandidate[] = [...hosts];
|
||||
|
||||
const resolveHop = (hop: ParsedJumpHost, group: string | undefined): string | null => {
|
||||
const endpointMatches = [
|
||||
...matchable.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
|
||||
...existingHosts.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
|
||||
];
|
||||
if (endpointMatches.length > 0) return pickCsvJumpCandidate(endpointMatches, group).id;
|
||||
const labelMatches = [
|
||||
...matchable.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
|
||||
...existingHosts.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
|
||||
];
|
||||
if (labelMatches.length > 0) return pickCsvJumpCandidate(labelMatches, group).id;
|
||||
|
||||
const inlineKey = `${hop.hostname.toLowerCase()}|${csvJumpPort(hop.port)}|${(hop.username ?? "").toLowerCase()}`;
|
||||
const existingInline = inlineHosts.find((host) => (
|
||||
`${host.hostname.toLowerCase()}|${csvJumpPort(host.port)}|${(host.username ?? "").toLowerCase()}` === inlineKey
|
||||
));
|
||||
if (existingInline) return existingInline.id;
|
||||
if (!looksLikeHostnameToken(hop.hostname)) return null;
|
||||
try {
|
||||
const inlineHost = createHost({
|
||||
label: hop.hostname,
|
||||
hostname: hop.hostname,
|
||||
username: hop.username,
|
||||
port: hop.port,
|
||||
protocol: "ssh",
|
||||
});
|
||||
inlineHosts.push(inlineHost);
|
||||
matchable.push(inlineHost);
|
||||
return inlineHost.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const host of hosts) {
|
||||
const entry = proxyJumpByHost.get(host);
|
||||
if (!entry) continue;
|
||||
const specs = entry.value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && part.toLowerCase() !== "none");
|
||||
if (specs.length === 0) continue;
|
||||
|
||||
const resolvedIds: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const inlineCountBefore = inlineHosts.length;
|
||||
let chainRejected = false;
|
||||
for (const spec of specs) {
|
||||
const jump = parseJumpHostSpec(spec);
|
||||
if (!jump) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
|
||||
});
|
||||
chainRejected = true;
|
||||
continue;
|
||||
}
|
||||
const inlineCount = inlineHosts.length;
|
||||
const resolvedId = resolveHop(jump, host.group);
|
||||
if (!resolvedId) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
|
||||
});
|
||||
chainRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (inlineHosts.length > inlineCount) unresolved.push(jump.hostname);
|
||||
if (!resolvedIds.includes(resolvedId)) resolvedIds.push(resolvedId);
|
||||
}
|
||||
|
||||
if (chainRejected || resolvedIds.includes(host.id)) {
|
||||
if (inlineHosts.length > inlineCountBefore) {
|
||||
const droppedIds = new Set(inlineHosts.splice(inlineCountBefore).map((item) => item.id));
|
||||
for (let index = matchable.length - 1; index >= 0; index -= 1) {
|
||||
if (droppedIds.has(matchable[index].id)) matchable.splice(index, 1);
|
||||
}
|
||||
}
|
||||
if (resolvedIds.includes(host.id)) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (resolvedIds.length > 0) host.hostChain = { hostIds: resolvedIds };
|
||||
if (unresolved.length > 0) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: created inline jump host(s) for "${host.label}": ${unresolved.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allHosts = [...hosts, ...inlineHosts];
|
||||
const participatesInCycle = (hostId: string, visited: Set<string>): boolean => {
|
||||
if (visited.has(hostId)) return true;
|
||||
visited.add(hostId);
|
||||
const current = allHosts.find((item) => item.id === hostId);
|
||||
for (const chainId of current?.hostChain?.hostIds ?? []) {
|
||||
if (!allHosts.some((item) => item.id === chainId)) continue;
|
||||
if (participatesInCycle(chainId, visited)) return true;
|
||||
}
|
||||
visited.delete(hostId);
|
||||
return false;
|
||||
};
|
||||
const cyclicHosts = allHosts.filter((host) => (
|
||||
Boolean(host.hostChain?.hostIds?.length) && participatesInCycle(host.id, new Set())
|
||||
));
|
||||
for (const host of cyclicHosts) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: detected circular dependency in jump chain for "${host.label}", removing chain.`,
|
||||
});
|
||||
delete host.hostChain;
|
||||
}
|
||||
return allHosts;
|
||||
};
|
||||
|
||||
const importFromCsv = (
|
||||
text: string,
|
||||
existingHosts: readonly VaultImportExistingHostRef[] = [],
|
||||
): VaultImportResult => {
|
||||
const importFromCsv = (text: string): VaultImportResult => {
|
||||
const issues: VaultImportIssue[] = [];
|
||||
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
|
||||
if (rows.length === 0) {
|
||||
@@ -663,7 +448,6 @@ const importFromCsv = (
|
||||
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
|
||||
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
|
||||
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
|
||||
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
|
||||
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
|
||||
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
|
||||
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
|
||||
@@ -698,7 +482,6 @@ const importFromCsv = (
|
||||
}
|
||||
|
||||
const parsedHosts: Host[] = [];
|
||||
const proxyJumpByHost = new Map<Host, { value: string; row: number }>();
|
||||
const keyPassphraseCandidates: Array<{
|
||||
hostKey: string;
|
||||
keyPathKey: string;
|
||||
@@ -766,8 +549,6 @@ const importFromCsv = (
|
||||
message: `CSV row ${i + 2}: LocalShell was ignored because it is not a valid encoded local-shell value.`,
|
||||
});
|
||||
}
|
||||
const proxyJumpRaw = (proxyJumpIdx >= 0 ? row[proxyJumpIdx] : undefined)?.trim();
|
||||
const proxyJump = proxyJumpRaw ? decodeCsvProxyJump(proxyJumpRaw).trim() : "";
|
||||
|
||||
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
|
||||
issues.push({
|
||||
@@ -805,9 +586,6 @@ const importFromCsv = (
|
||||
if (localShellSpec.startDir) host.localStartDir = localShellSpec.startDir;
|
||||
if (localShellSpec.os) host.os = localShellSpec.os;
|
||||
}
|
||||
if (proxyJump && proxyJump.toLowerCase() !== "none") {
|
||||
proxyJumpByHost.set(host, { value: proxyJump, row: i + 2 });
|
||||
}
|
||||
parsedHosts.push(host);
|
||||
if (keyPath && passphrase) {
|
||||
const keyPathKey = normalizeKeyPathKey(keyPath);
|
||||
@@ -834,8 +612,7 @@ const importFromCsv = (
|
||||
// the shell identity is part of buildVaultHostMergeKey; otherwise importing a
|
||||
// backup with CMD and PowerShell in the same group silently keeps only the
|
||||
// first shell and flags the rest as duplicates.
|
||||
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const hosts = attachCsvProxyJumpChains(dedupedHosts, proxyJumpByHost, issues, existingHosts);
|
||||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const keyPassphrases = hosts.flatMap((host) => {
|
||||
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||||
if (!selectedKeyPath) return [];
|
||||
@@ -1632,22 +1409,15 @@ const importFromMobaXterm = (
|
||||
};
|
||||
};
|
||||
|
||||
export interface VaultImportTextOptions {
|
||||
fileName?: string;
|
||||
masterPassword?: string;
|
||||
/** Hosts already in the vault. CSV ProxyJump hops bind to these by endpoint. */
|
||||
existingHosts?: readonly VaultImportExistingHostRef[];
|
||||
}
|
||||
|
||||
export const importVaultHostsFromText = (
|
||||
format: VaultImportFormat,
|
||||
text: string,
|
||||
options?: VaultImportTextOptions,
|
||||
options?: { fileName?: string; masterPassword?: string },
|
||||
): VaultImportResult => {
|
||||
const input = text ?? "";
|
||||
switch (format) {
|
||||
case "csv":
|
||||
return importFromCsv(input, options?.existingHosts);
|
||||
return importFromCsv(input);
|
||||
case "putty":
|
||||
return importFromPuttyReg(input);
|
||||
case "ssh_config":
|
||||
@@ -1738,40 +1508,15 @@ export function applyVaultHostImport(
|
||||
addedHosts: Host[];
|
||||
} {
|
||||
const skipDuplicates = options?.skipDuplicates !== false;
|
||||
const existingByKey = new Map(existingHosts.map((host) => [buildVaultHostMergeKey(host), host]));
|
||||
const droppedIdToExistingId = new Map<string, string>();
|
||||
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
|
||||
let newHosts = importResult.hosts;
|
||||
let skippedExistingCount = 0;
|
||||
|
||||
if (skipDuplicates) {
|
||||
newHosts = importResult.hosts.filter((host) => {
|
||||
const existing = existingByKey.get(buildVaultHostMergeKey(host));
|
||||
if (!existing) return true;
|
||||
skippedExistingCount++;
|
||||
if (existing.id !== host.id) droppedIdToExistingId.set(host.id, existing.id);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (droppedIdToExistingId.size > 0) {
|
||||
newHosts = newHosts.map((host) => {
|
||||
const currentIds = host.hostChain?.hostIds;
|
||||
if (!currentIds?.length) return host;
|
||||
const hostIds: string[] = [];
|
||||
for (const hostId of currentIds) {
|
||||
const mapped = droppedIdToExistingId.get(hostId) ?? hostId;
|
||||
if (mapped === host.id || hostIds.includes(mapped)) continue;
|
||||
hostIds.push(mapped);
|
||||
}
|
||||
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
|
||||
return host;
|
||||
}
|
||||
if (hostIds.length === 0) {
|
||||
const next = { ...host };
|
||||
delete next.hostChain;
|
||||
return next;
|
||||
}
|
||||
return { ...host, hostChain: { hostIds } };
|
||||
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
|
||||
if (duplicate) skippedExistingCount++;
|
||||
return !duplicate;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ const FORMULA_PREFIX = /^[=+\-@\t\r]/u;
|
||||
const KEY_PATH_MARKER = "__netcatty_csv_keypath_v1__:";
|
||||
const PASSPHRASE_MARKER = "__netcatty_csv_passphrase_v1__:";
|
||||
const PROXY_MARKER = "__netcatty_csv_proxy_v1__:";
|
||||
const PROXY_JUMP_MARKER = "__netcatty_csv_proxyjump_v1__:";
|
||||
const LOCAL_SHELL_MARKER = "__netcatty_csv_localshell_v1__:";
|
||||
|
||||
const encodeMarkedField = (value: string, marker: string): string => (
|
||||
@@ -44,14 +43,6 @@ export const decodeCsvProxy = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_MARKER)
|
||||
);
|
||||
|
||||
export const encodeCsvProxyJump = (value: string): string => (
|
||||
encodeMarkedField(value, PROXY_JUMP_MARKER)
|
||||
);
|
||||
|
||||
export const decodeCsvProxyJump = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_JUMP_MARKER)
|
||||
);
|
||||
|
||||
export interface CsvLocalShellSpec {
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
|
||||
@@ -3,9 +3,8 @@ import test from "node:test";
|
||||
|
||||
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
|
||||
import { applyGroupDefaults } from "../groupConfig.ts";
|
||||
import { applyVaultHostImport, applyVaultImportDestination, importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
|
||||
import { parseCsv } from "./csvUtils.ts";
|
||||
import type { Host } from "../models.ts";
|
||||
|
||||
const hostDefaults = { username: "root", tags: [], os: "linux" as const };
|
||||
@@ -607,230 +606,6 @@ test("CSV round-trips local-shell vault host settings", () => {
|
||||
assert.equal(roundTrippedDefault.localStartDir, undefined);
|
||||
});
|
||||
|
||||
const proxyJumpCell = (csv: string, hostname: string): string => {
|
||||
const rows = parseCsv(csv.replace(/^\uFEFF/, "")).filter((row) => row.some((cell) => cell.trim() !== ""));
|
||||
const header = rows[0] ?? [];
|
||||
const jumpIndex = header.indexOf("ProxyJump");
|
||||
const hostIndex = header.findIndex((cell) => cell.toLowerCase().includes("hostname"));
|
||||
const row = rows.slice(1).find((cells) => cells[hostIndex] === hostname);
|
||||
return jumpIndex >= 0 ? (row?.[jumpIndex] ?? "") : "";
|
||||
};
|
||||
|
||||
const chainHost = (id: string, label: string, hostname: string, username: string, port = 22): Host => ({
|
||||
...hostDefaults,
|
||||
id,
|
||||
label,
|
||||
hostname,
|
||||
username,
|
||||
port,
|
||||
});
|
||||
|
||||
test("CSV export writes one-hop and two-hop ProxyJump chains", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
|
||||
const v6 = chainHost("jump-v6", "IPv6 hop", "2001:db8::1", "carol", 2222);
|
||||
const oneHop = chainHost("app-1", "One hop", "one.example.com", "root");
|
||||
oneHop.hostChain = { hostIds: [bastion.id] };
|
||||
const twoHop = chainHost("app-2", "Two hop", "two.example.com", "root");
|
||||
twoHop.hostChain = { hostIds: [bastion.id, second.id] };
|
||||
const viaV6 = chainHost("app-v6", "Via IPv6", "v6.example.com", "root");
|
||||
viaV6.hostChain = { hostIds: [v6.id] };
|
||||
const direct = chainHost("app-3", "Direct", "direct.example.com", "root");
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, second, v6, oneHop, twoHop, viaV6, direct]);
|
||||
|
||||
assert.equal(proxyJumpCell(csv, bastion.hostname), "");
|
||||
assert.equal(proxyJumpCell(csv, oneHop.hostname), "alice@bastion.example.com");
|
||||
assert.equal(proxyJumpCell(csv, twoHop.hostname), "alice@bastion.example.com,bob@jump.example.com:2222");
|
||||
assert.equal(proxyJumpCell(csv, viaV6.hostname), "carol@[2001:db8::1]:2222");
|
||||
assert.equal(proxyJumpCell(csv, direct.hostname), "");
|
||||
assert.match(getVaultCsvTemplate().split(/\r?\n/, 1)[0] ?? "", /(^|,)ProxyJump(,|$)/);
|
||||
});
|
||||
|
||||
test("CSV round-trips a jump host chain onto the imported hosts", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Prod";
|
||||
target.hostChain = { hostIds: [bastion.id, second.id] };
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, second, target]);
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
const byHostname = new Map(imported.hosts.map((host) => [host.hostname, host]));
|
||||
const importedTarget = byHostname.get(target.hostname);
|
||||
const importedBastion = byHostname.get(bastion.hostname);
|
||||
const importedSecond = byHostname.get(second.hostname);
|
||||
|
||||
assert.equal(imported.issues.length, 0);
|
||||
assert.deepEqual(importedTarget?.hostChain?.hostIds, [importedBastion?.id, importedSecond?.id]);
|
||||
assert.equal(importedBastion?.hostChain, undefined);
|
||||
assert.equal(importedSecond?.port, 2222);
|
||||
assert.equal(importedSecond?.username, "bob");
|
||||
});
|
||||
|
||||
test("CSV import creates an inline jump host when the hop is not in the file", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,alice@bastion.example.com:2222",
|
||||
].join("\n"));
|
||||
const target = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const jump = imported.hosts.find((host) => host.hostname === "bastion.example.com");
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(jump?.username, "alice");
|
||||
assert.equal(jump?.port, 2222);
|
||||
assert.equal(jump?.protocol, "ssh");
|
||||
assert.deepEqual(target?.hostChain?.hostIds, jump ? [jump.id] : []);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /inline jump host/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import binds a missing hop to an existing vault host by endpoint", () => {
|
||||
const existing = chainHost("vault-jump", "Old bastion", "bastion.example.com", "alice", 2222);
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,alice@bastion.example.com:2222",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostname, "app.example.com");
|
||||
assert.deepEqual(imported.hosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
assert.deepEqual(imported.issues, []);
|
||||
|
||||
const moved = applyVaultImportDestination(imported, { mode: "group", group: "Moved" });
|
||||
assert.deepEqual(moved.hosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import keeps an old file without a ProxyJump column unchanged", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,Password",
|
||||
"legacy.example.com,root,secret",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostname, "legacy.example.com");
|
||||
assert.equal(imported.hosts[0]?.username, "root");
|
||||
assert.equal(imported.hosts[0]?.password, "secret");
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.deepEqual(imported.issues, []);
|
||||
});
|
||||
|
||||
test("CSV import retargets a chain when the jump host is already in the vault", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
bastion.group = "Prod";
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Prod";
|
||||
target.hostChain = { hostIds: [bastion.id] };
|
||||
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
|
||||
existing.group = "Prod";
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, target]);
|
||||
const imported = importVaultHostsFromText("csv", csv, { existingHosts: [existing] });
|
||||
const merged = applyVaultHostImport([existing], ["Prod"], imported);
|
||||
|
||||
assert.equal(merged.addedHosts.length, 1);
|
||||
assert.equal(merged.addedHosts[0]?.hostname, "app.example.com");
|
||||
assert.deepEqual(merged.addedHosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
assert.equal(merged.hosts.some((host) => host.id !== existing.id && host.hostname === bastion.hostname), false);
|
||||
});
|
||||
|
||||
test("CSV export materializes a jump chain inherited from group defaults", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Corp";
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, target], {
|
||||
groupConfigs: [{ path: "Corp", hostChain: { hostIds: [bastion.id] } }],
|
||||
});
|
||||
|
||||
assert.equal(proxyJumpCell(csv, target.hostname), "alice@bastion.example.com");
|
||||
});
|
||||
|
||||
test("CSV import warns and drops a circular ProxyJump chain", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"a.example.com,root,b.example.com",
|
||||
"b.example.com,root,a.example.com",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(imported.hosts.every((host) => host.hostChain === undefined), true);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import drops inline hops created only for a rejected self-referential ProxyJump", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"ok.example.com,root,kept-hop.example.com",
|
||||
"app.example.com,root,\"new-hop.example.com,app.example.com\"",
|
||||
].join("\n"));
|
||||
|
||||
const hostnames = imported.hosts.map((host) => host.hostname).sort();
|
||||
assert.deepEqual(hostnames, ["app.example.com", "kept-hop.example.com", "ok.example.com"]);
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const ok = imported.hosts.find((host) => host.hostname === "ok.example.com");
|
||||
const kept = imported.hosts.find((host) => host.hostname === "kept-hop.example.com");
|
||||
assert.equal(app?.hostChain, undefined);
|
||||
assert.deepEqual(ok?.hostChain?.hostIds, kept ? [kept.id] : []);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import prefers a same-group existing hop over a cross-group imported match", () => {
|
||||
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
|
||||
existing.group = "Prod";
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Hostname,Username,ProxyJump",
|
||||
"Other,bastion.example.com,alice,",
|
||||
"Prod,app.example.com,root,alice@bastion.example.com",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const importedBastion = imported.hosts.find((host) => host.hostname === "bastion.example.com");
|
||||
assert.equal(importedBastion?.group, "Other");
|
||||
assert.notEqual(importedBastion?.id, existing.id);
|
||||
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import prefers a same-group existing label hop over a cross-group imported label", () => {
|
||||
const existing = chainHost("vault-jump", "Bastion", "vault-bastion.internal", "alice");
|
||||
existing.group = "Prod";
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Username,ProxyJump",
|
||||
"Other,Bastion,other-bastion.internal,alice,",
|
||||
"Prod,App,app.example.com,root,alice@Bastion",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const importedBastion = imported.hosts.find((host) => host.hostname === "other-bastion.internal");
|
||||
assert.equal(importedBastion?.group, "Other");
|
||||
assert.equal(importedBastion?.label, "Bastion");
|
||||
assert.notEqual(importedBastion?.id, existing.id);
|
||||
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a ProxyJump value that is not a jump host", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,not a hop",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && issue.message.includes("ProxyJump")));
|
||||
});
|
||||
|
||||
test("CSV import rejects a ProxyJump chain that contains a malformed hop", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,\"bastion.example.com,not a hop\"",
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(imported.hosts.map((host) => host.hostname), ["app.example.com"]);
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.ok(imported.issues.some((issue) => (
|
||||
issue.level === "warning" && issue.message.includes("not a hop")
|
||||
)));
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a malformed LocalShell value", () => {
|
||||
const csv = [
|
||||
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy,LocalShell",
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
|
||||
import { isPluginHostProtocol } from '../pluginConnection';
|
||||
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
|
||||
import { canServeAsSshJumpHost } from '../terminalProtocol';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy, encodeCsvProxyJump } from './csvCredentialFields';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
|
||||
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
@@ -37,7 +36,7 @@ export const getVaultCsvTemplate = (
|
||||
opts: VaultCsvTemplateOptions = {},
|
||||
): string => {
|
||||
const includeExampleRows = opts.includeExampleRows !== false;
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
if (includeExampleRows) {
|
||||
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
|
||||
@@ -78,44 +77,8 @@ const getLocalShellSpec = (host: Host): CsvLocalShellSpec | null => (
|
||||
: null
|
||||
);
|
||||
|
||||
const UNSAFE_PROXY_JUMP_USERNAME = /[\s,#@]/;
|
||||
const UNSAFE_PROXY_JUMP_HOSTNAME = /[\s,#@]/;
|
||||
const DEFAULT_PROXY_JUMP_PORT = 22;
|
||||
|
||||
// OpenSSH ProxyJump: [user@]host[:port], repeated. Port 22 is omitted.
|
||||
// IPv6 is bracketed so the port separator stays unambiguous.
|
||||
const formatCsvProxyJumpHop = (host: Host): string | null => {
|
||||
if (!canServeAsSshJumpHost(host)) return null;
|
||||
const username = host.username?.trim() ?? "";
|
||||
const hostname = host.hostname.trim().replace(/^\[(.*)\]$/u, "$1");
|
||||
if (!hostname) return null;
|
||||
if (username && UNSAFE_PROXY_JUMP_USERNAME.test(username)) return null;
|
||||
if (UNSAFE_PROXY_JUMP_HOSTNAME.test(hostname)) return null;
|
||||
const port = host.port ?? DEFAULT_PROXY_JUMP_PORT;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
const hostPart = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
const withUser = username ? `${username}@${hostPart}` : hostPart;
|
||||
return port === DEFAULT_PROXY_JUMP_PORT ? withUser : `${withUser}:${port}`;
|
||||
};
|
||||
|
||||
// An empty cell means "no chain". A chain is omitted entirely when any hop
|
||||
// cannot be represented, so a shortened list is not written back as the truth.
|
||||
const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
|
||||
const ids = host.hostChain?.hostIds ?? [];
|
||||
if (ids.length === 0) return "";
|
||||
const byId = new Map(allHosts.map((item) => [item.id, item]));
|
||||
const parts: string[] = [];
|
||||
for (const id of ids) {
|
||||
const hop = byId.get(id);
|
||||
const formatted = hop ? formatCsvProxyJumpHop(hop) : null;
|
||||
if (!formatted) return "";
|
||||
parts.push(formatted);
|
||||
}
|
||||
return parts.join(",");
|
||||
};
|
||||
|
||||
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
@@ -198,9 +161,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
}
|
||||
}
|
||||
const localShellSpec = getLocalShellSpec(host);
|
||||
// Group defaults are already on effectiveHost, same as Proxy, so a chain
|
||||
// inherited from the group survives a file that has no group-config section.
|
||||
const proxyJumpValue = formatCsvProxyJump(effectiveHost, exportableHosts);
|
||||
|
||||
rows.push([
|
||||
host.group ?? "",
|
||||
@@ -215,7 +175,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
encodeCsvKeyPath(keyPath),
|
||||
encodeCsvPassphrase(passphrase),
|
||||
encodeCsvProxy(proxyValue),
|
||||
encodeCsvProxyJump(proxyJumpValue),
|
||||
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
|
||||
]);
|
||||
}
|
||||
@@ -224,10 +183,9 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const keyPathColIdx = header.indexOf("KeyPath");
|
||||
const passphraseColIdx = header.indexOf("Passphrase");
|
||||
const proxyColIdx = header.indexOf("Proxy");
|
||||
const proxyJumpColIdx = header.indexOf("ProxyJump");
|
||||
const csv = rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
|
||||
c,
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx || i === proxyJumpColIdx),
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
|
||||
)).join(",")).join("\r\n") + "\r\n";
|
||||
return { csv, unreadableProxyCredentialCount };
|
||||
};
|
||||
|
||||
@@ -82,9 +82,6 @@ module.exports = {
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
'!public/**/*',
|
||||
// README screenshots live under docs/ and are not app resources.
|
||||
// Keep them out of app.asar if the files globs ever become permissive.
|
||||
'!docs/assets/readme/**/*',
|
||||
'!**/*.map',
|
||||
'!**/*.d.ts',
|
||||
'!**/*.d.mts',
|
||||
|
||||
@@ -63,10 +63,7 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
|
||||
// consume the listener slot and let the real reply fall through. A
|
||||
// missing/falsy sender is anomalous and treated as a wrong-window reply.
|
||||
if (evt?.sender !== webContents) return;
|
||||
// "transfers": the user already declined the renderer's unfinished-
|
||||
// transfer quit prompt. Still truthy, so existing callers keep quit
|
||||
// cancelled; handleBeforeQuit uses it to skip the editor dialog.
|
||||
settle(payload?.hasDirty === true ? (payload?.reason === "transfers" ? "transfers" : true) : false);
|
||||
settle(payload?.hasDirty === true);
|
||||
}
|
||||
ipcMain.on("app:dirty-editors-result", onResult);
|
||||
|
||||
|
||||
@@ -221,15 +221,3 @@ test("resolves false when no ipcMain can be resolved", async () => {
|
||||
assert.equal(await queryDirtyEditors(wc, 5000), false);
|
||||
assert.deepEqual(wc.sent, []);
|
||||
});
|
||||
|
||||
test("carries a declined unfinished-transfer prompt as a truthy \"transfers\" result", async () => {
|
||||
const ipcMain = makeIpcMain();
|
||||
const wc = makeWebContents();
|
||||
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true, reason: "transfers" });
|
||||
assert.equal(await promise, "transfers");
|
||||
// A clean reply that names a reason is still clean.
|
||||
const clean = queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: false, reason: "transfers" });
|
||||
assert.equal(await clean, false);
|
||||
});
|
||||
@@ -201,8 +201,6 @@ const requestHostKeyVerification = (sender, info) => new Promise((resolve) => {
|
||||
}
|
||||
});
|
||||
|
||||
const toKeyBuffer = (rawKey) => (Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || ""));
|
||||
|
||||
const createHostVerifier = ({
|
||||
sender,
|
||||
sessionId,
|
||||
@@ -215,79 +213,64 @@ const createHostVerifier = ({
|
||||
// TTL leaves the upload on "Waiting..." with no dialog and no error.
|
||||
unattended = false,
|
||||
onAccepted,
|
||||
}) => {
|
||||
// ssh2 calls the verifier again on every rekey. A prompt there is async,
|
||||
// and while it waits ssh2 keeps sending channel data ahead of its NEWKEYS,
|
||||
// so the server drops the connection mid-transfer ("Bad packet length").
|
||||
// Like OpenSSH, a rekey only has to present the key already accepted for
|
||||
// this connection: answer it synchronously and never prompt again.
|
||||
let acceptedKey = null;
|
||||
|
||||
return (rawKey, callback) => {
|
||||
if (verifyHostKeys !== false && acceptedKey) {
|
||||
callback(acceptedKey.equals(toKeyBuffer(rawKey)));
|
||||
return;
|
||||
}) => (rawKey, callback) => {
|
||||
const keyInfo = describeHostKey(rawKey);
|
||||
const noteAccepted = () => {
|
||||
try {
|
||||
onAccepted?.({
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
});
|
||||
} catch {
|
||||
// Recording the accepted key must not change the verifier result.
|
||||
}
|
||||
|
||||
const keyInfo = describeHostKey(rawKey);
|
||||
const noteAccepted = () => {
|
||||
acceptedKey = Buffer.from(toKeyBuffer(rawKey));
|
||||
try {
|
||||
onAccepted?.({
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
});
|
||||
} catch {
|
||||
// Recording the accepted key must not change the verifier result.
|
||||
}
|
||||
};
|
||||
|
||||
if (verifyHostKeys === false) {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = classifyHostKey({
|
||||
knownHosts,
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
});
|
||||
|
||||
if (decision.status === "trusted") {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unattended) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void requestHostKeyVerification(sender, {
|
||||
sessionId,
|
||||
hostname,
|
||||
port,
|
||||
status: decision.status,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
knownHostId: decision.knownHost?.id,
|
||||
knownFingerprint: decision.expectedFingerprint,
|
||||
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
|
||||
}).then((response) => {
|
||||
if (response?.accept) noteAccepted();
|
||||
callback(Boolean(response?.accept));
|
||||
}).catch(() => {
|
||||
callback(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (verifyHostKeys === false) {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = classifyHostKey({
|
||||
knownHosts,
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
});
|
||||
|
||||
if (decision.status === "trusted") {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unattended) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void requestHostKeyVerification(sender, {
|
||||
sessionId,
|
||||
hostname,
|
||||
port,
|
||||
status: decision.status,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
knownHostId: decision.knownHost?.id,
|
||||
knownFingerprint: decision.expectedFingerprint,
|
||||
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
|
||||
}).then((response) => {
|
||||
if (response?.accept) noteAccepted();
|
||||
callback(Boolean(response?.accept));
|
||||
}).catch(() => {
|
||||
callback(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleResponse = (_event, payload) => {
|
||||
|
||||
@@ -356,100 +356,6 @@ test("createHostVerifier prompts for unknown host keys and waits for user respon
|
||||
assert.equal(await acceptedPromise, true);
|
||||
});
|
||||
|
||||
test("createHostVerifier answers rekeys synchronously with the key accepted at connect", async () => {
|
||||
const rawKey = Buffer.from("new server key");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
// The knownHosts snapshot stays empty for the life of the connection, as it
|
||||
// does when the user picks "Continue" or "Add and continue" on first connect.
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
});
|
||||
|
||||
const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
assert.equal(sent.length, 1);
|
||||
handleResponse({ sender: { id: sender.id } }, {
|
||||
requestId: sent[0].payload.requestId,
|
||||
accept: true,
|
||||
addToKnownHosts: false,
|
||||
});
|
||||
assert.equal(await acceptedPromise, true);
|
||||
|
||||
// ssh2 re-runs the verifier on every rekey. It must answer before returning
|
||||
// (ssh2 keeps sending channel data while an async answer is pending) and
|
||||
// must not show the host-key dialog again.
|
||||
const rekeyResults = [];
|
||||
verifier(Buffer.from(rawKey), (ok) => rekeyResults.push(ok));
|
||||
assert.deepEqual(rekeyResults, [true]);
|
||||
assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
test("createHostVerifier rejects a different host key on rekey without prompting", async () => {
|
||||
const rawKey = Buffer.from("trusted server key");
|
||||
const fingerprint = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "unknown",
|
||||
publicKey: `SHA256:${fingerprint}`,
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(await new Promise((resolve) => verifier(rawKey, resolve)), true);
|
||||
|
||||
const rekeyResults = [];
|
||||
verifier(Buffer.from("other server key"), (ok) => rekeyResults.push(ok));
|
||||
assert.deepEqual(rekeyResults, [false]);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("createHostVerifier still prompts on the next connect after a rejected key", async () => {
|
||||
const rawKey = Buffer.from("new server key");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
});
|
||||
|
||||
const first = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
handleResponse({ sender: { id: sender.id } }, { requestId: sent[0].payload.requestId, accept: false });
|
||||
assert.equal(await first, false);
|
||||
|
||||
const second = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
assert.equal(sent.length, 2);
|
||||
handleResponse({ sender: { id: sender.id } }, { requestId: sent[1].payload.requestId, accept: true });
|
||||
assert.equal(await second, true);
|
||||
});
|
||||
|
||||
test("createHostVerifier forwards bootEpoch on host-key prompts", async () => {
|
||||
const rawKey = Buffer.from("new server key");
|
||||
const sent = [];
|
||||
|
||||
@@ -1978,11 +1978,11 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
if (!tunnelId) return;
|
||||
trackedTunnelIds.delete(tunnelId);
|
||||
tunnelRuleIds.delete(tunnelId);
|
||||
for (const entry of subscriptionsBySender.values()) {
|
||||
for (const [webContentsId, entry] of subscriptionsBySender) {
|
||||
entry.tunnelIds.delete(tunnelId);
|
||||
// Runtime subscriptions outlive individual tunnels. Keep their owner
|
||||
// destruction hook until both subscription kinds have been released.
|
||||
releaseEmptySenderLifecycle(entry.sender, entry);
|
||||
if (entry.tunnelIds.size > 0) continue;
|
||||
entry.sender.removeListener?.("destroyed", entry.onDestroyed);
|
||||
subscriptionsBySender.delete(webContentsId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -238,91 +238,3 @@ test("worker crashes notify subscribed renderers and permit a replacement worker
|
||||
);
|
||||
assert.equal(starts, 2);
|
||||
});
|
||||
|
||||
|
||||
for (const removal of ["stop", "stopAll", "stopByRuleId", "remove", "inactive", "error"]) {
|
||||
test(`worker ${removal} preserves runtime owner cleanup after the final tunnel`, async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
const requests = [];
|
||||
let onRendererEvent;
|
||||
const terminalWorkerManager = {
|
||||
request(channel, payload, options) {
|
||||
requests.push({ channel, options });
|
||||
return Promise.resolve({ tunnelId: payload?.tunnelId, success: true, failed: 0 });
|
||||
},
|
||||
onWorkerRendererEvent(listener) { onRendererEvent = listener; },
|
||||
};
|
||||
registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
const sender = createSender(40);
|
||||
const invoke = (method, payload = {}) => ipcMain.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
|
||||
await invoke("subscribeRuntime");
|
||||
await invoke("start", { tunnelId: "last", ruleId: "rule-last" });
|
||||
if (["stop", "stopAll", "stopByRuleId"].includes(removal)) {
|
||||
await invoke(removal, { tunnelId: "last", ruleId: "rule-last" });
|
||||
} else {
|
||||
onRendererEvent({
|
||||
channel: removal === "remove" ? "netcatty:portforward:runtime" : "netcatty:portforward:status",
|
||||
payload: { tunnelId: "last", kind: removal, status: removal },
|
||||
});
|
||||
}
|
||||
assert.equal(sender.listenerCount("destroyed"), 1);
|
||||
sender.emit("destroyed");
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.filter(({ channel }) => channel === "netcatty:portforward:unsubscribeSender").length, 1);
|
||||
assert.equal(sender.listenerCount("destroyed"), 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("worker last-tunnel removal releases lifecycle after explicit runtime unsubscribe", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
registerHandlers(ipcMain, { terminalWorkerManager: {
|
||||
request(_channel, payload) { return Promise.resolve({ tunnelId: payload?.tunnelId, success: true }); },
|
||||
} });
|
||||
const sender = createSender(41);
|
||||
const invoke = (method, payload = {}) => ipcMain.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
|
||||
await invoke("subscribeRuntime");
|
||||
await invoke("start", { tunnelId: "last" });
|
||||
await invoke("stop", { tunnelId: "last" });
|
||||
assert.equal(sender.listenerCount("destroyed"), 1);
|
||||
await invoke("unsubscribeRuntime");
|
||||
assert.equal(sender.listenerCount("destroyed"), 0);
|
||||
});
|
||||
|
||||
test("worker final-tunnel stop then renderer destruction prevents stale runtime event fanout", async (t) => {
|
||||
const bridge = require("./portForwardingBridge.cjs");
|
||||
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
|
||||
bridge._resetPortForwardRuntimeMetaForTests();
|
||||
bridge._clearPortForwardTunnelsForTests();
|
||||
t.after(() => {
|
||||
bridge._resetPortForwardRuntimeMetaForTests();
|
||||
bridge._clearPortForwardTunnelsForTests();
|
||||
});
|
||||
const events = [];
|
||||
let onRendererEvent;
|
||||
const runtime = createTerminalWorkerRuntime({ parentPort: {
|
||||
postMessage(message) { events.push(message); onRendererEvent?.(message); },
|
||||
on() {},
|
||||
} });
|
||||
const workerIpc = createIpcMain();
|
||||
bridge.registerHandlers(workerIpc);
|
||||
const mainIpc = createIpcMain();
|
||||
bridge.registerHandlers(mainIpc, { terminalWorkerManager: {
|
||||
request(channel, payload, options) {
|
||||
return workerIpc.handlers.get(channel)({ sender: runtime.createSender(options.webContentsId) }, payload);
|
||||
},
|
||||
onWorkerRendererEvent(listener) { onRendererEvent = listener; },
|
||||
} });
|
||||
const sender = createSender(42);
|
||||
const invoke = (method, payload = {}) => mainIpc.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
|
||||
await invoke("subscribeRuntime");
|
||||
bridge._seedPortForwardTunnelForTests("fixture", { ruleId: "fixture", status: "active", type: "local" });
|
||||
await invoke("subscribe", { tunnelId: "fixture" });
|
||||
const stopped = await invoke("stop", { tunnelId: "fixture" });
|
||||
assert.equal(stopped.success, true);
|
||||
sender.emit("destroyed");
|
||||
await Promise.resolve();
|
||||
events.length = 0;
|
||||
bridge.publishTunnelStatus("another", { ruleId: "another", status: "active" }, "active");
|
||||
assert.deepEqual(events, []);
|
||||
assert.equal((await bridge.unsubscribePortForwardSender(null, { webContentsId: sender.id })).removed, 0);
|
||||
});
|
||||
@@ -34,11 +34,10 @@ function createProcessTree({ platform, listPosix, listWindows } = {}) {
|
||||
|
||||
function defaultListPosix(ppid, { execFileFn = execFile } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
// Separate `-o col=` options. A single `-o pid=,ppid=,args=` spec is
|
||||
// misread by procps-ng 3.3.x: the text after the first `=` becomes the
|
||||
// header, so only pid is printed. The split form works on BSD (macOS),
|
||||
// modern procps, and BusyBox. `args=` is the full command line.
|
||||
execFileFn("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "args="], {
|
||||
// `ps -A -o pid=,ppid=,args=` works on both BSD (macOS) and GNU (Linux).
|
||||
// `args=` shows the full command line (not truncated like `comm=`).
|
||||
// The trailing `=` on each column suppresses the header row.
|
||||
execFileFn("ps", ["-A", "-o", "pid=,ppid=,args="], {
|
||||
timeout: POSIX_PROCESS_LIST_TIMEOUT_MS,
|
||||
maxBuffer: POSIX_PROCESS_LIST_MAX_BUFFER_BYTES,
|
||||
windowsHide: true,
|
||||
|
||||
@@ -101,7 +101,7 @@ test("default posix process listing bounds the child lifetime and captured outpu
|
||||
{ pid: 2003, command: "python worker.py" },
|
||||
]);
|
||||
assert.equal(invocation.command, "ps");
|
||||
assert.deepEqual(invocation.args, ["-A", "-o", "pid=", "-o", "ppid=", "-o", "args="]);
|
||||
assert.deepEqual(invocation.args, ["-A", "-o", "pid=,ppid=,args="]);
|
||||
assert.equal(invocation.options.timeout, POSIX_PROCESS_LIST_TIMEOUT_MS);
|
||||
assert.equal(invocation.options.maxBuffer, POSIX_PROCESS_LIST_MAX_BUFFER_BYTES);
|
||||
assert.equal(invocation.options.windowsHide, true);
|
||||
|
||||
@@ -41,8 +41,6 @@ function createBridgeRegistrarForTest({
|
||||
tempDir,
|
||||
sftpClients = new Map(),
|
||||
transferBridge = createNoopBridge(),
|
||||
sshBridge = null,
|
||||
terminalBridge = null,
|
||||
electronModuleOverrides = {},
|
||||
}) {
|
||||
const noopBridge = createNoopBridge();
|
||||
@@ -83,12 +81,12 @@ function createBridgeRegistrarForTest({
|
||||
sftpClients,
|
||||
CLOUD_SYNC_PASSWORD_FILE: "cloud-sync-password",
|
||||
getCliDiscoveryFilePath: () => path.join(tempDir, "cli-discovery.json"),
|
||||
sshBridge: sshBridge || { ...noopBridge, ensureMoshStatsConnection() {} },
|
||||
sshBridge: { ...noopBridge, ensureMoshStatsConnection() {} },
|
||||
sftpBridge,
|
||||
localFsBridge: noopBridge,
|
||||
transferBridge,
|
||||
portForwardingBridge: noopBridge,
|
||||
terminalBridge: terminalBridge || { ...noopBridge, execOnEtSession() {} },
|
||||
terminalBridge: { ...noopBridge, execOnEtSession() {} },
|
||||
crashLogBridge: noopBridge,
|
||||
ptyProcessTree: { getChildProcesses: () => [] },
|
||||
getOauthBridge: () => ({ setupOAuthBridge() {} }),
|
||||
@@ -252,33 +250,3 @@ test("downloadToTempWithProgress proxies transfer work to the terminal worker in
|
||||
cancelled: false,
|
||||
});
|
||||
});
|
||||
|
||||
for (const mode of ["disabled", "unavailable", "worker"]) {
|
||||
test(`registrar enables saved-log restart guards only for main fallback (${mode})`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-log-fallback-test-"));
|
||||
const previousWorkerSetting = process.env.NETCATTY_TERMINAL_WORKER;
|
||||
process.env.NETCATTY_TERMINAL_WORKER = mode === "disabled" ? "0" : "1";
|
||||
t.after(async () => {
|
||||
if (previousWorkerSetting === undefined) delete process.env.NETCATTY_TERMINAL_WORKER;
|
||||
else process.env.NETCATTY_TERMINAL_WORKER = previousWorkerSetting;
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
const observed = {};
|
||||
const bridgeSpy = (name) => ({
|
||||
...createNoopBridge(),
|
||||
registerHandlers(_ipcMain, options) { observed[name] = options; },
|
||||
});
|
||||
const registerBridges = createBridgeRegistrarForTest({
|
||||
ipcMain: createIpcMainStub(), tempDir,
|
||||
sshBridge: bridgeSpy("ssh"), terminalBridge: bridgeSpy("terminal"),
|
||||
electronModuleOverrides: mode === "unavailable" ? {} : {
|
||||
utilityProcess: { fork() { throw new Error("registration must not cold-start the worker"); } },
|
||||
},
|
||||
});
|
||||
registerBridges({});
|
||||
for (const name of ["ssh", "terminal"]) {
|
||||
assert.equal(observed[name].manageSessionLogRestart, mode !== "worker");
|
||||
assert.equal(Boolean(observed[name].terminalWorkerManager), mode === "worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// In-process terminal backends share one registry. Remember the first renderer
|
||||
// for each session object, not its mutable output route: an attached popup is
|
||||
// only a display, and a replacement boot is a different object.
|
||||
const owners = new WeakMap();
|
||||
const trackedRegistries = new WeakMap();
|
||||
|
||||
function trackRendererSessionLifetime(sessions, sessionId, contents, closeSession, transferOwnership = false) {
|
||||
const session = sessions?.get(sessionId);
|
||||
// Worker/agent senders are synthetic and have no renderer destruction event.
|
||||
if (!session || typeof contents?.once !== "function") return;
|
||||
if (owners.has(session) && !transferOwnership) return;
|
||||
owners.set(session, contents);
|
||||
let tracked = trackedRegistries.get(sessions);
|
||||
if (!tracked) trackedRegistries.set(sessions, tracked = new WeakSet());
|
||||
const closeOwnedSessions = () => {
|
||||
for (const [id, current] of [...sessions]) {
|
||||
if (owners.get(current) !== contents || sessions.get(id) !== current) continue;
|
||||
closeSession({ sender: contents }, { sessionId: id, bootEpoch: current.bootEpoch });
|
||||
}
|
||||
};
|
||||
if (!tracked.has(contents)) {
|
||||
tracked.add(contents);
|
||||
contents.once("destroyed", closeOwnedSessions);
|
||||
}
|
||||
// An asynchronous start may finish after its window disappeared. Let its
|
||||
// synchronous setup finish (including acquiring SSH leases), then close only
|
||||
// the still-owned object through the same path as an explicit tab close.
|
||||
if (contents.isDestroyed?.()) queueMicrotask(closeOwnedSessions);
|
||||
}
|
||||
|
||||
module.exports = { trackRendererSessionLifetime };
|
||||
@@ -1,78 +0,0 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const EventEmitter = require("node:events");
|
||||
const test = require("node:test");
|
||||
const { trackRendererSessionLifetime: track } = require("./rendererSessionLifetime.cjs");
|
||||
|
||||
function renderer(id) {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = id;
|
||||
contents.destroyed = false;
|
||||
contents.isDestroyed = () => contents.destroyed;
|
||||
contents.destroy = () => { contents.destroyed = true; contents.emit("destroyed"); };
|
||||
return contents;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const sessions = new Map();
|
||||
const closed = [];
|
||||
const close = (_event, payload) => { closed.push(payload); sessions.delete(payload.sessionId); };
|
||||
const own = (id, contents, bootEpoch = 1) => {
|
||||
sessions.set(id, { bootEpoch });
|
||||
track(sessions, id, contents, close);
|
||||
};
|
||||
return { sessions, closed, close, own };
|
||||
}
|
||||
|
||||
test("fallback closes only session objects owned by the destroyed renderer", () => {
|
||||
const f = fixture();
|
||||
const first = renderer(7), second = renderer(8);
|
||||
f.own("a", first);
|
||||
f.own("b", first);
|
||||
f.own("c", second);
|
||||
assert.equal(first.listenerCount("destroyed"), 1);
|
||||
first.destroy();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "a", bootEpoch: 1 }, { sessionId: "b", bootEpoch: 1 }]);
|
||||
assert.equal(f.sessions.has("c"), true);
|
||||
});
|
||||
|
||||
test("fallback display rebind is not an ownership handoff; explicit home restore is", () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), popup = renderer(9), nextHome = renderer(8);
|
||||
f.own("a", home);
|
||||
track(f.sessions, "a", popup, f.close);
|
||||
popup.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
track(f.sessions, "a", nextHome, f.close, true);
|
||||
home.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
nextHome.destroy();
|
||||
assert.equal(f.closed.length, 1);
|
||||
});
|
||||
|
||||
test("fallback old owner cannot close a same-ID replacement or synthetic agent session", () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), nextHome = renderer(8);
|
||||
f.own("a", home);
|
||||
f.own("a", nextHome, 2);
|
||||
f.own("agent", { id: 7 });
|
||||
home.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
nextHome.destroy();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "a", bootEpoch: 2 }]);
|
||||
assert.equal(f.sessions.has("agent"), true);
|
||||
});
|
||||
|
||||
test("fallback late start into a destroyed renderer closes after setup, not a replacement", async () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), nextHome = renderer(8);
|
||||
home.destroy();
|
||||
f.own("late", home);
|
||||
assert.equal(f.closed.length, 0);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "late", bootEpoch: 1 }]);
|
||||
f.own("replacement", home);
|
||||
f.own("replacement", nextHome, 2);
|
||||
await Promise.resolve();
|
||||
assert.equal(f.closed.length, 1);
|
||||
assert.equal(f.sessions.has("replacement"), true);
|
||||
});
|
||||
@@ -16,16 +16,9 @@ const { createProgrammaticCommandLogRewriter } = require("./programmaticCommandL
|
||||
|
||||
// Active log streams keyed by sessionId
|
||||
const activeStreams = new Map();
|
||||
// Keep only the latest server-owned target for each session, including while
|
||||
// finalization is in flight. Finished records retain no terminal/parser data.
|
||||
const sessionLogs = new Map();
|
||||
|
||||
// Buffer flush interval (ms)
|
||||
const FLUSH_INTERVAL = 500;
|
||||
// A failed snapshot retries without waiting for more output, but not in a
|
||||
// tight loop. The delay grows while the same failure persists.
|
||||
const SNAPSHOT_RETRY_MIN_MS = 250;
|
||||
const SNAPSHOT_RETRY_MAX_MS = 5000;
|
||||
// Max buffer size before immediate flush (bytes)
|
||||
const MAX_BUFFER_SIZE = 64 * 1024;
|
||||
const SUDO_AUTOFILL_MARKER_PATTERN = /__NETCATTY_SUDO_[a-z0-9_]+__/gi;
|
||||
@@ -127,7 +120,6 @@ function startStream(sessionId, opts) {
|
||||
console.warn(`[SessionLogStream] Stream already active for ${sessionId}, stopping old one`);
|
||||
stopStream(sessionId, activeStreams.get(sessionId)?.startToken);
|
||||
}
|
||||
forgetSessionLog(sessionId);
|
||||
|
||||
const { hostLabel, hostname, directory, format, startTime } = opts;
|
||||
if (!directory) {
|
||||
@@ -174,6 +166,16 @@ function createStreamEntry(sessionId, opts) {
|
||||
? fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" })
|
||||
: null;
|
||||
|
||||
if (writeStream) {
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const startToken = Symbol("session-log-stream");
|
||||
const entry = {
|
||||
writeStream,
|
||||
@@ -200,13 +202,6 @@ function createStreamEntry(sessionId, opts) {
|
||||
snapshotPromise: null,
|
||||
snapshotRequested: false,
|
||||
snapshotDirty: false,
|
||||
snapshotError: null,
|
||||
snapshotRetryTimer: null,
|
||||
snapshotRetryDelay: 0,
|
||||
// Set only while prepareSessionLog is waiting on a snapshot, so that
|
||||
// caller can observe the failure before the delayed retry starts.
|
||||
deferSnapshotRetry: false,
|
||||
error: null,
|
||||
closing: false,
|
||||
disabled: false,
|
||||
startToken,
|
||||
@@ -215,21 +210,11 @@ function createStreamEntry(sessionId, opts) {
|
||||
pendingInitialLineLeadingCarriageReturn: false,
|
||||
};
|
||||
|
||||
if (writeStream) {
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
// A delayed error from an old connection must not disable a new stream.
|
||||
entry.disabled = true;
|
||||
entry.error = err;
|
||||
});
|
||||
}
|
||||
|
||||
entry.flushTimer = setInterval(() => {
|
||||
flushBuffer(entry);
|
||||
}, FLUSH_INTERVAL);
|
||||
|
||||
activeStreams.set(sessionId, entry);
|
||||
sessionLogs.set(sessionId, { entry, filePath, startToken, stopPromise: null, error: null });
|
||||
console.log(`[SessionLogStream] Started stream for ${sessionId} -> ${filePath}`);
|
||||
return startToken;
|
||||
}
|
||||
@@ -298,7 +283,6 @@ function flushBuffer(entry) {
|
||||
} catch (err) {
|
||||
console.error("[SessionLogStream] Flush error:", err.message);
|
||||
entry.disabled = true;
|
||||
entry.error = err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,27 +318,9 @@ function renderSnapshotContent(entry, { finalize = false } = {}) {
|
||||
: content;
|
||||
}
|
||||
|
||||
function clearSnapshotRetry(entry) {
|
||||
if (!entry?.snapshotRetryTimer) return;
|
||||
clearTimeout(entry.snapshotRetryTimer);
|
||||
entry.snapshotRetryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleSnapshotRetry(entry) {
|
||||
if (!entry || entry.closing || entry.disabled || entry.isRaw || entry.snapshotRetryTimer) return;
|
||||
const delay = entry.snapshotRetryDelay || SNAPSHOT_RETRY_MIN_MS;
|
||||
entry.snapshotRetryTimer = setTimeout(() => {
|
||||
entry.snapshotRetryTimer = null;
|
||||
if (entry.closing || entry.disabled) return;
|
||||
scheduleSnapshot(entry);
|
||||
}, delay);
|
||||
entry.snapshotRetryDelay = Math.min(delay * 2, SNAPSHOT_RETRY_MAX_MS);
|
||||
}
|
||||
|
||||
function scheduleSnapshot(entry) {
|
||||
if (!entry || entry.disabled || entry.isRaw || entry.closing) return;
|
||||
if (!entry.snapshotDirty) return;
|
||||
clearSnapshotRetry(entry);
|
||||
|
||||
if (entry.snapshotPromise) {
|
||||
entry.snapshotRequested = true;
|
||||
@@ -362,26 +328,18 @@ function scheduleSnapshot(entry) {
|
||||
}
|
||||
|
||||
entry.snapshotDirty = false;
|
||||
entry.snapshotError = null;
|
||||
entry.snapshotPromise = fs.promises
|
||||
.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8")
|
||||
.then(() => {
|
||||
entry.snapshotRetryDelay = 0;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[SessionLogStream] Snapshot write failed:", err.message);
|
||||
entry.snapshotDirty = true;
|
||||
entry.snapshotError = err;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.snapshotPromise = null;
|
||||
// prepareSessionLog must see this failure. Other callers retry on a
|
||||
// delay, or sooner when new output is flushed.
|
||||
if (entry.snapshotError && entry.deferSnapshotRetry) return;
|
||||
if (!(entry.snapshotRequested || entry.snapshotDirty) || entry.closing) return;
|
||||
entry.snapshotRequested = false;
|
||||
if (entry.snapshotError) scheduleSnapshotRetry(entry);
|
||||
else scheduleSnapshot(entry);
|
||||
if ((entry.snapshotRequested || entry.snapshotDirty) && !entry.closing) {
|
||||
entry.snapshotRequested = false;
|
||||
scheduleSnapshot(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -474,21 +432,6 @@ async function stopStream(sessionId, expectedToken) {
|
||||
activeStreams.delete(sessionId);
|
||||
entry.closing = true;
|
||||
|
||||
const record = sessionLogs.get(sessionId);
|
||||
const stopPromise = finalizeStream(sessionId, entry);
|
||||
if (record?.entry === entry) record.stopPromise = stopPromise;
|
||||
const finalPath = await stopPromise;
|
||||
if (record?.entry === entry) {
|
||||
record.entry = null;
|
||||
record.stopPromise = null;
|
||||
record.error = finalPath ? null : (entry.error || new Error("Failed to finalize session log"));
|
||||
}
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
async function finalizeStream(sessionId, entry) {
|
||||
clearSnapshotRetry(entry);
|
||||
|
||||
// Stop periodic flush
|
||||
if (entry.flushTimer) {
|
||||
clearInterval(entry.flushTimer);
|
||||
@@ -508,21 +451,11 @@ async function finalizeStream(sessionId, entry) {
|
||||
flushBuffer(entry);
|
||||
await waitForSnapshotIdle(entry);
|
||||
|
||||
// Close the raw write stream and wait for it to finish. A flush that
|
||||
// already disabled the entry must still end the stream, or the fd leaks.
|
||||
// Close the raw write stream and wait for it to finish.
|
||||
if (entry.writeStream) {
|
||||
let closeError = null;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
entry.writeStream.end((err) => err ? reject(err) : resolve());
|
||||
});
|
||||
} catch (err) {
|
||||
closeError = err;
|
||||
}
|
||||
if (entry.disabled || closeError) {
|
||||
entry.disabled = true;
|
||||
entry.error = entry.error || closeError || new Error("Session log stream failed");
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
} else if (!entry.disabled) {
|
||||
try {
|
||||
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry, { finalize: true }), "utf8");
|
||||
@@ -530,7 +463,6 @@ async function finalizeStream(sessionId, entry) {
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);
|
||||
entry.disabled = true;
|
||||
entry.error = err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,101 +472,6 @@ async function finalizeStream(sessionId, entry) {
|
||||
return entry.disabled ? null : finalPath;
|
||||
}
|
||||
|
||||
/** Flush the session's current log without ending recording, or return its
|
||||
* last finalized log. Paths are never inferred from labels or renderer input.
|
||||
*/
|
||||
async function prepareSessionLog(sessionId) {
|
||||
const record = sessionLogs.get(sessionId);
|
||||
if (!record) return null;
|
||||
|
||||
if (record.stopPromise) {
|
||||
await record.stopPromise;
|
||||
} else if (record.entry) {
|
||||
const entry = record.entry;
|
||||
if (entry.disabled) throw entry.error || new Error("Session log stream failed");
|
||||
flushBuffer(entry);
|
||||
if (entry.disabled) throw entry.error || new Error("Failed to flush session log");
|
||||
if (entry.isRaw) {
|
||||
// An empty write is a barrier for all earlier writes, not just the
|
||||
// manager's buffer. Calling write() alone does not make the file ready.
|
||||
await new Promise((resolve, reject) => {
|
||||
entry.writeStream.write("", (err) => err ? reject(err) : resolve());
|
||||
});
|
||||
} else {
|
||||
// Also materialize an empty txt/html log before its first output arrives.
|
||||
// Hold the retry until this wait returns, then resume on the backoff timer.
|
||||
entry.snapshotDirty = true;
|
||||
entry.deferSnapshotRetry = true;
|
||||
try {
|
||||
scheduleSnapshot(entry);
|
||||
await waitForSnapshotIdle(entry);
|
||||
if (entry.snapshotError) throw entry.snapshotError;
|
||||
} finally {
|
||||
entry.deferSnapshotRetry = false;
|
||||
if (!entry.snapshotPromise && !entry.closing
|
||||
&& (entry.snapshotRequested || entry.snapshotDirty)) {
|
||||
if (entry.snapshotError) scheduleSnapshotRetry(entry);
|
||||
else scheduleSnapshot(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop may have begun while the flush was pending; wait for its final file.
|
||||
if (record.stopPromise) await record.stopPromise;
|
||||
}
|
||||
|
||||
if (sessionLogs.get(sessionId) !== record) {
|
||||
throw new Error("Session log changed; try again");
|
||||
}
|
||||
if (record.error || record.entry?.disabled) {
|
||||
throw record.error || record.entry.error || new Error("Session log stream failed");
|
||||
}
|
||||
const stat = await fs.promises.stat(record.filePath);
|
||||
if (!stat.isFile()) throw new Error("Session log is not a file");
|
||||
if (sessionLogs.get(sessionId) !== record) {
|
||||
throw new Error("Session log changed; try again");
|
||||
}
|
||||
return record.filePath;
|
||||
}
|
||||
|
||||
/** Invalidate a previous connection's target without disturbing its writer.
|
||||
* Late finalization cannot restore it because records are identity-scoped.
|
||||
*/
|
||||
function forgetSessionLog(sessionId) {
|
||||
sessionLogs.delete(sessionId);
|
||||
}
|
||||
|
||||
function getSessionLogToken(sessionId) {
|
||||
return sessionLogs.get(sessionId)?.startToken ?? null;
|
||||
}
|
||||
|
||||
/** Drop only the inactive target observed before an asynchronous reconnect.
|
||||
* A continuing manual stream or a newly-created/finalized log must survive.
|
||||
*/
|
||||
function forgetInactiveSessionLog(sessionId, expectedToken) {
|
||||
if (!expectedToken || activeStreams.has(sessionId)
|
||||
|| getSessionLogToken(sessionId) !== expectedToken) return false;
|
||||
return sessionLogs.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Main-process fallback starts do not pass through the worker's lifecycle
|
||||
* guard. Retire their prior inactive log only after a successful same-id start.
|
||||
*/
|
||||
function withSessionLogRestart(handler) {
|
||||
return async (event, payload) => {
|
||||
const sessionId = payload?.sessionId;
|
||||
const previousToken = getSessionLogToken(sessionId);
|
||||
const result = await handler(event, payload);
|
||||
if (sessionId && result?.sessionId === sessionId && result.success !== false && !result.error) {
|
||||
forgetInactiveSessionLog(sessionId, previousToken);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function hasSessionLog(sessionId) {
|
||||
return sessionLogs.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has an active log stream.
|
||||
* @param {string} sessionId
|
||||
@@ -665,7 +502,6 @@ async function cleanupAll() {
|
||||
console.log(`[SessionLogStream] Cleaning up ${activeStreams.size} active streams`);
|
||||
const entries = [...activeStreams.entries()];
|
||||
await Promise.allSettled(entries.map(([id, entry]) => stopStream(id, entry.startToken)));
|
||||
sessionLogs.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -675,12 +511,6 @@ module.exports = {
|
||||
registerSudoAutofillInput,
|
||||
registerProgrammaticCommandLogRewrite,
|
||||
stopStream,
|
||||
prepareSessionLog,
|
||||
forgetSessionLog,
|
||||
getSessionLogToken,
|
||||
forgetInactiveSessionLog,
|
||||
withSessionLogRestart,
|
||||
hasSessionLog,
|
||||
hasStream,
|
||||
getActiveLogPaths,
|
||||
cleanupAll,
|
||||
|
||||
@@ -5,12 +5,6 @@ const path = require("node:path");
|
||||
const {
|
||||
appendData,
|
||||
hasStream,
|
||||
hasSessionLog,
|
||||
prepareSessionLog,
|
||||
forgetSessionLog,
|
||||
getSessionLogToken,
|
||||
forgetInactiveSessionLog,
|
||||
withSessionLogRestart,
|
||||
registerProgrammaticCommandLogRewrite,
|
||||
registerSudoAutofillInput,
|
||||
startStream,
|
||||
@@ -935,293 +929,3 @@ for (const format of ["txt", "html"]) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const format of ["txt", "html", "raw"]) {
|
||||
test(`prepare ${format} log flushes current output and leaves recording active`, async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-${format}-${Date.now()}`);
|
||||
const filePath = path.join(directory, `session.${format}`);
|
||||
const sessionId = `prepare-${format}`;
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format });
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath, "empty logs must exist before opening");
|
||||
assert.equal(fs.statSync(filePath).isFile(), true);
|
||||
appendData(sessionId, "first\r\n");
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath);
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /first/);
|
||||
assert.equal(hasStream(sessionId), true);
|
||||
appendData(sessionId, "second\r\n");
|
||||
await prepareSessionLog(sessionId);
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /first[\s\S]*second/);
|
||||
const stopping = stopStream(sessionId);
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath, "prepare waits for finalization");
|
||||
assert.equal(await stopping, filePath);
|
||||
assert.equal(hasStream(sessionId), false);
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath, "stopped logs remain accessible");
|
||||
assert.equal(await prepareSessionLog(`${sessionId}-wrong`), null);
|
||||
fs.unlinkSync(filePath);
|
||||
await assert.rejects(prepareSessionLog(sessionId), /ENOENT/);
|
||||
});
|
||||
}
|
||||
|
||||
test("prepare raw log waits for the write callback, including already-queued writes", async (t) => {
|
||||
const { Writable } = require("node:stream");
|
||||
const directory = path.join(TEMP_ROOT, `prepare-raw-barrier-${Date.now()}`);
|
||||
const filePath = path.join(directory, "session.log");
|
||||
const sessionId = "prepare-raw-barrier";
|
||||
let completedWrites = 0;
|
||||
t.mock.method(fs, "createWriteStream", () => new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
setTimeout(() => {
|
||||
fs.appendFileSync(filePath, chunk);
|
||||
completedWrites++;
|
||||
callback();
|
||||
}, 15);
|
||||
},
|
||||
}));
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format: "raw" });
|
||||
appendData(sessionId, "x".repeat(64 * 1024));
|
||||
const preparing = prepareSessionLog(sessionId);
|
||||
assert.equal(completedWrites, 0);
|
||||
assert.equal(await preparing, filePath);
|
||||
assert.equal(completedWrites, 2, "data write and barrier callbacks must both complete");
|
||||
assert.equal(fs.readFileSync(filePath, "utf8").length, 64 * 1024);
|
||||
});
|
||||
|
||||
test("prepare reports snapshot failures promptly and background writes keep retrying", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-error-${Date.now()}`);
|
||||
const filePath = path.join(directory, "session.txt");
|
||||
const sessionId = "prepare-snapshot-error";
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
let fail = true;
|
||||
let attempts = 0;
|
||||
t.mock.method(fs.promises, "writeFile", async (...args) => {
|
||||
if (args[0] === filePath) {
|
||||
attempts++;
|
||||
if (fail) throw new Error("disk full");
|
||||
}
|
||||
return writeFile(...args);
|
||||
});
|
||||
t.after(async () => {
|
||||
fail = false;
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
appendData(sessionId, "not lost\n");
|
||||
await assert.rejects(prepareSessionLog(sessionId), /disk full/);
|
||||
const attemptsAtReport = attempts;
|
||||
assert.ok(attemptsAtReport >= 1);
|
||||
fail = false;
|
||||
const started = Date.now();
|
||||
while (!fs.existsSync(filePath) || !fs.readFileSync(filePath, "utf8").includes("not lost")) {
|
||||
if (Date.now() - started > 2000) {
|
||||
assert.fail(`snapshot retry did not persist the log (attempts=${attempts})`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.ok(attempts > attemptsAtReport, "a failed snapshot write retries without another prepare");
|
||||
assert.equal(await prepareSessionLog(sessionId), filePath);
|
||||
});
|
||||
|
||||
test("persistent snapshot failures back off instead of retrying immediately", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `snapshot-backoff-${Date.now()}`);
|
||||
const filePath = path.join(directory, "session.txt");
|
||||
const sessionId = "snapshot-backoff";
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
let attempts = 0;
|
||||
t.mock.method(fs.promises, "writeFile", async (...args) => {
|
||||
if (args[0] === filePath) {
|
||||
attempts++;
|
||||
throw new Error("disk full");
|
||||
}
|
||||
return writeFile(...args);
|
||||
});
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
appendData(sessionId, "x".repeat(64 * 1024));
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.equal(attempts, 1, "a failed snapshot must not retry in a tight loop");
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
assert.equal(attempts, 2, "the first retry waits for the backoff delay");
|
||||
});
|
||||
|
||||
test("finalize ends a raw write stream that a synchronous flush already disabled", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `finalize-disabled-${Date.now()}`);
|
||||
const filePath = path.join(directory, "session.log");
|
||||
const sessionId = "finalize-disabled-raw";
|
||||
const stream = {
|
||||
ended: false,
|
||||
on() { return this; },
|
||||
write() { throw new Error("flush failed"); },
|
||||
end(callback) {
|
||||
this.ended = true;
|
||||
if (typeof callback === "function") callback();
|
||||
},
|
||||
};
|
||||
t.mock.method(fs, "createWriteStream", () => stream);
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const started = startStreamToFile(sessionId, { filePath, format: "raw" });
|
||||
assert.equal(started.ok, true);
|
||||
appendData(sessionId, "pending");
|
||||
assert.equal(await stopStream(sessionId), null);
|
||||
assert.equal(stream.ended, true);
|
||||
});
|
||||
|
||||
test("prepare reports raw stream errors rather than opening an empty or stale file", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-raw-error-${Date.now()}`);
|
||||
const filePath = path.join(directory, "directory.log");
|
||||
const sessionId = "prepare-raw-error";
|
||||
fs.mkdirSync(filePath, { recursive: true });
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath, format: "raw" });
|
||||
appendData(sessionId, "data");
|
||||
await assert.rejects(prepareSessionLog(sessionId), /EISDIR/);
|
||||
assert.equal(await stopStream(sessionId), null);
|
||||
await assert.rejects(prepareSessionLog(sessionId), /EISDIR/);
|
||||
});
|
||||
|
||||
test("late finalization and stale tokens cannot replace a reconnected session's target", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-reconnect-${Date.now()}`);
|
||||
const oldPath = path.join(directory, "old.txt");
|
||||
const newPath = path.join(directory, "new.txt");
|
||||
const sessionId = "prepare-reconnect";
|
||||
const writeFile = fs.promises.writeFile.bind(fs.promises);
|
||||
let releaseOldWrite;
|
||||
const oldWriteGate = new Promise((resolve) => { releaseOldWrite = resolve; });
|
||||
t.mock.method(fs.promises, "writeFile", async (...args) => {
|
||||
if (args[0] === oldPath) await oldWriteGate;
|
||||
return writeFile(...args);
|
||||
});
|
||||
t.after(async () => {
|
||||
releaseOldWrite();
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const first = startStreamToFile(sessionId, { filePath: oldPath, format: "txt" });
|
||||
appendData(sessionId, "old\n");
|
||||
const stopping = stopStream(sessionId, first.token);
|
||||
const stalePrepare = prepareSessionLog(sessionId);
|
||||
const second = startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
|
||||
assert.equal(second.ok, true);
|
||||
appendData(sessionId, "new\n");
|
||||
releaseOldWrite();
|
||||
await assert.rejects(stalePrepare, /changed/);
|
||||
assert.equal(await stopping, oldPath);
|
||||
assert.equal(await stopStream(sessionId, first.token), null);
|
||||
assert.equal(await prepareSessionLog(sessionId), newPath);
|
||||
await stopStream(sessionId, second.token);
|
||||
assert.equal(await prepareSessionLog(sessionId), newPath);
|
||||
});
|
||||
|
||||
test("forgetting a session prevents late finalization from restoring its old path", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `prepare-forget-${Date.now()}`);
|
||||
const filePath = path.join(directory, "old.txt");
|
||||
const sessionId = "prepare-forget";
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
appendData(sessionId, "old\n");
|
||||
const stopping = stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
await stopping;
|
||||
assert.equal(hasSessionLog(sessionId), false);
|
||||
assert.equal(await prepareSessionLog(sessionId), null);
|
||||
});
|
||||
|
||||
test("conditional log invalidation clears only the observed inactive stream token", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `conditional-forget-${Date.now()}`);
|
||||
const sessionId = "conditional-forget";
|
||||
const oldPath = path.join(directory, "old.txt");
|
||||
const newPath = path.join(directory, "new.txt");
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId);
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
assert.equal(getSessionLogToken(sessionId), null);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, null), false);
|
||||
const first = startStreamToFile(sessionId, { filePath: oldPath, format: "txt" });
|
||||
assert.equal(getSessionLogToken(sessionId), first.token);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false, "active recording survives");
|
||||
await stopStream(sessionId);
|
||||
assert.equal(getSessionLogToken(sessionId), first.token, "finalization preserves record identity");
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, Symbol("wrong")), false);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, first.token), true);
|
||||
assert.equal(await prepareSessionLog(sessionId), null);
|
||||
const second = startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
|
||||
assert.notEqual(second.token, first.token);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false);
|
||||
await stopStream(sessionId);
|
||||
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false, "a new finalized log survives");
|
||||
assert.equal(await prepareSessionLog(sessionId), newPath);
|
||||
});
|
||||
|
||||
for (const outcome of ["success", "throw", "failure", "different-session", "new-active", "new-finalized", "manual"]) {
|
||||
test(`fallback restart wrapper preserves the correct saved log (${outcome})`, async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `fallback-${outcome}-${Date.now()}`);
|
||||
const sessionId = `fallback-${outcome}`;
|
||||
const oldPath = path.join(directory, "old.txt");
|
||||
const newPath = path.join(directory, "new.txt");
|
||||
const event = { sender: { id: 9 } };
|
||||
const payload = { sessionId, sessionLog: { enabled: false } };
|
||||
t.after(async () => {
|
||||
await stopStream(sessionId, getSessionLogToken(sessionId));
|
||||
forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
startStreamToFile(sessionId, { filePath: oldPath, format: "txt", stopRequiresToken: outcome === "manual" });
|
||||
appendData(sessionId, "previous output\n");
|
||||
if (outcome !== "manual") await stopStream(sessionId);
|
||||
const previousToken = getSessionLogToken(sessionId);
|
||||
const start = withSessionLogRestart(async (receivedEvent, receivedPayload) => {
|
||||
assert.equal(receivedEvent, event);
|
||||
assert.equal(receivedPayload, payload);
|
||||
assert.equal(await prepareSessionLog(sessionId), oldPath, "pending starts retain the old target");
|
||||
if (outcome === "throw") throw new Error("authentication failed");
|
||||
if (outcome === "failure") return { sessionId, success: false, error: "connection refused" };
|
||||
if (outcome === "different-session") return { sessionId: "different" };
|
||||
if (outcome.startsWith("new-")) {
|
||||
startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
|
||||
appendData(sessionId, "new output\n");
|
||||
if (outcome === "new-finalized") await stopStream(sessionId);
|
||||
}
|
||||
return { sessionId };
|
||||
});
|
||||
if (outcome === "throw") await assert.rejects(start(event, payload), /authentication failed/);
|
||||
else {
|
||||
const result = await start(event, payload);
|
||||
assert.equal(result.sessionId, outcome === "different-session" ? "different" : sessionId);
|
||||
}
|
||||
if (outcome === "success") {
|
||||
assert.equal(await prepareSessionLog(sessionId), null);
|
||||
} else {
|
||||
const newLog = outcome.startsWith("new-");
|
||||
assert.equal(await prepareSessionLog(sessionId), newLog ? newPath : oldPath);
|
||||
if (newLog) assert.notEqual(getSessionLogToken(sessionId), previousToken);
|
||||
else assert.equal(getSessionLogToken(sessionId), previousToken);
|
||||
assert.equal(hasStream(sessionId), outcome === "manual" || outcome === "new-active");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { dialog, shell } = require("electron");
|
||||
const { dialog } = require("electron");
|
||||
const {
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
@@ -782,83 +782,13 @@ async function stopManualSessionLog(event, payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getManualSessionLogStatus(event, payload = {}, terminalWorkerManager = null) {
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
async function getManualSessionLogStatus(event, payload = {}) {
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { sessionId } = payload;
|
||||
if (!validSessionLogId(sessionId)) {
|
||||
return { success: false, isLogging: false, hasSessionLog: false, error: "Missing sessionId" };
|
||||
}
|
||||
// Keep the recording toggle main-owned. Automatic logs may live in the
|
||||
// worker, and stopped logs remain available independently of active streams.
|
||||
const isLogging = manager.hasStream(sessionId);
|
||||
let hasSessionLog = manager.hasSessionLog(sessionId);
|
||||
if (!hasSessionLog && isTerminalWorkerRunning(terminalWorkerManager)) {
|
||||
try {
|
||||
const result = await terminalWorkerManager.request(
|
||||
"netcatty:sessionLog:statusLocal", { sessionId }, {},
|
||||
);
|
||||
hasSessionLog = result?.success === true && result.hasSessionLog === true;
|
||||
} catch {
|
||||
// An unavailable worker must not manufacture an openable log target.
|
||||
}
|
||||
}
|
||||
return { success: true, isLogging, hasSessionLog };
|
||||
}
|
||||
|
||||
function validSessionLogId(sessionId) {
|
||||
return typeof sessionId === "string" && sessionId.trim().length > 0;
|
||||
}
|
||||
|
||||
async function prepareLocalSessionLog(_event, payload = {}) {
|
||||
if (!validSessionLogId(payload?.sessionId)) {
|
||||
return { success: false, error: "Missing sessionId" };
|
||||
}
|
||||
try {
|
||||
const filePath = await require("./sessionLogStreamManager.cjs").prepareSessionLog(payload.sessionId);
|
||||
return filePath
|
||||
? { success: true, filePath }
|
||||
: { success: false, error: "No saved log is available for this session" };
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve only a main/worker-owned session target. Renderer paths, host labels
|
||||
* and configured directories are deliberately not part of this operation.
|
||||
*/
|
||||
async function prepareSessionLog(event, payload = {}, terminalWorkerManager = null) {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!validSessionLogId(sessionId)) {
|
||||
return { success: false, error: "Missing sessionId" };
|
||||
}
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
if (manager.hasSessionLog(sessionId)) {
|
||||
return prepareLocalSessionLog(event, { sessionId });
|
||||
}
|
||||
if (!isTerminalWorkerRunning(terminalWorkerManager)) {
|
||||
return { success: false, error: "No saved log is available for this session" };
|
||||
}
|
||||
try {
|
||||
const result = await terminalWorkerManager.request(
|
||||
"netcatty:sessionLog:prepareLocal", { sessionId }, {},
|
||||
);
|
||||
return result?.success && typeof result.filePath === "string" && result.filePath
|
||||
? { success: true, filePath: result.filePath }
|
||||
: { success: false, error: result?.error || "No saved log is available for this session" };
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function revealSessionLog(event, payload = {}, terminalWorkerManager = null) {
|
||||
const result = await prepareSessionLog(event, payload, terminalWorkerManager);
|
||||
if (!result.success) return result;
|
||||
try {
|
||||
shell.showItemInFolder(result.filePath);
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
if (!sessionId) {
|
||||
return { success: false, isLogging: false, error: "Missing sessionId" };
|
||||
}
|
||||
return { success: true, isLogging: sessionLogStreamManager.hasStream(sessionId) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -867,11 +797,6 @@ async function revealSessionLog(event, payload = {}, terminalWorkerManager = nul
|
||||
*/
|
||||
function registerWorkerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
|
||||
ipcMain.handle("netcatty:sessionLog:prepareLocal", prepareLocalSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLog:statusLocal", async (_event, payload = {}) => ({
|
||||
success: true,
|
||||
hasSessionLog: require("./sessionLogStreamManager.cjs").hasSessionLog(payload.sessionId),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -893,34 +818,7 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
ipcMain.handle("netcatty:sessionLog:manualChoosePath", chooseManualSessionLogPath);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStart", startManualSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStop", stopManualSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStatus", (event, payload) => (
|
||||
getManualSessionLogStatus(event, payload, terminalWorkerManager)
|
||||
));
|
||||
ipcMain.handle("netcatty:sessionLog:prepare", (event, payload) => (
|
||||
prepareSessionLog(event, payload, terminalWorkerManager)
|
||||
));
|
||||
ipcMain.handle("netcatty:sessionLog:reveal", (event, payload) => (
|
||||
revealSessionLog(event, payload, terminalWorkerManager)
|
||||
));
|
||||
|
||||
// Disconnected tabs may still open their last manual log. Once that session
|
||||
// reconnects, it must not mask the new worker-owned automatic log instead.
|
||||
const disconnectedSessions = new Set();
|
||||
terminalWorkerManager?.onSessionClosed?.(({ sessionId, explicit }) => {
|
||||
if (explicit) {
|
||||
disconnectedSessions.delete(sessionId);
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
if (!manager.hasStream(sessionId)) manager.forgetSessionLog(sessionId);
|
||||
} else {
|
||||
disconnectedSessions.add(sessionId);
|
||||
}
|
||||
});
|
||||
terminalWorkerManager?.onSessionOwned?.(({ sessionId }) => {
|
||||
if (!disconnectedSessions.delete(sessionId)) return;
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
// Continuous manual recording intentionally survives terminal reconnects.
|
||||
if (!manager.hasStream(sessionId)) manager.forgetSessionLog(sessionId);
|
||||
});
|
||||
ipcMain.handle("netcatty:sessionLog:manualStatus", getManualSessionLogStatus);
|
||||
|
||||
// In the default terminal-worker runtime, sessions run in a utilityProcess
|
||||
// and call appendData() on the worker's own sessionLogStreamManager module
|
||||
@@ -952,9 +850,6 @@ module.exports = {
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
prepareLocalSessionLog,
|
||||
prepareSessionLog,
|
||||
revealSessionLog,
|
||||
toLocalISOString,
|
||||
terminalDataToHtml,
|
||||
terminalPlainTextToHtml,
|
||||
|
||||
@@ -15,11 +15,11 @@ async function waitForPath(targetPath, { timeoutMs = 2000 } = {}) {
|
||||
throw new Error(`Timed out waiting for path: ${targetPath}`);
|
||||
}
|
||||
|
||||
function loadBridgeWithDialog(dialogMock, shellMock = {}) {
|
||||
function loadBridgeWithDialog(dialogMock) {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "electron") {
|
||||
return { dialog: dialogMock, shell: shellMock };
|
||||
return { dialog: dialogMock };
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
@@ -1001,263 +1001,3 @@ test("screen export escapes HTML when the user selects an HTML filename", async
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function registerSessionLogTestHandlers(bridge, terminalWorkerManager) {
|
||||
const handlers = new Map();
|
||||
bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, { terminalWorkerManager });
|
||||
return handlers;
|
||||
}
|
||||
|
||||
test("prepare and reveal use the current manual log before querying automatic worker logs", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-manual-${Date.now()}`);
|
||||
const filePath = path.join(directory, "manual.txt");
|
||||
const sessionId = "shortcut-manual";
|
||||
const revealed = [];
|
||||
const bridge = loadBridgeWithDialog(
|
||||
{ showSaveDialog: async () => ({ canceled: false, filePath }) },
|
||||
{ showItemInFolder: (target) => revealed.push(target) },
|
||||
);
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const workerRequests = [];
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
request: async (...args) => { workerRequests.push(args); return { success: true, filePath: "/worker/log.txt" }; },
|
||||
});
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
await bridge.startManualSessionLog(null, { sessionId, format: "txt" });
|
||||
manager.appendData(sessionId, "fresh output\n");
|
||||
const prepare = handlers.get("netcatty:sessionLog:prepare");
|
||||
const reveal = handlers.get("netcatty:sessionLog:reveal");
|
||||
assert.deepEqual(await prepare(null, { sessionId, filePath: "/untrusted/path" }), { success: true, filePath });
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /fresh output/);
|
||||
assert.equal(manager.hasStream(sessionId), true);
|
||||
assert.deepEqual(await reveal(null, { sessionId, filePath: "/untrusted/path" }), { success: true, filePath });
|
||||
assert.deepEqual(revealed, [filePath]);
|
||||
await bridge.stopManualSessionLog(null, { sessionId });
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
assert.deepEqual(workerRequests, []);
|
||||
});
|
||||
|
||||
test("prepare routes automatic logs only to an already running worker with sessionId only", async () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const requests = [];
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
request: async (...args) => {
|
||||
requests.push(args);
|
||||
return { success: true, filePath: "/server-owned/automatic.html" };
|
||||
},
|
||||
});
|
||||
const result = await handlers.get("netcatty:sessionLog:prepare")(null, {
|
||||
sessionId: "shortcut-auto", filePath: "/arbitrary/renderer/path", directory: "/untrusted",
|
||||
});
|
||||
assert.deepEqual(result, { success: true, filePath: "/server-owned/automatic.html" });
|
||||
assert.deepEqual(requests, [["netcatty:sessionLog:prepareLocal", { sessionId: "shortcut-auto" }, {}]]);
|
||||
assert.equal(handlers.has("netcatty:sessionLog:prepareLocal"), false, "internal lookup is not renderer IPC");
|
||||
});
|
||||
|
||||
test("prepare rejects malformed ids and never cold-starts an idle worker", async () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
let requests = 0;
|
||||
const worker = { isRunning: () => false, request: async () => { requests++; } };
|
||||
for (const payload of [undefined, null, {}, { sessionId: "" }, { sessionId: " " }, { sessionId: {} }]) {
|
||||
assert.deepEqual(await bridge.prepareSessionLog(null, payload, worker), { success: false, error: "Missing sessionId" });
|
||||
}
|
||||
assert.deepEqual(await bridge.prepareSessionLog(null, { sessionId: "unknown", filePath: __filename }, worker), {
|
||||
success: false, error: "No saved log is available for this session",
|
||||
});
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test("prepare and reveal surface missing logs, worker errors, and shell failures", async () => {
|
||||
const bridge = loadBridgeWithDialog({}, { showItemInFolder: () => { throw new Error("file manager unavailable"); } });
|
||||
const payload = { sessionId: "shortcut-error" };
|
||||
const worker = {
|
||||
isRunning: () => true,
|
||||
request: async () => { throw new Error("worker unavailable"); },
|
||||
};
|
||||
assert.deepEqual(await bridge.prepareSessionLog(null, payload, worker), { success: false, error: "worker unavailable" });
|
||||
worker.request = async () => ({ success: false, error: "No saved log is available for this session" });
|
||||
assert.deepEqual(await bridge.revealSessionLog(null, payload, worker), { success: false, error: "No saved log is available for this session" });
|
||||
worker.request = async () => ({ success: true });
|
||||
assert.equal((await bridge.prepareSessionLog(null, payload, worker)).success, false);
|
||||
worker.request = async () => ({ success: true, filePath: "/server-owned/log.txt" });
|
||||
assert.deepEqual(await bridge.revealSessionLog(null, payload, worker), { success: false, error: "file manager unavailable" });
|
||||
});
|
||||
|
||||
test("worker prepare handler flushes and retains only the requested session's log", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-worker-${Date.now()}`);
|
||||
const filePath = path.join(directory, "worker.txt");
|
||||
const sessionId = "shortcut-worker";
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const handlers = new Map();
|
||||
bridge.registerWorkerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) });
|
||||
const prepare = handlers.get("netcatty:sessionLog:prepareLocal");
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
manager.appendData(sessionId, "worker output\n");
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /worker output/);
|
||||
assert.equal(manager.hasStream(sessionId), true);
|
||||
assert.equal((await prepare(null, { sessionId: "wrong-session", filePath })).success, false);
|
||||
await manager.stopStream(sessionId);
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
});
|
||||
|
||||
test("a missing manual log reports an error instead of silently opening another worker log", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-missing-${Date.now()}`);
|
||||
const filePath = path.join(directory, "missing.txt");
|
||||
const sessionId = "shortcut-missing";
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
await manager.stopStream(sessionId);
|
||||
fs.unlinkSync(filePath);
|
||||
const result = await bridge.prepareSessionLog(null, { sessionId }, {
|
||||
isRunning: () => true,
|
||||
request: async () => assert.fail("must not fall back to an unrelated automatic log"),
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /ENOENT/);
|
||||
});
|
||||
|
||||
test("disconnect retains a manual log but reconnect clears its precedence over a new automatic log", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-reconnect-${Date.now()}`);
|
||||
const filePath = path.join(directory, "old-manual.txt");
|
||||
const sessionId = "shortcut-reconnect";
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
let onClosed;
|
||||
let onOwned;
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
onSessionClosed: (listener) => { onClosed = listener; },
|
||||
onSessionOwned: (listener) => { onOwned = listener; },
|
||||
request: async () => ({ success: true, filePath: "/worker/new-connection.txt" }),
|
||||
});
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
await manager.stopStream(sessionId);
|
||||
const prepare = handlers.get("netcatty:sessionLog:prepare");
|
||||
onClosed({ sessionId, reason: "exited" });
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
onOwned({ sessionId, webContentsId: 2 });
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath: "/worker/new-connection.txt" });
|
||||
});
|
||||
|
||||
test("a live manual recording keeps its target across disconnect and reconnect", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `shortcut-live-reconnect-${Date.now()}`);
|
||||
const filePath = path.join(directory, "manual.txt");
|
||||
const sessionId = "shortcut-live-reconnect";
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
let onClosed;
|
||||
let onOwned;
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
onSessionClosed: (listener) => { onClosed = listener; },
|
||||
onSessionOwned: (listener) => { onOwned = listener; },
|
||||
request: async () => assert.fail("live manual recording must retain precedence"),
|
||||
});
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
|
||||
manager.appendData(sessionId, "before\n");
|
||||
onClosed({ sessionId, reason: "exited" });
|
||||
onOwned({ sessionId, webContentsId: 2 });
|
||||
manager.appendData(sessionId, "after\n");
|
||||
const prepare = handlers.get("netcatty:sessionLog:prepare");
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
assert.match(fs.readFileSync(filePath, "utf8"), /before[\s\S]*after/);
|
||||
onClosed({ sessionId, explicit: true });
|
||||
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
|
||||
});
|
||||
|
||||
test("log menu availability follows the owning stream, not automatic logging settings", async () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const workerHandlers = new Map();
|
||||
bridge.registerWorkerHandlers({ handle: (channel, handler) => workerHandlers.set(channel, handler) });
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const workerLogs = new Set();
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => true,
|
||||
request: async (channel, payload) => {
|
||||
assert.equal(channel, "netcatty:sessionLog:statusLocal");
|
||||
return { success: true, hasSessionLog: workerLogs.has(payload.sessionId) };
|
||||
},
|
||||
});
|
||||
const status = handlers.get("netcatty:sessionLog:manualStatus");
|
||||
for (const sessionId of ["enabled-after-connect", "failed-before-shell"]) {
|
||||
assert.equal((await status(null, { sessionId, enabled: true })).hasSessionLog, false);
|
||||
}
|
||||
workerLogs.add("recording-after-disabled");
|
||||
assert.deepEqual(await status(null, { sessionId: "recording-after-disabled", enabled: false }), {
|
||||
success: true, isLogging: false, hasSessionLog: true,
|
||||
});
|
||||
assert.equal((await status(null, { sessionId: "another-session" })).hasSessionLog, false);
|
||||
// A successful replacement without logging retires the worker-owned target.
|
||||
workerLogs.delete("recording-after-disabled");
|
||||
assert.equal((await status(null, { sessionId: "recording-after-disabled" })).hasSessionLog, false);
|
||||
assert.equal(handlers.has("netcatty:sessionLog:statusLocal"), false);
|
||||
assert.deepEqual(await workerHandlers.get("netcatty:sessionLog:statusLocal")(null, { sessionId: "missing" }), {
|
||||
success: true, hasSessionLog: false,
|
||||
});
|
||||
assert.equal(manager.hasSessionLog("missing"), false);
|
||||
});
|
||||
|
||||
test("log availability retains stopped main logs and never starts a worker to check", async (t) => {
|
||||
const directory = path.join(TEMP_ROOT, `availability-${Date.now()}`);
|
||||
const filePath = path.join(directory, "manual.txt");
|
||||
const sessionId = "availability-main";
|
||||
const bridge = loadBridgeWithDialog({ showSaveDialog: async () => ({ canceled: false, filePath }) });
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const handlers = registerSessionLogTestHandlers(bridge, {
|
||||
isRunning: () => false,
|
||||
request: () => assert.fail("must not cold-start a worker"),
|
||||
});
|
||||
t.after(async () => {
|
||||
await manager.cleanupAll();
|
||||
manager.forgetSessionLog(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const status = handlers.get("netcatty:sessionLog:manualStatus");
|
||||
assert.equal((await status(null, { sessionId })).hasSessionLog, false);
|
||||
assert.equal((await bridge.startManualSessionLog(null, { sessionId, format: "txt" })).started, true);
|
||||
assert.deepEqual(await status(null, { sessionId }), { success: true, isLogging: true, hasSessionLog: true });
|
||||
await bridge.stopManualSessionLog(null, { sessionId });
|
||||
assert.deepEqual(await status(null, { sessionId }), { success: true, isLogging: false, hasSessionLog: true });
|
||||
const workerHandlers = new Map();
|
||||
bridge.registerWorkerHandlers({ handle: (channel, handler) => workerHandlers.set(channel, handler) });
|
||||
assert.deepEqual(await workerHandlers.get("netcatty:sessionLog:statusLocal")(null, { sessionId }), {
|
||||
success: true, hasSessionLog: true,
|
||||
}, "worker lookup also retains stopped logs without requiring an active stream");
|
||||
});
|
||||
|
||||
test("an unavailable worker does not offer a nonexistent log or change manual recording status", async () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
for (const request of [
|
||||
async () => { throw new Error("worker stopped"); },
|
||||
async () => ({ success: false, error: "session missing" }),
|
||||
]) {
|
||||
const handlers = registerSessionLogTestHandlers(bridge, { isRunning: () => true, request });
|
||||
assert.deepEqual(await handlers.get("netcatty:sessionLog:manualStatus")(null, { sessionId: "unavailable" }), {
|
||||
success: true, isLogging: false, hasSessionLog: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1656,27 +1656,21 @@ async function runRemoteUploadTransaction(client, localPath, remotePath, options
|
||||
signal,
|
||||
));
|
||||
assertCanPromote();
|
||||
// Mode restore is a remote chmod on the stage. Recheck the destination
|
||||
// only after that round trip. A non-finite existingMode (new files, and
|
||||
// targets whose mode could not be captured) makes restore a no-op, so a
|
||||
// second lstat would be a serial RTT on every such upload.
|
||||
if (Number.isFinite(plan.existingMode)) {
|
||||
// Apply the old mode to the stage before promotion. A failed chmod must not
|
||||
// replace an executable final with a non-executable file and report success.
|
||||
await restoreRemoteMode(client, encodedStagedPath, plan.existingMode, {
|
||||
bestEffort: false,
|
||||
});
|
||||
assertCanPromote();
|
||||
await runCancelablePreflight(() => assertStagedPromotionTargetSafe(
|
||||
client,
|
||||
encodedPath,
|
||||
remotePath,
|
||||
plan.destinationExisted,
|
||||
plan.destinationSnapshot,
|
||||
signal,
|
||||
));
|
||||
assertCanPromote();
|
||||
}
|
||||
// Apply the old mode to the stage before promotion. A failed chmod must not
|
||||
// replace an executable final with a non-executable file and report success.
|
||||
await restoreRemoteMode(client, encodedStagedPath, plan.existingMode, {
|
||||
bestEffort: false,
|
||||
});
|
||||
assertCanPromote();
|
||||
await runCancelablePreflight(() => assertStagedPromotionTargetSafe(
|
||||
client,
|
||||
encodedPath,
|
||||
remotePath,
|
||||
plan.destinationExisted,
|
||||
plan.destinationSnapshot,
|
||||
signal,
|
||||
));
|
||||
assertCanPromote();
|
||||
commitPromotion();
|
||||
await renameRemotePath(client, encodedStagedPath, encodedPath, encodedBackupPath, {
|
||||
stagePath: stagedLogical,
|
||||
|
||||
@@ -120,12 +120,6 @@ test("openSftp clears its authentication timer when SSH becomes ready", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftp disables Nagle on direct and jump-hop SSH sockets", () => {
|
||||
const source = readFileSync(require.resolve("./sftpBridge/openConnection.cjs"), "utf8");
|
||||
assert.match(source, /sshClient\.once\('ready', \(\) => \{\s*clearAuthReadyTimer\(\);\s*cleanup\(\);[\s\S]{0,200}enableSshNoDelay\(sshClient\);/);
|
||||
assert.match(source, /conn\.once\('ready', \(\) => \{[\s\S]{0,400}enableSshNoDelay\(conn\);/);
|
||||
});
|
||||
|
||||
test("openSftp forwards target hostId to keyboard-interactive prompts", () => {
|
||||
const source = readFileSync(require.resolve("./sftpBridge/openConnection.cjs"), "utf8");
|
||||
assert.match(
|
||||
|
||||
@@ -571,104 +571,6 @@ test("existing destinations stage then restore mode after rename", async (t) =>
|
||||
assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755);
|
||||
});
|
||||
|
||||
test("new-file staged upload lstats the destination once before rename", async (t) => {
|
||||
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-new-file-lstat-"));
|
||||
t.after(async () => {
|
||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||||
|
||||
const localPath = path.join(tempRoot, "payload.bin");
|
||||
const payload = Buffer.from("new-file");
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
const remotePath = "/home/alice/new-file.bin";
|
||||
|
||||
const { channel, remoteFiles, chmodCalls } = createSessionChannel();
|
||||
const originalLstat = channel.lstat.bind(channel);
|
||||
let destinationLstats = 0;
|
||||
channel.lstat = (targetPath, callback) => {
|
||||
if (String(targetPath) === remotePath) destinationLstats += 1;
|
||||
originalLstat(targetPath, callback);
|
||||
};
|
||||
|
||||
const sftpClients = new Map();
|
||||
sftpBridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["session-new-file-lstat", { conn: { sftp: (cb) => cb(null, channel) } }]]),
|
||||
sftpClients,
|
||||
});
|
||||
const opened = await sftpBridge.openSftpForSession(null, {
|
||||
sessionId: "session-new-file-lstat",
|
||||
fileProtocol: "sftp",
|
||||
});
|
||||
|
||||
const result = await sftpBridge.uploadLocalToSftp(null, {
|
||||
sftpId: opened.sftpId,
|
||||
localPath,
|
||||
remotePath,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(remoteFiles.get(remotePath), payload);
|
||||
assert.deepEqual(chmodCalls, []);
|
||||
// Plan lstat plus one pre-rename check. existingMode is null, so the
|
||||
// post-restore lstat would be a no-op round trip.
|
||||
assert.equal(destinationLstats, 2);
|
||||
});
|
||||
|
||||
test("existing-file staged upload rechecks the destination after mode restore", async (t) => {
|
||||
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-existing-file-lstat-"));
|
||||
t.after(async () => {
|
||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||||
|
||||
const localPath = path.join(tempRoot, "tool.bin");
|
||||
const payload = Buffer.from("replaced-tool");
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
const remotePath = "/usr/local/bin/tool";
|
||||
|
||||
const { channel, remoteFiles, remoteMeta, chmodCalls } = createSessionChannel();
|
||||
remoteFiles.set(remotePath, Buffer.from("old-tool"));
|
||||
remoteMeta.set(remotePath, { mode: 0o100755, mtime: 1, uid: 1000, gid: 1000 });
|
||||
const originalLstat = channel.lstat.bind(channel);
|
||||
let destinationLstats = 0;
|
||||
channel.lstat = (targetPath, callback) => {
|
||||
if (String(targetPath) === remotePath) destinationLstats += 1;
|
||||
originalLstat(targetPath, callback);
|
||||
};
|
||||
|
||||
const sftpClients = new Map();
|
||||
sftpBridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["session-existing-file-lstat", { conn: { sftp: (cb) => cb(null, channel) } }]]),
|
||||
sftpClients,
|
||||
});
|
||||
const opened = await sftpBridge.openSftpForSession(null, {
|
||||
sessionId: "session-existing-file-lstat",
|
||||
fileProtocol: "sftp",
|
||||
});
|
||||
|
||||
const result = await sftpBridge.uploadLocalToSftp(null, {
|
||||
sftpId: opened.sftpId,
|
||||
localPath,
|
||||
remotePath,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(remoteFiles.get(remotePath), payload);
|
||||
assert.equal(remoteMeta.get(remotePath)?.mode & 0o777, 0o755);
|
||||
assert.ok(
|
||||
chmodCalls.some((call) => String(call.targetPath).includes(".netcatty-upload-")),
|
||||
`expected staged chmod, got ${JSON.stringify(chmodCalls)}`,
|
||||
);
|
||||
// Plan lstat, pre-restore check, and the post-chmod recheck that covers the
|
||||
// mode-restore round trip.
|
||||
assert.equal(destinationLstats, 3);
|
||||
});
|
||||
|
||||
test("mode restore failure leaves the existing destination untouched", async (t) => {
|
||||
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-mode-fail-"));
|
||||
t.after(async () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ const {
|
||||
terminateSshExecStream,
|
||||
} = require("../boundedSshExec.cjs");
|
||||
const { openBoundedSftpChannel } = require("../boundedSftpOpen.cjs");
|
||||
const { enableSshNoDelay } = require("../tcpNoDelay.cjs");
|
||||
const { openBoundedForwardOutCallback } = require("../boundedSshChannelOpen.cjs");
|
||||
const { remoteSoftwareRequiresSingleChannel } = require("../../../domain/singleChannelSshBanner.shared.cjs");
|
||||
|
||||
@@ -421,10 +420,6 @@ function createOpenConnectionApi(ctx) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearAuthReadyTimer();
|
||||
// SFTP RPCs are small request/response packets; with Nagle on, a
|
||||
// second request behind an unacked one waits for the peer's
|
||||
// delayed ACK (~40 ms per round trip on Linux, up to 200 ms on Windows).
|
||||
enableSshNoDelay(conn);
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'connected');
|
||||
resolve();
|
||||
@@ -1457,8 +1452,6 @@ function createOpenConnectionApi(ctx) {
|
||||
sshClient.once('ready', () => {
|
||||
clearAuthReadyTimer();
|
||||
cleanup();
|
||||
// See jump-hop note above: SFTP is RPC-bound, so disable Nagle.
|
||||
enableSshNoDelay(sshClient);
|
||||
if (remoteSoftwareRequiresSingleChannel(sshClient._remoteVer)) {
|
||||
peerSingleChannelSsh = true;
|
||||
}
|
||||
|
||||
@@ -508,10 +508,6 @@ function init(deps) {
|
||||
}
|
||||
|
||||
function openTerminalOutputSession(sessionId, webContents) {
|
||||
require("./rendererSessionLifetime.cjs").trackRendererSessionLifetime(
|
||||
sessions, sessionId, webContents,
|
||||
(event, payload) => require("./terminalBridge.cjs").closeSession(event, payload),
|
||||
);
|
||||
terminalOutputChannel?.openSession?.(sessionId, webContents);
|
||||
}
|
||||
|
||||
@@ -1625,9 +1621,7 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ipcMain.handle("netcatty:start", options.manageSessionLogRestart === true
|
||||
? sessionLogStreamManager.withSessionLogRestart(startSSHSessionWrapper)
|
||||
: startSSHSessionWrapper);
|
||||
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
|
||||
ipcMain.handle("netcatty:ssh:exec", execCommand);
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:ssh:remoteInfo", getSessionRemoteInfo);
|
||||
|
||||
@@ -105,7 +105,7 @@ function withRemoteWatchdog(command, timeoutMs) {
|
||||
// exec replaces the command-substitution shell: without it Bash may
|
||||
// report that intermediate PID instead of the watchdog subshell.
|
||||
`&& nc_self=$(exec sh -c 'echo $PPID' 2>/dev/null)`,
|
||||
`&& nc_tree=$(ps -e -o pid= -o ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
|
||||
`&& nc_tree=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
|
||||
`&& kill -9 $nc_tree "$$" 2>/dev/null ) </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!`,
|
||||
].join(' ');
|
||||
return [
|
||||
@@ -115,7 +115,7 @@ function withRemoteWatchdog(command, timeoutMs) {
|
||||
// Reap the watchdog's pending `sleep` before killing the subshell: after
|
||||
// the subshell is SIGKILLed its children are reparented to init and a
|
||||
// PPID walk can no longer find them.
|
||||
'nc_kids=$(ps -e -o pid= -o ppid= 2>/dev/null | awk -v root="$nc_watchdog_pid" -v self="-1" \'' +
|
||||
'nc_kids=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$nc_watchdog_pid" -v self="-1" \'' +
|
||||
descendantTreeAwk +
|
||||
"')",
|
||||
'kill -9 $nc_kids "$nc_watchdog_pid" 2>/dev/null',
|
||||
@@ -589,7 +589,7 @@ function createSessionOpsApi(ctx) {
|
||||
# different parent) of this exec session, so the sibling search misses it
|
||||
# entirely (#1123).
|
||||
find_login_shell() {
|
||||
_shell=$(ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
_shell=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
|
||||
$1 != self && $2 == pp && isshell($4) {
|
||||
if ($3 !~ /^\\?+$/) { print $1; found=1; exit }
|
||||
@@ -638,7 +638,7 @@ function createSessionOpsApi(ctx) {
|
||||
# su'd / sudo'd shell instead of stopping at the login shell (#1065). Falls
|
||||
# back to the login shell when no foreground shell is found.
|
||||
find_active_shell() {
|
||||
ps -e -o pid= -o ppid= -o stat= -o comm= 2>/dev/null | awk -v start="$1" '
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 }
|
||||
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
|
||||
function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 }
|
||||
@@ -826,7 +826,7 @@ function createSessionOpsApi(ctx) {
|
||||
if (session.singleChannelSsh) return null;
|
||||
const script = `SELF=$$
|
||||
find_login_shell() {
|
||||
ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
@@ -834,7 +834,7 @@ function createSessionOpsApi(ctx) {
|
||||
END { if (!found && any != "") print any }'
|
||||
}
|
||||
find_fg_leaf() {
|
||||
ps -e -o pid= -o ppid= -o stat= -o comm= 2>/dev/null | awk -v start="$1" '
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; ord[NR]=$1 }
|
||||
function depth(p, d){ d=0; while(p!="" && d<64){ if(p==start) return d; p=pp[p]; d++ } return -1 }
|
||||
END { best=-1; bp=""; for(i=1;i<=NR;i++){ p=ord[i];
|
||||
@@ -988,7 +988,7 @@ function createSessionOpsApi(ctx) {
|
||||
// so that relative paths like "." or "src" are resolved correctly.
|
||||
const needsCwdResolve = !dirPath.startsWith('/') && dirPath !== '~' && !dirPath.startsWith('~/');
|
||||
const cwdResolveCmd = needsCwdResolve
|
||||
? `_sc_p=$(ps --ppid $PPID -o pid= -o comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid= -o ppid= -o comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
|
||||
? `_sc_p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
|
||||
: '';
|
||||
const cmd = `${cwdResolveCmd}find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
|
||||
prefix="$1"
|
||||
@@ -1210,7 +1210,7 @@ function createSessionOpsApi(ctx) {
|
||||
`swapused=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="used"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);su=v*m}};printf "%.0f",su+0}')`,
|
||||
`swapfree=$(echo "$swaptotal $swapused" | awk '{printf "%.0f",$1-$2}')`,
|
||||
// Top processes by memory%
|
||||
`procs=$(ps -A -o pid= -o %mem= -o comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
|
||||
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
|
||||
// Disk: -P keeps a stable six-column POSIX layout on macOS.
|
||||
`mounts=$(mount 2>/dev/null || true)`,
|
||||
`disks=$({ printf "%s\n" "$mounts"; printf "__NETCATTY_DF__\n"; df -kP 2>/dev/null; } | awk '$0=="__NETCATTY_DF__"{in_df=1;next} !in_df{if(index($1,"/dev/disk")==1&&(index($0,"(apfs,")||index($0,"(apfs)"))){key=$1;sub(/s[0-9].*$/,"",key);capacity[$1]="apfs:" key}next} $1=="Filesystem"{next} index($1,"/dev/")==1{m=$6;if(m=="/"||index(m,"/Volumes/")==1){t=$2/1048576;key=(($1 in capacity)?capacity[$1]:$1);if(key~/^apfs:/){u=($2-$4)/1048576;p=(t>0?int(u*100/t+0.5):0)}else{u=$3/1048576;p=$5;gsub(/%/,"",p)}printf "%s:%.6f:%.6f:%s:%s,",m,u,t,p,key}}' | sed 's/,$//')`,
|
||||
|
||||
@@ -333,9 +333,6 @@ test("cwd probe forces POSIX sh, captures sshd PPID, and keeps the watchdog", as
|
||||
assert.ok(command.includes(") </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!"));
|
||||
assert.ok(command.includes('kill -9 $nc_kids "$nc_watchdog_pid"'));
|
||||
assert.ok(command.includes('find_login_shell "${NC_SSHD_PPID:-$PPID}"'));
|
||||
assert.match(command, /ps -e -o pid= -o ppid= -o tty= -o comm=/);
|
||||
assert.match(command, /ps -e -o pid= -o ppid= -o stat= -o comm=/);
|
||||
assert.equal(command.includes("pid=,"), false);
|
||||
});
|
||||
|
||||
test("immediate parked reconnect does not guess cwd from an exiting shell", async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ const discoveryDisabledConnections = new WeakSet();
|
||||
|
||||
function buildInteractiveShellPidCommand(quoteShellArg) {
|
||||
const script = `SELF=$$
|
||||
ps_output=$(ps -e -o pid= -o ppid= -o tty= -o comm= -o etimes= 2>/dev/null) || ps_output=$(ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null) || exit 69
|
||||
ps_output=$(ps -e -o pid=,ppid=,tty=,comm=,etimes= 2>/dev/null) || ps_output=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null) || exit 69
|
||||
{
|
||||
printf '%s\n' "$ps_output" | awk -v pp="$PPID" -v self="$SELF" '
|
||||
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
|
||||
|
||||
@@ -137,9 +137,6 @@ function init(deps) {
|
||||
}
|
||||
|
||||
function openTerminalOutputSession(sessionId, webContents) {
|
||||
require("./rendererSessionLifetime.cjs").trackRendererSessionLifetime(
|
||||
sessions, sessionId, webContents, closeSession,
|
||||
);
|
||||
const generation = webContents?.claimSessionGeneration?.(sessionId);
|
||||
const session = sessions?.get?.(sessionId);
|
||||
if (session && Number.isSafeInteger(generation)) {
|
||||
@@ -543,9 +540,6 @@ async function restoreAttachedSessionOutputOnce(
|
||||
} else {
|
||||
openTerminalOutputSession(sessionId, home);
|
||||
session.webContentsId = home.id;
|
||||
require("./rendererSessionLifetime.cjs").trackRendererSessionLifetime(
|
||||
sessions, sessionId, home, closeSession, true,
|
||||
);
|
||||
attachHomeWebContentsIds.delete(sessionId);
|
||||
result = { success: true, restored: true, webContentsId: home.id };
|
||||
}
|
||||
@@ -627,7 +621,7 @@ async function restoreTerminalSessionOutput(event, payload, terminalWorkerManage
|
||||
if (!result?.success) {
|
||||
return { success: false, error: result?.error || "Failed to restore session output" };
|
||||
}
|
||||
terminalWorkerManager.clearAttachHome?.(sessionId, target.id);
|
||||
terminalWorkerManager.clearAttachHome?.(sessionId);
|
||||
return { success: true, restored: true, webContentsId: target.id };
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
@@ -642,9 +636,6 @@ async function restoreTerminalSessionOutput(event, payload, terminalWorkerManage
|
||||
try {
|
||||
openTerminalOutputSession(sessionId, target);
|
||||
session.webContentsId = target.id;
|
||||
require("./rendererSessionLifetime.cjs").trackRendererSessionLifetime(
|
||||
sessions, sessionId, target, closeSession, true,
|
||||
);
|
||||
attachHomeWebContentsIds.delete(sessionId);
|
||||
return { success: true, restored: true, webContentsId: target.id };
|
||||
} catch (err) {
|
||||
@@ -2639,14 +2630,11 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const wrapSessionStart = options.manageSessionLogRestart === true
|
||||
? sessionLogStreamManager.withSessionLogRestart
|
||||
: (handler) => handler;
|
||||
ipcMain.handle("netcatty:local:start", wrapSessionStart(startLocalSession));
|
||||
ipcMain.handle("netcatty:telnet:start", wrapSessionStart(startTelnetSession));
|
||||
ipcMain.handle("netcatty:mosh:start", wrapSessionStart(startMoshSession));
|
||||
ipcMain.handle("netcatty:et:start", wrapSessionStart(startEtSession));
|
||||
ipcMain.handle("netcatty:serial:start", wrapSessionStart(startSerialSession));
|
||||
ipcMain.handle("netcatty:local:start", startLocalSession);
|
||||
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
|
||||
ipcMain.handle("netcatty:mosh:start", startMoshSession);
|
||||
ipcMain.handle("netcatty:et:start", startEtSession);
|
||||
ipcMain.handle("netcatty:serial:start", startSerialSession);
|
||||
ipcMain.handle("netcatty:serial:list", listSerialPorts);
|
||||
ipcMain.handle("netcatty:serial:ymodem-send", sendSerialYmodem);
|
||||
ipcMain.handle("netcatty:serial:ymodem-receive", receiveSerialYmodem);
|
||||
|
||||
@@ -125,101 +125,3 @@ test("legacy SSH session without connRef still ends its own connection + chain",
|
||||
assert.equal(chain[0].ended, 1);
|
||||
assert.equal(chain[1].ended, 1);
|
||||
});
|
||||
|
||||
test("destroying a copied child window closes only its shells on the shared SSH transport", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const { trackRendererSessionLifetime } = require("./rendererSessionLifetime.cjs");
|
||||
const sessions = new Map();
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
|
||||
const conn = makeConn();
|
||||
const mainA = makeSession(conn, null);
|
||||
const transport = createConnectionRef(mainA, conn, []);
|
||||
const mainB = makeSession(conn, null);
|
||||
const childA = makeSession(conn, null);
|
||||
const childB = makeSession(conn, null);
|
||||
acquireConnectionRef(mainB, transport);
|
||||
acquireConnectionRef(childA, transport);
|
||||
acquireConnectionRef(childB, transport);
|
||||
for (const [id, session] of [
|
||||
["main-a", mainA],
|
||||
["main-b", mainB],
|
||||
["child-a", childA],
|
||||
["child-b", childB],
|
||||
]) {
|
||||
session.bootEpoch = 1;
|
||||
sessions.set(id, session);
|
||||
}
|
||||
assert.equal(transport.count, 4);
|
||||
assert.equal(transport.state, "live");
|
||||
|
||||
const windowFor = (id) => {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = id;
|
||||
contents.isDestroyed = () => false;
|
||||
return contents;
|
||||
};
|
||||
const mainWindow = windowFor(1);
|
||||
const childWindow = windowFor(2);
|
||||
trackRendererSessionLifetime(sessions, "main-a", mainWindow, terminalBridge.closeSession);
|
||||
trackRendererSessionLifetime(sessions, "main-b", mainWindow, terminalBridge.closeSession);
|
||||
trackRendererSessionLifetime(sessions, "child-a", childWindow, terminalBridge.closeSession);
|
||||
trackRendererSessionLifetime(sessions, "child-b", childWindow, terminalBridge.closeSession);
|
||||
|
||||
childWindow.emit("destroyed");
|
||||
|
||||
assert.equal(childA.stream.closed, 1);
|
||||
assert.equal(childB.stream.closed, 1);
|
||||
assert.equal(mainA.stream.closed, 0);
|
||||
assert.equal(mainB.stream.closed, 0);
|
||||
assert.equal(conn.ended, 0, "shared SSH connection stays up for the main window");
|
||||
assert.equal(transport.count, 2, "only the child window's shell leases are released");
|
||||
assert.equal(transport.state, "live");
|
||||
assert.equal(sessions.has("child-a"), false);
|
||||
assert.equal(sessions.has("child-b"), false);
|
||||
assert.equal(sessions.has("main-a"), true);
|
||||
assert.equal(sessions.has("main-b"), true);
|
||||
|
||||
childWindow.emit("destroyed");
|
||||
assert.equal(transport.count, 2, "a repeated child-window destroy must not drop the main window's leases");
|
||||
assert.equal(conn.ended, 0);
|
||||
|
||||
mainWindow.emit("destroyed");
|
||||
assert.equal(mainA.stream.closed, 1);
|
||||
assert.equal(mainB.stream.closed, 1);
|
||||
assert.equal(transport.count, 0);
|
||||
assert.equal(transport.state, "idle", "the last shells park the transport");
|
||||
assert.equal(conn.ended, 0, "parking does not end the shared connection");
|
||||
});
|
||||
|
||||
test("renderer destruction closes its SSH shell through normal close while other shell and SFTP leases survive", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const { trackRendererSessionLifetime } = require("./rendererSessionLifetime.cjs");
|
||||
const { borrowTransport, returnTransport } = require("./sshConnectionPool.cjs");
|
||||
const sessions = new Map();
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
const conn = makeConn();
|
||||
const owner = makeSession(conn, null);
|
||||
const transport = createConnectionRef(owner, conn, []);
|
||||
const copy = makeSession(conn, null);
|
||||
acquireConnectionRef(copy, transport);
|
||||
const sftpHolder = {};
|
||||
borrowTransport(transport, { kind: "sftp", holder: sftpHolder });
|
||||
sessions.set("owner", owner);
|
||||
sessions.set("copy", copy);
|
||||
const contents = new EventEmitter();
|
||||
contents.id = 7;
|
||||
trackRendererSessionLifetime(sessions, "copy", contents, terminalBridge.closeSession);
|
||||
contents.emit("destroyed");
|
||||
assert.equal(copy.stream.closed, 1);
|
||||
assert.equal(owner.stream.closed, 0);
|
||||
assert.equal(conn.ended, 0);
|
||||
assert.equal(transport.count, 2);
|
||||
assert.equal(sessions.has("copy"), false);
|
||||
assert.equal(sessions.has("owner"), true);
|
||||
terminalBridge.closeSession({}, { sessionId: "owner" });
|
||||
assert.equal(transport.count, 1, "SFTP lease survives the last shell closing");
|
||||
assert.equal(conn.ended, 0);
|
||||
returnTransport(sftpHolder);
|
||||
assert.equal(transport.state, "idle");
|
||||
});
|
||||
@@ -626,21 +626,3 @@ test("worker replacements abort pending and skip superseded queued local starts"
|
||||
await drainMessages();
|
||||
});
|
||||
});
|
||||
|
||||
test("in-process local session is killed on renderer destruction without closing another renderer's PTY", async () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakes(spawns, []);
|
||||
const sessions = new Map();
|
||||
const renderers = [7, 8].map(id => Object.assign(new EventEmitter(), { id, send() {} }));
|
||||
bridge.init({ sessions, electronModule: { webContents: { fromId: id => renderers.find(r => r.id === id) } } });
|
||||
await bridge.startLocalSession({ sender: renderers[0] }, { sessionId: "lifetime-local", shell: "/bin/sh", bootEpoch: 1 });
|
||||
await bridge.startLocalSession({ sender: renderers[1] }, { sessionId: "lifetime-other", shell: "/bin/sh", bootEpoch: 1 });
|
||||
let killed = 0;
|
||||
spawns[0].kill = () => { killed++; };
|
||||
renderers[0].emit("destroyed");
|
||||
assert.equal(killed, 1);
|
||||
assert.equal(sessions.has("lifetime-local"), false);
|
||||
assert.equal(sessions.has("lifetime-other"), true);
|
||||
bridge.closeSession({}, { sessionId: "lifetime-other" });
|
||||
});
|
||||
@@ -292,9 +292,6 @@ class TerminalTextRenderer {
|
||||
}
|
||||
|
||||
#applySgr(values) {
|
||||
// Cells share style snapshots. Copy before SGR mutations so earlier output
|
||||
// (including preserved cleared screens) keeps its original appearance.
|
||||
this.style = { ...this.style };
|
||||
const codes = values.length > 0 ? values : [0];
|
||||
|
||||
for (let i = 0; i < codes.length; i += 1) {
|
||||
@@ -547,7 +544,7 @@ function createDefaultStyle() {
|
||||
function createCell(ch, style) {
|
||||
return {
|
||||
ch,
|
||||
style,
|
||||
style: { ...style },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -330,52 +330,3 @@ test("erasing styled spaces updates the visible line timestamp", () => {
|
||||
renderer.feed("\r\x1b[2C\x1b[1K", 9000);
|
||||
assert.equal(renderer.toHtmlContent({ formatLine }), "[9000] text");
|
||||
});
|
||||
|
||||
test("cells share style snapshots without recoloring earlier output on SGR changes", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("ab\x1b[31mcd\x1b[1mef\x1b[0mgh");
|
||||
const cells = renderer.lines[0];
|
||||
assert.equal(cells[0].style, cells[1].style);
|
||||
assert.equal(cells[2].style, cells[3].style);
|
||||
assert.notEqual(cells[1].style, cells[2].style);
|
||||
assert.notEqual(cells[3].style, cells[4].style);
|
||||
assert.equal(renderer.toString(), "abcdefgh");
|
||||
assert.equal(renderer.toHtmlContent(), 'ab<span style="color: var(--ansi-1, #cd3131)">cd</span><span style="color: var(--ansi-1, #cd3131); font-weight: 700">ef</span>gh');
|
||||
});
|
||||
|
||||
test("SGR attribute and color resets leave previous snapshots unchanged", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[1;3;4;7;38;5;196;48;2;1;2;3mA");
|
||||
const snapshot = renderer.lines[0][0].style;
|
||||
const original = { ...snapshot };
|
||||
renderer.feed("\x1b[22;23;24;27;39;49mB\x1b[0;32;1mC\x1b[mD");
|
||||
assert.deepEqual(snapshot, original);
|
||||
assert.deepEqual(original, { fg: "#ff0000", bg: "#010203", bold: true, italic: true, underline: true, inverse: true });
|
||||
assert.deepEqual(renderer.lines[0][1].style, { fg: null, bg: null, bold: false, italic: false, underline: false, inverse: false });
|
||||
assert.equal(renderer.toString(), "ABCD");
|
||||
assert.equal(renderer.toHtmlContent(), '<span style="color: #010203; background-color: #ff0000; font-weight: 700; font-style: italic; text-decoration: underline">A</span>B<span style="color: var(--ansi-2, #0dbc79); font-weight: 700">C</span>D');
|
||||
});
|
||||
|
||||
test("cursor overwrites and erasure do not mutate adjacent shared styles", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[31mabcd\r\x1b[32mX\x1b[3G\x1b[0mY");
|
||||
assert.equal(renderer.toString(), "XbYd");
|
||||
assert.equal(renderer.toHtmlContent(), '<span style="color: var(--ansi-2, #0dbc79)">X</span><span style="color: var(--ansi-1, #cd3131)">b</span>Y<span style="color: var(--ansi-1, #cd3131)">d</span>');
|
||||
renderer.feed("\x1b[1K");
|
||||
assert.equal(renderer.toHtmlContent(), "");
|
||||
});
|
||||
|
||||
test("pending cleared-screen clones share immutable styles but retain independent cells", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("first\x1b[H\x1b[2J\x1b[31mold", 10);
|
||||
const activeLine = renderer.lines.at(-1);
|
||||
renderer.feed("\x1b[H\x1b[2J");
|
||||
const savedLine = renderer.pendingClearedScreen.lines[0];
|
||||
assert.notEqual(savedLine[0], activeLine[0]);
|
||||
assert.equal(savedLine[0].style, activeLine[0].style);
|
||||
assert.equal(savedLine.timestamp, 10);
|
||||
renderer.feed("\x1b[32mnew", 20);
|
||||
assert.equal(savedLine[0].style.fg, "var(--ansi-1, #cd3131)");
|
||||
assert.equal(renderer.toString({ includePendingClearedScreen: true }), "first\nold\n\nnew");
|
||||
assert.equal(renderer.toHtmlContent({ includePendingClearedScreen: true }), 'first\n<span style="color: var(--ansi-1, #cd3131)">old</span>\n\n<span style="color: var(--ansi-2, #0dbc79)">new</span>');
|
||||
});
|
||||
@@ -409,9 +409,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
const workerSessionIds = new Set();
|
||||
const sessionWebContentsIds = new Map();
|
||||
const sessionHostIds = new Map();
|
||||
// Lifetime ownership is independent of the output route (observe popups).
|
||||
const rendererSessionOwners = new Map();
|
||||
const trackedLifetimeRenderers = new WeakSet();
|
||||
const sessionGenerations = new Map();
|
||||
const closedSessionGenerations = new Map();
|
||||
const supersedingSessionGenerations = new Map();
|
||||
@@ -661,34 +658,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function trackRendererSessionOwner(sessionId, entry) {
|
||||
// External terminals belong to their agent/provider, not their display.
|
||||
rendererSessionOwners.delete(sessionId);
|
||||
if (!entry.rendererOwned) return;
|
||||
const contents = electronModule?.webContents?.fromId?.(entry.webContentsId);
|
||||
if (!contents) {
|
||||
// A backend-assigned ID may arrive only after the renderer was removed.
|
||||
if (electronModule?.webContents?.fromId) send("netcatty:close", { sessionId });
|
||||
return;
|
||||
}
|
||||
rendererSessionOwners.set(sessionId, contents);
|
||||
const closeOwnedSessions = () => {
|
||||
for (const [ownedId, owner] of [...rendererSessionOwners]) {
|
||||
if (owner !== contents || rendererSessionOwners.get(ownedId) !== contents) continue;
|
||||
// Use the normal close path for pending boots, output/mirror cleanup,
|
||||
// and reference-counted SSH/SFTP transport release in the worker.
|
||||
// Lifetime teardown closes both an active boot and a queued replacement.
|
||||
// Scoping to the pending epoch could leave the older active PTY alive.
|
||||
send("netcatty:close", { sessionId: ownedId }, { webContentsId: contents.id });
|
||||
}
|
||||
};
|
||||
if (!trackedLifetimeRenderers.has(contents)) {
|
||||
trackedLifetimeRenderers.add(contents);
|
||||
contents.once?.("destroyed", closeOwnedSessions);
|
||||
}
|
||||
if (contents.isDestroyed?.()) closeOwnedSessions();
|
||||
}
|
||||
|
||||
async function openOutputSession(sessionId, webContentsId, isStillCurrent = null) {
|
||||
if (!sessionId || !webContentsId) return false;
|
||||
if (closedSessions.has(sessionId)) {
|
||||
@@ -889,7 +858,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
const ok = await openOutputSession(sessionId, homeId);
|
||||
if (ok) {
|
||||
clearAttachHome(sessionId, homeId);
|
||||
attachHomeWebContentsIds.delete(sessionId);
|
||||
}
|
||||
return {
|
||||
success: ok,
|
||||
@@ -925,16 +894,8 @@ function createTerminalWorkerManager(options = {}) {
|
||||
return attachHomeWebContentsIds.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
function clearAttachHome(sessionId, restoredHomeId) {
|
||||
function clearAttachHome(sessionId) {
|
||||
if (sessionId) attachHomeWebContentsIds.delete(sessionId);
|
||||
// A completed restore may explicitly adopt another main renderer as home.
|
||||
// Do not overwrite a newer start's lifetime owner while it is still booting.
|
||||
if (typeof restoredHomeId === "number"
|
||||
&& rendererSessionOwners.has(sessionId)
|
||||
&& !pendingSessionStartSequences.has(sessionId)
|
||||
&& sessionWebContentsIds.get(sessionId) === restoredHomeId) {
|
||||
trackRendererSessionOwner(sessionId, { rendererOwned: true, webContentsId: restoredHomeId });
|
||||
}
|
||||
}
|
||||
|
||||
function deleteClosedSessionTombstone(sessionId) {
|
||||
@@ -1128,7 +1089,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
outputPortReady.delete(sessionId);
|
||||
sessionWebContentsIds.delete(sessionId);
|
||||
sessionHostIds.delete(sessionId);
|
||||
rendererSessionOwners.delete(sessionId);
|
||||
const activeGeneration = sessionGenerations.get(sessionId);
|
||||
recordClosedSessionGeneration(sessionId, activeGeneration);
|
||||
sessionGenerations.delete(sessionId);
|
||||
@@ -1371,28 +1331,10 @@ function createTerminalWorkerManager(options = {}) {
|
||||
if (!sessionWebContentsIds.has(entry.requestedSessionId)
|
||||
&& !outputRoutePending.has(entry.requestedSessionId)) {
|
||||
workerSessionIds.delete(entry.requestedSessionId);
|
||||
rendererSessionOwners.delete(entry.requestedSessionId);
|
||||
}
|
||||
}
|
||||
finishPendingSessionStart(entry);
|
||||
pending.delete(message.requestId);
|
||||
// A failed replacement may leave the previous backend alive. Restore
|
||||
// its lifetime owner only if no newer start or generation took over.
|
||||
if (entry.requestedSessionId
|
||||
&& latestSessionStartSequences.get(entry.requestedSessionId) === entry.requestSequence
|
||||
&& sessionWebContentsIds.has(entry.requestedSessionId)
|
||||
&& sessionGenerations.get(entry.requestedSessionId) === entry.previousSessionGeneration) {
|
||||
rendererSessionOwners.delete(entry.requestedSessionId);
|
||||
if (entry.previousRendererOwner) {
|
||||
rendererSessionOwners.set(entry.requestedSessionId, entry.previousRendererOwner);
|
||||
if (entry.previousRendererOwner.isDestroyed?.()) {
|
||||
send("netcatty:close", {
|
||||
sessionId: entry.requestedSessionId,
|
||||
bootEpoch: resolveOwnerBootEpoch(entry.requestedSessionId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.reject(new Error(message.error));
|
||||
} else {
|
||||
const sessionId = message.result?.sessionId;
|
||||
@@ -1437,7 +1379,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
return latestSequence === entry.requestSequence
|
||||
&& !(currentClosedSequence != null && currentClosedSequence >= entry.requestSequence);
|
||||
};
|
||||
if (!entry.requestedSessionId) trackRendererSessionOwner(sessionId, entry);
|
||||
void openOutputSession(sessionId, entry.webContentsId, isStillCurrent).then((opened) => {
|
||||
if (pending.get(message.requestId) !== entry) return;
|
||||
finishPendingSessionStart(entry);
|
||||
@@ -1803,7 +1744,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
workerSessionIds.clear();
|
||||
sessionWebContentsIds.clear();
|
||||
sessionHostIds.clear();
|
||||
rendererSessionOwners.clear();
|
||||
sessionGenerations.clear();
|
||||
closedSessionGenerations.clear();
|
||||
supersedingSessionGenerations.clear();
|
||||
@@ -1885,13 +1825,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
|
||||
function request(channel, payload, optionsForRequest = {}) {
|
||||
pruneClosedSessionTombstones();
|
||||
const rendererOwned = SESSION_START_CHANNELS.has(channel) && channel !== "netcatty:external:start";
|
||||
if (rendererOwned && electronModule?.webContents?.fromId && optionsForRequest.webContentsId) {
|
||||
const contents = electronModule.webContents.fromId(optionsForRequest.webContentsId);
|
||||
if (!contents || contents.isDestroyed?.()) {
|
||||
return Promise.reject(new Error("Terminal window closed before startup"));
|
||||
}
|
||||
}
|
||||
if (channel === "netcatty:close:await"
|
||||
&& payload?.sessionId
|
||||
&& closedSessions.has(payload.sessionId)
|
||||
@@ -1909,10 +1842,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
const requestedSessionId = payload?.sessionId && SESSION_START_CHANNELS.has(channel)
|
||||
? payload.sessionId
|
||||
: null;
|
||||
const previousStart = requestedSessionId
|
||||
? [...pending.values()].find(entry => entry.requestedSessionId === requestedSessionId
|
||||
&& entry.requestSequence === latestSessionStartSequences.get(requestedSessionId))
|
||||
: null;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
pending.set(requestId, {
|
||||
resolve,
|
||||
@@ -1922,11 +1851,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
bootEpoch: normalizeBootEpoch(payload?.bootEpoch),
|
||||
webContentsId: optionsForRequest.webContentsId,
|
||||
beforeExecute: optionsForRequest.beforeExecute,
|
||||
rendererOwned,
|
||||
previousRendererOwner: previousStart
|
||||
? previousStart.previousRendererOwner
|
||||
: rendererSessionOwners.get(requestedSessionId),
|
||||
previousSessionGeneration: sessionGenerations.get(requestedSessionId),
|
||||
opensOutputSession: channel === "netcatty:start"
|
||||
|| channel === "netcatty:local:reconnect"
|
||||
|| channel === "netcatty:external:start"
|
||||
@@ -1952,7 +1876,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
pendingSessionStartSequences.set(requestedSessionId, requestSequence);
|
||||
latestSessionStartSequences.set(requestedSessionId, requestSequence);
|
||||
rememberPendingStartBootEpoch(requestedSessionId, payload?.bootEpoch);
|
||||
trackRendererSessionOwner(requestedSessionId, pending.get(requestId));
|
||||
// Track host id for SFTP transfer session leases (global transfer center).
|
||||
if (typeof payload?.hostId === "string" && payload.hostId) {
|
||||
sessionHostIds.set(requestedSessionId, payload.hostId);
|
||||
@@ -2191,7 +2114,6 @@ function createTerminalWorkerManager(options = {}) {
|
||||
workerSessionIds.clear();
|
||||
sessionWebContentsIds.clear();
|
||||
sessionHostIds.clear();
|
||||
rendererSessionOwners.clear();
|
||||
sessionGenerations.clear();
|
||||
closedSessionGenerations.clear();
|
||||
supersedingSessionGenerations.clear();
|
||||
|
||||
@@ -771,7 +771,7 @@ test("destroying a renderer immediately cancels its pending session ownership",
|
||||
assert.equal(outputOpened, false);
|
||||
});
|
||||
|
||||
test("renderer loss closes pending ownership before worker exit without duplicate notification", async () => {
|
||||
test("worker exit after renderer loss still closes a session with pending ownership", async () => {
|
||||
const child = new FakeChild();
|
||||
const contents = new EventEmitter();
|
||||
contents.id = 7;
|
||||
@@ -800,12 +800,12 @@ test("renderer loss closes pending ownership before worker exit without duplicat
|
||||
contents.emit("destroyed");
|
||||
releaseOwnership();
|
||||
await assert.rejects(start, /output route opened/u);
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "closed", explicit: true }]);
|
||||
assert.deepEqual(closed, []);
|
||||
|
||||
child.emit("exit", 1);
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "closed", explicit: true }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "worker-exit" }]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "closed", explicit: true }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "worker-exit" }]);
|
||||
});
|
||||
|
||||
test("a start closed while ownership is pending rejects instead of creating a ghost session", async () => {
|
||||
@@ -5003,283 +5003,3 @@ test("worker job diagnostics reach the main console with the diagnostic flag", (
|
||||
t.mock.method(console, "info", () => { throw new Error("unavailable log"); });
|
||||
assert.doesNotThrow(() => child.emit("message", { kind: "job-diagnostic", line }));
|
||||
});
|
||||
|
||||
function createRendererLifetimeFixture(t) {
|
||||
const child = new FakeChild();
|
||||
const renderers = new Map([7, 8, 9].map((id) => {
|
||||
const contents = new EventEmitter();
|
||||
Object.assign(contents, { id, destroyed: false, send() {} });
|
||||
contents.isDestroyed = () => contents.destroyed;
|
||||
contents.destroy = () => { contents.destroyed = true; contents.emit("destroyed"); };
|
||||
return [id, contents];
|
||||
}));
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
electronModule: { webContents: { fromId: id => renderers.get(id) } },
|
||||
terminalOutputChannel: { openSession() {}, closeSession() {} },
|
||||
});
|
||||
t.after(() => manager.stop());
|
||||
const begin = (id, owner = 7, bootEpoch = 1, channel = "netcatty:local:start") => {
|
||||
const promise = manager.request(channel, { sessionId: id, bootEpoch }, { webContentsId: owner });
|
||||
const message = child.messages.findLast(m => m.kind === "request");
|
||||
return { promise, respond() {
|
||||
child.emit("message", { kind: "response", requestId: message.requestId,
|
||||
result: { sessionId: id }, sessionGeneration: bootEpoch });
|
||||
} };
|
||||
};
|
||||
const start = async (...args) => { const pending = begin(...args); pending.respond(); await pending.promise; };
|
||||
const closes = () => child.messages.filter(m => m.channel === "netcatty:close");
|
||||
return { child, renderers, manager, begin, start, closes };
|
||||
}
|
||||
|
||||
test("destroyed lifetime owner closes established sessions through normal worker close once", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
const closed = [];
|
||||
f.manager.onSessionClosed(e => closed.push(e));
|
||||
await f.start("local");
|
||||
await f.start("ssh", 7, 1, "netcatty:start");
|
||||
await f.start("other", 8);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.deepEqual(f.closes().map(m => m.payload.sessionId), ["local", "ssh"]);
|
||||
assert.equal(f.manager.hasOpenSession("local"), false);
|
||||
assert.equal(f.manager.hasOpenSession("ssh"), false);
|
||||
assert.equal(f.manager.hasOpenSession("other"), true);
|
||||
assert.deepEqual(closed.map(e => e.sessionId), ["local", "ssh"]);
|
||||
f.renderers.get(7).emit("destroyed");
|
||||
assert.equal(f.closes().length, 2);
|
||||
});
|
||||
|
||||
test("attached display destruction keeps the source session but source destruction closes it", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
assert.equal((await f.manager.rebindOutputSession("local", 9)).success, true);
|
||||
f.renderers.get(9).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
assert.equal(f.manager.hasOpenSession("local"), true);
|
||||
assert.equal((await f.manager.restoreAttachHome("local")).success, true);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.equal(f.closes().length, 1);
|
||||
});
|
||||
|
||||
test("new pending and established owners survive the old renderer's destruction", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
const replacement = f.begin("local", 8, 2);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
replacement.respond();
|
||||
await replacement.promise;
|
||||
assert.equal(f.manager.getSessionWebContentsId("local"), 8);
|
||||
f.renderers.get(7).emit("destroyed");
|
||||
assert.equal(f.closes().length, 0);
|
||||
f.renderers.get(8).destroy();
|
||||
assert.deepEqual(f.closes()[0].payload, { sessionId: "local" });
|
||||
});
|
||||
|
||||
test("owner destruction cancels a pending backend start before its response", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
const pending = f.begin("local");
|
||||
f.renderers.get(7).destroy();
|
||||
pending.respond();
|
||||
await assert.rejects(pending.promise, /closed before its output route opened/);
|
||||
assert.equal(f.closes().length, 1);
|
||||
assert.equal(f.manager.hasOpenSession("local"), false);
|
||||
});
|
||||
|
||||
test("external agent sessions are not owned by their renderer display", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("external", 7, 1, "netcatty:external:start");
|
||||
f.renderers.get(7).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
assert.equal(f.manager.hasOpenSession("external"), true);
|
||||
});
|
||||
|
||||
test("explicit close and worker shutdown remove renderer lifetime cleanup targets", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
f.manager.send("netcatty:close", { sessionId: "local" });
|
||||
await f.start("second");
|
||||
f.manager.stop();
|
||||
f.renderers.get(7).destroy();
|
||||
assert.deepEqual(f.closes().map(m => m.payload.sessionId), ["local"]);
|
||||
});
|
||||
|
||||
test("restoring an attached terminal into a new home transfers lifetime ownership", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
await f.manager.rebindOutputSession("local", 9);
|
||||
assert.equal((await f.manager.restoreAttachHome("local", 8)).success, true);
|
||||
f.renderers.get(7).destroy();
|
||||
f.renderers.get(9).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
f.renderers.get(8).destroy();
|
||||
assert.equal(f.closes().length, 1);
|
||||
});
|
||||
|
||||
test("failed replacement does not transfer a surviving backend to the failed renderer", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
const replacement = f.begin("local", 8, 2);
|
||||
const request = f.child.messages.findLast(m => m.kind === "request");
|
||||
f.child.emit("message", { kind: "response", requestId: request.requestId, error: "start failed" });
|
||||
await assert.rejects(replacement.promise, /start failed/);
|
||||
f.renderers.get(8).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.deepEqual(f.closes()[0].payload, { sessionId: "local" });
|
||||
});
|
||||
|
||||
test("failed handoff closes a surviving backend if its original renderer already disappeared", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
const replacement = f.begin("local", 8, 2);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
const request = f.child.messages.findLast(m => m.kind === "request");
|
||||
f.child.emit("message", { kind: "response", requestId: request.requestId, error: "start failed" });
|
||||
await assert.rejects(replacement.promise, /start failed/);
|
||||
assert.deepEqual(f.closes()[0].payload, { sessionId: "local", bootEpoch: 1 });
|
||||
});
|
||||
|
||||
test("renderer lifetime close cancels queued replacement and closes the older active backend", async t => {
|
||||
const terminalBridge = require("./terminalBridge.cjs");
|
||||
const child = new LinkedRuntimeChild();
|
||||
const contents = Object.assign(new EventEmitter(), { id: 7, send() {} });
|
||||
const sessions = new Map();
|
||||
let starts = 0;
|
||||
let kills = 0;
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
child.connectRuntime(ipcMain => {
|
||||
ipcMain.handle("netcatty:local:start", (_event, payload) => {
|
||||
starts++;
|
||||
sessions.set(payload.sessionId, { bootEpoch: payload.bootEpoch, proc: { kill() { kills++; } } });
|
||||
return { sessionId: payload.sessionId };
|
||||
});
|
||||
ipcMain.on("netcatty:close", terminalBridge.closeSession);
|
||||
ipcMain.handle("netcatty:close:await", terminalBridge.closeSession);
|
||||
});
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
electronModule: { webContents: { fromId: () => contents } },
|
||||
});
|
||||
t.after(() => manager.stop());
|
||||
await manager.request("netcatty:local:start", { sessionId: "queued-close", bootEpoch: 1 }, { webContentsId: 7 });
|
||||
const replacement = manager.request("netcatty:local:start", { sessionId: "queued-close", bootEpoch: 2 }, { webContentsId: 7 });
|
||||
contents.emit("destroyed");
|
||||
await assert.rejects(replacement, /cancelled by close|closed before/);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(starts, 1, "replacement was cancelled before its handler ran");
|
||||
assert.equal(kills, 1, "the old active boot must still be closed");
|
||||
assert.equal(sessions.has("queued-close"), false);
|
||||
assert.equal(manager.hasOpenSession("queued-close"), false);
|
||||
});
|
||||
|
||||
test("destroying a copied child window releases only its shells on a shared SSH transport", async t => {
|
||||
const terminalBridge = require("./terminalBridge.cjs");
|
||||
const {
|
||||
createConnectionRef,
|
||||
acquireConnectionRef,
|
||||
resetSshTransportRegistryForTests,
|
||||
} = require("./sshConnectionPool.cjs");
|
||||
resetSshTransportRegistryForTests();
|
||||
t.after(() => resetSshTransportRegistryForTests());
|
||||
|
||||
const sessions = new Map();
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
const conn = { ended: 0, end() { this.ended += 1; } };
|
||||
const shell = () => ({
|
||||
stream: { closed: 0, close() { this.closed += 1; } },
|
||||
conn,
|
||||
chainConnections: [],
|
||||
connRef: null,
|
||||
zmodemSentry: { cancel() {} },
|
||||
bootEpoch: 1,
|
||||
});
|
||||
const mainA = shell();
|
||||
const transport = createConnectionRef(mainA, conn, []);
|
||||
const mainB = shell();
|
||||
const childA = shell();
|
||||
const childB = shell();
|
||||
acquireConnectionRef(mainB, transport);
|
||||
acquireConnectionRef(childA, transport);
|
||||
acquireConnectionRef(childB, transport);
|
||||
sessions.set("main-a", mainA);
|
||||
sessions.set("main-b", mainB);
|
||||
sessions.set("child-a", childA);
|
||||
sessions.set("child-b", childB);
|
||||
|
||||
const renderers = new Map([1, 2].map((id) => {
|
||||
const contents = new EventEmitter();
|
||||
Object.assign(contents, { id, destroyed: false, send() {} });
|
||||
contents.isDestroyed = () => contents.destroyed;
|
||||
contents.destroy = () => { contents.destroyed = true; contents.emit("destroyed"); };
|
||||
return [id, contents];
|
||||
}));
|
||||
const child = new LinkedRuntimeChild();
|
||||
child.connectRuntime((ipcMain) => {
|
||||
ipcMain.handle("netcatty:start", (_event, payload) => ({ sessionId: payload.sessionId }));
|
||||
ipcMain.on("netcatty:close", terminalBridge.closeSession);
|
||||
ipcMain.handle("netcatty:close:await", terminalBridge.closeSession);
|
||||
});
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
electronModule: { webContents: { fromId: (id) => renderers.get(id) } },
|
||||
terminalOutputChannel: { openSession() {}, closeSession() {} },
|
||||
});
|
||||
t.after(() => manager.stop());
|
||||
|
||||
const own = (sessionId, webContentsId) => manager.request(
|
||||
"netcatty:start",
|
||||
{ sessionId, bootEpoch: 1, hostId: "shared-host" },
|
||||
{ webContentsId },
|
||||
);
|
||||
await own("main-a", 1);
|
||||
await own("main-b", 1);
|
||||
await own("child-a", 2);
|
||||
await own("child-b", 2);
|
||||
assert.equal(transport.count, 4);
|
||||
assert.equal(transport.state, "live");
|
||||
|
||||
renderers.get(2).destroy();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(childA.stream.closed, 1);
|
||||
assert.equal(childB.stream.closed, 1);
|
||||
assert.equal(mainA.stream.closed, 0);
|
||||
assert.equal(mainB.stream.closed, 0);
|
||||
assert.equal(conn.ended, 0, "shared SSH connection stays up for the main window");
|
||||
assert.equal(transport.count, 2);
|
||||
assert.equal(transport.state, "live");
|
||||
assert.equal(sessions.has("main-a"), true);
|
||||
assert.equal(sessions.has("main-b"), true);
|
||||
assert.equal(sessions.has("child-a"), false);
|
||||
assert.equal(sessions.has("child-b"), false);
|
||||
assert.equal(manager.hasOpenSession("main-a"), true);
|
||||
assert.equal(manager.hasOpenSession("main-b"), true);
|
||||
assert.equal(manager.hasOpenSession("child-a"), false);
|
||||
assert.equal(manager.hasOpenSession("child-b"), false);
|
||||
|
||||
renderers.get(2).emit("destroyed");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(transport.count, 2, "a repeated child-window destroy must not drop surviving leases");
|
||||
assert.equal(conn.ended, 0);
|
||||
|
||||
renderers.get(1).destroy();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(mainA.stream.closed, 1);
|
||||
assert.equal(mainB.stream.closed, 1);
|
||||
assert.equal(transport.count, 0);
|
||||
assert.equal(transport.state, "idle");
|
||||
assert.equal(conn.ended, 0, "the parked transport is not ended while it can still be reused");
|
||||
});
|
||||
|
||||
test("backend-assigned session ID is closed if its renderer disappeared before the response", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
const start = f.manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
||||
const request = f.child.messages.findLast(m => m.kind === "request");
|
||||
f.renderers.get(7).destroy();
|
||||
f.renderers.delete(7);
|
||||
f.child.emit("message", { kind: "response", requestId: request.requestId, result: { sessionId: "assigned" } });
|
||||
await assert.rejects(start, /closed before its output route opened/);
|
||||
assert.deepEqual(f.closes().map(m => m.payload), [{ sessionId: "assigned" }]);
|
||||
});
|
||||
@@ -360,37 +360,3 @@ test("terminal worker mode proxies system management requests", async () => {
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("fallback log restart guard wraps all six starts only when main explicitly enables it", (t) => {
|
||||
const manager = require("./sessionLogStreamManager.cjs");
|
||||
const wrappers = new Map();
|
||||
t.mock.method(manager, "withSessionLogRestart", (handler) => {
|
||||
const wrapped = () => {};
|
||||
wrappers.set(handler, wrapped);
|
||||
return wrapped;
|
||||
});
|
||||
const workerHandlers = createFakeIpcMain();
|
||||
sshBridge.registerHandlers(workerHandlers);
|
||||
terminalBridge.registerHandlers(workerHandlers);
|
||||
assert.equal(wrappers.size, 0, "utility-process registration keeps the runtime generation guard");
|
||||
|
||||
const fallbackHandlers = createFakeIpcMain();
|
||||
sshBridge.registerHandlers(fallbackHandlers, { manageSessionLogRestart: true });
|
||||
terminalBridge.registerHandlers(fallbackHandlers, { manageSessionLogRestart: true });
|
||||
const startChannels = [
|
||||
"netcatty:start", "netcatty:local:start", "netcatty:telnet:start",
|
||||
"netcatty:mosh:start", "netcatty:et:start", "netcatty:serial:start",
|
||||
];
|
||||
assert.equal(wrappers.size, startChannels.length);
|
||||
for (const channel of startChannels) {
|
||||
assert.equal(fallbackHandlers.handlers.get(channel), wrappers.get(workerHandlers.handlers.get(channel)), channel);
|
||||
}
|
||||
assert.equal(fallbackHandlers.handlers.get("netcatty:serial:list"), workerHandlers.handlers.get("netcatty:serial:list"));
|
||||
|
||||
wrappers.clear();
|
||||
const proxyHandlers = createFakeIpcMain();
|
||||
const terminalWorkerManager = createFakeWorkerManager();
|
||||
sshBridge.registerHandlers(proxyHandlers, { terminalWorkerManager, manageSessionLogRestart: true });
|
||||
terminalBridge.registerHandlers(proxyHandlers, { terminalWorkerManager, manageSessionLogRestart: true });
|
||||
assert.equal(wrappers.size, 0, "worker proxies must not also apply the main fallback guard");
|
||||
});
|
||||
@@ -27,8 +27,6 @@ const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
|
||||
const {
|
||||
DOWNLOAD_TRANSFER_CONCURRENCY,
|
||||
FAST_DOWNLOAD_CHANNELS_PER_SESSION,
|
||||
SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES,
|
||||
SMALL_UPLOAD_SHARED_MAX_BYTES,
|
||||
SFTP_OPEN_TIMEOUT_MS,
|
||||
SFTP_REQUEST_TIMEOUT_MS,
|
||||
TRANSFER_CHUNK_SIZE,
|
||||
@@ -652,77 +650,28 @@ async function resolveLocalResumeCheckpoint(filePath, claimedCheckpoint) {
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingRemoteStageError(error) {
|
||||
const message = error?.message || String(error);
|
||||
return error?.code === "ENOENT"
|
||||
|| error?.code === 2
|
||||
|| /no such file|ENOENT|not found|does not exist/i.test(message);
|
||||
}
|
||||
|
||||
async function statRemoteStageSize(client, sftpId, filePath, encoding) {
|
||||
if (isSftpChannelEnded(client?.sftp)) {
|
||||
throw new Error("SFTP channel closed");
|
||||
}
|
||||
const stat = isScpModeClient(client)
|
||||
? await getScpBackendForClient(client).stat(filePath, { encoding })
|
||||
: await client.stat(encodePathForSession(sftpId, filePath, encoding));
|
||||
return Math.max(0, Number(stat?.size) || 0);
|
||||
}
|
||||
|
||||
async function resolveRemoteResumeCheckpoint(client, sftpId, filePath, encoding, claimedCheckpoint) {
|
||||
const claimed = Math.max(0, Number(claimedCheckpoint) || 0);
|
||||
if (!claimed) return 0;
|
||||
try {
|
||||
const size = await statRemoteStageSize(client, sftpId, filePath, encoding);
|
||||
return Math.min(claimed, size);
|
||||
const stat = isScpModeClient(client)
|
||||
? await getScpBackendForClient(client).stat(filePath, { encoding })
|
||||
: await client.stat(encodePathForSession(sftpId, filePath, encoding));
|
||||
return Math.min(claimed, Math.max(0, Number(stat?.size) || 0));
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error);
|
||||
// Missing remote .part after kill/crash — start over instead of blocking forever.
|
||||
if (isMissingRemoteStageError(error)) return 0;
|
||||
const message = error?.message || String(error);
|
||||
if (
|
||||
error?.code === "ENOENT"
|
||||
|| error?.code === 2
|
||||
|| /no such file|ENOENT|not found|does not exist/i.test(message)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
throw new Error(`Resume safety check failed for remote temporary file: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection-drop retry has a checkpoint but no pause-time SHA-256. Keep that
|
||||
* checkpoint only when the staged .part on *this* connection covers it. A longer
|
||||
* in-flight tail is trimmed here; a shorter or missing .part restarts from 0
|
||||
* instead of appending onto bytes that do not match the checkpoint.
|
||||
*/
|
||||
async function reconcileUnfingerprintedUploadCheckpoint(client, sftpId, filePath, encoding, claimedCheckpoint) {
|
||||
const claimed = Math.max(0, Number(claimedCheckpoint) || 0);
|
||||
if (!claimed) return 0;
|
||||
let size;
|
||||
try {
|
||||
size = await statRemoteStageSize(client, sftpId, filePath, encoding);
|
||||
} catch (error) {
|
||||
if (isMissingRemoteStageError(error)) return 0;
|
||||
const message = error?.message || String(error);
|
||||
throw new Error(`Resume safety check failed for remote temporary file: ${message}`);
|
||||
}
|
||||
if (size < claimed) return 0;
|
||||
if (size === claimed) return claimed;
|
||||
// Longer .part is an in-flight tail, not a prefix we can append to. Trim it
|
||||
// on this connection. If that fails, leave the stage untouched — do not fall
|
||||
// back to an OPEN "w" that discards the matching prefix.
|
||||
const stagedRemote = { sftpId, path: filePath, encoding };
|
||||
if (isSftpChannelEnded(client?.sftp)) {
|
||||
throw new Error("SFTP channel closed");
|
||||
}
|
||||
await truncateRemoteStagedToCheckpoint(client, stagedRemote, claimed);
|
||||
let trimmed;
|
||||
try {
|
||||
trimmed = await statRemoteStageSize(client, sftpId, filePath, encoding);
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error);
|
||||
throw new Error(`Resume safety check failed for remote temporary file: ${message}`);
|
||||
}
|
||||
if (trimmed !== claimed) {
|
||||
throw new Error("Resume safety check failed for remote temporary file: could not trim the staged upload to the checkpoint");
|
||||
}
|
||||
return claimed;
|
||||
}
|
||||
|
||||
async function hashReadable(readable, options = {}) {
|
||||
const { signal, onProgress } = options;
|
||||
const inactivityTimeoutMs = Number(options.inactivityTimeoutMs) > 0
|
||||
@@ -861,17 +810,9 @@ function raceLocalStreamOpenAgainstAbort(openPromise, signal) {
|
||||
|
||||
// Local source reads go through asarSafeFs so real files named *.asar are not
|
||||
// mistaken for asar archives by Electron's fs wrapper (#3450).
|
||||
// Test seam: replace the local prefix stream for one byte length so a retry
|
||||
// re-hash can be held open until Cancel aborts it.
|
||||
let localPrefixHashStreamOverrideForTests = null;
|
||||
|
||||
async function hashLocalPrefix(filePath, bytes, options) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return Promise.resolve(null);
|
||||
if (bytes === 0) return Promise.resolve(EMPTY_SHA256_HEX);
|
||||
const override = localPrefixHashStreamOverrideForTests;
|
||||
if (override && Number(override.bytes) === bytes) {
|
||||
return hashReadable(override.create(options), options);
|
||||
}
|
||||
const stream = await raceLocalStreamOpenAgainstAbort(
|
||||
openLocalReadStream(filePath, { start: 0, end: bytes - 1 }),
|
||||
options?.signal,
|
||||
@@ -1002,10 +943,6 @@ function isLegacyFullSourceFingerprint(fingerprint) {
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(String(fingerprint || ""));
|
||||
}
|
||||
|
||||
function hasFullSourceFingerprint(fingerprint) {
|
||||
return /^sha256:(?:p\d+:)?[a-f0-9]{64}$/i.test(String(fingerprint || ""));
|
||||
}
|
||||
|
||||
async function computeSourceFingerprint(
|
||||
{ sourceType, sourcePath, sourceSftpId, sourceEncoding, prefixBytes },
|
||||
options = {},
|
||||
@@ -1588,128 +1525,6 @@ function logTransferDiag(transfer, event, extra = {}) {
|
||||
// Shared references
|
||||
let sftpClients = null;
|
||||
|
||||
// Incremental SHA-256 of the bytes actually acknowledged and written to the
|
||||
// remote .part, in contiguous order. Retry re-reads the local prefix and
|
||||
// resumes only when that digest and the source size still match the offset
|
||||
// being resumed. SHA-256 cannot rewind, so a checkpoint trimmed below the
|
||||
// covered offset drops the digest and restarts at 0. Kept in memory because
|
||||
// Retry is same-process — a relaunch with no fingerprint already restarts at 0.
|
||||
// Entries stay only while that task can still resume from the checkpoint.
|
||||
const localUploadAcknowledgedPrefix = new Map();
|
||||
const UPLOAD_PREFIX_SESSION_LOSS = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
|
||||
|
||||
function forgetLocalUploadAcknowledgedPrefix(transferId) {
|
||||
if (transferId) localUploadAcknowledgedPrefix.delete(String(transferId));
|
||||
}
|
||||
|
||||
function resetLocalUploadAcknowledgedPrefix(transferId, size) {
|
||||
if (!transferId) return;
|
||||
const safeSize = Number(size);
|
||||
localUploadAcknowledgedPrefix.set(String(transferId), {
|
||||
size: Number.isFinite(safeSize) && safeSize >= 0 ? safeSize : null,
|
||||
hash: crypto.createHash("sha256"),
|
||||
offset: 0,
|
||||
savedHash: null,
|
||||
digest: null,
|
||||
digestOffset: 0,
|
||||
broken: false,
|
||||
});
|
||||
}
|
||||
|
||||
function noteAcknowledgedUploadBytes(transferId, position, bytes) {
|
||||
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
|
||||
if (!entry || entry.broken || !Buffer.isBuffer(bytes) || bytes.length === 0) return;
|
||||
const at = Math.floor(Number(position) || 0);
|
||||
if (at !== entry.offset) {
|
||||
entry.broken = true;
|
||||
entry.digest = null;
|
||||
entry.savedHash = null;
|
||||
return;
|
||||
}
|
||||
entry.hash.update(bytes);
|
||||
entry.offset += bytes.length;
|
||||
// Snapshot without finalizing the running hash. digestOffset is the only
|
||||
// checkpoint this digest can resume.
|
||||
entry.savedHash = entry.hash.copy();
|
||||
entry.digest = entry.savedHash.copy().digest("hex");
|
||||
entry.digestOffset = entry.offset;
|
||||
}
|
||||
|
||||
function localUploadAcknowledgedOffset(transferId) {
|
||||
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
|
||||
if (!entry || entry.broken || !entry.digest || !(entry.digestOffset > 0)) return 0;
|
||||
return entry.digestOffset;
|
||||
}
|
||||
|
||||
function localUploadPrefixFailureKeepsCheckpoint(transferId, message) {
|
||||
return localUploadAcknowledgedOffset(transferId) > 0
|
||||
&& UPLOAD_PREFIX_SESSION_LOSS.test(String(message || ""));
|
||||
}
|
||||
|
||||
async function localUploadAcknowledgedPrefixMatches(transferId, sourcePath, checkpoint, sourceSize, options) {
|
||||
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
|
||||
const offset = Math.floor(Number(checkpoint) || 0);
|
||||
if (!entry || entry.broken || !entry.digest || !entry.savedHash) return false;
|
||||
if (!(offset > 0) || entry.digestOffset !== offset) return false;
|
||||
if (!Number.isFinite(Number(sourceSize)) || Number(sourceSize) !== entry.size) return false;
|
||||
try {
|
||||
const digest = await hashLocalPrefix(sourcePath, offset, { signal: options?.signal });
|
||||
return digest === entry.digest;
|
||||
} catch (error) {
|
||||
if (
|
||||
options?.signal?.aborted
|
||||
|| error?.code === "ABORT_ERR"
|
||||
|| error?.message === "Transfer cancelled"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function rewindLocalUploadAcknowledgedPrefix(transferId) {
|
||||
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
|
||||
if (!entry || entry.broken || !entry.savedHash) return;
|
||||
entry.hash = entry.savedHash.copy();
|
||||
entry.offset = entry.digestOffset;
|
||||
}
|
||||
|
||||
// Keep the running hash aligned with the offset about to be written. A smaller
|
||||
// checkpoint than digestOffset cannot reuse this digest.
|
||||
function adoptAcknowledgedPrefixOffset(transferId, checkpoint, sourceSize) {
|
||||
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
|
||||
const offset = Math.max(0, Math.floor(Number(checkpoint) || 0));
|
||||
if (!entry) return offset;
|
||||
const size = Number(sourceSize);
|
||||
const sizeOk = !Number.isFinite(entry.size) || !Number.isFinite(size) || entry.size === size;
|
||||
if (
|
||||
offset > 0
|
||||
&& sizeOk
|
||||
&& !entry.broken
|
||||
&& entry.digest
|
||||
&& entry.savedHash
|
||||
&& entry.digestOffset === offset
|
||||
) {
|
||||
entry.hash = entry.savedHash.copy();
|
||||
entry.offset = entry.digestOffset;
|
||||
return offset;
|
||||
}
|
||||
resetLocalUploadAcknowledgedPrefix(transferId, Number.isFinite(size) ? size : entry.size);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function currentLocalUploadSourceSize(transfer, sourcePath) {
|
||||
const cachedSize = Number(transfer?.sourceSoftIdentity?.size);
|
||||
if (Number.isFinite(cachedSize) && cachedSize >= 0) return cachedSize;
|
||||
try {
|
||||
const st = await statLocal(sourcePath);
|
||||
if (!Number.isFinite(st?.size) || st.size < 0) return null;
|
||||
return Number(st.size);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Active transfers storage
|
||||
const activeTransfers = new Map();
|
||||
const admittedTransferQueue = [];
|
||||
@@ -2025,32 +1840,6 @@ function markDisposableUploadChannel(channel) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
// Mutable only for tests that exercise slot/isolated behaviour with tiny files.
|
||||
let smallDownloadSlotExemptBytes = SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES;
|
||||
|
||||
/**
|
||||
* ssh2 never answers (or fails) a request issued on an SFTP channel that has
|
||||
* already ended: cleanupRequests ran at close, later requests are dropped.
|
||||
* ssh2-sftp-client also keeps its stale `sftp` after a TCP reset, so its
|
||||
* stat()/open() hang forever. Check before issuing any recovery request.
|
||||
*/
|
||||
function isSftpChannelEnded(sftp) {
|
||||
return Boolean(sftp) && sftp.readable === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared-channel failure that main used to recover by opening a fresh SFTP
|
||||
* subsystem. File status (ENOENT / EACCES) is the same on any channel.
|
||||
*/
|
||||
function isStaleOrClosedSharedChannelError(error, sftp) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const code = error.code;
|
||||
if (code === "ENOENT" || code === "EACCES") return false;
|
||||
if (isSftpChannelEnded(sftp) || sftp?.destroyed === true) return true;
|
||||
if (error.sftpChannelClosed || isSftpChannelLossError(error)) return true;
|
||||
return /channel closed/i.test(String(error.message || ""));
|
||||
}
|
||||
|
||||
async function openIsolatedSftpChannel(client, signal = null) {
|
||||
// The dedicated one-channel login already holds its only session channel.
|
||||
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
|
||||
@@ -2089,7 +1878,6 @@ async function truncateRemoteStagedToCheckpoint(client, stagedRemote, checkpoint
|
||||
const checkpoint = Math.max(0, Number(checkpointBytes) || 0);
|
||||
if (!client || !stagedRemote?.path) return;
|
||||
if (isScpModeClient(client)) return;
|
||||
if (isSftpChannelEnded(client.sftp)) return;
|
||||
try {
|
||||
const encoded = encodePathForSession(
|
||||
stagedRemote.sftpId,
|
||||
@@ -2099,27 +1887,20 @@ async function truncateRemoteStagedToCheckpoint(client, stagedRemote, checkpoint
|
||||
const stat = await client.stat(encoded);
|
||||
const size = Math.max(0, Number(stat?.size) || 0);
|
||||
if (size <= checkpoint) return;
|
||||
// ssh2's SFTP has no ftruncate, and ssh2-sftp-client has no truncate.
|
||||
// SETSTAT { size } is what actually shortens a remote file. client.sftp is
|
||||
// the raw ssh2 channel, same as the mode/mtime setstat calls above.
|
||||
const sftp = client.sftp;
|
||||
if (sftp && typeof sftp.setstat === "function") {
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.setstat(encoded, { size: checkpoint }, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
// Prefer native truncate when available (ssh2-sftp-client).
|
||||
if (typeof client.truncate === "function") {
|
||||
await client.truncate(encoded, checkpoint);
|
||||
return;
|
||||
}
|
||||
if (sftp && typeof sftp.fsetstat === "function" && typeof sftp.open === "function") {
|
||||
if (typeof client.sftp?.ftruncate === "function" && typeof client.sftp?.open === "function") {
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.open(encoded, "r+", (openErr, handle) => {
|
||||
client.sftp.open(encoded, "r+", (openErr, handle) => {
|
||||
if (openErr) return reject(openErr);
|
||||
sftp.fsetstat(handle, { size: checkpoint }, (statErr) => {
|
||||
const finish = (err) => (err ? reject(err) : resolve());
|
||||
if (typeof sftp.close === "function") {
|
||||
sftp.close(handle, () => finish(statErr));
|
||||
return;
|
||||
}
|
||||
finish(statErr);
|
||||
client.sftp.ftruncate(handle, checkpoint, (truncErr) => {
|
||||
client.sftp.close(handle, () => {
|
||||
if (truncErr) reject(truncErr);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2492,9 +2273,6 @@ async function prepareUploadFallbackCheckpoint(transfer, client, fileSize, sendP
|
||||
let stagedSize = Number.POSITIVE_INFINITY;
|
||||
try {
|
||||
const staged = transfer.stagedRemote;
|
||||
if (isSftpChannelEnded((staged.client || client)?.sftp)) {
|
||||
throw new Error("SFTP channel closed");
|
||||
}
|
||||
const encoded = encodePathForSession(staged.sftpId, staged.path, staged.encoding);
|
||||
stagedSize = Number((await (staged.client || client).stat(encoded))?.size);
|
||||
} catch { /* missing staged file — restart */ }
|
||||
@@ -2698,11 +2476,7 @@ async function uploadFile(
|
||||
|
||||
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
|
||||
let isolated = null;
|
||||
const preferSharedChannel = Number(fileSize) > 0
|
||||
&& Number(fileSize) <= SMALL_UPLOAD_SHARED_MAX_BYTES
|
||||
&& typeof sftp?.open === "function"
|
||||
&& typeof sftp?.write === "function";
|
||||
if (!client.__netcattySudoMode && !preferSharedChannel) {
|
||||
if (!client.__netcattySudoMode) {
|
||||
try {
|
||||
isolated = markDisposableUploadChannel(
|
||||
await openIsolatedSftpChannel(client, transfer?.signal),
|
||||
@@ -2746,10 +2520,6 @@ async function uploadFile(
|
||||
// uploadFileConcurrent ends the isolated channel itself.
|
||||
isolated = null;
|
||||
if (transfer.cancelled) throw err;
|
||||
// Whole connection lost (reset/VPN drop/sshd killed): the browse channel
|
||||
// is dead too, and every fallback request on it would hang. Fail now so
|
||||
// the task shows an error and can be resumed/retried after reconnect.
|
||||
if (isSftpChannelEnded(sftp)) throw err;
|
||||
if (err?.noTransferFallback || transfer.noTransferFallback) {
|
||||
if (err && typeof err === "object" && transfer.noTransferFallback) {
|
||||
err.noTransferFallback = true;
|
||||
@@ -2774,7 +2544,7 @@ async function uploadFile(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isolated && !client.__netcattySudoMode && !preferSharedChannel) {
|
||||
if (!isolated && !client.__netcattySudoMode) {
|
||||
try {
|
||||
isolated = markDisposableUploadChannel(
|
||||
await openIsolatedSftpChannel(client, transfer?.signal),
|
||||
@@ -3379,21 +3149,9 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
|
||||
// the OPEN callback unanswered, so the upload stayed active with no error.
|
||||
sftp.on?.("end", onOpenChannelClosed);
|
||||
sftp.on?.("close", onOpenChannelClosed);
|
||||
if (sftp.readable === false) {
|
||||
// The channel already ended (TCP reset, sshd/sftp-server killed, VPN
|
||||
// drop). ssh2 never answers a request issued after close, and the
|
||||
// end/close events above will not fire again, so OPEN — read or write —
|
||||
// would wait forever and leave the task frozen at "Transferring".
|
||||
// No OPEN was sent. A write must release the path gate the same way a
|
||||
// pre-OPEN failure does: the generic write-error path keeps a generated
|
||||
// stage gate held, and a resumable upload skips the bounded fastPut
|
||||
// wait, so every later strategy on this path would wait forever.
|
||||
if (isWriteOpen) {
|
||||
settle(reject, sftpChannelClosedError(null, sftp));
|
||||
completeSharedWriteDrain();
|
||||
releasePathGate();
|
||||
return;
|
||||
}
|
||||
if (!isWriteOpen && sftp.readable === false) {
|
||||
// A failed isolated download may fall back after the browse channel's
|
||||
// end event already fired. ssh2 never answers a new OPEN on that channel.
|
||||
onOpenChannelClosed();
|
||||
return;
|
||||
}
|
||||
@@ -4885,31 +4643,16 @@ async function runPausableConcurrentRanges({
|
||||
active += 1;
|
||||
|
||||
void copyRange(position, length)
|
||||
.then((written) => {
|
||||
.then(() => {
|
||||
// After force-settle, abandoned ranges must not publish progress
|
||||
// or advance checkpoints into the caller's truncate/fallback window.
|
||||
if (settled) return;
|
||||
transferred += length;
|
||||
// Uploads return the bytes just acknowledged so the prefix digest
|
||||
// follows the contiguous checkpoint. Downloads store the end offset.
|
||||
if (Buffer.isBuffer(written)) {
|
||||
completedRanges.set(position, { end: position + length, bytes: written });
|
||||
} else {
|
||||
completedRanges.set(position, position + length);
|
||||
}
|
||||
completedRanges.set(position, position + length);
|
||||
while (completedRanges.has(contiguousCheckpoint)) {
|
||||
const next = completedRanges.get(contiguousCheckpoint);
|
||||
const nextCheckpoint = completedRanges.get(contiguousCheckpoint);
|
||||
completedRanges.delete(contiguousCheckpoint);
|
||||
if (typeof next === "number") {
|
||||
contiguousCheckpoint = next;
|
||||
} else {
|
||||
noteAcknowledgedUploadBytes(
|
||||
transfer.transferId,
|
||||
contiguousCheckpoint,
|
||||
next.bytes,
|
||||
);
|
||||
contiguousCheckpoint = next.end;
|
||||
}
|
||||
contiguousCheckpoint = nextCheckpoint;
|
||||
}
|
||||
publishContiguousCheckpoint(false);
|
||||
})
|
||||
@@ -5005,16 +4748,6 @@ async function uploadFileConcurrent(
|
||||
// Mutable: may shrink after path-gate wait if a stale truncating OPEN wiped
|
||||
// the stage while we were waiting (Codex P2 on 294b7a4b).
|
||||
let checkpoint = Math.max(0, Math.min(transfer.checkpointBytes || 0, fileSize));
|
||||
// A fallback strategy may have moved the resume offset. The prefix digest
|
||||
// only matches its own offset; anything else restarts at 0 before OPEN
|
||||
// chooses r+ vs w. Fingerprinted resumes do not consult this digest.
|
||||
if (!transfer.sourceFingerprint) {
|
||||
const alignedPrefix = adoptAcknowledgedPrefixOffset(transfer.transferId, checkpoint, fileSize);
|
||||
if (alignedPrefix !== checkpoint) {
|
||||
checkpoint = alignedPrefix;
|
||||
transfer.checkpointBytes = alignedPrefix;
|
||||
}
|
||||
}
|
||||
let channelError = null;
|
||||
const onChannelError = (error) => {
|
||||
channelError = channelError || error;
|
||||
@@ -5076,9 +4809,6 @@ async function uploadFileConcurrent(
|
||||
transfer.staleOpenTruncatedStage = false;
|
||||
checkpoint = 0;
|
||||
transfer.checkpointBytes = 0;
|
||||
if (localUploadAcknowledgedPrefix.has(String(transfer.transferId))) {
|
||||
resetLocalUploadAcknowledgedPrefix(transfer.transferId, fileSize);
|
||||
}
|
||||
try {
|
||||
sendProgress(0, fileSize, { force: true, checkpointBytes: 0 });
|
||||
} catch { /* best-effort */ }
|
||||
@@ -5102,18 +4832,6 @@ async function uploadFileConcurrent(
|
||||
size = Number(st?.size);
|
||||
}
|
||||
if (Number.isFinite(size) && size >= 0 && size < checkpoint) {
|
||||
const prefix = localUploadAcknowledgedPrefix.get(String(transfer.transferId));
|
||||
// The prefix digest cannot be rewound to a shorter stage.
|
||||
// Fingerprinted resumes keep the durable shorter offset.
|
||||
if (!transfer.sourceFingerprint && prefix && prefix.digestOffset > size) {
|
||||
checkpoint = 0;
|
||||
transfer.checkpointBytes = 0;
|
||||
resetLocalUploadAcknowledgedPrefix(transfer.transferId, fileSize);
|
||||
try {
|
||||
sendProgress(0, fileSize, { force: true, checkpointBytes: 0 });
|
||||
} catch { /* best-effort */ }
|
||||
return;
|
||||
}
|
||||
checkpoint = size;
|
||||
transfer.checkpointBytes = size;
|
||||
try {
|
||||
@@ -5137,13 +4855,11 @@ async function uploadFileConcurrent(
|
||||
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
|
||||
copyRange: async (position, length) => {
|
||||
// Size-based resume: read the source range at `position` and WRITE it.
|
||||
// The returned buffer is the prefix-digest input, hashed only after
|
||||
// the write is acknowledged and only in contiguous order.
|
||||
// No per-chunk content digest (FileZilla / WinSCP style).
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
await readLocalRange(localHandle, buffer, position, length);
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
await writeSftpRange(sftp, remoteHandle, buffer, position, length);
|
||||
return buffer;
|
||||
},
|
||||
sendProgress,
|
||||
abortChannel,
|
||||
@@ -5538,139 +5254,123 @@ async function downloadFile(
|
||||
|
||||
// One session-wide fast-path slot for isolated + shared READ fanout (#1507).
|
||||
// Wait (cancelable) instead of degrading to serial createReadStream (#2719).
|
||||
// Small files (one fanout window) skip the slot and the isolated channel and
|
||||
// read on the shared browse channel, so folder downloads run in parallel.
|
||||
// A stale or closed shared channel retries once on a fresh subsystem.
|
||||
// ENOENT and EACCES do not: a new channel would get the same file status.
|
||||
const smallDownload = Number(fileSize) > 0
|
||||
&& Number(fileSize) <= smallDownloadSlotExemptBytes
|
||||
&& typeof sftp.open === "function"
|
||||
&& typeof sftp.read === "function";
|
||||
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
|
||||
// True when this attempt finished the download (success or unhealthy close
|
||||
// after a complete body). False when the caller should try the next strategy.
|
||||
async function tryIsolatedDownload() {
|
||||
const fastSftp = await acquireIsolatedDownloadChannel(client, transfer);
|
||||
if (transfer.cancelled) {
|
||||
if (fastSftp) {
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, { dispose: true });
|
||||
}
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (fastSftp && (transfer.resumable || typeof fastSftp.fastGet === "function")) {
|
||||
try {
|
||||
if (transfer.resumable) {
|
||||
transfer.downloadStrategy = "concurrent-isolated";
|
||||
logTransferDiag(transfer, "strategy", {
|
||||
strategy: "concurrent-isolated",
|
||||
fields: {
|
||||
chunk: formatDiagBytes(TRANSFER_CHUNK_SIZE),
|
||||
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
|
||||
},
|
||||
});
|
||||
await downloadFileResumableFast(
|
||||
remotePath,
|
||||
localPath,
|
||||
fastSftp,
|
||||
fileSize,
|
||||
transfer,
|
||||
sendProgress,
|
||||
);
|
||||
await finishSuccessfulDownload();
|
||||
releaseIsolatedDownloadChannel(client, fastSftp);
|
||||
return true;
|
||||
}
|
||||
transfer.downloadStrategy = "fastGet-isolated";
|
||||
logTransferDiag(transfer, "strategy", { strategy: "fastGet-isolated" });
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let onFastSftpError = null;
|
||||
const onFastSftpClose = () => finish(new Error("SFTP channel closed during download"));
|
||||
const finish = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (transfer.abort === abortFastTransfer) {
|
||||
transfer.abort = null;
|
||||
}
|
||||
if (onFastSftpError) {
|
||||
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
|
||||
onFastSftpError = null;
|
||||
}
|
||||
fastSftp.removeListener("end", onFastSftpClose);
|
||||
fastSftp.removeListener("close", onFastSftpClose);
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, {
|
||||
dispose: !!err || transfer.cancelled,
|
||||
});
|
||||
|
||||
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
|
||||
else if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
const abortFastTransfer = () => {
|
||||
if (settled) return;
|
||||
transfer.cancelled = true;
|
||||
finish(new Error("Transfer cancelled"));
|
||||
};
|
||||
transfer.abort = abortFastTransfer;
|
||||
onFastSftpError = (err) => finish(err);
|
||||
fastSftp.once("error", onFastSftpError);
|
||||
fastSftp.once("end", onFastSftpClose);
|
||||
fastSftp.once("close", onFastSftpClose);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
finish(new Error("Transfer cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
fastSftp.fastGet(remotePath, localPath, {
|
||||
chunkSize: TRANSFER_CHUNK_SIZE,
|
||||
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
|
||||
step: (transferred, _chunk, total) => {
|
||||
if (transfer.cancelled) return;
|
||||
sendProgress(transferred, total || fileSize);
|
||||
},
|
||||
}, finish);
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Always release before rethrowing cancel — otherwise the channel stays
|
||||
// in pool.busy and the per-session fast-download budget is exhausted.
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, { dispose: true });
|
||||
if (transfer.cancelled) throw err;
|
||||
if (err?.noTransferFallback) throw err;
|
||||
if (err?.completedWithUnhealthyChannel) {
|
||||
await finishSuccessfulDownload();
|
||||
return true;
|
||||
}
|
||||
// Concurrent ranges may leave sparse tails past the contiguous
|
||||
// checkpoint; truncate before trying the next pipelined strategy.
|
||||
rememberPipelineError(err);
|
||||
await prepareDownloadFallbackCheckpoint();
|
||||
console.warn(
|
||||
"[transferBridge] isolated download failed, trying next pipelined strategy:",
|
||||
err?.message || String(err),
|
||||
);
|
||||
}
|
||||
} else if (fastSftp) {
|
||||
// Acquired a channel but cannot use fast path — return it to the pool.
|
||||
releaseIsolatedDownloadChannel(client, fastSftp);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let holdSessionSlot = false;
|
||||
try {
|
||||
if (!smallDownload) {
|
||||
await acquireSessionFastDownloadSlot(client, transfer);
|
||||
holdSessionSlot = true;
|
||||
}
|
||||
if (!client.__netcattySudoMode && !smallDownload) {
|
||||
if (await tryIsolatedDownload()) return;
|
||||
await acquireSessionFastDownloadSlot(client, transfer);
|
||||
holdSessionSlot = true;
|
||||
|
||||
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
|
||||
if (!client.__netcattySudoMode) {
|
||||
const fastSftp = await acquireIsolatedDownloadChannel(client, transfer);
|
||||
if (transfer.cancelled) {
|
||||
if (fastSftp) {
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, { dispose: true });
|
||||
}
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (fastSftp && (transfer.resumable || typeof fastSftp.fastGet === "function")) {
|
||||
try {
|
||||
if (transfer.resumable) {
|
||||
transfer.downloadStrategy = "concurrent-isolated";
|
||||
logTransferDiag(transfer, "strategy", {
|
||||
strategy: "concurrent-isolated",
|
||||
fields: {
|
||||
chunk: formatDiagBytes(TRANSFER_CHUNK_SIZE),
|
||||
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
|
||||
},
|
||||
});
|
||||
await downloadFileResumableFast(
|
||||
remotePath,
|
||||
localPath,
|
||||
fastSftp,
|
||||
fileSize,
|
||||
transfer,
|
||||
sendProgress,
|
||||
);
|
||||
await finishSuccessfulDownload();
|
||||
releaseIsolatedDownloadChannel(client, fastSftp);
|
||||
return;
|
||||
}
|
||||
transfer.downloadStrategy = "fastGet-isolated";
|
||||
logTransferDiag(transfer, "strategy", { strategy: "fastGet-isolated" });
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let onFastSftpError = null;
|
||||
const onFastSftpClose = () => finish(new Error("SFTP channel closed during download"));
|
||||
const finish = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (transfer.abort === abortFastTransfer) {
|
||||
transfer.abort = null;
|
||||
}
|
||||
if (onFastSftpError) {
|
||||
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
|
||||
onFastSftpError = null;
|
||||
}
|
||||
fastSftp.removeListener("end", onFastSftpClose);
|
||||
fastSftp.removeListener("close", onFastSftpClose);
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, {
|
||||
dispose: !!err || transfer.cancelled,
|
||||
});
|
||||
|
||||
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
|
||||
else if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
const abortFastTransfer = () => {
|
||||
if (settled) return;
|
||||
transfer.cancelled = true;
|
||||
finish(new Error("Transfer cancelled"));
|
||||
};
|
||||
transfer.abort = abortFastTransfer;
|
||||
onFastSftpError = (err) => finish(err);
|
||||
fastSftp.once("error", onFastSftpError);
|
||||
fastSftp.once("end", onFastSftpClose);
|
||||
fastSftp.once("close", onFastSftpClose);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
finish(new Error("Transfer cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
fastSftp.fastGet(remotePath, localPath, {
|
||||
chunkSize: TRANSFER_CHUNK_SIZE,
|
||||
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
|
||||
step: (transferred, _chunk, total) => {
|
||||
if (transfer.cancelled) return;
|
||||
sendProgress(transferred, total || fileSize);
|
||||
},
|
||||
}, finish);
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
// Always release before rethrowing cancel — otherwise the channel stays
|
||||
// in pool.busy and the per-session fast-download budget is exhausted.
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, { dispose: true });
|
||||
if (transfer.cancelled) throw err;
|
||||
if (err?.noTransferFallback) throw err;
|
||||
if (err?.completedWithUnhealthyChannel) {
|
||||
await finishSuccessfulDownload();
|
||||
return;
|
||||
}
|
||||
// Concurrent ranges may leave sparse tails past the contiguous
|
||||
// checkpoint; truncate before trying the next pipelined strategy.
|
||||
rememberPipelineError(err);
|
||||
await prepareDownloadFallbackCheckpoint();
|
||||
console.warn(
|
||||
"[transferBridge] isolated download failed, trying next pipelined strategy:",
|
||||
err?.message || String(err),
|
||||
);
|
||||
}
|
||||
} else if (fastSftp) {
|
||||
// Acquired a channel but cannot use fast path — return it to the pool.
|
||||
releaseIsolatedDownloadChannel(client, fastSftp);
|
||||
}
|
||||
}
|
||||
|
||||
// Pipelined READs on the shared browse session — covers sudo (no isolated
|
||||
// channel), small files, and non-sudo isolated miss/failure.
|
||||
// channel) and non-sudo isolated miss/failure. Same session slot already held.
|
||||
if (typeof sftp.open === "function" && typeof sftp.read === "function") {
|
||||
try {
|
||||
transfer.downloadStrategy = "concurrent-shared";
|
||||
@@ -5696,31 +5396,13 @@ async function downloadFile(
|
||||
return;
|
||||
} catch (err) {
|
||||
if (transfer.cancelled) throw err;
|
||||
// noTransferFallback means "do not keep using this dead channel".
|
||||
// A stale shared browse channel can still recover on one fresh
|
||||
// subsystem, which is the path main took before the small-file skip.
|
||||
const retryOnFreshChannel = smallDownload
|
||||
&& !client.__netcattySudoMode
|
||||
&& isStaleOrClosedSharedChannelError(err, sftp);
|
||||
if (err?.noTransferFallback && !retryOnFreshChannel) throw err;
|
||||
if (err?.noTransferFallback) throw err;
|
||||
rememberPipelineError(err);
|
||||
await prepareDownloadFallbackCheckpoint();
|
||||
if (!retryOnFreshChannel) {
|
||||
console.warn(
|
||||
"[transferBridge] concurrent shared download failed (no serial stream fallback):",
|
||||
err?.message || String(err),
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[transferBridge] shared download channel failed, trying a fresh SFTP channel:",
|
||||
err?.message || String(err),
|
||||
);
|
||||
if (!holdSessionSlot) {
|
||||
await acquireSessionFastDownloadSlot(client, transfer);
|
||||
holdSessionSlot = true;
|
||||
}
|
||||
if (await tryIsolatedDownload()) return;
|
||||
}
|
||||
console.warn(
|
||||
"[transferBridge] concurrent shared download failed (no serial stream fallback):",
|
||||
err?.message || String(err),
|
||||
);
|
||||
}
|
||||
} else if (!lastPipelineError) {
|
||||
lastPipelineError = new Error(
|
||||
@@ -6386,7 +6068,6 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
directoryEntryIdentity: transfer.directoryEntryIdentity,
|
||||
});
|
||||
cleanupTransfer();
|
||||
forgetLocalUploadAcknowledgedPrefix(transferId);
|
||||
};
|
||||
|
||||
const sendError = (error) => {
|
||||
@@ -6412,9 +6093,6 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
}
|
||||
cleanupTransfer();
|
||||
const cancelled = /cancel/i.test(message);
|
||||
if (cancelled || !localUploadPrefixFailureKeepsCheckpoint(transferId, message)) {
|
||||
forgetLocalUploadAcknowledgedPrefix(transferId);
|
||||
}
|
||||
if (cancelled) {
|
||||
sender.send("netcatty:transfer:cancelled", { transferId, error: message });
|
||||
broadcastGlobalTransferEvent({
|
||||
@@ -6501,45 +6179,6 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
const hasSavedCheckpoint = transfer.checkpointBytes > 0
|
||||
|| transfer.downloadCheckpointBytes > 0
|
||||
|| transfer.uploadCheckpointBytes > 0;
|
||||
// A dropped upload never pauses, so it has a checkpoint and no full-file
|
||||
// SHA-256. Keep that checkpoint only when the local bytes in [0, offset)
|
||||
// still hash to the digest of the bytes acknowledged on the .part, and
|
||||
// the source size is unchanged. The new connection stats and trims the
|
||||
// same .part below. A rewrite, a smaller offset than the digest covers,
|
||||
// a missing baseline, meta: identities, and every other direction restart.
|
||||
const unfingerprintedLocalUpload = sourceType === "local"
|
||||
&& targetType === "sftp"
|
||||
&& !transfer.sourceFingerprint;
|
||||
const localSourceSize = unfingerprintedLocalUpload
|
||||
? await currentLocalUploadSourceSize(transfer, sourcePath)
|
||||
: null;
|
||||
const claimedUnfingerprintedUpload = unfingerprintedLocalUpload
|
||||
&& transfer.checkpointBytes > 0;
|
||||
let localSourceUnchanged = false;
|
||||
if (claimedUnfingerprintedUpload) {
|
||||
// Start the read before the preflight race so Cancel aborts the same
|
||||
// signal hashLocalPrefix is watching. The extra catch keeps a late
|
||||
// hash rejection from surfacing once the race has already settled.
|
||||
const verifyPromise = localUploadAcknowledgedPrefixMatches(
|
||||
transferId,
|
||||
sourcePath,
|
||||
transfer.checkpointBytes,
|
||||
localSourceSize,
|
||||
{ signal: transfer.signal },
|
||||
);
|
||||
void verifyPromise.catch(() => {});
|
||||
localSourceUnchanged = await runCancelablePreflight(() => verifyPromise);
|
||||
}
|
||||
// Start a fresh digest when this attempt will write from byte 0. A matching
|
||||
// resume rewinds the running hash to the stored offset so later chunks append.
|
||||
if (unfingerprintedLocalUpload) {
|
||||
if (localSourceUnchanged) {
|
||||
rewindLocalUploadAcknowledgedPrefix(transferId);
|
||||
} else {
|
||||
resetLocalUploadAcknowledgedPrefix(transferId, localSourceSize);
|
||||
}
|
||||
}
|
||||
const verifyUploadStageOnNewConnection = localSourceUnchanged;
|
||||
if (
|
||||
transfer.resumable
|
||||
&& hasSavedCheckpoint
|
||||
@@ -6547,7 +6186,6 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
!transfer.sourceFingerprint
|
||||
|| String(transfer.sourceFingerprint).startsWith("meta:")
|
||||
)
|
||||
&& !verifyUploadStageOnNewConnection
|
||||
) {
|
||||
// Older builds persisted only size/mtime/sparse samples. They cannot prove
|
||||
// that the untransferred suffix still belongs to the same source. Restart
|
||||
@@ -6610,49 +6248,20 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
transfer.stagedRemote = usesStage
|
||||
? { client, sftpId: targetSftpId, path: uploadTargetPath, encoding: targetEncoding }
|
||||
: null;
|
||||
let claimedCheckpoint = transfer.checkpointBytes;
|
||||
if (
|
||||
usesStage
|
||||
&& transfer.resumable
|
||||
&& claimedCheckpoint > 0
|
||||
&& !hasFullSourceFingerprint(transfer.sourceFingerprint)
|
||||
) {
|
||||
claimedCheckpoint = await runCancelablePreflight(() => (
|
||||
reconcileUnfingerprintedUploadCheckpoint(
|
||||
client,
|
||||
targetSftpId,
|
||||
uploadTargetPath,
|
||||
targetEncoding,
|
||||
claimedCheckpoint,
|
||||
)
|
||||
));
|
||||
}
|
||||
transfer.checkpointBytes = usesStage && transfer.resumable
|
||||
? await runCancelablePreflight(() => resolveRemoteResumeCheckpoint(
|
||||
client,
|
||||
targetSftpId,
|
||||
uploadTargetPath,
|
||||
targetEncoding,
|
||||
claimedCheckpoint,
|
||||
transfer.checkpointBytes,
|
||||
))
|
||||
: 0;
|
||||
if (unfingerprintedLocalUpload) {
|
||||
const aligned = adoptAcknowledgedPrefixOffset(
|
||||
transferId,
|
||||
transfer.checkpointBytes,
|
||||
localSourceSize ?? fileSize,
|
||||
);
|
||||
if (aligned !== transfer.checkpointBytes) {
|
||||
transfer.checkpointBytes = aligned;
|
||||
transfer.downloadCheckpointBytes = 0;
|
||||
transfer.uploadCheckpointBytes = 0;
|
||||
}
|
||||
}
|
||||
sendProgress(transfer.checkpointBytes, fileSize, { force: true });
|
||||
// The .part size is reconciled above. Compare the local prefix to the
|
||||
// digest of bytes already acknowledged — do not hash the remote prefix.
|
||||
// Hashing both ends blocked large-file resume for seconds-to-minutes
|
||||
// with no bytes moving.
|
||||
// Resume is size-based only (WinSCP/FileZilla): the durable .part size
|
||||
// is the checkpoint. Do not re-hash [0, checkpoint) on both ends —
|
||||
// that blocked large-file resume for seconds-to-minutes with no bytes
|
||||
// moving.
|
||||
await uploadFile(
|
||||
sourcePath,
|
||||
encodedUploadPath,
|
||||
@@ -7185,25 +6794,6 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
|
||||
return { transferId, totalBytes: fileSize };
|
||||
} catch (err) {
|
||||
const failureMessage = err?.message || String(err);
|
||||
if (
|
||||
sourceType === "local"
|
||||
&& targetType === "sftp"
|
||||
&& !transfer.sourceFingerprint
|
||||
&& !transfer.cancelled
|
||||
&& !/superseded/i.test(failureMessage)
|
||||
&& failureMessage !== "Transfer cancelled"
|
||||
) {
|
||||
const covered = localUploadAcknowledgedOffset(transferId);
|
||||
if (covered > 0) {
|
||||
try {
|
||||
sendProgress(covered, Number(transfer.totalBytes) || covered, {
|
||||
force: true,
|
||||
checkpointBytes: covered,
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
logTransferDiag(transfer, transfer.cancelled ? "cancelled" : "error", {
|
||||
transferred: transfer.diagLastTransferred,
|
||||
total: transfer.diagTotalBytes,
|
||||
@@ -7259,7 +6849,6 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
} catch { }
|
||||
}
|
||||
cleanupTransfer();
|
||||
forgetLocalUploadAcknowledgedPrefix(transferId);
|
||||
sender.send("netcatty:transfer:cancelled", { transferId });
|
||||
broadcastGlobalTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
||||
} else {
|
||||
@@ -7853,9 +7442,6 @@ async function prioritizeTransfer(_event, payload) {
|
||||
async function cleanupTransferArtifacts(_event, payload) {
|
||||
const transferId = payload?.transferId;
|
||||
if (!transferId) return { success: false };
|
||||
// Dismiss, clear, and fresh-id retry all come through here. The digest is
|
||||
// only useful while this task id can still resume.
|
||||
forgetLocalUploadAcknowledgedPrefix(transferId);
|
||||
const stageNames = new Set([
|
||||
path.basename(payload.targetPath || "transfer"),
|
||||
path.basename(payload.sourcePath || "transfer"),
|
||||
@@ -8264,13 +7850,4 @@ module.exports = {
|
||||
_assertLocalDownloadMatchesRemotePrefixForTests: assertLocalDownloadMatchesRemotePrefix,
|
||||
_hashRemotePrefixForTests: hashRemotePrefix,
|
||||
_remoteOpenPathMatchesStagedForTests: remoteOpenPathMatchesStaged,
|
||||
_setSmallDownloadSlotExemptBytesForTests: (bytes) => {
|
||||
smallDownloadSlotExemptBytes = bytes == null ? SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES : Math.max(0, Number(bytes) || 0);
|
||||
},
|
||||
_setLocalPrefixHashStreamOverrideForTests: (override) => {
|
||||
localPrefixHashStreamOverrideForTests = override;
|
||||
},
|
||||
_hasLocalUploadAcknowledgedPrefixForTests: (transferId) => (
|
||||
localUploadAcknowledgedPrefix.has(String(transferId))
|
||||
),
|
||||
};
|
||||
@@ -28,24 +28,8 @@ const SFTP_REQUEST_TIMEOUT_MS = 30_000;
|
||||
// channel pool caps - they must never fall back to serial body streams.
|
||||
const FAST_DOWNLOAD_CHANNELS_PER_SESSION = 1;
|
||||
|
||||
// A file that fits in one READ fanout window (64 x 32KB) cannot hog the
|
||||
// session the way a multi-GB download can, so it skips the per-session slot
|
||||
// above and reads on the shared browse channel. Without this, folder downloads
|
||||
// of small files ran strictly one file at a time regardless of the transfer
|
||||
// concurrency setting.
|
||||
const SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES = DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE;
|
||||
|
||||
// A file that fits in one fanout window finishes in a couple of round trips.
|
||||
// Opening a dedicated sftp-server channel for it (channel open + subsystem +
|
||||
// SFTP init) costs more than the body itself on WAN links and spawns one
|
||||
// remote process per file in folder uploads, so small files use the shared
|
||||
// session's pipelined WRITE path instead.
|
||||
const SMALL_UPLOAD_SHARED_MAX_BYTES = UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE;
|
||||
|
||||
module.exports = {
|
||||
DOWNLOAD_TRANSFER_CONCURRENCY,
|
||||
SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES,
|
||||
SMALL_UPLOAD_SHARED_MAX_BYTES,
|
||||
FAST_DOWNLOAD_CHANNELS_PER_SESSION,
|
||||
SFTP_OPEN_TIMEOUT_MS,
|
||||
SFTP_REQUEST_TIMEOUT_MS,
|
||||
|
||||
@@ -3,9 +3,7 @@ const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
FAST_DOWNLOAD_CHANNELS_PER_SESSION,
|
||||
SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES,
|
||||
DOWNLOAD_TRANSFER_CONCURRENCY,
|
||||
SMALL_UPLOAD_SHARED_MAX_BYTES,
|
||||
UPLOAD_TRANSFER_CONCURRENCY,
|
||||
TRANSFER_CHUNK_SIZE,
|
||||
} = require("./transferLimits.cjs");
|
||||
@@ -20,18 +18,8 @@ test("SFTP transfer limits preserve upload safety and the pre-regression downloa
|
||||
// #2449 / Electerm-class: upload window is 2MB (64 × 32KB). Raising only
|
||||
// concurrency keeps the safe packet size while matching ssh2/Electerm defaults.
|
||||
assert.equal(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 2 * 1024 * 1024);
|
||||
// One fanout window. Files at or under this size upload on the shared browse
|
||||
// channel instead of opening a dedicated sftp-server per file.
|
||||
assert.equal(SMALL_UPLOAD_SHARED_MAX_BYTES, UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE);
|
||||
// #2030 reduced the shared chunk size from 512KB to 32KB. Downloads need
|
||||
// enough parallel reads to preserve the 2MB in-flight window that existed
|
||||
// after #1533, otherwise high-latency proxy paths collapse to KB/s speeds.
|
||||
assert.equal(DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 2 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("small downloads (one READ window) are exempt from the per-session fast slot", () => {
|
||||
// Big files still serialize on FAST_DOWNLOAD_CHANNELS_PER_SESSION (#1507).
|
||||
assert.equal(FAST_DOWNLOAD_CHANNELS_PER_SESSION, 1);
|
||||
assert.equal(SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES, DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE);
|
||||
assert.equal(SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES, 2 * 1024 * 1024);
|
||||
});
|
||||
@@ -97,28 +97,6 @@ test("collectSshDeepLinkQueueItems preserves tolerated PuTTY-style -newtab names
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems names PuTTY jump launches after the final server", () => {
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe",
|
||||
"-ssh",
|
||||
"alice@192.0.2.10@198.51.100.20",
|
||||
"-P",
|
||||
"2200",
|
||||
"-pw",
|
||||
"example",
|
||||
], { includeSchemeUrls: false }),
|
||||
{
|
||||
ssh: [{
|
||||
rawUrl: "ssh://alice%40192.0.2.10:example@198.51.100.20:2200",
|
||||
viaCommandLine: true,
|
||||
tabName: "192.0.2.10",
|
||||
}],
|
||||
telnet: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems preserves SecureCRT /N names", () => {
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
|
||||