Skip to content

refactor(server): extract createServerLifecycle to eliminate Postgres/PGlite drift (#948)#951

Merged
buremba merged 3 commits into
mainfrom
feat/server-buildapp-consolidation
May 20, 2026
Merged

refactor(server): extract createServerLifecycle to eliminate Postgres/PGlite drift (#948)#951
buremba merged 3 commits into
mainfrom
feat/server-buildapp-consolidation

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 20, 2026

Summary

Extracts a shared createServerLifecycle spine to packages/server/src/server-lifecycle.ts. Both entry files (server.ts Postgres, start-local.ts PGlite) call it with mode-specific hooks. Middleware ordering, route mounts, httpServer timeouts, shutdown sequence, and signal wiring are now identical by construction — drift is structurally impossible.

Mode differences confined to:

  • databaseReadiness hook (Postgres asserts schema; PGlite runs migrations)
  • preListenHooks (PGlite runs install-operator + default-agent provisioning)
  • postListenHooks (Postgres runs connector dep resolvability check)
  • extraTeardown (PGlite kills embeddings child, stops socket, closes PGlite db)

Closes #948.

Reproducer (red→fix→green)

Red: server.ts and start-local.ts diverged twice in one week — #940 mount fix added /lobu mount + Sentry-aware error wiring only to one entry; #943 was a 7-hygiene catch-up patching start-local against server. The same fix was independently written by two parallel agents in #940 and #944.

Green: with the new spine, every middleware/route/timeout/shutdown step lives in one file. The four named hooks are the only escape valves; anything else added to an entry that already has a home in the spine fails the comment-block contract at the top of server.ts and start-local.ts.

Boot smoke verified end-to-end against the bundled PGlite mode:

LOBU_DATA_DIR=/tmp/lobu-pglite-smoke ENCRYPTION_KEY=... PORT=18789 \
  /opt/homebrew/opt/node@22/bin/node packages/server/dist/start-local.bundle.mjs
# → "Lobu running at http://127.0.0.1:18789" with mode=pglite
# SIGINT → graceful shutdown: embedded worker → gateway → DB → socket → close

Validation

  • make build-packages — green (server.bundle.mjs + start-local.bundle.mjs both rebuilt, 0 errors)
  • bun run typecheck — green
  • bunx biome check on the four touched files — green
  • bunx vitest run src/__tests__/server-lifecycle.test.ts — 17/17 passing (covers middleware ordering, env-inject preserving adapter fields, peer-address stash, Sentry 5xx capture vs onError, no double-report, serializeBootError cause chains, plus source-level shutdown-ordering and SIGTERM/SIGINT contracts)
  • PGlite smoke boot: start-local.bundle.mjs reaches "Lobu running at ..." and SIGINT-shuts down cleanly through the documented order

Out of scope

Worker spawn refactor, signup-hang bug (#947), schema/migration changes.

Summary by CodeRabbit

  • New Features

    • Unified server lifecycle for PostgreSQL and PGlite modes; dedicated PGlite local startup path.
    • Optional heap snapshot support (enable with ALLOW_HEAP_SNAPSHOT=1).
    • Conditional mounting of auxiliary dev routes when provided.
  • Improvements

    • Structured boot-failure reporting and simplified boot error handling.
    • Env injection preserves existing adapter fields and peer address; improved post-response error reporting and single-flight graceful shutdown ordering.
  • Tests

    • Comprehensive contract tests for lifecycle, error serialization, mounting, and reporting flows.

Review Change Stack

…/PGlite drift (#948)

Both entries (server.ts, start-local.ts) now call a shared lifecycle spine
in server-lifecycle.ts. Middleware ordering, route mounts, httpServer
timeouts, shutdown sequence, and signal wiring are identical by construction.
Mode differences are confined to four named hooks (databaseReadiness,
preListenHooks, postListenHooks, extraTeardown) and per-mode resource
construction in the entry files.

Closes #948.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

📝 Walkthrough

Walkthrough

This pull request consolidates server startup/shutdown, middleware, and HTTP configuration into a shared createServerLifecycle() API; refactors server.ts and start-local.ts to delegate mode-specific boot steps (database readiness, migrations, embeddings) into that lifecycle; and adds contract tests for error serialization, wrapper app behavior, and lifecycle invariants.

Changes

Shared Server Lifecycle Consolidation

Layer / File(s) Summary
Lifecycle types, error handling, and boot contracts
packages/server/src/server-lifecycle.ts
Exports lifecycle types and adds serializeBootError() (normalizes Error/cause/issues into a plain object), reportBootFailure() (logs structured + plaintext and exits 1), and applyDevProjectPathDefault() to set LOBU_DEV_PROJECT_PATH when absent.
Hono wrapper app construction and lifecycle engine
packages/server/src/server-lifecycle.ts
Implements buildWrapperApp() with ordered middleware: stash peer remote address, inject env into c.env while preserving adapter fields, app.onError Sentry capture, and post-response 5xx reporting; mounts /lobu conditionally and / to main app. Adds maybeWireHeapSnapshot() (gated by ALLOW_HEAP_SNAPSHOT=1) and createServerLifecycle() returning start() that boots in sequence (DB readiness → workspace provider → gateway → scheduler → reaper → HTTP server setup with keep-alive/header timeouts → optional Vite → preListen hooks → listen → embedded worker start) and a centralized, ordered shutdown (stop worker → close Vite → stop reaper/scheduler → stop gateway → close DB → run extraTeardown hooks → close HTTP server → exit), with SIGTERM/SIGINT handlers wired.
PostgreSQL server entrypoint refactoring
packages/server/src/server.ts
Replaces in-file Hono/HTTP wiring with lifecycle usage: validate DATABASE_URL, derive env/host/port, define databaseReadiness (gated schema check and LISTEN/NOTIFY probe), construct createServerLifecycle({ mode: "postgres", ... }) and await lifecycle.start(); boot failures routed through reportBootFailure().
PGlite server entrypoint refactoring
packages/server/src/start-local.ts
Refactors to PGlite boot: start PGlite and socket server, set DATABASE_URL, start embeddings child, dynamically import server-lifecycle, build lifecycle in mode: "pglite" with migrations as readiness and preListenHooks for provisioning, provide extraTeardown to stop embeddings/socket/PGlite, and start lifecycle with failures via reportBootFailure(). Keeps migrations and embeddings helpers in-file.
Lifecycle contract validation tests
packages/server/src/__tests__/server-lifecycle.test.ts
Adds tests mocking Sentry and logger; validates serializeBootError (Error, cause chains, Zod-like issues, non-object inputs), buildWrapperApp (route mounting, env injection and adapter-field preservation, peer-address stashing order, Sentry captureMessage vs captureException, no double-reporting), and source-level invariants in createServerLifecycle (HTTP timeout constants, init/shutdown ordering, worker start placement, SIGTERM/SIGINT registration).

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant WrapperApp
  participant Database
  participant WorkspaceProvider
  participant LobuGateway
  participant Scheduler
  participant Reaper
  participant HTTPServer
  participant EmbeddedWorker

  Client->>WrapperApp: HTTP request
  WrapperApp->>HTTPServer: dispatch to mounted apps (/ or /lobu)
  Note over WrapperApp,HTTPServer: wrapper middleware stashes peer addr, injects env, handles errors
  WrapperApp->>LobuGateway: route to gateway (when mounted)
  alt Startup flow (createServerLifecycle.start)
    createServerLifecycle->>Database: run databaseReadiness checks
    createServerLifecycle->>WorkspaceProvider: init
    createServerLifecycle->>LobuGateway: init/start
    createServerLifecycle->>Scheduler: start
    createServerLifecycle->>Reaper: start
    createServerLifecycle->>HTTPServer: create + listen (keep-alive/header timeouts)
    createServerLifecycle->>EmbeddedWorker: start after listen/postListenHooks
  end
  alt Shutdown flow (SIGTERM/SIGINT)
    createServerLifecycle->>EmbeddedWorker: stop
    createServerLifecycle->>Reaper: stop
    createServerLifecycle->>Scheduler: stop
    createServerLifecycle->>LobuGateway: stop
    createServerLifecycle->>Database: close
    createServerLifecycle->>HTTPServer: close
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • lobu-ai/lobu#943: Closely related consolidation of boot-error reporting and lifecycle ordering that overlaps with server-lifecycle changes.
  • lobu-ai/lobu#839: Both modify start-local boot-time env derivation (DATABASE_URL/PORT/HOST/dataDir) used before lifecycle wiring.
  • lobu-ai/lobu#767: Both add database readiness/schema-version gating before server start and expose similar readiness checks.

Poem

🐰 I stitched a spine of start and end,
Routes mount steady, errors we mend,
Env and peer saved in tidy rows,
Workers wake when the listener glows,
A bunny hops — the lifecycle goes.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main refactoring: extracting a shared server lifecycle implementation to eliminate code drift between Postgres and PGlite entry points.
Description check ✅ Passed The description comprehensively covers the refactoring's motivation, scope, implementation approach, validation steps, and explicitly marks out-of-scope items; however, the Test plan section is incomplete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/server-buildapp-consolidation

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@buremba buremba added the skip-size-check Bypass PR size gate for intentionally large single-concern changes label May 20, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/server/src/server-lifecycle.ts (1)

407-412: ⚡ Quick win

Add re-entrancy guard to prevent concurrent shutdown.

If SIGINT arrives while a SIGTERM-triggered shutdown is in progress, shutdown() will be invoked a second time. This could cause double-closes on resources (logging confusing errors) or race conditions in the teardown sequence. While process.exit(0) terminates before the second call completes, a guard improves shutdown clarity.

Proposed fix
 		// 9. Shutdown wiring — declared once, called from both SIGTERM and SIGINT.
 		// Embedded worker handle is captured in the listen callback below.
 		let embeddedWorker: ReturnType<typeof startEmbeddedConnectorWorker> = null;
+		let shuttingDown = false;

 		const shutdown = async (signal: string): Promise<void> => {
+			if (shuttingDown) {
+				logger.info({ signal }, "Shutdown already in progress, ignoring duplicate signal");
+				return;
+			}
+			shuttingDown = true;
 			logger.info(
 				{ signal, mode },
 				"Received shutdown signal, stopping gracefully...",
 			);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/server-lifecycle.ts` around lines 407 - 412, Add a
re-entrancy guard so concurrent signal handlers cannot invoke shutdown() twice:
introduce a module-level boolean/atomic flag (e.g., isShuttingDown) checked at
the start of shutdown() and set to true before any async teardown begins; update
the process.on("SIGTERM") and process.on("SIGINT") handlers to call shutdown()
as before but rely on the guard to no-op if shutdown is already in progress;
ensure the flag is set synchronously before awaiting any async operations and
left true until teardown completes (or until you explicitly log a skipped
duplicate call).
packages/server/src/start-local.ts (1)

202-202: 💤 Low value

personalOrgId is declared but only used within a single hook closure.

The variable personalOrgId at line 202 is declared in the outer scope but is only assigned and used within the second preListenHooks closure (lines 230-248). It could be scoped locally within that hook.

♻️ Suggested refactor
-	// Personal-org id for default-agent provisioning. Resolved once during the
-	// pre-listen phase rather than per-call, so the dynamic postgres import
-	// happens with a hot DATABASE_URL.
-	let personalOrgId: string | null = null;
-
 	const lifecycle = createServerLifecycle({
 		mode: "pglite",
 		env,
 		host: HOST,
 		port: PORT,
 		databaseReadiness: () => runMigrations(dbUrl),
 		preListenHooks: [
 			// ... first hook unchanged ...
 			async () => {
 				try {
 					const personalOrgRows = (await import("postgres")).default(dbUrl, {
 						max: 1,
 					});
 					try {
 						const rows = (await personalOrgRows`...`) as unknown as Array<{ id: string }>;
-						personalOrgId = rows[0]?.id ?? null;
+						const personalOrgId = rows[0]?.id ?? null;
 						if (personalOrgId) await ensureDefaultAgent(personalOrgId);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/start-local.ts` at line 202, The variable personalOrgId
is declared in outer scope but only assigned/used inside the second
preListenHooks closure; move its declaration into that closure (remove the outer
let personalOrgId) so personalOrgId is a local variable inside the function
passed to preListenHooks where it is set and referenced, updating any references
inside that closure to the new local variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/server/src/server-lifecycle.ts`:
- Around line 407-412: Add a re-entrancy guard so concurrent signal handlers
cannot invoke shutdown() twice: introduce a module-level boolean/atomic flag
(e.g., isShuttingDown) checked at the start of shutdown() and set to true before
any async teardown begins; update the process.on("SIGTERM") and
process.on("SIGINT") handlers to call shutdown() as before but rely on the guard
to no-op if shutdown is already in progress; ensure the flag is set
synchronously before awaiting any async operations and left true until teardown
completes (or until you explicitly log a skipped duplicate call).

In `@packages/server/src/start-local.ts`:
- Line 202: The variable personalOrgId is declared in outer scope but only
assigned/used inside the second preListenHooks closure; move its declaration
into that closure (remove the outer let personalOrgId) so personalOrgId is a
local variable inside the function passed to preListenHooks where it is set and
referenced, updating any references inside that closure to the new local
variable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 105a8fd3-d304-46d2-8079-ece13dcaec8f

📥 Commits

Reviewing files that changed from the base of the PR and between 8695c57 and 09a6a15.

📒 Files selected for processing (4)
  • packages/server/src/__tests__/server-lifecycle.test.ts
  • packages/server/src/server-lifecycle.ts
  • packages/server/src/server.ts
  • packages/server/src/start-local.ts

…dings:Env}>

Per-package tsc (cd packages/server && bunx tsc --noEmit) rejects the
contravariant assignment of a typed mainApp to a parameter declared as
plain Hono. The root bun run typecheck is permissive enough to miss
this; CI runs both.
@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/server/src/server-lifecycle.ts (1)

373-406: 💤 Low value

Consider defensive error handling in shutdown sequence.

If any step in the shutdown sequence throws (e.g., stopLobuGateway() or embeddedWorker.wait()), subsequent cleanup won't run—closeDbSingleton(), extraTeardown hooks, and httpServer.close() could all be skipped. Since signal handlers use void shutdown(...), errors become unhandled rejections.

Given this is a shutdown path, the process will likely exit anyway, but wrapping each step or using a best-effort cleanup pattern would improve resilience.

🔧 Optional: best-effort shutdown pattern
 const shutdown = async (signal: string): Promise<void> => {
   logger.info(
     { signal, mode },
     "Received shutdown signal, stopping gracefully...",
   );
-  // Order matters:
-  //   a. Stop accepting new work from the embedded connector worker.
-  if (embeddedWorker) {
-    embeddedWorker.stop();
-    await embeddedWorker.wait(15_000);
+  const safeRun = async (label: string, fn: () => Promise<void> | void) => {
+    try {
+      await fn();
+    } catch (err) {
+      logger.error({ err, label }, "Shutdown step failed, continuing...");
+    }
+  };
+
+  if (embeddedWorker) {
+    await safeRun("embeddedWorker.stop", () => {
+      embeddedWorker!.stop();
+      return embeddedWorker!.wait(15_000);
+    });
   }
-  //   b. Close Vite (HMR sockets) before tearing down the http server
-  //      so dev-mode listeners detach cleanly.
-  await vite?.close();
-  //   c. Stop the reaper poll loop.
-  stopReaper();
-  //   d. Stop the task scheduler dispatch loop.
-  taskScheduler.stop();
-  //   e. Drain MCP sessions / DB listeners / secret-proxy. Gateway
-  //      holds postgres.js connections that must be released before
-  //      mode-specific db teardown runs.
-  await stopLobuGateway();
-  //   f. Close the postgres.js singleton pool.
-  await closeDbSingleton();
-  //   g. Mode-specific teardown (PGlite kills embeddings child, stops
-  //      socket server, closes the in-process db).
+  await safeRun("vite.close", () => vite?.close() ?? Promise.resolve());
+  safeRun("stopReaper", stopReaper);
+  safeRun("taskScheduler.stop", () => taskScheduler.stop());
+  await safeRun("stopLobuGateway", stopLobuGateway);
+  await safeRun("closeDbSingleton", closeDbSingleton);
   for (const teardown of extraTeardown) {
-    await teardown();
+    await safeRun("extraTeardown", teardown);
   }
-  //   h. Finally, close the listener. Matches the historical behavior of
-  //      not awaiting (server.ts:260; start-local.ts:322).
   httpServer.close();
   process.exit(0);
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/server-lifecycle.ts` around lines 373 - 406, The shutdown
sequence in shutdown(...) can abort if any awaited step throws, skipping later
cleanup and creating unhandled rejections; change it to a best-effort pattern:
wrap each major step (embeddedWorker.stop()/embeddedWorker.wait(...),
vite?.close(), stopReaper(), taskScheduler.stop(), stopLobuGateway(),
closeDbSingleton(), and each extraTeardown() call) in its own try/catch that
logs the error (use logger.error with context) and continues, ensuring
httpServer.close() and process.exit(0) always run in a finally-style guarantee
so the listener is closed and the process exits even if earlier steps fail.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/server/src/server-lifecycle.ts`:
- Around line 373-406: The shutdown sequence in shutdown(...) can abort if any
awaited step throws, skipping later cleanup and creating unhandled rejections;
change it to a best-effort pattern: wrap each major step
(embeddedWorker.stop()/embeddedWorker.wait(...), vite?.close(), stopReaper(),
taskScheduler.stop(), stopLobuGateway(), closeDbSingleton(), and each
extraTeardown() call) in its own try/catch that logs the error (use logger.error
with context) and continues, ensuring httpServer.close() and process.exit(0)
always run in a finally-style guarantee so the listener is closed and the
process exits even if earlier steps fail.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cfbc57ff-3d28-49cb-ab11-baf27c25a357

📥 Commits

Reviewing files that changed from the base of the PR and between 09a6a15 and 02f66b2.

📒 Files selected for processing (1)
  • packages/server/src/server-lifecycle.ts

…down safety

1. PGlite was missing assertExternalDepsResolvable() in postListenHooks;
   adding it back closes the exact drift this refactor exists to prevent.
   Caught by pi review on #951.
2. Wrap every shutdown step in a safe() helper so one rejecting teardown
   (e.g. stopLobuGateway) can no longer block the rest from running and
   leave the process stuck with a bound listener.
3. Single-flight guard on the shutdown handler: SIGTERM+SIGINT (or a
   double-tap on either) used to race gateway-stop / extraTeardown /
   process.exit. The guard short-circuits the second entry.

Strengthens the lifecycle contract test with assertions for the safe()
wrapper count and the single-flight guard so future refactors can't
silently re-introduce either gap.
@buremba
Copy link
Copy Markdown
Member Author

buremba commented May 20, 2026

pi review applied

pi -p 951 flagged 5 items. Triage:

Applied as fixes (commit 4b76d89):

  1. PGlite was missing assertExternalDepsResolvable() in postListenHooks — exact drift this refactor exists to prevent. Now wired in start-local.ts.
  2. Shutdown not bullet-proof — every step now wrapped in a safe() helper so one rejecting teardown (e.g. stopLobuGateway()) can no longer block the rest, leaving the process pinned with a bound listener.
  3. Concurrent SIGTERM+SIGINT — added a single-flight guard so a double-tap or both signals arriving in close succession short-circuits the second entry.

Contract test strengthened with:

  • assertion that ≥7 steps are wrapped in safe(...)
  • assertion that the single-flight guard is present

Rejected:

  1. "PGlite migrations no longer happen before app/gateway module evaluation" — pi misread. await import('./server-lifecycle') only evaluates that module's static imports (./index, gateway, scheduler). Those modules define functions; they don't execute DB queries at module load. databaseReadiness (which calls runMigrations) is invoked inside lifecycle.start() as the first action, before initWorkspaceProvider / initLobuGateway / scheduler boot. The dynamic-import boundary in start-local.ts:193 correctly ensures env mutation lands first; the migrations-then-DB-touch ordering is preserved.

  2. "Middleware ordering not exactly both originals" — by design. Per codex pre-review, the divergence between the two original strategies (c.env = env vs Object.assign(c.env, env)) is the actual bug — Postgres mode was silently dropping Hono adapter fields. Picking Object.assign is the fix, not a regression. Pi itself flagged this "not a blocker."

Validation: 19/19 lifecycle contract tests pass; per-package tsc --noEmit clean; root bun run typecheck clean.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/server/src/server-lifecycle.ts (1)

449-465: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject listen-phase failures instead of bypassing boot error handling.

start() only resolves here. A bind failure from httpServer.listen() (e.g., EADDRINUSE) will emit an unhandled 'error' event and crash the process, bypassing main().catch(reportBootFailure). Similarly, synchronous throws from postListenHooks or startEmbeddedConnectorWorker() inside the callback will not reject the Promise.

Suggested fix
-		await new Promise<void>((resolve) => {
-			httpServer.listen(port, host, () => {
-				logger.info(
-					{ host, port, mode },
-					`Lobu running at http://${host}:${port}`,
-				);
-				for (const hook of postListenHooks) {
-					hook();
-				}
-				const daemonHost = host === "0.0.0.0" ? "127.0.0.1" : host;
-				embeddedWorker = startEmbeddedConnectorWorker(
-					env,
-					`http://${daemonHost}:${port}`,
-				);
-				resolve();
-			});
-		});
+		await new Promise<void>((resolve, reject) => {
+			const onListenError = (err: Error) => {
+				httpServer.off("error", onListenError);
+				reject(err);
+			};
+			httpServer.once("error", onListenError);
+			httpServer.listen(port, host, () => {
+				httpServer.off("error", onListenError);
+				try {
+					logger.info(
+						{ host, port, mode },
+						`Lobu running at http://${host}:${port}`,
+					);
+					for (const hook of postListenHooks) {
+						hook();
+					}
+					const daemonHost = host === "0.0.0.0" ? "127.0.0.1" : host;
+					embeddedWorker = startEmbeddedConnectorWorker(
+						env,
+						`http://${daemonHost}:${port}`,
+					);
+					resolve();
+				} catch (err) {
+					httpServer.close();
+					reject(err);
+				}
+			});
+		});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/server-lifecycle.ts` around lines 449 - 465, The current
Promise around httpServer.listen never rejects on listen-phase failures or
synchronous throws inside the listen callback; add rejection handling by
attaching a one-time httpServer.once('error', (err) => reject(err)) before
calling httpServer.listen, and remove that listener (or use httpServer.off) when
the listen callback runs successfully; also wrap the postListenHooks loop and
the call to startEmbeddedConnectorWorker(...) in a try/catch and call
reject(err) on any thrown error (or resolve only after successful completion and
assignment to embeddedWorker) so that start() rejects on bind or startup
failures instead of letting errors become unhandled.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/server/src/start-local.ts`:
- Line 55: The post-listen dependency check currently calls process.exit(1)
directly (see assertExternalDepsResolvable usage), which bypasses the shared
shutdown lifecycle and skips extraTeardown; instead, make
assertExternalDepsResolvable throw an Error (or propagate its thrown error) and
remove any direct process.exit(1) calls in the postListenHook so that
createServerLifecycle() can detect the thrown error and run the centralized
teardown path. Update the postListenHook that calls assertExternalDepsResolvable
and the lifecycle boot handling in createServerLifecycle() to treat a thrown
error from postListenHook as a fatal boot error and invoke the shared
shutdown/extraTeardown rather than exiting inline.

