Skip to content

Commit 44eca48

Browse files
committed
fix(container): scope shutdown and boot reconciliation to the owning worker
Production runs `--workers 4` against one shared Docker socket, and both lifecycle hooks operated on "every unfinished session". So whenever any one worker restarted or crashed, its shutdown hook CANCELLED every live session on the host and its boot hook marked the survivors FAILED. Unlike the reaper, these were not idempotent -- they were actively destructive to peers. Neither function had a single test, which is how it survived. Shutdown is now scoped by ownership. Every runner carries a `helprs.boot_id` label identifying the process that started it, and `cleanup_own_running` asks Docker which containers carry this process's label. Docker is the authority on what is actually running; the database only records what was intended. Boot reconciliation stops guessing. `reconcile_stale_sessions` asks `container_is_running` per session instead of treating "unfinished" as evidence of death -- at boot, most RUNNING rows belong to a peer that never stopped and is still streaming to a user. `current_boot_id` recomputes when the PID changes rather than caching once, because uvicorn's --workers mode may fork after import and two workers sharing a boot id would each stop the other's containers -- reintroducing the bug through the mechanism meant to fix it. Adds `container_is_running` and `list_runners` to the DockerClient protocol and its doubles, plus `RunnerContainer` so the boundary returns a typed pair rather than a dict. Eight new tests, run against the previous behaviour first: four of them fail on it.
1 parent e95a398 commit 44eca48

9 files changed

