@adhd/apigen-python-env 0.2.2 → 0.2.3
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.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## 0.2.3 (2026-08-07)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### 🩹 Fixes
|
|
5
|
+
|
|
6
|
+
- **apigen-cli:** restore 2768 files mass-deleted by 0117eb22 (BUG-APIGEN-052)
|
|
7
|
+
|
|
8
|
+
- **apigen-python:** parent-death watchdog so Python hosts cannot outlive their spawning process (BUG-APIGEN-053)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ❤️ Thank You
|
|
12
|
+
|
|
13
|
+
- parity-harness-self-test
|
|
14
|
+
- pseudosky
|
|
15
|
+
|
|
1
16
|
## 0.2.2 (2026-07-30)
|
|
2
17
|
|
|
3
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhd/apigen-python-env",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"main": "./index.js",
|
|
5
5
|
"module": "./index.mjs",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"provisioning"
|
|
13
13
|
],
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@adhd/apigen-core-client": "^0.
|
|
15
|
+
"@adhd/apigen-core-client": "^0.3.0"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
@@ -93,6 +93,7 @@ if str(_THIS_DIR) not in sys.path:
|
|
|
93
93
|
sys.path.insert(0, str(_THIS_DIR))
|
|
94
94
|
|
|
95
95
|
from apigen_python.errors import ApiError, HTTP_STATUS # noqa: E402
|
|
96
|
+
from apigen_python.parent_watchdog import start_parent_death_watchdog # noqa: E402
|
|
96
97
|
from apigen_python.runtime import HostRequest, Runtime # noqa: E402
|
|
97
98
|
from apigen_python.validator import validate, ValidationError # noqa: E402
|
|
98
99
|
|
|
@@ -946,6 +947,10 @@ class ApigenFlaskServer:
|
|
|
946
947
|
self.start()
|
|
947
948
|
# §13.1 readiness signal — TS launcher polls for this line on stdout
|
|
948
949
|
print(json.dumps({"ready": True, "port": self._port}), flush=True)
|
|
950
|
+
# BUG-APIGEN-053: self-terminate if the TS parent process dies
|
|
951
|
+
# outright (SIGKILL/OOM-kill/crash) without ever sending a signal —
|
|
952
|
+
# see apigen_python.parent_watchdog's module docstring.
|
|
953
|
+
start_parent_death_watchdog()
|
|
949
954
|
try:
|
|
950
955
|
# Block main thread while the daemon thread serves
|
|
951
956
|
if self._thread is not None:
|
|
@@ -119,6 +119,7 @@ if str(_THIS_DIR) not in sys.path:
|
|
|
119
119
|
sys.path.insert(0, str(_THIS_DIR))
|
|
120
120
|
|
|
121
121
|
from apigen_python.errors import ApiError, GRPC_CODE # noqa: E402
|
|
122
|
+
from apigen_python.parent_watchdog import start_parent_death_watchdog # noqa: E402
|
|
122
123
|
from apigen_python.runtime import HostRequest, Runtime # noqa: E402
|
|
123
124
|
from apigen_python.validator import validate, ValidationError # noqa: E402
|
|
124
125
|
|
|
@@ -842,6 +843,11 @@ class ApigenGrpcServer:
|
|
|
842
843
|
_signal.signal(_signal.SIGTERM, _sig_handler)
|
|
843
844
|
_signal.signal(_signal.SIGINT, _sig_handler)
|
|
844
845
|
|
|
846
|
+
# BUG-APIGEN-053: self-terminate if the TS parent process dies
|
|
847
|
+
# outright (SIGKILL/OOM-kill/crash) without ever sending a signal —
|
|
848
|
+
# see apigen_python.parent_watchdog's module docstring.
|
|
849
|
+
start_parent_death_watchdog()
|
|
850
|
+
|
|
845
851
|
try:
|
|
846
852
|
stop_event.wait()
|
|
847
853
|
except KeyboardInterrupt:
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Parent-death watchdog (BUG-APIGEN-053).
|
|
2
|
+
|
|
3
|
+
Both of apigen_python's long-running servers (flask_server.py, grpc_server.py)
|
|
4
|
+
are spawned by a TS parent (apigen-plugin-py-flask/-py-grpc's `run()`) with
|
|
5
|
+
stdio `['pipe', 'pipe', 'pipe']`. The only teardown path either plugin wires
|
|
6
|
+
is `input.signal`'s 'abort' handler, which sends SIGTERM to the child -- but
|
|
7
|
+
that requires the TS parent process to still be alive to fire it. When the
|
|
8
|
+
parent instead dies outright (SIGKILL, OOM-kill, CI job cancellation, an
|
|
9
|
+
uncaught crash before its abort handler runs), no signal is ever sent,
|
|
10
|
+
`serve_forever()`'s wait/join call never wakes up, and the process reparents
|
|
11
|
+
to launchd (ppid=1), holding its listening socket forever -- this is
|
|
12
|
+
BUG-APIGEN-053's exact, measured failure mode (132 orphaned processes / 137
|
|
13
|
+
listening sockets, ~23h old).
|
|
14
|
+
|
|
15
|
+
Neither Python's default SIGTERM disposition (fires only when someone SENDS
|
|
16
|
+
SIGTERM) nor a PR_SET_PDEATHSIG-style mechanism (Linux-only; unavailable on
|
|
17
|
+
macOS, where this failure was measured) closes this gap. The portable fix:
|
|
18
|
+
the child's stdin is a pipe whose write end is owned by the parent process.
|
|
19
|
+
The OS closes that write end the INSTANT the parent dies, for ANY reason,
|
|
20
|
+
including SIGKILL -- no cooperation from the dying parent required. A
|
|
21
|
+
background thread blocked reading stdin sees EOF at exactly that moment.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import threading
|
|
27
|
+
from typing import Callable, Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def start_parent_death_watchdog(
|
|
31
|
+
on_parent_death: Optional[Callable[[], None]] = None,
|
|
32
|
+
) -> threading.Thread:
|
|
33
|
+
"""Starts a daemon thread blocked reading stdin (fd 0) via a raw
|
|
34
|
+
`os.read()` -- see the in-loop comment for why not `sys.stdin`. On EOF
|
|
35
|
+
(the parent's write end of the stdin pipe closed -- i.e. the parent
|
|
36
|
+
died, for ANY reason including SIGKILL), calls `on_parent_death`.
|
|
37
|
+
|
|
38
|
+
Default `on_parent_death` is `os._exit(0)` -- an immediate hard process
|
|
39
|
+
exit. That is deliberate and safe here: there is no client left to serve
|
|
40
|
+
a graceful response to (the process that spawned this server is gone),
|
|
41
|
+
and the OS reclaims the listening socket on process exit regardless of
|
|
42
|
+
whether `Server.stop()`/`shutdown()` ran first.
|
|
43
|
+
|
|
44
|
+
Returns the started (daemon=True) Thread so a caller/test can join it.
|
|
45
|
+
"""
|
|
46
|
+
callback = on_parent_death or (lambda: os._exit(0))
|
|
47
|
+
|
|
48
|
+
def _watch() -> None:
|
|
49
|
+
try:
|
|
50
|
+
while True:
|
|
51
|
+
# Deliberately `os.read(0, ...)` (a raw fd read via the
|
|
52
|
+
# syscall wrapper), NOT `sys.stdin.buffer.read(...)`: the
|
|
53
|
+
# buffered form takes CPython's internal BufferedReader lock
|
|
54
|
+
# for the duration of the blocking read. `grpc_server.py`
|
|
55
|
+
# handles SIGTERM/SIGINT gracefully (its own handler sets an
|
|
56
|
+
# Event and `_main()` returns normally, reaching an ORDERLY
|
|
57
|
+
# `Py_Finalize()`) -- and finalization tries to flush/close
|
|
58
|
+
# `sys.stdin`, which cannot acquire that same lock while this
|
|
59
|
+
# daemon thread still holds it, producing a real, reproduced
|
|
60
|
+
# `Fatal Python error: _enter_buffered_busy: could not
|
|
61
|
+
# acquire lock ... at interpreter shutdown` on every single
|
|
62
|
+
# graceful teardown. A raw `os.read()` never touches that
|
|
63
|
+
# lock, so it can be safely abandoned mid-call when the
|
|
64
|
+
# interpreter exits either gracefully or via `os._exit()`.
|
|
65
|
+
chunk = os.read(0, 4096)
|
|
66
|
+
if not chunk:
|
|
67
|
+
break
|
|
68
|
+
except OSError:
|
|
69
|
+
# stdin fd already closed/invalid (e.g. a harness that doesn't
|
|
70
|
+
# wire a pipe, or redirects fd 0 elsewhere) -- nothing to watch;
|
|
71
|
+
# exit the thread quietly.
|
|
72
|
+
return
|
|
73
|
+
callback()
|
|
74
|
+
|
|
75
|
+
thread = threading.Thread(
|
|
76
|
+
target=_watch, name="apigen-parent-death-watchdog", daemon=True
|
|
77
|
+
)
|
|
78
|
+
thread.start()
|
|
79
|
+
return thread
|