dsh-cherry-bridge 0.1.9 → 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/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 } {
@@ -1080,17 +1144,18 @@ export class DeviceConnection {
1080
1144
  // cannot tear down a newer link.
1081
1145
  //
1082
1146
  // ADJUDICATED WEAKENING: the close CODE is deliberately ignored here.
1083
- // 1011 (server error / rate termination) and 1013 (try again later, e.g.
1084
- // connection-count over limit) are the peer's AUTHORITATIVE session-level
1085
- // faces — it ends the whole session, never a single request — so every code
1086
- // takes the same offline → backoff → reconnect path. Differentiating them
1087
- // (e.g. a longer, Retry-After-aware backoff for 1013) is deliberately
1088
- // DEFERRED: it would need a per-code policy the protocol does not yet
1089
- // guarantee, and a uniform honest retry is strictly safer than guessing a
1090
- // longer one. The rate limiter still bounds the retry storm in the
1091
- // meantime, and the contract suite's `rate-limit-terminates-session` item
1092
- // proves the uniform transition shape: the 1011 close routes through
1093
- // 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.
1094
1159
  ws.on('close', () => this.#onLinkLost(epoch, 'socket closed'))
1095
1160
  ws.on('error', () => this.#onLinkLost(epoch, 'socket error'))
1096
1161
 
@@ -1472,6 +1537,8 @@ export class DeviceConnection {
1472
1537
  try {
1473
1538
  return await link.client.request<T>(method, params, opts)
1474
1539
  } catch (error) {
1540
+ const refusal = rateLimitRefusal(error)
1541
+ if (refusal !== undefined) return await this.#replayAfterRateLimit(refusal, method, params, opts)
1475
1542
  if (!this.#isTransportLoss(error)) throw error
1476
1543
  // Reported with the state the request DIED in ('connected'): the
1477
1544
  // supervisor has not necessarily observed the loss yet, and naming the
@@ -1561,6 +1628,70 @@ export class DeviceConnection {
1561
1628
  }
1562
1629
  }
1563
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
+
1564
1695
  /** Whether a rejection is the dead channel, as opposed to a peer's own answer. */
1565
1696
  #isTransportLoss(error: unknown): boolean {
1566
1697
  // A peer refusal is a verdict (JsonRpcRequestError) and a deliberate local
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,20 @@ 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
+
84
108
  /**
85
109
  * How long a DEVICE TOOL waits for an establishment attempt that is already in
86
110
  * flight (`state === 'connecting'`) before judging the device.
@@ -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
  /**