Lines changed: 370 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ Key additions for production: `ENVIRONMENT=production`, `ADMIN_PASSWORD`, `CORS_
126126
- **Worktrees and node_modules**: `npm install` must be run in each worktree separately — `node_modules` aren't shared from the main tree
127127
- **Shallow clone + `gh pr checkout --detach`**: `gh pr checkout` (without `--detach`) fails on `--depth=1` clones because git can't set up tracking branches from shallow refs. Always use `--detach` — containers don't need tracking branches, just files on disk.
128128
- **Installation IDs in URLs**: frontend routes (`/installations/:id`) use `github_installation_id` (integer, e.g. `123093268`), NOT the internal UUID. All API endpoints (installation AND container session creation) expect the GitHub integer ID. The service layer resolves to internal UUID via `get_installation_by_github_id()`.
129-
- **Multi-worker background tasks**: with `--workers N`, each uvicorn worker runs its own lifespan (webhook reaper + container cleanup). Both are idempotent: reaper uses atomic row-level claim (`mark_processing`), cleanup suppresses double-stop exceptions.
129+
- **Multi-worker background tasks**: with `--workers N`, each uvicorn worker runs its own lifespan. The reaper claims rows atomically (`mark_processing`). The container hooks are scoped to the process that owns the work: every runner carries a `helprs.boot_id` label (`container/service.py:current_boot_id`), shutdown stops only containers with that label, and boot reconciliation asks Docker `container_is_running` instead of assuming every unfinished row is dead. Both were previously unscoped and destroyed peers' live sessions on any single worker restart.
130130
- **Run migrations after checkout**: `docker exec helprs-api-1 uv run alembic current` vs `alembic heads` — if they differ, run `make migrate`. Missing columns cause 500s that surface as browser CORS errors (response lacks CORS headers on unhandled exceptions).
131131
- **API rebuild invalidates tokens**: re-authenticate after an API restart if the deploy changed `SECRET_KEY`. Nothing in the code generates one — `Settings.SECRET_KEY` is required and the app refuses to boot without it.
132132
- **Coolify `--project-directory`**: Coolify sets `--project-directory` to the repo root, not the compose file location. Relative paths in the compose (`context`, `volumes`) must be relative to the repo root (`./apps/api`, not `../../apps/api`).

apps/api/src/helprs/main.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from helprs.core.exceptions import DomainError, domain_exception_handler
2323
from helprs.core.middleware import configure_logging, configure_sentry, setup_middleware
2424
from helprs.modules.container import cleanup as container_cleanup
25+
from helprs.modules.container import service as container_service
2526
from helprs.modules.container.docker_client import AioDockerClient
2627
from helprs.modules.container.router import router as container_router
2728
from helprs.modules.identity.router import router as identity_router
@@ -129,25 +130,43 @@ async def _run_container_cleanup(
129130

130131

131132
async def _reconcile_stale_sessions(session_factory: SessionFactory) -> None:
132-
"""Mark sessions left RUNNING/PENDING by a previous run as FAILED."""
133+
"""Fail sessions whose container is no longer running.
134+
135+
Takes a Docker client because "unfinished" alone is not evidence of death:
136+
with several workers, most RUNNING rows at boot belong to a peer that is
137+
still streaming them.
138+
"""
133139
try:
134-
async with session_factory() as db:
135-
await container_cleanup.reconcile_stale_sessions(db)
136-
await db.commit()
140+
docker = AioDockerClient()
141+
try:
142+
async with session_factory() as db:
143+
await container_cleanup.reconcile_stale_sessions(db, docker)
144+
await db.commit()
145+
finally:
146+
await docker.close()
137147
except Exception:
138148
logger.exception("session_reconciliation_failed")
139149

140150

141-
async def _stop_running_containers(session_factory: SessionFactory) -> None:
142-
"""Stop every container still running before the engine goes away."""
151+
async def _stop_own_containers(session_factory: SessionFactory) -> None:
152+
"""Stop the containers THIS process started, before the engine goes away.
153+
154+
Not every container on the host: with ``--workers N`` they share a Docker
155+
socket, and stopping the lot cancelled peers' live sessions on any single
156+
worker restart.
157+
"""
143158
try:
144159
docker = AioDockerClient()
145-
async with session_factory() as db:
146-
stopped = await container_cleanup.cleanup_all_running(db, docker)
147-
await db.commit()
148-
if stopped:
149-
logger.info("shutdown_containers_stopped", count=stopped)
150-
await docker.close()
160+
try:
161+
async with session_factory() as db:
162+
stopped = await container_cleanup.cleanup_own_running(
163+
db, docker, boot_id=container_service.current_boot_id()
164+
)
165+
await db.commit()
166+
if stopped:
167+
logger.info("shutdown_containers_stopped", count=stopped)
168+
finally:
169+
await docker.close()
151170
except Exception:
152171
logger.exception("shutdown_container_cleanup_failed")
153172

@@ -188,7 +207,7 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
188207
await _replay_pending_webhook_events(app)
189208
await _reconcile_stale_sessions(session_factory)
190209

191-
stack.push_async_callback(_stop_running_containers, session_factory)
210+
stack.push_async_callback(_stop_own_containers, session_factory)
192211
stack.push_async_callback(_drain_replay_tasks, app)
193212

194213
for loop in (_run_webhook_reaper(app), _run_container_cleanup(app)):

apps/api/src/helprs/modules/container/cleanup.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,28 +44,47 @@ async def cleanup_expired(db: AsyncSession, docker: DockerClient, *, ttl_seconds
4444
return len(expired)
4545

4646

47-
async def cleanup_all_running(db: AsyncSession, docker: DockerClient) -> int:
48-
"""Stop every live session — used on graceful shutdown."""
49-
sessions = await repository.list_unfinished(db)
50-
51-
for cs in sessions:
47+
async def cleanup_own_running(db: AsyncSession, docker: DockerClient, *, boot_id: str) -> int:
48+
"""Stop the sessions THIS process started — used on graceful shutdown.
49+
50+
Scoped by boot id rather than "everything unfinished". Several uvicorn
51+
workers share one Docker socket, so the unscoped version cancelled every
52+
peer's live sessions whenever any one worker restarted. Docker is asked
53+
which containers carry this process's label, because it is the authority
54+
on what is actually running; the database only records what was intended.
55+
"""
56+
stopped = 0
57+
for runner in await docker.list_runners(boot_id=boot_id):
58+
cs = await repository.get(db, runner.session_id)
59+
if cs is None or cs.completed_at is not None:
60+
continue
5261
await _destroy(docker, cs)
5362
cs.status = ContainerStatus.CANCELLED
5463
cs.completed_at = datetime.now(UTC)
64+
stopped += 1
5565

56-
if sessions:
66+
if stopped:
5767
await db.flush()
58-
await logger.ainfo("shutdown_sessions_stopped", count=len(sessions))
59-
return len(sessions)
68+
await logger.ainfo("shutdown_sessions_stopped", count=stopped, boot_id=boot_id)
69+
return stopped
70+
6071

72+
async def reconcile_stale_sessions(db: AsyncSession, docker: DockerClient) -> int:
73+
"""Fail sessions whose container is gone.
6174
62-
async def reconcile_stale_sessions(db: AsyncSession) -> int:
63-
"""Fail sessions left RUNNING by a crashed process.
75+
Asks Docker rather than assuming, because "unfinished at boot" is not the
76+
same as "dead": with several workers a RUNNING row usually belongs to a
77+
peer that is still streaming it, and marking those FAILED killed live
78+
sessions every time one worker restarted.
6479
65-
Their containers died with the host process, so waiting for the TTL would
66-
only leave the dashboard lying for 15 minutes.
80+
A row with no container id never got that far, so nothing is running for
81+
it either way.
6782
"""
68-
stale = await repository.list_unfinished(db)
83+
stale = []
84+
for cs in await repository.list_unfinished(db):
85+
if cs.container_id and await docker.container_is_running(cs.container_id):
86+
continue
87+
stale.append(cs)
6988

7089
for cs in stale:
7190
cs.status = ContainerStatus.FAILED

apps/api/src/helprs/modules/container/docker_client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,30 @@
55
"""
66

77
import base64
8+
import contextlib
89
from collections.abc import AsyncIterator
10+
from dataclasses import dataclass
911
from typing import Protocol
12+
from uuid import UUID
1013

1114
CLAUDE_RUNNER_IMAGE = "claude-runner:latest"
1215

16+
# Labels stamped on every runner. ``boot_id`` identifies the API process that
17+
# spawned it, which is how a shutting-down worker tells its own containers
18+
# apart from a peer's: with several uvicorn workers sharing one Docker socket,
19+
# "every running container" is not the same set as "mine".
20+
LABEL_SESSION_ID = "helprs.session_id"
21+
LABEL_BOOT_ID = "helprs.boot_id"
22+
23+
24+
@dataclass(frozen=True)
25+
class RunnerContainer:
26+
"""A live runner, identified by both ids the cleanup paths need."""
27+
28+
container_id: str
29+
session_id: UUID
30+
31+
1332
CONTAINER_MEMORY_BYTES = 512 * 1024 * 1024
1433
CONTAINER_NANO_CPUS = 1_000_000_000
1534
# Generous for a git clone plus a node process, tight enough that a runaway
@@ -54,6 +73,19 @@ async def wait_container(self, container_id: str) -> int:
5473
"""Block until the container exits; return its exit code."""
5574
...
5675

76+
async def container_is_running(self, container_id: str) -> bool:
77+
"""Whether the container still exists and is running.
78+
79+
The honest answer to "is this session actually alive?", which the DB
80+
cannot give: a row says RUNNING whether its container is streaming or
81+
died with the process that started it.
82+
"""
83+
...
84+
85+
async def list_runners(self, *, boot_id: str) -> list[RunnerContainer]:
86+
"""Live runners started by one API process, newest state from Docker."""
87+
...
88+
5789
async def close(self) -> None: ...
5890

5991

@@ -96,6 +128,31 @@ async def create_container(
96128
)
97129
return container.id
98130

131+
async def container_is_running(self, container_id: str) -> bool:
132+
try:
133+
container = await self._docker.containers.get(container_id)
134+
state = (await container.show()).get("State") or {}
135+
except Exception:
136+
# Gone, unreachable, or never existed: for every caller here that
137+
# means "not alive", and treating an error as "still running"
138+
# would leave sessions stuck RUNNING forever.
139+
return False
140+
return bool(state.get("Running"))
141+
142+
async def list_runners(self, *, boot_id: str) -> list[RunnerContainer]:
143+
containers = await self._docker.containers.list(
144+
filters={"label": [f"{LABEL_BOOT_ID}={boot_id}"]},
145+
)
146+
runners = []
147+
for container in containers:
148+
# A runner whose session label is missing or unparseable is not
149+
# ours to reason about; skipping beats guessing which row it
150+
# belongs to and cancelling the wrong session.
151+
with contextlib.suppress(KeyError, TypeError, ValueError):
152+
labels = container["Labels"] or {}
153+
runners.append(RunnerContainer(container_id=container.id, session_id=UUID(labels[LABEL_SESSION_ID])))
154+
return runners
155+
99156
async def start_container(self, container_id: str) -> None:
100157
container = await self._docker.containers.get(container_id)
101158
await container.start()

apps/api/src/helprs/modules/container/service.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import os
1111
from datetime import UTC, datetime
1212
from pathlib import Path
13-
from uuid import UUID
13+
from uuid import UUID, uuid4
1414

1515
import structlog
1616
from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,7 +19,12 @@
1919
from helprs.core.database import get_db_context
2020
from helprs.core.exceptions import ConflictError, ExternalServiceError, NotFoundError
2121
from helprs.modules.container import repository
22-
from helprs.modules.container.docker_client import CLAUDE_RUNNER_IMAGE, DockerClient
22+
from helprs.modules.container.docker_client import (
23+
CLAUDE_RUNNER_IMAGE,
24+
LABEL_BOOT_ID,
25+
LABEL_SESSION_ID,
26+
DockerClient,
27+
)
2328
from helprs.modules.container.models import ContainerSession, ContainerStatus, SessionEvent
2429
from helprs.modules.container.pr_comment import build_session_url, extract_score_card, format_pr_comment
2530
from helprs.modules.container.scorecard import extract_scorecard
@@ -43,6 +48,29 @@
4348
# --- Session records -------------------------------------------------------
4449

4550

51+
# Identity of this API process, stamped on every container it starts.
52+
#
53+
# Several uvicorn workers share one Docker socket, so "every running
54+
# container" is not the same set as "the ones I started". Without this a
55+
# worker's shutdown hook cancelled its peers' live sessions.
56+
_boot_id: tuple[int, str] | None = None
57+
58+
59+
def current_boot_id() -> str:
60+
"""A value unique to this process and stable for its lifetime.
61+
62+
Recomputed when the PID changes, so a forked worker never inherits its
63+
parent's id: uvicorn's ``--workers`` mode may fork after import, and two
64+
workers sharing a boot id would each stop the other's containers -- the
65+
exact bug this exists to prevent.
66+
"""
67+
global _boot_id
68+
pid = os.getpid()
69+
if _boot_id is None or _boot_id[0] != pid:
70+
_boot_id = (pid, f"{pid}-{uuid4().hex[:8]}")
71+
return _boot_id[1]
72+
73+
4674
async def create_session(
4775
db: AsyncSession,
4876
installation_id: UUID,
@@ -147,7 +175,8 @@ async def start_container(
147175
},
148176
volumes=[f"{SKILLS_HOST_PATH}/{cs.skill_name}:/skills/{cs.skill_name}:ro"],
149177
labels={
150-
"helprs.session_id": str(cs.id),
178+
LABEL_SESSION_ID: str(cs.id),
179+
LABEL_BOOT_ID: current_boot_id(),
151180
"helprs.skill": cs.skill_name,
152181
"helprs.repo": cs.repo_full_name,
153182
},

apps/api/tests/integration/test_container_flow.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
2828

2929
from helprs.core.database import Base
30+
from helprs.modules.container.docker_client import RunnerContainer
3031
from helprs.modules.container.models import ContainerSession, ContainerStatus
3132
from helprs.modules.installation.models import BYOKConfig, Installation
3233

@@ -84,6 +85,12 @@ async def container_logs(self, container_id: str, follow: bool = False) -> Async
8485
async def wait_container(self, container_id: str) -> int:
8586
return 0
8687

88+
async def container_is_running(self, container_id: str) -> bool:
89+
return container_id in self.started and container_id not in self.removed
90+
91+
async def list_runners(self, *, boot_id: str) -> list[RunnerContainer]:
92+
return []
93+
8794
async def close(self) -> None:
8895
pass
8996

0 commit comments

Comments
 (0)