@@ -6,10 +6,10 @@ import { fileURLToPath } from "node:url";
66
77import { serve } from "@hono/node-server" ;
88
9+ import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk" ;
910import { stringifyConfig } from "@ensnode/ensnode-sdk/internal" ;
10- import type { EnsRainbow } from "@ensnode/ensrainbow-sdk" ;
1111
12- import { buildEnsRainbowPublicConfig } from "@/config/public" ;
12+ import { buildEnsRainbowPublicConfigFromLabelSet } from "@/config/public" ;
1313import type { AbsolutePath , DbConfig , DbSchemaVersion } from "@/config/types" ;
1414import { createApi } from "@/lib/api" ;
1515import { ENSRainbowDB } from "@/lib/database" ;
@@ -50,6 +50,16 @@ export interface EntrypointCommandOptions {
5050 * Tests should pass `false` to avoid leaking handlers across cases.
5151 */
5252 registerSignalHandlers ?: boolean ;
53+ /**
54+ * Hook used to terminate the process on fatal bootstrap errors (download failure or
55+ * env-vs-DB label-set mismatch). Defaults to `process.exit`. Implementations must not
56+ * return normally (same contract as `process.exit`). If a custom hook returns anyway,
57+ * {@link entrypointCommand} calls `process.exit(code)` as a fallback so the process
58+ * cannot keep serving after a fatal bootstrap error. Tests should throw from the hook
59+ * (caught internally) instead of returning, so the test runner is not killed by that
60+ * fallback.
61+ */
62+ exit ?: ( code : number ) => never ;
5363}
5464
5565/**
@@ -58,7 +68,8 @@ export interface EntrypointCommandOptions {
5868export interface EntrypointCommandHandle {
5969 /**
6070 * Resolves when bootstrap finishes or is aborted by shutdown.
61- * Never rejects: non-abort failures terminate the process via `process.exit(1)`.
71+ * Never rejects: non-abort failures terminate the process via `options.exit(1)`
72+ * (defaults to `process.exit(1)`).
6273 */
6374 readonly bootstrapComplete : Promise < void > ;
6475 close ( ) : Promise < void > ;
@@ -87,13 +98,15 @@ export async function entrypointCommand(
8798
8899 const ensRainbowServer = ENSRainbowServer . createPending ( ) ;
89100
90- let cachedPublicConfig : EnsRainbow . ENSRainbowPublicConfig | null = null ;
101+ // Public config from CLI/env so `/v1/config` works before attach; validated against DB after bootstrap.
102+ const argsServerLabelSet : EnsRainbowServerLabelSet = {
103+ labelSetId : options . labelSetId ,
104+ highestLabelSetVersion : options . labelSetVersion ,
105+ } ;
106+ const inMemoryPublicConfig = buildEnsRainbowPublicConfigFromLabelSet ( argsServerLabelSet ) ;
107+
91108 let cachedDbConfig : DbConfig | null = null ;
92- const app = createApi (
93- ensRainbowServer ,
94- ( ) => cachedPublicConfig ,
95- ( ) => cachedDbConfig ,
96- ) ;
109+ const app = createApi ( ensRainbowServer , inMemoryPublicConfig , ( ) => cachedDbConfig ) ;
97110
98111 const httpServer = serve ( {
99112 fetch : app . fetch ,
@@ -172,13 +185,54 @@ export async function entrypointCommand(
172185 process . once ( "SIGINT" , signalHandler ) ;
173186 }
174187
188+ const exit = options . exit ?? ( ( code : number ) => process . exit ( code ) ) ;
189+ let exitRequested = false ;
190+ const requestExit = ( code : number ) => {
191+ exitRequested = true ;
192+ let exitHookThrew = false ;
193+ try {
194+ exit ( code ) ;
195+ } catch ( _error ) {
196+ exitHookThrew = true ;
197+ // Tests may throw from a custom exit hook to short-circuit control flow.
198+ // Swallow to avoid this surfacing as a bootstrap failure.
199+ }
200+ if ( ! exitHookThrew ) {
201+ // TypeScript cannot enforce `never` at runtime; a buggy hook could return and leave
202+ // HTTP up after resolvePromise() + fatal bootstrap — force termination.
203+ logger . error (
204+ new Error ( "ENSRainbow exit hook returned without terminating the process" ) ,
205+ "Exit hook violated non-returning contract; calling process.exit as fallback" ,
206+ ) ;
207+ process . exit ( code ) ;
208+ }
209+ } ;
210+
175211 const bootstrapComplete = new Promise < void > ( ( resolvePromise ) => {
176212 // Defer bootstrap so the HTTP server starts accepting requests first.
177213 setTimeout ( ( ) => {
178214 runDbBootstrap ( options , ensRainbowServer , bootstrapAborter . signal )
179- . then ( ( { publicConfig, dbConfig } ) => {
215+ . then ( ( dbConfig ) => {
216+ if (
217+ dbConfig . serverLabelSet . labelSetId !== argsServerLabelSet . labelSetId ||
218+ dbConfig . serverLabelSet . highestLabelSetVersion !==
219+ argsServerLabelSet . highestLabelSetVersion
220+ ) {
221+ logger . error (
222+ `ENSRainbow database label set ` +
223+ `${ dbConfig . serverLabelSet . labelSetId } @${ dbConfig . serverLabelSet . highestLabelSetVersion } ` +
224+ `does not match the configured ` +
225+ `LABEL_SET_ID=${ argsServerLabelSet . labelSetId } / ` +
226+ `LABEL_SET_VERSION=${ argsServerLabelSet . highestLabelSetVersion } . ` +
227+ `Refusing to serve a misconfigured database; please reconcile the env/CLI ` +
228+ `arguments with the database in the data directory and restart.` ,
229+ ) ;
230+ resolvePromise ( ) ;
231+ requestExit ( 1 ) ;
232+ return ;
233+ }
234+
180235 cachedDbConfig = dbConfig ;
181- cachedPublicConfig = publicConfig ;
182236 logger . info (
183237 "ENSRainbow database bootstrap complete. Service is ready to serve heal requests." ,
184238 ) ;
@@ -190,8 +244,13 @@ export async function entrypointCommand(
190244 resolvePromise ( ) ;
191245 return ;
192246 }
247+ if ( exitRequested ) {
248+ resolvePromise ( ) ;
249+ return ;
250+ }
193251 logger . error ( error , "ENSRainbow database bootstrap failed - exiting" ) ;
194- process . exit ( 1 ) ;
252+ resolvePromise ( ) ;
253+ requestExit ( 1 ) ;
195254 } )
196255 . finally ( ( ) => {
197256 signalBootstrapSettled ( ) ;
@@ -206,13 +265,13 @@ export async function entrypointCommand(
206265 * Idempotent DB bootstrap pipeline.
207266 *
208267 * If marker + DB are present, reuse them; otherwise download + extract.
209- * Returns the public config and DB config for the attached DB.
268+ * Returns the { @link DbConfig} read from the attached DB.
210269 */
211270async function runDbBootstrap (
212271 options : EntrypointCommandOptions ,
213272 ensRainbowServer : ENSRainbowServer ,
214273 signal : AbortSignal ,
215- ) : Promise < { publicConfig : EnsRainbow . ENSRainbowPublicConfig ; dbConfig : DbConfig } > {
274+ ) : Promise < DbConfig > {
216275 const { dataDir, dbSchemaVersion, labelSetId, labelSetVersion } = options ;
217276 const downloadTempDir = options . downloadTempDir ?? join ( dataDir , ".download-temp" ) ;
218277 const markerFile = join ( dataDir , DB_READY_MARKER_FILENAME ) ;
@@ -233,8 +292,7 @@ async function runDbBootstrap(
233292 throwIfAborted ( signal ) ;
234293 await ensRainbowServer . attachDb ( existingDb ) ;
235294 existingDbAttached = true ;
236- const dbConfig = await buildDbConfig ( ensRainbowServer ) ;
237- return { publicConfig : buildEnsRainbowPublicConfig ( dbConfig ) , dbConfig } ;
295+ return await buildDbConfig ( ensRainbowServer ) ;
238296 } catch ( error ) {
239297 // Always release any opened DB handle/lock first, even when aborting. This prevents
240298 // a leaked LevelDB lock when SIGTERM races a non-abort failure (e.g. attachDb throws
@@ -296,8 +354,7 @@ async function runDbBootstrap(
296354 // Write marker only after a successful attach.
297355 await writeFile ( markerFile , "" ) ;
298356
299- const dbConfig = await buildDbConfig ( ensRainbowServer ) ;
300- return { publicConfig : buildEnsRainbowPublicConfig ( dbConfig ) , dbConfig } ;
357+ return await buildDbConfig ( ensRainbowServer ) ;
301358 } catch ( error ) {
302359 if ( ! dbAttached ) {
303360 await safeClose ( db ) ;
0 commit comments