@zhangfengshun/dsh-remote-ssh 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.3.1] — 修复 2.3.0 上线后的界面卡顿(GC 压力 + 客户端全页扫描)
6
+ ### 修复
7
+ - **读缓存字节预算(review m1)**:单条结果 >1MiB 不再缓存(结果照常返回),缓存总量上限 32MB 超限逐出最旧条目——消除大文件驻留字符串造成的 GC 压力(最坏 ~270MB → ≤32MB)。
8
+ - **设置导航齿轮观察器改为增量扫描**:此前任何 DOM 变动都会触发全文档 `querySelectorAll("button")` 扫描(流式聊天期间持续打断主线程);现改为只扫描新增子树、节流 500ms、后台页签零开销,稳态成本≈0。
9
+ - **exec 失败不再 bump epoch(review m3)**:失败的命令通常无副作用,不再无谓打断整代缓存(部分副作用由 ≤5s TTL 兜底)。
10
+ - **git 只读命令不再整代失效(review m4)**:仅 add/reset/commit/checkout/revert/cherry-pick 变更类子命令 bump,status/diff/log/branch/show 不再打断缓存。
11
+
12
+ ## [2.3.0] — 远程文件打开提速:单往返合并读 + 结果缓存(实测 ≈1.3×~5×)
13
+ ### 性能
14
+ - **fs.read 单往返合并读**:size/mtime 与文件内容合并为一条池化命令(`__DSH_ST__` 帧,`_` 不在 base64 字母表故标记天然抗噪),去掉每文件一次的 stat 往返。真实 HPC 实测(100KB 文件,各 10 次 p50):raw 直传 396.6→301.8ms、base64 443.8→337.9ms,约 **1.31×**;1MB 大文件同步受益。>4MB 仍 `head -c 4MiB` 截断,二进制探测与解码语义不变。
15
+ - **raw 文本快路径**:文本扩展名白名单(.py/.js/.md/… 等)免 base64 直传(传输段再省 ≈24-27%);字节长度 + U+FFFD 双校验,不符自动回退 base64 重读——结果与旧实现逐字节一致,仅多 1 RTT。
16
+ - **主机侧结果缓存**:读结果与目录列举各 LRU(32/64 条)+ TTL 5s。TTL 内重复打开/切回页签 **0 RTT**(实测 ≈0.02ms);过期后 1 次轻量 mtime+size 复验,未变免重传(实测 84.9ms,约 **5×**);目录列举 TTL 内 0ms。写/删/移/建目录/上传/推送/远端 exec/git 自动失效,并引入每 profile 缓存代(epoch)兜底「同秒同 size 写」与 `~/x` vs `/home/u/x` 路径字符串空间差。
17
+ - **目录列举 `cd` 包进子 shell**:修复列举后 `cd` 残留污染共享会话 cwd 的既有问题(旧实现会使会话内后续相对路径命令落空)。
18
+ ### 修复
19
+ - **写路径字节精确**:`remote_ssh_write` / 文件页签保存改走 `base64 -d` 管道写入,修复旧 `cat > file` heredoc 帧每次保存尾随多 1 字节的问题(实测 3/3/4B,旧实现 4/4/5B),且对 NUL/二进制内容安全。
20
+ ### 已知限制
21
+ - 集成终端(`ssh -tt`)与远端外部进程改动的文件,插件缓存最多 **5s** 陈旧;外部进程「同秒内同 size 写」且此后不再变化时,mtime 复验无法察觉(极窄窗口;插件自身写/exec/git 路径经失效与 epoch 兜底,不受此限)。
22
+ - `/sidebar/file` 下载池化路径有效上限 ≈**6.29MB**(会话 8MB 输出熔断 ÷ base64 4/3 膨胀);更大文件自动退回一次性连接下载(可成功,但多付一次废传输与重连),2.4.0 候选修复。
23
+ - 二进制内容伪装文本扩展名时,raw 校验拒绝后自动回退 base64 重读(多 1 RTT,结果正确)。
24
+
5
25
  ## [2.2.1] — 去掉设置导航「远程连接」左侧的齿轮图标
6
26
  ### 修复
7
27
  - DSH 壳层对未知 `settings.section` 渲染默认齿轮图标,且导航类名是构建期哈希(每版都会变),旧版硬编码的 CSS 选择器(`VOzbGW_*`)已随 DSH 更新失效。改为按栏目标题文本定位设置导航按钮并隐藏其图标 svg(MutationObserver + 语言切换自动重跑),跨 DSH 构建版本稳定。
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  ## 安装
18
18
 
19
19
  ```bash
20
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.7
20
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.0
21
21
  ```
22
22
 
23
23
  > 安装后需**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
@@ -54,6 +54,16 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.7
54
54
 
55
55
  Shell wrapper(`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`)检测工作区 `.remote-ssh.json`,自动 `ssh -tt` 连接远程,使内置「终端」页签透明接入。
56
56
 
57
+ ## 缓存与一致性
58
+
59
+ 远程读取与目录列举结果在主机侧缓存(LRU 32/64 条,TTL 5 秒):TTL 内重复打开或切回页签 0 网络往返;过期后先做一次轻量 mtime+size 复验,未变化则免重传。写、删除、移动、建目录、上传、推送(push)、远端 exec 与 git 面板操作会自动失效相关缓存。
60
+
61
+ 已知限制:
62
+
63
+ - 集成终端(`ssh -tt`)与远端其它进程改动的文件依赖 TTL + 复验兜底,最多 **5 秒**陈旧;
64
+ - `/sidebar/file` 下载池化路径有效上限约 **6.29MB**,更大文件自动退回一次性连接下载(可成功,多一次重连开销);
65
+ - 二进制内容伪装成文本扩展名时会多一次 base64 回退往返(结果正确)。
66
+
57
67
  ## ❤️ 七夕快乐
58
68
 
