Skip to content

Commit 94d0a9d

Browse files
committed
fix: resolve e2e bugs — double POST, SSE disconnect, orphan containers, PR link
- Prevent StrictMode double container creation with AbortController - Poll backend status on SSE disconnect instead of marking as failed - Add periodic container cleanup reaper task (every 5min) - Fix PR comment link to match frontend route format - Fix pre-existing ruff E402 (import os placement in service.py)
1 parent bc411b8 commit 94d0a9d

6 files changed

Lines changed: 95 additions & 26 deletions

File tree

apps/api/src/helprs/main.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
_REPLAY_DISCOVERY_TIMEOUT_SECONDS = 10.0
2323
_REPLAY_CONCURRENCY = 10
2424
_REAPER_INTERVAL_SECONDS = 300
25+
_CONTAINER_CLEANUP_INTERVAL_SECONDS = 300
2526

2627

2728
async def _replay_pending_webhook_events(app: FastAPI) -> None:
@@ -100,6 +101,35 @@ async def _run_webhook_reaper(app: FastAPI, *, interval_seconds: int = _REAPER_I
100101
raise
101102

102103

104+
async def _run_container_cleanup(
105+
app: FastAPI,
106+
*,
107+
interval_seconds: int = _CONTAINER_CLEANUP_INTERVAL_SECONDS,
108+
) -> None:
109+
"""Periodic cleanup of expired container sessions.
110+
111+
Finds sessions past their TTL and destroys their Docker containers.
112+
Cancelled cleanly by the lifespan teardown via ``task.cancel()``.
113+
"""
114+
from helprs.modules.container.service import AioDockerClient, cleanup_expired
115+
116+
try:
117+
while True:
118+
await asyncio.sleep(interval_seconds)
119+
try:
120+
docker = AioDockerClient()
121+
async with app.state.session_factory() as db:
122+
cleaned = await cleanup_expired(db, docker)
123+
await db.commit()
124+
if cleaned:
125+
logger.info("container_cleanup_cycle", cleaned=cleaned)
126+
except Exception:
127+
logger.exception("container_cleanup_cycle_failed")
128+
except asyncio.CancelledError:
129+
logger.info("container_cleanup_stopped")
130+
raise
131+
132+
103133
def create_app() -> FastAPI:
104134
settings = get_settings()
105135

@@ -112,6 +142,7 @@ async def lifespan(app: FastAPI):
112142
"""Manage database engine lifecycle, admin setup, and webhook reaper."""
113143
engine = create_engine()
114144
reaper_task: asyncio.Task | None = None
145+
cleanup_task: asyncio.Task | None = None
115146
try:
116147
session_factory = create_session_factory(engine)
117148
app.state.engine = engine
@@ -137,9 +168,15 @@ async def lifespan(app: FastAPI):
137168
# mark_processed commit failure) without waiting for the next
138169
# restart.
139170
reaper_task = asyncio.create_task(_run_webhook_reaper(app))
171+
cleanup_task = asyncio.create_task(_run_container_cleanup(app))
140172

141173
yield
142174
finally:
175+
if cleanup_task is not None:
176+
cleanup_task.cancel()
177+
with contextlib.suppress(asyncio.CancelledError, Exception):
178+
await cleanup_task
179+
143180
if reaper_task is not None:
144181
reaper_task.cancel()
145182
with contextlib.suppress(asyncio.CancelledError, Exception):

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import asyncio
1414
import contextlib
1515
import json
16+
import os
1617
from datetime import UTC, datetime
1718
from pathlib import Path
1819
from typing import TYPE_CHECKING, Protocol
@@ -46,8 +47,6 @@
4647
# Host path for skills — needed for Docker-in-Docker volume mounts.
4748
# The API container can't use its own /app/skills path as a bind mount source
4849
# for child containers; Docker needs the HOST filesystem path.
49-
import os
50-
5150
SKILLS_HOST_PATH = os.environ.get("SKILLS_HOST_PATH", str(SKILLS_BASE_PATH))
5251

5352

@@ -316,10 +315,12 @@ async def send_message(
316315
if cs.status != ContainerStatus.RUNNING or not cs.container_id:
317316
raise ExternalServiceError("Container is not running")
318317

319-
message = json.dumps({
320-
"type": "user",
321-
"message": {"role": "user", "content": content},
322-
})
318+
message = json.dumps(
319+
{
320+
"type": "user",
321+
"message": {"role": "user", "content": content},
322+
}
323+
)
323324

324325
try:
325326
await docker.write_to_container(cs.container_id, message)

apps/api/src/helprs/modules/webhook/handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ async def handle_pull_request_opened(payload: dict, session: AsyncSession) -> No
129129
comment_body = (
130130
f"**helPRs** session created for this PR.\n\n"
131131
f"Skill: `challenge-me` | "
132-
f"[Open session]({settings.APP_BASE_URL}/session/{cs.id})"
132+
f"[Open session]({settings.APP_BASE_URL}/session/{installation.id}/{repo_full_name}/{pr_number})"
133133
)
134134
await post_pr_comment_with_retry(
135135
owner=owner,

apps/web/src/features/session/ContainerSession.test.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,15 @@ describe('ContainerSession', () => {
129129
expect(screen.getAllByText(/challenge-me/).length).toBeGreaterThan(0)
130130

131131
await waitFor(() => {
132-
expect(mockedCreate).toHaveBeenCalledWith({
133-
installation_id: defaultProps.installationId,
134-
pr_number: 42,
135-
repo_full_name: 'acme/helprs',
136-
skill_name: 'challenge-me',
137-
})
132+
expect(mockedCreate).toHaveBeenCalledWith(
133+
{
134+
installation_id: defaultProps.installationId,
135+
pr_number: 42,
136+
repo_full_name: 'acme/helprs',
137+
skill_name: 'challenge-me',
138+
},
139+
expect.any(AbortSignal),
140+
)
138141
})
139142
})
140143

apps/web/src/features/session/ContainerSession.tsx

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useAuthStore } from '../auth/store'
1212
import {
1313
buildStreamUrl,
1414
createContainerSession,
15+
getContainerSession,
1516
sendMessage,
1617
stopContainerSession,
1718
} from './containerApi'
@@ -68,21 +69,24 @@ export default function ContainerSession({
6869
// Create session on mount
6970
useEffect(() => {
7071
mountedRef.current = true
71-
let cancelled = false
72+
const abortController = new AbortController()
7273

7374
async function init() {
7475
try {
7576
setStatus('starting')
7677
appendLine(`Starting ${skillName} for ${repoFullName}#${prNumber}...`)
7778

78-
const created = await createContainerSession({
79-
installation_id: installationId,
80-
pr_number: prNumber,
81-
repo_full_name: repoFullName,
82-
skill_name: skillName,
83-
})
79+
const created = await createContainerSession(
80+
{
81+
installation_id: installationId,
82+
pr_number: prNumber,
83+
repo_full_name: repoFullName,
84+
skill_name: skillName,
85+
},
86+
abortController.signal,
87+
)
8488

85-
if (cancelled) return
89+
if (abortController.signal.aborted) return
8690

8791
setSession(created)
8892
setStatus(created.status)
@@ -94,14 +98,36 @@ export default function ContainerSession({
9498
appendLine('[error] Container failed to start.')
9599
}
96100
} catch (err) {
97-
if (cancelled) return
101+
if (abortController.signal.aborted) return
98102
const msg = err instanceof Error ? err.message : 'Unknown error'
99103
setError(msg)
100104
setStatus('failed')
101105
appendLine(`[error] ${msg}`)
102106
}
103107
}
104108

109+
async function pollSessionStatus(sessionId: string) {
110+
try {
111+
const fresh = await getContainerSession(sessionId)
112+
if (!mountedRef.current) return
113+
setStatus(fresh.status)
114+
if (fresh.status === 'completed') {
115+
appendLine('Session completed.')
116+
} else if (fresh.status === 'failed') {
117+
setError('Container failed')
118+
appendLine('[error] Container failed.')
119+
} else if (fresh.status === 'running') {
120+
appendLine('[reconnecting] Stream dropped, container still running...')
121+
connectStream(sessionId)
122+
}
123+
} catch {
124+
if (!mountedRef.current) return
125+
setStatus('failed')
126+
setError('Lost connection to session')
127+
appendLine('[error] Lost connection to session.')
128+
}
129+
}
130+
105131
function connectStream(sessionId: string) {
106132
const accessToken = useAuthStore.getState().accessToken
107133
if (!accessToken) {
@@ -176,19 +202,19 @@ export default function ContainerSession({
176202
return
177203
}
178204

179-
// Native error — connection dropped
205+
// Native error — connection dropped. Poll backend for actual status
206+
// instead of immediately marking as failed.
180207
if (source.readyState === EventSource.CLOSED) {
181208
eventSourceRef.current = null
182-
// Only mark as failed if we haven't already completed
183-
setStatus((prev) => (prev === 'completed' ? prev : 'failed'))
209+
pollSessionStatus(sessionId)
184210
}
185211
})
186212
}
187213

188214
init()
189215

190216
return () => {
191-
cancelled = true
217+
abortController.abort()
192218
mountedRef.current = false
193219
if (eventSourceRef.current) {
194220
eventSourceRef.current.close()

apps/web/src/features/session/containerApi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ export class ContainerSessionError extends Error {
2121

2222
export async function createContainerSession(
2323
body: ContainerSessionRequest,
24+
signal?: AbortSignal,
2425
): Promise<ContainerSessionResponse> {
2526
const resp = await apiFetch('/api/v1/containers/sessions', {
2627
method: 'POST',
2728
headers: { 'Content-Type': 'application/json' },
2829
body: JSON.stringify(body),
30+
signal,
2931
})
3032
if (!resp.ok) {
3133
throw new ContainerSessionError(resp.status, `Failed to create session: ${resp.status}`)

0 commit comments

Comments
 (0)