diff --git a/scripts/lint/test-typecheck-baseline.json b/scripts/lint/test-typecheck-baseline.json index 99acee0734..c02df5a2ba 100644 --- a/scripts/lint/test-typecheck-baseline.json +++ b/scripts/lint/test-typecheck-baseline.json @@ -83,7 +83,6 @@ "src/security/path-validation/index.test.ts", "src/server/build-service-worker.test.ts", "src/server/handlers/response/cors.test.ts", - "src/server/service-server.test.ts", "src/server/shared/renderer/adapter.test.ts", "src/tool/factory.test.ts", "src/transforms/import-rewriter/strategies/cross-project-strategy.test.ts", diff --git a/src/agent/hosted/veryfront-cloud-agent-service.test.ts b/src/agent/hosted/veryfront-cloud-agent-service.test.ts index 949cc1b73e..35a45f9a50 100644 --- a/src/agent/hosted/veryfront-cloud-agent-service.test.ts +++ b/src/agent/hosted/veryfront-cloud-agent-service.test.ts @@ -1,12 +1,23 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { + assert, + assertEquals, + assertRejects, + assertStrictEquals, +} from "#veryfront/testing/assert.ts"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type { CreateSandboxBashTool } from "#veryfront/sandbox"; +import { + type ApplicationErrorContext, + type ApplicationErrorReporter, + setApplicationErrorReporter, +} from "#veryfront/observability/application-errors.ts"; import { register, unregister } from "#veryfront/extensions/contracts.ts"; import { SandboxShellToolsProviderName } from "#veryfront/extensions/sandbox/index.ts"; import { tool, toolRegistry } from "#veryfront/tool"; import { defineSchema } from "#veryfront/schemas/index.ts"; +import { __resetLogRecordEmitterForTests, agentLogger } from "#veryfront/utils/logger/index.ts"; import { createExecuteSkillScriptTool, createLoadSkillReferenceTool, @@ -15,6 +26,7 @@ import { agentRegistry } from "../composition/index.ts"; import { createNodeVeryfrontCloudAgentServiceRuntime, getDiscoveredHostTools, + startAgentService, startNodeVeryfrontCloudAgentService, veryfrontApiMcpServer, veryfrontCloudAgentServiceInternals, @@ -22,6 +34,105 @@ import { } from "./veryfront-cloud-agent-service.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; import type { HostedRuntimeSourceIdentity } from "./runtime-source-binding.ts"; +import { initializeNodeAgentServiceSentryApplicationErrors } from "../service/node-sentry.ts"; + +type CaptureRecord = { + error: unknown; + context: ApplicationErrorContext; +}; + +type TestDenoRuntime = { + serve: typeof Deno.serve; + addSignalListener: typeof Deno.addSignalListener; + removeSignalListener: typeof Deno.removeSignalListener; + exit: typeof Deno.exit; +}; + +function createReporter(options: { + flush?: () => Promise; +} = {}): ApplicationErrorReporter & { + captured: CaptureRecord[]; + flushTimeouts: Array; +} { + const reporter = { + captured: [] as CaptureRecord[], + flushTimeouts: [] as Array, + capture(error: unknown, context: ApplicationErrorContext) { + reporter.captured.push({ error, context }); + return "event-id"; + }, + async flush(timeoutMs?: number) { + reporter.flushTimeouts.push(timeoutMs); + return await (options.flush?.() ?? Promise.resolve(true)); + }, + }; + return reporter; +} + +async function withMockDenoServiceServer( + fn: ( + input: { + events: string[]; + signalHandlers: Map void>; + waitForExit: () => Promise; + }, + ) => Promise, +): Promise { + const denoRuntime = Deno as unknown as TestDenoRuntime; + const originalServe = denoRuntime.serve; + const originalAddSignalListener = denoRuntime.addSignalListener; + const originalRemoveSignalListener = denoRuntime.removeSignalListener; + const originalExit = denoRuntime.exit; + const events: string[] = []; + const signalHandlers = new Map void>(); + let resolveExit: ((code: number) => void) | undefined; + const exitPromise = new Promise((resolveExitPromise) => { + resolveExit = resolveExitPromise; + }); + + denoRuntime.serve = ((options: Parameters[0]) => { + events.push("serve"); + return { + addr: { port: "port" in options && typeof options.port === "number" ? options.port : 0 }, + shutdown: () => { + events.push("server-shutdown"); + }, + }; + }) as typeof Deno.serve; + denoRuntime.addSignalListener = ((signal: string, handler: () => void) => { + signalHandlers.set(signal, handler); + }) as typeof Deno.addSignalListener; + denoRuntime.removeSignalListener = ((signal: string) => { + signalHandlers.delete(signal); + }) as typeof Deno.removeSignalListener; + denoRuntime.exit = ((code: number) => { + events.push(`exit:${code}`); + resolveExit?.(code); + }) as typeof Deno.exit; + + try { + await fn({ + events, + signalHandlers, + waitForExit: () => exitPromise, + }); + } finally { + denoRuntime.serve = originalServe; + denoRuntime.addSignalListener = originalAddSignalListener; + denoRuntime.removeSignalListener = originalRemoveSignalListener; + denoRuntime.exit = originalExit; + } +} + +function withMutedConsole(fn: () => T): T { + const originalError = console.error; + console.error = () => {}; + try { + return fn(); + } finally { + console.error = originalError; + } +} async function withTempDir( fn: (dir: string) => Promise | void, @@ -158,6 +269,270 @@ Deno.test("hosted child project agents request only materialized skill and deleg ); }); +Deno.test("startAgentService keeps application-error reporting active after readiness and cleans up on graceful shutdown", async () => { + await withTempDir(async (rootDir) => { + writeMarkdownAgentDefinition(rootDir, "support"); + const reporter = createReporter(); + const events: string[] = []; + const restoreInitializeApplicationErrors = veryfrontCloudAgentServiceInternals + .setInitializeApplicationErrorsForTests(async () => { + const lifecycle = await initializeNodeAgentServiceSentryApplicationErrors({ + env: { + SENTRY_DSN: "https://public@example.ingest.sentry.io/1", + }, + flushTimeoutMs: 5, + loadExtension: () => + Promise.resolve({ + createNodeSentryApplicationErrorReporter: () => reporter, + }), + }); + return { + ...lifecycle, + flush: async (timeoutMs?: number) => { + events.push(`flush:${timeoutMs ?? "default"}`); + return await lifecycle.flush(timeoutMs); + }, + reset: () => { + events.push("reset"); + lifecycle.reset(); + }, + }; + }); + + try { + await withMockDenoServiceServer(async ({ signalHandlers, waitForExit }) => { + await startAgentService({ + serviceName: "agent-application-errors-test", + agentId: "support", + entrypointUrl: pathToFileURL(resolve(rootDir, "main.ts")), + signals: ["SIGTERM"], + env: { + NODE_ENV: "test", + VERYFRONT_API_URL: "https://api.example.com", + VERYFRONT_AGENT_SERVICE_REGISTRATION: "disabled", + PORT: "0", + ALLOWED_ORIGINS: "https://studio.example.com", + }, + }); + + assertEquals(events, []); + withMutedConsole(() => { + agentLogger.error("framework error after readiness"); + }); + assertEquals(reporter.captured.length, 1); + assertEquals(reporter.captured[0]?.context.boundary, "agent.framework-log"); + + signalHandlers.get("SIGTERM")?.(); + assertEquals(await waitForExit(), 0); + }); + + assertEquals(events, ["flush:default", "reset"]); + assertEquals(reporter.flushTimeouts, [5]); + withMutedConsole(() => { + agentLogger.error("framework error after shutdown"); + }); + assertEquals(reporter.captured.length, 1); + } finally { + restoreInitializeApplicationErrors(); + __resetLogRecordEmitterForTests(); + setApplicationErrorReporter(undefined); + } + }); +}); + +Deno.test("startAgentService resets application-error reporting when shutdown flush fails", async () => { + await withTempDir(async (rootDir) => { + writeMarkdownAgentDefinition(rootDir, "support"); + const events: string[] = []; + const restoreInitializeApplicationErrors = veryfrontCloudAgentServiceInternals + .setInitializeApplicationErrorsForTests(() => ({ + enabled: true, + captureStartupError: () => {}, + flush: () => { + events.push("flush"); + return Promise.reject(new Error("flush failed")); + }, + reset: () => { + events.push("reset"); + }, + })); + + try { + await withMockDenoServiceServer(async ({ signalHandlers, waitForExit }) => { + await startAgentService({ + serviceName: "agent-application-error-flush-failure-test", + agentId: "support", + entrypointUrl: pathToFileURL(resolve(rootDir, "main.ts")), + signals: ["SIGTERM"], + env: { + NODE_ENV: "test", + VERYFRONT_API_URL: "https://api.example.com", + VERYFRONT_AGENT_SERVICE_REGISTRATION: "disabled", + PORT: "0", + ALLOWED_ORIGINS: "https://studio.example.com", + }, + }); + + assertEquals(events, []); + signalHandlers.get("SIGTERM")?.(); + assertEquals(await waitForExit(), 1); + }); + + assertEquals(events, ["flush", "reset"]); + } finally { + restoreInitializeApplicationErrors(); + __resetLogRecordEmitterForTests(); + setApplicationErrorReporter(undefined); + } + }); +}); + +Deno.test("startAgentService captures, flushes, and resets terminal startup failures", async () => { + await withTempDir(async (rootDir) => { + writeMarkdownAgentDefinition(rootDir, "support"); + const startupError = new Error("listen failed"); + const reporter = createReporter(); + const events: string[] = []; + const exitCodes: number[] = []; + const restoreInitializeApplicationErrors = veryfrontCloudAgentServiceInternals + .setInitializeApplicationErrorsForTests(async () => { + const lifecycle = await initializeNodeAgentServiceSentryApplicationErrors({ + env: { + SENTRY_DSN: "https://public@example.ingest.sentry.io/1", + }, + flushTimeoutMs: 5, + loadExtension: () => + Promise.resolve({ + createNodeSentryApplicationErrorReporter: () => reporter, + }), + }); + return { + ...lifecycle, + captureStartupError: (error: unknown) => { + events.push("capture-startup"); + lifecycle.captureStartupError(error); + }, + flush: async (timeoutMs?: number) => { + events.push(`flush:${timeoutMs ?? "default"}`); + return await lifecycle.flush(timeoutMs); + }, + reset: () => { + events.push("reset"); + lifecycle.reset(); + }, + }; + }); + + try { + const denoRuntime = Deno as unknown as TestDenoRuntime; + const originalServe = denoRuntime.serve; + denoRuntime.serve = (() => { + throw startupError; + }) as typeof Deno.serve; + try { + await startAgentService({ + serviceName: "agent-startup-application-errors-test", + agentId: "support", + entrypointUrl: pathToFileURL(resolve(rootDir, "main.ts")), + signals: [], + processTarget: { + env: {}, + on: () => {}, + off: () => {}, + exit: (code) => { + exitCodes.push(code); + }, + }, + env: { + NODE_ENV: "test", + VERYFRONT_API_URL: "https://api.example.com", + VERYFRONT_AGENT_SERVICE_REGISTRATION: "disabled", + PORT: "0", + ALLOWED_ORIGINS: "https://studio.example.com", + }, + }); + } finally { + denoRuntime.serve = originalServe; + } + + assertEquals(events, ["capture-startup", "flush:default", "reset"]); + assertEquals(reporter.captured, [ + { error: startupError, context: { boundary: "agent.process.startup" } }, + ]); + assertEquals(reporter.flushTimeouts, [5]); + assertEquals(exitCodes, [1]); + } finally { + restoreInitializeApplicationErrors(); + __resetLogRecordEmitterForTests(); + setApplicationErrorReporter(undefined); + } + }); +}); + +Deno.test("startAgentService resets and exits when startup error flush rejects", async () => { + await withTempDir(async (rootDir) => { + writeMarkdownAgentDefinition(rootDir, "support"); + const startupError = new Error("listen failed"); + const events: string[] = []; + const exitCodes: number[] = []; + const restoreInitializeApplicationErrors = veryfrontCloudAgentServiceInternals + .setInitializeApplicationErrorsForTests(() => ({ + enabled: true, + captureStartupError: (error: unknown) => { + assertStrictEquals(error, startupError); + events.push("capture-startup"); + }, + flush: () => { + events.push("flush"); + return Promise.reject(new Error("flush failed")); + }, + reset: () => { + events.push("reset"); + }, + })); + + try { + const denoRuntime = Deno as unknown as TestDenoRuntime; + const originalServe = denoRuntime.serve; + denoRuntime.serve = (() => { + throw startupError; + }) as typeof Deno.serve; + try { + await startAgentService({ + serviceName: "agent-startup-flush-failure-test", + agentId: "support", + entrypointUrl: pathToFileURL(resolve(rootDir, "main.ts")), + signals: [], + processTarget: { + env: {}, + on: () => {}, + off: () => {}, + exit: (code) => { + exitCodes.push(code); + }, + }, + env: { + NODE_ENV: "test", + VERYFRONT_API_URL: "https://api.example.com", + VERYFRONT_AGENT_SERVICE_REGISTRATION: "disabled", + PORT: "0", + ALLOWED_ORIGINS: "https://studio.example.com", + }, + }); + } finally { + denoRuntime.serve = originalServe; + } + + assertEquals(events, ["capture-startup", "flush", "reset"]); + assertEquals(exitCodes, [1]); + } finally { + restoreInitializeApplicationErrors(); + __resetLogRecordEmitterForTests(); + setApplicationErrorReporter(undefined); + } + }); +}); + Deno.test("hosted generic invocation is only replaced by explicit delegates", () => { assertEquals( veryfrontCloudAgentServiceInternals.resolveHostedDelegationBinding({ @@ -635,6 +1010,79 @@ Deno.test("startNodeVeryfrontCloudAgentService registers the service with the co }); }); +Deno.test("startNodeVeryfrontCloudAgentService preserves startup error when registration rollback fails", async () => { + await withTempDir(async (rootDir) => { + writeMarkdownAgentDefinition(rootDir, "support"); + const originalFetch = globalThis.fetch; + const originalClearInterval = globalThis.clearInterval; + const rollbackError = new Error("registration stop failed"); + globalThis.fetch = () => + Promise.resolve( + new Response( + JSON.stringify({ + service: { + id: "22222222-2222-4222-a222-222222222222", + service_name: "registered-rollback-test", + service_key: "registered-rollback-test:key", + scope_kind: "project", + scope_key: "11111111-1111-4111-a111-111111111111", + project_id: "11111111-1111-4111-a111-111111111111", + agent_id: "support", + base_url: "https://agent.example.com", + invoke_url: "https://agent.example.com/api/runs", + status: "active", + capabilities: null, + metadata: null, + version: "0.1.0", + runtime: "node", + region: null, + last_heartbeat_at: "2026-05-13T00:00:00.000Z", + created_at: "2026-05-13T00:00:00.000Z", + updated_at: "2026-05-13T00:00:00.000Z", + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + globalThis.clearInterval = ((timerId) => { + originalClearInterval(timerId); + throw rollbackError; + }) as typeof globalThis.clearInterval; + + try { + const rejected = await assertRejects( + () => + startNodeVeryfrontCloudAgentService({ + serviceName: "registered-rollback-test", + agentId: "support", + runtimeSource: { type: "release", releaseId: "release-42" }, + entrypointUrl: pathToFileURL(resolve(rootDir, "main.ts")), + signals: [], + env: { + NODE_ENV: "test", + VERYFRONT_API_URL: "https://api.example.com", + VERYFRONT_API_TOKEN: "token-1", + VERYFRONT_PROJECT_ID: "11111111-1111-4111-a111-111111111111", + VERYFRONT_AGENT_SERVICE_URL: "https://agent.example.com", + VERYFRONT_AGENT_SERVICE_KEY: "registered-rollback-test:key", + VERYFRONT_AGENT_SERVICE_REGISTRATION: "enabled", + VERYFRONT_AGENT_SERVICE_HEARTBEAT_INTERVAL_MS: "60000", + PORT: "-1", + ALLOWED_ORIGINS: "https://studio.example.com", + }, + }), + Error, + ); + + assertStrictEquals(rejected === rollbackError, false); + assertEquals(rejected instanceof Error && rejected.message.includes("options.port"), true); + } finally { + globalThis.fetch = originalFetch; + globalThis.clearInterval = originalClearInterval; + } + }); +}); + Deno.test("startNodeVeryfrontCloudAgentService rejects registration without an immutable source binding", async () => { await withTempDir(async (rootDir) => { writeMarkdownAgentDefinition(rootDir, "support"); diff --git a/src/agent/hosted/veryfront-cloud-agent-service.ts b/src/agent/hosted/veryfront-cloud-agent-service.ts index bf7d4f9558..3c159b608e 100644 --- a/src/agent/hosted/veryfront-cloud-agent-service.ts +++ b/src/agent/hosted/veryfront-cloud-agent-service.ts @@ -11,12 +11,15 @@ import type { AgentServiceMcpServerConfig } from "../service/mcp-server-config.t import type { AgentVeryfrontMcpServerConfig } from "../types.ts"; import { type AgentServiceRuntimeBundle, + combineAgentServiceLifecycle, createAgentServiceRuntime, startAgentServiceRuntime, startNodeAgentService, type StartNodeAgentServiceResult, } from "../service/runtime.ts"; +import type { AgentServiceServerLifecycle } from "../service/server.ts"; import type { CreateNodeAgentServiceRuntimeInfrastructureOptions } from "../service/node-runtime-infrastructure.ts"; +import type { NodeAgentServiceApplicationErrorLifecycle } from "../service/node-sentry.ts"; import type { ProjectAgentRuntimeAgentSource } from "../project/agent-runtime.ts"; import type { HostedRuntimeSourceIdentity } from "./runtime-source-binding.ts"; import { resolveDefaultProcessTarget } from "./cloud-agent-paths.ts"; @@ -41,6 +44,12 @@ import { const DEFAULT_HARD_SHUTDOWN_TIMEOUT_MS = 20_000; +type InitializeApplicationErrors = () => + | NodeAgentServiceApplicationErrorLifecycle + | Promise; + +let initializeApplicationErrorsForTests: InitializeApplicationErrors | undefined; + /** Public API contract for node Veryfront Cloud agent service process target. */ export type NodeVeryfrontCloudAgentServiceProcessTarget = & NonNullable @@ -134,8 +143,49 @@ export const veryfrontCloudAgentServiceInternals = { resolveHostedChildAgentExecutionConfig, resolveHostedChildToolNames, resolveMcpServers, + setInitializeApplicationErrorsForTests( + initializeApplicationErrors: InitializeApplicationErrors | undefined, + ): () => void { + initializeApplicationErrorsForTests = initializeApplicationErrors; + return () => { + if (initializeApplicationErrorsForTests === initializeApplicationErrors) { + initializeApplicationErrorsForTests = undefined; + } + }; + }, }; +function createApplicationErrorShutdownLifecycle( + getApplicationErrors: () => NodeAgentServiceApplicationErrorLifecycle, +): AgentServiceServerLifecycle { + let cleanedUp = false; + return { + stop: async () => { + if (cleanedUp) return; + cleanedUp = true; + const applicationErrors = getApplicationErrors(); + try { + await applicationErrors.flush(); + } finally { + applicationErrors.reset(); + } + }, + }; +} + +async function stopRegistrationForStartupFailure( + lifecycle: AgentServiceServerLifecycle | undefined, + logger: Pick, +): Promise { + try { + await lifecycle?.stop?.(); + } catch (error) { + logger.warn("Agent service registration cleanup failed during startup rollback", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + /** Create node Veryfront Cloud agent service runtime. */ export async function createNodeVeryfrontCloudAgentServiceRuntime( options: NodeVeryfrontCloudAgentServiceOptions = {}, @@ -162,7 +212,10 @@ export async function startNodeVeryfrontCloudAgentService( hardShutdownTimeoutMs: options.hardShutdownTimeoutMs ?? DEFAULT_HARD_SHUTDOWN_TIMEOUT_MS, }); } catch (error) { - await registrationLifecycle?.stop?.(); + await stopRegistrationForStartupFailure( + registrationLifecycle, + context.infrastructure.logger, + ); throw error; } } @@ -181,18 +234,19 @@ export async function startAgentService( ...resolvedOptions, processTarget, }); - let applicationErrors = { + let applicationErrors: NodeAgentServiceApplicationErrorLifecycle = { + enabled: false, captureStartupError: (_error: unknown) => {}, flush: () => Promise.resolve(true), reset: () => {}, }; - let startupErrorHandled = false; getRuntimeTraceContext = context.infrastructure.getTraceContext; await runAgentServiceMain({ loadLogger: () => context.infrastructure.logger, initializeApplicationErrors: async () => { - applicationErrors = await context.infrastructure.initializeApplicationErrors(); + applicationErrors = await (initializeApplicationErrorsForTests?.() ?? + context.infrastructure.initializeApplicationErrors()); }, initializeTelemetry: async () => { return await context.infrastructure.initializeOpenTelemetry().catch((error) => { @@ -210,30 +264,39 @@ export async function startAgentService( start: async () => { await initializeNodeVeryfrontCloudAgentServiceContext(context); const registrationLifecycle = await createControlPlaneRegistrationLifecycle(context); + const applicationErrorLifecycle = createApplicationErrorShutdownLifecycle(() => + applicationErrors + ); try { await startAgentServiceRuntime({ ...createNodeVeryfrontCloudAgentServiceRuntimeOptions(context), - lifecycle: registrationLifecycle, + lifecycle: combineAgentServiceLifecycle( + registrationLifecycle ?? {}, + applicationErrorLifecycle, + ), signals: options.signals, hardShutdownTimeoutMs: options.hardShutdownTimeoutMs ?? DEFAULT_HARD_SHUTDOWN_TIMEOUT_MS, }); } catch (error) { - await registrationLifecycle?.stop?.(); + await stopRegistrationForStartupFailure( + registrationLifecycle, + context.infrastructure.logger, + ); throw error; } }, onStartupError: async (error) => { applicationErrors.captureStartupError(error); agentLogger.error("Error in server startup:", { error }); - await applicationErrors.flush(); - applicationErrors.reset(); - startupErrorHandled = true; - }, - onFinally: async () => { - if (!startupErrorHandled) { + try { await applicationErrors.flush(); + } catch (flushError) { + agentLogger.warn("Failed to flush application errors during startup failure", { + error: flushError instanceof Error ? flushError.message : String(flushError), + }); + } finally { + applicationErrors.reset(); } - applicationErrors.reset(); }, exit: processTarget?.exit, processTarget, diff --git a/src/agent/service/runtime.test.ts b/src/agent/service/runtime.test.ts index 61dd717509..eda81f2703 100644 --- a/src/agent/service/runtime.test.ts +++ b/src/agent/service/runtime.test.ts @@ -1,6 +1,7 @@ -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { + combineAgentServiceLifecycle, createAgentServiceRuntime, createHostedAgentServiceRuntime, startNodeAgentService, @@ -177,4 +178,33 @@ describe("agent/agent-service-runtime", () => { await service.nodeServer.stop(); } }); + + it("runs secondary shutdown lifecycle even when primary stop fails", async () => { + const events: string[] = []; + const shutdownError = new Error("primary shutdown failed"); + const lifecycle = combineAgentServiceLifecycle( + { + stop: () => { + events.push("primary-stop"); + throw shutdownError; + }, + }, + { + stop: () => { + events.push("secondary-stop"); + }, + }, + ); + + const rejected = await assertRejects( + async () => { + await lifecycle.stop?.(); + }, + Error, + "primary shutdown failed", + ); + + assertEquals(rejected, shutdownError); + assertEquals(events, ["primary-stop", "secondary-stop"]); + }); }); diff --git a/src/agent/service/runtime.ts b/src/agent/service/runtime.ts index ce528c89aa..2ff72fc676 100644 --- a/src/agent/service/runtime.ts +++ b/src/agent/service/runtime.ts @@ -185,7 +185,7 @@ function normalizeAgentServiceTools( return Object.fromEntries(tools.map((toolId) => [toolId, true])); } -function combineAgentServiceLifecycle( +export function combineAgentServiceLifecycle( primary: AgentServiceServerLifecycle, secondary: AgentServiceServerLifecycle | undefined, ): AgentServiceServerLifecycle { @@ -195,12 +195,36 @@ function combineAgentServiceLifecycle( return { setShuttingDown: () => { - primary.setShuttingDown?.(); - secondary.setShuttingDown?.(); + let failure: unknown; + try { + primary.setShuttingDown?.(); + } catch (error) { + failure = error; + } + try { + secondary.setShuttingDown?.(); + } catch (error) { + failure ??= error; + } + if (failure !== undefined) { + throw failure; + } }, stop: async () => { - await primary.stop?.(); - await secondary.stop?.(); + let failure: unknown; + try { + await primary.stop?.(); + } catch (error) { + failure = error; + } + try { + await secondary.stop?.(); + } catch (error) { + failure ??= error; + } + if (failure !== undefined) { + throw failure; + } }, }; } diff --git a/src/server/service-server.test.ts b/src/server/service-server.test.ts index c34ca28960..4fb67c94e0 100644 --- a/src/server/service-server.test.ts +++ b/src/server/service-server.test.ts @@ -1,5 +1,9 @@ -import { assertEquals } from "#veryfront/testing/assert.ts"; -import { createVeryfrontServer, startVeryfrontServer } from "./service-server.ts"; +import { assertEquals, assertRejects, assertStrictEquals } from "#veryfront/testing/assert.ts"; +import { + createVeryfrontServer, + startNodeVeryfrontServer, + startVeryfrontServer, +} from "./service-server.ts"; Deno.test("createVeryfrontServer dispatches to the first module response", async () => { const runtime = createVeryfrontServer({ @@ -40,13 +44,17 @@ Deno.test("createVeryfrontServer fans out shutdown state and stop hooks", async name: "first", handle: () => null, setShuttingDown: () => events.push("first:shutdown"), - stop: () => events.push("first:stop"), + stop: () => { + events.push("first:stop"); + }, }, { name: "second", handle: () => null, setShuttingDown: () => events.push("second:shutdown"), - stop: async () => events.push("second:stop"), + stop: async () => { + events.push("second:stop"); + }, }, ], }); @@ -64,7 +72,9 @@ Deno.test("startVeryfrontServer starts the current runtime fetch server", async name: "test", handle: () => new Response("served"), setShuttingDown: () => events.push("shutdown"), - stop: () => events.push("stop"), + stop: () => { + events.push("stop"); + }, }], }); const server = await startVeryfrontServer({ @@ -85,3 +95,239 @@ Deno.test("startVeryfrontServer starts the current runtime fetch server", async assertEquals(events, ["shutdown", "stop"]); }); + +Deno.test("Deno service shutdown runs runtime stop when server shutdown rejects", async () => { + const denoRuntime = Deno as unknown as { + serve: typeof Deno.serve; + addSignalListener: typeof Deno.addSignalListener; + removeSignalListener: typeof Deno.removeSignalListener; + }; + const originalServe = denoRuntime.serve; + const originalAddSignalListener = denoRuntime.addSignalListener; + const originalRemoveSignalListener = denoRuntime.removeSignalListener; + const shutdownError = new Error("deno shutdown failed"); + const events: string[] = []; + + denoRuntime.serve = (() => ({ + addr: { port: 3210 }, + shutdown: () => { + events.push("server-shutdown"); + return Promise.reject(shutdownError); + }, + })) as unknown as typeof Deno.serve; + denoRuntime.addSignalListener = (() => {}) as typeof Deno.addSignalListener; + denoRuntime.removeSignalListener = (() => {}) as typeof Deno.removeSignalListener; + + try { + const runtime = createVeryfrontServer({ + modules: [{ + name: "test", + handle: () => new Response("served"), + setShuttingDown: () => events.push("runtime-shutdown"), + stop: () => { + events.push("runtime-stop"); + }, + }], + }); + const server = await startVeryfrontServer({ + runtime, + port: 0, + bindAddress: "127.0.0.1", + signals: [], + }); + + const rejected = await assertRejects(() => server.stop(), Error, "deno shutdown failed"); + + assertStrictEquals(rejected, shutdownError); + assertEquals(events, ["runtime-shutdown", "server-shutdown", "runtime-stop"]); + } finally { + denoRuntime.serve = originalServe; + denoRuntime.addSignalListener = originalAddSignalListener; + denoRuntime.removeSignalListener = originalRemoveSignalListener; + } +}); + +Deno.test("Deno service shutdown preserves undefined transport rejection", async () => { + const denoRuntime = Deno as unknown as { + serve: typeof Deno.serve; + addSignalListener: typeof Deno.addSignalListener; + removeSignalListener: typeof Deno.removeSignalListener; + }; + const originalServe = denoRuntime.serve; + const originalAddSignalListener = denoRuntime.addSignalListener; + const originalRemoveSignalListener = denoRuntime.removeSignalListener; + const events: string[] = []; + + denoRuntime.serve = (() => ({ + addr: { port: 3212 }, + shutdown: () => { + events.push("server-shutdown"); + return Promise.reject(undefined); + }, + })) as unknown as typeof Deno.serve; + denoRuntime.addSignalListener = (() => {}) as typeof Deno.addSignalListener; + denoRuntime.removeSignalListener = (() => {}) as typeof Deno.removeSignalListener; + + try { + const runtime = createVeryfrontServer({ + modules: [{ + name: "test", + handle: () => new Response("served"), + setShuttingDown: () => events.push("runtime-shutdown"), + stop: () => { + events.push("runtime-stop"); + }, + }], + }); + const server = await startVeryfrontServer({ + runtime, + port: 0, + bindAddress: "127.0.0.1", + signals: [], + }); + + let rejected: unknown = "not-thrown"; + try { + await server.stop(); + } catch (error) { + rejected = error; + } + + assertStrictEquals(rejected, undefined); + assertEquals(events, ["runtime-shutdown", "server-shutdown", "runtime-stop"]); + } finally { + denoRuntime.serve = originalServe; + denoRuntime.addSignalListener = originalAddSignalListener; + denoRuntime.removeSignalListener = originalRemoveSignalListener; + } +}); + +Deno.test("Bun service shutdown runs runtime stop when transport stop rejects", async () => { + const originalBun = Reflect.get(globalThis, "Bun"); + const stopError = new Error("bun stop failed"); + const events: string[] = []; + Reflect.set(globalThis, "Bun", { + serve: () => ({ + port: 3211, + url: new URL("http://127.0.0.1:3211"), + stop: () => { + events.push("server-stop"); + return Promise.reject(stopError); + }, + }), + }); + + try { + const runtime = createVeryfrontServer({ + modules: [{ + name: "test", + handle: () => new Response("served"), + setShuttingDown: () => events.push("runtime-shutdown"), + stop: () => { + events.push("runtime-stop"); + }, + }], + }); + const server = await startVeryfrontServer({ + runtime, + port: 0, + bindAddress: "127.0.0.1", + signals: [], + }); + + const rejected = await assertRejects(() => server.stop(), Error, "bun stop failed"); + + assertStrictEquals(rejected, stopError); + assertEquals(events, ["runtime-shutdown", "server-stop", "runtime-stop"]); + } finally { + if (originalBun === undefined) { + Reflect.deleteProperty(globalThis, "Bun"); + } else { + Reflect.set(globalThis, "Bun", originalBun); + } + } +}); + +Deno.test("Bun service shutdown preserves null runtime stop rejection", async () => { + const originalBun = Reflect.get(globalThis, "Bun"); + const events: string[] = []; + Reflect.set(globalThis, "Bun", { + serve: () => ({ + port: 3213, + url: new URL("http://127.0.0.1:3213"), + stop: () => { + events.push("server-stop"); + }, + }), + }); + + try { + const runtime = createVeryfrontServer({ + modules: [{ + name: "test", + handle: () => new Response("served"), + setShuttingDown: () => events.push("runtime-shutdown"), + stop: () => { + events.push("runtime-stop"); + return Promise.reject(null); + }, + }], + }); + const server = await startVeryfrontServer({ + runtime, + port: 0, + bindAddress: "127.0.0.1", + signals: [], + }); + + let rejected: unknown = "not-thrown"; + try { + await server.stop(); + } catch (error) { + rejected = error; + } + + assertStrictEquals(rejected, null); + assertEquals(events, ["runtime-shutdown", "server-stop", "runtime-stop"]); + } finally { + if (originalBun === undefined) { + Reflect.deleteProperty(globalThis, "Bun"); + } else { + Reflect.set(globalThis, "Bun", originalBun); + } + } +}); + +Deno.test("Node service shutdown runs runtime stop when server close fails", async () => { + const closeError = new Error("node close failed"); + const events: string[] = []; + const runtime = createVeryfrontServer({ + modules: [{ + name: "test", + handle: () => new Response("served"), + setShuttingDown: () => events.push("runtime-shutdown"), + stop: () => { + events.push("runtime-stop"); + }, + }], + }); + const server = await startNodeVeryfrontServer({ + runtime, + port: 0, + bindAddress: "127.0.0.1", + signals: [], + }); + const originalClose = server.server.close; + server.server.close = ((callback?: (error?: Error) => void) => { + events.push("server-close"); + originalClose.call(server.server, () => { + callback?.(closeError); + }); + return server.server; + }) as typeof server.server.close; + + const rejected = await assertRejects(() => server.stop(), Error, "node close failed"); + + assertStrictEquals(rejected, closeError); + assertEquals(events, ["runtime-shutdown", "server-close", "runtime-stop"]); +}); diff --git a/src/server/service-server.ts b/src/server/service-server.ts index b1c1010141..ba466e1de6 100644 --- a/src/server/service-server.ts +++ b/src/server/service-server.ts @@ -340,8 +340,25 @@ async function stopRuntime( stopServer: () => void | Promise, ): Promise { runtime.setShuttingDown(); - await stopServer(); - await runtime.stop(); + let failure: unknown; + let hasFailure = false; + try { + await stopServer(); + } catch (error) { + failure = error; + hasFailure = true; + } + try { + await runtime.stop(); + } catch (error) { + if (!hasFailure) { + failure = error; + hasFailure = true; + } + } + if (hasFailure) { + throw failure; + } } function installSignalHandlers(options: {