---

Outside diff comments:
In `@packages/server/src/server-lifecycle.ts`:
- Around line 449-465: The current Promise around httpServer.listen never
rejects on listen-phase failures or synchronous throws inside the listen
callback; add rejection handling by attaching a one-time
httpServer.once('error', (err) => reject(err)) before calling httpServer.listen,
and remove that listener (or use httpServer.off) when the listen callback runs
successfully; also wrap the postListenHooks loop and the call to
startEmbeddedConnectorWorker(...) in a try/catch and call reject(err) on any
thrown error (or resolve only after successful completion and assignment to
embeddedWorker) so that start() rejects on bind or startup failures instead of
letting errors become unhandled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0a3d21f5-2732-42d0-aa09-f0210387f60c

📥 Commits

Reviewing files that changed from the base of the PR and between 02f66b2 and 4b76d89.

📒 Files selected for processing (3)
  • packages/server/src/__tests__/server-lifecycle.test.ts
  • packages/server/src/server-lifecycle.ts
  • packages/server/src/start-local.ts

import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
import { vector } from "@electric-sql/pglite/vector";
import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
import { assertExternalDepsResolvable } from "@lobu/connector-worker/compile";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't process.exit(1) from the post-listen dependency check.

Line 264 bypasses the shared shutdown path, so extraTeardown never runs when this check fails. In PGlite mode that can orphan the embeddings child and skip socket/DB cleanup on a failed boot. Let the lifecycle own the fatal path instead of exiting inline.

