Compare commits

..
Author SHA1 Message Date
bincxz b3bf7c25fc fix: load shared container runtime in Vite renderer 2026-10-21 11:03:51 +08:00
129 changed files with 491 additions and 7712 deletions

No files matched your search

+10 -16
View File
@@ -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&amp;utm_medium=badge&amp;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" />
---
+10 -10
View File
@@ -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" />
---
+10 -16
View File
@@ -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&amp;utm_medium=badge&amp;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" />
---
+10 -16
View File
@@ -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&amp;utm_medium=badge&amp;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");
}
});
}
+2 -23
View File
@@ -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]);
-9
View File
@@ -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)
-9
View File
@@ -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)
-9
View File
@@ -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)
-100
View File
@@ -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);
});
}
+2 -19
View File
@@ -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/);
});
-48
View File
@@ -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");
+9 -94
View File
@@ -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");
+17 -173
View File
@@ -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");
+3 -26
View File
@@ -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');
+9 -26
View File
@@ -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();
+1 -1
View File
@@ -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);
+29 -75
View File
@@ -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);
}
});
-2
View File
@@ -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];
}
}
});
-72
View File
@@ -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");
});
+12 -16
View File
@@ -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),
+1 -1
View File
@@ -270,7 +270,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
groupConfigs,
importDataFromString,
clearVaultData,
} = useVaultState({ syncConnectionLogTerminalData: false });
} = useVaultState();
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
+2 -25
View File
@@ -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,
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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 -12
View File
@@ -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/);
});
+1 -24
View File
@@ -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 -105
View File
@@ -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
+2 -6
View File
@@ -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"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

-27
View File
@@ -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.
-169
View File
@@ -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)));
});
-84
View File
@@ -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[],
-31
View File
@@ -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);
});
-36
View File
@@ -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 };
}
+10 -265
View File
@@ -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[];
+1 -226
View File
@@ -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",
+4 -46
View File
@@ -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 };
};
-3
View File
@@ -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',
+1 -4
View File
@@ -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);
});
+56 -73
View File
@@ -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) => {
-94
View File
@@ -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 = [];
+4 -4
View File
@@ -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);
});
+4 -5
View File
@@ -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,
+1 -1
View File
@@ -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);
});
+18 -188
View File
@@ -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");
}
});
}
+7 -112
View File
@@ -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,
+2 -262
View File
@@ -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,
});
}
});
+15 -21
View File
@@ -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;
}
+1 -7
View File
@@ -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);
+8 -8
View File
@@ -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 () => {
+1 -1
View File
@@ -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$/ }
+6 -18
View File
@@ -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" });
});
+1 -4
View File
@@ -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>');
});
+2 -80
View File
@@ -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();
+4 -284
View File
@@ -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");
});
+154 -577
View File
@@ -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))
),
};
File diff suppressed because it is too large. Load diff
-16
View File
@@ -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,
-12
View File
@@ -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);
});
-22
View File
@@ -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([
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more