@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/deliver-detach.d.ts.map +1 -1
- package/dist/cli/deliver-detach.js +11 -1
- package/dist/cli/deliver-detach.js.map +1 -1
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +6 -0
- package/dist/cli/deliver.js.map +1 -1
- package/dist/delivery/orphan-guard.d.ts +59 -0
- package/dist/delivery/orphan-guard.d.ts.map +1 -0
- package/dist/delivery/orphan-guard.js +122 -0
- package/dist/delivery/orphan-guard.js.map +1 -0
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +135 -1
- package/tools/agents/tests/test_client_disconnect.py +99 -0
- package/tools/agents/tests/test_inflight_cancel.py +129 -0
|
@@ -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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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()
|