@miller-tech/uap 1.172.10 → 1.172.12

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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Stop a detached deliver run once the session that ordered it is gone.
3
+ *
4
+ * `uap deliver` detaches on purpose (see deliver-detach.ts): a mission must
5
+ * outlive the agent's bash tool call, which is a short-lived, process-group-
6
+ * killed container. That is correct and this must not undo it.
7
+ *
8
+ * What it must not outlive is the SESSION. Observed live: an agent client
9
+ * exited, and two detached deliver runs kept driving the model for over an hour
10
+ * — holding both slots, generating tokens for a conversation nobody would ever
11
+ * read — while the operator reasonably reported "I have nothing running". Only
12
+ * killing them by pid stopped it.
13
+ *
14
+ * WHY NOT THE OBVIOUS CHECKS
15
+ * - `ppid === 1`: wrong on any modern Linux desktop. Both orphans observed here
16
+ * had ppid 11681 — the systemd --user manager, which registers as a child
17
+ * subreaper, so orphans are re-parented to IT and never to init.
18
+ * - "re-parented since start": fires immediately on every detached run, because
19
+ * being re-parented is exactly what detaching does. It would kill the feature.
20
+ *
21
+ * So the guard watches a specific OWNER pid — the nearest ancestor that is an
22
+ * agent client — resolved once at detach time and inherited by the child.
23
+ */
24
+ import { readFileSync } from 'node:fs';
25
+ /** Env var carrying the resolved owner pid across the detach boundary. */
26
+ export const OWNER_PID_ENV = 'UAP_DELIVER_OWNER_PID';
27
+ /** Poll interval. Slow on purpose — a janitor, not a latency path. */
28
+ const DEFAULT_INTERVAL_MS = 20_000;
29
+ /**
30
+ * Process names that own a deliver run. A deliver started by one of these is
31
+ * work on behalf of a live session; when that session goes, so does the reason
32
+ * for the run. Anything else (a plain shell, CI, systemd) is deliberately NOT
33
+ * matched — the guard then stays off rather than guessing.
34
+ */
35
+ const CLIENT_COMMS = ['opencode', 'claude', 'cursor', 'codex', 'windsurf'];
36
+ /** `comm` and `ppid` for a pid, or null if it is gone / unreadable. */
37
+ export function readProcInfo(pid, procRoot = '/proc') {
38
+ try {
39
+ // /proc/<pid>/stat: "pid (comm) state ppid ..." — comm can contain spaces and
40
+ // parens, so split on the LAST ')' rather than tokenising the whole line.
41
+ const stat = readFileSync(`${procRoot}/${pid}/stat`, 'utf8');
42
+ const close = stat.lastIndexOf(')');
43
+ const open = stat.indexOf('(');
44
+ if (open < 0 || close < 0 || close < open)
45
+ return null;
46
+ const comm = stat.slice(open + 1, close);
47
+ const rest = stat.slice(close + 2).trim().split(/\s+/);
48
+ const ppid = Number(rest[1]);
49
+ return Number.isFinite(ppid) ? { comm, ppid } : null;
50
+ }
51
+ catch {
52
+ return null; // gone or unreadable — treat as absent
53
+ }
54
+ }
55
+ /**
56
+ * Nearest ancestor that is an agent client, or undefined.
57
+ *
58
+ * Undefined is a first-class answer: a deliver run from a plain shell or from CI
59
+ * has no session to outlive, so it gets no guard at all.
60
+ */
61
+ export function resolveOwnerPid(startPid = process.ppid, opts = {}) {
62
+ const procRoot = opts.procRoot ?? '/proc';
63
+ let pid = startPid;
64
+ for (let depth = 0; depth < (opts.maxDepth ?? 12); depth++) {
65
+ if (pid <= 1)
66
+ return undefined;
67
+ const info = readProcInfo(pid, procRoot);
68
+ if (!info)
69
+ return undefined;
70
+ const comm = info.comm.toLowerCase();
71
+ if (CLIENT_COMMS.some((c) => comm === c || comm.startsWith(c)))
72
+ return pid;
73
+ pid = info.ppid;
74
+ }
75
+ return undefined;
76
+ }
77
+ /** Is that pid still around? */
78
+ export function pidAlive(pid) {
79
+ try {
80
+ process.kill(pid, 0); // signal 0: existence check, delivers nothing
81
+ return true;
82
+ }
83
+ catch (err) {
84
+ // EPERM means it exists but belongs to someone else — alive for our purpose.
85
+ return err?.code === 'EPERM';
86
+ }
87
+ }
88
+ /**
89
+ * Exit when the owning session exits. No-op when there is no owner, or when the
90
+ * operator opted out with UAP_ALLOW_ORPHAN=1 (a deliberately detached run — say,
91
+ * a long mission started over SSH).
92
+ *
93
+ * Returns a stop function. The timer is unref'd: a watchdog that by itself kept
94
+ * the process alive would be its own bug.
95
+ */
96
+ export function guardAgainstOwnerExit(opts = {}) {
97
+ const optOut = (process.env.UAP_ALLOW_ORPHAN ?? '').toLowerCase();
98
+ if (['1', 'true', 'on', 'yes'].includes(optOut))
99
+ return () => { };
100
+ const ownerPid = Number(process.env[OWNER_PID_ENV]);
101
+ if (!Number.isFinite(ownerPid) || ownerPid <= 1)
102
+ return () => { };
103
+ const isAlive = opts.isAlive ?? pidAlive;
104
+ const timer = setInterval(() => {
105
+ if (isAlive(ownerPid))
106
+ return;
107
+ clearInterval(timer);
108
+ if (opts.onOwnerGone) {
109
+ opts.onOwnerGone(ownerPid);
110
+ return;
111
+ }
112
+ // Say why on the way out: a run that vanishes silently is the same
113
+ // debugging problem as one that never stops.
114
+ console.error(`\nuap: the session that started this run (pid ${ownerPid}) has exited — ` +
115
+ `stopping rather than holding a model slot for output nobody will read. ` +
116
+ `Set ${'UAP_ALLOW_ORPHAN'}=1 to keep detached runs alive.`);
117
+ process.exit(130); // 128 + SIGINT: ended by circumstance, not by failing
118
+ }, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
119
+ timer.unref?.();
120
+ return () => clearInterval(timer);
121
+ }
122
+ //# sourceMappingURL=orphan-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orphan-guard.js","sourceRoot":"","sources":["../../src/delivery/orphan-guard.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.172.10",
3
+ "version": "1.172.12",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -83,6 +83,7 @@ Dependencies
83
83
 
84
84
  import asyncio
85
85
  import contextvars
86
+ from typing import Awaitable, Callable
86
87
  import copy
87
88
  import hashlib
88
89
  import json
@@ -2958,6 +2959,47 @@ _current_request_session: contextvars.ContextVar[str | None] = contextvars.Conte
2958
2959
  "uap_current_request_session", default=None
2959
2960
  )
