@neoline/hostbridge 2.0.2 → 2.0.4

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.
@@ -0,0 +1,592 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import binascii
6
+ import sys
7
+ import uuid
8
+ from collections import deque
9
+ from contextlib import suppress
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from typing import Protocol
13
+
14
+ import asyncssh
15
+
16
+ from .client import HostBridgeClient, HostBridgeError
17
+ from .remote_tunnel_agent import build_remote_tunnel_command
18
+
19
+ TUNNEL_PROTOCOL_VERSION = 1
20
+ UPSTREAM_FRAME_BYTES = 2048
21
+ DOWNSTREAM_FRAME_BYTES = 16 * 1024
22
+ TUNNEL_READ_BYTES = 64 * 1024
23
+ TUNNEL_WRITE_BATCH_BYTES = 32 * 1024
24
+ TUNNEL_HIGH_WATER_BYTES = 128 * 1024
25
+ TUNNEL_LOW_WATER_BYTES = 64 * 1024
26
+ TUNNEL_MAX_LINE_BYTES = 128 * 1024
27
+
28
+
29
+ class TunnelProtocolError(ValueError):
30
+ pass
31
+
32
+
33
+ class Direction(StrEnum):
34
+ UPSTREAM = "U"
35
+ DOWNSTREAM = "D"
36
+
37
+
38
+ class FrameKind(StrEnum):
39
+ READY = "READY"
40
+ DATA = "DATA"
41
+ EOF = "EOF"
42
+ ERROR = "ERROR"
43
+ CLOSED = "CLOSED"
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class TunnelFrame:
48
+ direction: Direction
49
+ sequence: int
50
+ kind: FrameKind
51
+ payload: bytes = b""
52
+ status: int | None = None
53
+
54
+
55
+ class TunnelCodec:
56
+ def __init__(self, prefix: str, *, inbound_direction: Direction) -> None:
57
+ if not prefix or any(character in prefix for character in "\r\n:"):
58
+ raise ValueError("tunnel prefix must be non-empty and must not contain CR, LF, or colon")
59
+ try:
60
+ prefix_bytes = prefix.encode("ascii")
61
+ except UnicodeEncodeError as exc:
62
+ raise ValueError("tunnel prefix must be ASCII") from exc
63
+ self._prefix = prefix_bytes
64
+ self._marker = prefix_bytes + b":V"
65
+ self._inbound_direction = inbound_direction
66
+ self._next_sequence = 0
67
+ self._buffer = bytearray()
68
+
69
+ def encode(self, frame: TunnelFrame) -> bytes:
70
+ self._validate_frame(frame)
71
+ status = b"-" if frame.status is None else str(frame.status).encode("ascii")
72
+ payload = base64.b64encode(frame.payload)
73
+ return (
74
+ b":".join(
75
+ (
76
+ self._prefix,
77
+ f"V{TUNNEL_PROTOCOL_VERSION}".encode("ascii"),
78
+ frame.direction.value.encode("ascii"),
79
+ str(frame.sequence).encode("ascii"),
80
+ frame.kind.value.encode("ascii"),
81
+ status,
82
+ payload,
83
+ )
84
+ )
85
+ + b"\n"
86
+ )
87
+
88
+ def feed(self, data: bytes) -> list[TunnelFrame]:
89
+ self._buffer.extend(data)
90
+ frames: list[TunnelFrame] = []
91
+ while b"\n" in self._buffer:
92
+ raw_line, _, remainder = self._buffer.partition(b"\n")
93
+ self._buffer = bytearray(remainder)
94
+ line = raw_line.rstrip(b"\r")
95
+ marker = line.find(self._marker)
96
+ if marker < 0:
97
+ continue
98
+ frames.append(self._decode_line(bytes(line[marker:])))
99
+ if len(self._buffer) > TUNNEL_MAX_LINE_BYTES:
100
+ raise TunnelProtocolError("tunnel frame exceeds maximum line size")
101
+ return frames
102
+
103
+ def _decode_line(self, line: bytes) -> TunnelFrame:
104
+ fields = line.split(b":", 6)
105
+ if len(fields) != 7:
106
+ raise TunnelProtocolError("malformed tunnel frame")
107
+ prefix, version, direction_raw, sequence_raw, kind_raw, status_raw, payload_raw = fields
108
+ if prefix != self._prefix:
109
+ raise TunnelProtocolError("tunnel frame prefix mismatch")
110
+ if version != f"V{TUNNEL_PROTOCOL_VERSION}".encode("ascii"):
111
+ raise TunnelProtocolError("unsupported tunnel protocol version")
112
+ try:
113
+ direction = Direction(direction_raw.decode("ascii"))
114
+ except (UnicodeDecodeError, ValueError) as exc:
115
+ raise TunnelProtocolError("invalid tunnel frame direction") from exc
116
+ if direction is not self._inbound_direction:
117
+ raise TunnelProtocolError("unexpected tunnel frame direction")
118
+ try:
119
+ sequence = int(sequence_raw)
120
+ except ValueError as exc:
121
+ raise TunnelProtocolError("invalid tunnel frame sequence") from exc
122
+ if sequence != self._next_sequence:
123
+ raise TunnelProtocolError(f"tunnel frame sequence expected {self._next_sequence}, received {sequence}")
124
+ try:
125
+ kind = FrameKind(kind_raw.decode("ascii"))
126
+ except (UnicodeDecodeError, ValueError) as exc:
127
+ raise TunnelProtocolError("invalid tunnel frame kind") from exc
128
+ if status_raw == b"-":
129
+ status = None
130
+ else:
131
+ try:
132
+ status = int(status_raw)
133
+ except ValueError as exc:
134
+ raise TunnelProtocolError("invalid tunnel frame status") from exc
135
+ try:
136
+ payload = base64.b64decode(payload_raw, validate=True)
137
+ except (binascii.Error, ValueError) as exc:
138
+ raise TunnelProtocolError("invalid tunnel frame base64 payload") from exc
139
+ frame = TunnelFrame(direction, sequence, kind, payload, status)
140
+ self._validate_frame(frame)
141
+ self._next_sequence += 1
142
+ return frame
143
+
144
+ @staticmethod
145
+ def _validate_frame(frame: TunnelFrame) -> None:
146
+ if not isinstance(frame.direction, Direction):
147
+ raise TunnelProtocolError("invalid tunnel frame direction")
148
+ if not isinstance(frame.sequence, int) or isinstance(frame.sequence, bool) or frame.sequence < 0:
149
+ raise TunnelProtocolError("invalid tunnel frame sequence")
150
+ if not isinstance(frame.kind, FrameKind):
151
+ raise TunnelProtocolError("invalid tunnel frame kind")
152
+ if not isinstance(frame.payload, bytes):
153
+ raise TunnelProtocolError("tunnel frame payload must be bytes")
154
+ if frame.kind is FrameKind.CLOSED:
155
+ if not isinstance(frame.status, int) or isinstance(frame.status, bool) or not 0 <= frame.status <= 255:
156
+ raise TunnelProtocolError("CLOSED tunnel frame requires an exit status")
157
+ elif frame.status is not None:
158
+ raise TunnelProtocolError("only CLOSED tunnel frames may contain a status")
159
+ if frame.kind not in {FrameKind.DATA, FrameKind.ERROR} and frame.payload:
160
+ raise TunnelProtocolError(f"{frame.kind.value} tunnel frame must not contain a payload")
161
+
162
+
163
+ class TunnelState(StrEnum):
164
+ CONNECTING = "CONNECTING"
165
+ OPEN = "OPEN"
166
+ LOCAL_EOF = "LOCAL_EOF"
167
+ REMOTE_EOF = "REMOTE_EOF"
168
+ DRAINING = "DRAINING"
169
+ ABORTING = "ABORTING"
170
+ CLOSED = "CLOSED"
171
+
172
+
173
+ class TunnelLifecycle:
174
+ def __init__(self) -> None:
175
+ self.state = TunnelState.CONNECTING
176
+ self.reusable = False
177
+
178
+ def mark_ready(self) -> None:
179
+ self._require(TunnelState.CONNECTING)
180
+ self.state = TunnelState.OPEN
181
+
182
+ def mark_local_eof(self) -> None:
183
+ if self.state is TunnelState.OPEN:
184
+ self.state = TunnelState.LOCAL_EOF
185
+ elif self.state is TunnelState.REMOTE_EOF:
186
+ self.state = TunnelState.DRAINING
187
+ else:
188
+ self._invalid("local EOF")
189
+
190
+ def mark_remote_eof(self) -> None:
191
+ if self.state is TunnelState.OPEN:
192
+ self.state = TunnelState.REMOTE_EOF
193
+ elif self.state is TunnelState.LOCAL_EOF:
194
+ self.state = TunnelState.DRAINING
195
+ else:
196
+ self._invalid("remote EOF")
197
+
198
+ def mark_closed(self, status: int) -> None:
199
+ self._require(TunnelState.DRAINING)
200
+ if not isinstance(status, int) or isinstance(status, bool) or not 0 <= status <= 255:
201
+ raise TunnelProtocolError("terminal tunnel status is invalid")
202
+ self.state = TunnelState.CLOSED
203
+ self.reusable = status == 0
204
+
205
+ def abort(self) -> None:
206
+ if self.state is not TunnelState.CLOSED:
207
+ self.state = TunnelState.ABORTING
208
+ self.reusable = False
209
+
210
+ def finish_abort(self) -> None:
211
+ self._require(TunnelState.ABORTING)
212
+ self.state = TunnelState.CLOSED
213
+
214
+ def _require(self, expected: TunnelState) -> None:
215
+ if self.state is not expected:
216
+ raise TunnelProtocolError(f"tunnel state must be {expected.value}, found {self.state.value}")
217
+
218
+ def _invalid(self, event: str) -> None:
219
+ raise TunnelProtocolError(f"cannot accept {event} while tunnel state is {self.state.value}")
220
+
221
+
222
+ class SessionPool(Protocol):
223
+ def open(self) -> str: ...
224
+
225
+ def close(self, session_id: str, *, reusable: bool = True) -> None: ...
226
+
227
+
228
+ class TunnelLease:
229
+ def __init__(self, pool: SessionPool, session_id: str) -> None:
230
+ self._pool = pool
231
+ self.session_id = session_id
232
+ self._close_task: asyncio.Task[None] | None = None
233
+
234
+ @classmethod
235
+ async def acquire(cls, pool: SessionPool) -> TunnelLease:
236
+ open_task = asyncio.create_task(asyncio.to_thread(pool.open))
237
+ try:
238
+ session_id = await asyncio.shield(open_task)
239
+ except asyncio.CancelledError:
240
+ await cls._wait_ignoring_cancellation(open_task)
241
+ if not open_task.cancelled() and open_task.exception() is None:
242
+ lease = cls(pool, open_task.result())
243
+ await lease.close(reusable=False)
244
+ raise
245
+ return cls(pool, session_id)
246
+
247
+ async def close(self, *, reusable: bool) -> None:
248
+ if self._close_task is None:
249
+ self._close_task = asyncio.create_task(
250
+ asyncio.to_thread(self._pool.close, self.session_id, reusable=reusable)
251
+ )
252
+ await self._wait_ignoring_cancellation(self._close_task)
253
+ await self._close_task
254
+
255
+ @staticmethod
256
+ async def _wait_ignoring_cancellation(task: asyncio.Task[object]) -> None:
257
+ while not task.done():
258
+ try:
259
+ await asyncio.shield(task)
260
+ except asyncio.CancelledError:
261
+ continue
262
+
263
+
264
+ class _TunnelFrameStream:
265
+ def __init__(self, client: HostBridgeClient, lease: TunnelLease, prefix: str) -> None:
266
+ self._client = client
267
+ self._lease = lease
268
+ self._codec = TunnelCodec(prefix, inbound_direction=Direction.DOWNSTREAM)
269
+ self._pending: deque[TunnelFrame] = deque()
270
+
271
+ async def next(self) -> TunnelFrame:
272
+ while not self._pending:
273
+ result = await asyncio.to_thread(
274
+ self._client.shell_read,
275
+ self._lease.session_id,
276
+ timeout=0.2,
277
+ max_bytes=TUNNEL_READ_BYTES,
278
+ owner="mock-ssh",
279
+ )
280
+ if result.data:
281
+ try:
282
+ self._pending.extend(self._codec.feed(result.data))
283
+ except TunnelProtocolError as exc:
284
+ raise HostBridgeError("protocol_mismatch", str(exc)) from exc
285
+ if not result.alive and not self._pending:
286
+ raise HostBridgeError(
287
+ "connection_lost",
288
+ "remote TCP tunnel closed without a terminal frame",
289
+ retryable=True,
290
+ )
291
+ return self._pending.popleft()
292
+
293
+
294
+ class TunnelConnector:
295
+ def __init__(
296
+ self,
297
+ client: HostBridgeClient,
298
+ session_pool: SessionPool,
299
+ *,
300
+ connect_timeout: int,
301
+ ) -> None:
302
+ if connect_timeout <= 0:
303
+ raise ValueError("connect_timeout must be positive")
304
+ self._client = client
305
+ self._session_pool = session_pool
306
+ self._connect_timeout = connect_timeout
307
+
308
+ @staticmethod
309
+ def validate_destination(dest_host: str, dest_port: int) -> None:
310
+ if not isinstance(dest_host, str) or not dest_host.strip() or "\x00" in dest_host or len(dest_host) > 253:
311
+ raise ValueError("TCP forwarding destination host is invalid")
312
+ if not isinstance(dest_port, int) or isinstance(dest_port, bool) or not 1 <= dest_port <= 65535:
313
+ raise ValueError("TCP forwarding destination port must be between 1 and 65535")
314
+
315
+ async def connect(self, dest_host: str, dest_port: int) -> asyncssh.SSHTCPSession[bytes]:
316
+ self.validate_destination(dest_host, dest_port)
317
+ try:
318
+ lease = await TunnelLease.acquire(self._session_pool)
319
+ except asyncio.CancelledError:
320
+ raise
321
+ except Exception as exc:
322
+ raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
323
+
324
+ prefix = f"__HB_TUNNEL_{uuid.uuid4().hex}__"
325
+ frames = _TunnelFrameStream(self._client, lease, prefix)
326
+ lifecycle = TunnelLifecycle()
327
+ try:
328
+ await asyncio.to_thread(
329
+ self._client.shell_write,
330
+ lease.session_id,
331
+ build_remote_tunnel_command(dest_host, dest_port, prefix),
332
+ owner="mock-ssh",
333
+ )
334
+ first = await asyncio.wait_for(frames.next(), timeout=self._connect_timeout)
335
+ if first.kind is FrameKind.ERROR:
336
+ raise HostBridgeError(
337
+ "connection_lost",
338
+ first.payload.decode("utf-8", errors="replace") or "remote TCP connection failed",
339
+ retryable=True,
340
+ )
341
+ if first.kind is not FrameKind.READY:
342
+ raise HostBridgeError(
343
+ "protocol_mismatch",
344
+ f"remote TCP tunnel returned {first.kind.value} before READY",
345
+ )
346
+ lifecycle.mark_ready()
347
+ except asyncio.CancelledError:
348
+ lifecycle.abort()
349
+ await lease.close(reusable=False)
350
+ lifecycle.finish_abort()
351
+ raise
352
+ except Exception as exc:
353
+ lifecycle.abort()
354
+ await lease.close(reusable=False)
355
+ lifecycle.finish_abort()
356
+ raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
357
+ return DirectTCPIPSession(
358
+ self._client,
359
+ lease,
360
+ prefix,
361
+ frames,
362
+ lifecycle,
363
+ dest_host=dest_host,
364
+ dest_port=dest_port,
365
+ )
366
+
367
+
368
+ _INPUT_EOF = object()
369
+
370
+
371
+ class DirectTCPIPSession(asyncssh.SSHTCPSession[bytes]):
372
+ def __init__(
373
+ self,
374
+ client: HostBridgeClient,
375
+ lease: TunnelLease,
376
+ prefix: str,
377
+ frames: _TunnelFrameStream,
378
+ lifecycle: TunnelLifecycle,
379
+ *,
380
+ dest_host: str,
381
+ dest_port: int,
382
+ ) -> None:
383
+ self._client = client
384
+ self._lease = lease
385
+ self._codec = TunnelCodec(prefix, inbound_direction=Direction.DOWNSTREAM)
386
+ self._frames = frames
387
+ self._lifecycle = lifecycle
388
+ self._dest_host = dest_host
389
+ self._dest_port = dest_port
390
+ self._channel: asyncssh.SSHTCPChannel[bytes] | None = None
391
+ self._input: asyncio.Queue[bytes | object] = asyncio.Queue()
392
+ self._input_buffered_bytes = 0
393
+ self._input_paused = False
394
+ self._upstream_sequence = 0
395
+ self._write_ready = asyncio.Event()
396
+ self._write_ready.set()
397
+ self._runner: asyncio.Task[None] | None = None
398
+ self._orphan_cleanup: asyncio.Task[None] | None = None
399
+ self._finished = asyncio.Event()
400
+
401
+ @property
402
+ def state(self) -> TunnelState:
403
+ return self._lifecycle.state
404
+
405
+ def connection_made(self, chan: asyncssh.SSHTCPChannel[bytes]) -> None:
406
+ self._channel = chan
407
+
408
+ def session_started(self) -> None:
409
+ self._runner = asyncio.create_task(self._run())
410
+ self._runner.add_done_callback(self._runner_done)
411
+
412
+ def data_received(self, data: bytes, datatype: asyncssh.DataType) -> None: # noqa: ARG002
413
+ if not data:
414
+ return
415
+ payload = bytes(data)
416
+ self._input.put_nowait(payload)
417
+ self._input_buffered_bytes += len(payload)
418
+ if (
419
+ self._input_buffered_bytes >= TUNNEL_HIGH_WATER_BYTES
420
+ and not self._input_paused
421
+ and self._channel is not None
422
+ ):
423
+ self._channel.pause_reading()
424
+ self._input_paused = True
425
+
426
+ def eof_received(self) -> bool:
427
+ self._input.put_nowait(_INPUT_EOF)
428
+ return True
429
+
430
+ def connection_lost(self, exc: Exception | None) -> None: # noqa: ARG002
431
+ self.abort()
432
+
433
+ def pause_writing(self) -> None:
434
+ self._write_ready.clear()
435
+
436
+ def resume_writing(self) -> None:
437
+ self._write_ready.set()
438
+
439
+ def abort(self) -> None:
440
+ self._lifecycle.abort()
441
+ if self._runner is not None and self._runner is not asyncio.current_task() and not self._runner.done():
442
+ self._runner.cancel()
443
+ elif self._runner is None:
444
+ self._schedule_orphan_cleanup()
445
+
446
+ async def wait_finished(self) -> None:
447
+ await self._finished.wait()
448
+
449
+ def _runner_done(self, task: asyncio.Task[None]) -> None: # noqa: ARG002
450
+ if not self._finished.is_set():
451
+ self._schedule_orphan_cleanup()
452
+
453
+ def _schedule_orphan_cleanup(self) -> None:
454
+ if self._orphan_cleanup is None:
455
+ self._orphan_cleanup = asyncio.create_task(self._finish_orphaned_session())
456
+
457
+ async def _finish_orphaned_session(self) -> None:
458
+ self._lifecycle.abort()
459
+ await self._lease.close(reusable=False)
460
+ if self._lifecycle.state is TunnelState.ABORTING:
461
+ self._lifecycle.finish_abort()
462
+ self._close_channel()
463
+ self._finished.set()
464
+
465
+ async def _run(self) -> None:
466
+ input_task = asyncio.create_task(self._pump_input())
467
+ output_task = asyncio.create_task(self._pump_output())
468
+ try:
469
+ await asyncio.gather(input_task, output_task)
470
+ except asyncio.CancelledError:
471
+ self._lifecycle.abort()
472
+ raise
473
+ except Exception as exc:
474
+ self._lifecycle.abort()
475
+ print(
476
+ f"hostbridge mock-ssh TCP forwarding to {self._dest_host}:{self._dest_port} closed: {exc}",
477
+ file=sys.stderr,
478
+ flush=True,
479
+ )
480
+ finally:
481
+ for task in (input_task, output_task):
482
+ if not task.done():
483
+ task.cancel()
484
+ await asyncio.gather(input_task, output_task, return_exceptions=True)
485
+ await self._lease.close(reusable=self._lifecycle.reusable)
486
+ if self._lifecycle.state is TunnelState.ABORTING:
487
+ self._lifecycle.finish_abort()
488
+ self._close_channel()
489
+ self._finished.set()
490
+
491
+ async def _pump_input(self) -> None:
492
+ while True:
493
+ item = await self._input.get()
494
+ if item is _INPUT_EOF:
495
+ await self._send_eof()
496
+ return
497
+
498
+ if not isinstance(item, bytes):
499
+ raise RuntimeError("TCP tunnel input queue contained an invalid item")
500
+ batch = bytearray(item)
501
+ saw_eof = False
502
+ while not self._input.empty():
503
+ queued = self._input.get_nowait()
504
+ if queued is _INPUT_EOF:
505
+ saw_eof = True
506
+ break
507
+ if not isinstance(queued, bytes):
508
+ raise RuntimeError("TCP tunnel input queue contained an invalid item")
509
+ batch.extend(queued)
510
+ try:
511
+ for offset in range(0, len(batch), TUNNEL_WRITE_BATCH_BYTES):
512
+ await self._send_data(bytes(batch[offset : offset + TUNNEL_WRITE_BATCH_BYTES]))
513
+ finally:
514
+ self._input_buffered_bytes -= len(batch)
515
+ self._resume_input_if_ready()
516
+ if saw_eof:
517
+ await self._send_eof()
518
+ return
519
+
520
+ async def _send_data(self, payload: bytes) -> None:
521
+ frames = []
522
+ for offset in range(0, len(payload), UPSTREAM_FRAME_BYTES):
523
+ frames.append(
524
+ TunnelFrame(
525
+ Direction.UPSTREAM,
526
+ self._upstream_sequence,
527
+ FrameKind.DATA,
528
+ payload=payload[offset : offset + UPSTREAM_FRAME_BYTES],
529
+ )
530
+ )
531
+ self._upstream_sequence += 1
532
+ await self._write_frames(frames)
533
+
534
+ async def _send_eof(self) -> None:
535
+ frame = TunnelFrame(Direction.UPSTREAM, self._upstream_sequence, FrameKind.EOF)
536
+ self._upstream_sequence += 1
537
+ await self._write_frames([frame])
538
+ self._lifecycle.mark_local_eof()
539
+
540
+ async def _write_frames(self, frames: list[TunnelFrame]) -> None:
541
+ payload = b"".join(self._codec.encode(frame) for frame in frames)
542
+ await asyncio.to_thread(
543
+ self._client.shell_write,
544
+ self._lease.session_id,
545
+ payload,
546
+ owner="mock-ssh",
547
+ )
548
+
549
+ async def _pump_output(self) -> None:
550
+ if self._channel is None:
551
+ raise RuntimeError("TCP tunnel channel was not initialized")
552
+ while True:
553
+ frame = await self._frames.next()
554
+ if frame.kind is FrameKind.DATA:
555
+ if self._lifecycle.state not in {TunnelState.OPEN, TunnelState.LOCAL_EOF}:
556
+ raise HostBridgeError("protocol_mismatch", "remote TCP data arrived after EOF")
557
+ await self._write_ready.wait()
558
+ self._channel.write(frame.payload)
559
+ elif frame.kind is FrameKind.EOF:
560
+ self._lifecycle.mark_remote_eof()
561
+ self._channel.write_eof()
562
+ elif frame.kind is FrameKind.ERROR:
563
+ raise HostBridgeError(
564
+ "connection_lost",
565
+ frame.payload.decode("utf-8", errors="replace") or "remote TCP tunnel failed",
566
+ retryable=True,
567
+ )
568
+ elif frame.kind is FrameKind.CLOSED:
569
+ assert frame.status is not None
570
+ self._lifecycle.mark_closed(frame.status)
571
+ if frame.status != 0:
572
+ raise HostBridgeError(
573
+ "connection_lost",
574
+ f"remote TCP tunnel exited with status {frame.status}",
575
+ retryable=True,
576
+ )
577
+ return
578
+ else:
579
+ raise HostBridgeError(
580
+ "protocol_mismatch",
581
+ f"unexpected {frame.kind.value} frame after READY",
582
+ )
583
+
584
+ def _resume_input_if_ready(self) -> None:
585
+ if self._input_paused and self._input_buffered_bytes <= TUNNEL_LOW_WATER_BYTES and self._channel is not None:
586
+ self._channel.resume_reading()
587
+ self._input_paused = False
588
+
589
+ def _close_channel(self) -> None:
590
+ if self._channel is not None:
591
+ with suppress(Exception):
592
+ self._channel.close()