From 45e695db8571174ecea5ebb067a8e8fed2f37e81 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 16:59:10 -0400 Subject: [PATCH 1/7] A Vite context's build retires with its last open environment build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom buildApp builds a shared plugin's environments in parallel, and #380 retired the Session and engine in the first environment's closeBundle: the other environments then transformed without them and failed the build ("v2 transform before analyze — engine instance not initialized"). The context now tracks the environments whose build started and has not closed, and retires its build when the set empties. A set, not a count: Vite closes a failed environment's build twice and, in dev, closes environments whose build never started. Probe (run-and-discard): build --watch pairs each rebuild's buildStart with its closeBundle; a failing environment still rejects the build; sequential builds and dev close as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/vite-plugin/src/context.ts | 17 +++++++++++++++++ packages/vite-plugin/src/index.ts | 3 +++ 2 files changed, 20 insertions(+) diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index edad495e5..d21b6b184 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -489,6 +489,23 @@ export class PluginContext { this.applyAssetSubstitutions(); } + /** Environments whose build started and has not closed. A custom + * `buildApp` builds a shared plugin's environments in parallel, so the + * context's build retires with the last of them, not the first. */ + private readonly openBuilds = new Set(); + + openBuild(environment: string): void { + this.openBuilds.add(environment); + } + + /** Whether `environment` was the last open build. Vite can close one + * environment twice (a failed build) or close one that never started + * (dev), so the set, not a count, decides. */ + closeBuild(environment: string): boolean { + this.openBuilds.delete(environment); + return this.openBuilds.size === 0; + } + /** Retires this context's build when Vite closes it: the Session leaves * the registry (with its manifest and watch state) and the engine handle * is released, injected engine or not. The next build start rejoins. */ diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 48fa37010..fc4043058 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -209,11 +209,14 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { // The end of a build, and of a dev server, which Vite closes the same // way: the context's Session and engine are retired with it. closeBundle() { + // The last open environment build retires the context's build. + if (!ctx.closeBuild(this.environment?.name ?? 'build')) return; cancelSystemReload(ctx); ctx.close(); }, async buildStart() { + ctx.openBuild(this.environment?.name ?? 'build'); await runBuildStart( ctx, async (specifier) => { From 3adeb4e845a03c29fd6f6d650a08814eba695b04 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 16:59:11 -0400 Subject: [PATCH 2/7] Row: the first environment to close leaves the others their Session A real Vite createBuilder over the dev-server fixture builds client and SSR in parallel through a custom buildApp, with SSR transforms delayed so the client closes first; SSR output must be extracted. Red on main 26a2b511 (macOS): the build fails with "v2 transform before analyze". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/dev-server/build-app.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/vite-plugin/tests/dev-server/build-app.test.ts diff --git a/packages/vite-plugin/tests/dev-server/build-app.test.ts b/packages/vite-plugin/tests/dev-server/build-app.test.ts new file mode 100644 index 000000000..4975cb843 --- /dev/null +++ b/packages/vite-plugin/tests/dev-server/build-app.test.ts @@ -0,0 +1,95 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { createBuilder } from 'vite-plus'; +import { describe, expect, it } from 'vitest'; + +import { animusExtract } from '../../src/index'; +import { createDevFixture } from './fixture'; + +import type { ViteBuilder } from 'vite-plus'; + +/** Delays every SSR transform, so the client environment closes first. */ +const slowSsr = { + name: 'slow-ssr', + enforce: 'pre' as const, + async transform(this: { environment: { name: string } }) { + if (this.environment.name === 'ssr') { + await new Promise((settle) => setTimeout(settle, 300)); + } + return null; + }, +}; + +interface EnvironmentBuild { + build: { outDir: string; ssr?: true; rollupOptions: { input: string } }; +} + +interface ParallelBuildConfig { + root: string; + configFile: false; + logLevel: 'silent'; + plugins: Array>; + environments: { client: EnvironmentBuild; ssr: EnvironmentBuild }; + builder: { buildApp(app: ViteBuilder): Promise }; +} + +type StartParallelBuild = ( + config: ParallelBuildConfig +) => Promise<{ buildApp(): Promise }>; + +function filesUnder(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() + ? filesUnder(join(dir, entry.name)) + : [join(dir, entry.name)] + ); +} + +/** + * A custom `buildApp` builds a shared plugin's environments in parallel: the + * first to close must not retire the Session and engine the others still + * transform with. + */ +describe('environments built in parallel', () => { + it('the first environment to close leaves the others their Session', async () => { + const fixture = createDevFixture(); + try { + const input = join(fixture.root, 'src', 'main.ts'); + // SAFETY: vite-plus runs Vite's builder and consumes the same plugin + // hooks; only the nominal declarations differ, and tsc overflows on them. + const startParallelBuild = createBuilder as StartParallelBuild; + const builder = await startParallelBuild({ + root: fixture.root, + configFile: false, + logLevel: 'silent', + plugins: [slowSsr, animusExtract({ system: './src/ds.ts' })], + environments: { + client: { + build: { outDir: 'dist/client', rollupOptions: { input } }, + }, + ssr: { + build: { outDir: 'dist/ssr', ssr: true, rollupOptions: { input } }, + }, + }, + builder: { + async buildApp(app) { + await Promise.all([ + app.build(app.environments.client), + app.build(app.environments.ssr), + ]); + }, + }, + }); + await builder.buildApp(); + + const ssrCode = filesUnder(join(fixture.root, 'dist', 'ssr')) + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(file, 'utf-8')) + .join('\n'); + expect(ssrCode).toContain('createComponent'); + expect(ssrCode).not.toContain('.styles('); + } finally { + fixture.dispose(); + } + }); +}); From bffeeee88414c2e4212f4eeceb61eba80322ee4b Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:19:27 -0400 Subject: [PATCH 3/7] #395 P2: refuse an overlapping build of an active environment, or two under one name spike-codex: the name-keyed set conflated distinct active builds. A builder reused right after a rejected parallel buildApp let the old SSR build's late closeBundle empty 'ssr' under the new SSR build; two distinct environments named alike let the first close retire the engine under the second (both loud, 'engine instance not initialized'). The context now maps each open build's name to its environment and, in buildStart, refuses a second build of an active environment ('a previous build of environment ssr is still running on this plugin instance') and a second environment under an active name. Only the environment that opened an entry removes it, so a refused build's closeBundle never retires the running one. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/vite-plugin/src/context.ts | 42 ++++++++++++++++++++--------- packages/vite-plugin/src/index.ts | 16 +++++++++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index d21b6b184..db4d5fc82 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -489,20 +489,38 @@ export class PluginContext { this.applyAssetSubstitutions(); } - /** Environments whose build started and has not closed. A custom - * `buildApp` builds a shared plugin's environments in parallel, so the - * context's build retires with the last of them, not the first. */ - private readonly openBuilds = new Set(); - - openBuild(environment: string): void { - this.openBuilds.add(environment); + /** Each environment whose build started and has not closed, by name. A + * custom `buildApp` builds a shared plugin's environments in parallel, so + * the context's build retires with the last of them, not the first. */ + private readonly openBuilds = new Map(); + + /** Opens `environment`'s build. Builds are tracked by environment, not by + * generation, so a second build of an active environment, or a second + * environment under an active name, is refused. */ + openBuild(name: string, environment: object): void { + const open = this.openBuilds.get(name); + if (open === environment) { + throw new Error( + `[animus-extract] a previous build of environment ${name} is still ` + + `running on this plugin instance; wait for it to finish before ` + + `building it again` + ); + } + if (open !== undefined) { + throw new Error( + `[animus-extract] two environments named ${name} build on this ` + + `plugin instance at once; give each environment its own name` + ); + } + this.openBuilds.set(name, environment); } - /** Whether `environment` was the last open build. Vite can close one - * environment twice (a failed build) or close one that never started - * (dev), so the set, not a count, decides. */ - closeBuild(environment: string): boolean { - this.openBuilds.delete(environment); + /** Whether no build is open once `environment`'s closes. Vite can close + * one environment twice (a failed build), close one that never started + * (dev), or close a build this context refused, so only the environment + * that opened an entry removes it. */ + closeBuild(name: string, environment: object): boolean { + if (this.openBuilds.get(name) === environment) this.openBuilds.delete(name); return this.openBuilds.size === 0; } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index fc4043058..7f3c520e5 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -153,6 +153,18 @@ type AnimusExtractOptionRecord = { [Key in keyof AnimusExtractOptions]: AnimusExtractOptions[Key]; }; +/** The building environment's name and identity; a host without + * environments builds as one, the context itself. */ +function environmentOf( + hook: { environment?: { name: string } }, + ctx: PluginContext +): [string, object] { + const environment = hook.environment; + return environment === undefined + ? ['build', ctx] + : [environment.name, environment]; +} + export function animusExtract(options: AnimusExtractOptions): Plugin { // The option type excludes 'v1', but a raw runtime value still reaches here. assertSupportedEngine(options.engine); @@ -210,13 +222,13 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { // way: the context's Session and engine are retired with it. closeBundle() { // The last open environment build retires the context's build. - if (!ctx.closeBuild(this.environment?.name ?? 'build')) return; + if (!ctx.closeBuild(...environmentOf(this, ctx))) return; cancelSystemReload(ctx); ctx.close(); }, async buildStart() { - ctx.openBuild(this.environment?.name ?? 'build'); + ctx.openBuild(...environmentOf(this, ctx)); await runBuildStart( ctx, async (specifier) => { From 8674dc482e85a061c56de626ad2ca774c6f62e72 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:19:27 -0400 Subject: [PATCH 4/7] #395 P2 rows: spike-codex's two witnesses, refused with a clear error - 'a second build of a running environment is refused, naming it': the builder reused straight after a parallel buildApp whose client failed while SSR still ran. - 'two environments under one name are refused, naming it': buildApp builds the client environment and a distinct twin named client in parallel. Both red at 3adeb4e8. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/dev-server/build-app.test.ts | 94 ++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/vite-plugin/tests/dev-server/build-app.test.ts b/packages/vite-plugin/tests/dev-server/build-app.test.ts index 4975cb843..f19f8a3dc 100644 --- a/packages/vite-plugin/tests/dev-server/build-app.test.ts +++ b/packages/vite-plugin/tests/dev-server/build-app.test.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync } from 'fs'; import { join } from 'path'; -import { createBuilder } from 'vite-plus'; +import { BuildEnvironment, createBuilder } from 'vite-plus'; import { describe, expect, it } from 'vitest'; import { animusExtract } from '../../src/index'; @@ -8,6 +8,18 @@ import { createDevFixture } from './fixture'; import type { ViteBuilder } from 'vite-plus'; +/** Fails the client build while `failing.client` is set. */ +const failing = { client: false }; +const failClient = { + name: 'fail-client', + transform(this: { environment: { name: string } }) { + if (failing.client && this.environment.name === 'client') { + throw new Error('planned client failure'); + } + return null; + }, +}; + /** Delays every SSR transform, so the client environment closes first. */ const slowSsr = { name: 'slow-ssr', @@ -21,14 +33,20 @@ const slowSsr = { }; interface EnvironmentBuild { - build: { outDir: string; ssr?: true; rollupOptions: { input: string } }; + build: { + outDir: string; + ssr?: true; + rollupOptions: { input: string }; + }; } interface ParallelBuildConfig { root: string; configFile: false; logLevel: 'silent'; - plugins: Array>; + plugins: Array< + typeof slowSsr | typeof failClient | ReturnType + >; environments: { client: EnvironmentBuild; ssr: EnvironmentBuild }; builder: { buildApp(app: ViteBuilder): Promise }; } @@ -92,4 +110,74 @@ describe('environments built in parallel', () => { fixture.dispose(); } }); + + it('a second build of a running environment is refused, naming it', async () => { + const fixture = createDevFixture(); + try { + const builder = await parallelBuilder(fixture.root); + // The client fails at once; the slow SSR build is still running. + failing.client = true; + await expect(builder.buildApp()).rejects.toThrow( + 'planned client failure' + ); + failing.client = false; + + await expect(builder.buildApp()).rejects.toThrow( + 'a previous build of environment ssr is still running on this plugin instance' + ); + } finally { + failing.client = false; + fixture.dispose(); + } + }); + + it('two environments under one name are refused, naming it', async () => { + const fixture = createDevFixture(); + try { + // A second, distinct environment object under the client's name. + const builder = await parallelBuilder(fixture.root, async (app) => { + const twin = new BuildEnvironment('client', app.config); + await twin.init(); + await Promise.all([ + app.build(app.environments.client), + app.build(twin), + ]); + }); + + await expect(builder.buildApp()).rejects.toThrow( + 'two environments named client build on this plugin instance at once' + ); + } finally { + fixture.dispose(); + } + }); }); + +/** A builder whose app builds client and SSR in parallel, SSR slowly, + * unless `buildApp` builds otherwise. */ +function parallelBuilder( + root: string, + buildApp: (app: ViteBuilder) => Promise = async (app) => { + await Promise.all([ + app.build(app.environments.client), + app.build(app.environments.ssr), + ]); + } +) { + const input = join(root, 'src', 'main.ts'); + // SAFETY: as above. + const startParallelBuild = createBuilder as StartParallelBuild; + return startParallelBuild({ + root, + configFile: false, + logLevel: 'silent', + plugins: [slowSsr, failClient, animusExtract({ system: './src/ds.ts' })], + environments: { + client: { build: { outDir: 'dist/client', rollupOptions: { input } } }, + ssr: { + build: { outDir: 'dist/ssr', ssr: true, rollupOptions: { input } }, + }, + }, + builder: { buildApp }, + }); +} From 53d81d27b9a82a14c8ceb3ff5b753c146c4177b7 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:20:03 -0400 Subject: [PATCH 5/7] #395 P2: name the building environment's type The pushed head failed lint (anti-slop no-object-parameters): the open builds took a broad `object`. They now take a BuildingEnvironment (Vite's environment, or the context's single stand-in for a host without environments). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/vite-plugin/src/context.ts | 20 ++++++++++++++++---- packages/vite-plugin/src/index.ts | 16 ++-------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index db4d5fc82..63003611a 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -120,6 +120,12 @@ const NO_SHEETS: AssetSheets = { componentCss: '', }; +/** One environment's build, as Vite hands it to a hook: the object is its + * identity, the name its key. */ +export interface BuildingEnvironment { + readonly name: string; +} + export class PluginContext { readonly options: AnimusExtractOptions; readonly verbose: boolean; @@ -489,15 +495,19 @@ export class PluginContext { this.applyAssetSubstitutions(); } + /** The environment a host without environments builds as. */ + readonly singleEnvironment: BuildingEnvironment = { name: 'build' }; + /** Each environment whose build started and has not closed, by name. A * custom `buildApp` builds a shared plugin's environments in parallel, so * the context's build retires with the last of them, not the first. */ - private readonly openBuilds = new Map(); + private readonly openBuilds = new Map(); /** Opens `environment`'s build. Builds are tracked by environment, not by * generation, so a second build of an active environment, or a second * environment under an active name, is refused. */ - openBuild(name: string, environment: object): void { + openBuild(environment: BuildingEnvironment): void { + const { name } = environment; const open = this.openBuilds.get(name); if (open === environment) { throw new Error( @@ -519,8 +529,10 @@ export class PluginContext { * one environment twice (a failed build), close one that never started * (dev), or close a build this context refused, so only the environment * that opened an entry removes it. */ - closeBuild(name: string, environment: object): boolean { - if (this.openBuilds.get(name) === environment) this.openBuilds.delete(name); + closeBuild(environment: BuildingEnvironment): boolean { + if (this.openBuilds.get(environment.name) === environment) { + this.openBuilds.delete(environment.name); + } return this.openBuilds.size === 0; } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 7f3c520e5..1faf9fc0a 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -153,18 +153,6 @@ type AnimusExtractOptionRecord = { [Key in keyof AnimusExtractOptions]: AnimusExtractOptions[Key]; }; -/** The building environment's name and identity; a host without - * environments builds as one, the context itself. */ -function environmentOf( - hook: { environment?: { name: string } }, - ctx: PluginContext -): [string, object] { - const environment = hook.environment; - return environment === undefined - ? ['build', ctx] - : [environment.name, environment]; -} - export function animusExtract(options: AnimusExtractOptions): Plugin { // The option type excludes 'v1', but a raw runtime value still reaches here. assertSupportedEngine(options.engine); @@ -222,13 +210,13 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { // way: the context's Session and engine are retired with it. closeBundle() { // The last open environment build retires the context's build. - if (!ctx.closeBuild(...environmentOf(this, ctx))) return; + if (!ctx.closeBuild(this.environment ?? ctx.singleEnvironment)) return; cancelSystemReload(ctx); ctx.close(); }, async buildStart() { - ctx.openBuild(...environmentOf(this, ctx)); + ctx.openBuild(this.environment ?? ctx.singleEnvironment); await runBuildStart( ctx, async (specifier) => { From c3e3fca1ad280bed1b0993189fd8b7b0a68ba267 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:40:36 -0400 Subject: [PATCH 6/7] #395 last round: serialise each environment's builds and end them at buildEnd spike-codex: a refused build owned the active generation's end. Its closeBundle removed the original's entry, so the reused builder's second client retired the engine under the original SSR build, which rejected. Hook contexts are new objects per call (no per-attempt token), but Vite ends every build exactly once in buildEnd, failed ones included, while closeBundle can run twice. So a buildStart for an environment whose build is open (the same object or one named alike) now waits for that build's end and then opens its own generation, and buildEnd ends only the generation its environment opened; the last to end retires the Session and engine. No refusal is left, so no stray end exists. A dev server ends only the environments that started. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/vite-plugin/src/build-start.ts | 2 +- packages/vite-plugin/src/context.ts | 67 +++++++++++++------------ packages/vite-plugin/src/index.ts | 13 ++--- 3 files changed, 43 insertions(+), 39 deletions(-) diff --git a/packages/vite-plugin/src/build-start.ts b/packages/vite-plugin/src/build-start.ts index 120805eff..f85a889a0 100644 --- a/packages/vite-plugin/src/build-start.ts +++ b/packages/vite-plugin/src/build-start.ts @@ -44,7 +44,7 @@ export async function runBuildStart( // System load, discovery, package collection and analysis are the // Session's; Vite serves its publication and substitutes asset URLs. const session = ctx.session ?? ctx.createSession(); - // An earlier environment's closeBundle closed it: it joins its build again. + // An earlier build's end retired it: it joins its build again. session.reattach(); session.hostResolve = resolveSpecifier; // A build fails on an aborted parse. A dev server starts through it with diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index 63003611a..5418c5623 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -126,6 +126,13 @@ export interface BuildingEnvironment { readonly name: string; } +/** An environment's open build, and the promise its end settles. */ +interface OpenBuild { + environment: BuildingEnvironment; + ended: Promise; + end(): void; +} + export class PluginContext { readonly options: AnimusExtractOptions; readonly verbose: boolean; @@ -498,41 +505,37 @@ export class PluginContext { /** The environment a host without environments builds as. */ readonly singleEnvironment: BuildingEnvironment = { name: 'build' }; - /** Each environment whose build started and has not closed, by name. A + /** Each environment build that started and has not ended, by name. A * custom `buildApp` builds a shared plugin's environments in parallel, so * the context's build retires with the last of them, not the first. */ - private readonly openBuilds = new Map(); - - /** Opens `environment`'s build. Builds are tracked by environment, not by - * generation, so a second build of an active environment, or a second - * environment under an active name, is refused. */ - openBuild(environment: BuildingEnvironment): void { - const { name } = environment; - const open = this.openBuilds.get(name); - if (open === environment) { - throw new Error( - `[animus-extract] a previous build of environment ${name} is still ` + - `running on this plugin instance; wait for it to finish before ` + - `building it again` - ); - } - if (open !== undefined) { - throw new Error( - `[animus-extract] two environments named ${name} build on this ` + - `plugin instance at once; give each environment its own name` - ); - } - this.openBuilds.set(name, environment); - } - - /** Whether no build is open once `environment`'s closes. Vite can close - * one environment twice (a failed build), close one that never started - * (dev), or close a build this context refused, so only the environment - * that opened an entry removes it. */ - closeBuild(environment: BuildingEnvironment): boolean { - if (this.openBuilds.get(environment.name) === environment) { - this.openBuilds.delete(environment.name); + private readonly openBuilds = new Map(); + + /** Opens `environment`'s build, after any open build under its name has + * ended: a builder reused while an earlier build still runs, or a second + * environment under one name, builds after it rather than beside it. + * Each build then ends its own generation once, in `buildEnd`. */ + async openBuild(environment: BuildingEnvironment): Promise { + for (;;) { + const open = this.openBuilds.get(environment.name); + if (open === undefined) break; + await open.ended; } + let end = () => {}; + const ended = new Promise((settle) => { + end = settle; + }); + this.openBuilds.set(environment.name, { environment, ended, end }); + } + + /** Ends `environment`'s open build; whether that left none open. Vite + * ends each build once, in `buildEnd`, and a dev server ends only the + * environments that started, so only the build that opened an entry + * ends it. */ + endBuild(environment: BuildingEnvironment): boolean { + const open = this.openBuilds.get(environment.name); + if (open === undefined || open.environment !== environment) return false; + this.openBuilds.delete(environment.name); + open.end(); return this.openBuilds.size === 0; } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 1faf9fc0a..6f8fee130 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -206,17 +206,18 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { applyResolvedConfig(ctx, config); }, - // The end of a build, and of a dev server, which Vite closes the same - // way: the context's Session and engine are retired with it. - closeBundle() { - // The last open environment build retires the context's build. - if (!ctx.closeBuild(this.environment ?? ctx.singleEnvironment)) return; + // The end of a build, and of a dev server, which Vite ends the same way: + // the last open environment build retires the context's Session and + // engine. buildEnd runs once per build, failed ones included; closeBundle + // can run twice for a failed one. + buildEnd() { + if (!ctx.endBuild(this.environment ?? ctx.singleEnvironment)) return; cancelSystemReload(ctx); ctx.close(); }, async buildStart() { - ctx.openBuild(this.environment ?? ctx.singleEnvironment); + await ctx.openBuild(this.environment ?? ctx.singleEnvironment); await runBuildStart( ctx, async (specifier) => { From 5abde2339de57e7d4c3ab675271de8db1970164b Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:40:36 -0400 Subject: [PATCH 7/7] #395 last round rows: the reused builder's four builds, and one name built in turn - 'a builder reused while an earlier build still runs builds after it': spike-codex's witness awaiting all four build promises; only the planned client failure rejects, the original SSR build fulfils. - 'two environments under one name build one after the other': the twin waits for the client and both build. Both red at 53d81d27. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/dev-server/build-app.test.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/vite-plugin/tests/dev-server/build-app.test.ts b/packages/vite-plugin/tests/dev-server/build-app.test.ts index f19f8a3dc..7c4718481 100644 --- a/packages/vite-plugin/tests/dev-server/build-app.test.ts +++ b/packages/vite-plugin/tests/dev-server/build-app.test.ts @@ -111,27 +111,42 @@ describe('environments built in parallel', () => { } }); - it('a second build of a running environment is refused, naming it', async () => { + it('a builder reused while an earlier build still runs builds after it', async () => { const fixture = createDevFixture(); try { - const builder = await parallelBuilder(fixture.root); - // The client fails at once; the slow SSR build is still running. + // Every environment build either app starts, in order. + const builds: Array> = []; + const builder = await parallelBuilder(fixture.root, async (app) => { + const started = [ + app.build(app.environments.client), + app.build(app.environments.ssr), + ]; + builds.push(...started); + await Promise.all(started); + }); + // The client fails at once; the slow SSR build is still running when + // the builder is reused. failing.client = true; await expect(builder.buildApp()).rejects.toThrow( 'planned client failure' ); failing.client = false; + await builder.buildApp(); - await expect(builder.buildApp()).rejects.toThrow( - 'a previous build of environment ssr is still running on this plugin instance' - ); + const outcomes = await Promise.allSettled(builds); + expect(outcomes.map((outcome) => outcome.status)).toEqual([ + 'rejected', + 'fulfilled', + 'fulfilled', + 'fulfilled', + ]); } finally { failing.client = false; fixture.dispose(); } }); - it('two environments under one name are refused, naming it', async () => { + it('two environments under one name build one after the other', async () => { const fixture = createDevFixture(); try { // A second, distinct environment object under the client's name. @@ -144,9 +159,7 @@ describe('environments built in parallel', () => { ]); }); - await expect(builder.buildApp()).rejects.toThrow( - 'two environments named client build on this plugin instance at once' - ); + await expect(builder.buildApp()).resolves.toBeUndefined(); } finally { fixture.dispose(); }