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/debug.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Opt-in diagnostic log for the adapter side, written to a file.
|
|
2
|
+
|
|
3
|
+
The driver has its own live log (`TERMWRIGHT_DEBUG=1`, stderr, see
|
|
4
|
+
`packages/driver/src/debug.ts`). This is the other half: what the *adapter*
|
|
5
|
+
inside the application decided, which is the half that goes missing when a
|
|
6
|
+
conformance run reports skips and nobody can say why the app never attached.
|
|
7
|
+
|
|
8
|
+
**Never stderr.** The application under test owns the terminal; a stray line on
|
|
9
|
+
stderr lands in the middle of a render and corrupts the very screen the driver
|
|
10
|
+
is asserting on. So this log goes to a file the caller names, or nowhere.
|
|
11
|
+
|
|
12
|
+
**Never fatal.** Every failure here — an unwritable path, a full disk, a closed
|
|
13
|
+
file — leaves the application running and the log silently off. A diagnostic
|
|
14
|
+
that can break the thing it diagnoses is worse than no diagnostic.
|
|
15
|
+
|
|
16
|
+
Enable it with either variable::
|
|
17
|
+
|
|
18
|
+
TERMWRIGHT_DEBUG_FILE=/tmp/adapter.log # preferred
|
|
19
|
+
TERMWRIGHT_DEBUG=/tmp/adapter.log # path, not 1/true/all
|
|
20
|
+
|
|
21
|
+
The second form is a convenience, and it is deliberately restricted to values
|
|
22
|
+
that are *not* the driver's own switches: `TERMWRIGHT_DEBUG=1` reaches the
|
|
23
|
+
child process too, and if that turned this log on it would have to invent a
|
|
24
|
+
destination for it. So `1`, `true`, `on`, `api`, `all`, `0`, `false` and `off`
|
|
25
|
+
all leave the adapter silent, and only a path enables it.
|
|
26
|
+
|
|
27
|
+
The line format matches the driver's so one reader can take both::
|
|
28
|
+
|
|
29
|
+
tw:diag [p41207] 0.004s dormant: TERMWRIGHT_ENDPOINT is not set
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import sys
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
from typing import Mapping, Optional
|
|
39
|
+
|
|
40
|
+
#: Names the file this log is written to. Preferred over `TERMWRIGHT_DEBUG`
|
|
41
|
+
#: because it cannot collide with the driver's stderr switch.
|
|
42
|
+
ENV_DEBUG_FILE = "TERMWRIGHT_DEBUG_FILE"
|
|
43
|
+
|
|
44
|
+
#: The driver's switch, honoured here only when it carries a path.
|
|
45
|
+
ENV_DEBUG = "TERMWRIGHT_DEBUG"
|
|
46
|
+
|
|
47
|
+
#: Values of `TERMWRIGHT_DEBUG` that mean "driver-side logging" and must not be
|
|
48
|
+
#: mistaken for a filename.
|
|
49
|
+
_DRIVER_SWITCHES = frozenset({"", "0", "1", "true", "false", "on", "off", "api", "all"})
|
|
50
|
+
|
|
51
|
+
#: Categories, borrowed from the driver so a reader greps one vocabulary.
|
|
52
|
+
#: `diag` is a decision or a failure, `sem` the semantic session, `io` traffic.
|
|
53
|
+
_CATEGORIES = ("diag", "sem", "io", "app")
|
|
54
|
+
|
|
55
|
+
_MAX_MESSAGE = 400
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def debug_path(env: Optional[Mapping[str, str]] = None) -> Optional[str]:
|
|
59
|
+
"""The file this process should log to, or ``None`` to stay silent."""
|
|
60
|
+
source: Mapping[str, str] = os.environ if env is None else env
|
|
61
|
+
explicit = (source.get(ENV_DEBUG_FILE) or "").strip()
|
|
62
|
+
if explicit:
|
|
63
|
+
return explicit
|
|
64
|
+
raw = (source.get(ENV_DEBUG) or "").strip()
|
|
65
|
+
if not raw or raw.lower() in _DRIVER_SWITCHES:
|
|
66
|
+
return None
|
|
67
|
+
return raw
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DebugLog:
|
|
71
|
+
"""Appends diagnostic lines to one file.
|
|
72
|
+
|
|
73
|
+
Instances are cheap but not free — they hold an open file — so build one
|
|
74
|
+
per process and pass it down, which is what :func:`from_env` encourages.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, handle, *, label: str, now=time.monotonic) -> None:
|
|
78
|
+
self._handle = handle
|
|
79
|
+
self._label = label
|
|
80
|
+
self._now = now
|
|
81
|
+
self._started = now()
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
|
|
84
|
+
# -- construction ------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_env(
|
|
88
|
+
cls,
|
|
89
|
+
env: Optional[Mapping[str, str]] = None,
|
|
90
|
+
*,
|
|
91
|
+
adapter: str = "adapter",
|
|
92
|
+
) -> Optional["DebugLog"]:
|
|
93
|
+
"""Open the log named by the environment, or return ``None``.
|
|
94
|
+
|
|
95
|
+
Returns ``None`` for every failure, including an unwritable path: a
|
|
96
|
+
diagnostic that refuses to start must not stop the application.
|
|
97
|
+
"""
|
|
98
|
+
path = debug_path(env)
|
|
99
|
+
if path is None:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
handle = open(path, "a", encoding="utf-8", errors="replace")
|
|
103
|
+
except OSError:
|
|
104
|
+
return None
|
|
105
|
+
log = cls(handle, label=f"p{os.getpid()}")
|
|
106
|
+
log.line(
|
|
107
|
+
"diag",
|
|
108
|
+
f"open adapter={adapter} pid={os.getpid()} platform={sys.platform} "
|
|
109
|
+
f"python={sys.version_info[0]}.{sys.version_info[1]} argv0={_short(sys.argv[0] if sys.argv else '')}",
|
|
110
|
+
)
|
|
111
|
+
return log
|
|
112
|
+
|
|
113
|
+
# -- writing -----------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def label(self) -> str:
|
|
117
|
+
"""The bracketed identifier on every line."""
|
|
118
|
+
return self._label
|
|
119
|
+
|
|
120
|
+
@label.setter
|
|
121
|
+
def label(self, value: str) -> None:
|
|
122
|
+
"""Adopt the driver's session id once the handshake supplies one."""
|
|
123
|
+
self._label = value[:8] if value else self._label
|
|
124
|
+
|
|
125
|
+
def line(self, category: str, message: str) -> None:
|
|
126
|
+
"""Write one line. Silently does nothing once the file is gone."""
|
|
127
|
+
if category not in _CATEGORIES:
|
|
128
|
+
category = "diag"
|
|
129
|
+
if len(message) > _MAX_MESSAGE:
|
|
130
|
+
message = f"{message[:_MAX_MESSAGE]}…"
|
|
131
|
+
seconds = f"{self._now() - self._started:.3f}".rjust(7)
|
|
132
|
+
text = f" tw:{category.ljust(4)} [{self._label}] {seconds}s {message}\n"
|
|
133
|
+
with self._lock:
|
|
134
|
+
handle = self._handle
|
|
135
|
+
if handle is None:
|
|
136
|
+
return
|
|
137
|
+
try:
|
|
138
|
+
handle.write(text)
|
|
139
|
+
handle.flush()
|
|
140
|
+
except (OSError, ValueError):
|
|
141
|
+
# ValueError covers a file closed underneath us. Either way the
|
|
142
|
+
# log is over; the application is not.
|
|
143
|
+
self._handle = None
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
"""Close the file. Safe to call more than once."""
|
|
147
|
+
with self._lock:
|
|
148
|
+
handle, self._handle = self._handle, None
|
|
149
|
+
if handle is not None:
|
|
150
|
+
try:
|
|
151
|
+
handle.close()
|
|
152
|
+
except OSError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _short(value: str, limit: int = 60) -> str:
|
|
157
|
+
"""A path or argument shortened for one log line."""
|
|
158
|
+
return value if len(value) <= limit else f"…{value[-(limit - 1):]}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def describe_endpoint(endpoint: str) -> str:
|
|
162
|
+
"""How an endpoint reads in the log: its transport and its path.
|
|
163
|
+
|
|
164
|
+
The endpoint is not a secret — the token is, and the token never appears
|
|
165
|
+
here — but it is long, so it is shortened from the left, keeping the tail
|
|
166
|
+
that distinguishes one session's socket from another's.
|
|
167
|
+
"""
|
|
168
|
+
kind = "pipe" if endpoint.startswith("\\\\") else "unix"
|
|
169
|
+
return f"{kind}:{_short(endpoint)}"
|
termwright/diffing.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Turning two consecutive trees into the delta between them.
|
|
2
|
+
|
|
3
|
+
The driver asks for `subscribe: 'diffs'` when it wants patches instead of whole
|
|
4
|
+
trees. Producing one is the mirror of composing one, and it has to agree with
|
|
5
|
+
:func:`termwright.validate.apply_tree_delta` exactly: whatever this emits, the
|
|
6
|
+
driver will apply, and any disagreement shows up as a tree that silently drifts
|
|
7
|
+
from the screen.
|
|
8
|
+
|
|
9
|
+
Two rules here are easy to get wrong and both are load-bearing:
|
|
10
|
+
|
|
11
|
+
* A node that *survives* under a parent being removed must be re-sent in
|
|
12
|
+
``changed``, even when nothing about it changed, because the removal cascades
|
|
13
|
+
through it first.
|
|
14
|
+
* ``rootIds`` must be sent whenever the inherited list — the base's roots minus
|
|
15
|
+
whatever the removals took — is not the list the new tree wants.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
|
22
|
+
|
|
23
|
+
#: Beyond this share of the tree, a delta stops paying for itself and the whole
|
|
24
|
+
#: snapshot is cheaper to send and cheaper to reason about.
|
|
25
|
+
DELTA_SHARE_CEILING = 0.5
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _canonical(node: Mapping[str, Any]) -> str:
|
|
29
|
+
"""Stable text for a node, so two nodes compare by value."""
|
|
30
|
+
return json.dumps(node, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def diff_trees(
|
|
34
|
+
base: Mapping[str, Any], next_tree: Mapping[str, Any]
|
|
35
|
+
) -> Tuple[List[Dict[str, Any]], List[str], Optional[List[str]], bool]:
|
|
36
|
+
"""Compute ``(changed, removed, root_ids, cursor_changed)``.
|
|
37
|
+
|
|
38
|
+
``root_ids`` is ``None`` when the inherited list already matches, which
|
|
39
|
+
keeps the delta smaller by the most common amount.
|
|
40
|
+
"""
|
|
41
|
+
base_nodes: Dict[str, Any] = {node["id"]: node for node in base["nodes"]}
|
|
42
|
+
next_nodes: Dict[str, Any] = {node["id"]: node for node in next_tree["nodes"]}
|
|
43
|
+
|
|
44
|
+
children_of: Dict[str, List[str]] = {}
|
|
45
|
+
for node in base["nodes"]:
|
|
46
|
+
parent = node.get("parentId")
|
|
47
|
+
if parent is not None:
|
|
48
|
+
children_of.setdefault(parent, []).append(node["id"])
|
|
49
|
+
|
|
50
|
+
gone = {node_id for node_id in base_nodes if node_id not in next_nodes}
|
|
51
|
+
|
|
52
|
+
# Only the topmost id of each removed subtree needs sending: the cascade
|
|
53
|
+
# takes the rest, which is what makes a delta small.
|
|
54
|
+
removal_roots: List[str] = []
|
|
55
|
+
for node_id in gone:
|
|
56
|
+
parent = base_nodes[node_id].get("parentId")
|
|
57
|
+
if parent is None or parent not in gone:
|
|
58
|
+
removal_roots.append(node_id)
|
|
59
|
+
|
|
60
|
+
# Everything the cascade will take, so survivors underneath can be re-sent.
|
|
61
|
+
swept: Set[str] = set()
|
|
62
|
+
pending = list(removal_roots)
|
|
63
|
+
while pending:
|
|
64
|
+
current = pending.pop()
|
|
65
|
+
if current in swept:
|
|
66
|
+
continue
|
|
67
|
+
swept.add(current)
|
|
68
|
+
pending.extend(children_of.get(current, ()))
|
|
69
|
+
|
|
70
|
+
changed: List[Dict[str, Any]] = []
|
|
71
|
+
for node_id, node in next_nodes.items():
|
|
72
|
+
previous = base_nodes.get(node_id)
|
|
73
|
+
if previous is None or _canonical(previous) != _canonical(node) or node_id in swept:
|
|
74
|
+
changed.append(dict(node))
|
|
75
|
+
|
|
76
|
+
survivors = (set(base_nodes) - swept) | set(next_nodes)
|
|
77
|
+
inherited = [node_id for node_id in base["rootIds"] if node_id in survivors]
|
|
78
|
+
root_ids = None if inherited == list(next_tree["rootIds"]) else list(next_tree["rootIds"])
|
|
79
|
+
|
|
80
|
+
cursor_changed = base.get("cursor") != next_tree.get("cursor")
|
|
81
|
+
return changed, sorted(removal_roots), root_ids, cursor_changed
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_delta(
|
|
85
|
+
base: Mapping[str, Any], next_tree: Mapping[str, Any]
|
|
86
|
+
) -> Optional[Dict[str, Any]]:
|
|
87
|
+
"""Build the `tree-delta` body, or ``None`` when a full tree is cheaper.
|
|
88
|
+
|
|
89
|
+
Returning ``None`` is not a failure: past roughly half the tree a delta
|
|
90
|
+
costs more to send and far more to reason about than the snapshot it
|
|
91
|
+
replaces.
|
|
92
|
+
"""
|
|
93
|
+
changed, removed, root_ids, cursor_changed = diff_trees(base, next_tree)
|
|
94
|
+
|
|
95
|
+
node_count = max(1, len(next_tree["nodes"]))
|
|
96
|
+
if len(changed) > node_count * DELTA_SHARE_CEILING:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
if base.get("cursor") is not None and next_tree.get("cursor") is None:
|
|
100
|
+
# A delta can replace a cursor but never remove one, and an absent
|
|
101
|
+
# cursor is inherited — so the only honest way to drop it is a whole
|
|
102
|
+
# tree. Sending the delta anyway would leave the driver holding a
|
|
103
|
+
# cursor the application no longer reports.
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
delta: Dict[str, Any] = {
|
|
107
|
+
"type": "tree-delta",
|
|
108
|
+
"baseRevision": base["revision"],
|
|
109
|
+
"revision": next_tree["revision"],
|
|
110
|
+
"changed": changed,
|
|
111
|
+
"removed": removed,
|
|
112
|
+
}
|
|
113
|
+
if root_ids is not None:
|
|
114
|
+
delta["rootIds"] = root_ids
|
|
115
|
+
# An absent cursor means unchanged, so it is sent only when it moved.
|
|
116
|
+
if cursor_changed and next_tree.get("cursor") is not None:
|
|
117
|
+
delta["cursor"] = next_tree["cursor"]
|
|
118
|
+
return delta
|
termwright/errors.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Error types shared by the protocol modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TermwrightError(Exception):
|
|
7
|
+
"""Base class for every error raised across this package's public surface."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProtocolViolation(TermwrightError):
|
|
11
|
+
"""Untrusted input broke a wire invariant.
|
|
12
|
+
|
|
13
|
+
``code`` mirrors the reference implementation's ``ProtocolViolation.code``
|
|
14
|
+
so the cross-language vectors can assert on it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, code: str, message: str) -> None:
|
|
18
|
+
super().__init__(f"{code}: {message}")
|
|
19
|
+
self.code = code
|
|
20
|
+
self.detail = message
|
termwright/framing.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Wire framing: 4-byte big-endian unsigned length prefix + UTF-8 JSON body.
|
|
2
|
+
|
|
3
|
+
The declared length is checked against ``max_frame_bytes`` before any decoding.
|
|
4
|
+
Anything oversized, partial past the ceiling, or structurally hostile fails
|
|
5
|
+
closed with :class:`ProtocolViolation`, and a failed decoder never resumes:
|
|
6
|
+
resynchronising on an attacker-chosen offset is worse than dropping the link.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, List, Optional
|
|
14
|
+
|
|
15
|
+
from .errors import ProtocolViolation
|
|
16
|
+
|
|
17
|
+
FRAME_HEADER_BYTES = 4
|
|
18
|
+
|
|
19
|
+
#: Property names that carry meaning in JavaScript engines. The reference
|
|
20
|
+
#: implementation rejects them, so we reject them too — a Python adapter must
|
|
21
|
+
#: not be able to smuggle a payload past a JS driver's projection.
|
|
22
|
+
RESERVED_KEYS = frozenset({"__proto__", "constructor", "prototype"})
|
|
23
|
+
|
|
24
|
+
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
25
|
+
|
|
26
|
+
_JSON_SEPARATORS = (",", ":")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _reject_constant(name: str) -> Any:
|
|
30
|
+
raise ProtocolViolation("dto-scalar", f"non-finite number literal {name}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_string(value: str, path: str, what: str) -> None:
|
|
34
|
+
if _LONE_SURROGATE.search(value):
|
|
35
|
+
raise ProtocolViolation("dto-string", f"unpaired surrogate in {what} at {path}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def project_dto(value: Any, max_depth: int, _depth: int = 0, _path: str = "$") -> Any:
|
|
39
|
+
"""Deep-check an untrusted parsed value and return a JSON-safe copy.
|
|
40
|
+
|
|
41
|
+
:param value: Untrusted input, typically the result of :func:`json.loads`.
|
|
42
|
+
:param max_depth: Maximum nesting depth; the root sits at depth 0.
|
|
43
|
+
:raises ProtocolViolation: On reserved keys, unpaired surrogates, non-finite
|
|
44
|
+
numbers, non-JSON types, or nesting beyond ``max_depth``.
|
|
45
|
+
"""
|
|
46
|
+
if _depth == 0 and (not isinstance(max_depth, int) or max_depth < 0):
|
|
47
|
+
raise ProtocolViolation("dto-depth", "max_depth must be a non-negative integer")
|
|
48
|
+
|
|
49
|
+
if value is None or isinstance(value, bool):
|
|
50
|
+
return value
|
|
51
|
+
if isinstance(value, int):
|
|
52
|
+
return value
|
|
53
|
+
if isinstance(value, float):
|
|
54
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
55
|
+
raise ProtocolViolation("dto-scalar", f"non-finite number at {_path}")
|
|
56
|
+
return value
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
_check_string(value, _path, "string")
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
if _depth > max_depth:
|
|
62
|
+
raise ProtocolViolation("dto-depth", f"nesting exceeds {max_depth} at {_path}")
|
|
63
|
+
|
|
64
|
+
if isinstance(value, list):
|
|
65
|
+
return [
|
|
66
|
+
project_dto(item, max_depth, _depth + 1, f"{_path}[{index}]")
|
|
67
|
+
for index, item in enumerate(value)
|
|
68
|
+
]
|
|
69
|
+
if isinstance(value, dict):
|
|
70
|
+
projected = {}
|
|
71
|
+
for key, item in value.items():
|
|
72
|
+
if not isinstance(key, str):
|
|
73
|
+
raise ProtocolViolation("dto-key", f"non-string key at {_path}")
|
|
74
|
+
if key in RESERVED_KEYS:
|
|
75
|
+
raise ProtocolViolation("dto-key", f'reserved property name "{key}" at {_path}')
|
|
76
|
+
_check_string(key, _path, "key")
|
|
77
|
+
projected[key] = project_dto(item, max_depth, _depth + 1, f"{_path}.{key}")
|
|
78
|
+
return projected
|
|
79
|
+
|
|
80
|
+
raise ProtocolViolation(
|
|
81
|
+
"dto-scalar", f"value of type {type(value).__name__} is not JSON-representable at {_path}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def encode_json(message: Any) -> bytes:
|
|
86
|
+
"""Serialise to the canonical UTF-8 JSON body (no spaces, no NaN)."""
|
|
87
|
+
try:
|
|
88
|
+
text = json.dumps(message, ensure_ascii=False, separators=_JSON_SEPARATORS, allow_nan=False)
|
|
89
|
+
except (TypeError, ValueError) as error:
|
|
90
|
+
raise ProtocolViolation("frame-malformed", "message is not JSON-serialisable") from error
|
|
91
|
+
try:
|
|
92
|
+
return text.encode("utf-8")
|
|
93
|
+
except UnicodeEncodeError as error:
|
|
94
|
+
raise ProtocolViolation("dto-string", "message contains an unpaired surrogate") from error
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def encode_frame(message: Any, max_frame_bytes: int) -> bytes:
|
|
98
|
+
"""Serialise a message into a single length-prefixed frame.
|
|
99
|
+
|
|
100
|
+
:raises ProtocolViolation: If the value is not JSON-representable or the
|
|
101
|
+
encoded body exceeds ``max_frame_bytes``.
|
|
102
|
+
"""
|
|
103
|
+
_assert_ceiling(max_frame_bytes)
|
|
104
|
+
body = encode_json(message)
|
|
105
|
+
if len(body) > max_frame_bytes:
|
|
106
|
+
raise ProtocolViolation(
|
|
107
|
+
"frame-oversized",
|
|
108
|
+
f"encoded frame is {len(body)} bytes, ceiling is {max_frame_bytes}",
|
|
109
|
+
)
|
|
110
|
+
return len(body).to_bytes(FRAME_HEADER_BYTES, "big") + body
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _assert_ceiling(max_frame_bytes: int) -> None:
|
|
114
|
+
if not isinstance(max_frame_bytes, int) or isinstance(max_frame_bytes, bool) or max_frame_bytes <= 0:
|
|
115
|
+
raise ProtocolViolation("frame-malformed", "max_frame_bytes must be a positive integer")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def decode_body(body: bytes, max_depth: int) -> Any:
|
|
119
|
+
"""Decode one frame body into a projected DTO."""
|
|
120
|
+
try:
|
|
121
|
+
text = body.decode("utf-8")
|
|
122
|
+
except UnicodeDecodeError as error:
|
|
123
|
+
raise ProtocolViolation("frame-encoding", "frame body is not valid UTF-8") from error
|
|
124
|
+
try:
|
|
125
|
+
parsed = json.loads(text, parse_constant=_reject_constant)
|
|
126
|
+
except ProtocolViolation:
|
|
127
|
+
raise
|
|
128
|
+
except (ValueError, RecursionError) as error:
|
|
129
|
+
raise ProtocolViolation("frame-malformed", "frame body is not valid JSON") from error
|
|
130
|
+
return project_dto(parsed, max_depth)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class FrameDecoder:
|
|
134
|
+
"""Streaming decoder for length-prefixed JSON frames.
|
|
135
|
+
|
|
136
|
+
Feed arbitrary chunks to :meth:`push`; it returns the messages that became
|
|
137
|
+
complete. The first violation poisons the instance permanently.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, max_frame_bytes: int, max_depth: int) -> None:
|
|
141
|
+
_assert_ceiling(max_frame_bytes)
|
|
142
|
+
self._max_frame_bytes = max_frame_bytes
|
|
143
|
+
self._max_depth = max_depth
|
|
144
|
+
self._buffer = bytearray()
|
|
145
|
+
self._failure: Optional[ProtocolViolation] = None
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def buffered(self) -> int:
|
|
149
|
+
"""Bytes held back waiting for the rest of a frame."""
|
|
150
|
+
return len(self._buffer)
|
|
151
|
+
|
|
152
|
+
def push(self, chunk: bytes) -> List[Any]:
|
|
153
|
+
"""Feed raw bytes; return the frames that completed, in order."""
|
|
154
|
+
if self._failure is not None:
|
|
155
|
+
raise ProtocolViolation(
|
|
156
|
+
"decoder-poisoned",
|
|
157
|
+
f"decoder failed earlier ({self._failure.code}) and accepts no further input",
|
|
158
|
+
)
|
|
159
|
+
try:
|
|
160
|
+
return self._push_or_raise(chunk)
|
|
161
|
+
except ProtocolViolation as error:
|
|
162
|
+
self._failure = error
|
|
163
|
+
self._buffer = bytearray()
|
|
164
|
+
raise
|
|
165
|
+
except Exception as error: # pragma: no cover - defensive
|
|
166
|
+
self._failure = ProtocolViolation("frame-malformed", "frame decoding failed")
|
|
167
|
+
self._buffer = bytearray()
|
|
168
|
+
raise self._failure from error
|
|
169
|
+
|
|
170
|
+
def _push_or_raise(self, chunk: bytes) -> List[Any]:
|
|
171
|
+
self._buffer.extend(chunk)
|
|
172
|
+
messages: List[Any] = []
|
|
173
|
+
offset = 0
|
|
174
|
+
|
|
175
|
+
while len(self._buffer) - offset >= FRAME_HEADER_BYTES:
|
|
176
|
+
length = int.from_bytes(self._buffer[offset : offset + FRAME_HEADER_BYTES], "big")
|
|
177
|
+
if length == 0:
|
|
178
|
+
raise ProtocolViolation("frame-malformed", "frame length must be non-zero")
|
|
179
|
+
if length > self._max_frame_bytes:
|
|
180
|
+
raise ProtocolViolation(
|
|
181
|
+
"frame-oversized",
|
|
182
|
+
f"frame declares {length} bytes, ceiling is {self._max_frame_bytes}",
|
|
183
|
+
)
|
|
184
|
+
end = offset + FRAME_HEADER_BYTES + length
|
|
185
|
+
if len(self._buffer) < end:
|
|
186
|
+
break
|
|
187
|
+
messages.append(decode_body(bytes(self._buffer[offset + FRAME_HEADER_BYTES : end]), self._max_depth))
|
|
188
|
+
offset = end
|
|
189
|
+
|
|
190
|
+
if offset:
|
|
191
|
+
del self._buffer[:offset]
|
|
192
|
+
if len(self._buffer) > self._max_frame_bytes + FRAME_HEADER_BYTES:
|
|
193
|
+
raise ProtocolViolation(
|
|
194
|
+
"frame-oversized", f"buffered {len(self._buffer)} bytes without a complete frame"
|
|
195
|
+
)
|
|
196
|
+
return messages
|
termwright/limits.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Protocol limits. Callers may tighten the defaults, never widen the maxima."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, fields
|
|
6
|
+
from typing import Any, Dict, Mapping
|
|
7
|
+
|
|
8
|
+
LIMIT_FIELDS = (
|
|
9
|
+
"maxFrameBytes",
|
|
10
|
+
"maxSnapshotBytes",
|
|
11
|
+
"maxNodes",
|
|
12
|
+
"maxDepth",
|
|
13
|
+
"maxStringBytes",
|
|
14
|
+
"maxRelationTargets",
|
|
15
|
+
"maxQueuedFrames",
|
|
16
|
+
"maxPendingWaiters",
|
|
17
|
+
"maxSessions",
|
|
18
|
+
"maxLogRecordBytes",
|
|
19
|
+
"maxLogQueue",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ProtocolLimits:
|
|
25
|
+
"""Per-session capacity ceilings, wire-named to match the JSON payload."""
|
|
26
|
+
|
|
27
|
+
maxFrameBytes: int
|
|
28
|
+
maxSnapshotBytes: int
|
|
29
|
+
maxNodes: int
|
|
30
|
+
maxDepth: int
|
|
31
|
+
maxStringBytes: int
|
|
32
|
+
maxRelationTargets: int
|
|
33
|
+
maxQueuedFrames: int
|
|
34
|
+
maxPendingWaiters: int
|
|
35
|
+
maxSessions: int
|
|
36
|
+
maxLogRecordBytes: int
|
|
37
|
+
maxLogQueue: int
|
|
38
|
+
|
|
39
|
+
def to_wire(self) -> Dict[str, int]:
|
|
40
|
+
"""Serialise to the JSON object shape used by ``hello-ack``."""
|
|
41
|
+
return {field.name: getattr(self, field.name) for field in fields(self)}
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def from_wire(value: Mapping[str, Any]) -> "ProtocolLimits":
|
|
45
|
+
"""Build limits from a validated ``hello-ack`` payload.
|
|
46
|
+
|
|
47
|
+
Only the fields this version knows are read. A newer driver may send
|
|
48
|
+
ceilings that did not exist when this client was published; ignoring
|
|
49
|
+
them is what lets an old client keep talking to a new driver.
|
|
50
|
+
"""
|
|
51
|
+
return ProtocolLimits(**{name: int(value[name]) for name in LIMIT_FIELDS})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
DEFAULT_LIMITS = ProtocolLimits(
|
|
55
|
+
maxFrameBytes=1 * 1024 * 1024,
|
|
56
|
+
maxSnapshotBytes=2 * 1024 * 1024,
|
|
57
|
+
maxNodes=5_000,
|
|
58
|
+
maxDepth=64,
|
|
59
|
+
maxStringBytes=16 * 1024,
|
|
60
|
+
maxRelationTargets=64,
|
|
61
|
+
maxQueuedFrames=32,
|
|
62
|
+
maxPendingWaiters=256,
|
|
63
|
+
maxSessions=16,
|
|
64
|
+
maxLogRecordBytes=32 * 1024,
|
|
65
|
+
maxLogQueue=1_000,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
ABSOLUTE_LIMITS = ProtocolLimits(
|
|
69
|
+
maxFrameBytes=8 * 1024 * 1024,
|
|
70
|
+
maxSnapshotBytes=8 * 1024 * 1024,
|
|
71
|
+
maxNodes=50_000,
|
|
72
|
+
maxDepth=256,
|
|
73
|
+
maxStringBytes=256 * 1024,
|
|
74
|
+
maxRelationTargets=1_024,
|
|
75
|
+
maxQueuedFrames=256,
|
|
76
|
+
maxPendingWaiters=4_096,
|
|
77
|
+
maxSessions=128,
|
|
78
|
+
maxLogRecordBytes=256 * 1024,
|
|
79
|
+
maxLogQueue=10_000,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
DEFAULT_NEGOTIATION_MS = 250
|