From 53ee22af4f6c67aaef77875b9a403888be93910d Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 29 Jul 2026 13:13:26 +0200 Subject: [PATCH 1/3] Keep agent error reporting live until shutdown Attach application-error cleanup to the agent service lifecycle so readiness no longer tears down reporting while the server keeps serving. Startup and rollback cleanup errors are isolated so the original fatal path still reaches the expected exit or rejection. Constraint: Published agent services return from startup after readiness while the process remains live. Rejected: Keep runAgentServiceMain success-path cleanup | It flushes and resets the reporter immediately after readiness. Confidence: high Scope-risk: narrow Directive: Keep application-error cleanup owned by service shutdown, and keep cleanup failures from replacing startup failures. Tested: deno test --allow-all src/agent/hosted/veryfront-cloud-agent-service.test.ts src/agent/service/runtime.test.ts; deno fmt --check touched files; deno lint touched files; deno check touched files; deno task typecheck; deno task typecheck:consumer Not-tested: Full deno task verify and binary e2e suite were not run; pre-commit verify:quick hit unrelated missing release-assets API docs. --- .../veryfront-cloud-agent-service.test.ts | 450 +++++++++++++++++- .../hosted/veryfront-cloud-agent-service.ts | 89 +++- src/agent/service/runtime.test.ts | 32 +- src/agent/service/runtime.ts | 34 +- 4 files changed, 585 insertions(+), 20 deletions(-) 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; + } }, }; } From 61849539440a8e1e529de261b3368bd5b492b2ac Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 29 Jul 2026 13:45:46 +0200 Subject: [PATCH 2/3] Run service cleanup after transport stop failures Keep service module lifecycle cleanup independent from transport shutdown so application-error flush/reset still runs when Deno, Bun, or Node server shutdown fails. Constraint: Agent application-error cleanup is attached to service runtime stop, but transports may fail before runtime stop runs. Rejected: Leave stopRuntime sequenced after successful transport stop only | It skips lifecycle cleanup on server.shutdown, server.stop, or server.close failure. Confidence: high Scope-risk: narrow Directive: Preserve the first shutdown failure while still attempting module lifecycle cleanup exactly once. Tested: deno test --allow-all src/server/service-server.test.ts; deno test --allow-all src/agent/hosted/veryfront-cloud-agent-service.test.ts src/agent/service/runtime.test.ts; deno fmt --check touched files; deno lint touched files; deno check touched files; deno task typecheck Not-tested: Full deno task verify and binary e2e suite were not run; prior PR verification noted unrelated release-assets docs gaps. --- src/server/service-server.test.ts | 151 +++++++++++++++++++++++++++++- src/server/service-server.ts | 16 +++- 2 files changed, 160 insertions(+), 7 deletions(-) diff --git a/src/server/service-server.test.ts b/src/server/service-server.test.ts index c34ca28960..dbbc1e8de1 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,134 @@ 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("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("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..da3e6ffcac 100644 --- a/src/server/service-server.ts +++ b/src/server/service-server.ts @@ -340,8 +340,20 @@ async function stopRuntime( stopServer: () => void | Promise, ): Promise { runtime.setShuttingDown(); - await stopServer(); - await runtime.stop(); + let failure: unknown; + try { + await stopServer(); + } catch (error) { + failure = error; + } + try { + await runtime.stop(); + } catch (error) { + failure ??= error; + } + if (failure !== undefined) { + throw failure; + } } function installSignalHandlers(options: { From 51324d90d96a527dffe2d27fe5bd1f364339fdf8 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 29 Jul 2026 13:51:18 +0200 Subject: [PATCH 3/3] Preserve nullish service shutdown failures Track shutdown failure presence separately from the failure value so undefined or null transport/runtime failures are still rethrown after lifecycle cleanup runs. Constraint: JavaScript allows throwing or rejecting nullish values, and service shutdown must preserve the first failure exactly. Rejected: Use the failure value itself as the sentinel | undefined and null are valid failure values and were treated as no failure. Confidence: high Scope-risk: narrow Directive: Keep shutdown cleanup independent from transport failures and preserve first-failure identity, including nullish failures. Tested: deno test --allow-all src/server/service-server.test.ts; deno test --allow-all src/agent/hosted/veryfront-cloud-agent-service.test.ts src/agent/service/runtime.test.ts; deno fmt --check touched files; deno check touched files; deno run --allow-read --allow-run scripts/lint/check-test-typecheck-baseline.ts; deno task lint; deno task typecheck Not-tested: Full deno task verify and binary e2e suite were not run. --- scripts/lint/test-typecheck-baseline.json | 1 - src/server/service-server.test.ts | 105 ++++++++++++++++++++++ src/server/service-server.ts | 9 +- 3 files changed, 112 insertions(+), 3 deletions(-) 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/server/service-server.test.ts b/src/server/service-server.test.ts index dbbc1e8de1..4fb67c94e0 100644 --- a/src/server/service-server.test.ts +++ b/src/server/service-server.test.ts @@ -147,6 +147,61 @@ Deno.test("Deno service shutdown runs runtime stop when server shutdown rejects" } }); +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"); @@ -193,6 +248,56 @@ Deno.test("Bun service shutdown runs runtime stop when transport stop rejects", } }); +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[] = []; diff --git a/src/server/service-server.ts b/src/server/service-server.ts index da3e6ffcac..ba466e1de6 100644 --- a/src/server/service-server.ts +++ b/src/server/service-server.ts @@ -341,17 +341,22 @@ async function stopRuntime( ): Promise { runtime.setShuttingDown(); let failure: unknown; + let hasFailure = false; try { await stopServer(); } catch (error) { failure = error; + hasFailure = true; } try { await runtime.stop(); } catch (error) { - failure ??= error; + if (!hasFailure) { + failure = error; + hasFailure = true; + } } - if (failure !== undefined) { + if (hasFailure) { throw failure; } }