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.