fix(reset): close all DBs before restore/reset; clear entire Data/ on factory reset#12892
fix(reset): close all DBs before restore/reset; clear entire Data/ on factory reset#12892kangfenmao merged 9 commits intomainfrom
Conversation
On Windows, open file handles prevent deletion (EBUSY). Before removing the Data directory during backup restore, close the DatabaseManager's libsql connection to agents.db so the file can be deleted. Add DatabaseManager.close() static method that closes the connection and resets the singleton. The next getInstance() call will lazily re-initialize a fresh connection to the restored database. Closes #12891 Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace window.api.reload() (renderer-only refresh) with window.api.relaunchApp() (full process restart) after backup restore and data reset. This ensures main process singletons like ApiServer re-initialize with the restored config, fixing auth mismatch (403) and stale DB connection (500) errors. Closes #12838 Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
Note This issue/comment/review was translated by Claude.
Original Content
|
…y reset Factory reset previously only cleared localStorage, IndexedDB, and Data/Files/. This left agents.db, memories.db, KnowledgeBase/, and Notes/ intact. Add App_ResetData IPC that closes all database connections (agents, memory, knowledge) before removing the entire Data/ directory. Also close memory and knowledge DBs during backup restore to prevent EBUSY on Windows. Closes #12837 Co-Authored-By: Claude Opus 4.6 <[email protected]>
EurFelux
left a comment
There was a problem hiding this comment.
Review Summary
Overall this is a well-structured PR that addresses real pain points (Windows EBUSY, stale singletons after restore, incomplete factory reset). The approach of using app.relaunch() instead of renderer-only reload is the right call, and the new App_ResetData IPC channel cleanly separates the factory reset logic.
Key findings:
-
Error resilience in close sequences — The three
close()calls in bothipc.tsandBackupManager.tsrun sequentially without individual error guards. IfmemoryService.close()throws (it lacks internal try/catch unlike the other two), the remaining close calls andfs.promises.rmwon't execute. Consider.catch()guards orPromise.allSettled. -
Race condition in
DatabaseManager.close()— The method nullsDatabaseManager.instancelast, creating a small window where concurrentgetInstance()calls could return a stale instance with null client. Nulling the singleton first would be safer. -
(db as any).clientin KnowledgeService — Acknowledged in the PR description as a necessary encapsulation break. Would benefit from a tracking issue reference and a warn-level log whenclientis not accessible (to catch upstream changes). -
Inconsistent singleton access —
BackupManager.tsusesMemoryService.getInstance().close()whileipc.tsuses a module-levelmemoryServicereference. Not a bug, but worth standardizing. -
Factory reset UX — Unlike the restore paths which show a success toast before relaunching with a 1s delay, the factory reset path relaunches immediately with no user feedback.
None of these are blockers — the core logic is sound and addresses the bugs correctly. The suggestions above are improvements for robustness.
This comment was marked as off-topic.
This comment was marked as off-topic.
|
Note This issue/comment/review was translated by Claude.
Original Content
|
…, and UX - Wrap memoryService.close() and KnowledgeService.closeAll() with .catch() guards so a failure in one doesn't prevent subsequent close calls or fs.rm from executing - Null DatabaseManager.instance before closing client to prevent concurrent getInstance() from returning a stale instance - Log warn when LibSqlDb client is not accessible in closeAll() to detect upstream changes that break the (db as any).client cast - Add toast + 1s delay before relaunch on factory reset for consistent UX with restore paths - Add message.reset.success i18n key across all locales Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
Note This issue/comment/review was translated by Claude.
Original Content
|
EurFelux
left a comment
There was a problem hiding this comment.
Re-review: All feedback addressed ✅
All five review comments have been addressed. Summary of the fixes:
-
Error handling ✅ —
.catch()guards added onmemoryService.close()andKnowledgeService.closeAll()in bothipc.tsandBackupManager.ts, ensuringfs.promises.rmalways runs. -
KnowledgeService encapsulation ✅ —
warn-level log whenclientis not accessible, plus aTODOcomment tracking the need for upstreamclose()support in LibSqlDb. -
DatabaseManager race condition ✅ —
DatabaseManager.instanceis now nulled first, so concurrentgetInstance()calls will create a fresh connection instead of getting a stale one. -
BackupManager singleton access ✅ — Author clarified: using
MemoryService.getInstance()is intentional here sinceBackupManageris an independent class without a module-levelmemoryServicereference. Makes sense. -
Factory reset UX ✅ — Now shows
toast.successand uses the samesetTimeout(() => relaunchApp(), 1000)pattern as restore paths. i18n translations formessage.reset.successadded across all locales.
LGTM. Clean, well-addressed feedback. No further concerns.
DeJeune
left a comment
There was a problem hiding this comment.
Review: Gap Analysis — Are all file handles released?
The core approach is sound — relaunchApp() over reload() is the right architectural call, and the new App_ResetData IPC cleanly separates the factory reset logic. The three issues are correctly root-caused and addressed. However, I spotted two gaps that could still cause EBUSY on Windows:
1. FileStorage chokidar watcher not stopped before fs.remove() (Medium severity)
Affected paths: BackupManager.restore() (line ~413) and App_ResetData IPC handler in ipc.ts
FileStorage uses chokidar to watch Data/Files/ and Data/Notes/ directories (FileStorage.ts:1674). This watcher holds open file handles via fs.watch/FSEvents underneath. Before calling fs.remove(destPath) in BackupManager or fs.promises.rm(getDataPath()) in the reset handler, the watcher is not stopped.
On Windows, chokidar's underlying ReadDirectoryChangesW holds directory handles that will cause EBUSY when trying to delete the directory — the exact same class of bug this PR is fixing for DB connections.
FileStorage already exposes stopFileWatcher() (FileStorage.ts:1798). Consider calling it before fs.remove:
// In BackupManager.restore(), before fs.remove(destPath):
await fileStorage.stopFileWatcher()
// Similarly in App_ResetData handler, before fs.promises.rm():
await fileStorage.stopFileWatcher()Since the app relaunches immediately after, there's no need to restart the watcher.
2. KnowledgeService.closeAll() silently skips unclosable handles (Medium severity)
Location: KnowledgeService.ts, closeAll() method
When (db as any).client is not accessible (e.g., upstream LibSqlDb changes its internals), the current code logs a warning and continues:
if (client && typeof client.close === 'function') {
client.close()
} else {
logger.warn(`Cannot close database instance for id: ${id} — client not accessible`)
continue // ← caller thinks all handles released
}The caller (both BackupManager and App_ResetData) will proceed to fs.remove() believing all handles are released, but the skipped DBs still hold file locks. On Windows this means EBUSY — silently masking the very bug this PR fixes.
Suggestion: throw an error (or collect failures and throw at the end) so the caller is aware that not all handles were released:
} else {
const msg = `Cannot close database instance for id: ${id} — client not accessible`
logger.error(msg)
throw new Error(msg)
}Or at minimum, track failures and return a result that callers can check:
public closeAll = async (): Promise<{ failed: string[] }> => {
const failed: string[] = []
// ...
if (!client || typeof client.close !== 'function') {
failed.push(id)
}
// ...
return { failed }
}Minor note
The inconsistent singleton access pattern (MemoryService.getInstance().close() in BackupManager vs module-level memoryService in ipc.ts) is fine given the different module contexts, just calling it out for awareness.
Summary
The PR correctly fixes the root causes of all three issues. The two gaps above are worth addressing before merge to avoid residual EBUSY failures on Windows, particularly the FileStorage watcher which is a near-certain issue when the Notes panel has been opened during the session.
Address review feedback from DeJeune: 1. Stop FileStorage chokidar watcher before fs.remove() in both App_ResetData handler and BackupManager.restore(). On Windows, chokidar's ReadDirectoryChangesW holds directory handles that cause EBUSY when deleting Data/. 2. KnowledgeService.closeAll() now collects failed close attempts and throws an error instead of silently skipping. Callers are already guarded with .catch() so they'll log the warning and proceed with fs.remove() — the subsequent relaunch will release any remaining handles via process exit. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Follow-up: Address DeJeune's review + deduplicate close logicCommits: 810cf85 → b92bbe01. Stop FileStorage watcher before fs.remove() (810cf85)Addresses DeJeune's review point about chokidar. FileStorage uses chokidar to watch Data/Files/ and Data/Notes/. On Windows, ReadDirectoryChangesW holds directory handles that cause EBUSY. Now stopFileWatcher() is called before deleting the Data directory in both App_ResetData and BackupManager.restore(). 2. KnowledgeService.closeAll() throws on unclosable handles (810cf85)Addresses DeJeune's review point about silent skipping. Instead of silently skipping when client is not accessible, closeAll() now collects failed IDs and throws Error. Callers already have .catch() guards so they log and proceed. 3. Extract closeAllDataConnections() to src/main/utils/lifecycle.ts (df0572d)The 4-step close sequence (DatabaseManager → MemoryService → KnowledgeService → FileWatcher) was duplicated in ipc.ts and BackupManager.ts. Extracted to a shared function using Promise.allSettled — all closes run in parallel, failures in one don't block others. 4. Best-effort fs.rm() in factory reset (b92bbe0)If fs.rm() fails (e.g., a handle wasn't released on Windows), the error is logged but not thrown. This ensures relaunchApp() always executes — process exit releases all remaining handles, and services auto-recreate on next start. |
What this PR does
Before this PR:
EBUSY: resource busy or locked, unlink '...\Data\agents.db'because the DatabaseManager holds an open libsql connection.Settings > Data > Reset Data) only clears localStorage, IndexedDB, andData/Files/, leavingagents.db,memories.db,KnowledgeBase/, andNotes/intact.After this PR:
DatabaseManager.close()releases the file handle before deleting the Data directory, allowing restore to succeed on Windows.window.api.relaunchApp()replaceswindow.api.reload()after restore/reset, restarting the entire Electron process so all singletons (ApiServer, DatabaseManager, etc.) re-initialize with fresh config.Data/directory, ensuring a complete data wipe. On relaunch, all services auto-recreate empty databases.memories.dband KnowledgeBase files.Fixes #12891, Fixes #12838, Fixes #12837
Why we need it and why it was done in this way
The following tradeoffs were made:
ApiServer.restart()at the right time becausereduxService.select()reads from in-memory Redux state which hasn't rehydrated yet.Data/directory instead of individual files. This is simpler and more thorough — any future data files added toData/will automatically be covered.KnowledgeService.closeAll()accessesLibSqlDb's privateclientfield via(db as any).client.close()to release file handles. This breaks encapsulation but is necessary becauseLibSqlDbdoesn't expose aclose()method, and on Windows open handles prevent file deletion.The following alternatives were considered:
apiServer.restart()from the renderer before reload — doesn't work because Redux in-memory state still has old config at that point.ReduxStoreReadyafter reload to trigger ApiServer restart — more complex, fragile, and only partially fixes the problem (other singletons would still be stale).Breaking changes
None. The user experience changes from a page refresh to a full app restart after restore/reset, which is a minor UX difference. Factory reset now actually clears all data as users expect.
Special notes for your reviewer
DatabaseManager.close()is a new public static method that closes the connection and resets the singleton. The nextgetInstance()call lazily re-initializes.KnowledgeService.closeAll()is a new method that closes all open knowledge base DB connections and clears cached RAG application instances.relaunchApp()change affects 3 code paths inBackupService.ts: v1 restore, v2+ restore, and data reset.App_ResetDataIPC channel orchestrates the complete factory reset sequence from the main process.Data/directory, soagents.db,memories.db,KnowledgeBase/, andNotes/are all properly backed up and restored.Checklist
Release note