From f590432d3b176240ad61719bc62f7e633ff309d0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:07:54 +0800 Subject: [PATCH] feat(runtime): add GET /ready readiness probe for graceful rolling restarts The dispatcher exposed only /health (always 200). Multi-replica deployments need a readiness signal so a load balancer drains a replica before its in-flight requests are force-closed on shutdown. /ready returns 200 only when the kernel state is 'running'; 503 while booting (idle/initializing) or shutting down (stopping/stopped). Added to the membership-skip paths like /health so it is reachable without auth/environment scoping. Pairs with the existing in-flight drain in HonoHttpServer.close() (P1-3). Co-Authored-By: Claude Opus 4.8 --- .../runtime/src/http-dispatcher.ready.test.ts | 27 +++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 16 ++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/http-dispatcher.ready.test.ts diff --git a/packages/runtime/src/http-dispatcher.ready.test.ts b/packages/runtime/src/http-dispatcher.ready.test.ts new file mode 100644 index 0000000000..6e40a48b33 --- /dev/null +++ b/packages/runtime/src/http-dispatcher.ready.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { HttpDispatcher } from './http-dispatcher.js'; + +function kernel(state: string): any { + return { + getState: () => state, + getService: () => undefined, + getServiceAsync: async () => undefined, + }; +} +const ctx: any = {}; + +describe('HttpDispatcher — GET /ready readiness probe', () => { + it('returns 200 when the kernel is running', async () => { + const res = await new HttpDispatcher(kernel('running')).dispatch('GET', '/ready', undefined, undefined, ctx); + expect(res.handled).toBe(true); + expect(res.response.status).toBe(200); + expect(res.response.body.data.state).toBe('running'); + }); + + it('returns 503 while booting or shutting down', async () => { + for (const state of ['idle', 'initializing', 'stopping', 'stopped']) { + const res = await new HttpDispatcher(kernel(state)).dispatch('GET', '/ready', undefined, undefined, ctx); + expect(res.response.status).toBe(503); + } + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index dca9d675b1..6e271dda61 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -699,7 +699,7 @@ export class HttpDispatcher { if (!this.enforceMembership) return null; // Control-plane paths — never gated by project membership. - const skipPaths = ['/auth', '/cloud', '/health', '/discovery']; + const skipPaths = ['/auth', '/cloud', '/health', '/ready', '/discovery']; if (skipPaths.some(p => path.startsWith(p))) return null; // Public share-link resolve/messages — the token IS the authorisation, @@ -3076,6 +3076,20 @@ export class HttpDispatcher { }; } + // 0b2. Readiness Endpoint (GET /ready) — k8s / load-balancer readiness probe. + // 200 only when the kernel is fully running; 503 while booting + // (idle/initializing) or shutting down (stopping/stopped) so a load + // balancer stops routing to this replica BEFORE in-flight requests are + // drained and the server closes (graceful rolling restart). + if (cleanPath === '/ready' && method === 'GET') { + const state: string = typeof (this.kernel as any)?.getState === 'function' + ? (this.kernel as any).getState() + : 'running'; + return state === 'running' + ? { handled: true, response: this.success({ status: 'ready', state }) } + : { handled: true, response: this.error('Service not ready', 503, { state }) }; + } + // 0c. Plan-A diagnostics removed; the seed-replay and oauth2/callback // probes were temporary debugging tools used during the SSO rollout.