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/README.en.md +3 -3
- package/README.md +3 -3
- package/dist/index.mjs +253 -20
- package/docs/e2e-checklist.md +46 -0
- package/package.json +1 -1
- package/src/AGENTS.md +12 -16
- package/src/connection.ts +73 -5
- package/src/diagnostics.ts +37 -5
- package/src/index.ts +15 -1
- package/src/subscriptions.ts +280 -8
- package/src/tools.ts +10 -5
package/src/subscriptions.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { JsonRpcRequestError } from './rpc/jsonRpcClient.js'
|
|
|
2
2
|
import type { JsonRpcClient } from './rpc/jsonRpcClient.js'
|
|
3
3
|
import { DEFAULT_PER_SESSION_CACHE_BYTES } from './defaults.js'
|
|
4
4
|
import { failureText } from './text.js'
|
|
5
|
+
import { remoteLimits } from '@cherrystudio/remote-protocol'
|
|
5
6
|
import type { AgentCursor, AgentEvent } from '@cherrystudio/remote-protocol/agent'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -40,6 +41,27 @@ const DEFAULT_CHECKPOINT_MS = 300_000
|
|
|
40
41
|
*/
|
|
41
42
|
const MAX_RECOVERY_ATTEMPTS = 4
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
|
|
46
|
+
* bytes it has sent since our last `agent.subscriptions.ack` stay inside
|
|
47
|
+
* `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
|
|
48
|
+
* the subscription still live — and a held batch can be a run's terminal.
|
|
49
|
+
*
|
|
50
|
+
* This layer used to ack ONLY from `cherry_events`, so a waiter (which by
|
|
51
|
+
* design consumes without acking) parked on a buffer that stopped advancing
|
|
52
|
+
* just under one window: a real 240 s wait answered `timeout` with the finished
|
|
53
|
+
* text, its cursor frozen at 666 events ≈ 262144 bytes.
|
|
54
|
+
*
|
|
55
|
+
* The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
|
|
56
|
+
* memory; acking does not lose them) once a quarter of the peer's window has
|
|
57
|
+
* accumulated. A retry cooldown keeps a refusing peer from turning every batch
|
|
58
|
+
* into a round trip.
|
|
59
|
+
*/
|
|
60
|
+
/** First retry delay after a refused auto-ack; doubles per attempt. */
|
|
61
|
+
const AUTO_ACK_RETRY_MS = 3_000
|
|
62
|
+
/** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
|
|
63
|
+
const AUTO_ACK_RETRY_MAX_MS = 30_000
|
|
64
|
+
|
|
43
65
|
export type ReadResult = { events: AgentEvent[]; nextCursor: AgentCursor } | { error: 'cursor-out-of-window' }
|
|
44
66
|
|
|
45
67
|
export interface SessionSubscriptionsOptions {
|
|
@@ -60,6 +82,20 @@ export interface SessionSubscriptionsOptions {
|
|
|
60
82
|
* default 300_000). Only bounds recovery retries; the peer is authoritative.
|
|
61
83
|
*/
|
|
62
84
|
checkpointMs?: number
|
|
85
|
+
/**
|
|
86
|
+
* The peer's hello-advertised un-acked delivery window in bytes (protocol
|
|
87
|
+
* `remoteLimits.unackedBytes`, 262144 in the pinned package). Past that much
|
|
88
|
+
* delivered-but-unacknowledged traffic the desktop HOLDS its event stream
|
|
89
|
+
* (issue #212), so the layer acks its buffer at a quarter of this value.
|
|
90
|
+
*
|
|
91
|
+
* Omitted falls back to the pinned protocol constant. A peer that does not
|
|
92
|
+
* advertise the key is treated as the pinned generation — the same
|
|
93
|
+
* conservative rule the other negotiated limits follow.
|
|
94
|
+
*
|
|
95
|
+
* A FUNCTION is read at every watermark check, so a layer that outlives a
|
|
96
|
+
* reconnect picks up the new hello's value instead of pinning the first one.
|
|
97
|
+
*/
|
|
98
|
+
unackedBytes?: number | (() => number | undefined)
|
|
63
99
|
}
|
|
64
100
|
|
|
65
101
|
/** Peer-side subscription phases (mirrors the real peer's internal state). */
|
|
@@ -108,6 +144,45 @@ interface SessionState {
|
|
|
108
144
|
cursor: AgentCursor
|
|
109
145
|
buffer: CachedEvent[]
|
|
110
146
|
bytes: number
|
|
147
|
+
/**
|
|
148
|
+
* Bytes appended since the last ack we sent (issue #212). Drives
|
|
149
|
+
* {@link SessionSubscriptions.#maybeAutoAck}; kept separate from `bytes`,
|
|
150
|
+
* which measures what the CACHE holds rather than what the peer may still
|
|
151
|
+
* consider un-acknowledged.
|
|
152
|
+
*/
|
|
153
|
+
bytesSinceAck: number
|
|
154
|
+
/**
|
|
155
|
+
* Event payload bytes received since this subscription was INSTALLED
|
|
156
|
+
* (issue #217). Monotone for the life of one subscription — unlike `bytes`
|
|
157
|
+
* (what the cache still holds) and `bytesSinceAck` (what the peer may still
|
|
158
|
+
* count) — so 「the stream exceeded the window」 becomes a measurement in
|
|
159
|
+
* diagnostics instead of an extrapolation from a frozen cursor.
|
|
160
|
+
*/
|
|
161
|
+
deliveredBytes: number
|
|
162
|
+
/**
|
|
163
|
+
* Successful `agent.subscriptions.ack` calls for this subscription (issue
|
|
164
|
+
* #217). NOT a progress metric: every accepted call counts, including a
|
|
165
|
+
* repeated explicit ack at the cursor the peer already holds.
|
|
166
|
+
*/
|
|
167
|
+
acks: number
|
|
168
|
+
/** One auto-ack in flight at a time; a second would only duplicate a cursor. */
|
|
169
|
+
ackInFlight: boolean
|
|
170
|
+
/** Cooldown after a refused auto-ack (`AUTO_ACK_RETRY_MS`, doubling). */
|
|
171
|
+
ackRetryAt: number
|
|
172
|
+
/**
|
|
173
|
+
* Current retry delay: `AUTO_ACK_RETRY_MS` after the first refusal, doubled
|
|
174
|
+
* on every further one up to `AUTO_ACK_RETRY_MAX_MS`. Reset on a success, so
|
|
175
|
+
* a peer that recovers is back to prompt retries.
|
|
176
|
+
*/
|
|
177
|
+
ackRetryDelay: number
|
|
178
|
+
/**
|
|
179
|
+
* The ARMED retry behind `ackRetryAt`. An ack failure is the one case where
|
|
180
|
+
* no further event will arrive to re-trigger the watermark check — the peer
|
|
181
|
+
* is holding at its window — so the retry cannot wait for traffic (issue
|
|
182
|
+
* #212's own failure mode, one ack later). Cleared by {@link #installReplay},
|
|
183
|
+
* {@link #installCheckpoint} and {@link SessionSubscriptions.dispose}.
|
|
184
|
+
*/
|
|
185
|
+
ackRetryTimer?: ReturnType<typeof setTimeout>
|
|
111
186
|
/**
|
|
112
187
|
* The oldest stream position this session still TRACKS: the baseline the
|
|
113
188
|
* subscription started from, advanced whenever old events are dropped. It is
|
|
@@ -189,6 +264,8 @@ export class SessionSubscriptions {
|
|
|
189
264
|
readonly #limit: number
|
|
190
265
|
readonly #perSessionCacheBytes: number
|
|
191
266
|
readonly #checkpointMs: number
|
|
267
|
+
/** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
|
|
268
|
+
readonly #unackedSource: number | (() => number | undefined) | undefined
|
|
192
269
|
readonly #sessions = new Map<string, SessionState>()
|
|
193
270
|
/**
|
|
194
271
|
* Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
|
|
@@ -211,6 +288,7 @@ export class SessionSubscriptions {
|
|
|
211
288
|
this.#limit = Math.max(1, Math.floor(positive(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)))
|
|
212
289
|
this.#perSessionCacheBytes = positive(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
|
|
213
290
|
this.#checkpointMs = positive(opts.checkpointMs, DEFAULT_CHECKPOINT_MS)
|
|
291
|
+
this.#unackedSource = opts.unackedBytes
|
|
214
292
|
}
|
|
215
293
|
|
|
216
294
|
/**
|
|
@@ -256,6 +334,31 @@ export class SessionSubscriptions {
|
|
|
256
334
|
return this.#sessions.get(sessionId)?.cursor
|
|
257
335
|
}
|
|
258
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The DELIVERY LEDGER of one subscription (issue #217): the payload bytes it
|
|
339
|
+
* has received since it was installed, how much of that the peer may still
|
|
340
|
+
* count as un-acknowledged, and how many acks it has taken. Undefined for a
|
|
341
|
+
* session this layer does not hold — never a fabricated zero.
|
|
342
|
+
*
|
|
343
|
+
* Diagnostics is the caller: these three numbers are what turns 「the stream
|
|
344
|
+
* exceeded the peer's window」 from an extrapolation into a measurement.
|
|
345
|
+
*
|
|
346
|
+
* A CLIENT-SIDE ledger, never the peer's: `unackedBytes` is this layer's
|
|
347
|
+
* model of what the peer may still count (bytes since its last accepted
|
|
348
|
+
* ack), not a reading of the peer's own window or ack position.
|
|
349
|
+
*/
|
|
350
|
+
deliveryOf(
|
|
351
|
+
sessionId: string,
|
|
352
|
+
): { deliveredBytes: number; unackedBytes: number; acks: number } | undefined {
|
|
353
|
+
const state = this.#sessions.get(sessionId)
|
|
354
|
+
if (state === undefined) return undefined
|
|
355
|
+
return {
|
|
356
|
+
deliveredBytes: state.deliveredBytes,
|
|
357
|
+
unackedBytes: state.bytesSinceAck,
|
|
358
|
+
acks: state.acks,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
259
362
|
/**
|
|
260
363
|
* The session's cache WINDOW START — the retention BOUNDARY of this layer's
|
|
261
364
|
* cache: the oldest position it still tracks — or undefined when the session
|
|
@@ -316,6 +419,7 @@ export class SessionSubscriptions {
|
|
|
316
419
|
// session that will not stay subscribed.
|
|
317
420
|
console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`)
|
|
318
421
|
await this.#closeSubscription(existing.subscriptionId)
|
|
422
|
+
this.#cancelAckRetry(existing)
|
|
319
423
|
this.#rejectWaiters(existing, new Error(`session ${sessionId} re-subscribed`))
|
|
320
424
|
this.#sessions.delete(sessionId)
|
|
321
425
|
}
|
|
@@ -424,17 +528,39 @@ export class SessionSubscriptions {
|
|
|
424
528
|
/**
|
|
425
529
|
* Advances the peer's view of this session: `agent.subscriptions.ack` with
|
|
426
530
|
* the delivered cursor. The peer only accepts a cursor inside the window it
|
|
427
|
-
* actually delivered (an out-of-window ack is its CONFLICT)
|
|
428
|
-
*
|
|
531
|
+
* actually delivered (an out-of-window ack is its CONFLICT).
|
|
532
|
+
*
|
|
533
|
+
* Two callers, one contract — "this position is accounted for, keep sending":
|
|
534
|
+
* `cherry_events` passes the cursor a CONSUMER has processed, and
|
|
535
|
+
* {@link #maybeAutoAck} passes the BUFFER frontier because the events are
|
|
536
|
+
* retained here (see that method for why an ack cannot lose replay).
|
|
429
537
|
*/
|
|
430
538
|
async ack(sessionId: string, cursor?: AgentCursor): Promise<void> {
|
|
431
539
|
const state = this.#sessions.get(sessionId)
|
|
432
540
|
if (state === undefined) return
|
|
433
|
-
|
|
541
|
+
// COALESCED TO THE FRONTIER. The peer keeps ONE `lastAck` per subscription
|
|
542
|
+
// and refuses a lower position, so an explicit ack carrying what a consumer
|
|
543
|
+
// has processed would lose a race against the layer's own ack and be
|
|
544
|
+
// answered CONFLICT — which the caller treats as a broken subscription.
|
|
545
|
+
// The buffer already holds everything up to `state.cursor`, so acking the
|
|
546
|
+
// frontier is both safe (an ack only frees flow control) and monotone.
|
|
547
|
+
const requested = cursor === undefined ? state.cursor : cursor
|
|
548
|
+
const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor
|
|
549
|
+
// What THIS ack covers: everything received up to the moment it is sent.
|
|
550
|
+
// Events arriving while it is in flight sit past the applied cursor, so
|
|
551
|
+
// they stay counted.
|
|
552
|
+
const covered = state.bytesSinceAck
|
|
434
553
|
await this.#rpc.request('agent.subscriptions.ack', {
|
|
435
554
|
subscriptionId: state.subscriptionId,
|
|
436
555
|
cursor: applied,
|
|
437
556
|
})
|
|
557
|
+
// COUNTED ONLY AFTER the peer accepted it: a refused ack frees no window,
|
|
558
|
+
// so a ledger that moved optimistically would claim progress the peer never
|
|
559
|
+
// granted. The SAME rule governs the byte side, which is why BOTH live here
|
|
560
|
+
// rather than in the auto-ack caller: an explicit ack frees the window just
|
|
561
|
+
// as well, and the ledger must not disagree with it.
|
|
562
|
+
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered)
|
|
563
|
+
state.acks += 1
|
|
438
564
|
}
|
|
439
565
|
|
|
440
566
|
/**
|
|
@@ -484,6 +610,7 @@ export class SessionSubscriptions {
|
|
|
484
610
|
this.#closed = true
|
|
485
611
|
const ids = [...this.#sessions.values()].map((state) => state.subscriptionId)
|
|
486
612
|
for (const state of this.#sessions.values()) {
|
|
613
|
+
this.#cancelAckRetry(state)
|
|
487
614
|
const error = new Error('SessionSubscriptions disposed')
|
|
488
615
|
for (const waiter of state.waiters.splice(0)) waiter.reject(error)
|
|
489
616
|
}
|
|
@@ -590,7 +717,10 @@ export class SessionSubscriptions {
|
|
|
590
717
|
*/
|
|
591
718
|
#installReplay(sessionId: string, descriptor: Extract<SubscribeDescriptor, { mode: 'replay' }>): void {
|
|
592
719
|
const previous = this.#sessions.get(sessionId)
|
|
593
|
-
if (previous !== undefined)
|
|
720
|
+
if (previous !== undefined) {
|
|
721
|
+
this.#cancelAckRetry(previous)
|
|
722
|
+
this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
|
|
723
|
+
}
|
|
594
724
|
this.#sessions.delete(sessionId)
|
|
595
725
|
// Re-inserting moves the session to the MRU end of the Map's order.
|
|
596
726
|
this.#sessions.set(sessionId, {
|
|
@@ -598,6 +728,15 @@ export class SessionSubscriptions {
|
|
|
598
728
|
cursor: descriptor.highWatermark,
|
|
599
729
|
buffer: [],
|
|
600
730
|
bytes: 0,
|
|
731
|
+
bytesSinceAck: 0,
|
|
732
|
+
// A fresh subscription owns a fresh ledger (issue #217): the counters
|
|
733
|
+
// describe THIS install, never a previous one.
|
|
734
|
+
deliveredBytes: 0,
|
|
735
|
+
acks: 0,
|
|
736
|
+
ackInFlight: false,
|
|
737
|
+
ackRetryAt: 0,
|
|
738
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
739
|
+
ackRetryTimer: undefined,
|
|
601
740
|
windowStart: Number(descriptor.highWatermark.seq),
|
|
602
741
|
// A fresh window: nothing has been dropped from it, so the baseline is a
|
|
603
742
|
// position this layer may serve (issue #2).
|
|
@@ -723,13 +862,25 @@ export class SessionSubscriptions {
|
|
|
723
862
|
items: Array<{ kind: string; value: unknown }>,
|
|
724
863
|
): void {
|
|
725
864
|
const previous = this.#sessions.get(sessionId)
|
|
726
|
-
if (previous !== undefined)
|
|
865
|
+
if (previous !== undefined) {
|
|
866
|
+
this.#cancelAckRetry(previous)
|
|
867
|
+
this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
|
|
868
|
+
}
|
|
727
869
|
this.#sessions.delete(sessionId)
|
|
728
870
|
this.#sessions.set(sessionId, {
|
|
729
871
|
subscriptionId: descriptor.subscriptionId,
|
|
730
872
|
cursor: descriptor.checkpoint.cursor,
|
|
731
873
|
buffer: [],
|
|
732
874
|
bytes: 0,
|
|
875
|
+
bytesSinceAck: 0,
|
|
876
|
+
// A fresh subscription owns a fresh ledger (issue #217): the counters
|
|
877
|
+
// describe THIS install, never a previous one.
|
|
878
|
+
deliveredBytes: 0,
|
|
879
|
+
acks: 0,
|
|
880
|
+
ackInFlight: false,
|
|
881
|
+
ackRetryAt: 0,
|
|
882
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
883
|
+
ackRetryTimer: undefined,
|
|
733
884
|
windowStart: Number(descriptor.checkpoint.cursor.seq),
|
|
734
885
|
// A fresh window: nothing has been dropped from it, so the baseline is a
|
|
735
886
|
// position this layer may serve (issue #2).
|
|
@@ -801,6 +952,7 @@ export class SessionSubscriptions {
|
|
|
801
952
|
`${sessionId}`,
|
|
802
953
|
)
|
|
803
954
|
// The entry is removed outright, so no `live` flag needs clearing.
|
|
955
|
+
this.#cancelAckRetry(state)
|
|
804
956
|
this.#rejectWaiters(state, new Error(`session ${sessionId} evicted by LRU`))
|
|
805
957
|
this.#sessions.delete(sessionId)
|
|
806
958
|
// AWAITED: the peer must observe the close before the next subscribe.
|
|
@@ -821,7 +973,9 @@ export class SessionSubscriptions {
|
|
|
821
973
|
|
|
822
974
|
/**
|
|
823
975
|
* `agent.events`: append to the session buffer, enforce the byte budget by
|
|
824
|
-
* dropping the OLDEST events (logged
|
|
976
|
+
* dropping the OLDEST events (logged, and never the last remaining one — see
|
|
977
|
+
* `#trimToBudget`'s floor, issue #221), advance the cursor, then wake
|
|
978
|
+
* waiters.
|
|
825
979
|
*
|
|
826
980
|
* SEQ MONOTONICITY IS ENFORCED HERE, per (sessionId, streamEpoch). The peer is
|
|
827
981
|
* supposed to deliver each stream position exactly once and in order, and the
|
|
@@ -907,6 +1061,8 @@ export class SessionSubscriptions {
|
|
|
907
1061
|
const bytes = eventBytes(event)
|
|
908
1062
|
state.buffer.push({ cursor, event, bytes })
|
|
909
1063
|
state.bytes += bytes
|
|
1064
|
+
state.bytesSinceAck += bytes
|
|
1065
|
+
state.deliveredBytes += bytes
|
|
910
1066
|
state.cursor = cursor
|
|
911
1067
|
appended += 1
|
|
912
1068
|
}
|
|
@@ -915,6 +1071,104 @@ export class SessionSubscriptions {
|
|
|
915
1071
|
// Waking on a wholly stale batch would make a parked waiter re-judge state
|
|
916
1072
|
// that has not moved; only real progress is a wake.
|
|
917
1073
|
if (appended > 0) this.#wakeWaiters(state)
|
|
1074
|
+
// The wake comes FIRST: a waiter that can settle from what just arrived
|
|
1075
|
+
// must not wait behind an ack round trip.
|
|
1076
|
+
if (appended > 0) this.#maybeAutoAck(sessionId, state)
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
|
|
1081
|
+
* window, so the peer keeps delivering (issue #212).
|
|
1082
|
+
*
|
|
1083
|
+
* WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
|
|
1084
|
+
* a caller has consumed". That is safe against the pinned peer because an ack
|
|
1085
|
+
* does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
|
|
1086
|
+
* un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
|
|
1087
|
+
* retention lives on the journal (`replayBytes`/`replayMs`) and is not
|
|
1088
|
+
* touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
|
|
1089
|
+
* below the acked position is still served — by the local buffer here and by
|
|
1090
|
+
* the journal there. The ack's own doc comment states the same contract.
|
|
1091
|
+
*
|
|
1092
|
+
* The ack is fire-and-forget with its failure SURFACED (a refused ack must be
|
|
1093
|
+
* visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
|
|
1094
|
+
* holding at its window no further event arrives, so a retry that waited for
|
|
1095
|
+
* traffic would never fire and the wait would time out exactly as in issue
|
|
1096
|
+
* #212.
|
|
1097
|
+
*/
|
|
1098
|
+
#maybeAutoAck(sessionId: string, state: SessionState): void {
|
|
1099
|
+
if (state.bytesSinceAck < this.#ackWatermark()) return
|
|
1100
|
+
if (state.ackInFlight) return
|
|
1101
|
+
if (Date.now() < state.ackRetryAt) return
|
|
1102
|
+
state.ackInFlight = true
|
|
1103
|
+
void this.ack(sessionId)
|
|
1104
|
+
.then(() => {
|
|
1105
|
+
// The BYTE ledger moved inside `ack()` itself — one place for remarks
|
|
1106
|
+
// and explicit acks alike — so only the retry state is the auto-ack's.
|
|
1107
|
+
// A peer that answers ack again gets prompt retries next time.
|
|
1108
|
+
state.ackRetryDelay = AUTO_ACK_RETRY_MS
|
|
1109
|
+
})
|
|
1110
|
+
.catch((error: unknown) => {
|
|
1111
|
+
// THIS attempt's delay, then the next one doubles: a peer that refuses
|
|
1112
|
+
// every ack (a wedged controller, a link about to drop) must not log a
|
|
1113
|
+
// line every few seconds for the life of the layer.
|
|
1114
|
+
const delay = state.ackRetryDelay
|
|
1115
|
+
state.ackRetryAt = Date.now() + delay
|
|
1116
|
+
state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS)
|
|
1117
|
+
console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`)
|
|
1118
|
+
// ARMED HERE, not on the next batch: a peer holding at its window sends
|
|
1119
|
+
// nothing more, so traffic-driven retry is no retry at all.
|
|
1120
|
+
this.#armAckRetry(sessionId, state, delay)
|
|
1121
|
+
})
|
|
1122
|
+
.finally(() => {
|
|
1123
|
+
state.ackInFlight = false
|
|
1124
|
+
// RE-JUDGE: batches can arrive WHILE the ack was in flight (the peer
|
|
1125
|
+
// flushes on the ack, so this is the common case), and those batches
|
|
1126
|
+
// were refused a trigger by `ackInFlight`. Without this second look the
|
|
1127
|
+
// layer could sit with bytes over the watermark and no event left to
|
|
1128
|
+
// trigger it — the peer holding, exactly as in issue #212.
|
|
1129
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return
|
|
1130
|
+
this.#maybeAutoAck(sessionId, state)
|
|
1131
|
+
})
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Quarter of the peer's window, read from the CURRENT source so a layer that
|
|
1136
|
+
* outlives a reconnect follows the new hello instead of its first answer.
|
|
1137
|
+
* Falls back to the pinned protocol constant when nothing is advertised.
|
|
1138
|
+
*/
|
|
1139
|
+
#ackWatermark(): number {
|
|
1140
|
+
const source = this.#unackedSource
|
|
1141
|
+
const advertised = typeof source === 'function' ? source() : source
|
|
1142
|
+
return Math.max(1, Math.floor(positive(advertised, remoteLimits.unackedBytes) / 4))
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** Disarms the retry, if any (idempotent; safe on a state being replaced). */
|
|
1146
|
+
#cancelAckRetry(state: SessionState): void {
|
|
1147
|
+
if (state.ackRetryTimer === undefined) return
|
|
1148
|
+
clearTimeout(state.ackRetryTimer)
|
|
1149
|
+
state.ackRetryTimer = undefined
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* One timer-driven retry after a refused ack. At most one is armed per
|
|
1154
|
+
* session; it no-ops when the state it was armed for is no longer current
|
|
1155
|
+
* (a re-subscribe, a checkpoint recovery or `dispose`).
|
|
1156
|
+
*/
|
|
1157
|
+
#armAckRetry(sessionId: string, state: SessionState, delayMs: number): void {
|
|
1158
|
+
if (this.#closed) return
|
|
1159
|
+
if (state.ackRetryTimer !== undefined) return
|
|
1160
|
+
const timer = setTimeout(() => {
|
|
1161
|
+
state.ackRetryTimer = undefined
|
|
1162
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return
|
|
1163
|
+
// CLEARED HERE, not left to the wall clock: a clock stepped backwards
|
|
1164
|
+
// would otherwise make `#maybeAutoAck` return early and arm nothing — a
|
|
1165
|
+
// retry silently lost while the peer holds, which is #212 again.
|
|
1166
|
+
state.ackRetryAt = 0
|
|
1167
|
+
this.#maybeAutoAck(sessionId, state)
|
|
1168
|
+
}, delayMs)
|
|
1169
|
+
// Never hold the process open on a retry that is only about flow control.
|
|
1170
|
+
timer.unref?.()
|
|
1171
|
+
state.ackRetryTimer = timer
|
|
918
1172
|
}
|
|
919
1173
|
|
|
920
1174
|
/**
|
|
@@ -937,17 +1191,31 @@ export class SessionSubscriptions {
|
|
|
937
1191
|
`[cherry-remote] subscription for session ${sessionId} reset by the peer ` +
|
|
938
1192
|
`(${String(params['reason'] ?? 'unspecified')}); re-subscribe required`,
|
|
939
1193
|
)
|
|
1194
|
+
this.#cancelAckRetry(state)
|
|
940
1195
|
this.#rejectWaiters(state, new Error(`session ${sessionId} reset by the peer`))
|
|
941
1196
|
this.#sessions.delete(sessionId)
|
|
942
1197
|
return
|
|
943
1198
|
}
|
|
944
1199
|
}
|
|
945
1200
|
|
|
946
|
-
/**
|
|
1201
|
+
/**
|
|
1202
|
+
* Drops the OLDEST buffered events until the session fits its byte budget.
|
|
1203
|
+
*
|
|
1204
|
+
* The loop stops at ONE remaining event (issue #221): the budget governs the
|
|
1205
|
+
* window's TAIL, never its head. A single event above the budget — only
|
|
1206
|
+
* reachable when `perSessionCacheBytes` is set below one record's size, since
|
|
1207
|
+
* the transport caps a record at 65,536 bytes — used to be evicted too,
|
|
1208
|
+
* leaving an EMPTY window whose `windowStart` had advanced past it: the event
|
|
1209
|
+
* that just happened, the one thing a reader wants, became unreachable and
|
|
1210
|
+
* every read from the baseline answered `cursor-out-of-window` about content
|
|
1211
|
+
* this layer had held a moment earlier. Keeping it is also the honest
|
|
1212
|
+
* reading of `evicted`: nothing was discarded, so the baseline stays a
|
|
1213
|
+
* servable resume point.
|
|
1214
|
+
*/
|
|
947
1215
|
#trimToBudget(sessionId: string, state: SessionState): void {
|
|
948
1216
|
if (state.bytes <= this.#perSessionCacheBytes) return
|
|
949
1217
|
let dropped = 0
|
|
950
|
-
while (state.bytes > this.#perSessionCacheBytes && state.buffer.length >
|
|
1218
|
+
while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 1) {
|
|
951
1219
|
const removed = state.buffer.shift()!
|
|
952
1220
|
state.bytes -= removed.bytes
|
|
953
1221
|
dropped += 1
|
|
@@ -958,6 +1226,10 @@ export class SessionSubscriptions {
|
|
|
958
1226
|
state.windowStart = Math.max(state.windowStart, Number(removed.cursor.seq))
|
|
959
1227
|
state.evicted = true
|
|
960
1228
|
}
|
|
1229
|
+
if (dropped === 0) return
|
|
1230
|
+
// Only a REAL drop is logged: an over-budget window holding one event has
|
|
1231
|
+
// discarded nothing, and "dropped 0 oldest event(s)" would send an operator
|
|
1232
|
+
// looking for a gap that does not exist.
|
|
961
1233
|
console.warn(
|
|
962
1234
|
`[cherry-remote] session ${sessionId} cache exceeded ${this.#perSessionCacheBytes} bytes; ` +
|
|
963
1235
|
`dropped ${dropped} oldest event(s)`,
|
package/src/tools.ts
CHANGED
|
@@ -4077,9 +4077,13 @@ export class CherryToolEngine {
|
|
|
4077
4077
|
} satisfies CherryCursorOutOfWindow
|
|
4078
4078
|
}
|
|
4079
4079
|
|
|
4080
|
-
// ACK AFTER DELIVERY (controller ruling ④):
|
|
4081
|
-
//
|
|
4082
|
-
//
|
|
4080
|
+
// ACK AFTER DELIVERY (controller ruling ④): this EXPLICIT ack reports
|
|
4081
|
+
// the position the caller has just been handed. The layer ALSO acks its
|
|
4082
|
+
// own buffer on a byte watermark (issue #212, subscriptions
|
|
4083
|
+
// `#maybeAutoAck`), because the peer holds the stream past its un-acked
|
|
4084
|
+
// window. The peer keeps ONE `lastAck` and refuses a lower position, so
|
|
4085
|
+
// both sites coalesce to the buffer frontier — see `ack()`.
|
|
4086
|
+
// A failing ack is logged, never allowed to swallow the delivered events.
|
|
4083
4087
|
// A FAILING ACK ALSO RE-ENSURES THE SESSION (v0.1.3 UltraReview P3-3).
|
|
4084
4088
|
// The ack is the peer's verdict on the SUBSCRIPTION, not on the
|
|
4085
4089
|
// delivery: a refused ack says the cursor is outside the window this
|
|
@@ -6041,8 +6045,9 @@ export class CherryToolEngine {
|
|
|
6041
6045
|
* dimension and deliberately does NOT settle a wait (Task 13 asserts the two
|
|
6042
6046
|
* layers separately).
|
|
6043
6047
|
*
|
|
6044
|
-
* Waiters
|
|
6045
|
-
*
|
|
6048
|
+
* Waiters do not ack what they fold. The subscription LAYER acks its own
|
|
6049
|
+
* buffer on a byte watermark (issue #212) so the peer keeps delivering past
|
|
6050
|
+
* its un-acked window; that ack is about flow control, not consumption.
|
|
6046
6051
|
*
|
|
6047
6052
|
* A wait can also end because the LINK went away — the plugin was torn down,
|
|
6048
6053
|
* the device was unpaired, or the connection closed. That is not a timeout and
|