Suggested direction
 postListenHooks: [
 	() => {
 		try {
 			assertExternalDepsResolvable(require.resolve);
 		} catch (err) {
 			logger.error({ err }, "Connector external dependency check failed");
-			process.exit(1);
+			throw err;
 		}
 	},
 ],

If createServerLifecycle() does not already treat a thrown postListenHook as a fatal boot error with teardown, that behavior should live there rather than in this hook.

Also applies to: 252-267

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/start-local.ts` at line 55, The post-listen dependency
check currently calls process.exit(1) directly (see assertExternalDepsResolvable
usage), which bypasses the shared shutdown lifecycle and skips extraTeardown;
instead, make assertExternalDepsResolvable throw an Error (or propagate its
thrown error) and remove any direct process.exit(1) calls in the postListenHook
so that createServerLifecycle() can detect the thrown error and run the
centralized teardown path. Update the postListenHook that calls
assertExternalDepsResolvable and the lifecycle boot handling in
createServerLifecycle() to treat a thrown error from postListenHook as a fatal
boot error and invoke the shared shutdown/extraTeardown rather than exiting
inline.

@buremba buremba added skip-size-check Bypass PR size gate for intentionally large single-concern changes and removed skip-size-check Bypass PR size gate for intentionally large single-concern changes labels May 20, 2026
@buremba buremba merged commit fcc99b7 into main May 20, 2026
19 of 20 checks passed
@buremba buremba deleted the feat/server-buildapp-consolidation branch May 20, 2026 01:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-size-check Bypass PR size gate for intentionally large single-concern changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor: extract buildApp(deps) to eliminate server.ts vs start-local.ts drift

2 participants