dsh-cherry-bridge 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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). A hit is still not a zero-RPC read: 2.1.6+ externalizes
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`. The settings
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 empties its view and 404s first,
789
- so this arm only covers a store that refuses after that read — unreachable for the
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
@@ -832,10 +830,11 @@ the error message).
832
830
  - **May be emitted** (`sanitizeDevice`): `deviceId`, `displayName`, `state`,
833
831
  `enabled`, `hosts`, `port`, a **12-char digest** of `desktopIdentity`, the
834
832
  hello-negotiated `limits` (`heartbeatMs`/`subscriptions`/`pageItems`/
835
- `inFlightRequests` — **only with a live connection**; the endpoint never
836
- dials and never wakes a device to fetch fallbacks), `messageSummaries` (the
837
- hello capability bit, absent when not advertised), `sessionId` and session
838
- cursors (unsubscribed sessions have none), the `stats` counters
833
+ `inFlightRequests` — **only with a live connection**; it never dials or wakes
834
+ a device for fallbacks), `messageSummaries` (the
835
+ hello capability bit, absent when not advertised), `sessionId`, session
836
+ cursors (none when unsubscribed) and their ledger (`deliveredBytes`/
837
+ `unackedBytes`/`acks`, #217), the `stats` counters
839
838
  (`connections`/`sockets`/`connectionsBuilt`/`subscriptions`), the two resolved
840
839
  `config` keys, and error `reason`/`message`.
841
840
  - **Never emitted**: `accessToken`, `tokenExpiresAt`, `invitationSecret`, the
@@ -844,9 +843,8 @@ the error message).
844
843
  construction**, not by a deny-list. `limits` is re-projected from its four
845
844
  keys: a whitelist inside the whitelist.
846
845
  - `stats.connectionsBuilt` counts connections built since the last `apply`, not
847
- RPC requests — those belong to the transport; a same-named metric would be
848
- misread. Counters and the error ring are zeroed on every `apply`: the
849
- statistics describe **only** the current assembly.
846
+ RPC requests (those belong to the transport). Counters and the error ring are
847
+ zeroed on every `apply`: they describe **only** the current assembly.
850
848
 
851
849
  ### Logging: levels, identifiers, and what never appears
852
850
 
@@ -944,6 +942,4 @@ faces:
944
942
  answer is the byte-safe page **clamp**: never ask for a page that cannot fit.
945
943
  - Its read path refuses an unusable `byteLength`; we fall back to a slice bound
946
944
  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
945
 
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.
@@ -885,6 +896,29 @@ export class DeviceConnection {
885
896
  #pendingPings = new Set<{ nonce: string; sentAt: number }>()
886
897
  #token: string | undefined
887
898
  #tokenExpiresAt: number | undefined
899
+ /**
900
+ * Hosts whose LAST attempt failed for that ADDRESS's own reason (issue #220).
901
+ * A pass dials the hosts it has not failed yet FIRST, so a stale candidate
902
+ * sitting first in `record.hosts` cannot cost its dial timeout on every
903
+ * reconnect — the rule the official client states as "failed candidates
904
+ * rotate behind untried addresses across retries, so the round budget cannot
905
+ * permanently starve a later route"
906
+ * (`.scratch-csa/docs/references/remote-access/connectivity.md` §Connection
907
+ * lifetime; the tree is gitignored, and its remote-agent directory is the
908
+ * same `16e505fe` at app tags v0.5.2 and v0.5.3, so this is not a rule that
909
+ * arrived with a newer app).
910
+ *
911
+ * Demotion is ORDER, never exclusion: with every host marked the list falls
912
+ * back to record order, so a record whose only address is marked still dials
913
+ * it. A mark is cleared when that host itself connects, so a repaired address
914
+ * returns to the front on its own — no expiry timer, no persistence, no
915
+ * config key. A pairing-level verdict and a cancellation never mark (they are
916
+ * not this address's fault: the line `isPerHostFailure` draws for the
917
+ * handshake face, mirrored explicitly in the dial face, where only
918
+ * `IdentityMismatchError` can arise today), and `close()` keeps the marks
919
+ * because they describe addresses, not this lifecycle.
920
+ */
921
+ readonly #demotedHosts = new Set<string>()
888
922
 
889
923
  readonly #wsOptions: WsDialOptions | undefined
890
924
 
@@ -1244,7 +1278,7 @@ export class DeviceConnection {
1244
1278
  }
1245
1279
 
1246
1280
  let lastError: Error = new Error(`device ${this.record.deviceId}: all candidate hosts unreachable`)
1247
- for (const host of this.record.hosts) {
1281
+ for (const host of this.#dialOrder()) {
1248
1282
  // CANCELLATION GATE, at the top of every candidate. `close()` bumps the
1249
1283
  // epoch and clears `#running`, so either check is enough — both are here
1250
1284
  // because they answer different halves of the same question ("is this