2960
2961
 
2962
+ # Is the DOWNSTREAM client still there?
2963
+ #
2964
+ # Nothing used to ask. Once a turn was accepted, the guardrail loops kept
2965
+ # retrying and re-POSTing upstream on their own — so when the client died the
2966
+ # work carried on regardless. Observed live: two `uap deliver` runs were killed,
2967
+ # no socket remained on :4000, and the proxy still drove llama for minutes,
2968
+ # generating 32k tokens per orphaned turn that nobody would ever read. Only a
2969
+ # proxy restart stopped it.
2970
+ #
2971
+ # Same ContextVar trick as the session above: request-local without threading a
2972
+ # Request through every signature. Holds Starlette's `request.is_disconnected`.
2973
+ _current_client_gone: contextvars.ContextVar[
2974
+ Callable[[], Awaitable[bool]] | None
2975
+ ] = contextvars.ContextVar("uap_current_client_gone", default=None)
2976
+
2977
+
2978
+ class ClientGoneError(Exception):
2979
+ """The downstream client disconnected; abandon the turn.
2980
+
2981
+ Raised instead of returning a value so the whole in-flight chain unwinds at
2982
+ once — every guardrail loop, retry and recovery step above it stops without
2983
+ each needing its own check. Caught at the handler boundary and answered with
2984
+ a status nobody reads, since by definition there is no one left to read it.
2985
+ """
2986
+
2987
+
2988
+ async def _client_gone() -> bool:
2989
+ """True when the caller has hung up. Never raises, never blocks meaningfully.
2990
+
2991
+ A disconnect check that can itself fail or stall would be worse than no
2992
+ check: it sits in front of every upstream call. Any error means "assume the
2993
+ client is still there" — the pre-existing behaviour.
2994
+ """
2995
+ probe = _current_client_gone.get()
2996
+ if probe is None:
2997
+ return False
2998
+ try:
2999
+ return bool(await probe())
3000
+ except Exception: # noqa: BLE001
3001
+ return False
3002
+
2961
3003
  # Session admission state. _admitted_sessions maps session_id -> last-seen
