fix(kiwix): self-heal a missing or corrupt library XML on startup
Kiwix runs in library mode reading kiwix-library.xml via --monitorLibrary. Today a missing or corrupt XML is only repaired on the download path (rebuildFromDisk after a completed download), so if the file is lost or truncated outside that flow — storage relocation, an interrupted write, manual deletion — Kiwix comes up serving an empty library with no path to recovery. Add KiwixLibraryService.ensureLibraryXmlHealthy(): reads the XML, and if it's missing (ENOENT) or fails to parse / lacks a <library> root, rebuilds it from the ZIM files on disk. A well-formed but empty library is treated as valid (no spurious rebuild), and filesystem errors other than ENOENT are surfaced rather than masked. The boot provider calls it on the already-in-library-mode path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
026ab6df8f
commit
1b9f4f30f2
|
|
@ -201,6 +201,56 @@ export class KiwixLibraryService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the library XML parses and has a <library> root. A truncated or
|
||||||
|
* corrupt file (e.g. an interrupted write) fails this even though it exists,
|
||||||
|
* so the caller can rebuild rather than leave Kiwix serving a broken library.
|
||||||
|
* An empty-but-well-formed library is considered valid (nothing to repair).
|
||||||
|
*/
|
||||||
|
private _isValidLibraryXml(xmlContent: string): boolean {
|
||||||
|
try {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: '@_',
|
||||||
|
isArray: (name) => name === 'book',
|
||||||
|
})
|
||||||
|
const parsed = parser.parse(xmlContent)
|
||||||
|
return parsed?.library !== undefined && parsed?.library !== null
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time safety net: if the library XML is missing or unparseable, rebuild
|
||||||
|
* it from the ZIM files on disk so Kiwix (running in library mode with
|
||||||
|
* --monitorLibrary) doesn't come up serving an empty/broken library with no
|
||||||
|
* path to recovery. This covers files lost or corrupted outside the normal
|
||||||
|
* download flow (storage relocation, interrupted write, manual deletion).
|
||||||
|
*
|
||||||
|
* Returns true if a rebuild was performed. Filesystem errors other than
|
||||||
|
* "not found" are surfaced rather than masked by a rebuild.
|
||||||
|
*/
|
||||||
|
async ensureLibraryXmlHealthy(): Promise<boolean> {
|
||||||
|
let content: string
|
||||||
|
try {
|
||||||
|
content = await readFile(this.getLibraryFilePath(), 'utf-8')
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'ENOENT') {
|
||||||
|
logger.warn('[KiwixLibraryService] Library XML missing on startup; rebuilding from disk.')
|
||||||
|
await this.rebuildFromDisk()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._isValidLibraryXml(content)) return false
|
||||||
|
|
||||||
|
logger.warn('[KiwixLibraryService] Library XML present but invalid; rebuilding from disk.')
|
||||||
|
await this.rebuildFromDisk()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<number> {
|
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<number> {
|
||||||
const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)
|
const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)
|
||||||
await ensureDirectoryExists(dirPath)
|
await ensureDirectoryExists(dirPath)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export default class KiwixMigrationProvider {
|
||||||
const Service = (await import('#models/service')).default
|
const Service = (await import('#models/service')).default
|
||||||
const { SERVICE_NAMES } = await import('../constants/service_names.js')
|
const { SERVICE_NAMES } = await import('../constants/service_names.js')
|
||||||
const { DockerService } = await import('#services/docker_service')
|
const { DockerService } = await import('#services/docker_service')
|
||||||
|
const { KiwixLibraryService } = await import('#services/kiwix_library_service')
|
||||||
|
|
||||||
const kiwixService = await Service.query()
|
const kiwixService = await Service.query()
|
||||||
.where('service_name', SERVICE_NAMES.KIWIX)
|
.where('service_name', SERVICE_NAMES.KIWIX)
|
||||||
|
|
@ -36,7 +37,15 @@ export default class KiwixMigrationProvider {
|
||||||
const isLegacy = await dockerService.isKiwixOnLegacyConfig()
|
const isLegacy = await dockerService.isKiwixOnLegacyConfig()
|
||||||
|
|
||||||
if (!isLegacy) {
|
if (!isLegacy) {
|
||||||
logger.info('[KiwixMigrationProvider] Kiwix is already in library mode — no migration needed.')
|
// Already in library mode. Make sure the library XML actually exists and
|
||||||
|
// is parseable — if it was lost or corrupted outside the download flow,
|
||||||
|
// rebuild it from disk so Kiwix isn't left serving an empty library.
|
||||||
|
const rebuilt = await new KiwixLibraryService().ensureLibraryXmlHealthy()
|
||||||
|
logger.info(
|
||||||
|
rebuilt
|
||||||
|
? '[KiwixMigrationProvider] Rebuilt missing/invalid Kiwix library XML on startup.'
|
||||||
|
: '[KiwixMigrationProvider] Kiwix is already in library mode — no migration needed.'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue