fix(concurrency): release slot on subprocess SIGTERM failure (#40)

Per forensic analysis in #37, the timeout handler at server.mjs:471
called `proc.kill('SIGTERM')` without decrementing the concurrency
counter (`stats.activeRequests`). If the subprocess was stuck in a
syscall (e.g. MCP I/O) and ignored SIGTERM, its slot was not freed
until — and only if — the `proc.on('close')` handler eventually fired
after the 5s SIGKILL escalation successfully reaped the child.
Real-world observation (#37) shows 3 stuck claude subprocesses running
20 minutes to 2h 45m, exhausting the 8-slot pool and returning
`500 concurrency limit reached (8/8)` on every subsequent request.

Fix: attach `cleanup` to `proc.once('exit', ...)`. The 'exit' event
fires before 'close' and runs on every termination path — normal
completion, error, SIGTERM, SIGKILL, crash — even if stdio pipes
stay open. `cleanup()` is already idempotent (guarded by `cleaned`
flag), so this listener is safe alongside the existing 'close' and
'error' paths that also call it.

ALIGNMENT: cli.js has no equivalent subprocess-pool / concurrency-
limit logic — cli.js is a single-user CLI, the process being pooled,
not the pool itself. Per ALIGNMENT.md Rule 2, this fix is proxy-
internal (subprocess lifecycle management) with no cli.js equivalent
to cite; documented here instead. No wire-protocol, HTTP surface, or
response shape change (Rule 3 preserved).

Release kit: bumps version to 3.11.1 and adds CHANGELOG entry so the
auto-release workflow tags v3.11.1 on merge. README version refs are
feature-gate markers for v3.11.0 (models.json SPOT / auto-sync) and
remain historically accurate — no README change needed.

Fixes #37.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
This commit is contained in:
dtzp555-max
2026-04-21 20:21:36 +10:00
committed by GitHub
co-authored by taodeng
parent 9facd8307a
commit cdd6b41261
3 changed files with 15 additions and 1 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## v3.11.1 — 2026-04-21
### Fixes
- Concurrency slot leak on subprocess timeout (#37). The request-timeout handler called `proc.kill("SIGTERM")` without decrementing `stats.activeRequests`. A subprocess stuck in a syscall that ignored SIGTERM would hold its slot until (or beyond) the 5s SIGKILL escalation actually reaped it. Slot release is now wired to `proc.once("exit", cleanup)` so every termination path — normal close, error, SIGTERM, SIGKILL — releases the slot exactly once.
## v3.11.0 — 2026-04-20 ## v3.11.0 — 2026-04-20
### Features ### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "openclaw-claude-proxy", "name": "openclaw-claude-proxy",
"version": "3.11.0", "version": "3.11.1",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module", "type": "module",
"bin": { "bin": {
+9
View File
@@ -444,6 +444,15 @@ function spawnClaudeProcess(model, messages, conversationId) {
stats.activeRequests--; stats.activeRequests--;
} }
// Guarantee slot release on ANY exit path (normal close, error, timeout kill,
// SIGKILL escalation). The 'exit' event fires before 'close' and runs even
// if stdio pipes stay open. Fixes #37: the timeout path called
// proc.kill('SIGTERM') without decrementing the concurrency counter, so a
// stuck subprocess that ignored SIGTERM could leak its slot until (or
// beyond) the SIGKILL escalation actually reaped it. cleanup() is idempotent
// so this listener is safe alongside the existing 'close'/'error' paths.
proc.once("exit", cleanup);
function handleSessionFailure() { function handleSessionFailure() {
if (sessionInfo?.resume && conversationId) { if (sessionInfo?.resume && conversationId) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`); console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);