@@ -1268,7 +1302,15 @@ export class DeviceConnection {
1268
1302
  link = await this.#dial(host, port, epoch)
1269
1303
  } catch (error) {
1270
1304
  if (error instanceof IdentityMismatchError) throw error
1305
+ // A cancellation is not this ADDRESS's fault, so it neither marks it
1306
+ // nor continues the pass. UNREACHABLE today — `#dial`'s AbortController
1307
+ // is aborted only through `#teardownLink`, which needs `#link`, and
1308
+ // `#link` is not committed until the dial returns — so this is a
1309
+ // DEFENSIVE arm, not a pinned one: it keeps the rule true if the dial
1310
+ // ever grows a close path, at the cost of two lines and no behaviour.
1311
+ if (error instanceof CancelledError) throw error
1271
1312
  lastError = error instanceof Error ? error : new Error(String(error))
1313
+ this.#demotedHosts.add(host)
1272
1314
  continue
1273
1315
  }
1274
1316
  // Commit the link before the RPC handshake so every failure path below
@@ -1277,6 +1319,8 @@ export class DeviceConnection {
1277
1319
  this.#link = link
1278
1320
  try {
1279
1321
  await this.#handshake(link, epoch)
1322
+ // This address works: any mark an earlier pass left on it is stale.
1323
+ this.#demotedHosts.delete(host)
1280
1324
  return
1281
1325
  } catch (error) {
1282
1326
  // A close() that landed mid-handshake surfaces here as the failure of
@@ -1288,10 +1332,11 @@ export class DeviceConnection {
1288
1332
  // The terminal faces end the pass: they describe the PAIRING, not this
1289
1333
  // address, so trying the next candidate would only repeat them.
1290
1334
  if (!isPerHostFailure(error)) throw error
1291
- // A channel-level failure (socket dropped mid-handshake) is this
1292
- // ADDRESS's problem: tear the link down here, since the loop's next
1293
- // iteration would otherwise overwrite `#link` and leak this socket, and
1294
- // fall through to the next candidate.
1335
+ // A peer that answers hello/authenticate with a per-address failure is
1336
+ // this ADDRESS's problem too: demote it for the NEXT pass, tear the
1337
+ // link down here (the loop's next iteration would otherwise overwrite
1338
+ // `#link` and leak this socket), and fall through to the next candidate.
1339
+ this.#demotedHosts.add(host)
1295
1340
  this.#teardownLink()
1296
1341
  lastError = error instanceof Error ? error : new Error(String(error))
1297
1342
  continue
@@ -1300,6 +1345,27 @@ export class DeviceConnection {
1300
1345
  throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError)
1301
1346
  }
1302
1347
 
1348
+ /**
1349
+ * The pass's dial order (issue #220): hosts this connection has NOT failed
1350
+ * yet, in record order, then the demoted ones, in record order.
1351
+ *
1352
+ * Record order is preserved INSIDE each half, so an ordinary single-address
1353
+ * record, a fresh record and an all-demoted record all dial exactly the list
1354
+ * the record carries — the demotion can only move an address that failed
1355
+ * behind addresses that did not, and can never drop one.
1356
+ */
1357
+ #dialOrder(): readonly string[] {
1358
+ const hosts = this.record.hosts
1359
+ if (this.#demotedHosts.size === 0) return hosts
1360
+ const untried: string[] = []
1361
+ const demoted: string[] = []
1362
+ for (const host of hosts) {
1363
+ if (this.#demotedHosts.has(host)) demoted.push(host)
1364
+ else untried.push(host)
1365
+ }
1366
+ return [...untried, ...demoted]
1367
+ }
1368
+
1303
1369
  async #dial(host: string, port: number, epoch: number): Promise<LiveLink> {
1304
1370
  // (see {@link DeviceConnectionOptions.wsOptions} for TLS trust)
1305
1371
  // The URL comes from the SAME helper the pairing dial uses (issue #126):
@@ -1402,11 +1468,13 @@ export class DeviceConnection {
1402
1468
  // reconnect to a peer that no longer advertises the capability (or a
1403
1469
  // downgrade) cannot inherit a stale `messageSummaries` from the previous
1404
1470
  // link — the exact failure mode that would send `partsMode` to a 2.1.7 peer.
1471
+ const unackedBytes = positive(hello.limits?.['unackedBytes'])
1405
1472
  this.#limits = {
1406
1473
  heartbeatMs: positive(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
1407
1474
  subscriptions: positive(hello.limits?.['subscriptions']) ?? FALLBACK_LIMITS.subscriptions,
1408
1475
  pageItems: positive(hello.limits?.['pageItems']) ?? FALLBACK_LIMITS.pageItems,
1409
1476
  inFlightRequests: positive(hello.limits?.['inFlightRequests']) ?? FALLBACK_LIMITS.inFlightRequests,
1477
+ ...(unackedBytes === undefined ? {} : { unackedBytes }),
1410
1478
  ...(typeof hello.agentMessageSummariesVersion === 'number'
1411
1479
  ? { messageSummaries: hello.agentMessageSummariesVersion }
1412
1480
  : {}),
@@ -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 documented as the four numeric caps. Present only
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
@@ -216,6 +216,16 @@ export interface DiagnosticsSession {
216
216
  deviceId: string
217
217
  subscriptions: number
218
218
  cursor?: { sessionId: string; streamEpoch: string; seq: string }
219
+ /**
220
+ * Issue #217: the layer's own delivery ledger for THIS subscription — payload
221
+ * bytes received since it was installed, how many of them the peer may still
222
+ * count as un-acknowledged, and successful acks. Absent when the layer does
223
+ * not report it (an unsubscribed session has no ledger); counts describe the
224
+ * current subscription, so a re-subscribe restarts them.
225
+ */
226
+ deliveredBytes?: number
227
+ unackedBytes?: number
228
+ acks?: number
219
229
  }
220
230
 
221
231
  /** One recent error summary (spec §5: 「最近错误摘要」). */
@@ -324,6 +334,10 @@ export interface SubscriptionStat {
324
334
  sessionId: string
325
335
  subscriptions: number
326
336
  cursor?: { sessionId: string; streamEpoch: string; seq: string }
337
+ /** See {@link DiagnosticsSession.deliveredBytes} (issue #217). */
338
+ deliveredBytes?: number
339
+ unackedBytes?: number
340
+ acks?: number
327
341
  }
328
342
 
329
343
  /**
@@ -555,10 +569,11 @@ function projectMessageSummaries(raw: unknown): { messageSummaries?: number } {
555
569
  }
556
570
 
557
571
  /**
558
- * Re-projects the four numeric limit keys, dropping any foreign key that a
559
- * caller's `limits` object might carry. A whitelist inside the whitelist: the
560
- * shape is rebuilt field by field rather than spread, so a future addition to
561
- * `EffectiveLimits` cannot arrive here unvetted.
572
+ * Re-projects the four numeric limit keys this report carries, dropping any
573
+ * foreign key that a caller's `limits` object might carry — including
574
+ * `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
575
+ * A whitelist inside the whitelist: the shape is rebuilt field by field rather
576
+ * than spread, so a future addition cannot arrive here unvetted.
562
577
  */
563
578
  function projectLimits(raw: unknown): { limits?: EffectiveLimits } {
564
579
  if (raw === null || typeof raw !== 'object') return {}
@@ -576,6 +591,17 @@ function numberOr(value: unknown, fallback: number): number {
576
591
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback
577
592
  }
578
593
 
594
+ /**
595
+ * A delivery-ledger counter (issue #217): a NON-NEGATIVE finite number, or
596
+ * nothing at all. Absence means "not reported"; a fabricated zero would read as
597
+ * "nothing was delivered", which is a claim this projection has no evidence for.
598
+ */
599
+ function countEntry(key: 'deliveredBytes' | 'unackedBytes' | 'acks', raw: unknown): Partial<DiagnosticsSession> {
600
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0
601
+ ? ({ [key]: raw } as Partial<DiagnosticsSession>)
602
+ : {}
603
+ }
604
+
579
605
  /** The loose-bag view every projection in this module takes of its input. */
580
606
  function bag(raw: unknown): Record<string, unknown> {
581
607
  return (raw ?? {}) as Record<string, unknown>
@@ -727,6 +753,9 @@ export function sanitizeDiagnostics(source: {
727
753
  seq: typeof cursor['seq'] === 'string' ? cursor['seq'] : '',
728
754
  },
729
755
  }),
756
+ ...countEntry('deliveredBytes', value['deliveredBytes']),
757
+ ...countEntry('unackedBytes', value['unackedBytes']),
758
+ ...countEntry('acks', value['acks']),
730
759
  }
731
760
  }),
732
761
  recentErrors: (source.recentErrors ?? []).map((raw): DiagnosticsError => {
@@ -807,6 +836,9 @@ export function buildDiagnostics(
807
836
  deviceId: entry.deviceId,
808
837
  subscriptions: entry.subscriptions,
809
838
  ...(entry.cursor === undefined ? {} : { cursor: { ...entry.cursor } }),
839
+ ...(entry.deliveredBytes === undefined ? {} : { deliveredBytes: entry.deliveredBytes }),
840
+ ...(entry.unackedBytes === undefined ? {} : { unackedBytes: entry.unackedBytes }),
841
+ ...(entry.acks === undefined ? {} : { acks: entry.acks }),
810
842
  })
811
843
  }
812
844
  } catch {
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
@@ -873,7 +878,16 @@ function assemblyDeps(): CherryHttpDeps & CherryToolsDeps {
873
878
  [...state.subscriptions.entries()].flatMap(([deviceId, layer]) =>
874
879
  layer.activeSessions().map((sessionId) => {
875
880
  const cursor = layer.cursorOf(sessionId)
876
- return { deviceId, sessionId, subscriptions: 1, ...(cursor === undefined ? {} : { cursor }) }
881
+ // The layer's own delivery ledger (issue #217), beside the cursor it
882
+ // is measured against: both come from ONE session state read.
883
+ const delivery = layer.deliveryOf(sessionId)
884
+ return {
885
+ deviceId,
886
+ sessionId,
887
+ subscriptions: 1,
888
+ ...(cursor === undefined ? {} : { cursor }),
889
+ ...(delivery === undefined ? {} : delivery),
890
+ }
877
891
  }),
878
892
  ),
879
893
  diagnosticsErrors: () => [...state.recentErrors],