Skip to content

Commit e6a2fd2

Browse files
committed
Ensure disposable service teardown survives interruption
1 parent 03c3aff commit e6a2fd2

6 files changed

Lines changed: 73 additions & 12 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@
202202
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",
203203
"test:runtime-requirements-readiness": "tsx tests/runtime-requirements-readiness.test.ts",
204204
"test:runtime-services": "tsx tests/runtime-services.test.ts",
205+
"test:runtime-services-lifecycle": "tsx tests/runtime-services-lifecycle.test.ts",
205206
"test:disposable-mysql-mysqli-e2e": "tsx tests/disposable-mysql-mysqli.integration.test.ts",
206207
"test:runtime-contract-manifest": "tsx tests/runtime-contract-manifest.test.ts",
207208
"test:runtime-contract-package-exports": "tsx tests/runtime-contract-package-exports.test.ts",

packages/cli/src/commands/recipe-run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
371371
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
372372
await cleanupInputMountBaselines(inputMountBaselinePaths)
373373
})
374+
runRecord = await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: serviceEvidence } })
374375
runRecord = await runRegistry.read(runRecord.runId)
375376
interruption?.throwIfInterrupted()
376377

@@ -510,6 +511,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
510511
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
511512
await cleanupInputMountBaselines(inputMountBaselinePaths)
512513
})
514+
runRecord = await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: serviceEvidence } })
513515
runRecord = await runRegistry.read(runRecord.runId)
514516
const fuzzRunResult = recipeFuzzRunResult(recipe, executions)
515517
return await finalizeRecoveredRecipeFailure({

packages/cli/src/runtime-services.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,24 @@ export interface RuntimeServiceDependencies {
3737
randomBytes(size: number): Buffer
3838
}
3939

40+
export interface RuntimeServiceProvider {
41+
readonly name: string
42+
readonly kind: string
43+
readonly version: string
44+
provision(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidence: RuntimeServiceEvidence[]): Promise<ManagedRuntimeService>
45+
}
46+
4047
const defaultDependencies: RuntimeServiceDependencies = {
4148
execute: async (command, args, options) => await execFileAsync(command, args, options),
4249
waitForReady: waitForMysqlProtocol,
4350
randomBytes,
4451
}
4552

4653
export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback"; port: "ephemeral"; persistentVolume: false; outputs: Record<string, string> }> {
47-
return services.map((service) => ({ id: service.id, kind: service.kind, provider: "docker", version: MYSQL_IMAGE, bind: "loopback", port: "ephemeral", persistentVolume: false, outputs: service.outputs }))
54+
return services.map((service) => {
55+
const provider = runtimeServiceProvider(service.kind)
56+
return { id: service.id, kind: service.kind, provider: provider.name, version: provider.version, bind: "loopback", port: "ephemeral", persistentVolume: false, outputs: service.outputs }
57+
})
4858
}
4959

5060
export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: { signal?: AbortSignal; dependencies?: RuntimeServiceDependencies } = {}): Promise<{ env: Record<string, string>; evidence: RuntimeServiceEvidence[]; release(): Promise<void> }> {
@@ -53,7 +63,7 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS
5363
const evidence: RuntimeServiceEvidence[] = []
5464
try {
5565
for (const service of services) {
56-
const managed = await provisionRuntimeService(service, dependencies, options.signal, evidence)
66+
const managed = await runtimeServiceProvider(service.kind).provision(service, dependencies, options.signal, evidence)
5767
provisioned.push(managed)
5868
}
5969
} catch (error) {
@@ -71,15 +81,21 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS
7181
}
7282
}
7383

