Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/opencode/test/server/httpapi-exercise/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,23 @@ type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }

const appCache: Partial<Record<string, CachedApp>> = {}

export async function disposeApps() {
export async function disposeApps(heartbeat?: (label: string) => void) {
const apps = Object.values(appCache)
for (const key of Object.keys(appCache)) delete appCache[key]
await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
heartbeat?.(`teardown: disposing ${apps.filter((app) => app !== undefined).length} app(s)`)
await Promise.all(
apps.flatMap((app, i) =>
app === undefined
? []
: [
app.dispose().then(
() => heartbeat?.(`teardown: app[${i}] disposed`),
(err) => heartbeat?.(`teardown: app[${i}] dispose error: ${err}`),
),
],
),
)
heartbeat?.("teardown: all apps disposed")
}

function app(modules: Runtime, options: CallOptions) {
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/test/server/httpapi-exercise/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2151,7 +2151,13 @@ const llmScenarios = new Set([
])

const main = Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => disposeApps()).pipe(Effect.andThen(cleanupExercisePaths)))
yield* Effect.addFinalizer(() =>
Effect.promise(() => disposeApps(options.heartbeat)).pipe(
Effect.andThen(Effect.sync(() => options.heartbeat?.("teardown: cleanupExercisePaths"))),
Effect.andThen(cleanupExercisePaths),
Effect.andThen(Effect.sync(() => options.heartbeat?.("teardown: complete"))),
),
)
const parsed = parseOptions(Bun.argv.slice(2))
const options: Options = parsed.progress ? { ...parsed, heartbeat: startProgressWatchdog() } : parsed
const modules = yield* Effect.promise(() => runtime())
Expand Down
15 changes: 13 additions & 2 deletions packages/opencode/test/server/httpapi-exercise/watchdog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const pid = Number(process.env.WATCHDOG_PID)
const file = process.env.WATCHDOG_FILE
const timeoutMs = Number(process.env.WATCHDOG_TIMEOUT_MS)
const pollMs = Number(process.env.WATCHDOG_POLL_MS)
// A missing heartbeat file is treated as stalled (tracked via missingSince),
// never as healthy: CI runners can reclaim tmpdir files, and silently
// standing down when the file disappears is what let the 2026-08-17
// teardown hang burn the full 15m step timeout without a kill.
let missingSince = 0
setInterval(() => {
let alive = true
try {
Expand All @@ -36,12 +41,18 @@ setInterval(() => {
try {
mtime = fs.statSync(file).mtimeMs
} catch {}
if (!mtime || Date.now() - mtime <= timeoutMs) return
if (mtime) {
missingSince = 0
if (Date.now() - mtime <= timeoutMs) return
} else {
if (!missingSince) missingSince = Date.now()
if (Date.now() - missingSince <= timeoutMs) return
}
let last = "<none>"
try {
last = fs.readFileSync(file, "utf8")
} catch {}
console.error("[watchdog] no progress for " + Math.round((Date.now() - mtime) / 1000) + "s; last activity: " + last + " — killing pid " + pid)
console.error("[watchdog] no progress for " + Math.round((Date.now() - (mtime || missingSince)) / 1000) + "s; last activity: " + last + " — killing pid " + pid)
try {
process.kill(pid, "SIGKILL")
} catch {}
Expand Down
Loading