@miller-tech/uap 1.172.11 → 1.172.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.172.11",
3
+ "version": "1.172.13",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -2766,7 +2766,7 @@ def _detach_aclose(closeable) -> None:
2766
2766
 
2767
2767
  def _build_http_client() -> httpx.AsyncClient:
2768
2768
  """Upstream client factory — used at startup AND by pool self-healing."""
2769
- c = httpx.AsyncClient(
2769
+ c = DisconnectAwareClient(
2770
2770
  timeout=httpx.Timeout(
2771
2771
  connect=10.0, # 10s to establish connection
2772
2772
  read=PROXY_READ_TIMEOUT, # configurable (default 10 min)
@@ -3358,6 +3358,58 @@ async def _post_with_retry(
3358
3358
  _release_upstream_slot()
3359
3359
 
3360
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
+ class DisconnectAwareClient(httpx.AsyncClient):
3368
+ """An httpx client that abandons a call when the caller hangs up.
3369
+
3370
+ THE CHOKE POINT. The previous attempt guarded ONE call site and claimed to
3371
+ cover them all; there are fourteen, and the guardrail loops that caused the
3372
+ incident call the model directly rather than through _post_with_retry. So
3373
+ the check belongs where it cannot be missed: every high-level httpx method —
3374
+ post(), stream(), request() — funnels through send(), so overriding send()
3375
+ covers all of them, including any added later.
3376
+
3377
+ Cancelling matters rather than merely returning: closing the connection is
3378
+ what makes llama.cpp release the slot (verified against the running server —
3379
+ slots went 2 -> 0 within 15s of a raw socket close). Returning early without
3380
+ cancelling would leave the model generating exactly as before.
3381
+
3382
+ No probe (background task, health check, tests) means a plain send,
3383
+ unwatched. Streaming sends are covered for their header phase; a body being
3384
+ written to a vanished client fails on write anyway.
3385
+ """
3386
+
3387
+ async def send(self, request, **kwargs): # type: ignore[override]
3388
+ probe = _current_client_gone.get()
3389
+ if probe is None:
3390
+ return await super().send(request, **kwargs)
3391
+
3392
+ task = asyncio.ensure_future(super().send(request, **kwargs))
3393
+ try:
3394
+ while True:
3395
+ done, _pending = await asyncio.wait(
3396
+ {task}, timeout=PROXY_DISCONNECT_POLL_SECS
3397
+ )
3398
+ if task in done:
3399
+ return task.result()
3400
+ if await _client_gone():
3401
+ task.cancel()
3402
+ try:
3403
+ await task
3404
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
3405
+ pass # cancellation is the point; the connection is closed
3406
+ raise ClientGoneError("client disconnected mid-generation")
3407
+ finally:
3408
+ # Never leave an upstream call running once we stop waiting on it.
3409
+ if not task.done():
3410
+ task.cancel()
3411
+
3412
+
3361
3413
  async def _post_with_retry_inner(
3362
3414
  client: httpx.AsyncClient,
3363
3415
  url: str,
@@ -3446,7 +3498,7 @@ async def _check_slot_hang(slot_url: str) -> bool:
3446
3498
  if PROXY_SLOT_HANG_TIMEOUT <= 0:
3447
3499
  return False
3448
3500
  try:
3449
- async with httpx.AsyncClient() as check_client:
3501
+ async with DisconnectAwareClient() as check_client:
3450
3502
  resp = await check_client.get(slot_url, timeout=5.0)
3451
3503
  if resp.status_code != 200:
3452
3504
  return False
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """Every upstream call must pass the disconnect check — structurally, not by claim.
3
+
4
+ This test exists because of a specific mistake. Two releases (v1.172.11,
5
+ v1.172.12) guarded ONE call site while the commit message asserted that "every
6
+ upstream call funnels through _post_with_retry, so one check covers them all".
7
+ There are fourteen upstream call sites, and the guardrail loops that caused the
8
+ incident — malformed-tool retry, completion-contract, empty-max-tokens recovery,
9
+ unexpected-end-turn, recipe — call the model DIRECTLY. Both releases were
10
+ therefore near-no-ops for the workload they were written for, and every unit
11
+ test still passed.
12
+
13
+ The fix was to move the check somewhere it cannot be missed: httpx routes every
14
+ high-level method (post, stream, request) through send(), so DisconnectAwareClient
15
+ overrides send() and the coverage becomes a property of the client rather than a
16
+ property of remembering.
17
+
18
+ These tests pin that property. A future call site added anywhere in the proxy is
19
+ covered automatically; a future *client* constructed bare would not be, and that
20
+ is exactly what the enumeration below fails on.
21
+ """
22
+
23
+ import ast
24
+ import asyncio
25
+ import importlib.util
26
+ import re
27
+ import unittest
28
+ from pathlib import Path
29
+
30
+ PROXY_PATH = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
31
+
32
+
33
+ def _load_proxy():
34
+ spec = importlib.util.spec_from_file_location("anthropic_proxy_choke", PROXY_PATH)
35
+ assert spec is not None and spec.loader is not None
36
+ m = importlib.util.module_from_spec(spec)
37
+ spec.loader.exec_module(m)
38
+ return m
39
+
40
+
41
+ proxy = _load_proxy()
42
+ SRC = PROXY_PATH.read_text()
43
+
44
+
45
+ class ChokePointEnumerationTest(unittest.TestCase):
46
+ def test_no_bare_httpx_client_is_ever_constructed(self):
47
+ """The invariant. A bare httpx.AsyncClient bypasses the override, so its
48
+ calls silently go unwatched — which is precisely how the first two
49
+ attempts failed, one call site at a time."""
50
+ bare = [
51
+ (i + 1, l.strip())
52
+ for i, l in enumerate(SRC.split("\n"))
53
+ if re.search(r"httpx\.AsyncClient\s*\(", l) and "class " not in l
54
+ ]
55
+ self.assertEqual(
56
+ bare, [], f"these construct an unwatched client: {bare}"
57
+ )
58
+
59
+ def test_the_client_actually_subclasses_httpx(self):
60
+ """Guards the reverse mistake: a wrapper that no longer IS an httpx
61
+ client would break every caller rather than fail this suite loudly."""
62
+ import httpx
63
+
64
+ self.assertTrue(issubclass(proxy.DisconnectAwareClient, httpx.AsyncClient))
65
+
66
+ def test_send_is_overridden_on_the_client_itself(self):
67
+ """send() is the funnel. If a refactor moves the logic off send() onto,
68
+ say, post(), then stream() and request() silently lose their guard."""
69
+ self.assertIn("send", vars(proxy.DisconnectAwareClient))
70
+
71
+ def test_every_upstream_call_site_is_reachable_through_send(self):
72
+ """Inventory, so the count cannot quietly drift.
73
+
74
+ Any client.post / client.stream / client.send in the proxy is covered by
75
+ the override — that is the point of a choke point. This asserts the call
76
+ sites still exist in numbers consistent with the audit (they were the
77
+ thing miscounted), and that none of them re-implements its own transport.
78
+ """
79
+ calls = re.findall(r"client\.(post|send|stream)\s*\(", SRC)
80
+ self.assertGreaterEqual(
81
+ len(calls), 10, "upstream call sites vanished — was transport re-implemented?"
82
+ )
83
+ # No call site may build its own client inline and slip the override.
84
+ self.assertNotRegex(SRC, r"await\s+httpx\.AsyncClient\s*\(")
85
+
86
+
87
+ class ChokePointBehaviourTest(unittest.TestCase):
88
+ """The override must do the job, not merely exist."""
89
+
90
+ class _Req:
91
+ pass
92
+
93
+ def setUp(self):
94
+ proxy.PROXY_DISCONNECT_POLL_SECS = 0.05
95
+
96
+ def _client_with_send(self, behaviour):
97
+ """A DisconnectAwareClient whose SUPERCLASS send is stubbed, so the
98
+ override under test runs for real."""
99
+ import httpx
100
+
101
+ state = {"cancelled": False}
102
+
103
+ async def fake_send(self, request, **kwargs):
104
+ if behaviour == "fast":
105
+ return "RESPONSE"
106
+ try:
107
+ await asyncio.sleep(3600)
108
+ except asyncio.CancelledError:
109
+ state["cancelled"] = True
110
+ raise
111
+
112
+ original = httpx.AsyncClient.send
113
+ httpx.AsyncClient.send = fake_send # type: ignore[assignment]
114
+ client = proxy.DisconnectAwareClient()
115
+ return client, state, (lambda: setattr(httpx.AsyncClient, "send", original))
116
+
117
+ def _set_probe(self, gone: bool):
118
+ async def probe() -> bool:
119
+ return gone
120
+ proxy._current_client_gone.set(probe)
121
+
122
+ def test_cancels_upstream_when_the_caller_leaves(self):
123
+ client, state, restore = self._client_with_send("never")
124
+ try:
125
+ self._set_probe(True)
126
+
127
+ async def run():
128
+ with self.assertRaises(proxy.ClientGoneError):
129
+ await client.send(self._Req())
130
+
131
+ asyncio.run(run())
132
+ self.assertTrue(state["cancelled"], "upstream call was not cancelled")
133
+ finally:
134
+ restore()
135
+
136
+ def test_leaves_a_live_call_alone(self):
137
+ client, state, restore = self._client_with_send("fast")
138
+ try:
139
+ self._set_probe(False)
140
+ self.assertEqual(asyncio.run(client.send(self._Req())), "RESPONSE")
141
+ self.assertFalse(state["cancelled"])
142
+ finally:
143
+ restore()
144
+
145
+ def test_no_probe_means_unwatched(self):
146
+ """Health checks and background tasks have no request context and must
147
+ keep working rather than acquiring surprise cancellation."""
148
+ client, state, restore = self._client_with_send("fast")
149
+ try:
150
+ proxy._current_client_gone.set(None)
151
+ self.assertEqual(asyncio.run(client.send(self._Req())), "RESPONSE")
152
+ finally:
153
+ restore()
154
+
155
+ def test_a_slow_but_live_call_is_left_pending(self):
156
+ """Mistaking 'slow' for 'gone' would be worse than the bug being fixed."""
157
+ client, state, restore = self._client_with_send("never")
158
+ try:
159
+ self._set_probe(False)
160
+
161
+ async def run():
162
+ task = asyncio.ensure_future(client.send(self._Req()))
163
+ await asyncio.sleep(0.4) # ~8 poll intervals
164
+ still = not task.done()
165
+ task.cancel()
166
+ try:
167
+ await task
168
+ except (asyncio.CancelledError, Exception):
169
+ pass
170
+ return still
171
+
172
+ self.assertTrue(asyncio.run(run()), "a live-but-slow call was ended early")
173
+ finally:
174
+ restore()
175
+
176
+
177
+ if __name__ == "__main__":
178
+ unittest.main()