dsh-over-ssh 0.2.4 → 0.2.5

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/README.md CHANGED
@@ -41,6 +41,28 @@ shell:
41
41
 
42
42
  不配也能用,只是 Web 终端永远开在本地(远程文件、远端执行、`@` 补全不受影响)。
43
43
 
44
+ ## 0.2.5 更新
45
+
46
+ > 相对 npm 上的 **0.2.4** 的补丁版本,只有一处修复。
47
+
48
+ - **修复:释放一条连接/删除一个会话,不再取消无关连接正在进行的重连。** transport 的"已释放代际"
49
+ 原来是**全局**计数器:`release(连接 A)` 或删除任意会话都会让它自增,于是连接 B 正在 `discard` 旧
50
+ 传输、准备重建的 `acquire()` 会拿到一次 `CANCELLED`,调用方无法把它与真实故障区分。现在按连接计数,
51
+ 只有真正被释放的那条连接会取消自己的重建。同时补齐"重建窗口内删除本会话仍然取消"的判断:
52
+ 该窗口里 slot 与 entry 都已删除、pending 尚未登记,新增 `#rebuilding` 记录在飞的 lane key,
53
+ `releaseSession` 依据 slots/entries/pending/rebuilding 四张表判断归属。
54
+ - **修复:代理侧的编辑/补丁/删除/改名把版本守卫与读-改-写放进同一段临界区。** 之前只有 `fs.write`
55
+ 内部的"校验 + rename"持路径条带锁:并发的 `fs.edit`/`fs.patch` 都能读到同一份旧内容,在 mtime 粒度粗的
56
+ 文件系统上(`size:mtime` 版本串两次写之间不变)两边都会通过守卫、后者静默覆盖前者;在 ns 粒度上则表现
57
+ 为并发编辑偶发 `E_VERSION_CONFLICT`。现在 `Edit`/`Patch` 的整段读-改-写、`Remove` 的校验+删除、
58
+ `Rename` 的源/目标(按字典序取锁,避免 A→B 与 B→A 互等)都在同一把路径锁里。
59
+ - **修复:`fs.copy` / `fs.copyTree` 的目标侧判定与落地现在真的原子。** 旧实现先 `os.Stat(to)` 判断目标是否存在、
60
+ 再拷临时文件、再 rename,三段之间没有按目标路径串行化:两个并发 `fs.copy(overwrite=false)` 能同时
61
+ 看到"目标不存在",于是两边都报成功、后者覆盖前者(实测 2 并发 = 2 次成功)。现在目标侧的检查与落地
62
+ 在同一把路径锁里,实测并发下恰好 1 次成功、另一次 `E_VERSION_CONFLICT`。`fs.copyTree` 同样:
63
+ 目标"不存在/为空"的判定与整棵拷贝同持目标锁,避免两棵交错的树同时落盘(旧行为下失败方还会报
64
+ `E_IO: mkdir ... file exists` 而不是契约里的 `E_VERSION_CONFLICT`)。
65
+
44
66
  ## 0.2.4 更新
45
67
 
46
68
  > 下面这些内容相对 npm 上的 **0.2.3** 一起生效。
@@ -132,11 +154,11 @@ shell:
132
154
 
133
155
  | 平台 | 字节 | sha256 |
134
156
  |---|---|---|
135
- | linux-amd64 | 5,591,200 | `c1b5e1ffcc9c7fd26e21140090dafec28f90a50fd3c5a3edb315d0165f84dc40` |
136
- | linux-arm64 | 5,243,040 | `524e8752ba5a63ac006d50f132f6286afa2ccfe55ed217d286e4ada361deea71` |
157
+ | linux-amd64 | 5,595,296 | `38a32c04c60005cbe3688b05f56b2eed348e2d6de1b4d8dca925f203dd978ac2` |
158
+ | linux-arm64 | 5,243,040 | `eb48c16da284caebd1de3656b3f8be7c7e8741d48bc20e012d07ecd7824d9ba4` |
137
159
 
138
- - 代理版本:`0.2.4-a7e77fb`
139
- - 源码提交:`a7e77fb`(Go 仓 `dsh-over-ssh-agent` 的这个提交)
160
+ - 代理版本:`0.2.5-59d9305`
161
+ - 源码提交:`59d9305`(Go 仓 `dsh-over-ssh-agent` 的这个提交)
140
162
  - 清单原文:包内 `agents/agent-manifest.json`
141
163
  - 核对:`sha256sum agents/<平台>/dsh-over-ssh-agent` 与上表比对;按上面的提交检出 Go 仓后
