Skip to content

Commit c2c6fe3

Browse files
authored
fix: diagnose unavailable MySQL runtime providers (#2116)
* fix: diagnose unavailable runtime service providers * fix: clean up unavailable provider processes * fix: preserve primary provider diagnostics
1 parent 0fbc186 commit c2c6fe3

2 files changed

Lines changed: 82 additions & 5 deletions

File tree

packages/cli/src/runtime-services.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ export interface RuntimeServiceEvidence {
3030
readiness: "pending" | "ready" | "failed"
3131
lifecycle: "provisioning" | "provisioned" | "released" | "failed"
3232
teardown?: "completed" | "failed"
33-
diagnostic?: { code: "readiness-failed" | "provision-failed" | "teardown-failed" | "interrupted" }
33+
diagnostic?: {
34+
code: "readiness-failed" | "provision-failed" | "provider-unavailable" | "teardown-failed" | "interrupted"
35+
command?: string
36+
cause?: { code: string; message: string }
37+
}
3438
controls?: RuntimeServiceControlResult[]
3539
memory?: { budgetMiB: number; observedRssMiB?: number }
3640
}
@@ -300,12 +304,19 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic
300304
} catch (error) {
301305
evidence.readiness = "failed"
302306
evidence.lifecycle = "failed"
303-
evidence.diagnostic = { code: signal?.aborted ? "interrupted" : started ? "readiness-failed" : "provision-failed" }
307+
evidence.diagnostic = signal?.aborted
308+
? { code: "interrupted" }
309+
: providerUnavailableDiagnostic(error, "docker") ?? { code: started ? "readiness-failed" : "provision-failed" }
304310
if (started) await releaseService(container, evidence, dependencies, undefined).catch(() => undefined)
305311
throw new RuntimeServiceProvisionError(`Managed runtime service failed: ${service.id}`, evidenceList)
306312
}
307313
}
308314

