forked from balisujohn/localwriter
-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathipc.py
More file actions
180 lines (146 loc) · 6.36 KB
/
Copy pathipc.py
File metadata and controls
180 lines (146 loc) · 6.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# WriterAgent - AI Writing Assistant for LibreOffice
# Copyright (c) 2026 KeithCu (modifications and relicensing)
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Shared subprocess IPC framing helpers.
This module owns the outer pipe protocol only: Pickle5 frames for trusted private
binary subprocess pipes, and newline-delimited JSON for small text protocols.
Payload-specific envelopes such as split_grid remain in payload_codec.py.
"""
from __future__ import annotations
import json
import pickle
import select
import struct
import subprocess
import sys
import time
from typing import Any, Callable, IO
PICKLE_PROTOCOL = 5
FRAME_HEADER_SIZE = 4
class IpcFrameError(ValueError):
"""Raised when a framed IPC message has an invalid length or payload."""
def _validate_frame_size(size: int, *, max_payload_bytes: int | None, frame_label: str) -> None:
if size <= 0 or (max_payload_bytes is not None and size > max_payload_bytes):
raise IpcFrameError(f"Invalid {frame_label} size: {size}")
def pack_pickle_frame(message: Any, *, max_payload_bytes: int | None = None) -> bytes:
"""Return one Pickle5 message framed with a 4-byte big-endian length prefix."""
payload = pickle.dumps(message, protocol=PICKLE_PROTOCOL)
if max_payload_bytes is not None and len(payload) > max_payload_bytes:
raise IpcFrameError(f"Pickle frame exceeds maximum payload size: {len(payload)}")
return struct.pack("!I", len(payload)) + payload
def write_pickle_frame(stream: IO[bytes], message: Any, *, max_payload_bytes: int | None = None) -> None:
"""Write one Pickle5 length-prefixed message to a binary pipe."""
stream.write(pack_pickle_frame(message, max_payload_bytes=max_payload_bytes))
stream.flush()
def read_frame_payload(
stream: IO[bytes],
*,
max_payload_bytes: int | None = None,
frame_label: str = "IPC frame",
read_exact: Callable[[int], bytes] | None = None,
) -> bytes | None:
"""Read one length-prefixed payload. Return None on clean EOF or truncation."""
reader = read_exact if read_exact is not None else stream.read
header = reader(FRAME_HEADER_SIZE)
if not header or len(header) < FRAME_HEADER_SIZE:
return None
size = struct.unpack("!I", header)[0]
_validate_frame_size(size, max_payload_bytes=max_payload_bytes, frame_label=frame_label)
payload = reader(size)
if len(payload) < size:
return None
return payload
def unpack_pickle_frame(payload: bytes) -> Any:
"""Decode one trusted Pickle5 payload read from a private subprocess pipe."""
try:
return pickle.loads(payload) # nosec B301
except pickle.UnpicklingError as exc:
raise ValueError(str(exc)) from exc
def read_pickle_frame(
stream: IO[bytes],
*,
max_payload_bytes: int | None = None,
frame_label: str = "IPC frame",
require_dict: bool = False,
) -> Any | None:
"""Read and unpickle one length-prefixed message. Return None on EOF/truncation."""
payload = read_frame_payload(stream, max_payload_bytes=max_payload_bytes, frame_label=frame_label)
if payload is None:
return None
decoded = unpack_pickle_frame(payload)
if require_dict and not isinstance(decoded, dict):
raise ValueError(f"{frame_label} must contain a dict")
return decoded
def write_json_line(stream: IO[str], payload: dict[str, Any]) -> None:
"""Write one JSON object followed by a newline to a text-mode pipe."""
stream.write(json.dumps(payload) + "\n")
stream.flush()
def _peek_pipe_bytes_available(fd: int) -> int | None:
"""Return queued byte count for a Windows pipe fd, or None when the pipe is closed."""
if sys.platform != "win32":
return None
import ctypes
import msvcrt
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
peek_named_pipe = kernel32.PeekNamedPipe
peek_named_pipe.argtypes = [
wintypes.HANDLE,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.POINTER(wintypes.DWORD),
ctypes.POINTER(wintypes.DWORD),
]
peek_named_pipe.restype = wintypes.BOOL
avail = wintypes.DWORD(0)
handle = msvcrt.get_osfhandle(fd)
if peek_named_pipe(handle, None, 0, None, ctypes.byref(avail), None):
return int(avail.value)
if ctypes.get_last_error() in (109, 233): # BROKEN_PIPE / NO_DATA
return None
raise OSError(ctypes.get_last_error(), ctypes.FormatError(ctypes.get_last_error()))
def _readline_with_timeout_win32(stream: IO[str], timeout_sec: float, *, cmd: str = "IPC JSON line") -> str:
"""Windows path: poll pipe with PeekNamedPipe; readline only when bytes are queued."""
try:
fd = stream.fileno()
except (AttributeError, OSError, ValueError):
return stream.readline()
deadline = time.monotonic() + max(0.0, timeout_sec)
while time.monotonic() < deadline:
avail = _peek_pipe_bytes_available(fd)
if avail is None:
return stream.readline()
if avail > 0:
return stream.readline()
time.sleep(min(0.001, deadline - time.monotonic()))
raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout_sec)
def _readline_with_timeout(stream: IO[str], timeout_sec: float | None) -> str:
if timeout_sec is None:
return stream.readline()
# Windows select.select() only supports sockets, not pipes (WinError 10038).
if sys.platform == "win32":
return _readline_with_timeout_win32(stream, timeout_sec)
try:
fd = stream.fileno()
except (AttributeError, OSError, ValueError):
fd = None
if isinstance(fd, int):
ready, _, _ = select.select([stream], [], [], max(0.0, timeout_sec))
if not ready:
raise subprocess.TimeoutExpired(cmd="IPC JSON line", timeout=timeout_sec)
return stream.readline()
return stream.readline()
def read_json_line(stream: IO[str], *, timeout_sec: float | None = None) -> dict[str, Any] | None:
"""Read one newline-delimited JSON object. Return None on clean EOF."""
line = _readline_with_timeout(stream, timeout_sec)
if not line:
return None
try:
payload = json.loads(line.strip())
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON line: {line!r}") from exc
if not isinstance(payload, dict):
raise ValueError(f"JSON line must contain an object: {payload!r}")
return payload