Skip to content

fix(webhook): shutdown kills in-flight processRequest work; orphans tracking comments and leaks clones #12

Description

@chrisleekr

Finding

The graceful-shutdown handler in src/app.ts:181-195 drains HTTP connections only — it does not wait for the fire-and-forget async pipeline started by processRequest(). When Kubernetes sends SIGTERM (rolling deploy, HPA scale-down, node drain):

  1. isReady = false flips /readyz to 503 (src/app.ts:183) — correct.
  2. server.close(cb) at src/app.ts:185 fires cb as soon as all open HTTP connections close. Per the Node.js docs, the callback waits on connection lifecycle only — it does not know about module-level promises.
  3. Every active webhook handler — handleIssueComment (src/webhook/events/issue-comment.ts:35-37) and handleReviewComment (src/webhook/events/review-comment.ts:35-37) — calls processRequest(ctx).catch(...) fire-and-forget after the webhook has already returned 200 OK in milliseconds. By the time SIGTERM arrives, the HTTP request is long closed, so the close() callback fires almost instantly.
  4. The callback then calls process.exit(0) (src/app.ts:187), killing the process before the 290 s force-exit timer at src/app.ts:191-194 can fire. That timer is effectively dead code: it only triggers if server.close hangs, which never happens in this topology because fire-and-forget promises are not HTTP connections.

The router already tracks the right signal: activeCount (src/webhook/router.ts:29, incremented at :161, decremented at :261 inside a finally) counts exactly the in-flight processRequest invocations. Nothing awaits it at shutdown. processRequest has 10 sequential steps — tracking-comment creation, GraphQL fetch, repo clone, Claude Agent SDK execution, finalize comment, temp-dir cleanup — any of which can be interrupted.

User-visible failure modes on every pod restart:

  • Orphaned "Working..." tracking commentscreateTrackingComment (src/core/tracking-comment.ts:48-66) posted the spinner, but finalizeTrackingComment never ran. The durable <!-- delivery:... --> marker (src/core/tracking-comment.ts:12-14) sits in a permanently unresolved comment.
  • Leaked temp clone dirs and credential helperscheckoutRepo's cleanup() runs in the finally block of src/webhook/router.ts:204-241, which never executes after process.exit. The startup sweep at src/app.ts:139-165 removes stale *.cred.sh files but not the clone directories.
  • Silent money loss — agent turns already billed by executeAgent (src/core/executor.ts:83-96) deliver no output. No retry happens because GitHub does not automatically redeliver failed webhooks — the user must re-@mention manually.

Diagram

flowchart TD
    sigterm["SIGTERM<br/>rolling deploy / HPA / node drain"]:::signal
    ready["isReady = false<br/>readyz -> 503"]:::ok
    fire["Fire-and-forget processRequest<br/>webhook already returned 200 OK"]:::warn
    noconn["Zero open HTTP connections<br/>activeCount = N &gt; 0"]:::warn
    close["server.close callback<br/>waits ONLY on HTTP conns"]:::warn
    cbfast["callback fires within ms<br/>290s force-exit never reached"]:::bad
    exit["process.exit 0<br/>aborts in-flight processRequest"]:::bad
    orphan["Orphaned Working... comment<br/>with delivery marker"]:::bad
    leak["Leaked temp clone dir"]:::bad
    wallet["API cost billed<br/>no output delivered"]:::bad
    noretry["GitHub does NOT auto-redeliver<br/>user must re-@mention"]:::bad
    fix["MISSING: await activeCount == 0<br/>before process.exit"]:::fix

    sigterm --> ready --> close
    fire -.-> noconn
    close --> noconn --> cbfast --> exit
    exit --> orphan --> noretry
    exit --> leak
    exit --> wallet
    exit -.->|should wait for| fix

    classDef signal fill:#1e3a8a;color:#ffffff;stroke:#0c2050
    classDef ok fill:#166534;color:#ffffff;stroke:#052e16
    classDef warn fill:#92400e;color:#ffffff;stroke:#451a03
    classDef bad fill:#7f1d1d;color:#ffffff;stroke:#450a0a
    classDef fix fill:#0f766e;color:#ffffff;stroke:#042f2e
Loading

Rationale

