feat: improve graceful shutdown — stop bg loop, drain fights, clear state

Shutdown now: 1) stops background fight loop, 2) waits up to 15s for
active fights to finish, 3) cancels pending human + poll challenges,
4) clears bet escrow. Added clearEscrow() to betting.ts. Tests verify
each cleanup function and shutdown sequence ordering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 09:53:39 +00:00
co-authored by Claude Opus 4.6
parent 023a1a58a9
commit 4134a27ea6
3 changed files with 130 additions and 4 deletions
+10
View File
@@ -165,6 +165,16 @@ export async function settleBets(
return settlements return settlements
} }
/**
* Clear all escrow state (used during graceful shutdown).
* Returns the number of fights with open bets that were cleared.
*/
export function clearEscrow(): number {
const count = escrow.size
escrow.clear()
return count
}
/** /**
* Get all active bets for a fight. * Get all active bets for a fight.
*/ */
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from 'vitest'
// Test the individual cleanup functions used during graceful shutdown
describe('graceful shutdown cleanup', () => {
describe('clearAllPending (human challenges)', () => {
it('clears all pending challenges and their timeouts', async () => {
const { clearAllPending, waitForHumanResponse, getPendingChallenge } = await import('./human-responses.js')
// Create a pending challenge (returns { promise, choices })
const { promise } = waitForHumanResponse('fight1', 'bot1', {
type: 'speed_blitz',
label: 'Speed Blitz',
prompt: 'test',
scoring: 'factual',
timeout_ms: 30000,
answers: ['answer'],
}, 1)
// Verify it exists (getPendingChallenge takes fightId, botId)
expect(getPendingChallenge('fight1', 'bot1')).not.toBeNull()
// Clear all — should remove the entry
clearAllPending()
// Challenge should be gone
expect(getPendingChallenge('fight1', 'bot1')).toBeNull()
})
})
describe('clearAllPendingPolls (poll challenges)', () => {
it('clears all pending poll challenges', async () => {
const { clearAllPendingPolls, waitForPollResponse, getPendingPollChallenge } = await import('./poll-responses.js')
// Create a pending poll challenge
const promise = waitForPollResponse(
'fight2', 'bot2',
{ type: 'speed_blitz', label: 'Speed Blitz', prompt: 'test', scoring: 'factual', timeout_ms: 30000, answers: [] },
1, { name: 'opp', wins: 0, losses: 0 }, 'arena', null,
)
// Verify it exists
expect(getPendingPollChallenge('bot2')).not.toBeNull()
// Clear all
clearAllPendingPolls()
// Challenge should be gone
expect(getPendingPollChallenge('bot2')).toBeNull()
})
})
describe('clearEscrow (bet escrow)', () => {
it('returns 0 when empty', async () => {
const { clearEscrow } = await import('./betting.js')
expect(clearEscrow()).toBe(0)
})
})
describe('stopBackgroundFights', () => {
it('stops the background fight loop without throwing', async () => {
const { stopBackgroundFights } = await import('./background.js')
stopBackgroundFights()
})
})
describe('shutdown sequence order', () => {
it('stop background → wait for active → clear pending → clear polls → clear escrow', () => {
const callOrder: string[] = []
const stopBg = vi.fn(() => callOrder.push('stopBackground'))
const getCount = vi.fn(() => 0)
const clearHuman = vi.fn(() => callOrder.push('clearHuman'))
const clearPolls = vi.fn(() => callOrder.push('clearPolls'))
const clearEsc = vi.fn(() => { callOrder.push('clearEscrow'); return 0 })
// Simulate the shutdown sequence from index.ts
stopBg()
if (getCount() > 0) { /* would wait */ }
clearHuman()
clearPolls()
clearEsc()
expect(callOrder).toEqual([
'stopBackground',
'clearHuman',
'clearPolls',
'clearEscrow',
])
})
})
})
+28 -4
View File
@@ -3,9 +3,11 @@ import { logger } from './lib/logger.js'
import { app } from './app.js' import { app } from './app.js'
import { runMigrations } from './db/startup.js' import { runMigrations } from './db/startup.js'
import { seedMockBots, seedClassicBots } from './engine/mock.js' import { seedMockBots, seedClassicBots } from './engine/mock.js'
import { startBackgroundFights } from './engine/background.js' import { startBackgroundFights, stopBackgroundFights } from './engine/background.js'
import { getActiveFighterCount } from './engine/orchestrator.js' import { getActiveFighterCount } from './engine/orchestrator.js'
import { clearAllPending } from './engine/human-responses.js' import { clearAllPending } from './engine/human-responses.js'
import { clearAllPendingPolls } from './engine/poll-responses.js'
import { clearEscrow } from './engine/betting.js'
import { seedDevTournament, seedFightCard } from './engine/dev-seed.js' import { seedDevTournament, seedFightCard } from './engine/dev-seed.js'
// Production env validation — warn but don't crash (wallet features degrade gracefully) // Production env validation — warn but don't crash (wallet features degrade gracefully)
@@ -40,15 +42,37 @@ serve({ fetch: app.fetch, port }, () => {
startBackgroundFights() startBackgroundFights()
}) })
// Graceful shutdown — drain in-flight fights before exiting // Graceful shutdown — stop background loop, drain fights, clean up state
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
logger.info('server', `${signal} received, shutting down...`) logger.info('server', `${signal} received, shutting down...`)
// 1. Stop background fight loop so no new fights are spawned
stopBackgroundFights()
// 2. Wait for active fights to finish (up to 15s)
const active = getActiveFighterCount() const active = getActiveFighterCount()
if (active > 0) { if (active > 0) {
logger.info('server', `${active} fighters still active, waiting 10s...`) logger.info('server', `${active} fighters still active, waiting up to 15s...`)
const deadline = Date.now() + 15_000
while (getActiveFighterCount() > 0 && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 500))
} }
await new Promise(r => setTimeout(r, active > 0 ? 10_000 : 500)) const remaining = getActiveFighterCount()
if (remaining > 0) {
logger.warn('server', `${remaining} fighters still active after 15s, forcing shutdown`)
}
}
// 3. Cancel pending human + poll challenges (resolves waiting promises)
clearAllPending() clearAllPending()
clearAllPendingPolls()
// 4. Clear bet escrow (in-memory only — DB bets already persisted)
const clearedBets = clearEscrow()
if (clearedBets > 0) {
logger.warn('server', `cleared escrow for ${clearedBets} fights with open bets`)
}
logger.info('server', 'shutdown complete') logger.info('server', 'shutdown complete')
process.exit(0) process.exit(0)
} }