315+
function providerUnavailableDiagnostic(error: unknown, command: string): RuntimeServiceEvidence["diagnostic"] | undefined {
316+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") return undefined
317+
return { code: "provider-unavailable", command, cause: { code: "ENOENT", message: "Provider command executable was not found" } }
318+
}
319+
309320
const NATIVE_MARIADB_ROOT_PREFIX = "wp-codebox-mariadb-"
310321
const NATIVE_MARIADB_START_ATTEMPTS = 5
311322
const NATIVE_MARIADB_START_TIMEOUT_MS = 30_000
@@ -1403,6 +1414,7 @@ async function waitForMysqlDatabase(container: string, engine: keyof typeof MYSQ
14031414
return
14041415
} catch (error) {
14051416
if (signal?.aborted) throw error
1417+
if (providerUnavailableDiagnostic(error, "docker")) throw error
14061418
await abortableDelay(100, signal)
14071419
}
14081420
}
@@ -1438,7 +1450,7 @@ async function releaseService(container: string, evidence: RuntimeServiceEvidenc
14381450
}
14391451
evidence.lifecycle = "failed"
14401452
evidence.teardown = "failed"
1441-
evidence.diagnostic = { code: "teardown-failed" }
1453+
if (evidence.diagnostic?.code !== "provider-unavailable") evidence.diagnostic = { code: "teardown-failed" }
14421454
throw new Error(`Managed runtime service teardown failed: ${evidence.id}`)
14431455
}
14441456
}
@@ -1548,9 +1560,10 @@ export function executeRuntimeServiceProcess(command: string, args: string[], op
15481560
const child = spawn(command, args, {
15491561
env: options.env,
15501562
signal: options.signal,
1551-
timeout: options.timeout,
15521563
stdio: ["pipe", "pipe", "pipe"],
15531564
})
1565+
const timeout = setTimeout(() => child.kill(), options.timeout)
1566+
timeout.unref()
15541567
const stdout: Buffer[] = []
15551568
const stderr: Buffer[] = []
15561569
let stdoutBytes = 0
@@ -1571,11 +1584,13 @@ export function executeRuntimeServiceProcess(command: string, args: string[], op
15711584
child.once("error", (error) => {
15721585
if (settled) return
15731586
settled = true
1587+
clearTimeout(timeout)
15741588
reject(error)
15751589
})
15761590
child.once("close", (code) => {
15771591
if (settled) return
15781592
settled = true
1593+
clearTimeout(timeout)
15791594
const boundedStdout = Buffer.concat(stdout).toString("utf8")
15801595
const boundedStderr = Buffer.concat(stderr).toString("utf8")
15811596
if (overflow) {

tests/runtime-services.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import assert from "node:assert/strict"
2+
import { spawnSync } from "node:child_process"
23
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
34
import { createServer } from "node:net"
45
import { tmpdir } from "node:os"
56
import { join } from "node:path"
67
import { runRecipeBuildCommand } from "../packages/cli/src/commands/recipe-build.ts"
7-
import { parseLoopbackPort, provisionRuntimeServices, provisionRuntimeServicesForRecipe, RuntimeServiceProvisionError, runtimeServiceEvidenceFromError, runtimeServicePlan, waitForMysqlProtocol, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts"
8+
import { executeRuntimeServiceProcess, parseLoopbackPort, provisionRuntimeServices, provisionRuntimeServicesForRecipe, RuntimeServiceProvisionError, runtimeServiceEvidenceFromError, runtimeServicePlan, waitForMysqlProtocol, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts"
89
import { planWorkspaceRecipe } from "../packages/cli/src/recipe-dry-run.ts"
910
import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts"
1011
import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.ts"
@@ -139,6 +140,67 @@ await provisioned.release()
139140
await provisioned.release()
140141
assert.equal(calls.filter((call) => call.args[0] === "rm").length, 1, "release is idempotent")
141142

143+
const missingProviderDependencies: RuntimeServiceDependencies = {
144+
...dependencies,
145+
execute: async (_command, args, options) => await executeRuntimeServiceProcess("wp-codebox-provider-command-that-does-not-exist", args, options),
146+
}
147+
await assert.rejects(provisionRuntimeServices([service], { dependencies: missingProviderDependencies }), (error: unknown) => {
148+
assert.ok(error instanceof RuntimeServiceProvisionError)
149+
assert.deepEqual(error.evidence[0]?.diagnostic, {
150+
code: "provider-unavailable",
151+
command: "docker",
152+
cause: { code: "ENOENT", message: "Provider command executable was not found" },
153+
})
154+
return true
155+
})
156+
const missingProviderChild = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", `
157+
import { executeRuntimeServiceProcess } from "./packages/cli/src/runtime-services.ts";
158+
await executeRuntimeServiceProcess("wp-codebox-provider-command-that-does-not-exist", [], { timeout: 300_000 }).catch(() => undefined);
159+
`], { cwd: process.cwd(), encoding: "utf8", timeout: 5_000 })
160+
assert.equal(missingProviderChild.error, undefined, `spawn ENOENT child exits without retaining its process timeout: ${missingProviderChild.error?.message ?? ""}`)
161+
assert.equal(missingProviderChild.status, 0, missingProviderChild.stderr)
162+
163+
const failedStartDependencies: RuntimeServiceDependencies = {
164+
...dependencies,
165+
async execute(command, args, options) {
166+
if (args[0] === "run") throw new Error("provider start failed")
167+
return dependencies.execute(command, args, options)
168+
},
169+
}
170+
await assert.rejects(provisionRuntimeServices([service], { dependencies: failedStartDependencies }), (error: unknown) => error instanceof RuntimeServiceProvisionError && error.evidence[0]?.diagnostic?.code === "provision-failed")
171+
172+
const failedReadinessDependencies: RuntimeServiceDependencies = {
173+
...dependencies,
174+
async waitForReady() { throw new Error("provider readiness failed") },
175+
}
176+
await assert.rejects(provisionRuntimeServices([service], { dependencies: failedReadinessDependencies }), (error: unknown) => error instanceof RuntimeServiceProvisionError && error.evidence[0]?.diagnostic?.code === "readiness-failed")
177+
178+
const unavailableDuringReadinessDependencies: RuntimeServiceDependencies = {
179+
...dependencies,
180+
async execute(command, args, options) {
181+
if (args[0] === "exec" || args[0] === "rm") return await executeRuntimeServiceProcess("wp-codebox-provider-command-that-does-not-exist", args, options)
182+
return dependencies.execute(command, args, options)
183+
},
184+
}
185+
await assert.rejects(provisionRuntimeServices([service], { dependencies: unavailableDuringReadinessDependencies }), (error: unknown) => {
186+
assert.ok(error instanceof RuntimeServiceProvisionError)
187+
assert.equal(error.evidence[0]?.diagnostic?.code, "provider-unavailable", "cleanup failure does not replace the primary provider failure")
188+
assert.equal(error.evidence[0]?.teardown, "failed", "failed cleanup remains visible in service evidence")
189+
return true
190+
})
191+
192+
let ordinaryReadinessAttempts = 0
193+
const retryingReadinessDependencies: RuntimeServiceDependencies = {
194+
...dependencies,
195+
async execute(command, args, options) {
196+
if (args[0] === "exec" && ordinaryReadinessAttempts++ === 0) throw new Error("provider readiness command failed")
197+
return dependencies.execute(command, args, options)
198+
},
199+
}
200+
const retriedReadiness = await provisionRuntimeServices([service], { dependencies: retryingReadinessDependencies })
201+
assert.equal(ordinaryReadinessAttempts, 2, "ordinary Docker readiness failures remain retryable")
202+
await retriedReadiness.release()
203+
142204
const emptyRootCalls: Array<{ args: string[]; env?: NodeJS.ProcessEnv }> = []
143205
const emptyRootDependencies: RuntimeServiceDependencies = {
144206
...dependencies,

0 commit comments

Comments
 (0)