dsh-cherry-bridge 0.1.8 → 0.1.10
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 +14 -8
- package/README.md +10 -7
- package/cordis.patch.yml +3 -2
- package/dist/index.d.mts +36 -22
- package/dist/index.mjs +365 -60
- package/docs/e2e-checklist.md +8 -8
- package/lib/client.js +2 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
- package/src/connection.ts +213 -21
- package/src/defaults.ts +61 -19
- package/src/diagnostics.ts +17 -10
- package/src/index.ts +7 -3
- package/src/tools.ts +240 -36
- package/ui/index.html +1 -1
- package/ui/src/i18n.ts +2 -2
package/docs/e2e-checklist.md
CHANGED
|
@@ -55,17 +55,17 @@
|
|
|
55
55
|
|
|
56
56
|
---
|
|
57
57
|
|
|
58
|
-
### 3. `rate-limit-terminates-session` — Over-limit rate limiting
|
|
58
|
+
### 3. `rate-limit-terminates-session` — Over-limit rate limiting is generation-dependent: a whole-session 1011 close (≤2.1.6) or a per-request refusal (≥2.1.7)
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
code **1011
|
|
60
|
+
**真实对端行为**:≤2.1.6 超过桌面每套接字预算(64 req/s 共享、512 req/s 内容)会以 close
|
|
61
|
+
code **1011 终止整条会话**;≥2.1.7(PR #21687)共享额度改为 256 req/s,超限只拒绝**该条**
|
|
62
|
+
请求(`RATE_LIMITED` + `retryAfterMs`),会话存活。
|
|
62
63
|
|
|
63
|
-
**手动核验步骤**(与 `CONTRACT_DIFFERENCES[2].checklistStep`
|
|
64
|
+
**手动核验步骤**(与 `CONTRACT_DIFFERENCES[2].checklistStep` 同源,逐字一致):
|
|
64
65
|
|
|
65
|
-
- [ ] rate-limit-terminates-session:
|
|
66
|
+
- [ ] rate-limit-terminates-session: on a ≥2.1.7 desktop, raise `rateBudgetPerSecond` (or use an unpaced probe) and drive a burst above the allowance; confirm each over-budget request is refused with `RATE_LIMITED` + `retryAfterMs` while the session stays live, a refused READ succeeds after ONE replay, and a refused COMMAND surfaces as a verdict and is not replayed. On ≤2.1.6, the same burst closes the socket with 1011 (state offline, backoff restart).
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
**不出现**「某一条请求被单独拒绝、连接仍存活」这一形态。
|
|
68
|
+
**期望**:按对端版本二选一命中;两种形态都**不出现**未分类的 bare channel-closed 文本。
|
|
69
69
|
|
|
70
70
|
---
|
|
71
71
|
|
|
@@ -175,7 +175,7 @@ schema 已允许 `interrupted`,所以客户端**必须**处理,但当前服
|
|
|
175
175
|
**W1**(步骤 6 会真的把它删掉),其中有一个**保持开着**的会话;③ 一个不勾选的工作区
|
|
176
176
|
**W2**(含一个会话);④ 一个**没有工作区**的会话——`cwd` 不属于任何已注册工作区,最省事的造法是:
|
|
177
177
|
注册一个临时工作区 W0 → 在其中建一个会话 → 删掉 W0(该会话转入「未分组」,D7 的判定对象就是它)。
|
|
178
|
-
面板入口:设置页 →「**Cherry
|
|
178
|
+
面板入口:设置页 →「**Cherry 互联**」→「**启用范围**」。`curl -s
|
|
179
179
|
http://127.0.0.1:23023/cherry-remote/api/scope`(宿主换过端口就换端口;curl 不带 `Origin`,走
|
|
180
180
|
信任 fence 的放行分支)应返回 `host.state: "ready"`、`host.capable: true`、`host.degraded: []`。
|
|
181
181
|
|
package/lib/client.js
CHANGED
|
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
|
|
|
13
13
|
*/
|
|
14
14
|
var dictionaries = {
|
|
15
15
|
zh: {
|
|
16
|
-
"app.title": "Cherry
|
|
16
|
+
"app.title": "Cherry 互联",
|
|
17
17
|
"app.subtitle": "通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端;设备档案保存在插件数据目录,页面不接触任何凭据。",
|
|
18
18
|
"app.eventSourceUnavailable": "此环境不支持 EventSource",
|
|
19
19
|
"app.devices.heading": "已配对设备",
|
|
@@ -113,7 +113,7 @@ window.__ModuleLoader__.load({
|
|
|
113
113
|
"list.separator": "、"
|
|
114
114
|
},
|
|
115
115
|
en: {
|
|
116
|
-
"app.title": "Cherry
|
|
116
|
+
"app.title": "Cherry Bridge",
|
|
117
117
|
"app.subtitle": "Connect this machine’s DeepSeek Harness to a Cherry Studio desktop over the remote transport. Device records live in the plugin data directory; this page never touches a credential.",
|
|
118
118
|
"app.eventSourceUnavailable": "EventSource is unavailable in this environment",
|
|
119
119
|
"app.devices.heading": "Paired devices",
|
package/locale/en.json
CHANGED
package/locale/zh.json
CHANGED
package/package.json
CHANGED
package/src/connection.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
DEFAULT_CONTENT_READ_BUDGET_PER_SECOND,
|
|
12
12
|
DEFAULT_RATE_BUDGET_PER_SECOND,
|
|
13
13
|
DEFAULT_TRANSPORT_RETRY_WAIT_MS,
|
|
14
|
+
RATE_LIMIT_MAX_WAIT_MS,
|
|
14
15
|
} from './defaults.js'
|
|
15
16
|
import { urlHost } from './urlHost.js'
|
|
16
17
|
import type { DeviceRecord } from './deviceStore.js'
|
|
@@ -51,9 +52,14 @@ export interface DeviceConnectionOptions {
|
|
|
51
52
|
* budget satisfies it with no cross-device scheduling.
|
|
52
53
|
*
|
|
53
54
|
* The content tier DERIVES from this value at the production ratio
|
|
54
|
-
* (DEFAULT_CONTENT_READ_BUDGET_PER_SECOND / DEFAULT_RATE_BUDGET_PER_SECOND
|
|
55
|
-
* so
|
|
56
|
-
*
|
|
55
|
+
* (DEFAULT_CONTENT_READ_BUDGET_PER_SECOND / DEFAULT_RATE_BUDGET_PER_SECOND,
|
|
56
|
+
* i.e. 8 : 1), so LOWERING this lowers both tiers. That ratio matches the
|
|
57
|
+
* <=2.1.6 allowances (512:64) and is where the 2.1.7 story used to be
|
|
58
|
+
* conservative — but only the shared line moved (256 in 2.1.7), so the derived
|
|
59
|
+
* tier is CLAMPED at the peer's 512/s content allowance
|
|
60
|
+
* (`contentBudgetFor`, issue #111): above a shared budget of 64 the knob stops
|
|
61
|
+
* scaling it instead of deriving 2,048/s from 2.1.7's shared figure and
|
|
62
|
+
* spending the tier being refused.
|
|
57
63
|
*/
|
|
58
64
|
rateBudgetPerSecond?: number
|
|
59
65
|
/** First reconnect backoff (default 1s); doubles per attempt. */
|
|
@@ -228,6 +234,31 @@ const RATE_WINDOW_MS = 1_000
|
|
|
228
234
|
const RATE_WINDOW_MARGIN_MS = 150
|
|
229
235
|
/** The deliberate local-shutdown face of a disposed client (see the replay path). */
|
|
230
236
|
const DISPOSED_RE = /^JSON-RPC client disposed$/
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The peer's structured over-budget refusal (desktop >= 2.1.7), or undefined.
|
|
240
|
+
*
|
|
241
|
+
* 2.1.7 answers an over-budget request with ONE `RATE_LIMITED` failure carrying
|
|
242
|
+
* `retryAfterMs` and keeps the session; <=2.1.6 instead closed the whole socket
|
|
243
|
+
* with 1011, which is the transport-loss path and never reaches this check.
|
|
244
|
+
*/
|
|
245
|
+
function rateLimitRefusal(error: unknown): JsonRpcRequestError | undefined {
|
|
246
|
+
if (!(error instanceof JsonRpcRequestError)) return undefined
|
|
247
|
+
const data = error.data
|
|
248
|
+
if (typeof data !== 'object' || data === null) return undefined
|
|
249
|
+
return (data as Record<string, unknown>)['reason'] === 'RATE_LIMITED' ? error : undefined
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* How long a refusal asks the caller to wait, bounded by
|
|
254
|
+
* {@link RATE_LIMIT_MAX_WAIT_MS}. An absent or unusable value is 0: retry at
|
|
255
|
+
* the next pacing slot rather than invent a delay the peer never asked for.
|
|
256
|
+
*/
|
|
257
|
+
function rateLimitWaitMs(refusal: JsonRpcRequestError): number {
|
|
258
|
+
const raw = (refusal.data as Record<string, unknown>)['retryAfterMs']
|
|
259
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return 0
|
|
260
|
+
return Math.min(raw, RATE_LIMIT_MAX_WAIT_MS)
|
|
261
|
+
}
|
|
231
262
|
/**
|
|
232
263
|
* The read methods whose transport loss may be replayed automatically.
|
|
233
264
|
*
|
|
@@ -338,8 +369,9 @@ class SecondWindowRateLimiter {
|
|
|
338
369
|
* ({@link DEFAULT_CONTENT_READ_BUDGET_PER_SECOND}).
|
|
339
370
|
*
|
|
340
371
|
* WHY TWO TIERS: the desktop paces the two against different allowances (512/s
|
|
341
|
-
* for content reads
|
|
342
|
-
* suit (480/s and 40/s). Sharing one
|
|
372
|
+
* for content reads; 64/s for everything else through 2.1.6 and 256/s from
|
|
373
|
+
* 2.1.7) and the official client follows suit (480/s and 40/s). Sharing one
|
|
374
|
+
* 32/s tier spent content-read throughput on
|
|
343
375
|
* unrelated RPCs and vice versa: a multi-slice walk of an externalized part —
|
|
344
376
|
* every part over 4,096 characters on Cherry >= 2.1.6 — queued behind list
|
|
345
377
|
* calls at ~36ms per slice.
|
|
@@ -350,9 +382,9 @@ type RateTier = 'shared' | 'content'
|
|
|
350
382
|
* Which tier paces `method`. The rule is EXACT-MATCH: `agent.content.read` is
|
|
351
383
|
* the only method on the content tier, and every other method — a command, a
|
|
352
384
|
* list read, a different `agent.*.read` — keeps the shared tier. A prefix or
|
|
353
|
-
* suffix match would silently widen the
|
|
354
|
-
* allowance is the 64/s
|
|
355
|
-
* so a new content-plane method must be named
|
|
385
|
+
* suffix match would silently widen the content tier to methods whose peer-side
|
|
386
|
+
* allowance is the shared one — 64/s through 2.1.6, where exceeding it closes
|
|
387
|
+
* the SESSION (1011) — so a new content-plane method must be named deliberately.
|
|
356
388
|
*/
|
|
357
389
|
function classifyRateTier(method: string): RateTier {
|
|
358
390
|
return method === 'agent.content.read' ? 'content' : 'shared'
|
|
@@ -558,6 +590,28 @@ function businessWindowFor(reportedInFlightRequests: number): number {
|
|
|
558
590
|
return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)))
|
|
559
591
|
}
|
|
560
592
|
|
|
593
|
+
/**
|
|
594
|
+
* The desktop's content-read allowance: 512/s, and the SAME in both generations
|
|
595
|
+
* (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
|
|
596
|
+
* 512 in v2.1.6 and v2.1.7 alike; only the shared line moved, 64 ⇒ 256).
|
|
597
|
+
*
|
|
598
|
+
* A CEILING on the derived tier, not a third knob. The 8 : 1 ratio exists so an
|
|
599
|
+
* operator who LOWERS the shared budget lowers both tiers; upward, the ratio
|
|
600
|
+
* would spend headroom the peer does not have — `rateBudgetPerSecond: 256`
|
|
601
|
+
* (2.1.7's SHARED figure, an easy number to copy) derives 2,048/s, which the
|
|
602
|
+
* limiter's 1 ms gap compresses to ~1,000/s against this allowance, so roughly
|
|
603
|
+
* every other content read is refused. On >=2.1.7 each refusal costs up to a
|
|
604
|
+
* second plus one replay and a second refusal fails the whole read; on <=2.1.6
|
|
605
|
+
* an overrun closes the SESSION (1011). Because this line is
|
|
606
|
+
* generation-INDEPENDENT, bounding it needs no generation detection — the one
|
|
607
|
+
* bound in this file that does not.
|
|
608
|
+
*
|
|
609
|
+
* 512 is the ceiling and 333/s is what the limiter then ACHIEVES from it
|
|
610
|
+
* (`(1,000 + 150) / 512` ceils to a 3 ms gap), so the saturated tier still
|
|
611
|
+
* keeps a 1.5x margin on arrival times.
|
|
612
|
+
*/
|
|
613
|
+
const CONTENT_BUDGET_CEILING_PER_SECOND = 512
|
|
614
|
+
|
|
561
615
|
/**
|
|
562
616
|
* The content-read tier's budget for a given SHARED budget.
|
|
563
617
|
*
|
|
@@ -566,10 +620,20 @@ function businessWindowFor(reportedInFlightRequests: number): number {
|
|
|
566
620
|
* them rather than staying pinned at 256 while the shared tier shrinks. An
|
|
567
621
|
* absent injection is the production default (which yields exactly
|
|
568
622
|
* `DEFAULT_CONTENT_READ_BUDGET_PER_SECOND`).
|
|
623
|
+
*
|
|
624
|
+
* Upward the ratio stops at {@link CONTENT_BUDGET_CEILING_PER_SECOND}: past a
|
|
625
|
+
* shared budget of 64 the derived value would exceed an allowance that never
|
|
626
|
+
* changed, so it is clamped instead of trying to pace past it.
|
|
627
|
+
*
|
|
628
|
+
* Exported for `tests/unit/contentBudgetCeiling.test.ts`, the same reason
|
|
629
|
+
* `reconnectWait` is: the ceiling is a property of this mapping, and asserting
|
|
630
|
+
* it through the limiter's timing would be a slower, flakier version of the
|
|
631
|
+
* same claim.
|
|
569
632
|
*/
|
|
570
|
-
function contentBudgetFor(sharedBudgetPerSecond: number | undefined): number {
|
|
633
|
+
export function contentBudgetFor(sharedBudgetPerSecond: number | undefined): number {
|
|
571
634
|
const ratio = DEFAULT_CONTENT_READ_BUDGET_PER_SECOND / DEFAULT_RATE_BUDGET_PER_SECOND
|
|
572
|
-
|
|
635
|
+
const derived = Math.ceil((sharedBudgetPerSecond ?? DEFAULT_RATE_BUDGET_PER_SECOND) * ratio)
|
|
636
|
+
return Math.min(CONTENT_BUDGET_CEILING_PER_SECOND, derived)
|
|
573
637
|
}
|
|
574
638
|
|
|
575
639
|
function defer<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: Error) => void } {
|
|
@@ -627,6 +691,13 @@ export class DeviceConnection {
|
|
|
627
691
|
readonly #helloProtocolVersions: readonly number[]
|
|
628
692
|
readonly #transportRetryWaitMs: number
|
|
629
693
|
readonly #stateListeners = new Set<(state: DeviceState) => void>()
|
|
694
|
+
/**
|
|
695
|
+
* Callers parked in {@link waitForSettled}. Separate from the state listeners
|
|
696
|
+
* because their lifetime is ONE wait: a listener stays for the lifecycle, a
|
|
697
|
+
* settle waiter removes itself the moment it is answered (or times out), and
|
|
698
|
+
* mixing them would leave every answered wait in the multicast set.
|
|
699
|
+
*/
|
|
700
|
+
readonly #settleWaiters = new Set<(state: DeviceState) => void>()
|
|
630
701
|
/**
|
|
631
702
|
* Waits parked inside `#awaitReconnect`. close() drains them: a shutdown has
|
|
632
703
|
* no reconnect to wait for, and — the case that matters — a close() followed
|
|
@@ -783,6 +854,53 @@ export class DeviceConnection {
|
|
|
783
854
|
this.#stateListeners.add(cb)
|
|
784
855
|
}
|
|
785
856
|
|
|
857
|
+
/**
|
|
858
|
+
* Waits for an attempt that is ALREADY IN FLIGHT to settle, bounded by
|
|
859
|
+
* `timeoutMs`, and answers with the state it ends on.
|
|
860
|
+
*
|
|
861
|
+
* The only state this waits on is `connecting`: an attempt is running, and
|
|
862
|
+
* its outcome is what the caller asked about. `connected` is answered at
|
|
863
|
+
* once, and so are `offline` (parked in backoff), `disabled` and
|
|
864
|
+
* `identity-mismatch` — no wait can change those, and waiting on them would
|
|
865
|
+
* merely delay the caller's answer by the whole bound. `offline` is the
|
|
866
|
+
* common case for a genuinely powered-off desktop, so it is the one that
|
|
867
|
+
* matters: the tools stay fail-fast there.
|
|
868
|
+
*
|
|
869
|
+
* This NEVER starts an attempt — it observes one the pool already started.
|
|
870
|
+
* That is the line the tool face has always drawn (a tool must not dial); the
|
|
871
|
+
* wait only stops the tool from calling a live dial a failure.
|
|
872
|
+
*
|
|
873
|
+
* The bound expiring is not an error: the current state (`connecting`) is
|
|
874
|
+
* returned, and the caller judges it exactly as it would have without the
|
|
875
|
+
* wait.
|
|
876
|
+
*/
|
|
877
|
+
async waitForSettled(timeoutMs: number): Promise<DeviceState> {
|
|
878
|
+
if (this.#state !== 'connecting' || timeoutMs <= 0) return this.#state
|
|
879
|
+
return new Promise<DeviceState>((resolve) => {
|
|
880
|
+
let settled = false
|
|
881
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
882
|
+
const finish = (state: DeviceState): void => {
|
|
883
|
+
if (settled) return
|
|
884
|
+
settled = true
|
|
885
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
886
|
+
this.#settleWaiters.delete(onState)
|
|
887
|
+
resolve(state)
|
|
888
|
+
}
|
|
889
|
+
// The waiter's own filter: a transition to `offline` IS the attempt
|
|
890
|
+
// settling (failed); only a transition INTO `connecting` is not an
|
|
891
|
+
// answer, and `#setState` never reports a state it is already in.
|
|
892
|
+
const onState = (state: DeviceState): void => {
|
|
893
|
+
if (state !== 'connecting') finish(state)
|
|
894
|
+
}
|
|
895
|
+
timer = setTimeout(() => finish(this.#state), timeoutMs)
|
|
896
|
+
this.#settleWaiters.add(onState)
|
|
897
|
+
// The attempt may have settled between the guard above and the
|
|
898
|
+
// registration; that transition is already gone from the multicast, so
|
|
899
|
+
// it has to be observed by hand.
|
|
900
|
+
if (this.#state !== 'connecting') finish(this.#state)
|
|
901
|
+
})
|
|
902
|
+
}
|
|
903
|
+
|
|
786
904
|
/**
|
|
787
905
|
* Starts (or joins) the supervised lifecycle. Resolves once this cycle
|
|
788
906
|
* reaches `connected`; rejects with the cycle's failure. Transient
|
|
@@ -1026,17 +1144,18 @@ export class DeviceConnection {
|
|
|
1026
1144
|
// cannot tear down a newer link.
|
|
1027
1145
|
//
|
|
1028
1146
|
// ADJUDICATED WEAKENING: the close CODE is deliberately ignored here.
|
|
1029
|
-
// 1011 (server error
|
|
1030
|
-
// connection-count over limit) are
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1147
|
+
// 1011 (server error; rate termination through 2.1.6) and 1013 (try again
|
|
1148
|
+
// later, e.g. connection-count over limit) are session-level faces — the
|
|
1149
|
+
// peer ends the whole session, never a single request — so every code takes
|
|
1150
|
+
// the same offline → backoff → reconnect path. Differentiating them (e.g. a
|
|
1151
|
+
// longer, Retry-After-aware backoff for 1013) is deliberately DEFERRED: it
|
|
1152
|
+
// would need a per-code policy the protocol does not yet guarantee, and a
|
|
1153
|
+
// uniform honest retry is strictly safer than guessing a longer one. The
|
|
1154
|
+
// rate limiter still bounds the retry storm in the meantime, and the
|
|
1155
|
+
// contract suite's `rate-limit-terminates-session` item proves the uniform
|
|
1156
|
+
// transition shape: the 1011 close routes through offline → reconnect.
|
|
1157
|
+
// Since 2.1.7 an over-budget request is refused per request instead (a
|
|
1158
|
+
// structured `RATE_LIMITED`, not a close), so it never reaches this path.
|
|
1040
1159
|
ws.on('close', () => this.#onLinkLost(epoch, 'socket closed'))
|
|
1041
1160
|
ws.on('error', () => this.#onLinkLost(epoch, 'socket error'))
|
|
1042
1161
|
|
|
@@ -1418,6 +1537,8 @@ export class DeviceConnection {
|
|
|
1418
1537
|
try {
|
|
1419
1538
|
return await link.client.request<T>(method, params, opts)
|
|
1420
1539
|
} catch (error) {
|
|
1540
|
+
const refusal = rateLimitRefusal(error)
|
|
1541
|
+
if (refusal !== undefined) return await this.#replayAfterRateLimit(refusal, method, params, opts)
|
|
1421
1542
|
if (!this.#isTransportLoss(error)) throw error
|
|
1422
1543
|
// Reported with the state the request DIED in ('connected'): the
|
|
1423
1544
|
// supervisor has not necessarily observed the loss yet, and naming the
|
|
@@ -1507,6 +1628,70 @@ export class DeviceConnection {
|
|
|
1507
1628
|
}
|
|
1508
1629
|
}
|
|
1509
1630
|
|
|
1631
|
+
/**
|
|
1632
|
+
* ONE bounded replay for a READ the peer refused because the socket is over
|
|
1633
|
+
* its rate window.
|
|
1634
|
+
*
|
|
1635
|
+
* 2.1.7 answers that request with a structured `RATE_LIMITED` failure and a
|
|
1636
|
+
* `retryAfterMs` instead of closing the session (see `rateLimitRefusal`), so
|
|
1637
|
+
* the caller no longer learns it was throttled by losing its connection:
|
|
1638
|
+
* without this, a transient refusal became a failed tool call whose part text
|
|
1639
|
+
* the projection would even degrade.
|
|
1640
|
+
*
|
|
1641
|
+
* The shape mirrors the transport-loss replay, for the same reasons:
|
|
1642
|
+
*
|
|
1643
|
+
* - only a READ is replayed (a command's refusal is a verdict on that
|
|
1644
|
+
* arrival, and commands are never replayed — receipts recover them);
|
|
1645
|
+
* - the wait is charged to the caller's deadline and never extends it, so a
|
|
1646
|
+
* wait answer's budget turns a refusal near its bound into a `'deadline'`
|
|
1647
|
+
* stop instead of an overrun;
|
|
1648
|
+
* - the replay takes a FRESH pacing admission: it is a new arrival at the
|
|
1649
|
+
* peer, and skipping the admission would replay into the same full window.
|
|
1650
|
+
*
|
|
1651
|
+
* A SECOND refusal is a real failure and propagates as itself (the fresh
|
|
1652
|
+
* `retryAfterMs` is the more useful one); a link lost under the replay is
|
|
1653
|
+
* reported as the classified `TransportLostError`, never as bare text.
|
|
1654
|
+
*/
|
|
1655
|
+
async #replayAfterRateLimit<T>(
|
|
1656
|
+
refusal: JsonRpcRequestError,
|
|
1657
|
+
method: string,
|
|
1658
|
+
params: unknown,
|
|
1659
|
+
opts: JsonRpcRequestOptions | undefined,
|
|
1660
|
+
): Promise<T> {
|
|
1661
|
+
if (!RETRYABLE_READ_METHODS.has(method) || !this.#running) throw refusal
|
|
1662
|
+
const remaining = this.#remainingMs(opts)
|
|
1663
|
+
if (remaining !== undefined && remaining <= 0) throw new RpcDeadlineExceededError(this.record.deviceId, method)
|
|
1664
|
+
const requested = rateLimitWaitMs(refusal)
|
|
1665
|
+
const waitMs = Math.min(requested, remaining ?? Number.POSITIVE_INFINITY)
|
|
1666
|
+
// WHICH BOUND WAS SPENT is captured BEFORE the timer, never re-derived from
|
|
1667
|
+
// a clock read after it: a timer may fire a millisecond EARLY (the same race
|
|
1668
|
+
// `reconnectWait`/`deadlineCapped` exists to avoid, and the reason
|
|
1669
|
+
// `RequestTimeoutError.clampedByDeadline` is a flag). When the requested
|
|
1670
|
+
// wait consumed the caller's whole budget, the outcome is the CALLER's
|
|
1671
|
+
// deadline — throwing it here also keeps the one replay from being sent
|
|
1672
|
+
// into a budget that was already spent.
|
|
1673
|
+
const deadlineSpent = remaining !== undefined && requested >= remaining
|
|
1674
|
+
if (waitMs > 0) await new Promise<void>((resolve) => setTimeout(resolve, waitMs))
|
|
1675
|
+
// The wait is not cancellable by `close()`; the state is re-judged instead,
|
|
1676
|
+
// the same way every other parked wait in this class is.
|
|
1677
|
+
if (!this.#running || this.#state !== 'connected') throw refusal
|
|
1678
|
+
if (deadlineSpent || this.#deadlinePassed(opts)) {
|
|
1679
|
+
throw new RpcDeadlineExceededError(this.record.deviceId, method)
|
|
1680
|
+
}
|
|
1681
|
+
await this.#limiter.acquire(method)
|
|
1682
|
+
const link = this.#link
|
|
1683
|
+
if (!this.#running || this.#state !== 'connected' || link === undefined) throw refusal
|
|
1684
|
+
if (this.#deadlinePassed(opts)) throw new RpcDeadlineExceededError(this.record.deviceId, method)
|
|
1685
|
+
try {
|
|
1686
|
+
return await link.client.request<T>(method, params, opts)
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
if (this.#isTransportLoss(error)) {
|
|
1689
|
+
throw new TransportLostError(this.record.deviceId, 'connected', method, { cause: error })
|
|
1690
|
+
}
|
|
1691
|
+
throw error
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1510
1695
|
/** Whether a rejection is the dead channel, as opposed to a peer's own answer. */
|
|
1511
1696
|
#isTransportLoss(error: unknown): boolean {
|
|
1512
1697
|
// A peer refusal is a verdict (JsonRpcRequestError) and a deliberate local
|
|
@@ -1582,5 +1767,12 @@ export class DeviceConnection {
|
|
|
1582
1767
|
console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error)
|
|
1583
1768
|
}
|
|
1584
1769
|
}
|
|
1770
|
+
for (const cb of [...this.#settleWaiters]) {
|
|
1771
|
+
try {
|
|
1772
|
+
cb(next)
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
console.warn(`[cherry-remote] settle waiter threw for ${this.record.deviceId}`, error)
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1585
1777
|
}
|
|
1586
1778
|
}
|
package/src/defaults.ts
CHANGED
|
@@ -21,18 +21,21 @@
|
|
|
21
21
|
* This is the SHARED tier: it covers every business RPC EXCEPT the content-read
|
|
22
22
|
* tier below (see {@link DEFAULT_CONTENT_READ_BUDGET_PER_SECOND}).
|
|
23
23
|
*
|
|
24
|
-
* WHY 32: the desktop's
|
|
25
|
-
* (`RemoteAccessService.ts
|
|
26
|
-
* `DesktopSession.ts:
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
24
|
+
* WHY 32: the desktop's allowance for "other" RPCs is 64/s through 2.1.6 and
|
|
25
|
+
* 256/s from 2.1.7 (`RemoteAccessService.ts`; the official client paces them at
|
|
26
|
+
* 40/s, `DesktopSession.ts:36-37`). What an over-budget request does also
|
|
27
|
+
* changed: through 2.1.6 it closes the WHOLE session (1011), while 2.1.7 (PR
|
|
28
|
+
* #21687) refuses that one request (`RATE_LIMITED` + `retryAfterMs`) and keeps
|
|
29
|
+
* the session. The generations cannot be told apart before one answers, so the
|
|
30
|
+
* default is sized for the older, harsher peer: 32 is half of 64 and an eighth
|
|
31
|
+
* of 256, leaving room for the housekeeping traffic that shares the socket
|
|
32
|
+
* (hello/authenticate/ping/refresh bypass pacing by design) and for arrival
|
|
33
|
+
* compression between our admission times and the peer's arrival times.
|
|
31
34
|
*
|
|
32
35
|
* SOURCES (neither is in this repository): the desktop lines are
|
|
33
|
-
* `_research/cherry-studio` @ v2.1.6 (90b234e)
|
|
34
|
-
* CherryHQ/cherry-studio-app @
|
|
35
|
-
*
|
|
36
|
+
* `_research/cherry-studio` @ v2.1.6 (90b234e) and CherryHQ/cherry-studio @
|
|
37
|
+
* v2.1.7; the official-client lines are CherryHQ/cherry-studio-app @ v0.4.0,
|
|
38
|
+
* both read on 2026-10-22 (issue #102).
|
|
36
39
|
*/
|
|
37
40
|
export const DEFAULT_RATE_BUDGET_PER_SECOND = 32
|
|
38
41
|
|
|
@@ -49,9 +52,10 @@ export const DEFAULT_RATE_BUDGET_PER_SECOND = 32
|
|
|
49
52
|
* (`RemoteAccessService.ts:223-225`) and the official client issues them at
|
|
50
53
|
* 480/s (`DesktopSession.ts:33-37`). Pacing them with the 32/s shared tier
|
|
51
54
|
* bound a multi-slice walk to ~670 KiB/s and queued it behind unrelated RPCs;
|
|
52
|
-
* 256 is half the
|
|
53
|
-
*
|
|
54
|
-
* client while leaving the desktop a
|
|
55
|
+
* 256 is half the 512/s content allowance in BOTH desktop generations (the
|
|
56
|
+
* shared-tier allowance changed in 2.1.7; this one did not) and lifts the bound
|
|
57
|
+
* to the multi-MiB/s range of the official client while leaving the desktop a
|
|
58
|
+
* session-sized headroom.
|
|
55
59
|
*
|
|
56
60
|
* NOT a plugin-config key: the two tiers derive from ONE injected shared budget
|
|
57
61
|
* at the ratio below, so an operator who lowers `rateBudgetPerSecond` lowers
|
|
@@ -60,12 +64,18 @@ export const DEFAULT_RATE_BUDGET_PER_SECOND = 32
|
|
|
60
64
|
*
|
|
61
65
|
* THE MARGINS ARE A DEFAULT-INJECTION CLAIM: the 2× headroom above holds at
|
|
62
66
|
* the default injection (32/s), where this tier is exactly 256/s. An injected
|
|
63
|
-
* budget consumes BOTH tiers' headroom
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
67
|
+
* budget consumes BOTH tiers' headroom up to the point where this tier reaches
|
|
68
|
+
* the PEER'S CEILING — at 64/s the shared tier sits on the ≤2.1.6 line (the
|
|
69
|
+
* harsher generation, where overrun still closes the session) and this tier
|
|
70
|
+
* derives to exactly 512/s, the content allowance BOTH generations share. Above
|
|
71
|
+
* 64/s the clamp holds it at 512/s: `contentBudgetFor` (issue #111) still
|
|
72
|
+
* derives the ratio, then bounds it — because a knob copied from 2.1.7's shared
|
|
73
|
+
* figure (256)
|
|
74
|
+
* would otherwise derive 2,048/s and spend the tier being refused. The
|
|
75
|
+
* limiter's integer gap means the ACHIEVED rate stays below the nominal one in
|
|
76
|
+
* that case (≤ 334/s on this tier, 55.6/s on the shared one; see
|
|
77
|
+
* `SecondWindowRateLimiter` in `connection.ts`), which is margin by accident
|
|
78
|
+
* rather than by design.
|
|
69
79
|
*/
|
|
70
80
|
export const DEFAULT_CONTENT_READ_BUDGET_PER_SECOND = 256
|
|
71
81
|
|
|
@@ -81,6 +91,38 @@ export const DEFAULT_CONTENT_READ_BUDGET_PER_SECOND = 256
|
|
|
81
91
|
*/
|
|
82
92
|
export const DEFAULT_TRANSPORT_RETRY_WAIT_MS = 5_000
|
|
83
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The longest a READ may wait for the peer's rate window to clear before its
|
|
96
|
+
* ONE bounded replay (`RATE_LIMITED` + `retryAfterMs`, desktop >= 2.1.7).
|
|
97
|
+
*
|
|
98
|
+
* Why the peer's own window and not a separate budget: the desktop derives
|
|
99
|
+
* `retryAfterMs` from its 1 s accounting window, so a larger value cannot come
|
|
100
|
+
* from the peer, and waiting longer would hold a tool call for no reason. The
|
|
101
|
+
* caller's own deadline (`JsonRpcRequestOptions.deadline`) still clamps it, and
|
|
102
|
+
* a wait answer's `AssemblyDeadline` is what makes a refusal near the budget a
|
|
103
|
+
* `'deadline'` stop rather than an overrun. A COMMAND never takes this wait: its
|
|
104
|
+
* refusal is a verdict on that arrival, and commands are never replayed.
|
|
105
|
+
*/
|
|
106
|
+
export const RATE_LIMIT_MAX_WAIT_MS = 1_000
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* How long a DEVICE TOOL waits for an establishment attempt that is already in
|
|
110
|
+
* flight (`state === 'connecting'`) before judging the device.
|
|
111
|
+
*
|
|
112
|
+
* WHY THIS EXISTS: the connection pool builds a `DeviceConnection` on FIRST
|
|
113
|
+
* TOUCH and that constructor dials immediately, so every tool called in the
|
|
114
|
+
* same tick as the first touch — and every tool called during a reconnect —
|
|
115
|
+
* observed `connecting` and answered `device-unavailable` about a connection
|
|
116
|
+
* that comes up a moment later. The state is a transient, not a verdict.
|
|
117
|
+
*
|
|
118
|
+
* It is deliberately NOT a reconnect budget: an `offline` device is parked in
|
|
119
|
+
* its backoff, where waiting cannot change anything, so the tools answer at
|
|
120
|
+
* once. Five seconds is ~15x a LAN dial plus handshake; a bound longer than
|
|
121
|
+
* that only delays the unavoidable answer, and the tool call is the caller's
|
|
122
|
+
* unit of patience.
|
|
123
|
+
*/
|
|
124
|
+
export const DEVICE_SETTLE_WAIT_MS = 5_000
|
|
125
|
+
|
|
84
126
|
/** 2 MiB of event text cached per session (spec §3.5). */
|
|
85
127
|
export const DEFAULT_PER_SESSION_CACHE_BYTES = 2 * 1024 * 1024
|
|
86
128
|
|
package/src/diagnostics.ts
CHANGED
|
@@ -97,19 +97,26 @@ export const CONTRACT_DIFFERENCES: readonly ContractDifference[] = [
|
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
id: 'rate-limit-terminates-session',
|
|
100
|
-
title: 'Over-limit rate limiting
|
|
100
|
+
title: 'Over-limit rate limiting is generation-dependent: a whole-session 1011 close (≤2.1.6) or a per-request refusal (≥2.1.7)',
|
|
101
101
|
realPeer:
|
|
102
|
-
'
|
|
103
|
-
'
|
|
104
|
-
'
|
|
102
|
+
'Through 2.1.6, exceeding the desktop\'s per-socket budget (64 req/s shared, 512 req/s content; ' +
|
|
103
|
+
'implementation values with no negotiation channel) terminated the ENTIRE session with close ' +
|
|
104
|
+
'code 1011. From 2.1.7 (PR #21687) the shared allowance is 256 req/s and an over-budget request ' +
|
|
105
|
+
'is answered with ONE structured `RATE_LIMITED` failure carrying `retryAfterMs`, while the ' +
|
|
106
|
+
'session survives.',
|
|
105
107
|
fakePeer:
|
|
106
|
-
'The fake
|
|
107
|
-
'
|
|
108
|
-
|
|
108
|
+
'The fake models BOTH generations: `rateLimitMode` `close` (the ≤2.1.6 default) closes the ' +
|
|
109
|
+
'session with 1011 once `rateLimitPerSecond` is exceeded, exercising the ' +
|
|
110
|
+
'offline→backoff→reconnect path; `refuse` answers one structured `RATE_LIMITED` failure with ' +
|
|
111
|
+
'`retryAfterMs` and keeps the session, exercising the bounded replay.',
|
|
112
|
+
schemaAnchor:
|
|
113
|
+
'remoteLimits + peer close code 1011 (≤2.1.6) / failure data reason RATE_LIMITED + retryAfterMs (≥2.1.7); implementation values, not protocol promises',
|
|
109
114
|
checklistStep:
|
|
110
|
-
'rate-limit-terminates-session:
|
|
111
|
-
'
|
|
112
|
-
'
|
|
115
|
+
'rate-limit-terminates-session: on a ≥2.1.7 desktop, raise `rateBudgetPerSecond` (or use an ' +
|
|
116
|
+
'unpaced probe) and drive a burst above the allowance; confirm each over-budget request is ' +
|
|
117
|
+
'refused with `RATE_LIMITED` + `retryAfterMs` while the session stays live, a refused READ ' +
|
|
118
|
+
'succeeds after ONE replay, and a refused COMMAND surfaces as a verdict and is not replayed. ' +
|
|
119
|
+
'On ≤2.1.6, the same burst closes the socket with 1011 (state offline, backoff restart).',
|
|
113
120
|
},
|
|
114
121
|
{
|
|
115
122
|
id: 'execution-terminal-no-interrupted',
|
package/src/index.ts
CHANGED
|
@@ -83,9 +83,13 @@ export interface CherryBridgeConfig {
|
|
|
83
83
|
* The CONTENT tier is not configurable on its own: it DERIVES from this value
|
|
84
84
|
* at the 8 : 1 production ratio (`DEFAULT_CONTENT_READ_BUDGET_PER_SECOND` /
|
|
85
85
|
* `DEFAULT_RATE_BUDGET_PER_SECOND`), so the default 32 yields the 256/s
|
|
86
|
-
* content tier and
|
|
87
|
-
*
|
|
88
|
-
*
|
|
86
|
+
* content tier and lowering this lowers both. UPWARD the derivation stops at
|
|
87
|
+
* the peer's 512/s content allowance (issue #111): a value above 64 no longer
|
|
88
|
+
* scales that tier, it only spends the shared tier's margin. Raising this
|
|
89
|
+
* toward the peer's own lines spends margin: on a <=2.1.6 desktop that is
|
|
90
|
+
* BOTH tiers (64 shared / 512 content), while on 2.1.7 only the shared line
|
|
91
|
+
* moves (256 there). See `DEFAULT_CONTENT_READ_BUDGET_PER_SECOND` for the
|
|
92
|
+
* numbers.
|
|
89
93
|
*/
|
|
90
94
|
rateBudgetPerSecond?: number
|
|
91
95
|
/**
|