Skip to content

Commit 8cf4f7c

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(runtime): mount GET /ready so the readiness probe is reachable over HTTP (#2227)
Seam #2 added the dispatcher's `/ready` handler (200 running / 503 booting or draining) but never mounted a route for it. The dispatcher mounts routes EXPLICITLY (no catch-all), so `GET /api/v1/ready` matched nothing and returned the Hono not-found 404 before reaching dispatch() — the exact failure mode that shipped `/mcp` and `/keys` broken, hidden because the unit test called dispatch() directly. Mount `${prefix}/ready` next to `${prefix}/health`. This is the contract the EE multi-node rolling-restart drain gate polls (cloud ADR-0018) so a load balancer stops routing to a replica before it closes. Tests: - dispatcher-plugin.routes.test.ts: assert both /health and /ready are mounted (registration-level guard that would have caught this). - dispatcher-plugin.ready.integration.test.ts (new): boot a real HonoServerPlugin + dispatcher on a socket, fetch /ready -> 200 (running) and 503 (draining via getState), and confirm a bogus path returns the prod {"error":"Not found"} 404 so the test reproduces the pre-fix failure rather than passing vacuously. Adds a test-only devDependency on @objectstack/plugin-hono-server. Verified: pnpm --filter @objectstack/runtime build (strict DTS), full runtime suite (420 tests), and a CLI boot+curl (/api/v1/ready 404 -> 200). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 53f146e commit 8cf4f7c

6 files changed

Lines changed: 136 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): mount `GET /ready` so the readiness probe is reachable over HTTP
6+
7+
The dispatcher's `/ready` branch (seam #2) was only reachable when calling
8+
`dispatch()` directly — no `server.get('${prefix}/ready')` registration existed,
9+
so a real server returned the Hono not-found 404 before the handler ran (the same
10+
class of bug as `/mcp` and `/keys`). `/ready` is now mounted alongside `/health`,
11+
returning 200 while the kernel is `running` and 503 while it is booting or
12+
draining — the contract the EE multi-node rolling-restart drain gate polls
13+
(cloud ADR-0018). Adds a registration assertion plus an integration test that
14+
hits the endpoint through a real HTTP server.

