fix: retry file reads when out of file descriptors

Content, include, route-template and search reads now run
concurrently, so large sites can hit EMFILE/ENFILE. Route all async
reads through a shared readFile util that retries with backoff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5342/head
Divyansh Singh 1 month ago
parent 3372516152
commit da71173afc

@ -1,5 +1,5 @@
import matter from 'gray-matter' import matter from 'gray-matter'
import { readFile, stat } from 'node:fs/promises' import { stat } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import pMap from 'p-map' import pMap from 'p-map'
import { normalizePath } from 'vite' import { normalizePath } from 'vite'
@ -9,6 +9,7 @@ import {
mergeMarkdownLocales mergeMarkdownLocales
} from './markdown/markdown' } from './markdown/markdown'
import type { Awaitable, MarkdownEnv } from './shared' import type { Awaitable, MarkdownEnv } from './shared'
import { readFile } from './utils/fs'
import { glob, normalizeGlob, type GlobOptions } from './utils/glob' import { glob, normalizeGlob, type GlobOptions } from './utils/glob'
export interface ContentOptions<T = ContentData[]> { export interface ContentOptions<T = ContentData[]> {
@ -121,7 +122,7 @@ export function createContentLoader<T = ContentData[]>(
if (cached && timestamp === cached.timestamp) return cached.data if (cached && timestamp === cached.timestamp) return cached.data
const src = await readFile(file, 'utf8') const src = await readFile(file)
const renderExcerpt = options.excerpt const renderExcerpt = options.excerpt
const { data: frontmatter, excerpt } = matter( const { data: frontmatter, excerpt } = matter(

@ -1,5 +1,4 @@
import fs from 'node:fs' import fs from 'node:fs'
import { readFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import c from 'picocolors' import c from 'picocolors'
import pm from 'picomatch' import pm from 'picomatch'
@ -13,6 +12,7 @@ import {
} from 'vite' } from 'vite'
import type { Awaitable } from '../shared' import type { Awaitable } from '../shared'
import { type SiteConfig, type UserConfig } from '../siteConfig' import { type SiteConfig, type UserConfig } from '../siteConfig'
import { readFile } from '../utils/fs'
import { glob, normalizeGlob, type GlobOptions } from '../utils/glob' import { glob, normalizeGlob, type GlobOptions } from '../utils/glob'
import { ModuleGraph } from '../utils/moduleGraph' import { ModuleGraph } from '../utils/moduleGraph'
import { resolveRewrites } from './rewritesPlugin' import { resolveRewrites } from './rewritesPlugin'
@ -158,7 +158,7 @@ export const dynamicRoutesPlugin = async (
moduleGraph.add(id, [routeFile]) moduleGraph.add(id, [routeFile])
moduleGraph.add(routeFile, [matched.loaderPath]) moduleGraph.add(routeFile, [matched.loaderPath])
let baseContent = await readFile(routeFile, 'utf8') let baseContent = await readFile(routeFile)
// inject raw content // inject raw content
// this is intended for integration with CMS // this is intended for integration with CMS

@ -1,6 +1,5 @@
import { prefixRegex } from '@rolldown/pluginutils' import { prefixRegex } from '@rolldown/pluginutils'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { readFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { createDebug } from 'obug' import { createDebug } from 'obug'
import type { Plugin, ViteDevServer } from 'vite' import type { Plugin, ViteDevServer } from 'vite'
@ -8,6 +7,7 @@ import type { SiteConfig } from '../config'
import type { DefaultTheme } from '../defaultTheme' import type { DefaultTheme } from '../defaultTheme'
import { createMarkdownRenderer } from '../markdown/markdown' import { createMarkdownRenderer } from '../markdown/markdown'
import { getLocaleForPath, slash, type MarkdownEnv } from '../shared' import { getLocaleForPath, slash, type MarkdownEnv } from '../shared'
import { readFile } from '../utils/fs'
import { processIncludes } from '../utils/processIncludes' import { processIncludes } from '../utils/processIncludes'
const debug = createDebug('vitepress:local-search') const debug = createDebug('vitepress:local-search')
@ -52,7 +52,7 @@ export async function localSearchPlugin(
const { srcDir, cleanUrls = false } = siteConfig const { srcDir, cleanUrls = false } = siteConfig
const relativePath = slash(path.relative(srcDir, file)) const relativePath = slash(path.relative(srcDir, file))
const env: MarkdownEnv = { path: file, relativePath, cleanUrls } const env: MarkdownEnv = { path: file, relativePath, cleanUrls }
const raw = await readFile(file, 'utf8').catch((e) => { const raw = await readFile(file).catch((e) => {
if (e.code === 'ENOENT') { if (e.code === 'ENOENT') {
debug(`File not found: ${file}`) debug(`File not found: ${file}`)
return '' return ''

@ -1,9 +1,9 @@
import compression from '@polka/compression' import compression from '@polka/compression'
import { readFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import polka, { type IOptions } from 'polka' import polka, { type IOptions } from 'polka'
import sirv from 'sirv' import sirv from 'sirv'
import { resolveConfig } from '../config' import { resolveConfig } from '../config'
import { readFile } from '../utils/fs'
export interface ServeOptions { export interface ServeOptions {
base?: string base?: string
@ -21,10 +21,7 @@ export async function serve(options: ServeOptions = {}) {
const notAnAsset = (pathname: string) => const notAnAsset = (pathname: string) =>
!pathname.includes(`/${config.assetsDir}/`) !pathname.includes(`/${config.assetsDir}/`)
const notFound = await readFile( const notFound = await readFile(path.resolve(config.outDir, './404.html'))
path.resolve(config.outDir, './404.html'),
'utf8'
)
const onNoMatch: IOptions['onNoMatch'] = (req, res) => { const onNoMatch: IOptions['onNoMatch'] = (req, res) => {
res.statusCode = 404 res.statusCode = 404
if (notAnAsset(req.path)) res.write(notFound) if (notAnAsset(req.path)) res.write(notFound)

@ -0,0 +1,19 @@
import { readFile as fsReadFile } from 'node:fs/promises'
const retryCodes = new Set(['EMFILE', 'ENFILE'])
/**
* Reads a file as utf8, retrying with backoff when the process is
* temporarily out of file descriptors (EMFILE/ENFILE).
*/
export async function readFile(file: string): Promise<string> {
for (let attempt = 0; ; attempt++) {
try {
return await fsReadFile(file, 'utf8')
} catch (e) {
const code = (e as NodeJS.ErrnoException).code
if (attempt >= 9 || !code || !retryCodes.has(code)) throw e
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 10))
}
}
}

@ -1,9 +1,9 @@
import matter from 'gray-matter' import matter from 'gray-matter'
import { replaceAsync, type MarkdownItAsync } from 'markdown-it-async' import { replaceAsync, type MarkdownItAsync } from 'markdown-it-async'
import { readFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { findRegion } from '../markdown/plugins/snippet' import { findRegion } from '../markdown/plugins/snippet'
import { slash, type MarkdownEnv } from '../shared' import { slash, type MarkdownEnv } from '../shared'
import { readFile } from './fs'
export function processIncludes( export function processIncludes(
md: MarkdownItAsync, md: MarkdownItAsync,
@ -41,7 +41,7 @@ export function processIncludes(
// chain are cycles, the same file may still be included by siblings // chain are cycles, the same file may still be included by siblings
if (includePath === file || ancestors.includes(includePath)) return m if (includePath === file || ancestors.includes(includePath)) return m
let content = await readFile(includePath, 'utf8') let content = await readFile(includePath)
if (region) { if (region) {
const [regionName] = region const [regionName] = region

Loading…
Cancel
Save