diff --git a/dist/chunks/cac.uFydS1Z4.js b/dist/chunks/cac.uFydS1Z4.js index bf394fb6f662f2eed77d066b45be9898ede971c4..9f38659e3fa65d6dee938ba6188a41472a118a74 100644 --- a/dist/chunks/cac.uFydS1Z4.js +++ b/dist/chunks/cac.uFydS1Z4.js @@ -2290,7 +2290,12 @@ function parseCLI(argv, config = {}) { if (arrayArgs[0] !== "vitest") throw new Error(`Expected "vitest" as the first argument, received "${arrayArgs[0]}"`); arrayArgs[0] = "/index.js"; arrayArgs.unshift("node"); - let { args, options } = createCLI(config).parse(arrayArgs, { run: false }); + const cli = createCLI(config); + let { args, options } = cli.parse(arrayArgs, { run: false }); + // Validate without executing; help/version clear the matched command in parse(). + cli.matchedCommand?.checkUnknownOptions(); + cli.matchedCommand?.checkOptionValue(); + cli.matchedCommand?.checkRequiredArgs(); if (arrayArgs[2] === "watch" || arrayArgs[2] === "dev") options.watch = true; if (arrayArgs[2] === "run" && !options.watch) options.run = true; if (arrayArgs[2] === "related") { diff --git a/dist/chunks/cli-api.CnMVyzaz.js b/dist/chunks/cli-api.CnMVyzaz.js index 2b8bb8d9cb13dc89892f4983afd404c8594bdcc5..3b94359b39b4ffa7f466a7c9817d60997e536c68 100644 --- a/dist/chunks/cli-api.CnMVyzaz.js +++ b/dist/chunks/cli-api.CnMVyzaz.js @@ -628,6 +628,7 @@ class FileSystemModuleCache { rootCache; metadataFilePath; version = "1.0.0-beta.4"; + lockfileHash; fsCacheRoots = /* @__PURE__ */ new WeakMap(); fsEnvironmentHashMap = /* @__PURE__ */ new WeakMap(); fsCacheKeyGenerators = /* @__PURE__ */ new Set(); @@ -644,9 +645,12 @@ class FileSystemModuleCache { this.fsCacheKeyGenerators.add(callback); } async clearCache(log = true) { - const fsCachePaths = this.vitest.projects.map((r) => { + // Disk deletion also retires paths retained by live module graphs. + this.fsCacheRoots = /* @__PURE__ */ new WeakMap(); + this.vitest.clearAllCachePaths(); + const fsCachePaths = [this.rootCache, ...this.vitest.projects.map((r) => { return r.config.experimental.fsModuleCachePath || this.rootCache; - }); + })]; const uniquePaths = Array.from(new Set(fsCachePaths)); await Promise.all(uniquePaths.map((directory) => rm(directory, { force: true, @@ -715,6 +719,11 @@ class FileSystemModuleCache { invalidateAllCachePaths(environment) { debugFs?.(`the ${environment.name} environment cache is invalidated`); this.fsCacheKeys.get(environment)?.clear(); + for (const module of environment.moduleGraph.idToModuleMap.values()) { + for (const transform of [module.transformResult, module.invalidationState]) { + if (transform && typeof transform !== "string") delete transform.__vitestTmp; + } + } } getMemoryCachePath(environment, id) { const result = this.fsCacheKeys.get(environment)?.get(id); @@ -766,7 +775,9 @@ class FileSystemModuleCache { }); this.fsEnvironmentHashMap.set(environment, cacheConfig); } - hashString += id + fileContent + (process.env.NODE_ENV ?? "") + this.version + cacheConfig + coverageAffectsCache; + // Shared metadata cannot certify excluded or post-clear cache entries. + // Their keys must carry the generation prepared during initialization. + hashString += id + fileContent + (process.env.NODE_ENV ?? "") + this.version + this.lockfileHash + cacheConfig + coverageAffectsCache; const cacheKey = hash("sha1", hashString, "hex"); let cacheRoot = this.fsCacheRoots.get(vitestConfig); if (cacheRoot == null) { @@ -800,25 +811,24 @@ class FileSystemModuleCache { // or a new version of vite/vitest is installed // for the same reason we also cache config file content, but that won't catch changes made in external plugins async ensureCacheIntegrity() { - if (![this.vitest.getRootProject(), ...this.vitest.projects].some((p) => p.config.experimental.fsModuleCache)) return; - const metadata = await this.readMetadata(); - const currentLockfileHash = getLockfileHash(this.vitest.vite.config.root); - // no metadata found, just store a new one, don't reset the cache - if (!metadata) { - if (!existsSync(this.rootCache)) mkdirSync(this.rootCache, { recursive: true }); - debugFs?.(`fs metadata file was created with hash ${currentLockfileHash}`); - await writeFile(this.metadataFilePath, JSON.stringify({ lockfileHash: currentLockfileHash }, null, 2), "utf-8"); - return; + if ([this.vitest.getRootProject(), ...this.vitest.projects].some((p) => p.config.experimental.fsModuleCache)) { + const metadata = await this.readMetadata(); + const currentLockfileHash = getLockfileHash(this.vitest.vite.config.root); + if (metadata?.lockfileHash !== currentLockfileHash) { + if (metadata) { + await this.clearCache(false); + this.vitest.vite.config.logger.info(`fs cache was cleared because lockfile has changed`, { + timestamp: true, + environment: c.yellow("[vitest]") + }); + debugFs?.(`fs cache was cleared because lockfile has changed`); + } + if (!existsSync(this.rootCache)) mkdirSync(this.rootCache, { recursive: true }); + debugFs?.(`fs metadata file was created with hash ${currentLockfileHash}`); + await writeFile(this.metadataFilePath, JSON.stringify({ lockfileHash: currentLockfileHash }, null, 2), "utf-8"); + } + this.lockfileHash = currentLockfileHash; } - // if lockfile didn't change, don't do anything - if (metadata.lockfileHash === currentLockfileHash) return; - // lockfile changed, let's clear all caches - await this.clearCache(false); - this.vitest.vite.config.logger.info(`fs cache was cleared because lockfile has changed`, { - timestamp: true, - environment: c.yellow("[vitest]") - }); - debugFs?.(`fs cache was cleared because lockfile has changed`); } } /** @@ -1068,7 +1078,9 @@ class ModuleFetcher { if ("code" in result) trace.setAttribute("vitest.fetched_module.code_length", result.code.length); } async getCachePath(environment, moduleGraphModule) { - if (!this.fsCacheEnabled) return null; + // VCS and configureVitest imports precede project/cache-key initialization. + // Use normal Vite transforms until persistent cache integrity is established. + if (!this.fsCacheEnabled || this.fsCache.lockfileHash === void 0) return null; const moduleId = moduleGraphModule.id; const memoryCacheKey = this.fsCache.getMemoryCachePath(environment, moduleId); // undefined means there is no key in memory @@ -3031,7 +3043,8 @@ class PoolRunner { stopSpan.recordException(response.error); this.project.vitest.state.catchError(response.error, "Teardown Error"); } - resolve(); + // Only the responding transport can promise an explicit graceful exit. + resolve(response.willExit ? Promise.resolve().then(() => this.worker.waitForExit?.()) : undefined); this.off("message", onStop); } }; @@ -3046,21 +3059,21 @@ class PoolRunner { __vitest_worker_request__: true, otelCarrier: this.getOTELCarrier() }); - }), STOP_TIMEOUT).finally(() => { + }), STOP_TIMEOUT).finally(async () => { stopSpan.end(); + // Deadline/error paths must also terminate and join the worker. + await this._traces.$(`vitest.${this.worker.name}.stop`, { context: this._otel?.workerContext }, () => this.worker.stop()); }); - this._eventEmitter.removeAllListeners(); - this._offCancel(); - this._rpc.$close(/* @__PURE__ */ new Error("[vitest-pool-runner]: Pending methods while closing rpc")); - // Stop the worker process (this sets _fork/_thread to undefined) - // Worker's event listeners (error, message) are implicitly removed when worker terminates - await this._traces.$(`vitest.${this.worker.name}.stop`, { context: this._otel?.workerContext }, () => this.worker.stop()); this._state = RunnerState.STOPPED; } catch (error) { - // Ensure we transition to stopped state even on error + // Ensure failed graceful shutdown cannot report a successful test run. + this.project.vitest.state.catchError(error, "Teardown Error"); this._state = RunnerState.STOPPED; throw error; } finally { + this._eventEmitter.removeAllListeners(); + this._offCancel(); + this._rpc.$close(/* @__PURE__ */ new Error("[vitest-pool-runner]: Pending methods while closing rpc")); this._operationLock.resolve(); this._operationLock = null; this._otel?.span.end(); @@ -3163,10 +3176,22 @@ class ForksPoolWorker { this._fork.stderr.pipe(this.stderr); } } - async stop() { + waitForExit() { const fork = this.fork; + return new Promise((resolve, reject) => { + const onExit = (code, signal) => { + if (code === 0 && signal == null) resolve(); + else reject(new Error(`Worker exited during graceful shutdown (code: ${code}, signal: ${signal})`)); + }; + if (fork.exitCode != null || fork.signalCode != null) onExit(fork.exitCode, fork.signalCode); + else fork.once("exit", onExit); + }); + } + async stop() { + const fork = this._fork; + if (!fork) return; const waitForExit = new Promise((resolve) => { - if (fork.exitCode != null) resolve(); + if (fork.exitCode != null || fork.signalCode != null) resolve(); else fork.once("exit", resolve); }); /* @@ -3175,10 +3200,12 @@ class ForksPoolWorker { * - https://github.com/jestjs/jest/blob/25a8785584c9d54a05887001ee7f498d489a5441/packages/jest-worker/src/workers/ChildProcessWorker.ts#L463-L477 * - https://github.com/tinylibs/tinypool/blob/40b4b3eb926dabfbfd3d0a7e3d1222d4dd1c0d2d/src/runtime/process-worker.ts#L56 */ - const sigkillTimeout = setTimeout(() => fork.kill("SIGKILL"), SIGKILL_TIMEOUT); - fork.kill(); - await waitForExit; - clearTimeout(sigkillTimeout); + if (fork.exitCode == null && fork.signalCode == null) { + const sigkillTimeout = setTimeout(() => fork.kill("SIGKILL"), SIGKILL_TIMEOUT); + fork.kill(); + await waitForExit; + clearTimeout(sigkillTimeout); + } if (fork.stdout) { fork.stdout?.unpipe(this.stdout); this.stdout.setMaxListeners(this.stdout.getMaxListeners() - 1); @@ -3548,7 +3575,7 @@ class Pool { // Runner termination can also already start from task cancellation. if (!runner.isTerminated) { const id = setTimeout(() => this.logger.error(`[vitest-pool]: Timeout terminating ${task.worker} worker for test files ${formatFiles(task)}.`), this.options.teardownTimeout); - this.exitPromises.push(runner.stop({ force: resolver.isRejected }).then(() => clearTimeout(id)).catch((error) => this.logger.error(`[vitest-pool]: Failed to terminate ${task.worker} worker for test files ${formatFiles(task)}.`, error))); + this.exitPromises.push(runner.stop({ force: resolver.isRejected }).finally(() => clearTimeout(id)).catch((error) => this.logger.error(`[vitest-pool]: Failed to terminate ${task.worker} worker for test files ${formatFiles(task)}.`, error))); } this.freeWorkerId(poolId); } @@ -3779,6 +3806,8 @@ function createPool(ctx) { const groupResults = await Promise.allSettled(promises); results.push(...groupResults); } + // Join worker teardown before reporters and the CLI compute the run result. + await pool.close(); const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason); if (errors.length > 0) throw new AggregateError(errors, "Errors occurred while running tests. For more information, see serialized error."); } @@ -13241,8 +13270,8 @@ class Vitest { // populate will merge all configs into every project, // we don't want that when just listing tags if (!this.config.listTags) populateProjectsTags(this.coreWorkspaceProject, this.projects); - this.reporters = resolved.mode === "benchmark" ? await createBenchmarkReporters(toArray(resolved.benchmark?.reporters), this.runner) : await createReporters(resolved.reporters, this); await this._fsCache.ensureCacheIntegrity(); + this.reporters = resolved.mode === "benchmark" ? await createBenchmarkReporters(toArray(resolved.benchmark?.reporters), this.runner) : await createReporters(resolved.reporters, this); await Promise.all([...this._onSetServer.map((fn) => fn()), this._traces.waitInit()]); } /** @internal */ @@ -13283,7 +13312,7 @@ class Vitest { if (this.coverageProvider?.onFileTransform) this.clearAllCachePaths(); } clearAllCachePaths() { - this.projects.forEach(({ vite, browser }) => { + new Set([this.getRootProject(), ...this.projects]).forEach(({ vite, browser }) => { [...Object.values(vite.environments), ...Object.values(browser?.vite.environments || {})].forEach((environment) => this._fsCache.invalidateAllCachePaths(environment)); }); } diff --git a/dist/chunks/init-forks.H5ZuobOQ.js b/dist/chunks/init-forks.H5ZuobOQ.js index 3a3a5f671e3d503eae1af13c6068e4d18bb8f60b..5754b53fc412909f0697c043c7b29f3846e1e134 100644 --- a/dist/chunks/init-forks.H5ZuobOQ.js +++ b/dist/chunks/init-forks.H5ZuobOQ.js @@ -13,7 +13,12 @@ processOn("error", onError); function workerInit(options) { const { runTests } = options; init({ - post: (v) => processSend(v), + // Flush the response, then exit explicitly while native profiler signal handlers + // are still installed. The parent joins actual exit within its stop deadline. + post: (v) => { + if (v.__vitest_worker_response__ && v.type === "stopped") return processSend({ ...v, willExit: true }, (error) => processExit(error ? 1 : process.exitCode)); + return processSend(v); + }, on: (cb) => processOn("message", cb), off: (cb) => processOff("message", cb), teardown: () => { diff --git a/dist/chunks/reporters.d.DtoKVV2s.d.ts b/dist/chunks/reporters.d.DtoKVV2s.d.ts index fbe82be6ea7d5dd5e3fa6771c040f0a438f5717f..b60a1abbb785f588bdf04ec1e8ba29baa630d5b1 100644 --- a/dist/chunks/reporters.d.DtoKVV2s.d.ts +++ b/dist/chunks/reporters.d.DtoKVV2s.d.ts @@ -2073,6 +2073,8 @@ interface PoolWorker { send: (message: WorkerRequest) => void; deserialize: (data: unknown) => unknown; start: () => Promise; + /** Observe exit when a stopped response declares willExit; reject abnormal termination. */ + waitForExit?: () => Promise; stop: () => Promise; /** * This is called on workers that already satisfy certain constraints: @@ -2139,6 +2141,8 @@ type WorkerResponse = { } | { type: "stopped"; error?: unknown; + /** The transport will exit itself after sending this response. */ + willExit?: true; } | { type: "testfileFinished"; usedMemory?: number; diff --git a/dist/node.d.ts b/dist/node.d.ts index 4af12597408d7f46a148003f4425e83272e3ccdd..529b9b837041ed73f3daa2cf810cbe60450611f6 100644 --- a/dist/node.d.ts +++ b/dist/node.d.ts @@ -172,6 +172,7 @@ declare class ForksPoolWorker implements PoolWorker { off(event: string, callback: (arg: any) => void): void; send(message: WorkerRequest): void; start(): Promise; + waitForExit(): Promise; stop(): Promise; deserialize(data: unknown): unknown; private get fork();