packages/runtime/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@objectstack/driver-mongodb": "workspace:*"
4444
},
4545
"devDependencies": {
46+
"@objectstack/plugin-hono-server": "workspace:*",
4647
"@objectstack/service-datasource": "workspace:*",
4748
"typescript": "^6.0.3",
4849
"vitest": "^4.1.9"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4+
import { LiteKernel } from '@objectstack/core';
5+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
6+
7+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
8+
9+
/**
10+
* Integration regression for framework #2217 seam #2.
11+
*
12+
* The dispatcher's GET /ready branch had a passing UNIT test that called
13+
* `dispatch()` directly — but in a real server the route was never mounted on
14+
* the HTTP layer, so it 404'd with the Hono not-found body (`{"error":"Not
15+
* found"}`) BEFORE reaching the handler. A dispatch()-only test cannot catch
16+
* that; this one boots the actual HTTP stack (HonoServerPlugin + the dispatcher
17+
* plugin), opens a real socket and uses `fetch`, exactly like a k8s / load
18+
* balancer readiness probe (the EE rolling-restart drain gate — cloud ADR-0018).
19+
*/
20+
describe('GET /ready over a real HTTP server (integration)', () => {
21+
let kernel: LiteKernel;
22+
let baseUrl: string;
23+
24+
beforeAll(async () => {
25+
kernel = new LiteKernel();
26+
// port 0 → OS-assigned free port; resolved via getPort() after listening.
27+
kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints: true }));
28+
kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }));
29+
30+
await kernel.bootstrap();
31+
32+
const httpServer = kernel.getService<any>('http.server');
33+
baseUrl = `http://127.0.0.1:${httpServer.getPort()}`;
34+
}, 30_000);
35+
36+
afterAll(async () => {
37+
if (kernel) {
38+
await Promise.race([
39+
kernel.shutdown(),
40+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
41+
]);
42+
}
43+
}, 30_000);
44+
45+
it('returns 200 with state "running" once bootstrapped', async () => {
46+
const res = await fetch(`${baseUrl}/api/v1/ready`);
47+
expect(res.status).toBe(200);
48+
const body = await res.json();
49+
expect(body.success).toBe(true);
50+
expect(body.data.status).toBe('ready');
51+
expect(body.data.state).toBe('running');
52+
});
53+
54+
it('mounts /health alongside /ready (both probes reachable)', async () => {
55+
const res = await fetch(`${baseUrl}/api/v1/health`);
56+
expect(res.status).toBe(200);
57+
const body = await res.json();
58+
expect(body.data.status).toBe('ok');
59+
});
60+
61+
it('proves the harness mirrors prod: an unmounted path 404s with the Hono not-found body', async () => {
62+
// This is the exact response /ready produced BEFORE the fix. Asserting it
63+
// here shows the test would have failed against the old code (the /ready
64+
// assertion above would have returned this body), not passed vacuously.
65+
const res = await fetch(`${baseUrl}/api/v1/this-route-does-not-exist`);
66+
expect(res.status).toBe(404);
67+
const body = await res.json();
68+
expect(body).toEqual({ error: 'Not found' });
69+
});
70+
71+
it('returns 503 while the kernel is shutting down (drain signal)', async () => {
72+
// The server socket must stay open to serve the probe, so we can't call
73+
// shutdown() (it closes the socket). Instead simulate the draining state
74+
// the dispatcher reads per-request via kernel.getState().
75+
const realGetState = kernel.getState.bind(kernel);
76+
(kernel as any).getState = () => 'stopping';
77+
try {
78+
const res = await fetch(`${baseUrl}/api/v1/ready`);
79+
expect(res.status).toBe(503);
80+
const body = await res.json();
81+
expect(body.success).toBe(false);
82+
expect(body.error.code).toBe(503);
83+
expect(body.error.details.state).toBe('stopping');
84+
} finally {
85+
(kernel as any).getState = realGetState;
86+
}
87+
});
88+
});

packages/runtime/src/dispatcher-plugin.routes.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
5959
expect(routes).toContain('POST /api/v1/keys');
6060
});
6161

62+
// Regression (framework #2217 seam #2): /ready shipped with a dispatch()
63+
// branch but NO server.<verb>() registration, so it 404'd over HTTP before
64+
// reaching the handler — the same class of bug as /mcp and /keys. /health and
65+
// /ready are the k8s / load-balancer probes the EE rolling-restart drain gate
66+
// polls (cloud ADR-0018); both must be mounted to be reachable.
67+
it('mounts /health and /ready so the liveness/readiness probes reach dispatch()', async () => {
68+
const { server, routes } = makeFakeServer();
69+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
70+
await plugin.start?.(makeCtx(server));
71+
72+
expect(routes).toContain('GET /api/v1/health');
73+
expect(routes).toContain('GET /api/v1/ready');
74+
});
75+
6276
it('also mounts a known existing route (sanity that start() ran)', async () => {
6377
const { server, routes } = makeFakeServer();
6478
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,22 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
486486
}
487487
});
488488

489+
// ── Readiness ───────────────────────────────────────────────
490+
// Like /health, the dispatcher owns the /ready branch but it is
491+
// only reachable over HTTP once mounted EXPLICITLY here (there is
492+
// no catch-all). 200 while the kernel is `running`, 503 while it is
493+
// booting or shutting down — the contract the EE multi-node
494+
// rolling-restart drain gate polls (cloud ADR-0018) so a load
495+
// balancer stops routing to a replica before it closes.
496+
server.get(`${prefix}/ready`, async (_req: any, res: any) => {
497+
try {
498+
const result = await dispatcher.dispatch('GET', '/ready', undefined, {}, { request: _req });
499+
sendResult(result, res);
500+
} catch (err: any) {
501+
errorResponse(err, res);
502+
}
503+
});
504+
489505
// ── Auth ────────────────────────────────────────────────────
490506
// NOTE: /auth/* wildcard is mounted by AuthProxyPlugin (cloud)
491507
// or AuthPlugin (single-tenant) directly on the raw Hono app —

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)