142
164
  `scripts/check.sh` 可重建同样的产物。
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-11-08T16:23:50.384Z",
4
- "agentVersion": "0.2.4-a7e77fb",
5
- "sourceCommit": "a7e77fb",
3
+ "generatedAt": "2026-11-08T23:42:20.933Z",
4
+ "agentVersion": "0.2.5-59d9305",
5
+ "sourceCommit": "59d9305",
6
6
  "artifacts": [
7
7
  {
8
8
  "platform": "linux-amd64",
9
9
  "path": "linux-amd64/dsh-over-ssh-agent",
10
- "sha256": "c1b5e1ffcc9c7fd26e21140090dafec28f90a50fd3c5a3edb315d0165f84dc40",
11
- "bytes": 5591200
10
+ "sha256": "38a32c04c60005cbe3688b05f56b2eed348e2d6de1b4d8dca925f203dd978ac2",
11
+ "bytes": 5595296
12
12
  },
13
13
  {
14
14
  "platform": "linux-arm64",
15
15
  "path": "linux-arm64/dsh-over-ssh-agent",
16
- "sha256": "524e8752ba5a63ac006d50f132f6286afa2ccfe55ed217d286e4ada361deea71",
16
+ "sha256": "eb48c16da284caebd1de3656b3f8be7c7e8741d48bc20e012d07ecd7824d9ba4",
17
17
  "bytes": 5243040
18
18
  }
19
19
  ]
