termwright 0.2.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.
- termwright/__init__.py +125 -0
- termwright/client.py +708 -0
- termwright/debug.py +169 -0
- termwright/diffing.py +118 -0
- termwright/errors.py +20 -0
- termwright/framing.py +196 -0
- termwright/limits.py +82 -0
- termwright/logging_bridge.py +108 -0
- termwright/logs.py +207 -0
- termwright/marker.py +128 -0
- termwright/messages.py +417 -0
- termwright/roles.py +57 -0
- termwright/textual.py +249 -0
- termwright/tree.py +282 -0
- termwright/validate.py +846 -0
- termwright-0.2.0.dist-info/METADATA +336 -0
- termwright-0.2.0.dist-info/RECORD +25 -0
- termwright-0.2.0.dist-info/WHEEL +4 -0
- termwright_probe/__init__.py +73 -0
- termwright_probe/__main__.py +45 -0
- termwright_probe/bootstrap.py +207 -0
- termwright_probe/defer.py +130 -0
- termwright_probe/session.py +197 -0
- termwright_probe/textual_probe.py +239 -0
- termwright_probe/textual_tree.py +634 -0
termwright/client.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""asyncio client for the semantic side-channel.
|
|
2
|
+
|
|
3
|
+
**Dormant rule.** Without ``TERMWRIGHT_ENDPOINT`` and ``TERMWRIGHT_TOKEN`` in
|
|
4
|
+
the environment, :func:`client_from_env` returns ``None`` and the application
|
|
5
|
+
opens no socket, writes no marker, and renders exactly the bytes it would have
|
|
6
|
+
rendered anyway. Instrumentation is something the driver switches on, never
|
|
7
|
+
something the app does on its own.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
from typing import Any, Dict, Mapping, MutableMapping, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
from .debug import DebugLog, describe_endpoint
|
|
19
|
+
from .errors import ProtocolViolation, TermwrightError
|
|
20
|
+
from .framing import FrameDecoder, encode_frame
|
|
21
|
+
from .limits import DEFAULT_LIMITS, ProtocolLimits
|
|
22
|
+
from .marker import encode_marker
|
|
23
|
+
from .diffing import build_delta
|
|
24
|
+
from .logs import LogRecord, flatten_attrs, validate_log_record
|
|
25
|
+
from .messages import (
|
|
26
|
+
PROTOCOL_ID,
|
|
27
|
+
PROTOCOL_V2_ID,
|
|
28
|
+
get_tree_result,
|
|
29
|
+
hello,
|
|
30
|
+
log_message,
|
|
31
|
+
parse_driver_message,
|
|
32
|
+
protocol_error,
|
|
33
|
+
revision_commit,
|
|
34
|
+
snapshot_message,
|
|
35
|
+
)
|
|
36
|
+
from .tree import SemanticSnapshot
|
|
37
|
+
from .validate import validate_snapshot
|
|
38
|
+
|
|
39
|
+
ENV_ENDPOINT = "TERMWRIGHT_ENDPOINT"
|
|
40
|
+
ENV_TOKEN = "TERMWRIGHT_TOKEN"
|
|
41
|
+
ENV_PROTOCOL = "TERMWRIGHT_PROTOCOL"
|
|
42
|
+
|
|
43
|
+
DEFAULT_CAPABILITIES = (
|
|
44
|
+
"tree",
|
|
45
|
+
"bounds",
|
|
46
|
+
"absolute-bounds",
|
|
47
|
+
"states",
|
|
48
|
+
"actions",
|
|
49
|
+
"render-revisions",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
#: Capabilities for an adapter that also forwards application logs. Announcing
|
|
53
|
+
#: `logs` is what makes the driver send a budget back; without it the driver
|
|
54
|
+
#: sends none and the adapter must stay silent.
|
|
55
|
+
CAPABILITIES_WITH_LOGS = DEFAULT_CAPABILITIES + ("logs",)
|
|
56
|
+
|
|
57
|
+
#: How many recent snapshots stay answerable by a ``get-tree`` for a past revision.
|
|
58
|
+
_SNAPSHOT_HISTORY = 8
|
|
59
|
+
|
|
60
|
+
#: Seconds a single frame write may wait for the driver to read.
|
|
61
|
+
#:
|
|
62
|
+
#: A probe publishes from the render path, so an unbounded write turns a driver
|
|
63
|
+
#: that stopped reading into an application that stopped drawing. asyncio keeps
|
|
64
|
+
#: the loop turning either way, but an unbounded ``drain`` queues frames in
|
|
65
|
+
#: memory for as long as the driver stays away, which is its own failure. A
|
|
66
|
+
#: driver that cannot take a frame in a quarter of a second is not keeping up,
|
|
67
|
+
#: and the next frame carries newer state anyway.
|
|
68
|
+
DEFAULT_WRITE_TIMEOUT = 0.25
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WriteTimeout(TermwrightError):
|
|
72
|
+
"""The driver did not read within the write deadline.
|
|
73
|
+
|
|
74
|
+
Distinguishable on purpose: a caller reacting to a slow driver does
|
|
75
|
+
something quite different from one whose snapshot was refused for being
|
|
76
|
+
invalid, which raises :class:`ProtocolViolation` and will do so again for
|
|
77
|
+
the same tree.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_pipe_path(endpoint: str) -> bool:
|
|
83
|
+
"""Whether the endpoint names a Windows pipe rather than a unix socket."""
|
|
84
|
+
return endpoint.startswith("\\\\.\\pipe\\") or endpoint.startswith("\\\\?\\pipe\\")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _open_connection(endpoint: str):
|
|
88
|
+
"""Open the driver's endpoint on whichever transport it needs.
|
|
89
|
+
|
|
90
|
+
The driver listens on a unix socket everywhere but Windows, where it
|
|
91
|
+
listens on a named pipe (``\\\\.\\pipe\\termwright-<hex>``). asyncio reaches a
|
|
92
|
+
pipe only through the proactor loop's ``create_pipe_connection``, and does
|
|
93
|
+
not expose ``open_unix_connection`` on Windows at all — so choosing by the
|
|
94
|
+
endpoint's shape is what keeps one client working on both.
|
|
95
|
+
"""
|
|
96
|
+
loop = asyncio.get_event_loop()
|
|
97
|
+
if _is_pipe_path(endpoint):
|
|
98
|
+
connect = getattr(loop, "create_pipe_connection", None)
|
|
99
|
+
if connect is None:
|
|
100
|
+
# A pipe path under a loop that cannot open one: nothing to do but
|
|
101
|
+
# stay silent, which the caller treats as no side channel.
|
|
102
|
+
raise NotImplementedError("this event loop cannot open a named pipe")
|
|
103
|
+
reader = asyncio.StreamReader(loop=loop)
|
|
104
|
+
protocol = asyncio.StreamReaderProtocol(reader, loop=loop)
|
|
105
|
+
transport, _ = await connect(lambda: protocol, endpoint)
|
|
106
|
+
writer = asyncio.StreamWriter(transport, protocol, reader, loop)
|
|
107
|
+
return reader, writer
|
|
108
|
+
return await asyncio.open_unix_connection(endpoint)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _TokenBucket:
|
|
112
|
+
"""Rate limiter for the log channel: `burst` capacity, refilled per second.
|
|
113
|
+
|
|
114
|
+
The adapter enforces its own budget and drops locally, which is what keeps
|
|
115
|
+
a log storm from eating the frame budget the semantic tree needs.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, per_second: int, burst: int, now: float) -> None:
|
|
119
|
+
self._per_second = max(0, per_second)
|
|
120
|
+
self._capacity = float(max(0, burst) + max(0, per_second))
|
|
121
|
+
self._tokens = self._capacity
|
|
122
|
+
self._updated = now
|
|
123
|
+
|
|
124
|
+
def take(self, now: float) -> bool:
|
|
125
|
+
"""Consume one token, refilling first. False means "over budget"."""
|
|
126
|
+
if self._per_second <= 0:
|
|
127
|
+
return False
|
|
128
|
+
elapsed = max(0.0, now - self._updated)
|
|
129
|
+
self._updated = now
|
|
130
|
+
self._tokens = min(self._capacity, self._tokens + elapsed * self._per_second)
|
|
131
|
+
if self._tokens < 1.0:
|
|
132
|
+
return False
|
|
133
|
+
self._tokens -= 1.0
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SemanticClient:
|
|
138
|
+
"""One semantic session: handshake, snapshot publishing, render markers.
|
|
139
|
+
|
|
140
|
+
The client owns the revision counter. :meth:`publish` allocates the next
|
|
141
|
+
revision, sends the snapshot and its commit, and returns the marker string
|
|
142
|
+
the caller must write to stdout **after** the render's last byte.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
endpoint: str,
|
|
148
|
+
token: str,
|
|
149
|
+
*,
|
|
150
|
+
adapter_name: str,
|
|
151
|
+
adapter_version: str,
|
|
152
|
+
capabilities: Sequence[str] = DEFAULT_CAPABILITIES,
|
|
153
|
+
limits: ProtocolLimits = DEFAULT_LIMITS,
|
|
154
|
+
debug: Optional[DebugLog] = None,
|
|
155
|
+
probe: Optional[Mapping[str, Any]] = None,
|
|
156
|
+
write_timeout: float = DEFAULT_WRITE_TIMEOUT,
|
|
157
|
+
protocol: str = PROTOCOL_ID,
|
|
158
|
+
) -> None:
|
|
159
|
+
self._endpoint = endpoint
|
|
160
|
+
self._token = token
|
|
161
|
+
self._adapter_name = adapter_name
|
|
162
|
+
self._adapter_version = adapter_version
|
|
163
|
+
self._capabilities = tuple(capabilities)
|
|
164
|
+
self._limits = limits
|
|
165
|
+
#: Diagnostic log, or None. Every use is guarded; the client behaves
|
|
166
|
+
#: identically with and without one.
|
|
167
|
+
self._debug = debug
|
|
168
|
+
#: What a probe says it can observe, sent with `hello`. None for a
|
|
169
|
+
#: hand-written adapter, which is what the driver assumes by default.
|
|
170
|
+
self._probe = probe
|
|
171
|
+
#: Seconds one frame write may wait. Non-positive disables the bound,
|
|
172
|
+
#: which is only sane for a caller that publishes off the render path.
|
|
173
|
+
self._write_timeout = write_timeout
|
|
174
|
+
self.protocol = protocol
|
|
175
|
+
#: Set when the producer lost something and owes a whole tree.
|
|
176
|
+
self._force_full = False
|
|
177
|
+
|
|
178
|
+
self._reader: Optional[asyncio.StreamReader] = None
|
|
179
|
+
self._writer: Optional[asyncio.StreamWriter] = None
|
|
180
|
+
self._decoder = FrameDecoder(limits.maxFrameBytes, limits.maxDepth)
|
|
181
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
182
|
+
self._ready: Optional[asyncio.Future] = None
|
|
183
|
+
self._history: MutableMapping[int, Dict[str, Any]] = OrderedDict()
|
|
184
|
+
#: The last tree the driver has, which every delta is based on.
|
|
185
|
+
self._published: Optional[Dict[str, Any]] = None
|
|
186
|
+
#: Counters a test or a diagnostic can read.
|
|
187
|
+
self.deltas_sent = 0
|
|
188
|
+
self.snapshots_sent = 0
|
|
189
|
+
|
|
190
|
+
self.session_id: Optional[str] = None
|
|
191
|
+
self.revision = 0
|
|
192
|
+
self.marker_enabled = False
|
|
193
|
+
#: Log-channel budget from ``hello-ack``; ``None`` means logs are off.
|
|
194
|
+
self.log_budget: Optional[Dict[str, Any]] = None
|
|
195
|
+
self._log_seq = 0
|
|
196
|
+
self._log_bucket: Optional[_TokenBucket] = None
|
|
197
|
+
#: Records dropped locally for being over budget or over a limit.
|
|
198
|
+
self.logs_dropped = 0
|
|
199
|
+
self.subscribe = "snapshots"
|
|
200
|
+
self.closed = False
|
|
201
|
+
|
|
202
|
+
# -- lifecycle ---------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def connected(self) -> bool:
|
|
206
|
+
"""True once the driver has acknowledged the handshake."""
|
|
207
|
+
return self.session_id is not None and not self.closed
|
|
208
|
+
|
|
209
|
+
async def start(self, timeout: float = 5.0) -> bool:
|
|
210
|
+
"""Connect, send ``hello`` and wait for ``hello-ack``.
|
|
211
|
+
|
|
212
|
+
:returns: ``True`` on a completed handshake. Returns ``False`` — never
|
|
213
|
+
raises — when the endpoint is unreachable or the driver rejects us:
|
|
214
|
+
a failed side-channel must not take the application down with it.
|
|
215
|
+
"""
|
|
216
|
+
self._log("sem", f"dial {describe_endpoint(self._endpoint)} timeout={int(timeout * 1000)}ms")
|
|
217
|
+
try:
|
|
218
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
219
|
+
_open_connection(self._endpoint), timeout
|
|
220
|
+
)
|
|
221
|
+
except (OSError, asyncio.TimeoutError, NotImplementedError, AttributeError) as error:
|
|
222
|
+
# AttributeError belongs here: `asyncio.open_unix_connection` does
|
|
223
|
+
# not exist on Windows at all, so a wrong transport choice raises
|
|
224
|
+
# rather than failing to connect, and that must not reach the app.
|
|
225
|
+
self._log("diag", f"dial failed, staying dormant: {_error_label(error)}")
|
|
226
|
+
self.closed = True
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
loop = asyncio.get_event_loop()
|
|
230
|
+
self._ready = loop.create_future()
|
|
231
|
+
self._reader_task = asyncio.ensure_future(self._read_loop())
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
await self._send(
|
|
235
|
+
hello(
|
|
236
|
+
self._token,
|
|
237
|
+
self._adapter_name,
|
|
238
|
+
self._adapter_version,
|
|
239
|
+
self._capabilities,
|
|
240
|
+
self._probe,
|
|
241
|
+
protocol=self.protocol,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
self._log(
|
|
245
|
+
"sem",
|
|
246
|
+
f"hello sent adapter={self._adapter_name}/{self._adapter_version} "
|
|
247
|
+
f"caps={','.join(self._capabilities)}",
|
|
248
|
+
)
|
|
249
|
+
await asyncio.wait_for(asyncio.shield(self._ready), timeout)
|
|
250
|
+
except (asyncio.TimeoutError, asyncio.CancelledError, OSError, TermwrightError) as error:
|
|
251
|
+
self._log("diag", f"handshake failed, staying dormant: {_error_label(error)}")
|
|
252
|
+
await self.close()
|
|
253
|
+
return False
|
|
254
|
+
if self.session_id is None:
|
|
255
|
+
self._log("diag", "handshake ended without a session, staying dormant")
|
|
256
|
+
return self.session_id is not None
|
|
257
|
+
|
|
258
|
+
async def close(self) -> None:
|
|
259
|
+
"""Close the channel. Safe to call more than once."""
|
|
260
|
+
if not self.closed:
|
|
261
|
+
self._log(
|
|
262
|
+
"sem",
|
|
263
|
+
f"close r{self.revision} snapshots={self.snapshots_sent} "
|
|
264
|
+
f"deltas={self.deltas_sent} logs_dropped={self.logs_dropped}",
|
|
265
|
+
)
|
|
266
|
+
self.closed = True
|
|
267
|
+
if self._reader_task is not None:
|
|
268
|
+
self._reader_task.cancel()
|
|
269
|
+
self._reader_task = None
|
|
270
|
+
writer, self._writer = self._writer, None
|
|
271
|
+
if writer is not None:
|
|
272
|
+
try:
|
|
273
|
+
writer.close()
|
|
274
|
+
except OSError:
|
|
275
|
+
pass
|
|
276
|
+
self._reader = None
|
|
277
|
+
|
|
278
|
+
# -- publishing --------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def prepare(self, snapshot: SemanticSnapshot) -> Optional[Dict[str, Any]]:
|
|
281
|
+
"""Allocate the next revision and return the validated wire snapshot.
|
|
282
|
+
|
|
283
|
+
Synchronous on purpose: the caller emits the marker for this revision
|
|
284
|
+
immediately after the render's last byte, so the revision number cannot
|
|
285
|
+
wait on a socket write.
|
|
286
|
+
|
|
287
|
+
The snapshot's ``sessionId``/``revision`` are overwritten with the
|
|
288
|
+
session's own — an adapter never picks its own revision numbers.
|
|
289
|
+
|
|
290
|
+
:raises ProtocolViolation: If the snapshot fails validation. That is an
|
|
291
|
+
adapter bug, not hostile input, so it is loud rather than silent.
|
|
292
|
+
"""
|
|
293
|
+
if not self.connected or self._writer is None or self.session_id is None:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
self.revision += 1
|
|
297
|
+
wire = snapshot.to_wire()
|
|
298
|
+
wire["sessionId"] = self.session_id
|
|
299
|
+
wire["revision"] = self.revision
|
|
300
|
+
|
|
301
|
+
result = validate_snapshot(wire, self._limits)
|
|
302
|
+
if not result.ok:
|
|
303
|
+
self.revision -= 1
|
|
304
|
+
raise ProtocolViolation("snapshot-invalid", f"{result.code}: {result.detail}")
|
|
305
|
+
|
|
306
|
+
return wire
|
|
307
|
+
|
|
308
|
+
async def publish(self, snapshot: SemanticSnapshot) -> Optional[str]:
|
|
309
|
+
"""Send a snapshot for the next revision and return its marker sequence.
|
|
310
|
+
|
|
311
|
+
:returns: The OSC marker to write to stdout after the render, or
|
|
312
|
+
``None`` when the session is not (or no longer) live.
|
|
313
|
+
"""
|
|
314
|
+
wire = self.prepare(snapshot)
|
|
315
|
+
if wire is None:
|
|
316
|
+
return None
|
|
317
|
+
try:
|
|
318
|
+
frames, tree_kind, forced = self._encode_snapshot(wire)
|
|
319
|
+
except ProtocolViolation:
|
|
320
|
+
self._reject_snapshot(wire)
|
|
321
|
+
raise
|
|
322
|
+
if not await self._send_snapshot(frames):
|
|
323
|
+
self._reject_snapshot(wire)
|
|
324
|
+
return None
|
|
325
|
+
self._accept_snapshot(wire, tree_kind, forced)
|
|
326
|
+
return self.marker(wire["revision"])
|
|
327
|
+
|
|
328
|
+
def publish_nowait(self, snapshot: SemanticSnapshot) -> Optional[str]:
|
|
329
|
+
"""Same as :meth:`publish`, but the frames are sent on a background task.
|
|
330
|
+
|
|
331
|
+
The marker comes back at once so it can follow the render immediately;
|
|
332
|
+
the frames still reach the driver in revision order, because each task
|
|
333
|
+
writes to the transport before its first suspension point.
|
|
334
|
+
"""
|
|
335
|
+
wire = self.prepare(snapshot)
|
|
336
|
+
if wire is None:
|
|
337
|
+
return None
|
|
338
|
+
try:
|
|
339
|
+
frames, tree_kind, forced = self._encode_snapshot(wire)
|
|
340
|
+
except ProtocolViolation:
|
|
341
|
+
self._reject_snapshot(wire)
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
writer = self._writer
|
|
345
|
+
if writer is None:
|
|
346
|
+
self._reject_snapshot(wire)
|
|
347
|
+
return None
|
|
348
|
+
try:
|
|
349
|
+
for frame in frames:
|
|
350
|
+
writer.write(frame)
|
|
351
|
+
except (OSError, ConnectionResetError):
|
|
352
|
+
self._reject_snapshot(wire)
|
|
353
|
+
asyncio.ensure_future(self.close())
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
self._accept_snapshot(wire, tree_kind, forced)
|
|
357
|
+
asyncio.ensure_future(self._drain(writer))
|
|
358
|
+
return self.marker(wire["revision"])
|
|
359
|
+
|
|
360
|
+
def _encode_snapshot(
|
|
361
|
+
self, wire: Dict[str, Any]
|
|
362
|
+
) -> Tuple[Tuple[bytes, ...], Optional[str], bool]:
|
|
363
|
+
"""Build and encode a whole publication before writing any of it.
|
|
364
|
+
|
|
365
|
+
``maxFrameBytes`` may be tighter than ``maxSnapshotBytes``. Encoding
|
|
366
|
+
every message first keeps that local refusal atomic: no tree, commit or
|
|
367
|
+
marker escapes, and the last tree the driver actually received remains
|
|
368
|
+
the only legal delta base.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
forced = self._force_full
|
|
372
|
+
messages = []
|
|
373
|
+
tree_kind: Optional[str] = None
|
|
374
|
+
if self.subscribe != "revisions":
|
|
375
|
+
tree, tree_kind = self._tree_message(wire, forced)
|
|
376
|
+
messages.append(tree)
|
|
377
|
+
messages.append(revision_commit(wire["revision"]))
|
|
378
|
+
return (
|
|
379
|
+
tuple(encode_frame(message, self._limits.maxFrameBytes) for message in messages),
|
|
380
|
+
tree_kind,
|
|
381
|
+
forced,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
async def _send_snapshot(self, frames: Sequence[bytes]) -> bool:
|
|
385
|
+
writer = self._writer
|
|
386
|
+
if writer is None:
|
|
387
|
+
return False
|
|
388
|
+
try:
|
|
389
|
+
for frame in frames:
|
|
390
|
+
writer.write(frame)
|
|
391
|
+
except (OSError, ConnectionResetError):
|
|
392
|
+
await self.close()
|
|
393
|
+
return False
|
|
394
|
+
return await self._drain(writer)
|
|
395
|
+
|
|
396
|
+
def _accept_snapshot(
|
|
397
|
+
self, wire: Dict[str, Any], tree_kind: Optional[str], forced: bool
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Commit bookkeeping only after every frame was accepted for writing."""
|
|
400
|
+
|
|
401
|
+
self._remember(wire["revision"], wire)
|
|
402
|
+
if tree_kind is not None:
|
|
403
|
+
self._published = wire
|
|
404
|
+
if tree_kind == "snapshot":
|
|
405
|
+
self.snapshots_sent += 1
|
|
406
|
+
elif tree_kind == "delta":
|
|
407
|
+
self.deltas_sent += 1
|
|
408
|
+
if forced:
|
|
409
|
+
self._force_full = False
|
|
410
|
+
|
|
411
|
+
def _reject_snapshot(self, wire: Dict[str, Any]) -> None:
|
|
412
|
+
"""Undo a locally refused revision and require a full recovery tree."""
|
|
413
|
+
|
|
414
|
+
revision = wire["revision"]
|
|
415
|
+
if self.revision == revision:
|
|
416
|
+
self.revision -= 1
|
|
417
|
+
self._history.pop(revision, None)
|
|
418
|
+
self._force_full = True
|
|
419
|
+
|
|
420
|
+
def _tree_message(
|
|
421
|
+
self, wire: Dict[str, Any], forced: bool
|
|
422
|
+
) -> Tuple[Dict[str, Any], str]:
|
|
423
|
+
"""A delta when the driver asked for one and it is worth sending.
|
|
424
|
+
|
|
425
|
+
Falls back to the whole tree on the first publish, when the driver
|
|
426
|
+
wants snapshots, and whenever the delta would carry more than about
|
|
427
|
+
half the tree — past that a patch costs more than the thing it
|
|
428
|
+
replaces. The base is only advanced once a message is built from it,
|
|
429
|
+
so a skipped publish cannot leave the driver applying a delta onto a
|
|
430
|
+
tree it never received.
|
|
431
|
+
"""
|
|
432
|
+
delta = None
|
|
433
|
+
if self.subscribe == "diffs" and self._published is not None and not forced:
|
|
434
|
+
delta = build_delta(self._published, wire)
|
|
435
|
+
if forced:
|
|
436
|
+
self._log("io", f"r{wire['revision']} full snapshot: the producer reported a gap")
|
|
437
|
+
|
|
438
|
+
if delta is None:
|
|
439
|
+
self._log("io", f"r{wire['revision']} snapshot nodes={len(wire.get('nodes', ()))}")
|
|
440
|
+
return snapshot_message(wire), "snapshot"
|
|
441
|
+
self._log(
|
|
442
|
+
"io",
|
|
443
|
+
f"r{wire['revision']} delta changed={len(delta.get('changed', ()))} "
|
|
444
|
+
f"removed={len(delta.get('removed', ()))}",
|
|
445
|
+
)
|
|
446
|
+
return delta, "delta"
|
|
447
|
+
|
|
448
|
+
def log(
|
|
449
|
+
self,
|
|
450
|
+
level: str,
|
|
451
|
+
message: str,
|
|
452
|
+
*,
|
|
453
|
+
attrs: Optional[Mapping[str, Any]] = None,
|
|
454
|
+
logger: Optional[str] = None,
|
|
455
|
+
ts: Optional[int] = None,
|
|
456
|
+
) -> bool:
|
|
457
|
+
"""Forward one application log record, if the driver asked for logs.
|
|
458
|
+
|
|
459
|
+
Returns whether the record went out. A record is dropped when the
|
|
460
|
+
session is not live, when the driver granted no budget, when this
|
|
461
|
+
adapter is over its rate, or when the record breaks a limit.
|
|
462
|
+
|
|
463
|
+
Every attempt consumes a sequence number, dropped or not: the gap left
|
|
464
|
+
in ``seq`` is precisely how the driver learns records were lost here
|
|
465
|
+
rather than in transit.
|
|
466
|
+
"""
|
|
467
|
+
if not self.connected or self._log_bucket is None:
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
self._log_seq += 1
|
|
471
|
+
record = LogRecord(
|
|
472
|
+
ts=int(time.time() * 1000) if ts is None else ts,
|
|
473
|
+
level=level,
|
|
474
|
+
message=message,
|
|
475
|
+
seq=self._log_seq,
|
|
476
|
+
attrs=flatten_attrs(attrs) if attrs else None,
|
|
477
|
+
logger=logger,
|
|
478
|
+
revision=self.revision or None,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
if not self._log_bucket.take(time.monotonic()):
|
|
482
|
+
self.logs_dropped += 1
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
wire = record.to_wire()
|
|
486
|
+
result = validate_log_record(wire, self._limits)
|
|
487
|
+
if not result.ok:
|
|
488
|
+
# An oversized or malformed record is dropped locally rather than
|
|
489
|
+
# taking the channel down; the gap in seq reports it.
|
|
490
|
+
self.logs_dropped += 1
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
asyncio.ensure_future(self._send(log_message(record)))
|
|
494
|
+
return True
|
|
495
|
+
|
|
496
|
+
def marker(self, revision: int) -> Optional[str]:
|
|
497
|
+
"""Marker sequence committing ``revision``, or ``None`` if not enabled."""
|
|
498
|
+
if not self.marker_enabled or self.session_id is None:
|
|
499
|
+
return None
|
|
500
|
+
return encode_marker(self._token, self.session_id, revision)
|
|
501
|
+
|
|
502
|
+
def require_full_snapshot(self) -> None:
|
|
503
|
+
"""Make the next publish send a whole tree.
|
|
504
|
+
|
|
505
|
+
The producer's obligation from D5: a probe that lost anything from its
|
|
506
|
+
own stream of facts — a dropped frame, a coalesced burst, a write that
|
|
507
|
+
failed — must not follow it with a patch. The driver would apply that
|
|
508
|
+
patch to a tree that never accounted for what was lost, and the
|
|
509
|
+
divergence would be silent.
|
|
510
|
+
"""
|
|
511
|
+
self._force_full = True
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def full_snapshot_required(self) -> bool:
|
|
515
|
+
"""Whether the obligation is outstanding."""
|
|
516
|
+
return self._force_full
|
|
517
|
+
|
|
518
|
+
def _log(self, category: str, message: str) -> None:
|
|
519
|
+
"""Write one diagnostic line, when diagnostics are on."""
|
|
520
|
+
if self._debug is not None:
|
|
521
|
+
self._debug.line(category, message)
|
|
522
|
+
|
|
523
|
+
def _remember(self, revision: int, wire: Dict[str, Any]) -> None:
|
|
524
|
+
self._history[revision] = wire
|
|
525
|
+
while len(self._history) > _SNAPSHOT_HISTORY:
|
|
526
|
+
self._history.pop(next(iter(self._history)))
|
|
527
|
+
|
|
528
|
+
async def _send(self, message: Mapping[str, Any]) -> None:
|
|
529
|
+
writer = self._writer
|
|
530
|
+
if writer is None:
|
|
531
|
+
return
|
|
532
|
+
frame = encode_frame(message, self._limits.maxFrameBytes)
|
|
533
|
+
try:
|
|
534
|
+
writer.write(frame)
|
|
535
|
+
except (OSError, ConnectionResetError):
|
|
536
|
+
await self.close()
|
|
537
|
+
return
|
|
538
|
+
await self._drain(writer)
|
|
539
|
+
|
|
540
|
+
async def _drain(self, writer: Any) -> bool:
|
|
541
|
+
"""Drain already-written frames and make background failures quiet."""
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
if self._write_timeout > 0:
|
|
545
|
+
await asyncio.wait_for(writer.drain(), self._write_timeout)
|
|
546
|
+
else:
|
|
547
|
+
await writer.drain()
|
|
548
|
+
except asyncio.TimeoutError:
|
|
549
|
+
# Part of a length-prefixed frame may already be on the wire and
|
|
550
|
+
# there is no resynchronisation point, so the session is over
|
|
551
|
+
# rather than merely delayed.
|
|
552
|
+
self._log(
|
|
553
|
+
"diag",
|
|
554
|
+
f"write deadline of {int(self._write_timeout * 1000)}ms exceeded; "
|
|
555
|
+
"session is unrecoverable",
|
|
556
|
+
)
|
|
557
|
+
await self.close()
|
|
558
|
+
return False
|
|
559
|
+
except (OSError, ConnectionResetError):
|
|
560
|
+
await self.close()
|
|
561
|
+
return False
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
# -- receiving ---------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
async def _read_loop(self) -> None:
|
|
567
|
+
reader = self._reader
|
|
568
|
+
if reader is None:
|
|
569
|
+
return
|
|
570
|
+
try:
|
|
571
|
+
while True:
|
|
572
|
+
chunk = await reader.read(64 * 1024)
|
|
573
|
+
if not chunk:
|
|
574
|
+
break
|
|
575
|
+
for raw in self._decoder.push(chunk):
|
|
576
|
+
await self._handle(raw)
|
|
577
|
+
except (asyncio.CancelledError, ProtocolViolation, OSError):
|
|
578
|
+
pass
|
|
579
|
+
finally:
|
|
580
|
+
if self._ready is not None and not self._ready.done():
|
|
581
|
+
self._ready.set_result(False)
|
|
582
|
+
self.closed = True
|
|
583
|
+
|
|
584
|
+
async def _handle(self, raw: Any) -> None:
|
|
585
|
+
parsed = parse_driver_message(raw, self._limits)
|
|
586
|
+
if not parsed.ok:
|
|
587
|
+
self._log("diag", f"rejected a driver message: {parsed.detail[:200]}")
|
|
588
|
+
await self._send(protocol_error("malformed", parsed.detail[:512]))
|
|
589
|
+
await self.close()
|
|
590
|
+
return
|
|
591
|
+
message = parsed.message
|
|
592
|
+
assert message is not None
|
|
593
|
+
|
|
594
|
+
if message["type"] == "hello-ack":
|
|
595
|
+
if message["protocol"] != self.protocol:
|
|
596
|
+
self._log("diag", f"driver acknowledged {message['protocol']} after requesting {self.protocol}")
|
|
597
|
+
await self.close()
|
|
598
|
+
return
|
|
599
|
+
self.session_id = message["sessionId"]
|
|
600
|
+
self.marker_enabled = bool(message["marker"]["enabled"])
|
|
601
|
+
self.log_budget = message.get("logs")
|
|
602
|
+
budget = self.log_budget
|
|
603
|
+
if budget is not None and budget.get("enabled"):
|
|
604
|
+
self._log_bucket = _TokenBucket(
|
|
605
|
+
int(budget["maxRecordsPerSecond"]), int(budget["burst"]), time.monotonic()
|
|
606
|
+
)
|
|
607
|
+
else:
|
|
608
|
+
self._log_bucket = None
|
|
609
|
+
self.subscribe = message["subscribe"]
|
|
610
|
+
self._limits = ProtocolLimits.from_wire(message["limits"])
|
|
611
|
+
if self._debug is not None:
|
|
612
|
+
self._debug.label = self.session_id or ""
|
|
613
|
+
self._log(
|
|
614
|
+
"sem",
|
|
615
|
+
f"hello-ack session={self.session_id} marker={'on' if self.marker_enabled else 'off'} "
|
|
616
|
+
f"subscribe={self.subscribe} logs={'on' if self._log_bucket is not None else 'off'}",
|
|
617
|
+
)
|
|
618
|
+
if self._ready is not None and not self._ready.done():
|
|
619
|
+
self._ready.set_result(True)
|
|
620
|
+
elif message["type"] == "get-tree":
|
|
621
|
+
requested = message.get("revision", self.revision)
|
|
622
|
+
held = self._history.get(requested)
|
|
623
|
+
if held is None:
|
|
624
|
+
await self._send(
|
|
625
|
+
get_tree_result(message["requestId"], error=f"revision {requested} is not retained")
|
|
626
|
+
)
|
|
627
|
+
else:
|
|
628
|
+
await self._send(get_tree_result(message["requestId"], snapshot=held))
|
|
629
|
+
elif message["type"] == "error":
|
|
630
|
+
self._log("diag", f"driver ended the session: {message.get('code')}")
|
|
631
|
+
await self.close()
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def client_from_env(
|
|
635
|
+
*,
|
|
636
|
+
adapter_name: str,
|
|
637
|
+
adapter_version: str,
|
|
638
|
+
capabilities: Sequence[str] = DEFAULT_CAPABILITIES,
|
|
639
|
+
env: Optional[Mapping[str, str]] = None,
|
|
640
|
+
limits: ProtocolLimits = DEFAULT_LIMITS,
|
|
641
|
+
debug: Optional[DebugLog] = None,
|
|
642
|
+
probe: Optional[Mapping[str, Any]] = None,
|
|
643
|
+
qualified_capabilities: Sequence[str] = (),
|
|
644
|
+
) -> Optional[SemanticClient]:
|
|
645
|
+
"""Build a client from ``TERMWRIGHT_*``, or ``None`` when not instrumented.
|
|
646
|
+
|
|
647
|
+
This is the dormant rule in one function: no endpoint or no token means no
|
|
648
|
+
client, and the caller must then do nothing at all.
|
|
649
|
+
|
|
650
|
+
When diagnostics are enabled — by ``TERMWRIGHT_DEBUG_FILE``, or by passing
|
|
651
|
+
``debug`` — the *reason* for staying dormant is written to the log before
|
|
652
|
+
returning ``None``. That line is the whole point of the file: a run where
|
|
653
|
+
the adapter never attached otherwise leaves no trace anywhere.
|
|
654
|
+
"""
|
|
655
|
+
source: Mapping[str, str] = os.environ if env is None else env
|
|
656
|
+
log = DebugLog.from_env(source, adapter=adapter_name) if debug is None else debug
|
|
657
|
+
endpoint = source.get(ENV_ENDPOINT)
|
|
658
|
+
token = source.get(ENV_TOKEN)
|
|
659
|
+
if not endpoint or not token:
|
|
660
|
+
if log is not None:
|
|
661
|
+
missing = [
|
|
662
|
+
name
|
|
663
|
+
for name, value in ((ENV_ENDPOINT, endpoint), (ENV_TOKEN, token))
|
|
664
|
+
if not value
|
|
665
|
+
]
|
|
666
|
+
log.line("diag", f"dormant: {' and '.join(missing)} not set")
|
|
667
|
+
return None
|
|
668
|
+
protocol = source.get(ENV_PROTOCOL)
|
|
669
|
+
if protocol is not None and protocol not in ("", PROTOCOL_ID, PROTOCOL_V2_ID, "1", "2"):
|
|
670
|
+
if log is not None:
|
|
671
|
+
log.line(
|
|
672
|
+
"diag",
|
|
673
|
+
f"dormant: {ENV_PROTOCOL}={protocol!r} is not {PROTOCOL_ID!r}",
|
|
674
|
+
)
|
|
675
|
+
return None
|
|
676
|
+
selected_protocol = PROTOCOL_V2_ID if protocol in (PROTOCOL_V2_ID, "2") else PROTOCOL_ID
|
|
677
|
+
selected_capabilities = tuple(capabilities)
|
|
678
|
+
if selected_protocol == PROTOCOL_V2_ID and "qualified-observations" not in selected_capabilities:
|
|
679
|
+
selected_capabilities += ("qualified-observations",)
|
|
680
|
+
if selected_protocol == PROTOCOL_V2_ID:
|
|
681
|
+
selected_capabilities += tuple(
|
|
682
|
+
item for item in qualified_capabilities if item not in selected_capabilities
|
|
683
|
+
)
|
|
684
|
+
return SemanticClient(
|
|
685
|
+
endpoint,
|
|
686
|
+
token,
|
|
687
|
+
adapter_name=adapter_name,
|
|
688
|
+
adapter_version=adapter_version,
|
|
689
|
+
capabilities=selected_capabilities,
|
|
690
|
+
limits=limits,
|
|
691
|
+
debug=log,
|
|
692
|
+
probe=probe,
|
|
693
|
+
protocol=selected_protocol,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _error_label(error: BaseException) -> str:
|
|
698
|
+
"""One-line description of a failure: class, errno and first message line.
|
|
699
|
+
|
|
700
|
+
The class alone is what usually settles a Windows question — a
|
|
701
|
+
``FileNotFoundError`` on a pipe path means the driver was never listening,
|
|
702
|
+
while a ``NotImplementedError`` means the loop could not open one at all —
|
|
703
|
+
so it is always printed, even when the message is empty.
|
|
704
|
+
"""
|
|
705
|
+
code = getattr(error, "errno", None)
|
|
706
|
+
suffix = f" [errno {code}]" if code is not None else ""
|
|
707
|
+
text = str(error).split("\n")[0]
|
|
708
|
+
return f"{type(error).__name__}{suffix}" + (f": {text}" if text else "")
|