termverify 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.
termverify/_conpty.py ADDED
@@ -0,0 +1,586 @@
1
+ """Minimal Windows ConPTY binding boundary for the terminal adapter plan.
2
+
3
+ This module is the only place that touches ``pywinpty``. It owns the native
4
+ ``winpty._winpty.PTY`` object directly instead of ``winpty.PtyProcess``: the
5
+ ``PtyProcess`` wrapper routes output through an internal socket-relay reader
6
+ thread that swallows the native end-of-stream signal and can drop buffered
7
+ output once the child exits, and the accepted verification plan rejects
8
+ reader-thread state as evidence. Driving the native object keeps every
9
+ observable — output bytes, end-of-stream, liveness, exit status — a direct
10
+ native signal.
11
+
12
+ Process-tree containment uses a Windows job object created per spawn with
13
+ ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` and neither breakaway limit, so every
14
+ descendant the child starts inherits membership and cannot leave. Forced
15
+ close terminates the whole tree atomically with ``TerminateJobObject``;
16
+ releasing the binding closes the job handle, which makes the OS sweep any
17
+ survivors even if this process dies abruptly. Disclosed boundary: the child
18
+ is assigned to the job immediately after ``CreateProcess`` returns, so a
19
+ process the child manages to start within that microseconds-wide window
20
+ would fall outside the job; the binding does not claim pre-start assignment.
21
+
22
+ I/O is single-flight by contract: at most one native read or write may be in
23
+ flight, and overlap fails fast with ``ConptyConcurrentIOError``. The native
24
+ layer is not thread-safe for overlapped calls on one pseudoconsole —
25
+ concurrent ``pty.write`` against a blocked ``pty.read`` intermittently
26
+ crashes the interpreter with a native access violation — and the transcript
27
+ protocol is single-flight anyway, so the binding refuses the overlap rather
28
+ than risking the crash. ``close`` is the one concurrent-safe operation; it
29
+ cancels in-flight I/O and waits it out before releasing the native object,
30
+ because releasing during a native call is the same crash.
31
+
32
+ The binding stays deliberately thin: every future adapter behavior above it
33
+ must be testable cross-platform against an injected fake binding, so this
34
+ native boundary is excluded from the coverage ratchet with recorded rationale
35
+ in the developer guide. Cancellation and recovery are evidenced at this
36
+ binding level only — startup failure fails closed for both a missing command
37
+ and a command the OS refuses to start, forced close recovers from hostile
38
+ children (output flood, busy spin, in-flight write) without leaking threads,
39
+ with handle release proven by the release-only close evidence, and conin
40
+ writes showed no backpressure on the verified matrix. Classification into
41
+ the structured failure/abort taxonomy is adapter behavior and remains
42
+ unclaimed here.
43
+
44
+ ``write`` intentionally returns ``None``: the ConPTY write return value is not
45
+ a reliable byte-count receipt, and exposing it would fabricate evidence.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import contextlib
51
+ import os
52
+ import shutil
53
+ import subprocess
54
+ import sys
55
+ import threading
56
+ import time
57
+ from collections.abc import Mapping, Sequence
58
+ from typing import Any, Final
59
+
60
+ _CHILD_EXIT_WAIT_MS = 30_000
61
+ _READ_CANCEL_TIMEOUT_SECONDS = 30.0
62
+ _READ_CANCEL_RETRY_SECONDS = 0.01
63
+
64
+ #: Exit code set on every process in the tree by a forced close. The value
65
+ #: keeps parity with the previous single-process termination convention.
66
+ FORCED_TERMINATION_EXIT_CODE: Final = 15
67
+
68
+
69
+ class ConptyUnsupportedError(RuntimeError):
70
+ """Raised when the ConPTY binding is used on a host without ConPTY."""
71
+
72
+
73
+ def is_supported() -> bool:
74
+ """Report whether this host can create ConPTY pseudoconsoles.
75
+
76
+ This is the explicit support probe the adapter consults during
77
+ negotiation, answering the same precondition :meth:`ConptyChild.spawn`
78
+ checks so platform support is decidable before any spawn is attempted.
79
+ It inspects no session state and creates nothing.
80
+ """
81
+ return os.name == "nt"
82
+
83
+
84
+ class ConptyClosedError(RuntimeError):
85
+ """Raised when an operation is attempted after the binding was closed."""
86
+
87
+
88
+ class ConptyConcurrentIOError(RuntimeError):
89
+ """Raised when a read or write is attempted while another is in flight.
90
+
91
+ The native layer is not thread-safe for overlapped calls on one
92
+ pseudoconsole: concurrent ``pty.write`` against a blocked ``pty.read``
93
+ intermittently dies with a native access violation. The transcript
94
+ protocol is single-flight, so the binding forbids overlap outright and
95
+ fails fast instead of risking the crash or deadlocking behind an
96
+ indefinitely blocked read. ``close`` is the one concurrent-safe
97
+ operation; it cancels in-flight I/O.
98
+ """
99
+
100
+
101
+ class ConptyEndOfStreamError(Exception):
102
+ """Raised by ``read`` when the native output pipe reports end-of-stream.
103
+
104
+ Only raised while the binding is open: a read interrupted by ``close``
105
+ raises :class:`ConptyClosedError` instead, because close may abandon
106
+ buffered output. On this genuine end-of-stream path Windows pipe
107
+ semantics deliver all buffered output before the read side observes the
108
+ broken pipe, so every byte the pseudoconsole emitted has been returned by
109
+ earlier ``read`` calls when this is raised.
110
+ """
111
+
112
+
113
+ if sys.platform == "win32":
114
+ import ctypes
115
+ from ctypes import wintypes
116
+
117
+ _SYNCHRONIZE = 0x0010_0000
118
+ _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
119
+ _PROCESS_SET_QUOTA = 0x0100
120
+ _PROCESS_TERMINATE = 0x0001
121
+ _WAIT_OBJECT_0 = 0
122
+ _WAIT_TIMEOUT = 0x102
123
+ _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
124
+ _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
125
+
126
+ class _IoCounters(ctypes.Structure):
127
+ _fields_ = [
128
+ ("ReadOperationCount", ctypes.c_uint64),
129
+ ("WriteOperationCount", ctypes.c_uint64),
130
+ ("OtherOperationCount", ctypes.c_uint64),
131
+ ("ReadTransferCount", ctypes.c_uint64),
132
+ ("WriteTransferCount", ctypes.c_uint64),
133
+ ("OtherTransferCount", ctypes.c_uint64),
134
+ ]
135
+
136
+ class _JobBasicLimits(ctypes.Structure):
137
+ _fields_ = [
138
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
139
+ ("PerJobUserTimeLimit", ctypes.c_int64),
140
+ ("LimitFlags", wintypes.DWORD),
141
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
142
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
143
+ ("ActiveProcessLimit", wintypes.DWORD),
144
+ ("Affinity", ctypes.c_size_t),
145
+ ("PriorityClass", wintypes.DWORD),
146
+ ("SchedulingClass", wintypes.DWORD),
147
+ ]
148
+
149
+ class _JobExtendedLimits(ctypes.Structure):
150
+ _fields_ = [
151
+ ("BasicLimitInformation", _JobBasicLimits),
152
+ ("IoInfo", _IoCounters),
153
+ ("ProcessMemoryLimit", ctypes.c_size_t),
154
+ ("JobMemoryLimit", ctypes.c_size_t),
155
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
156
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
157
+ ]
158
+
159
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
160
+ _kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
161
+ _kernel32.OpenProcess.restype = wintypes.HANDLE
162
+ _kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
163
+ _kernel32.WaitForSingleObject.restype = wintypes.DWORD
164
+ _kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
165
+ _kernel32.CloseHandle.restype = wintypes.BOOL
166
+ _kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
167
+ _kernel32.CreateJobObjectW.restype = wintypes.HANDLE
168
+ _kernel32.SetInformationJobObject.argtypes = [
169
+ wintypes.HANDLE,
170
+ ctypes.c_int,
171
+ ctypes.c_void_p,
172
+ wintypes.DWORD,
173
+ ]
174
+ _kernel32.SetInformationJobObject.restype = wintypes.BOOL
175
+ _kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
176
+ _kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
177
+ _kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
178
+ _kernel32.TerminateJobObject.restype = wintypes.BOOL
179
+ _kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
180
+ _kernel32.TerminateProcess.restype = wintypes.BOOL
181
+
182
+ def _create_containment_job() -> int:
183
+ """Create a kill-on-close job object for one pseudoconsole child."""
184
+ job = _kernel32.CreateJobObjectW(None, None)
185
+ if not job:
186
+ raise OSError(f"CreateJobObject failed: {ctypes.get_last_error()}")
187
+ limits = _JobExtendedLimits()
188
+ limits.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
189
+ if not _kernel32.SetInformationJobObject(
190
+ job,
191
+ _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
192
+ ctypes.byref(limits),
193
+ ctypes.sizeof(limits),
194
+ ):
195
+ error = ctypes.get_last_error()
196
+ _kernel32.CloseHandle(job)
197
+ raise OSError(f"SetInformationJobObject failed: {error}")
198
+ return int(job)
199
+
200
+ def _open_containment_handle(pid: int) -> int:
201
+ """Open the child's real process handle for assignment and waits."""
202
+ handle = _kernel32.OpenProcess(
203
+ _SYNCHRONIZE
204
+ | _PROCESS_QUERY_LIMITED_INFORMATION
205
+ | _PROCESS_SET_QUOTA
206
+ | _PROCESS_TERMINATE,
207
+ False,
208
+ pid,
209
+ )
210
+ if not handle:
211
+ raise OSError(f"OpenProcess({pid}) failed: {ctypes.get_last_error()}")
212
+ return int(handle)
213
+
214
+ def _assign_to_job(job: int, process_handle: int) -> None:
215
+ if not _kernel32.AssignProcessToJobObject(job, process_handle):
216
+ raise OSError(f"AssignProcessToJobObject failed: {ctypes.get_last_error()}")
217
+
218
+ def _terminate_job(job: int, exit_code: int) -> None:
219
+ if not _kernel32.TerminateJobObject(job, exit_code):
220
+ raise OSError(f"TerminateJobObject failed: {ctypes.get_last_error()}")
221
+
222
+ def _terminate_process(process_handle: int, exit_code: int) -> None:
223
+ _kernel32.TerminateProcess(process_handle, exit_code)
224
+
225
+ def _wait_for_handle(handle: int, timeout_ms: int) -> bool:
226
+ """OS wait on a real handle; True once it is signaled, never a sleep.
227
+
228
+ A wait failure is a real error, not a timeout, and is raised as such
229
+ so it cannot masquerade as "the process did not terminate".
230
+ """
231
+ result = int(_kernel32.WaitForSingleObject(handle, timeout_ms))
232
+ if result == _WAIT_OBJECT_0:
233
+ return True
234
+ if result == _WAIT_TIMEOUT:
235
+ return False
236
+ raise OSError(
237
+ f"WaitForSingleObject failed ({result:#x}): {ctypes.get_last_error()}"
238
+ )
239
+
240
+ def _close_handle(handle: int) -> None:
241
+ _kernel32.CloseHandle(handle)
242
+
243
+ else:
244
+
245
+ def _unsupported() -> ConptyUnsupportedError:
246
+ return ConptyUnsupportedError(
247
+ "the ConPTY binding requires Windows; this host has no ConPTY"
248
+ )
249
+
250
+ def _create_containment_job() -> int:
251
+ raise _unsupported()
252
+
253
+ def _open_containment_handle(pid: int) -> int:
254
+ raise _unsupported()
255
+
256
+ def _assign_to_job(job: int, process_handle: int) -> None:
257
+ raise _unsupported()
258
+
259
+ def _terminate_job(job: int, exit_code: int) -> None:
260
+ raise _unsupported()
261
+
262
+ def _terminate_process(process_handle: int, exit_code: int) -> None:
263
+ raise _unsupported()
264
+
265
+ def _wait_for_handle(handle: int, timeout_ms: int) -> bool:
266
+ raise _unsupported()
267
+
268
+ def _close_handle(handle: int) -> None:
269
+ raise _unsupported()
270
+
271
+
272
+ class ConptyChild:
273
+ """Thin ownership wrapper around one native ConPTY pseudoconsole child."""
274
+
275
+ def __init__(self, pty: Any, pid: int, job: int, process_handle: int) -> None:
276
+ self._pty: Any | None = pty
277
+ self._pid = pid
278
+ self._job: int | None = job
279
+ self._process_handle: int | None = process_handle
280
+ self._exit_status: int | None = None
281
+ self._lock = threading.Lock()
282
+ self._pending_io = 0
283
+
284
+ @classmethod
285
+ def spawn(
286
+ cls,
287
+ argv: Sequence[str],
288
+ *,
289
+ rows: int,
290
+ columns: int,
291
+ env_overlay: Mapping[str, str] | None = None,
292
+ cwd: str | None = None,
293
+ ) -> ConptyChild:
294
+ """Spawn a contained child on a ConPTY pseudoconsole.
295
+
296
+ The child is assigned to a fresh kill-on-close job object before the
297
+ binding is returned. If containment cannot be established, the child
298
+ is terminated and the spawn fails closed: no uncontained session is
299
+ ever handed out.
300
+
301
+ ``env_overlay`` variables are overlaid onto this process's ambient
302
+ environment at spawn time; an overlay variable always wins over an
303
+ ambient variable of the same name. Disclosed: the child inherits the
304
+ ambient environment underneath the overlay — ambient contents are
305
+ not evidence and are not recorded, only the overlay is. ``cwd``
306
+ selects the child's working directory; without it, the child starts
307
+ in this process's current directory.
308
+ """
309
+ if os.name != "nt":
310
+ raise ConptyUnsupportedError(
311
+ "the ConPTY binding requires Windows; this host has no ConPTY"
312
+ )
313
+ from winpty import Backend
314
+ from winpty._winpty import PTY
315
+
316
+ arguments = list(argv)
317
+ command = shutil.which(arguments[0])
318
+ if command is None:
319
+ raise FileNotFoundError(
320
+ f"the command was not found or was not executable: {arguments[0]}"
321
+ )
322
+ merged = dict(os.environ)
323
+ if env_overlay is not None:
324
+ merged.update(env_overlay)
325
+ pty = PTY(columns, rows, backend=Backend.ConPTY)
326
+ environment = (
327
+ "\0".join(f"{name}={value}" for name, value in merged.items()) + "\0"
328
+ )
329
+ cmdline = (
330
+ " " + subprocess.list2cmdline(arguments[1:]) if len(arguments) > 1 else None
331
+ )
332
+ working_directory = cwd if cwd is not None else os.getcwd()
333
+ try:
334
+ spawned = pty.spawn(
335
+ command, cmdline=cmdline, cwd=working_directory, env=environment
336
+ )
337
+ except Exception as error:
338
+ # Drop the native reference before raising so the held exception's
339
+ # traceback cannot pin the freshly created pseudoconsole.
340
+ del pty
341
+ raise OSError(f"ConPTY spawn failed for {command}") from error
342
+ if not spawned:
343
+ del pty
344
+ raise OSError(f"ConPTY spawn reported failure for {command}")
345
+ pid = int(pty.pid)
346
+ job: int | None = None
347
+ process_handle: int | None = None
348
+ try:
349
+ job = _create_containment_job()
350
+ process_handle = _open_containment_handle(pid)
351
+ _assign_to_job(job, process_handle)
352
+ except OSError as error:
353
+ if process_handle is not None:
354
+ _terminate_process(process_handle, FORCED_TERMINATION_EXIT_CODE)
355
+ _close_handle(process_handle)
356
+ if job is not None:
357
+ _close_handle(job)
358
+ # Drop the native reference before raising so the exception
359
+ # traceback cannot pin the pseudoconsole handles.
360
+ del pty
361
+ raise OSError(
362
+ f"failed to contain ConPTY child {pid} in a job object"
363
+ ) from error
364
+ return cls(pty, pid, job, process_handle)
365
+
366
+ @property
367
+ def pid(self) -> int:
368
+ """Return the child's OS process id."""
369
+ return self._pid
370
+
371
+ def read(self) -> str:
372
+ """Block until pseudoconsole output is available and return it.
373
+
374
+ Raises :class:`ConptyEndOfStreamError` when the binding is still open
375
+ and the native output pipe reports end-of-stream after the child has
376
+ exited; the native exit status has been captured by then. Raises
377
+ :class:`ConptyClosedError` when the binding is closed before or while
378
+ the read is in flight, and :class:`ConptyConcurrentIOError` when
379
+ another read or write is already in flight. Any other native read
380
+ failure — the binding open, the child alive — is re-raised unchanged.
381
+ """
382
+ pty = self._begin_io()
383
+ try:
384
+ return str(pty.read(blocking=True))
385
+ except Exception as error:
386
+ replacement = self._classify_io_failure(pty, end_of_stream=True)
387
+ # Drop the frame-local native reference before raising: the
388
+ # exception's traceback keeps this frame alive, and a pinned
389
+ # native object would defer the handle release indefinitely.
390
+ del pty
391
+ if replacement is None:
392
+ raise
393
+ raise replacement from error
394
+ finally:
395
+ self._end_io()
396
+
397
+ def write(self, text: str) -> None:
398
+ """Write ``text`` to the child without claiming a byte-count receipt.
399
+
400
+ Raises :class:`ConptyClosedError` when the binding is closed before
401
+ or while the write is in flight, and
402
+ :class:`ConptyConcurrentIOError` when another read or write is
403
+ already in flight; other native write failures are re-raised
404
+ unchanged.
405
+ """
406
+ pty = self._begin_io()
407
+ try:
408
+ pty.write(text)
409
+ except Exception as error:
410
+ replacement = self._classify_io_failure(pty, end_of_stream=False)
411
+ del pty
412
+ if replacement is None:
413
+ raise
414
+ raise replacement from error
415
+ finally:
416
+ self._end_io()
417
+
418
+ def resize(self, *, rows: int, columns: int) -> None:
419
+ """Resize the pseudoconsole explicitly."""
420
+ self._require_open().set_size(columns, rows)
421
+
422
+ def is_alive(self) -> bool:
423
+ """Report whether the child process is still alive.
424
+
425
+ A closed binding reports ``False``: it no longer owns a live native
426
+ session through which liveness could be observed.
427
+ """
428
+ pty = self._pty
429
+ return False if pty is None else bool(pty.isalive())
430
+
431
+ def close(self, *, force: bool) -> None:
432
+ """Release native ownership; with ``force``, terminate the child's tree.
433
+
434
+ The forced path terminates the entire job — the child and every
435
+ descendant — atomically with ``TerminateJobObject`` (uniform exit
436
+ code :data:`FORCED_TERMINATION_EXIT_CODE`), waits on the child's real
437
+ process handle, and captures the native exit record before releasing
438
+ the handles.
439
+
440
+ A release-only close (``force=False``) of a live child records no
441
+ exit status — the binding never observed a native exit record — while
442
+ the pseudoconsole handle release itself makes ConPTY terminate the
443
+ attached client, which callers can observe at the OS level. The close
444
+ waits for that termination on the child's process handle and then
445
+ closes the job handle, whose kill-on-close limit sweeps any remaining
446
+ descendants; the no-kill path therefore cannot leak a process tree
447
+ either.
448
+
449
+ Close first unpublishes the native object so no new I/O can start,
450
+ then cancels pending native I/O until every in-flight read and write
451
+ has returned. Interrupted I/O surfaces :class:`ConptyClosedError` with
452
+ its frame-local native reference dropped, so a held exception cannot
453
+ pin the native object and the handles are released as soon as the
454
+ last frame still holding it unwinds.
455
+ """
456
+ with self._lock:
457
+ pty = self._pty
458
+ if pty is None:
459
+ return
460
+ self._pty = None
461
+ job = self._job
462
+ process_handle = self._process_handle
463
+ self._job = None
464
+ self._process_handle = None
465
+ child_exited = True
466
+ try:
467
+ try:
468
+ if force and pty.isalive():
469
+ if job is None:
470
+ # Defensive: unreachable on the only construction
471
+ # path; failing fast beats waiting on a live child.
472
+ raise OSError("no containment job to terminate")
473
+ _terminate_job(job, FORCED_TERMINATION_EXIT_CODE)
474
+ if process_handle is not None and not _wait_for_handle(
475
+ process_handle, _CHILD_EXIT_WAIT_MS
476
+ ):
477
+ raise OSError(
478
+ f"child process {self._pid} did not terminate on"
479
+ " forced close"
480
+ )
481
+ if not pty.isalive():
482
+ self._capture_exit_status(pty)
483
+ finally:
484
+ try:
485
+ self._cancel_pending_io(pty)
486
+ finally:
487
+ # Release the binding's native reference even when the
488
+ # cancel loop raises: the propagating exception's
489
+ # traceback must never pin the native object. The
490
+ # destructor closes the pseudoconsole once the last
491
+ # in-flight frame unwinds.
492
+ del pty
493
+ if not force and process_handle is not None:
494
+ child_exited = _wait_for_handle(process_handle, _CHILD_EXIT_WAIT_MS)
495
+ finally:
496
+ if job is not None:
497
+ # Kill-on-close sweeps every remaining job member, so even a
498
+ # failed graceful path cannot leak the tree.
499
+ _close_handle(job)
500
+ if not child_exited and process_handle is not None:
501
+ child_exited = _wait_for_handle(process_handle, _CHILD_EXIT_WAIT_MS)
502
+ if process_handle is not None:
503
+ _close_handle(process_handle)
504
+ if not child_exited:
505
+ raise OSError(
506
+ f"child process {self._pid} did not terminate after handle release"
507
+ )
508
+
509
+ def _cancel_pending_io(self, pty: Any) -> None:
510
+ """Cancel native I/O until no read or write frame can block on ``pty``."""
511
+ deadline = time.monotonic() + _READ_CANCEL_TIMEOUT_SECONDS
512
+ while True:
513
+ with self._lock:
514
+ pending = self._pending_io
515
+ if pending == 0:
516
+ return
517
+ with contextlib.suppress(Exception):
518
+ pty.cancel_io()
519
+ if time.monotonic() >= deadline:
520
+ raise OSError("pending native ConPTY I/O did not cancel during close")
521
+ time.sleep(_READ_CANCEL_RETRY_SECONDS)
522
+
523
+ @property
524
+ def exit_status(self) -> int | None:
525
+ """Return the natively observed exit status, else ``None``."""
526
+ pty = self._pty
527
+ if self._exit_status is None and pty is not None:
528
+ self._capture_exit_status(pty)
529
+ return self._exit_status
530
+
531
+ def _require_open(self) -> Any:
532
+ pty = self._pty
533
+ if pty is None:
534
+ raise ConptyClosedError("the ConPTY binding is closed")
535
+ return pty
536
+
537
+ def _begin_io(self) -> Any:
538
+ """Atomically take the native object and count the in-flight call.
539
+
540
+ At most one read or write may be in flight: overlapped native calls
541
+ on one pseudoconsole can crash the interpreter, and blocking a write
542
+ behind an indefinitely blocked read would deadlock, so overlap fails
543
+ fast as :class:`ConptyConcurrentIOError`.
544
+ """
545
+ with self._lock:
546
+ pty = self._pty
547
+ if pty is None:
548
+ raise ConptyClosedError("the ConPTY binding is closed")
549
+ if self._pending_io > 0:
550
+ raise ConptyConcurrentIOError(
551
+ "another native read or write is already in flight; the"
552
+ " binding is single-flight by contract"
553
+ )
554
+ self._pending_io += 1
555
+ return pty
556
+
557
+ def _end_io(self) -> None:
558
+ with self._lock:
559
+ self._pending_io -= 1
560
+
561
+ def _classify_io_failure(
562
+ self, pty: Any, *, end_of_stream: bool
563
+ ) -> Exception | None:
564
+ """Map a native I/O failure to the binding's honest exception, if any.
565
+
566
+ A failure observed after ``close`` unpublished the native object is
567
+ the close's own cancellation (or indistinguishable from it) and
568
+ becomes :class:`ConptyClosedError` — never an end-of-stream claim,
569
+ because close may have abandoned buffered output. A read failure on
570
+ an open binding with a dead child is the native end-of-stream signal.
571
+ Anything else is the caller's to see unchanged (``None``).
572
+ """
573
+ if not pty.isalive():
574
+ self._capture_exit_status(pty)
575
+ if self._pty is None:
576
+ return ConptyClosedError("the ConPTY binding was closed during native I/O")
577
+ if end_of_stream and not pty.isalive():
578
+ return ConptyEndOfStreamError(
579
+ "the native ConPTY output pipe reported end-of-stream"
580
+ )
581
+ return None
582
+
583
+ def _capture_exit_status(self, pty: Any) -> None:
584
+ status = pty.get_exitstatus()
585
+ if status is not None:
586
+ self._exit_status = int(status)
@@ -0,0 +1,30 @@
1
+ """Protocol-owned enforcement-tier vocabulary `termverify.enforcement-tier/v1`.
2
+
3
+ A closed, versioned vocabulary owned by the protocol exactly like the
4
+ timezone and key registries: exact case-sensitive membership, no aliases or
5
+ normalization, runtime validation authoritative. Post-freeze membership or
6
+ meaning changes require a new vocabulary version. Membership is not evidence
7
+ that an emitter exists; each tier's authorized emitting path is fixed by the
8
+ accepted cooperation-tier design
9
+ (`docs/agent/design/cooperation-tier-constraint-ports.md`) and enforced
10
+ fail-closed during receipt-binding validation.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Literal, TypeGuard
16
+
17
+ type EnforcementTier = Literal["os", "constructive", "delivered"]
18
+
19
+ #: The closed v1 membership, in disclosure-strength order: ``os`` (applied by
20
+ #: an operating-system mechanism at the subject boundary), ``constructive``
21
+ #: (applied by construction of the controlled in-process runtime),
22
+ #: ``delivered`` (placed into the subject's spawn environment; honoring it is
23
+ #: subject cooperation — nothing is enforced).
24
+ ENFORCEMENT_TIERS: tuple[EnforcementTier, ...] = ("os", "constructive", "delivered")
25
+ _ENFORCEMENT_TIER_SET = frozenset(ENFORCEMENT_TIERS)
26
+
27
+
28
+ def is_enforcement_tier(value: object) -> TypeGuard[EnforcementTier]:
29
+ """Return whether *value* is an exact v1 enforcement-tier member."""
30
+ return type(value) is str and value in _ENFORCEMENT_TIER_SET
termverify/_json.py ADDED
@@ -0,0 +1,7 @@
1
+ """Mutable JSON value shape used by v1 protocol boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ type JsonValue = (
6
+ None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
7
+ )