package/lib/index.js CHANGED
@@ -1592,11 +1592,26 @@ var TransportService = class {
1592
1592
  */
1593
1593
  #retired = /* @__PURE__ */ new Set();
1594
1594
  /**
1595
- * Bumped by every explicit release. An `acquire()` that had to discard a lost transport checks this
1596
- * after the async discard: a release that arrived in that window must not be undone by recreating the
1597
- * lane. A global counter avoids a per-session map that would itself retain deleted session ids.
1595
+ * Per-connection release counter: bumped by every explicit release of **that** connection.
1596
+ *
1597
+ * An `acquire()` that had to discard a lost transport checks it after the async discard: a release that
1598
+ * arrived in that window must not be undone by recreating the lane.
1599
+ *
1600
+ * Keyed by connection id (not a single global counter): a global one meant that deleting any session, or
1601
+ * releasing any *other* connection, cancelled an unrelated in-flight reconnect with `CANCELLED` — a
1602
+ * spurious failure the caller could only interpret as a real one. The key space is the profile set
1603
+ * (bounded), so this map cannot grow with sessions and cannot retain deleted session ids.
1604
+ */
1605
+ #releaseGeneration = /* @__PURE__ */ new Map();
1606
+ /**
1607
+ * Lane keys whose owning `acquire()` is between "the old transport is lost" and "the lanes were rebuilt".
1608
+ *
1609
+ * That window deletes the slot and the entry **before** it awaits the async discard, so neither table can
1610
+ * say "this session is still being rebuilt here". Without it, a session deleted exactly then would not be
1611
+ * recognised as the owner and its lane would be recreated (the bug `#releaseGeneration` exists to
1612
+ * prevent). It is a set of *in-flight* keys, so it cannot grow.
1598
1613
  */
1599
- #releaseGeneration = 0;
1614
+ #rebuilding = /* @__PURE__ */ new Set();
1600
1615
  constructor(options) {
1601
1616
  this.#selector = options.selector;
1602
1617
  this.#factories = options.factories;
@@ -1610,7 +1625,7 @@ var TransportService = class {
1610
1625
  const key = laneKey(profile.id, options);
1611
1626
  const lane = options?.lane ?? "interactive";
1612
1627
  const decided = this.#selector.decide(profile);
1613
- const releaseGeneration = this.#releaseGeneration;
1628
+ const releaseGeneration = this.#generationOf(profile.id);
1614
1629
  if (lane === "stateful" && this.#statefulLaneLimit > 0 && !this.#entries.has(key) && !this.#pending.has(key) && this.#statefulLaneCount(profile.id, isWatchSessionKey(options?.sessionKey ?? "")) >= this.#statefulLaneLimit) {
1615
1630
  throw new SshDevError(
1616
1631
  "CONNECTION_INVALID",
@@ -1624,12 +1639,17 @@ var TransportService = class {
1624
1639
  if (decisionKey(existing.decision) === decisionKey(decided)) return existing;
1625
1640
  }
1626
1641
  if (existing !== void 0) {
1627
- await this.#discard(profile.id);
1628
- if (this.#releaseGeneration !== releaseGeneration) {
1629
- this.#forgetSlotIfIdle(profile.id, key);
1630
- throw new SshDevError("CANCELLED", `\u8FDE\u63A5\u5728\u91CD\u5EFA\u8FC7\u7A0B\u4E2D\u5DF2\u88AB\u91CA\u653E\uFF1A${profile.id}`, { connectionId: profile.id });
1642
+ this.#rebuilding.add(key);
1643
+ try {
1644
+ await this.#discard(profile.id);
1645
+ if (this.#generationOf(profile.id) !== releaseGeneration) {
1646
+ this.#forgetSlotIfIdle(profile.id, key);
1647
+ throw new SshDevError("CANCELLED", `\u8FDE\u63A5\u5728\u91CD\u5EFA\u8FC7\u7A0B\u4E2D\u5DF2\u88AB\u91CA\u653E\uFF1A${profile.id}`, { connectionId: profile.id });
1648
+ }
1649
+ this.#rememberSlot(profile.id, key);
1650
+ } finally {
1651
+ this.#rebuilding.delete(key);
1631
1652
  }
1632
- this.#rememberSlot(profile.id, key);
1633
1653
  }
1634
1654
  const inflight = this.#pending.get(key);
1635
1655
  if (inflight !== void 0) return inflight;
@@ -1677,9 +1697,43 @@ var TransportService = class {
1677
1697
  }
1678
1698
  return decisions;
1679
1699
  }
1700
+ /**
1701
+ * Connections that currently own (or are still building / tearing down) the stateful lane of one session.
1702
+ *
1703
+ * `#slots` alone is not enough: a reconnect deletes the slot **before** it awaits the async discard, so the
1704
+ * release that happens in that window must still be recognised — otherwise the rebuild is not cancelled
1705
+ * and an already-deleted session gets its lane back. Pending keys carry the connection id as their first
1706
+ * `\u0000`-separated segment (see {@link laneKey}).
1707
+ */
1708
+ #connectionsOwningSessionLane(sessionKey2) {
1709
+ const candidates = new Set(this.#slots.keys());
1710
+ for (const entry of this.#entries.values()) {
1711
+ if (entry.connectionId !== void 0) candidates.add(entry.connectionId);
1712
+ }
1713
+ for (const key of [...this.#pending.keys(), ...this.#rebuilding]) {
1714
+ const separator = key.indexOf("\0");
1715
+ if (separator > 0) candidates.add(key.slice(0, separator));
1716
+ }
1717
+ const found = /* @__PURE__ */ new Set();
1718
+ for (const connectionId of candidates) {
1719
+ const key = laneKey(connectionId, { lane: "stateful", sessionKey: sessionKey2 });
1720
+ if (this.#pending.has(key) || this.#rebuilding.has(key) || this.#entries.has(key) || (this.#slots.get(connectionId)?.has(key) ?? false)) {
1721
+ found.add(connectionId);
1722
+ }
1723
+ }
1724
+ return found;
1725
+ }
1726
+ /** Current release counter of one connection. */
1727
+ #generationOf(connectionId) {
1728
+ return this.#releaseGeneration.get(connectionId) ?? 0;
1729
+ }
1730
+ /** Record one explicit release of a connection. */
1731
+ #bumpGeneration(connectionId) {
1732
+ this.#releaseGeneration.set(connectionId, this.#generationOf(connectionId) + 1);
1733
+ }
1680
1734
  /** Dispose and forget **every lane** of one connection. */
1681
1735
  async release(connectionId) {
1682
- this.#releaseGeneration++;
1736
+ this.#bumpGeneration(connectionId);
1683
1737
  await this.#discard(connectionId);
1684
1738
  }
1685
1739
  /** Number of cached/pending stateful lanes of one class (watch vs session) on one connection. */
@@ -1721,7 +1775,7 @@ var TransportService = class {
1721
1775
  * @param sessionKey - the DSH session id used when the stateful lane was acquired.
1722
1776
  */
1723
1777
  async releaseSession(sessionKey2) {
1724
- this.#releaseGeneration++;
1778
+ const released = this.#connectionsOwningSessionLane(sessionKey2);
1725
1779
  const disposals = [];
1726
1780
  for (const [connectionId, keys] of [...this.#slots]) {
1727
1781
  for (const key of [...keys]) {
@@ -1742,11 +1796,12 @@ var TransportService = class {
1742
1796
  }
1743
1797
  if (keys.size === 0) this.#slots.delete(connectionId);
1744
1798
  }
1799
+ for (const connectionId of released) this.#bumpGeneration(connectionId);
1745
1800
  await Promise.all(disposals);
1746
1801
  }
1747
1802
  /** Dispose every connection and lane (plugin unload, connection-service shutdown). */
1748
1803
  async disposeAll() {
1749
- this.#releaseGeneration++;
1804
+ for (const connectionId of this.#slots.keys()) this.#bumpGeneration(connectionId);
1750
1805
  for (const key of this.#pending.keys()) this.#retired.add(key);
1751
1806
  await Promise.all([...this.#slots.keys()].map((id) => this.#discard(id)));
1752
1807
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-over-ssh",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "SSH remote development for DeepSeek Harness: connections (key/password/ProxyJump/ssh config), remote files without sync, remote terminal, remote workspaces, remote @-completion, upload/download.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",