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/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