From 1506beb78dae3938f63c83c729574c4f78b0f0b0 Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Wed, 15 Jul 2026 00:25:56 -0700 Subject: [PATCH] Fix HMR breaking after vite server restarts --- .changeset/vite-restart-session-handoff.md | 7 ++ packages/houdini/src/lib/codegen.ts | 15 ++- packages/houdini/src/vite/hmr.test.ts | 111 +++++++++++++++++++++ packages/houdini/src/vite/hmr.ts | 62 +++++++++--- packages/houdini/src/vite/houdini.ts | 6 ++ packages/houdini/src/vite/index.ts | 12 ++- packages/houdini/src/vite/schema.ts | 11 +- packages/houdini/src/vite/session.ts | 63 ++++++++++++ plugins/run.go | 16 ++- 9 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 .changeset/vite-restart-session-handoff.md create mode 100644 packages/houdini/src/vite/hmr.test.ts create mode 100644 packages/houdini/src/vite/session.ts diff --git a/.changeset/vite-restart-session-handoff.md b/.changeset/vite-restart-session-handoff.md new file mode 100644 index 000000000..da056634b --- /dev/null +++ b/.changeset/vite-restart-session-handoff.md @@ -0,0 +1,7 @@ +--- +"houdini": patch +"houdini-core": patch +"houdini-svelte": patch +--- + +Fix HMR breaking after a vite server restart ("database is not open" or unreachable plugins until the dev server was manually restarted). diff --git a/packages/houdini/src/lib/codegen.ts b/packages/houdini/src/lib/codegen.ts index 074005e7e..379ea7c1f 100644 --- a/packages/houdini/src/lib/codegen.ts +++ b/packages/houdini/src/lib/codegen.ts @@ -332,10 +332,19 @@ export async function codegen_setup( spec_results[name] = spec // WebSocket plugins insert themselves into the DB; for stdio plugins (port=0) - // we do it here. INSERT OR IGNORE avoids a duplicate-key error either way. + // we do it here. Upsert so a row left by a predecessor session is + // refreshed instead of silently kept stale. _db.run( - `INSERT OR IGNORE INTO plugins (name, hooks, port, plugin_order, include_runtime, include_static_runtime, config_module, client_plugins) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO plugins (name, hooks, port, plugin_order, include_runtime, include_static_runtime, config_module, client_plugins) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + hooks = excluded.hooks, + port = excluded.port, + plugin_order = excluded.plugin_order, + include_runtime = excluded.include_runtime, + include_static_runtime = excluded.include_static_runtime, + config_module = excluded.config_module, + client_plugins = excluded.client_plugins`, [ spec.name, JSON.stringify([...spec.hooks]), diff --git a/packages/houdini/src/vite/hmr.test.ts b/packages/houdini/src/vite/hmr.test.ts new file mode 100644 index 000000000..620a5313c --- /dev/null +++ b/packages/houdini/src/vite/hmr.test.ts @@ -0,0 +1,111 @@ +import { EventEmitter } from 'node:events' +import { test, expect, vi } from 'vitest' + +import { document_hmr } from './hmr.js' +import { dispose_active_session } from './session.js' + +// codegen_setup spawns real plugin processes and get_config reads the project config; +// mock both so the test only exercises the plugin's compiler lifecycle wiring. +vi.mock('../lib/index.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + get_config: vi.fn(async () => ({ config_file: {} })), + codegen_setup: vi.fn(async () => fake_compiler()), + } +}) + +function fake_compiler() { + return { + close: vi.fn(async () => {}), + run_pipeline: vi.fn(async () => ({})), + trigger_hook: vi.fn(async () => ({})), + pipeline_lock: vi.fn((fn: () => Promise) => fn()), + database_path: '', + } +} + +function fake_ctx(db_file = '/tmp/houdini-test.db') { + return { + config: { config_file: {} }, + db: { get: () => ({ count: 0 }) }, + db_file, + } as any +} + +function fake_server() { + return { + config: { root: '/project' }, + httpServer: new EventEmitter(), + } as any +} + +// Vite restarts re-run the config file (fresh plugin instance and context per server) +// but create the replacement server *before* closing the old one. The old server's +// close handler must tear down only its own compiler — a shared module-level reference +// would point at the replacement's compiler by then and close its database out from +// under it ("database is not open" on every subsequent HMR run). +test('a vite restart does not close the replacement compiler', async () => { + const oldCtx = fake_ctx() + const newCtx = fake_ctx() + const oldPlugin: any = document_hmr(oldCtx) + const newPlugin: any = document_hmr(newCtx) + + const oldServer = fake_server() + const newServer = fake_server() + await oldPlugin.configureServer(oldServer) + // capture before the replacement configures: its session handoff disposes + // the old session and clears the old context's reference + const oldCompiler = oldCtx.compiler + await newPlugin.configureServer(newServer) + + const newCompiler = newCtx.compiler + expect(oldCompiler).toBeDefined() + expect(newCompiler).toBeDefined() + expect(oldCompiler).not.toBe(newCompiler) + + // the old server closes after the replacement is already configured + oldServer.httpServer.emit('close') + + expect(oldCompiler.close).toHaveBeenCalledTimes(1) + expect(newCompiler.close).not.toHaveBeenCalled() + // the closed server's reference is cleared so pending debounce work no-ops + // instead of running against a closed database + expect(oldCtx.compiler).toBeUndefined() + expect(newCtx.compiler).toBe(newCompiler) +}) + +// The replacement session must be able to tear down its predecessor *before* +// recreating the database and spawning its own plugin processes — otherwise the +// two sessions race on plugin registration and the survivor can end up dialing +// dead plugin ports. houdini.ts's configResolved performs this handoff through +// dispose_active_session; the old server's eventual close must then be a no-op. +test('a replacement session disposes its predecessor before taking over', async () => { + const dbFile = '/tmp/houdini-handoff-test.db' + const oldCtx = fake_ctx(dbFile) + const oldPlugin: any = document_hmr(oldCtx) + const oldServer = fake_server() + await oldPlugin.configureServer(oldServer) + const oldCompiler = oldCtx.compiler + + // the replacement generation runs the handoff before init_db recreates the file + await dispose_active_session(dbFile) + expect(oldCompiler.close).toHaveBeenCalledTimes(1) + expect(oldCtx.compiler).toBeUndefined() + + // then it configures its own session + const newCtx = fake_ctx(dbFile) + const newPlugin: any = document_hmr(newCtx) + const newServer = fake_server() + await newPlugin.configureServer(newServer) + const newCompiler = newCtx.compiler + expect(newCompiler).toBeDefined() + + // when vite finally closes the old server, the handoff already happened — + // its close handler must not tear anything down again + oldServer.httpServer.emit('close') + await new Promise((resolve) => setImmediate(resolve)) + expect(oldCompiler.close).toHaveBeenCalledTimes(1) + expect(newCompiler.close).not.toHaveBeenCalled() + expect(newCtx.compiler).toBe(newCompiler) +}) diff --git a/packages/houdini/src/vite/hmr.ts b/packages/houdini/src/vite/hmr.ts index bd83646f9..0b605d14c 100644 --- a/packages/houdini/src/vite/hmr.ts +++ b/packages/houdini/src/vite/hmr.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from 'fs' import type { HmrContext, Plugin as VitePlugin } from 'vite' import { type CompilerProxy, codegen_setup, get_config, path, run_pipeline } from '../lib/index.js' import type { VitePluginContext } from './index.js' +import { dispose_active_session, register_session } from './session.js' /** * Houdini Vite HMR Plugin @@ -19,10 +20,13 @@ import type { VitePluginContext } from './index.js' * the dependency graph to ensure all related documents are properly regenerated. */ -export let compiler: CompilerProxy - export function document_hmr(ctx: VitePluginContext): VitePlugin { const debounceHmr = createDebounceHmr(50) // 50ms debounce window + // Scoped to the plugin instance, never module-level: on a vite restart the + // replacement server runs configureServer before the old server closes, so a + // module-level binding would let the old server's close handler shut down the + // replacement's compiler (and its database connection). + let compiler: CompilerProxy | undefined let config: Awaited> // Tracks files we re-wrote via writeFileSync so we can skip the resulting // hotUpdate events. Uses timestamps instead of a Set because Vite 8 fires @@ -112,8 +116,18 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { async configureServer(server) { config = await get_config() - // and a proxy to talk to the compiler - compiler = await codegen_setup(config, 'dev', ctx.db, ctx.db_file) + // if a previous session still owns the database (configResolved skips the + // handoff when the plugin context is reused), dispose it before spawning + // our own plugin processes + await dispose_active_session(ctx.db_file) + + // and a proxy to talk to the compiler. capture it in a local so the + // teardown below always closes the compiler this server created, even if + // the instance-level reference has moved on to a replacement. + const ownedCompiler = await codegen_setup(config, 'dev', ctx.db, ctx.db_file) + compiler = ownedCompiler + // share it with the sibling schema plugins (they get the same ctx object) + ctx.compiler = ownedCompiler generatedDir = path.join( server.config.root, @@ -123,14 +137,31 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { ? server.config.root : `${server.config.root}/` - // and make sure the compiler cleans up gracefully when the http server dies + // register the session so the replacement server can tear it down before + // recreating the database (a vite restart configures the new server while + // this one is still running) + const dispose_session = register_session(ctx.db_file, async () => { + // clear the live references only if they still point at this server's + // compiler so pending debounce work no-ops instead of hitting a closed db + if (compiler === ownedCompiler) { + compiler = undefined + } + if (ctx.compiler === ownedCompiler) { + ctx.compiler = undefined + } + await ownedCompiler.close() + }) + + // and make sure the compiler cleans up gracefully when the http server dies. + // the disposer runs at most once, so this is a no-op if a replacement + // session already performed the handoff. server.httpServer?.once('close', () => { - compiler.close() + dispose_session() }) // before we do anyting we neeed to make sure everything has run try { - await compiler.run_pipeline({ + await ownedCompiler.run_pipeline({ // the pipeline through schema is run as part of codegen_setup after: 'Schema', }) @@ -239,9 +270,12 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { deletedFiles: string[], task_id: string ) { - // hotUpdate can fire before configureServer completes during startup - if (!compiler) return - await compiler.pipeline_lock(async () => { + // hotUpdate can fire before configureServer completes during startup, + // or after the server closed (the close handler clears the reference). + // capture a local so the whole batch runs against one compiler instance. + const batchCompiler = compiler + if (!batchCompiler) return + await batchCompiler.pipeline_lock(async () => { // Remove DB entries for deleted files first so extraction and cleanup // see a consistent state for both deletions and updates in the same batch. if (deletedFiles.length > 0) { @@ -319,7 +353,7 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { `) }) try { - const results = await run_pipeline(compiler.trigger_hook, { + const results = await run_pipeline(batchCompiler.trigger_hook, { after: 'AfterExtract', }) const updated_modules = [ @@ -389,7 +423,7 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { // trigger_hook flushes before Go runs and reloads after try { - await compiler.trigger_hook('ExtractDocuments', { + await batchCompiler.trigger_hook('ExtractDocuments', { payload: { filepaths }, }) } catch (err) { @@ -429,7 +463,7 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { } // trigger_hook handles flush before AfterExtract and reload after - await compiler.trigger_hook('AfterExtract', { task_id }) + await batchCompiler.trigger_hook('AfterExtract', { task_id }) // walk the dependency graph and include transitive dependencies in the task ctx.db.run( @@ -475,7 +509,7 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin { // BeforeValidate → Validate → AfterValidate → GenerateDocuments → GenerateRuntime let results: Awaited> try { - results = await run_pipeline(compiler.trigger_hook, { + results = await run_pipeline(batchCompiler.trigger_hook, { task_id, after: 'AfterExtract', }) diff --git a/packages/houdini/src/vite/houdini.ts b/packages/houdini/src/vite/houdini.ts index 6d2c4d606..b320edd81 100644 --- a/packages/houdini/src/vite/houdini.ts +++ b/packages/houdini/src/vite/houdini.ts @@ -5,6 +5,7 @@ import type { VitePluginContext } from './index.js' import { codegen_setup, init_db } from '../lib/codegen.js' import * as fs from '../lib/fs.js' import type { CompilerProxy } from '../lib/index.js' +import { dispose_active_session } from './session.js' export let compiler: CompilerProxy let alreadyBuilt = false @@ -37,6 +38,11 @@ export function houdini(ctx: VitePluginContext): VitePlugin { // open the orchestration DB lazily (the plugin's default export no longer does this // eagerly, so that the worker check above can run first) if (!ctx.db) { + // a vite restart resolves the replacement server's config while the old + // server is still running. tear the previous session down first so its + // database connection and plugin processes are gone before init_db + // recreates the file they were holding. + await dispose_active_session(ctx.db_file) const [db] = await init_db(ctx.config, false) ctx.db = db } diff --git a/packages/houdini/src/vite/index.ts b/packages/houdini/src/vite/index.ts index a21720583..84455224b 100644 --- a/packages/houdini/src/vite/index.ts +++ b/packages/houdini/src/vite/index.ts @@ -5,7 +5,13 @@ import type { Db } from '../lib/db.js' import { pathToFileURL } from 'node:url' import type { PluginOption } from 'vite' -import { get_config, type Adapter, type ConfigFile, type Config } from '../lib/index.js' +import { + get_config, + type Adapter, + type CompilerProxy, + type ConfigFile, + type Config, +} from '../lib/index.js' import { db_path } from '../router/conventions.js' import { document_hmr } from './hmr.js' import { houdini } from './houdini.js' @@ -20,6 +26,10 @@ export type VitePluginContext = PluginConfig & { db: Db db_file: string config: Config + // the dev-server compiler, assigned in document_hmr's configureServer. Lives on the + // shared context (not module state) so a vite restart — which re-runs the plugin + // factory and creates a fresh context — can't leak one server's compiler into another. + compiler?: CompilerProxy } export default async function (opts?: PluginConfig): Promise> { diff --git a/packages/houdini/src/vite/schema.ts b/packages/houdini/src/vite/schema.ts index 93d968554..5c16238e8 100644 --- a/packages/houdini/src/vite/schema.ts +++ b/packages/houdini/src/vite/schema.ts @@ -5,7 +5,6 @@ import type { ModuleNode, PluginOption } from 'vite' import { fs, get_config, run_pipeline } from '../lib/index.js' import { pull_schema } from '../lib/schema.js' import { sleep } from '../lib/sleep.js' -import { compiler } from './hmr.js' import type { VitePluginContext } from './index.js' /* @@ -19,7 +18,7 @@ import type { VitePluginContext } from './index.js' // can skip them — otherwise the startup write triggers a second pipeline run. const ownSchemaWrites = new Set() -export function refresh_on_schema(_ctx: VitePluginContext): PluginOption { +export function refresh_on_schema(ctx: VitePluginContext): PluginOption { return { name: 'houdini-refresh-on-schema', @@ -38,7 +37,10 @@ export function refresh_on_schema(_ctx: VitePluginContext): PluginOption { return } - // if the compiler hasn't started yet then there's nothing to do + // if the compiler hasn't started yet then there's nothing to do. + // ctx.compiler is assigned by document_hmr's configureServer and is scoped + // to the current server (a vite restart creates a fresh plugin context). + const compiler = ctx.compiler if (!compiler) { return } @@ -149,7 +151,7 @@ export function poll_remote_schema(_ctx: VitePluginContext): PluginOption { } // a plugin that re-runs the codegen pipline when the schema changes -export function watch_local_schema(_ctx: VitePluginContext): PluginOption { +export function watch_local_schema(ctx: VitePluginContext): PluginOption { return { name: 'houdini-watch-local-schema', @@ -196,6 +198,7 @@ export function watch_local_schema(_ctx: VitePluginContext): PluginOption { // Trigger the pipeline directly — no need to bounce through the file watcher. // The Schema hook itself clears stale type_fields before re-inserting. + const compiler = ctx.compiler if (!compiler) return try { await compiler.pipeline_lock(() => diff --git a/packages/houdini/src/vite/session.ts b/packages/houdini/src/vite/session.ts new file mode 100644 index 000000000..e0f35e983 --- /dev/null +++ b/packages/houdini/src/vite/session.ts @@ -0,0 +1,63 @@ +/** + * Coordinates the handoff between houdini dev sessions within one process. + * + * A vite restart creates the replacement server (and with it a fresh houdini + * session: plugin context, database connection, compiler, and Go plugin + * processes) *before* closing the old server. Without coordination the two + * sessions overlap on the shared orchestration database: the new session wipes + * the file and spawns its plugins while the old session's processes still hold + * it, so plugin registrations collide and the surviving session can end up + * pointing at plugin processes that no longer exist. + * + * This registry is deliberately module-level: its whole purpose is to pass + * ownership from one plugin-instance generation to the next, which no + * per-instance state can do. Each dev session registers a dispose callback + * keyed by its database file; the next session for that file (or the owning + * server's close event, whichever comes first) runs it exactly once. + */ + +type SessionEntry = { + dispose: () => Promise +} + +const sessions = new Map() + +/** + * Register the active session for a database file. Returns a disposer bound to + * this registration: it runs the teardown at most once, and removes the + * registration only if it is still the active one (a replacement session may + * have already taken the slot). + */ +export function register_session( + db_file: string, + dispose: () => Promise +): () => Promise { + let running: Promise | null = null + const entry: SessionEntry = { + dispose: () => { + running ??= dispose() + return running + }, + } + sessions.set(db_file, entry) + return () => { + if (sessions.get(db_file) === entry) { + sessions.delete(db_file) + } + return entry.dispose() + } +} + +/** + * Dispose whatever session currently owns the database file (no-op when none). + * A new session calls this before it recreates the database and spawns its own + * plugin processes. + */ +export async function dispose_active_session(db_file: string): Promise { + const entry = sessions.get(db_file) + if (!entry) { + return + } + sessions.delete(db_file) + await entry.dispose() +} diff --git a/plugins/run.go b/plugins/run.go index cd7329901..acc803009 100644 --- a/plugins/run.go +++ b/plugins/run.go @@ -177,12 +177,24 @@ func Run[PluginConfig any](plugin HoudiniPlugin[PluginConfig]) error { db.Put(conn) - // insert the plugin metadata + // insert the plugin metadata. registration is an upsert: during a dev-server + // restart a replacement plugin process can race a row left by its predecessor, + // and dying on the conflict would leave the orchestrator dialing a port that + // nobody listens on. last writer wins; a superseded process exits on its own + // when the orchestrator never dials it (see ArmConnectionDeadline below). err = db.ExecQuery(ctx, `INSERT INTO plugins ( name, hooks, port, plugin_order, include_runtime, include_static_runtime, config_module, client_plugins ) VALUES - ($name, $hooks, $port, $plugin_order, $include_runtime, $include_static_runtime, $config_module, $client_plugins)`, + ($name, $hooks, $port, $plugin_order, $include_runtime, $include_static_runtime, $config_module, $client_plugins) + ON CONFLICT(name) DO UPDATE SET + hooks = excluded.hooks, + port = excluded.port, + plugin_order = excluded.plugin_order, + include_runtime = excluded.include_runtime, + include_static_runtime = excluded.include_static_runtime, + config_module = excluded.config_module, + client_plugins = excluded.client_plugins`, map[string]any{ "name": cmp(pluginKey, plugin.Name()), "hooks": string(hooksStr),