59
69
  本项目是送给 **zhangyi** 的七夕礼物。
package/README_EN.md CHANGED
@@ -17,7 +17,7 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
17
17
  ## Installation
18
18
 
19
19
  ```bash
20
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.7
20
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.0
21
21
  ```
22
22
 
23
23
  > **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
@@ -54,6 +54,16 @@ The plugin registers 4 exact routes (`/sidebar/api/fs.tree`, `fs.read`, `fs.writ
54
54
 
55
55
  A shell wrapper (`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`) detects the workspace's `.remote-ssh.json` and auto-launches `ssh -tt`, making the built-in **Terminal** tab transparently connect to remote.
56
56
 
57
+ ## Caching & Consistency
58
+
59
+ Remote reads and directory listings are cached host-side (LRU 32/64 entries, TTL 5s): re-opening or switching back to a tab within the TTL costs 0 network round-trips; after expiry a lightweight mtime+size revalidation runs first, and unchanged files are served without re-transfer. Write, delete, move, mkdir, upload, push (syncUp), remote exec and git-panel operations automatically invalidate the affected cache entries.
60
+
61
+ Known limitations:
62
+
63
+ - Files changed from the integrated terminal (`ssh -tt`) or by other remote processes rely on TTL + revalidation and may be stale for up to **5 seconds**;
64
+ - The pooled `/sidebar/file` download path has an effective limit of ≈**6.29MB**; larger files automatically fall back to a one-shot connection download (succeeds, with one extra reconnect);
65
+ - Binary content masquerading with a text extension costs one extra base64 fallback round-trip (results are still correct).
66
+
57
67
  ## ❤️ Happy Qixi
58
68
 
59
69
  This project is a Qixi Festival gift for **zhangyi**.
package/lib/client.js CHANGED
@@ -1115,32 +1115,58 @@ window.__ModuleLoader__.load({
1115
1115
  // 不可靠。这里改为按条目文本(settings.nav)定位设置导航按钮,隐藏其 svg 图标;
1116
1116
  // 设置面板按需挂载、语言会切换,用 MutationObserver + 语言订阅自动重跑。
1117
1117
  if (slots) {
1118
+ function checkCell(btn) {
1119
+ var label = i18n.t("settings.nav");
1120
+ var text = (btn.textContent || "").trim();
1121
+ if (text !== label.trim() || text === "") return false;
1122
+ var icon = btn.querySelector("svg");
1123
+ if (icon) { icon.style.display = "none"; return true; }
1124
+ return false;
1125
+ }
1126
+ function checkCellIn(root) {
1127
+ try {
1128
+ if (root.tagName && String(root.tagName).toLowerCase() === "button") checkCell(root);
1129
+ var cells = root.querySelectorAll("button");
1130
+ for (var i = 0; i < cells.length; i++) checkCell(cells[i]);
1131
+ } catch (e) {}
1132
+ }
1118
1133
  function hideRemoteGearIcon() {
1119
1134
  try {
1120
- var label = i18n.t("settings.nav");
1121
1135
  var buttons = document.querySelectorAll("button");
1122
- for (var i = 0; i < buttons.length; i++) {
1123
- var btn = buttons[i];
1124
- var text = (btn.textContent || "").trim();
1125
- if (text === label.trim() && text !== "") {
1126
- var icon = btn.querySelector("svg");
1127
- if (icon) { icon.style.display = "none"; return true; }
1128
- }
1129
- }
1136
+ for (var i = 0; i < buttons.length; i++) checkCell(buttons[i]);
1130
1137
  } catch (e) {}
1131
- return false;
1132
1138
  }
1133
1139
  ctx.effect(function () {
1134
1140
  if (typeof document === "undefined") return;
1135
1141
  hideRemoteGearIcon();
1136
1142
  var timer = null;
1137
- var schedule = function () {
1143
+ var pendingRoots = null;
1144
+ var schedule = function (root) {
1145
+ if (root && root.nodeType === 1) (pendingRoots || (pendingRoots = [])).push(root);
1138
1146
  if (timer) return;
1139
- timer = setTimeout(function () { timer = null; hideRemoteGearIcon(); }, 120);
1147
+ timer = setTimeout(function () {
1148
+ timer = null;
1149
+ var roots = pendingRoots;
1150
+ pendingRoots = null;
1151
+ if (document.hidden) return; // 后台页签零开销
1152
+ if (roots && roots.length) {
1153
+ // 只扫新增子树:流式聊天期间不做全文档扫描(稳态成本≈0)
1154
+ for (var i = 0; i < roots.length; i++) {
1155
+ if (roots[i].isConnected) checkCellIn(roots[i]);
1156
+ }
1157
+ } else {
1158
+ hideRemoteGearIcon(); // 语言切换等无根场景全量扫一次
1159
+ }
1160
+ }, 500);
1140
1161
  };
1141
- var mo = new MutationObserver(schedule);
1162
+ var mo = new MutationObserver(function (muts) {
1163
+ for (var i = 0; i < muts.length; i++) {
1164
+ var added = muts[i].addedNodes;
1165
+ for (var j = 0; j < added.length; j++) schedule(added[j]);
1166
+ }
1167
+ });
1142
1168
  mo.observe(document.body, { childList: true, subtree: true });
1143
- var off = i18n.subscribe(schedule);
1169
+ var off = i18n.subscribe(function () { schedule(null); });
1144
1170
  return function () {
1145
1171
  mo.disconnect();
1146
1172
  if (off) off();
package/lib/index.js CHANGED
@@ -408,7 +408,17 @@ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes) {
408
408
 
409
409
  async function remoteListDir(runner, p, path) {
410
410
  const target = path || p.remoteRoot || "~";
411
- const script = "cd " + shellQuotePath(target) + " 2>/dev/null || { echo '__DSH_ERR__ cannot cd'; exit 1; }; find . -maxdepth 1 -mindepth 1 -printf '%Y\\t%f\\t%s\\n' 2>/dev/null | sort";
411
+ const pk = profileKey(p);
412
+ const key = pk + "|" + String(target);
413
+ const epoch = cacheEpoch(pk);
414
+ const hit = lruGet(treeCache, key);
415
+ if (hit && hit.epoch === epoch && Date.now() - hit.at <= TREE_CACHE_TTL_MS) {
416
+ return { ok: true, path: target, entries: hit.entries.slice() }; // TTL 内 0 RTT
417
+ }
418
+ if (hit && hit.epoch !== epoch) treeCache.delete(key);
419
+ // cd 包进子 shell:列举完成后不污染共享会话 cwd(findings §4.4 —— 旧实现 cd 持久生效,
420
+ // 会话内后续相对路径命令全部落空)。exit 只退出子 shell,哨兵协议不受影响。
421
+ const script = "( cd " + shellQuotePath(target) + " 2>/dev/null || { echo '__DSH_ERR__ cannot cd'; exit 1; }; find . -maxdepth 1 -mindepth 1 -printf '%Y\\t%f\\t%s\\n' 2>/dev/null | sort )";
412
422
  const r = await runner(p, script, undefined, MAX_BYTES);
413
423
  if (!r.ok) return { ok: false, error: (r.error || r.stderr || "").trim() || "读取目录失败" };
414
424
  const entries = [];
@@ -425,39 +435,322 @@ async function remoteListDir(runner, p, path) {
425
435
  entries.sort(function (a, b) {
426
436
  return a.type === b.type ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : (a.type === "directory" ? -1 : 1);
427
437
  });
438
+ lruPut(treeCache, key, { entries: entries, epoch: epoch, at: Date.now() }, TREE_CACHE_MAX);
428
439
  return { ok: true, path: target, entries: entries };
429
440
  }
430
441
 
442
+ // ---------------------------------------------------------------------------
443
+ // 远程读取提速(t2):单往返合并读 + raw 文本快路径 + mtime/size LRU 结果缓存
444
+ //
445
+ // 帧协议(沿用既有非 split 快速路径 "{ cmd ; } 2>&1" + 退出码哨兵;路径一律先
446
+ // f=<shellQuotePath> 再以 "$f" 引用,无任何 cwd 依赖):
447
+ // stdout 首行固定为 __DSH_ST__<size>:<mtime> (stat 成功,均为十进制)
448
+ // 或 __DSH_ST__ERR:<单行摘要> (stat 失败:摘要压成单行 ≤200 字符,
449
+ // 整条命令以内建 false 收尾保证非零
450
+ // 退出码 —— 绝不空内容假成功/挂起;
451
+ // 不用 exit,因非 split 帧无子 shell 隔离)
452
+ // 载荷 = 首行 \n 之后:base64 模式 `base64 -w0`(size>4MB 先 `head -c 4MB` 截断),
453
+ // raw 模式为文件原始字节(仅限文本扩展名白名单)。
454
+ // `_` 不在 base64 字母表内 → base64 载荷不可能出现 `__DSH_ST__`,标记定位天然抗噪;
455
+ // 协议层固定在哨兵前补一个 \n(exitMark),raw 解析按“恰好去掉末尾一个 \n”还原。
456
+ // ---------------------------------------------------------------------------
457
+
458
+ const DSH_ST_MARK = "__DSH_ST__";
459
+ /** raw 快路径扩展名白名单(findings §3②):只对按 UTF-8 文本处理的文件免 base64。
460
+ * 字节长度或 \uFFFD 校验失败时自动回退 base64 重读,不劣于现状。 */
461
+ const RAW_TEXT_EXTS = new Set([
462
+ ".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".tsv",
463
+ ".py", ".pyw", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".vue", ".svelte",
464
+ ".json", ".jsonc", ".json5", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
465
+ ".sh", ".bash", ".zsh", ".fish", ".ps1", ".psm1", ".bat", ".cmd", ".sql",
466
+ ".c", ".h", ".cpp", ".hpp", ".cc", ".hh", ".cxx", ".hxx", ".f", ".for", ".f90", ".f95",
467
+ ".java", ".go", ".rs", ".rb", ".pl", ".pm", ".php", ".lua", ".r", ".jl", ".dart", ".kt",
468
+ ".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".svg", ".xsl", ".proto",
469
+ ".tex", ".bib", ".properties", ".gradle", ".cmake", ".mk", ".diff", ".patch", ".ipynb"
470
+ ]);
471
+ /** 无扩展名 / 点开头的常读文本文件(按整名匹配,小写)。 */
472
+ const RAW_TEXT_NAMES = new Set([
473
+ "makefile", "dockerfile", "license", "readme", "changelog", "authors", "contributors",
474
+ ".gitignore", ".gitattributes", ".gitmodules", ".gitconfig", ".gitkeep", ".editorconfig",
475
+ ".dockerignore", ".npmrc", ".nvmrc", ".bashrc", ".bash_profile", ".profile", ".vimrc", ".tmux.conf"
476
+ ]);
477
+ /** 读结果 LRU:key = profileKey|path,TTL ≤5s;过期后 1 裸 RTT stat 复验 mtime+size。 */
478
+ const READ_CACHE_MAX = 32;
479
+ const READ_CACHE_TTL_MS = 5000;
480
+ /** 目录列举 LRU:key = profileKey|dir,TTL 5s。 */
481
+ const TREE_CACHE_MAX = 64;
482
+ const TREE_CACHE_TTL_MS = 5000;
483
+ /** 每个 profile 的缓存代(epoch):插件侧任何变更(写/删/移/建目录/上传/syncUp/
484
+ * 远程 exec/git)都 +1,条目按填充时代号校验 —— 兜底 mtime 秒级粒度的“同秒同 size”
485
+ * 写、~/x 与 /home/u/x 路径字符串空间不一致、以及漏钩子场景(findings §4.3)。 */
486
+ const cacheEpochs = new Map();
487
+ const readCache = new Map();
488
+ const treeCache = new Map();
489
+
490
+ function profileKey(p) {
491
+ // 认证信息变化时应重建会话/缓存:修改 keyPath/authMethod 后旧身份不能继续复用。
492
+ return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "") + "|" + String(p.authMethod || "key") + "|" + String(p.keyPath || "");
493
+ }
494
+
495
+ function cacheEpoch(pk) {
496
+ let e = cacheEpochs.get(pk);
497
+ if (!e) { e = 1; cacheEpochs.set(pk, e); }
498
+ return e;
499
+ }
500
+
501
+ /** 只递增 epoch 不删条目:远程 exec / git 这类“可能改了任意文件”的入口调用。 */
502
+ function bumpCacheEpoch(p) {
503
+ const pk = profileKey(p);
504
+ cacheEpochs.set(pk, cacheEpoch(pk) + 1);
505
+ }
506
+
507
+ function lruGet(map, key) {
508
+ const v = map.get(key);
509
+ if (v === undefined) return undefined;
510
+ map.delete(key);
511
+ map.set(key, v); // LRU touch
512
+ return v;
513
+ }
514
+
515
+ function lruPut(map, key, val, max) {
516
+ map.delete(key);
517
+ map.set(key, val);
518
+ while (map.size > max) map.delete(map.keys().next().value);
519
+ }
520
+
521
+ /** 读缓存字节预算(review m1):单条 >1MiB 不缓存、总量 ≤32MB,防止大文件驻留
522
+ * 造成 GC 压力拖慢整个宿主进程。超预算时从最旧条目开始逐出。 */
523
+ const READ_CACHE_MAX_ENTRY_BYTES = 1024 * 1024;
524
+ const READ_CACHE_TOTAL_BYTES = 32 * 1024 * 1024;
525
+ function readCachePut(key, entry, max) {
526
+ entry.bytes = Buffer.byteLength(entry.content, "utf8");
527
+ if (entry.bytes > READ_CACHE_MAX_ENTRY_BYTES) return; // 大文件不缓存(结果照常返回)
528
+ lruPut(readCache, key, entry, max);
529
+ let total = 0;
530
+ for (const v of readCache.values()) total += v.bytes || 0;
531
+ while (total > READ_CACHE_TOTAL_BYTES && readCache.size > 1) {
532
+ const oldest = readCache.keys().next().value;
533
+ total -= readCache.get(oldest).bytes || 0;
534
+ readCache.delete(oldest);
535
+ }
536
+ }
537
+
538
+ function normalizeCachePath(path) {
539
+ let s = String(path || "");
540
+ while (s.length > 1 && s.charCodeAt(s.length - 1) === 47) s = s.slice(0, -1);
541
+ return s;
542
+ }
543
+
544
+ function posixDirname(path) {
545
+ const s = normalizeCachePath(path);
546
+ const i = s.lastIndexOf("/");
547
+ if (i < 0) return ".";
548
+ if (i === 0) return "/";
549
+ return s.slice(0, i);
550
+ }
551
+
552
+ /** 文件读取缓存失效(统一入口之一;pk 为 profileKey 字符串)。
553
+ * subtree=true 同时失效 path 子树(rm -rf / mv 目录场景)。 */
554
+ function invalidateReadCache(pk, path, opts) {
555
+ const sub = !!(opts && opts.subtree);
556
+ const target = pk + "|" + normalizeCachePath(path);
557
+ for (const k of Array.from(readCache.keys())) {
558
+ if (k === target || (sub && k.startsWith(target + "/"))) readCache.delete(k);
559
+ }
560
+ }
561
+
562
+ /** 目录列举缓存失效(同语义,作用于目录键)。 */
563
+ function invalidateTreeCache(pk, dir, opts) {
564
+ const sub = !!(opts && opts.subtree);
565
+ const target = pk + "|" + normalizeCachePath(dir);
566
+ for (const k of Array.from(treeCache.keys())) {
567
+ if (k === target || (sub && k.startsWith(target + "/"))) treeCache.delete(k);
568
+ }
569
+ }
570
+
571
+ /** 收敛失效入口:所有可能改变远端文件/目录的插件入口都调用它 ——
572
+ * 文件缓存(exact + 可选子树)+ 列举缓存(被改目录本身 + 其父目录)+ 整代 epoch+1。 */
573
+ function invalidateRemoteCaches(p, path, opts) {
574
+ bumpCacheEpoch(p);
575
+ const pk = profileKey(p);
576
+ const target = normalizeCachePath(path);
577
+ if (!target) return;
578
+ invalidateReadCache(pk, target, opts);
579
+ invalidateTreeCache(pk, target, opts);
580
+ invalidateTreeCache(pk, posixDirname(target), { subtree: false });
581
+ }
582
+
583
+ /** 整个 profile 的缓存清空(syncUp 批量覆盖远端后调用)。 */
584
+ function clearCachesForProfile(p) {
585
+ const pk = profileKey(p);
586
+ cacheEpochs.set(pk, cacheEpoch(pk) + 1);
587
+ for (const k of Array.from(readCache.keys())) { if (k.startsWith(pk + "|")) readCache.delete(k); }
588
+ for (const k of Array.from(treeCache.keys())) { if (k.startsWith(pk + "|")) treeCache.delete(k); }
589
+ }
590
+
591
+ function isRawTextPath(path) {
592
+ const base = String(path || "").split(/[\\/]/).pop() || "";
593
+ const lower = base.toLowerCase();
594
+ const dot = lower.lastIndexOf(".");
595
+ if (dot > 0 && RAW_TEXT_EXTS.has(lower.slice(dot))) return true;
596
+ if (RAW_TEXT_NAMES.has(lower)) return true;
597
+ if (dot === 0 && RAW_TEXT_EXTS.has(lower)) return true; // .gitignore 这类整名即后缀
598
+ return false;
599
+ }
600
+
601
+ /** 远端错误摘要:压成单行、去重空白、≤300 字符(远端侧已 cut -c1-200,这里再兜底)。 */
602
+ function sanitizeErrLine(s) {
603
+ const t = String(s || "").replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ").trim();
604
+ return t.slice(0, 300);
605
+ }
606
+
607
+ /**
608
+ * 解析 __DSH_ST__ 帧。stdout 首行必须严格以标记开头(登录噪声场景按旧行为降级,
609
+ * framed=false → 整段按旧协议处理,绝不误切)。stat 值异常(非数字/缺冒号)一律走
610
+ * err 分支,禁止 NaN 继续传播。首行之后即载荷(文件内容不会出现在标记行之前:
611
+ * 命令先 printf 标记,head/base64/cat 在其后输出)。
612
+ */
613
+ function parseStatFrame(stdout) {
614
+ const out = String(stdout || "");
615
+ const nl = out.indexOf("\n");
616
+ const first = nl >= 0 ? out.slice(0, nl) : out;
617
+ const rest = nl >= 0 ? out.slice(nl + 1) : "";
618
+ if (!first.startsWith(DSH_ST_MARK)) {
619
+ return { framed: false, err: null, size: null, mtime: null, rest: out };
620
+ }
621
+ const body = first.slice(DSH_ST_MARK.length);
622
+ if (body.startsWith("ERR:")) {
623
+ return { framed: true, err: sanitizeErrLine(body.slice(4)) || "远端读取失败", size: null, mtime: null, rest: rest };
624
+ }
625
+ const ci = body.indexOf(":");
626
+ if (ci < 0) return { framed: true, err: "stat 标记解析异常", size: null, mtime: null, rest: rest };
627
+ const sizeStr = body.slice(0, ci);
628
+ const mtimeStr = body.slice(ci + 1);
629
+ if (!/^\d*$/.test(sizeStr) || !/^\d*$/.test(mtimeStr) || (sizeStr === "" && mtimeStr === "")) {
630
+ return { framed: true, err: "stat 标记数值异常", size: null, mtime: null, rest: rest };
631
+ }
632
+ return {
633
+ framed: true,
634
+ err: null,
635
+ size: sizeStr === "" ? null : parseInt(sizeStr, 10),
636
+ mtime: mtimeStr === "" ? null : parseInt(mtimeStr, 10),
637
+ rest: rest
638
+ };
639
+ }
640
+
641
+ /** 帧命令共享段:stat(size mtime) + 标记行。stat 失败 → ERR 标记 + false(非零收尾)。 */
642
+ function statFrameSnippet() {
643
+ return "if st=$(stat -c'%s %Y' -- \"$f\" 2>&1); then printf '" + DSH_ST_MARK + "%s:%s\\n' \"${st%% *}\" \"${st#* }\"; ";
644
+ }
645
+
646
+ function errSnippet() {
647
+ return "else printf '" + DSH_ST_MARK + "ERR:%s\\n' \"$(printf '%s' \"$st\" | tr '\\r\\n' ' ' | cut -c1-200)\"; false; fi";
648
+ }
649
+
650
+ /** 复验命令(过期缓存条目的 mtime+size 校验):1 裸 RTT,无内容传输。 */
651
+ function buildStatCommand(path) {
652
+ return "f=" + shellQuotePath(path) + "; " + statFrameSnippet() + errSnippet();
653
+ }
654
+
655
+ /** 合并读命令:一次往返同时拿 size/mtime 与内容;>4MB 保留 head -c 截断语义。
656
+ * raw=true 载荷为原始字节(仅白名单文本),否则 base64 -w0。 */
657
+ function buildReadCommand(path, raw) {
658
+ const readPart = raw
659
+ ? "if [ \"${st%% *}\" -gt " + MAX_BYTES + " ] 2>/dev/null; then head -c " + MAX_BYTES + " -- \"$f\"; else cat -- \"$f\"; fi"
660
+ : "if [ \"${st%% *}\" -gt " + MAX_BYTES + " ] 2>/dev/null; then head -c " + MAX_BYTES + " -- \"$f\" | base64 -w0; else base64 -w0 -- \"$f\"; fi";
661
+ return "f=" + shellQuotePath(path) + "; " + statFrameSnippet() + readPart + "; " + errSnippet();
662
+ }
663
+
664
+ function decodeBase64Text(b64) {
665
+ const bytes = new Uint8Array(Buffer.from(String(b64).trim(), "base64"));
666
+ let binary = false;
667
+ const first = bytes.subarray(0, 8000);
668
+ for (let i = 0; i < first.length; i++) { if (first[i] === 0) { binary = true; break; } }
669
+ return { text: new TextDecoder().decode(bytes), binary: binary };
670
+ }
671
+
672
+ /** raw 载荷字节级校验:去掉协议层补的末尾 \n 后,字节数必须等于 size,且不含
673
+ * \uFFFD(会话层 chunk.toString("utf8") 有损解码的痕迹)。任一不符 → 返回 null,
674
+ * 由调用方回退 base64 重读。二进制探测语义与旧实现一致(前 8000 含 NUL → binary)。 */
675
+ function decodeRawText(rest, size) {
676
+ if (size === null || size === undefined) return null;
677
+ if (!rest.endsWith("\n")) return null; // 协议保证哨兵前有一个分隔 \n
678
+ const rawText = rest.slice(0, -1);
679
+ const expected = Math.min(size, MAX_BYTES);
680
+ if (Buffer.byteLength(rawText, "utf8") !== expected) return null;
681
+ if (rawText.indexOf("\uFFFD") >= 0) return null;
682
+ return { text: rawText, binary: rawText.slice(0, 8000).indexOf("\u0000") >= 0 };
683
+ }
684
+
685
+ function readResultOk(path, text, binary, truncated) {
686
+ return { ok: true, path: path, content: text, binary: binary, truncated: truncated };
687
+ }
688
+
431
689
  async function remoteReadFile(runner, p, path) {
432
690
  if (!path) return { ok: false, error: "path 为必填项" };
433
- // stat 拿大小:超过 MAX_BYTES 的大文件只读前 4MB(head -c 截断),
434
- // 避免把持久会话的 stdout 缓冲撑爆(会话层另有 8MB 硬上限兜底)。
435
- // 读路径走 2>&1 合并协议(热路径,省掉一次子 shell 与 stderr 重定向)。
436
- const st = await runner(p, "stat -c%s " + shellQuotePath(path));
437
- let size = null;
438
- if (st.ok) {
439
- const n = parseInt(String(st.stdout || "").trim(), 10);
440
- if (!isNaN(n)) size = n;
441
- }
442
- let cmd = "base64 -w0 " + shellQuotePath(path);
443
- let truncated = false;
444
- if (size !== null && size > MAX_BYTES) {
445
- cmd = "head -c " + MAX_BYTES + " " + shellQuotePath(path) + " | base64 -w0";
446
- truncated = true;
691
+ const pk = profileKey(p);
692
+ const key = pk + "|" + String(path);
693
+ const epoch = cacheEpoch(pk);
694
+ const hit = lruGet(readCache, key);
695
+ if (hit && hit.epoch === epoch) {
696
+ if (Date.now() - hit.at <= READ_CACHE_TTL_MS) {
697
+ return readResultOk(path, hit.content, hit.binary, hit.truncated); // TTL 内 0 RTT
698
+ }
699
+ // TTL 过期:1 裸 RTT 复验 mtime+size,未变则免内容重传(findings §3③)
700
+ const rv = await runner(p, buildStatCommand(path), undefined, MAX_BYTES);
701
+ if (rv.ok) {
702
+ const rst = parseStatFrame(rv.stdout);
703
+ if (rst.framed && rst.err !== null) {
704
+ readCache.delete(key); // 文件已消失/不可 stat:全量读也只会得到同样错误
705
+ return { ok: false, error: rst.err };
706
+ }
707
+ if (rst.framed && rst.size === hit.size && rst.mtime === hit.mtime) {
708
+ hit.at = Date.now();
709
+ readCachePut(key, hit, READ_CACHE_MAX);
710
+ return readResultOk(path, hit.content, hit.binary, hit.truncated);
711
+ }
712
+ }
713
+ // 复验失败/文件已变更 → 落到全量合并读
714
+ } else if (hit) {
715
+ readCache.delete(key); // 旧代条目(epoch 已推进)直接丢弃
447
716
  }
448
- const r = await runner(p, cmd, undefined, MAX_BYTES);
449
- if (!r.ok) return { ok: false, error: String(r.error || r.stderr || "").trim() || "读取失败" };
450
- let text = "";
451
- let binary = false;
717
+ const isRaw = isRawTextPath(path);
718
+ let r = await runner(p, buildReadCommand(path, isRaw), undefined, MAX_BYTES);
719
+ let st = parseStatFrame(r.stdout);
720
+ if (isRaw && r.ok && st.framed && st.err === null) {
721
+ const rawOk = decodeRawText(st.rest, st.size);
722
+ if (rawOk) {
723
+ const truncated = st.size !== null && st.size > MAX_BYTES;
724
+ if (!r.truncated) {
725
+ readCachePut(key, { content: rawOk.text, binary: rawOk.binary, truncated: truncated, size: st.size, mtime: st.mtime, epoch: epoch, at: Date.now() }, READ_CACHE_MAX);
726
+ }
727
+ return readResultOk(path, rawOk.text, rawOk.binary, truncated || !!r.truncated);
728
+ }
729
+ // raw 校验失败(二进制冒充文本/截断断字/异常)→ base64 重读,不劣于现状
730
+ r = await runner(p, buildReadCommand(path, false), undefined, MAX_BYTES);
731
+ st = parseStatFrame(r.stdout);
732
+ }
733
+ if (!r.ok) {
734
+ readCache.delete(key);
735
+ let msg = String(r.error || r.stderr || "").trim();
736
+ if (st.framed) msg = st.err !== null ? st.err : sanitizeErrLine(st.rest);
737
+ return { ok: false, error: msg || "读取失败" };
738
+ }
739
+ if (st.framed && st.err !== null) {
740
+ // 帧内错误但退出码 0(异常远端):按错误处理,绝不返回空内容假成功
741
+ return { ok: false, error: st.err };
742
+ }
743
+ let decoded;
452
744
  try {
453
- const bytes = new Uint8Array(Buffer.from(String(r.stdout).trim(), "base64"));
454
- const first = bytes.subarray(0, 8000);
455
- for (let i = 0; i < first.length; i++) { if (first[i] === 0) { binary = true; break; } }
456
- text = new TextDecoder().decode(bytes);
745
+ decoded = decodeBase64Text(st.framed ? st.rest : r.stdout);
457
746
  } catch (e) {
458
747
  return { ok: false, error: "解码失败: " + String(e) };
459
748
  }
460
- return { ok: true, path: path, content: text, binary: binary, truncated: truncated || r.truncated };
749
+ const truncated = st.framed && st.size !== null && st.size > MAX_BYTES;
750
+ if (st.framed && !r.truncated) {
751
+ readCachePut(key, { content: decoded.text, binary: decoded.binary, truncated: truncated, size: st.size, mtime: st.mtime, epoch: epoch, at: Date.now() }, READ_CACHE_MAX);
752
+ }
753
+ return readResultOk(path, decoded.text, decoded.binary, truncated || !!r.truncated);
461
754
  }
462
755
 
463
756
  async function remoteWriteFile(runner, p, path, content) {
@@ -466,7 +759,13 @@ async function remoteWriteFile(runner, p, path, content) {
466
759
  if (c.length > MAX_BYTES) {
467
760
  return { ok: false, exitCode: -1, stdout: "", stderr: "", error: "写入内容超过 " + MAX_BYTES + " 字节上限", truncated: false };
468
761
  }
469
- return await runner(p, "cat > " + shellQuotePath(path), c, MAX_BYTES, true);
762
+ // base64 -d 写入(heredoc stdin):heredoc 帧固定补一个尾随 \n,base64 解码会忽略它,
763
+ // 从而字节级精确 —— 修复旧 `cat > file` 写法“每次保存尾随 +1 字节”的 quirk
764
+ // (findings §3⑦),且对 NUL/二进制安全。内容 ≤4MB → base64 ≤5.6MB,内存可控。
765
+ const r = await runner(p, "base64 -d > " + shellQuotePath(path), Buffer.from(c, "utf8").toString("base64"), MAX_BYTES, true);
766
+ // 写后失效:成功与否都失效(失败的写也可能已截断旧文件)。
767
+ invalidateRemoteCaches(p, path, { subtree: false });
768
+ return r;
470
769
  }
471
770
 
472
771
  /**
@@ -607,7 +906,11 @@ async function remoteSyncUp(subprocess, p, remotePath, mirrorPath) {
607
906
  try { sshOut = await sshH.done; } catch (e) { sshOut = { exitCode: -1 }; }
608
907
  const tarErr = readCollected(tarH, "stderr");
609
908
  const sshErr = readCollected(sshH, "stderr");
610
- if (tarOut.exitCode === 0 && sshOut.exitCode === 0) return { ok: true, remotePath: remotePath };
909
+ if (tarOut.exitCode === 0 && sshOut.exitCode === 0) {
910
+ // 批量覆盖远端文件:清空该 profile 的读/列举缓存(findings §4.3a)
911
+ clearCachesForProfile(p);
912
+ return { ok: true, remotePath: remotePath };
913
+ }
611
914
  const parts = [];
612
915
  if (pipeErr) parts.push("管道: " + pipeErr);
613
916
  if (tarOut.exitCode !== 0) parts.push("tar 退出码 " + tarOut.exitCode + (tarErr ? ": " + tarErr.slice(0, 500) : ""));
@@ -743,10 +1046,7 @@ function apply(ctx, config) {
743
1046
  // ---- 持久 SSH 会话池(连接复用,避免每次操作都做完整 SSH 握手)----
744
1047
  const sessions = new Map(); // profileKey -> CommandSession
745
1048
  const SESSION_IDLE_MS = 10 * 60 * 1000;
746
- function profileKey(p) {
747
- // 认证信息变化时应重建会话:修改 keyPath/authMethod 后旧连接不能继续复用。
748
- return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "") + "|" + String(p.authMethod || "key") + "|" + String(p.keyPath || "");
749
- }
1049
+ // profileKey 已上移模块层(读缓存/列举缓存与失效逻辑共用同一身份键,定义见 remoteReadFile 上方)。
750
1050
  function getSession(p) {
751
1051
  const key = profileKey(p);
752
1052
  let s = sessions.get(key);
@@ -1023,7 +1323,11 @@ function apply(ctx, config) {
1023
1323
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
1024
1324
  if (!args || !args.command) return { ok: false, error: "command 为必填项" };
1025
1325
  // 走持久会话池(stderr 分离):首调用建立连接后,后续调用毫秒级返回。
1026
- return await runPooled(p, args.command, args.stdin, undefined, true);
1326
+ // 远程命令可能改任意文件(插件不可见)→ 成功后整代失效读/列举缓存(findings §4.3a);
1327
+ // 失败的命令通常无副作用,不 bump 以免无谓打断缓存(部分副作用由 ≤5s TTL 兜底)。
1328
+ const r = await runPooled(p, args.command, args.stdin, undefined, true);
1329
+ if (r.ok) bumpCacheEpoch(p);
1330
+ return r;
1027
1331
  },
1028
1332
  listDir: async (args) => {
1029
1333
  const p = resolveProfile(args);
@@ -1054,19 +1358,26 @@ function apply(ctx, config) {
1054
1358
  const p = resolveProfile(args);
1055
1359
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
1056
1360
  if (!args || !args.path) return { ok: false, error: "path 为必填项" };
1057
- return await runPooled(p, "mkdir -p " + shellQuotePath(args.path), undefined, undefined);
1361
+ const r = await runPooled(p, "mkdir -p " + shellQuotePath(args.path), undefined, undefined);
1362
+ invalidateRemoteCaches(p, args.path, { subtree: false }); // 父目录列举变化
1363
+ return r;
1058
1364
  },
1059
1365
  deleteFile: async (args) => {
1060
1366
  const p = resolveProfile(args);
1061
1367
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
1062
1368
  if (!args || !args.path) return { ok: false, error: "path 为必填项" };
1063
- return await runPooled(p, "rm -rf " + shellQuotePath(args.path), undefined, undefined);
1369
+ const r = await runPooled(p, "rm -rf " + shellQuotePath(args.path), undefined, undefined);
1370
+ invalidateRemoteCaches(p, args.path, { subtree: true });
1371
+ return r;
1064
1372
  },
1065
1373
  move: async (args) => {
1066
1374
  const p = resolveProfile(args);
1067
1375
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
1068
1376
  if (!args || !args.src || !args.dst) return { ok: false, error: "src 和 dst 为必填项" };
1069
- return await runPooled(p, "mv " + shellQuotePath(args.src) + " " + shellQuotePath(args.dst), undefined, undefined);
1377
+ const r = await runPooled(p, "mv " + shellQuotePath(args.src) + " " + shellQuotePath(args.dst), undefined, undefined);
1378
+ invalidateRemoteCaches(p, args.src, { subtree: true });
1379
+ invalidateRemoteCaches(p, args.dst, { subtree: true });
1380
+ return r;
1070
1381
  },
1071
1382
  /** 远端 → 本地镜像(按工作区)。 */
1072
1383
  syncDown: async (args) => {
@@ -1222,7 +1533,10 @@ function apply(ctx, config) {
1222
1533
  // 走持久会话池(stderr 分离,双哨兵协议):首次调用完成建连后,
1223
1534
  // 后续调用不再重复 TCP 握手 + 认证,单次耗时从秒级降到毫秒级;
1224
1535
  // 会话异常时 runPooled 自动重建并降级一次性连接。
1225
- return normExec(await runPooled(tc.profile, cmd, args.stdin, undefined, true));
1536
+ // 远程命令可能改任意文件(插件不可见)→ 整代失效读/列举缓存(findings §4.3a)。
1537
+ const r = await runPooled(tc.profile, cmd, args.stdin, undefined, true);
1538
+ bumpCacheEpoch(tc.profile);
1539
+ return normExec(r);
1226
1540
  }
1227
1541
  });
1228
1542
 
@@ -1435,8 +1749,10 @@ function apply(ctx, config) {
1435
1749
  const tc = toolContext(args, exec);
1436
1750
  if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1437
1751
  if (!args.path) return normExec({ ok: false, error: "path 为必填项" });
1438
- let cmd = "mkdir -p " + shellQuotePath(resolveRemotePath(args.path, tc.remotePath));
1439
- return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
1752
+ const rp = resolveRemotePath(args.path, tc.remotePath);
1753
+ const r = await runPooled(tc.profile, "mkdir -p " + shellQuotePath(rp), undefined, undefined);
1754
+ invalidateRemoteCaches(tc.profile, rp, { subtree: false }); // 父目录列举变化
1755
+ return normExec(r);
1440
1756
  }
1441
1757
  });
1442
1758
 
@@ -1450,8 +1766,10 @@ function apply(ctx, config) {
1450
1766
  const tc = toolContext(args, exec);
1451
1767
  if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1452
1768
  if (!args.path) return normExec({ ok: false, error: "path 为必填项" });
1453
- let cmd = "rm -rf " + shellQuotePath(resolveRemotePath(args.path, tc.remotePath));
1454
- return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
1769
+ const rp = resolveRemotePath(args.path, tc.remotePath);
1770
+ const r = await runPooled(tc.profile, "rm -rf " + shellQuotePath(rp), undefined, undefined);
1771
+ invalidateRemoteCaches(tc.profile, rp, { subtree: true });
1772
+ return normExec(r);
1455
1773
  }
1456
1774
  });
1457
1775
 
@@ -1468,8 +1786,12 @@ function apply(ctx, config) {
1468
1786
  const tc = toolContext(args, exec);
1469
1787
  if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1470
1788
  if (!args.src || !args.dst) return normExec({ ok: false, error: "src 和 dst 为必填项" });
1471
- let cmd = "mv " + shellQuotePath(resolveRemotePath(args.src, tc.remotePath)) + " " + shellQuotePath(resolveRemotePath(args.dst, tc.remotePath));
1472
- return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
1789
+ const src = resolveRemotePath(args.src, tc.remotePath);
1790
+ const dst = resolveRemotePath(args.dst, tc.remotePath);
1791
+ const r = await runPooled(tc.profile, "mv " + shellQuotePath(src) + " " + shellQuotePath(dst), undefined, undefined);
1792
+ invalidateRemoteCaches(tc.profile, src, { subtree: true });
1793
+ invalidateRemoteCaches(tc.profile, dst, { subtree: true }); // dst 子树:mv file dir/ 会落入既有目录
1794
+ return normExec(r);
1473
1795
  }
1474
1796
  });
1475
1797
 
@@ -1853,10 +2175,15 @@ function apply(ctx, config) {
1853
2175
  }
1854
2176
 
1855
2177
  /** 远端 git 执行(远程工作区):在 remoteDir 里跑同一 git 语义。 */
2178
+ const GIT_MUTATING_FIRST_TOKENS = new Set(["add", "reset", "commit", "checkout", "revert", "cherry-pick"]);
1856
2179
  async function runGitRemote(profile, remoteDir, args) {
1857
2180
  const quoted = args.map((a) => shellQuote(a)).join(" ");
1858
2181
  const r = await runPooled(profile, "git -C " + shellQuotePath(remoteDir) + " --no-pager -c color.ui=false " + quoted, undefined, 8 * 1024 * 1024);
1859
2182
  if (!r.ok) throw { code: "git-error", message: String(r.stdout || r.error || "").trim() || "remote git exited with " + r.exitCode };
2183
+ // 仅变更类子命令(stage→add / unstage→reset / commit / checkout / discard→checkout /
2184
+ // revert / cherry-pick)会改工作区文件 → 整代失效读/列举缓存;
2185
+ // status/diff/log/branch/show 等只读命令不再打断缓存(review m4)。
2186
+ if (GIT_MUTATING_FIRST_TOKENS.has(String(args[0] || ""))) bumpCacheEpoch(profile);
1860
2187
  return r.stdout;
1861
2188
  }
1862
2189
 
@@ -2137,6 +2464,8 @@ function apply(ctx, config) {
2137
2464
  writeJson(res, status, { ok: false, error: { code: r.code, message: r.message } });
2138
2465
  return;
2139
2466
  }
2467
+ // 上传落盘:目标文件 + 父目录列举失效
2468
+ invalidateRemoteCaches(profile, target, { subtree: false });
2140
2469
  writeOk(res, { path: dir.replace(/\\/g, "/").replace(/\/+$/, "") + "/" + rel, size: r.size });
2141
2470
  } catch (e) {
2142
2471
  writeJson(res, 400, { ok: false, error: { code: "fs-error", message: String(e && e.message ? e.message : e) } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
5
5
  "keywords": [
6
6
  "dsh",