@adhd/apigen-python-env 0.2.2 โ†’ 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
1
+ ## 0.2.4 (2026-08-08)
2
+
3
+
4
+ ### ๐Ÿฉน Fixes
5
+
6
+ - **apigen-python-env:** probe monorepo-live python source before co-located dist copy (DEBT-APIGEN-010)
7
+
8
+
9
+ ### โค๏ธ Thank You
10
+
11
+ - pseudosky
12
+
13
+ ## 0.2.3 (2026-08-07)
14
+
15
+
16
+ ### ๐Ÿฉน Fixes
17
+
18
+ - **apigen-cli:** restore 2768 files mass-deleted by 0117eb22 (BUG-APIGEN-052)
19
+
20
+ - **apigen-python:** parent-death watchdog so Python hosts cannot outlive their spawning process (BUG-APIGEN-053)
21
+
22
+
23
+ ### โค๏ธ Thank You
24
+
25
+ - parity-harness-self-test
26
+ - pseudosky
27
+
1
28
  ## 0.2.2 (2026-07-30)
2
29
 
3
30
 
package/index.js CHANGED
@@ -1,10 +1,10 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const D=require("node:fs"),I=require("node:os"),q=require("node:path"),F=require("node:crypto"),h=require("node:child_process");function P(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t){for(const r in t)if(r!=="default"){const i=Object.getOwnPropertyDescriptor(t,r);Object.defineProperty(e,r,i.get?i:{enumerable:!0,get:()=>t[r]})}}return e.default=t,Object.freeze(e)}const n=P(D),b=P(I),o=P(q);function k(t){const e=t??__dirname,r=o.join(e,"python");if(n.existsSync(o.join(r,"apigen_python")))return r;const i=o.resolve(e,"..","..","..","python");if(n.existsSync(o.join(i,"apigen_python")))return i;let a=e;for(let c=0;c<20;c++){const p=o.join(a,"packages","apigen","python");if(n.existsSync(o.join(p,"apigen_python")))return p;const u=o.dirname(a);if(u===a)break;a=u}return i}const H=["python3.13","python3.12","python3.11","python3"],m=[3,11];function J(t){const e=h.spawnSync(t,["-c",'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")'],{encoding:"utf8",timeout:15e3});if(e.status!==0||!e.stdout)return;const[r,i]=e.stdout.trim().split(".").map(Number);if(!(!Number.isFinite(r)||!Number.isFinite(i)))return[r,i]}function M(){for(const t of H){const e=J(t);if(e&&(e[0]>m[0]||e[0]===m[0]&&e[1]>=m[1]))return t}throw new Error(`apigen-python-env: no Python >= ${m.join(".")} found on PATH (tried: ${H.join(", ")}). Install Python ${m.join(".")}+ or set APIGEN_PYTHON to a provisioned interpreter.`)}function Y(t){return process.platform==="win32"?o.join(t,"Scripts","python.exe"):o.join(t,"bin","python3")}function G(t){const e=n.readFileSync(o.join(t,"pyproject.toml"));return F.createHash("sha256").update(e).digest("hex")}function O(t){try{const e=JSON.parse(n.readFileSync(t,"utf8"));if(typeof e.pyprojectHash=="string"&&Array.isArray(e.extras))return e}catch{}}function B(t,e,r){const i=Date.now()+e;for(;;)try{n.mkdirSync(t,{recursive:!1});break}catch{if(Date.now()>i){try{n.rmdirSync(t)}catch{}continue}h.spawnSync(process.execPath,["-e","setTimeout(()=>{}, 250)"],{timeout:5e3})}try{return r()}finally{try{n.rmdirSync(t)}catch{}}}function V(t={}){const e=k(),r=process.env.APIGEN_PYTHON;if(r){if(!n.existsSync(r))throw new Error(`apigen-python-env: APIGEN_PYTHON points to a missing file: ${r}`);return{python:r,venvDir:"",pythonPkgDir:e}}const i=[...new Set(t.extras??[])].sort(),a=t.envHome??process.env.APIGEN_PYENV_HOME??o.join(b.homedir(),".adhd","apigen"),c=o.join(a,"pyvenv"),p=o.join(c,".apigen-stamp"),u=G(e),y=Y(c),l=()=>{if(!n.existsSync(y))return!1;const s=O(p);return!s||s.pyprojectHash!==u?!1:i.every(d=>s.extras.includes(d))};if(l())return{python:y,venvDir:c,pythonPkgDir:e};n.mkdirSync(a,{recursive:!0});const v=o.join(a,"pyvenv.lock"),w=t.lockTimeoutMs??3e5;return B(v,w,()=>{if(l())return{python:y,venvDir:c,pythonPkgDir:e};const s=O(p),d=[...new Set([...(s==null?void 0:s.extras)??[],...i])].sort();n.rmSync(c,{recursive:!0,force:!0});const _=M(),j=h.spawnSync(_,["-m","venv",c],{encoding:"utf8",timeout:12e4});if(j.status!==0)throw new Error(`apigen-python-env: "${_} -m venv" failed (exit ${j.status}):
2
- ${j.stderr}`);const x=o.join(e,"build");n.existsSync(x)&&n.rmSync(x,{recursive:!0,force:!0});for(const f of n.readdirSync(e))f.endsWith(".egg-info")&&n.rmSync(o.join(e,f),{recursive:!0,force:!0});const S=n.mkdtempSync(o.join(b.tmpdir(),"apigen-python-wheel-"));try{const f=h.spawnSync(y,["-m","pip","wheel","--no-deps","--wheel-dir",S,e],{encoding:"utf8",timeout:6e5});if(f.status!==0)throw new Error(`apigen-python-env: pip wheel failed (exit ${f.status}):
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const D=require("node:fs"),I=require("node:os"),q=require("node:path"),F=require("node:crypto"),h=require("node:child_process");function P(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t){for(const n in t)if(n!=="default"){const i=Object.getOwnPropertyDescriptor(t,n);Object.defineProperty(e,n,i.get?i:{enumerable:!0,get:()=>t[n]})}}return e.default=t,Object.freeze(e)}const r=P(D),b=P(I),o=P(q);function k(t){const e=t??__dirname,n=o.resolve(e,"..","..","..","python");if(r.existsSync(o.join(n,"apigen_python")))return n;const i=o.join(e,"python");if(r.existsSync(o.join(i,"apigen_python")))return i;let a=e;for(let c=0;c<20;c++){const p=o.join(a,"packages","apigen","python");if(r.existsSync(o.join(p,"apigen_python")))return p;const u=o.dirname(a);if(u===a)break;a=u}return n}const H=["python3.13","python3.12","python3.11","python3"],m=[3,11];function J(t){const e=h.spawnSync(t,["-c",'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")'],{encoding:"utf8",timeout:15e3});if(e.status!==0||!e.stdout)return;const[n,i]=e.stdout.trim().split(".").map(Number);if(!(!Number.isFinite(n)||!Number.isFinite(i)))return[n,i]}function M(){for(const t of H){const e=J(t);if(e&&(e[0]>m[0]||e[0]===m[0]&&e[1]>=m[1]))return t}throw new Error(`apigen-python-env: no Python >= ${m.join(".")} found on PATH (tried: ${H.join(", ")}). Install Python ${m.join(".")}+ or set APIGEN_PYTHON to a provisioned interpreter.`)}function Y(t){return process.platform==="win32"?o.join(t,"Scripts","python.exe"):o.join(t,"bin","python3")}function G(t){const e=r.readFileSync(o.join(t,"pyproject.toml"));return F.createHash("sha256").update(e).digest("hex")}function O(t){try{const e=JSON.parse(r.readFileSync(t,"utf8"));if(typeof e.pyprojectHash=="string"&&Array.isArray(e.extras))return e}catch{}}function B(t,e,n){const i=Date.now()+e;for(;;)try{r.mkdirSync(t,{recursive:!1});break}catch{if(Date.now()>i){try{r.rmdirSync(t)}catch{}continue}h.spawnSync(process.execPath,["-e","setTimeout(()=>{}, 250)"],{timeout:5e3})}try{return n()}finally{try{r.rmdirSync(t)}catch{}}}function V(t={}){const e=k(),n=process.env.APIGEN_PYTHON;if(n){if(!r.existsSync(n))throw new Error(`apigen-python-env: APIGEN_PYTHON points to a missing file: ${n}`);return{python:n,venvDir:"",pythonPkgDir:e}}const i=[...new Set(t.extras??[])].sort(),a=t.envHome??process.env.APIGEN_PYENV_HOME??o.join(b.homedir(),".adhd","apigen"),c=o.join(a,"pyvenv"),p=o.join(c,".apigen-stamp"),u=G(e),y=Y(c),l=()=>{if(!r.existsSync(y))return!1;const s=O(p);return!s||s.pyprojectHash!==u?!1:i.every(d=>s.extras.includes(d))};if(l())return{python:y,venvDir:c,pythonPkgDir:e};r.mkdirSync(a,{recursive:!0});const v=o.join(a,"pyvenv.lock"),w=t.lockTimeoutMs??3e5;return B(v,w,()=>{if(l())return{python:y,venvDir:c,pythonPkgDir:e};const s=O(p),d=[...new Set([...(s==null?void 0:s.extras)??[],...i])].sort();r.rmSync(c,{recursive:!0,force:!0});const _=M(),j=h.spawnSync(_,["-m","venv",c],{encoding:"utf8",timeout:12e4});if(j.status!==0)throw new Error(`apigen-python-env: "${_} -m venv" failed (exit ${j.status}):
2
+ ${j.stderr}`);const x=o.join(e,"build");r.existsSync(x)&&r.rmSync(x,{recursive:!0,force:!0});for(const f of r.readdirSync(e))f.endsWith(".egg-info")&&r.rmSync(o.join(e,f),{recursive:!0,force:!0});const S=r.mkdtempSync(o.join(b.tmpdir(),"apigen-python-wheel-"));try{const f=h.spawnSync(y,["-m","pip","wheel","--no-deps","--wheel-dir",S,e],{encoding:"utf8",timeout:6e5});if(f.status!==0)throw new Error(`apigen-python-env: pip wheel failed (exit ${f.status}):
3
3
  ${f.stderr}
4
4
  stdout:
5
- ${f.stdout}`);const E=n.readdirSync(S).filter(A=>A.endsWith(".whl"));if(E.length===0)throw new Error("apigen-python-env: pip wheel produced no .whl file");const $=o.join(S,E[0]),N=d.length?`${$}[${d.join(",")}]`:$,g=h.spawnSync(y,["-m","pip","install","--quiet","--disable-pip-version-check",N],{encoding:"utf8",timeout:6e5});if(g.status!==0)throw new Error(`apigen-python-env: pip install "${N}" failed (exit ${g.status}):
5
+ ${f.stdout}`);const E=r.readdirSync(S).filter(A=>A.endsWith(".whl"));if(E.length===0)throw new Error("apigen-python-env: pip wheel produced no .whl file");const $=o.join(S,E[0]),N=d.length?`${$}[${d.join(",")}]`:$,g=h.spawnSync(y,["-m","pip","install","--quiet","--disable-pip-version-check",N],{encoding:"utf8",timeout:6e5});if(g.status!==0)throw new Error(`apigen-python-env: pip install "${N}" failed (exit ${g.status}):
6
6
  ${g.stderr}
7
7
  stdout:
8
- ${g.stdout}`)}finally{n.rmSync(S,{recursive:!0,force:!0})}const T={pyprojectHash:u,extras:d};return n.writeFileSync(p,JSON.stringify(T)),{python:y,venvDir:c,pythonPkgDir:e}})}function L(t,e,r,i,a){return new Promise((c,p)=>{var v,w;const u=h.spawn(t,["-m","apigen_python.extractor",r,"--namespace",i,"--emit-json"],{cwd:e,env:{...process.env,PYTHONPATH:e},stdio:["ignore","pipe","pipe"]});let y="",l="";(v=u.stdout)==null||v.on("data",s=>{y+=s.toString("utf8")}),(w=u.stderr)==null||w.on("data",s=>{l+=s.toString("utf8")}),u.on("error",p),u.on("exit",s=>{if(s!==0){p(new Error(`${a}: extractor --emit-json exited with code ${s}:
8
+ ${g.stdout}`)}finally{r.rmSync(S,{recursive:!0,force:!0})}const T={pyprojectHash:u,extras:d};return r.writeFileSync(p,JSON.stringify(T)),{python:y,venvDir:c,pythonPkgDir:e}})}function L(t,e,n,i,a){return new Promise((c,p)=>{var v,w;const u=h.spawn(t,["-m","apigen_python.extractor",n,"--namespace",i,"--emit-json"],{cwd:e,env:{...process.env,PYTHONPATH:e},stdio:["ignore","pipe","pipe"]});let y="",l="";(v=u.stdout)==null||v.on("data",s=>{y+=s.toString("utf8")}),(w=u.stderr)==null||w.on("data",s=>{l+=s.toString("utf8")}),u.on("error",p),u.on("exit",s=>{if(s!==0){p(new Error(`${a}: extractor --emit-json exited with code ${s}:
9
9
  ${l}`));return}try{c(JSON.parse(y))}catch(d){p(new Error(`${a}: extractor --emit-json produced invalid JSON (${d.message}):
10
10
  ${y}`))}})})}exports.ensurePythonEnv=V;exports.resolvePythonPkgDir=k;exports.runExtractorEmitJson=L;
package/index.mjs CHANGED
@@ -4,10 +4,10 @@ import * as r from "node:path";
4
4
  import { createHash as T } from "node:crypto";
5
5
  import { spawnSync as l, spawn as I } from "node:child_process";
6
6
  function O(e) {
7
- const t = e ?? __dirname, o = r.join(t, "python");
7
+ const t = e ?? __dirname, o = r.resolve(t, "..", "..", "..", "python");
8
8
  if (n.existsSync(r.join(o, "apigen_python")))
9
9
  return o;
10
- const s = r.resolve(t, "..", "..", "..", "python");
10
+ const s = r.join(t, "python");
11
11
  if (n.existsSync(r.join(s, "apigen_python")))
12
12
  return s;
13
13
  let a = t;
@@ -20,7 +20,7 @@ function O(e) {
20
20
  break;
21
21
  a = u;
22
22
  }
23
- return s;
23
+ return o;
24
24
  }
25
25
  const _ = ["python3.13", "python3.12", "python3.11", "python3"], m = [3, 11];
26
26
  function D(e) {
@@ -27,17 +27,27 @@ export interface IEnsurePythonEnvOptions {
27
27
  * Locate the directory containing the `apigen_python` package sources.
28
28
  *
29
29
  * Probed in order:
30
- * 1. Co-located (SHIPPED) copy: `<this file's dir>/python`. This package's
31
- * build copies `packages/apigen/python/apigen_python/**` into its own
32
- * `dist/python/apigen_python/`, so for a consumer installed from npm
33
- * OUTSIDE the monorepo (where __dirname is `node_modules/@adhd/
34
- * apigen-python-env/dist`), the sources are bundled right next to the
35
- * compiled code. This MUST be probed first so published consumers never
36
- * fall through to a monorepo-only path that doesn't exist for them.
37
- * 2. Monorepo source (dev-time fallback): `<this file>/../../../python`
38
- * (source layout: packages/apigen/python-env/src/lib โ†’ packages/apigen/
39
- * python). Keeps vitest/ts-node runs resolving the LIVE source instead
40
- * of the copied snapshot.
30
+ * 1. Monorepo source (LIVE, preferred when present): `<this file>/../../
31
+ * ../python` (source layout: packages/apigen/python-env/src/lib โ†’
32
+ * packages/apigen/python; from a build, `dist/lib` โ†’ `packages/apigen/
33
+ * python` by the same three-levels-up relationship). When both this
34
+ * AND the co-located dist copy below exist โ€” i.e. running inside the
35
+ * monorepo, built or not โ€” this MUST win. `dist/python` is a
36
+ * point-in-time copy made by this package's build (vite.config.ts
37
+ * `copyPythonPackagePlugin`); it goes stale the moment someone edits
38
+ * `packages/apigen/python` without rebuilding, and code run outside
39
+ * Nx's task graph (`npx vitest run` directly, bypassing the `test`
40
+ * target's `dependsOn: ["build"]`) has no mechanism to refresh it. Prior
41
+ * ordering probed the dist copy FIRST, so any monorepo dev/test
42
+ * invocation that happened to have a `dist/python` lying around from a
43
+ * previous build silently exercised stale Python source with no
44
+ * indication the live edit was never observed โ€” see
45
+ * DEBT-APIGEN-010. Source must win whenever it's reachable.
46
+ * 2. Co-located (SHIPPED) copy: `<this file's dir>/python`. For a
47
+ * consumer installed from npm OUTSIDE the monorepo (where __dirname is
48
+ * `node_modules/@adhd/apigen-python-env/dist`), step 1's monorepo-
49
+ * relative path doesn't exist, so this bundled-alongside-the-compiled-
50
+ * code copy is the only candidate and is used.
41
51
  * 3. Walk up from __dirname looking for `packages/apigen/python` (covers
42
52
  * the vite-bundled CLI, where __dirname is the bundle output dir).
43
53
  * (Shared by py-grpc / py-flask โ€” previously duplicated in each plugin.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhd/apigen-python-env",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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.2.2"
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