muse-code-sdk 1.3.0__py3-none-any.whl
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.
- muse_code/__init__.py +129 -0
- muse_code/connection/__init__.py +49 -0
- muse_code/connection/connection.py +905 -0
- muse_code/connection/discovery.py +55 -0
- muse_code/connection/spawn.py +1067 -0
- muse_code/connection/validation.py +149 -0
- muse_code/errors.py +199 -0
- muse_code/facade/__init__.py +89 -0
- muse_code/facade/approval.py +279 -0
- muse_code/facade/client.py +444 -0
- muse_code/facade/discarded.py +45 -0
- muse_code/facade/gap_fill.py +483 -0
- muse_code/facade/host_death.py +254 -0
- muse_code/facade/session.py +977 -0
- muse_code/facade/turn_handle.py +440 -0
- muse_code/facade/turn_submit.py +289 -0
- muse_code/fold/__init__.py +82 -0
- muse_code/fold/item_store.py +268 -0
- muse_code/fold/session_fold.py +785 -0
- muse_code/fold/state_store.py +141 -0
- muse_code/pending/__init__.py +53 -0
- muse_code/pending/pending_command_set.py +842 -0
- muse_code/py.typed +0 -0
- muse_code/replay.py +144 -0
- muse_code/sync_facade.py +493 -0
- muse_code_sdk-1.3.0.dist-info/METADATA +47 -0
- muse_code_sdk-1.3.0.dist-info/RECORD +30 -0
- muse_code_sdk-1.3.0.dist-info/WHEEL +5 -0
- muse_code_sdk-1.3.0.dist-info/licenses/LICENSE +21 -0
- muse_code_sdk-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""SS4.8 gap recovery by splice-fill (spec 638 FR-638-019c / T032, carrying
|
|
2
|
+
spec 14990 FR-020; tdd SS4.8). Port of ``clients/sdk-ts/src/facade/gap-fill.ts``.
|
|
3
|
+
|
|
4
|
+
Split out of ``session.py`` for the same reason ``approval.py`` and
|
|
5
|
+
``turn_submit.py`` were: this is a delivery-plane concern with its own buffer,
|
|
6
|
+
page walk, and failure vocabulary, and ``Session`` was already the package's
|
|
7
|
+
largest module.
|
|
8
|
+
|
|
9
|
+
THE RECIPE, verbatim from tdd SS4.8 and unchanged here: buffer live events at
|
|
10
|
+
cursors at or after ``next`` as they arrive; page forward from ``after`` with
|
|
11
|
+
``view/page`` until the walk reaches ``next``; discard the paged events the
|
|
12
|
+
buffer already holds; splice the buffer after the paged prefix. D-030's second
|
|
13
|
+
sanctioned path (drop state and re-anchor at the latest compaction snapshot)
|
|
14
|
+
is NOT built here — it is the #208 lane's spec work, and building both would
|
|
15
|
+
be a second concrete path for one current use.
|
|
16
|
+
|
|
17
|
+
CURSORS STAY OPAQUE (tdd SS4.1). Nothing below orders two cursors: "at or
|
|
18
|
+
after ``next``" is delivery ORDER, not a comparison — every live frame that
|
|
19
|
+
arrives after the gap marker is, by the server's own delivery contract, at or
|
|
20
|
+
after ``next``. The walk stops on cursor EQUALITY with the target or on the
|
|
21
|
+
server's own end-of-view, and the overlap is discarded by set membership.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import (
|
|
29
|
+
Any,
|
|
30
|
+
Awaitable,
|
|
31
|
+
Callable,
|
|
32
|
+
Generator,
|
|
33
|
+
Generic,
|
|
34
|
+
List,
|
|
35
|
+
Mapping,
|
|
36
|
+
TypeVar,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from muse_code_msp import ViewPageParams
|
|
40
|
+
|
|
41
|
+
from ..connection.connection import Connection
|
|
42
|
+
from ..errors import GapFillFailureReason, MuseForeignSessionError, MuseGapFillError
|
|
43
|
+
from ..fold.session_fold import SessionFold
|
|
44
|
+
from ..pending.pending_command_set import PendingRetirement
|
|
45
|
+
|
|
46
|
+
_I = TypeVar("_I")
|
|
47
|
+
|
|
48
|
+
PAGE_LIMIT = 200
|
|
49
|
+
"""How many events one ``view/page`` asks for.
|
|
50
|
+
|
|
51
|
+
Any value in the published 1–1000 bound (``ViewPageParams.limit``) is correct
|
|
52
|
+
— the server MAY serve fewer and the walk follows ``nextCursor`` either way —
|
|
53
|
+
so this trades round trips against frame size and is deliberately not a knob:
|
|
54
|
+
a per-session page size would be a public option with no current consumer
|
|
55
|
+
asking for one (Constitution XI).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
WireFrame = Mapping[str, Any]
|
|
59
|
+
"""One wire frame, exactly as ``Session.apply`` accepts it (``method`` +
|
|
60
|
+
``params``)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class DrainSink(Generic[_I]):
|
|
65
|
+
"""Where a drained frame's side effects go.
|
|
66
|
+
|
|
67
|
+
The filler re-feeds frames through ``Session``'s own fold-and-route path,
|
|
68
|
+
so the retirements and the client→server I/O they trigger belong to
|
|
69
|
+
``Session``, not here.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
retirements: List[PendingRetirement[_I]]
|
|
73
|
+
tasks: List[Awaitable[tuple[PendingRetirement[_I], ...]]]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ResolvedIo(Generic[_I]):
|
|
77
|
+
"""An already-settled ``io`` that needs no event loop.
|
|
78
|
+
|
|
79
|
+
``Session.apply`` runs synchronously and is called off a fold with no host
|
|
80
|
+
at all (every fold-only arm in this package), so the
|
|
81
|
+
overwhelming-majority no-I/O case must not require a running loop to build
|
|
82
|
+
its ``io`` — and a bare coroutine would warn "never awaited" for the
|
|
83
|
+
consumers (most of them) that do not await it. This awaitable can be
|
|
84
|
+
awaited any number of times or never, allocates nothing on the loop, and
|
|
85
|
+
always yields its held tuple.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
__slots__ = ("_value",)
|
|
89
|
+
|
|
90
|
+
def __init__(self, value: tuple[PendingRetirement[_I], ...]) -> None:
|
|
91
|
+
self._value = value
|
|
92
|
+
|
|
93
|
+
def __await__(
|
|
94
|
+
self,
|
|
95
|
+
) -> Generator[Any, None, tuple[PendingRetirement[_I], ...]]:
|
|
96
|
+
return self._value
|
|
97
|
+
yield # pragma: no cover - unreachable; marks this a generator
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
GapFillFailureHandler = Callable[[MuseGapFillError], None]
|
|
101
|
+
"""Reported, never raised — see :class:`~muse_code.errors.MuseGapFillError`."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class GapFillerOptions(Generic[_I]):
|
|
106
|
+
"""What the filler composes over.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
session_id: The owning session's id, named in every ``view/page``.
|
|
110
|
+
connection: Absent on a fold-only ``Session``: nothing to page through.
|
|
111
|
+
fold: The fold whose ``pending_gap``/``gap_filled`` the walk drives.
|
|
112
|
+
apply: Fold and route ONE frame, collecting what it produced.
|
|
113
|
+
discarded: SS2.13.3b — a discharged session folds nothing, fill or no
|
|
114
|
+
fill.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
session_id: str
|
|
118
|
+
connection: Connection | None
|
|
119
|
+
fold: SessionFold
|
|
120
|
+
apply: Callable[[WireFrame, DrainSink[_I]], object]
|
|
121
|
+
discarded: Callable[[], bool]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class GapFiller(Generic[_I]):
|
|
125
|
+
"""Drives the SS4.8 splice-fill for one session (spec 638 FR-638-019c)."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, options: GapFillerOptions[_I]) -> None:
|
|
128
|
+
"""Builds the filler over its session's seams."""
|
|
129
|
+
self._options = options
|
|
130
|
+
# Live frames held while a fill is in flight, in arrival order.
|
|
131
|
+
self._buffer: List[WireFrame] = []
|
|
132
|
+
self._filling = False
|
|
133
|
+
self._cursor = ""
|
|
134
|
+
self._on_failure: GapFillFailureHandler | None = None
|
|
135
|
+
# Cursors the page served at or after a target — the events whose LIVE
|
|
136
|
+
# twin the wire has yet to deliver.
|
|
137
|
+
#
|
|
138
|
+
# THE LATE HALF of the recipe's overlap discard, and only that half. A
|
|
139
|
+
# twin that has already arrived is in the buffer, and ``_drain``
|
|
140
|
+
# discards those against its own local set of EVERY paged cursor. This
|
|
141
|
+
# set covers the other window: the wire promises no order between the
|
|
142
|
+
# marker and the frame at ``next``, so a twin can legally arrive after
|
|
143
|
+
# the fill completed, when the buffer is gone. Folding it then re-runs
|
|
144
|
+
# its routing — for a ``turn/started``, a duplicate SS4.13
|
|
145
|
+
# queue-movement replay on the wire.
|
|
146
|
+
#
|
|
147
|
+
# SELF-DRAINING: an entry is removed the moment its twin arrives, and
|
|
148
|
+
# only cursors at or after a target ever enter (the walk stops at the
|
|
149
|
+
# page that carries it, so at most one page's worth per target).
|
|
150
|
+
#
|
|
151
|
+
# RESIDUAL, stated rather than hidden: an entry whose twin the wire
|
|
152
|
+
# never delivers — because a LATER hole swallowed it — has no arrival
|
|
153
|
+
# to retire it and stays, a short string each, bounded by one page's
|
|
154
|
+
# worth per TARGET.
|
|
155
|
+
#
|
|
156
|
+
# Two prunes were tried on the TS twin and both were defects (PR
|
|
157
|
+
# #24930 review rounds 2–4), so neither is here: clearing per fill
|
|
158
|
+
# drops the refusal for a twin still on its way, and retiring inside
|
|
159
|
+
# the WALK when it meets the cursor before its own target deletes a
|
|
160
|
+
# buffered twin's only refusal (the walk cannot see the buffer).
|
|
161
|
+
# Deciding staleness any other way means ordering two opaque cursors,
|
|
162
|
+
# which tdd SS4.1 forbids.
|
|
163
|
+
self._served_twins: set[str] = set()
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def filling(self) -> bool:
|
|
167
|
+
"""While ``True``, every live frame but the gap marker itself is held."""
|
|
168
|
+
return self._filling
|
|
169
|
+
|
|
170
|
+
def claim_served_twin(self, frame: WireFrame) -> str | None:
|
|
171
|
+
"""Was this frame already applied from a page, as part of the overlap
|
|
172
|
+
the splice discards? Consuming: a twin is refused exactly once.
|
|
173
|
+
|
|
174
|
+
Returns the matched CURSOR rather than a boolean so the caller can
|
|
175
|
+
report which event it refused without re-reading (and re-defaulting) a
|
|
176
|
+
field this method already proved is there.
|
|
177
|
+
"""
|
|
178
|
+
cursor = _view_cursor_of(frame)
|
|
179
|
+
if cursor is None or cursor not in self._served_twins:
|
|
180
|
+
return None
|
|
181
|
+
self._served_twins.discard(cursor)
|
|
182
|
+
return cursor
|
|
183
|
+
|
|
184
|
+
def on_error(self, handler: GapFillFailureHandler) -> None:
|
|
185
|
+
"""Observe fills that did not complete. See :class:`MuseGapFillError`."""
|
|
186
|
+
self._on_failure = handler
|
|
187
|
+
|
|
188
|
+
def hold(self, frame: WireFrame) -> None:
|
|
189
|
+
"""Hold one live frame until the paged prefix is in."""
|
|
190
|
+
self._buffer.append(frame)
|
|
191
|
+
|
|
192
|
+
def start(self) -> Awaitable[tuple[PendingRetirement[_I], ...]] | None:
|
|
193
|
+
"""Recover the fold's outstanding hole.
|
|
194
|
+
|
|
195
|
+
Called on every ``view/gap``, including one that lands mid-fill: the
|
|
196
|
+
running walk reads its target from ``fold.pending_gap`` on each pass,
|
|
197
|
+
so a coalesced hole extends the walk in flight rather than starting a
|
|
198
|
+
second one racing it for the same cursor stream.
|
|
199
|
+
|
|
200
|
+
Returns ``None`` when this call starts no walk — nothing outstanding,
|
|
201
|
+
a walk already in flight, or a fold-only session (whose
|
|
202
|
+
``noConnection`` failure is reported synchronously before returning).
|
|
203
|
+
``None`` rather than an already-settled awaitable, deliberately: the
|
|
204
|
+
caller feeds the return into the ``io`` channel, and an entry there
|
|
205
|
+
forces ``_settled_io`` onto its gather arm, which needs a running
|
|
206
|
+
loop a fold-only ``apply`` may not have (PR #32249 review round 1 —
|
|
207
|
+
the same shape as ``ApprovalRouter.requested``).
|
|
208
|
+
"""
|
|
209
|
+
gap = self._options.fold.pending_gap
|
|
210
|
+
# Only reachable if a caller invokes this with nothing outstanding; the
|
|
211
|
+
# one production caller folds the marker first, which always sets it.
|
|
212
|
+
if gap is None:
|
|
213
|
+
return None
|
|
214
|
+
if self._filling:
|
|
215
|
+
return None
|
|
216
|
+
connection = self._options.connection
|
|
217
|
+
if connection is None:
|
|
218
|
+
# Nothing is buffered on this path: no fill is coming to release
|
|
219
|
+
# it, and holding the live tail forever would turn one reported
|
|
220
|
+
# hole into a silent second one.
|
|
221
|
+
self._report(MuseGapFillError("noConnection", gap["after"], gap["next"]))
|
|
222
|
+
return None
|
|
223
|
+
self._filling = True
|
|
224
|
+
self._cursor = gap["after"]
|
|
225
|
+
return asyncio.ensure_future(self._run(connection))
|
|
226
|
+
|
|
227
|
+
async def _run(
|
|
228
|
+
self, connection: Connection
|
|
229
|
+
) -> tuple[PendingRetirement[_I], ...]:
|
|
230
|
+
paged: List[WireFrame] = []
|
|
231
|
+
try:
|
|
232
|
+
await self._walk(connection, paged)
|
|
233
|
+
except Exception as error: # noqa: BLE001 - reported, never escapes
|
|
234
|
+
self._report_failure(error)
|
|
235
|
+
# ALWAYS drained, success or failure: the buffered tail is made of
|
|
236
|
+
# events this client really did receive, and swallowing them would add
|
|
237
|
+
# a second hole to the one just reported.
|
|
238
|
+
#
|
|
239
|
+
# The TS twin re-checks ``pendingGap`` after this and restarts a walk
|
|
240
|
+
# (its "clear window": TS ``#drain`` lowers the flag a microtask after
|
|
241
|
+
# ``#walk`` cleared the hole, and a ``view/gap`` folding in between
|
|
242
|
+
# would strand). That window CANNOT exist here: the walk's final
|
|
243
|
+
# ``gap_filled`` and this ``_drain`` — which lowers ``_filling`` — run
|
|
244
|
+
# in one synchronous task step with no await between them, so no frame
|
|
245
|
+
# can fold inside it, and a ``view/gap`` arriving after this task step
|
|
246
|
+
# finds ``_filling`` already false and starts its own walk from
|
|
247
|
+
# ``apply``. A Python restart block would be unreachable defense
|
|
248
|
+
# (PR #32249 review round 1 proved it dead by mutation).
|
|
249
|
+
return await self._drain(paged)
|
|
250
|
+
|
|
251
|
+
async def _walk(self, connection: Connection, paged: List[WireFrame]) -> None:
|
|
252
|
+
"""Page until the fold's outstanding hole is closed.
|
|
253
|
+
|
|
254
|
+
The outer loop is what makes a mid-walk ``view/gap`` safe:
|
|
255
|
+
``gap_filled`` refuses to clear a target the fold has since extended,
|
|
256
|
+
so the walk simply keeps going toward the newer ``next`` on the SAME
|
|
257
|
+
cursor stream.
|
|
258
|
+
"""
|
|
259
|
+
while True:
|
|
260
|
+
# A session discharged mid-walk stops here, BEFORE ``gap_filled``:
|
|
261
|
+
# there is nothing left to be current with, and clearing the hole
|
|
262
|
+
# would have the fold report a discarded transcript complete. The
|
|
263
|
+
# host is gone, so a further page would also wait on an answer
|
|
264
|
+
# that is never coming — which is a hang, not a fill.
|
|
265
|
+
if self._options.discarded():
|
|
266
|
+
return
|
|
267
|
+
gap = self._options.fold.pending_gap
|
|
268
|
+
if gap is None:
|
|
269
|
+
return
|
|
270
|
+
target = gap["next"]
|
|
271
|
+
await self._walk_to(connection, target, gap["after"], paged)
|
|
272
|
+
if self._options.discarded():
|
|
273
|
+
return
|
|
274
|
+
if self._options.fold.gap_filled(target):
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
async def _walk_to(
|
|
278
|
+
self,
|
|
279
|
+
connection: Connection,
|
|
280
|
+
target: str,
|
|
281
|
+
after: str,
|
|
282
|
+
paged: List[WireFrame],
|
|
283
|
+
) -> None:
|
|
284
|
+
while True:
|
|
285
|
+
if self._options.discarded():
|
|
286
|
+
return
|
|
287
|
+
result = await self._page(connection, self._cursor)
|
|
288
|
+
reached = False
|
|
289
|
+
events = result["events"]
|
|
290
|
+
for event in events:
|
|
291
|
+
self._require_own_session(event, after, target)
|
|
292
|
+
cursor = str(event["params"]["viewCursor"])
|
|
293
|
+
# An EARLIER fill already served — and applied — this durable
|
|
294
|
+
# event. A later walk may legally page through the same range,
|
|
295
|
+
# and re-applying it here is the same double fold the discard
|
|
296
|
+
# exists to prevent.
|
|
297
|
+
already_applied = cursor in self._served_twins
|
|
298
|
+
# Equality, never a relational compare: ``next`` is a cursor,
|
|
299
|
+
# and cursors are opaque (tdd SS4.1). Set BEFORE the skip
|
|
300
|
+
# below, or a target the walk skips is a target the walk never
|
|
301
|
+
# reaches, and it pages straight past its own stopping point.
|
|
302
|
+
if cursor == target:
|
|
303
|
+
reached = True
|
|
304
|
+
if already_applied:
|
|
305
|
+
# SKIPPED AND NOTHING ELSE. Retiring the entry here when
|
|
306
|
+
# the walk meets it before its own target deletes a
|
|
307
|
+
# buffered twin's only refusal — the walk cannot see the
|
|
308
|
+
# buffer, and a coalescing gap moves the target past a
|
|
309
|
+
# cursor whose twin is sitting in it.
|
|
310
|
+
continue
|
|
311
|
+
paged.append(event)
|
|
312
|
+
# At or after ``next`` — by page ORDER, not by comparing two
|
|
313
|
+
# cursors. These are the events the live tail also delivers,
|
|
314
|
+
# so their twins are the overlap the splice discards.
|
|
315
|
+
if reached:
|
|
316
|
+
self._served_twins.add(cursor)
|
|
317
|
+
# The server's own end of the view. ``next`` names a LIVE cursor
|
|
318
|
+
# and ``view/page`` serves durable-sourced events only (tdd
|
|
319
|
+
# SS4.7.3), so a hole whose tail was ephemeral ends here and never
|
|
320
|
+
# at ``target``. An ABSENT ``nextCursor`` is a different fact — the
|
|
321
|
+
# member is "never omitted", so it falls through to the stall arm.
|
|
322
|
+
if "nextCursor" in result and result["nextCursor"] is None:
|
|
323
|
+
return
|
|
324
|
+
next_cursor = result.get("nextCursor")
|
|
325
|
+
# PROGRESS is the only bound on this walk, and it has to be,
|
|
326
|
+
# because a legitimate fill is unbounded in length: an attempt cap
|
|
327
|
+
# would silently truncate one. A page that carries no event,
|
|
328
|
+
# repeats the cursor it was given, or answers with no cursor at
|
|
329
|
+
# all has not advanced, and paging again would ask the identical
|
|
330
|
+
# question forever.
|
|
331
|
+
if (
|
|
332
|
+
len(events) == 0
|
|
333
|
+
or not isinstance(next_cursor, str)
|
|
334
|
+
or next_cursor == self._cursor
|
|
335
|
+
):
|
|
336
|
+
raise MuseGapFillError("pageStalled", after, target)
|
|
337
|
+
self._cursor = next_cursor
|
|
338
|
+
if reached:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
async def _page(
|
|
342
|
+
self, connection: Connection, cursor: str
|
|
343
|
+
) -> Mapping[str, Any]:
|
|
344
|
+
params: ViewPageParams = {
|
|
345
|
+
"cursor": cursor,
|
|
346
|
+
"limit": PAGE_LIMIT,
|
|
347
|
+
"sessionId": self._options.session_id,
|
|
348
|
+
}
|
|
349
|
+
return await connection.request("view/page", dict(params))
|
|
350
|
+
|
|
351
|
+
def _require_own_session(
|
|
352
|
+
self, event: Mapping[str, Any], after: str, target: str
|
|
353
|
+
) -> None:
|
|
354
|
+
"""A page that serves another session's events aborts the fill.
|
|
355
|
+
|
|
356
|
+
The same check ``Session.apply`` makes on a live frame, at the one
|
|
357
|
+
other place a frame can enter the fold. Folding it would corrupt this
|
|
358
|
+
transcript with another session's history, and the request that asked
|
|
359
|
+
for it named this ``sessionId``, so there is no reading under which
|
|
360
|
+
the answer is right.
|
|
361
|
+
"""
|
|
362
|
+
params = event.get("params")
|
|
363
|
+
named = params.get("sessionId") if isinstance(params, dict) else None
|
|
364
|
+
if isinstance(named, str) and named != self._options.session_id:
|
|
365
|
+
raise MuseGapFillError(
|
|
366
|
+
"pageFailed",
|
|
367
|
+
after,
|
|
368
|
+
target,
|
|
369
|
+
MuseForeignSessionError(
|
|
370
|
+
f"view/page for {self._options.session_id} served an event "
|
|
371
|
+
f"for session {named}"
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _drain(
|
|
376
|
+
self, paged: List[WireFrame]
|
|
377
|
+
) -> Awaitable[tuple[PendingRetirement[_I], ...]]:
|
|
378
|
+
"""Splice: the paged prefix, then the buffered tail minus the overlap.
|
|
379
|
+
|
|
380
|
+
Synchronous up to its return, deliberately. Clearing the buffer and
|
|
381
|
+
the flag in the same run of code that re-feeds them is what makes the
|
|
382
|
+
handover atomic — an await in the middle would let a live frame arrive
|
|
383
|
+
after the flag dropped and fold ahead of the tail still waiting in the
|
|
384
|
+
buffer.
|
|
385
|
+
|
|
386
|
+
It is outside the walk's ``try`` because the buffered tail must be
|
|
387
|
+
released on FAILURE too, and it needs no ``try`` of its own: every
|
|
388
|
+
frame it re-feeds has already passed the one check ``Session.apply``
|
|
389
|
+
can raise on — the live half at buffer time, the paged half in the
|
|
390
|
+
walk — so this cannot be the rejection ``SessionApplyOutcome.io``
|
|
391
|
+
promises never to be.
|
|
392
|
+
"""
|
|
393
|
+
buffered = list(self._buffer)
|
|
394
|
+
self._buffer.clear()
|
|
395
|
+
self._filling = False
|
|
396
|
+
# SS2.13.3b outranks the fill: a session discharged while the walk was
|
|
397
|
+
# in flight folds nothing more, and neither half of the splice is
|
|
398
|
+
# exempt.
|
|
399
|
+
if self._options.discarded():
|
|
400
|
+
return ResolvedIo(())
|
|
401
|
+
sink: DrainSink[_I] = DrainSink(retirements=[], tasks=[])
|
|
402
|
+
# EVERY paged cursor, not just the ones at or after a target. A
|
|
403
|
+
# coalesced gap restarts the walk toward the NEW target with
|
|
404
|
+
# ``reached`` false, so paged events between the two targets never
|
|
405
|
+
# enter ``_served_twins`` — yet their twins ARE in this buffer,
|
|
406
|
+
# because a cursor at or after the first target was delivered live
|
|
407
|
+
# before the second hole opened. Claiming on the persistent set alone
|
|
408
|
+
# folded those twice.
|
|
409
|
+
served: set[str] = set()
|
|
410
|
+
for frame in paged:
|
|
411
|
+
cursor = _view_cursor_of(frame)
|
|
412
|
+
if cursor is not None:
|
|
413
|
+
served.add(cursor)
|
|
414
|
+
self._options.apply(frame, sink)
|
|
415
|
+
for frame in buffered:
|
|
416
|
+
# The overlap the recipe discards: the page already served this
|
|
417
|
+
# cursor, so folding the buffered copy would route the same event
|
|
418
|
+
# twice. BOTH sets: ``served`` is what THIS fill applied;
|
|
419
|
+
# ``_served_twins`` carries what EARLIER fills served, including
|
|
420
|
+
# the cursors this walk declined to re-apply — nothing deletes
|
|
421
|
+
# those inside the walk, precisely so this branch can still find
|
|
422
|
+
# them. A twin of either can be sitting in this buffer, because
|
|
423
|
+
# ``Session.apply`` buffers every live frame while a fill runs,
|
|
424
|
+
# before the late-window claim can see it, so this branch is the
|
|
425
|
+
# only thing left to refuse it.
|
|
426
|
+
cursor = _view_cursor_of(frame)
|
|
427
|
+
if cursor is not None and (
|
|
428
|
+
cursor in served or cursor in self._served_twins
|
|
429
|
+
):
|
|
430
|
+
# Consume the persistent entry too, so a twin refused here is
|
|
431
|
+
# not refused a second time if the wire also delivers it later.
|
|
432
|
+
self._served_twins.discard(cursor)
|
|
433
|
+
continue
|
|
434
|
+
self._options.apply(frame, sink)
|
|
435
|
+
if not sink.tasks:
|
|
436
|
+
return ResolvedIo(tuple(sink.retirements))
|
|
437
|
+
|
|
438
|
+
async def run() -> tuple[PendingRetirement[_I], ...]:
|
|
439
|
+
batches = await asyncio.gather(*sink.tasks)
|
|
440
|
+
out: List[PendingRetirement[_I]] = list(sink.retirements)
|
|
441
|
+
for batch in batches:
|
|
442
|
+
out.extend(batch)
|
|
443
|
+
return tuple(out)
|
|
444
|
+
|
|
445
|
+
return asyncio.ensure_future(run())
|
|
446
|
+
|
|
447
|
+
def _report_failure(self, error: BaseException) -> None:
|
|
448
|
+
if isinstance(error, MuseGapFillError):
|
|
449
|
+
self._report(error)
|
|
450
|
+
return
|
|
451
|
+
# Anything the round trip itself raised — an ``MspError`` the host
|
|
452
|
+
# authored, a ``ProtocolError``, a dead transport — is the same fact
|
|
453
|
+
# from a consumer's point of view: the hole is still there. The cause
|
|
454
|
+
# is carried rather than flattened so the repair stays diagnosable.
|
|
455
|
+
gap = self._options.fold.pending_gap
|
|
456
|
+
reason: GapFillFailureReason = "pageFailed"
|
|
457
|
+
self._report(
|
|
458
|
+
MuseGapFillError(
|
|
459
|
+
reason,
|
|
460
|
+
gap["after"] if gap is not None else "",
|
|
461
|
+
gap["next"] if gap is not None else "",
|
|
462
|
+
error,
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def _report(self, failure: MuseGapFillError) -> None:
|
|
467
|
+
handler = self._on_failure
|
|
468
|
+
if handler is None:
|
|
469
|
+
return
|
|
470
|
+
try:
|
|
471
|
+
handler(failure)
|
|
472
|
+
except Exception: # noqa: BLE001, S110
|
|
473
|
+
# The consumer's failure OBSERVER raised. Swallowed for the reason
|
|
474
|
+
# ``ApprovalRouter._report`` states: this rides
|
|
475
|
+
# ``SessionApplyOutcome.io``, whose contract is "IT NEVER
|
|
476
|
+
# REJECTS", and most consumers never await it.
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _view_cursor_of(frame: WireFrame) -> str | None:
|
|
481
|
+
params = frame.get("params")
|
|
482
|
+
cursor = params.get("viewCursor") if isinstance(params, dict) else None
|
|
483
|
+
return cursor if isinstance(cursor, str) else None
|