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.
@@ -55,17 +55,17 @@
55
55
 
56
56
  ---
57
57
 
58
- ### 3. `rate-limit-terminates-session` — Over-limit rate limiting terminates the whole session (close 1011)
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
- **真实对端行为**:超过桌面每套接字预算(当前实现值 64 req/s,无协商通道)会以 close
61
- code **1011 终止整条会话**——不是可原地重试的**单请求** `RATE_LIMITED` 拒绝。
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: against a real desktop, drive a burst well above the smooth budget and confirm the socket is closed with 1011 (session gone, state offline, backoff restart) rather than a single structured per-request rejection.
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
- **期望**:套接字被 1011 关闭 → 设备状态转 `offline` → 进入退避重启;
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 Studio 互联**」→「**启用范围**」。`curl -s
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 Studio 互联",
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 Studio Bridge",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Cherry Studio Bridge",
3
+ "title": "Cherry Bridge",
4
4
  "description": "DeepSeek Harness plugin bridging to Cherry Studio desktop instances over remote transport"
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Cherry Studio 互联",
3
+ "title": "Cherry 互联",
4
4
  "description": "通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端的插件"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",
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 one injected budget scales both tiers and the two stay in the same
56
- * proportion the peer's allowances have.
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, 64/s for everything else) and the official client follows
342
- * suit (480/s and 40/s). Sharing one 32/s tier spent content-read throughput on
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 256/s tier to methods whose peer-side
354
- * allowance is the 64/s one, which is a session-killing mistake (close 1011),
355
- * so a new content-plane method must be named here deliberately.
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
- return Math.ceil((sharedBudgetPerSecond ?? DEFAULT_RATE_BUDGET_PER_SECOND) * ratio)
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 / rate termination) and 1013 (try again later, e.g.
1030
- // connection-count over limit) are the peer's AUTHORITATIVE session-level
1031
- // faces — it ends the whole session, never a single request — so every code
1032
- // takes the same offline → backoff → reconnect path. Differentiating them
1033
- // (e.g. a longer, Retry-After-aware backoff for 1013) is deliberately
1034
- // DEFERRED: it would need a per-code policy the protocol does not yet
1035
- // guarantee, and a uniform honest retry is strictly safer than guessing a
1036
- // longer one. The rate limiter still bounds the retry storm in the
1037
- // meantime, and the contract suite's `rate-limit-terminates-session` item
1038
- // proves the uniform transition shape: the 1011 close routes through
1039
- // offline → reconnect rather than surfacing a per-request refusal.
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 own allowance for "other" RPCs is 64/s
25
- * (`RemoteAccessService.ts:223-225`; the official client paces them at 40/s,
26
- * `DesktopSession.ts:242-244`), and exceeding the allowance is not a refusal
27
- * but a whole-session close (1011). Half the peer's line leaves room for the
28
- * housekeeping traffic that shares the socket (hello/authenticate/ping/refresh
29
- * bypass pacing by design) and for arrival compression between our admission
30
- * times and the peer's arrival times.
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); the official-client lines are
34
- * CherryHQ/cherry-studio-app @ main 18329fe (app 0.3.0), read on 2026-10-18 —
35
- * recorded in `.superpowers/review-v013-r1/context.md`.
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 peer's own kill line — the same 2× margin the shared tier
53
- * keeps on 64/s — and lifts the bound to the multi-MiB/s range of the official
54
- * client while leaving the desktop a session-sized headroom.
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 in proportion — at 64/s the shared tier
64
- * is at the peer's 64/s line and this tier derives to 512/s, i.e. nominal
65
- * saturation of both allowances. The limiter's integer gap means the ACHIEVED
66
- * rate stays below the nominal one in that case (≤ 334/s on this tier, 55.6/s
67
- * on the shared one; see `SecondWindowRateLimiter` in `connection.ts`), which
68
- * is margin by accident rather than by design.
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
 
@@ -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 terminates the whole session (close 1011)',
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
- 'Exceeding the desktop\'s per-socket budget (64 req/s in the current implementation, a server ' +
103
- 'value with no negotiation channel) terminates the ENTIRE session with close code 1011 — it is ' +
104
- 'not a per-request `RATE_LIMITED` rejection the caller can retry in place.',
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 closes the session with 1011 once `rateLimitPerSecond` is exceeded, so the client\'s ' +
107
- 'offline→backoff→reconnect path is exercised rather than a request-level refusal.',
108
- schemaAnchor: 'remoteLimits + peer close code 1011 (implementation value, not a protocol promise)',
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: against a real desktop, drive a burst well above the smooth ' +
111
- 'budget and confirm the socket is closed with 1011 (session gone, state offline, backoff ' +
112
- 'restart) rather than a single structured per-request rejection.',
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 any other value scales both tiers together. Raising this
87
- * toward the peer's own lines therefore spends BOTH tiers' safety margin —
88
- * see `DEFAULT_CONTENT_READ_BUDGET_PER_SECOND` for the numbers.
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
  /**