74-
async function provisionRuntimeService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise<ManagedRuntimeService> {
84+
const mysqlDockerProvider: RuntimeServiceProvider = {
85+
name: "docker",
86+
kind: "mysql",
87+
version: MYSQL_IMAGE,
88+
provision: provisionMysqlDockerService,
89+
}
90+
91+
function runtimeServiceProvider(kind: string): RuntimeServiceProvider {
92+
if (kind === mysqlDockerProvider.kind) return mysqlDockerProvider
93+
throw new Error(`Unsupported managed runtime service kind: ${kind}`)
94+
}
95+
96+
async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise<ManagedRuntimeService> {
7597
const evidence: RuntimeServiceEvidence = { id: service.id, kind: service.kind, provider: "docker", version: MYSQL_IMAGE, readiness: "pending", lifecycle: "provisioning" }
7698
evidenceList.push(evidence)
77-
if (service.kind !== "mysql") {
78-
evidence.lifecycle = "failed"
79-
evidence.diagnostic = { code: "provision-failed" }
80-
throw new RuntimeServiceProvisionError(`Unsupported managed runtime service kind: ${service.kind}`, evidenceList)
81-
}
82-
8399
const container = `wp-codebox-${service.id}-${dependencies.randomBytes(6).toString("hex")}`
84100
const password = dependencies.randomBytes(24).toString("base64url")
85101
const childEnvironment = { PATH: process.env.PATH, MYSQL_DATABASE: "runtime", MYSQL_USER: "runtime", MYSQL_PASSWORD: password, MYSQL_ROOT_PASSWORD: password }
@@ -96,7 +112,7 @@ async function provisionRuntimeService(service: WorkspaceRecipeRuntimeService, d
96112
evidence.readiness = "ready"
97113
evidence.lifecycle = "provisioned"
98114
const values: Record<string, string> = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" }
99-
return { env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), evidence, async release() { await releaseService(container, evidence, dependencies, signal) } }
115+
return { env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), evidence, async release() { await releaseService(container, evidence, dependencies) } }
100116
} catch (error) {
101117
evidence.readiness = "failed"
102118
evidence.lifecycle = "failed"

scripts/smoke-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export const smokeGroups = {
130130
commands: [
131131
npmScript("build"),
132132
npmScript("test:runtime-services"),
133+
npmScript("test:runtime-services-lifecycle"),
133134
npmScript("test:disposable-mysql-mysqli-e2e"),
134135
],
135136
},
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import assert from "node:assert/strict"
2+
import { mkdtemp, rm } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
import { RuntimeRunRegistry } from "../packages/runtime-core/src/run-registry.ts"
6+
import { runRecipeCleanup } from "../packages/cli/src/commands/recipe-run-finalizer.ts"
7+
import type { RuntimeServiceEvidence } from "../packages/cli/src/runtime-services.ts"
8+
9+
async function assertTerminalCleanup(terminal: string): Promise<void> {
10+
const directory = await mkdtemp(join(tmpdir(), "wp-codebox-service-lifecycle-"))
11+
try {
12+
const registry = new RuntimeRunRegistry(directory)
13+
const run = await registry.create({ runId: `service-${terminal}`, status: "running", metadata: {} })
14+
const evidence: RuntimeServiceEvidence[] = [{ id: "mysql", kind: "mysql", provider: "test", version: "test", readiness: "ready", lifecycle: "provisioned" }]
15+
let releases = 0
16+
await runRecipeCleanup(registry, run, async () => {
17+
releases += 1
18+
evidence[0]!.lifecycle = "released"
19+
evidence[0]!.teardown = "completed"
20+
})
21+
await registry.update(run.runId, { metadata: { managedRuntimeServices: evidence, terminal } })
22+
const completed = await registry.read(run.runId)
23+
assert.equal(releases, 1, `${terminal} releases services once`)
24+
assert.deepEqual(completed.metadata.managedRuntimeServices, evidence, `${terminal} persists final service evidence`)
25+
} finally {
26+
await rm(directory, { recursive: true, force: true })
27+
}
28+
}
29+
30+
for (const terminal of ["runtime-creation-failure", "workflow-failure", "timeout", "cancellation", "interruption", "success"]) {
31+
await assertTerminalCleanup(terminal)
32+
}
33+
34+
console.log("runtime service lifecycle cleanup tests passed")

tests/runtime-services.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ await waitForMysqlProtocol("127.0.0.1", address.port, 250)
2222
await new Promise<void>((resolve) => server.close(() => resolve()))
2323
await assert.rejects(waitForMysqlProtocol("127.0.0.1", address.port, 25), /readiness timed out/)
2424

25-
const calls: Array<{ args: string[]; env?: NodeJS.ProcessEnv }> = []
25+
const calls: Array<{ args: string[]; env?: NodeJS.ProcessEnv; signal?: AbortSignal }> = []
2626
const dependencies: RuntimeServiceDependencies = {
2727
randomBytes: (size) => Buffer.alloc(size, 7),
2828
async execute(_command, args, options) {
29-
calls.push({ args, env: options.env })
29+
calls.push({ args, env: options.env, signal: options.signal })
3030
if (args[0] === "port") return { stdout: "127.0.0.1:41001\n" }
3131
return { stdout: "" }
3232
},
@@ -42,6 +42,13 @@ await provisioned.release()
4242
await provisioned.release()
4343
assert.equal(calls.filter((call) => call.args[0] === "rm").length, 1, "release is idempotent")
4444

45+
const interruptedAfterProvision = new AbortController()
46+
const provisionedBeforeAbort = await provisionRuntimeServices([service], { dependencies, signal: interruptedAfterProvision.signal })
47+
interruptedAfterProvision.abort()
48+
await provisionedBeforeAbort.release()
49+
const cleanupCall = calls.filter((call) => call.args[0] === "rm").at(-1)
50+
assert.equal(cleanupCall?.signal, undefined, "teardown has an independent cleanup context after interruption")
51+
4552
let failedCleanup = false
4653
const failingDependencies: RuntimeServiceDependencies = {
4754
...dependencies,

0 commit comments

Comments
 (0)