dsh-cherry-bridge 0.2.1 → 0.2.2
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 +2 -2
- package/README.md +2 -2
- package/dist/index.mjs +141 -13
- package/docs/e2e-checklist.md +23 -0
- package/package.json +1 -1
- package/src/AGENTS.md +7 -12
- package/src/connection.ts +13 -0
- package/src/diagnostics.ts +6 -5
- package/src/index.ts +5 -0
- package/src/subscriptions.ts +201 -5
- package/src/tools.ts +10 -5
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.2
|
|
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.2
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
|
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.2
|
|
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.2
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
|
package/dist/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { RemoteSocketStream, connectSecureChannel, createDeviceIdentity, deviceIdentityId } from "@cherrystudio/remote-transport";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { WebSocket } from "ws";
|
|
8
|
-
import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
|
|
8
|
+
import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema, remoteLimits } from "@cherrystudio/remote-protocol";
|
|
9
9
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
10
10
|
/**
|
|
11
11
|
* Adds the candidate addresses a peer ADVERTISED that this record does not
|
|
@@ -2414,10 +2414,11 @@ function projectMessageSummaries(raw) {
|
|
|
2414
2414
|
return typeof raw === "number" && Number.isFinite(raw) ? { messageSummaries: raw } : {};
|
|
2415
2415
|
}
|
|
2416
2416
|
/**
|
|
2417
|
-
* Re-projects the four numeric limit keys, dropping any
|
|
2418
|
-
* caller's `limits` object might carry
|
|
2419
|
-
*
|
|
2420
|
-
*
|
|
2417
|
+
* Re-projects the four numeric limit keys this report carries, dropping any
|
|
2418
|
+
* foreign key that a caller's `limits` object might carry — including
|
|
2419
|
+
* `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
|
|
2420
|
+
* A whitelist inside the whitelist: the shape is rebuilt field by field rather
|
|
2421
|
+
* than spread, so a future addition cannot arrive here unvetted.
|
|
2421
2422
|
*/
|
|
2422
2423
|
function projectLimits(raw) {
|
|
2423
2424
|
if (raw === null || typeof raw !== "object") return {};
|
|
@@ -3704,11 +3705,13 @@ var DeviceConnection = class {
|
|
|
3704
3705
|
if (rpcReason$1(error) === "INVALID_CONNECTION_STATE") throw new ProtocolVersionError(String(error));
|
|
3705
3706
|
throw error instanceof Error ? error : new Error(String(error));
|
|
3706
3707
|
}
|
|
3708
|
+
const unackedBytes = positive$2(hello.limits?.["unackedBytes"]);
|
|
3707
3709
|
this.#limits = {
|
|
3708
3710
|
heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
|
|
3709
3711
|
subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
|
|
3710
3712
|
pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
|
|
3711
3713
|
inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
|
|
3714
|
+
...unackedBytes === void 0 ? {} : { unackedBytes },
|
|
3712
3715
|
...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
|
|
3713
3716
|
};
|
|
3714
3717
|
link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
|
|
@@ -4716,6 +4719,26 @@ const DEFAULT_CHECKPOINT_MS = 3e5;
|
|
|
4716
4719
|
* not a tuning knob.
|
|
4717
4720
|
*/
|
|
4718
4721
|
const MAX_RECOVERY_ATTEMPTS = 4;
|
|
4722
|
+
/**
|
|
4723
|
+
* ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
|
|
4724
|
+
* bytes it has sent since our last `agent.subscriptions.ack` stay inside
|
|
4725
|
+
* `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
|
|
4726
|
+
* the subscription still live — and a held batch can be a run's terminal.
|
|
4727
|
+
*
|
|
4728
|
+
* This layer used to ack ONLY from `cherry_events`, so a waiter (which by
|
|
4729
|
+
* design consumes without acking) parked on a buffer that stopped advancing
|
|
4730
|
+
* just under one window: a real 240 s wait answered `timeout` with the finished
|
|
4731
|
+
* text, its cursor frozen at 666 events ≈ 262144 bytes.
|
|
4732
|
+
*
|
|
4733
|
+
* The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
|
|
4734
|
+
* memory; acking does not lose them) once a quarter of the peer's window has
|
|
4735
|
+
* accumulated. A retry cooldown keeps a refusing peer from turning every batch
|
|
4736
|
+
* into a round trip.
|
|
4737
|
+
*/
|
|
4738
|
+
/** First retry delay after a refused auto-ack; doubles per attempt. */
|
|
4739
|
+
const AUTO_ACK_RETRY_MS = 3e3;
|
|
4740
|
+
/** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
|
|
4741
|
+
const AUTO_ACK_RETRY_MAX_MS = 3e4;
|
|
4719
4742
|
function positive$1(value, fallback) {
|
|
4720
4743
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
4721
4744
|
}
|
|
@@ -4753,6 +4776,8 @@ var SessionSubscriptions = class {
|
|
|
4753
4776
|
#limit;
|
|
4754
4777
|
#perSessionCacheBytes;
|
|
4755
4778
|
#checkpointMs;
|
|
4779
|
+
/** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
|
|
4780
|
+
#unackedSource;
|
|
4756
4781
|
#sessions = /* @__PURE__ */ new Map();
|
|
4757
4782
|
/**
|
|
4758
4783
|
* Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
|
|
@@ -4774,6 +4799,7 @@ var SessionSubscriptions = class {
|
|
|
4774
4799
|
this.#limit = Math.max(1, Math.floor(positive$1(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)));
|
|
4775
4800
|
this.#perSessionCacheBytes = positive$1(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES);
|
|
4776
4801
|
this.#checkpointMs = positive$1(opts.checkpointMs, DEFAULT_CHECKPOINT_MS);
|
|
4802
|
+
this.#unackedSource = opts.unackedBytes;
|
|
4777
4803
|
}
|
|
4778
4804
|
/**
|
|
4779
4805
|
* Session ids with a live subscription, LRU-first. Task 10's reconnect
|
|
@@ -4873,6 +4899,7 @@ var SessionSubscriptions = class {
|
|
|
4873
4899
|
if (existing !== void 0) {
|
|
4874
4900
|
console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`);
|
|
4875
4901
|
await this.#closeSubscription(existing.subscriptionId);
|
|
4902
|
+
this.#cancelAckRetry(existing);
|
|
4876
4903
|
this.#rejectWaiters(existing, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
|
|
4877
4904
|
this.#sessions.delete(sessionId);
|
|
4878
4905
|
}
|
|
@@ -4948,13 +4975,18 @@ var SessionSubscriptions = class {
|
|
|
4948
4975
|
/**
|
|
4949
4976
|
* Advances the peer's view of this session: `agent.subscriptions.ack` with
|
|
4950
4977
|
* the delivered cursor. The peer only accepts a cursor inside the window it
|
|
4951
|
-
* actually delivered (an out-of-window ack is its CONFLICT)
|
|
4952
|
-
*
|
|
4978
|
+
* actually delivered (an out-of-window ack is its CONFLICT).
|
|
4979
|
+
*
|
|
4980
|
+
* Two callers, one contract — "this position is accounted for, keep sending":
|
|
4981
|
+
* `cherry_events` passes the cursor a CONSUMER has processed, and
|
|
4982
|
+
* {@link #maybeAutoAck} passes the BUFFER frontier because the events are
|
|
4983
|
+
* retained here (see that method for why an ack cannot lose replay).
|
|
4953
4984
|
*/
|
|
4954
4985
|
async ack(sessionId, cursor) {
|
|
4955
4986
|
const state = this.#sessions.get(sessionId);
|
|
4956
4987
|
if (state === void 0) return;
|
|
4957
|
-
const
|
|
4988
|
+
const requested = cursor === void 0 ? state.cursor : cursor;
|
|
4989
|
+
const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor;
|
|
4958
4990
|
await this.#rpc.request("agent.subscriptions.ack", {
|
|
4959
4991
|
subscriptionId: state.subscriptionId,
|
|
4960
4992
|
cursor: applied
|
|
@@ -5006,6 +5038,7 @@ var SessionSubscriptions = class {
|
|
|
5006
5038
|
this.#closed = true;
|
|
5007
5039
|
const ids = [...this.#sessions.values()].map((state) => state.subscriptionId);
|
|
5008
5040
|
for (const state of this.#sessions.values()) {
|
|
5041
|
+
this.#cancelAckRetry(state);
|
|
5009
5042
|
const error = /* @__PURE__ */ new Error("SessionSubscriptions disposed");
|
|
5010
5043
|
for (const waiter of state.waiters.splice(0)) waiter.reject(error);
|
|
5011
5044
|
}
|
|
@@ -5088,13 +5121,21 @@ var SessionSubscriptions = class {
|
|
|
5088
5121
|
*/
|
|
5089
5122
|
#installReplay(sessionId, descriptor) {
|
|
5090
5123
|
const previous = this.#sessions.get(sessionId);
|
|
5091
|
-
if (previous !== void 0)
|
|
5124
|
+
if (previous !== void 0) {
|
|
5125
|
+
this.#cancelAckRetry(previous);
|
|
5126
|
+
this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
|
|
5127
|
+
}
|
|
5092
5128
|
this.#sessions.delete(sessionId);
|
|
5093
5129
|
this.#sessions.set(sessionId, {
|
|
5094
5130
|
subscriptionId: descriptor.subscriptionId,
|
|
5095
5131
|
cursor: descriptor.highWatermark,
|
|
5096
5132
|
buffer: [],
|
|
5097
5133
|
bytes: 0,
|
|
5134
|
+
bytesSinceAck: 0,
|
|
5135
|
+
ackInFlight: false,
|
|
5136
|
+
ackRetryAt: 0,
|
|
5137
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
5138
|
+
ackRetryTimer: void 0,
|
|
5098
5139
|
windowStart: Number(descriptor.highWatermark.seq),
|
|
5099
5140
|
evicted: false,
|
|
5100
5141
|
waiters: [],
|
|
@@ -5174,13 +5215,21 @@ var SessionSubscriptions = class {
|
|
|
5174
5215
|
*/
|
|
5175
5216
|
#installCheckpoint(sessionId, descriptor, items) {
|
|
5176
5217
|
const previous = this.#sessions.get(sessionId);
|
|
5177
|
-
if (previous !== void 0)
|
|
5218
|
+
if (previous !== void 0) {
|
|
5219
|
+
this.#cancelAckRetry(previous);
|
|
5220
|
+
this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
|
|
5221
|
+
}
|
|
5178
5222
|
this.#sessions.delete(sessionId);
|
|
5179
5223
|
this.#sessions.set(sessionId, {
|
|
5180
5224
|
subscriptionId: descriptor.subscriptionId,
|
|
5181
5225
|
cursor: descriptor.checkpoint.cursor,
|
|
5182
5226
|
buffer: [],
|
|
5183
5227
|
bytes: 0,
|
|
5228
|
+
bytesSinceAck: 0,
|
|
5229
|
+
ackInFlight: false,
|
|
5230
|
+
ackRetryAt: 0,
|
|
5231
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
5232
|
+
ackRetryTimer: void 0,
|
|
5184
5233
|
windowStart: Number(descriptor.checkpoint.cursor.seq),
|
|
5185
5234
|
evicted: false,
|
|
5186
5235
|
waiters: [],
|
|
@@ -5226,6 +5275,7 @@ var SessionSubscriptions = class {
|
|
|
5226
5275
|
for (const [sessionId, state] of this.#sessions) {
|
|
5227
5276
|
if (sessionId === protectedSessionId) continue;
|
|
5228
5277
|
console.warn(`[cherry-remote] subscription limit (${this.#limit}) reached; evicting least-recently-used session ${sessionId}`);
|
|
5278
|
+
this.#cancelAckRetry(state);
|
|
5229
5279
|
this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} evicted by LRU`));
|
|
5230
5280
|
this.#sessions.delete(sessionId);
|
|
5231
5281
|
await this.#closeSubscription(state.subscriptionId);
|
|
@@ -5307,11 +5357,86 @@ var SessionSubscriptions = class {
|
|
|
5307
5357
|
bytes
|
|
5308
5358
|
});
|
|
5309
5359
|
state.bytes += bytes;
|
|
5360
|
+
state.bytesSinceAck += bytes;
|
|
5310
5361
|
state.cursor = cursor;
|
|
5311
5362
|
appended += 1;
|
|
5312
5363
|
}
|
|
5313
5364
|
this.#trimToBudget(sessionId, state);
|
|
5314
5365
|
if (appended > 0) this.#wakeWaiters(state);
|
|
5366
|
+
if (appended > 0) this.#maybeAutoAck(sessionId, state);
|
|
5367
|
+
}
|
|
5368
|
+
/**
|
|
5369
|
+
* Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
|
|
5370
|
+
* window, so the peer keeps delivering (issue #212).
|
|
5371
|
+
*
|
|
5372
|
+
* WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
|
|
5373
|
+
* a caller has consumed". That is safe against the pinned peer because an ack
|
|
5374
|
+
* does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
|
|
5375
|
+
* un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
|
|
5376
|
+
* retention lives on the journal (`replayBytes`/`replayMs`) and is not
|
|
5377
|
+
* touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
|
|
5378
|
+
* below the acked position is still served — by the local buffer here and by
|
|
5379
|
+
* the journal there. The ack's own doc comment states the same contract.
|
|
5380
|
+
*
|
|
5381
|
+
* The ack is fire-and-forget with its failure SURFACED (a refused ack must be
|
|
5382
|
+
* visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
|
|
5383
|
+
* holding at its window no further event arrives, so a retry that waited for
|
|
5384
|
+
* traffic would never fire and the wait would time out exactly as in issue
|
|
5385
|
+
* #212.
|
|
5386
|
+
*/
|
|
5387
|
+
#maybeAutoAck(sessionId, state) {
|
|
5388
|
+
if (state.bytesSinceAck < this.#ackWatermark()) return;
|
|
5389
|
+
if (state.ackInFlight) return;
|
|
5390
|
+
if (Date.now() < state.ackRetryAt) return;
|
|
5391
|
+
state.ackInFlight = true;
|
|
5392
|
+
const covered = state.bytesSinceAck;
|
|
5393
|
+
this.ack(sessionId).then(() => {
|
|
5394
|
+
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
|
|
5395
|
+
state.ackRetryDelay = AUTO_ACK_RETRY_MS;
|
|
5396
|
+
}).catch((error) => {
|
|
5397
|
+
const delay = state.ackRetryDelay;
|
|
5398
|
+
state.ackRetryAt = Date.now() + delay;
|
|
5399
|
+
state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS);
|
|
5400
|
+
console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`);
|
|
5401
|
+
this.#armAckRetry(sessionId, state, delay);
|
|
5402
|
+
}).finally(() => {
|
|
5403
|
+
state.ackInFlight = false;
|
|
5404
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return;
|
|
5405
|
+
this.#maybeAutoAck(sessionId, state);
|
|
5406
|
+
});
|
|
5407
|
+
}
|
|
5408
|
+
/**
|
|
5409
|
+
* Quarter of the peer's window, read from the CURRENT source so a layer that
|
|
5410
|
+
* outlives a reconnect follows the new hello instead of its first answer.
|
|
5411
|
+
* Falls back to the pinned protocol constant when nothing is advertised.
|
|
5412
|
+
*/
|
|
5413
|
+
#ackWatermark() {
|
|
5414
|
+
const source = this.#unackedSource;
|
|
5415
|
+
const advertised = typeof source === "function" ? source() : source;
|
|
5416
|
+
return Math.max(1, Math.floor(positive$1(advertised, remoteLimits.unackedBytes) / 4));
|
|
5417
|
+
}
|
|
5418
|
+
/** Disarms the retry, if any (idempotent; safe on a state being replaced). */
|
|
5419
|
+
#cancelAckRetry(state) {
|
|
5420
|
+
if (state.ackRetryTimer === void 0) return;
|
|
5421
|
+
clearTimeout(state.ackRetryTimer);
|
|
5422
|
+
state.ackRetryTimer = void 0;
|
|
5423
|
+
}
|
|
5424
|
+
/**
|
|
5425
|
+
* One timer-driven retry after a refused ack. At most one is armed per
|
|
5426
|
+
* session; it no-ops when the state it was armed for is no longer current
|
|
5427
|
+
* (a re-subscribe, a checkpoint recovery or `dispose`).
|
|
5428
|
+
*/
|
|
5429
|
+
#armAckRetry(sessionId, state, delayMs) {
|
|
5430
|
+
if (this.#closed) return;
|
|
5431
|
+
if (state.ackRetryTimer !== void 0) return;
|
|
5432
|
+
const timer = setTimeout(() => {
|
|
5433
|
+
state.ackRetryTimer = void 0;
|
|
5434
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return;
|
|
5435
|
+
state.ackRetryAt = 0;
|
|
5436
|
+
this.#maybeAutoAck(sessionId, state);
|
|
5437
|
+
}, delayMs);
|
|
5438
|
+
timer.unref?.();
|
|
5439
|
+
state.ackRetryTimer = timer;
|
|
5315
5440
|
}
|
|
5316
5441
|
/**
|
|
5317
5442
|
* `agent.subscriptions.resetRequired`: the peer tore the subscription down
|
|
@@ -5330,6 +5455,7 @@ var SessionSubscriptions = class {
|
|
|
5330
5455
|
for (const [sessionId, state] of this.#sessions) {
|
|
5331
5456
|
if (state.subscriptionId !== subscriptionId) continue;
|
|
5332
5457
|
console.warn(`[cherry-remote] subscription for session ${sessionId} reset by the peer (${String(params["reason"] ?? "unspecified")}); re-subscribe required`);
|
|
5458
|
+
this.#cancelAckRetry(state);
|
|
5333
5459
|
this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} reset by the peer`));
|
|
5334
5460
|
this.#sessions.delete(sessionId);
|
|
5335
5461
|
return;
|
|
@@ -9160,8 +9286,9 @@ var CherryToolEngine = class {
|
|
|
9160
9286
|
* dimension and deliberately does NOT settle a wait (Task 13 asserts the two
|
|
9161
9287
|
* layers separately).
|
|
9162
9288
|
*
|
|
9163
|
-
* Waiters
|
|
9164
|
-
*
|
|
9289
|
+
* Waiters do not ack what they fold. The subscription LAYER acks its own
|
|
9290
|
+
* buffer on a byte watermark (issue #212) so the peer keeps delivering past
|
|
9291
|
+
* its un-acked window; that ack is about flow control, not consumption.
|
|
9165
9292
|
*
|
|
9166
9293
|
* A wait can also end because the LINK went away — the plugin was torn down,
|
|
9167
9294
|
* the device was unpaired, or the connection closed. That is not a timeout and
|
|
@@ -12671,7 +12798,8 @@ function subsFor(deviceId) {
|
|
|
12671
12798
|
const connection = connectionFor(deviceId);
|
|
12672
12799
|
const created = new SessionSubscriptions(connection.rpc, {
|
|
12673
12800
|
subscriptionsLimit: connection.limits.subscriptions,
|
|
12674
|
-
perSessionCacheBytes: state.perSessionCacheBytes
|
|
12801
|
+
perSessionCacheBytes: state.perSessionCacheBytes,
|
|
12802
|
+
unackedBytes: () => connection.limits.unackedBytes
|
|
12675
12803
|
});
|
|
12676
12804
|
state.subscriptions.set(deviceId, created);
|
|
12677
12805
|
console.info(`[cherry-remote] subscription layer established for ${deviceId} (peer limit ${connection.limits.subscriptions})`);
|
package/docs/e2e-checklist.md
CHANGED
|
@@ -137,6 +137,7 @@ schema 已允许 `interrupted`,所以客户端**必须**处理,但当前服
|
|
|
137
137
|
- [ ] 长回复(>4096 单元)经 `{ref}` 外置,聚合结果仍包含完整文本。
|
|
138
138
|
- [ ] `cherry_read` 能按 messageId 读到该轮消息与 parts。
|
|
139
139
|
- [ ] `wait: false` 立即返回命令回执,随后 `cherry_wait` / `cherry_events` 能续查到同一终态。
|
|
140
|
+
- [ ] 长回答(事件流超过 256 KiB,约 30 小节的一次长输出)时 `wait: true` 仍在终态后立即返回 `completed`;期间每 5 s 采一次 `GET /diagnostics` 的 `sessions[].cursor.seq`,确认它没有停住(修复前会冻结在窗口处,最终报 `timeout`)。
|
|
140
141
|
- [ ] 同 `commandId` 同参重发 → 返回**同一回执**(幂等);异参重发 → `IDEMPOTENCY_CONFLICT` 透传。
|
|
141
142
|
|
|
142
143
|
### C. 交互应答
|
|
@@ -337,3 +338,25 @@ A–G 各节要真人 + Electron 端批准,无法被 CI 化;下表只记录
|
|
|
337
338
|
被测设备是单主机同端口;需要造一条混合端口记录才能走。
|
|
338
339
|
- **#161 `failureText`**:要制造一个 peer 可达的 caught failure(如 ack 失败)才会出现,
|
|
339
340
|
本轮 `recentErrors` 为空。
|
|
341
|
+
|
|
342
|
+
### 0.2.1(2026-11-01 · DSH profile `web` · 桌面端 ISV-ZYX 2.1.10)
|
|
343
|
+
|
|
344
|
+
| 项 | 结果 | 观测 |
|
|
345
|
+
| --- | --- | --- |
|
|
346
|
+
| 装机生效 | 通过 | 版本 `0.2.1`;包 mtime `23:19:54` 早于宿主 PID 1 启动 `23:20:24`;装机 `dist/index.mjs`、`dist/index.d.mts`、`lib/client.js`、`locale/zh.json` 与 npm `dsh-cherry-bridge@0.2.1` tarball(shasum `ae7ed02b…`)sha256 全同 |
|
|
347
|
+
| 档案无迁移 | 通过 | `devices.json` 仍是 `schemaVersion: 4`(本轮没有触发任何迁移);`forgottenHosts` 是**可选键、不升版本**(代码依据:档案校验器与视图),首次 `forget` 后出现在文件里。注:捕获里**没有 forget 之前的档案快照**,所以「之前不在文件里」不算本轮观测 |
|
|
348
|
+
| `GET /cherry-remote/api/devices` 新键 | 通过 | 每台设备多出 `forgottenHosts`(无标记时是空数组 `[]`,不是缺键);本机设备 `hosts` 两条、`security` / `ports` 各两条 |
|
|
349
|
+
| 忘记一条非末位地址(#185 / #204) | 通过 | `POST …/devices/<id>/forget {"hosts":["172.17.14.36"]}` → 200;同一响应里 `hosts` 少一条、`forgottenHosts` 多一条、`state` 变 `connecting`(驱逐),`security` / `ports` 同步删掉该键;档案已落盘。注:forget 之前的「两条地址」由收尾/live 的原状锚定,捕获的第一条事件就是 forget |
|
|
350
|
+
| **重连后不会学回来** | 通过 | 21 s 后 `state: connected`,`hosts` **仍是单条**(观测)。**推断**:桌面一直在广播两条、学习合并跳过了被标记者 —— 依据是下一行「恢复只摘标记」之后 18 s 地址就自己回来了,中间没有别的写入 |
|
|
351
|
+
| 恢复只摘标记 | 通过 | `POST …/remember` → 200,`forgottenHosts: []` 而 `hosts` **未变**;再 18 s 后 `hosts` 回到两条,`security` / `ports` 里重新出现该键(`ws` / 23333,这两个值是否对端所报见下方「未覆盖」) |
|
|
352
|
+
| 拒绝忘记最后一条 | 通过 | 只剩一条时 `forget` → `400` `{"error":"last-address","message":"… refusing to forget the last dial candidate — the record would have nothing to dial. Keep at least one address."}`;档案**未变**(`hosts` 与标记都保持原样;代码在持久化**之前**抛出) |
|
|
353
|
+
| 驱逐的代价可观测 | 通过 | 四次改动 = 四次重建:`stats.connectionsBuilt` 收尾 **5**,两次改动后读到 **3**(故基线推为 1,`1` 本身没有直接读到);`connections` / `sockets` 两次读到 1/1(23:25:00 那次没取到 `stats`);`recentErrors` 恰好 **4** 条 `CONNECTION_OFFLINE`(`device transitioned to offline`,每次驱逐一条,无其他;捕获里第 4 条被截断,`/diagnostics` 现读为 4);收尾回到原状(两条地址、无标记、`connected`) |
|
|
354
|
+
|
|
355
|
+
**本轮未覆盖**(**不是通过**):
|
|
356
|
+
|
|
357
|
+
- **恢复后的端口 / 协议是否真来自对端**:本环境桌面广播的就是记录级默认(`ws` / 23333),
|
|
358
|
+
观测无法区分「对端说的」与「默认值」;由 `DeviceStore.learnEndpoints` 与
|
|
359
|
+
`advertisedEndpoints` 的单测覆盖(每个端点自带 port/scheme)。
|
|
360
|
+
- **设置页那对按钮的点击**:需要能打开宿主的浏览器;本机渲染由 `tests/unit/ui.test.ts`
|
|
361
|
+
覆盖,地址编辑器打开时两个控件冻结由 `#204m` 覆盖。本轮走的是页面调用的同两条 HTTP 路由。
|
|
362
|
+
- **驱逐时 in-flight 命令丢失**:需要一个正在执行的 command 才会出现,本轮没有。
|
package/package.json
CHANGED
package/src/AGENTS.md
CHANGED
|
@@ -36,6 +36,7 @@ in [`docs/e2e-checklist.md`](../docs/e2e-checklist.md).
|
|
|
36
36
|
| `recordBytes` | 65,536 (64 KiB) | one JSON-RPC record above it **aborts the connection** — the transport answers by killing it, so every in-flight request fails |
|
|
37
37
|
| `contentBytes` | 24,576 (24 KiB) | the slice size for one `agent.content.read` |
|
|
38
38
|
| `pageItems` | 100 | the protocol's single-page hard cap; `hello` may negotiate smaller |
|
|
39
|
+
| `unackedBytes` | 262,144 (256 KiB) | the peer HOLDS `agent.events` beyond this much un-acked delivery — terminal included; the layer acks at ¼ of it (hello value, else this) (issue #212) |
|
|
39
40
|
|
|
40
41
|
Through 2.1.7 the desktop's `pageOf` pages by **count alone** (2.1.8's byte
|
|
41
42
|
budget aside, still count-paged): `pageItems: 100` fat parts produced a record
|
|
@@ -187,8 +188,7 @@ projection. An unchanged message costs no repeat `agent.parts.list`
|
|
|
187
188
|
RPC on send → wait → read rounds, while every read still re-fetches and
|
|
188
189
|
sha256-verifies every `{ref}`'s bytes (a corrupted ref behind an unchanged
|
|
189
190
|
revision fails with `ContentIntegrityError`; `toolsReadCache.test.ts` T1b pins
|
|
190
|
-
this beside the cache).
|
|
191
|
-
every part over 4,096 characters.
|
|
191
|
+
this beside the cache).
|
|
192
192
|
|
|
193
193
|
- **Entry & key**: the `agent.parts.list` result — `{revision, page}` — keyed
|
|
194
194
|
`<deviceId>\0<messageId>` (device-scoped: a message id is the peer's; two
|
|
@@ -654,8 +654,7 @@ one host; whole-replace `hosts` defaults new hosts to
|
|
|
654
654
|
`ws`/port; a CHANGED `port` re-points EVERY final host, the current value a map
|
|
655
655
|
no-op (the settings UI sends both on save). `GET /devices` reports both maps and
|
|
656
656
|
`forgottenHosts` (addresses and intent, not credentials); tools/diagnostics show
|
|
657
|
-
only `port`.
|
|
658
|
-
page shows the per-host ports only when they differ from the record-level one.
|
|
657
|
+
only `port`.
|
|
659
658
|
TLS trust is operator config (`wsTls`: FLAT `tls.connect` `rejectUnauthorized`/
|
|
660
659
|
`ca` only), forwarded to both dial sites (pool, pairing). `resolveBridgeConfig`
|
|
661
660
|
validates it: unknown key or unusable value warns by FIELD NAME, WHOLE object
|
|
@@ -785,9 +784,8 @@ that is not valid URL encoding) · `last-address` 400 (the action would leave no
|
|
|
785
784
|
to dial; nothing written) · `invalid-device` 400 (the store refused) · `not-found`
|
|
786
785
|
404 (unknown device OR action — also what a QUARANTINED archive answers, its view
|
|
787
786
|
being empty) · `method-not-allowed` 405 (before the body) · `archive-quarantined`
|
|
788
|
-
503 (`{file, corrupt, recovery}`; a damaged archive
|
|
789
|
-
|
|
790
|
-
shipped store, kept so the arm exists); anything else is the generic 500
|
|
787
|
+
503 (`{file, corrupt, recovery}`; a damaged archive 404s first, so this arm covers
|
|
788
|
+
only a store that refuses after that read); anything else is the generic 500
|
|
791
789
|
`internal-error`.
|
|
792
790
|
|
|
793
791
|
> The minimal `pairing-progress` frame shape is `{stage, detail?}`; this plugin
|
|
@@ -844,9 +842,8 @@ the error message).
|
|
|
844
842
|
construction**, not by a deny-list. `limits` is re-projected from its four
|
|
845
843
|
keys: a whitelist inside the whitelist.
|
|
846
844
|
- `stats.connectionsBuilt` counts connections built since the last `apply`, not
|
|
847
|
-
RPC requests
|
|
848
|
-
|
|
849
|
-
statistics describe **only** the current assembly.
|
|
845
|
+
RPC requests (those belong to the transport). Counters and the error ring are
|
|
846
|
+
zeroed on every `apply`: they describe **only** the current assembly.
|
|
850
847
|
|
|
851
848
|
### Logging: levels, identifiers, and what never appears
|
|
852
849
|
|
|
@@ -944,6 +941,4 @@ faces:
|
|
|
944
941
|
answer is the byte-safe page **clamp**: never ask for a page that cannot fit.
|
|
945
942
|
- Its read path refuses an unusable `byteLength`; we fall back to a slice bound
|
|
946
943
|
plus the cumulative arm.
|
|
947
|
-
- Its read path never replays a read; we replay reads **once** (deliberate
|
|
948
|
-
enhancement) while both sides keep commands receipt-recovered.
|
|
949
944
|
|
package/src/connection.ts
CHANGED
|
@@ -38,6 +38,17 @@ export interface EffectiveLimits {
|
|
|
38
38
|
subscriptions: number
|
|
39
39
|
pageItems: number
|
|
40
40
|
inFlightRequests: number
|
|
41
|
+
/**
|
|
42
|
+
* The peer's hello-advertised UN-ACKED DELIVERY WINDOW (protocol
|
|
43
|
+
* `remoteLimits.unackedBytes`). Past this much delivered-but-unacknowledged
|
|
44
|
+
* traffic the desktop holds its event stream (issue #212), so the
|
|
45
|
+
* subscription layer sizes its own ack watermark from it.
|
|
46
|
+
*
|
|
47
|
+
* ABSENT, never defaulted — same rule as `messageSummaries` below: a hello
|
|
48
|
+
* that does not carry the key leaves the layer on the pinned protocol
|
|
49
|
+
* constant rather than on a guess about this peer.
|
|
50
|
+
*/
|
|
51
|
+
unackedBytes?: number
|
|
41
52
|
/**
|
|
42
53
|
* The peer's hello-advertised `agentMessageSummariesVersion` (issue #105),
|
|
43
54
|
* stored BESIDE the limits because it is re-read by that same handshake.
|
|
@@ -1402,11 +1413,13 @@ export class DeviceConnection {
|
|
|
1402
1413
|
// reconnect to a peer that no longer advertises the capability (or a
|
|
1403
1414
|
// downgrade) cannot inherit a stale `messageSummaries` from the previous
|
|
1404
1415
|
// link — the exact failure mode that would send `partsMode` to a 2.1.7 peer.
|
|
1416
|
+
const unackedBytes = positive(hello.limits?.['unackedBytes'])
|
|
1405
1417
|
this.#limits = {
|
|
1406
1418
|
heartbeatMs: positive(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
|
|
1407
1419
|
subscriptions: positive(hello.limits?.['subscriptions']) ?? FALLBACK_LIMITS.subscriptions,
|
|
1408
1420
|
pageItems: positive(hello.limits?.['pageItems']) ?? FALLBACK_LIMITS.pageItems,
|
|
1409
1421
|
inFlightRequests: positive(hello.limits?.['inFlightRequests']) ?? FALLBACK_LIMITS.inFlightRequests,
|
|
1422
|
+
...(unackedBytes === undefined ? {} : { unackedBytes }),
|
|
1410
1423
|
...(typeof hello.agentMessageSummariesVersion === 'number'
|
|
1411
1424
|
? { messageSummaries: hello.agentMessageSummariesVersion }
|
|
1412
1425
|
: {}),
|
package/src/diagnostics.ts
CHANGED
|
@@ -199,7 +199,7 @@ export interface DiagnosticsDevice {
|
|
|
199
199
|
* `partsMode: 'paged'`.
|
|
200
200
|
*
|
|
201
201
|
* Its own field, not a fifth key inside `limits`: it is a VERSION, not a
|
|
202
|
-
* count, and `limits` is
|
|
202
|
+
* count, and `limits` here is the four numeric caps this report CARRIES. Present only
|
|
203
203
|
* when a LIVE connection reported it (the same rule as `limits`: the endpoint
|
|
204
204
|
* never dials to find out). ABSENT therefore means "not advertised **or** not
|
|
205
205
|
* connected" — every desktop through 2.1.7, and any device whose link is
|
|
@@ -555,10 +555,11 @@ function projectMessageSummaries(raw: unknown): { messageSummaries?: number } {
|
|
|
555
555
|
}
|
|
556
556
|
|
|
557
557
|
/**
|
|
558
|
-
* Re-projects the four numeric limit keys, dropping any
|
|
559
|
-
* caller's `limits` object might carry
|
|
560
|
-
*
|
|
561
|
-
*
|
|
558
|
+
* Re-projects the four numeric limit keys this report carries, dropping any
|
|
559
|
+
* foreign key that a caller's `limits` object might carry — including
|
|
560
|
+
* `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
|
|
561
|
+
* A whitelist inside the whitelist: the shape is rebuilt field by field rather
|
|
562
|
+
* than spread, so a future addition cannot arrive here unvetted.
|
|
562
563
|
*/
|
|
563
564
|
function projectLimits(raw: unknown): { limits?: EffectiveLimits } {
|
|
564
565
|
if (raw === null || typeof raw !== 'object') return {}
|
package/src/index.ts
CHANGED
|
@@ -664,6 +664,11 @@ function subsFor(deviceId: string): SessionSubscriptions {
|
|
|
664
664
|
const created = new SessionSubscriptions(connection.rpc, {
|
|
665
665
|
subscriptionsLimit: connection.limits.subscriptions,
|
|
666
666
|
perSessionCacheBytes: state.perSessionCacheBytes,
|
|
667
|
+
// The peer's OWN un-acked window when hello advertised it (protocol 0.7.0
|
|
668
|
+
// peers do); absent falls back to the pinned constant inside the layer.
|
|
669
|
+
// A PROVIDER, not a value: the layer outlives a reconnect, and `limits` is
|
|
670
|
+
// rebuilt by every hello — a pinned number would freeze the first answer.
|
|
671
|
+
unackedBytes: () => connection.limits.unackedBytes,
|
|
667
672
|
})
|
|
668
673
|
state.subscriptions.set(deviceId, created)
|
|
669
674
|
// INFO (spec §5 「订阅建立」): the layer is announced with the cap it was
|
package/src/subscriptions.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { JsonRpcRequestError } from './rpc/jsonRpcClient.js'
|
|
|
2
2
|
import type { JsonRpcClient } from './rpc/jsonRpcClient.js'
|
|
3
3
|
import { DEFAULT_PER_SESSION_CACHE_BYTES } from './defaults.js'
|
|
4
4
|
import { failureText } from './text.js'
|
|
5
|
+
import { remoteLimits } from '@cherrystudio/remote-protocol'
|
|
5
6
|
import type { AgentCursor, AgentEvent } from '@cherrystudio/remote-protocol/agent'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -40,6 +41,27 @@ const DEFAULT_CHECKPOINT_MS = 300_000
|
|
|
40
41
|
*/
|
|
41
42
|
const MAX_RECOVERY_ATTEMPTS = 4
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
|
|
46
|
+
* bytes it has sent since our last `agent.subscriptions.ack` stay inside
|
|
47
|
+
* `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
|
|
48
|
+
* the subscription still live — and a held batch can be a run's terminal.
|
|
49
|
+
*
|
|
50
|
+
* This layer used to ack ONLY from `cherry_events`, so a waiter (which by
|
|
51
|
+
* design consumes without acking) parked on a buffer that stopped advancing
|
|
52
|
+
* just under one window: a real 240 s wait answered `timeout` with the finished
|
|
53
|
+
* text, its cursor frozen at 666 events ≈ 262144 bytes.
|
|
54
|
+
*
|
|
55
|
+
* The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
|
|
56
|
+
* memory; acking does not lose them) once a quarter of the peer's window has
|
|
57
|
+
* accumulated. A retry cooldown keeps a refusing peer from turning every batch
|
|
58
|
+
* into a round trip.
|
|
59
|
+
*/
|
|
60
|
+
/** First retry delay after a refused auto-ack; doubles per attempt. */
|
|
61
|
+
const AUTO_ACK_RETRY_MS = 3_000
|
|
62
|
+
/** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
|
|
63
|
+
const AUTO_ACK_RETRY_MAX_MS = 30_000
|
|
64
|
+
|
|
43
65
|
export type ReadResult = { events: AgentEvent[]; nextCursor: AgentCursor } | { error: 'cursor-out-of-window' }
|
|
44
66
|
|
|
45
67
|
export interface SessionSubscriptionsOptions {
|
|
@@ -60,6 +82,20 @@ export interface SessionSubscriptionsOptions {
|
|
|
60
82
|
* default 300_000). Only bounds recovery retries; the peer is authoritative.
|
|
61
83
|
*/
|
|
62
84
|
checkpointMs?: number
|
|
85
|
+
/**
|
|
86
|
+
* The peer's hello-advertised un-acked delivery window in bytes (protocol
|
|
87
|
+
* `remoteLimits.unackedBytes`, 262144 in the pinned package). Past that much
|
|
88
|
+
* delivered-but-unacknowledged traffic the desktop HOLDS its event stream
|
|
89
|
+
* (issue #212), so the layer acks its buffer at a quarter of this value.
|
|
90
|
+
*
|
|
91
|
+
* Omitted falls back to the pinned protocol constant. A peer that does not
|
|
92
|
+
* advertise the key is treated as the pinned generation — the same
|
|
93
|
+
* conservative rule the other negotiated limits follow.
|
|
94
|
+
*
|
|
95
|
+
* A FUNCTION is read at every watermark check, so a layer that outlives a
|
|
96
|
+
* reconnect picks up the new hello's value instead of pinning the first one.
|
|
97
|
+
*/
|
|
98
|
+
unackedBytes?: number | (() => number | undefined)
|
|
63
99
|
}
|
|
64
100
|
|
|
65
101
|
/** Peer-side subscription phases (mirrors the real peer's internal state). */
|
|
@@ -108,6 +144,31 @@ interface SessionState {
|
|
|
108
144
|
cursor: AgentCursor
|
|
109
145
|
buffer: CachedEvent[]
|
|
110
146
|
bytes: number
|
|
147
|
+
/**
|
|
148
|
+
* Bytes appended since the last ack we sent (issue #212). Drives
|
|
149
|
+
* {@link SessionSubscriptions.#maybeAutoAck}; kept separate from `bytes`,
|
|
150
|
+
* which measures what the CACHE holds rather than what the peer may still
|
|
151
|
+
* consider un-acknowledged.
|
|
152
|
+
*/
|
|
153
|
+
bytesSinceAck: number
|
|
154
|
+
/** One auto-ack in flight at a time; a second would only duplicate a cursor. */
|
|
155
|
+
ackInFlight: boolean
|
|
156
|
+
/** Cooldown after a refused auto-ack (`AUTO_ACK_RETRY_MS`, doubling). */
|
|
157
|
+
ackRetryAt: number
|
|
158
|
+
/**
|
|
159
|
+
* Current retry delay: `AUTO_ACK_RETRY_MS` after the first refusal, doubled
|
|
160
|
+
* on every further one up to `AUTO_ACK_RETRY_MAX_MS`. Reset on a success, so
|
|
161
|
+
* a peer that recovers is back to prompt retries.
|
|
162
|
+
*/
|
|
163
|
+
ackRetryDelay: number
|
|
164
|
+
/**
|
|
165
|
+
* The ARMED retry behind `ackRetryAt`. An ack failure is the one case where
|
|
166
|
+
* no further event will arrive to re-trigger the watermark check — the peer
|
|
167
|
+
* is holding at its window — so the retry cannot wait for traffic (issue
|
|
168
|
+
* #212's own failure mode, one ack later). Cleared by {@link #installReplay},
|
|
169
|
+
* {@link #installCheckpoint} and {@link SessionSubscriptions.dispose}.
|
|
170
|
+
*/
|
|
171
|
+
ackRetryTimer?: ReturnType<typeof setTimeout>
|
|
111
172
|
/**
|
|
112
173
|
* The oldest stream position this session still TRACKS: the baseline the
|
|
113
174
|
* subscription started from, advanced whenever old events are dropped. It is
|
|
@@ -189,6 +250,8 @@ export class SessionSubscriptions {
|
|
|
189
250
|
readonly #limit: number
|
|
190
251
|
readonly #perSessionCacheBytes: number
|
|
191
252
|
readonly #checkpointMs: number
|
|
253
|
+
/** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
|
|
254
|
+
readonly #unackedSource: number | (() => number | undefined) | undefined
|
|
192
255
|
readonly #sessions = new Map<string, SessionState>()
|
|
193
256
|
/**
|
|
194
257
|
* Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
|
|
@@ -211,6 +274,7 @@ export class SessionSubscriptions {
|
|
|
211
274
|
this.#limit = Math.max(1, Math.floor(positive(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)))
|
|
212
275
|
this.#perSessionCacheBytes = positive(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
|
|
213
276
|
this.#checkpointMs = positive(opts.checkpointMs, DEFAULT_CHECKPOINT_MS)
|
|
277
|
+
this.#unackedSource = opts.unackedBytes
|
|
214
278
|
}
|
|
215
279
|
|
|
216
280
|
/**
|
|
@@ -316,6 +380,7 @@ export class SessionSubscriptions {
|
|
|
316
380
|
// session that will not stay subscribed.
|
|
317
381
|
console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`)
|
|
318
382
|
await this.#closeSubscription(existing.subscriptionId)
|
|
383
|
+
this.#cancelAckRetry(existing)
|
|
319
384
|
this.#rejectWaiters(existing, new Error(`session ${sessionId} re-subscribed`))
|
|
320
385
|
this.#sessions.delete(sessionId)
|
|
321
386
|
}
|
|
@@ -424,13 +489,24 @@ export class SessionSubscriptions {
|
|
|
424
489
|
/**
|
|
425
490
|
* Advances the peer's view of this session: `agent.subscriptions.ack` with
|
|
426
491
|
* the delivered cursor. The peer only accepts a cursor inside the window it
|
|
427
|
-
* actually delivered (an out-of-window ack is its CONFLICT)
|
|
428
|
-
*
|
|
492
|
+
* actually delivered (an out-of-window ack is its CONFLICT).
|
|
493
|
+
*
|
|
494
|
+
* Two callers, one contract — "this position is accounted for, keep sending":
|
|
495
|
+
* `cherry_events` passes the cursor a CONSUMER has processed, and
|
|
496
|
+
* {@link #maybeAutoAck} passes the BUFFER frontier because the events are
|
|
497
|
+
* retained here (see that method for why an ack cannot lose replay).
|
|
429
498
|
*/
|
|
430
499
|
async ack(sessionId: string, cursor?: AgentCursor): Promise<void> {
|
|
431
500
|
const state = this.#sessions.get(sessionId)
|
|
432
501
|
if (state === undefined) return
|
|
433
|
-
|
|
502
|
+
// COALESCED TO THE FRONTIER. The peer keeps ONE `lastAck` per subscription
|
|
503
|
+
// and refuses a lower position, so an explicit ack carrying what a consumer
|
|
504
|
+
// has processed would lose a race against the layer's own ack and be
|
|
505
|
+
// answered CONFLICT — which the caller treats as a broken subscription.
|
|
506
|
+
// The buffer already holds everything up to `state.cursor`, so acking the
|
|
507
|
+
// frontier is both safe (an ack only frees flow control) and monotone.
|
|
508
|
+
const requested = cursor === undefined ? state.cursor : cursor
|
|
509
|
+
const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor
|
|
434
510
|
await this.#rpc.request('agent.subscriptions.ack', {
|
|
435
511
|
subscriptionId: state.subscriptionId,
|
|
436
512
|
cursor: applied,
|
|
@@ -484,6 +560,7 @@ export class SessionSubscriptions {
|
|
|
484
560
|
this.#closed = true
|
|
485
561
|
const ids = [...this.#sessions.values()].map((state) => state.subscriptionId)
|
|
486
562
|
for (const state of this.#sessions.values()) {
|
|
563
|
+
this.#cancelAckRetry(state)
|
|
487
564
|
const error = new Error('SessionSubscriptions disposed')
|
|
488
565
|
for (const waiter of state.waiters.splice(0)) waiter.reject(error)
|
|
489
566
|
}
|
|
@@ -590,7 +667,10 @@ export class SessionSubscriptions {
|
|
|
590
667
|
*/
|
|
591
668
|
#installReplay(sessionId: string, descriptor: Extract<SubscribeDescriptor, { mode: 'replay' }>): void {
|
|
592
669
|
const previous = this.#sessions.get(sessionId)
|
|
593
|
-
if (previous !== undefined)
|
|
670
|
+
if (previous !== undefined) {
|
|
671
|
+
this.#cancelAckRetry(previous)
|
|
672
|
+
this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
|
|
673
|
+
}
|
|
594
674
|
this.#sessions.delete(sessionId)
|
|
595
675
|
// Re-inserting moves the session to the MRU end of the Map's order.
|
|
596
676
|
this.#sessions.set(sessionId, {
|
|
@@ -598,6 +678,11 @@ export class SessionSubscriptions {
|
|
|
598
678
|
cursor: descriptor.highWatermark,
|
|
599
679
|
buffer: [],
|
|
600
680
|
bytes: 0,
|
|
681
|
+
bytesSinceAck: 0,
|
|
682
|
+
ackInFlight: false,
|
|
683
|
+
ackRetryAt: 0,
|
|
684
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
685
|
+
ackRetryTimer: undefined,
|
|
601
686
|
windowStart: Number(descriptor.highWatermark.seq),
|
|
602
687
|
// A fresh window: nothing has been dropped from it, so the baseline is a
|
|
603
688
|
// position this layer may serve (issue #2).
|
|
@@ -723,13 +808,21 @@ export class SessionSubscriptions {
|
|
|
723
808
|
items: Array<{ kind: string; value: unknown }>,
|
|
724
809
|
): void {
|
|
725
810
|
const previous = this.#sessions.get(sessionId)
|
|
726
|
-
if (previous !== undefined)
|
|
811
|
+
if (previous !== undefined) {
|
|
812
|
+
this.#cancelAckRetry(previous)
|
|
813
|
+
this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
|
|
814
|
+
}
|
|
727
815
|
this.#sessions.delete(sessionId)
|
|
728
816
|
this.#sessions.set(sessionId, {
|
|
729
817
|
subscriptionId: descriptor.subscriptionId,
|
|
730
818
|
cursor: descriptor.checkpoint.cursor,
|
|
731
819
|
buffer: [],
|
|
732
820
|
bytes: 0,
|
|
821
|
+
bytesSinceAck: 0,
|
|
822
|
+
ackInFlight: false,
|
|
823
|
+
ackRetryAt: 0,
|
|
824
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
825
|
+
ackRetryTimer: undefined,
|
|
733
826
|
windowStart: Number(descriptor.checkpoint.cursor.seq),
|
|
734
827
|
// A fresh window: nothing has been dropped from it, so the baseline is a
|
|
735
828
|
// position this layer may serve (issue #2).
|
|
@@ -801,6 +894,7 @@ export class SessionSubscriptions {
|
|
|
801
894
|
`${sessionId}`,
|
|
802
895
|
)
|
|
803
896
|
// The entry is removed outright, so no `live` flag needs clearing.
|
|
897
|
+
this.#cancelAckRetry(state)
|
|
804
898
|
this.#rejectWaiters(state, new Error(`session ${sessionId} evicted by LRU`))
|
|
805
899
|
this.#sessions.delete(sessionId)
|
|
806
900
|
// AWAITED: the peer must observe the close before the next subscribe.
|
|
@@ -907,6 +1001,7 @@ export class SessionSubscriptions {
|
|
|
907
1001
|
const bytes = eventBytes(event)
|
|
908
1002
|
state.buffer.push({ cursor, event, bytes })
|
|
909
1003
|
state.bytes += bytes
|
|
1004
|
+
state.bytesSinceAck += bytes
|
|
910
1005
|
state.cursor = cursor
|
|
911
1006
|
appended += 1
|
|
912
1007
|
}
|
|
@@ -915,6 +1010,106 @@ export class SessionSubscriptions {
|
|
|
915
1010
|
// Waking on a wholly stale batch would make a parked waiter re-judge state
|
|
916
1011
|
// that has not moved; only real progress is a wake.
|
|
917
1012
|
if (appended > 0) this.#wakeWaiters(state)
|
|
1013
|
+
// The wake comes FIRST: a waiter that can settle from what just arrived
|
|
1014
|
+
// must not wait behind an ack round trip.
|
|
1015
|
+
if (appended > 0) this.#maybeAutoAck(sessionId, state)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
|
|
1020
|
+
* window, so the peer keeps delivering (issue #212).
|
|
1021
|
+
*
|
|
1022
|
+
* WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
|
|
1023
|
+
* a caller has consumed". That is safe against the pinned peer because an ack
|
|
1024
|
+
* does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
|
|
1025
|
+
* un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
|
|
1026
|
+
* retention lives on the journal (`replayBytes`/`replayMs`) and is not
|
|
1027
|
+
* touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
|
|
1028
|
+
* below the acked position is still served — by the local buffer here and by
|
|
1029
|
+
* the journal there. The ack's own doc comment states the same contract.
|
|
1030
|
+
*
|
|
1031
|
+
* The ack is fire-and-forget with its failure SURFACED (a refused ack must be
|
|
1032
|
+
* visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
|
|
1033
|
+
* holding at its window no further event arrives, so a retry that waited for
|
|
1034
|
+
* traffic would never fire and the wait would time out exactly as in issue
|
|
1035
|
+
* #212.
|
|
1036
|
+
*/
|
|
1037
|
+
#maybeAutoAck(sessionId: string, state: SessionState): void {
|
|
1038
|
+
if (state.bytesSinceAck < this.#ackWatermark()) return
|
|
1039
|
+
if (state.ackInFlight) return
|
|
1040
|
+
if (Date.now() < state.ackRetryAt) return
|
|
1041
|
+
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
|
+
void this.ack(sessionId)
|
|
1046
|
+
.then(() => {
|
|
1047
|
+
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered)
|
|
1048
|
+
// A peer that answers ack again gets prompt retries next time.
|
|
1049
|
+
state.ackRetryDelay = AUTO_ACK_RETRY_MS
|
|
1050
|
+
})
|
|
1051
|
+
.catch((error: unknown) => {
|
|
1052
|
+
// THIS attempt's delay, then the next one doubles: a peer that refuses
|
|
1053
|
+
// every ack (a wedged controller, a link about to drop) must not log a
|
|
1054
|
+
// line every few seconds for the life of the layer.
|
|
1055
|
+
const delay = state.ackRetryDelay
|
|
1056
|
+
state.ackRetryAt = Date.now() + delay
|
|
1057
|
+
state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS)
|
|
1058
|
+
console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`)
|
|
1059
|
+
// ARMED HERE, not on the next batch: a peer holding at its window sends
|
|
1060
|
+
// nothing more, so traffic-driven retry is no retry at all.
|
|
1061
|
+
this.#armAckRetry(sessionId, state, delay)
|
|
1062
|
+
})
|
|
1063
|
+
.finally(() => {
|
|
1064
|
+
state.ackInFlight = false
|
|
1065
|
+
// RE-JUDGE: batches can arrive WHILE the ack was in flight (the peer
|
|
1066
|
+
// flushes on the ack, so this is the common case), and those batches
|
|
1067
|
+
// were refused a trigger by `ackInFlight`. Without this second look the
|
|
1068
|
+
// layer could sit with bytes over the watermark and no event left to
|
|
1069
|
+
// trigger it — the peer holding, exactly as in issue #212.
|
|
1070
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return
|
|
1071
|
+
this.#maybeAutoAck(sessionId, state)
|
|
1072
|
+
})
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Quarter of the peer's window, read from the CURRENT source so a layer that
|
|
1077
|
+
* outlives a reconnect follows the new hello instead of its first answer.
|
|
1078
|
+
* Falls back to the pinned protocol constant when nothing is advertised.
|
|
1079
|
+
*/
|
|
1080
|
+
#ackWatermark(): number {
|
|
1081
|
+
const source = this.#unackedSource
|
|
1082
|
+
const advertised = typeof source === 'function' ? source() : source
|
|
1083
|
+
return Math.max(1, Math.floor(positive(advertised, remoteLimits.unackedBytes) / 4))
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Disarms the retry, if any (idempotent; safe on a state being replaced). */
|
|
1087
|
+
#cancelAckRetry(state: SessionState): void {
|
|
1088
|
+
if (state.ackRetryTimer === undefined) return
|
|
1089
|
+
clearTimeout(state.ackRetryTimer)
|
|
1090
|
+
state.ackRetryTimer = undefined
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* One timer-driven retry after a refused ack. At most one is armed per
|
|
1095
|
+
* session; it no-ops when the state it was armed for is no longer current
|
|
1096
|
+
* (a re-subscribe, a checkpoint recovery or `dispose`).
|
|
1097
|
+
*/
|
|
1098
|
+
#armAckRetry(sessionId: string, state: SessionState, delayMs: number): void {
|
|
1099
|
+
if (this.#closed) return
|
|
1100
|
+
if (state.ackRetryTimer !== undefined) return
|
|
1101
|
+
const timer = setTimeout(() => {
|
|
1102
|
+
state.ackRetryTimer = undefined
|
|
1103
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return
|
|
1104
|
+
// CLEARED HERE, not left to the wall clock: a clock stepped backwards
|
|
1105
|
+
// would otherwise make `#maybeAutoAck` return early and arm nothing — a
|
|
1106
|
+
// retry silently lost while the peer holds, which is #212 again.
|
|
1107
|
+
state.ackRetryAt = 0
|
|
1108
|
+
this.#maybeAutoAck(sessionId, state)
|
|
1109
|
+
}, delayMs)
|
|
1110
|
+
// Never hold the process open on a retry that is only about flow control.
|
|
1111
|
+
timer.unref?.()
|
|
1112
|
+
state.ackRetryTimer = timer
|
|
918
1113
|
}
|
|
919
1114
|
|
|
920
1115
|
/**
|
|
@@ -937,6 +1132,7 @@ export class SessionSubscriptions {
|
|
|
937
1132
|
`[cherry-remote] subscription for session ${sessionId} reset by the peer ` +
|
|
938
1133
|
`(${String(params['reason'] ?? 'unspecified')}); re-subscribe required`,
|
|
939
1134
|
)
|
|
1135
|
+
this.#cancelAckRetry(state)
|
|
940
1136
|
this.#rejectWaiters(state, new Error(`session ${sessionId} reset by the peer`))
|
|
941
1137
|
this.#sessions.delete(sessionId)
|
|
942
1138
|
return
|
package/src/tools.ts
CHANGED
|
@@ -4077,9 +4077,13 @@ export class CherryToolEngine {
|
|
|
4077
4077
|
} satisfies CherryCursorOutOfWindow
|
|
4078
4078
|
}
|
|
4079
4079
|
|
|
4080
|
-
// ACK AFTER DELIVERY (controller ruling ④):
|
|
4081
|
-
//
|
|
4082
|
-
//
|
|
4080
|
+
// ACK AFTER DELIVERY (controller ruling ④): this EXPLICIT ack reports
|
|
4081
|
+
// the position the caller has just been handed. The layer ALSO acks its
|
|
4082
|
+
// own buffer on a byte watermark (issue #212, subscriptions
|
|
4083
|
+
// `#maybeAutoAck`), because the peer holds the stream past its un-acked
|
|
4084
|
+
// window. The peer keeps ONE `lastAck` and refuses a lower position, so
|
|
4085
|
+
// both sites coalesce to the buffer frontier — see `ack()`.
|
|
4086
|
+
// A failing ack is logged, never allowed to swallow the delivered events.
|
|
4083
4087
|
// A FAILING ACK ALSO RE-ENSURES THE SESSION (v0.1.3 UltraReview P3-3).
|
|
4084
4088
|
// The ack is the peer's verdict on the SUBSCRIPTION, not on the
|
|
4085
4089
|
// delivery: a refused ack says the cursor is outside the window this
|
|
@@ -6041,8 +6045,9 @@ export class CherryToolEngine {
|
|
|
6041
6045
|
* dimension and deliberately does NOT settle a wait (Task 13 asserts the two
|
|
6042
6046
|
* layers separately).
|
|
6043
6047
|
*
|
|
6044
|
-
* Waiters
|
|
6045
|
-
*
|
|
6048
|
+
* Waiters do not ack what they fold. The subscription LAYER acks its own
|
|
6049
|
+
* buffer on a byte watermark (issue #212) so the peer keeps delivering past
|
|
6050
|
+
* its un-acked window; that ack is about flow control, not consumption.
|
|
6046
6051
|
*
|
|
6047
6052
|
* A wait can also end because the LINK went away — the plugin was torn down,
|
|
6048
6053
|
* the device was unpaired, or the connection closed. That is not a timeout and
|