dsh-cherry-bridge 0.2.2 → 0.2.3
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.en.md +3 -3
- package/README.md +3 -3
- package/dist/index.mjs +114 -9
- package/docs/e2e-checklist.md +23 -0
- package/package.json +1 -1
- package/src/AGENTS.md +5 -4
- package/src/connection.ts +60 -5
- package/src/diagnostics.ts +31 -0
- package/src/index.ts +10 -1
- package/src/subscriptions.ts +83 -7
package/README.en.md
CHANGED
|
@@ -40,13 +40,13 @@ flowchart TB
|
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
# From npm (recommended, pinned)
|
|
43
|
-
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.
|
|
43
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.3
|
|
44
44
|
|
|
45
45
|
# Install the latest npm release
|
|
46
46
|
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
47
47
|
|
|
48
48
|
# From GitHub source (same commit as the npm release)
|
|
49
|
-
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.
|
|
49
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.3
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
|
|
@@ -93,7 +93,7 @@ cherry_interactions → see pending question/approval cards; cherry_respond answ
|
|
|
93
93
|
| Key | Type | Default | Notes |
|
|
94
94
|
| --- | --- | --- | --- |
|
|
95
95
|
| `dataDir` | string | `$DSH_HOME/cherry-remote` (fallback `~/.dsh/cherry-remote`) | Plugin data directory holding `identity.key` and `devices.json` (both `0600`). |
|
|
96
|
-
| `perSessionCacheBytes` | number | `2097152` (2 MiB) | Per-session cap on the **local** subscription event cache; beyond it
|
|
96
|
+
| `perSessionCacheBytes` | number | `2097152` (2 MiB) | Per-session cap on the **local** subscription event cache; beyond it events are dropped from the oldest end (FIFO) with a `warn`, but the **newest single event is always kept** — even when it alone exceeds the cap (no drop line is logged then, and the window stays over budget). |
|
|
97
97
|
| `rateBudgetPerSecond` | number | `32` | Per-device smoothed request budget (req/s). The desktop's shared allowance is 64 req/s through 2.1.6 and 256 req/s from 2.1.7, and only the newer generation downgrades an overrun to a single `RATE_LIMITED` refusal (the older one terminates the session with close `1011`) — hence a value sized for the harsher generation. Content reads (`agent.content.read`) derive their own tier at 8:1, **capped at 512/s** (that allowance is the same in both generations). |
|
|
98
98
|
| `wsTls` | object | — | **Flat** `tls.connect` options for every `wss` dial (`rejectUnauthorized` / `ca` only): operator-supplied trust for a desktop behind a private CA or a self-signed TLS front. Applied to BOTH dial sites — pairing and reconnect. **Never written to the device archive, never logged, never in diagnostics.** The whole object applies as written or is ignored entirely: an unknown key or unusable value warns by name and falls back to Node's default trust store (no partial application). |
|
|
99
99
|
|
package/README.md
CHANGED
|
@@ -38,13 +38,13 @@ flowchart TB
|
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
# 从 npm 安装(推荐,钉住版本)
|
|
41
|
-
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.
|
|
41
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.3
|
|
42
42
|
|
|
43
43
|
# 安装 npm 最新发布版
|
|
44
44
|
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
45
45
|
|
|
46
46
|
# 从 GitHub 源码安装(与 npm 发布同一提交)
|
|
47
|
-
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.
|
|
47
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.3
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
|
|
@@ -85,7 +85,7 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
|
|
|
85
85
|
| 键 | 类型 | 默认 | 说明 |
|
|
86
86
|
| --- | --- | --- | --- |
|
|
87
87
|
| `dataDir` | string | `$DSH_HOME/cherry-remote`(回退 `~/.dsh/cherry-remote`) | 插件数据目录,存放 `identity.key` 与 `devices.json`(均 `0600`)。 |
|
|
88
|
-
| `perSessionCacheBytes` | number | `2097152`(2 MiB) |
|
|
88
|
+
| `perSessionCacheBytes` | number | `2097152`(2 MiB) | 每个会话的**本地**订阅事件缓存上限;超出后从最旧一端丢弃(FIFO)并 `warn`,但**最新的单条事件总会保留**——即使它自己就超过上限(此时不打丢弃日志,窗口保持超预算)。 |
|
|
89
89
|
| `rateBudgetPerSecond` | number | `32` | 每设备平滑请求预算(请求/秒)。桌面端共享额度在 ≤2.1.6 是 64 req/s、≥2.1.7 是 256 req/s,且只有新版本才把超限降级为单请求 `RATE_LIMITED`(旧版本直接 close `1011` 终止会话),故按更严格的一代取保守值。内容读取(`agent.content.read`)按 8:1 派生独立一档,且**封顶 512/s**(该额度两代一致)。 |
|
|
90
90
|
| `wsTls` | object | — | 每次 `wss` 拨号的**扁平** `tls.connect` 选项(只接受 `rejectUnauthorized` / `ca`),供桌面端位于私有 CA 或自签 TLS 前置之后时由运维提供信任;配对与重连**两个拨号点**共用。**不写入设备档案、不写日志、不进诊断报告。** 整个对象要么按原样生效、要么整体忽略:未知键或不可用值按名 `warn` 后回退 Node 默认信任库(不部分应用)。 |
|
|
91
91
|
|
package/dist/index.mjs
CHANGED
|
@@ -2433,6 +2433,14 @@ function projectLimits(raw) {
|
|
|
2433
2433
|
function numberOr(value, fallback) {
|
|
2434
2434
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
2435
2435
|
}
|
|
2436
|
+
/**
|
|
2437
|
+
* A delivery-ledger counter (issue #217): a NON-NEGATIVE finite number, or
|
|
2438
|
+
* nothing at all. Absence means "not reported"; a fabricated zero would read as
|
|
2439
|
+
* "nothing was delivered", which is a claim this projection has no evidence for.
|
|
2440
|
+
*/
|
|
2441
|
+
function countEntry(key, raw) {
|
|
2442
|
+
return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? { [key]: raw } : {};
|
|
2443
|
+
}
|
|
2436
2444
|
/** The loose-bag view every projection in this module takes of its input. */
|
|
2437
2445
|
function bag(raw) {
|
|
2438
2446
|
return raw ?? {};
|
|
@@ -2550,7 +2558,10 @@ function sanitizeDiagnostics(source) {
|
|
|
2550
2558
|
sessionId: typeof cursor["sessionId"] === "string" ? cursor["sessionId"] : sessionId,
|
|
2551
2559
|
streamEpoch: typeof cursor["streamEpoch"] === "string" ? cursor["streamEpoch"] : "",
|
|
2552
2560
|
seq: typeof cursor["seq"] === "string" ? cursor["seq"] : ""
|
|
2553
|
-
} }
|
|
2561
|
+
} },
|
|
2562
|
+
...countEntry("deliveredBytes", value["deliveredBytes"]),
|
|
2563
|
+
...countEntry("unackedBytes", value["unackedBytes"]),
|
|
2564
|
+
...countEntry("acks", value["acks"])
|
|
2554
2565
|
};
|
|
2555
2566
|
}),
|
|
2556
2567
|
recentErrors: (source.recentErrors ?? []).map((raw) => {
|
|
@@ -2611,7 +2622,10 @@ function buildDiagnostics(sources, identity = DEFAULT_IDENTITY) {
|
|
|
2611
2622
|
sessionId: entry.sessionId,
|
|
2612
2623
|
deviceId: entry.deviceId,
|
|
2613
2624
|
subscriptions: entry.subscriptions,
|
|
2614
|
-
...entry.cursor === void 0 ? {} : { cursor: { ...entry.cursor } }
|
|
2625
|
+
...entry.cursor === void 0 ? {} : { cursor: { ...entry.cursor } },
|
|
2626
|
+
...entry.deliveredBytes === void 0 ? {} : { deliveredBytes: entry.deliveredBytes },
|
|
2627
|
+
...entry.unackedBytes === void 0 ? {} : { unackedBytes: entry.unackedBytes },
|
|
2628
|
+
...entry.acks === void 0 ? {} : { acks: entry.acks }
|
|
2615
2629
|
});
|
|
2616
2630
|
} catch {}
|
|
2617
2631
|
let recentErrors = [];
|
|
@@ -3380,6 +3394,29 @@ var DeviceConnection = class {
|
|
|
3380
3394
|
#pendingPings = /* @__PURE__ */ new Set();
|
|
3381
3395
|
#token;
|
|
3382
3396
|
#tokenExpiresAt;
|
|
3397
|
+
/**
|
|
3398
|
+
* Hosts whose LAST attempt failed for that ADDRESS's own reason (issue #220).
|
|
3399
|
+
* A pass dials the hosts it has not failed yet FIRST, so a stale candidate
|
|
3400
|
+
* sitting first in `record.hosts` cannot cost its dial timeout on every
|
|
3401
|
+
* reconnect — the rule the official client states as "failed candidates
|
|
3402
|
+
* rotate behind untried addresses across retries, so the round budget cannot
|
|
3403
|
+
* permanently starve a later route"
|
|
3404
|
+
* (`.scratch-csa/docs/references/remote-access/connectivity.md` §Connection
|
|
3405
|
+
* lifetime; the tree is gitignored, and its remote-agent directory is the
|
|
3406
|
+
* same `16e505fe` at app tags v0.5.2 and v0.5.3, so this is not a rule that
|
|
3407
|
+
* arrived with a newer app).
|
|
3408
|
+
*
|
|
3409
|
+
* Demotion is ORDER, never exclusion: with every host marked the list falls
|
|
3410
|
+
* back to record order, so a record whose only address is marked still dials
|
|
3411
|
+
* it. A mark is cleared when that host itself connects, so a repaired address
|
|
3412
|
+
* returns to the front on its own — no expiry timer, no persistence, no
|
|
3413
|
+
* config key. A pairing-level verdict and a cancellation never mark (they are
|
|
3414
|
+
* not this address's fault: the line `isPerHostFailure` draws for the
|
|
3415
|
+
* handshake face, mirrored explicitly in the dial face, where only
|
|
3416
|
+
* `IdentityMismatchError` can arise today), and `close()` keeps the marks
|
|
3417
|
+
* because they describe addresses, not this lifecycle.
|
|
3418
|
+
*/
|
|
3419
|
+
#demotedHosts = /* @__PURE__ */ new Set();
|
|
3383
3420
|
#wsOptions;
|
|
3384
3421
|
constructor(opts) {
|
|
3385
3422
|
this.record = opts.record;
|
|
@@ -3614,7 +3651,7 @@ var DeviceConnection = class {
|
|
|
3614
3651
|
this.#setState("connecting");
|
|
3615
3652
|
if (this.record.hosts.length === 0) throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, /* @__PURE__ */ new Error("the device record has no candidate hosts"));
|
|
3616
3653
|
let lastError = /* @__PURE__ */ new Error(`device ${this.record.deviceId}: all candidate hosts unreachable`);
|
|
3617
|
-
for (const host of this
|
|
3654
|
+
for (const host of this.#dialOrder()) {
|
|
3618
3655
|
if (!this.#running || epoch !== this.#epoch) throw new CancelledError();
|
|
3619
3656
|
let link;
|
|
3620
3657
|
try {
|
|
@@ -3622,16 +3659,20 @@ var DeviceConnection = class {
|
|
|
3622
3659
|
link = await this.#dial(host, port, epoch);
|
|
3623
3660
|
} catch (error) {
|
|
3624
3661
|
if (error instanceof IdentityMismatchError) throw error;
|
|
3662
|
+
if (error instanceof CancelledError) throw error;
|
|
3625
3663
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
3664
|
+
this.#demotedHosts.add(host);
|
|
3626
3665
|
continue;
|
|
3627
3666
|
}
|
|
3628
3667
|
this.#link = link;
|
|
3629
3668
|
try {
|
|
3630
3669
|
await this.#handshake(link, epoch);
|
|
3670
|
+
this.#demotedHosts.delete(host);
|
|
3631
3671
|
return;
|
|
3632
3672
|
} catch (error) {
|
|
3633
3673
|
if (error instanceof CancelledError || !this.#running || epoch !== this.#epoch) throw new CancelledError();
|
|
3634
3674
|
if (!isPerHostFailure(error)) throw error;
|
|
3675
|
+
this.#demotedHosts.add(host);
|
|
3635
3676
|
this.#teardownLink();
|
|
3636
3677
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
3637
3678
|
continue;
|
|
@@ -3639,6 +3680,24 @@ var DeviceConnection = class {
|
|
|
3639
3680
|
}
|
|
3640
3681
|
throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError);
|
|
3641
3682
|
}
|
|
3683
|
+
/**
|
|
3684
|
+
* The pass's dial order (issue #220): hosts this connection has NOT failed
|
|
3685
|
+
* yet, in record order, then the demoted ones, in record order.
|
|
3686
|
+
*
|
|
3687
|
+
* Record order is preserved INSIDE each half, so an ordinary single-address
|
|
3688
|
+
* record, a fresh record and an all-demoted record all dial exactly the list
|
|
3689
|
+
* the record carries — the demotion can only move an address that failed
|
|
3690
|
+
* behind addresses that did not, and can never drop one.
|
|
3691
|
+
*/
|
|
3692
|
+
#dialOrder() {
|
|
3693
|
+
const hosts = this.record.hosts;
|
|
3694
|
+
if (this.#demotedHosts.size === 0) return hosts;
|
|
3695
|
+
const untried = [];
|
|
3696
|
+
const demoted = [];
|
|
3697
|
+
for (const host of hosts) if (this.#demotedHosts.has(host)) demoted.push(host);
|
|
3698
|
+
else untried.push(host);
|
|
3699
|
+
return [...untried, ...demoted];
|
|
3700
|
+
}
|
|
3642
3701
|
async #dial(host, port, epoch) {
|
|
3643
3702
|
const ws = new WebSocket(directEndpointUrl({
|
|
3644
3703
|
host,
|
|
@@ -4842,6 +4901,28 @@ var SessionSubscriptions = class {
|
|
|
4842
4901
|
return this.#sessions.get(sessionId)?.cursor;
|
|
4843
4902
|
}
|
|
4844
4903
|
/**
|
|
4904
|
+
* The DELIVERY LEDGER of one subscription (issue #217): the payload bytes it
|
|
4905
|
+
* has received since it was installed, how much of that the peer may still
|
|
4906
|
+
* count as un-acknowledged, and how many acks it has taken. Undefined for a
|
|
4907
|
+
* session this layer does not hold — never a fabricated zero.
|
|
4908
|
+
*
|
|
4909
|
+
* Diagnostics is the caller: these three numbers are what turns 「the stream
|
|
4910
|
+
* exceeded the peer's window」 from an extrapolation into a measurement.
|
|
4911
|
+
*
|
|
4912
|
+
* A CLIENT-SIDE ledger, never the peer's: `unackedBytes` is this layer's
|
|
4913
|
+
* model of what the peer may still count (bytes since its last accepted
|
|
4914
|
+
* ack), not a reading of the peer's own window or ack position.
|
|
4915
|
+
*/
|
|
4916
|
+
deliveryOf(sessionId) {
|
|
4917
|
+
const state = this.#sessions.get(sessionId);
|
|
4918
|
+
if (state === void 0) return void 0;
|
|
4919
|
+
return {
|
|
4920
|
+
deliveredBytes: state.deliveredBytes,
|
|
4921
|
+
unackedBytes: state.bytesSinceAck,
|
|
4922
|
+
acks: state.acks
|
|
4923
|
+
};
|
|
4924
|
+
}
|
|
4925
|
+
/**
|
|
4845
4926
|
* The session's cache WINDOW START — the retention BOUNDARY of this layer's
|
|
4846
4927
|
* cache: the oldest position it still tracks — or undefined when the session
|
|
4847
4928
|
* is untracked.
|
|
@@ -4987,10 +5068,13 @@ var SessionSubscriptions = class {
|
|
|
4987
5068
|
if (state === void 0) return;
|
|
4988
5069
|
const requested = cursor === void 0 ? state.cursor : cursor;
|
|
4989
5070
|
const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor;
|
|
5071
|
+
const covered = state.bytesSinceAck;
|
|
4990
5072
|
await this.#rpc.request("agent.subscriptions.ack", {
|
|
4991
5073
|
subscriptionId: state.subscriptionId,
|
|
4992
5074
|
cursor: applied
|
|
4993
5075
|
});
|
|
5076
|
+
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
|
|
5077
|
+
state.acks += 1;
|
|
4994
5078
|
}
|
|
4995
5079
|
/**
|
|
4996
5080
|
* Parks a resolver that settles when the session buffer advances past
|
|
@@ -5132,6 +5216,8 @@ var SessionSubscriptions = class {
|
|
|
5132
5216
|
buffer: [],
|
|
5133
5217
|
bytes: 0,
|
|
5134
5218
|
bytesSinceAck: 0,
|
|
5219
|
+
deliveredBytes: 0,
|
|
5220
|
+
acks: 0,
|
|
5135
5221
|
ackInFlight: false,
|
|
5136
5222
|
ackRetryAt: 0,
|
|
5137
5223
|
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
@@ -5226,6 +5312,8 @@ var SessionSubscriptions = class {
|
|
|
5226
5312
|
buffer: [],
|
|
5227
5313
|
bytes: 0,
|
|
5228
5314
|
bytesSinceAck: 0,
|
|
5315
|
+
deliveredBytes: 0,
|
|
5316
|
+
acks: 0,
|
|
5229
5317
|
ackInFlight: false,
|
|
5230
5318
|
ackRetryAt: 0,
|
|
5231
5319
|
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
@@ -5289,7 +5377,9 @@ var SessionSubscriptions = class {
|
|
|
5289
5377
|
}
|
|
5290
5378
|
/**
|
|
5291
5379
|
* `agent.events`: append to the session buffer, enforce the byte budget by
|
|
5292
|
-
* dropping the OLDEST events (logged
|
|
5380
|
+
* dropping the OLDEST events (logged, and never the last remaining one — see
|
|
5381
|
+
* `#trimToBudget`'s floor, issue #221), advance the cursor, then wake
|
|
5382
|
+
* waiters.
|
|
5293
5383
|
*
|
|
5294
5384
|
* SEQ MONOTONICITY IS ENFORCED HERE, per (sessionId, streamEpoch). The peer is
|
|
5295
5385
|
* supposed to deliver each stream position exactly once and in order, and the
|
|
@@ -5358,6 +5448,7 @@ var SessionSubscriptions = class {
|
|
|
5358
5448
|
});
|
|
5359
5449
|
state.bytes += bytes;
|
|
5360
5450
|
state.bytesSinceAck += bytes;
|
|
5451
|
+
state.deliveredBytes += bytes;
|
|
5361
5452
|
state.cursor = cursor;
|
|
5362
5453
|
appended += 1;
|
|
5363
5454
|
}
|
|
@@ -5389,9 +5480,7 @@ var SessionSubscriptions = class {
|
|
|
5389
5480
|
if (state.ackInFlight) return;
|
|
5390
5481
|
if (Date.now() < state.ackRetryAt) return;
|
|
5391
5482
|
state.ackInFlight = true;
|
|
5392
|
-
const covered = state.bytesSinceAck;
|
|
5393
5483
|
this.ack(sessionId).then(() => {
|
|
5394
|
-
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
|
|
5395
5484
|
state.ackRetryDelay = AUTO_ACK_RETRY_MS;
|
|
5396
5485
|
}).catch((error) => {
|
|
5397
5486
|
const delay = state.ackRetryDelay;
|
|
@@ -5461,17 +5550,31 @@ var SessionSubscriptions = class {
|
|
|
5461
5550
|
return;
|
|
5462
5551
|
}
|
|
5463
5552
|
}
|
|
5464
|
-
/**
|
|
5553
|
+
/**
|
|
5554
|
+
* Drops the OLDEST buffered events until the session fits its byte budget.
|
|
5555
|
+
*
|
|
5556
|
+
* The loop stops at ONE remaining event (issue #221): the budget governs the
|
|
5557
|
+
* window's TAIL, never its head. A single event above the budget — only
|
|
5558
|
+
* reachable when `perSessionCacheBytes` is set below one record's size, since
|
|
5559
|
+
* the transport caps a record at 65,536 bytes — used to be evicted too,
|
|
5560
|
+
* leaving an EMPTY window whose `windowStart` had advanced past it: the event
|
|
5561
|
+
* that just happened, the one thing a reader wants, became unreachable and
|
|
5562
|
+
* every read from the baseline answered `cursor-out-of-window` about content
|
|
5563
|
+
* this layer had held a moment earlier. Keeping it is also the honest
|
|
5564
|
+
* reading of `evicted`: nothing was discarded, so the baseline stays a
|
|
5565
|
+
* servable resume point.
|
|
5566
|
+
*/
|
|
5465
5567
|
#trimToBudget(sessionId, state) {
|
|
5466
5568
|
if (state.bytes <= this.#perSessionCacheBytes) return;
|
|
5467
5569
|
let dropped = 0;
|
|
5468
|
-
while (state.bytes > this.#perSessionCacheBytes && state.buffer.length >
|
|
5570
|
+
while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 1) {
|
|
5469
5571
|
const removed = state.buffer.shift();
|
|
5470
5572
|
state.bytes -= removed.bytes;
|
|
5471
5573
|
dropped += 1;
|
|
5472
5574
|
state.windowStart = Math.max(state.windowStart, Number(removed.cursor.seq));
|
|
5473
5575
|
state.evicted = true;
|
|
5474
5576
|
}
|
|
5577
|
+
if (dropped === 0) return;
|
|
5475
5578
|
console.warn(`[cherry-remote] session ${sessionId} cache exceeded ${this.#perSessionCacheBytes} bytes; dropped ${dropped} oldest event(s)`);
|
|
5476
5579
|
}
|
|
5477
5580
|
/** Coalesced wake: each parked waiter settles exactly once. */
|
|
@@ -12924,11 +13027,13 @@ function assemblyDeps() {
|
|
|
12924
13027
|
}),
|
|
12925
13028
|
diagnosticsSessions: () => [...state.subscriptions.entries()].flatMap(([deviceId, layer]) => layer.activeSessions().map((sessionId) => {
|
|
12926
13029
|
const cursor = layer.cursorOf(sessionId);
|
|
13030
|
+
const delivery = layer.deliveryOf(sessionId);
|
|
12927
13031
|
return {
|
|
12928
13032
|
deviceId,
|
|
12929
13033
|
sessionId,
|
|
12930
13034
|
subscriptions: 1,
|
|
12931
|
-
...cursor === void 0 ? {} : { cursor }
|
|
13035
|
+
...cursor === void 0 ? {} : { cursor },
|
|
13036
|
+
...delivery === void 0 ? {} : delivery
|
|
12932
13037
|
};
|
|
12933
13038
|
})),
|
|
12934
13039
|
diagnosticsErrors: () => [...state.recentErrors],
|
package/docs/e2e-checklist.md
CHANGED
|
@@ -360,3 +360,26 @@ A–G 各节要真人 + Electron 端批准,无法被 CI 化;下表只记录
|
|
|
360
360
|
- **设置页那对按钮的点击**:需要能打开宿主的浏览器;本机渲染由 `tests/unit/ui.test.ts`
|
|
361
361
|
覆盖,地址编辑器打开时两个控件冻结由 `#204m` 覆盖。本轮走的是页面调用的同两条 HTTP 路由。
|
|
362
362
|
- **驱逐时 in-flight 命令丢失**:需要一个正在执行的 command 才会出现,本轮没有。
|
|
363
|
+
|
|
364
|
+
### 0.2.2(2026-11-03 · DSH profile `web` · 桌面端 ISV-ZYX)
|
|
365
|
+
|
|
366
|
+
| 项 | 结果 | 观测 |
|
|
367
|
+
| --- | --- | --- |
|
|
368
|
+
| 装机生效 | 通过 | 包版本 `0.2.2`;`dist/index.mjs` mtime `00:13:42` 早于宿主 PID 1 启动 `00:13:54`;装机 `dist/index.mjs`、`dist/index.d.mts`、`lib/client.js`、`locale/zh.json`、`locale/en.json` 与发行 tarball(sha1 `1ac80762…`,等于 registry 的 `dist.shasum`)逐文件 sha256 **全同** |
|
|
369
|
+
| 超窗长回答 `wait: true` 仍返回 `completed`(§B 新增项) | 通过 | 同一会话(`dsh-bridge 2.1.10 smoke`)连发两轮 30 节长回答,会话 `seq` 从 341 推到 **1374**、再从 1374 推到 **2204**;两轮 `cherry_send` 都返回 `status: "completed"` 并带回助手全文。对端提交时刻是 `00:18:05.797` / `00:19:18.597`,而提交后 1.4 s 的首个采样点已经读到终值 1374 / 2204;第三轮的下一次工具调用发生在提交后 6.6 s,故返回延迟的上界是 6.6 s(含 agent 侧一次调度) |
|
|
370
|
+
| 窗口不冻结(§B 新增项) | 通过 | 每 5 s 采一次 `GET …/diagnostics` 的 `sessions[].cursor.seq`:`00:17:32→00:18:07` 为 346→**918**→993→1074→1153→1227→1308→**1374**,`00:18:49→00:19:19` 为 1374→**1854**→1930→1994→2065→2144→**2204**;两段单调推进、终值后保持,**没有任何一段平台期**(修复前同一会话在整个 240 s 等待窗口里冻结在 666,见 v0.2.2 release 正文) |
|
|
371
|
+
|
|
372
|
+
**本轮未覆盖**(**不是通过**):
|
|
373
|
+
|
|
374
|
+
- **字节级的窗口证据**:`GET …/diagnostics` 按设计只带四个数字 limit 键(`projectLimits`
|
|
375
|
+
的白名单刻意剔掉 `unackedBytes`),也不报 ack 次数,所以「事件流累计 > 262144 B」是
|
|
376
|
+
**外推**:同一会话修复前的实测是 666 个事件到窗口(≈394 B/event),本轮 1374 / 2204 事件
|
|
377
|
+
对应约 0.54 / 0.87 MB。要直接测字节,得先给诊断报告加字段。
|
|
378
|
+
- **ack 是否从未失败**:失败只会 `console.warn`,不进 `recentErrors`;本环境的宿主日志不可读
|
|
379
|
+
(`/proc/1/fd/1` 拒读)。本轮能断言的是「没有出现冻结与告警级停滞」,不是「ack 零失败」。
|
|
380
|
+
- **桌面端版本号**:本轮没有重读(上一轮记录为 2.1.10);本客户端不从握手读桌面端版本号
|
|
381
|
+
(`EffectiveLimits` 只有心跳/订阅/分页/并发/未确认字节五个窗口参数加 `messageSummaries`),`/devices`
|
|
382
|
+
与 `/diagnostics` 也都不带。
|
|
383
|
+
- **`wait: true` 的毫秒级时延**:只有上界,没有工具级计时器。
|
|
384
|
+
- 第二部分其余小节(配对、交互应答、断连恢复、多设备、`/scope`、语言切换)本轮没有重跑:
|
|
385
|
+
它们不在 0.2.2 的改动面上。
|
package/package.json
CHANGED
package/src/AGENTS.md
CHANGED
|
@@ -830,10 +830,11 @@ the error message).
|
|
|
830
830
|
- **May be emitted** (`sanitizeDevice`): `deviceId`, `displayName`, `state`,
|
|
831
831
|
`enabled`, `hosts`, `port`, a **12-char digest** of `desktopIdentity`, the
|
|
832
832
|
hello-negotiated `limits` (`heartbeatMs`/`subscriptions`/`pageItems`/
|
|
833
|
-
`inFlightRequests` — **only with a live connection**;
|
|
834
|
-
|
|
835
|
-
hello capability bit, absent when not advertised), `sessionId
|
|
836
|
-
cursors (unsubscribed
|
|
833
|
+
`inFlightRequests` — **only with a live connection**; it never dials or wakes
|
|
834
|
+
a device for fallbacks), `messageSummaries` (the
|
|
835
|
+
hello capability bit, absent when not advertised), `sessionId`, session
|
|
836
|
+
cursors (none when unsubscribed) and their ledger (`deliveredBytes`/
|
|
837
|
+
`unackedBytes`/`acks`, #217), the `stats` counters
|
|
837
838
|
(`connections`/`sockets`/`connectionsBuilt`/`subscriptions`), the two resolved
|
|
838
839
|
`config` keys, and error `reason`/`message`.
|
|
839
840
|
- **Never emitted**: `accessToken`, `tokenExpiresAt`, `invitationSecret`, the
|
package/src/connection.ts
CHANGED
|
@@ -896,6 +896,29 @@ export class DeviceConnection {
|
|
|
896
896
|
#pendingPings = new Set<{ nonce: string; sentAt: number }>()
|
|
897
897
|
#token: string | undefined
|
|
898
898
|
#tokenExpiresAt: number | undefined
|
|
899
|
+
/**
|
|
900
|
+
* Hosts whose LAST attempt failed for that ADDRESS's own reason (issue #220).
|
|
901
|
+
* A pass dials the hosts it has not failed yet FIRST, so a stale candidate
|
|
902
|
+
* sitting first in `record.hosts` cannot cost its dial timeout on every
|
|
903
|
+
* reconnect — the rule the official client states as "failed candidates
|
|
904
|
+
* rotate behind untried addresses across retries, so the round budget cannot
|
|
905
|
+
* permanently starve a later route"
|
|
906
|
+
* (`.scratch-csa/docs/references/remote-access/connectivity.md` §Connection
|
|
907
|
+
* lifetime; the tree is gitignored, and its remote-agent directory is the
|
|
908
|
+
* same `16e505fe` at app tags v0.5.2 and v0.5.3, so this is not a rule that
|
|
909
|
+
* arrived with a newer app).
|
|
910
|
+
*
|
|
911
|
+
* Demotion is ORDER, never exclusion: with every host marked the list falls
|
|
912
|
+
* back to record order, so a record whose only address is marked still dials
|
|
913
|
+
* it. A mark is cleared when that host itself connects, so a repaired address
|
|
914
|
+
* returns to the front on its own — no expiry timer, no persistence, no
|
|
915
|
+
* config key. A pairing-level verdict and a cancellation never mark (they are
|
|
916
|
+
* not this address's fault: the line `isPerHostFailure` draws for the
|
|
917
|
+
* handshake face, mirrored explicitly in the dial face, where only
|
|
918
|
+
* `IdentityMismatchError` can arise today), and `close()` keeps the marks
|
|
919
|
+
* because they describe addresses, not this lifecycle.
|
|
920
|
+
*/
|
|
921
|
+
readonly #demotedHosts = new Set<string>()
|
|
899
922
|
|
|
900
923
|
readonly #wsOptions: WsDialOptions | undefined
|
|
901
924
|
|
|
@@ -1255,7 +1278,7 @@ export class DeviceConnection {
|
|
|
1255
1278
|
}
|
|
1256
1279
|
|
|
1257
1280
|
let lastError: Error = new Error(`device ${this.record.deviceId}: all candidate hosts unreachable`)
|
|
1258
|
-
for (const host of this
|
|
1281
|
+
for (const host of this.#dialOrder()) {
|
|
1259
1282
|
// CANCELLATION GATE, at the top of every candidate. `close()` bumps the
|
|
1260
1283
|
// epoch and clears `#running`, so either check is enough — both are here
|
|
1261
1284
|
// because they answer different halves of the same question ("is this
|
|
@@ -1279,7 +1302,15 @@ export class DeviceConnection {
|
|
|
1279
1302
|
link = await this.#dial(host, port, epoch)
|
|
1280
1303
|
} catch (error) {
|
|
1281
1304
|
if (error instanceof IdentityMismatchError) throw error
|
|
1305
|
+
// A cancellation is not this ADDRESS's fault, so it neither marks it
|
|
1306
|
+
// nor continues the pass. UNREACHABLE today — `#dial`'s AbortController
|
|
1307
|
+
// is aborted only through `#teardownLink`, which needs `#link`, and
|
|
1308
|
+
// `#link` is not committed until the dial returns — so this is a
|
|
1309
|
+
// DEFENSIVE arm, not a pinned one: it keeps the rule true if the dial
|
|
1310
|
+
// ever grows a close path, at the cost of two lines and no behaviour.
|
|
1311
|
+
if (error instanceof CancelledError) throw error
|
|
1282
1312
|
lastError = error instanceof Error ? error : new Error(String(error))
|
|
1313
|
+
this.#demotedHosts.add(host)
|
|
1283
1314
|
continue
|
|
1284
1315
|
}
|
|
1285
1316
|
// Commit the link before the RPC handshake so every failure path below
|
|
@@ -1288,6 +1319,8 @@ export class DeviceConnection {
|
|
|
1288
1319
|
this.#link = link
|
|
1289
1320
|
try {
|
|
1290
1321
|
await this.#handshake(link, epoch)
|
|
1322
|
+
// This address works: any mark an earlier pass left on it is stale.
|
|
1323
|
+
this.#demotedHosts.delete(host)
|
|
1291
1324
|
return
|
|
1292
1325
|
} catch (error) {
|
|
1293
1326
|
// A close() that landed mid-handshake surfaces here as the failure of
|
|
@@ -1299,10 +1332,11 @@ export class DeviceConnection {
|
|
|
1299
1332
|
// The terminal faces end the pass: they describe the PAIRING, not this
|
|
1300
1333
|
// address, so trying the next candidate would only repeat them.
|
|
1301
1334
|
if (!isPerHostFailure(error)) throw error
|
|
1302
|
-
// A
|
|
1303
|
-
// ADDRESS's problem:
|
|
1304
|
-
//
|
|
1305
|
-
// fall through to the next candidate.
|
|
1335
|
+
// A peer that answers hello/authenticate with a per-address failure is
|
|
1336
|
+
// this ADDRESS's problem too: demote it for the NEXT pass, tear the
|
|
1337
|
+
// link down here (the loop's next iteration would otherwise overwrite
|
|
1338
|
+
// `#link` and leak this socket), and fall through to the next candidate.
|
|
1339
|
+
this.#demotedHosts.add(host)
|
|
1306
1340
|
this.#teardownLink()
|
|
1307
1341
|
lastError = error instanceof Error ? error : new Error(String(error))
|
|
1308
1342
|
continue
|
|
@@ -1311,6 +1345,27 @@ export class DeviceConnection {
|
|
|
1311
1345
|
throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError)
|
|
1312
1346
|
}
|
|
1313
1347
|
|
|
1348
|
+
/**
|
|
1349
|
+
* The pass's dial order (issue #220): hosts this connection has NOT failed
|
|
1350
|
+
* yet, in record order, then the demoted ones, in record order.
|
|
1351
|
+
*
|
|
1352
|
+
* Record order is preserved INSIDE each half, so an ordinary single-address
|
|
1353
|
+
* record, a fresh record and an all-demoted record all dial exactly the list
|
|
1354
|
+
* the record carries — the demotion can only move an address that failed
|
|
1355
|
+
* behind addresses that did not, and can never drop one.
|
|
1356
|
+
*/
|
|
1357
|
+
#dialOrder(): readonly string[] {
|
|
1358
|
+
const hosts = this.record.hosts
|
|
1359
|
+
if (this.#demotedHosts.size === 0) return hosts
|
|
1360
|
+
const untried: string[] = []
|
|
1361
|
+
const demoted: string[] = []
|
|
1362
|
+
for (const host of hosts) {
|
|
1363
|
+
if (this.#demotedHosts.has(host)) demoted.push(host)
|
|
1364
|
+
else untried.push(host)
|
|
1365
|
+
}
|
|
1366
|
+
return [...untried, ...demoted]
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1314
1369
|
async #dial(host: string, port: number, epoch: number): Promise<LiveLink> {
|
|
1315
1370
|
// (see {@link DeviceConnectionOptions.wsOptions} for TLS trust)
|
|
1316
1371
|
// The URL comes from the SAME helper the pairing dial uses (issue #126):
|
package/src/diagnostics.ts
CHANGED
|
@@ -216,6 +216,16 @@ export interface DiagnosticsSession {
|
|
|
216
216
|
deviceId: string
|
|
217
217
|
subscriptions: number
|
|
218
218
|
cursor?: { sessionId: string; streamEpoch: string; seq: string }
|
|
219
|
+
/**
|
|
220
|
+
* Issue #217: the layer's own delivery ledger for THIS subscription — payload
|
|
221
|
+
* bytes received since it was installed, how many of them the peer may still
|
|
222
|
+
* count as un-acknowledged, and successful acks. Absent when the layer does
|
|
223
|
+
* not report it (an unsubscribed session has no ledger); counts describe the
|
|
224
|
+
* current subscription, so a re-subscribe restarts them.
|
|
225
|
+
*/
|
|
226
|
+
deliveredBytes?: number
|
|
227
|
+
unackedBytes?: number
|
|
228
|
+
acks?: number
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
/** One recent error summary (spec §5: 「最近错误摘要」). */
|
|
@@ -324,6 +334,10 @@ export interface SubscriptionStat {
|
|
|
324
334
|
sessionId: string
|
|
325
335
|
subscriptions: number
|
|
326
336
|
cursor?: { sessionId: string; streamEpoch: string; seq: string }
|
|
337
|
+
/** See {@link DiagnosticsSession.deliveredBytes} (issue #217). */
|
|
338
|
+
deliveredBytes?: number
|
|
339
|
+
unackedBytes?: number
|
|
340
|
+
acks?: number
|
|
327
341
|
}
|
|
328
342
|
|
|
329
343
|
/**
|
|
@@ -577,6 +591,17 @@ function numberOr(value: unknown, fallback: number): number {
|
|
|
577
591
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
578
592
|
}
|
|
579
593
|
|
|
594
|
+
/**
|
|
595
|
+
* A delivery-ledger counter (issue #217): a NON-NEGATIVE finite number, or
|
|
596
|
+
* nothing at all. Absence means "not reported"; a fabricated zero would read as
|
|
597
|
+
* "nothing was delivered", which is a claim this projection has no evidence for.
|
|
598
|
+
*/
|
|
599
|
+
function countEntry(key: 'deliveredBytes' | 'unackedBytes' | 'acks', raw: unknown): Partial<DiagnosticsSession> {
|
|
600
|
+
return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0
|
|
601
|
+
? ({ [key]: raw } as Partial<DiagnosticsSession>)
|
|
602
|
+
: {}
|
|
603
|
+
}
|
|
604
|
+
|
|
580
605
|
/** The loose-bag view every projection in this module takes of its input. */
|
|
581
606
|
function bag(raw: unknown): Record<string, unknown> {
|
|
582
607
|
return (raw ?? {}) as Record<string, unknown>
|
|
@@ -728,6 +753,9 @@ export function sanitizeDiagnostics(source: {
|
|
|
728
753
|
seq: typeof cursor['seq'] === 'string' ? cursor['seq'] : '',
|
|
729
754
|
},
|
|
730
755
|
}),
|
|
756
|
+
...countEntry('deliveredBytes', value['deliveredBytes']),
|
|
757
|
+
...countEntry('unackedBytes', value['unackedBytes']),
|
|
758
|
+
...countEntry('acks', value['acks']),
|
|
731
759
|
}
|
|
732
760
|
}),
|
|
733
761
|
recentErrors: (source.recentErrors ?? []).map((raw): DiagnosticsError => {
|
|
@@ -808,6 +836,9 @@ export function buildDiagnostics(
|
|
|
808
836
|
deviceId: entry.deviceId,
|
|
809
837
|
subscriptions: entry.subscriptions,
|
|
810
838
|
...(entry.cursor === undefined ? {} : { cursor: { ...entry.cursor } }),
|
|
839
|
+
...(entry.deliveredBytes === undefined ? {} : { deliveredBytes: entry.deliveredBytes }),
|
|
840
|
+
...(entry.unackedBytes === undefined ? {} : { unackedBytes: entry.unackedBytes }),
|
|
841
|
+
...(entry.acks === undefined ? {} : { acks: entry.acks }),
|
|
811
842
|
})
|
|
812
843
|
}
|
|
813
844
|
} catch {
|
package/src/index.ts
CHANGED
|
@@ -878,7 +878,16 @@ function assemblyDeps(): CherryHttpDeps & CherryToolsDeps {
|
|
|
878
878
|
[...state.subscriptions.entries()].flatMap(([deviceId, layer]) =>
|
|
879
879
|
layer.activeSessions().map((sessionId) => {
|
|
880
880
|
const cursor = layer.cursorOf(sessionId)
|
|
881
|
-
|
|
881
|
+
// The layer's own delivery ledger (issue #217), beside the cursor it
|
|
882
|
+
// is measured against: both come from ONE session state read.
|
|
883
|
+
const delivery = layer.deliveryOf(sessionId)
|
|
884
|
+
return {
|
|
885
|
+
deviceId,
|
|
886
|
+
sessionId,
|
|
887
|
+
subscriptions: 1,
|
|
888
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
889
|
+
...(delivery === undefined ? {} : delivery),
|
|
890
|
+
}
|
|
882
891
|
}),
|
|
883
892
|
),
|
|
884
893
|
diagnosticsErrors: () => [...state.recentErrors],
|
package/src/subscriptions.ts
CHANGED
|
@@ -151,6 +151,20 @@ interface SessionState {
|
|
|
151
151
|
* consider un-acknowledged.
|
|
152
152
|
*/
|
|
153
153
|
bytesSinceAck: number
|
|
154
|
+
/**
|
|
155
|
+
* Event payload bytes received since this subscription was INSTALLED
|
|
156
|
+
* (issue #217). Monotone for the life of one subscription — unlike `bytes`
|
|
157
|
+
* (what the cache still holds) and `bytesSinceAck` (what the peer may still
|
|
158
|
+
* count) — so 「the stream exceeded the window」 becomes a measurement in
|
|
159
|
+
* diagnostics instead of an extrapolation from a frozen cursor.
|
|
160
|
+
*/
|
|
161
|
+
deliveredBytes: number
|
|
162
|
+
/**
|
|
163
|
+
* Successful `agent.subscriptions.ack` calls for this subscription (issue
|
|
164
|
+
* #217). NOT a progress metric: every accepted call counts, including a
|
|
165
|
+
* repeated explicit ack at the cursor the peer already holds.
|
|
166
|
+
*/
|
|
167
|
+
acks: number
|
|
154
168
|
/** One auto-ack in flight at a time; a second would only duplicate a cursor. */
|
|
155
169
|
ackInFlight: boolean
|
|
156
170
|
/** Cooldown after a refused auto-ack (`AUTO_ACK_RETRY_MS`, doubling). */
|
|
@@ -320,6 +334,31 @@ export class SessionSubscriptions {
|
|
|
320
334
|
return this.#sessions.get(sessionId)?.cursor
|
|
321
335
|
}
|
|
322
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The DELIVERY LEDGER of one subscription (issue #217): the payload bytes it
|
|
339
|
+
* has received since it was installed, how much of that the peer may still
|
|
340
|
+
* count as un-acknowledged, and how many acks it has taken. Undefined for a
|
|
341
|
+
* session this layer does not hold — never a fabricated zero.
|
|
342
|
+
*
|
|
343
|
+
* Diagnostics is the caller: these three numbers are what turns 「the stream
|
|
344
|
+
* exceeded the peer's window」 from an extrapolation into a measurement.
|
|
345
|
+
*
|
|
346
|
+
* A CLIENT-SIDE ledger, never the peer's: `unackedBytes` is this layer's
|
|
347
|
+
* model of what the peer may still count (bytes since its last accepted
|
|
348
|
+
* ack), not a reading of the peer's own window or ack position.
|
|
349
|
+
*/
|
|
350
|
+
deliveryOf(
|
|
351
|
+
sessionId: string,
|
|
352
|
+
): { deliveredBytes: number; unackedBytes: number; acks: number } | undefined {
|
|
353
|
+
const state = this.#sessions.get(sessionId)
|
|
354
|
+
if (state === undefined) return undefined
|
|
355
|
+
return {
|
|
356
|
+
deliveredBytes: state.deliveredBytes,
|
|
357
|
+
unackedBytes: state.bytesSinceAck,
|
|
358
|
+
acks: state.acks,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
323
362
|
/**
|
|
324
363
|
* The session's cache WINDOW START — the retention BOUNDARY of this layer's
|
|
325
364
|
* cache: the oldest position it still tracks — or undefined when the session
|
|
@@ -507,10 +546,21 @@ export class SessionSubscriptions {
|
|
|
507
546
|
// frontier is both safe (an ack only frees flow control) and monotone.
|
|
508
547
|
const requested = cursor === undefined ? state.cursor : cursor
|
|
509
548
|
const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor
|
|
549
|
+
// What THIS ack covers: everything received up to the moment it is sent.
|
|
550
|
+
// Events arriving while it is in flight sit past the applied cursor, so
|
|
551
|
+
// they stay counted.
|
|
552
|
+
const covered = state.bytesSinceAck
|
|
510
553
|
await this.#rpc.request('agent.subscriptions.ack', {
|
|
511
554
|
subscriptionId: state.subscriptionId,
|
|
512
555
|
cursor: applied,
|
|
513
556
|
})
|
|
557
|
+
// COUNTED ONLY AFTER the peer accepted it: a refused ack frees no window,
|
|
558
|
+
// so a ledger that moved optimistically would claim progress the peer never
|
|
559
|
+
// granted. The SAME rule governs the byte side, which is why BOTH live here
|
|
560
|
+
// rather than in the auto-ack caller: an explicit ack frees the window just
|
|
561
|
+
// as well, and the ledger must not disagree with it.
|
|
562
|
+
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered)
|
|
563
|
+
state.acks += 1
|
|
514
564
|
}
|
|
515
565
|
|
|
516
566
|
/**
|
|
@@ -679,6 +729,10 @@ export class SessionSubscriptions {
|
|
|
679
729
|
buffer: [],
|
|
680
730
|
bytes: 0,
|
|
681
731
|
bytesSinceAck: 0,
|
|
732
|
+
// A fresh subscription owns a fresh ledger (issue #217): the counters
|
|
733
|
+
// describe THIS install, never a previous one.
|
|
734
|
+
deliveredBytes: 0,
|
|
735
|
+
acks: 0,
|
|
682
736
|
ackInFlight: false,
|
|
683
737
|
ackRetryAt: 0,
|
|
684
738
|
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
@@ -819,6 +873,10 @@ export class SessionSubscriptions {
|
|
|
819
873
|
buffer: [],
|
|
820
874
|
bytes: 0,
|
|
821
875
|
bytesSinceAck: 0,
|
|
876
|
+
// A fresh subscription owns a fresh ledger (issue #217): the counters
|
|
877
|
+
// describe THIS install, never a previous one.
|
|
878
|
+
deliveredBytes: 0,
|
|
879
|
+
acks: 0,
|
|
822
880
|
ackInFlight: false,
|
|
823
881
|
ackRetryAt: 0,
|
|
824
882
|
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
@@ -915,7 +973,9 @@ export class SessionSubscriptions {
|
|
|
915
973
|
|
|
916
974
|
/**
|
|
917
975
|
* `agent.events`: append to the session buffer, enforce the byte budget by
|
|
918
|
-
* dropping the OLDEST events (logged
|
|
976
|
+
* dropping the OLDEST events (logged, and never the last remaining one — see
|
|
977
|
+
* `#trimToBudget`'s floor, issue #221), advance the cursor, then wake
|
|
978
|
+
* waiters.
|
|
919
979
|
*
|
|
920
980
|
* SEQ MONOTONICITY IS ENFORCED HERE, per (sessionId, streamEpoch). The peer is
|
|
921
981
|
* supposed to deliver each stream position exactly once and in order, and the
|
|
@@ -1002,6 +1062,7 @@ export class SessionSubscriptions {
|
|
|
1002
1062
|
state.buffer.push({ cursor, event, bytes })
|
|
1003
1063
|
state.bytes += bytes
|
|
1004
1064
|
state.bytesSinceAck += bytes
|
|
1065
|
+
state.deliveredBytes += bytes
|
|
1005
1066
|
state.cursor = cursor
|
|
1006
1067
|
appended += 1
|
|
1007
1068
|
}
|
|
@@ -1039,12 +1100,10 @@ export class SessionSubscriptions {
|
|
|
1039
1100
|
if (state.ackInFlight) return
|
|
1040
1101
|
if (Date.now() < state.ackRetryAt) return
|
|
1041
1102
|
state.ackInFlight = true
|
|
1042
|
-
// The bytes covered by THIS ack. Events that arrive while it is in flight
|
|
1043
|
-
// stay counted, so the next window starts from the right place.
|
|
1044
|
-
const covered = state.bytesSinceAck
|
|
1045
1103
|
void this.ack(sessionId)
|
|
1046
1104
|
.then(() => {
|
|
1047
|
-
|
|
1105
|
+
// The BYTE ledger moved inside `ack()` itself — one place for remarks
|
|
1106
|
+
// and explicit acks alike — so only the retry state is the auto-ack's.
|
|
1048
1107
|
// A peer that answers ack again gets prompt retries next time.
|
|
1049
1108
|
state.ackRetryDelay = AUTO_ACK_RETRY_MS
|
|
1050
1109
|
})
|
|
@@ -1139,11 +1198,24 @@ export class SessionSubscriptions {
|
|
|
1139
1198
|
}
|
|
1140
1199
|
}
|
|
1141
1200
|
|
|
1142
|
-
/**
|
|
1201
|
+
/**
|
|
1202
|
+
* Drops the OLDEST buffered events until the session fits its byte budget.
|
|
1203
|
+
*
|
|
1204
|
+
* The loop stops at ONE remaining event (issue #221): the budget governs the
|
|
1205
|
+
* window's TAIL, never its head. A single event above the budget — only
|
|
1206
|
+
* reachable when `perSessionCacheBytes` is set below one record's size, since
|
|
1207
|
+
* the transport caps a record at 65,536 bytes — used to be evicted too,
|
|
1208
|
+
* leaving an EMPTY window whose `windowStart` had advanced past it: the event
|
|
1209
|
+
* that just happened, the one thing a reader wants, became unreachable and
|
|
1210
|
+
* every read from the baseline answered `cursor-out-of-window` about content
|
|
1211
|
+
* this layer had held a moment earlier. Keeping it is also the honest
|
|
1212
|
+
* reading of `evicted`: nothing was discarded, so the baseline stays a
|
|
1213
|
+
* servable resume point.
|
|
1214
|
+
*/
|
|
1143
1215
|
#trimToBudget(sessionId: string, state: SessionState): void {
|
|
1144
1216
|
if (state.bytes <= this.#perSessionCacheBytes) return
|
|
1145
1217
|
let dropped = 0
|
|
1146
|
-
while (state.bytes > this.#perSessionCacheBytes && state.buffer.length >
|
|
1218
|
+
while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 1) {
|
|
1147
1219
|
const removed = state.buffer.shift()!
|
|
1148
1220
|
state.bytes -= removed.bytes
|
|
1149
1221
|
dropped += 1
|
|
@@ -1154,6 +1226,10 @@ export class SessionSubscriptions {
|
|
|
1154
1226
|
state.windowStart = Math.max(state.windowStart, Number(removed.cursor.seq))
|
|
1155
1227
|
state.evicted = true
|
|
1156
1228
|
}
|
|
1229
|
+
if (dropped === 0) return
|
|
1230
|
+
// Only a REAL drop is logged: an over-budget window holding one event has
|
|
1231
|
+
// discarded nothing, and "dropped 0 oldest event(s)" would send an operator
|
|
1232
|
+
// looking for a gap that does not exist.
|
|
1157
1233
|
console.warn(
|
|
1158
1234
|
`[cherry-remote] session ${sessionId} cache exceeded ${this.#perSessionCacheBytes} bytes; ` +
|
|
1159
1235
|
`dropped ${dropped} oldest event(s)`,
|