Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/vite-restart-session-handoff.md
Original file line number Diff line number Diff line change
@@ -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).
15 changes: 12 additions & 3 deletions packages/houdini/src/lib/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
111 changes: 111 additions & 0 deletions packages/houdini/src/vite/hmr.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../lib/index.js')>()
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<any>) => 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)
})
62 changes: 48 additions & 14 deletions packages/houdini/src/vite/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ReturnType<typeof get_config>>
// 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
Expand Down Expand Up @@ -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,
Expand All @@ -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',
})
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -475,7 +509,7 @@ export function document_hmr(ctx: VitePluginContext): VitePlugin {
// BeforeValidate → Validate → AfterValidate → GenerateDocuments → GenerateRuntime
let results: Awaited<ReturnType<typeof run_pipeline>>
try {
results = await run_pipeline(compiler.trigger_hook, {
results = await run_pipeline(batchCompiler.trigger_hook, {
task_id,
after: 'AfterExtract',
})
Expand Down
6 changes: 6 additions & 0 deletions packages/houdini/src/vite/houdini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 11 additions & 1 deletion packages/houdini/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<Array<PluginOption>> {
Expand Down
11 changes: 7 additions & 4 deletions packages/houdini/src/vite/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/*
Expand All @@ -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<string>()

export function refresh_on_schema(_ctx: VitePluginContext): PluginOption {
export function refresh_on_schema(ctx: VitePluginContext): PluginOption {
return {
name: 'houdini-refresh-on-schema',

Expand All @@ -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
}
Expand Down Expand Up @@ -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',

Expand Down Expand Up @@ -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(() =>
Expand Down
Loading
Loading