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:
Chris Sherwood 2026-06-06 19:06:06 -07:00 committed by jakeaturner
parent 026ab6df8f
commit 1b9f4f30f2
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
2 changed files with 60 additions and 1 deletions

View File

@ -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> {
const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)
await ensureDirectoryExists(dirPath)

View File

@ -22,6 +22,7 @@ export default class KiwixMigrationProvider {
const Service = (await import('#models/service')).default
const { SERVICE_NAMES } = await import('../constants/service_names.js')
const { DockerService } = await import('#services/docker_service')
const { KiwixLibraryService } = await import('#services/kiwix_library_service')
const kiwixService = await Service.query()
.where('service_name', SERVICE_NAMES.KIWIX)
@ -36,7 +37,15 @@ export default class KiwixMigrationProvider {
const isLegacy = await dockerService.isKiwixOnLegacyConfig()
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
}