2962
3004
  # monotonic ts; OrderedDict insertion order is the LRU (oldest = front).
2963
3005
  # Guarded by _admission_cond's lock (created lazily on the running event loop).
@@ -3289,6 +3331,12 @@ async def _post_with_retry(
3289
3331
  # don't evict each other's KV (sticky, released by idle-TTL). No-op when
3290
3332
  # PROXY_SESSION_ADMISSION is off. Runs BEFORE the per-request semaphore so a
3291
3333
  # queued new session waits without holding a concurrency slot.
3334
+ # Every upstream call funnels through here, so one check covers all of them:
3335
+ # the guardrail retry loop, the completion-contract retries, recovery passes.
3336
+ # Placed BEFORE admission and the slot acquire so a dead client never takes a
3337
+ # concurrency slot away from a live one, and never queues behind one either.
3338
+ if await _client_gone():
3339
+ raise ClientGoneError("client disconnected before upstream call")
3292
3340
  await _ensure_session_admitted(_current_request_session.get())
3293
3341
  acquired = await _acquire_upstream_slot()
3294
3342
  if not acquired:
@@ -3310,6 +3358,55 @@ async def _post_with_retry(
3310
3358
  _release_upstream_slot()
3311
3359
 
3312
3360
 
3361
+ # How often to re-check the caller while an upstream generation is in flight.
3362
+ PROXY_DISCONNECT_POLL_SECS = max(
3363
+ 0.5, float(os.environ.get("PROXY_DISCONNECT_POLL_SECS", "2"))
3364
+ )
3365
+
3366
+
3367
+ async def _post_watching_client(
3368
+ client: httpx.AsyncClient, url: str, payload: dict, headers: dict
3369
+ ) -> httpx.Response:
3370
+ """POST upstream, cancelling it if the caller hangs up mid-generation.
3371
+
3372
+ Checking only BEFORE each upstream call stops the retry loops, but does
3373
+ nothing for a generation already in flight: a single turn is one POST, so
3374
+ there is no next call to block and the model runs to completion — up to
3375
+ PROXY_TOOL_TURN_MAX_TOKENS (32k, ~13 minutes here) for a client that has
3376
+ already gone. This closes that.
3377
+
3378
+ Cancelling the httpx task closes the upstream connection, and llama.cpp
3379
+ releases the slot when its client disappears — verified directly against the
3380
+ running server: slots went 2 -> 0 within 15s of closing the socket. So this
3381
+ genuinely frees the GPU rather than merely letting the proxy stop waiting.
3382
+
3383
+ No probe (background task, tests) means no watching: plain await, unchanged.
3384
+ """
3385
+ probe = _current_client_gone.get()
3386
+ if probe is None:
3387
+ return await client.post(url, json=payload, headers=headers)
3388
+
3389
+ post_task = asyncio.ensure_future(client.post(url, json=payload, headers=headers))
3390
+ try:
3391
+ while True:
3392
+ done, _pending = await asyncio.wait(
3393
+ {post_task}, timeout=PROXY_DISCONNECT_POLL_SECS
3394
+ )
3395
+ if post_task in done:
3396
+ return post_task.result()
3397
+ if await _client_gone():
3398
+ post_task.cancel()
3399
+ try:
3400
+ await post_task
3401
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
3402
+ pass # cancellation is the point; the connection is closed
3403
+ raise ClientGoneError("client disconnected mid-generation")
3404
+ finally:
3405
+ # Never leave the upstream call running after we stop waiting on it.
3406
+ if not post_task.done():
3407
+ post_task.cancel()
3408
+
3409
+
3313
3410
  async def _post_with_retry_inner(
3314
3411
  client: httpx.AsyncClient,
3315
3412
  url: str,
@@ -3321,7 +3418,7 @@ async def _post_with_retry_inner(
3321
3418
  try:
3322
3419
  _inflight_inc(client)
3323
3420
  try:
3324
- resp = await client.post(url, json=payload, headers=headers)
3421
+ resp = await _post_watching_client(client, url, payload, headers)
3325
3422
  finally:
3326
3423
  _inflight_dec(client)
3327
3424
  # Cycle 19 Option 1: if 503 "Loading model", wait for health then retry
@@ -3542,6 +3639,23 @@ app = FastAPI(
3542
3639
  lifespan=lifespan,
3543
3640
  )
3544
3641
 
3642
+ @app.exception_handler(ClientGoneError)
3643
+ async def _client_gone_handler(request: Request, exc: ClientGoneError):
3644
+ """The caller hung up mid-turn: stop, and say so at INFO rather than ERROR.
3645
+
3646
+ 499 is nginx's "client closed request" — chosen because no standard code
3647
+ means this and the body goes nowhere anyway; what matters is that the log
3648
+ line reads as an abandoned turn rather than a proxy fault, so this does not
3649
+ masquerade as an incident when someone kills an agent run.
3650
+ """
3651
+ logger.info(
3652
+ "CLIENT GONE: abandoned %s mid-turn (session=%s) — no further upstream calls",
3653
+ request.url.path,
3654
+ (_current_request_session.get() or "?")[:24],
3655
+ )
3656
+ return Response(status_code=499)
3657
+
3658
+
3545
3659
  @app.exception_handler(httpx.PoolTimeout)
3546
3660
  async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
3547
3661
  """Saturated upstream pool: answer 529 overloaded (Anthropic semantics —
@@ -10791,6 +10905,10 @@ async def messages(request: Request):
10791
10905
  """
10792
10906
  global last_session_id
10793
10907
 
10908
+ # Publish this request's disconnect probe for the whole call tree. Set before
10909
+ # ANY upstream work so an already-dead client is caught on the first call.
10910
+ _current_client_gone.set(request.is_disconnected)
10911
+
10794
10912
  body = await request.json()
10795
10913
  is_stream = body.get("stream", False)
10796
10914
  model = body.get("model", "default")
@@ -11067,6 +11185,8 @@ async def messages(request: Request):
11067
11185
  strict_body,
11068
11186
  {"Content-Type": "application/json"},
11069
11187
  )
11188
+ except ClientGoneError:
11189
+ raise # control flow: never swallow into a broad handler
11070
11190
  except Exception as exc:
11071
11191
  # Check if upstream is hung before returning error
11072
11192
  await _check_slot_hang(LLAMA_CPP_BASE.replace("/v1", "/slots"))
@@ -11099,6 +11219,8 @@ async def messages(request: Request):
11099
11219
  strict_body,
11100
11220
  {"Content-Type": "application/json"},
11101
11221
  )
11222
+ except ClientGoneError:
11223
+ raise # control flow: never swallow into a broad handler
11102
11224
  except Exception:
11103
11225
  pass # fall through to next handler
11104
11226
  if strict_resp.status_code != 200:
@@ -11116,6 +11238,8 @@ async def messages(request: Request):
11116
11238
  strict_body,
11117
11239
  {"Content-Type": "application/json"},
11118
11240
  )
11241
+ except ClientGoneError:
11242
+ raise # control flow: never swallow into a broad handler
11119
11243
  except Exception as exc:
11120
11244
  return Response(
11121
11245
  content=json.dumps(
@@ -11242,6 +11366,8 @@ async def messages(request: Request):
11242
11366
  openai_resp = retry_data
11243
11367
  else:
11244
11368
  logger.info("DEGENERATE RETRY: retry insufficient, using truncated original")
11369
+ except ClientGoneError:
11370
+ raise # control flow: never swallow into a broad handler
11245
11371
  except Exception as exc:
11246
11372
  logger.warning("DEGENERATE RETRY: failed: %s", exc)
11247
11373
  anthropic_resp = openai_to_anthropic_response(
@@ -11535,6 +11661,8 @@ async def messages(request: Request):
11535
11661
  openai_body,
11536
11662
  {"Content-Type": "application/json"},
11537
11663
  )
11664
+ except ClientGoneError:
11665
+ raise # control flow: never swallow into a broad handler
11538
11666
  except Exception as exc:
11539
11667
  return Response(
11540
11668
  content=json.dumps(
@@ -11565,6 +11693,8 @@ async def messages(request: Request):
11565
11693
  openai_body,
11566
11694
  {"Content-Type": "application/json"},
11567
11695
  )
11696
+ except ClientGoneError:
11697
+ raise # control flow: never swallow into a broad handler
11568
11698
  except Exception:
11569
11699
  pass # fall through
11570
11700
  if resp.status_code != 200:
@@ -11582,6 +11712,8 @@ async def messages(request: Request):
11582
11712
  openai_body,
11583
11713
  {"Content-Type": "application/json"},
11584
11714
  )
11715
+ except ClientGoneError:
11716
+ raise # control flow: never swallow into a broad handler
11585
11717
  except Exception as exc:
11586
11718
  return Response(
11587
11719
  content=json.dumps(
@@ -11699,6 +11831,8 @@ async def messages(request: Request):
11699
11831
  openai_resp = retry_data
11700
11832
  else:
11701
11833
  logger.info("DEGENERATE RETRY (stream): no tool call, using truncated")
11834
+ except ClientGoneError:
11835
+ raise # control flow: never swallow into a broad handler
11702
11836
  except Exception as exc:
11703
11837
  logger.warning("DEGENERATE RETRY (stream): failed: %s", exc)
11704
11838
  anthropic_resp = openai_to_anthropic_response(
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """The proxy must abandon a turn when the downstream client disconnects.
3
+
4
+ Nothing used to ask whether the caller was still there. Once a turn was
5
+ accepted, the guardrail loops kept retrying and re-POSTing upstream on their
6
+ own — so when the client died the work carried on regardless. Observed live
7
+ (2026-07-29): two `uap deliver` runs were killed, no socket remained on :4000,
8
+ and the proxy still drove llama for minutes, generating ~32k tokens per
9
+ orphaned turn that nobody would ever read. Only a proxy restart stopped it.
10
+ """
11
+
12
+ import asyncio
13
+ import importlib.util
14
+ import unittest
15
+ from pathlib import Path
16
+
17
+
18
+ def _load_proxy():
19
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
20
+ spec = importlib.util.spec_from_file_location("anthropic_proxy_disc", p)
21
+ assert spec is not None and spec.loader is not None
22
+ m = importlib.util.module_from_spec(spec)
23
+ spec.loader.exec_module(m)
24
+ return m
25
+
26
+
27
+ proxy = _load_proxy()
28
+
29
+
30
+ class ClientGoneProbeTest(unittest.TestCase):
31
+ def test_absent_probe_means_client_is_present(self):
32
+ """No probe set (background task, test harness) must not read as gone —
33
+ that would abandon perfectly live turns."""
34
+ proxy._current_client_gone.set(None)
35
+ self.assertFalse(asyncio.run(proxy._client_gone()))
36
+
37
+ def test_reports_disconnect(self):
38
+ async def gone() -> bool:
39
+ return True
40
+
41
+ proxy._current_client_gone.set(gone)
42
+ self.assertTrue(asyncio.run(proxy._client_gone()))
43
+
44
+ def test_reports_connected(self):
45
+ async def here() -> bool:
46
+ return False
47
+
48
+ proxy._current_client_gone.set(here)
49
+ self.assertFalse(asyncio.run(proxy._client_gone()))
50
+
51
+ def test_a_failing_probe_never_breaks_the_request(self):
52
+ """This check sits in front of every upstream call. A probe that can
53
+ raise would be worse than no probe, so failure means 'still there'."""
54
+ async def broken() -> bool:
55
+ raise RuntimeError("receive channel exploded")
56
+
57
+ proxy._current_client_gone.set(broken)
58
+ self.assertFalse(asyncio.run(proxy._client_gone()))
59
+
60
+
61
+ class ClientGoneIsNotSwallowedTest(unittest.TestCase):
62
+ """The unwind only works if no broad handler eats it on the way out.
63
+
64
+ `messages()` has eight `except Exception` blocks. Any one of them catching
65
+ ClientGoneError would turn "client left" into a logged error and let the
66
+ turn continue — silently reinstating the bug while every other test passed.
67
+ """
68
+
69
+ def test_every_broad_except_in_messages_reraises_first(self):
70
+ src = (
71
+ Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
72
+ ).read_text().split("\n")
73
+ start = next(i for i, l in enumerate(src) if l.startswith("async def messages(request: Request)"))
74
+ end = next(
75
+ (i for i in range(start + 1, len(src)) if src[i].startswith("async def ") or src[i].startswith("@app.")),
76
+ len(src),
77
+ )
78
+
79
+ unguarded = []
80
+ for i in range(start, end):
81
+ stripped = src[i].lstrip()
82
+ if not stripped.startswith("except Exception") and not stripped.startswith("except BaseException"):
83
+ continue
84
+ # The two lines above must be the ClientGoneError re-raise.
85
+ prev = [src[i - 2].strip(), src[i - 1].strip()] if i >= 2 else []
86
+ if prev[:1] != ["except ClientGoneError:"] or prev[1:] and not prev[1].startswith("raise"):
87
+ unguarded.append((i + 1, stripped[:60]))
88
+
89
+ self.assertEqual(unguarded, [], f"broad handlers that would swallow the disconnect: {unguarded}")
90
+
91
+ def test_client_gone_is_an_exception_so_fastapi_can_handle_it(self):
92
+ """Deliberately NOT a BaseException: Starlette's middleware only
93
+ dispatches Exception subclasses, so a BaseException would sail past the
94
+ registered handler and take down the worker instead of answering 499."""
95
+ self.assertTrue(issubclass(proxy.ClientGoneError, Exception))
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """Cancel the upstream generation when the caller hangs up mid-turn.
3
+
4
+ Checking only BEFORE each upstream call (v1.172.11) stops the retry loops, but
5
+ does nothing for a generation already in flight: a single turn is ONE post, so
6
+ there is no next call to block and the model runs to completion — up to
7
+ PROXY_TOOL_TURN_MAX_TOKENS (32k, ~13 minutes on this box) for a client that has
8
+ already gone.
9
+
10
+ That gap was caught by an end-to-end test, not by reasoning: the abandon simply
11
+ never fired for a single long request. These tests pin the closing behaviour so
12
+ it cannot silently regress to the pre-check-only version.
13
+
14
+ Cancelling matters rather than merely returning: closing the upstream connection
15
+ is what makes llama.cpp release the slot (verified against the running server —
16
+ slots went 2 -> 0 within 15s of a raw socket close).
17
+ """
18
+
19
+ import asyncio
20
+ import importlib.util
21
+ import unittest
22
+ from pathlib import Path
23
+
24
+
25
+ def _load_proxy():
26
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
27
+ spec = importlib.util.spec_from_file_location("anthropic_proxy_inflight", p)
28
+ assert spec is not None and spec.loader is not None
29
+ m = importlib.util.module_from_spec(spec)
30
+ spec.loader.exec_module(m)
31
+ return m
32
+
33
+
34
+ proxy = _load_proxy()
35
+
36
+
37
+ class _Client:
38
+ """Stands in for httpx.AsyncClient.post.
39
+
40
+ `never` models the real failure: the model is generating, so the POST is
41
+ neither erroring nor returning — it is simply not done yet.
42
+ """
43
+
44
+ def __init__(self, mode: str):
45
+ self.mode = mode
46
+ self.cancelled = False
47
+
48
+ async def post(self, url, json=None, headers=None): # noqa: A002
49
+ if self.mode == "fast":
50
+ return "RESPONSE"
51
+ try:
52
+ await asyncio.sleep(3600)
53
+ except asyncio.CancelledError:
54
+ self.cancelled = True
55
+ raise
56
+ return "RESPONSE"
57
+
58
+
59
+ def _set_probe(gone: bool):
60
+ async def probe() -> bool:
61
+ return gone
62
+ proxy._current_client_gone.set(probe)
63
+
64
+
65
+ class InflightCancelTest(unittest.TestCase):
66
+ def setUp(self):
67
+ proxy.PROXY_DISCONNECT_POLL_SECS = 0.05 # keep the tests quick
68
+
69
+ def test_cancels_the_upstream_call_when_the_caller_leaves(self):
70
+ client = _Client("never")
71
+ _set_probe(True)
72
+
73
+ async def run():
74
+ with self.assertRaises(proxy.ClientGoneError):
75
+ await proxy._post_watching_client(client, "u", {}, {})
76
+
77
+ asyncio.run(run())
78
+ # The cancellation is the whole point: it closes the connection, which is
79
+ # what frees the llama slot. Returning early without it would leave the
80
+ # model generating exactly as before.
81
+ self.assertTrue(client.cancelled, "upstream POST was not cancelled")
82
+
83
+ def test_leaves_a_live_turn_completely_alone(self):
84
+ client = _Client("fast")
85
+ _set_probe(False)
86
+ self.assertEqual(asyncio.run(proxy._post_watching_client(client, "u", {}, {})), "RESPONSE")
87
+ self.assertFalse(client.cancelled)
88
+
89
+ def test_no_probe_means_a_plain_await(self):
90
+ """Background tasks and tests have no request context. They must keep
91
+ working, unwatched, rather than acquiring surprise cancellation."""
92
+ client = _Client("fast")
93
+ proxy._current_client_gone.set(None)
94
+ self.assertEqual(asyncio.run(proxy._post_watching_client(client, "u", {}, {})), "RESPONSE")
95
+
96
+ def test_a_slow_but_live_turn_is_left_pending(self):
97
+ """The poll must not mistake 'slow' for 'gone' — long generations are
98
+ normal, and cancelling them would be a far worse bug than the one fixed.
99
+
100
+ Asserted by letting it poll many times and checking it is STILL running.
101
+ (Wrapping it in wait_for would prove nothing: a wait_for timeout is the
102
+ caller giving up, and the cleanup correctly cancels upstream then — which
103
+ is how the first version of this test fooled itself.)
104
+ """
105
+ client = _Client("never")
106
+ _set_probe(False)
107
+
108
+ async def run():
109
+ task = asyncio.ensure_future(proxy._post_watching_client(client, "u", {}, {}))
110
+ await asyncio.sleep(0.4) # ~8 poll intervals
111
+ still_running = not task.done()
112
+ task.cancel()
113
+ try:
114
+ await task
115
+ except (asyncio.CancelledError, Exception):
116
+ pass
117
+ return still_running
118
+
119
+ self.assertTrue(asyncio.run(run()), "a live-but-slow turn was ended early")
120
+
121
+ def test_poll_interval_has_a_floor(self):
122
+ """A zero/negative interval would spin the event loop."""
123
+ self.assertGreaterEqual(proxy.PROXY_DISCONNECT_POLL_SECS, 0.0)
124
+ mod = _load_proxy()
125
+ self.assertGreaterEqual(mod.PROXY_DISCONNECT_POLL_SECS, 0.5)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ unittest.main()