errlens-cli 0.1.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.
- errlens/__init__.py +7 -0
- errlens/__main__.py +6 -0
- errlens/_conpty.py +236 -0
- errlens/_conpty_worker.py +158 -0
- errlens/_pty_child.py +25 -0
- errlens/analyzers/__init__.py +1 -0
- errlens/analyzers/base.py +45 -0
- errlens/analyzers/python.py +362 -0
- errlens/capture.py +92 -0
- errlens/cli.py +177 -0
- errlens/conpty_runner.py +269 -0
- errlens/i18n.py +61 -0
- errlens/locales/en.json +96 -0
- errlens/locales/ru.json +96 -0
- errlens/models.py +120 -0
- errlens/posix_shell.py +71 -0
- errlens/powershell.py +77 -0
- errlens/pty_runner.py +172 -0
- errlens/py.typed +0 -0
- errlens/render.py +96 -0
- errlens/rules/__init__.py +1 -0
- errlens/rules/python.py +84 -0
- errlens/runner.py +241 -0
- errlens/service.py +58 -0
- errlens_cli-0.1.0.dist-info/METADATA +233 -0
- errlens_cli-0.1.0.dist-info/RECORD +30 -0
- errlens_cli-0.1.0.dist-info/WHEEL +5 -0
- errlens_cli-0.1.0.dist-info/entry_points.txt +2 -0
- errlens_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- errlens_cli-0.1.0.dist-info/top_level.txt +1 -0
errlens/__init__.py
ADDED
errlens/__main__.py
ADDED
errlens/_conpty.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Private Win32 bindings, imported only by the Windows terminal transport.
|
|
2
|
+
|
|
3
|
+
Session lives in a disposable helper process: ClosePseudoConsole has no timeout
|
|
4
|
+
on older Windows. Its job owns every client; no breakaway is enabled.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import ctypes as c
|
|
9
|
+
from ctypes import wintypes as w
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class COORD(c.Structure):
|
|
17
|
+
_fields_ = [("X", c.c_short), ("Y", c.c_short)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class INFO(c.Structure):
|
|
21
|
+
_fields_ = [("cb", w.DWORD), ("reserved", w.LPWSTR), ("desktop", w.LPWSTR),
|
|
22
|
+
("title", w.LPWSTR), ("x", w.DWORD), ("y", w.DWORD),
|
|
23
|
+
("width", w.DWORD), ("height", w.DWORD), ("chars_x", w.DWORD),
|
|
24
|
+
("chars_y", w.DWORD), ("fill", w.DWORD), ("flags", w.DWORD),
|
|
25
|
+
("show", w.WORD), ("reserved_size", w.WORD), ("reserved_data", w.LPVOID),
|
|
26
|
+
("stdin", w.HANDLE), ("stdout", w.HANDLE), ("stderr", w.HANDLE)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class INFOEX(c.Structure):
|
|
30
|
+
_fields_ = [("info", INFO), ("attributes", w.LPVOID)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PROCESS(c.Structure):
|
|
34
|
+
_fields_ = [("process", w.HANDLE), ("thread", w.HANDLE),
|
|
35
|
+
("pid", w.DWORD), ("tid", w.DWORD)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LIMITS(c.Structure):
|
|
39
|
+
_fields_ = [("process_time", c.c_int64), ("job_time", c.c_int64),
|
|
40
|
+
("flags", w.DWORD), ("min_working_set", c.c_size_t),
|
|
41
|
+
("max_working_set", c.c_size_t), ("process_limit", w.DWORD),
|
|
42
|
+
("affinity", c.c_size_t), ("priority", w.DWORD), ("scheduling", w.DWORD)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class JOBINFO(c.Structure):
|
|
46
|
+
_fields_ = [("limits", LIMITS), ("io", c.c_uint64 * 6),
|
|
47
|
+
("process_memory", c.c_size_t), ("job_memory", c.c_size_t),
|
|
48
|
+
("peak_process", c.c_size_t), ("peak_job", c.c_size_t)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SCREEN(c.Structure):
|
|
52
|
+
_fields_ = [("size", COORD), ("cursor", COORD), ("attributes", w.WORD),
|
|
53
|
+
("window", c.c_short * 4), ("maximum", COORD)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_kernel: Any = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def api(name: str, result: Any, args: list[Any]) -> Any:
|
|
60
|
+
# Loading bindings is lazy too: module discovery must remain portable.
|
|
61
|
+
function: Any = None
|
|
62
|
+
def invoke(*values: Any) -> Any:
|
|
63
|
+
nonlocal function
|
|
64
|
+
global _kernel
|
|
65
|
+
if sys.platform != "win32":
|
|
66
|
+
raise OSError("Windows-only ConPTY API")
|
|
67
|
+
if function is None:
|
|
68
|
+
if _kernel is None:
|
|
69
|
+
_kernel = c.WinDLL("kernel32", use_last_error=True)
|
|
70
|
+
function = getattr(_kernel, name)
|
|
71
|
+
function.restype, function.argtypes = result, args
|
|
72
|
+
return function(*values)
|
|
73
|
+
return invoke
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
close_handle = api("CloseHandle", w.BOOL, [w.HANDLE])
|
|
77
|
+
get_mode = api("GetConsoleMode", w.BOOL, [w.HANDLE, c.POINTER(w.DWORD)])
|
|
78
|
+
set_mode = api("SetConsoleMode", w.BOOL, [w.HANDLE, w.DWORD])
|
|
79
|
+
screen_info = api("GetConsoleScreenBufferInfo", w.BOOL, [w.HANDLE, c.POINTER(SCREEN)])
|
|
80
|
+
read_file = api("ReadFile", w.BOOL, [w.HANDLE, w.LPVOID, w.DWORD, c.POINTER(w.DWORD), w.LPVOID])
|
|
81
|
+
write_file = api("WriteFile", w.BOOL, [w.HANDLE, w.LPCVOID, w.DWORD, c.POINTER(w.DWORD), w.LPVOID])
|
|
82
|
+
peek = api("PeekNamedPipe", w.BOOL, [w.HANDLE, w.LPVOID, w.DWORD, w.LPVOID,
|
|
83
|
+
c.POINTER(w.DWORD), w.LPVOID])
|
|
84
|
+
wait = api("WaitForSingleObject", w.DWORD, [w.HANDLE, w.DWORD])
|
|
85
|
+
terminate_job = api("TerminateJobObject", w.BOOL, [w.HANDLE, w.UINT])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check(value: Any) -> None:
|
|
89
|
+
if not value:
|
|
90
|
+
raise win_error()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def last_error() -> int:
|
|
94
|
+
if sys.platform == "win32":
|
|
95
|
+
return c.get_last_error()
|
|
96
|
+
raise OSError("Windows-only ConPTY API")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def win_error() -> OSError:
|
|
100
|
+
if sys.platform == "win32":
|
|
101
|
+
return c.WinError(c.get_last_error())
|
|
102
|
+
return OSError("Windows-only ConPTY API")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cancel_thread(native_id: int) -> None:
|
|
106
|
+
handle = api("OpenThread", w.HANDLE, [w.DWORD, w.BOOL, w.DWORD])(1, False, native_id)
|
|
107
|
+
if handle:
|
|
108
|
+
try:
|
|
109
|
+
api("CancelSynchronousIo", w.BOOL, [w.HANDLE])(handle)
|
|
110
|
+
finally:
|
|
111
|
+
close_handle(handle)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Session:
|
|
115
|
+
def __init__(self) -> None:
|
|
116
|
+
self.handles: list[int] = []
|
|
117
|
+
self.hpc: int | None = None
|
|
118
|
+
self.process = self.job = self.input = self.output = 0
|
|
119
|
+
|
|
120
|
+
def own(self, handle: int) -> int:
|
|
121
|
+
check(handle)
|
|
122
|
+
self.handles.append(handle)
|
|
123
|
+
return handle
|
|
124
|
+
|
|
125
|
+
def release(self, handle: int) -> None:
|
|
126
|
+
self.handles.remove(handle)
|
|
127
|
+
check(close_handle(handle))
|
|
128
|
+
|
|
129
|
+
def start(self, command: list[str], cwd: str, size: tuple[int, int]) -> None:
|
|
130
|
+
executable = shutil.which(command[0])
|
|
131
|
+
if executable is None:
|
|
132
|
+
raise FileNotFoundError(2, "Executable not found", command[0])
|
|
133
|
+
# CreateProcess must not implicitly dispatch batch files through cmd.exe.
|
|
134
|
+
if os.path.splitext(executable)[1].lower() not in {".exe", ".com"}:
|
|
135
|
+
raise OSError("ConPTY requires a native executable (.exe or .com)")
|
|
136
|
+
pipe = api("CreatePipe", w.BOOL, [c.POINTER(w.HANDLE), c.POINTER(w.HANDLE), w.LPVOID, w.DWORD])
|
|
137
|
+
def make_pipe() -> tuple[int, int]:
|
|
138
|
+
left, right = w.HANDLE(), w.HANDLE()
|
|
139
|
+
check(pipe(c.byref(left), c.byref(right), None, 0))
|
|
140
|
+
assert left.value is not None and right.value is not None
|
|
141
|
+
return self.own(left.value), self.own(right.value)
|
|
142
|
+
read_input, self.input = make_pipe()
|
|
143
|
+
self.output, write_output = make_pipe()
|
|
144
|
+
create = api("CreatePseudoConsole", c.c_long,
|
|
145
|
+
[COORD, w.HANDLE, w.HANDLE, w.DWORD, c.POINTER(w.HANDLE)])
|
|
146
|
+
hpc = w.HANDLE()
|
|
147
|
+
result = create(COORD(*size), read_input, write_output, 0, c.byref(hpc))
|
|
148
|
+
if result < 0:
|
|
149
|
+
raise OSError(f"CreatePseudoConsole failed: HRESULT 0x{result & 0xffffffff:08x}")
|
|
150
|
+
self.hpc = hpc.value
|
|
151
|
+
self.release(read_input)
|
|
152
|
+
self.release(write_output)
|
|
153
|
+
self.job = self.own(api("CreateJobObjectW", w.HANDLE, [w.LPVOID, w.LPCWSTR])(None, None))
|
|
154
|
+
limits = JOBINFO()
|
|
155
|
+
limits.limits.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
156
|
+
check(api("SetInformationJobObject", w.BOOL, [w.HANDLE, c.c_int, w.LPVOID, w.DWORD])(
|
|
157
|
+
self.job, 9, c.byref(limits), c.sizeof(limits)))
|
|
158
|
+
initialize = api("InitializeProcThreadAttributeList", w.BOOL,
|
|
159
|
+
[w.LPVOID, w.DWORD, w.DWORD, c.POINTER(c.c_size_t)])
|
|
160
|
+
length = c.c_size_t()
|
|
161
|
+
initialize(None, 2, 0, c.byref(length))
|
|
162
|
+
attributes = c.create_string_buffer(length.value)
|
|
163
|
+
check(initialize(attributes, 2, 0, c.byref(length)))
|
|
164
|
+
try:
|
|
165
|
+
check(api("UpdateProcThreadAttribute", w.BOOL,
|
|
166
|
+
[w.LPVOID, w.DWORD, c.c_size_t, w.LPVOID, c.c_size_t, w.LPVOID, w.LPVOID])(
|
|
167
|
+
attributes, 0, 0x20016, self.hpc, c.sizeof(w.HANDLE), None, None))
|
|
168
|
+
jobs = (w.HANDLE * 1)(self.job)
|
|
169
|
+
# Assign at creation, avoiding an orphan if the owner dies between
|
|
170
|
+
# CreateProcess and AssignProcessToJobObject/ResumeThread.
|
|
171
|
+
check(api("UpdateProcThreadAttribute", w.BOOL,
|
|
172
|
+
[w.LPVOID, w.DWORD, c.c_size_t, w.LPVOID, c.c_size_t, w.LPVOID, w.LPVOID])(
|
|
173
|
+
attributes, 0, 0x2000d, jobs, c.sizeof(jobs), None, None))
|
|
174
|
+
info, process = INFOEX(), PROCESS()
|
|
175
|
+
info.info.cb = c.sizeof(info)
|
|
176
|
+
# Null standard handles with this flag prevent redirected owner pipes
|
|
177
|
+
# from leaking into the client instead of its pseudoconsole handles.
|
|
178
|
+
info.info.flags = 0x100 # STARTF_USESTDHANDLES
|
|
179
|
+
info.attributes = c.cast(attributes, w.LPVOID)
|
|
180
|
+
argv = c.create_unicode_buffer(subprocess.list2cmdline([executable, *command[1:]]))
|
|
181
|
+
check(api("CreateProcessW", w.BOOL, [w.LPCWSTR, w.LPWSTR, w.LPVOID, w.LPVOID,
|
|
182
|
+
w.BOOL, w.DWORD, w.LPVOID, w.LPCWSTR, c.POINTER(INFOEX), c.POINTER(PROCESS)])(
|
|
183
|
+
executable, argv, None, None, False, 0x80000, None, cwd,
|
|
184
|
+
c.byref(info), c.byref(process)))
|
|
185
|
+
self.process = self.own(process.process)
|
|
186
|
+
thread = self.own(process.thread)
|
|
187
|
+
self.release(thread)
|
|
188
|
+
finally:
|
|
189
|
+
api("DeleteProcThreadAttributeList", None, [w.LPVOID])(attributes)
|
|
190
|
+
|
|
191
|
+
def resize(self, size: tuple[int, int]) -> None:
|
|
192
|
+
result = api("ResizePseudoConsole", c.c_long, [w.HANDLE, COORD])(self.hpc, COORD(*size))
|
|
193
|
+
if result < 0:
|
|
194
|
+
raise OSError(f"ResizePseudoConsole failed: {result}")
|
|
195
|
+
|
|
196
|
+
def status(self) -> int | None:
|
|
197
|
+
result = wait(self.process, 0)
|
|
198
|
+
if result == 258:
|
|
199
|
+
return None
|
|
200
|
+
if result != 0:
|
|
201
|
+
raise win_error()
|
|
202
|
+
code = w.DWORD()
|
|
203
|
+
check(api("GetExitCodeProcess", w.BOOL, [w.HANDLE, c.POINTER(w.DWORD)])(self.process, c.byref(code)))
|
|
204
|
+
return code.value
|
|
205
|
+
|
|
206
|
+
def read(self) -> bytes | None:
|
|
207
|
+
available = w.DWORD()
|
|
208
|
+
if not peek(self.output, None, 0, None, c.byref(available), None):
|
|
209
|
+
if last_error() in (109, 232):
|
|
210
|
+
return b""
|
|
211
|
+
raise win_error()
|
|
212
|
+
if not available.value:
|
|
213
|
+
return None
|
|
214
|
+
data, count = c.create_string_buffer(min(available.value, 65536)), w.DWORD()
|
|
215
|
+
check(read_file(self.output, data, len(data), c.byref(count), None))
|
|
216
|
+
return data.raw[:count.value]
|
|
217
|
+
|
|
218
|
+
def write(self, data: bytes) -> None:
|
|
219
|
+
while data:
|
|
220
|
+
count = w.DWORD()
|
|
221
|
+
check(write_file(self.input, data, len(data), c.byref(count), None))
|
|
222
|
+
if count.value == 0:
|
|
223
|
+
raise OSError("ConPTY input made no progress")
|
|
224
|
+
data = data[count.value:]
|
|
225
|
+
|
|
226
|
+
def close_pseudo_console(self) -> None:
|
|
227
|
+
if self.job:
|
|
228
|
+
terminate_job(self.job, 125)
|
|
229
|
+
hpc, self.hpc = self.hpc, None
|
|
230
|
+
if hpc:
|
|
231
|
+
api("ClosePseudoConsole", None, [w.HANDLE])(hpc)
|
|
232
|
+
|
|
233
|
+
def close_handles(self) -> None:
|
|
234
|
+
for handle in reversed(self.handles):
|
|
235
|
+
close_handle(handle)
|
|
236
|
+
self.handles.clear()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Disposable ConPTY owner. stdin/stdout carry a private bounded binary protocol."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import queue
|
|
5
|
+
import struct
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import BinaryIO
|
|
10
|
+
|
|
11
|
+
MAX_FRAME = 65536
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def receive(stream: BinaryIO) -> tuple[bytes, bytes]:
|
|
15
|
+
def exact(size: int) -> bytes:
|
|
16
|
+
data = bytearray()
|
|
17
|
+
while len(data) < size:
|
|
18
|
+
chunk = stream.read(size - len(data))
|
|
19
|
+
if not chunk:
|
|
20
|
+
raise EOFError("ConPTY transport closed")
|
|
21
|
+
data.extend(chunk)
|
|
22
|
+
return bytes(data)
|
|
23
|
+
kind, size = struct.unpack("!cI", exact(5))
|
|
24
|
+
if size > MAX_FRAME:
|
|
25
|
+
raise ValueError("ConPTY frame exceeds limit")
|
|
26
|
+
return kind, exact(size)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def send(stream: BinaryIO, kind: bytes, data: bytes = b"") -> None:
|
|
30
|
+
if len(data) > MAX_FRAME:
|
|
31
|
+
raise ValueError("ConPTY frame exceeds limit")
|
|
32
|
+
view = memoryview(struct.pack("!cI", kind, len(data)) + data)
|
|
33
|
+
while view:
|
|
34
|
+
count = stream.write(view)
|
|
35
|
+
if count is None or count <= 0:
|
|
36
|
+
raise OSError("ConPTY transport made no progress")
|
|
37
|
+
view = view[count:]
|
|
38
|
+
stream.flush()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> None:
|
|
42
|
+
if sys.platform != "win32":
|
|
43
|
+
raise SystemExit("Windows only")
|
|
44
|
+
import os
|
|
45
|
+
from ctypes import wintypes as w
|
|
46
|
+
from errlens._conpty import api, check, close_handle, Session, wait
|
|
47
|
+
session = Session()
|
|
48
|
+
controls: queue.Queue[tuple[bytes, bytes]] = queue.Queue(16)
|
|
49
|
+
inputs: queue.Queue[bytes] = queue.Queue(16)
|
|
50
|
+
failure: queue.SimpleQueue[Exception] = queue.SimpleQueue()
|
|
51
|
+
stop = threading.Event()
|
|
52
|
+
# If the frontend is killed (e.g. outer test timeout), do not leave this
|
|
53
|
+
# owner or its clients behind, even while a native API is blocked.
|
|
54
|
+
parent = api("OpenProcess", w.HANDLE, [w.DWORD, w.BOOL, w.DWORD])(0x100000, False, os.getppid())
|
|
55
|
+
check(parent)
|
|
56
|
+
|
|
57
|
+
def watch_parent() -> None:
|
|
58
|
+
try:
|
|
59
|
+
while not stop.is_set():
|
|
60
|
+
if wait(parent, 100) != 258:
|
|
61
|
+
os._exit(125) # the kill-on-close job owns clients from birth
|
|
62
|
+
finally:
|
|
63
|
+
close_handle(parent)
|
|
64
|
+
watcher = threading.Thread(target=watch_parent, daemon=True)
|
|
65
|
+
watcher.start()
|
|
66
|
+
|
|
67
|
+
def read_controls() -> None:
|
|
68
|
+
try:
|
|
69
|
+
while not stop.is_set():
|
|
70
|
+
item = receive(sys.stdin.buffer)
|
|
71
|
+
while not stop.is_set():
|
|
72
|
+
try:
|
|
73
|
+
controls.put(item, timeout=0.05)
|
|
74
|
+
break
|
|
75
|
+
except queue.Full:
|
|
76
|
+
pass
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
failure.put(exc)
|
|
79
|
+
|
|
80
|
+
def write_input() -> None:
|
|
81
|
+
try:
|
|
82
|
+
while not stop.is_set():
|
|
83
|
+
try:
|
|
84
|
+
session.write(inputs.get(timeout=0.05))
|
|
85
|
+
except queue.Empty:
|
|
86
|
+
pass
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
failure.put(exc)
|
|
89
|
+
|
|
90
|
+
closer: threading.Thread | None = None
|
|
91
|
+
try:
|
|
92
|
+
kind, raw = receive(sys.stdin.buffer)
|
|
93
|
+
if kind != b"C":
|
|
94
|
+
raise ValueError("Expected ConPTY configuration")
|
|
95
|
+
config = json.loads(raw)
|
|
96
|
+
session.start(config["command"], config["cwd"], tuple(config["size"]))
|
|
97
|
+
threading.Thread(target=read_controls, daemon=True).start()
|
|
98
|
+
threading.Thread(target=write_input, daemon=True).start()
|
|
99
|
+
send(sys.stdout.buffer, b"R")
|
|
100
|
+
closing_at: float | None = None
|
|
101
|
+
eof_at: float | None = None
|
|
102
|
+
while True:
|
|
103
|
+
data = session.read()
|
|
104
|
+
if data:
|
|
105
|
+
send(sys.stdout.buffer, b"O", data)
|
|
106
|
+
if closer is None:
|
|
107
|
+
status = session.status()
|
|
108
|
+
if status is not None:
|
|
109
|
+
send(sys.stdout.buffer, b"S", struct.pack("!I", status))
|
|
110
|
+
stop.set()
|
|
111
|
+
closer = threading.Thread(target=session.close_pseudo_console, daemon=True)
|
|
112
|
+
closer.start()
|
|
113
|
+
closing_at = time.monotonic()
|
|
114
|
+
else:
|
|
115
|
+
if data == b"":
|
|
116
|
+
if eof_at is None:
|
|
117
|
+
eof_at = time.monotonic()
|
|
118
|
+
elif time.monotonic() - eof_at > 1:
|
|
119
|
+
raise OSError("ConPTY output closed while client remained alive")
|
|
120
|
+
if not failure.empty():
|
|
121
|
+
raise failure.get()
|
|
122
|
+
if not inputs.full():
|
|
123
|
+
try:
|
|
124
|
+
kind, payload = controls.get_nowait()
|
|
125
|
+
if kind == b"I":
|
|
126
|
+
inputs.put_nowait(payload)
|
|
127
|
+
elif kind == b"W":
|
|
128
|
+
session.resize(struct.unpack("!HH", payload))
|
|
129
|
+
else:
|
|
130
|
+
raise ValueError("Unknown ConPTY control")
|
|
131
|
+
except queue.Empty:
|
|
132
|
+
pass
|
|
133
|
+
elif not closer.is_alive() and data == b"":
|
|
134
|
+
break
|
|
135
|
+
elif closing_at is not None and time.monotonic() - closing_at > 2:
|
|
136
|
+
raise TimeoutError("ConPTY close/drain exceeded two seconds")
|
|
137
|
+
if not data:
|
|
138
|
+
time.sleep(0.005)
|
|
139
|
+
send(sys.stdout.buffer, b"D")
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
code = 127 if isinstance(exc, FileNotFoundError) else 126 if isinstance(exc, PermissionError) else 125
|
|
142
|
+
send(sys.stdout.buffer, b"E", json.dumps({"detail": str(exc)[:8192], "code": code}).encode("utf-8"))
|
|
143
|
+
finally:
|
|
144
|
+
stop.set()
|
|
145
|
+
# The parent enforces the ultimate deadline even if this Windows API blocks.
|
|
146
|
+
if closer is None:
|
|
147
|
+
closer = threading.Thread(target=session.close_pseudo_console, daemon=True)
|
|
148
|
+
closer.start()
|
|
149
|
+
closer.join(2)
|
|
150
|
+
if not closer.is_alive():
|
|
151
|
+
session.close_handles()
|
|
152
|
+
# Avoid Python's buffered-stdin shutdown lock held by the daemon reader.
|
|
153
|
+
watcher.join(0.5)
|
|
154
|
+
os._exit(0)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
main()
|
errlens/_pty_child.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Single-threaded POSIX session setup helper; no Python preexec_fn in parent."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
if sys.platform == "win32":
|
|
9
|
+
raise RuntimeError("POSIX PTY helper is not available on Windows")
|
|
10
|
+
import fcntl
|
|
11
|
+
import termios
|
|
12
|
+
|
|
13
|
+
error_fd = int(sys.argv[1])
|
|
14
|
+
os.set_inheritable(error_fd, False)
|
|
15
|
+
try:
|
|
16
|
+
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
|
17
|
+
os.tcsetpgrp(0, os.getpgrp())
|
|
18
|
+
os.execvpe(sys.argv[2], sys.argv[2:], os.environ)
|
|
19
|
+
except OSError as exc:
|
|
20
|
+
os.write(error_fd, f"{exc.errno}: {exc}".encode("utf-8", errors="replace")[:4096])
|
|
21
|
+
os._exit(127 if isinstance(exc, FileNotFoundError) else 126)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analyzer contracts; no automatic discovery or third-party code loading."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Pure analyzer contract and explicit, instance-scoped internal registry."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from errlens.capture import CapturedData
|
|
6
|
+
from errlens.models import Diagnostic, ExecutionContext, ExecutionResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Analyzer(Protocol):
|
|
10
|
+
"""Inspect supplied data only: no output, translation, commands or writes."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def analyzer_id(self) -> str:
|
|
14
|
+
"""Stable identifier unique within a registry."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
def analyze(
|
|
18
|
+
self,
|
|
19
|
+
context: ExecutionContext,
|
|
20
|
+
result: ExecutionResult,
|
|
21
|
+
data: CapturedData,
|
|
22
|
+
) -> list[Diagnostic]:
|
|
23
|
+
"""Return observations; return an empty list when nothing is recognized."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AnalyzerRegistry:
|
|
28
|
+
"""Register trusted analyzer instances in deterministic insertion order."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._analyzers: dict[str, Analyzer] = {}
|
|
32
|
+
|
|
33
|
+
def register(self, analyzer: Analyzer) -> None:
|
|
34
|
+
identifier = analyzer.analyzer_id
|
|
35
|
+
if not identifier:
|
|
36
|
+
raise ValueError("analyzer_id must not be empty")
|
|
37
|
+
if identifier in self._analyzers:
|
|
38
|
+
raise ValueError(f"Analyzer already registered: {identifier}")
|
|
39
|
+
self._analyzers[identifier] = analyzer
|
|
40
|
+
|
|
41
|
+
def get(self, analyzer_id: str) -> Analyzer:
|
|
42
|
+
return self._analyzers[analyzer_id]
|
|
43
|
+
|
|
44
|
+
def all(self) -> tuple[Analyzer, ...]:
|
|
45
|
+
return tuple(self._analyzers.values())
|