diff --git a/src/serviceFeatures/conflictResolution/checker.ts b/src/serviceFeatures/conflictResolution/checker.ts index 21597d59..81290dd9 100644 --- a/src/serviceFeatures/conflictResolution/checker.ts +++ b/src/serviceFeatures/conflictResolution/checker.ts @@ -25,6 +25,8 @@ export interface ConflictCheckingDependencies { } export interface ConflictCheckingHandlers { + /** Stop new checks and logging from pending checks after shutdown. */ + readonly dispose: () => void; readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise; readonly queueCheckFor: (file: FilePathWithPrefix) => Promise; readonly ensureAllProcessed: () => Promise; @@ -32,11 +34,14 @@ export interface ConflictCheckingHandlers { /** Create conflict-checking handlers while retaining scheduling state privately. */ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers { + let disposed = false; const conflictQueue = createConflictCheckQueue({ async process(filename) { + if (disposed) return; try { await dependencies.conflict.resolve(filename); } catch (error) { + if (disposed) return; dependencies.log(`Conflict check failed: ${filename}`); dependencies.log(error, LOG_LEVEL_VERBOSE); } @@ -45,11 +50,13 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep // and automatic merges can use the remaining execution slots. concurrency: 10, onProgress(remaining) { + // Accepted checks still update their accounting as they settle. dependencies.conflictProcessQueueCount.value = remaining; }, }); const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise => { + if (disposed) return; const path = file; if (dependencies.currentSettings().checkConflictOnlyOnOpen) { const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath(); @@ -62,7 +69,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep }; const queueCheckFor = async (file: FilePathWithPrefix): Promise => { + if (disposed) return; const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file); + if (disposed) return; if (optionalConflictResult == true) { // The conflict has been resolved by another process. return; @@ -79,8 +88,12 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep }; const ensureAllProcessed = (): Promise => conflictQueue.waitForIdle(); + const dispose = (): void => { + disposed = true; + }; return { + dispose, queueCheckForIfOpen, queueCheckFor, ensureAllProcessed, diff --git a/src/serviceFeatures/conflictResolution/conflictResolution.unit.spec.ts b/src/serviceFeatures/conflictResolution/conflictResolution.unit.spec.ts index 30a0d279..1e4b888a 100644 --- a/src/serviceFeatures/conflictResolution/conflictResolution.unit.spec.ts +++ b/src/serviceFeatures/conflictResolution/conflictResolution.unit.spec.ts @@ -14,7 +14,7 @@ import { type MetaEntry, type ObsidianLiveSyncSettings, } from "@vrtmrz/livesync-commonlib/compat/common/types"; -import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication"; import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; import { @@ -44,6 +44,7 @@ type HarnessOptions = { function createHarness(options: HarnessOptions = {}) { const context = createServiceContext(); const conflict = new InjectableConflictService(context); + const unloadHandlers: Array<() => Promise> = []; const settings = { ...DEFAULT_SETTINGS, ...options.settings }; const tryAutoMerge = vi.fn(); const databaseFileAccess = { @@ -62,7 +63,14 @@ function createHarness(options: HarnessOptions = {}) { const activeFile = vi.fn(() => options.activeFile); const services = { API: { addLog }, - appLifecycle: { isSuspended: vi.fn(() => false) }, + appLifecycle: { + isSuspended: vi.fn(() => false), + onUnload: { + addHandler: vi.fn((handler: () => Promise) => { + unloadHandlers.push(handler); + }), + }, + }, conflict, context, database: { localDatabase: { tryAutoMerge } }, @@ -88,9 +96,22 @@ function createHarness(options: HarnessOptions = {}) { serviceModules, storageAccess, tryAutoMerge, + unload: async () => { + for (const handler of unloadHandlers) await handler(); + }, }; } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function leaf(rev: string, data: string, mtime: number): ConflictLeaf { return { rev, data, mtime, ctime: mtime, deleted: false }; } @@ -919,6 +940,306 @@ describe("conflict resolution serviceFeature", () => { expect(harness.conflict.conflictProcessQueueCount.value).toBe(0); }); + it("settles queued checks without logging a resolver rejection after unload", async () => { + const path = "queued-rejection-after-unload.md" as FilePathWithPrefix; + const harness = createHarness(); + const started = deferred(); + const pendingResolve = deferred(); + const resolve = vi.fn(async () => { + started.resolve(undefined); + await pendingResolve.promise; + }); + harness.conflict.resolve.setHandler(resolve, true); + + await harness.conflict.queueCheckFor(path); + await started.promise; + const completion = harness.conflict.ensureAllProcessed().then( + (result) => ({ status: "resolved" as const, result }), + (error: unknown) => ({ status: "rejected" as const, error }) + ); + + await harness.unload(); + harness.addLog.mockImplementation(() => { + throw new Error("Handler addLog not assigned"); + }); + pendingResolve.reject(new Error("Resolver rejected after unload")); + + await expect(completion).resolves.toEqual({ status: "resolved", result: true }); + expect(resolve).toHaveBeenCalledOnce(); + expect(harness.addLog).not.toHaveBeenCalled(); + }); + + it("does not emit, log, or requeue a pending NOT_CONFLICTED result after the unload event", async () => { + const path = "not-conflicted-after-unload.md" as FilePathWithPrefix; + const harness = createHarness(); + const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor"); + const started = deferred(); + const pendingMergeCheck = deferred<{ ok: typeof NOT_CONFLICTED }>(); + harness.tryAutoMerge.mockImplementation(async () => { + started.resolve(undefined); + return await pendingMergeCheck.promise; + }); + const emitEvent = vi.spyOn(harness.context.events, "emitEvent"); + const operation = harness.conflict.resolve(path); + const completion = operation.then( + () => ({ status: "resolved" as const }), + (error: unknown) => ({ status: "rejected" as const, error }) + ); + await started.promise; + expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true); + + harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED); + harness.addLog.mockImplementation(() => { + throw new Error("Handler addLog not assigned"); + }); + pendingMergeCheck.resolve({ ok: NOT_CONFLICTED }); + + await expect(completion).resolves.toEqual({ status: "resolved" }); + expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path); + expect(queueCheckFor).not.toHaveBeenCalled(); + expect(harness.addLog).not.toHaveBeenCalled(); + }); + + it("does not delete a conflict or requeue when auto-merge storage completes after unload", async () => { + const path = "stored-merge-after-unload.md" as FilePathWithPrefix; + const harness = createHarness({ settings: { syncAfterMerge: false } }); + const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor"); + const started = deferred(); + const pendingStore = deferred(); + harness.tryAutoMerge.mockResolvedValue({ result: "Merged content\n", conflictedRev: "2-conflict" }); + harness.databaseFileAccess.storeContent.mockImplementation(async () => { + started.resolve(undefined); + return await pendingStore.promise; + }); + const emitEvent = vi.spyOn(harness.context.events, "emitEvent"); + const operation = harness.conflict.resolve(path); + const completion = operation.then( + () => ({ status: "resolved" as const }), + (error: unknown) => ({ status: "rejected" as const, error }) + ); + await started.promise; + expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged content\n"); + + await harness.unload(); + harness.addLog.mockImplementation(() => { + throw new Error("Handler addLog not assigned"); + }); + pendingStore.resolve(true); + + await expect(completion).resolves.toEqual({ status: "resolved" }); + expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled(); + expect(queueCheckFor).not.toHaveBeenCalled(); + expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path); + expect(harness.addLog).not.toHaveBeenCalled(); + }); + + it("does not continue revision deletion after the unload event", async () => { + const path = "deleted-after-unload.md" as FilePathWithPrefix; + const harness = createHarness(); + const started = deferred(); + const pendingDelete = deferred(); + harness.fileHandler.deleteRevisionFromDB.mockImplementation(async () => { + started.resolve(undefined); + return await pendingDelete.promise; + }); + const emitEvent = vi.spyOn(harness.context.events, "emitEvent"); + const operation = harness.conflict.resolveByDeletingRevision(path, "2-conflict", "UI Selected"); + const completion = operation.then( + (result) => ({ status: "resolved" as const, result }), + (error: unknown) => ({ status: "rejected" as const, error }) + ); + await started.promise; + expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict"); + + harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED); + harness.addLog.mockImplementation(() => { + throw new Error("Handler addLog not assigned"); + }); + pendingDelete.resolve(true); + + await expect(completion).resolves.toMatchObject({ status: "resolved" }); + expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled(); + expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled(); + expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path); + expect(harness.addLog).not.toHaveBeenCalled(); + }); + + it("does not requeue or log after pending replication completes during unload", async () => { + const path = "replication-after-unload.md" as FilePathWithPrefix; + const harness = createHarness({ settings: { syncAfterMerge: true } }); + const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor"); + const started = deferred(); + const pendingReplication = deferred<{ status: "completed" }>(); + harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED }); + harness.services.replication.replicateUnattendedByEvent.mockImplementation(async () => { + started.resolve(undefined); + return await pendingReplication.promise; + }); + const operation = harness.conflict.resolve(path); + const completion = operation.then( + () => ({ status: "resolved" as const }), + (error: unknown) => ({ status: "rejected" as const, error }) + ); + await started.promise; + expect(harness.services.replication.replicateUnattendedByEvent).toHaveBeenCalledWith({ + trigger: "merge", + interaction: NO_INTERACTION, + }); + + await harness.unload(); + harness.addLog.mockImplementation(() => { + throw new Error("Handler addLog not assigned"); + }); + pendingReplication.resolve({ status: "completed" }); + + await expect(completion).resolves.toEqual({ status: "resolved" }); + expect(queueCheckFor).not.toHaveBeenCalled(); + expect(harness.addLog).not.toHaveBeenCalled(); + }); + + it("does not start resolveByNewest when an optional conflict check returns newer after unload", async () => { + const path = "optional-newer-after-unload.md" as FilePathWithPrefix; + const harness = createHarness(); + const started = deferred(); + const pendingOptionalCheck = deferred<"newer" | undefined>(); + const optionalCheck = vi.fn(async () => { + started.resolve(undefined); + return await pendingOptionalCheck.promise; + }); + const resolveByNewest = vi.fn(async () => true); + const unregisterOptionalCheck = harness.conflict.getOptionalConflictCheckMethod.addHandler(optionalCheck); + harness.conflict.resolveByNewest.setHandler(resolveByNewest, true); + const operation = harness.conflict.queueCheckFor(path); + const completion = operation.then( + () => ({ status: "resolved" as const }), + (error: unknown) => ({ status: "rejected" as const, error }) + ); + try { + await started.promise; + expect(optionalCheck).toHaveBeenCalledWith(path); + + await harness.unload(); + harness.addLog.mockImplementation(() => { + throw new Error("Handler addLog not assigned"); + }); + pendingOptionalCheck.resolve("newer"); + + await expect(completion).resolves.toEqual({ status: "resolved" }); + expect(resolveByNewest).not.toHaveBeenCalled(); + expect(harness.addLog).not.toHaveBeenCalled(); + } finally { + unregisterOptionalCheck(); + } + }); + + it.each(["lifecycle", "event"] as const)( + "settles conflict operations without side effects after %s disposal", + async (trigger) => { + const path = "operation-after-unload.md" as FilePathWithPrefix; + const harness = createHarness(); + const optionalCheck = vi.fn(async () => "newer" as const); + const unregisterOptionalCheck = harness.conflict.getOptionalConflictCheckMethod.addHandler(optionalCheck); + const publicOperations = [ + vi.spyOn(harness.conflict, "queueCheckForIfOpen"), + vi.spyOn(harness.conflict, "queueCheckFor"), + vi.spyOn(harness.conflict, "resolve"), + vi.spyOn(harness.conflict, "resolveByDeletingRevision"), + vi.spyOn(harness.conflict, "resolveByNewest"), + vi.spyOn(harness.conflict, "resolveAllConflictedFilesByNewerOnes"), + ]; + const emitEvent = vi.spyOn(harness.context.events, "emitEvent"); + + if (trigger === "lifecycle") { + await harness.unload(); + } else { + harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED); + } + harness.addLog.mockImplementation(() => { + throw new Error("Handler addLog not assigned"); + }); + + try { + const results = await Promise.allSettled([ + harness.conflict.queueCheckForIfOpen(path), + harness.conflict.queueCheckFor(path), + harness.conflict.resolve(path), + harness.conflict.resolveByDeletingRevision(path, "2-conflict", "UI Selected"), + harness.conflict.resolveByNewest(path), + harness.conflict.resolveAllConflictedFilesByNewerOnes(), + ]); + + expect(results.every(({ status }) => status === "fulfilled")).toBe(true); + for (const operation of publicOperations) expect(operation).toHaveBeenCalledOnce(); + expect(optionalCheck).not.toHaveBeenCalled(); + expect(harness.tryAutoMerge).not.toHaveBeenCalled(); + expect(harness.databaseFileAccess.fetchEntryMeta).not.toHaveBeenCalled(); + expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled(); + expect(harness.databaseFileAccess.storeContent).not.toHaveBeenCalled(); + expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled(); + expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled(); + expect(harness.storageAccess.getFileNames).not.toHaveBeenCalled(); + expect(harness.services.replication.replicateUnattendedByEvent).not.toHaveBeenCalled(); + expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path); + expect(harness.addLog).not.toHaveBeenCalled(); + } finally { + unregisterOptionalCheck(); + } + } + ); + + it("does not start a queued resolver after active checks settle during unload", async () => { + const paths = Array.from({ length: 11 }, (_, index) => `unload-concurrent-${index}.md` as FilePathWithPrefix); + const harness = createHarness(); + const tenStarted = deferred(); + const releaseResolvers = deferred(); + const resolve = vi.fn(async (_path: FilePathWithPrefix) => { + if (resolve.mock.calls.length === 10) tenStarted.resolve(undefined); + await releaseResolvers.promise; + }); + harness.conflict.resolve.setHandler(resolve, true); + + await Promise.all(paths.map(async (path) => await harness.conflict.queueCheckFor(path))); + await tenStarted.promise; + expect(harness.conflict.conflictProcessQueueCount.value).toBe(11); + + const completion = harness.conflict.ensureAllProcessed(); + harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED); + releaseResolvers.resolve(undefined); + + await expect(completion).resolves.toBe(true); + expect(resolve).toHaveBeenCalledTimes(10); + expect(harness.conflict.conflictProcessQueueCount.value).toBe(0); + }); + + it("stops bulk conflict resolution when its storage scan completes after unload", async () => { + const path = "bulk-resolution-after-unload.md" as FilePathWithPrefix; + const harness = createHarness(); + const namesStarted = deferred(); + const pendingNames = deferred(); + harness.storageAccess.getFileNames.mockImplementation(async () => { + namesStarted.resolve(undefined); + return await pendingNames.promise; + }); + + const operation = harness.conflict.resolveAllConflictedFilesByNewerOnes(); + await namesStarted.promise; + expect(harness.addLog).toHaveBeenCalled(); + harness.addLog.mockClear(); + + await harness.unload(); + harness.addLog.mockImplementation(() => { + throw new Error("Handler addLog not assigned"); + }); + pendingNames.resolve([path]); + + await expect(operation).resolves.toBeUndefined(); + expect(harness.databaseFileAccess.fetchEntryMeta).not.toHaveBeenCalled(); + expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled(); + expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled(); + expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled(); + expect(harness.addLog).not.toHaveBeenCalled(); + }); + it("drains requeued automatic checks when every resolver slot is occupied", async () => { const harness = createHarness({ settings: { syncAfterMerge: false } }); const paths = Array.from({ length: 10 }, (_, index) => `requeue-${index}.md` as FilePathWithPrefix); diff --git a/src/serviceFeatures/conflictResolution/index.ts b/src/serviceFeatures/conflictResolution/index.ts index 5ca0293b..3aae623b 100644 --- a/src/serviceFeatures/conflictResolution/index.ts +++ b/src/serviceFeatures/conflictResolution/index.ts @@ -6,6 +6,7 @@ import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; import { createConflictCheckingHandlers } from "./checker"; import { createConflictResolutionOperations } from "./operations"; +import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; export type ConflictResolutionHost = NecessaryServices< "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault", @@ -47,6 +48,18 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict services.conflict.resolve.setHandler(operations.resolve); services.conflict.resolveByNewest.setHandler(operations.resolveByNewest); services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes); + + const eventSubscriptions = new AbortController(); + const dispose = (): void => { + checking.dispose(); + operations.dispose(); + eventSubscriptions.abort(); + }; + services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal }); + services.appLifecycle.onUnload.addHandler(() => { + dispose(); + return Promise.resolve(true); + }); }); export { createConflictCheckingHandlers } from "./checker"; diff --git a/src/serviceFeatures/conflictResolution/operations.ts b/src/serviceFeatures/conflictResolution/operations.ts index 98e13276..c57a9792 100644 --- a/src/serviceFeatures/conflictResolution/operations.ts +++ b/src/serviceFeatures/conflictResolution/operations.ts @@ -55,6 +55,8 @@ export interface ConflictResolutionOperationsDependencies { } export interface ConflictResolutionOperations { + /** Stop continuations without cancelling file or database operations already started. */ + readonly dispose: () => void; readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void; readonly resolveByDeletingRevision: ( path: FilePathWithPrefix, @@ -68,13 +70,21 @@ export interface ConflictResolutionOperations { readonly resolveAllConflictedFilesByNewerOnes: () => Promise; } +/** Create conflict-resolution operations which stop their continuations when disposed. */ export function createConflictResolutionOperations( dependencies: ConflictResolutionOperationsDependencies ): ConflictResolutionOperations { + let disposed = false; const latestResolveRequestByFilename = new Map(); let nextResolveRequestId = 0; + const dispose = (): void => { + disposed = true; + latestResolveRequestByFilename.clear(); + }; + const cancelActiveResolution = (filename: FilePathWithPrefix): void => { + if (disposed) return; latestResolveRequestByFilename.delete(filename); if (isLockAcquired(`conflict-resolve:${filename}`)) { dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename); @@ -87,8 +97,11 @@ export function createConflictResolutionOperations( subTitle = "", showNotice = true ): Promise => { + if (disposed) return MISSING_OR_ERROR; const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`; - if (!(await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision))) { + const deleted = await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision); + if (disposed) return MISSING_OR_ERROR; + if (!deleted) { dependencies.log( `${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`, LOG_LEVEL_NOTICE @@ -100,7 +113,9 @@ export function createConflictResolutionOperations( `${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`, LOG_LEVEL_INFO ); - if ((await dependencies.databaseFileAccess.getConflictedRevs(path)).length != 0) { + const conflicts = await dependencies.databaseFileAccess.getConflictedRevs(path); + if (disposed) return MISSING_OR_ERROR; + if (conflicts.length != 0) { dependencies.log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO); return AUTO_MERGED; } @@ -111,7 +126,9 @@ export function createConflictResolutionOperations( // If no conflicts were found, write the resolved content to the storage. const documentPath = parseDocumentPath(path); const { path: filePath } = splitDocumentPath(documentPath); - if (!(await dependencies.fileHandler.dbToStorage(documentPath, filePath, true))) { + const reflected = await dependencies.fileHandler.dbToStorage(documentPath, filePath, true); + if (disposed) return MISSING_OR_ERROR; + if (!reflected) { dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE); return MISSING_OR_ERROR; } @@ -121,9 +138,11 @@ export function createConflictResolutionOperations( }; const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise => { + if (disposed) return CANCELLED; const ret = await dependencies .localDatabase() .tryAutoMerge(path, !dependencies.currentSettings().disableMarkdownAutoMerge); + if (disposed) return CANCELLED; if ("ok" in ret) { return ret.ok; } @@ -131,7 +150,9 @@ export function createConflictResolutionOperations( if ("result" in ret) { const p = ret.result; // 1. Store the merged content to the storage. - if (!(await dependencies.databaseFileAccess.storeContent(path, p))) { + const stored = await dependencies.databaseFileAccess.storeContent(path, p); + if (disposed) return CANCELLED; + if (!stored) { dependencies.log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE); return MISSING_OR_ERROR; } @@ -186,6 +207,7 @@ export function createConflictResolutionOperations( }; const resolve = async (filename: FilePathWithPrefix): Promise => { + if (disposed) return; const requestId = ++nextResolveRequestId; latestResolveRequestByFilename.set(filename, requestId); const serialisationKey = `conflict-resolve:${filename}`; @@ -196,12 +218,12 @@ export function createConflictResolutionOperations( dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename); } return await withLock(serialisationKey, async () => { - if (latestResolveRequestByFilename.get(filename) !== requestId) { + if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) { return; } try { const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename); - if (latestResolveRequestByFilename.get(filename) !== requestId) { + if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) { return; } if (conflictCheckResult === NOT_CONFLICTED) { @@ -223,6 +245,7 @@ export function createConflictResolutionOperations( interaction: NO_INTERACTION, }); } + if (disposed) return; dependencies.log("[conflict] Automatically merged, but we have to check it again"); await dependencies.conflict.queueCheckFor(filename); return; @@ -249,12 +272,15 @@ export function createConflictResolutionOperations( }; const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise => { + if (disposed) return false; const currentRev = await dependencies.databaseFileAccess.fetchEntryMeta(filename, undefined, true); + if (disposed) return false; if (currentRev == false) { dependencies.log(`Could not get current revision of ${filename}`); return Promise.resolve(false); } const revs = await dependencies.databaseFileAccess.getConflictedRevs(filename); + if (disposed) return false; if (revs.length == 0) { return Promise.resolve(true); } @@ -278,25 +304,31 @@ export function createConflictResolutionOperations( } return diff; }); + if (disposed) return false; dependencies.log( `Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)` ); for (let i = 1; i < mTimeAndRev.length; i++) { + if (disposed) return false; dependencies.log( `conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}` ); await resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST", showNotice); } + if (disposed) return false; return true; }; const resolveAllConflictedFilesByNewerOnes = async (): Promise => { + if (disposed) return; dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE); const files = await dependencies.storageAccess.getFileNames(); + if (disposed) return; let i = 0; for (const file of files) { + if (disposed) return; i++; if (i % 10 === 0) dependencies.log( @@ -306,10 +338,12 @@ export function createConflictResolutionOperations( ); await resolveByNewest(file, false); } + if (disposed) return; dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes"); }; return { + dispose, cancelActiveResolution, resolveByDeletingRevision, checkConflictAndPerformAutoMerge,