Single-pod Claude Agent SDK runs routinely take 30-120 s (multi-turn conversation + repo clone + MCP tool calls, bounded at maxTurns: 50 per src/core/executor.ts:72). A rolling deploy on every merge to main, Kubernetes HPA scale-down on every traffic dip, and node-drain on every infra rotation all truncate an arbitrary slice of in-flight requests. Conservatively — one deploy/day, one in-flight request at deploy time, mean duration 60 s, two extra SIGTERM events/day from other churn — that is roughly 3 orphaned tracking comments per day per pod. After a week the PR timeline is polluted with "Working..." stubs that require admin GC.

The setTimeout(..., 290_000) at src/app.ts:191-194 is documented as "Force exit after terminationGracePeriodSeconds if server.close hangs", but server.close never hangs here — its callback fires as soon as HTTP connections drain (immediate). The 290 s budget the operator paid for with terminationGracePeriodSeconds is thrown away. Draining on activeCount instead of HTTP-connection count recovers that budget and delivers the graceful behaviour operators already expect.

activeCount is the correct signal: the invariant is preserved across success (src/webhook/router.ts:229-237) and failure (src/webhook/router.ts:242-262) branches via finally. No new state is required; it simply needs to be awaited. The pattern matches the Node.js production shutdown contract — "stop accepting new work → drain in-flight work → close external deps → exit".

References

Internal:

  • src/app.ts:181-195shutdown() drains HTTP only, not background promises
  • src/app.ts:185-188server.close callback immediately calls process.exit(0)
  • src/app.ts:191-194 — 290 s force-exit timer unreachable in this topology
  • src/webhook/router.ts:29activeCount module-level counter (never awaited at shutdown)
  • src/webhook/router.ts:161,261 — increment/decrement in try/finally
  • src/webhook/events/issue-comment.ts:35-37 — fire-and-forget processRequest(ctx).catch(...)
  • src/webhook/events/review-comment.ts:35-37 — same pattern
  • src/core/tracking-comment.ts:12-14,48-66 — delivery marker + "Working..." comment creation
  • src/core/executor.ts:83-96 — agent loop bounded by internal timeout only, not shutdown
  • CLAUDE.md — "Async processing: Webhook must respond within 10 seconds. All heavy work runs asynchronously after 200 OK."

External:

Suggested Next Steps

  1. Export waitForDrain(timeoutMs): Promise<void> from src/webhook/router.ts that polls activeCount === 0 on a short interval (e.g. 250 ms) and resolves early or rejects on timeout. Keep the pure-function style used for cleanupStaleIdempotencyEntries so the logic is unit-testable.
  2. In src/app.ts shutdown(), after isReady = false, await waitForDrain(270_000) (≈20 s headroom below the assumed terminationGracePeriodSeconds). Then call server.close() and process.exit(0). Move process.exit out of the server.close callback so drain gates exit, not HTTP connection closure.
  3. Add a defensive startup sweep that finalizes stale "Working..." comments older than ~10 min (symmetric with the *.cred.sh sweep at src/app.ts:139-165). The durable marker <!-- delivery:... --> already exists; isAlreadyProcessed at src/core/tracking-comment.ts:23-40 gives the lookup primitive.
  4. Add a regression test in test/webhook/router.test.ts that asserts waitForDrain resolves only after all active processRequest invocations complete (use a fake executeAgent bound to a controlled promise). A grep of test/ for shutdown|SIGTERM|server.close|drain returns zero hits today.
  5. Document the new shutdown contract in CLAUDE.md under "How It Runs" so contributors know fire-and-forget work is explicitly drained, not implicitly abandoned.

Areas Evaluated

  • src/webhook/router.ts — idempotency fast path, durable check ordering, activeCount lifecycle, owner allowlist gate, concurrency limit, retry wrappers, error path
  • src/webhook/events/ — all 5 handlers; issue-comment.ts + review-comment.ts active, rest are placeholders
  • src/webhook/authorize.tsisOwnerAllowed ordering relative to durable idempotency
  • src/app.ts — signal handlers, readiness, startup checks, stale credential sweep
  • src/core/tracking-comment.ts — delivery marker, create/finalize flow
  • src/core/executor.ts — agent-loop wall-clock timeout
  • test/webhook/router.test.ts — confirmed zero shutdown/drain coverage
  • gh issue list --label research --state all — empty; no duplicate risk

Generated by scheduled research workflow run #24312419817 on 2026-04-12

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions